diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 82faa06..fc994f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,21 +7,6 @@ on: branches: [main] jobs: - backend: - name: 后端(Java) - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - name: 安装 JDK 17 - uses: actions/setup-java@v4 - with: - java-version: '17' - distribution: temurin - cache: maven - # 单测均为纯单元测试,不依赖 MySQL,可直接跑 - - name: 编译 + 单测 - run: ./mvnw -B clean test - app: name: 桌面端(Flutter) runs-on: ubuntu-latest @@ -40,26 +25,3 @@ jobs: run: flutter analyze - name: 单测 run: flutter test - - cli: - name: CLI(Node) - runs-on: ubuntu-latest - defaults: - run: - working-directory: clients/cli - steps: - - uses: actions/checkout@v4 - - name: 安装 Node 20 - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: npm - cache-dependency-path: clients/cli/package-lock.json - - name: 装依赖 - run: npm ci - - name: 类型检查 - run: npm run typecheck - - name: 单测 - run: npm test - - name: 构建 - run: npm run build diff --git a/.gitignore b/.gitignore index f1f81ee..209b83b 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,14 @@ logs/ # CLI 客户端(Node)构建产物与依赖 clients/cli/node_modules/ clients/cli/dist/ + +# 本地保留的私有实现:不再提交到远程仓库 +/src/ +/pom.xml +/.mvn/ +/mvnw +/mvnw.cmd +/Dockerfile +/docker-compose.yml +/.env.example +/clients/cli/ diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar deleted file mode 100644 index 7967f30..0000000 Binary files a/.mvn/wrapper/maven-wrapper.jar and /dev/null differ diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties deleted file mode 100644 index aa7b1d6..0000000 --- a/.mvn/wrapper/maven-wrapper.properties +++ /dev/null @@ -1,19 +0,0 @@ -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -wrapperVersion=3.3.2 -distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip -wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 783e8f9..11cc008 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,106 +1,45 @@ # 贡献指南 -感谢你对 LowenSSH 的兴趣。这份指南帮你快速上手本地开发、了解项目约定与提交流程。 - -## 项目结构速览 - -LowenSSH 是「同一套理念、三种独立形态」的项目,三端互不依赖: - -| 形态 | 目录 | 技术栈 | -|------|------|--------| -| 后端服务 | `src/` | Java 17 · Spring Boot 3.4 · Spring AI | -| 桌面客户端 | `clients/app/` | Flutter(macOS / Windows) | -| CLI 客户端 | `clients/cli/` | Node 20 · Ink(TUI) | - -核心理念(手写 Agent loop + Deny/Ask/Allow 安全门禁 + 上下文管理)在三端各自实现,**门禁规则与事件语义需手动对齐**。改动涉及核心逻辑时,请留意是否需要同步到其他端。 - -设计取舍详见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 +感谢你对 LowenSSH 的兴趣。远程仓库当前仅维护 [`clients/app/`](clients/app/) 下的 Flutter 桌面客户端。 ## 本地开发环境 -### 后端(`src/`) - -需要 JDK 17。项目自带 Maven Wrapper,无需预装 Maven。 - -```bash -export MYSQL_PASSWORD='你的MySQL密码' -export GLM_API_KEY='你的智谱AI key' # https://open.bigmodel.cn 申请 - -# 初始化数据库 -mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS lowenssh DEFAULT CHARSET utf8mb4;" -mysql -u root -p lowenssh < src/main/resources/schema.sql - -./mvnw spring-boot:run # Windows 用 mvnw.cmd -``` - -运行测试: - -```bash -./mvnw test -``` - -### 桌面端(`clients/app/`) - -需要 Flutter SDK 3.12+。详见 [clients/app/README.md](clients/app/README.md)。 +需要 Flutter SDK 3.12+。macOS 构建需要 Xcode;Windows 构建需要 Visual Studio,并安装“使用 C++ 的桌面开发”工作负载。 ```bash cd clients/app flutter pub get -flutter run -d macos # 或 -d windows -flutter analyze # 提交前确保零问题 +flutter run -d macos # 或 flutter run -d windows +flutter analyze +flutter test ``` -### CLI(`clients/cli/`) - -需要 Node 20+。详见 [clients/cli/README.md](clients/cli/README.md)。 +完整运行、打包和模型配置说明见 [`clients/app/README.md`](clients/app/README.md)。 ## 代码约定 -- **注释用中文,标识符(变量/函数/类名)用英文**。 -- 优先可读性,不做过度优化;改动范围尽量小,不顺手重构无关代码。 -- 后端 Java 用 Java 17 语法,不用过时写法。 -- Flutter 优先 Composition 风格的 Widget 拆分,复用动画/组件放对应封装文件。 -- 涉及安全门禁规则改动,必须补充或更新对应单元测试。 +- 注释使用中文,标识符使用英文。 +- 优先可读性,不重构与当前任务无关的代码。 +- Widget 保持职责清晰,可复用动画和组件放入对应封装文件。 +- 修改安全门禁、凭据保存或 SSH 执行逻辑时,必须补充相应测试。 +- 不提交 API Key、密码、`.env`、本机构建产物和日志。 ## 提交前检查 -- 后端:`./mvnw test` 全绿。 -- 桌面端:`flutter analyze` 零问题,`flutter build macos --debug`(或 windows)可编译。 -- CLI:按 `clients/cli/README.md` 的检查方式验证。 -- 不提交任何明文密钥、`.env` 文件、本地构建产物。 - -## 提交信息规范 - -- 用简洁的中文描述「做了什么」,必要时补充「为什么」。 -- 前缀标明影响范围,例如 `app:`、`cli:`、`backend:`、`docs:`。 -- 一个提交聚焦一件事,避免把无关改动混在一起。 - -示例: - -``` -app: 修复切主题时终端不变色 - -终端配色从冻结的顶层 final 改为按当前 palette 实时计算。 +```bash +cd clients/app +flutter analyze +flutter test +flutter build macos --debug # Windows 使用对应构建命令 ``` -## Pull Request 流程 - -1. 从 `main` 切出 feature 分支(如 `feature/xxx`、`fix/xxx`),**不要直接提交到 main**。 -2. 完成开发并通过提交前检查。 -3. 推送分支并发起 PR,目标分支为 `main`。 -4. PR 描述请包含:改了什么、为什么、如何测试、是否涉及多端对齐。 -5. 等待 review,合并后删除 feature 分支。 - -## 安全相关改动 - -本项目的安全门禁(高危命令拦截)是真实防护,不是演示。涉及以下改动请在 PR 中重点说明: - -- 修改 deny / ask 规则名单。 -- 调整命令拆段、正则匹配逻辑。 -- 改动密码加密、密钥读取、审计落库相关代码。 +## 提交与 Pull Request -发现安全漏洞请不要直接提 public issue,先通过私下渠道联系维护者。 +1. 从 `main` 创建功能分支,不直接提交到 `main`。 +2. 一个提交只处理一类问题,提交信息使用简洁中文。 +3. 推送分支后创建 PR,目标分支为 `main`。 +4. PR 说明应包含改动内容、原因、验证方式和安全影响。 -## 报告问题 +## 安全问题 -提 issue 时请尽量包含:复现步骤、预期与实际行为、运行环境(操作系统、形态、版本)、相关日志或截图。 +安全门禁和凭据保护属于真实防护。发现可导致未授权命令执行、凭据泄露或安全规则绕过的问题时,请先通过私下渠道联系维护者,不要直接公开利用细节。 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 0355bd0..0000000 --- a/Dockerfile +++ /dev/null @@ -1,20 +0,0 @@ -# ---- 阶段 1:打后端 jar ---- -FROM maven:3.9-eclipse-temurin-17 AS backend -WORKDIR /build -# 先拷 pom 预热依赖缓存:源码变了不必重新下依赖 -COPY pom.xml ./ -RUN mvn -q dependency:go-offline -# 拷后端源码 -COPY src/ ./src/ -# 跳过测试打包(测试需要 MySQL,构建环境没有) -RUN mvn -q clean package -DskipTests - -# ---- 阶段 2:运行 ---- -# 只带 JRE,镜像更小 -FROM eclipse-temurin:17-jre -WORKDIR /app -COPY --from=backend /build/target/lowenssh-*.jar app.jar -EXPOSE 8081 -# 纯后端 API 服务,供 Flutter 桌面端 / CLI 客户端连接 -# 密钥全走环境变量,镜像里不含任何凭据 -ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/README.md b/README.md index 590177c..46448d3 100644 --- a/README.md +++ b/README.md @@ -1,129 +1,107 @@ # LowenSSH [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE) -[![Java 17](https://img.shields.io/badge/Java-17-orange.svg)](https://openjdk.org/projects/jdk/17/) -[![Spring Boot](https://img.shields.io/badge/Spring%20Boot-3.4-6DB33F.svg)](https://spring.io/projects/spring-boot) [![Flutter](https://img.shields.io/badge/Flutter-macOS%20%7C%20Windows-02569B.svg)](https://flutter.dev) +[![CI](https://github.com/Lowen-0621/LowenSSH/actions/workflows/ci.yml/badge.svg)](https://github.com/Lowen-0621/LowenSSH/actions/workflows/ci.yml) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) -AI 驱动的 SSH 智能运维 Agent。给它一个运维目标和一台服务器,它会像工程师一样一步步排查:自己决定跑什么命令、读结果、调整思路,直到给出结论。危险命令会被安全门禁实时拦截。 +AI 驱动的 SSH 智能运维 Agent。用户给出运维目标和目标服务器后,Agent 会自主选择工具、读取执行结果并持续调整方案;危险命令在实际执行前经过安全门禁。 -核心看点是「看得见 AI 在干什么,也看得见安全护栏起作用」——整个 agentic loop 是从零手写的,不套用任何编排框架。 +## 仓库范围 -## 界面预览 +公开仓库仅维护 Flutter 桌面客户端,代码位于 [`clients/app/`](clients/app/)。客户端内置 SSH 连接、Agent loop、安全门禁、上下文管理和大模型调用能力,可以独立运行。 -> 截图待补充。桌面端运行界面、解锁动画、智能体面板与安全门禁可视化效果。 -> -> +Java 后端与 Node CLI 为本地实现,不再包含在远程仓库当前版本中。 -## 三种形态 +## 核心能力 -同一套「Agent loop + 安全门禁 + 上下文管理」理念,落地为三个独立实现,按需选用: - -| 形态 | 目录 | 技术栈 | 说明 | -|------|------|--------|------| -| **后端服务** | `src/` | Java 17 · Spring Boot 3.4 · Spring AI | REST + SSE API,参考实现,逻辑最完整 | -| **桌面客户端** | `clients/app/` | Flutter(macOS / Windows) | 独立桌面应用,内置全套逻辑,直连大模型 | -| **CLI 客户端** | `clients/cli/` | Node 20 · Ink(TUI) | 终端里跑,类 Claude Code 的交互,内置全套逻辑 | - -三者**互不依赖**:桌面端和 CLI 各自内置 SSH + Agent loop + 门禁 + 大模型调用,不需要先起后端。门禁规则与事件语义在三端手动对齐。 - -> 想了解手写 agentic loop、安全门禁、上下文管理的设计取舍,见 [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)。 - -## 能力 - -- **手写 Agentic Loop**:不依赖 LangChain 之类的编排框架,自己实现「模型决策 → 调工具 → 喂回结果 → 再决策」的循环,逻辑完全可控、可读。 -- **安全门禁三态**:每条命令在执行前经过 `deny / ask / allow` 判定。`rm -rf`、`find -delete` 等高危操作直接拦截,模型被拦后会自主改用安全方式。 -- **流式可视化**:实时推送多类事件(模型 token、要跑的命令、命令结果、被拦截、最终结论、错误),逐字渲染整个排查过程。 -- **上下文管理**:多轮对话爆 context 时,自动做大工具结果截断 + 全量 LLM 摘要,复用消息表持久化。 -- **全程审计**:每次连接、每条命令、每个拦截决策都落库,可追溯。 +- **手写 Agent Loop**:实现“模型决策 → 工具调用 → 结果回灌 → 再决策”的循环。 +- **安全门禁**:命令执行前进行 `deny / ask / allow` 判定,高风险操作需要人工确认。 +- **流式过程展示**:区分模型输出、工具调用、工具结果、安全拦截和最终结论。 +- **上下文治理**:对大工具结果进行截断,并在历史过长时生成摘要。 +- **SSH 工具集**:支持命令执行、日志读取、文件管理、监控和端口转发。 ## 技术栈 -**后端**:Java 17 · Spring Boot 3.4 · Spring AI 1.1 · JSch(SSH)· MyBatis-Plus · MySQL · GLM-4.6(OpenAI 兼容协议,可换任意兼容模型) - -**桌面端**:Flutter · Dart(macOS / Windows 桌面) +Flutter · Dart · Riverpod · dartssh2 · Dio · PointyCastle · Secure Storage · xterm · docking -**CLI**:Node 20 · TypeScript · Ink · ssh2 · openai SDK +## 本地启动 -## 快速开始(后端服务) +### 1. 准备环境 -后端提供 REST + SSE API。客户端的运行方式见各自目录的 README([桌面端](clients/app/README.md) · [CLI](clients/cli/README.md))。 +- Flutter SDK 3.12+(Dart 3.12+) +- macOS:安装 Xcode +- Windows:安装 Visual Studio,并勾选“使用 C++ 的桌面开发” -### 方式一:Docker 一键启动(推荐) +```bash +flutter doctor +``` -需要 Docker。两个密钥走环境变量,不写进任何文件: +### 2. 安装依赖 ```bash -export MYSQL_PASSWORD='给MySQL容器设的root密码' -export GLM_API_KEY='你的智谱AI key' # https://open.bigmodel.cn 申请 -docker compose up --build +cd clients/app +flutter pub get ``` -compose 会自动起 MySQL(建库 + 执行 schema.sql 建表)、构建后端、等 DB 就绪后启动应用。API 监听 http://localhost:8081。 - -### 方式二:本地手动启动 +### 3. 配置大模型 -#### 1. 准备环境变量 +启动后可以在“设置”中填写 API Key、模型名称和 OpenAI 兼容接口地址。也可以通过环境变量临时注入默认 GLM Key: -应用读取两个环境变量,源码里不含任何明文密钥: +macOS / Linux: ```bash -export MYSQL_PASSWORD='你的MySQL密码' # 本机 MySQL root 密码,空密码则设为 '' -export GLM_API_KEY='你的智谱AI key' # https://open.bigmodel.cn 申请 +export GLM_API_KEY='你的 API Key' ``` -> 不设这两个变量,启动会因连不上 MySQL(500)或鉴权失败(401)而报错。 +Windows PowerShell: + +```powershell +$env:GLM_API_KEY='你的 API Key' +``` -#### 2. 初始化数据库 +环境变量只在当前进程中使用,不会被写回配置文件。 -先建库,再执行建表脚本: +### 4. 运行桌面端 ```bash -mysql -u root -p -e "CREATE DATABASE IF NOT EXISTS lowenssh DEFAULT CHARSET utf8mb4;" -mysql -u root -p lowenssh < src/main/resources/schema.sql +cd clients/app +flutter run -d macos # macOS +flutter run -d windows # Windows ``` -#### 3. 启动后端 - -项目自带 Maven Wrapper,无需预装 Maven: +### 5. 检查与打包 ```bash -./mvnw spring-boot:run # Windows 用 mvnw.cmd +cd clients/app +flutter analyze +flutter test +flutter build macos --release # macOS +flutter build windows --release # Windows ``` -API 监听 http://localhost:8081。 +更多模型配置和产物目录说明见 [`clients/app/README.md`](clients/app/README.md)。 ## 项目结构 -``` +```text LowenSSH/ -├── src/main/java/com/lowenssh/ -│ ├── agent/ # Agent 核心:loop、SSE 事件、上下文管理、安全门禁 -│ ├── ssh/ # JSch SSH 执行 -│ └── ... -├── src/main/resources/ -│ ├── application.yml # 配置(密钥走环境变量) -│ └── schema.sql # 建表脚本 -├── clients/ -│ ├── app/ # Flutter 桌面客户端(见 clients/app/README.md) -│ └── cli/ # Node CLI 客户端(见 clients/cli/README.md) -└── DESIGN.md # 设计规范 +├── clients/app/ # Flutter 桌面客户端 +├── docs/ # 架构与项目文档 +├── DESIGN.md # 设计规范 +└── CONTRIBUTING.md # 贡献指南 ``` ## 安全说明 -- 所有密钥走环境变量,源码无任何明文凭据。 -- 客户端密码字段不写入明文持久化(AES-GCM 加密落库),不打印到控制台。 -- 安全门禁的高危命令规则(含 `rm -rf`、`find -delete` 等变体)是真实防护,请勿在生产前移除。 -- 这是一个运维 Agent,会真实在目标服务器执行命令。请只连接你有权操作的服务器。 +- 主机密码经 AES-GCM 加密后保存,不记录明文。 +- 端口转发默认只绑定 `127.0.0.1`。 +- 危险命令执行前必须经过安全策略和人工确认。 +- 本项目会在目标服务器执行真实操作,请只连接你有权管理的服务器。 ## 贡献 -欢迎提 issue 和 PR。开发环境搭建、代码约定、提交规范见 [CONTRIBUTING.md](CONTRIBUTING.md)。 +欢迎提交 Issue 和 PR。开发环境、代码约定和提交流程见 [CONTRIBUTING.md](CONTRIBUTING.md)。 ## License diff --git a/clients/cli/.gitignore b/clients/cli/.gitignore deleted file mode 100644 index 9d3b50f..0000000 --- a/clients/cli/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -*.log -.lowenssh/ diff --git a/clients/cli/README.md b/clients/cli/README.md deleted file mode 100644 index 9e81585..0000000 --- a/clients/cli/README.md +++ /dev/null @@ -1,58 +0,0 @@ -# LowenSSH CLI - -LowenSSH 的命令行形态,在终端里跑的 AI SSH 运维 Agent,交互体验类似 Claude Code。基于 Node + Ink(TUI)。 - -内置全套逻辑——SSH 连接、手写 Agent loop、安全门禁、上下文管理、直连大模型——**不依赖项目的 Java 后端**,独立运行。 - -## 环境要求 - -- Node.js 20+ - -## 安装依赖 - -```bash -cd clients/cli -npm install -``` - -## 大模型配置 - -CLI 需要大模型 API Key(默认接入 GLM,走 OpenAI 兼容协议)。两种方式,环境变量优先: - -```bash -export GLM_API_KEY='你的智谱AI key' # https://open.bigmodel.cn 申请 -``` - -或写进配置文件 `~/.lowenssh/config.json`(文件权限 600)。缺 Key 时启动会给出明确提示,不会静默失败。 - -## 运行 - -开发模式(直接跑 TS 源码): - -```bash -npm run dev # 启动交互式 TUI -npm run dev add-host # 添加主机(无需 API Key) -``` - -构建后作为命令安装: - -```bash -npm run build # 产物输出到 dist/ -npm link # 注册全局命令 lowenssh -lowenssh # 启动 -lowenssh add-host # 添加主机 -``` - -## 开发 - -```bash -npm test # vitest 跑单测(门禁、加密) -npm run typecheck # tsc 类型检查 -``` - -## 安全说明 - -- 主机密码 AES-GCM 加密后落盘,配置文件权限 600,不存明文。 -- 环境变量注入的 API Key 不会被写回配置文件。 -- 安全门禁的高危命令规则与后端、桌面端对齐,是真实防护。 -- 这是一个运维 Agent,会真实在目标服务器执行命令。请只连接你有权操作的服务器。 diff --git a/clients/cli/package-lock.json b/clients/cli/package-lock.json deleted file mode 100644 index cf548dd..0000000 --- a/clients/cli/package-lock.json +++ /dev/null @@ -1,4113 +0,0 @@ -{ - "name": "lowenssh", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "lowenssh", - "version": "0.1.0", - "dependencies": { - "ink": "^5.1.0", - "ink-text-input": "^6.0.0", - "openai": "^4.77.0", - "react": "^18.3.1", - "ssh2": "^1.16.0" - }, - "bin": { - "lowenssh": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "@types/react": "^18.3.1", - "@types/ssh2": "^1.15.0", - "tsup": "^8.3.5", - "tsx": "^4.19.2", - "typescript": "^5.7.2", - "vitest": "^2.1.8" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@alcalzone/ansi-tokenize": { - "version": "0.1.3", - "resolved": "https://registry.npmmirror.com/@alcalzone/ansi-tokenize/-/ansi-tokenize-0.1.3.tgz", - "integrity": "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=14.13.1" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", - "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.27.7.tgz", - "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", - "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.27.7.tgz", - "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", - "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", - "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", - "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", - "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", - "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", - "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", - "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", - "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", - "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", - "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", - "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", - "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", - "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", - "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", - "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", - "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", - "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", - "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", - "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", - "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", - "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", - "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", - "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", - "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", - "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", - "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", - "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", - "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", - "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", - "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", - "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", - "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", - "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", - "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", - "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", - "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", - "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", - "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", - "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", - "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", - "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", - "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", - "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", - "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", - "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", - "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", - "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmmirror.com/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "@types/prop-types": "*", - "csstype": "^3.2.2" - } - }, - "node_modules/@types/ssh2": { - "version": "1.15.5", - "resolved": "https://registry.npmmirror.com/@types/ssh2/-/ssh2-1.15.5.tgz", - "integrity": "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "^18.11.18" - } - }, - "node_modules/@types/ssh2/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/@types/ssh2/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/expect": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-2.1.9.tgz", - "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@vitest/mocker/-/mocker-2.1.9.tgz", - "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "2.1.9", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.12" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", - "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@vitest/runner/-/runner-2.1.9.tgz", - "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "2.1.9", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/snapshot": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@vitest/snapshot/-/snapshot-2.1.9.tgz", - "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "magic-string": "^0.30.12", - "pathe": "^1.1.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vitest/spy": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@vitest/spy/-/spy-2.1.9.tgz", - "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^3.0.2" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/@vitest/utils/-/utils-2.1.9.tgz", - "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "2.1.9", - "loupe": "^3.1.2", - "tinyrainbow": "^1.2.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "license": "MIT", - "dependencies": { - "event-target-shim": "^5.0.0" - }, - "engines": { - "node": ">=6.5" - } - }, - "node_modules/acorn": { - "version": "8.17.0", - "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.17.0.tgz", - "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", - "dev": true, - "license": "MIT", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmmirror.com/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/ansi-escapes": { - "version": "7.3.0", - "resolved": "https://registry.npmmirror.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz", - "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", - "license": "MIT", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ansi-regex": { - "version": "6.2.2", - "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-6.2.2.tgz", - "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmmirror.com/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/asynckit": { - "version": "0.4.0", - "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "license": "MIT" - }, - "node_modules/auto-bind": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/auto-bind/-/auto-bind-5.0.1.tgz", - "integrity": "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "license": "BSD-3-Clause", - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, - "node_modules/buildcheck": { - "version": "0.0.7", - "resolved": "https://registry.npmmirror.com/buildcheck/-/buildcheck-0.0.7.tgz", - "integrity": "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==", - "optional": true, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/bundle-require": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/bundle-require/-/bundle-require-5.1.0.tgz", - "integrity": "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==", - "dev": true, - "license": "MIT", - "dependencies": { - "load-tsconfig": "^0.2.3" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "peerDependencies": { - "esbuild": ">=0.18" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.14.tgz", - "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/chai": { - "version": "5.3.3", - "resolved": "https://registry.npmmirror.com/chai/-/chai-5.3.3.tgz", - "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmmirror.com/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/check-error/-/check-error-2.1.3.tgz", - "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/chokidar": { - "version": "4.0.3", - "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", - "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "readdirp": "^4.0.1" - }, - "engines": { - "node": ">= 14.16.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmmirror.com/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/cli-cursor/-/cli-cursor-4.0.0.tgz", - "integrity": "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==", - "license": "MIT", - "dependencies": { - "restore-cursor": "^4.0.0" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "license": "MIT", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/code-excerpt": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/code-excerpt/-/code-excerpt-4.0.0.tgz", - "integrity": "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==", - "license": "MIT", - "dependencies": { - "convert-to-spaces": "^2.0.1" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/combined-stream": { - "version": "1.0.8", - "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "license": "MIT", - "dependencies": { - "delayed-stream": "~1.0.0" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/confbox": { - "version": "0.1.8", - "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", - "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/consola": { - "version": "3.4.2", - "resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz", - "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, - "node_modules/convert-to-spaces": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/convert-to-spaces/-/convert-to-spaces-2.0.1.tgz", - "integrity": "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/cpu-features": { - "version": "0.0.10", - "resolved": "https://registry.npmmirror.com/cpu-features/-/cpu-features-0.0.10.tgz", - "integrity": "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==", - "hasInstallScript": true, - "optional": true, - "dependencies": { - "buildcheck": "~0.0.6", - "nan": "^2.19.0" - }, - "engines": { - "node": ">=10.0.0" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "devOptional": true, - "license": "MIT" - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "resolved": "https://registry.npmmirror.com/deep-eql/-/deep-eql-5.0.2.tgz", - "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/delayed-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "license": "MIT", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "license": "MIT" - }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", - "dev": true, - "license": "MIT" - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-set-tostringtag": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.6", - "has-tostringtag": "^1.0.2", - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-toolkit": { - "version": "1.48.1", - "resolved": "https://registry.npmmirror.com/es-toolkit/-/es-toolkit-1.48.1.tgz", - "integrity": "sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==", - "license": "MIT", - "workspaces": [ - "docs", - "benchmarks" - ] - }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" - } - }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/expect-type": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/expect-type/-/expect-type-1.3.0.tgz", - "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/fix-dts-default-cjs-exports": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz", - "integrity": "sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.17", - "mlly": "^1.7.4", - "rollup": "^4.34.8" - } - }, - "node_modules/form-data": { - "version": "4.0.6", - "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.6.tgz", - "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "license": "MIT", - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.8", - "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.4", - "mime-types": "^2.1.35" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmmirror.com/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmmirror.com/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-east-asian-width": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", - "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/has-tostringtag": { - "version": "1.0.2", - "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "license": "MIT", - "dependencies": { - "has-symbols": "^1.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, - "node_modules/indent-string": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/indent-string/-/indent-string-5.0.0.tgz", - "integrity": "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ink": { - "version": "5.2.1", - "resolved": "https://registry.npmmirror.com/ink/-/ink-5.2.1.tgz", - "integrity": "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg==", - "license": "MIT", - "dependencies": { - "@alcalzone/ansi-tokenize": "^0.1.3", - "ansi-escapes": "^7.0.0", - "ansi-styles": "^6.2.1", - "auto-bind": "^5.0.1", - "chalk": "^5.3.0", - "cli-boxes": "^3.0.0", - "cli-cursor": "^4.0.0", - "cli-truncate": "^4.0.0", - "code-excerpt": "^4.0.0", - "es-toolkit": "^1.22.0", - "indent-string": "^5.0.0", - "is-in-ci": "^1.0.0", - "patch-console": "^2.0.0", - "react-reconciler": "^0.29.0", - "scheduler": "^0.23.0", - "signal-exit": "^3.0.7", - "slice-ansi": "^7.1.0", - "stack-utils": "^2.0.6", - "string-width": "^7.2.0", - "type-fest": "^4.27.0", - "widest-line": "^5.0.0", - "wrap-ansi": "^9.0.0", - "ws": "^8.18.0", - "yoga-layout": "~3.2.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "react": ">=18.0.0", - "react-devtools-core": "^4.19.1" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react-devtools-core": { - "optional": true - } - } - }, - "node_modules/ink-text-input": { - "version": "6.0.0", - "resolved": "https://registry.npmmirror.com/ink-text-input/-/ink-text-input-6.0.0.tgz", - "integrity": "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw==", - "license": "MIT", - "dependencies": { - "chalk": "^5.3.0", - "type-fest": "^4.18.2" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "ink": ">=5", - "react": ">=18" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-in-ci": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/is-in-ci/-/is-in-ci-1.0.0.tgz", - "integrity": "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg==", - "license": "MIT", - "bin": { - "is-in-ci": "cli.js" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/joycon": { - "version": "3.1.1", - "resolved": "https://registry.npmmirror.com/joycon/-/joycon-3.1.1.tgz", - "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true, - "license": "MIT" - }, - "node_modules/load-tsconfig": { - "version": "0.2.5", - "resolved": "https://registry.npmmirror.com/load-tsconfig/-/load-tsconfig-0.2.5.tgz", - "integrity": "sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/loupe/-/loupe-3.2.1.tgz", - "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "license": "MIT", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/mlly": { - "version": "1.8.2", - "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", - "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", - "dev": true, - "license": "MIT", - "dependencies": { - "acorn": "^8.16.0", - "pathe": "^2.0.3", - "pkg-types": "^1.3.1", - "ufo": "^1.6.3" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, - "node_modules/nan": { - "version": "2.27.0", - "resolved": "https://registry.npmmirror.com/nan/-/nan-2.27.0.tgz", - "integrity": "sha512-hC+0LidcL3XE4rp1C4H54KujgXKzbfyTngZTwBByQxsOxCEKZT0MPQ4hOKUH2jU1OYstqdDH4onyHPDzcV0XdQ==", - "license": "MIT", - "optional": true - }, - "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-domexception": { - "version": "1.0.0", - "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", - "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", - "deprecated": "Use your platform's native DOMException instead", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/jimmywarting" - }, - { - "type": "github", - "url": "https://paypal.me/jimmywarting" - } - ], - "license": "MIT", - "engines": { - "node": ">=10.5.0" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/onetime": { - "version": "5.1.2", - "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", - "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "license": "MIT", - "dependencies": { - "mimic-fn": "^2.1.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/openai": { - "version": "4.104.0", - "resolved": "https://registry.npmmirror.com/openai/-/openai-4.104.0.tgz", - "integrity": "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - }, - "bin": { - "openai": "bin/cli" - }, - "peerDependencies": { - "ws": "^8.18.0", - "zod": "^3.23.8" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - }, - "zod": { - "optional": true - } - } - }, - "node_modules/openai/node_modules/@types/node": { - "version": "18.19.130", - "resolved": "https://registry.npmmirror.com/@types/node/-/node-18.19.130.tgz", - "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", - "license": "MIT", - "dependencies": { - "undici-types": "~5.26.4" - } - }, - "node_modules/openai/node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", - "license": "MIT" - }, - "node_modules/patch-console": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/patch-console/-/patch-console-2.0.0.tgz", - "integrity": "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==", - "license": "MIT", - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - } - }, - "node_modules/pathe": { - "version": "2.0.3", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", - "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "resolved": "https://registry.npmmirror.com/pathval/-/pathval-2.0.1.tgz", - "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmmirror.com/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/pkg-types": { - "version": "1.3.1", - "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", - "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "confbox": "^0.1.8", - "mlly": "^1.7.4", - "pathe": "^2.0.1" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/postcss-load-config": { - "version": "6.0.1", - "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz", - "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.1.1" - }, - "engines": { - "node": ">= 18" - }, - "peerDependencies": { - "jiti": ">=1.21.0", - "postcss": ">=8.0.9", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "jiti": { - "optional": true - }, - "postcss": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-reconciler": { - "version": "0.29.2", - "resolved": "https://registry.npmmirror.com/react-reconciler/-/react-reconciler-0.29.2.tgz", - "integrity": "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "react": "^18.3.1" - } - }, - "node_modules/readdirp": { - "version": "4.1.2", - "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", - "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.18.0" - }, - "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - }, - "node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/restore-cursor": { - "version": "4.0.0", - "resolved": "https://registry.npmmirror.com/restore-cursor/-/restore-cursor-4.0.0.tgz", - "integrity": "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==", - "license": "MIT", - "dependencies": { - "onetime": "^5.1.0", - "signal-exit": "^3.0.2" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/rollup": { - "version": "4.62.2", - "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", - "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.62.2", - "@rollup/rollup-android-arm64": "4.62.2", - "@rollup/rollup-darwin-arm64": "4.62.2", - "@rollup/rollup-darwin-x64": "4.62.2", - "@rollup/rollup-freebsd-arm64": "4.62.2", - "@rollup/rollup-freebsd-x64": "4.62.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", - "@rollup/rollup-linux-arm-musleabihf": "4.62.2", - "@rollup/rollup-linux-arm64-gnu": "4.62.2", - "@rollup/rollup-linux-arm64-musl": "4.62.2", - "@rollup/rollup-linux-loong64-gnu": "4.62.2", - "@rollup/rollup-linux-loong64-musl": "4.62.2", - "@rollup/rollup-linux-ppc64-gnu": "4.62.2", - "@rollup/rollup-linux-ppc64-musl": "4.62.2", - "@rollup/rollup-linux-riscv64-gnu": "4.62.2", - "@rollup/rollup-linux-riscv64-musl": "4.62.2", - "@rollup/rollup-linux-s390x-gnu": "4.62.2", - "@rollup/rollup-linux-x64-gnu": "4.62.2", - "@rollup/rollup-linux-x64-musl": "4.62.2", - "@rollup/rollup-openbsd-x64": "4.62.2", - "@rollup/rollup-openharmony-arm64": "4.62.2", - "@rollup/rollup-win32-arm64-msvc": "4.62.2", - "@rollup/rollup-win32-ia32-msvc": "4.62.2", - "@rollup/rollup-win32-x64-gnu": "4.62.2", - "@rollup/rollup-win32-x64-msvc": "4.62.2", - "fsevents": "~2.3.2" - } - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT" - }, - "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "license": "ISC" - }, - "node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmmirror.com/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { - "version": "5.1.0", - "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", - "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.1" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/ssh2": { - "version": "1.17.0", - "resolved": "https://registry.npmmirror.com/ssh2/-/ssh2-1.17.0.tgz", - "integrity": "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==", - "hasInstallScript": true, - "dependencies": { - "asn1": "^0.2.6", - "bcrypt-pbkdf": "^1.0.2" - }, - "engines": { - "node": ">=10.16.0" - }, - "optionalDependencies": { - "cpu-features": "~0.0.10", - "nan": "^2.23.0" - } - }, - "node_modules/stack-utils": { - "version": "2.0.6", - "resolved": "https://registry.npmmirror.com/stack-utils/-/stack-utils-2.0.6.tgz", - "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", - "license": "MIT", - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmmirror.com/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmmirror.com/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmmirror.com/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-0.3.2.tgz", - "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "resolved": "https://registry.npmmirror.com/tinypool/-/tinypool-1.1.1.tgz", - "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "1.2.0", - "resolved": "https://registry.npmmirror.com/tinyrainbow/-/tinyrainbow-1.2.0.tgz", - "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "3.0.2", - "resolved": "https://registry.npmmirror.com/tinyspy/-/tinyspy-3.0.2.tgz", - "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/tree-kill": { - "version": "1.2.2", - "resolved": "https://registry.npmmirror.com/tree-kill/-/tree-kill-1.2.2.tgz", - "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", - "dev": true, - "license": "MIT", - "bin": { - "tree-kill": "cli.js" - } - }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmmirror.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, - "node_modules/tsup": { - "version": "8.5.1", - "resolved": "https://registry.npmmirror.com/tsup/-/tsup-8.5.1.tgz", - "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==", - "dev": true, - "license": "MIT", - "dependencies": { - "bundle-require": "^5.1.0", - "cac": "^6.7.14", - "chokidar": "^4.0.3", - "consola": "^3.4.0", - "debug": "^4.4.0", - "esbuild": "^0.27.0", - "fix-dts-default-cjs-exports": "^1.0.0", - "joycon": "^3.1.1", - "picocolors": "^1.1.1", - "postcss-load-config": "^6.0.1", - "resolve-from": "^5.0.0", - "rollup": "^4.34.8", - "source-map": "^0.7.6", - "sucrase": "^3.35.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.11", - "tree-kill": "^1.2.2" - }, - "bin": { - "tsup": "dist/cli-default.js", - "tsup-node": "dist/cli-node.js" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@microsoft/api-extractor": "^7.36.0", - "@swc/core": "^1", - "postcss": "^8.4.12", - "typescript": ">=4.5.0" - }, - "peerDependenciesMeta": { - "@microsoft/api-extractor": { - "optional": true - }, - "@swc/core": { - "optional": true - }, - "postcss": { - "optional": true - }, - "typescript": { - "optional": true - } - } - }, - "node_modules/tsx": { - "version": "4.22.4", - "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz", - "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "~0.28.0" - }, - "bin": { - "tsx": "dist/cli.mjs" - }, - "engines": { - "node": ">=18.0.0" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - } - }, - "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", - "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz", - "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", - "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/android-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz", - "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", - "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/darwin-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", - "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", - "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", - "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", - "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", - "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", - "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-loong64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", - "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", - "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", - "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", - "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-s390x": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", - "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/linux-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", - "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", - "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", - "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", - "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", - "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", - "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/sunos-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", - "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-arm64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", - "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-ia32": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", - "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/@esbuild/win32-x64": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", - "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/tsx/node_modules/esbuild": { - "version": "0.28.1", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.1.tgz", - "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.1", - "@esbuild/android-arm": "0.28.1", - "@esbuild/android-arm64": "0.28.1", - "@esbuild/android-x64": "0.28.1", - "@esbuild/darwin-arm64": "0.28.1", - "@esbuild/darwin-x64": "0.28.1", - "@esbuild/freebsd-arm64": "0.28.1", - "@esbuild/freebsd-x64": "0.28.1", - "@esbuild/linux-arm": "0.28.1", - "@esbuild/linux-arm64": "0.28.1", - "@esbuild/linux-ia32": "0.28.1", - "@esbuild/linux-loong64": "0.28.1", - "@esbuild/linux-mips64el": "0.28.1", - "@esbuild/linux-ppc64": "0.28.1", - "@esbuild/linux-riscv64": "0.28.1", - "@esbuild/linux-s390x": "0.28.1", - "@esbuild/linux-x64": "0.28.1", - "@esbuild/netbsd-arm64": "0.28.1", - "@esbuild/netbsd-x64": "0.28.1", - "@esbuild/openbsd-arm64": "0.28.1", - "@esbuild/openbsd-x64": "0.28.1", - "@esbuild/openharmony-arm64": "0.28.1", - "@esbuild/sunos-x64": "0.28.1", - "@esbuild/win32-arm64": "0.28.1", - "@esbuild/win32-ia32": "0.28.1", - "@esbuild/win32-x64": "0.28.1" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmmirror.com/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "license": "Unlicense" - }, - "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmmirror.com/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/ufo": { - "version": "1.6.4", - "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.4.tgz", - "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", - "dev": true, - "license": "MIT" - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "license": "MIT" - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/vite-node/-/vite-node-2.1.9.tgz", - "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.3.7", - "es-module-lexer": "^1.5.4", - "pathe": "^1.1.2", - "vite": "^5.0.0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vite-node/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/vite/node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/vitest": { - "version": "2.1.9", - "resolved": "https://registry.npmmirror.com/vitest/-/vitest-2.1.9.tgz", - "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "2.1.9", - "@vitest/mocker": "2.1.9", - "@vitest/pretty-format": "^2.1.9", - "@vitest/runner": "2.1.9", - "@vitest/snapshot": "2.1.9", - "@vitest/spy": "2.1.9", - "@vitest/utils": "2.1.9", - "chai": "^5.1.2", - "debug": "^4.3.7", - "expect-type": "^1.1.0", - "magic-string": "^0.30.12", - "pathe": "^1.1.2", - "std-env": "^3.8.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.1", - "tinypool": "^1.0.1", - "tinyrainbow": "^1.2.0", - "vite": "^5.0.0", - "vite-node": "2.1.9", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/node": "^18.0.0 || >=20.0.0", - "@vitest/browser": "2.1.9", - "@vitest/ui": "2.1.9", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/vitest/node_modules/pathe": { - "version": "1.1.2", - "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", - "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/widest-line": { - "version": "5.0.0", - "resolved": "https://registry.npmmirror.com/widest-line/-/widest-line-5.0.0.tgz", - "integrity": "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==", - "license": "MIT", - "dependencies": { - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/ws": { - "version": "8.21.0", - "resolved": "https://registry.npmmirror.com/ws/-/ws-8.21.0.tgz", - "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yoga-layout": { - "version": "3.2.1", - "resolved": "https://registry.npmmirror.com/yoga-layout/-/yoga-layout-3.2.1.tgz", - "integrity": "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ==", - "license": "MIT" - } - } -} diff --git a/clients/cli/package.json b/clients/cli/package.json deleted file mode 100644 index 0857d61..0000000 --- a/clients/cli/package.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "name": "lowenssh", - "version": "0.1.0", - "description": "LowenSSH —— 终端里的 AI SSH 运维 Agent(类 Claude Code 的 CLI)", - "type": "module", - "bin": { - "lowenssh": "dist/cli.js" - }, - "scripts": { - "dev": "tsx src/cli.tsx", - "build": "tsup", - "test": "vitest run", - "test:watch": "vitest", - "typecheck": "tsc --noEmit" - }, - "engines": { - "node": ">=20" - }, - "dependencies": { - "ink": "^5.1.0", - "ink-text-input": "^6.0.0", - "react": "^18.3.1", - "ssh2": "^1.16.0", - "openai": "^4.77.0" - }, - "devDependencies": { - "@types/node": "^22.10.0", - "@types/react": "^18.3.1", - "@types/ssh2": "^1.15.0", - "tsup": "^8.3.5", - "tsx": "^4.19.2", - "typescript": "^5.7.2", - "vitest": "^2.1.8" - }, - "files": [ - "dist" - ] -} diff --git a/clients/cli/src/cli.tsx b/clients/cli/src/cli.tsx deleted file mode 100644 index 7eb6037..0000000 --- a/clients/cli/src/cli.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/** - * LowenSSH CLI 入口。读配置 → 渲染 TUI。 - * apiKey 缺失时给出明确提示(环境变量 GLM_API_KEY 或写进配置文件),不静默失败。 - */ -import { render, Box, Text } from 'ink' -import { loadConfig, CONFIG_FILE } from './core/config.js' -import { App } from './ui/App.js' -import { AddHost } from './ui/AddHost.js' - -const command = process.argv[2] - -// 子命令:add-host —— 加主机不依赖 apiKey,单独路由 -if (command === 'add-host') { - render() -} else { - const config = loadConfig() - - if (!config.llm.apiKey || config.llm.apiKey.trim() === '') { - render( - - ✗ 缺少 GLM API Key - 设置环境变量 GLM_API_KEY,或填进配置文件: - {CONFIG_FILE} - , - ) - } else { - render() - } -} diff --git a/clients/cli/src/core/agent.ts b/clients/cli/src/core/agent.ts deleted file mode 100644 index c62ca2e..0000000 --- a/clients/cli/src/core/agent.ts +++ /dev/null @@ -1,290 +0,0 @@ -/** - * Agent 核心 —— 手写的 agentic loop + 安全门禁。 - * 移植自 Java 版 AgentService,对外用 async generator 吐 6 类事件。 - * - * 循环结构(Claude Code 同款状态机): - * 请求 → 模型返回 → 有 tool_call? - * 有 → 门禁预检 → 全放行才执行工具 → 结果回灌 → 带新历史再请求 - * 任一被拒 → 不执行,回灌"拒绝"作为工具结果 → loop 继续让模型换方案 - * 无 → 模型给出最终结论,循环结束 - * 加最大轮数上限防死循环。 - * - * 安全是独立代码路径:门禁不写进工具、不靠模型自觉,越狱也绕不过。 - */ -import { evaluate } from './guard.js' -import type { SshClient } from './ssh.js' -import type { GlmClient, ChatMessage, ToolCall, ToolDef } from './glm.js' -import { ContextManager } from './context.js' -import type { AgentEvent, Confirmer } from './events.js' - -const MAX_ROUNDS = 40 -const EXEC_TOOL = 'execCommand' - -const SYSTEM_PROMPT = `## 身份 -你是 LowenSSH,一个面向 Linux 服务器的 SSH/SFTP 智能体。 -你帮用户远程排查问题、执行命令、读取文件、查看日志,并在用户授权下完成文件传输等运维操作。 -你的能力随工具集扩展——当前可用的工具见工具列表,只调用列表里实际存在的工具,不要臆造工具。 - -## 环境与安全 -你执行的每条命令都会经过一道独立的安全门禁,危险命令会被拦截。 -被拦时换一个更安全的方式达成目标,不要重复同一条被拒命令,也不要改用等价的危险命令绕过拦截。 - -## 工作方式 -- 先理解任务目标再决定查什么,每步拿到结果后判断下一步,不要一次堆一堆命令。 -- 优先用只读命令探查(df / free / ps / cat / tail),看清现状再动有副作用的操作。 -- 命令输出可能被截断(节省 token),抓关键信息即可,需要时再精确查询。 - -## 输出格式 -- 用中文,给出结论,不要只罗列原始命令输出。 -- 简单结果用自然语言简短回答,多维度信息才用列表或表格。 -- 关键数字(磁盘占用 %、内存、负载等)直接点出来,别让用户自己从输出里找。` - -/** 工具集 schema —— 与 Java 版 SshTools 的 @Tool 对齐 */ -const TOOLS: ToolDef[] = [ - { - type: 'function', - function: { - name: 'execCommand', - description: - '在目标服务器上执行一条 shell 命令,返回标准输出、错误输出和退出码。用于查看系统状态、进程、磁盘等运维操作。', - parameters: { - type: 'object', - properties: { - command: { type: 'string', description: "要执行的 shell 命令,例如 'df -h'" }, - }, - required: ['command'], - }, - }, - }, - { - type: 'function', - function: { - name: 'readRemoteFile', - description: '读取目标服务器上指定路径的文本文件的完整内容。', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: "远程文件绝对路径,例如 '/etc/nginx/nginx.conf'" }, - }, - required: ['path'], - }, - }, - }, - { - type: 'function', - function: { - name: 'tailLog', - description: '读取目标服务器上日志文件的末尾若干行,用于快速查看最新日志。', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: '日志文件绝对路径' }, - lines: { type: 'number', description: '读取末尾的行数,例如 100' }, - }, - required: ['path', 'lines'], - }, - }, - }, - { - type: 'function', - function: { - name: 'listFiles', - description: '列出目标服务器上指定目录的文件和子目录。', - parameters: { - type: 'object', - properties: { - path: { type: 'string', description: "目录绝对路径,例如 '/var/log'" }, - }, - required: ['path'], - }, - }, - }, -] - -/** 执行一个工具调用,返回喂回模型的文本结果。只读工具直接跑;execCommand 已过门禁。 */ -async function runTool(ssh: SshClient, name: string, args: Record): Promise { - try { - switch (name) { - case 'execCommand': { - const r = await ssh.exec(String(args.command ?? '')) - return formatExec(r) - } - case 'readRemoteFile': { - const r = await ssh.exec(`cat '${args.path}'`) - return formatExec(r) - } - case 'tailLog': { - const r = await ssh.exec(`tail -n ${Number(args.lines) || 100} '${args.path}'`) - return formatExec(r) - } - case 'listFiles': { - const files = await ssh.listDir(String(args.path ?? '')) - if (files.length === 0) return '(空目录)' + args.path - const lines = files.map( - (f) => `${f.isDir ? '[d]' : '[f]'} ${f.name} ${f.isDir ? '-' : f.size + 'B'} ${f.perms}`, - ) - return `目录 ${args.path} 共 ${files.length} 项:\n` + lines.join('\n') - } - default: - return `未知工具: ${name}` - } - } catch (e) { - // 工具内部异常不抛给 loop,作为"工具结果"回灌,让模型知道这步失败 - return `命令执行异常: ${(e as Error).message}` - } -} - -function formatExec(r: { stdout: string; stderr: string; exitCode: number }): string { - let s = `exitCode=${r.exitCode}\n` - if (r.stdout) s += `stdout:\n${r.stdout}` - if (r.stderr) s += `stderr:\n${r.stderr}` - return s -} - -/** 从 tool_call 参数 JSON 取出 command 字段 */ -function extractCommand(argsJson: string): string { - try { - const obj = JSON.parse(argsJson) as { command?: string } - return obj.command ?? '' - } catch { - return '' - } -} - -export interface AgentDeps { - llm: GlmClient - ssh: SshClient - confirmer: Confirmer - /** 历史消息(多轮续聊)。首轮传空数组。loop 结束后调用方可读回更新后的历史。 */ - history?: ChatMessage[] -} - -/** - * 跑一轮 agent 任务,以 async generator 吐事件流。 - * 调用方 for-await 消费事件;事件语义见 events.ts。 - */ -export async function* runAgent(task: string, deps: AgentDeps): AsyncGenerator { - const { llm, ssh, confirmer } = deps - const ctx = new ContextManager(llm) - - let messages: ChatMessage[] = [ - { role: 'system', content: SYSTEM_PROMPT }, - ...(deps.history ?? []), - { role: 'user', content: task }, - ] - - for (let round = 1; round <= MAX_ROUNDS; round++) { - // 进模型前整理上下文:Layer 0 截断 + Layer 4 压缩 - messages = ctx.truncateToolResponses(messages) - messages = await ctx.compressIfNeeded(messages) - - // 一次流式调用:边推 token/reasoning 边聚合 - const pending: AgentEvent[] = [] - const result = await llm.stream(messages, TOOLS, { - onToken: (t) => pending.push({ type: 'token', text: t }), - onReasoning: (t) => pending.push({ type: 'reasoning', text: t }), - }) - // 把流式期间攒的增量事件吐出去 - for (const ev of pending) yield ev - - // 没有 tool_call:模型给出最终结论,结束 - if (result.toolCalls.length === 0) { - const text = result.text?.trim() || '模型暂时没有返回内容,请重试。' - yield { type: 'done', finalText: text } - return - } - - // 落 assistant(文字 + tool_calls) - const assistant: ChatMessage = { - role: 'assistant', - content: result.text || null, - tool_calls: result.toolCalls, - } - // 先把本轮要调的工具吐出去 - for (const call of result.toolCalls) { - yield { type: 'tool_call', name: call.function.name, args: call.function.arguments } - } - - // —— 门禁预检 + 执行 —— - const toolResponses: ChatMessage[] = [] - let anyRejected = false - - for (const call of result.toolCalls) { - const reject = await screenAndRun(call, ssh, confirmer, (ev) => pending.push(ev)) - // screenAndRun 把 blocked/tool_result 事件塞进 pending - toolResponses.push({ role: 'tool', tool_call_id: call.id, content: reject.content }) - if (reject.rejected) anyRejected = true - } - // 吐出执行阶段攒的事件(blocked / tool_result) - for (const ev of pending) yield ev - pending.length = 0 - - // 回灌历史:assistant + 所有 tool 结果(被拒的也回灌"拒绝"文本,让模型换方案) - messages.push(assistant, ...toolResponses) - void anyRejected // 拒绝与否都已通过 tool 结果回灌,loop 自然继续 - } - - yield { - type: 'done', - finalText: `已达到最大循环轮数(${MAX_ROUNDS}),任务可能未完成。请拆分任务后重试。`, - } -} - -/** - * 对单个 tool_call 过门禁并执行。 - * 返回 { content, rejected }:content 是回灌给模型的文本,rejected 表示被拒未执行。 - * 通过 emit 推 blocked / tool_result 事件。 - */ -async function screenAndRun( - call: ToolCall, - ssh: SshClient, - confirmer: Confirmer, - emit: (ev: AgentEvent) => void, -): Promise<{ content: string; rejected: boolean }> { - const name = call.function.name - let args: Record = {} - try { - args = JSON.parse(call.function.arguments) as Record - } catch { - // 参数解析失败,交给工具自己处理(会报错回灌) - } - - // 非 execCommand 的工具(读文件/看日志/列目录)只读,直接放行 - if (name !== EXEC_TOOL) { - const content = await runTool(ssh, name, args) - emit({ type: 'tool_result', name, summary: summarize(content), executed: true }) - return { content, rejected: false } - } - - const command = extractCommand(call.function.arguments) - const verdict = evaluate(command) - - if (verdict.decision === 'DENY') { - const reason = verdict.reason - emit({ type: 'blocked', command, reason }) - return { - content: `命令被安全门禁拒绝执行(${reason})。请改用更安全的方式。`, - rejected: true, - } - } - - if (verdict.decision === 'ASK') { - const ok = await confirmer(command, verdict.reason) - if (!ok) { - emit({ type: 'blocked', command, reason: '用户拒绝: ' + verdict.reason }) - return { content: '用户拒绝执行该命令。请换一种方式或询问用户。', rejected: true } - } - } - - // ALLOW 或 ASK 已批准:执行 - const content = await runTool(ssh, name, args) - emit({ type: 'tool_result', name, summary: summarize(content), executed: true }) - return { content, rejected: false } -} - -/** 工具结果摘要:超 500 字符截断(仅用于事件展示,回灌给模型的是完整内容) */ -function summarize(data: string): string { - return data.length > 500 ? data.slice(0, 500) + '…' : data -} - -export { TOOLS, SYSTEM_PROMPT, MAX_ROUNDS } diff --git a/clients/cli/src/core/config.ts b/clients/cli/src/core/config.ts deleted file mode 100644 index 679e4cb..0000000 --- a/clients/cli/src/core/config.ts +++ /dev/null @@ -1,124 +0,0 @@ -/** - * 本地配置 —— 主机簿 + GLM 接入设置,存 ~/.lowenssh/config.json。 - * - * 内置版(不依赖后端)的持久化层:替代 Java 版的 MySQL t_host。 - * 主机密码用 AES-GCM 加密后存 passwordEnc 字段,绝不存明文(复用 crypto.ts)。 - * 配置文件权限设为 600,只有属主可读写。 - */ -import { homedir } from 'node:os' -import { join } from 'node:path' -import { mkdirSync, readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs' -import { randomUUID } from 'node:crypto' -import { encrypt, decrypt } from './crypto.js' - -/** 一台主机的连接信息 */ -export interface Host { - id: string - alias?: string - host: string - port: number - user: string - /** AES-GCM 加密后的密码;未存密码则为空 */ - passwordEnc?: string -} - -/** GLM/OpenAI 兼容接入设置 */ -export interface LlmConfig { - baseURL: string - apiKey: string - model: string -} - -export interface AppConfig { - hosts: Host[] - llm: LlmConfig -} - -const CONFIG_DIR = join(homedir(), '.lowenssh') -const CONFIG_FILE = join(CONFIG_DIR, 'config.json') - -/** 默认 LLM 设置:GLM。apiKey 留空,首次运行提示用户填或从环境变量读 */ -const DEFAULT_LLM: LlmConfig = { - baseURL: 'https://open.bigmodel.cn/api/paas/v4', - apiKey: '', - model: 'glm-4.6', -} - -function emptyConfig(): AppConfig { - return { hosts: [], llm: { ...DEFAULT_LLM } } -} - -/** 读配置;不存在则返回空配置。环境变量 GLM_API_KEY 优先覆盖文件里的 apiKey。 */ -export function loadConfig(): AppConfig { - let cfg: AppConfig - if (!existsSync(CONFIG_FILE)) { - cfg = emptyConfig() - } else { - try { - const raw = readFileSync(CONFIG_FILE, 'utf8') - const parsed = JSON.parse(raw) as Partial - cfg = { - hosts: parsed.hosts ?? [], - llm: { ...DEFAULT_LLM, ...parsed.llm }, - } - } catch { - // 配置损坏不影响启动,退回空配置(用户可重新添加) - cfg = emptyConfig() - } - } - // 环境变量优先:方便 CI / 临时覆盖,且不把 key 写进文件 - const envKey = process.env.GLM_API_KEY - if (envKey && envKey.trim() !== '') { - cfg.llm.apiKey = envKey - } - return cfg -} - -/** 写配置(权限 600)。注意:不会把环境变量注入的 apiKey 持久化回文件。 */ -export function saveConfig(cfg: AppConfig): void { - if (!existsSync(CONFIG_DIR)) { - mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 }) - } - writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), { mode: 0o600 }) - try { - chmodSync(CONFIG_FILE, 0o600) - } catch { - // Windows 不支持 chmod,忽略 - } -} - -/** 新增主机:密码加密后落库,返回带 id 的 Host */ -export function addHost(input: Omit & { password?: string }): Host { - const cfg = loadConfig() - const host: Host = { - id: randomUUID(), - alias: input.alias, - host: input.host, - port: input.port || 22, - user: input.user || 'root', - passwordEnc: input.password ? encrypt(input.password) ?? undefined : undefined, - } - cfg.hosts.push(host) - saveConfig(cfg) - return host -} - -/** 删除主机 */ -export function removeHost(id: string): void { - const cfg = loadConfig() - cfg.hosts = cfg.hosts.filter((h) => h.id !== id) - saveConfig(cfg) -} - -/** 取某主机的明文密码(解密);未存返回 null */ -export function getHostPassword(host: Host): string | null { - if (!host.passwordEnc) return null - return decrypt(host.passwordEnc) -} - -/** 主机是否已存密码 */ -export function hasPassword(host: Host): boolean { - return !!host.passwordEnc -} - -export { CONFIG_FILE } diff --git a/clients/cli/src/core/context.ts b/clients/cli/src/core/context.ts deleted file mode 100644 index 66d2b86..0000000 --- a/clients/cli/src/core/context.ts +++ /dev/null @@ -1,162 +0,0 @@ -/** - * 上下文管理 —— 防止 agentic loop 多轮滚下来把模型上下文撑爆。 - * 移植自 Java 版 ContextManager,只做两层(抄 Claude Code 思路): - * - * Layer 0 —— 大工具结果截断:单条工具结果超阈值截掉中段,留头尾 + 提示。 - * 分级:最近 K 条用大阈值保细节,更早的用小阈值大力收紧 → - * token 不随轮数线性膨胀,且按"距末尾距离"判定,跨轮稳定不破坏缓存前缀。 - * - * Layer 4 —— 历史压缩:整段估算 token 超阈值,把较早对话丢给 LLM 摘要成一条, - * 保留 system + 最近 K 条原文。成对约束:保留区不能以孤儿 tool 消息开头。 - * 摘要连续失败到熔断阈值就停止压缩、裸跑兜底。 - * - * token 用字符数粗估:中英混合约 2.5 字符/token,不引 tokenizer。 - */ -import type { ChatMessage } from './glm.js' -import type { GlmClient } from './glm.js' - -const CHARS_PER_TOKEN = 2.5 -const TRUNCATE_MARKER = '完整结果见历史记录' - -export interface ContextOptions { - toolResultMaxChars: number // Layer 0 近区阈值 - oldToolResultMaxChars: number // Layer 0 旧区阈值 - maxContextTokens: number // Layer 4 触发压缩阈值 - keepRecentMessages: number // Layer 4 保留最近条数 - circuitLimit: number // 摘要连续失败熔断次数 -} - -export const DEFAULT_CONTEXT_OPTIONS: ContextOptions = { - toolResultMaxChars: 8000, - oldToolResultMaxChars: 800, - maxContextTokens: 32000, - keepRecentMessages: 6, - circuitLimit: 3, -} - -const SUMMARY_PROMPT = `你是上下文压缩器。下面是一段 AI 运维助手与目标服务器之间的历史对话(含用户任务、助手发起的命令调用、命令执行结果)。 -请把它压缩成简洁的中文摘要,必须保留以下信息,丢弃冗长的原始命令输出(只留结论): -1. 用户的原始运维目标; -2. 已执行过的关键命令及其结果结论(例如磁盘占用多少、进程是否存活、配置是否正确); -3. 已发现的问题或系统状态; -4. 被安全门禁拦截的危险操作(如果有)。 -只输出摘要正文,不要解释你在做什么。` - -export class ContextManager { - private opts: ContextOptions - private llm: GlmClient - private consecutiveFailures = 0 - - constructor(llm: GlmClient, opts: ContextOptions = DEFAULT_CONTEXT_OPTIONS) { - this.llm = llm - this.opts = opts - } - - // ===================== Layer 0:工具结果截断 ===================== - - /** - * 对历史里所有工具结果做截断(幂等)。返回新数组,不改原数组。 - * 分级:距末尾 keepRecentMessages 条内用大阈值,更早用小阈值。 - */ - truncateToolResponses(messages: ChatMessage[]): ChatMessage[] { - const size = messages.length - return messages.map((msg, i) => { - if (msg.role !== 'tool') return msg - const recent = size - i <= this.opts.keepRecentMessages - const limit = recent ? this.opts.toolResultMaxChars : this.opts.oldToolResultMaxChars - return { ...msg, content: this.truncateText(msg.content, limit) } - }) - } - - /** 截掉中段,保留头 60% / 尾 40%,中间塞提示。幂等:含哨兵跳过。 */ - private truncateText(text: string, limit: number): string { - if (!text || text.length <= limit) return text - if (text.includes(TRUNCATE_MARKER)) return text - const headLen = Math.floor(limit * 0.6) - const tailLen = limit - headLen - const cut = text.length - headLen - tailLen - const head = text.slice(0, headLen) - const tail = text.slice(text.length - tailLen) - return `${head}\n...[已截断 ${cut} 字符,${TRUNCATE_MARKER}]...\n${tail}` - } - - // ===================== Layer 4:历史压缩 ===================== - - /** 估算超阈值时压缩历史,否则原样返回。 */ - async compressIfNeeded(messages: ChatMessage[]): Promise { - if (this.consecutiveFailures >= this.opts.circuitLimit) return messages - if (this.estimateTokens(messages) <= this.opts.maxContextTokens) return messages - if (messages.length <= this.opts.keepRecentMessages + 1) return messages - - let cutIndex = messages.length - this.opts.keepRecentMessages - // 保留区不能以孤儿 tool 消息开头(它的 tool_call 在 assistant 上,会被切走) - while (cutIndex > 1 && messages[cutIndex]?.role === 'tool') { - cutIndex-- - } - if (cutIndex <= 1) return messages - - const summaryRegion = messages.slice(1, cutIndex) - const summary = await this.summarize(summaryRegion) - if (summary === null) { - this.consecutiveFailures++ - return messages - } - this.consecutiveFailures = 0 - - return [ - messages[0]!, // system - { role: 'user', content: '以下是早先对话的摘要,供你继续任务时参考:\n' + summary }, - ...messages.slice(cutIndex), - ] - } - - /** 调摘要 LLM 把一段历史压成结论文本;失败返回 null */ - private async summarize(region: ChatMessage[]): Promise { - try { - const rendered = this.renderRegion(region) - const text = await this.llm.complete([ - { role: 'system', content: SUMMARY_PROMPT }, - { role: 'user', content: rendered }, - ]) - return text && text.trim() !== '' ? text : null - } catch { - return null - } - } - - /** 把一段消息渲染成纯文本喂给摘要 LLM */ - private renderRegion(region: ChatMessage[]): string { - const lines: string[] = [] - for (const msg of region) { - if (msg.role === 'user') { - lines.push('用户: ' + msg.content) - } else if (msg.role === 'assistant') { - if (msg.content) lines.push('助手: ' + msg.content) - for (const call of msg.tool_calls ?? []) { - lines.push(`助手调用工具 ${call.function.name}: ${call.function.arguments}`) - } - } else if (msg.role === 'tool') { - lines.push(`工具结果: ${msg.content}`) - } - } - return lines.join('\n') - } - - // ===================== 工具方法 ===================== - - /** 估算整段消息的 token 数(字符数粗估) */ - estimateTokens(messages: ChatMessage[]): number { - let chars = 0 - for (const msg of messages) { - if (msg.role === 'assistant') { - chars += msg.content?.length ?? 0 - for (const call of msg.tool_calls ?? []) { - chars += call.function.arguments?.length ?? 0 - } - } else { - chars += msg.content?.length ?? 0 - } - } - return Math.floor(chars / CHARS_PER_TOKEN) - } -} diff --git a/clients/cli/src/core/crypto.test.ts b/clients/cli/src/core/crypto.test.ts deleted file mode 100644 index 0fe2231..0000000 --- a/clients/cli/src/core/crypto.test.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { encrypt, decrypt } from './crypto.js' - -describe('CryptoUtil AES-GCM', () => { - it('加密后能解回原文', () => { - const plain = 'my-secret-password-123' - const enc = encrypt(plain) - expect(enc).not.toBeNull() - expect(enc).not.toBe(plain) - expect(decrypt(enc)).toBe(plain) - }) - - it('同一明文每次密文不同(IV 随机)', () => { - expect(encrypt('same')).not.toBe(encrypt('same')) - }) - - it('空值返回 null', () => { - expect(encrypt('')).toBeNull() - expect(encrypt(null)).toBeNull() - expect(decrypt('')).toBeNull() - expect(decrypt(null)).toBeNull() - }) - - it('中文密码往返正确', () => { - const plain = '密码测试🔐' - expect(decrypt(encrypt(plain))).toBe(plain) - }) - - it('密文被篡改解密抛错(GCM 完整性校验)', () => { - const enc = encrypt('data')! - const tampered = enc.slice(0, -4) + (enc.slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA') - expect(() => decrypt(tampered)).toThrow() - }) -}) diff --git a/clients/cli/src/core/crypto.ts b/clients/cli/src/core/crypto.ts deleted file mode 100644 index f90cd47..0000000 --- a/clients/cli/src/core/crypto.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * 密码加密工具 —— 主机密码落本地配置前用 AES-256-GCM 加密,绝不存明文。 - * 1:1 移植自 Java 版 CryptoUtil,密文格式互通。 - * - * 密文格式:Base64( iv[12] + cipherText + authTag[16] )。 - * Node crypto 的 GCM 把 authTag 单独返回,这里手动拼到密文尾部,与 Java 版 - * (tag 内联在 doFinal 输出尾部)布局一致,两端密文可互相解密。 - * - * 密钥来源:环境变量 XWSSH_CRYPTO_KEY;任意长度经 SHA-256 派生成 32 字节 AES-256 密钥。 - */ -import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto' - -const ALGO = 'aes-256-gcm' -const IV_LEN = 12 // GCM 推荐 12 字节 IV -const TAG_LEN = 16 // 认证标签 16 字节(128 位) -const DEV_DEFAULT_KEY = 'xwssh-dev-default-key-change-me' - -/** 任意密钥串经 SHA-256 派生成固定 32 字节 */ -function deriveKey(raw: string): Buffer { - return createHash('sha256').update(raw, 'utf8').digest() -} - -function resolveKey(): Buffer { - const raw = process.env.XWSSH_CRYPTO_KEY - if (!raw || raw.trim() === '') { - // 没配密钥退到开发默认值,仅保证能跑;生产务必设 XWSSH_CRYPTO_KEY - return deriveKey(DEV_DEFAULT_KEY) - } - return deriveKey(raw) -} - -/** 加密:明文 → Base64(iv + 密文 + tag)。空串返回 null。 */ -export function encrypt(plain: string | null): string | null { - if (!plain) return null - const key = resolveKey() - const iv = randomBytes(IV_LEN) - const cipher = createCipheriv(ALGO, key, iv, { authTagLength: TAG_LEN }) - const ct = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]) - const tag = cipher.getAuthTag() - // 布局与 Java 版对齐:iv + 密文 + tag - return Buffer.concat([iv, ct, tag]).toString('base64') -} - -/** 解密:Base64(iv + 密文 + tag) → 明文。空串返回 null。 */ -export function decrypt(enc: string | null): string | null { - if (!enc) return null - const key = resolveKey() - const all = Buffer.from(enc, 'base64') - const iv = all.subarray(0, IV_LEN) - const tag = all.subarray(all.length - TAG_LEN) - const ct = all.subarray(IV_LEN, all.length - TAG_LEN) - const decipher = createDecipheriv(ALGO, key, iv, { authTagLength: TAG_LEN }) - decipher.setAuthTag(tag) - return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') -} diff --git a/clients/cli/src/core/events.ts b/clients/cli/src/core/events.ts deleted file mode 100644 index 63d805c..0000000 --- a/clients/cli/src/core/events.ts +++ /dev/null @@ -1,66 +0,0 @@ -/** - * Agent 事件 —— 对外流式输出的带类型事件流。 - * 移植自 Java 版 AgentEvent(sealed interface → discriminated union)。 - * - * 为什么用带类型事件而不是纯字符串流:运维 Agent 输出有多种语义——模型在说话、 - * 要调命令、命令出结果、命令被拦、任务完成。UI 据此区别渲染。语义色是记忆点核心, - * 与 Web 版 / App 端必须对齐。 - */ - -/** 模型增量输出的一段文本 token */ -export interface TokenEvent { - type: 'token' - text: string -} - -/** 模型思考过程增量(GLM reasoning_content) */ -export interface ReasoningEvent { - type: 'reasoning' - text: string -} - -/** 模型决定调用某个工具 */ -export interface ToolCallEvent { - type: 'tool_call' - name: string - args: string -} - -/** 工具执行结果(executed=false 表示被拦截/拒绝,未真正执行) */ -export interface ToolResultEvent { - type: 'tool_result' - name: string - summary: string - executed: boolean -} - -/** 命令被安全门禁拦截 / 用户拒绝 */ -export interface BlockedEvent { - type: 'blocked' - command: string - reason: string -} - -/** 任务完成,带最终结论文本 */ -export interface DoneEvent { - type: 'done' - finalText: string -} - -/** 出错 */ -export interface ErrorEvent { - type: 'error' - message: string -} - -export type AgentEvent = - | TokenEvent - | ReasoningEvent - | ToolCallEvent - | ToolResultEvent - | BlockedEvent - | DoneEvent - | ErrorEvent - -/** ASK 态命令的人工确认入口:返回 true 放行,false 拒绝 */ -export type Confirmer = (command: string, reason: string) => Promise diff --git a/clients/cli/src/core/glm.ts b/clients/cli/src/core/glm.ts deleted file mode 100644 index 53f2fbe..0000000 --- a/clients/cli/src/core/glm.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * GLM 接入 —— OpenAI 兼容协议封装,支持 function calling + streaming + reasoning_content。 - * 替代 Java 版 Spring AI 的 OpenAiChatModel + ToolCallingManager。 - * - * 模型无关设计:换模型只改 baseURL / model(GLM / 通义 / DeepSeek 都走 OpenAI 兼容协议)。 - * GLM 端点是 .../paas/v4/chat/completions,openai sdk 的 baseURL 指到 .../paas/v4 即可。 - */ -import OpenAI from 'openai' -import type { LlmConfig } from './config.js' - -/** 对话消息(OpenAI chat 格式子集,够 agent loop 用) */ -export type ChatMessage = - | { role: 'system'; content: string } - | { role: 'user'; content: string } - | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] } - | { role: 'tool'; tool_call_id: string; content: string } - -/** 一次工具调用 */ -export interface ToolCall { - id: string - type: 'function' - function: { name: string; arguments: string } -} - -/** 工具定义(function schema) */ -export interface ToolDef { - type: 'function' - function: { - name: string - description: string - parameters: Record - } -} - -/** 一次模型响应聚合结果 */ -export interface ChatResult { - /** 模型正文文本 */ - text: string - /** 本轮发起的工具调用(无则空数组) */ - toolCalls: ToolCall[] - /** token 用量(用于缓存命中测量) */ - usage?: { - promptTokens?: number - completionTokens?: number - totalTokens?: number - cachedTokens?: number - } -} - -/** 流式回调:边收边推 */ -export interface StreamHandlers { - onToken?: (text: string) => void - onReasoning?: (text: string) => void -} - -export class GlmClient { - private client: OpenAI - private model: string - - constructor(cfg: LlmConfig) { - this.client = new OpenAI({ baseURL: cfg.baseURL, apiKey: cfg.apiKey }) - this.model = cfg.model - } - - /** - * 一次流式调用:透传 token / reasoning 增量,聚合成完整结果返回。 - * 关闭框架自动工具执行——工具调用聚合后交回上层 loop 过门禁再执行。 - */ - async stream( - messages: ChatMessage[], - tools: ToolDef[], - handlers: StreamHandlers, - ): Promise { - const stream = await this.client.chat.completions.create({ - model: this.model, - messages: messages as OpenAI.Chat.ChatCompletionMessageParam[], - tools: tools.length > 0 ? (tools as OpenAI.Chat.ChatCompletionTool[]) : undefined, - stream: true, - stream_options: { include_usage: true }, - }) - - let text = '' - // tool_calls 在流式下分片到达,按 index 累积 - const toolAcc = new Map() - let usage: ChatResult['usage'] - - for await (const chunk of stream) { - const choice = chunk.choices[0] - if (choice) { - const delta = choice.delta as { - content?: string | null - reasoning_content?: string | null - tool_calls?: Array<{ - index: number - id?: string - function?: { name?: string; arguments?: string } - }> - } - - // GLM 思考阶段:reasoning_content 增量,单独推 - if (delta.reasoning_content) { - handlers.onReasoning?.(delta.reasoning_content) - } - if (delta.content) { - text += delta.content - handlers.onToken?.(delta.content) - } - if (delta.tool_calls) { - for (const tc of delta.tool_calls) { - const cur = toolAcc.get(tc.index) ?? { id: '', name: '', args: '' } - if (tc.id) cur.id = tc.id - if (tc.function?.name) cur.name = tc.function.name - if (tc.function?.arguments) cur.args += tc.function.arguments - toolAcc.set(tc.index, cur) - } - } - } - // usage 通常在最后一个 chunk(include_usage) - if (chunk.usage) { - const u = chunk.usage as OpenAI.Completions.CompletionUsage & { - prompt_tokens_details?: { cached_tokens?: number } - } - usage = { - promptTokens: u.prompt_tokens, - completionTokens: u.completion_tokens, - totalTokens: u.total_tokens, - cachedTokens: u.prompt_tokens_details?.cached_tokens ?? 0, - } - } - } - - const toolCalls: ToolCall[] = [...toolAcc.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([, v]) => ({ - id: v.id, - type: 'function' as const, - function: { name: v.name, arguments: v.args }, - })) - - return { text, toolCalls, usage } - } - - /** 非流式调用:用于上下文压缩的摘要请求(纯文本进出,不带工具) */ - async complete(messages: ChatMessage[]): Promise { - const resp = await this.client.chat.completions.create({ - model: this.model, - messages: messages as OpenAI.Chat.ChatCompletionMessageParam[], - stream: false, - }) - return resp.choices[0]?.message?.content ?? '' - } -} diff --git a/clients/cli/src/core/guard.test.ts b/clients/cli/src/core/guard.test.ts deleted file mode 100644 index 8d32951..0000000 --- a/clients/cli/src/core/guard.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, it, expect } from 'vitest' -import { evaluate } from './guard.js' - -describe('CommandGuard 门禁三态', () => { - describe('DENY —— 毁灭性操作直接拒', () => { - it.each([ - 'rm -rf /', - 'rm -fr /var/data', - 'rm -r -f /tmp', - 'mkfs.ext4 /dev/sdb', - 'dd if=/dev/zero of=/dev/sda', - 'shutdown -h now', - 'reboot', - 'halt', - 'echo x > /dev/sda', - ':(){ :|:& };:', - 'mv /important /dev/null', - 'find /tmp -name "*.log" -delete', - 'find / -name core -exec rm {} \\;', - ])('拒绝: %s', (cmd) => { - expect(evaluate(cmd).decision).toBe('DENY') - }) - }) - - describe('ASK —— 有副作用需确认', () => { - it.each([ - 'rm /tmp/old.log', - 'kill 1234', - 'systemctl stop nginx', - 'systemctl restart docker', - 'service mysql stop', - 'chmod 777 /etc/passwd', - 'chown root:root /opt', - 'apt-get install vim', - 'yum remove httpd', - 'truncate -s 0 app.log', - 'echo data > /etc/config', - ])('询问: %s', (cmd) => { - expect(evaluate(cmd).decision).toBe('ASK') - }) - }) - - describe('ALLOW —— 只读/安全命令放行', () => { - it.each([ - 'df -h', - 'free -m', - 'ps aux | grep java', - 'cat /etc/nginx/nginx.conf', - 'tail -n 100 /var/log/syslog', - 'ls -la', - 'add-apt-repository ppa:x', // \b 不应误伤 add - ])('放行: %s', (cmd) => { - expect(evaluate(cmd).decision).toBe('ALLOW') - }) - }) - - describe('复合命令取最严', () => { - it('ls && rm -rf / —— 整条 DENY', () => { - expect(evaluate('ls && rm -rf /').decision).toBe('DENY') - }) - it('df -h ; kill 1 —— 整条 ASK', () => { - expect(evaluate('df -h ; kill 1').decision).toBe('ASK') - }) - it('cat a | grep b —— 全只读 ALLOW', () => { - expect(evaluate('cat a | grep b').decision).toBe('ALLOW') - }) - }) - - describe('边界', () => { - it('空命令 ALLOW', () => { - expect(evaluate('').decision).toBe('ALLOW') - expect(evaluate(' ').decision).toBe('ALLOW') - }) - }) -}) diff --git a/clients/cli/src/core/guard.ts b/clients/cli/src/core/guard.ts deleted file mode 100644 index b6c5004..0000000 --- a/clients/cli/src/core/guard.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * 命令门禁 —— deny / ask / allow 三态判定。Agent 安全的硬边界。 - * 1:1 移植自 Java 版 CommandGuard,规则与语义保持一致(两端必须对齐)。 - * - * 设计原则: - * 1. 安全检查是独立代码路径,不写进工具方法、不靠模型自觉。模型越狱也绕不过。 - * 2. 三态顺序固定:先查 deny(命中即拒)→ 再看是否需 ask → 默认 allow。 - * 3. 只看实际要执行的命令,不看模型话术。 - * 4. 复合命令(&& | ; 串起来)拆段逐查,取最严结果。 - */ - -export type Decision = 'DENY' | 'ASK' | 'ALLOW' - -export interface Verdict { - decision: Decision - reason: string -} - -/** - * deny 名单:不可逆的毁灭性操作,直接拒绝。 - * 用 \b 保证匹配独立命令词而非子串(dd 不误伤 add)。 - */ -const DENY: RegExp[] = [ - /\brm\s+(-\w*\s+)*-\w*[rf]/, // rm -rf / rm -fr 等带 r/f 组合 - /\bmkfs\b/, // 格式化文件系统 - /\bdd\b/, // 块设备读写,易毁盘 - /\bshutdown\b/, // 关机 - /\breboot\b/, // 重启 - /\bhalt\b/, // 停机 - />\s*\/dev\/sd/, // 直接写裸盘 - /:\(\)\s*\{.*\}/, // fork 炸弹 :(){ :|:& };: - /\bmv\s+.*\s+\/dev\/null/, // mv 到 /dev/null 销毁数据 - /\bfind\b.*-delete/, // find ... -delete 批量删除(rm -rf 等价绕过) - /\bfind\b.*-exec\s+rm/, // find ... -exec rm 批量删除 -] - -/** - * ask 名单:有副作用但未必致命,执行前问一句。 - */ -const ASK: RegExp[] = [ - /\brm\b/, // 普通 rm(非 -rf) - /\bkill\b/, // 杀进程 - /\bsystemctl\s+(stop|restart|disable)/, // 停/重启/禁用服务 - /\bservice\s+\S+\s+(stop|restart)/, - /\b(chmod|chown)\b/, // 改权限/属主 - /\b(apt|apt-get|yum|dnf)\s+(install|remove|purge)/, // 装/卸软件 - /\btruncate\b/, // 清空文件 - />\s*\//, // 重定向覆盖写到绝对路径文件 -] - -/** 三态严重程度排序,越小越严:DENY < ASK < ALLOW */ -const ORDINAL: Record = { DENY: 0, ASK: 1, ALLOW: 2 } - -/** - * 判定一条命令。复合命令会被拆段,取最严结果(任一段 deny 则整条 deny)。 - */ -export function evaluate(command: string): Verdict { - if (!command || command.trim() === '') { - return { decision: 'ALLOW', reason: '空命令' } - } - - // 先对完整命令整体过一遍 DENY:fork 炸弹 :(){ :|:& };: 本身含 | 和 ;, - // 拆段会把它切碎导致漏判。DENY 命中即拒,整体多查一次只会更安全。 - // (相对 Java 版的增强,Java 版 splitSegments 同样会漏 fork 炸弹,待同步修复。) - for (const p of DENY) { - const m = command.match(p) - if (m) { - return { decision: 'DENY', reason: `命中危险命令拦截规则: '${m[0]}'` } - } - } - - let worst: Decision = 'ALLOW' - let worstReason = '' - - for (const seg of splitSegments(command)) { - const s = seg.trim() - if (s === '') continue - - const v = evaluateSingle(s) - if (ORDINAL[v.decision] < ORDINAL[worst]) { - worst = v.decision - worstReason = v.reason - } - if (worst === 'DENY') break // 已最严,提前结束 - } - - if (worst === 'ALLOW') { - return { decision: 'ALLOW', reason: '只读/安全命令' } - } - return { decision: worst, reason: worstReason } -} - -/** 单段命令判定:先 deny 再 ask 后 allow */ -function evaluateSingle(seg: string): Verdict { - for (const p of DENY) { - const m = seg.match(p) - if (m) { - return { decision: 'DENY', reason: `命中危险命令拦截规则: '${m[0]}'` } - } - } - for (const p of ASK) { - const m = seg.match(p) - if (m) { - return { decision: 'ASK', reason: `涉及有副作用的操作: '${m[0]}'` } - } - } - return { decision: 'ALLOW', reason: '' } -} - -/** 按命令分隔符拆段:&& || | ; 换行,分隔符本身丢弃 */ -function splitSegments(command: string): string[] { - return command.split(/&&|\|\||[|;\n]/) -} diff --git a/clients/cli/src/core/ssh.ts b/clients/cli/src/core/ssh.ts deleted file mode 100644 index 65adb23..0000000 --- a/clients/cli/src/core/ssh.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * SSH 客户端 —— 一个实例持有一个长连接,多条命令复用同一会话。 - * 移植自 Java 版 SshClient(JSch → ssh2)。 - * - * 为什么长连接复用:agentic loop 里连续执行多条命令,每次重连既慢又丢上下文。 - * 非线程安全:一个实例对应一台机器一个会话,由上层串行使用。 - */ -import { Client, type ConnectConfig, type SFTPWrapper } from 'ssh2' - -/** 命令执行结果三件套 */ -export interface ExecResult { - stdout: string - stderr: string - exitCode: number -} - -/** 远程文件项 */ -export interface RemoteFile { - name: string - path: string - size: number - isDir: boolean - perms: string -} - -export class SshClient { - private conn: Client | null = null - private connected = false - - /** 建立连接(密码认证)。10s 超时。 */ - connect(host: string, port: number, username: string, password: string): Promise { - return new Promise((resolve, reject) => { - const conn = new Client() - const cfg: ConnectConfig = { - host, - port: port || 22, - username, - password, - readyTimeout: 10_000, - // demo 方便跳过 host key 校验;生产应校验 known_hosts,否则有中间人风险 - } - conn - .on('ready', () => { - this.conn = conn - this.connected = true - resolve() - }) - .on('error', (err) => { - this.connected = false - reject(err) - }) - .on('close', () => { - this.connected = false - }) - .connect(cfg) - }) - } - - isConnected(): boolean { - return this.connected && this.conn !== null - } - - /** - * 执行一条命令,收集 stdout、stderr、exitCode。 - * ssh2 的 exec 回调给一个 stream,stdout 走 data 事件,stderr 走 stream.stderr, - * exitCode 在 close 事件回调里拿。 - */ - exec(command: string): Promise { - return new Promise((resolve, reject) => { - if (!this.conn || !this.connected) { - reject(new Error('SSH 未连接,先调用 connect()')) - return - } - this.conn.exec(command, (err, stream) => { - if (err) { - reject(err) - return - } - let stdout = '' - let stderr = '' - stream - .on('close', (code: number | null) => { - resolve({ stdout, stderr, exitCode: code ?? 0 }) - }) - .on('data', (data: Buffer) => { - stdout += data.toString('utf8') - }) - stream.stderr.on('data', (data: Buffer) => { - stderr += data.toString('utf8') - }) - }) - }) - } - - /** 懒开 SFTP 通道 */ - private sftp(): Promise { - return new Promise((resolve, reject) => { - if (!this.conn || !this.connected) { - reject(new Error('SSH 未连接')) - return - } - this.conn.sftp((err, sftp) => { - if (err) reject(err) - else resolve(sftp) - }) - }) - } - - /** 列目录。过滤 . 和 ..,目录在前、名称升序。 */ - async listDir(path: string): Promise { - const sftp = await this.sftp() - const base = path.endsWith('/') ? path : path + '/' - const entries = await new Promise((resolve, reject) => { - sftp.readdir(path, (err, list) => { - if (err) { - reject(err) - return - } - const files: RemoteFile[] = list - .filter((e) => e.filename !== '.' && e.filename !== '..') - .map((e) => ({ - name: e.filename, - path: base + e.filename, - size: e.attrs.size, - isDir: e.attrs.isDirectory(), - perms: e.longname.split(/\s+/)[0] ?? '', // longname 首列形如 drwxr-xr-x - })) - resolve(files) - }) - }) - entries.sort((a, b) => { - if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 - return a.name.toLowerCase().localeCompare(b.name.toLowerCase()) - }) - return entries - } - - /** 删除文件 */ - async deleteFile(path: string): Promise { - const sftp = await this.sftp() - await new Promise((resolve, reject) => { - sftp.unlink(path, (err) => (err ? reject(err) : resolve())) - }) - } - - /** 新建目录 */ - async mkdir(path: string): Promise { - const sftp = await this.sftp() - await new Promise((resolve, reject) => { - sftp.mkdir(path, (err) => (err ? reject(err) : resolve())) - }) - } - - /** 重命名/移动 */ - async rename(from: string, to: string): Promise { - const sftp = await this.sftp() - await new Promise((resolve, reject) => { - sftp.rename(from, to, (err) => (err ? reject(err) : resolve())) - }) - } - - /** 关闭连接 */ - close(): void { - if (this.conn) { - this.conn.end() - this.conn = null - } - this.connected = false - } -} diff --git a/clients/cli/src/ui/AddHost.tsx b/clients/cli/src/ui/AddHost.tsx deleted file mode 100644 index 316ab0e..0000000 --- a/clients/cli/src/ui/AddHost.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/** - * 交互式添加主机表单(`lowenssh add-host`)。 - * - * 逐字段填写,回车进下一项:alias → host → port → user → password。 - * - host 必填,空则停留当前字段 - * - port 默认 22,user 默认 root(直接回车用默认值) - * - password 用 mask 隐藏输入;存库时由 config.addHost 走 AES-GCM 加密,绝不存明文 - * 全部填完保存到 ~/.lowenssh/config.json 并退出。 - */ -import { useState } from 'react' -import { Box, Text, useApp } from 'ink' -import TextInput from 'ink-text-input' -import { addHost } from '../core/config.js' - -/** 表单字段定义:按 steps 顺序逐个填写 */ -interface FieldDef { - key: 'alias' | 'host' | 'port' | 'user' | 'password' - label: string - placeholder: string - mask?: boolean - required?: boolean -} - -const FIELDS: FieldDef[] = [ - { key: 'alias', label: '别名(可选)', placeholder: '如 生产-web01,可留空' }, - { key: 'host', label: '主机地址', placeholder: 'IP 或域名', required: true }, - { key: 'port', label: '端口', placeholder: '22' }, - { key: 'user', label: '用户名', placeholder: 'root' }, - { key: 'password', label: '密码(可选)', placeholder: '留空则连接时不带密码', mask: true }, -] - -export function AddHost() { - const { exit } = useApp() - const [step, setStep] = useState(0) - const [value, setValue] = useState('') - const [draft, setDraft] = useState>({}) - const [saved, setSaved] = useState(null) - - const onSubmit = () => { - const field = FIELDS[step]! - const v = value.trim() - // 必填字段空则不放行 - if (field.required && v === '') return - - const nextDraft = { ...draft, [field.key]: v } - setDraft(nextDraft) - setValue('') - - if (step < FIELDS.length - 1) { - setStep(step + 1) - return - } - - // 最后一项:保存 - const host = addHost({ - alias: nextDraft.alias || undefined, - host: nextDraft.host!, - port: nextDraft.port ? Number(nextDraft.port) : 22, - user: nextDraft.user || 'root', - password: nextDraft.password || undefined, - }) - const label = host.alias ? `${host.alias} (${host.user}@${host.host}:${host.port})` : `${host.user}@${host.host}:${host.port}` - setSaved(label) - // 渲染成功提示后退出 - setTimeout(() => exit(), 50) - } - - if (saved) { - return ( - - ✓ 已添加主机:{saved} - 密码已加密存入 ~/.lowenssh/config.json,直接运行 lowenssh 即可连接。 - - ) - } - - const field = FIELDS[step]! - return ( - - ▰ 添加主机(回车下一项,Ctrl+C 取消) - {/* 已填字段回显 */} - - {FIELDS.slice(0, step).map((f) => ( - - {f.label}:{f.mask ? maskValue(draft[f.key]) : draft[f.key] || '(默认)'} - - ))} - - {/* 当前字段输入 */} - 0 ? 1 : 0}> - {field.label}: - - - - ) -} - -/** 密码回显成等长星号,空值显示"(无)" */ -function maskValue(v?: string): string { - if (!v) return '(无)' - return '*'.repeat(v.length) -} diff --git a/clients/cli/src/ui/App.tsx b/clients/cli/src/ui/App.tsx deleted file mode 100644 index f9b6024..0000000 --- a/clients/cli/src/ui/App.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * 顶层应用状态机。 - * - * 三阶段: - * select —— 选主机(或提示先去加主机) - * connect —— 正在 SSH 连接 - * chat —— 对话流,跑 agent loop - * - * Confirmer 桥:agent loop 在 ASK 态需要用户 y/n,但 loop 是异步 generator, - * 不能直接读键盘。这里用一个 pending Promise 把 loop 的"等确认"和 UI 的按键解耦—— - * loop 调 confirmer() 拿到 Promise 并挂起,UI 渲染确认框,用户按键 resolve 这个 Promise。 - */ -import { useState, useRef, useCallback } from 'react' -import { Box, Text } from 'ink' -import type { Host, AppConfig } from '../core/config.js' -import { getHostPassword } from '../core/config.js' -import { GlmClient } from '../core/glm.js' -import { SshClient } from '../core/ssh.js' -import type { Confirmer } from '../core/events.js' -import { HostSelect } from './HostSelect.js' -import { Chat } from './Chat.js' -import { ConfirmPrompt, type PendingConfirm } from './ConfirmPrompt.js' - -type Stage = 'select' | 'connect' | 'chat' | 'fatal' - -export interface AppProps { - config: AppConfig -} - -export function App({ config }: AppProps) { - const [stage, setStage] = useState('select') - const [error, setError] = useState(null) - const [host, setHost] = useState(null) - - // 连接产物:连上后才有 ssh / llm 实例 - const sshRef = useRef(null) - const llmRef = useRef(null) - - // 当前挂起的确认请求(ASK 态);null 表示没有待确认 - const [pendingConfirm, setPendingConfirm] = useState(null) - - /** Confirmer:被 agent loop 调用,返回一个 Promise,UI 按键后 resolve */ - const confirmer = useCallback((command, reason) => { - return new Promise((resolve) => { - setPendingConfirm({ command, reason, resolve }) - }) - }, []) - - /** 用户在确认框按了 y/n */ - const onConfirm = useCallback((approved: boolean) => { - setPendingConfirm((cur) => { - cur?.resolve(approved) - return null - }) - }, []) - - /** 选定主机后建立连接 */ - const onPickHost = useCallback( - async (picked: Host) => { - setHost(picked) - setStage('connect') - try { - const password = getHostPassword(picked) - if (!password) { - setError(`主机 ${picked.host} 未保存密码,请先在配置里补充。`) - setStage('fatal') - return - } - const ssh = new SshClient() - await ssh.connect(picked.host, picked.port, picked.user, password) - sshRef.current = ssh - llmRef.current = new GlmClient(config.llm) - setStage('chat') - } catch (e) { - setError(`连接失败: ${(e as Error).message}`) - setStage('fatal') - } - }, - [config.llm], - ) - - if (stage === 'fatal') { - return ( - - ✗ {error} - 按 Ctrl+C 退出。 - - ) - } - - if (stage === 'select') { - return - } - - if (stage === 'connect') { - return ( - - ◇ 正在连接 {host?.host} … - - ) - } - - // chat - return ( - - - {pendingConfirm && } - - ) -} diff --git a/clients/cli/src/ui/Chat.tsx b/clients/cli/src/ui/Chat.tsx deleted file mode 100644 index ea73cec..0000000 --- a/clients/cli/src/ui/Chat.tsx +++ /dev/null @@ -1,163 +0,0 @@ -/** - * 对话主界面。输入任务 → 跑 agent loop → 流式渲染 6 类事件。 - * - * 事件语义色(记忆点核心,与后端 SSE / App 端保持一致): - * token 默认色,模型正文,逐字累积 - * reasoning 灰显,模型思考过程 - * tool_call 青色,要执行的工具(命令折叠成一行) - * tool_result 暗灰,工具结果摘要 - * blocked 红色高亮,被门禁/用户拦截 - * done 正文收尾 - * error 红色,异常 - */ -import { useState, useCallback } from 'react' -import { Box, Text } from 'ink' -import TextInput from 'ink-text-input' -import type { Host } from '../core/config.js' -import type { SshClient } from '../core/ssh.js' -import type { GlmClient, ChatMessage } from '../core/glm.js' -import type { Confirmer } from '../core/events.js' -import { runAgent } from '../core/agent.js' - -/** 屏幕上的一条消息块(按语义渲染) */ -interface Line { - kind: 'user' | 'token' | 'reasoning' | 'tool_call' | 'tool_result' | 'blocked' | 'error' - text: string -} - -export interface ChatProps { - host: Host - ssh: SshClient - llm: GlmClient - confirmer: Confirmer - /** ASK 确认框弹出时锁住输入,避免按键串进 TextInput */ - inputLocked: boolean -} - -export function Chat({ host, ssh, llm, confirmer, inputLocked }: ChatProps) { - const [lines, setLines] = useState([]) - const [input, setInput] = useState('') - const [busy, setBusy] = useState(false) - // 多轮续聊历史:loop 之间累积(system 由 agent 内部加,这里只存 user/assistant/tool) - const [history, setHistory] = useState([]) - - const push = useCallback((line: Line) => { - setLines((prev) => [...prev, line]) - }, []) - - /** 把流式 token 累积到最后一条 token 行,避免每个字一行 */ - const appendToken = useCallback((kind: 'token' | 'reasoning', text: string) => { - setLines((prev) => { - const last = prev[prev.length - 1] - if (last && last.kind === kind) { - const copy = prev.slice(0, -1) - copy.push({ kind, text: last.text + text }) - return copy - } - return [...prev, { kind, text }] - }) - }, []) - - const onSubmit = useCallback(async () => { - const task = input.trim() - if (!task || busy) return - setInput('') - setBusy(true) - push({ kind: 'user', text: task }) - - try { - for await (const ev of runAgent(task, { llm, ssh, confirmer, history })) { - switch (ev.type) { - case 'token': - appendToken('token', ev.text) - break - case 'reasoning': - appendToken('reasoning', ev.text) - break - case 'tool_call': - push({ kind: 'tool_call', text: `${ev.name}(${shorten(ev.args)})` }) - break - case 'tool_result': - push({ kind: 'tool_result', text: oneLine(ev.summary) }) - break - case 'blocked': - push({ kind: 'blocked', text: `⛔ 已拦截: ${ev.command} —— ${ev.reason}` }) - break - case 'done': - // 最终结论作为一条 token 行收尾(若正文已流式输出过,done 文本可能与其重复,仍补一条确保完整) - push({ kind: 'token', text: '\n' + ev.finalText }) - break - case 'error': - push({ kind: 'error', text: `✗ ${ev.message}` }) - break - } - } - // 续聊:把本轮 user 追进历史(assistant/tool 由下一轮 agent 内部 messages 重建, - // 这里保留 user 提问让模型有上下文) - setHistory((h) => [...h, { role: 'user', content: task }]) - } catch (e) { - push({ kind: 'error', text: `✗ ${(e as Error).message}` }) - } finally { - setBusy(false) - } - }, [input, busy, llm, ssh, confirmer, history, push, appendToken]) - - return ( - - ▰ {host.alias ?? host.host} - - {lines.map((l, i) => ( - - ))} - - - {busy ? ( - ◇ 处理中… - ) : ( - <> - - - - )} - - - ) -} - -/** 单行渲染:按语义上色 */ -function LineView({ line }: { line: Line }) { - switch (line.kind) { - case 'user': - return ❯ {line.text} - case 'token': - return {line.text} - case 'reasoning': - return {line.text} - case 'tool_call': - return ⚙ {line.text} - case 'tool_result': - return ↳ {line.text} - case 'blocked': - return {line.text} - case 'error': - return {line.text} - } -} - -/** 工具参数 JSON 折叠成短文本 */ -function shorten(argsJson: string): string { - const s = argsJson.replace(/\s+/g, ' ') - return s.length > 80 ? s.slice(0, 80) + '…' : s -} - -/** 多行摘要压成一行展示 */ -function oneLine(text: string): string { - const s = text.replace(/\n+/g, ' ┊ ') - return s.length > 120 ? s.slice(0, 120) + '…' : s -} diff --git a/clients/cli/src/ui/ConfirmPrompt.tsx b/clients/cli/src/ui/ConfirmPrompt.tsx deleted file mode 100644 index 5eb1808..0000000 --- a/clients/cli/src/ui/ConfirmPrompt.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * ASK 态确认框:危险但可控的命令(rm / kill / chmod 等)执行前弹出,等用户 y/n。 - * 命令文本完整展示,让用户看清要批准什么再决定。 - */ -import { Box, Text, useInput } from 'ink' - -/** 一个挂起的确认请求:loop 提供 command/reason 和待 resolve 的回调 */ -export interface PendingConfirm { - command: string - reason: string - resolve: (approved: boolean) => void -} - -export interface ConfirmPromptProps { - pending: PendingConfirm - onAnswer: (approved: boolean) => void -} - -export function ConfirmPrompt({ pending, onAnswer }: ConfirmPromptProps) { - useInput((input, key) => { - if (input === 'y' || input === 'Y') { - onAnswer(true) - } else if (input === 'n' || input === 'N' || key.escape) { - onAnswer(false) - } - }) - - return ( - - ⚠ 需要确认 —— {pending.reason} - - $ - {pending.command} - - 执行? [y] 批准 / [n] 拒绝 - - ) -} diff --git a/clients/cli/src/ui/HostSelect.tsx b/clients/cli/src/ui/HostSelect.tsx deleted file mode 100644 index 01f25cf..0000000 --- a/clients/cli/src/ui/HostSelect.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * 主机选择界面。上下键移动光标,回车连接。 - * 没有主机时提示去配置文件加主机(内置版主机簿在 ~/.lowenssh/config.json)。 - */ -import { useState } from 'react' -import { Box, Text, useInput } from 'ink' -import type { Host } from '../core/config.js' -import { CONFIG_FILE } from '../core/config.js' - -export interface HostSelectProps { - hosts: Host[] - onPick: (host: Host) => void -} - -export function HostSelect({ hosts, onPick }: HostSelectProps) { - const [cursor, setCursor] = useState(0) - - useInput((input, key) => { - if (hosts.length === 0) return - if (key.upArrow || input === 'k') { - setCursor((c) => (c - 1 + hosts.length) % hosts.length) - } else if (key.downArrow || input === 'j') { - setCursor((c) => (c + 1) % hosts.length) - } else if (key.return) { - onPick(hosts[cursor]!) - } - }) - - return ( - - ▰ LowenSSH - 选择要连接的主机(↑↓ 移动,回车连接,Ctrl+C 退出) - - {hosts.length === 0 ? ( - - 还没有主机。 - 请编辑配置文件添加:{CONFIG_FILE} - - ) : ( - hosts.map((h, i) => { - const active = i === cursor - const label = h.alias ? `${h.alias} (${h.user}@${h.host}:${h.port})` : `${h.user}@${h.host}:${h.port}` - return ( - - {active ? '❯ ' : ' '} - {label} - - ) - }) - )} - - - ) -} diff --git a/clients/cli/tsconfig.json b/clients/cli/tsconfig.json deleted file mode 100644 index 5d122de..0000000 --- a/clients/cli/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ESNext", - "moduleResolution": "Bundler", - "lib": ["ES2023"], - "jsx": "react-jsx", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "resolveJsonModule": true, - "declaration": false, - "outDir": "dist", - "rootDir": "src" - }, - "include": ["src"] -} diff --git a/clients/cli/tsup.config.ts b/clients/cli/tsup.config.ts deleted file mode 100644 index 1ad522c..0000000 --- a/clients/cli/tsup.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from 'tsup' - -// 打包配置:把 TUI 入口和 bin 入口打成 ESM。 -// ssh2 含原生依赖,标记 external 不打进 bundle,由 node_modules 提供。 -export default defineConfig({ - entry: { - cli: 'src/cli.tsx', - }, - format: ['esm'], - target: 'node20', - platform: 'node', - banner: { js: '#!/usr/bin/env node' }, - clean: true, - external: ['ssh2', 'react', 'ink', 'openai'], -}) diff --git a/clients/cli/vitest.config.ts b/clients/cli/vitest.config.ts deleted file mode 100644 index d2d9690..0000000 --- a/clients/cli/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from 'vitest/config' - -export default defineConfig({ - test: { - include: ['src/**/*.test.ts'], - environment: 'node', - }, -}) diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 0831a1d..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,38 +0,0 @@ -# LowenSSH 本地一键启动 -# 用法:先 export MYSQL_PASSWORD 和 GLM_API_KEY,再 docker compose up -services: - mysql: - image: mysql:8.4 - environment: - # root 密码 + 自动建库 lowenssh - MYSQL_ROOT_PASSWORD: ${MYSQL_PASSWORD} - MYSQL_DATABASE: lowenssh - ports: - - "3306:3306" - volumes: - # 容器首次启动时在 lowenssh 库执行建表脚本 - - ./src/main/resources/schema.sql:/docker-entrypoint-initdb.d/schema.sql:ro - - mysql-data:/var/lib/mysql - healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-p${MYSQL_PASSWORD}"] - interval: 5s - timeout: 3s - retries: 10 - - app: - build: . - ports: - - "8081:8081" - environment: - # 指向 mysql 容器(覆盖默认 localhost) - DB_HOST: mysql - # 密钥从宿主环境透传,compose 文件里不含明文 - MYSQL_PASSWORD: ${MYSQL_PASSWORD} - GLM_API_KEY: ${GLM_API_KEY} - depends_on: - mysql: - # 等 MySQL 就绪再启动,避免连接失败 - condition: service_healthy - -volumes: - mysql-data: diff --git a/mvnw b/mvnw deleted file mode 100755 index 5272759..0000000 --- a/mvnw +++ /dev/null @@ -1,332 +0,0 @@ -#!/bin/sh -# ---------------------------------------------------------------------------- -# Licensed to the Apache Software Foundation (ASF) under one -# or more contributor license agreements. See the NOTICE file -# distributed with this work for additional information -# regarding copyright ownership. The ASF licenses this file -# to you under the Apache License, Version 2.0 (the -# "License"); you may not use this file except in compliance -# with the License. You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, -# software distributed under the License is distributed on an -# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -# KIND, either express or implied. See the License for the -# specific language governing permissions and limitations -# under the License. -# ---------------------------------------------------------------------------- - -# ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version @@project.version@@ -# -# Required ENV vars: -# ------------------ -# JAVA_HOME - location of a JDK home dir -# -# Optional ENV vars -# ----------------- -# MAVEN_OPTS - parameters passed to the Java VM when running Maven -# e.g. to debug Maven itself, use -# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -# MAVEN_SKIP_RC - flag to disable loading of mavenrc files -# ---------------------------------------------------------------------------- - -if [ -z "$MAVEN_SKIP_RC" ]; then - - if [ -f /usr/local/etc/mavenrc ]; then - . /usr/local/etc/mavenrc - fi - - if [ -f /etc/mavenrc ]; then - . /etc/mavenrc - fi - - if [ -f "$HOME/.mavenrc" ]; then - . "$HOME/.mavenrc" - fi - -fi - -# OS specific support. $var _must_ be set to either true or false. -cygwin=false -darwin=false -mingw=false -case "$(uname)" in -CYGWIN*) cygwin=true ;; -MINGW*) mingw=true ;; -Darwin*) - darwin=true - # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home - # See https://developer.apple.com/library/mac/qa/qa1170/_index.html - if [ -z "$JAVA_HOME" ]; then - if [ -x "/usr/libexec/java_home" ]; then - JAVA_HOME="$(/usr/libexec/java_home)" - export JAVA_HOME - else - JAVA_HOME="/Library/Java/Home" - export JAVA_HOME - fi - fi - ;; -esac - -if [ -z "$JAVA_HOME" ]; then - if [ -r /etc/gentoo-release ]; then - JAVA_HOME=$(java-config --jre-home) - fi -fi - -# For Cygwin, ensure paths are in UNIX format before anything is touched -if $cygwin; then - [ -n "$JAVA_HOME" ] \ - && JAVA_HOME=$(cygpath --unix "$JAVA_HOME") - [ -n "$CLASSPATH" ] \ - && CLASSPATH=$(cygpath --path --unix "$CLASSPATH") -fi - -# For Mingw, ensure paths are in UNIX format before anything is touched -if $mingw; then - [ -n "$JAVA_HOME" ] && [ -d "$JAVA_HOME" ] \ - && JAVA_HOME="$( - cd "$JAVA_HOME" || ( - echo "cannot cd into $JAVA_HOME." >&2 - exit 1 - ) - pwd - )" -fi - -if [ -z "$JAVA_HOME" ]; then - javaExecutable="$(which javac)" - if [ -n "$javaExecutable" ] && ! [ "$(expr "$javaExecutable" : '\([^ ]*\)')" = "no" ]; then - # readlink(1) is not available as standard on Solaris 10. - readLink=$(which readlink) - if [ ! "$(expr "$readLink" : '\([^ ]*\)')" = "no" ]; then - if $darwin; then - javaHome="$(dirname "$javaExecutable")" - javaExecutable="$(cd "$javaHome" && pwd -P)/javac" - else - javaExecutable="$(readlink -f "$javaExecutable")" - fi - javaHome="$(dirname "$javaExecutable")" - javaHome=$(expr "$javaHome" : '\(.*\)/bin') - JAVA_HOME="$javaHome" - export JAVA_HOME - fi - fi -fi - -if [ -z "$JAVACMD" ]; then - if [ -n "$JAVA_HOME" ]; then - if [ -x "$JAVA_HOME/jre/sh/java" ]; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD="$JAVA_HOME/jre/sh/java" - else - JAVACMD="$JAVA_HOME/bin/java" - fi - else - JAVACMD="$( - \unset -f command 2>/dev/null - \command -v java - )" - fi -fi - -if [ ! -x "$JAVACMD" ]; then - echo "Error: JAVA_HOME is not defined correctly." >&2 - echo " We cannot execute $JAVACMD" >&2 - exit 1 -fi - -if [ -z "$JAVA_HOME" ]; then - echo "Warning: JAVA_HOME environment variable is not set." >&2 -fi - -# traverses directory structure from process work directory to filesystem root -# first directory with .mvn subdirectory is considered project base directory -find_maven_basedir() { - if [ -z "$1" ]; then - echo "Path not specified to find_maven_basedir" >&2 - return 1 - fi - - basedir="$1" - wdir="$1" - while [ "$wdir" != '/' ]; do - if [ -d "$wdir"/.mvn ]; then - basedir=$wdir - break - fi - # workaround for JBEAP-8937 (on Solaris 10/Sparc) - if [ -d "${wdir}" ]; then - wdir=$( - cd "$wdir/.." || exit 1 - pwd - ) - fi - # end of workaround - done - printf '%s' "$( - cd "$basedir" || exit 1 - pwd - )" -} - -# concatenates all lines of a file -concat_lines() { - if [ -f "$1" ]; then - # Remove \r in case we run on Windows within Git Bash - # and check out the repository with auto CRLF management - # enabled. Otherwise, we may read lines that are delimited with - # \r\n and produce $'-Xarg\r' rather than -Xarg due to word - # splitting rules. - tr -s '\r\n' ' ' <"$1" - fi -} - -log() { - if [ "$MVNW_VERBOSE" = true ]; then - printf '%s\n' "$1" - fi -} - -BASE_DIR=$(find_maven_basedir "$(dirname "$0")") -if [ -z "$BASE_DIR" ]; then - exit 1 -fi - -MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} -export MAVEN_PROJECTBASEDIR -log "$MAVEN_PROJECTBASEDIR" - -########################################################################################## -# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -# This allows using the maven wrapper in projects that prohibit checking in binary data. -########################################################################################## -wrapperJarPath="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" -if [ -r "$wrapperJarPath" ]; then - log "Found $wrapperJarPath" -else - log "Couldn't find $wrapperJarPath, downloading it ..." - - if [ -n "$MVNW_REPOURL" ]; then - wrapperUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar" - else - wrapperUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar" - fi - while IFS="=" read -r key value; do - # Remove '\r' from value to allow usage on windows as IFS does not consider '\r' as a separator ( considers space, tab, new line ('\n'), and custom '=' ) - safeValue=$(echo "$value" | tr -d '\r') - case "$key" in wrapperUrl) - wrapperUrl="$safeValue" - break - ;; - esac - done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" - log "Downloading from: $wrapperUrl" - - if $cygwin; then - wrapperJarPath=$(cygpath --path --windows "$wrapperJarPath") - fi - - if command -v wget >/dev/null; then - log "Found wget ... using wget" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--quiet" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - wget $QUIET "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - else - wget $QUIET --http-user="$MVNW_USERNAME" --http-password="$MVNW_PASSWORD" "$wrapperUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" - fi - elif command -v curl >/dev/null; then - log "Found curl ... using curl" - [ "$MVNW_VERBOSE" = true ] && QUIET="" || QUIET="--silent" - if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then - curl $QUIET -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - else - curl $QUIET --user "$MVNW_USERNAME:$MVNW_PASSWORD" -o "$wrapperJarPath" "$wrapperUrl" -f -L || rm -f "$wrapperJarPath" - fi - else - log "Falling back to using Java to download" - javaSource="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.java" - javaClass="$MAVEN_PROJECTBASEDIR/.mvn/wrapper/MavenWrapperDownloader.class" - # For Cygwin, switch paths to Windows format before running javac - if $cygwin; then - javaSource=$(cygpath --path --windows "$javaSource") - javaClass=$(cygpath --path --windows "$javaClass") - fi - if [ -e "$javaSource" ]; then - if [ ! -e "$javaClass" ]; then - log " - Compiling MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/javac" "$javaSource") - fi - if [ -e "$javaClass" ]; then - log " - Running MavenWrapperDownloader.java ..." - ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$wrapperUrl" "$wrapperJarPath") || rm -f "$wrapperJarPath" - fi - fi - fi -fi -########################################################################################## -# End of extension -########################################################################################## - -# If specified, validate the SHA-256 sum of the Maven wrapper jar file -wrapperSha256Sum="" -while IFS="=" read -r key value; do - case "$key" in wrapperSha256Sum) - wrapperSha256Sum=$value - break - ;; - esac -done <"$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.properties" -if [ -n "$wrapperSha256Sum" ]; then - wrapperSha256Result=false - if command -v sha256sum >/dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | sha256sum -c >/dev/null 2>&1; then - wrapperSha256Result=true - fi - elif command -v shasum >/dev/null; then - if echo "$wrapperSha256Sum $wrapperJarPath" | shasum -a 256 -c >/dev/null 2>&1; then - wrapperSha256Result=true - fi - else - echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 - echo "Please install either command, or disable validation by removing 'wrapperSha256Sum' from your maven-wrapper.properties." >&2 - exit 1 - fi - if [ $wrapperSha256Result = false ]; then - echo "Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised." >&2 - echo "Investigate or delete $wrapperJarPath to attempt a clean download." >&2 - echo "If you updated your Maven version, you need to update the specified wrapperSha256Sum property." >&2 - exit 1 - fi -fi - -MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" - -# For Cygwin, switch paths to Windows format before running java -if $cygwin; then - [ -n "$JAVA_HOME" ] \ - && JAVA_HOME=$(cygpath --path --windows "$JAVA_HOME") - [ -n "$CLASSPATH" ] \ - && CLASSPATH=$(cygpath --path --windows "$CLASSPATH") - [ -n "$MAVEN_PROJECTBASEDIR" ] \ - && MAVEN_PROJECTBASEDIR=$(cygpath --path --windows "$MAVEN_PROJECTBASEDIR") -fi - -# Provide a "standardized" way to retrieve the CLI args that will -# work with both Windows and non-Windows executions. -MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $*" -export MAVEN_CMD_LINE_ARGS - -WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -# shellcheck disable=SC2086 # safe args -exec "$JAVACMD" \ - $MAVEN_OPTS \ - $MAVEN_DEBUG_OPTS \ - -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ - "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ - ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd deleted file mode 100644 index 708460f..0000000 --- a/mvnw.cmd +++ /dev/null @@ -1,206 +0,0 @@ -@REM ---------------------------------------------------------------------------- -@REM Licensed to the Apache Software Foundation (ASF) under one -@REM or more contributor license agreements. See the NOTICE file -@REM distributed with this work for additional information -@REM regarding copyright ownership. The ASF licenses this file -@REM to you under the Apache License, Version 2.0 (the -@REM "License"); you may not use this file except in compliance -@REM with the License. You may obtain a copy of the License at -@REM -@REM http://www.apache.org/licenses/LICENSE-2.0 -@REM -@REM Unless required by applicable law or agreed to in writing, -@REM software distributed under the License is distributed on an -@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY -@REM KIND, either express or implied. See the License for the -@REM specific language governing permissions and limitations -@REM under the License. -@REM ---------------------------------------------------------------------------- - -@REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version @@project.version@@ -@REM -@REM Required ENV vars: -@REM JAVA_HOME - location of a JDK home dir -@REM -@REM Optional ENV vars -@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands -@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending -@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven -@REM e.g. to debug Maven itself, use -@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 -@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files -@REM ---------------------------------------------------------------------------- - -@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' -@echo off -@REM set title of command window -title %0 -@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' -@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% - -@REM set %HOME% to equivalent of $HOME -if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") - -@REM Execute a user defined script before this one -if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre -@REM check for pre script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* -if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* -:skipRcPre - -@setlocal - -set ERROR_CODE=0 - -@REM To isolate internal variables from possible post scripts, we use another setlocal -@setlocal - -@REM ==== START VALIDATION ==== -if not "%JAVA_HOME%" == "" goto OkJHome - -echo. >&2 -echo Error: JAVA_HOME not found in your environment. >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. >&2 -goto error - -:OkJHome -if exist "%JAVA_HOME%\bin\java.exe" goto init - -echo. >&2 -echo Error: JAVA_HOME is set to an invalid directory. >&2 -echo JAVA_HOME = "%JAVA_HOME%" >&2 -echo Please set the JAVA_HOME variable in your environment to match the >&2 -echo location of your Java installation. >&2 -echo. >&2 -goto error - -@REM ==== END VALIDATION ==== - -:init - -@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". -@REM Fallback to current working directory if not found. - -set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% -IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir - -set EXEC_DIR=%CD% -set WDIR=%EXEC_DIR% -:findBaseDir -IF EXIST "%WDIR%"\.mvn goto baseDirFound -cd .. -IF "%WDIR%"=="%CD%" goto baseDirNotFound -set WDIR=%CD% -goto findBaseDir - -:baseDirFound -set MAVEN_PROJECTBASEDIR=%WDIR% -cd "%EXEC_DIR%" -goto endDetectBaseDir - -:baseDirNotFound -set MAVEN_PROJECTBASEDIR=%EXEC_DIR% -cd "%EXEC_DIR%" - -:endDetectBaseDir - -IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig - -@setlocal EnableExtensions EnableDelayedExpansion -for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a -@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% - -:endReadAdditionalConfig - -SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" -set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" -set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain - -set WRAPPER_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar" - -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperUrl" SET WRAPPER_URL=%%B -) - -@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central -@REM This allows using the maven wrapper in projects that prohibit checking in binary data. -if exist %WRAPPER_JAR% ( - if "%MVNW_VERBOSE%" == "true" ( - echo Found %WRAPPER_JAR% - ) -) else ( - if not "%MVNW_REPOURL%" == "" ( - SET WRAPPER_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/@@project.version@@/maven-wrapper-@@project.version@@.jar" - ) - if "%MVNW_VERBOSE%" == "true" ( - echo Couldn't find %WRAPPER_JAR%, downloading it ... - echo Downloading from: %WRAPPER_URL% - ) - - powershell -Command "&{"^ - "$webclient = new-object System.Net.WebClient;"^ - "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ - "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ - "}"^ - "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%WRAPPER_URL%', '%WRAPPER_JAR%')"^ - "}" - if "%MVNW_VERBOSE%" == "true" ( - echo Finished downloading %WRAPPER_JAR% - ) -) -@REM End of extension - -@REM If specified, validate the SHA-256 sum of the Maven wrapper jar file -SET WRAPPER_SHA_256_SUM="" -FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( - IF "%%A"=="wrapperSha256Sum" SET WRAPPER_SHA_256_SUM=%%B -) -IF NOT %WRAPPER_SHA_256_SUM%=="" ( - powershell -Command "&{"^ - "Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash;"^ - "$hash = (Get-FileHash \"%WRAPPER_JAR%\" -Algorithm SHA256).Hash.ToLower();"^ - "If('%WRAPPER_SHA_256_SUM%' -ne $hash){"^ - " Write-Error 'Error: Failed to validate Maven wrapper SHA-256, your Maven wrapper might be compromised.';"^ - " Write-Error 'Investigate or delete %WRAPPER_JAR% to attempt a clean download.';"^ - " Write-Error 'If you updated your Maven version, you need to update the specified wrapperSha256Sum property.';"^ - " exit 1;"^ - "}"^ - "}" - if ERRORLEVEL 1 goto error -) - -@REM Provide a "standardized" way to retrieve the CLI args that will -@REM work with both Windows and non-Windows executions. -set MAVEN_CMD_LINE_ARGS=%* - -%MAVEN_JAVA_EXE% ^ - %JVM_CONFIG_MAVEN_PROPS% ^ - %MAVEN_OPTS% ^ - %MAVEN_DEBUG_OPTS% ^ - -classpath %WRAPPER_JAR% ^ - "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ - %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* -if ERRORLEVEL 1 goto error -goto end - -:error -set ERROR_CODE=1 - -:end -@endlocal & set ERROR_CODE=%ERROR_CODE% - -if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost -@REM check for post script, once with legacy .bat ending and once with .cmd ending -if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" -if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" -:skipRcPost - -@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' -if "%MAVEN_BATCH_PAUSE%"=="on" pause - -if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% - -cmd /C exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml deleted file mode 100644 index b6852b9..0000000 --- a/pom.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - 4.0.0 - - - org.springframework.boot - spring-boot-starter-parent - 3.4.3 - - - - com.lowenssh - lowenssh - 0.0.1-SNAPSHOT - lowenssh - AI SSH 智能运维 Agent - - - 17 - - 1.1.5 - - - - - - org.springframework.ai - spring-ai-bom - ${spring-ai.version} - pom - import - - - - - - - - org.springframework.boot - spring-boot-starter-web - - - - - org.springframework.boot - spring-boot-starter-actuator - - - - - org.springframework.ai - spring-ai-starter-model-openai - - - - - com.github.mwiede - jsch - 0.2.21 - - - - org.springframework.boot - spring-boot-starter-test - test - - - - - com.baomidou - mybatis-plus-spring-boot3-starter - 3.5.9 - - - com.mysql - mysql-connector-j - runtime - - - - - org.projectlombok - lombok - true - - - - - - - org.springframework.boot - spring-boot-maven-plugin - - - - diff --git a/src/main/java/com/lowenssh/LowenSshApplication.java b/src/main/java/com/lowenssh/LowenSshApplication.java deleted file mode 100644 index f2e4c50..0000000 --- a/src/main/java/com/lowenssh/LowenSshApplication.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.lowenssh; - -import org.mybatis.spring.annotation.MapperScan; -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.scheduling.annotation.EnableScheduling; - -/** - * LowenSSH 启动类 —— AI SSH 智能运维 Agent - */ -@SpringBootApplication -@EnableScheduling // 开启定时任务:SessionManager 定时回收超时的常驻 SSH 连接 -@MapperScan("com.lowenssh.persistence.mapper") // 扫描 MyBatis Mapper 接口 -public class LowenSshApplication { - - public static void main(String[] args) { - SpringApplication.run(LowenSshApplication.class, args); - } -} diff --git a/src/main/java/com/lowenssh/agent/AgentController.java b/src/main/java/com/lowenssh/agent/AgentController.java deleted file mode 100644 index b5564ca..0000000 --- a/src/main/java/com/lowenssh/agent/AgentController.java +++ /dev/null @@ -1,187 +0,0 @@ -package com.lowenssh.agent; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.lowenssh.agent.guard.AutoConfirmationHandler; -import com.lowenssh.agent.guard.CommandGuard; -import com.lowenssh.persistence.AuditService; -import com.lowenssh.persistence.MessageService; -import com.lowenssh.persistence.entity.SessionEntity; -import com.lowenssh.persistence.mapper.SessionMapper; -import com.lowenssh.ssh.SshClient; -import org.springframework.http.MediaType; -import org.springframework.http.codec.ServerSentEvent; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; -import reactor.core.publisher.Flux; - -import java.time.format.DateTimeFormatter; -import java.util.List; - -/** - * Agent 接口。 - * - * 流式端点支持多轮对话: - * - 首轮:sessionId 为空,带 host/port/user/password,SessionManager 建会话 + 连一次 SSH, - * 把连接常驻;先回 session_ready 事件把 sessionId 给前端。 - * - 续聊:sessionId 非空,复用该会话的常驻连接(保留 cd 等上下文),历史从库里回灌给模型。 - * - * 连接生命周期归 SessionManager 管(超时回收 / 显式关闭),不再随单次请求开关。 - */ -@RestController -public class AgentController { - - private final AgentService agentService; - private final SessionManager sessionManager; - private final SessionMapper sessionMapper; - private final AuditService auditService; - private final MessageService messageService; - private final CommandGuard guard; - - public AgentController(AgentService agentService, SessionManager sessionManager, - SessionMapper sessionMapper, AuditService auditService, - MessageService messageService, CommandGuard guard) { - this.agentService = agentService; - this.sessionManager = sessionManager; - this.sessionMapper = sessionMapper; - this.auditService = auditService; - this.messageService = messageService; - this.guard = guard; - } - - private static final DateTimeFormatter TS_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); - - /** - * 列出会话,按更新时间倒序(左侧历史栏用)。 - * 带 hostId 则只列该主机的会话(历史按主机隔离);不带则列全部(兼容旧调用)。 - * 只读查库,不碰 SSH。 - */ - @GetMapping("/api/agent/sessions") - public List sessions( - @org.springframework.web.bind.annotation.RequestParam(value = "hostId", required = false) Long hostId) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(hostId != null, SessionEntity::getHostId, hostId) - .orderByDesc(SessionEntity::getUpdatedAt) - .orderByDesc(SessionEntity::getId); // updatedAt 为空时退而按 id 排 - return sessionMapper.selectList(wrapper).stream() - .map(s -> new SessionDto.SessionItem( - s.getId(), s.getTitle(), s.getSshHost(), s.getSshUser(), s.getSshPort(), - s.getUpdatedAt() == null ? null : s.getUpdatedAt().format(TS_FMT))) - .toList(); - } - - /** - * 拉某会话的历史消息 + 连接信息 + 常驻连接是否存活(左栏点开旧会话回看用)。 - * live=true 表示常驻连接还在,可直接续聊;false 则前端提示需重连。 - * 只读查库 + 查内存连接状态,不碰 SSH 执行。 - */ - @GetMapping("/api/agent/sessions/{id}/messages") - public SessionDto.SessionDetail sessionMessages(@PathVariable("id") Long id) { - SessionEntity s = sessionMapper.selectById(id); - if (s == null) { - return new SessionDto.SessionDetail(id, null, null, null, false, List.of()); - } - boolean live = sessionManager.get(id) != null; - return new SessionDto.SessionDetail( - id, s.getSshHost(), s.getSshPort(), s.getSshUser(), live, - messageService.loadHistoryForView(id)); - } - - @PostMapping("/api/agent/run") - public String run(@RequestBody RunRequest req) { - int port = req.port() == 0 ? 22 : req.port(); - - // 先建会话拿 id:审计要 session_id 关联 - SessionEntity session = new SessionEntity(); - session.setTitle(SessionManager.toTitle(req.task())); - session.setSshHost(req.host()); - session.setSshPort(port); - session.setSshUser(req.user()); - sessionMapper.insert(session); - Long sessionId = session.getId(); - - // try-with-resources:loop 跑完自动关连接(同步接口是一次性测试用,不参与多轮常驻) - try (SshClient ssh = new SshClient()) { - ssh.connect(req.host(), port, req.user(), req.password()); - SshTools tools = new SshTools(ssh, sessionId, auditService, guard); - // REST 场景用自动确认:deny 已被门禁拦死,ask 态自动放行以便自动化测试 - return agentService.run(sessionId, req.task(), tools, new AutoConfirmationHandler()); - } catch (Exception e) { - return "任务执行失败: " + e.getMessage(); - } - } - - /** - * 流式 + 多轮:用 SSE 把 agent 执行过程逐事件吐出来。 - * - * 首轮(curl 示例,sessionId 不传): - * curl -N -X POST http://localhost:8081/api/agent/stream \ - * -H 'Content-Type: application/json' \ - * -d '{"host":"1.2.3.4","user":"root","password":"xxx","task":"看下根分区还剩多少空间"}' - * 续聊:带上首轮拿到的 sessionId,连接信息可省: - * -d '{"sessionId":1,"task":"那内存呢"}' - * - * 连接由 SessionManager 常驻,这里不再 doFinally 关连接。 - */ - @PostMapping(value = "/api/agent/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public Flux> stream(@RequestBody RunRequest req) { - SessionManager.LiveSession live; - boolean firstTurn = req.sessionId() == null; - - if (firstTurn) { - // 首轮:复用进主机时建好的预连接;没有(curl 直连)则现连一次。再落库建会话行(title=任务摘要)。 - int port = req.port() == 0 ? 22 : req.port(); - try { - live = sessionManager.getByHost(req.hostId()); - if (live == null) { - live = sessionManager.connectHost(req.hostId(), req.host(), port, req.user(), req.password()); - } - } catch (Exception e) { - return Flux.just(sse(new AgentEvent.Error("SSH 连接失败: " + e.getMessage()))); - } - // 落库与连接分开归类:落库失败不能误报成连接失败,否则排查方向全错 - try { - sessionManager.attachSession(live, req.task()); - } catch (Exception e) { - return Flux.just(sse(new AgentEvent.Error("创建会话失败: " + e.getMessage()))); - } - } else { - // 续聊:取常驻连接;不存在/已过期则提示前端重连 - live = sessionManager.get(req.sessionId()); - if (live == null) { - return Flux.just(sse(new AgentEvent.SessionExpired(req.sessionId(), - "常驻连接已断开(空闲超时回收),请在右侧开新会话重连"))); - } - } - - Long sessionId = live.sessionId(); - // 每轮新建 SshTools,但底层复用 manager 里同一个常驻 SshClient(保留 cd 等上下文)。 - // 传 live.lock() 让 SFTP 工具与人工面板/监控串行化,避免抢同一条 Session。 - SshTools tools = new SshTools(live.ssh(), sessionId, auditService, guard, live.lock()); - - Flux> events = agentService - .runStream(sessionId, req.task(), tools, new AutoConfirmationHandler()) - .map(this::sse); - - // 首轮在事件流最前面插一个 session_ready,把 sessionId 交给前端用于后续续聊 - if (firstTurn) { - events = Flux.concat(Flux.just(sse(new AgentEvent.SessionReady(sessionId))), events); - } - return events; - } - - /** 把领域事件包成 SSE:event 名取事件 type,方便前端按类型分发 */ - private ServerSentEvent sse(AgentEvent event) { - return ServerSentEvent.builder() - .event(event.type()) - .data(event) - .build(); - } - - /** 请求体。sessionId 为空=首轮(带 hostId/host/port/user/password 新建会话+连 SSH); - * 非空=续聊(复用该会话的常驻连接,连接信息可不传) */ - public record RunRequest(Long sessionId, Long hostId, String host, int port, String user, String password, String task) { - } -} diff --git a/src/main/java/com/lowenssh/agent/AgentEvent.java b/src/main/java/com/lowenssh/agent/AgentEvent.java deleted file mode 100644 index dc6c9d4..0000000 --- a/src/main/java/com/lowenssh/agent/AgentEvent.java +++ /dev/null @@ -1,55 +0,0 @@ -package com.lowenssh.agent; - -/** - * Agent 事件 —— 对外流式输出的带类型事件流。 - * - * 为什么用带类型事件而不是纯 Flux:运维 Agent 的输出有多种语义—— - * 模型在说话、模型要调命令、命令出结果、命令被拦、任务完成。前端要据此区别渲染 - * (token 追加、工具调用展开、拦截高亮、完成收尾)。纯字符串流分不出这些。 - * - * sealed + record(Java 17):限定子类集合,消费方 switch 表达式时编译器保证穷尽, - * 加事件类型不会漏处理。 - */ -public sealed interface AgentEvent { - - /** 会话就绪:首轮建会话后回传 sessionId,前端存下来用于后续多轮续聊 */ - record SessionReady(Long sessionId) implements AgentEvent {} - - /** 模型增量输出的一段文本 token */ - record Token(String text) implements AgentEvent {} - - /** 模型的思考过程增量(GLM reasoning_content),前端实时展示"在想什么" */ - record Reasoning(String text) implements AgentEvent {} - - /** 模型决定调用某个工具 */ - record ToolCall(String name, String args) implements AgentEvent {} - - /** 工具执行结果(executed=false 表示被拦截/拒绝,未真正执行) */ - record ToolResult(String name, String summary, boolean executed) implements AgentEvent {} - - /** 命令被安全门禁拦截 / 用户拒绝 */ - record Blocked(String command, String reason) implements AgentEvent {} - - /** 任务完成,带最终结论文本 */ - record Done(String finalText) implements AgentEvent {} - - /** 出错 */ - record Error(String message) implements AgentEvent {} - - /** 续聊时常驻连接已被回收/不存在:前端据此锁输入并切到断线态,提示开新会话重连 */ - record SessionExpired(Long sessionId, String message) implements AgentEvent {} - - /** 事件类型名,用作 SSE 的 event 字段,方便前端按类型监听 */ - default String type() { - if (this instanceof SessionReady) return "session_ready"; - if (this instanceof Token) return "token"; - if (this instanceof Reasoning) return "reasoning"; - if (this instanceof ToolCall) return "tool_call"; - if (this instanceof ToolResult) return "tool_result"; - if (this instanceof Blocked) return "blocked"; - if (this instanceof Done) return "done"; - if (this instanceof Error) return "error"; - if (this instanceof SessionExpired) return "session_expired"; - return "unknown"; - } -} diff --git a/src/main/java/com/lowenssh/agent/AgentService.java b/src/main/java/com/lowenssh/agent/AgentService.java deleted file mode 100644 index 302dc9b..0000000 --- a/src/main/java/com/lowenssh/agent/AgentService.java +++ /dev/null @@ -1,520 +0,0 @@ -package com.lowenssh.agent; - -import com.fasterxml.jackson.databind.ObjectMapper; -import com.lowenssh.agent.guard.CommandGuard; -import com.lowenssh.agent.guard.ConfirmationHandler; -import com.lowenssh.persistence.AuditService; -import com.lowenssh.persistence.MessageService; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.ToolResponseMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.MessageAggregator; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.tool.ToolCallingManager; -import org.springframework.ai.model.tool.ToolExecutionResult; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.ai.openai.OpenAiChatOptions; -import org.springframework.ai.support.ToolCallbacks; -import org.springframework.ai.tool.ToolCallback; -import org.springframework.stereotype.Service; -import reactor.core.publisher.Flux; -import reactor.core.publisher.Sinks; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Consumer; - -/** - * Agent 核心 —— 手写的 agentic loop + 安全门禁。 - * - * 为什么手写而不用 Spring AI 的自动循环:自动循环把 tool_call 在框架内部执行掉, - * 我们插不进"执行前人工确认 / 危险命令拦截"这一刀。关键开关: - * options.internalToolExecutionEnabled(false) —— 关掉自动执行,让 tool_call - * 回到我们手里,先过门禁、再由 ToolCallingManager.executeToolCalls 显式执行。 - * - * 循环结构(Claude Code 同款状态机思路): - * 请求 → 模型返回 → 有 tool_call? - * 有 → 门禁预检 → 全放行才执行工具 → 结果回灌 → 带新历史再请求 - * 任一被拒 → 不执行,手动回灌"拒绝"作为工具结果 → loop 继续让模型换方案 - * 无 → 模型给出最终结论,循环结束 - * 加最大轮数上限防死循环。 - * - * 安全是独立代码路径:门禁不写进工具方法、不靠模型自觉,越狱也绕不过。 - */ -@Service -public class AgentService { - - private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AgentService.class); - - /** 最大循环轮数,防止模型反复调工具停不下来。可配置:xwssh.agent.max-rounds */ - private final int maxRounds; - - /** 只对这个工具的命令做门禁;其余(读文件/看日志)天然只读,放行 */ - private static final String EXEC_TOOL = "execCommand"; - - private static final String SYSTEM_PROMPT = """ - ## 身份 - 你是 LowenSSH,一个面向 Linux 服务器的 SSH/SFTP 智能体。 - 你帮用户远程排查问题、执行命令、读取文件、查看日志,并在用户授权下完成文件传输等运维操作。 - 你的能力随工具集扩展——当前可用的工具见工具列表,只调用列表里实际存在的工具,不要臆造工具。 - - ## 环境与安全 - 你执行的每条命令都会经过一道独立的安全门禁,危险命令会被拦截。 - 被拦时换一个更安全的方式达成目标,不要重复同一条被拒命令,也不要改用等价的危险命令绕过拦截。 - - ## 工作方式 - - 先理解任务目标再决定查什么,每步拿到结果后判断下一步,不要一次堆一堆命令。 - - 优先用只读命令探查(df / free / ps / cat / tail),看清现状再动有副作用的操作。 - - 命令输出可能被截断(节省 token),抓关键信息即可,需要时再精确查询。 - - ## 输出格式 - - 用中文,给出结论,不要只罗列原始命令输出。 - - 简单结果用自然语言简短回答,多维度信息才用列表或表格。 - - 关键数字(磁盘占用 %、内存、负载等)直接点出来,别让用户自己从输出里找。 - """; - - private final OpenAiChatModel chatModel; - private final ToolCallingManager toolCallingManager; - private final CommandGuard guard; - private final AuditService auditService; - private final MessageService messageService; - private final ContextManager contextManager; - private final ObjectMapper objectMapper = new ObjectMapper(); - - // OpenAiChatModel 和 ToolCallingManager 都由 starter 自动配置好,直接注入 - public AgentService(OpenAiChatModel chatModel, ToolCallingManager toolCallingManager, - CommandGuard guard, AuditService auditService, MessageService messageService, - ContextManager contextManager, - @org.springframework.beans.factory.annotation.Value("${xwssh.agent.max-rounds:40}") int maxRounds) { - this.chatModel = chatModel; - this.toolCallingManager = toolCallingManager; - this.guard = guard; - this.auditService = auditService; - this.messageService = messageService; - this.contextManager = contextManager; - this.maxRounds = maxRounds; - } - - /** - * 跑一轮 agent 任务。 - * - * @param sessionId 本次会话 id(审计落库用) - * @param task 用户的运维任务 - * @param tools 会话级工具集(已绑定连好的 SSH 会话) - * @param confirmer ask 态命令的人工确认入口(控制台真人 / REST 自动放行) - * @return 模型的最终结论文本 - */ - public String run(Long sessionId, String task, SshTools tools, ConfirmationHandler confirmer) { - ToolCallback[] callbacks = ToolCallbacks.from(tools); - - // 关键:internalToolExecutionEnabled(false) 关掉框架自动执行工具。 - // options 在循环外只构建一次:工具 schema 是 GLM 上下文缓存前缀的一部分, - // 若每轮重建导致 schema 序列化抖动,会让缓存前缀失配、整段历史按全价重算。 - OpenAiChatOptions options = OpenAiChatOptions.builder() - .toolCallbacks(callbacks) - .internalToolExecutionEnabled(false) - .build(); - - List messages = new ArrayList<>(); - messages.add(new SystemMessage(SYSTEM_PROMPT)); - messages.addAll(messageService.loadHistory(sessionId)); // 还原历史,支持多轮续聊 - messages.add(new UserMessage(task)); - messageService.saveUser(sessionId, task); // 落用户任务 - - for (int round = 1; round <= maxRounds; round++) { - // 进模型前整理上下文:Layer 0 截断大工具结果 + Layer 4 历史超阈值则压缩 - messages = contextManager.truncateToolResponses(messages); - messages = contextManager.compressIfNeeded(messages); - - Prompt prompt = new Prompt(messages, options); - ChatResponse response = chatModel.call(prompt); - logUsage(response); // 测缓存命中 - - // 没有 tool_call 了,模型给出最终结论,结束 - if (!response.hasToolCalls()) { - String text = response.getResult().getOutput().getText(); - // 兜底:模型可能给出空结论(尤其命令被反复拦截后),别返回空串误导调用方 - if (text == null || text.isBlank()) { - text = "模型暂时没有返回内容,请重试。"; - messageService.saveAssistant(sessionId, text, null); - return text; - } - messageService.saveAssistant(sessionId, text, null); // 落最终结论 - return text; - } - - AssistantMessage assistant = response.getResult().getOutput(); - persistAssistant(sessionId, assistant); // 落 assistant(文字 + tool_calls) - - // —— 门禁预检:逐个 tool_call 判定,收集被拒的 —— - List rejected = screen(sessionId, assistant, confirmer, null); - - if (!rejected.isEmpty()) { - // 有被拒的:不调框架执行(executeToolCalls 是整批执行,没法只跑一部分)。 - // 手动把 assistant 的 tool_call 消息 + 拒绝结果回灌,loop 继续让模型换方案。 - messages.add(assistant); - messages.add(ToolResponseMessage.builder().responses(rejected).build()); - persistToolResponses(sessionId, rejected); // 落拒绝结果(还原"想跑啥被拦了") - continue; - } - - // 全放行:交给框架执行,拿回灌后的完整历史 - ToolExecutionResult execResult = toolCallingManager.executeToolCalls(prompt, response); - persistLastToolResponses(sessionId, execResult); // 落本轮工具执行结果 - messages = new ArrayList<>(execResult.conversationHistory()); - } - - return "已达到最大循环轮数(" + maxRounds + "),任务可能未完成。请拆分任务后重试。"; - } - - /** - * 流式版本:把同步 run() 的执行过程拆成带类型事件流对外推送(SSE 用)。 - * - * 架构(混合线程): - * - 对外用 Sinks.Many 造一条 Flux,方法立刻返回,不阻塞。 - * - 真正的 agentic loop 是命令式 while,放到独立线程里跑(loop 内部有 block 调用, - * 不能跑在 reactor 调度线程上)。 - * - 每轮 chatModel.stream 拿到 token 流,用 MessageAggregator 边推 Token 事件、 - * 边把碎片聚合成完整 ChatResponse(含 tool_call),聚合完再走门禁/执行。 - * - * 注意:SSH 连接的关闭由调用方在 Flux.doFinally 里做——这里是异步的,方法返回时 loop 还没跑完, - * 不能用 try-with-resources。 - */ - public Flux runStream(Long sessionId, String task, SshTools tools, ConfirmationHandler confirmer) { - Sinks.Many sink = Sinks.many().unicast().onBackpressureBuffer(); - - Thread worker = new Thread(() -> { - try { - ToolCallback[] callbacks = ToolCallbacks.from(tools); - // 同步版同款:循环外构建一次,保住缓存前缀稳定 - OpenAiChatOptions options = OpenAiChatOptions.builder() - .toolCallbacks(callbacks) - .internalToolExecutionEnabled(false) - .build(); - - List messages = new ArrayList<>(); - messages.add(new SystemMessage(SYSTEM_PROMPT)); - messages.addAll(messageService.loadHistory(sessionId)); // 还原历史,支持多轮续聊 - messages.add(new UserMessage(task)); - messageService.saveUser(sessionId, task); // 落用户任务 - - for (int round = 1; round <= maxRounds; round++) { - // 进模型前整理上下文:Layer 0 截断大工具结果 + Layer 4 历史超阈值则压缩 - messages = contextManager.truncateToolResponses(messages); - messages = contextManager.compressIfNeeded(messages); - - Prompt prompt = new Prompt(messages, options); - - // 一次流式模型调用:边推 token 边聚合,返回完整 ChatResponse - ChatResponse response = streamOnce(prompt, sink); - - // 没有 tool_call:模型给出最终结论,结束 - if (response == null || !response.hasToolCalls()) { - String text = response == null ? null : response.getResult().getOutput().getText(); - // 空响应可能是模型偶发抖动(返回空 choice),重试一次再判定 - if (text == null || text.isBlank()) { - log.warn("模型返回空结论,重试一次 sessionId={} round={}", sessionId, round); - response = streamOnce(prompt, sink); - text = response == null ? null : response.getResult().getOutput().getText(); - // 重试后又冒出 tool_call,回主循环正常处理 - if (response != null && response.hasToolCalls()) { - AssistantMessage retried = response.getResult().getOutput(); - persistAssistant(sessionId, retried); - for (AssistantMessage.ToolCall call : retried.getToolCalls()) { - sink.tryEmitNext(new AgentEvent.ToolCall(call.name(), call.arguments())); - } - List rj = - screen(sessionId, retried, confirmer, sink::tryEmitNext); - if (!rj.isEmpty()) { - messages.add(retried); - messages.add(ToolResponseMessage.builder().responses(rj).build()); - persistToolResponses(sessionId, rj); - continue; - } - Prompt retryPrompt = new Prompt(messages, options); - ToolExecutionResult er = toolCallingManager.executeToolCalls(retryPrompt, response); - emitToolResults(er, sink); - persistLastToolResponses(sessionId, er); - messages = new ArrayList<>(er.conversationHistory()); - continue; - } - } - if (text == null || text.isBlank()) { - // 重试仍空:中性文案,不再误导为"被安全策略阻止" - text = "模型暂时没有返回内容,请重试。"; - } - messageService.saveAssistant(sessionId, text, null); // 落最终结论 - sink.tryEmitNext(new AgentEvent.Done(text)); - sink.tryEmitComplete(); - return; - } - - AssistantMessage assistant = response.getResult().getOutput(); - persistAssistant(sessionId, assistant); // 落 assistant(文字 + tool_calls) - // 先把本轮要调的工具吐出去,让前端看到"准备执行什么" - for (AssistantMessage.ToolCall call : assistant.getToolCalls()) { - sink.tryEmitNext(new AgentEvent.ToolCall(call.name(), call.arguments())); - } - - // 门禁预检:DENY/用户拒绝会通过 onBlocked 推 Blocked 事件 - List rejected = - screen(sessionId, assistant, confirmer, sink::tryEmitNext); - - if (!rejected.isEmpty()) { - // 有被拒:整批不执行,把 assistant + 拒绝结果回灌,让模型换方案 - messages.add(assistant); - messages.add(ToolResponseMessage.builder().responses(rejected).build()); - persistToolResponses(sessionId, rejected); // 落拒绝结果 - continue; - } - - // 全放行:交框架执行,并把每个工具结果摘要吐给前端 - ToolExecutionResult execResult = toolCallingManager.executeToolCalls(prompt, response); - emitToolResults(execResult, sink); - persistLastToolResponses(sessionId, execResult); // 落本轮工具执行结果 - messages = new ArrayList<>(execResult.conversationHistory()); - } - - sink.tryEmitNext(new AgentEvent.Done( - "已达到最大循环轮数(" + maxRounds + "),任务可能未完成。请拆分任务后重试。")); - sink.tryEmitComplete(); - } catch (Exception e) { - log.warn("流式 agent 执行异常 sessionId={}", sessionId, e); - sink.tryEmitNext(new AgentEvent.Error(e.getMessage() == null ? e.toString() : e.getMessage())); - sink.tryEmitComplete(); - } - }, "agent-stream-" + sessionId); - worker.setDaemon(true); - worker.start(); - - return sink.asFlux(); - } - - /** - * 一次流式模型调用:透传 chunk 推 Token 事件,聚合成完整 ChatResponse 返回。 - * 抽出来供主循环和空响应重试复用。 - */ - private ChatResponse streamOnce(Prompt prompt, Sinks.Many sink) { - AtomicReference aggregatedRef = new AtomicReference<>(); - new MessageAggregator() - .aggregate(chatModel.stream(prompt), aggregatedRef::set) - .doOnNext(chunk -> { - if (chunk.getResult() == null) { - return; - } - var output = chunk.getResult().getOutput(); - // GLM 思考阶段 text 为 null,思考增量在 output.metadata.reasoningContent, - // 单独推 Reasoning 事件,前端实时展示"在想什么"。 - Object reasoning = output.getMetadata() == null ? null - : output.getMetadata().get("reasoningContent"); - if (reasoning instanceof String rc && !rc.isEmpty()) { - sink.tryEmitNext(new AgentEvent.Reasoning(rc)); - } - String t = output.getText(); - if (t != null && !t.isEmpty()) { - sink.tryEmitNext(new AgentEvent.Token(t)); - } - }) - .blockLast(); - ChatResponse resp = aggregatedRef.get(); - logUsage(resp); - return resp; - } - - /** - * 打印本轮 token 用量和缓存命中率(省 token 的测量基础)。 - * - * GLM 隐式上下文缓存:命中的 token 按更低价计费,命中数在 - * usage.prompt_tokens_details.cached_tokens(OpenAI 兼容字段,Spring AI 收进 nativeUsage)。 - * Spring AI 的标准 Usage 只有 prompt/completion/total,cached 要从 nativeUsage 里挖, - * 字段不一定有,全程防御式读取,取不到只打基础值,绝不影响主流程。 - */ - private void logUsage(ChatResponse resp) { - try { - if (resp == null || resp.getMetadata() == null || resp.getMetadata().getUsage() == null) { - return; - } - var usage = resp.getMetadata().getUsage(); - Integer prompt = usage.getPromptTokens(); - Integer completion = usage.getCompletionTokens(); - Integer total = usage.getTotalTokens(); - long cached = extractCachedTokens(usage.getNativeUsage()); - String hitRate = (prompt != null && prompt > 0) - ? String.format("%.0f%%", cached * 100.0 / prompt) : "n/a"; - log.info("token 用量 prompt={} completion={} total={} cached={} 命中率={}", - prompt, completion, total, cached, hitRate); - } catch (Exception e) { - // 测量失败绝不能拖累主流程 - log.debug("读取 token 用量失败: {}", e.getMessage()); - } - } - - /** 从 nativeUsage(GLM 返回的原始 usage 对象)里挖 prompt_tokens_details.cached_tokens;挖不到返回 0 */ - private long extractCachedTokens(Object nativeUsage) { - if (nativeUsage == null) { - return 0; - } - try { - // nativeUsage 一般是 OpenAI SDK 的 Usage 对象,序列化成树后按字段名取,避免硬依赖具体类型 - var node = objectMapper.valueToTree(nativeUsage); - var details = node.get("promptTokensDetails"); - if (details == null) { - details = node.get("prompt_tokens_details"); - } - if (details == null) { - return 0; - } - var cached = details.get("cachedTokens"); - if (cached == null) { - cached = details.get("cached_tokens"); - } - return cached == null ? 0 : cached.asLong(); - } catch (Exception e) { - return 0; - } - } - - /** 从框架执行后的会话历史里抽取本轮工具结果,逐个推 ToolResult 事件 */ - private void emitToolResults(ToolExecutionResult execResult, Sinks.Many sink) { - List history = execResult.conversationHistory(); - if (history.isEmpty()) { - return; - } - Message last = history.get(history.size() - 1); - if (last instanceof ToolResponseMessage trm) { - for (ToolResponseMessage.ToolResponse resp : trm.getResponses()) { - String data = unwrapToolData(resp.responseData()); - String summary = data.length() > 500 ? data.substring(0, 500) + "…" : data; - sink.tryEmitNext(new AgentEvent.ToolResult(resp.name(), summary, true)); - } - } - } - - /** 把 assistant 本轮发起的 tool_call 列表序列化成 JSON 落库;无工具调用返回 null */ - private String toolCallsToJson(AssistantMessage assistant) { - if (assistant.getToolCalls() == null || assistant.getToolCalls().isEmpty()) { - return null; - } - try { - return objectMapper.writeValueAsString(assistant.getToolCalls()); - } catch (Exception e) { - // 序列化失败不影响主流程,落个占位串即可 - return "[\"tool_calls 序列化失败\"]"; - } - } - - /** 落一条 assistant 消息:文字 + 本轮 tool_calls(两者可同时为空/有值) */ - private void persistAssistant(Long sessionId, AssistantMessage assistant) { - messageService.saveAssistant(sessionId, assistant.getText(), toolCallsToJson(assistant)); - } - - /** - * 框架的 resp.responseData() 是 JSON 序列化后的字符串(带外层引号、\n 被转义成 \\n)。 - * 推给前端、落库前先反序列化成干净文本,避免截断切掉结尾引号导致前端解析失败。 - */ - private String unwrapToolData(String data) { - if (data == null) { - return ""; - } - if (data.startsWith("\"")) { - try { - return objectMapper.readValue(data, String.class); - } catch (Exception e) { - // 解析失败就用原文,至少不丢内容 - return data; - } - } - return data; - } - - /** 把一批工具结果(执行结果 / 拒绝结果)逐条落 t_message */ - private void persistToolResponses(Long sessionId, List responses) { - for (ToolResponseMessage.ToolResponse resp : responses) { - messageService.saveToolResult(sessionId, resp.id(), unwrapToolData(resp.responseData())); - } - } - - /** 从框架执行后的会话历史末尾抽取本轮工具结果落库(数据源同 emitToolResults) */ - private void persistLastToolResponses(Long sessionId, ToolExecutionResult execResult) { - List history = execResult.conversationHistory(); - if (history.isEmpty()) { - return; - } - Message last = history.get(history.size() - 1); - if (last instanceof ToolResponseMessage trm) { - persistToolResponses(sessionId, trm.getResponses()); - } - } - - /** - * 对本轮所有 tool_call 做门禁预检。 - * 返回被拒绝的 tool_call 对应的"拒绝"工具结果;空列表表示全部放行。 - * - * 注意:只要有一个被拒,本轮就整批不执行(受框架整批执行限制)。所以这里把 - * 被拒的攒成拒绝结果,放行的不在这里执行——交给外层 executeToolCalls 统一跑。 - */ - private List screen(Long sessionId, AssistantMessage assistant, - ConfirmationHandler confirmer, - Consumer onBlocked) { - List rejected = new ArrayList<>(); - - for (AssistantMessage.ToolCall call : assistant.getToolCalls()) { - // 非 execCommand 的工具(读文件/看日志)只读,直接放行 - if (!EXEC_TOOL.equals(call.name())) { - continue; - } - - String command = extractCommand(call.arguments()); - CommandGuard.Verdict verdict = guard.evaluate(command); - - switch (verdict.decision()) { - case DENY -> { - // 拦截点审计:模型试图跑危险命令、被门禁拦下——最有价值的审计记录 - auditService.logBlocked(sessionId, command, true, - "DENY: " + verdict.reason()); - if (onBlocked != null) { - onBlocked.accept(new AgentEvent.Blocked(command, verdict.reason())); - } - rejected.add(reject(call, - "命令被安全门禁拒绝执行(" + verdict.reason() + ")。请改用更安全的方式。")); - } - case ASK -> { - boolean ok = confirmer.confirm(command, verdict.reason()); - if (!ok) { - // 用户拒绝也记一笔(dangerous=true 因为是 ask 态命中副作用规则) - auditService.logBlocked(sessionId, command, true, - "用户拒绝: " + verdict.reason()); - if (onBlocked != null) { - onBlocked.accept(new AgentEvent.Blocked(command, "用户拒绝: " + verdict.reason())); - } - rejected.add(reject(call, "用户拒绝执行该命令。请换一种方式或询问用户。")); - } - // 批准则不加入 rejected,留给外层执行(执行点会在 SshTools 落审计) - } - case ALLOW -> { /* 放行 */ } - } - } - return rejected; - } - - /** 从 tool_call 的 JSON 参数里取出 command 字段 */ - private String extractCommand(String argumentsJson) { - try { - var node = objectMapper.readTree(argumentsJson); - var cmd = node.get("command"); - return cmd == null ? "" : cmd.asText(); - } catch (Exception e) { - // 解析失败当空命令处理(门禁会放行,但实际执行会报错,模型自己会看到) - return ""; - } - } - - /** 构造一条"拒绝"工具结果,id/name 必须和原 tool_call 对上,模型才知道是哪一步被拒 */ - private ToolResponseMessage.ToolResponse reject(AssistantMessage.ToolCall call, String reason) { - return new ToolResponseMessage.ToolResponse(call.id(), call.name(), reason); - } -} diff --git a/src/main/java/com/lowenssh/agent/ContextManager.java b/src/main/java/com/lowenssh/agent/ContextManager.java deleted file mode 100644 index 14a386c..0000000 --- a/src/main/java/com/lowenssh/agent/ContextManager.java +++ /dev/null @@ -1,290 +0,0 @@ -package com.lowenssh.agent; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.ToolResponseMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.openai.OpenAiChatModel; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * 上下文管理 —— 防止 agentic loop 多轮滚下来把模型上下文撑爆。 - * - * 抄 Claude Code 的思路,只做两层(其余 cache_edits / session memory 是为 prompt cache - * 做的精细活,DeepSeek/GLM 场景 ROI 低,不做): - * - * Layer 0 —— 大工具结果截断:单条工具结果(cat 大文件、tail 海量日志)超阈值就截掉中段, - * 只留头尾 + 一行截断提示。分级:最近 K 条用大阈值保细节,更早的用小阈值大力收紧, - * 让 token 不随轮数线性膨胀。注意:完整内容我们本来就落了 t_message, - * 截断只作用于"回灌给模型的副本",落库的仍是完整内容,可还原。 - * - * Layer 4 —— 历史压缩:整段 messages 估算 token 超阈值时,把较早的对话丢给 LLM 摘要成一条, - * 保留 system + 最近 K 条原文。硬约束:assistant 的 tool_call 和它的 - * tool_result 必须成对,切割点不能落在中间,否则 GLM 直接报错。 - * 摘要 LLM 连续失败到熔断阈值就停止压缩、裸跑兜底,避免摘要本身挂了拖死主流程。 - * - * 阈值全部可配置(application 配置项 xwssh.context.*),方便联调时调小快速触发验证。 - * token 用字符数粗估:中英混合约 2.5 字符 / token,不引 tokenizer 依赖。 - */ -@Component -public class ContextManager { - - private static final Logger log = LoggerFactory.getLogger(ContextManager.class); - - /** 字符数到 token 的粗估系数:中英混合约 2.5 字符 = 1 token */ - private static final double CHARS_PER_TOKEN = 2.5; - - private final OpenAiChatModel chatModel; - - /** Layer 0:最近 K 条内的工具结果保留的最大字符数,超出截断中段 */ - private final int toolResultMaxChars; - /** Layer 0:更早(保留区之外)的工具结果用更小的阈值,旧命令输出大力收紧——token 不随轮数膨胀 */ - private final int oldToolResultMaxChars; - /** Layer 4:整段上下文估算 token 超过此值触发压缩 */ - private final int maxContextTokens; - /** Layer 4:压缩时保留最近多少条消息原文(不进摘要) */ - private final int keepRecentMessages; - /** Layer 4:摘要 LLM 连续失败达到此次数后熔断,不再压缩 */ - private final int circuitLimit; - - /** 摘要 LLM 连续失败计数,成功清零;达到 circuitLimit 触发熔断 */ - private final AtomicInteger consecutiveFailures = new AtomicInteger(0); - - public ContextManager( - OpenAiChatModel chatModel, - @Value("${xwssh.context.tool-result-max-chars:8000}") int toolResultMaxChars, - @Value("${xwssh.context.old-tool-result-max-chars:800}") int oldToolResultMaxChars, - @Value("${xwssh.context.max-context-tokens:32000}") int maxContextTokens, - @Value("${xwssh.context.keep-recent-messages:6}") int keepRecentMessages, - @Value("${xwssh.context.circuit-limit:3}") int circuitLimit) { - this.chatModel = chatModel; - this.toolResultMaxChars = toolResultMaxChars; - this.oldToolResultMaxChars = oldToolResultMaxChars; - this.maxContextTokens = maxContextTokens; - this.keepRecentMessages = keepRecentMessages; - this.circuitLimit = circuitLimit; - } - - // ============================ Layer 0:工具结果截断 ============================ - - /** - * 对历史里所有工具结果做截断(幂等:已截短的再跑也不变)。 - * 返回新列表,不改原列表。只重建超长的 ToolResponseMessage,其余消息原样保留。 - */ - /** - * 对历史里所有工具结果做截断(幂等:已截短的再跑也不变)。 - * 返回新列表,不改原列表。只重建超长的 ToolResponseMessage,其余消息原样保留。 - * - * 分级截断(省 token 核心 + 缓存友好): - * - 最近 keepRecentMessages 条内的工具结果:用大阈值 toolResultMaxChars,保住当前推理需要的细节; - * - 更早的工具结果:用小阈值 oldToolResultMaxChars 大力收紧——旧命令输出模型已读过、结论已在历史里, - * 没必要每轮全量重发。这样 token 不随轮数线性膨胀。 - * 判定按"距末尾的距离"而非绝对下标:一旦某条进入"旧区",后续轮次它只会更旧, - * 截断结果跨轮稳定 → 不破坏 GLM 上下文缓存前缀。 - */ - public List truncateToolResponses(List messages) { - List result = new ArrayList<>(messages.size()); - int size = messages.size(); - for (int i = 0; i < size; i++) { - Message msg = messages.get(i); - if (msg instanceof ToolResponseMessage trm) { - // 距末尾 keepRecentMessages 条以内算"近",用大阈值;否则算"旧",用小阈值 - boolean recent = (size - i) <= keepRecentMessages; - int limit = recent ? toolResultMaxChars : oldToolResultMaxChars; - result.add(truncateOne(trm, limit)); - } else { - result.add(msg); - } - } - return result; - } - - /** 重建一条 ToolResponseMessage:把每个超长的 responseData 截掉中段 */ - private ToolResponseMessage truncateOne(ToolResponseMessage trm, int limit) { - List truncated = new ArrayList<>(); - for (ToolResponseMessage.ToolResponse resp : trm.getResponses()) { - String data = resp.responseData(); - truncated.add(new ToolResponseMessage.ToolResponse( - resp.id(), resp.name(), truncateText(data, limit))); - } - return ToolResponseMessage.builder().responses(truncated).build(); - } - - /** 截断提示里的哨兵串:已含此串说明截过了,幂等跳过,避免二次截断 */ - private static final String TRUNCATE_MARKER = "完整结果见 t_message"; - - /** 截掉中段,保留头 60% / 尾 40%,中间塞一行提示(指向 t_message 取完整内容) */ - private String truncateText(String text, int limit) { - if (text == null || text.length() <= limit) { - return text; - } - // 幂等:已经截过的(含哨兵)不再处理,否则头尾+提示可能仍超阈值被反复截。 - // 注意:旧区阈值比近区小,一条"近区截断版"挪到旧区后因含哨兵会保持原样, - // 不会再按小阈值二次收紧——可接受:避免反复改写历史、保住缓存比再省几百字符更划算。 - if (text.contains(TRUNCATE_MARKER)) { - return text; - } - int headLen = (int) (limit * 0.6); - int tailLen = limit - headLen; - int cut = text.length() - headLen - tailLen; - String head = text.substring(0, headLen); - String tail = text.substring(text.length() - tailLen); - return head - + "\n...[已截断 " + cut + " 字符," + TRUNCATE_MARKER + "]...\n" - + tail; - } - - // ============================ Layer 4:历史压缩 ============================ - - /** - * 估算上下文超阈值时压缩历史,否则原样返回。 - * - * 切割策略:messages[0] 是 system,固定保留;尾部保留 keepRecentMessages 条原文; - * 中间较早的对话渲染成纯文本丢给 LLM 摘要,压成一条 UserMessage 插在 system 之后。 - * - * 成对约束:保留区开头不能是 ToolResponseMessage(否则它的 tool_call 落在摘要区被切走, - * 模型见到孤儿 tool_result 会报错)。切割点往前移到对应 assistant,让两者一起进保留区。 - */ - public List compressIfNeeded(List messages) { - // 熔断:摘要 LLM 连续挂了就别再试,裸跑兜底 - if (consecutiveFailures.get() >= circuitLimit) { - return messages; - } - if (estimateTokens(messages) <= maxContextTokens) { - return messages; - } - // 太短没什么可压(至少要有 system + 摘要区 + 保留区) - if (messages.size() <= keepRecentMessages + 1) { - return messages; - } - - int cutIndex = messages.size() - keepRecentMessages; - // 把切割点往前挪,避免保留区以孤儿 tool_result 开头 - while (cutIndex > 1 && messages.get(cutIndex) instanceof ToolResponseMessage) { - cutIndex--; - } - // 挪到头了说明摘要区为空,没东西可压 - if (cutIndex <= 1) { - return messages; - } - - List summaryRegion = messages.subList(1, cutIndex); - String summary = summarize(summaryRegion); - if (summary == null) { - // 摘要失败:计数 +1,本轮放弃压缩,原样返回 - int fails = consecutiveFailures.incrementAndGet(); - log.warn("历史摘要失败(连续 {} 次),本轮跳过压缩", fails); - return messages; - } - consecutiveFailures.set(0); // 成功清零 - - List compressed = new ArrayList<>(); - compressed.add(messages.get(0)); // system - compressed.add(new UserMessage("以下是早先对话的摘要,供你继续任务时参考:\n" + summary)); - compressed.addAll(messages.subList(cutIndex, messages.size())); // 最近 K 条原文 - - log.info("上下文压缩:{} 条 -> {} 条(摘要了 {} 条)", - messages.size(), compressed.size(), summaryRegion.size()); - return compressed; - } - - /** 调摘要 LLM 把一段历史压成结论文本;失败返回 null(由调用方走熔断逻辑) */ - private String summarize(List region) { - try { - String rendered = renderRegion(region); - // 摘要请求不带任何工具,纯文本进纯文本出,避免又触发 tool_call - List prompt = List.of( - new SystemMessage(SUMMARY_PROMPT), - new UserMessage(rendered)); - ChatResponse resp = chatModel.call(new Prompt(prompt)); - if (resp == null || resp.getResult() == null) { - return null; - } - String text = resp.getResult().getOutput().getText(); - return (text == null || text.isBlank()) ? null : text; - } catch (Exception e) { - log.warn("摘要 LLM 调用异常: {}", e.getMessage()); - return null; - } - } - - private static final String SUMMARY_PROMPT = """ - 你是上下文压缩器。下面是一段 AI 运维助手与目标服务器之间的历史对话(含用户任务、助手发起的命令调用、命令执行结果)。 - 请把它压缩成简洁的中文摘要,必须保留以下信息,丢弃冗长的原始命令输出(只留结论): - 1. 用户的原始运维目标; - 2. 已执行过的关键命令及其结果结论(例如磁盘占用多少、进程是否存活、配置是否正确); - 3. 已发现的问题或系统状态; - 4. 被安全门禁拦截的危险操作(如果有)。 - 只输出摘要正文,不要解释你在做什么。 - """; - - /** 把一段消息渲染成纯文本喂给摘要 LLM(按角色标注,工具调用/结果也转成可读文本) */ - private String renderRegion(List region) { - StringBuilder sb = new StringBuilder(); - for (Message msg : region) { - if (msg instanceof UserMessage um) { - sb.append("用户: ").append(um.getText()).append("\n"); - } else if (msg instanceof AssistantMessage am) { - if (am.getText() != null && !am.getText().isBlank()) { - sb.append("助手: ").append(am.getText()).append("\n"); - } - if (am.getToolCalls() != null) { - for (AssistantMessage.ToolCall call : am.getToolCalls()) { - sb.append("助手调用工具 ").append(call.name()) - .append(": ").append(call.arguments()).append("\n"); - } - } - } else if (msg instanceof ToolResponseMessage trm) { - for (ToolResponseMessage.ToolResponse resp : trm.getResponses()) { - sb.append("工具[").append(resp.name()).append("]结果: ") - .append(resp.responseData()).append("\n"); - } - } - } - return sb.toString(); - } - - // ============================ 工具方法 ============================ - - /** 估算整段消息的 token 数(字符数粗估,不引 tokenizer) */ - public int estimateTokens(List messages) { - long chars = 0; - for (Message msg : messages) { - chars += messageChars(msg); - } - return (int) (chars / CHARS_PER_TOKEN); - } - - /** 单条消息的字符量:取其文本 + 工具调用参数 + 工具结果内容 */ - private long messageChars(Message msg) { - if (msg instanceof ToolResponseMessage trm) { - long n = 0; - for (ToolResponseMessage.ToolResponse resp : trm.getResponses()) { - String d = resp.responseData(); - n += d == null ? 0 : d.length(); - } - return n; - } - if (msg instanceof AssistantMessage am) { - long n = am.getText() == null ? 0 : am.getText().length(); - if (am.getToolCalls() != null) { - for (AssistantMessage.ToolCall call : am.getToolCalls()) { - n += call.arguments() == null ? 0 : call.arguments().length(); - } - } - return n; - } - String t = msg.getText(); - return t == null ? 0 : t.length(); - } -} diff --git a/src/main/java/com/lowenssh/agent/HostController.java b/src/main/java/com/lowenssh/agent/HostController.java deleted file mode 100644 index b1331da..0000000 --- a/src/main/java/com/lowenssh/agent/HostController.java +++ /dev/null @@ -1,110 +0,0 @@ -package com.lowenssh.agent; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.lowenssh.persistence.entity.HostEntity; -import com.lowenssh.persistence.mapper.HostMapper; -import com.lowenssh.ssh.SshClient; -import com.lowenssh.util.CryptoUtil; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; - -import java.util.List; - -/** - * 主机簿接口 —— 管理常用服务器 + 进入主机时建立连接。 - * - * - GET /api/hosts 列出主机(不回传密码) - * - POST /api/hosts 新增主机(密码 AES 加密落库) - * - DELETE /api/hosts/{id} 删除主机 - * - POST /api/hosts/{id}/connect 进入主机:解密密码 → 建/复用常驻连接 → 回 sessionId - * - * 密码只在 connect 时解密用一次去连 SSH,不回传前端、不落明文。 - */ -@RestController -public class HostController { - - private final HostMapper hostMapper; - private final SessionManager sessionManager; - private final CryptoUtil crypto; - - public HostController(HostMapper hostMapper, SessionManager sessionManager, CryptoUtil crypto) { - this.hostMapper = hostMapper; - this.sessionManager = sessionManager; - this.crypto = crypto; - } - - /** 列主机,按更新时间倒序。hasPassword 标记是否存了密码(迁移出的老主机没有,前端提示补填)。 */ - @GetMapping("/api/hosts") - public List list() { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .orderByDesc(HostEntity::getUpdatedAt) - .orderByDesc(HostEntity::getId); - return hostMapper.selectList(wrapper).stream() - .map(h -> new HostDto.HostItem( - h.getId(), h.getAlias(), h.getSshHost(), h.getSshPort(), h.getSshUser(), - h.getPasswordEnc() != null && !h.getPasswordEnc().isBlank())) - .toList(); - } - - /** 新增主机:密码加密存。返回新主机 id。 */ - @PostMapping("/api/hosts") - public HostDto.HostItem create(@RequestBody HostDto.CreateRequest req) { - HostEntity h = new HostEntity(); - h.setAlias(req.alias()); - h.setSshHost(req.host()); - h.setSshPort(req.port() == null || req.port() == 0 ? 22 : req.port()); - h.setSshUser(req.user()); - h.setPasswordEnc(crypto.encrypt(req.password())); // 明文不落库 - hostMapper.insert(h); - return new HostDto.HostItem(h.getId(), h.getAlias(), h.getSshHost(), h.getSshPort(), - h.getSshUser(), h.getPasswordEnc() != null); - } - - /** 删除主机(历史会话仍在库里,只是从主机簿移除入口) */ - @DeleteMapping("/api/hosts/{id}") - public ResponseEntity delete(@PathVariable("id") Long id) { - hostMapper.deleteById(id); - return ResponseEntity.noContent().build(); - } - - /** - * 进入主机:解密密码连一次 SSH(或复用该主机已活预连接),只建连不落库。 - * 会话行延迟到首条任务才建(lazy create),所以这里返回的 sessionId 恒为 null。 - * 没存密码(迁移出的老主机)则要求前端带 password 进来补连。 - */ - @PostMapping("/api/hosts/{id}/connect") - public ResponseEntity connect(@PathVariable("id") Long id, - @RequestBody(required = false) HostDto.ConnectRequest req) { - HostEntity h = hostMapper.selectById(id); - if (h == null) { - return ResponseEntity.status(404).body(new HostDto.ConnectResult(null, "主机不存在")); - } - // 优先用库里存的密码;没存则用前端补填的 - String password; - try { - String stored = crypto.decrypt(h.getPasswordEnc()); - password = (stored != null && !stored.isBlank()) - ? stored - : (req == null ? null : req.password()); - } catch (Exception e) { - return ResponseEntity.status(500).body(new HostDto.ConnectResult(null, "密码解密失败,请重新保存主机密码")); - } - if (password == null || password.isBlank()) { - return ResponseEntity.status(400).body(new HostDto.ConnectResult(null, "该主机未保存密码,请补填密码后连接")); - } - - try { - sessionManager.connectHost( - id, h.getSshHost(), h.getSshPort() == null ? 22 : h.getSshPort(), h.getSshUser(), password); - // 只建连不落库,sessionId 留给首条任务时 attach;这里回 null 表示「连上了,等首条任务」 - return ResponseEntity.ok(new HostDto.ConnectResult(null, null)); - } catch (Exception e) { - return ResponseEntity.status(502).body(new HostDto.ConnectResult(null, "SSH 连接失败: " + e.getMessage())); - } - } -} diff --git a/src/main/java/com/lowenssh/agent/HostDto.java b/src/main/java/com/lowenssh/agent/HostDto.java deleted file mode 100644 index ec05995..0000000 --- a/src/main/java/com/lowenssh/agent/HostDto.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.lowenssh.agent; - -/** - * 主机簿相关 DTO。密码绝不出现在响应里(只在 connect 入参里临时补填)。 - */ -public final class HostDto { - - private HostDto() { - } - - /** 主机列表项 / 新增响应。hasPassword 表示库里是否已存密码(false 则连接时需补填)。 */ - public record HostItem(Long id, String alias, String host, Integer port, String user, boolean hasPassword) { - } - - /** 新增主机请求 */ - public record CreateRequest(String alias, String host, Integer port, String user, String password) { - } - - /** 进入主机连接请求:库里没存密码时带上明文补连,否则可不传 */ - public record ConnectRequest(String password) { - } - - /** 连接结果:成功带 sessionId,失败带 error */ - public record ConnectResult(Long sessionId, String error) { - } -} diff --git a/src/main/java/com/lowenssh/agent/HostMetrics.java b/src/main/java/com/lowenssh/agent/HostMetrics.java deleted file mode 100644 index a7c2ef7..0000000 --- a/src/main/java/com/lowenssh/agent/HostMetrics.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.lowenssh.agent; - -/** - * 远程主机监控指标快照(一次采集的结果)。 - * 字段都是采集瞬间的值,CPU 使用率由两次 /proc/stat 采样差值算得。 - */ -public record HostMetrics( - double cpuPercent, // CPU 使用率 %(0~100) - int cpuCores, // 核数 - double load1, // 1 分钟负载 - double load5, // 5 分钟负载 - double load15, // 15 分钟负载 - long memTotalKb, // 内存总量 KB - long memUsedKb, // 已用内存 KB(total - available) - double memPercent, // 内存使用率 % - long diskTotalKb, // 根分区总量 KB - long diskUsedKb, // 根分区已用 KB - double diskPercent, // 根分区使用率 % - long uptimeSec // 开机时长(秒) -) {} diff --git a/src/main/java/com/lowenssh/agent/MetricsCollector.java b/src/main/java/com/lowenssh/agent/MetricsCollector.java deleted file mode 100644 index acd7d28..0000000 --- a/src/main/java/com/lowenssh/agent/MetricsCollector.java +++ /dev/null @@ -1,170 +0,0 @@ -package com.lowenssh.agent; - -import com.lowenssh.ssh.ExecResult; -import com.lowenssh.ssh.SshClient; - -/** - * 主机指标采集器:用一条复合 shell 命令把 CPU/内存/磁盘/负载/uptime 的原始数据一次取回, - * 在 Java 端解析,避免多次 SSH 往返。 - * - * CPU 使用率需要两次 /proc/stat 采样求差:命令里 sleep 0.3 取前后两行, - * 解析时算 (busyΔ / totalΔ) * 100。 - * - * 输出用标记行分隔,逐段解析,避免被 locale / 多余空白干扰。 - */ -public final class MetricsCollector { - - private MetricsCollector() {} - - // 一条命令取全部原始指标。各段用 ===TAG=== 包起来,Java 端按标记切分。 - private static final String CMD = String.join(" ; ", - "echo ===CPU1===", "cat /proc/stat | grep '^cpu '", - "sleep 0.3", - "echo ===CPU2===", "cat /proc/stat | grep '^cpu '", - "echo ===CORES===", "nproc", - "echo ===LOAD===", "cat /proc/loadavg", - "echo ===MEM===", "cat /proc/meminfo | grep -E '^(MemTotal|MemAvailable):'", - "echo ===DISK===", "df -k / | tail -1", - "echo ===UPTIME===", "cat /proc/uptime" - ); - - /** 在已加锁的 SshClient 上采集一次。调用方负责 lock。 */ - public static HostMetrics collect(SshClient ssh) throws Exception { - ExecResult r = ssh.exec(CMD); - if (!r.isSuccess()) { - throw new IllegalStateException("采集失败 exit=" + r.exitCode() + " " + r.stderr()); - } - return parse(r.stdout()); - } - - // —— 解析 —— - static HostMetrics parse(String out) { - String[] lines = out.split("\n"); - // 先把各标记段的内容收集起来 - String cpu1 = null, cpu2 = null, cores = null, load = null, disk = null, uptime = null; - String memTotal = null, memAvail = null; - String tag = ""; - for (String raw : lines) { - String line = raw.trim(); - if (line.startsWith("===") && line.endsWith("===")) { - tag = line; - continue; - } - if (line.isEmpty()) continue; - switch (tag) { - case "===CPU1===" -> cpu1 = line; - case "===CPU2===" -> cpu2 = line; - case "===CORES===" -> cores = line; - case "===LOAD===" -> load = line; - case "===MEM===" -> { - if (line.startsWith("MemTotal")) memTotal = line; - else if (line.startsWith("MemAvailable")) memAvail = line; - } - case "===DISK===" -> disk = line; - case "===UPTIME===" -> uptime = line; - default -> { /* 忽略 */ } - } - } - - double cpuPercent = parseCpu(cpu1, cpu2); - int cpuCores = parseInt(cores, 1); - - double[] loads = parseLoad(load); - - long memTotalKb = parseMemKb(memTotal); - long memAvailKb = parseMemKb(memAvail); - long memUsedKb = Math.max(0, memTotalKb - memAvailKb); - double memPercent = memTotalKb > 0 ? memUsedKb * 100.0 / memTotalKb : 0; - - long[] diskKb = parseDisk(disk); // [total, used] - double diskPercent = diskKb[0] > 0 ? diskKb[1] * 100.0 / diskKb[0] : 0; - - long uptimeSec = parseUptime(uptime); - - return new HostMetrics( - round1(cpuPercent), cpuCores, - loads[0], loads[1], loads[2], - memTotalKb, memUsedKb, round1(memPercent), - diskKb[0], diskKb[1], round1(diskPercent), - uptimeSec - ); - } - - // /proc/stat 行:cpu user nice system idle iowait irq softirq steal guest guest_nice - // 使用率 = (totalΔ - idleΔ) / totalΔ * 100,idle = idle + iowait - private static double parseCpu(String l1, String l2) { - if (l1 == null || l2 == null) return 0; - long[] a = cpuFields(l1); - long[] b = cpuFields(l2); - if (a == null || b == null) return 0; - long idleA = a[3] + (a.length > 4 ? a[4] : 0); - long idleB = b[3] + (b.length > 4 ? b[4] : 0); - long totalA = sum(a), totalB = sum(b); - long totalD = totalB - totalA, idleD = idleB - idleA; - if (totalD <= 0) return 0; - double pct = (totalD - idleD) * 100.0 / totalD; - return clamp(pct); - } - - private static long[] cpuFields(String line) { - // 去掉开头的 "cpu" 标签 - String[] p = line.split("\\s+"); - if (p.length < 5) return null; - long[] v = new long[p.length - 1]; - for (int i = 1; i < p.length; i++) { - v[i - 1] = parseLong(p[i], 0); - } - return v; - } - - // /proc/loadavg: "0.00 0.01 0.05 1/123 4567" - private static double[] parseLoad(String line) { - double[] d = {0, 0, 0}; - if (line == null) return d; - String[] p = line.split("\\s+"); - for (int i = 0; i < 3 && i < p.length; i++) d[i] = parseDouble(p[i], 0); - return d; - } - - // "MemTotal: 16331756 kB" - private static long parseMemKb(String line) { - if (line == null) return 0; - String[] p = line.split("\\s+"); - if (p.length < 2) return 0; - return parseLong(p[1], 0); - } - - // df -k / 末行: "/dev/vda1 41152736 8765432 30293560 23% /" - private static long[] parseDisk(String line) { - long[] r = {0, 0}; - if (line == null) return r; - String[] p = line.split("\\s+"); - if (p.length >= 4) { - r[0] = parseLong(p[1], 0); // total - r[1] = parseLong(p[2], 0); // used - } - return r; - } - - // /proc/uptime: "350735.47 234388.90",取第一个 - private static long parseUptime(String line) { - if (line == null) return 0; - String[] p = line.split("\\s+"); - return (long) parseDouble(p[0], 0); - } - - // —— 小工具 —— - private static long sum(long[] a) { long s = 0; for (long x : a) s += x; return s; } - private static double clamp(double v) { return v < 0 ? 0 : (v > 100 ? 100 : v); } - private static double round1(double v) { return Math.round(v * 10) / 10.0; } - - private static int parseInt(String s, int def) { - try { return Integer.parseInt(s.trim()); } catch (Exception e) { return def; } - } - private static long parseLong(String s, long def) { - try { return Long.parseLong(s.trim()); } catch (Exception e) { return def; } - } - private static double parseDouble(String s, double def) { - try { return Double.parseDouble(s.trim()); } catch (Exception e) { return def; } - } -} diff --git a/src/main/java/com/lowenssh/agent/MonitorController.java b/src/main/java/com/lowenssh/agent/MonitorController.java deleted file mode 100644 index bd436f8..0000000 --- a/src/main/java/com/lowenssh/agent/MonitorController.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.lowenssh.agent; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RestController; - -import java.util.Map; - -/** - * 远程主机监控接口(给人用):前端轮询拉一次快照,自己在内存里攒历史画趋势。 - * - * - GET /api/monitor/{hostId}/metrics 采集一次 CPU/内存/磁盘/负载/uptime - * - * 复用主机常驻连接,lock 串行化,避免和 SFTP/Agent 抢同一 Session。 - */ -@RestController -public class MonitorController { - - private final SessionManager sessionManager; - - public MonitorController(SessionManager sessionManager) { - this.sessionManager = sessionManager; - } - - @GetMapping("/api/monitor/{hostId}/metrics") - public ResponseEntity metrics(@PathVariable("hostId") Long hostId) { - SessionManager.LiveSession ls = sessionManager.getByHost(hostId); - if (ls == null) { - return ResponseEntity.status(409).body(Map.of("error", "该主机未连接,请先从主机簿进入")); - } - ls.lock().lock(); - try { - HostMetrics m = MetricsCollector.collect(ls.ssh()); - return ResponseEntity.ok(m); - } catch (Exception e) { - return ResponseEntity.status(500).body(Map.of("error", "采集失败: " + e.getMessage())); - } finally { - ls.lock().unlock(); - } - } -} diff --git a/src/main/java/com/lowenssh/agent/SessionDto.java b/src/main/java/com/lowenssh/agent/SessionDto.java deleted file mode 100644 index 4bd7934..0000000 --- a/src/main/java/com/lowenssh/agent/SessionDto.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.lowenssh.agent; - -import java.util.List; - -/** - * 左侧历史栏用的只读 DTO 集合。 - * 这些接口只查库 + 查常驻连接状态,不碰 SSH 执行,给前端会话列表/历史回看用。 - */ -public final class SessionDto { - - private SessionDto() { - } - - /** 会话列表项:左栏每一行 */ - public record SessionItem(Long id, String title, String host, String user, Integer port, String updatedAt) { - } - - /** 单条历史消息:转成前端能直接渲染的格式 */ - public record HistoryMessage(String type, String text, String name, String summary) { - } - - /** - * 点开某会话的完整回看数据:连接信息 + 历史消息 + 常驻连接是否还活着。 - * live=true 表示能直接续聊(复用常驻连接);false 则前端提示需重连。 - */ - public record SessionDetail(Long id, String host, Integer port, String user, - boolean live, List messages) { - } -} diff --git a/src/main/java/com/lowenssh/agent/SessionManager.java b/src/main/java/com/lowenssh/agent/SessionManager.java deleted file mode 100644 index 4d9b343..0000000 --- a/src/main/java/com/lowenssh/agent/SessionManager.java +++ /dev/null @@ -1,236 +0,0 @@ -package com.lowenssh.agent; - -import com.lowenssh.persistence.entity.SessionEntity; -import com.lowenssh.persistence.mapper.SessionMapper; -import com.lowenssh.ssh.SshClient; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.time.Instant; -import java.util.Iterator; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.locks.ReentrantLock; - -/** - * 会话管理器 —— 支撑多轮对话的「连接常驻」: - * 进主机时连一次 SSH,把连接挂成「预连接」常驻,首条任务到来才落库建会话行(lazy create), - * 后续轮复用同一连接(保留 cd 等上下文),会话结束或超时无活动才关闭。 - * - * 为什么 lazy create:进主机就建会话行会堆出一堆没消息、没标题的空会话。改成 - * 「进主机只连不落库,首条任务才落库(title=任务)」,标题和消息天然落在同一个 sessionId 上。 - * - * 两张表: - * byHost —— 进主机建的预连接(hostId→连接),还没发首条任务,sessionId 仍为 null。 - * bySession —— 首条任务 attach 后的正式会话(sessionId→连接),续聊按它查。 - * 一条预连接首条任务后从 byHost 移出、登记进 bySession,不会同时在两张表里。 - * - * 并发:SshClient 非线程安全。每个 LiveSession 自带一把锁,同一会话的多个请求串行执行。 - * 连接泄漏防护:@Scheduled 定时扫两张表,关掉超时无活动的连接。 - */ -@Component -public class SessionManager { - - private static final Logger log = LoggerFactory.getLogger(SessionManager.class); - - private final SessionMapper sessionMapper; - - /** 会话空闲超时(分钟):超过这么久没活动的连接会被定时任务回收 */ - private final long idleTimeoutMinutes; - - /** hostId -> 进主机时建的预连接(已连 SSH、未发首条任务)。首条任务 attach 后移出。 */ - private final Map byHost = new ConcurrentHashMap<>(); - - /** sessionId -> 已绑定会话的活连接,续聊按它查。 */ - private final Map bySession = new ConcurrentHashMap<>(); - - public SessionManager(SessionMapper sessionMapper, - @Value("${xwssh.agent.session-idle-timeout-minutes:30}") long idleTimeoutMinutes) { - this.sessionMapper = sessionMapper; - this.idleTimeoutMinutes = idleTimeoutMinutes; - } - - /** - * 一个活跃连接:SSH 连接 + 锁 + 最后活跃时间 + 建连时的连接信息(attach 落库要用)。 - * sessionId 未绑定会话前为 null(进主机已连,但还没发首条任务)。 - * lock 保证同一会话的请求串行(SshClient 非线程安全)。 - */ - public static class LiveSession { - volatile Long sessionId; // 首条任务 attach 后回填 - final Long hostId; // 所属主机,进主机按它复用预连接 - final String host; - final int port; - final String user; - final SshClient ssh; - final ReentrantLock lock = new ReentrantLock(); - volatile Instant lastActiveAt = Instant.now(); - - LiveSession(Long hostId, String host, int port, String user, SshClient ssh) { - this.hostId = hostId; - this.host = host; - this.port = port; - this.user = user; - this.ssh = ssh; - } - - public Long sessionId() { - return sessionId; - } - - public Long hostId() { - return hostId; - } - - public SshClient ssh() { - return ssh; - } - - public ReentrantLock lock() { - return lock; - } - - void touch() { - this.lastActiveAt = Instant.now(); - } - } - - /** - * 进主机:复用该主机的预连接(若仍连通),否则连一次 SSH 建预连接。不落库。 - * 真正的会话行延迟到首条任务 attachSession 时才插,避免堆空会话。 - * hostId 为 null(curl 直连调试)时不进 byHost,连完直接返回。 - * 连接失败抛异常,调用方转成 error 事件。 - */ - public LiveSession connectHost(Long hostId, String host, int port, String user, String password) throws Exception { - if (hostId != null) { - LiveSession existing = byHost.get(hostId); - if (existing != null && existing.ssh.isConnected()) { - existing.touch(); - return existing; // 复用该主机现有预连接 - } - } - SshClient ssh = new SshClient(); - try { - ssh.connect(host, port, user, password); - } catch (Exception e) { - ssh.close(); - throw e; - } - LiveSession live = new LiveSession(hostId, host, port, user, ssh); - if (hostId != null) { - byHost.put(hostId, live); - } - log.info("预连接已建立 hostId={} host={}", hostId, host); - return live; - } - - /** 取该主机进主机时建的预连接(尚未发首条任务);无或已断返回 null。 */ - public LiveSession getByHost(Long hostId) { - if (hostId == null) { - return null; - } - LiveSession live = byHost.get(hostId); - if (live != null && live.ssh.isConnected()) { - live.touch(); - return live; - } - return null; - } - - /** - * 首条任务:把预连接升级为正式会话 —— 落库拿 sessionId(title=首条任务), - * 回填到 live、移出 byHost、登记进 bySession。返回 sessionId。 - */ - public Long attachSession(LiveSession live, String task) { - SessionEntity session = new SessionEntity(); - session.setHostId(live.hostId); - session.setTitle(toTitle(task)); // 标题是会话名摘要,截断防超列长(title VARCHAR(255)) - session.setSshHost(live.host); - session.setSshPort(live.port); - session.setSshUser(live.user); - sessionMapper.insert(session); - Long sessionId = session.getId(); - - live.sessionId = sessionId; - if (live.hostId != null) { - byHost.remove(live.hostId, live); // 出预连接槽(仅当仍是当前预连接) - } - bySession.put(sessionId, live); - log.info("会话已绑定 sessionId={} hostId={} 当前活跃会话数={}", sessionId, live.hostId, bySession.size()); - return sessionId; - } - - /** 任务文本压成会话标题:取首行、超 40 字截断加省略号,远小于 title 列上限避免落库截断报错 */ - public static String toTitle(String task) { - if (task == null) return "新会话"; - String t = task.strip(); - int nl = t.indexOf('\n'); - if (nl >= 0) t = t.substring(0, nl).strip(); - if (t.isEmpty()) return "新会话"; - return t.length() > 40 ? t.substring(0, 40) + "…" : t; - } - - /** - * 续聊:取已绑定会话的常驻连接。 - * 返回 null 表示会话不存在或已过期(前端据此提示重新连接)。 - */ - public LiveSession get(Long sessionId) { - LiveSession live = bySession.get(sessionId); - if (live == null) { - return null; - } - // 连接可能已被对端断开,校验一下 - if (!live.ssh.isConnected()) { - log.warn("会话连接已断开 sessionId={},移除", sessionId); - close(sessionId); - return null; - } - live.touch(); - return live; - } - - /** 关闭并移除一个会话(显式结束 / 连接失效时调用) */ - public void close(Long sessionId) { - LiveSession live = bySession.remove(sessionId); - if (live != null) { - live.ssh.close(); - if (live.hostId != null) { - byHost.remove(live.hostId, live); - } - log.info("会话已关闭 sessionId={} 剩余活跃会话数={}", sessionId, bySession.size()); - } - } - - /** 定时回收超时无活动的连接(含从未发任务的预连接),防连接泄漏。每 5 分钟扫一次。 */ - @Scheduled(fixedDelay = 5 * 60 * 1000) - public void reapIdleSessions() { - Instant deadline = Instant.now().minus(Duration.ofMinutes(idleTimeoutMinutes)); - int reaped = reap(byHost, deadline) + reap(bySession, deadline); - if (reaped > 0) { - log.info("回收超时连接 {} 个,剩余活跃会话 {} 个", reaped, bySession.size()); - } - } - - /** 扫一张表,关掉超时或已断开的连接 */ - private int reap(Map map, Instant deadline) { - Iterator> it = map.entrySet().iterator(); - int reaped = 0; - while (it.hasNext()) { - LiveSession live = it.next().getValue(); - if (live.lastActiveAt.isBefore(deadline) || !live.ssh.isConnected()) { - live.ssh.close(); - it.remove(); - reaped++; - } - } - return reaped; - } - - /** 当前活跃会话数(监控/测试用) */ - public int activeCount() { - return bySession.size(); - } -} diff --git a/src/main/java/com/lowenssh/agent/SftpController.java b/src/main/java/com/lowenssh/agent/SftpController.java deleted file mode 100644 index 0e8b9a0..0000000 --- a/src/main/java/com/lowenssh/agent/SftpController.java +++ /dev/null @@ -1,153 +0,0 @@ -package com.lowenssh.agent; - -import com.lowenssh.ssh.RemoteFile; -import org.springframework.core.io.InputStreamResource; -import org.springframework.http.ContentDisposition; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.multipart.MultipartFile; - -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Map; - -/** - * SFTP 文件管理接口(给人用)—— 复用主机的常驻 SSH 连接开 sftp 通道,不重连。 - * - * - GET /api/sftp/{hostId}/list?path=/xxx 列目录 - * - POST /api/sftp/{hostId}/upload 上传(multipart:file + path) - * - GET /api/sftp/{hostId}/download?path=/xxx 下载(流式) - * - DELETE /api/sftp/{hostId}/file?path=/xxx 删除文件 - * - POST /api/sftp/{hostId}/mkdir 建目录(body: {path}) - * - * 关键:SFTP / Agent 命令 / 监控共用同一条 JSch Session,必须用 LiveSession.lock() - * 串行化,否则 channel 会串数据。每个接口都在 lock 内操作。 - */ -@RestController -public class SftpController { - - private final SessionManager sessionManager; - - public SftpController(SessionManager sessionManager) { - this.sessionManager = sessionManager; - } - - /** 取该主机的常驻连接,没连上返回 null */ - private SessionManager.LiveSession live(Long hostId) { - return sessionManager.getByHost(hostId); - } - - /** 列目录 */ - @GetMapping("/api/sftp/{hostId}/list") - public ResponseEntity list(@PathVariable("hostId") Long hostId, - @RequestParam(value = "path", defaultValue = "/") String path) { - SessionManager.LiveSession ls = live(hostId); - if (ls == null) return notConnected(); - ls.lock().lock(); - try { - List files = ls.ssh().listDir(path); - return ResponseEntity.ok(Map.of("path", path, "files", files)); - } catch (Exception e) { - return ResponseEntity.status(500).body(Map.of("error", "列目录失败: " + e.getMessage())); - } finally { - ls.lock().unlock(); - } - } - - /** 上传文件到指定目录 */ - @PostMapping("/api/sftp/{hostId}/upload") - public ResponseEntity upload(@PathVariable("hostId") Long hostId, - @RequestParam("file") MultipartFile file, - @RequestParam("path") String dir) { - SessionManager.LiveSession ls = live(hostId); - if (ls == null) return notConnected(); - String remote = (dir.endsWith("/") ? dir : dir + "/") + file.getOriginalFilename(); - ls.lock().lock(); - try { - ls.ssh().upload(file.getInputStream(), remote); - return ResponseEntity.ok(Map.of("path", remote)); - } catch (Exception e) { - return ResponseEntity.status(500).body(Map.of("error", "上传失败: " + e.getMessage())); - } finally { - ls.lock().unlock(); - } - } - - /** 下载文件。先在 lock 内读进内存再返回,避免流式期间长期占着 lock。 */ - @GetMapping("/api/sftp/{hostId}/download") - public ResponseEntity download(@PathVariable("hostId") Long hostId, - @RequestParam("path") String path) { - SessionManager.LiveSession ls = live(hostId); - if (ls == null) return notConnected(); - ByteArrayOutputStream buf = new ByteArrayOutputStream(); - ls.lock().lock(); - try { - ls.ssh().download(path, buf); - } catch (Exception e) { - return ResponseEntity.status(500).body(Map.of("error", "下载失败: " + e.getMessage())); - } finally { - ls.lock().unlock(); - } - String name = path.substring(path.lastIndexOf('/') + 1); - byte[] data = buf.toByteArray(); - HttpHeaders headers = new HttpHeaders(); - headers.setContentDisposition(ContentDisposition.attachment().filename(name).build()); - return ResponseEntity.ok() - .headers(headers) - .contentType(MediaType.APPLICATION_OCTET_STREAM) - .contentLength(data.length) - .body(new InputStreamResource(new ByteArrayInputStream(data))); - } - - /** 删除文件 */ - @DeleteMapping("/api/sftp/{hostId}/file") - public ResponseEntity delete(@PathVariable("hostId") Long hostId, - @RequestParam("path") String path) { - SessionManager.LiveSession ls = live(hostId); - if (ls == null) return notConnected(); - ls.lock().lock(); - try { - ls.ssh().deleteFile(path); - return ResponseEntity.noContent().build(); - } catch (Exception e) { - return ResponseEntity.status(500).body(Map.of("error", "删除失败: " + e.getMessage())); - } finally { - ls.lock().unlock(); - } - } - - /** 新建目录 */ - @PostMapping("/api/sftp/{hostId}/mkdir") - public ResponseEntity mkdir(@PathVariable("hostId") Long hostId, - @RequestBody Map body) { - String path = body.get("path"); - if (path == null || path.isBlank()) { - return ResponseEntity.status(400).body(Map.of("error", "path 不能为空")); - } - SessionManager.LiveSession ls = live(hostId); - if (ls == null) return notConnected(); - ls.lock().lock(); - try { - ls.ssh().mkdir(path); - return ResponseEntity.ok(Map.of("path", path)); - } catch (Exception e) { - return ResponseEntity.status(500).body(Map.of("error", "建目录失败: " + e.getMessage())); - } finally { - ls.lock().unlock(); - } - } - - private ResponseEntity notConnected() { - return ResponseEntity.status(409).body(Map.of("error", "该主机未连接,请先从主机簿进入")); - } -} diff --git a/src/main/java/com/lowenssh/agent/SshTools.java b/src/main/java/com/lowenssh/agent/SshTools.java deleted file mode 100644 index 1987ff8..0000000 --- a/src/main/java/com/lowenssh/agent/SshTools.java +++ /dev/null @@ -1,189 +0,0 @@ -package com.lowenssh.agent; - -import com.lowenssh.agent.guard.CommandGuard; -import com.lowenssh.ssh.ExecResult; -import com.lowenssh.ssh.RemoteFile; -import com.lowenssh.ssh.SshClient; -import com.lowenssh.persistence.AuditService; -import org.springframework.ai.tool.annotation.Tool; -import org.springframework.ai.tool.annotation.ToolParam; - -import java.util.List; -import java.util.concurrent.locks.Lock; - -/** - * Agent 的工具集 —— 会话级实例:一个 SshTools 绑定一台已连接的目标机。 - * - * 为什么不做成 @Service 单例:连接信息是会话级的("这次会话操作哪台机"), - * 单例工具没法持有"当前会话的连接"。所以每次发起一轮 agent 任务时 new 一个, - * 把连好的 SshClient + 本次会话 id + 审计/门禁注进来,loop 结束随会话释放。 - * - * 审计:execCommand 是真正下发命令的点,在这里记一笔 t_audit(执行点审计)。 - * 能到达这里的命令必经 AgentService.screen 放行,所以危险命令视为已确认。 - * - * SFTP 工具(listFiles/deleteFile/makeDir/moveFile)走 ChannelSftp。写操作映射成 - * 等价 shell 命令(rm/mkdir/mv)过同一个 CommandGuard:DENY 直接拒,复用现有规则, - * 审计可读。SFTP 与人工面板共用一条 Session,操作在 lock 内串行化(lock 可为 null, - * 同步测试场景独占连接无需锁)。 - */ -public class SshTools { - - private final SshClient ssh; - private final Long sessionId; - private final AuditService auditService; - private final CommandGuard guard; - private final Lock lock; // 与人工 SFTP/监控串行化,可为 null(独占连接时) - - public SshTools(SshClient ssh, Long sessionId, AuditService auditService, CommandGuard guard) { - this(ssh, sessionId, auditService, guard, null); - } - - public SshTools(SshClient ssh, Long sessionId, AuditService auditService, CommandGuard guard, Lock lock) { - this.ssh = ssh; - this.sessionId = sessionId; - this.auditService = auditService; - this.guard = guard; - this.lock = lock; - } - - @Tool(description = "在目标服务器上执行一条 shell 命令,返回标准输出、错误输出和退出码。用于查看系统状态、进程、磁盘等运维操作。") - public String execCommand( - @ToolParam(description = "要执行的 shell 命令,例如 'df -h' 或 'ps aux | grep java'") String command) { - // execCommand 是可写工具:审计要标注危险性。能执行到这说明已过门禁放行, - // 危险命令(非 ALLOW)视为已确认(confirmed=true)。 - boolean dangerous = guard.evaluate(command).decision() != CommandGuard.Decision.ALLOW; - return runAndAudit(command, dangerous, dangerous); - } - - @Tool(description = "读取目标服务器上指定路径的文本文件的完整内容。") - public String readRemoteFile( - @ToolParam(description = "远程文件的绝对路径,例如 '/etc/nginx/nginx.conf'") String path) { - // 只读工具:固定非危险、无需确认。单引号包裹防路径里的空格/特殊字符 - return runAndAudit("cat '" + path + "'", false, false); - } - - @Tool(description = "读取目标服务器上日志文件的末尾若干行,用于快速查看最新日志。") - public String tailLog( - @ToolParam(description = "日志文件的绝对路径,例如 '/var/log/nginx/error.log'") String path, - @ToolParam(description = "读取末尾的行数,例如 100") int lines) { - return runAndAudit("tail -n " + lines + " '" + path + "'", false, false); - } - - // —— SFTP 文件操作工具 —— - - @Tool(description = "列出目标服务器上指定目录的文件和子目录,返回每项的名称、是否目录、大小(字节)和权限。") - public String listFiles( - @ToolParam(description = "要列出的目录绝对路径,例如 '/var/log'") String path) { - return withLock(() -> { - try { - List files = ssh.listDir(path); - if (files.isEmpty()) return "(空目录)" + path; - StringBuilder sb = new StringBuilder("目录 ").append(path).append(" 共 ") - .append(files.size()).append(" 项:\n"); - for (RemoteFile f : files) { - sb.append(f.isDir() ? "[d] " : "[f] ").append(f.name()) - .append(" ").append(f.isDir() ? "-" : f.size() + "B") - .append(" ").append(f.perms()).append("\n"); - } - return sb.toString(); - } catch (Exception e) { - return "列目录失败: " + e.getMessage(); - } - }); - } - - @Tool(description = "删除目标服务器上的一个文件(不能删目录)。危险操作,会经过安全门禁审查。") - public String deleteFile( - @ToolParam(description = "要删除的文件绝对路径,例如 '/tmp/old.log'") String path) { - // 映射成等价 rm 命令过门禁,复用现有删除规则 - return sftpWrite("rm '" + path + "'", () -> { - ssh.deleteFile(path); - return "已删除文件: " + path; - }); - } - - @Tool(description = "在目标服务器上创建一个目录。会经过安全门禁审查。") - public String makeDir( - @ToolParam(description = "要创建的目录绝对路径,例如 '/opt/app/data'") String path) { - return sftpWrite("mkdir '" + path + "'", () -> { - ssh.mkdir(path); - return "已创建目录: " + path; - }); - } - - @Tool(description = "重命名或移动目标服务器上的文件/目录。会经过安全门禁审查。") - public String moveFile( - @ToolParam(description = "源路径绝对路径") String from, - @ToolParam(description = "目标路径绝对路径") String to) { - return sftpWrite("mv '" + from + "' '" + to + "'", () -> { - ssh.rename(from, to); - return "已移动: " + from + " -> " + to; - }); - } - - /** - * SFTP 写操作统一入口:先把等价 shell 命令过 CommandGuard,DENY 直接拒绝(复刻线上 - * AutoConfirmationHandler 语义:ASK 自动放行)。放行后在 lock 内执行 SFTP 动作并落审计。 - */ - private String sftpWrite(String equivCommand, SftpAction action) { - CommandGuard.Verdict verdict = guard.evaluate(equivCommand); - if (verdict.decision() == CommandGuard.Decision.DENY) { - auditService.logBlocked(sessionId, equivCommand, true, "DENY: " + verdict.reason()); - return "操作被安全门禁拒绝(" + verdict.reason() + ")。请改用更安全的方式或询问用户。"; - } - boolean dangerous = verdict.decision() != CommandGuard.Decision.ALLOW; - return withLock(() -> { - try { - String msg = action.run(); - // SFTP 无 shell exitCode,成功即 0、失败走 catch - auditService.logExecuted(sessionId, equivCommand, - new ExecResult(msg, "", 0), dangerous, dangerous); - return msg; - } catch (Exception e) { - auditService.logExecuted(sessionId, equivCommand, - new ExecResult("", e.getMessage(), 1), dangerous, dangerous); - return "操作失败: " + e.getMessage(); - } - }); - } - - /** 在 lock 内执行(lock 为 null 时直接执行),与人工 SFTP/监控串行化共用一条 Session */ - private String withLock(java.util.function.Supplier body) { - if (lock == null) return body.get(); - lock.lock(); - try { - return body.get(); - } finally { - lock.unlock(); - } - } - - /** SFTP 动作:可抛异常,返回成功描述 */ - @FunctionalInterface - private interface SftpAction { - String run() throws Exception; - } - - /** - * 执行命令 → 落审计 → 把三件套格式化成文本喂回模型。 - * 模型靠这段文本判断命令成败、决定下一步,所以 exitCode 和 stderr 都要明确带上。 - */ - private String runAndAudit(String command, boolean dangerous, boolean confirmed) { - try { - ExecResult r = ssh.exec(command); - auditService.logExecuted(sessionId, command, r, dangerous, confirmed); - StringBuilder sb = new StringBuilder(); - sb.append("exitCode=").append(r.exitCode()).append("\n"); - if (!r.stdout().isEmpty()) { - sb.append("stdout:\n").append(r.stdout()); - } - if (!r.stderr().isEmpty()) { - sb.append("stderr:\n").append(r.stderr()); - } - return sb.toString(); - } catch (Exception e) { - // 工具内部异常不能抛给 loop,要作为"工具结果"回灌,让模型知道这步失败了 - return "命令执行异常: " + e.getMessage(); - } - } -} diff --git a/src/main/java/com/lowenssh/agent/guard/AutoConfirmationHandler.java b/src/main/java/com/lowenssh/agent/guard/AutoConfirmationHandler.java deleted file mode 100644 index e3c2c8f..0000000 --- a/src/main/java/com/lowenssh/agent/guard/AutoConfirmationHandler.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.lowenssh.agent.guard; - -/** - * 自动确认实现 —— REST/自动化测试场景用:ask 态默认放行。 - * - * 注意:这不削弱安全。deny 态命令在门禁那层就被拦死了,根本到不了这里; - * 这里只处理 ask 态("可疑但不致命"),自动场景下选择放行以便自动化跑通。 - * 真要人盯着的高危场景用 ConsoleConfirmationHandler 走真人 y/n。 - */ -public class AutoConfirmationHandler implements ConfirmationHandler { - - @Override - public boolean confirm(String command, String reason) { - // 自动放行 ask 态命令(deny 已在门禁拦截) - return true; - } -} diff --git a/src/main/java/com/lowenssh/agent/guard/CommandGuard.java b/src/main/java/com/lowenssh/agent/guard/CommandGuard.java deleted file mode 100644 index 6a194cf..0000000 --- a/src/main/java/com/lowenssh/agent/guard/CommandGuard.java +++ /dev/null @@ -1,123 +0,0 @@ -package com.lowenssh.agent.guard; - -import org.springframework.stereotype.Component; - -import java.util.List; -import java.util.regex.Pattern; - -/** - * 命令门禁 —— deny / ask / allow 三态判定。Agent 安全的硬边界。 - * - * 设计原则(抄 Claude Code 并落地): - * 1. 安全检查是独立代码路径,不写进工具方法、不靠模型自觉。模型越狱也绕不过这层。 - * 2. 三态评估顺序固定:先查 deny(命中即拒,deny 永远赢)→ 再看是否需 ask → 默认 allow。 - * 3. 只看"实际要执行的命令",不看模型的话术,防花言巧语骗过门禁。 - * 4. 复合命令(&& | ; 串起来的)拆开逐段查,防"ls && rm -rf /"整条被当成一段漏过。 - * - * 判定结果是纯函数,无副作用,方便单测。 - */ -@Component -public class CommandGuard { - - /** 三态 */ - public enum Decision { DENY, ASK, ALLOW } - - /** 判定结果:状态 + 原因(原因用于回灌给模型 / 展示给用户) */ - public record Verdict(Decision decision, String reason) { - } - - /** - * deny 名单:不可逆的毁灭性操作,直接拒绝,不给确认机会。 - * 用正则匹配,\b 保证匹配的是独立命令词而非子串(如 dd 不误伤 add)。 - */ - private static final List DENY = List.of( - Pattern.compile("\\brm\\s+(-\\w*\\s+)*-\\w*[rf]"), // rm -rf / rm -fr 等带 r/f 组合 - Pattern.compile("\\bmkfs\\b"), // 格式化文件系统 - Pattern.compile("\\bdd\\b"), // 块设备读写,易毁盘 - Pattern.compile("\\bshutdown\\b"), // 关机 - Pattern.compile("\\breboot\\b"), // 重启 - Pattern.compile("\\bhalt\\b"), // 停机 - Pattern.compile(">\\s*/dev/sd"), // 直接写裸盘 - Pattern.compile(":\\(\\)\\s*\\{.*\\}"), // fork 炸弹 :(){ :|:& };: - Pattern.compile("\\bmv\\s+.*\\s+/dev/null"), // mv 到 /dev/null 销毁数据 - // 真机联调发现:find 是 rm -rf 的等价绕过——模型被拦 rm -rf 后改用 find 删 - Pattern.compile("\\bfind\\b.*-delete"), // find ... -delete 批量删除 - Pattern.compile("\\bfind\\b.*-exec\\s+rm") // find ... -exec rm 批量删除 - ); - - /** - * ask 名单:有副作用但未必致命,执行前问一句。 - */ - private static final List ASK = List.of( - Pattern.compile("\\brm\\b"), // 普通 rm(非 -rf,已被 deny 漏下来的) - Pattern.compile("\\bkill\\b"), // 杀进程 - Pattern.compile("\\bsystemctl\\s+(stop|restart|disable)"), // 停/重启/禁用服务 - Pattern.compile("\\bservice\\s+\\S+\\s+(stop|restart)"), - Pattern.compile("\\b(chmod|chown)\\b"), // 改权限/属主 - Pattern.compile("\\b(apt|apt-get|yum|dnf)\\s+(install|remove|purge)"), // 装/卸软件 - Pattern.compile("\\btruncate\\b"), // 清空文件 - Pattern.compile(">\\s*/") // 重定向覆盖写到绝对路径文件 - ); - - /** - * 判定一条命令。复合命令会被拆段,取最严结果(任一段 deny 则整条 deny)。 - */ - public Verdict evaluate(String command) { - if (command == null || command.isBlank()) { - return new Verdict(Decision.ALLOW, "空命令"); - } - - Decision worst = Decision.ALLOW; - String worstReason = ""; - - // 复合命令拆段:&& || | ; 都是命令分隔符 - for (String seg : splitSegments(command)) { - String s = seg.trim(); - if (s.isEmpty()) continue; - - Verdict v = evaluateSingle(s); - // 取最严:DENY > ASK > ALLOW(enum ordinal 越小越严) - if (v.decision().ordinal() < worst.ordinal()) { - worst = v.decision(); - worstReason = v.reason(); - } - // 已经最严了,提前结束 - if (worst == Decision.DENY) break; - } - - if (worst == Decision.ALLOW) { - return new Verdict(Decision.ALLOW, "只读/安全命令"); - } - return new Verdict(worst, worstReason); - } - - /** 单段命令判定:先 deny 再 ask 后 allow */ - private Verdict evaluateSingle(String seg) { - for (Pattern p : DENY) { - if (p.matcher(seg).find()) { - return new Verdict(Decision.DENY, "命中危险命令拦截规则: " + describe(p, seg)); - } - } - for (Pattern p : ASK) { - if (p.matcher(seg).find()) { - return new Verdict(Decision.ASK, "涉及有副作用的操作: " + describe(p, seg)); - } - } - return new Verdict(Decision.ALLOW, ""); - } - - /** 按命令分隔符拆段,分隔符本身丢弃 */ - private List splitSegments(String command) { - // 用正则一次切掉 && || | ; 以及换行 - return List.of(command.split("&&|\\|\\||[|;\\n]")); - } - - /** 给出命中片段,便于用户/模型理解为什么被拦 */ - private String describe(Pattern p, String seg) { - var m = p.matcher(seg); - if (m.find()) { - return "'" + m.group() + "'"; - } - return p.pattern(); - } -} diff --git a/src/main/java/com/lowenssh/agent/guard/ConfirmationHandler.java b/src/main/java/com/lowenssh/agent/guard/ConfirmationHandler.java deleted file mode 100644 index 74cf688..0000000 --- a/src/main/java/com/lowenssh/agent/guard/ConfirmationHandler.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.lowenssh.agent.guard; - -/** - * 人工确认抽象 —— ask 态命令在执行前问一句"干不干"。 - * - * 为什么抽象成接口:不同入口的确认方式不一样。控制台走 System.in 真敲 y/n; - * 将来 WebSocket 走前端弹窗。loop 只依赖这个接口,换入口不动核心逻辑。 - */ -public interface ConfirmationHandler { - - /** - * 请求用户确认是否执行某条命令。 - * - * @param command 待执行的完整命令 - * @param reason 为什么需要确认(门禁给出的原因,例如"涉及写操作 rm") - * @return true=批准执行,false=拒绝 - */ - boolean confirm(String command, String reason); -} diff --git a/src/main/java/com/lowenssh/agent/guard/ConsoleConfirmationHandler.java b/src/main/java/com/lowenssh/agent/guard/ConsoleConfirmationHandler.java deleted file mode 100644 index 95600a5..0000000 --- a/src/main/java/com/lowenssh/agent/guard/ConsoleConfirmationHandler.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.lowenssh.agent.guard; - -import java.util.Scanner; - -/** - * 控制台确认实现 —— 前台运行时走 System.in,真人敲 y/n。 - * - * 用于 CLI 交互入口(CommandLineRunner 模式)演示"执行前人工确认"这一刀。 - * Web 后台进程没有终端,别用这个,用 AutoConfirmationHandler。 - */ -public class ConsoleConfirmationHandler implements ConfirmationHandler { - - // System.in 全局只有一个,复用同一个 Scanner,别每次 new(会吃掉缓冲) - private final Scanner scanner = new Scanner(System.in); - - @Override - public boolean confirm(String command, String reason) { - System.out.println("\n⚠️ 需要确认:" + reason); - System.out.println(" 命令: " + command); - System.out.print(" 执行吗?(y/n): "); - String line = scanner.nextLine().trim().toLowerCase(); - return line.equals("y") || line.equals("yes"); - } -} diff --git a/src/main/java/com/lowenssh/persistence/AuditService.java b/src/main/java/com/lowenssh/persistence/AuditService.java deleted file mode 100644 index 6709660..0000000 --- a/src/main/java/com/lowenssh/persistence/AuditService.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.lowenssh.persistence; - -import com.lowenssh.persistence.entity.AuditEntity; -import com.lowenssh.persistence.mapper.AuditMapper; -import com.lowenssh.ssh.ExecResult; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -/** - * 审计服务 —— 每条命令落一笔 t_audit,可追溯。Agent 安全卖点的证据链。 - * - * 记两类: - * - 已执行:命令真落到服务器、带 stdout/stderr/exitCode({@link #logExecuted}) - * - 被拦截:deny 拒掉 / ask 被拒,没执行、无结果({@link #logBlocked}) - * - * 铁律:审计失败绝不能拖垮主任务。所有写库 try/catch 兜住,只记日志不抛。 - */ -@Service -public class AuditService { - - private static final Logger log = LoggerFactory.getLogger(AuditService.class); - - private final AuditMapper auditMapper; - - public AuditService(AuditMapper auditMapper) { - this.auditMapper = auditMapper; - } - - /** 记录一条已执行的命令(带执行结果) */ - public void logExecuted(Long sessionId, String command, ExecResult result, - boolean dangerous, boolean confirmed) { - AuditEntity e = new AuditEntity(); - e.setSessionId(sessionId); - e.setCommand(command); - e.setStdout(result.stdout()); - e.setStderr(result.stderr()); - e.setExitCode(result.exitCode()); - e.setDangerous(dangerous); - e.setConfirmed(confirmed); - save(e); - } - - /** 记录一条被门禁拦截 / 用户拒绝的命令(未执行,exitCode 留空,原因记进 stderr) */ - public void logBlocked(Long sessionId, String command, boolean dangerous, String reason) { - AuditEntity e = new AuditEntity(); - e.setSessionId(sessionId); - e.setCommand(command); - e.setStderr(reason); // 拦截原因借 stderr 字段存,便于审计查阅 - e.setDangerous(dangerous); - e.setConfirmed(false); // 被拦截 = 未经确认放行 - save(e); - } - - private void save(AuditEntity e) { - try { - auditMapper.insert(e); - } catch (Exception ex) { - // 审计写库失败不影响主流程,只告警 - log.warn("审计写库失败 command={}: {}", e.getCommand(), ex.getMessage()); - } - } -} diff --git a/src/main/java/com/lowenssh/persistence/MessageService.java b/src/main/java/com/lowenssh/persistence/MessageService.java deleted file mode 100644 index f8d7c56..0000000 --- a/src/main/java/com/lowenssh/persistence/MessageService.java +++ /dev/null @@ -1,252 +0,0 @@ -package com.lowenssh.persistence; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.lowenssh.persistence.entity.MessageEntity; -import com.lowenssh.persistence.mapper.MessageMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.ToolResponseMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.stereotype.Service; - -import java.util.ArrayList; -import java.util.List; - -/** - * 对话消息服务 —— 把 agentic loop 的每轮消息落 t_message,按 session_id 可还原完整对话。 - * - * 不落 system prompt:它每次固定,捞历史时代码里加回去即可,存了是冗余。 - * - * 铁律同 AuditService:落库失败绝不能拖垮主任务,写库 try/catch 兜住,只告警不抛。 - */ -@Service -public class MessageService { - - private static final Logger log = LoggerFactory.getLogger(MessageService.class); - - private final MessageMapper messageMapper; - private final ObjectMapper objectMapper = new ObjectMapper(); - - public MessageService(MessageMapper messageMapper) { - this.messageMapper = messageMapper; - } - - /** 落一条用户消息(loop 开始时的 task) */ - public void saveUser(Long sessionId, String content) { - MessageEntity e = new MessageEntity(); - e.setSessionId(sessionId); - e.setRole("user"); - e.setContent(content); - save(e); - } - - /** - * 落一条 assistant 消息。 - * @param content 模型的文字回复(可能为空,纯工具调用时) - * @param toolCalls 本轮发起的工具调用 JSON(无则传 null) - */ - public void saveAssistant(Long sessionId, String content, String toolCalls) { - MessageEntity e = new MessageEntity(); - e.setSessionId(sessionId); - e.setRole("assistant"); - e.setContent(content); - e.setToolCalls(toolCalls); - save(e); - } - - /** - * 落一条工具结果消息(含被门禁拒绝的"拒绝结果",这样历史能还原"模型想跑啥被拦了")。 - * @param toolCallId 对应的工具调用 id - * @param content 工具返回内容 / 拒绝原因 - */ - public void saveToolResult(Long sessionId, String toolCallId, String content) { - MessageEntity e = new MessageEntity(); - e.setSessionId(sessionId); - e.setRole("tool"); - e.setToolCallId(toolCallId); - e.setContent(content); - save(e); - } - - private void save(MessageEntity e) { - try { - messageMapper.insert(e); - } catch (Exception ex) { - // 落库失败不影响主流程,只告警 - log.warn("消息写库失败 sessionId={} role={}: {}", e.getSessionId(), e.getRole(), ex.getMessage()); - } - } - - /** - * 按 sessionId 还原历史对话为 Spring AI 的 Message 列表(供多轮续聊回灌给模型)。 - * - * 不含 system prompt(捞回去由 AgentService 自己加)。还原规则: - * - user -> UserMessage - * - assistant -> AssistantMessage(带 tool_calls 反序列化,id/name 必须和后续 tool 结果配对) - * - tool -> ToolResponseMessage;同一轮可能有多条 tool 行,连续的 tool 合并进一个 - * ToolResponseMessage,符合 OpenAI 协议「一个 assistant.tool_calls 对应一组 tool 结果」 - * - * 任一条解析失败只跳过该条,不拖垮续聊。 - */ - public List loadHistory(Long sessionId) { - List messages = new ArrayList<>(); - if (sessionId == null) { - return messages; - } - - List rows; - try { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(MessageEntity::getSessionId, sessionId) - .orderByAsc(MessageEntity::getId); // 按落库顺序还原 - rows = messageMapper.selectList(wrapper); - } catch (Exception ex) { - log.warn("加载历史失败 sessionId={}: {}", sessionId, ex.getMessage()); - return messages; - } - - // 连续的 tool 行要合并成一个 ToolResponseMessage,先攒着,遇到非 tool 行再 flush - List pendingTool = new ArrayList<>(); - - for (MessageEntity row : rows) { - String role = row.getRole(); - if ("tool".equals(role)) { - // 工具结果先攒进 pending,name 历史没单独存,回灌不影响模型理解,用占位即可 - pendingTool.add(new ToolResponseMessage.ToolResponse( - row.getToolCallId(), "", nullToEmpty(row.getContent()))); - continue; - } - - // 遇到非 tool 行,先把攒着的工具结果 flush 成一条 ToolResponseMessage - flushTool(messages, pendingTool); - - switch (role) { - case "user" -> messages.add(new UserMessage(nullToEmpty(row.getContent()))); - case "assistant" -> messages.add(toAssistant(row)); - default -> { /* system 等不还原 */ } - } - } - // 收尾 flush(历史以工具结果结尾的情况) - flushTool(messages, pendingTool); - - return messages; - } - - /** - * 按 sessionId 把历史转成前端可直接渲染的消息列表(左栏点开旧会话回看用)。 - * - * 与 loadHistory 不同:这里不是回灌给模型,而是给人看,所以: - * - user -> {type:user, text} - * - assistant -> 文字非空时产出 {type:assistant, text};带 tool_calls 时每个调用 - * 额外产出一条 {type:tool_call, name, summary=命令}(从 arguments 提取 command) - * - tool -> {type:tool_result, summary=结果内容} - * - * 任一条解析失败只跳过该条,不影响整体回看。 - */ - public List loadHistoryForView(Long sessionId) { - List out = new ArrayList<>(); - if (sessionId == null) { - return out; - } - - List rows; - try { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(MessageEntity::getSessionId, sessionId) - .orderByAsc(MessageEntity::getId); - rows = messageMapper.selectList(wrapper); - } catch (Exception ex) { - log.warn("回看历史失败 sessionId={}: {}", sessionId, ex.getMessage()); - return out; - } - - for (MessageEntity row : rows) { - String role = row.getRole(); - switch (role == null ? "" : role) { - case "user" -> out.add(new com.lowenssh.agent.SessionDto.HistoryMessage( - "user", nullToEmpty(row.getContent()), null, null)); - case "assistant" -> { - // 文字部分(纯工具调用时为空,不产出空气泡) - String text = nullToEmpty(row.getContent()); - if (!text.isBlank()) { - out.add(new com.lowenssh.agent.SessionDto.HistoryMessage( - "assistant", text, null, null)); - } - // 工具调用部分:每个 call 转一条 tool_call,展示命令 - appendToolCalls(out, row.getToolCalls()); - } - case "tool" -> out.add(new com.lowenssh.agent.SessionDto.HistoryMessage( - "tool_result", null, null, nullToEmpty(row.getContent()))); - default -> { /* system 等不回看 */ } - } - } - return out; - } - - /** 解析 assistant 的 tool_calls JSON,每个调用产出一条 tool_call 历史项(命令从 arguments 提取) */ - private void appendToolCalls(List out, String toolCallsJson) { - if (toolCallsJson == null || toolCallsJson.isBlank()) { - return; - } - try { - List calls = objectMapper.readValue( - toolCallsJson, new TypeReference>() {}); - for (AssistantMessage.ToolCall call : calls) { - out.add(new com.lowenssh.agent.SessionDto.HistoryMessage( - "tool_call", null, call.name(), extractCommand(call.arguments()))); - } - } catch (Exception ex) { - log.warn("回看解析 tool_calls 失败: {}", ex.getMessage()); - } - } - - /** 从工具参数 JSON 里取 command 字段;取不到就原样返回 */ - private String extractCommand(String arguments) { - if (arguments == null || arguments.isBlank()) { - return ""; - } - try { - var node = objectMapper.readTree(arguments); - if (node.has("command")) { - return node.get("command").asText(); - } - } catch (Exception ignored) { - // 解析失败原样返回 - } - return arguments; - } - - /** 把攒着的工具结果合并成一条 ToolResponseMessage 加入历史,并清空缓冲 */ - private void flushTool(List messages, List pending) { - if (!pending.isEmpty()) { - messages.add(ToolResponseMessage.builder().responses(new ArrayList<>(pending)).build()); - pending.clear(); - } - } - - /** 还原一条 assistant 消息:文字 + tool_calls(JSON 反序列化回 ToolCall 列表) */ - private AssistantMessage toAssistant(MessageEntity row) { - String content = nullToEmpty(row.getContent()); - String toolCallsJson = row.getToolCalls(); - if (toolCallsJson == null || toolCallsJson.isBlank()) { - return new AssistantMessage(content); - } - try { - List calls = objectMapper.readValue( - toolCallsJson, new TypeReference>() {}); - return AssistantMessage.builder().content(content).toolCalls(calls).build(); - } catch (Exception ex) { - // 反序列化失败:退化成纯文字 assistant,至少保住对话连续性 - log.warn("还原 tool_calls 失败,退化为纯文字 sessionId={}: {}", row.getSessionId(), ex.getMessage()); - return new AssistantMessage(content); - } - } - - private static String nullToEmpty(String s) { - return s == null ? "" : s; - } -} diff --git a/src/main/java/com/lowenssh/persistence/SchemaInitializer.java b/src/main/java/com/lowenssh/persistence/SchemaInitializer.java deleted file mode 100644 index 59a3837..0000000 --- a/src/main/java/com/lowenssh/persistence/SchemaInitializer.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.lowenssh.persistence; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; - -import javax.sql.DataSource; -import java.util.List; - -/** - * 启动时自动建表 + 轻量迁移 —— 让首次部署 / 升级无需手动跑 schema.sql。 - * - * 做两件事(都幂等,重复启动安全): - * 1. CREATE TABLE IF NOT EXISTS:建齐 t_host / t_session / t_message / t_audit。 - * 2. 给 t_session 补 host_id 列(老库没有),并把历史会话按 host/port/user 去重, - * 自动生成对应主机、回填 host_id —— 老对话不丢,能归到主机簿对应主机下。 - * - * 为什么不用 Flyway:项目刻意保持轻量(无额外依赖),迁移逻辑简单,手写幂等 SQL 够用。 - * 用 JdbcTemplate 直接执行 DDL;列是否存在查 information_schema,避开 MySQL - * 不支持「ADD COLUMN IF NOT EXISTS」的问题。 - */ -@Component -public class SchemaInitializer { - - private static final Logger log = LoggerFactory.getLogger(SchemaInitializer.class); - private final JdbcTemplate jdbc; - - public SchemaInitializer(DataSource dataSource) { - this.jdbc = new JdbcTemplate(dataSource); - } - - @jakarta.annotation.PostConstruct - public void init() { - createTables(); - migrateSessionHostId(); - } - - /** 建齐所有表(幂等) */ - private void createTables() { - jdbc.execute(""" - CREATE TABLE IF NOT EXISTS t_host ( - id BIGINT NOT NULL AUTO_INCREMENT, - alias VARCHAR(128) DEFAULT NULL, - ssh_host VARCHAR(128) NOT NULL, - ssh_port INT DEFAULT 22, - ssh_user VARCHAR(64) NOT NULL, - password_enc VARCHAR(512) DEFAULT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主机簿' - """); - jdbc.execute(""" - CREATE TABLE IF NOT EXISTS t_session ( - id BIGINT NOT NULL AUTO_INCREMENT, - host_id BIGINT DEFAULT NULL, - title VARCHAR(255) DEFAULT NULL, - ssh_host VARCHAR(128) DEFAULT NULL, - ssh_port INT DEFAULT 22, - ssh_user VARCHAR(64) DEFAULT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - PRIMARY KEY (id), - KEY idx_host (host_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话' - """); - jdbc.execute(""" - CREATE TABLE IF NOT EXISTS t_message ( - id BIGINT NOT NULL AUTO_INCREMENT, - session_id BIGINT NOT NULL, - role VARCHAR(16) NOT NULL, - content MEDIUMTEXT DEFAULT NULL, - tool_calls MEDIUMTEXT DEFAULT NULL, - tool_call_id VARCHAR(64) DEFAULT NULL, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - KEY idx_session (session_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话消息' - """); - jdbc.execute(""" - CREATE TABLE IF NOT EXISTS t_audit ( - id BIGINT NOT NULL AUTO_INCREMENT, - session_id BIGINT NOT NULL, - command TEXT NOT NULL, - stdout MEDIUMTEXT DEFAULT NULL, - stderr MEDIUMTEXT DEFAULT NULL, - exit_code INT DEFAULT NULL, - dangerous TINYINT NOT NULL DEFAULT 0, - confirmed TINYINT NOT NULL DEFAULT 0, - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (id), - KEY idx_session (session_id) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='命令执行审计' - """); - } - - /** - * 给 t_session 补 host_id 列并迁移老数据。 - * 仅当列不存在时执行加列 + 迁移,已迁移过的库再启动是空操作。 - */ - private void migrateSessionHostId() { - if (columnExists("t_session", "host_id")) { - return; // 新库建表时已带 host_id,或已迁移过,跳过 - } - log.info("检测到老版 t_session 无 host_id 列,开始迁移历史会话到主机簿…"); - jdbc.execute("ALTER TABLE t_session ADD COLUMN host_id BIGINT DEFAULT NULL AFTER id"); - jdbc.execute("ALTER TABLE t_session ADD KEY idx_host (host_id)"); - - // 把历史会话里出现过的 (host,port,user) 去重,每组生成一台主机 - List> groups = jdbc.queryForList(""" - SELECT ssh_host, ssh_port, ssh_user - FROM t_session - WHERE ssh_host IS NOT NULL - GROUP BY ssh_host, ssh_port, ssh_user - """); - int migrated = 0; - for (var g : groups) { - String host = (String) g.get("ssh_host"); - Integer port = g.get("ssh_port") == null ? 22 : ((Number) g.get("ssh_port")).intValue(); - String user = (String) g.get("ssh_user"); - // 老会话没存密码,迁移出的主机 password_enc 留空,首次连接时让用户补填 - jdbc.update("INSERT INTO t_host (alias, ssh_host, ssh_port, ssh_user) VALUES (?,?,?,?)", - null, host, port, user); - Long hostId = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); - jdbc.update(""" - UPDATE t_session SET host_id = ? - WHERE ssh_host = ? AND ssh_port = ? AND ssh_user = ? - """, hostId, host, port, user); - migrated++; - } - log.info("历史会话迁移完成,自动生成主机 {} 台", migrated); - } - - /** 查 information_schema 判断列是否存在 */ - private boolean columnExists(String table, String column) { - Integer cnt = jdbc.queryForObject(""" - SELECT COUNT(*) FROM information_schema.COLUMNS - WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ? - """, Integer.class, table, column); - return cnt != null && cnt > 0; - } -} diff --git a/src/main/java/com/lowenssh/persistence/entity/AuditEntity.java b/src/main/java/com/lowenssh/persistence/entity/AuditEntity.java deleted file mode 100644 index 1e390c3..0000000 --- a/src/main/java/com/lowenssh/persistence/entity/AuditEntity.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.lowenssh.persistence.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 命令执行审计实体 —— 对应 t_audit - * 每条实际下发到服务器的命令都落一笔,可追溯(危险命令、是否人工确认) - */ -@Data -@TableName("t_audit") -public class AuditEntity { - - @TableId(type = IdType.AUTO) - private Long id; - - private Long sessionId; - private String command; - private String stdout; - private String stderr; - private Integer exitCode; - private Boolean dangerous; // TINYINT 0/1 自动映射 Boolean - private Boolean confirmed; - private LocalDateTime createdAt; -} diff --git a/src/main/java/com/lowenssh/persistence/entity/HostEntity.java b/src/main/java/com/lowenssh/persistence/entity/HostEntity.java deleted file mode 100644 index d1857f8..0000000 --- a/src/main/java/com/lowenssh/persistence/entity/HostEntity.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.lowenssh.persistence.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 主机实体 —— 对应 t_host,主机簿里的一台常用服务器。 - * - * 与 t_session 的关系:一台主机下可有多个历史会话(session.host_id 外键关联), - * 进入某主机后只看该主机的会话历史(按主机隔离)。 - * - * passwordEnc 存的是 AES-GCM 密文(CryptoUtil 加密),绝不存明文; - * 对外 DTO 也不回传密码,只在 connect 时解密用一次。 - */ -@Data -@TableName("t_host") -public class HostEntity { - - @TableId(type = IdType.AUTO) - private Long id; - - private String alias; // 用户起的别名,如「京东云」,可空 - private String sshHost; // 驼峰自动映射下划线 ssh_host - private Integer sshPort; - private String sshUser; - private String passwordEnc; // AES-GCM 密文,对应 password_enc - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/lowenssh/persistence/entity/MessageEntity.java b/src/main/java/com/lowenssh/persistence/entity/MessageEntity.java deleted file mode 100644 index 06df6e3..0000000 --- a/src/main/java/com/lowenssh/persistence/entity/MessageEntity.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.lowenssh.persistence.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 对话消息实体 —— 对应 t_message - * agentic loop 的上下文就是按 session_id 捞出这张表的历史 - */ -@Data -@TableName("t_message") -public class MessageEntity { - - @TableId(type = IdType.AUTO) - private Long id; - - private Long sessionId; - private String role; // user / assistant / tool / system - private String content; - private String toolCalls; // assistant 发起工具调用时的 JSON - private String toolCallId; // role=tool 时对应的调用 id - private LocalDateTime createdAt; -} diff --git a/src/main/java/com/lowenssh/persistence/entity/SessionEntity.java b/src/main/java/com/lowenssh/persistence/entity/SessionEntity.java deleted file mode 100644 index 03eadc4..0000000 --- a/src/main/java/com/lowenssh/persistence/entity/SessionEntity.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.lowenssh.persistence.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * 会话实体 —— 对应 t_session - */ -@Data -@TableName("t_session") -public class SessionEntity { - - @TableId(type = IdType.AUTO) - private Long id; - - private Long hostId; // 所属主机(t_host.id),历史按主机隔离的关键 - private String title; - private String sshHost; // 驼峰自动映射下划线 ssh_host(MP 默认开启) - private Integer sshPort; - private String sshUser; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/src/main/java/com/lowenssh/persistence/mapper/AuditMapper.java b/src/main/java/com/lowenssh/persistence/mapper/AuditMapper.java deleted file mode 100644 index 73e8857..0000000 --- a/src/main/java/com/lowenssh/persistence/mapper/AuditMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.lowenssh.persistence.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.lowenssh.persistence.entity.AuditEntity; -import org.apache.ibatis.annotations.Mapper; - -/** - * 审计 Mapper - */ -@Mapper -public interface AuditMapper extends BaseMapper { -} diff --git a/src/main/java/com/lowenssh/persistence/mapper/HostMapper.java b/src/main/java/com/lowenssh/persistence/mapper/HostMapper.java deleted file mode 100644 index 2b21863..0000000 --- a/src/main/java/com/lowenssh/persistence/mapper/HostMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.lowenssh.persistence.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.lowenssh.persistence.entity.HostEntity; -import org.apache.ibatis.annotations.Mapper; - -/** - * 主机 Mapper —— 继承 BaseMapper 即得基础 CRUD,无需写 XML - */ -@Mapper -public interface HostMapper extends BaseMapper { -} diff --git a/src/main/java/com/lowenssh/persistence/mapper/MessageMapper.java b/src/main/java/com/lowenssh/persistence/mapper/MessageMapper.java deleted file mode 100644 index 4931c7d..0000000 --- a/src/main/java/com/lowenssh/persistence/mapper/MessageMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.lowenssh.persistence.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.lowenssh.persistence.entity.MessageEntity; -import org.apache.ibatis.annotations.Mapper; - -/** - * 消息 Mapper - */ -@Mapper -public interface MessageMapper extends BaseMapper { -} diff --git a/src/main/java/com/lowenssh/persistence/mapper/SessionMapper.java b/src/main/java/com/lowenssh/persistence/mapper/SessionMapper.java deleted file mode 100644 index b258ee6..0000000 --- a/src/main/java/com/lowenssh/persistence/mapper/SessionMapper.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.lowenssh.persistence.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.lowenssh.persistence.entity.SessionEntity; -import org.apache.ibatis.annotations.Mapper; - -/** - * 会话 Mapper —— 继承 BaseMapper 即得基础 CRUD,无需写 XML - */ -@Mapper -public interface SessionMapper extends BaseMapper { -} diff --git a/src/main/java/com/lowenssh/ssh/ExecResult.java b/src/main/java/com/lowenssh/ssh/ExecResult.java deleted file mode 100644 index fc4fb85..0000000 --- a/src/main/java/com/lowenssh/ssh/ExecResult.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.lowenssh.ssh; - -/** - * 命令执行结果 —— stdout / stderr / exitCode 三件套 - * 用 record(Java 17):不可变、自带 equals/toString,正好装这种纯数据 - */ -public record ExecResult(String stdout, String stderr, int exitCode) { - - /** exitCode 为 0 视为成功 */ - public boolean isSuccess() { - return exitCode == 0; - } -} diff --git a/src/main/java/com/lowenssh/ssh/RemoteFile.java b/src/main/java/com/lowenssh/ssh/RemoteFile.java deleted file mode 100644 index 04b17f0..0000000 --- a/src/main/java/com/lowenssh/ssh/RemoteFile.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.lowenssh.ssh; - -/** - * 远程文件/目录的元信息,SFTP 列目录用。 - * - * @param name 文件名(不含路径) - * @param path 绝对路径 - * @param size 字节大小(目录为 0) - * @param isDir 是否目录 - * @param perms 权限字符串,如 "rwxr-xr-x" - * @param mtime 修改时间(Unix 秒) - */ -public record RemoteFile( - String name, - String path, - long size, - boolean isDir, - String perms, - long mtime -) {} diff --git a/src/main/java/com/lowenssh/ssh/SshClient.java b/src/main/java/com/lowenssh/ssh/SshClient.java deleted file mode 100644 index d8fe945..0000000 --- a/src/main/java/com/lowenssh/ssh/SshClient.java +++ /dev/null @@ -1,188 +0,0 @@ -package com.lowenssh.ssh; - -import com.jcraft.jsch.ChannelExec; -import com.jcraft.jsch.ChannelSftp; -import com.jcraft.jsch.JSch; -import com.jcraft.jsch.Session; -import com.jcraft.jsch.SftpException; - -import java.io.ByteArrayOutputStream; -import java.io.InputStream; -import java.io.OutputStream; -import java.util.ArrayList; -import java.util.List; -import java.util.Properties; -import java.util.Vector; - -/** - * SSH 客户端 —— 简化版方案 B:一个实例持有一个长连接,多条命令复用同一会话。 - * - * 为什么是长连接复用:这是个 agentic 运维 agent,loop 里会连续执行多条命令, - * 每次重连既慢、又丢上下文。MVP 阶段先不上连接池,够用。 - * - * 注意:非线程安全,一个 SshClient 实例对应一台机器的一个会话,由上层串行使用。 - */ -public class SshClient implements AutoCloseable { - - private final JSch jsch = new JSch(); - private Session session; - // SFTP 通道:懒开 + 保持复用(同一 Session 上长期有效),随 close 一并释放。 - // 上层用 LiveSession.lock() 串行化,这里不另加锁。 - private ChannelSftp sftp; - - /** - * 建立连接。密码认证(MVP 够用,后续可加密钥)。 - */ - public void connect(String host, int port, String username, String password) throws Exception { - session = jsch.getSession(username, host, port); - session.setPassword(password); - - // demo 方便:跳过 host key 校验。生产环境要换成 known_hosts 校验,否则有中间人风险 - Properties config = new Properties(); - config.put("StrictHostKeyChecking", "no"); - session.setConfig(config); - - // 连接超时 10s - session.connect(10_000); - } - - /** - * 执行一条命令,同时收集 stdout、stderr、exitCode。 - * - * JSch 的坑:stdout 走 channel 的 InputStream,stderr 要单独用 setErrStream 接, - * exitCode 必须等 channel 真正关闭后才能拿到,所以这里要轮询 isClosed。 - */ - public ExecResult exec(String command) throws Exception { - if (session == null || !session.isConnected()) { - throw new IllegalStateException("SSH 未连接,先调用 connect()"); - } - - ChannelExec channel = (ChannelExec) session.openChannel("exec"); - channel.setCommand(command); - - ByteArrayOutputStream stdout = new ByteArrayOutputStream(); - ByteArrayOutputStream stderr = new ByteArrayOutputStream(); - channel.setErrStream(stderr); // stderr 直接重定向到内存流 - InputStream in = channel.getInputStream(); // stdout 手动读 - - channel.connect(); - - // 边读 stdout 边等命令结束 - byte[] buf = new byte[4096]; - while (true) { - while (in.available() > 0) { - int n = in.read(buf, 0, buf.length); - if (n < 0) break; - stdout.write(buf, 0, n); - } - // channel 关闭代表命令执行完毕 - if (channel.isClosed()) { - if (in.available() > 0) continue; // 还有残留数据,再读一轮 - break; - } - Thread.sleep(50); // 没数据也没关闭,稍等避免空转 - } - - int exitCode = channel.getExitStatus(); - channel.disconnect(); - - return new ExecResult( - stdout.toString(java.nio.charset.StandardCharsets.UTF_8), - stderr.toString(java.nio.charset.StandardCharsets.UTF_8), - exitCode - ); - } - - /** 当前是否连接中 */ - public boolean isConnected() { - return session != null && session.isConnected(); - } - - // ===================== SFTP 文件操作 ===================== - // 复用同一条 SSH Session 开 sftp 通道,不重连。非线程安全,由上层串行调用。 - - /** 懒开并复用 sftp 通道;断了就重开 */ - private ChannelSftp sftp() throws Exception { - if (session == null || !session.isConnected()) { - throw new IllegalStateException("SSH 未连接,先调用 connect()"); - } - if (sftp == null || !sftp.isConnected()) { - sftp = (ChannelSftp) session.openChannel("sftp"); - sftp.connect(10_000); - } - return sftp; - } - - /** 列目录。过滤掉 . 和 ..,按「目录在前、名称升序」排列 */ - @SuppressWarnings("unchecked") - public List listDir(String path) throws Exception { - Vector entries = sftp().ls(path); - String base = path.endsWith("/") ? path : path + "/"; - List result = new ArrayList<>(); - for (ChannelSftp.LsEntry e : entries) { - String name = e.getFilename(); - if (name.equals(".") || name.equals("..")) continue; - var attrs = e.getAttrs(); - result.add(new RemoteFile( - name, - base + name, - attrs.getSize(), - attrs.isDir(), - attrs.getPermissionsString(), // 形如 "drwxr-xr-x" - attrs.getMTime() - )); - } - result.sort((a, b) -> { - if (a.isDir() != b.isDir()) return a.isDir() ? -1 : 1; - return a.name().compareToIgnoreCase(b.name()); - }); - return result; - } - - /** 上传:从输入流写到远端路径(覆盖) */ - public void upload(InputStream in, String remotePath) throws Exception { - sftp().put(in, remotePath, ChannelSftp.OVERWRITE); - } - - /** 下载:把远端文件写到输出流 */ - public void download(String remotePath, OutputStream out) throws Exception { - sftp().get(remotePath, out); - } - - /** 删除文件 */ - public void deleteFile(String path) throws Exception { - sftp().rm(path); - } - - /** 新建目录 */ - public void mkdir(String path) throws Exception { - sftp().mkdir(path); - } - - /** 重命名/移动 */ - public void rename(String from, String to) throws Exception { - sftp().rename(from, to); - } - - /** 远端路径是否为目录(不存在也返回 false) */ - public boolean isDir(String path) { - try { - return sftp().stat(path).isDir(); - } catch (SftpException e) { - return false; - } catch (Exception e) { - return false; - } - } - - /** 关闭会话,释放 sftp 通道和连接 */ - @Override - public void close() { - if (sftp != null && sftp.isConnected()) { - sftp.disconnect(); - } - if (session != null && session.isConnected()) { - session.disconnect(); - } - } -} diff --git a/src/main/java/com/lowenssh/util/CryptoUtil.java b/src/main/java/com/lowenssh/util/CryptoUtil.java deleted file mode 100644 index fae7f3b..0000000 --- a/src/main/java/com/lowenssh/util/CryptoUtil.java +++ /dev/null @@ -1,89 +0,0 @@ -package com.lowenssh.util; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -import javax.crypto.Cipher; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.SecureRandom; -import java.util.Base64; - -/** - * 密码加密工具 —— 主机簿密码落库前用 AES-GCM 加密,绝不存明文。 - * - * 为什么 AES-GCM:对称加密里 GCM 自带完整性校验(认证标签),密文被篡改解密会失败, - * 比 AES-CBC 安全。密钥从环境变量 XWSSH_CRYPTO_KEY 读,遵循本项目「密钥走环境变量」惯例。 - * - * 密文格式:Base64( iv[12] + cipherText + tag[16] ),IV 每次随机生成同密文一起存, - * 解密时切出来用。同一明文每次加密结果不同(IV 随机),符合预期。 - * - * 注意:这是演示/面试项目的够用方案。生产应上 KMS / Vault 管密钥,不靠单个环境变量。 - */ -@Component -public class CryptoUtil { - - private static final Logger log = LoggerFactory.getLogger(CryptoUtil.class); - private static final String ALGO = "AES/GCM/NoPadding"; - private static final int IV_LEN = 12; // GCM 推荐 12 字节 IV - private static final int TAG_BITS = 128; // 认证标签 128 位 - private final SecretKeySpec key; - private final SecureRandom random = new SecureRandom(); - - public CryptoUtil(@Value("${XWSSH_CRYPTO_KEY:}") String rawKey) { - if (rawKey == null || rawKey.isBlank()) { - // 没配密钥时退到开发默认值,仅保证能跑;生产务必设 XWSSH_CRYPTO_KEY - rawKey = "xwssh-dev-default-key-change-me"; - log.warn("未设置环境变量 XWSSH_CRYPTO_KEY,主机密码用开发默认密钥加密,生产环境请务必配置!"); - } - // 任意长度的密钥串经 SHA-256 派生成固定 32 字节,得到 AES-256 密钥 - this.key = new SecretKeySpec(sha256(rawKey), "AES"); - } - - /** 加密:明文 → Base64(iv + 密文 + tag)。入参为空返回 null。 */ - public String encrypt(String plain) { - if (plain == null || plain.isEmpty()) return null; - try { - byte[] iv = new byte[IV_LEN]; - random.nextBytes(iv); - Cipher cipher = Cipher.getInstance(ALGO); - cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); - byte[] ct = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8)); - // iv 拼在密文前一起 Base64 - byte[] out = new byte[iv.length + ct.length]; - System.arraycopy(iv, 0, out, 0, iv.length); - System.arraycopy(ct, 0, out, iv.length, ct.length); - return Base64.getEncoder().encodeToString(out); - } catch (Exception e) { - throw new IllegalStateException("密码加密失败", e); - } - } - - /** 解密:Base64(iv + 密文 + tag) → 明文。入参为空返回 null。 */ - public String decrypt(String enc) { - if (enc == null || enc.isEmpty()) return null; - try { - byte[] all = Base64.getDecoder().decode(enc); - byte[] iv = new byte[IV_LEN]; - System.arraycopy(all, 0, iv, 0, IV_LEN); - Cipher cipher = Cipher.getInstance(ALGO); - cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_BITS, iv)); - byte[] plain = cipher.doFinal(all, IV_LEN, all.length - IV_LEN); - return new String(plain, StandardCharsets.UTF_8); - } catch (Exception e) { - throw new IllegalStateException("密码解密失败(密钥变更或密文损坏)", e); - } - } - - private static byte[] sha256(String s) { - try { - return MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)); - } catch (Exception e) { - throw new IllegalStateException(e); - } - } -} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 131e119..0000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,89 +0,0 @@ -server: - # 8080 被本机常驻的 Tomcat 占用,换 8081 - port: 8081 - -spring: - application: - name: lowenssh - - # SFTP 上传:默认 1MB 太小,运维传包/日志放宽到 100MB - servlet: - multipart: - max-file-size: 100MB - max-request-size: 100MB - - # 本机 MySQL,库 lowenssh 已建,密码走环境变量 MYSQL_PASSWORD - datasource: - # allowPublicKeyRetrieval:MySQL9 默认 caching_sha2 认证,非SSL连接需开此项取公钥加密密码(本地连接安全) - # DB_HOST:本地直跑默认 localhost;docker-compose 下设为 mysql 指向容器 - url: jdbc:mysql://${DB_HOST:localhost}:3306/lowenssh?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&characterEncoding=UTF-8 - username: root - password: ${MYSQL_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - - ai: - openai: - # 模型无关设计:统一走 OpenAI 兼容协议,换模型只改下面三处即可,零代码改动 - # 智谱GLM(当前): base-url=https://open.bigmodel.cn/api/paas/v4 model=glm-4.6 - # 通义千问: base-url=https://dashscope.aliyuncs.com/compatible-mode/v1 model=qwen-plus - # DeepSeek: base-url=https://api.deepseek.com model=deepseek-chat - # 选 GLM:agent 命门是 tool_call 稳定性,GLM 原生 function calling 口碑最稳; - # 真测下来若漂,切上面任一个,零代码改动。 - base-url: https://open.bigmodel.cn/api/paas/v4 - # API key 从环境变量读,不要写死在配置里 - api-key: ${GLM_API_KEY} - chat: - # GLM 端点是 .../paas/v4/chat/completions,没有 /v1; - # 覆盖 Spring AI 默认的 /v1/chat/completions,否则会 404 - completions-path: /chat/completions - options: - model: glm-4.7 - temperature: 0.7 - -# 日志:方便看 Spring AI 发出的请求 -logging: - level: - org.springframework.ai: INFO - -# 应用自监控:暴露 health + 关键 metrics 给前端"监控"页读取(仅本机/内网,不鉴权) -management: - endpoints: - web: - exposure: - include: health,info,metrics - endpoint: - health: - show-details: always - -# 上下文管理(M3):防止多轮 loop 把模型上下文撑爆 -# 联调验证压缩逻辑时把阈值调小即可快速触发: -# tool-result-max-chars 调到 2000、max-context-tokens 调到 2000 -xwssh: - agent: - # agentic loop 最大循环轮数,防模型反复调工具停不下来。 - # 注意:轮数直接乘 token——第 N 轮要把前 N-1 轮历史全发一遍,是 O(N²) 累积。 - # 25 对复杂运维够用,又能挡住失控的烧钱长循环。 - max-rounds: 25 - # 会话空闲超时(分钟):超过这么久没活动的常驻 SSH 连接会被定时回收,防连接泄漏。 - # 设 120(2h)让演示/续聊期间基本不会中途断;断了也能点"新会话"用同样信息秒重连。 - session-idle-timeout-minutes: 120 - context: - # Layer 0:最近几条工具结果回灌给模型的最大字符数,超出截掉中段(完整内容仍存 t_message)。 - # 这条最关键:工具结果每轮都重发,留得越大、轮数越多,token 烧得越凶。 - # 3000 字符≈1200token,运维命令输出大多看头尾就够判断,调小立竿见影省钱。 - tool-result-max-chars: 3000 - # Layer 0:更早(保留区之外)的工具结果用更小阈值大力收紧——旧命令模型已读过、结论已在历史里, - # 没必要每轮全量重发。800 字符≈320token,让上下文不随轮数线性膨胀。 - old-tool-result-max-chars: 800 - # Layer 4:整段上下文估算 token 超此值触发 LLM 压缩历史。 - # 12000 比原来 32000 早压缩,但压缩本身也要调一次模型,不宜过小,否则频繁压缩反而费钱。 - max-context-tokens: 12000 - # Layer 4:压缩时保留最近多少条消息原文(不进摘要) - keep-recent-messages: 4 - # Layer 4:摘要 LLM 连续失败达此次数后熔断,停止压缩裸跑兜底 - circuit-limit: 3 - -# MyBatis-Plus:开发期打印 SQL 方便调试 -mybatis-plus: - configuration: - log-impl: org.apache.ibatis.logging.stdout.StdOutImpl diff --git a/src/main/resources/schema.sql b/src/main/resources/schema.sql deleted file mode 100644 index fb51b46..0000000 --- a/src/main/resources/schema.sql +++ /dev/null @@ -1,58 +0,0 @@ --- LowenSSH 建表 SQL(手动执行:mysql -u root -p lowenssh < schema.sql) --- 库已建:CREATE DATABASE lowenssh DEFAULT CHARACTER SET utf8mb4; --- 注:应用启动时 SchemaInitializer 会自动跑这些建表/加列,平时无需手动执行此文件。 - --- 主机表:主机簿里的一台常用服务器,password_enc 存 AES-GCM 密文 -CREATE TABLE IF NOT EXISTS t_host ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - alias VARCHAR(128) DEFAULT NULL COMMENT '主机别名', - ssh_host VARCHAR(128) NOT NULL COMMENT '目标服务器 host', - ssh_port INT DEFAULT 22 COMMENT '端口', - ssh_user VARCHAR(64) NOT NULL COMMENT 'SSH 用户名', - password_enc VARCHAR(512) DEFAULT NULL COMMENT 'SSH 密码密文(AES-GCM)', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='主机簿'; - --- 会话表:一次对话 = 一个 session,绑定一台目标服务器(host_id 关联 t_host) -CREATE TABLE IF NOT EXISTS t_session ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - host_id BIGINT DEFAULT NULL COMMENT '所属主机 t_host.id', - title VARCHAR(255) DEFAULT NULL COMMENT '会话标题', - ssh_host VARCHAR(128) DEFAULT NULL COMMENT '目标服务器 host', - ssh_port INT DEFAULT 22 COMMENT '目标服务器端口', - ssh_user VARCHAR(64) DEFAULT NULL COMMENT 'SSH 用户名', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - PRIMARY KEY (id), - KEY idx_host (host_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='会话'; - --- 消息表:对话历史,agentic loop 的上下文来源 -CREATE TABLE IF NOT EXISTS t_message ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - session_id BIGINT NOT NULL COMMENT '所属会话', - role VARCHAR(16) NOT NULL COMMENT '角色: user/assistant/tool/system', - content MEDIUMTEXT DEFAULT NULL COMMENT '消息内容', - tool_calls MEDIUMTEXT DEFAULT NULL COMMENT '工具调用 JSON(assistant 发起时)', - tool_call_id VARCHAR(64) DEFAULT NULL COMMENT '工具结果对应的调用 id(role=tool 时)', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - PRIMARY KEY (id), - KEY idx_session (session_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='对话消息'; - --- 审计表:每条实际执行的命令都记一笔,可追溯 -CREATE TABLE IF NOT EXISTS t_audit ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键', - session_id BIGINT NOT NULL COMMENT '所属会话', - command TEXT NOT NULL COMMENT '执行的命令', - stdout MEDIUMTEXT DEFAULT NULL COMMENT '标准输出', - stderr MEDIUMTEXT DEFAULT NULL COMMENT '错误输出', - exit_code INT DEFAULT NULL COMMENT '退出码', - dangerous TINYINT NOT NULL DEFAULT 0 COMMENT '是否危险命令: 0否 1是', - confirmed TINYINT NOT NULL DEFAULT 0 COMMENT '是否经人工确认: 0否 1是', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '执行时间', - PRIMARY KEY (id), - KEY idx_session (session_id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='命令执行审计'; diff --git a/src/test/java/com/lowenssh/agent/AgentServiceTest.java b/src/test/java/com/lowenssh/agent/AgentServiceTest.java deleted file mode 100644 index 94f0939..0000000 --- a/src/test/java/com/lowenssh/agent/AgentServiceTest.java +++ /dev/null @@ -1,175 +0,0 @@ -package com.lowenssh.agent; - -import com.lowenssh.agent.guard.CommandGuard; -import com.lowenssh.agent.guard.ConfirmationHandler; -import com.lowenssh.persistence.AuditService; -import com.lowenssh.persistence.MessageService; -import com.lowenssh.ssh.SshClient; -import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.ToolResponseMessage; -import org.springframework.ai.chat.metadata.ChatGenerationMetadata; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.chat.prompt.Prompt; -import org.springframework.ai.model.tool.ToolCallingManager; -import org.springframework.ai.model.tool.ToolExecutionResult; -import org.springframework.ai.openai.OpenAiChatModel; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * Agent loop 核心单测 —— 项目卖点是"手写 loop + 安全门禁",loop 的决策分支必须覆盖: - * 1. 模型不再调工具 → 给出结论结束 - * 2. DENY 命令 → 不执行、回灌拒绝、loop 继续让模型换方案 - * 3. ASK 命令用户拒绝 → 同 DENY(不执行) - * 4. ASK 命令用户批准 → 交框架执行 - * 5. MAX_ROUNDS 上限 → 防死循环兜底 - * - * 全程不连真模型/真 SSH:chatModel、toolCallingManager 用 mock;门禁用真 CommandGuard - * (它本身已有独立单测,这里用真实判定让用例更接近线上)。 - */ -class AgentServiceTest { - - private static final Long SID = 1L; - - // —— 依赖:模型和工具执行器 mock,其余给真实/哑实现 —— - private final OpenAiChatModel chatModel = mock(OpenAiChatModel.class); - private final ToolCallingManager toolCallingManager = mock(ToolCallingManager.class); - private final CommandGuard guard = new CommandGuard(); - private final AuditService auditService = mock(AuditService.class); - private final MessageService messageService = mock(MessageService.class); - // ContextManager 用真实对象但阈值设到永不压缩,截断也不影响这里的短消息 - private final ContextManager contextManager = new ContextManager(null, 8000, 800, 999999, 6, 3); - - private final AgentService service = new AgentService( - chatModel, toolCallingManager, guard, auditService, messageService, contextManager, 15); - - // ToolCallbacks.from(tools) 只反射读 @Tool 注解,不真连 SSH,deps 给 null 即可 - private SshTools tools() { - return new SshTools(new SshClient(), SID, auditService, guard); - } - - // —— 造 ChatResponse 的辅助方法 —— - - /** 造一个"模型给出纯文字结论、无 tool_call"的响应 */ - private ChatResponse textResponse(String text) { - return new ChatResponse(List.of( - new Generation(new AssistantMessage(text), ChatGenerationMetadata.NULL))); - } - - /** 造一个"模型要调 execCommand 跑某条命令"的响应。 - * 用 AssistantMessage.builder() 公开 API(1.1.5 带 toolCall 的构造器是 protected)。 */ - private ChatResponse execCallResponse(String callId, String command) { - AssistantMessage.ToolCall call = new AssistantMessage.ToolCall( - callId, "function", "execCommand", - "{\"command\":\"" + command + "\"}"); - AssistantMessage assistant = AssistantMessage.builder() - .content("") - .toolCalls(List.of(call)) - .build(); - return new ChatResponse(List.of( - new Generation(assistant, ChatGenerationMetadata.NULL))); - } - - // ============================ 1. 正常结束 ============================ - - @Test - void 模型不调工具时直接给出结论结束() { - when(chatModel.call(any(Prompt.class))).thenReturn(textResponse("磁盘还剩 58%,一切正常。")); - - String result = service.run(SID, "看下磁盘", tools(), (cmd, reason) -> true); - - assertEquals("磁盘还剩 58%,一切正常。", result); - // 没有工具调用,执行器一次都不该被碰 - verify(toolCallingManager, never()).executeToolCalls(any(), any()); - } - - // ============================ 2. DENY 命令被拦 ============================ - - @Test - void DENY命令不执行且回灌拒绝后继续loop() { - // 第 1 轮:模型想跑 rm -rf /(必被 DENY);第 2 轮:模型改口给结论 - when(chatModel.call(any(Prompt.class))) - .thenReturn(execCallResponse("c1", "rm -rf /")) - .thenReturn(textResponse("好的,我不执行删除操作。")); - - String result = service.run(SID, "清理磁盘", tools(), (cmd, reason) -> true); - - assertEquals("好的,我不执行删除操作。", result); - // 危险命令绝不能进框架执行 - verify(toolCallingManager, never()).executeToolCalls(any(), any()); - // 拦截点必须落审计 - verify(auditService).logBlocked(eq(SID), eq("rm -rf /"), eq(true), anyString()); - } - - // ============================ 3. ASK 用户拒绝 ============================ - - @Test - void ASK命令用户拒绝则不执行() { - // systemctl restart 命中 ASK;确认器返回 false(拒绝) - when(chatModel.call(any(Prompt.class))) - .thenReturn(execCallResponse("c1", "systemctl restart nginx")) - .thenReturn(textResponse("已取消重启。")); - - ConfirmationHandler denyAll = (cmd, reason) -> false; - String result = service.run(SID, "重启nginx", tools(), denyAll); - - assertEquals("已取消重启。", result); - verify(toolCallingManager, never()).executeToolCalls(any(), any()); - } - - // ============================ 4. ASK 用户批准 → 执行 ============================ - - @Test - void ASK命令用户批准则交框架执行() { - when(chatModel.call(any(Prompt.class))) - .thenReturn(execCallResponse("c1", "systemctl restart nginx")) - .thenReturn(textResponse("nginx 已重启完成。")); - - // 批准后框架执行,返回一段只含工具结果、无新 tool_call 的历史,让下一轮收尾 - ToolExecutionResult execResult = mock(ToolExecutionResult.class); - when(execResult.conversationHistory()).thenReturn(List.of( - ToolResponseMessage.builder() - .responses(List.of(new ToolResponseMessage.ToolResponse( - "c1", "execCommand", "Job for nginx.service done."))) - .build())); - when(toolCallingManager.executeToolCalls(any(), any())).thenReturn(execResult); - - ConfirmationHandler approveAll = (cmd, reason) -> true; - String result = service.run(SID, "重启nginx", tools(), approveAll); - - assertEquals("nginx 已重启完成。", result); - // 批准的命令确实交给框架执行了一次 - verify(toolCallingManager).executeToolCalls(any(), any()); - } - - // ============================ 5. 死循环兜底 ============================ - - @Test - void 模型反复调安全命令达上限则兜底返回() { - // 每轮都返回一个 ALLOW 命令,永不收尾 → 必然撞 MAX_ROUNDS - when(chatModel.call(any(Prompt.class))) - .thenReturn(execCallResponse("c", "ls -al")); - - // ALLOW 命令会进框架执行,给个空历史让 loop 继续转 - ToolExecutionResult execResult = mock(ToolExecutionResult.class); - when(execResult.conversationHistory()).thenReturn(List.of()); - when(toolCallingManager.executeToolCalls(any(), any())).thenReturn(execResult); - - String result = service.run(SID, "一直查", tools(), (cmd, reason) -> true); - - assertTrue(result.contains("最大循环轮数"), "撞上限应返回兜底文案,实际:" + result); - } -} diff --git a/src/test/java/com/lowenssh/agent/ContextManagerTest.java b/src/test/java/com/lowenssh/agent/ContextManagerTest.java deleted file mode 100644 index f5d5fc7..0000000 --- a/src/test/java/com/lowenssh/agent/ContextManagerTest.java +++ /dev/null @@ -1,227 +0,0 @@ -package com.lowenssh.agent; - -import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.SystemMessage; -import org.springframework.ai.chat.messages.ToolResponseMessage; -import org.springframework.ai.chat.messages.UserMessage; -import org.springframework.ai.chat.metadata.ChatGenerationMetadata; -import org.springframework.ai.chat.model.ChatResponse; -import org.springframework.ai.chat.model.Generation; -import org.springframework.ai.openai.OpenAiChatModel; - -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * 上下文管理单测 —— 截断/压缩/配对/熔断是 M3 的硬逻辑,必须覆盖。 - * Layer 0 截断不调模型,chatModel 传 null;Layer 4 压缩用 mock 模拟摘要返回,纯逻辑不连真模型。 - */ -class ContextManagerTest { - - // ============================ Layer 0:截断 ============================ - - /** 截断不调模型,chatModel 给 null 也能跑 */ - private ContextManager truncator(int maxChars) { - // old 阈值给同值:旧用例只放单条工具结果、位置都在保留区内,走 recent 分支,old 不生效 - return new ContextManager(null, maxChars, maxChars, 999999, 6, 3); - } - - /** 造一条工具结果消息 */ - private ToolResponseMessage toolMsg(String id, String name, String data) { - return ToolResponseMessage.builder() - .responses(List.of(new ToolResponseMessage.ToolResponse(id, name, data))) - .build(); - } - - private String toolData(Message msg) { - return ((ToolResponseMessage) msg).getResponses().get(0).responseData(); - } - - @Test - void 短工具结果不截断() { - ContextManager cm = truncator(8000); - List in = List.of(toolMsg("1", "execCommand", "磁盘占用 42%")); - List out = cm.truncateToolResponses(in); - assertEquals("磁盘占用 42%", toolData(out.get(0))); - } - - @Test - void 超长工具结果截断中段保留头尾() { - ContextManager cm = truncator(100); - String big = "H".repeat(80) + "M".repeat(500) + "T".repeat(80); - List out = cm.truncateToolResponses(List.of(toolMsg("1", "tailLog", big))); - String data = toolData(out.get(0)); - // 截断后总长远小于原始;含截断提示;保留了开头的 H 和结尾的 T - assertTrue(data.length() < big.length(), "应被截短"); - assertTrue(data.contains("已截断"), "应含截断提示"); - assertTrue(data.contains("t_message"), "提示应指向 t_message"); - assertTrue(data.startsWith("H"), "应保留头部"); - assertTrue(data.endsWith("T"), "应保留尾部"); - } - - @Test - void 截断只动工具结果不动普通消息() { - ContextManager cm = truncator(50); - List in = List.of( - new SystemMessage("系统提示"), - new UserMessage("查一下磁盘"), - toolMsg("1", "execCommand", "X".repeat(500))); - List out = cm.truncateToolResponses(in); - assertEquals("系统提示", out.get(0).getText()); - assertEquals("查一下磁盘", out.get(1).getText()); - assertTrue(toolData(out.get(2)).contains("已截断")); - } - - @Test - void 截断幂等再跑结果不变() { - ContextManager cm = truncator(100); - List once = cm.truncateToolResponses(List.of(toolMsg("1", "x", "Z".repeat(800)))); - List twice = cm.truncateToolResponses(once); - assertEquals(toolData(once.get(0)), toolData(twice.get(0))); - } - - @Test - void 旧工具结果用更小阈值收紧() { - // 近区大阈值 1000、旧区小阈值 100,keep-recent=2 - ContextManager cm = new ContextManager(null, 1000, 100, 999999, 2, 3); - String big = "X".repeat(900); - // 列表:旧工具结果(距末尾4) + 两条占位 + 近工具结果(距末尾1) - List in = List.of( - toolMsg("old", "tailLog", big), - new UserMessage("中间一"), - new UserMessage("中间二"), - toolMsg("new", "tailLog", big)); - List out = cm.truncateToolResponses(in); - String oldData = toolData(out.get(0)); - String newData = toolData(out.get(3)); - // 旧的被小阈值截断(含提示且远小于 900);新的在阈值内不截 - assertTrue(oldData.contains("已截断"), "旧工具结果应被截断"); - assertTrue(oldData.length() < 200, "旧工具结果应收紧到小阈值附近"); - assertEquals(big, newData, "近区工具结果在大阈值内不应被截"); - } - - // ============================ Layer 4:压缩 ============================ - - /** 造一个会返回固定摘要文本的 mock 模型 */ - private OpenAiChatModel mockModel(String summary) { - OpenAiChatModel m = mock(OpenAiChatModel.class); - ChatResponse resp = new ChatResponse(List.of( - new Generation(new AssistantMessage(summary), ChatGenerationMetadata.NULL))); - when(m.call(any(org.springframework.ai.chat.prompt.Prompt.class))).thenReturn(resp); - return m; - } - - /** 造一段够长的对话(system + 多轮 user/assistant),确保 token 估算超阈值 */ - private List longHistory(int rounds) { - java.util.List msgs = new java.util.ArrayList<>(); - msgs.add(new SystemMessage("你是运维助手")); - for (int i = 0; i < rounds; i++) { - msgs.add(new UserMessage("第" + i + "步:" + "内".repeat(100))); - msgs.add(new AssistantMessage("回复" + i + ":" + "容".repeat(100))); - } - return msgs; - } - - @Test - void 未超阈值不压缩() { - // 阈值设很大,短历史不该触发压缩 - ContextManager cm = new ContextManager(mockModel("摘要"), 8000, 800, 999999, 6, 3); - List in = longHistory(2); - List out = cm.compressIfNeeded(in); - assertEquals(in.size(), out.size(), "未超阈值应原样返回"); - } - - @Test - void 超阈值触发压缩且保留system和最近K条() { - // 阈值调到 100 token,长历史必然超 - ContextManager cm = new ContextManager(mockModel("【这是早先对话的摘要】"), 8000, 800, 100, 6, 3); - List in = longHistory(10); // 1 system + 20 条 - List out = cm.compressIfNeeded(in); - assertTrue(out.size() < in.size(), "应被压缩变短"); - // 第一条仍是 system - assertTrue(out.get(0) instanceof SystemMessage, "首条应保留 system"); - // 第二条是摘要(UserMessage 含摘要文本) - assertTrue(out.get(1).getText().contains("早先对话的摘要"), "次条应是摘要"); - // 保留了最近 6 条原文 - assertEquals(6, out.size() - 2, "应保留最近 6 条原文 + system + 摘要"); - } - - @Test - void 保留区开头是孤儿工具结果时切割点前移保配对() { - // 构造:system + assistant(tool_call) + tool_result,且 tool_result 恰好落在保留区开头 - AssistantMessage.ToolCall call = new AssistantMessage.ToolCall("c1", "function", "execCommand", "{\"command\":\"df -h\"}"); - java.util.List msgs = new java.util.ArrayList<>(); - msgs.add(new SystemMessage("系统")); - // 前面填一堆把 token 撑上去 - for (int i = 0; i < 8; i++) { - msgs.add(new UserMessage("填充" + "占".repeat(80))); - msgs.add(new AssistantMessage("回复" + "位".repeat(80))); - } - // 末尾一对:assistant 带 tool_call + 对应 tool_result - msgs.add(AssistantMessage.builder().content("").toolCalls(List.of(call)).build()); - msgs.add(toolMsg("c1", "execCommand", "结果")); - - // keep-recent=1 会让切割点正好落在 tool_result 上 -> 须前移把 assistant 一起留下 - ContextManager cm = new ContextManager(mockModel("摘要"), 8000, 800, 50, 1, 3); - List out = cm.compressIfNeeded(msgs); - - // 保留区不能以孤儿 ToolResponseMessage 开头:找到摘要后的第一条原文 - // out = [system, 摘要, ...保留区],保留区首条不应是 ToolResponseMessage - Message firstKept = out.get(2); - assertFalse(firstKept instanceof ToolResponseMessage, - "保留区开头不能是孤儿 tool_result,切割点应前移到对应 assistant"); - } - - @Test - void 摘要连续失败达阈值后熔断不再调模型() { - // mock 模型抛异常模拟摘要失败 - OpenAiChatModel failModel = mock(OpenAiChatModel.class); - when(failModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))) - .thenThrow(new RuntimeException("模型挂了")); - // circuit-limit=3 - ContextManager cm = new ContextManager(failModel, 8000, 800, 100, 6, 3); - - // 前 3 次都会尝试调用并失败 - for (int i = 0; i < 3; i++) { - cm.compressIfNeeded(longHistory(10)); - } - // 第 4、5 次应已熔断,不再调模型 - cm.compressIfNeeded(longHistory(10)); - cm.compressIfNeeded(longHistory(10)); - - // 总调用次数应恰好 3(熔断后不再调) - verify(failModel, times(3)).call(any(org.springframework.ai.chat.prompt.Prompt.class)); - } - - @Test - void 摘要失败时原样返回不丢历史() { - OpenAiChatModel failModel = mock(OpenAiChatModel.class); - when(failModel.call(any(org.springframework.ai.chat.prompt.Prompt.class))) - .thenThrow(new RuntimeException("挂了")); - ContextManager cm = new ContextManager(failModel, 8000, 800, 100, 6, 3); - List in = longHistory(10); - List out = cm.compressIfNeeded(in); - // 摘要失败不能丢历史,必须原样返回 - assertEquals(in.size(), out.size(), "摘要失败应原样返回不丢历史"); - } - - // ============================ token 估算 ============================ - - @Test - void token估算随内容增长() { - ContextManager cm = truncator(8000); - int small = cm.estimateTokens(List.of(new UserMessage("短"))); - int big = cm.estimateTokens(List.of(new UserMessage("长".repeat(1000)))); - assertTrue(big > small, "内容越多估算 token 越大"); - } -} diff --git a/src/test/java/com/lowenssh/agent/SessionManagerTest.java b/src/test/java/com/lowenssh/agent/SessionManagerTest.java deleted file mode 100644 index ee6cb6b..0000000 --- a/src/test/java/com/lowenssh/agent/SessionManagerTest.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.lowenssh.agent; - -import com.lowenssh.persistence.mapper.SessionMapper; -import com.lowenssh.ssh.SshClient; -import org.junit.jupiter.api.Test; - -import java.lang.reflect.Field; -import java.time.Instant; -import java.util.Map; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -/** - * SessionManager 单测 —— open() 要连真 SSH,这里不测;聚焦不依赖真连接的逻辑: - * get(命中/不存在/连接已断)、close(关连接 + 幂等)、reap(回收超时/断开会话)。 - * - * 用反射把 mock 的 LiveSession 塞进内部 map,绕开 open() 的真实 SSH 连接。 - */ -class SessionManagerTest { - - private final SessionMapper sessionMapper = mock(SessionMapper.class); - - /** 反射取出内部 bySession map(已绑定会话的活连接) */ - @SuppressWarnings("unchecked") - private Map sessionsOf(SessionManager mgr) throws Exception { - Field f = SessionManager.class.getDeclaredField("bySession"); - f.setAccessible(true); - return (Map) f.get(mgr); - } - - /** 造一个挂了 mock SshClient 的 LiveSession(回填 sessionId)并塞进 manager */ - private SshClient injectSession(SessionManager mgr, Long id, boolean connected) throws Exception { - SshClient ssh = mock(SshClient.class); - when(ssh.isConnected()).thenReturn(connected); - SessionManager.LiveSession live = new SessionManager.LiveSession(1L, "h", 22, "root", ssh); - live.sessionId = id; - sessionsOf(mgr).put(id, live); - return ssh; - } - - @Test - void get命中活跃会话() throws Exception { - SessionManager mgr = new SessionManager(sessionMapper, 30); - injectSession(mgr, 1L, true); - - SessionManager.LiveSession live = mgr.get(1L); - - assertThat(live).isNotNull(); - assertThat(live.sessionId()).isEqualTo(1L); - } - - @Test - void get不存在的会话返回null() { - SessionManager mgr = new SessionManager(sessionMapper, 30); - assertThat(mgr.get(999L)).isNull(); - } - - @Test - void get发现连接已断则移除并返回null() throws Exception { - SessionManager mgr = new SessionManager(sessionMapper, 30); - SshClient ssh = injectSession(mgr, 2L, false); // 连接已断 - - assertThat(mgr.get(2L)).isNull(); - assertThat(mgr.activeCount()).isZero(); - verify(ssh).close(); // 顺手关掉 - } - - @Test - void close关连接并移除且幂等() throws Exception { - SessionManager mgr = new SessionManager(sessionMapper, 30); - SshClient ssh = injectSession(mgr, 3L, true); - - mgr.close(3L); - assertThat(mgr.activeCount()).isZero(); - verify(ssh, times(1)).close(); - - // 再关一次不报错、不重复关 - mgr.close(3L); - verify(ssh, times(1)).close(); - } - - @Test - void reap回收超时会话保留活跃会话() throws Exception { - SessionManager mgr = new SessionManager(sessionMapper, 30); - // 活跃会话:刚活动过 - SshClient fresh = injectSession(mgr, 10L, true); - // 超时会话:lastActiveAt 拨到 31 分钟前 - SshClient stale = injectSession(mgr, 11L, true); - SessionManager.LiveSession staleLive = sessionsOf(mgr).get(11L); - Field lastActive = SessionManager.LiveSession.class.getDeclaredField("lastActiveAt"); - lastActive.setAccessible(true); - lastActive.set(staleLive, Instant.now().minusSeconds(31 * 60)); - - mgr.reapIdleSessions(); - - assertThat(mgr.activeCount()).isEqualTo(1); - verify(stale).close(); // 超时的被回收 - verify(fresh, never()).close(); // 活跃的保留 - } -} diff --git a/src/test/java/com/lowenssh/agent/guard/CommandGuardTest.java b/src/test/java/com/lowenssh/agent/guard/CommandGuardTest.java deleted file mode 100644 index 48c4929..0000000 --- a/src/test/java/com/lowenssh/agent/guard/CommandGuardTest.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.lowenssh.agent.guard; - -import com.lowenssh.agent.guard.CommandGuard.Decision; -import com.lowenssh.agent.guard.CommandGuard.Verdict; -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -/** - * 门禁单测 —— 三态判定是 Agent 安全的硬边界,必须覆盖到位。 - * 不依赖 SSH/模型,纯逻辑,跑得快。 - */ -class CommandGuardTest { - - private final CommandGuard guard = new CommandGuard(); - - private Decision decide(String cmd) { - return guard.evaluate(cmd).decision(); - } - - // —— allow:只读命令 —— - @Test - void 只读命令放行() { - assertEquals(Decision.ALLOW, decide("df -h")); - assertEquals(Decision.ALLOW, decide("ps aux | grep java")); - assertEquals(Decision.ALLOW, decide("cat /etc/hostname")); - assertEquals(Decision.ALLOW, decide("free -m")); - } - - // —— deny:毁灭性命令直接拒 —— - @Test - void 危险命令拒绝() { - assertEquals(Decision.DENY, decide("rm -rf /")); - assertEquals(Decision.DENY, decide("rm -rf /var/data")); - assertEquals(Decision.DENY, decide("rm -fr /tmp/x")); - assertEquals(Decision.DENY, decide("mkfs.ext4 /dev/sdb")); - assertEquals(Decision.DENY, decide("dd if=/dev/zero of=/dev/sda")); - assertEquals(Decision.DENY, decide("shutdown -h now")); - assertEquals(Decision.DENY, decide("reboot")); - } - - // —— ask:有副作用,要确认 —— - @Test - void 副作用命令要确认() { - assertEquals(Decision.ASK, decide("rm /tmp/a.log")); // 普通 rm(非 -rf) - assertEquals(Decision.ASK, decide("kill 1234")); - assertEquals(Decision.ASK, decide("systemctl restart nginx")); - assertEquals(Decision.ASK, decide("chmod 777 /etc/passwd")); - assertEquals(Decision.ASK, decide("apt-get install vim")); - } - - // —— 复合命令拆段:任一段最严即整条最严 —— - @Test - void 复合命令取最严() { - // 前段安全、后段毁灭 → DENY(防整条被当一段漏过) - assertEquals(Decision.DENY, decide("ls && rm -rf /data")); - // 管道里藏 dd → DENY - assertEquals(Decision.DENY, decide("cat x | dd of=/dev/sda")); - // 分号串联,含 ask 段 → ASK - assertEquals(Decision.ASK, decide("df -h; systemctl stop nginx")); - // 全段安全 → ALLOW - assertEquals(Decision.ALLOW, decide("cd /var && ls -al")); - } - - // —— deny 优先于 ask:评估顺序保证 deny 永远赢 —— - @Test - void deny优先于ask() { - // rm -rf 同时命中 ask(rm) 和 deny(rm -rf),必须 DENY - assertEquals(Decision.DENY, decide("rm -rf /opt/app")); - } - - // —— 边界 —— - @Test - void 空命令放行() { - assertEquals(Decision.ALLOW, decide("")); - assertEquals(Decision.ALLOW, decide(" ")); - assertEquals(Decision.ALLOW, decide(null)); - } - - // —— 防误伤:dd 不该误伤 add,rm 不该误伤 chmod 之外的词 —— - @Test - void 不误伤子串() { - assertEquals(Decision.ALLOW, decide("git add .")); // add 含 dd 不该命中 - assertEquals(Decision.ALLOW, decide("echo warm")); // warm 含 rm 不该命中 - } - - @Test - void 拒绝原因可读() { - Verdict v = guard.evaluate("rm -rf /"); - assertEquals(Decision.DENY, v.decision()); - // 原因里应带命中片段,便于回灌给模型/展示用户 - org.junit.jupiter.api.Assertions.assertTrue(v.reason().contains("rm")); - } - - // —— find 等价绕过:真机联调发现模型被拦 rm -rf 后改用 find 删除 —— - @Test - void find删除变体也拒绝() { - assertEquals(Decision.DENY, decide("find /tmp -mindepth 1 -delete")); - assertEquals(Decision.DENY, decide("find /var/log -name '*.log' -delete")); - assertEquals(Decision.DENY, decide("find /data -type f -exec rm -f {} \\;")); - // 普通 find 查找不该误伤 - assertEquals(Decision.ALLOW, decide("find /etc -name nginx.conf")); - } -} diff --git a/src/test/java/com/lowenssh/persistence/MessageServiceTest.java b/src/test/java/com/lowenssh/persistence/MessageServiceTest.java deleted file mode 100644 index 2ca8ea2..0000000 --- a/src/test/java/com/lowenssh/persistence/MessageServiceTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.lowenssh.persistence; - -import com.lowenssh.persistence.entity.MessageEntity; -import com.lowenssh.persistence.mapper.MessageMapper; -import org.junit.jupiter.api.Test; -import org.springframework.ai.chat.messages.AssistantMessage; -import org.springframework.ai.chat.messages.Message; -import org.springframework.ai.chat.messages.MessageType; -import org.springframework.ai.chat.messages.ToolResponseMessage; -import org.springframework.ai.chat.messages.UserMessage; - -import java.util.ArrayList; -import java.util.List; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -/** - * MessageService.loadHistory 单测 —— 多轮对话的核心:把 t_message 还原成 - * Spring AI 的 Message 列表,再回灌给模型。这里重点验证还原规则正确。 - */ -class MessageServiceTest { - - /** 造一条 MessageEntity 行 */ - private MessageEntity row(String role, String content, String toolCalls, String toolCallId) { - MessageEntity e = new MessageEntity(); - e.setRole(role); - e.setContent(content); - e.setToolCalls(toolCalls); - e.setToolCallId(toolCallId); - return e; - } - - @SuppressWarnings("unchecked") - private MessageService serviceReturning(List rows) { - MessageMapper mapper = mock(MessageMapper.class); - when(mapper.selectList(any())).thenReturn(rows); - return new MessageService(mapper); - } - - @Test - void 空sessionId返回空列表() { - MessageService svc = serviceReturning(new ArrayList<>()); - assertThat(svc.loadHistory(null)).isEmpty(); - } - - @Test - void 还原user和assistant纯文字消息() { - List rows = List.of( - row("user", "看下磁盘", null, null), - row("assistant", "根分区还剩 30G", null, null)); - - List history = serviceReturning(rows).loadHistory(1L); - - assertThat(history).hasSize(2); - assertThat(history.get(0)).isInstanceOf(UserMessage.class); - assertThat(history.get(0).getText()).isEqualTo("看下磁盘"); - assertThat(history.get(1)).isInstanceOf(AssistantMessage.class); - assertThat(history.get(1).getText()).isEqualTo("根分区还剩 30G"); - } - - @Test - void 还原带工具调用的完整一轮() { - // assistant 发起一个 execCommand 调用,随后一条 tool 结果 - String toolCallsJson = "[{\"id\":\"call_1\",\"type\":\"function\"," - + "\"name\":\"execCommand\",\"arguments\":\"{\\\"command\\\":\\\"df -h\\\"}\"}]"; - List rows = List.of( - row("user", "看下磁盘", null, null), - row("assistant", "", toolCallsJson, null), - row("tool", "Filesystem ... 30G", null, "call_1"), - row("assistant", "根分区还剩 30G", null, null)); - - List history = serviceReturning(rows).loadHistory(1L); - - assertThat(history).hasSize(4); - // 第二条 assistant 带 tool_calls - AssistantMessage assistant = (AssistantMessage) history.get(1); - assertThat(assistant.getToolCalls()).hasSize(1); - assertThat(assistant.getToolCalls().get(0).id()).isEqualTo("call_1"); - assertThat(assistant.getToolCalls().get(0).name()).isEqualTo("execCommand"); - // 第三条是工具结果,id 与调用配对 - assertThat(history.get(2)).isInstanceOf(ToolResponseMessage.class); - ToolResponseMessage trm = (ToolResponseMessage) history.get(2); - assertThat(trm.getResponses()).hasSize(1); - assertThat(trm.getResponses().get(0).id()).isEqualTo("call_1"); - } - - @Test - void 同一轮多条tool结果合并为一个ToolResponseMessage() { - List rows = List.of( - row("assistant", "", "[{\"id\":\"c1\",\"type\":\"function\",\"name\":\"execCommand\",\"arguments\":\"{}\"}]", null), - row("tool", "结果1", null, "c1"), - row("tool", "结果2", null, "c2")); - - List history = serviceReturning(rows).loadHistory(1L); - - // 1 条 assistant + 1 条合并后的 ToolResponseMessage(含 2 个 response) - assertThat(history).hasSize(2); - ToolResponseMessage trm = (ToolResponseMessage) history.get(1); - assertThat(trm.getResponses()).hasSize(2); - } - - @Test - void 坏的toolCallsJson退化为纯文字assistant() { - List rows = List.of( - row("assistant", "兜底文字", "{不是合法json", null)); - - List history = serviceReturning(rows).loadHistory(1L); - - assertThat(history).hasSize(1); - AssistantMessage assistant = (AssistantMessage) history.get(0); - assertThat(assistant.getMessageType()).isEqualTo(MessageType.ASSISTANT); - assertThat(assistant.getText()).isEqualTo("兜底文字"); - // 反序列化失败:退化成无 tool_calls - assertThat(assistant.getToolCalls()).isEmpty(); - } -}