diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..9e683b911768ad14541e2355decf191094a8a249 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,25 @@ +# DAEDALUS_AGENT 项目协作规则 + +## 功能清单同步 + +- 本规则仅适用于本仓库 `E:\CODE\daedalus-agent` 及其子目录,不是全局 Codex 规则。 +- 每次新增、删除或调整系统功能时,必须在同一变更中同步更新 [`doc/system-feature-list.md`](doc/system-feature-list.md)。 +- 需要同步记录的功能变更包括但不限于:管理控制台页面或 API、Agent/模板/Skill/LLM/MCP/业务系统能力、权限和安全策略、CLI 命令或协议、MQTT 通道行为、Docker 启动/运行/持久化机制、外部集成、可选 Skill,以及用户可观察的运维行为。 +- 删除功能时,应从清单中移除已不存在的能力,或在“当前边界与使用前提”中明确标记为不支持;调整功能时,应同时更新功能描述、限制条件和测试验证范围。 +- 更新清单前必须以当前源码和测试为依据。只有源码实现的能力标记 `[x]`,有测试证据的能力追加 `[T]`,限制或明确不支持的能力使用 `[!]`;不要把设计文档中的规划项写成已实现功能。 +- 若功能变更新增或改变测试,应在清单“测试验证范围”中补充对应测试位置或验证内容;若影响既有章节,应同步修正相关条目,避免只追加重复描述。 +- 纯重构、格式调整、仅测试工具改动或不改变系统行为的依赖升级可以不更新功能清单;但只要行为、接口、配置、限制或运维流程发生变化,就必须更新。 +- 提交或交付前检查 `git diff`,确认功能代码和 `doc/system-feature-list.md` 的修改处于同一变更上下文;最终说明中应注明清单是否已同步,若未同步需给出“不涉及功能行为”的理由。 + +## 功能设计与测试同步 + +- 每次新增、删除或调整功能设计时,必须在同一变更中同步检查并修改对应测试脚本;不得只修改功能设计或实现而继续使用过时的测试脚本。 +- 需要同步的测试脚本包括但不限于:单元测试、接口/API 测试、集成测试、端到端/浏览器测试、回归测试、CLI 测试、MQTT/协议测试、Docker/容器测试、外部集成测试及专项验证脚本。 +- 应根据功能设计变更同步更新测试场景、断言、测试数据、鉴权方式、配置项和执行入口;既有测试无法覆盖新行为时,必须补充对应测试。 +- 提交或交付前应检查功能设计、实现和测试脚本是否一致;若某类测试经评估无需修改,必须在交付说明中注明检查结果和理由。 + +## 文档风格 + +- 功能清单使用中文,保持现有章节结构和状态标记风格。 +- 优先修改已有条目,避免为同一能力创建重复条目。 +- 文档中的路径、命令、API、环境变量和状态值使用反引号标记;示例必须与当前源码一致。 diff --git a/README.md b/README.md index 2ec47e0016aa13a09ebaee326293706447af8990..800c220c2aedcaeb693fa284a9873ec1f30baf51 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,6 @@ -# 快速部署手册 +# 企业级 AI 智能体集群平台 -解决方案由一体化容器和对话应用构成,一体化容器中内置智能体集群构建与管理所需的全部组件,对话应用使用活字格构建,提供实现对话式交互。 - -## 推荐环境 - -- x64 架构 -- 4C8G 或更高配置 -- Linux / WSL / Docker Desktop - -## 步骤1:启动容器 - -```bash -docker volume create daedalus-agent-data -docker run -d --restart unless-stopped --name daedalus-agent --cap-add NET_ADMIN -p 3030-3050:3030-3050 -p 1883:1883 -p 9000:9000 -v daedalus-agent-data:/var/daedalus-agent crpi-4auaoyyj6r36p6lb.cn-hangzhou.personal.cr.aliyuncs.com/huozige_lab/daedalus-agent-x64:[版本号] -``` - -`[版本号]` 请参考项目 Release,例如 `20260804.01`。运行数据保存在 Docker volume `daedalus-agent-data` 中;如需隔离多套环境,请替换为新的 volume 名称。详细说明见 [docker/README.md](./docker/README.md)。 - -## 步骤2:登录管理控制台 - -容器启动后,等待 Docker logs 出现 `management console should be available ...`,然后访问宿主机 `3030` 端口,例如: - -```text -http://127.0.0.1:3030 -``` - -启动命令会开放 `3030-3050`。其中 `3030` 当前用于管理控制台,`3031-3050` 作为后续服务预留端口。 - -默认账号: - -- 用户名:`admin` -- 初始密码:`admin` - -首次登录后请按提示修改密码。 - -## 步骤3:配置大语言模型 - -在管理控制台“大语言模型”菜单中依次创建服务商和模型。 - -服务商需要: - -- 服务商 ID -- OpenAI 兼容 API 基础地址,通常以 `/v1` 结尾 -- API Key - -模型需要: - -- 所属 Provider -- 模型 ID -- 名称 -- 上下文长度、输入上限等参数 - -如果模型服务位于内网,需要在管理控制台 `系统设置` 页面中,将该服务器的地址(如 `172.16.11.12`)添加到白名单。需要注意的是,白名单的变更需要重启容器方可生效,而不是重启服务。 - -## 步骤4:接入业务系统 - -首先,在管理控制台 `系统设置` 页面中,将业务系统和对话应用所在的服务器 IP 地址或域名(对话应用和业务系统需要部署在同一台服务器中)设置到 `对话应用/业务应用服务器`。服务器地址保存到系统数据库后会立即用于业务调用;IP 地址会同步到 IP 白名单,白名单变更需重启容器后生效;域名按外网地址处理,不加入白名单。 - -然后,在“业务系统”菜单中添加接入的业务系统。活字格系统通常需要填写: - -- 应用名 -- 端口 -- 是否启用 HTTPS -- Ontology zip - -Ontology zip 需通过 `huozige-ontology-builder` 构建,[点击阅读使用指南](https://www.npmjs.com/package/huozige-ontology-builder)。 - -## 步骤5:添加数字员工 - -在“数字员工”菜单中创建数字员工。通常需要配置: - -- ID -- 模板 -- 名称 -- 服务商 / 模型 -- 每日 Token 限额 -- 授权用户 - -创建完成后,点击 `管理用户` 按钮,将该数字员工分配和活字格管理控制台上的用户。此处需要输入的是用户名,而不是用户全名。 - -## 步骤6:对接对话应用 - -对话应用采用活字格构建,可使用 `https://gitee.com/low-code-dev-lab/daedalus-agent-cui-hzg`。 - -原则上,对话应用仅需要配置以下四个全局变量: - -- `DA_SERVER_IP` -- `DA_MQTT_CHANNEL_PASSWORD` -- `DA_S3_SECRET_ACCESS_KEY` -- `DA_MANAGEMENT_CONSOLE_TOKEN` - -MQTT/MinIO 密码可在 `系统设置` 页面中 `基础设置` 选项卡获取;管理控制台 Token 在 `访问令牌` 选项卡中创建。 - -接下来,检查以下全局变量的值是否和预设值相符,否则可能会导致系统无法正常工作。 - -|全局变量|预设值|需要修改的场景| -|----|----|----| -|DA_MQTT_CHANNEL_PORT|1883|docker启动脚本中修改过MQTT的端口映射,通常用于规避端口占用,下同| -|DA_S3_PORT|9000|docker启动脚本中修改过S3的端口映射| -|DA_MANAGEMENT_CONSOLE_PORT|3030|docker启动脚本中修改过管理控制台的端口映射| -|DA_MQTT_CHANNEL_USERNAME|daedalus-agent|除非必要,不要修改,下同| -|DA_S3_ACCESS_KEY_ID|daedalus-agent|-| -|DA_S3_REGION|local|-| -|DA_S3_FORCE_PATH_STYLE|true|-| -|DA_S3_AGENT_INBOX_BUCKET|daedalus-agent-inbox|-| -|DA_S3_AGENT_OUTBOX_BUCKET|daedalus-agent-outbox|-| -|MQTT_CONNECTION_NAME|daedalus_conn_|-| -|MQTT_TOPIC_FILTER|daedalus-agent/agent/#|-| - -重启应用后,就可以登录对话应用,开始使用您的数字员工了。 - -## 性能测试 - -- 测试日期:2026-08-06 -- 测试环境:2C4G,100 个数字员工 -- LLM:本地模拟,在 4-6 秒之间随机等待后返回,理论均值和中位数均为 5 秒,容量 128 -- 测试规格: - - 用户 x 1000,每个数字员工关联 10 个用户 - - 数字员工模板 x 100,每个工作区模板 100 个文件 - - 技能 x 100 - - 系统 x 100 -- 测试计划:每档预热 30 秒,稳态压测 15 分钟;并发档位为 5、10、30、100 -- 测试结论:10 并发下,2C4G 的配置可以正常使用;更高并发量不会导致错误,但体验下滑严重。 - -| 并发 | 结果 | 稳态成功/请求 | 失败 | 吞吐 req/s | Avg | P50 | P90 | P99 | CPU 峰值 | 内存峰值 | -| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | -| 5 | 通过 | 707/707 | 0 | 0.79 | 6.370s | 6.344s | 7.487s | 8.467s | 198.26% | 2.33GiB | -| 10 | 通过 | 1213/1213 | 0 | 1.35 | 7.411s | 7.280s | 8.758s | 10.435s | 203.01% | 2.57GiB | -| 30 | 通过 | 1109/1109 | 0 | 1.23 | 24.347s | 23.691s | 27.510s | 46.764s | 201.26% | 3.23GiB | -| 100 | 通过 | 1110/1110 | 0 | 1.23 | 79.367s | 78.747s | 90.629s | 120.325s | 199.54% | 3.99GiB | - -详细测试报告见 [DAEDALUS_AGENT-100a-2C4G-stress-report.md](./doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md)。 - -## 本地开发文件 - -本地 `.env`、`.env.*`、测试运行配置、运行状态、报告和常见构建缓存均不应提交到版本库。仓库允许提交 `.env.example` 和 `.env.*.example` 作为脱敏模板;真实 API Key、MQTT/S3 凭据和访问令牌只能写入被 `.gitignore` 覆盖的本地文件或仓库外临时配置。 - -集成与压测的本地配置及运行文件约定见 [test/README.md](./test/README.md),Docker 构建与测试说明见 [docker/BUILD.md](./docker/BUILD.md)。 - -## 常见故障排查 - -### 管理控制台无法打开 - -从 Docker 启动到管理控制台就绪通常需要花费数十秒时间,视服务器性能而定,如等待一分钟后重试仍无法打开,说明服务出现故障,请执行 `docker logs daedalus-agent` 命令检查。 - -- 端口冲突:在日志中发现 `bind: Address already in use` 说明端口被占用,您需要检查服务器上是否有其他容器或服务占用了该端口,如果无法排除(如您需要在同一台服务器上运行多个相互硬隔离的 deadalus agent),可以先移除容器,修改 docker 的启动脚本,映射到其他端口号后重新启动,最后同步修改对话应用的全局变量中对应端口号的值。 -- 其他错误:如在日志中没有找到端口冲突的标识性文字,这通常说明遇到了其他错误,请与我们联系排查。 - -### 对话应用提示:Connecting with MQTT server failed (BadUserNameOrPassword). - -检查对话应用全局配置中的 `DA_MQTT_CHANNEL_USERNAME` 和 `DA_MQTT_CHANNEL_PASSWORD`,确保和管理控制台中【系统设置】页面显示的运行凭据一致。重点检查内容是否完整,中间是否有多余的空格。 - -### 对话应用中发送消息后无应答 - -如果发送消息后,5分钟依然无应答,且无法通过重启对话应用来修复,说明通讯链路可能存在异常,请按照以下顺序进行排查: - -1. 在管理控制台的【系统设置】页面中【系统日志下载】选项卡,下载当前日期的 `交互日志`,检查是否存在 `"direction":"IN"` 的入站日志和 `"direction":"OUT"` 的出站日志。 -2. 如果没有进站日志,则检查对话应用全局配置中的 `DA_MQTT_CHANNEL_USERNAME` 和 `DA_MQTT_CHANNEL_PASSWORD`;如果有入站日志,但没有出站日志,则需要在管理控制台的【数字员工】页面中,点击该员工,通过右上角的【对话测试】,检查大语言模型配置是否正确。 -3. 如果存在进出站日志,请检查对话应用全局配置中的 `MQTT_TOPIC_FILTER`。 +- [快速部署手册](/doc/install-guide.md) +- [MQTT消息协议](/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/README.md) +- [系统功能清单](/doc/system-feature-list.md) +- [性能测试报告](/doc/100a-2C4G-stress-report.md) diff --git a/doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md b/doc/100a-2C4G-stress-report.md similarity index 100% rename from doc/DAEDALUS_AGENT-100a-2C4G-stress-report.md rename to doc/100a-2C4G-stress-report.md diff --git a/doc/agent-ecosystem-research-report.md b/doc/agent-ecosystem-research-report.md new file mode 100644 index 0000000000000000000000000000000000000000..15be080e15c6648e8cc835abe87e628bcffb16f0 --- /dev/null +++ b/doc/agent-ecosystem-research-report.md @@ -0,0 +1,648 @@ +# Agent 生态现状与低代码平台切入方案 + +> 调研日期:2026-08-18 +> 重点案例:DAEDALUS_AGENT、Codex、WorkBuddy、OpenClaw +> 研究范围:企业 Copilot、AI Workflow、Computer Use、Research Agent、语音实时 Agent、垂直领域 Agent、多 Agent、设备/边缘 Agent、Runtime/Gateway、Agent Builder/Platform,以及横向的评估与治理能力。 +> 目标:为低代码开发平台设计接入 Agent 生态的产品边界、技术分层和落地路线。 + +## 摘要 + +Agent 生态已经从单一聊天入口扩展成一组相互连接的系统:模型负责推理,工具负责访问数据和执行动作,Workflow 负责确定性编排,Agent Runtime 负责会话和长任务,MCP 等协议负责连接能力,治理层负责身份、权限、审批、审计和成本控制。 + +目前较稳定的行业共识集中在两个分界上: + +1. **Workflow 与 Agent 分开描述。** Anthropic 对两者的定义很清楚:Workflow 通过预先定义的代码路径编排模型和工具;Agent 由模型动态决定过程和工具使用。实际产品通常采用混合结构,在预定义步骤和动态决策之间取平衡。[Building effective agents](https://www.anthropic.com/research/building-effective-agents) +2. **MCP 与 Agent-to-Agent 分工。** MCP 面向模型或 Agent 与工具、数据源、资源的连接;A2A 面向独立 Agent 之间的发现、委派、任务状态和成果交换。A2A 规范也明确把两者视为互补协议。[MCP](https://modelcontextprotocol.io/introduction) · [A2A Specification](https://a2a-protocol.org/latest/specification/) + +对低代码平台而言,最有价值的入口是把应用元数据、业务对象、用户权限、流程引擎和页面运行时变成 Agent 可以安全调用的能力,再让外部 Agent 进入设计器、应用和流程。复制一个通用聊天工作台无法充分利用这些资产。 + +推荐的产品切入顺序是: + +1. **Tool 与 Context 层**:用 MCP、OpenAPI 和 OAuth/OIDC 暴露业务对象、页面上下文和动作。 +2. **Agent Run 层**:在流程中调用外部 Agent,统一处理异步任务、回调、取消、重试、成果和费用。 +3. **AI Workflow 混合层**:让 Workflow 调用 Agent、让 Agent 调用受控子流程,并保留人工审批和业务事务边界。 +4. **应用内嵌 Agent 层**:在已有低代码应用中提供带页面和权限上下文的 Agent 组件。 +5. **开发设计 Copilot 层**:帮助开发者生成应用变更集,经过预览、校验和发布后才影响项目。 + +完整的 Agent 托管、事件驱动数字员工和多 Agent 编排可以作为后续平台能力。第一阶段应避免与 WorkBuddy 争夺通用办公入口,也避免把低代码平台改造成一个没有业务边界的聊天产品。 + +## 1. 调研口径与术语 + +### 1.1 资料口径 + +本报告使用四类资料: + +- 行业方法资料:Anthropic 对 Workflow/Agent 的架构区分,MCP 和 A2A 官方规范。 +- 厂商官方资料:Codex 开发者文档、OpenAI Agent、Computer Use、Deep Research、Voice、编排和安全文档,WorkBuddy 官方概览,OpenClaw 官方文档与仓库 README。 +- 用户提供的《WorkBuddy 能力简报》:用于补充 WorkBuddy 的内部推广口径。简报中的“140+ 专家、100+ 连接器、100+ 技能”等数字按简报声称处理,官方概览页没有逐项复现这些数量。 +- 当前仓库:`README.md`、`doc/system-feature-list.md`、Docker 规格、管理控制台和插件源码。仓库功能清单声明只描述源码和测试已经验证的实现,不把设计文档中的未来规划当作已交付功能。 + +### 1.2 术语分层 + +| 名称 | 主要职责 | 是否动态决定下一步 | 典型接口 | +| --- | --- | --- | --- | +| 模型调用 | 生成文本、结构化结果或模型决策 | 否,调用方控制流程 | Chat Completions、Responses、模型 SDK | +| AI Workflow | 按固定步骤、分支、循环和审批编排模型与工具 | 通常否,路径由流程定义 | Workflow API、队列、Webhook、BPMN/流程 DSL | +| Agent | 根据目标、上下文和工具结果动态选择下一步 | 是,在授权边界内动态决定 | Agent Run API、Gateway、SDK | +| Tool / Function | 执行一个明确的查询或动作 | 否 | OpenAPI、Function Calling、MCP Tool | +| Skill / Plugin | 给 Agent 增加可复用的说明、资源、工具或运行代码 | 本身不决定整体任务 | Skill 包、插件 SDK、MCP Server | +| Agent Runtime | 管理会话、上下文、工具调用、执行状态和事件 | 负责运行,不等同于业务 Agent | Gateway、Worker、Sandbox、事件流 | +| Multi-Agent | 多个 Agent 通过委派或编排共同完成任务 | 取决于组织者 Agent 或 Workflow | A2A、内部委派协议、Supervisor/Worker | + +“Agentic system”可以作为总称,包含 Workflow、Agent、工具和治理组件。低代码产品设计时应保留这些边界,否则一个“AI 节点”会同时承担模型调用、工作流、权限和运行时职责,后续难以测试和审计。 + +本报告使用两层分类标准:第四章的“生态方向”描述 Agent 解决的任务和运行环境,例如 Research Agent、Voice Agent 或 Device Agent;第三章的六种“接入形态”描述谁调用谁、发生在设计期还是运行期。一个 Research Agent 可以作为 C 类 Workflow 服务节点,也可以作为 B 类应用内嵌 Agent;一个 Runtime/Gateway 可以支撑 C、D、F 多种接入。治理、评估和安全横跨两层,属于平台底座能力。 + +## 2. Agent 生态的行业共识 + +### 2.1 Workflow 和 Agent 是连续谱上的两种执行方式 + +Anthropic 将 Workflow 描述为预先定义的代码路径,将 Agent 描述为模型动态指导过程和工具使用。两者之间还存在大量混合形态: + +| 混合方式 | 运行方式 | 低代码对应物 | +| --- | --- | --- | +| Workflow 调用 Agent | 流程负责顺序、权限和事务,某个节点把任务交给 Agent | “调用 Agent”节点 | +| Agent 调用 Workflow | Agent 选择一个经过验证的子流程,子流程按固定步骤执行 | Workflow 作为受控 Tool | +| Agent 生成 Workflow 草案 | Agent 根据自然语言生成流程变更,人工预览和发布 | 流程设计 Copilot | +| Supervisor + 子流程 | 监督 Agent 负责路由,财务、数据、通知等子流程负责执行 | Agent 编排器 + Workflow 子图 | +| Manager + Agent as Tool | Manager 保留最终回答权,Specialist 只返回受控结果 | Manager 节点 + Specialist Tool | +| Handoff to Specialist | 当前 Agent 把对话控制权交给更适合的 Specialist | Handoff 路由 + 独立会话上下文 | +| Evaluator-Optimizer | 一个执行节点产出结果,另一个评估节点检查并要求重做 | 评估/重试节点 | + +生产系统应让 Workflow 持有业务流程状态,让 Agent Run 持有模型任务状态。二者通过 `workflow_instance_id`、`agent_run_id` 和 `correlation_id` 关联。 + +拆分 Agent 的理由应能在能力隔离、策略隔离、提示边界或 Trace 可读性中找到。低代码设计器可以把“作为 Tool 调用”和“交接给 Specialist”做成两种不同节点,分别对应主控 Agent 保留最终回答权和对话控制权转移。 + +### 2.2 工具连接和 Agent 协作是两种不同问题 + +低代码平台至少需要准备两套互补能力: + +| 能力 | 解决的问题 | 适用协议 | 低代码平台要暴露或消费的对象 | +| --- | --- | --- | --- | +| Tool/Context 连接 | Agent 如何读取数据、发现工具和执行动作 | MCP、OpenAPI、Function Calling | 工具、资源、提示模板、Schema、权限 | +| Agent 委派 | Agent 如何发现另一个 Agent、提交任务、接收进度和成果 | A2A 或内部 Agent Run 协议 | Agent Card、Task、Message、Artifact、Capability | + +MCP 适合把“查询客户”“创建审批”“读取页面上下文”这类能力发布给 Agent。它自身不负责长任务的审批、预算、取消和业务事务。A2A 适合 Agent 之间的任务委派,但它也不会替代低代码平台的用户权限和业务对象授权。 + +### 2.3 长任务需要统一的 Run 生命周期 + +Agent 一旦执行时间超过一次 HTTP 请求,就需要独立的运行对象。最低限度应支持: + +```text +created -> queued -> running -> waiting_approval -> waiting_input + -> completed / failed / cancelled / expired +``` + +每次运行应有: + +- 发起人和委托身份。 +- 目标 Agent、模型和版本。 +- 输入上下文引用,不把所有业务数据复制进 Prompt。 +- Tool 授权和审批记录。 +- 事件流、步骤结果、错误和重试次数。 +- 最终文本、结构化数据和文件成果。 +- Token、费用、耗时和资源用量。 +- 幂等键,防止网络重试重复写入业务系统。 + +没有 Run 生命周期时,低代码平台很容易把 Agent 当成一个不稳定的同步 HTTP 节点,遇到超时、重复执行或人工审批就无法恢复。 + +### 2.4 生态产品正在分化成多个表面 + +一个成熟的 Agent 产品通常同时拥有以下表面: + +| 表面 | 面向谁 | 主要职责 | +| --- | --- | --- | +| Chat/UI | 最终用户 | 提出目标、确认操作、查看成果 | +| IDE/设计器 | 开发者 | 理解项目、生成变更、运行检查 | +| CLI/脚本 | 工程团队 | 批处理、自动化和 CI | +| Gateway/Runtime | 平台 | 连接模型、渠道、工具和会话 | +| MCP/插件 | 生态开发者 | 扩展工具、数据和界面 | +| SDK/App Server | 产品集成方 | 在自己的系统中嵌入 Agent | +| 管理控制台 | 管理员 | 模型、用户、权限、配额、审计和版本 | + +WorkBuddy 侧重用户工作台,Codex 侧重研发工作流,OpenClaw 侧重本地 Gateway 和渠道运行,DAEDALUS_AGENT 侧重企业数字员工管理。这些产品形态不同,但都在向多个表面扩展。 + +## 3. 面向低代码平台的六种接入形态 + +六种形态回答的是“谁接入谁”,用于描述集成关系;它们可以组合在同一个产品中。 + +| 形态 | 调用方向 | 接入阶段 | 接入对象 | 低代码平台的产品能力 | +| --- | --- | --- | --- | --- | +| A. 开发设计 Copilot | 低代码设计器调用开发 Agent | 开发设计阶段 | 项目元数据、页面、数据表、流程、公式、代码 | 项目上下文、结构化变更集、预览、校验、Diff、回滚 | +| B. 应用内嵌 Agent | 低代码应用承载 Agent | 应用运行阶段 | 页面、当前记录、用户、租户、业务动作 | Agent 组件、页面上下文、会话、权限和人工确认 | +| C. Agent 服务节点 | Workflow 调用外部 Agent | 流程执行阶段 | Agent Run、状态、结果、文件和回调 | Agent Connector、异步节点、Schema、重试和超时 | +| D. 事件驱动数字员工 | 事件系统触发 Agent Runtime | 无人值守阶段 | 定时器、Webhook、消息、业务事件 | 调度器、队列、长期状态、幂等、人工接管 | +| E. 低代码平台作为 Agent Tool | 外部 Agent 调用低代码平台 | Agent 执行阶段 | 数据、业务对象、页面、流程和文件 | MCP Server、OpenAPI、Tool Catalog、OAuth、审计 | +| F. Agent 创建与托管平台 | 低代码平台接入或托管 Agent Runtime | 平台管理阶段 | Agent、模型、Skill、知识、权限、Sandbox、配额 | Agent 生命周期、模板、运行、治理和分发 | + +一个平台可以同时覆盖 A 到 F。例如,设计器使用 Codex 完成 A,业务应用嵌入一个 Agent 完成 B,订单流程调用外部调研 Agent 完成 C,外部 WorkBuddy 通过 MCP 查询低代码系统完成 E,平台再通过 F 管理企业自建员工。 + +### 3.1 AI Workflow 应放在六种形态的上层 + +AI Workflow 位于六种接入关系之上,承担连接 A、B、C、D、F 的编排职责: + +```mermaid +flowchart LR + Trigger[业务事件或用户请求] --> Workflow[低代码 Workflow] + Workflow --> AgentNode[Agent Run 节点] + Workflow --> ToolNode[确定性业务动作] + AgentNode --> Approval[人工审批或补充信息] + AgentNode --> ToolNode + ToolNode --> Business[业务对象与外部系统] + AgentNode --> Artifact[文本、结构化数据和文件成果] + Approval --> Workflow +``` + +推荐的融合规则: + +1. **Workflow 负责业务状态和确定性动作。** 订单状态、审批结果、写入事务和通知记录由流程引擎管理。 +2. **Agent 负责理解、规划和非结构化处理。** 例如从合同提取风险项、根据上下文选择知识来源、生成下一步建议。 +3. **Agent 的副作用通过 Tool 或 Workflow 节点发生。** Agent 不应直接连接数据库,也不应绕过低代码权限。 +4. **长任务使用 Agent Run。** Workflow 进入等待状态,Agent 通过事件或回调回报进度和成果。 +5. **Agent 生成流程时只提交变更草案。** 设计者预览、校验、测试并发布后,流程定义才改变。 + +## 4. 生态分层与技术成熟度 + +| 层 | 主要对象 | 当前行业状态 | 低代码平台的判断 | +| --- | --- | --- | --- | +| 模型层 | 通用模型、推理模型、视觉和多模态模型 | 供应商变化快 | 采用 Provider/Model 抽象,避免业务绑定单一模型 | +| Agent Runtime | 会话、上下文、工具循环、Sandbox、执行队列 | 产品差异明显 | 低代码平台需要统一 Run 接口,不必先自研所有 Runtime | +| Workflow 层 | 顺序、分支、循环、审批、补偿、定时 | 企业软件成熟 | 这是低代码平台已有的核心资产,应保留为事实来源 | +| Tool/Context 层 | 业务对象、文件、页面、知识和动作 | MCP 正在形成广泛互操作入口,OpenAPI 仍是基础 | 优先构建 MCP Server + OpenAPI 双出口 | +| Agent 协作层 | 委派、Agent Card、任务、成果、流式进度 | A2A 处于快速演进阶段 | 先建立内部 Agent Run,再增加 A2A 适配器 | +| 交互层 | Chat、组件、IDE、CLI、消息渠道 | 多表面并存 | 低代码平台应支持组件和流程入口,不只做聊天窗口 | +| 治理层 | 身份、权限、审批、审计、用量、评估、数据保留 | 企业采购的关键差异 | 直接复用低代码的租户、用户和权限体系 | +| 分发层 | Skill、Plugin、模板、Agent 市场 | 生态尚未统一 | 先做签名、版本和权限,再做开放市场 | + +### 4.1 其他 Agent 方向:按任务和运行边界细分 + +“Agent”在行业交流中经常同时指产品入口、任务模式和运行底座。为了便于产品决策,建议使用四个维度描述一个具体方案: + +- **自治程度**:建议生成、受限执行、委派执行、无人值守、物理执行。 +- **任务形态**:结构化业务任务、非结构化知识任务、多模态交互、现实世界动作。 +- **交互方式**:设计器、应用内组件、异步任务、消息渠道、设备控制。 +- **运行归属**:应用运行时、企业平台、个人设备、边缘设备或机器人。 + +在这个口径下,以下方向都属于 Agent 生态的重要组成部分。它们可以共享同一个 Runtime,也可以通过 Workflow 组合,多个方向也可以出现在同一个产品中。 + +| 方向 | 解决的问题 | 典型交互 | 自治程度与运行边界 | 常见协议或技术形态 | 对低代码平台的价值 | +| --- | --- | --- | --- | --- | --- | +| AI Workflow / Agent Workflow | 把模型节点、工具、分支、循环和审批组合成可重复任务 | 表单、流程图、Webhook、队列、人工审批 | 受限执行;流程定义掌握状态和副作用 | Workflow DSL、BPMN、队列、Webhook、结构化输出 | 把现有流程引擎升级为可插入 Agent 的业务编排层 | +| 企业 Copilot / Embedded Agent | 在业务页面或工作台中减少查询、理解和操作成本 | 对话、建议动作、当前记录问答、生成草稿 | 用户发起;动作范围由页面权限和确认策略约束 | 组件 SDK、Function Calling、MCP、OAuth/OIDC | 复用页面、用户、租户和业务对象上下文,形成应用粘性 | +| Computer Use / Browser Agent | 操作没有 API 或跨多个网页的界面 | 截图、点击、输入、滚动、文件上传 | 受限执行到高自治;需要隔离浏览器、域名和动作白名单 | Computer Use API、浏览器自动化、VM/容器、人工确认 | 为遗留系统提供过渡接入;适合低频、可回放、可审批任务 | +| Deep Research / Research Agent | 多源检索、交叉验证、长文档分析和带引用的报告生成 | 提出研究目标,查看进度、来源、引用和成果文件 | 长任务、读多写少;外部内容属于不可信输入 | Web Search、Remote MCP、File Search、代码解释器、后台任务 | 为业务调查、合规核查、市场分析提供结构化研究节点 | +| Voice / Realtime Agent | 处理电话、会议、现场语音和实时问答 | 语音对话、打断、转接、实时字幕 | 会话级自治;涉及承诺、付款或工单变更时需要审批 | Speech-to-Speech Realtime、语音链式 Pipeline、SIP/WebRTC | 将客服、现场服务和审批流程接入已有应用与流程 | +| 垂直领域 Agent | 在软件开发、财务、法务、客服等领域提供深度任务能力 | 领域指令、专业资料、结构化结果和业务动作 | 领域约束强;工具和知识范围通常固定 | 领域模型、RAG、MCP/OpenAPI、策略引擎、模板 | 低代码平台可沉淀行业对象、规则、表单和流程,形成可配置领域 Agent | +| Multi-Agent / Supervisor | 将复杂任务拆给专业 Agent,并汇总成果 | 路由、委派、并行、交接、结果合并 | 由 Workflow 或 Supervisor 控制;每个子 Agent 仍受独立授权 | A2A、Agent Card、内部委派、Supervisor/Worker | 跨应用、跨组织或跨供应商协作时提供统一任务和权限边界 | +| Device / Edge / Robotics Agent | 在设备、工厂、车辆或机器人上感知和执行 | 摄像头、传感器、语音、设备指令、现场反馈 | 高风险、实时或物理执行;需要安全联锁和本地降级 | MQTT、ROS、设备 SDK、边缘 Runtime、策略网关 | 为 IoT、制造和现场服务应用提供受控设备动作入口 | +| Agent Runtime / Gateway | 管理会话、工具循环、渠道、队列、沙箱和长任务 | API、WebSocket、事件流、CLI、控制台 | 运行底座;自治由上层 Agent 和策略决定 | Gateway API、事件总线、Worker、Sandbox、Run ID | 让低代码平台接入可替换 Runtime,并统一状态、日志和资源用量 | +| Agent Builder / Agent Platform | 定义、测试、发布、版本化和托管 Agent/Workflow | 可视化设计器、模板、预览、评估、部署 | 平台级控制;支持版本、环境、回滚和分发 | 类型化节点、SDK、配置仓、CI/CD、Registry | 将 Agent 变成低代码平台可治理、可复用、可交付的对象 | + +OpenAI 文档中的 Agent Builder 是 Agent Workflow Builder 类产品的一个实例,当前处于弃用过渡,计划于 `2026-11-30` 关闭。这个事实说明“可视化 Agent 编排器”仍是行业形态,具体产品和接口会变化,低代码平台应抽象自己的 Workflow、版本和发布模型。 + +评估、治理和安全属于横向能力,覆盖上述所有方向。它们不应单独包装成一种 Agent。一个方案只有同时说明任务、自治范围、运行环境、工具权限和质量闭环,才具备可评审的产品边界。 + +### 4.2 Agent 的生命周期 + +低代码平台可以把 Agent 和 Workflow 视为可版本化的产品对象,生命周期至少包括以下阶段: + +| 阶段 | 核心动作 | 应保存的对象 | 低代码平台对应能力 | +| --- | --- | --- | --- | +| Define | 明确目标、触发方式、输入、输出和禁止动作 | AgentDefinition、任务说明、输出 Schema | 设计器、模板和能力边界 | +| Configure | 绑定模型、知识、Tool、上下文、身份和预算 | ModelBinding、ToolGrant、ContextPolicy | 连接器、权限和配额配置 | +| Validate | 用样例、评估集和安全用例验证行为 | Dataset、Trace、Grader、ValidationResult | 测试环境、回放和静态校验 | +| Publish / Version | 固化提示、工具版本、策略和依赖 | Version、Release、Environment | 草稿、发布、灰度和回滚 | +| Run | 接收目标,执行工具循环,产出结果或成果文件 | AgentRun、RunEvent、Artifact、UsageRecord | 异步节点、事件流、幂等和重试 | +| Approve / Interrupt | 等待用户确认、补充信息或管理员接管 | ApprovalRequest、Intervention、Decision | 审批节点、暂停、恢复和人工接管 | +| Observe / Evaluate | 查看轨迹、错误、费用、成功率和用户反馈 | Trace、EvaluationRun、AuditEvent | 监控、评估、告警和成本分析 | +| Retire | 停止新调用,保留可追溯记录并迁移依赖 | Deprecation、Migration、RetentionRecord | 版本下线、依赖检查和数据保留 | + +这套生命周期能把“提示词改了一行”和“业务流程变更”放在同一个可审计框架内,也能为外部 Agent、内嵌 Agent 和数字员工使用统一的运行对象。 + +### 4.3 安全、身份和质量的共同底线 + +官方安全指南和企业落地经验反复出现同一组问题:模型可能受到直接或间接 Prompt Injection,工具描述和返回内容可能携带恶意指令,过宽的工具权限会产生 Excessive Agency,跨租户或跨用户调用会造成私有数据泄漏。低代码平台应把这些问题设计成运行时策略,提示词只能作为补充。 + +| 风险 | 典型表现 | 低代码平台应提供的控制 | +| --- | --- | --- | +| Prompt Injection | 用户输入、网页、文件或检索内容改变任务目标 | 不可信内容标记、指令与数据分离、工具前置策略、人工确认 | +| 间接注入 | Agent 读取的邮件、网页、文档要求它调用危险工具 | 外部内容隔离、来源记录、敏感动作二次确认、输出 Schema 校验 | +| 私有数据泄漏 | 把一个用户或租户的数据带入另一个 Run | 委托身份、租户边界、字段级权限、上下文最小化和脱敏 | +| Excessive Agency | Agent 具备批量删除、付款、发布等超出任务所需的权限 | Tool allowlist、危险等级、最小权限、额度、审批和可撤销事务 | +| Tool Poisoning / 不可信 MCP | Tool 描述、Schema 或返回值诱导错误调用 | Server 信任清单、签名和版本、来源隔离、Schema 约束、沙箱 | +| 重试与重复副作用 | 网络超时后重复创建订单或提交审批 | 幂等键、业务版本号、去重表、补偿流程和人工核对 | +| 长任务失控 | 任务持续运行、费用超限或陷入工具循环 | 超时、步数和预算上限、熔断、暂停、取消和接管 | +| 结果不可验证 | 生成内容缺少来源,结构化字段不符合业务约束 | 引用和 Artifact、结构化输出、规则校验、Trace 和评估集 | + +OpenAI 的 Agent 评估文档把 Trace、grader、dataset 和 evaluation run 作为质量闭环;对于高风险流程,还需要记录工具调用、审批决定和数据传输。低代码平台应在设计期提供样例和评估,在运行期保留可回放轨迹,在发布期设置通过门槛。 + +NIST AI Risk Management Framework 和 OWASP《大语言模型应用十大风险》提供了跨厂商的风险管理词汇。Agent 系统还需要把这些风险落到 Tool 授权、委托身份、Run 状态、审批和可回放轨迹等运行时对象上。 + +## 5. 四个案例的详细位置 + +### 5.1 当前项目:企业数字员工托管与业务调用平台 + +DAEDALUS_AGENT 的核心定位是企业内部的 Agent 运行和治理平台。仓库说明它由一体化容器和独立对话应用组成;管理控制台负责模型、模板、数字员工、技能、MCP、业务系统、用户和日志,终端用户通过 CUI 或 MQTT 使用员工。 + +当前实现已经覆盖: + +- Docker 内的 OpenClaw Gateway、MQTT、MinIO、管理控制台和应用调用服务。 +- Provider/Model 管理、主模型和备选模型、每日 Token 限额。 +- 模板 ZIP、工作区、`AGENTS.md`、全局 Skill 和 Agent 专属 Skill。 +- Agent 用户授权、业务系统权限、MCP 权限、审计和调用日志。 +- 活字格 Ontology 业务系统和受控 `servercommand`/`binding` 调用。 +- Agent 工作区文件隔离、文件成果传输、图表图片生成等技能。 + +按六种形态映射: + +| 形态 | 当前状态 | 事实判断 | +| --- | --- | --- | +| A 开发设计 Copilot | 未形成产品入口 | 管理控制台没有面向低代码开发者的项目设计 Copilot、变更集和发布预览 | +| B 应用内嵌 Agent | 已有对话承载,页面上下文能力有限 | CUI/MQTT 可以承载对话,但当前项目没有通用的低代码页面 Agent 组件协议 | +| C Agent 服务节点 | 有内部调用和外部会话 API,缺少通用 Agent Run 产品 | 可以创建会话和发送消息,尚未形成面向任意外部 Agent 的统一长任务接口 | +| D 事件驱动数字员工 | 基础设施存在,产品能力未完成 | OpenClaw 有 heartbeat/cron 概念,但当前默认 heartbeat 为 `0m`,管理控制台没有用户任务调度器 | +| E 低代码平台作为 Agent Tool | 业务系统调用能力较强,生态协议出口有限 | Ontology 和 invoke service 已把活字格能力提供给 Agent;尚未形成面向外部 Agent 的通用 MCP Server 产品 | +| F Agent 创建与托管平台 | 当前最成熟的形态 | 模板、员工、模型、技能、权限、配额、日志和容器运维均有实现或测试覆盖 | + +当前项目的优势是权限、隔离、审计和活字格业务系统接入。它的短板是缺少通用 Workflow/Agent Run 层,以及对外的标准 Tool 出口。这两项正好是低代码平台可以借鉴和扩展的方向。 + +### 5.2 Codex:软件工程领域的多表面 Agent + +OpenAI 官方文档把 Codex 的开发面分为代码审查、集成终端、Skills/Plugins、环境、SDK、App Server、MCP Server、GitHub Action、非交互模式以及 GitHub、Slack、Linear 等集成。[Developers](https://learn.chatgpt.com/docs/developers.md) + +Codex 的主形态是软件工程领域的垂直 Agent,具体表面承担不同职责: + +| Codex 表面 | 对应形态 | 对低代码平台的启示 | +| --- | --- | --- | +| IDE/桌面工作面 | A 开发设计 Copilot | Agent 需要完整项目上下文、终端、变更预览和测试反馈 | +| CLI | C 任务型 Agent | 需要脚本化输入、结构化输出和非交互运行 | +| Cloud | C/D 长任务 Agent | 需要隔离环境、任务状态、日志和成果回传 | +| GitHub Action、Hooks | D 事件驱动 Agent | 代码事件可以触发审查、修复或发布流程 | +| SDK、App Server、MCP Server | C/E 集成服务 | Agent 能被嵌入其他系统,也能暴露为系统能力 | + +Codex 对低代码平台最有参考价值的地方是设计时协作:Agent 读取项目上下文,提出结构化修改,运行检查,再把结果交给开发者审阅。低代码平台应借鉴这个闭环,但把“代码变更”替换成页面、数据模型、权限、流程和服务配置的变更集。 + +### 5.3 WorkBuddy:面向个人用户的通用工作台 + +WorkBuddy 官方概览将其定位为全场景 AI 办公工作台,强调自然语言下达任务、自主规划执行、本地文件操作、文档/表格/PPT/数据分析、连接器、专家、技能、自动化和多人多 Agent 协作。[WorkBuddy 概览](https://www.workbuddy.cn/docs/workbuddy/Overview) + +用户提供的能力简报进一步描述了 Ask、Plan、Craft 三种模式,以及连接器、技能、专家、自动化和多模态内容生产能力。简报适合作为产品能力全景,具体数量不作为本报告的独立验证数据。 + +WorkBuddy 在六种形态中的位置: + +| 形态 | 适配程度 | 说明 | +| --- | --- | --- | +| A | 部分适配 | 它可以辅助开发和零代码制作本地应用,但官方概览的核心重点仍是办公工作台 | +| B | 部分适配 | 有桌面侧面板、任务栏和应用能力,低代码平台若提供嵌入协议还需单独适配 | +| C | 资料可见但需确认通用 API | 具备连接器和自动化能力,公开概览没有给出完整通用 Agent Run 合同 | +| D | 已有产品叙述 | 官方概览包含每日资讯推送、自动化等场景 | +| E | 低代码平台的优先对接方 | 低代码平台可以通过连接器、MCP 或 OpenAPI 向它暴露业务数据和动作 | +| F | 非主形态 | WorkBuddy 更像用户工作台和能力生态,不以企业级 Agent 托管控制面为核心 | + +WorkBuddy 对低代码平台的启示是交互入口和能力发现。低代码平台提供的 Tool 如果只有 API 文档,普通用户很难发现和使用。需要在 Agent 的连接器目录、Tool 描述、示例任务和权限授权页面中呈现出来。 + +### 5.4 OpenClaw:本地 Gateway、渠道和 Agent Runtime + +OpenClaw 官方 README 将其定位为运行在用户设备上的个人 AI 助手,通过一个 Gateway 连接模型、工具、消息渠道和可选的伴侣应用。[OpenClaw README](https://github.com/openclaw/openclaw) 官方文档进一步描述了 Gateway、Control UI、CLI、节点、会话、工具、Skill、插件、heartbeat 和 cron 等运行概念。[Gateway architecture](https://docs.openclaw.ai/concepts/architecture) + +OpenClaw 的产品主轴是 Runtime 和连接层,办公内容生产属于外围能力: + +- Gateway 长期运行,持有渠道、会话、模型和工具连接。 +- Control UI、CLI、Web UI 和自动化客户端通过 WebSocket 访问 Gateway。 +- Channels 把 WhatsApp、Telegram、Slack、Discord、Signal、iMessage、WebChat 等消息入口接入同一运行时。 +- Nodes 提供设备能力,使用明确的命令和权限集合。 +- Skills、Plugins、MCP 和设备节点扩展 Agent 的工具边界。 +- Agent 调用有 `runId`、事件流和最终状态;副作用方法需要幂等键。 + +OpenClaw 在六种形态中的位置: + +| 形态 | 适配程度 | 说明 | +| --- | --- | --- | +| A | 可作为开发运行环境 | 通过 CLI、Skill、插件和 MCP 执行开发或自动化任务 | +| B | 强 | Gateway 可以承载多个消息和控制界面,适合嵌入渠道层 | +| C | 强 | Gateway 有请求、事件、运行 ID 和最终状态的运行协议 | +| D | 强 | 官方架构包含 heartbeat、cron 和自动化客户端入口 | +| E | 强 | Skill、Plugin、MCP 和 Gateway API 可让外部系统调用能力 | +| F | 偏 Runtime | OpenClaw 提供运行底座,企业用户、配额、业务 ACL 和审计需要外围平台补齐 | + +当前 DAEDALUS_AGENT 已经把 OpenClaw 放进企业容器,并在其外面增加管理控制台、用户授权、业务系统 ACL、MCP 策略、审计和 HZG invoke service。这个组合说明低代码平台可以采用“业务治理平台 + 可替换 Agent Runtime”的架构,避免把 Gateway 的渠道能力和企业业务权限混成一层。 + +### 5.5 四个案例与其他方向的映射 + +下表用于区分“产品已经形成的主形态”和“可以借鉴的生态方向”。“需适配”表示方向在产品架构上可接入,公开资料或当前源码没有足够证据证明它已经作为完整产品能力交付。 + +| 方向 | DAEDALUS_AGENT | Codex | WorkBuddy | OpenClaw | 低代码平台的判断 | +| --- | --- | --- | --- | --- | --- | +| AI Workflow / Agent Workflow | 有同步任务和运行服务,通用 Workflow 产品层仍待建设 | 代码任务、审查、CI 和长任务形成工程流程 | 自动化和任务模式是重要入口 | cron、heartbeat、队列和事件提供运行基础 | P0,复用流程引擎承载确定性状态 | +| 企业 Copilot / Embedded Agent | CUI/MQTT 有对话承载,页面上下文协议有限 | IDE、桌面和代码库上下文强 | 桌面工作台和应用入口强 | 消息渠道和 Control UI 强 | P1,先做页面上下文和建议动作 | +| Computer Use / Browser Agent | 未形成通用产品入口 | 可作为模型工具或隔离环境能力接入,未作为 Codex 主定位 | 本地文件和应用操作场景相关,公开资料需按版本核实 | 节点和插件可承载,安全策略取决于部署 | P2,先限定域名、动作和沙箱 | +| Deep Research / Research Agent | 可用文件、业务对象和 Skill 组合领域研究 | 适合代码库和工程资料研究 | 办公资料、数据分析和内容生产场景强 | 依赖 Skill、工具和渠道配置 | P1/P2,提供带引用的异步研究节点 | +| Voice / Realtime Agent | 当前产品面以文本和消息为主 | 公开 Codex 开发面未把语音作为主入口 | 官方概览未把语音作为核心产品面 | 渠道能力可扩展,语音依赖插件或外部服务 | P2,适合客服和现场服务流程 | +| 垂直领域 Agent | 企业数字员工和活字格业务系统是垂直场景 | 软件工程垂直 Agent | 通用办公工作台 | 个人设备助手,领域能力由配置决定 | P0/P1,优先沉淀低代码业务对象和规则 | +| Multi-Agent / Supervisor | 可管理多个数字员工,协作编排仍有限 | 支持子任务和研发工作流组合 | 官方概览包含多人多 Agent 协作 | 有多 Agent 路由和 specialist 运行概念 | P2,先统一内部委派和权限,再接 A2A | +| Device / Edge / Robotics Agent | 以容器和业务系统为主,设备 Agent 需单独接入 | 软件环境和隔离工作区为主 | 以个人电脑和本地文件为主 | Nodes 提供设备能力,是主要参考 | P2/P3,物理动作必须增加安全联锁 | +| Agent Runtime / Gateway | OpenClaw Gateway 外加企业治理控制面 | Cloud、CLI、App Server 和运行环境 | 工作台和连接器运行环境 | Gateway 是核心产品主轴 | P0/P1,定义统一 Run 和 Provider 抽象 | +| Agent Builder / Platform | 管理控制台已具备模板、员工、Skill、模型和权限对象 | IDE、SDK、App Server 和 Skills/Plugins 形成开发面 | 零代码应用、专家、技能和自动化形成用户侧平台面 | 配置、Skill、Plugin 和 Gateway 组成运行平台 | P1/P2,重点是版本、评估、发布和回滚 | +| 评估与治理 | 用户、租户、业务 ACL、MCP 策略、审计和 Token 限额较强 | 代码变更、环境、审批、Trace 和 Evals 是关键实践 | 公开资料对企业级治理细节披露有限 | 设备配对、Gateway 暴露和本地信任是关键 | P0,与 Tool、Run 和 Workflow 同步建设 | + +四个案例分别代表企业治理、垂直开发、通用工作台和个人 Runtime。低代码平台应组合这些长处:吸收 DAEDALUS_AGENT 的治理边界,借鉴 Codex 的设计时变更闭环,参考 WorkBuddy 的入口与能力发现,采用 OpenClaw 的 Gateway、渠道和 Run 机制,再把它们落到自己的业务对象和流程事务上。 + +## 6. 四个案例的横向比较 + +| 维度 | DAEDALUS_AGENT | Codex | WorkBuddy | OpenClaw | +| --- | --- | --- | --- | --- | +| 主定位 | 企业数字员工管理与运行 | 软件工程垂直 Agent | 通用办公工作台 | 本地 Agent Gateway/Runtime | +| 主要使用者 | 管理员、企业用户 | 开发者、研发团队 | 知识工作者 | 个人操作者和技术用户 | +| 主要入口 | CUI、MQTT、管理控制台 | IDE、桌面、CLI、Cloud、CI | 桌面任务、专家、连接器、自动化 | 消息渠道、Control UI、CLI、节点 | +| Tool 扩展 | MCP、Ontology、invoke service、Skill | Skills、Plugins、MCP、Shell、Computer Use | 连接器、技能、专家和应用 | Tools、Skills、Plugins、MCP、Nodes | +| Workflow 关系 | 有运行服务和同步任务,缺少通用 AI Workflow 产品层 | 强调代码任务、审查、CI 和长任务 | 有自动化和任务编排产品叙述 | 有 cron、heartbeat、队列和事件运行能力 | +| 多 Agent | 管理多个独立数字员工,协作编排有限 | 支持子 Agent 和研发工作流组合 | 官方概览包含多人多 Agent 协作 | 有多 Agent 路由、并行 specialist lanes 等运行概念 | +| 治理重点 | 用户、业务系统、MCP、Token、审计和网络隔离 | 代码权限、环境、审批、变更和测试 | 本地文件、连接器授权和用户操作 | 设备配对、Gateway 暴露、渠道信任和 Sandbox | +| 对低代码的参考价值 | 企业治理和业务系统调用 | 设计时 Copilot 和 Agent SDK 化 | 用户入口、能力发现和自动化 | Gateway、渠道、Run 状态和插件扩展 | + +## 7. 低代码平台可切入的层 + +低代码平台拥有四项天然资产:应用元数据、业务对象和服务、用户与租户权限、流程和页面运行时。切入点应围绕这些资产展开。 + +### 7.1 Tool/Context 层:把低代码平台变成 Agent 的能力提供方 + +这是最建议优先建设的入口,对应形态 E。 + +可暴露的 Tool 不应停留在通用 CRUD。应围绕低代码用户能理解的业务动作设计: + +| Tool 类别 | 示例 | +| --- | --- | +| 数据查询 | 查询客户、订单、库存、合同、流程待办 | +| 业务动作 | 创建客户、提交审批、分配任务、生成出库单 | +| 页面上下文 | 当前页面、当前记录、筛选条件、选中行、字段说明 | +| 流程操作 | 发起流程、审批、转交、撤回、查看流程状态 | +| 应用知识 | 数据字典、帮助文档、字段枚举、权限说明 | +| 文件与成果 | 上传附件、读取报告、创建导出文件、关联业务记录 | + +技术形态: + +- MCP Server 负责 Agent 发现 Tool、资源和上下文。 +- OpenAPI 负责传统集成、SDK 生成和低代码连接器兼容。 +- Function Calling Schema 负责模型侧结构化调用。 +- OAuth/OIDC 和用户委托 Token 负责“Agent 代表谁调用”。 +- Tool Catalog 负责版本、示例、危险等级、输入输出 Schema 和可用范围。 + +低代码平台的 Tool 需要有业务语义和权限语义。`update_record` 这种通用动作要同时返回对象权限、字段权限、版本号和幂等要求,避免 Agent 通过一个宽泛接口绕过现有业务规则。 + +### 7.2 Agent Run 层:把外部 Agent 接入 Workflow + +这是形态 C 的核心,也是 AI Workflow 能够稳定落地的基础。 + +建议提供统一内部模型,不让每个流程节点直接绑定某个 Agent 厂商: + +```text +AgentProvider +AgentDefinition +AgentCapability +AgentRun +RunEvent +ApprovalRequest +Artifact +ToolGrant +UsageRecord +``` + +建议的最小接口: + +```http +POST /api/agent-runs +GET /api/agent-runs/{runId} +GET /api/agent-runs/{runId}/events +POST /api/agent-runs/{runId}/cancel +POST /api/agent-runs/{runId}/resume +POST /api/agent-runs/{runId}/approve +``` + +`POST /api/agent-runs` 至少应包含: + +- `agentRef`:外部 Agent、OpenClaw Agent、Codex 服务或企业自建 Agent。 +- `input`:结构化输入和自然语言目标。 +- `contextRefs`:应用、记录、文件、流程实例等引用。 +- `delegatedIdentity`:最终用户或服务账号。 +- `toolGrant`:本次允许的 Tool 和数据范围。 +- `callback`:Webhook、SSE 或消息队列地址。 +- `idempotencyKey`:防止重试重复执行。 + +流程节点需要处理 `waiting_approval`、`waiting_input` 和 `waiting_external`,不应把这些状态当成失败。基础设施失败可以自动重试,业务动作失败应由流程分支或人工处理。 + +### 7.3 AI Workflow 混合层:让 Agent 和流程互相调用 + +建议在低代码设计器里提供三类节点: + +1. **Agent Task 节点**:把目标、上下文和允许的 Tool 交给 Agent,等待结构化结果或成果文件。 +2. **Agent Decision 节点**:Agent 只返回一个经过 Schema 校验的选择,流程负责执行后续分支。 +3. **Human Review 节点**:用户审阅 Agent 草稿、批准副作用或补充缺失信息。 + +同时把以下能力暴露为 Agent Tool: + +- 调用一个经过治理的子流程。 +- 查询某个流程实例的状态。 +- 发起一个需要授权的业务流程。 +- 读取流程定义和可用分支。 + +这样可以保留确定性流程的可测试性,又让 Agent 处理自然语言、非结构化材料和动态路由。 + +### 7.4 应用内嵌 Agent 层:把页面上下文变成第一类输入 + +形态 B 的核心在于让 Agent 理解页面中的业务上下文,聊天气泡只是一个可能的展示方式。 + +组件至少需要接收: + +- 当前应用、页面和租户。 +- 当前用户和角色。 +- 当前记录、选中行和筛选条件。 +- 可见字段与字段说明。 +- 可执行动作和动作确认策略。 +- 当前 Workflow 实例和待办。 + +建议 Agent 先返回“建议动作”或“结构化变更”,组件再根据权限和危险等级显示确认。删除、批量更新、提交审批等操作应进入显式审批或二次确认。 + +### 7.5 开发设计 Copilot 层:以变更集连接设计器和 Agent + +形态 A 适合借鉴 Codex 的开发闭环。低代码平台应建立以下对象: + +```text +ProjectContext +ChangeSet +ValidationResult +PreviewArtifact +PublishRecord +``` + +Agent 负责理解需求和生成 `ChangeSet`,平台负责: + +- 校验表、字段、权限和流程引用。 +- 生成预览和 Diff。 +- 在测试环境执行检查。 +- 支持人工修改、撤销和回滚。 +- 记录谁发起、谁批准、谁发布。 + +低代码应用的变更往往影响数据结构和用户权限。直接让 Agent 编辑运行时配置会把设计器变成不可审计的脚本入口。 + +### 7.6 事件与托管层:从一次任务发展成数字员工 + +形态 D 和 F 适合在平台已有 Run 和治理基础后建设: + +- 事件触发器:定时器、Webhook、消息队列、数据库变更和业务事件。 +- Agent Scheduler:日历、时区、有效期、并发和预算。 +- Agent Workspace:文件、知识、记忆和成果。 +- Agent Registry:能力、版本、协议、认证和健康状态。 +- Agent Governance:权限、配额、审批、审计、数据保留和人工接管。 +- Agent Marketplace:模板、Skill、插件、连接器和签名验证。 + +持续自治 Agent 需要持久化状态、幂等和人工接管。没有这些能力时,定时执行一个 Agent 只能算“周期调用”,还不具备数字员工的生产可靠性。 + +## 8. 面向低代码平台的推荐架构 + +```mermaid +flowchart TB + subgraph Experience[体验面] + Designer[低代码设计器 Copilot] + AppAgent[应用内嵌 Agent] + WorkflowDesigner[AI Workflow 设计器] + end + + subgraph Orchestration[编排面] + Workflow[确定性 Workflow 引擎] + Run[统一 Agent Run 服务] + Events[事件与调度] + end + + subgraph Integration[连接面] + MCP[MCP Server] + OpenAPI[OpenAPI/SDK] + A2A[A2A 适配器] + Connectors[外部 Agent Connector] + end + + subgraph Governance[治理面] + Identity[用户委托与租户] + Policy[Tool/数据权限] + Approval[审批与人工接管] + Audit[审计、观测与费用] + end + + Designer --> Run + AppAgent --> Run + WorkflowDesigner --> Workflow + Events --> Workflow + Workflow --> Run + Run --> Connectors + Run --> MCP + Run --> OpenAPI + Run --> A2A + Run --> Governance + MCP --> Data[业务对象、页面、文件、流程、外部系统] + OpenAPI --> Data +``` + +四个面的责任应保持清楚: + +| 面 | 低代码平台负责什么 | 不应交给模型决定什么 | +| --- | --- | --- | +| 体验面 | 上下文展示、确认、成果呈现、变更预览 | 用户身份和最终权限 | +| 编排面 | 状态、等待、重试、补偿、事务和超时 | 任意绕过流程的副作用 | +| 连接面 | Tool Schema、协议适配、上下文引用和结果转换 | 直接访问数据库或越权调用 | +| 治理面 | 授权、审批、审计、配额、保留和人工接管 | 只依赖 Prompt 的安全承诺 | + +## 9. 分阶段切入建议 + +### 阶段一:Agent-ready 低代码平台 + +目标是让外部 Agent 能安全使用低代码能力,让低代码 Workflow 能调用外部 Agent。 + +- 建立 Tool Catalog 和业务对象 Tool 设计规范。 +- 发布 MCP Server,同时保留 OpenAPI。 +- 建立 Agent Provider 和 Agent Run 抽象。 +- 提供 Workflow Agent Task 节点。 +- 统一用户委托、Tool 授权、审批、审计和费用。 +- 接入 Codex、OpenClaw、自建 Agent 等可编程端点。 + +这一阶段已经能形成产品差异:外部 Agent 可以操作低代码业务系统,低代码流程可以消费任意 Agent 的结构化成果。 + +### 阶段二:Agent in Apps + +目标是让现有低代码应用直接拥有业务 Copilot。 + +- 提供标准 Agent 组件和移动端/桌面端适配。 +- 注入页面、记录、字段和用户权限上下文。 +- 提供建议动作、结构化结果、文件成果和人工确认。 +- 支持会话与 Workflow 实例关联。 +- 建立数据脱敏、上下文最小化和结果引用机制。 + +WorkBuddy 代表的用户入口、Codex 代表的开发入口,可以在这一阶段通过连接器或 Agent Provider 接入,平台不需要复制它们的模型和通用聊天体验。 + +### 阶段三:Agent 设计与托管 + +目标是把 Agent 当作低代码平台的可配置对象。 + +- 设计器 Copilot 生成 ChangeSet。 +- Agent 模板、Skill、知识和 Tool 权限可复用。 +- 事件驱动数字员工拥有调度、预算、暂停和接管。 +- 多 Agent 通过内部委派协议或 A2A 协作。 +- 建立 Agent 版本、评估集、回滚和市场分发。 + +当前 DAEDALUS_AGENT 的模板、Skill、模型、MCP、用户授权和审计能力可以作为这一阶段的企业治理参考。OpenClaw 的 Gateway、渠道和插件体系可以作为 Runtime 参考。 + +## 10. 优先级判断 + +| 切入层 | 建议优先级 | 原因 | 主要风险 | +| --- | --- | --- | --- | +| E Tool/Context | P0 | 直接利用低代码业务对象和权限资产,能接入 WorkBuddy、Codex、OpenClaw 等外部 Agent | Tool 设计过宽导致越权或不可维护 | +| C Agent Run 节点 | P0 | 把外部 Agent 变成 Workflow 可消费的服务,形成明确采购价值 | 长任务、回调、重复执行和费用控制复杂 | +| AI Workflow 混合层 | P0 | 低代码平台的流程优势最容易形成差异 | 让 Agent 直接控制业务状态会破坏可测试性 | +| 身份、审批、审计和用量 | P0 | 决定 Agent 能否进入真实业务,支撑委托身份、最小权限和责任追踪 | 只依赖 Prompt 或单一管理员 Token 会造成越权和追责困难 | +| B 应用内嵌 Agent | P1 | 能快速提升现有应用的使用体验和业务粘性 | 页面上下文、数据权限和操作确认要做细 | +| A 设计 Copilot | P1 | 能提高开发效率,建立平台级智能入口 | 元数据变更、版本和回滚要求高 | +| 评估与质量闭环 | P1 | Trace、评估集和发布门槛能降低模型升级带来的回归风险 | 评估集脱离真实业务,线上质量无法预测 | +| D 事件驱动数字员工 | P2 | 能从助手升级为无人值守流程 | 需要调度、幂等、预算和人工接管 | +| F Agent 托管平台 | P2 | 可形成完整生态和企业控制面 | 研发和运维投入大,容易与通用 Agent 平台正面竞争 | +| Agent Registry、模板和市场 | P2 | 支持 Agent、Tool、Skill 和 Workflow 的复用与分发 | 签名、版本、依赖和权限治理成本高 | +| A2A 多 Agent | P2/P3 | 适合跨 Agent 委派和生态合作 | 协议演进快,先做内部 Run 抽象更稳妥 | +| Computer Use、Voice、Device/Robotics | P2/P3 | 覆盖遗留系统、客服现场和物理设备等新入口 | 运行环境、安全联锁、隐私和人工接管要求更高 | + +## 11. 不建议作为第一阶段的方向 + +- 自建一个面向所有办公任务的桌面 Agent,直接与 WorkBuddy 竞争。 +- 先做开放 Agent 市场,再补权限、签名、版本和审计。 +- 让模型直接读写低代码数据库或生产配置。 +- 只实现 MCP Tool 列表,不实现 Run、审批、回调和结果交付。 +- 把所有 AI 节点都设计成“自由 Agent”,放弃 Workflow 的确定性、可测试和可回滚能力。 +- 过早采用 A2A 作为内部唯一协议,忽略本平台自己的用户、租户和业务对象模型。 + +## 12. 结论 + +低代码平台进入 Agent 生态有三条可行路径: + +1. **成为 Agent 的业务能力提供方。** 通过 MCP、OpenAPI 和 OAuth,把数据、页面、流程和业务动作安全暴露出去。 +2. **成为 Agent 的流程编排方。** 用统一 Agent Run 把 Codex、WorkBuddy、OpenClaw 或企业自建 Agent 接进 Workflow。 +3. **成为 Agent 的业务运行方。** 在应用中嵌入 Agent,之后再建设设计 Copilot、事件驱动数字员工和托管平台。 + +最值得优先投资的组合是 **E + C + AI Workflow + 身份/审批/审计**,随后建设 **B + A + 评估**。它可以同时利用低代码平台的业务资产、流程能力和用户入口,也能接入外部 Agent 生态。D、F、Agent Registry 和市场属于平台化扩展,A2A、Computer Use、Voice、Device/Robotics 适合在 Run 生命周期和治理基础上逐步引入。 + +低代码平台的核心竞争力不会来自“拥有一个更会聊天的模型”。它来自能否把自然语言任务接到真实业务对象上,能否在正确的用户身份下执行,能否在失败和审批时恢复,能否让开发者看到变更并回滚。Agent 生态接入的技术方案应围绕这几个可验证的工程问题展开。 + +## 参考资料 + +- [Anthropic:Building effective agents](https://www.anthropic.com/research/building-effective-agents) +- [Model Context Protocol:Introduction](https://modelcontextprotocol.io/introduction) +- [A2A Protocol Specification](https://a2a-protocol.org/latest/specification/) +- [OpenAI Codex Developers](https://learn.chatgpt.com/docs/developers.md) +- [OpenAI:Agent Builder](https://developers.openai.com/api/docs/guides/agent-builder.md) +- [OpenAI:Agent workflow evaluations](https://developers.openai.com/api/docs/guides/agent-evals.md) +- [OpenAI:Computer use](https://developers.openai.com/api/docs/guides/tools-computer-use.md) +- [OpenAI:Deep research](https://developers.openai.com/api/docs/guides/deep-research.md) +- [OpenAI:Voice agents](https://developers.openai.com/api/docs/guides/voice-agents.md) +- [OpenAI:Orchestration and handoffs](https://developers.openai.com/api/docs/guides/agents/orchestration.md) +- [OpenAI:Safety in building agents](https://developers.openai.com/api/docs/guides/agent-builder-safety.md) +- [NIST:AI Risk Management Framework](https://www.nist.gov/itl/ai-risk-management-framework) +- [OWASP:Top 10 for Large Language Model Applications](https://owasp.org/www-project-top-10-for-large-language-model-applications/) +- [WorkBuddy 官方概览](https://www.workbuddy.cn/docs/workbuddy/Overview) +- [OpenClaw README](https://github.com/openclaw/openclaw) +- [OpenClaw Gateway architecture](https://docs.openclaw.ai/concepts/architecture) +- 当前项目:[README.md](../README.md)、[system-feature-list.md](system-feature-list.md)、[business-system-access-control-design.md](business-system-access-control-design.md) diff --git a/doc/agent-permission-sandbox-design.md b/doc/agent-permission-sandbox-design.md new file mode 100644 index 0000000000000000000000000000000000000000..06a41ee8ab273c422ec53612f803e4b8b6f1840f --- /dev/null +++ b/doc/agent-permission-sandbox-design.md @@ -0,0 +1,360 @@ +# Agent 权限与 OpenClaw 原生 Sandbox 设计 + +## 1. 文档目的 + +本文定义 DAEDALUS_AGENT 的 Agent 级权限模型,并说明在 OpenClaw `2026.7.1` 原生 Agent sandbox 上的落地方式、能力缺口、迁移步骤和副作用。 + +本文只描述设计,不包含代码修改。 + +关联设计: + +- [Agent 模板创建与内容管理设计](./agent-template-creation-design.md) +- [业务系统调用控制设计](./business-system-access-control-design.md) + +## 2. 需求结论 + +### 2.1 工具 + +- 工具权限只配置到 Agent 级,不新增全局工具管理页面。 +- 新建和存量 Agent 默认使用全部可用工具。 +- “全部可用”指通过现有全局安全策略后仍可用的工具。Agent 级配置不能重新放开已被全局 `deny` 的工具。 +- Agent 可切换为“指定工具”,由管理端生成 `agents.list[].tools.allow/deny`。 +- `tools.elevated` 必须关闭,防止 `exec` 绕过 sandbox 回到 Gateway 宿主环境。 + +### 2.2 Skill + +- 不改变全局 Skill 的安装、删除和启停逻辑。 +- 默认不写 `agents.defaults.skills`,也不写 `agents.list[].skills`,因此所有 Agent 继续看到全部符合运行条件的全局 Skill。 +- 模板中上传的 Skill 放入模板自己的 `skills//`;Agent 从模板创建后,成为该 Agent workspace 内的 Skill。 +- 本期不使用 `agents.list[].skills` 做 Skill 白名单。OpenClaw 中该字段是最终集合而不是与默认值合并,启用后会意外过滤全局 Skill,与“全局技能不受影响”冲突。 + +### 2.3 目录 + +默认目录能力如下,未列出的目录不可见: + +| 逻辑资源 | Sandbox 内路径 | 默认权限 | 说明 | +| --- | --- | --- | --- | +| Ontology | `/ontology` | 只读 | 对应 `/var/daedalus-agent/ontology` | +| Agent Skill | `/workspace/skills` | 读写 | 仅当前 Agent 的模板内 Skill | +| Sender 工作区 | `/workspace/{senderId}` | 读写 | 仅当前会话的 `senderId` | +| `AGENTS.md` | 不作为可写挂载暴露 | 不可修改 | 由 Gateway 读取作为启动上下文 | +| 其他 Agent workspace | 不挂载 | 不可访问 | 防止跨 Agent 访问 | +| 其他 sender 目录 | 不挂载 | 不可访问 | 防止只靠提示词做隔离 | + +自定义目录权限只能从管理端维护的“受管资源目录”中选择,不允许管理员直接输入任意宿主机绝对路径。这样可避免把 `/etc`、Docker socket、凭据目录等敏感路径误挂载进 sandbox。 + +## 3. 当前实现事实 + +1. 项目在 `docker/Dockerfile` 中固定 OpenClaw 版本为 `2026.7.1`。 +2. Agent 创建由 `lifecycle-service.ts` 直接维护 `openclaw.json` 的 `agents.list`。 +3. 当前全局配置为 `tools.exec.mode = "full"`,未启用 Agent sandbox。 +4. Agent workspace 位于 `/var/daedalus-agent/.openclaw/workspaces/`。 +5. Ontology 位于 `/var/daedalus-agent/ontology`,当前宿主容器权限已经是管理端读写、OpenClaw 只读。 +6. 当前 `AGENTS.md` 依靠 `senderId` 文本规则约束跨目录访问,但底层文件权限仍能看到同一 Agent 下的其他 sender 目录。 +7. 业务系统 CLI 通过固定 Unix socket `/var/daedalus-agent/run/app-invoke.sock` 调用服务端。 + +## 4. 总体方案 + +```mermaid +flowchart LR + UI["管理端 Agent 权限"] --> DB["Agent 权限策略"] + DB --> Compiler["OpenClaw 配置编译器"] + Compiler --> Config["agents.list[].sandbox / tools"] + Config --> Gateway["OpenClaw Gateway"] + Gateway --> Sandbox["按 Session 创建的 Docker Sandbox"] + Sandbox -->|ro| Ontology["Ontology"] + Sandbox -->|rw| Skills["当前 Agent skills"] + Sandbox -->|rw| Sender["当前 sender 工作区"] + Sandbox -->|Unix socket| Invoke["业务系统调用服务"] + Invoke --> ACL["服务端 Agent 业务系统 ACL"] +``` + +采用两层控制: + +- OpenClaw 原生 sandbox 负责进程、文件系统、工具执行和网络边界。 +- DAEDALUS_AGENT 服务端负责动态 `senderId` 映射以及业务系统授权。两者不能互相替代。 + +## 5. OpenClaw Sandbox 配置 + +### 5.1 推荐配置 + +每个受管 Agent 生成如下配置。示例中的宿主路径需要由管理端根据 Agent ID 编译,不能直接接受前端传入。 + +```json5 +{ + agents: { + list: [ + { + id: "sales-agent", + workspace: "/var/daedalus-agent/.openclaw/workspaces/sales-agent", + sandbox: { + mode: "all", + backend: "docker", + scope: "session", + workspaceAccess: "none", + docker: { + image: "daedalus-agent-sandbox:2026.7.1", + network: "none", + readOnlyRoot: true, + capDrop: ["ALL"], + dangerouslyAllowExternalBindSources: true, + binds: [ + "/var/daedalus-agent/ontology:/ontology:ro", + "/var/daedalus-agent/.openclaw/workspaces/sales-agent/skills:/workspace/skills:rw", + "/var/daedalus-agent/run/app-invoke:/var/daedalus-agent/run/app-invoke:rw" + ] + } + }, + tools: { + elevated: { enabled: false }, + exec: { host: "sandbox" } + } + } + ] + } +} +``` + +说明: + +- `mode: "all"`:主会话也必须进入 sandbox,不能使用 `non-main`。 +- `scope: "session"`:一个 OpenClaw session 一个 sandbox,作为 sender 隔离的基础。 +- `workspaceAccess: "none"`:不挂载完整 Agent workspace,避免其他 sender 目录被看到。 +- `network: "none"`:默认不允许 sandbox 直接访问网络;业务系统访问统一经过 Unix socket 服务。 +- `readOnlyRoot: true` 和 `capDrop: ["ALL"]`:保留 OpenClaw Docker backend 的安全默认值。 +- `dangerouslyAllowExternalBindSources: true`:Ontology 和调用 socket 目录都在 Agent workspace 之外,`2026.7.1` 需要显式允许。该开关只应由受保护的配置编译器写入,且绑定源必须经过服务端固定白名单校验。 +- 默认“全部工具”时不生成 Agent 级 `allow/deny`。选择指定工具时才生成 Agent 级限制。 + +当前 CLI 固定使用 `/var/daedalus-agent/run/app-invoke.sock`。实现时应将其调整到专用目录,例如 `/var/daedalus-agent/run/app-invoke/app-invoke.sock`,并只挂载该目录。不要直接挂载单个 socket 文件:服务重启重建 socket 后,已有容器可能仍引用旧 inode;也不要挂载整个 `/var/daedalus-agent/run`,避免暴露无关运行时文件。 + +配置示例是目标结构,不应作为不经校验的静态配置直接复制。上线前必须通过目标镜像中的 `openclaw doctor` 与 `openclaw sandbox explain --agent ` 验证字段兼容性。 + +### 5.2 Sender 目录的原生能力缺口 + +OpenClaw `agents.list[].sandbox.docker.binds` 是 Agent 级静态配置,不能按当前消息的 `senderId` 动态插值。因此下面两项不能同时只靠原生配置完成: + +1. 宿主机继续以 `//` 保存文件。 +2. 每个 session sandbox 只挂载当前 `senderId`,完全看不到其他 sender。 + +不能使用 `workspaceAccess: "rw"` 规避这个问题,因为它会把整个 Agent workspace 挂载到 `/workspace`,当前 sender 可以探测其他 sender 目录。 + +### 5.3 推荐的 Sender 工作区桥接 + +保留原生 `scope: "session"` 和 `workspaceAccess: "none"`,增加一个不向模型暴露权限的工作区桥接层: + +1. MQTT 通道或 Gateway 根据可信会话上下文取得 `agentId`、`sessionId` 和 `senderId`,不接受模型或 CLI 自报身份。 +2. 对 `senderId` 做规范化和目录穿越校验,并解析为唯一的宿主目录。 +3. session 首次运行时,只把该 sender 的目录物化到 sandbox 的 `/workspace/`。 +4. 每次 turn 完成或显式文件操作完成后,按版本号同步回宿主 sender 目录。 +5. 同一 sender 并发写入时使用 session/sender 锁和乐观版本号,冲突时拒绝覆盖。 +6. 桥接层只允许 `upload`、`download`、`generated`、`memory`、`MEMORY.md` 等约定路径,不接受任意宿主路径。 + +```mermaid +sequenceDiagram + participant C as Channel + participant G as Gateway/Bridge + participant H as Host sender dir + participant S as Session sandbox + + C->>G: message(agentId, sessionId, trusted senderId) + G->>G: validate identity and path + G->>H: read current sender revision + G->>S: materialize /workspace/{senderId} + S-->>G: tool execution result and changed files + G->>G: validate paths, quotas, revision + G->>H: atomic sync +``` + +若坚持“零桥接、只使用原生 sandbox”,则必须把 OpenClaw session sandbox 的私有 workspace 改为 sender 文件的唯一真源,并改造文件上传、下载、导出和备份链路去访问 sandbox 存储。这个方案侵入面更大,不建议作为第一阶段。 + +### 5.4 `AGENTS.md` 保护 + +使用 `workspaceAccess: "none"` 后,工具看不到宿主 Agent workspace 根目录,因而不能直接改写宿主 `AGENTS.md`。还需保留以下防线: + +- 模板管理 API 禁止修改、重命名和删除顶层 `AGENTS.md`。 +- 模板每次保存时校验 `AGENTS.md` 存在且为普通文件。 +- Agent 创建和模板展开由管理服务执行,不通过 Agent 自身工具执行。 +- `openclaw.json`、模板目录和管理数据目录均不得绑定到 sandbox。 + +当前内置 `AGENTS.md` 中包含“禁止访问 Ontology”的文字规则,而新默认权限要求 Ontology 只读。启用只读挂载前,需要由产品安全负责人决定是否随产品升级更新内置模板资源;模板编辑器本身仍不得修改内置模板的 `AGENTS.md`。否则技术上可读、提示词上禁止,最终行为会不一致。 + +## 6. 权限数据模型 + +不把 OpenClaw JSON 作为管理端唯一数据源。管理数据库保存意图,配置编译器生成运行配置。 + +### 6.1 Agent 运行策略 + +建议新增 `agent_runtime_policies`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `agent_id` | INTEGER PK/FK | 对应 `agents.id` | +| `tool_mode` | TEXT | `all` 或 `selected`,默认 `all` | +| `tool_allow_json` | TEXT | `selected` 时的工具 ID 数组 | +| `sandbox_mode` | TEXT | 当前固定为 `all`,为后续演进保留 | +| `policy_version` | INTEGER | 乐观锁版本 | +| `created_at` / `updated_at` | TEXT | 审计时间 | + +### 6.2 目录授权 + +建议新增 `agent_directory_grants`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `agent_id` | INTEGER FK | Agent | +| `resource_key` | TEXT | `ontology`、`workspace_skills`、`sender_workspace` 或受管扩展资源 | +| `access_mode` | TEXT | `ro` 或 `rw` | +| `enabled` | INTEGER | 是否启用 | +| `created_at` / `updated_at` | TEXT | 审计时间 | + +主键为 `(agent_id, resource_key)`。默认创建三条授权:Ontology `ro`、Agent Skill `rw`、sender workspace `rw`。 + +前端和 API 只传 `resource_key`,实际宿主路径、容器路径和允许的最大权限由服务端资源目录表解析。 + +### 6.3 管理 API 与界面 + +Agent 详情页新增“运行权限”区域: + +- 工具使用分为“全部工具”和“指定工具”。默认选中全部工具。 +- 指定工具时从当前 OpenClaw 工具目录多选,并明确显示全局策略已经禁用、Agent 无法重新放开的工具。 +- 目录使用表格展示受管资源、sandbox 路径、最大权限和当前权限。 +- Ontology 最大权限固定为 `ro`;Agent Skill 和 sender workspace 最大权限为 `rw`。 +- 管理员可以关闭某个资源或在最大权限范围内降级,不能粘贴宿主绝对路径。 +- 保存后展示 `applying` 状态,直到配置写入和 sandbox 重建都成功。 + +建议接口: + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/runtime-resources` | 获取可分配的受管目录和最大权限 | +| `GET` | `/api/agents/:slug/runtime-policy` | 获取 Agent 策略、有效配置和应用状态 | +| `PUT` | `/api/agents/:slug/runtime-policy` | 更新工具和目录策略 | + +更新请求示例: + +```json +{ + "tool_mode": "selected", + "tool_allow": ["read", "write", "edit", "apply_patch", "exec"], + "directory_grants": [ + { "resource_key": "ontology", "access_mode": "ro", "enabled": true }, + { "resource_key": "workspace_skills", "access_mode": "rw", "enabled": true }, + { "resource_key": "sender_workspace", "access_mode": "rw", "enabled": true } + ], + "policy_version": 3 +} +``` + +接口使用 `policy_version` 做乐观锁,并返回编译后的 OpenClaw 配置摘要,但不返回宿主机真实路径。 + +## 7. 管理流程 + +### 7.1 创建 Agent + +1. 在数据库事务中准备默认运行策略和目录授权。 +2. 创建 Agent workspace,并从模板复制内容。 +3. 编译 `agents.list[]` 的 sandbox 和 tools 配置。 +4. 原子写入 `openclaw.json`。 +5. 重启或刷新 Gateway。 +6. 首次调用前运行 `sandbox explain` 检查有效策略。 + +任一步骤失败都恢复 OpenClaw 配置快照并删除未完成的本地策略记录。 + +### 7.2 更新权限 + +1. 校验策略版本和资源白名单。 +2. 写入数据库并编译新配置。 +3. 原子更新 `openclaw.json`。 +4. 执行 `openclaw sandbox recreate --agent `,使已有容器应用新挂载和镜像。 +5. 重启或刷新 Gateway。 +6. 失败时恢复数据库和配置快照;若容器已删除,则下一次调用按旧配置重建。 + +权限保存接口应返回 `applying`、`ready` 或 `failed`,不能在容器尚未重建时提前显示“已生效”。 + +## 8. 自定义 Sandbox 镜像 + +OpenClaw 默认 sandbox 镜像不继承 DAEDALUS_AGENT 主容器中的 Node、npm/pip 包、环境变量和二进制。需要构建固定版本的 `daedalus-agent-sandbox:2026.7.1`,至少包含: + +- OpenClaw 文件工具所需的 Python 运行时。 +- `bash`、`ca-certificates`、`curl`、`git`、`jq`、`ripgrep`。 +- 当前模板 Skill 确实需要的 Node 24、pnpm 或其他固定运行时。 +- `daedalus-agent-apps-invoke-cli` 客户端。 + +镜像构建必须采用白名单,不要把主容器完整工具链、管理端代码或凭据复制进去。Skill 声明的环境变量和密钥不会自动进入 sandbox,应改由受控代理服务提供能力,避免把长期密钥注入 Agent 容器。 + +## 9. 副作用与风险 + +| 影响 | 具体表现 | 缓解措施 | +| --- | --- | --- | +| Docker-out-of-Docker | 主容器需要访问宿主 Docker daemon | 只给 Gateway 主容器 socket;绝不把 Docker socket挂进 Agent sandbox | +| 路径映射 | Docker daemon 按宿主路径解析 bind,当前命名卷路径可能无法直接复用 | 生产环境改为宿主与主容器同路径 bind mount | +| Windows 部署复杂 | Docker Desktop 的 Windows/Linux 路径转换使同路径挂载困难 | 推荐 Linux 主机;Windows 仅作为开发验证环境 | +| 容器数量增加 | `scope: session` 会按活跃 session 创建容器 | 设置 CPU、内存、PID、闲置回收和并发上限 | +| 冷启动增加 | 首次会话需创建容器、物化 Skill 和 sender 文件 | 预构建镜像、限制镜像层、监控 P50/P95 冷启动 | +| 默认无网络 | 网络搜索、S3 下载、在线安装依赖会失败 | 通过受控代理/Unix socket提供能力;不直接开放任意 egress | +| Skill 兼容性 | 依赖宿主二进制、包、环境变量或绝对路径的 Skill 会失败 | 建立 Skill sandbox 兼容性检查和镜像依赖清单 | +| 全局 Skill 文件只读 | `workspaceAccess: none` 下全局 Skill 会被物化到只读目录 | 保持全局 Skill 不变;可写 Skill 仅放 Agent workspace `skills/` | +| `.learnings/` 不可写 | 默认目录授权未包含该共享目录,现有自改进 Skill 无法持久化 | 后续将其作为独立受管资源评审;本期不隐式放开 | +| Socket 暴露 | 挂载调用 socket 后,Agent 可以主动构造请求 | 服务端必须按可信 `sessionId -> agentId` 再授权,不能信任 `AgentName` | +| Socket 生命周期 | 单文件 bind 在服务重启重建 socket 后可能失效 | 使用只包含调用 socket 的专用目录挂载,并同步调整 CLI 固定路径 | +| 外部 bind 开关 | `dangerouslyAllowExternalBindSources` 扩大错误配置风险 | 路径由服务端固定白名单编译,禁止前端传绝对路径 | +| 变更非即时 | 已存在 sandbox 不会自动采用新镜像或挂载 | 每次权限变更后执行 `sandbox recreate` | +| 安全边界有限 | OpenClaw 官方明确 sandbox 不是完美安全边界 | 叠加最小挂载、禁 elevated、服务端 ACL、审计与容器运行限制 | + +## 10. 预计影响模块 + +- `packages/daedalus-agent-management-web/server/src/db/index.js`:策略表和迁移。 +- `packages/daedalus-agent-management-web/server/src/services/agent-service.js`:创建、更新、删除时保存策略和补偿。 +- `packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts`:编译 `agents.list[].sandbox/tools`。 +- `packages/daedalus-agent-management-web/server/src/api/routes/index.js`:运行权限管理接口。 +- Agent 管理页面:工具和受管目录选择、应用状态。 +- `docker/assets/config.json` 与启动脚本:Docker backend、镜像、同路径 bind 和运行限制。 +- MQTT channel/Gateway 会话处理:可信 sender 身份和工作区桥接。 +- `packages/daedalus-agent-apps-invoke-cli`:专用 socket 目录路径。 + +## 11. 迁移计划 + +### 阶段 A:验证环境 + +1. 在 Linux 测试机用项目锁定的 OpenClaw `2026.7.1` 构建 sandbox 镜像。 +2. 验证 DooD 的同路径 bind mount、Ontology 只读和 Unix socket 通信。 +3. 使用 `openclaw sandbox explain` 固化有效配置快照。 +4. 验证全局 Skill 仍全部可见,且 Agent workspace Skill 可写。 + +### 阶段 B:权限数据与配置编译 + +1. 新增 Agent 运行策略和目录授权表。 +2. 存量 Agent 回填默认值。 +3. 扩展 Agent 创建、更新和删除的补偿事务。 +4. 增加管理页面,但不开放任意宿主路径。 + +### 阶段 C:Sender 桥接 + +1. 建立可信 session 到 sender 的映射。 +2. 实现单 sender 物化、原子同步、锁和配额。 +3. 改造文件上传、发送、导出和备份链路。 +4. 完成跨 sender 渗透测试后再切换生产流量。 + +### 阶段 D:灰度启用 + +1. 先为内部测试 Agent 启用。 +2. 比较 sandbox 前后的冷启动、内存、调用成功率和 Skill 兼容率。 +3. 按 Agent 批次启用,可通过 Agent 级 `sandbox.mode` 回退。 + +## 12. 验收标准 + +- 默认 Agent 能调用现有全局 Skill,且全局 Skill 管理数据没有变化。 +- 默认 Agent 只能读 Ontology,写入返回权限错误。 +- Agent 可以读写自己的 `skills/`,不能看到其他 Agent 的 `skills/`。 +- 当前 sender 可以读写自己的目录,列目录也看不到其他 sender 名称。 +- Agent 无法读写宿主 `AGENTS.md`、`openclaw.json`、模板目录和管理数据库。 +- Agent 无法使用 elevated exec 回到 Gateway 宿主。 +- 未授权业务系统调用在 socket 服务端返回拒绝,并留下包含真实 Agent 身份的审计记录。 +- 权限变更后旧 sandbox 被重建;失败时界面不显示为已生效。 +- 100 个 Agent 的基准场景重新完成资源和冷启动测试,容量结论不能沿用未启用 sandbox 时的数据。 + +## 13. 参考资料 + +- OpenClaw `v2026.7.1` Sandboxing: +- OpenClaw `v2026.7.1` Multi-agent sandbox and tools: +- OpenClaw `v2026.7.1` Skills: diff --git a/doc/agent-template-creation-design.md b/doc/agent-template-creation-design.md new file mode 100644 index 0000000000000000000000000000000000000000..afd41f1ec9d35e2f91fcafbb3225134415ecda63 --- /dev/null +++ b/doc/agent-template-creation-design.md @@ -0,0 +1,115 @@ +# 员工模板创建与内容管理设计 + +## 1. 目标 + +员工模板支持创建、编辑、删除和复制。每个模板只有一份当前内容和当前设置,不存在版本、草稿或发布状态。任何保存操作成功后立即生效。 + +已创建的数字员工拥有独立 workspace、Skill、模型和权限快照。后续编辑或删除源模板,不修改、停用或删除这些数字员工。 + +## 2. 产品规则 + +1. 自定义模板支持编辑和删除。 +2. 内置模板只支持查看、下载、复制和创建数字员工,不支持编辑或删除。 +3. 创建模板成功后立即写入当前模板并进入工作台。 +4. 文件、默认 LLM、模板 Skill、业务系统和 MCP 分别保存;每次保存成功后立即成为模板当前状态。 +5. 页面和 API 不提供模板版本号、草稿状态、发布按钮或发布接口。 +6. 删除自定义模板不影响已经由该模板创建的数字员工。 +7. 顶层 `AGENTS.md` 可编辑和恢复系统默认内容,但不允许删除、移动或重命名。 + +## 3. 用户流程 + +### 3.1 创建模板 + +| 方式 | 初始内容 | +| --- | --- | +| 空白创建 | 使用内置模板的 `AGENTS.md` 建立最小模板 | +| ZIP 导入 | 导入顶层包含 `AGENTS.md` 的 workspace ZIP | +| 基于模板创建 | 深复制指定模板的当前文件和设置 | + +`POST /api/templates` 完成校验、运行时创建和数据库写入后返回当前模板。创建弹窗关闭后可直接进入该模板工作台。 + +### 3.2 编辑模板 + +自定义模板列表提供“编辑”操作,工作台由 `templateName` 直接打开当前模板。不存在单独编辑副本。 + +- 文本编辑器中的改动只存在于浏览器内,点击文件“保存”后替换模板当前制品。 +- 新建、上传、移动、删除文件,以及上传或删除 Skill,操作成功后立即替换模板当前制品。 +- 默认 LLM、业务系统和 MCP 使用各自的“保存设置”按钮,成功后立即覆盖模板当前设置。 +- 离开工作台时只检查当前编辑器中尚未提交的文本,不存在待发布状态。 + +### 3.3 删除模板 + +删除自定义模板时移除运行时模板、`agent_templates` 当前记录、模板策略和当前 artifact。`agents.template_name` 仅保留创建来源名称,不建立外键依赖。 + +删除确认区分两种情况:无关联员工时提示不可恢复;有关联员工时显示数量,并说明存量员工不受影响。内置模板不能删除。 + +## 4. 工作台 + +工作台采用三栏结构:左侧文件树,中间文本编辑器或文件信息,右侧默认 LLM、模板 Skill、业务系统、MCP 和校验结果。 + +工作台顶部只显示当前文件是否有未保存改动和模板校验入口,不显示状态标签或全局保存按钮。工作台底部显示当前文件和 Skill 数量。 + +## 5. 文件安全 + +服务端统一规范化相对 POSIX 路径,拒绝绝对路径、盘符、反斜杠、`.`、`..`、NUL、控制字符、符号链接和不可移植文件名。 + +顶层 `AGENTS.md` 是保护路径:允许编辑内容和恢复系统默认内容;删除、移动、重命名和旁路覆盖返回 `409 Conflict`。每次制品写入前均要求该文件存在且为普通文件。 + +默认限额:单文件 10 MiB、展开后总大小 100 MiB、文件和目录共 2,000 个、路径最多 512 UTF-8 bytes、ZIP 压缩比最多 100:1。 + +## 6. 模板 Skill 和权限 + +模板 Skill 存放在当前模板的 `skills//`。由模板创建数字员工时复制到员工 workspace,不写全局 Skill 目录。 + +模板业务系统和 MCP 均支持 `all` 与 `selected`。创建数字员工时把模板当前策略复制成员工快照;模板之后的编辑或删除不改变该快照。 + +## 7. 数据模型 + +`agent_templates` 保存模板唯一当前状态:模板名、当前 artifact、当前默认 LLM、当前 Skill 数量、运行状态、内置标记和更新时间。 + +模板业务系统当前策略由 `template_business_system_policies` 和 `template_business_system_access` 保存;模板 MCP 当前策略由 `template_mcp_policies` 和 `template_mcp_access` 保存。模板策略表不包含 `policy_version`。 + +数据库不创建模板草稿表、模板历史表、模板 revision 字段或 Agent 的模板版本来源字段。 + +数据库仅兼容从 `main` 分支 schema 升级。当前分支开发过程中曾出现的中间 schema 不在兼容范围内。 + +## 8. API + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/templates` | 获取模板当前状态列表 | +| `POST` | `/api/templates` | 直接创建 `blank`、`import` 或 `clone` 模板 | +| `GET` | `/api/templates/:templateName/details` | 获取模板当前内容摘要和设置 | +| `GET` | `/api/templates/:templateName/tree` | 获取当前文件树 | +| `GET/PUT` | `/api/templates/:templateName/files/content` | 读取或立即保存文件 | +| `POST/DELETE` | `/api/templates/:templateName/skills...` | 立即上传或删除模板 Skill | +| `PUT` | `/api/templates/:templateName/default-llm` | 保存当前默认 LLM | +| `PUT` | `/api/templates/:templateName/business-systems` | 保存当前业务系统策略 | +| `PUT` | `/api/templates/:templateName/mcp` | 保存当前 MCP 策略 | +| `POST` | `/api/templates/:templateName/validate` | 校验模板当前状态 | +| `DELETE` | `/api/templates/:templateName` | 删除自定义模板 | + +所有写接口按模板名串行执行,不接受 revision、状态或发布参数。 + +## 9. 保存与补偿 + +文件和 Skill 写入流程: + +1. 从当前 artifact 加载 ZIP 到一次性临时目录。 +2. 应用本次文件操作并校验路径、限额和 `AGENTS.md`。 +3. 生成确定性 ZIP,写入新 artifact。 +4. 调用 `agent.template.create(replace=true)` 替换运行时模板。 +5. 更新 `agent_templates.artifact_id` 和当前 Skill 数量。 +6. 删除不再引用的旧 artifact 和临时目录。 + +同一模板的写操作使用进程内 Promise 队列串行执行。若运行时替换或数据库提交失败,服务端恢复旧 artifact;新建模板失败时删除已创建的运行时模板和数据库记录。 + +## 10. 验收标准 + +- 自定义模板可编辑和删除,内置模板服务端保持只读。 +- 创建模板后列表立即出现,不需要发布。 +- 每次文件或设置保存成功后,新建数字员工立即使用修改后的模板。 +- 基于模板创建会复制当前文件、Skill、默认 LLM、业务系统和 MCP 设置。 +- 编辑或删除模板不影响已有数字员工。 +- 页面、API、数据库和测试中不存在模板草稿、发布或版本机制。 +- 从 `main` schema 迁移、新建数据库、单元测试、生产构建和浏览器 E2E 全部通过。 diff --git a/doc/agent-workspace-management-design.md b/doc/agent-workspace-management-design.md new file mode 100644 index 0000000000000000000000000000000000000000..a52facfcbac267200128141b9da3bf22be0a0208 --- /dev/null +++ b/doc/agent-workspace-management-design.md @@ -0,0 +1,77 @@ +# Agent 工作区与专属 Skill 管理设计 + +## 1. 目标与边界 + +Agent 详情页提供统一工作台,包含“概览”“Markdown”“Agent Skill”三个页签。管理员可以维护 Agent 工作区内受管 Markdown 和 Agent 专属 Skill;全局 Skill 继续对所有 Agent 可用,不受 Agent 级操作影响。 + +所有写入直接落到 Agent workspace,但不自动重建或重启活跃 sandbox。界面统一提示“新会话生效”。 + +## 2. Markdown 管理范围 + +服务端只展示和操作以下 Markdown: + +- workspace 根目录的 `*.md`。 +- `knowledge/**/*.md`。 +- `.learnings/**/*.md`。 +- `skills//**/*.md`。 + +Sender 工作目录、`memory/` 和其他运行产物不进入文件列表。路径必须是相对 POSIX 路径,拒绝绝对路径、盘符、反斜杠、`.`、`..`、控制字符、不可移植文件名和符号链接。 + +普通 Markdown 支持读取、新建、显式保存、重命名或移动、删除。内容上限为 10 MiB;写、重命名和删除使用 SHA-256 `etag` 做乐观锁,避免覆盖并发修改。修改前在管理数据目录保存版本备份,同一路径最多保留 20 份。 + +## 3. `AGENTS.md` 不变量 + +每个有效模板和 Agent workspace 都必须包含顶层普通文件 `AGENTS.md`。创建 Agent 时在模板复制前后分别校验该不变量,缺失、目录或符号链接都会使创建失败。 + +`AGENTS.md` 允许在线编辑,也允许恢复到系统默认文件: + +`docker/assets/workspace-templates/default/AGENTS.md` + +但不允许删除、重命名、移动或被其他路径覆盖。前端不显示删除和重命名入口,服务端仍独立强制校验。 + +## 4. Agent 专属 Skill + +Agent 专属 Skill 位于该 Agent workspace 的 `skills//`。工作台支持: + +- 上传 Skill ZIP。 +- 使用当前目录摘要 `etag` 替换已有 Skill。 +- 删除并移入管理回收目录。 +- 导出 ZIP 下载。 + +上传包必须包含顶层 `SKILL.md`,frontmatter 的 `name` 与 slug 一致且包含 `description`。ZIP 校验拒绝绝对路径、父目录穿越、反斜杠、符号链接、大小写重复路径、超限条目和高压缩比内容。 + +Agent 专属 Skill 不得与全局 Skill 使用相同 slug。全局 Skill 在工作台中只读展示,Agent 上传、替换和删除操作不会写入全局 Skill 目录或全局技能表。 + +## 5. API 与管理动作 + +Portal API: + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/agents/:slug/workspace/markdown` | 列出受管 Markdown | +| `GET` / `PUT` | `/api/agents/:slug/workspace/markdown/content` | 读取或保存 Markdown | +| `POST` | `/api/agents/:slug/workspace/markdown` | 新建 Markdown | +| `POST` | `/api/agents/:slug/workspace/markdown/rename` | 重命名或移动 Markdown | +| `DELETE` | `/api/agents/:slug/workspace/markdown` | 删除 Markdown | +| `POST` | `/api/agents/:slug/workspace/agents-md/restore-default` | 恢复默认 `AGENTS.md` | +| `GET` / `POST` | `/api/agents/:slug/skills` | 列出或上传 Agent Skill | +| `DELETE` | `/api/agents/:slug/skills/:skillSlug` | 删除 Agent Skill | +| `GET` | `/api/agents/:slug/skills/:skillSlug/archive` | 导出 Agent Skill | + +对应 Management actions 为 `agent.workspace.markdown.*`、`agent.docs.restore-default` 和 `agent.skills.*`。所有 Portal 写接口要求管理员权限并记录路径、slug 和摘要,不记录文件正文或 Skill 中的敏感内容。 + +## 6. 界面行为 + +Markdown 页签采用文件树和编辑器双栏布局;窄屏时上下排列。保存是显式操作,切换文件或页签遇到未保存内容时要求确认。`AGENTS.md` 使用保护标识,并提供“恢复系统默认”按钮。 + +Agent Skill 页签分为“Agent 专属”和“全局可用”两段。专属项提供上传、替换、下载和删除;全局项只展示状态和“所有 Agent 可用”作用域。 + +## 7. 验收要点 + +- Sender 目录不会出现在 Markdown 列表。 +- 旧 `etag` 的保存、重命名、删除和替换请求返回冲突。 +- `AGENTS.md` 可以编辑和恢复默认,但不能删除或重命名。 +- 缺少顶层 `AGENTS.md` 的模板不能创建 Agent,也不能发布为有效模板。 +- Agent Skill 生命周期不修改同名或其他全局 Skill。 +- 写入后不调用 gateway restart 或 sandbox 重建。 +- 桌面和窄屏下文件树、编辑器、按钮和表格不重叠。 diff --git a/doc/business-system-access-control-design.md b/doc/business-system-access-control-design.md new file mode 100644 index 0000000000000000000000000000000000000000..74ad2e3c3ed23ef8afc21509e0dcaa7dfec18b04 --- /dev/null +++ b/doc/business-system-access-control-design.md @@ -0,0 +1,433 @@ +# Agent 业务系统调用控制设计 + +## 1. 文档目的 + +本文定义业务系统调用的 Agent 级访问控制,满足: + +- 不同 Agent 可以访问不同业务系统。 +- 默认所有 Agent 可访问全部启用的业务系统,保持现有行为兼容。 +- 模板可以配置默认业务系统,创建 Agent 时复制为权限快照。 +- 权限在服务端实际调用链路强制执行,不能只依赖页面、Skill 提示或 sandbox。 + +本文只描述设计,不包含代码修改。关联设计: + +- [Agent 权限与 OpenClaw 原生 Sandbox 设计](./agent-permission-sandbox-design.md) +- [Agent 模板创建与内容管理设计](./agent-template-creation-design.md) + +## 2. 当前调用链与问题 + +当前链路为: + +```mermaid +flowchart LR + Agent --> CLI["apps-invoke-cli"] + CLI --> Socket["app-invoke.sock"] + Socket --> Resolver["按 applicationName 查询启用系统"] + Resolver --> Cookie["按 sessionId 获取 Cookie"] + Cookie --> Provider["业务系统 SDK"] + Provider --> Log["调用日志"] +``` + +现状存在以下授权缺口: + +1. `/api/internal/app-invoke/system` 只接收 `applicationName`,不知道真实 Agent。 +2. `findSystemForInvocation` 只检查业务系统存在且启用,没有 Agent ACL。 +3. `getCookie` 在命中 Cookie 缓存时会提前返回,未先校验 session、业务系统和 Agent 权限。 +4. CLI 请求中的 `AgentName` 由调用方提供,可用于展示但不能作为授权身份。 +5. 调用日志通过 `sessionId` 可以解析真实 Agent,但当前只在调用后记录,不能阻止调用。 +6. OpenClaw sandbox 只限制 Agent 能否接触调用 socket,不理解业务应用级权限;挂载了 socket 的 Agent 仍可以构造任意 `applicationName`。 + +因此必须建立统一的服务端授权函数,并在系统解析、Cookie 获取和实际调用三个位置重复使用。 + +## 3. 权限语义 + +### 3.1 模板策略 + +模板支持: + +- `all`:默认,可访问所有当前和未来处于 `active` 状态的业务系统。 +- `selected`:只允许关联表中列出的业务系统。 + +`selected` 可以为空,表示该模板创建的 Agent 默认不能调用任何业务系统。 + +### 3.2 Agent 策略 + +Agent 创建或编辑接口支持三个用户意图: + +- `inherit`:使用模板当前默认值。 +- `all`:显式允许全部启用系统。 +- `selected`:显式允许指定系统。 + +运行时不做动态模板继承。`inherit` 在保存时立即解析成具体的 `all` 或 `selected` 快照,并记录来源模板名称。这样模板后续变更不会静默扩大或缩小已有 Agent 权限。 + +Agent 页面提供“恢复模板默认”,本质是再次读取模板当前策略并覆盖 Agent 快照。 + +### 3.3 强制规则 + +无论策略如何,以下情况始终拒绝: + +- 业务系统不存在或状态不是 `active`。 +- session 不存在、已失效或不能解析到 Agent。 +- Agent 状态不是 `normal`。 +- 当前外部用户已经不再具有该 Agent 的使用权限。 +- Agent 策略为 `selected` 且目标系统不在关联表中。 + +权限判断的身份只来自服务端 `external_sessions` 关系,不接受 `AgentName`、用户名或请求正文中的 Agent ID 作为依据。 + +## 4. 数据模型 + +### 4.1 模板策略 + +建议新增 `template_business_system_policies`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `template_id` | INTEGER PK/FK | `agent_templates.id` | +| `mode` | TEXT | `all` 或 `selected`,默认 `all` | +| `created_at` / `updated_at` | TEXT | 时间 | + +建议新增 `template_business_system_access`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `template_id` | INTEGER FK | 模板 | +| `business_system_id` | INTEGER FK | 业务系统 | +| `created_at` | TEXT | 选择时间 | + +主键为 `(template_id, business_system_id)`。模板或业务系统删除时关联行级联删除。 + +模板不做版本管理。工作台保存时直接覆盖 `template_business_system_policies` 和 `template_business_system_access` 的当前状态并立即生效;审计日志记录操作者、模板、变更时间和结果,不保存历史模板策略快照。 + +### 4.2 Agent 策略 + +建议新增 `agent_business_system_policies`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `agent_id` | INTEGER PK/FK | `agents.id` | +| `mode` | TEXT | 已解析的 `all` 或 `selected` | +| `source` | TEXT | `default`、`template_snapshot` 或 `agent_override` | +| `source_template_id` | INTEGER NULL | 快照来源 | +| `policy_version` | INTEGER | 每次修改递增 | +| `created_by` / `updated_by` | INTEGER NULL | 管理员 | +| `created_at` / `updated_at` | TEXT | 时间 | + +建议新增 `agent_business_system_access`: + +| 字段 | 类型 | 说明 | +| --- | --- | --- | +| `agent_id` | INTEGER FK | Agent | +| `business_system_id` | INTEGER FK | 允许的业务系统 | +| `created_at` | TEXT | 选择时间 | + +主键为 `(agent_id, business_system_id)`。`mode = 'all'` 时不需要写关联行;`mode = 'selected'` 时关联行是完整允许集合。 + +不建议把系统 ID 数组存入 `agents` 的 JSON 字段。关联表更容易做外键清理、查询、审计和授权 SQL,也不会因应用名改名而失效。 + +### 4.3 调用日志 + +建议扩展 `system_invocation_logs`: + +| 字段 | 说明 | +| --- | --- | +| `agent_id` | 服务端解析出的 Agent 数据库 ID | +| `authorization_result` | `allowed` 或 `denied` | +| `authorization_reason` | 规范化原因码,不保存敏感细节 | +| `policy_version` | 做出决定时的 Agent 策略版本 | +| `business_system_id` | 目标业务系统 ID | + +被拒绝的请求也写日志,`success = 0`,不创建响应正文 artifact。建议原因码包括: + +- `session_not_found` +- `agent_inactive` +- `agent_user_revoked` +- `system_not_found` +- `system_disabled` +- `system_not_allowed` + +## 5. 统一授权服务 + +新增单一领域方法,所有运行时入口必须调用它: + +```ts +authorizeBusinessSystemInvocation({ + sessionId, + applicationName, + traceId +}): AuthorizedInvocationContext +``` + +成功结果至少包含: + +```ts +interface AuthorizedInvocationContext { + sessionId: string; + agentId: number; + agentSlug: string; + agentUserId: number; + businessSystemId: number; + applicationName: string; + provider: string; + baseUrl: string; + policyVersion: number; +} +``` + +建议使用一条主查询解析 session、Agent、用户关系、系统和策略,再根据 `mode` 判断关联表,避免调用方分别查询后出现身份不一致。 + +授权逻辑等价于: + +```text +sessionId + -> external_sessions + -> active agent and current agent-user permission + -> active business_system(applicationName) + -> agent_business_system_policies + -> mode == all OR agent_business_system_access contains system +``` + +策略记录在完成迁移后必须存在。不能长期使用“查不到策略就按 all”作为默认,否则部分写入失败会变成权限放大。 + +## 6. 调用时序 + +```mermaid +sequenceDiagram + participant A as Agent sandbox + participant C as Invoke CLI + participant S as Socket service + participant W as Management web + participant D as SQLite + participant B as Business system + + A->>C: invoke(sessionId, applicationName, command) + C->>S: Unix socket request + S->>W: resolve(sessionId, applicationName) + W->>D: session -> agent -> policy -> active system + alt denied + W->>D: write denied audit + W-->>S: 403 + reason code + S-->>C: normalized error + C-->>A: invocation denied + else allowed + W-->>S: authorized context + S->>W: get cookie after authorization + W-->>S: cookie + S->>B: execute command + B-->>S: result + S->>W: record result with sessionId + W->>D: derive agent again and write log + S-->>C: result + C-->>A: result + end +``` + +### 6.1 系统解析 + +将内部接口调整为: + +```http +GET /api/internal/app-invoke/system?sessionId=&applicationName= +``` + +`sessionId` 必填。`WebApiClient.findSystemForInvocation` 同步增加参数。接口返回的 Agent 信息仅供后续日志关联,不允许调用方覆盖。 + +该接口继续使用内部服务认证;不能仅依赖来源是 localhost。OpenClaw sandbox 不直接访问 Web API,而是通过受控 Unix socket 服务访问。 + +### 6.2 Cookie 获取 + +`GET /api/external/cookie` 已包含 `sessionId` 和 `applicationName`,但处理顺序必须改为: + +1. 解析 session 和真实 Agent。 +2. 对目标系统执行授权。 +3. 授权成功后再读取 `external_session_cookies` 缓存。 +4. 未命中时创建 Cookie 并缓存。 + +即使 Cookie 按 provider 共享或已经缓存,每一次业务系统请求仍必须重新做 Agent ACL。缓存命中不能成为绕过授权的快捷路径。 + +### 6.3 实际调用 + +Socket handler 在调用 Provider SDK 前执行统一授权。不能只依赖 Cookie 接口检查,因为: + +- 某些 Provider 未来可能不需要 Cookie。 +- 调用链可能增加新的凭据来源。 +- 权限可能在系统解析后、实际调用前发生变化。 + +首期允许同一请求内复用刚返回的 `AuthorizedInvocationContext`,但上下文必须短生命周期且与 `traceId` 绑定。权限撤销对下一次调用生效,不承诺中止已经发出的远端请求。 + +### 6.4 日志 + +调用日志中的 `agent_id` 和 `agent_slug` 必须按 `sessionId` 再次从服务端解析。请求中的 `AgentName` 仅为兼容字段,忽略其授权和审计含义。 + +日志写入失败不应改变已经完成的业务调用结果,但必须产生本地错误日志和监控告警。拒绝日志应在返回 403 前尽力写入;若失败,同样告警。 + +## 7. 管理 API + +### 7.1 Agent + +| 方法 | 路径 | 用途 | +| --- | --- | --- | +| `GET` | `/api/agents/:slug/business-systems` | 获取 Agent 当前快照和来源 | +| `PUT` | `/api/agents/:slug/business-systems` | 设置 `all` 或 `selected` | +| `POST` | `/api/agents/:slug/business-systems/restore-template` | 重新复制模板当前策略 | + +更新请求示例: + +```json +{ + "mode": "selected", + "business_system_ids": [3, 8], + "policy_version": 5 +} +``` + +使用 `policy_version` 做乐观锁。不存在、重复或已删除的系统 ID 返回 `400`;停用系统允许保存关联,但运行时仍拒绝。 + +### 7.2 模板 + +模板工作台使用: + +```http +PUT /api/templates/:templateName/business-systems +``` + +修改模板默认值时直接覆盖模板当前策略,接口成功即生效,不包含策略版本参数。 + +### 7.3 目录查询 + +业务系统选择器复用现有业务系统列表接口,返回 `id`、`application_name`、描述和状态。前端提交稳定的数据库 ID,不提交应用名作为关联键。 + +## 8. Agent 创建与模板快照 + +创建 Agent 的事务顺序建议为: + +1. 读取模板当前内容和业务系统策略。 +2. 解析创建请求的 `inherit`、`all` 或 `selected`。 +3. 调用 OpenClaw 创建 Agent 和复制 workspace。 +4. 在本地事务中写入 `agents`、Agent 业务系统策略及关联行。 +5. 任一步骤失败时调用现有 `agent.delete` 补偿并恢复 OpenClaw 配置。 + +模板策略为 `selected` 时必须深复制关联行。不能让 Agent 运行时直接联表读取模板,否则模板修改会影响存量 Agent。 + +## 9. 管理界面 + +### 9.1 模板 + +模板工作台右侧显示: + +- 单选:全部业务系统 / 指定业务系统。 +- 指定模式下显示可搜索、多选的系统列表。 +- 停用系统使用明确状态标识,允许从历史选择中移除。 +- 复制模板时完整复制模式和选中项。 + +### 9.2 Agent + +Agent 创建和编辑页显示: + +- 使用模板默认。 +- 全部业务系统。 +- 指定业务系统。 + +详情页显示当前有效策略、来源模板名称和最近更新时间。使用模板快照时不要显示为“持续继承”,应显示“创建时复制自模板 X”。 + +## 10. 错误语义 + +| 场景 | HTTP/错误 | 对 Agent 的消息 | +| --- | --- | --- | +| 缺少 `sessionId` 或应用名 | `400` | 请求参数不完整 | +| session 无效 | `404` 或统一会话错误 | 会话无效,需要重新建立 | +| 系统不存在或已停用 | `404` | 业务系统不可用 | +| Agent 无权访问 | `403` | 当前 Agent 未获授权访问该业务系统 | +| Agent 停用或超限 | `403` | 当前 Agent 状态不允许调用 | +| 内部授权服务故障 | `503` | 权限校验暂不可用 | + +授权服务异常必须失败关闭,不能因为数据库或管理服务不可用而默认允许调用。 + +## 11. 缓存与性能 + +首期不建议增加权限缓存。当前 SQLite 查询成本低,直接查询更容易保证撤权即时生效。 + +如果后续压测证明需要缓存,缓存键至少包含: + +```text +(agentId, businessSystemId, agentPolicyVersion, businessSystemUpdatedAt) +``` + +修改 Agent 策略、业务系统状态或删除系统时主动失效。不得只按 `applicationName` 缓存授权结果。 + +## 12. 迁移与发布 + +### 12.1 数据迁移 + +1. 创建四张策略和关联表。 +2. 为所有存量模板写入 `mode = 'all'`。 +3. 为所有存量 Agent 写入 `mode = 'all'`、`source = 'default'`。 +4. 校验策略行数量与模板、Agent 数量一致。 +5. 兼容发布期间可以临时把缺失策略解释为 `all`,但只用于滚动升级窗口。 +6. 完成回填和校验后,运行时遇到缺失策略必须失败关闭并告警。 + +该迁移保证上线时不改变存量 Agent 的有效权限。 + +### 12.2 发布顺序 + +1. 先发布数据库迁移和只写不拦截的管理能力。 +2. 回填并验证所有策略。 +3. 发布 CLI/Web API 对 `sessionId` 的透传和统一授权服务。 +4. 以审计模式运行,记录“将被拒绝”的请求但暂不阻断。 +5. 检查误判后开启强制阻断。 +6. 最后开放模板和 Agent 页面上的业务系统选择。 + +## 13. Sandbox 的关系与副作用 + +- Sandbox 可以限制哪些 Agent 能调用 `apps-invoke-cli`,但不能表达“Agent A 可调用 CRM、不可调用 ERP”。 +- 所有 Agent 默认可使用全部工具时,业务系统 ACL 是唯一可靠的应用级授权边界。 +- 专用 Unix socket 目录挂载到 sandbox 后,任何拥有 `exec` 的 Agent 都可能绕过 Skill 文档直接构造 CLI 请求,因此服务端不能信任调用姿势。目标路径见权限设计;当前单文件固定路径需要在实现时迁移。 +- `network: "none"` 不影响 socket 方案,远程业务请求由 sandbox 外的服务进程发起。 +- 若将来开放 sandbox 网络,仍不能移除服务端 ACL;Agent 可能绕过 CLI 直接访问业务系统地址,因此业务系统网络应继续只允许受控服务进程访问。 + +## 14. 预计影响模块 + +- `packages/daedalus-agent-management-web/server/src/db/index.js`:策略、关联和调用日志字段迁移。 +- `packages/daedalus-agent-management-web/server/src/services/system-service.js`:统一系统授权和拒绝日志。 +- `packages/daedalus-agent-management-web/server/src/services/external-service.js`:先授权后读取 Cookie 缓存,并校验存量 session。 +- `packages/daedalus-agent-management-web/server/src/services/agent-service.js`:创建 Agent 时复制模板策略快照。 +- `packages/daedalus-agent-management-web/server/src/api/routes/index.js`:管理接口和带 `sessionId` 的内部解析接口。 +- `packages/daedalus-agent-apps-invoke-cli/src/proxy/handler.ts`:调用前授权并忽略 `AgentName` 的身份含义。 +- `packages/daedalus-agent-apps-invoke-cli/src/proxy/web-api-client.ts`:系统解析时透传 `sessionId`。 +- Agent 和模板管理页面:业务系统模式、选择器、快照来源和恢复模板默认。 + +## 15. 测试与验收 + +### 15.1 授权矩阵 + +至少覆盖: + +| Agent 策略 | 系统状态 | 是否选中 | 结果 | +| --- | --- | --- | --- | +| `all` | active | 不适用 | 允许 | +| `all` | disabled | 不适用 | 拒绝 | +| `selected` | active | 是 | 允许 | +| `selected` | active | 否 | 拒绝 | +| `selected` | disabled | 是 | 拒绝 | +| `selected` | active | 空集合 | 拒绝 | + +### 15.2 绕过测试 + +- 伪造 `AgentName` 不能改变授权结果和日志中的真实 Agent。 +- 已缓存 Cookie 时,未授权系统调用仍被拒绝。 +- 直接调用内部系统解析接口缺少 `sessionId` 时失败。 +- 直接向 Unix socket 构造请求仍执行相同 ACL。 +- 业务系统在授权后被停用时,下一次调用被拒绝。 +- 撤销用户对 Agent 的使用权后,旧 session 不能继续调用。 + +### 15.3 模板与迁移 + +- 存量模板和 Agent 迁移后有效策略均为 `all`。 +- 从 `selected` 模板创建 Agent 后,Agent 得到独立快照。 +- 修改模板选择不影响已有 Agent。 +- “恢复模板默认”只更新目标 Agent,且写入审计日志。 + +### 15.4 审计 + +- 允许和拒绝请求均记录服务端解析出的 Agent、session、系统、策略版本和结果。 +- 拒绝日志不保存 Cookie、密钥或未脱敏请求正文。 +- 正常调用日志记录失败时产生监控告警。 diff --git a/doc/install-guide.md b/doc/install-guide.md new file mode 100644 index 0000000000000000000000000000000000000000..0dcfd34afc1412be950f06fdd6fd0b899efce67c --- /dev/null +++ b/doc/install-guide.md @@ -0,0 +1,132 @@ +# 快速部署手册 + +解决方案由一体化容器和对话应用构成,一体化容器中内置智能体集群构建与管理所需的全部组件,对话应用使用活字格构建,提供实现对话式交互。 + +## 推荐环境 + +- x64 架构 +- 4C8G 或更高配置 +- Linux / WSL / Docker Desktop + +## 步骤1:启动容器 + +```bash +docker volume create daedalus-agent-data +docker run -d --restart unless-stopped --name daedalus-agent --cap-add NET_ADMIN -p 3030-3050:3030-3050 -p 1883:1883 -p 9000:9000 -v daedalus-agent-data:/var/daedalus-agent crpi-4auaoyyj6r36p6lb.cn-hangzhou.personal.cr.aliyuncs.com/huozige_lab/daedalus-agent-x64:[版本号] +``` + +`[版本号]` 请参考项目 Release,例如 `20260804.01`。运行数据保存在 Docker volume `daedalus-agent-data` 中;如需隔离多套环境,请替换为新的 volume 名称。详细说明见 [docker/README.md](./docker/README.md)。 + +## 步骤2:登录管理控制台 + +容器启动后,等待 Docker logs 出现 `management console should be available ...`,然后访问宿主机 `3030` 端口,例如: + +```text +http://127.0.0.1:3030 +``` + +启动命令会开放 `3030-3050`。其中 `3030` 当前用于管理控制台,`3031-3050` 作为后续服务预留端口。 + +默认账号: + +- 用户名:`admin` +- 初始密码:`admin` + +首次登录后请按提示修改密码。 + +## 步骤3:配置大语言模型 + +在管理控制台“大语言模型”菜单中依次创建服务商和模型。 + +服务商需要: + +- 服务商 ID +- OpenAI 兼容 API 基础地址,通常以 `/v1` 结尾 +- API Key + +模型需要: + +- 所属 Provider +- 模型 ID +- 名称 +- 上下文长度、输入上限等参数 + +如果模型服务位于内网,需要在管理控制台 `系统设置` 页面中,将该服务器的地址(如 `172.16.11.12`)添加到白名单。需要注意的是,白名单的变更需要重启容器方可生效,而不是重启服务。 + +## 步骤4:接入业务系统 + +首先,在管理控制台 `系统设置` 页面中,将业务系统和对话应用所在的服务器 IP 地址或域名(对话应用和业务系统需要部署在同一台服务器中)设置到 `对话应用/业务应用服务器`。服务器地址保存到系统数据库后会立即用于业务调用;IP 地址会同步到 IP 白名单,白名单变更需重启容器后生效;域名按外网地址处理,不加入白名单。 + +然后,在“业务系统”菜单中添加接入的业务系统。活字格系统通常需要填写: + +- 应用名 +- 端口 +- 是否启用 HTTPS +- Ontology zip + +Ontology zip 需通过 `huozige-ontology-builder` 构建,[点击阅读使用指南](https://www.npmjs.com/package/huozige-ontology-builder)。 + +## 步骤5:添加数字员工 + +在“数字员工”菜单中创建数字员工。通常需要配置: + +- ID +- 模板 +- 名称 +- 服务商 / 模型 +- 每日 Token 限额 +- 授权用户 + +创建完成后,点击 `编辑` 按钮,在 `分配` 选项卡中,将该数字员工分配和活字格管理控制台上的用户。此处需要输入的是用户名,而不是用户全名。 + +## 步骤6:对接对话应用 + +对话应用采用活字格构建,可使用 `https://gitee.com/low-code-dev-lab/daedalus-agent-cui-hzg`。 + +原则上,对话应用仅需要配置以下四个全局变量: + +- `DA_SERVER_IP` +- `DA_MQTT_CHANNEL_PASSWORD` +- `DA_S3_SECRET_ACCESS_KEY` +- `DA_MANAGEMENT_CONSOLE_TOKEN` + +MQTT/MinIO 密码可在 `系统设置` 页面中 `基础设置` 选项卡获取;管理控制台 Token 在 `访问令牌` 选项卡中创建。 + +接下来,检查以下全局变量的值是否和预设值相符,否则可能会导致系统无法正常工作。 + +|全局变量|预设值|需要修改的场景| +|----|----|----| +|DA_MQTT_CHANNEL_PORT|1883|docker启动脚本中修改过MQTT的端口映射,通常用于规避端口占用,下同| +|DA_S3_PORT|9000|docker启动脚本中修改过S3的端口映射| +|DA_MANAGEMENT_CONSOLE_PORT|3030|docker启动脚本中修改过管理控制台的端口映射| +|DA_MQTT_CHANNEL_USERNAME|daedalus-agent|除非必要,不要修改,下同| +|DA_S3_ACCESS_KEY_ID|daedalus-agent|-| +|DA_S3_REGION|local|-| +|DA_S3_FORCE_PATH_STYLE|true|-| +|DA_S3_AGENT_INBOX_BUCKET|daedalus-agent-inbox|-| +|DA_S3_AGENT_OUTBOX_BUCKET|daedalus-agent-outbox|-| +|MQTT_CONNECTION_NAME|daedalus_conn_|-| +|MQTT_TOPIC_FILTER|daedalus-agent/agent/#|-| + +重启应用后,就可以登录对话应用,开始使用您的数字员工了。 + +## 常见故障排查 + +### 管理控制台无法打开 + +从 Docker 启动到管理控制台就绪通常需要花费数十秒时间,视服务器性能而定,如等待一分钟后重试仍无法打开,说明服务出现故障,请执行 `docker logs daedalus-agent` 命令检查。 + +- 端口冲突:在日志中发现 `bind: Address already in use` 说明端口被占用,您需要检查服务器上是否有其他容器或服务占用了该端口,如果无法排除(如您需要在同一台服务器上运行多个相互硬隔离的 deadalus agent),可以先移除容器,修改 docker 的启动脚本,映射到其他端口号后重新启动,最后同步修改对话应用的全局变量中对应端口号的值。 +- 其他错误:如在日志中没有找到端口冲突的标识性文字,这通常说明遇到了其他错误,请与我们联系排查。 + +### 对话应用提示:Connecting with MQTT server failed (BadUserNameOrPassword). + +检查对话应用全局配置中的 `DA_MQTT_CHANNEL_USERNAME` 和 `DA_MQTT_CHANNEL_PASSWORD`,确保和管理控制台中【系统设置】页面显示的运行凭据一致。重点检查内容是否完整,中间是否有多余的空格。 + +### 对话应用中发送消息后无应答 + +如果发送消息后,5分钟依然无应答,且无法通过重启对话应用来修复,说明通讯链路可能存在异常,请按照以下顺序进行排查: + +1. 在管理控制台的【系统设置】页面中【系统日志下载】选项卡,下载当前日期的 `交互日志`,检查是否存在 `"direction":"IN"` 的入站日志和 `"direction":"OUT"` 的出站日志。 +2. 如果没有进站日志,则检查对话应用全局配置中的 `DA_MQTT_CHANNEL_USERNAME` 和 `DA_MQTT_CHANNEL_PASSWORD`;如果有入站日志,但没有出站日志,则需要在管理控制台的【数字员工】页面中,点击该员工,通过右上角的【对话测试】,检查大语言模型配置是否正确。 +3. 如果存在进出站日志,请检查对话应用全局配置中的 `MQTT_TOPIC_FILTER`。 diff --git a/doc/system-feature-list.md b/doc/system-feature-list.md new file mode 100644 index 0000000000000000000000000000000000000000..47a3151031338a8f56b0b7baec5b1163aa429634 --- /dev/null +++ b/doc/system-feature-list.md @@ -0,0 +1,181 @@ +# 系统功能清单 + +> 依据仓库当前源码、单元/契约测试和 Docker 集成测试整理。本文描述当前实现,不把设计文档中的未来规划当作已交付功能。 +> +> 标记说明:`[x]` 源码已有实现;`[T]` 有对应测试覆盖;`[!]` 当前限制或明确不支持。 + +## 1. 系统定位与运行拓扑 + +- [x][T] 提供一体化 Docker 部署,包含管理控制台、OpenClaw Gateway、MQTT Broker(Aedes)、MinIO、应用调用 socket service。 +- [x][T] 默认开放管理端口 `3030`、预留服务端口 `3031-3050`、MQTT `1883`、MinIO S3 API `9000`。 +- [x] 使用命名 Docker volume 持久化 `/var/daedalus-agent`,保存 OpenClaw 状态、工作区、模板、Ontology、SQLite 数据、制品、日志、MQTT/MinIO 数据和运行状态。 +- [x][T] 启动时校验 `env.json`、`config.json`;缺失时写入示例文件并要求用户检查后重启;拒绝用户覆盖由系统托管的内部路径、MQTT、MinIO、Topic 和 IPC 配置。 +- [x][T] 自动生成并持久化 MQTT/MinIO 运行凭据;管理控制台可显示运行凭据。 +- [x] 以 `daedalus-agent` 运行 OpenClaw/Agent,以 `daedalus-svc` 运行守护进程、Broker、MinIO、管理端和 invoke service,通过共享组控制只读目录、日志和 socket 访问。 +- [x][T] 对 Agent 进程实施内网访问白名单和 iptables 限制;管理控制台可维护白名单,IP 地址变更在容器重启后生效。 +- [x][T] 提供启动步骤耗时日志、子服务守护、服务重启、容器重启请求文件和快速重启保护。 +- [x][T] 通过内置内容指纹执行 OpenClaw home、插件、Skill、模板和脚本的增量 seed/迁移;被替换内容先移入备份目录;Ontology 不参与 seed 覆盖。 +- [x][T] 提供 `run_after_startup.sh` 启动后钩子,支持用户编辑并在重启后执行。 +- [x][T] 持久化 npm、pip/venv、uv 包安装目录,支持 Agent 运行时安装包并在容器重启后保留。 +- [x] 内置文档/文件处理工具链,包括 Python、LibreOffice、Pandoc、PDF/图片/OCR、FFmpeg 等运行依赖。 + +## 2. 管理控制台 Web + +### 2.1 登录、用户与品牌 + +- [x][T] 管理员登录、登出、当前用户查询、会话 Token 管理。 +- [x][T] 默认账号 `admin/admin`;首次使用默认密码后可修改密码。 +- [x][T] 修改本人密码;管理员创建、编辑、停用/启用、删除其他管理员,并可重置密码为默认值或指定值。 +- [x][T] 管理员与普通用户的权限校验;高风险操作统一要求管理员权限。 +- [x][T] 管理操作写入审计日志,支持分页查询。 +- [x] 门户名称、副标题、Logo 上传/替换/恢复默认、主题配置和门户元数据展示。 +- [x][T] 创建、查看、删除管理端 Access Token;Token 支持复制、显示/隐藏,删除后立即失效。 + +### 2.2 总览、同步与版本 + +- [x] 仪表盘展示数字员工、模板、Skill、业务系统数量和 Token 用量等统计。 +- [x][T] 启动同步等待 `service.ping`,随后执行 `version.list`、`catalog.snapshot`;目录快照成功后延迟刷新 `agent.usage.list`。 +- [x][T] 目录和用量按计划自动刷新;每次完成后按配置间隔再次执行;页面可轮询同步状态。 +- [x][T] “同步数据”手动执行目录快照再执行用量刷新,并展示各对象的状态、开始/结束时间、错误和摘要。 +- [x] 将 Provider/Model、Agent、模板、Skill、业务系统、Ontology 目录缓存到 SQLite;页面列表从 SQLite 读取而不是逐页直接调用远端管理动作。 +- [x][T] 缓存组件版本信息并在设置页展示 Gateway、插件、CLI 等版本状态。 + +### 2.3 大语言模型管理 + +- [x][T] Provider 新增、编辑、删除、列表;保存 OpenAI 兼容 `baseUrl` 和 API Key(仅保存掩码/指纹展示)。 +- [x][T] Provider 连接测试,返回结构化成功/失败、HTTP/网络错误信息。 +- [x][T] Model 新增、编辑、删除、按 Provider 筛选;维护模型 ID、名称、上下文长度、输出 Token 上限。 +- [x][T] Model 连接测试,使用 OpenAI-compatible chat completion 验证配置和上下文。 +- [x] 使用 `provider_id/model_id` 作为运行时模型引用;删除操作保护仍被 Agent/模板引用的记录。 +- [x] 支持 Agent 主模型和备选模型;校验 Provider/Model 必须成对选择,备选模型不能与主模型相同。 + +### 2.4 员工模板管理 + +- [x][T] 模板以 workspace ZIP 保存;创建方式包括空白模板、导入 ZIP、基于已有模板克隆。 +- [x][T] 导入包校验顶层 `AGENTS.md`、路径安全、文件数量/总大小和 Skill 结构。 +- [x][T] 模板列表、详情、关联数字员工、下载归档、删除;列表名称和编辑按钮均进入详情,操作列仅保留编辑、删除,内置模板支持编辑但不可删除。 +- [x][T] 模板和数字员工统一使用全屏详情编辑体验;模板支持概览、文件、技能、业务系统、MCP、模型 tabs,概览只读并展示模板使用情况;编辑工具栏提供重命名、下载和创建数字员工。 +- [x][T] 模板工作台支持文件树、文本/二进制读取、下载、保存文件、新建目录/文件、上传文件、移动/重命名、删除,内置模板使用相同的文件与 Skill 编辑能力。 +- [x][T] 保护 `AGENTS.md`,支持恢复系统默认内容;写入采用队列和校验,避免并发编辑覆盖。 +- [x][T] 模板内 Skill ZIP 上传、替换、删除;模板校验返回文件和 Skill 摘要。 +- [x][T] 配置模板默认主 Provider/Model 和备选 Provider/Model;业务系统访问范围(全部/选定)和 MCP 访问范围(全部/选定)可在详情 tabs 中保存,全部模式覆盖当前及未来新增对象。 +- [x][T] 新建 Agent 时继承模板默认主/备选模型、工作区文件、模板 Skill 和模板 MCP/业务系统策略。 +- [x][T] 自定义与内置模板均支持详情页重命名;重命名同步远端模板和已有 Agent 的模板引用,并保留内置模板标记与删除保护。 + +### 2.5 全局 Skill 管理 + +- [x][T] 全局 Skill 列表,区分内置与管理员上传的 Skill,并展示远端安装状态、上传人和时间。 +- [x][T] 上传前解析 ZIP 中的 `SKILL.md` frontmatter,要求 `name`、`description`,并将名称归一化为安全 slug。 +- [x][T] 全局 Skill 新增、替换、删除;通过 `skills.global.install.local` / `skills.global.delete` 同步到 OpenClaw。 +- [x][T] 校验 ZIP 路径穿越、符号链接、条目数量、单包大小和 Skill 目录结构。 +- [!] 内置 Skill 不允许删除;当前全局 Skill 管理不提供在线编辑 Skill 文件的工作台。 + +### 2.6 数字员工(Agent)管理 + +- [x][T] Agent 新增、列表、详情、编辑、启用/停用、删除;ID/slug 校验并保持与远端 Agent 一致。 +- [x][T] 配置名称、描述、标签、模板、主/备选 LLM、每日 Token 限额、状态和授权用户;用户范围支持指定用户或所有人,所有人模式对当前及未来新增的 Agent 平台用户动态生效。 +- [x][T] 数字员工名称和编辑按钮均从列表进入全屏详情,操作列仅保留编辑、删除;详情包含只读概览、文件(默认加载 `AGENTS.md`)、技能、业务系统、MCP 和分配 tabs;技能、业务系统、MCP 三个 tab 复用员工模板对应的设置布局(模板式 `List`、`Segmented`、搜索、勾选、状态摘要和保存区域),数字员工技能页仅展示当前 Agent 专属 Skill,不展示全局或内置 Skill,分配复合框占满区块宽度,支持从现有用户选择或直接添加用户名,并支持全部当前及未来用户。 +- [x][T] 状态包括 `normal`、`disabled`、`overlimit`;用量刷新超过每日限额时自动标记 `overlimit`,不可直接恢复为 `normal` 直到超额问题解决。 +- [x][T] 创建/更新具备远端先行、本地 SQLite 落库、失败补偿和回滚;远端已不存在的 Agent 可清理本地记录。 +- [x][T] Agent 用户目录支持分页搜索、单个新增、换行批量导入、删除和用量/关联员工查询。 +- [x][T] 为 Agent 分配或移除用户,支持带策略版本的“所有人/指定用户”访问范围;外部目录按 `userName`/`id` 返回用户有权限的 Agent,未传身份时仅返回“所有人”可用的 Agent,并在每个目录项返回 `is_public` 标记,所有人模式对新增用户即时生效。 +- [x][T] 服务层维护 Agent 用户访问策略 `selected/all`、策略版本和乐观并发更新;数据库 v17 迁移将历史 Agent 初始化为 `selected`,保持原有显式授权语义,管理端提供“所有人/指定用户”配置入口。 +- [x][T] 配置 Agent 业务系统权限(全部/选定)和 MCP 权限(全部/选定),带策略版本号并进行乐观并发控制。 +- [x][T] Agent 工作区导出为 ZIP;导出制品写入本地临时制品存储,下载后清理,不依赖 S3。 +- [x][T] Agent 工作区 Markdown 文件列表、读取、创建、保存、移动/重命名、删除、下载,使用 etag 做乐观并发校验;文件页不展示额外的“新会话生效”提示。 +- [x][T] 恢复 Agent 默认 `AGENTS.md`;Agent Skill 列表、ZIP 安装/替换、删除、导出,支持 etag 和 slug 校验。 +- [x][T] 管理控制台提供 Agent 入站 Topic 探测、管理 Topic 探测和对话测试;对话测试展示发送消息、回复消息、步骤耗时和完整 MQTT 报文。 + +### 2.7 MCP 服务管理 + +- [x][T] MCP 服务新增、编辑、删除、启用/停用、列表和运行时同步。 +- [x][T] 支持 HTTP(Streamable HTTP/SSE)和 CLI/stdio 两种接入模式。 +- [x][T] HTTP 支持无认证、OAuth、自定义 Header;CLI 支持白名单可执行文件、参数、受控工作目录和环境变量。 +- [x][T] 配置连接/请求超时、Tool include/exclude 过滤和并行 Tool Call 能力。 +- [x][T] 保存前或保存后 Probe,读取 Tool 列表并保存最后一次探测结果;MCP 配置变更失败时回滚数据库和运行时投影。 +- [x][T] Agent/模板按全部或选定 MCP 服务生成运行时配置;无权限服务投影为 Agent deny 规则。 + +### 2.8 业务系统(Ontology)管理 + +- [x][T] 业务系统新增、编辑、启用/停用、删除、列表;当前 Provider 固定为 `hzg`。 +- [x][T] 保存应用名、描述、HTTP/HTTPS、端口和 Ontology ZIP;ZIP 顶层必须包含 `index.md`。 +- [x][T] 业务系统地址由系统设置中的对话/业务应用服务器地址加端口和应用名派生;不接受请求直接覆盖 host。 +- [x][T] 业务系统创建/更新/删除会同步 OpenClaw Ontology;远端或本地失败时执行补偿和制品清理。 +- [x][T] 对业务系统做 GET 连通性测试并保存最近结果。 +- [x][T] 展示调用统计;按应用名/Agent 筛选调用日志,查看请求/响应摘要、trace、耗时、成功状态和错误。 +- [x][T] 请求与响应 Payload 以摘要、字节数、SHA-256 和有限预览保存;若可用则提供受控附件下载。 + +### 2.9 日志、设置与运维操作 + +- [x][T] 系统日志页包含审计日志、业务系统调用日志和内核/管理动作调用日志,并支持详情查看。 +- [x][T] 系统日志下载支持按日期选择 MQTT、MinIO、内核、应用、OpenClaw、管理端、invoke service、MQTT 通讯和交互日志。 +- [x][T] 基础设置维护门户信息、对话/业务应用服务器地址、运行凭据和 IP 白名单。 +- [x][T] 设置页展示 MQTT/MinIO/服务运行健康状态,支持刷新健康缓存。 +- [x][T] 支持目录同步、查询 Gateway 状态、重启 Gateway、重启容器、执行任意已注册管理指令和运行服务器诊断。 +- [T] 设置页对重启进行轮询等待、提示服务重启状态;后台实时更新通过 WebSocket/update notifier 推送。 + +## 3. 外部与内部 API + +- [x][T] 外部服务 API:按 `userName`/`id` 查询可用 Agent;未传身份时仅查询“所有人”可用的 Agent,并返回每项的 `is_public` 标记;支持创建/复用用户-Agent 会话、获取会话上下文、获取业务系统 Cookie、记录调用日志。 +- [x][T] 管理 Web 提供面向 `/api/external/*` 对外接口的 OpenAPI 3.0 文档和本地 Swagger UI:文档入口为 `/doc`,规范 JSON 为 `/doc/openapi.json`,包含应用场景、鉴权、参数用途/限制/可选项、请求体和常见错误说明;基础设置的版本信息下方提供“API 文档”入口。 +- [x][T] 外部会话 ID 使用 `s-` 加 30 位数字格式;同一用户-Agent 复用会话;会话创建校验 Agent 状态和用户授权。 +- [x][T] 外部调用在每次解析业务系统时重新校验会话有效性、Agent 状态、业务系统状态和 Agent 业务系统策略。 +- [x][T] 内部 CUI API:查询可用 Agent 目录;内部 invoke API:解析授权业务系统、获取业务系统 base URL、记录调用日志。 +- [x][T] API 鉴权分层:管理端登录 Token、服务 Bearer Token、内部请求校验;localhost 请求按配置可走内部通道。 +- [x][T] 外部 Cookie 缓存并按 `hzg` Provider 获取;业务系统调用结果写入统一调用日志和 Payload 制品。 + +## 4. 应用调用 CLI 与 socket service + +- [x][T] `servercommand`:按应用名、命令名调用业务系统 Server Command,支持 GET/POST。 +- [x][T] `binding`:调用系统 Binding;支持 `GetTableDataWithOffset`、`GetComboBindingOptions`、`CalcBindingDataSource`。 +- [x][T] Binding 命令别名归一化;校验表格列、分页数字、查询参数和数据源字段结构。 +- [x][T] `status` 输出固定 socket 路径、超时和支持的命令;`serve` 启动常驻服务;`service-status` 输出服务状态结构。 +- [x][T] 请求必须带 `-s/--sessionId`;POST body、GET query 支持位置参数、文件或 stdin 三选一,支持大请求体和 JSON 校验。 +- [x][T] Client 固定连接 `/var/daedalus-agent/run/app-invoke.sock`,默认请求超时 500 秒;通过换行分隔 JSON envelope 通信。 +- [x][T] Socket service 由 `daedalus-svc` 运行,读取管理端 API 解析业务系统、外部 Cookie 和调用日志;Agent 侧不直接访问业务系统。 +- [x][T] 统一返回 trace、状态码、消息和数据;覆盖 socket 断开、超时、超大 payload、畸形 JSON、Provider 错误和日志失败。 +- [!] 不支持通过 CLI 传入旧版 Provider、用户名或 Agent 名参数;业务系统类型固定为 HZG。 +- [!] `GetMetadata`、`GetMetadata2` 当前只做命令名识别,Provider 层明确拒绝执行。 + +## 5. MQTT 双向通道插件 + +- [x][T] OpenClaw 插件 ID、npm 包名和 Channel ID 均为 `daedalus-agent-mqtt-channel`。 +- [x][T] 订阅 inbound Topic,将纯文本或 JSON 消息送入 OpenClaw;将 Agent `block`/`final` 回复封装为 JSON 发布到 outbound Topic。 +- [x][T] JSON 入站字段包括 `senderId`、`text`、可选 `sessionId`、可选 `disableBlockStreaming`;纯文本按 Topic 派生 sender。 +- [x][T] 出站字段包括 `senderId=openclaw`、`text`、`kind`、时间戳和 `sessionId`;未提供 sessionId 时使用 `-1`,数字 sessionId 规范化为字符串。 +- [x][T] 以账号 ID + senderId 路由 Agent;按路由 sessionKey 串行处理同一会话,记录队列深度并在深度达到阈值时告警。 +- [x][T] 支持多账号、多 MQTT 客户端逻辑复用、默认账号、旧版单账号配置和环境变量配置。 +- [x][T] 支持 `mqtt://`/`mqtts://`、用户名密码、clientId、QoS 0/1/2、TLS 校验和交互式 onboarding。 +- [x][T] 支持断线、offline、close、error 事件的指数退避重连,重连后自动重新订阅;支持订阅/发布/取消订阅失败处理。 +- [x][T] 记录功能日志和按天交互日志,包含 IN/OUT、sender、session、Topic、kind、队列等诊断字段。 +- [!] Channel 能力当前仅为 direct chat;不支持媒体、reaction、thread。 +- [!] MQTT `sessionId` 只做请求/回复关联,不会创建 OpenClaw 新会话或直接改变记忆上下文;需要隔离记忆时应使用不同 senderId。 +- [!] `MQTT_CA_PATH` 当前仅作为配置字段传递,代码不会自动读取证书文件内容。 + +## 6. 文件传输与可选 Skill + +- [x][T] `daedalus-agent-transfer-file` Skill 支持从 S3/MinIO 下载 `file_input://...` 到 `/upload`,以及将当前 sender 工作区文件上传到 outbox bucket。 +- [x] 约定 `/upload`、`download`、`generated` 三类目录,隔离不同会话的文件。 +- [x] 通过 `DA_S3_ENDPOINT/REGION/ACCESS_KEY_ID/SECRET_ACCESS_KEY/FORCE_PATH_STYLE` 和 inbox/outbox bucket 配置运行。 +- [x] Skill 文档要求只读操作可自动执行;创建、更新、提交、审批、驳回、删除、触发等状态变更必须预览并取得人工确认。 + +## 7. 测试验证范围 + +| 范围 | 主要验证内容 | 代表测试位置 | +| --- | --- | --- | +| 管理 Web 服务 | 路由、鉴权、用户/Agent/模板/Skill/LLM/MCP/业务系统 CRUD、内置模板编辑/重命名与删除保护、模板主/备选模型和 Agent 继承、Agent 用户访问策略、外部 Agent 目录匿名公开筛选与 `is_public` 标记、仅面向 `/api/external/*` 的 OpenAPI/Swagger 文档 5 个操作及静态资源路由、审计、同步、健康检查、日志下载、SQLite 回滚和制品安全 | `packages/daedalus-agent-management-web/server/test/app-smoke.test.js`、其他 `server/test/`、`server/management-test/` | +| 管理 Web 前端 | API 客户端鉴权/超时/错误归一化、同步门控、重启轮询、格式化、浏览器覆盖契约、员工与模板列表操作精简、内置模板编辑、名称/编辑详情入口、详情 tabs 保存流程、员工技能/业务系统/MCP 与模板设置布局一致及内置 Skill 隐藏、设置页 API 文档入口 | `packages/daedalus-agent-management-web/src/**/*.test.js`、`test/browser-coverage.test.js`、`test/run-browser-e2e.js`、`test/integration/website-full.js` | +| invoke CLI | 参数、JSON/body/query 来源、Binding schema、socket 协议、超时、大小限制、Provider 错误和调用日志 | `packages/daedalus-agent-apps-invoke-cli/test/` | +| MQTT 插件 | 账号配置、旧配置兼容、环境变量覆盖、连接/断线重连、通配符、入站路由、串行队列、出站 envelope、日志 | `extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/*test.ts` | +| Docker 集成 | 登录、版本/同步、Provider/Model/模板/MCP/Agent 生命周期、模板继承、工作区/Skill、Ontology、重启、启动钩子、包持久化、文件输出、会话隔离和压测 | `test/case/integration/`、`test/suite/integration/`、`test/case/stress/` | +| Docker 升级与品牌契约 | OpenClaw/Node 版本、插件 seed、迁移、重启保护、DA_ 环境变量前缀和视觉品牌 | `test/case/upgrade/`、`test/case/branding/` | + +## 8. 当前边界与使用前提 + +- [!] 管理控制台是管理员/运维面板,不是终端对话应用;终端用户通过 CUI 或 MQTT 接入 Agent。 +- [!] 业务系统当前按 HZG/活字格模型接入,Ontology ZIP 和 `index.md` 是必要前提。 +- [!] Agent 只能访问配置白名单内的内网地址;白名单更新后必须重启容器。 +- [!] 内置模板、内置 Skill、内置 Ontology 受删除保护;内置 Skill 和内置 Ontology 不能按普通资源直接编辑。 +- [!] 内置模板可从名称或编辑按钮进入详情,支持重命名以及修改文件、Skill、业务系统、MCP 和模型配置,但不支持删除;所有模板的重命名均依赖远端模板创建/删除动作成功。 +- [!] 业务调用应经过 invoke CLI/service;配套规则禁止 Agent 直接向业务系统发 HTTP 请求或手工启动 invoke service。 +- [!] 集成/压测脚本默认使用 mock LLM;压测结果反映当前测试规格下的容量,不等同于真实模型 SLA。 diff --git a/docker/BUILD.md b/docker/BUILD.md index b82253507c408fbae28e310e3cf4301d88197a84..5b99a2c7f51daf4590f149c83f3b46c2e61cffb2 100644 --- a/docker/BUILD.md +++ b/docker/BUILD.md @@ -7,7 +7,8 @@ - `daedalus-agent-apps-invoke-cli` 和 `daedalus-agent-mqtt-channel` 从当前仓库源码构建、打包并安装。 - `daedalus-agent-management-web` 从当前仓库源码构建,并把 `dist`、`server`、`scripts` 放入镜像。 - `daedalus-agent-call-app-service`、`daedalus-agent-transfer-file`、`daedalus-agent-make-chart-image`、`daedalus-agent-self-improving-agent` 从当前仓库复制;其余全局 skills 仍通过 ClawHub 安装。 -- `openclaw`、`clawhub`、`@tobilu/qmd`、`mcporter`、`agent-browser` 等第三方工具通过 npm 安装。 +- `openclaw`、`clawhub`、`@tobilu/qmd`、`agent-browser` 等第三方工具通过 npm 安装。 +- 镜像预装 PDF 与 Office 的 Python/系统依赖,并在 Docker build 的断网阶段执行 skill 烟测;运行时无需再下载依赖。`video-frames`、`ffmpeg-video-editor` 和 `mcporter` 不再预装。 Docker 资产统一放在 `docker/assets`;`docker/internal` 只保留构建脚本和离线 vendor 资源。 diff --git a/docker/Dockerfile b/docker/Dockerfile index 00b6269e0d0707501d294c3aca2a64c71038d249..96bdf68b31165685118b5b96434b8aa0b15d0fcd 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -103,13 +103,21 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ curl \ ffmpeg \ fonts-noto-cjk \ + ghostscript \ git \ iptables \ jq \ libimage-exiftool-perl \ + libreoffice-calc \ + libreoffice-impress \ + libreoffice-writer \ + pandoc \ + pdftk-java \ + poppler-utils \ python3 \ python3-pip \ python3-venv \ + qpdf \ tar \ tesseract-ocr \ tesseract-ocr-chi-sim \ @@ -156,12 +164,45 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime; \ echo Asia/Shanghai > /etc/timezone -RUN set -eux; \ +RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked \ + set -eux; \ mkdir -p "${DA_TOOLCHAIN_DIR}/bin"; \ python3 -m venv "${DA_TOOLCHAIN_DIR}/python"; \ - "${DA_TOOLCHAIN_DIR}/python/bin/pip" install --no-cache-dir "uv==${UV_VERSION}"; \ + "${DA_TOOLCHAIN_DIR}/python/bin/pip" install \ + "uv==${UV_VERSION}" \ + "markitdown[all]==0.1.5" \ + "PyYAML==6.0.3" \ + "pypdf==6.15.0" \ + "reportlab==5.0.0" \ + "pytesseract==0.3.13" \ + "pdf2image==1.17.0" \ + "pdfplumber==0.11.10" \ + "pandas==3.0.5" \ + "openpyxl==3.1.5" \ + "XlsxWriter==3.2.9" \ + "python-docx==1.2.0" \ + "python-pptx==1.0.2" \ + "Pillow==12.3.0" \ + "lxml==6.1.1" \ + "xlrd==2.0.2"; \ ln -sf "${DA_TOOLCHAIN_DIR}/python/bin/uv" "${DA_TOOLCHAIN_DIR}/bin/uv"; \ ln -sf "${DA_TOOLCHAIN_DIR}/python/bin/uvx" "${DA_TOOLCHAIN_DIR}/bin/uvx.real"; \ + ln -sf "${DA_TOOLCHAIN_DIR}/python/bin/markitdown" "${DA_TOOLCHAIN_DIR}/bin/markitdown"; \ + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'exec /opt/daedalus-agent-toolchain/python/bin/python "$@"' \ + > "${DA_TOOLCHAIN_DIR}/bin/python"; \ + cp "${DA_TOOLCHAIN_DIR}/bin/python" "${DA_TOOLCHAIN_DIR}/bin/python3"; \ + printf '%s\n' \ + '#!/usr/bin/env bash' \ + 'exec /opt/daedalus-agent-toolchain/python/bin/python -m pip "$@"' \ + > "${DA_TOOLCHAIN_DIR}/bin/pip"; \ + cp "${DA_TOOLCHAIN_DIR}/bin/pip" "${DA_TOOLCHAIN_DIR}/bin/pip3"; \ + chmod +x \ + "${DA_TOOLCHAIN_DIR}/bin/python" \ + "${DA_TOOLCHAIN_DIR}/bin/python3" \ + "${DA_TOOLCHAIN_DIR}/bin/pip" \ + "${DA_TOOLCHAIN_DIR}/bin/pip3"; \ printf '%s\n' \ '#!/usr/bin/env bash' \ 'set -euo pipefail' \ @@ -217,7 +258,6 @@ ARG OPENCLAW_VERSION ARG CLAWHUB_VERSION=0.23.0 ARG QMD_VERSION=2.5.3 ARG AGENT_BROWSER_VERSION=0.31.0 -ARG MCPORTER_VERSION=0.12.0 ARG DA_BUILD_NPM_REGISTRY=https://registry.npmjs.org ARG DA_CLAWHUB_FETCH_TIMEOUT_MS=300000 @@ -238,6 +278,9 @@ RUN --mount=type=bind,source=docker/internal/kernel/vendor,target=/tmp/daedalus- install-versioned-npm-global "openclaw" "${OPENCLAW_VERSION}" RUN set -eux; \ + rm -rf \ + "${DA_TOOLCHAIN_DIR}/lib/node_modules/openclaw/skills/video-frames" \ + "${DA_TOOLCHAIN_DIR}/lib/node_modules/openclaw/skills/mcporter"; \ mv "${DA_TOOLCHAIN_DIR}/bin/openclaw" "${DA_TOOLCHAIN_DIR}/bin/openclaw.real"; \ printf '%s\n' \ '#!/usr/bin/env bash' \ @@ -273,11 +316,6 @@ RUN --mount=type=bind,from=local-package-builder,source=/build/npm,target=/tmp/d DA_BUILD_NPM_VENDOR_DIR=/tmp/daedalus-agent-local-npm \ install-versioned-npm-global "daedalus-agent-apps-invoke-cli" "$(cat /tmp/daedalus-agent-local-npm/daedalus-agent-apps-invoke-cli.version)" -RUN --mount=type=bind,source=docker/internal/kernel/vendor,target=/tmp/daedalus-agent-vendor-src,readonly \ - --mount=type=cache,target=/root/.npm,sharing=locked \ - --mount=type=cache,target=/opt/daedalus-agent-build-cache/npm-tools,sharing=locked \ - install-versioned-npm-global "mcporter" "${MCPORTER_VERSION}" - RUN --mount=type=bind,source=docker/internal/kernel/vendor,target=/tmp/daedalus-agent-vendor-src,readonly \ --mount=type=cache,target=/root/.npm,sharing=locked \ --mount=type=cache,target=/opt/daedalus-agent-build-cache/npm-tools,sharing=locked \ @@ -374,17 +412,13 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked \ COPY extensions/clawhub-skills/daedalus-agent-self-improving-agent ${DA_OPENCLAW_HOME}/skills/daedalus-agent-self-improving-agent -RUN skill="@steipete/video-frames"; \ - install-seed-skill "${skill}" - -RUN skill="@mahmoudadelbghany/ffmpeg-video-editor"; \ - install-seed-skill "${skill}" - RUN skill="@gpyangyoujun/multi-search-engine"; \ install-seed-skill "${skill}" -RUN skill="@matrixy/agent-browser-clawdbot"; \ - install-seed-skill "${skill}" +RUN if [[ "${TARGETARCH}" == "amd64" ]]; then \ + skill="@matrixy/agent-browser-clawdbot"; \ + install-seed-skill "${skill}"; \ + fi RUN skill="@chindden/skill-creator"; \ install-seed-skill "${skill}" @@ -404,15 +438,9 @@ RUN skill="@ivangdavila/powerpoint-pptx"; \ RUN skill="@awspace/pdf"; \ install-seed-skill "${skill}" -RUN skill="@steipete/mcporter"; \ - install-seed-skill "${skill}" - RUN skill="@steipete/markdown-converter"; \ install-seed-skill "${skill}" -RUN --mount=type=cache,target=/root/.cache/uv,sharing=locked \ - uv tool install --force "markitdown[all]" - RUN set -eux; \ rm -rf "${XDG_CACHE_HOME}" "${HOME}/.npm" @@ -468,6 +496,7 @@ ENV DA_WORKSPACE_TEMPLATES_DIR=/var/daedalus-agent/workspace-templates ENV DA_ONTOLOGY_DIR=/var/daedalus-agent/ontology ENV DA_MANAGEMENT_ROOT=/var/daedalus-agent/management ENV DA_WEB_DATA_DIR=/var/daedalus-agent/management/data +ENV DA_LOCAL_ARTIFACTS_DIR=/var/daedalus-agent/management/data/local-artifacts ENV DA_DATA_DIR=/var/daedalus-agent/startup-state/data ENV DA_TMP_DIR=/var/daedalus-agent/.openclaw/tmp ENV NPM_CONFIG_PREFIX=/var/daedalus-agent/.openclaw/packages/npm @@ -513,10 +542,12 @@ RUN --mount=type=bind,source=docker/assets/workspace-templates/default,target=/t built_in_manifest=/opt/daedalus-agent-seed/data/build-in-component.json; \ built_in_fingerprint=/opt/daedalus-agent-seed/data/built-in-content.fingerprint; \ formatted_openclaw_version="$(printf '%s' "${OPENCLAW_VERSION}" | awk -F. '{printf "%04d.%02d.%02d", $1, $2, $3}')"; \ - npm_packages='["openclaw","clawhub","@tobilu/qmd","daedalus-agent-apps-invoke-cli","mcporter"]'; \ - pip_packages='["uv","markitdown[all]"]'; \ + skill_packages='["daedalus-agent-call-app-service","daedalus-agent-transfer-file","daedalus-agent-make-chart-image","daedalus-agent-self-improving-agent","multi-search-engine","skill-creator","tesseract-ocr","word-docx","excel-xlsx","powerpoint-pptx","pdf","markdown-converter"]'; \ + npm_packages='["openclaw","clawhub","@tobilu/qmd","daedalus-agent-apps-invoke-cli"]'; \ + pip_packages='["uv==0.11.24","markitdown[all]==0.1.5","PyYAML==6.0.3","pypdf==6.15.0","reportlab==5.0.0","pytesseract==0.3.13","pdf2image==1.17.0","pdfplumber==0.11.10","pandas==3.0.5","openpyxl==3.1.5","XlsxWriter==3.2.9","python-docx==1.2.0","python-pptx==1.0.2","Pillow==12.3.0","lxml==6.1.1","xlrd==2.0.2"]'; \ if [[ "${TARGETARCH}" == "amd64" ]]; then \ - npm_packages='["openclaw","clawhub","@tobilu/qmd","daedalus-agent-apps-invoke-cli","mcporter","agent-browser"]'; \ + skill_packages='["daedalus-agent-call-app-service","daedalus-agent-transfer-file","daedalus-agent-make-chart-image","daedalus-agent-self-improving-agent","multi-search-engine","agent-browser-clawdbot","skill-creator","tesseract-ocr","word-docx","excel-xlsx","powerpoint-pptx","pdf","markdown-converter"]'; \ + npm_packages='["openclaw","clawhub","@tobilu/qmd","daedalus-agent-apps-invoke-cli","agent-browser"]'; \ fi; \ mkdir -p "${DA_ROOT}" "${seed_openclaw_home}" "${seed_workspace}" "${workspace_template}" /opt/daedalus-agent-seed/scripts /opt/daedalus-agent-seed/data; \ cp -a --no-preserve=ownership /opt/daedalus-agent-assets/scripts/kernel-startup.sh /opt/daedalus-agent-seed/scripts/startup.sh; \ @@ -540,7 +571,7 @@ RUN --mount=type=bind,source=docker/assets/workspace-templates/default,target=/t done; \ fi; \ done; \ - printf '%s\n' "{\"openclaw-version\":\"${formatted_openclaw_version}\",\"agent-template\":[\"default\"],\"ontology\":[],\"skill\":[\"daedalus-agent-call-app-service\",\"daedalus-agent-transfer-file\",\"daedalus-agent-make-chart-image\",\"daedalus-agent-self-improving-agent\",\"video-frames\",\"ffmpeg-video-editor\",\"multi-search-engine\",\"agent-browser-clawdbot\",\"skill-creator\",\"tesseract-ocr\",\"word-docx\",\"excel-xlsx\",\"powerpoint-pptx\",\"pdf\",\"mcporter\",\"markdown-converter\"],\"npm\":${npm_packages},\"pip\":${pip_packages}}" > "${built_in_manifest}"; \ + printf '%s\n' "{\"openclaw-version\":\"${formatted_openclaw_version}\",\"agent-template\":[\"default\"],\"ontology\":[],\"skill\":${skill_packages},\"npm\":${npm_packages},\"pip\":${pip_packages}}" > "${built_in_manifest}"; \ { \ printf 'manifest:'; sha256sum "${built_in_manifest}"; \ for tree in "${seed_openclaw_home}/skills" /opt/daedalus-agent-seed/workspace-templates; do \ @@ -658,6 +689,9 @@ COPY --from=mc-image /usr/bin/mc /usr/local/bin/mc COPY docker/sample /opt/daedalus-agent/sample +RUN --network=none set -eux; \ + TARGETARCH="${TARGETARCH}" bash /opt/daedalus-agent-assets/scripts/verify-preinstalled-skills.sh + RUN set -eux; \ mkdir -p "${DA_ROOT}" "${DA_MANAGEMENT_ROOT}" "${DA_WEB_DATA_DIR}" "${DA_OPENCLAW_HOME}/logs" "${DA_ROOT}/logs"; \ chown "${DA_SERVICE_USER}:${DA_OPENCLAW_GROUP}" "${DA_ROOT}" "${DA_MANAGEMENT_ROOT}" "${DA_WEB_DATA_DIR}"; \ diff --git a/docker/assets/scripts/apps-startup.sh b/docker/assets/scripts/apps-startup.sh index 54ee0521ab435fc07a4ec797b09e90eb3ae88ed8..0d2db5f92e03a4e7df3a5ce03e09a4502ccb53d5 100644 --- a/docker/assets/scripts/apps-startup.sh +++ b/docker/assets/scripts/apps-startup.sh @@ -17,6 +17,7 @@ RUNTIME_ENV_JSON="${DA_RUNTIME_ENV_JSON:-${DA_ROOT}/generated/runtime-env.json}" DA_LOG_DIR="${DA_LOG_DIR:-${DA_ROOT}/logs}" LOG_DIR="${DA_LOG_DIR}" DA_WEB_DATA_DIR="${DA_WEB_DATA_DIR:-${DA_MANAGEMENT_ROOT}/data}" +DA_LOCAL_ARTIFACTS_DIR="${DA_LOCAL_ARTIFACTS_DIR:-${DA_WEB_DATA_DIR}/local-artifacts}" DA_RUN_DIR="${DA_RUN_DIR:-${DA_ROOT}/run}" WEB_PID_FILE="${DA_RUN_DIR}/management-web.pid" INVOKE_CLI_PID_FILE="${DA_RUN_DIR}/app-invoke.pid" @@ -280,7 +281,7 @@ restart_delay_seconds() { is_reserved_runtime_env_key() { case "$1" in - HOME|USER|LOGNAME|PATH|TZ|DA_ROOT|DA_MANAGEMENT_ROOT|DA_LOG_DIR|DA_ONTOLOGY_DIR|DA_SERVICE_USER|DA_SERVICE_GROUP|DA_OPENCLAW_GROUP|DA_WEB_HOME|DA_WEB_ENTRY|DA_INVOKE_CLI_COMMAND|DA_APPS_HOME|DA_WEB_DATA_DIR|DA_RUN_DIR|DA_RUNTIME_ENV_JSON|DA_APP_INVOKE_SOCKET_PATH) + HOME|USER|LOGNAME|PATH|TZ|DA_ROOT|DA_MANAGEMENT_ROOT|DA_LOG_DIR|DA_ONTOLOGY_DIR|DA_SERVICE_USER|DA_SERVICE_GROUP|DA_OPENCLAW_GROUP|DA_WEB_HOME|DA_WEB_ENTRY|DA_INVOKE_CLI_COMMAND|DA_APPS_HOME|DA_WEB_DATA_DIR|DA_LOCAL_ARTIFACTS_DIR|DA_RUN_DIR|DA_RUNTIME_ENV_JSON|DA_APP_INVOKE_SOCKET_PATH) return 0 ;; *) @@ -291,7 +292,7 @@ is_reserved_runtime_env_key() { is_readonly_runtime_env_key() { case "$1" in - DA_ROOT|DA_ONTOLOGY_DIR|DA_WEB_DATA_DIR) + DA_ROOT|DA_ONTOLOGY_DIR|DA_WEB_DATA_DIR|DA_LOCAL_ARTIFACTS_DIR) return 0 ;; *) @@ -311,6 +312,9 @@ readonly_runtime_env_value() { DA_WEB_DATA_DIR) printf '%s\n' "$DA_WEB_DATA_DIR" ;; + DA_LOCAL_ARTIFACTS_DIR) + printf '%s\n' "$DA_LOCAL_ARTIFACTS_DIR" + ;; *) return 1 ;; @@ -508,6 +512,7 @@ export_runtime_environment() { export DA_OPENCLAW_GROUP export DA_LOG_DIR export DA_WEB_DATA_DIR + export DA_LOCAL_ARTIFACTS_DIR export DA_RUN_DIR export DA_INVOKE_CLI_COMMAND export PATH="${DA_WEB_HOME}/node_modules/.bin:/opt/daedalus-agent-toolchain/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" @@ -547,6 +552,7 @@ run_with_runtime_env() { export DA_WEB_HOME="${DA_WEB_HOME}" export DA_INVOKE_CLI_COMMAND="${DA_INVOKE_CLI_COMMAND}" export DA_WEB_DATA_DIR="${DA_WEB_DATA_DIR}" + export DA_LOCAL_ARTIFACTS_DIR="${DA_LOCAL_ARTIFACTS_DIR}" export DA_RUN_DIR="${DA_RUN_DIR}" export PATH="${PATH}" for runtime_env_arg in "${runtime_env_args[@]}"; do diff --git a/docker/assets/scripts/kernel-startup.sh b/docker/assets/scripts/kernel-startup.sh index b69f6f8a092840a6521a580e37021f3fc77d3561..2cd48c6540dee23b77d00a36d6de931c5e25d5e7 100644 --- a/docker/assets/scripts/kernel-startup.sh +++ b/docker/assets/scripts/kernel-startup.sh @@ -58,6 +58,7 @@ OPENCLAW_RESTART_DELAY_SECONDS="${OPENCLAW_RESTART_DELAY_SECONDS:-2}" OPENCLAW_FATAL_EXIT_CODE="${OPENCLAW_FATAL_EXIT_CODE:-78}" OPENCLAW_MAX_RAPID_RESTARTS="${OPENCLAW_MAX_RAPID_RESTARTS:-5}" OPENCLAW_RAPID_RESTART_WINDOW_SECONDS="${OPENCLAW_RAPID_RESTART_WINDOW_SECONDS:-60}" +declare -ar RETIRED_BUILTIN_SKILLS=(video-frames ffmpeg-video-editor mcporter) OPENCLAW_MIGRATION_STAMP="${DA_OPENCLAW_HOME}/state/daedalus-agent-openclaw-migrated.version" RUNTIME_ENV_KEYS=() UV_PACKAGE_CHANGED=false @@ -1112,6 +1113,52 @@ cleanup_replaced_builtin_content() { ) & } +retire_removed_builtin_skills() { + local skill_name="" + local target="" + local link_target="" + local retired_root="" + local retired_target="" + + for skill_name in "${RETIRED_BUILTIN_SKILLS[@]}"; do + target="${DA_OPENCLAW_HOME}/skills/${skill_name}" + if [[ ! -e "$target" && ! -L "$target" ]]; then + continue + fi + + if [[ -L "$target" ]]; then + link_target="$(readlink "$target" 2>/dev/null || true)" + case "$link_target" in + /opt/daedalus-agent-seed/*|"${SEED_OPENCLAW_HOME}"/*) + rm -- "$target" + log "removed retired built-in ${skill_name} skill link" + ;; + *) + log "warning: retained custom ${skill_name} skill link at ${target}" + ;; + esac + continue + fi + + if [[ ! -f "$target/.daedalus-agent-builtin.fingerprint" ]]; then + log "warning: retained custom ${skill_name} skill directory at ${target}" + continue + fi + + if [[ -z "$retired_root" ]]; then + retired_root="${DA_DATA_DIR}/.daedalus-agent-retired/$(date +%Y%m%d%H%M%S)-$$" + ensure_service_directory "$retired_root" + fi + retired_target="${retired_root}/${skill_name}" + if mv "$target" "$retired_target"; then + log "moved retired built-in ${skill_name} skill to ${retired_target}" + else + log "warning: unable to retire built-in ${skill_name} skill at ${target}" + BUILTIN_CONTENT_REPLACE_INCOMPLETE=true + fi + done +} + sync_builtin_content_stamp() { local expected_stamp="" local current_stamp="" @@ -2086,6 +2133,7 @@ ensure_directories() { seed_runtime_content() { profile_step "seed.sync_builtin_content_stamp" sync_builtin_content_stamp + profile_step "seed.retire_removed_skills" retire_removed_builtin_skills profile_step "seed.sync_openclaw_home" sync_missing_openclaw_home "$SEED_OPENCLAW_HOME" "$DA_OPENCLAW_HOME" profile_step "seed.sync_workspace" sync_missing_tree "$SEED_WORKSPACE_DIR" "$DA_OPENCLAW_WORKSPACE_DIR" profile_step "seed.sync_templates" sync_builtin_children_replace_if_needed "$SEED_TEMPLATES_DIR" "$RUNTIME_TEMPLATES_DIR" @@ -2140,7 +2188,7 @@ export PYTHONPATH="'"${site_packages_root}"'${PYTHONPATH:+:${PYTHONPATH}}" export PIP_TARGET="'"${site_packages_root}"'" export PIP_CACHE_DIR="'"${PIP_CACHE_DIR}"'" export PIP_DISABLE_PIP_VERSION_CHECK=1 -exec /usr/bin/python3 "$@"' + exec /opt/daedalus-agent-toolchain/python/bin/python "$@"' cp "${python_shim}" "${python3_shim}" chown "${DA_OPENCLAW_USER}:${DA_OPENCLAW_GROUP}" "${python3_shim}" @@ -2153,7 +2201,7 @@ export PYTHONPATH="'"${site_packages_root}"'${PYTHONPATH:+:${PYTHONPATH}}" export PIP_TARGET="'"${site_packages_root}"'" export PIP_CACHE_DIR="'"${PIP_CACHE_DIR}"'" export PIP_DISABLE_PIP_VERSION_CHECK=1 -exec /usr/bin/python3 -m pip "$@"' + exec /opt/daedalus-agent-toolchain/python/bin/python -m pip "$@"' cp "${pip_shim}" "${pip3_shim}" chown "${DA_OPENCLAW_USER}:${DA_OPENCLAW_GROUP}" "${pip3_shim}" diff --git a/docker/assets/scripts/startup.sh b/docker/assets/scripts/startup.sh index 0ca4abf73d45612f49199948ec3c33bbdbdbdf66..0afb168b3e93fbdb1c1ad4d9adb5ad55d8414a44 100644 --- a/docker/assets/scripts/startup.sh +++ b/docker/assets/scripts/startup.sh @@ -10,6 +10,7 @@ DA_WORKSPACE_TEMPLATES_DIR="${DA_ROOT}/workspace-templates" DA_ONTOLOGY_DIR="${DA_ROOT}/ontology" DA_MANAGEMENT_ROOT="${DA_ROOT}/management" DA_WEB_DATA_DIR="${DA_MANAGEMENT_ROOT}/data" +DA_LOCAL_ARTIFACTS_DIR="${DA_WEB_DATA_DIR}/local-artifacts" DA_STARTUP_STATE_DIR="${DA_ROOT}/startup-state" DA_RUNTIME_DATA_DIR="${DA_STARTUP_STATE_DIR}/data" DA_TMP_DIR="${DA_OPENCLAW_HOME}/tmp" @@ -268,6 +269,7 @@ reject_unsupported_user_env() { . == "DA_ONTOLOGY_DIR" or . == "DA_TMP_DIR" or . == "DA_WEB_DATA_DIR" or + . == "DA_LOCAL_ARTIFACTS_DIR" or . == "DA_MANAGEMENT_ROOT" or . == "DA_RUN_DIR" or . == "DA_LOG_DIR" or @@ -753,8 +755,6 @@ const s3 = { DA_S3_ACCESS_KEY_ID: "daedalus-agent", DA_S3_SECRET_ACCESS_KEY: credentials.minio.password, DA_S3_FORCE_PATH_STYLE: "true", - DA_S3_ADMIN_INBOX_BUCKET: "daedalus-admin-inbox", - DA_S3_ADMIN_OUTBOX_BUCKET: "daedalus-admin-outbox", DA_S3_AGENT_INBOX_BUCKET: "daedalus-agent-inbox", DA_S3_AGENT_OUTBOX_BUCKET: "daedalus-agent-outbox" }; @@ -883,8 +883,6 @@ init_minio_buckets() { log "initializing MinIO buckets" MC_HOST_local="http://${access_key}:${secret_key}@127.0.0.1:9000" \ mc mb --ignore-existing \ - "local/$(jq -r '.DA_S3_ADMIN_INBOX_BUCKET' "${RUNTIME_ENV_JSON}")" \ - "local/$(jq -r '.DA_S3_ADMIN_OUTBOX_BUCKET' "${RUNTIME_ENV_JSON}")" \ "local/$(jq -r '.DA_S3_AGENT_INBOX_BUCKET' "${RUNTIME_ENV_JSON}")" \ "local/$(jq -r '.DA_S3_AGENT_OUTBOX_BUCKET' "${RUNTIME_ENV_JSON}")" \ > >(write_daily_package_log "minio") 2>&1 @@ -1046,6 +1044,7 @@ start_apps() { DA_LOG_DIR="${LOG_DIR}" \ DA_ONTOLOGY_DIR="${DA_ONTOLOGY_DIR}" \ DA_WEB_DATA_DIR="${DA_WEB_DATA_DIR}" \ + DA_LOCAL_ARTIFACTS_DIR="${DA_LOCAL_ARTIFACTS_DIR}" \ DA_MANAGEMENT_ROOT="${DA_MANAGEMENT_ROOT}" \ DA_RUN_DIR="${RUN_DIR}" \ DA_RUNTIME_ENV_JSON="${RUNTIME_ENV_JSON}" \ diff --git a/docker/assets/scripts/verify-preinstalled-skills.sh b/docker/assets/scripts/verify-preinstalled-skills.sh new file mode 100644 index 0000000000000000000000000000000000000000..c5d4b4a67d5aeb3aeb70fcac4e47f1ef7b2f308e --- /dev/null +++ b/docker/assets/scripts/verify-preinstalled-skills.sh @@ -0,0 +1,227 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +readonly TOOLCHAIN_PYTHON=/opt/daedalus-agent-toolchain/python/bin/python +readonly OPENCLAW_BUNDLED_SKILLS=/opt/daedalus-agent-toolchain/lib/node_modules/openclaw/skills +readonly SEED_SKILLS=/opt/daedalus-agent-seed/home/.openclaw/skills +readonly BUILTIN_MANIFEST=/opt/daedalus-agent-seed/data/build-in-component.json + +fail() { + printf 'preinstalled skill verification failed: %s\n' "$*" >&2 + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" +} + +for removed_skill in video-frames ffmpeg-video-editor mcporter; do + [[ ! -e "${SEED_SKILLS}/${removed_skill}" ]] || fail "retired skill is still present: ${removed_skill}" + [[ ! -e "${OPENCLAW_BUNDLED_SKILLS}/${removed_skill}" ]] \ + || fail "retired OpenClaw bundled skill is still present: ${removed_skill}" + ! jq -e --arg skill "$removed_skill" '.skill | index($skill)' "$BUILTIN_MANIFEST" >/dev/null \ + || fail "retired skill is still listed in the manifest: ${removed_skill}" +done + +[[ ! -e /opt/daedalus-agent-toolchain/lib/node_modules/mcporter ]] \ + || fail "retired mcporter npm package is still installed" +! command -v mcporter >/dev/null 2>&1 || fail "retired mcporter command is still installed" +! jq -e '.npm | index("mcporter")' "$BUILTIN_MANIFEST" >/dev/null \ + || fail "retired mcporter npm package is still listed in the manifest" + +for skill in \ + daedalus-agent-call-app-service \ + daedalus-agent-transfer-file \ + daedalus-agent-make-chart-image \ + daedalus-agent-self-improving-agent \ + multi-search-engine \ + skill-creator \ + tesseract-ocr \ + word-docx \ + excel-xlsx \ + powerpoint-pptx \ + pdf \ + markdown-converter; do + [[ -f "${SEED_SKILLS}/${skill}/SKILL.md" ]] || fail "missing skill: ${skill}" +done + +for command_name in \ + exiftool \ + ffmpeg \ + ffprobe \ + gs \ + libreoffice \ + pandoc \ + pdfimages \ + pdfinfo \ + pdftk \ + pdftotext \ + qpdf \ + soffice \ + tesseract; do + require_command "$command_name" +done + +if [[ "${TARGETARCH:-}" == "amd64" ]]; then + [[ -f "${SEED_SKILLS}/agent-browser-clawdbot/SKILL.md" ]] || fail "missing amd64 agent-browser skill" + require_command agent-browser + [[ -x /opt/agent-browser/chrome-linux64/chrome ]] || fail "missing bundled Chrome" +fi + +"$TOOLCHAIN_PYTHON" - <<'PY' +import docx +import lxml +import openpyxl +import pandas +import pdf2image +import pdfplumber +import pptx +import pypdf +import pytesseract +import reportlab +import xlrd +import xlsxwriter +import yaml +from PIL import Image +from markitdown import MarkItDown + +assert MarkItDown +assert Image +assert all(( + docx, lxml, openpyxl, pandas, pdf2image, pdfplumber, pptx, pypdf, + pytesseract, reportlab, xlrd, xlsxwriter, yaml, +)) +PY + +python3 -c 'import docx, openpyxl, pdfplumber, pptx, pypdf, reportlab, yaml' + +"$TOOLCHAIN_PYTHON" "${SEED_SKILLS}/skill-creator/scripts/quick_validate.py" \ + "${SEED_SKILLS}/skill-creator" >/dev/null + +work_dir="$(mktemp -d)" +cleanup() { + if command -v agent-browser >/dev/null 2>&1; then + HOME="${work_dir}/browser-home" agent-browser --session offline-smoke close >/dev/null 2>&1 || true + fi + rm -rf -- "$work_dir" +} +trap cleanup EXIT + +export SMOKE_WORK_DIR="$work_dir" +"$TOOLCHAIN_PYTHON" - <<'PY' +import os +from pathlib import Path + +import pandas as pd +import pdfplumber +import pytesseract +from PIL import Image, ImageDraw +from docx import Document +from openpyxl import Workbook, load_workbook +from pdf2image import convert_from_path +from pptx import Presentation +from pypdf import PdfReader, PdfWriter +from reportlab.pdfgen import canvas + +root = Path(os.environ["SMOKE_WORK_DIR"]) + +document = Document() +document.add_heading("Offline DOCX", level=1) +document.add_paragraph("document-ready") +document.save(root / "sample-doc.docx") + +workbook = Workbook() +sheet = workbook.active +sheet.append(["name", "value"]) +sheet.append(["ready", 42]) +sheet["C2"] = "=B2*2" +workbook.save(root / "sample-sheet.xlsx") +assert load_workbook(root / "sample-sheet.xlsx", data_only=False).active["C2"].value == "=B2*2" +pd.DataFrame({"status": ["ready"]}).to_excel(root / "pandas.xlsx", engine="xlsxwriter", index=False) + +presentation = Presentation() +slide = presentation.slides.add_slide(presentation.slide_layouts[0]) +slide.shapes.title.text = "Offline PPTX" +slide.placeholders[1].text = "presentation-ready" +presentation.save(root / "sample-slides.pptx") + +pdf_canvas = canvas.Canvas(str(root / "sample.pdf")) +pdf_canvas.drawString(72, 720, "offline-pdf-ready") +pdf_canvas.save() +reader = PdfReader(root / "sample.pdf") +assert "offline-pdf-ready" in (reader.pages[0].extract_text() or "") +writer = PdfWriter() +writer.add_page(reader.pages[0]) +with (root / "pypdf-copy.pdf").open("wb") as output: + writer.write(output) +with pdfplumber.open(root / "sample.pdf") as pdf: + assert "offline-pdf-ready" in (pdf.pages[0].extract_text() or "") +images = convert_from_path(root / "sample.pdf", first_page=1, last_page=1) +assert images and images[0].width > 0 + +ocr_image = Image.new("RGB", (600, 140), "white") +ImageDraw.Draw(ocr_image).text((20, 40), "OFFLINE OCR 123", fill="black") +ocr_image.save(root / "ocr.png") +pytesseract.image_to_string(ocr_image, lang="eng") +PY + +pdftotext "$work_dir/sample.pdf" "$work_dir/sample.txt" +grep -q 'offline-pdf-ready' "$work_dir/sample.txt" +pdfinfo "$work_dir/sample.pdf" >/dev/null +pdfimages -list "$work_dir/sample.pdf" >/dev/null +qpdf "$work_dir/sample.pdf" "$work_dir/qpdf-copy.pdf" +pdftk "$work_dir/sample.pdf" cat output "$work_dir/pdftk-copy.pdf" +pandoc "$work_dir/sample-doc.docx" -t plain -o "$work_dir/sample-docx.txt" +grep -q 'document-ready' "$work_dir/sample-docx.txt" + +mkdir -p "$work_dir/office-output" "$work_dir/lo-profile" +soffice \ + "-env:UserInstallation=file://${work_dir}/lo-profile" \ + --headless \ + --convert-to pdf \ + --outdir "$work_dir/office-output" \ + "$work_dir/sample-doc.docx" \ + "$work_dir/sample-sheet.xlsx" \ + "$work_dir/sample-slides.pptx" >/dev/null +for rendered_pdf in sample-doc.pdf sample-sheet.pdf sample-slides.pdf; do + [[ -s "$work_dir/office-output/$rendered_pdf" ]] \ + || fail "LibreOffice did not render ${rendered_pdf}" +done + +for source_file in sample-doc.docx sample-sheet.xlsx sample-slides.pptx sample.pdf; do + uvx markitdown "$work_dir/$source_file" -o "$work_dir/$source_file.md" + [[ -s "$work_dir/$source_file.md" ]] || fail "markitdown returned an empty result for ${source_file}" +done +grep -q 'document-ready' "$work_dir/sample-doc.docx.md" +grep -q 'ready' "$work_dir/sample-sheet.xlsx.md" +grep -q 'presentation-ready' "$work_dir/sample-slides.pptx.md" +grep -q 'offline-pdf-ready' "$work_dir/sample.pdf.md" + +tesseract --list-langs 2>/dev/null | grep -qx 'chi_sim' +tesseract --list-langs 2>/dev/null | grep -qx 'eng' +tesseract "$work_dir/ocr.png" stdout -l eng >/dev/null 2>&1 + +chart_skill="${SEED_SKILLS}/daedalus-agent-make-chart-image" +mkdir -p "$work_dir/workspace/offline/generated" +printf '| name | value |\n| --- | ---: |\n| ready | 42 |\n' \ + | (cd "$chart_skill" && node scripts/make_chart_image.mjs \ + --workspace "$work_dir/workspace" \ + --sender-id offline \ + --input - \ + --output chart.png) >/dev/null +[[ -s "$work_dir/workspace/offline/generated/chart.png" ]] || fail "chart skill did not create an image" + +transfer_skill="${SEED_SKILLS}/daedalus-agent-transfer-file" +(cd "$transfer_skill" && npm ls @aws-sdk/client-s3 --depth=0 >/dev/null) +(cd "$transfer_skill" && node --input-type=module -e 'await import("@aws-sdk/client-s3")') + +if [[ "${TARGETARCH:-}" == "amd64" ]]; then + export HOME="$work_dir/browser-home" + export AGENT_BROWSER_EXECUTABLE_PATH=/opt/agent-browser/chrome-linux64/chrome + mkdir -p "$HOME" + agent-browser --session offline-smoke open \ + 'data:text/html,Offline%20Browser

ready

' >/dev/null + agent-browser --session offline-smoke get title --json | grep -q 'Offline Browser' +fi + +printf 'preinstalled skill verification passed\n' diff --git a/docker/assets/workspace-templates/default/AGENTS.md b/docker/assets/workspace-templates/default/AGENTS.md index 5329f8a3a9a0c4a9a5e2d0440e18cdcbe6fce18b..01d1bc4529fc606e914432c49bc0ddc3e25e4a14 100644 --- a/docker/assets/workspace-templates/default/AGENTS.md +++ b/docker/assets/workspace-templates/default/AGENTS.md @@ -2,7 +2,7 @@ ## 基础要求 -你主要服务中国大陆的企业员工,回答需要符合法律法规要求,优先使用简体中文。 +你主要服务中国大陆的企业员工,回答需要符合法律法规要求,优先使用简体中文。思考过程、工具调用等中间结果也需要优先使用简体中文。 - 采用 Markdown 格式 - 优先使用表格、有序列表、无序列表 diff --git a/docker/build-x64.sh b/docker/build-x64.sh index 8ac8d642becb88100bc3d9de49a9abde46691365..2c7dbbbb50e10cd1fae6e36d273b6f5c16f605de 100755 --- a/docker/build-x64.sh +++ b/docker/build-x64.sh @@ -182,19 +182,16 @@ prepare_vendor() { local clawhub_version local qmd_version local agent_browser_version - local mcporter_version local chrome_version="147.0.7727.57" openclaw_version="$(read_docker_arg OPENCLAW_VERSION)" clawhub_version="$(read_docker_arg CLAWHUB_VERSION)" qmd_version="$(read_docker_arg QMD_VERSION)" agent_browser_version="$(read_docker_arg AGENT_BROWSER_VERSION)" - mcporter_version="$(read_docker_arg MCPORTER_VERSION)" download_npm_package "openclaw" "${openclaw_version}" "openclaw-${openclaw_version}.tgz" download_npm_package "clawhub" "${clawhub_version}" "clawhub-${clawhub_version}.tgz" download_npm_package "@tobilu/qmd" "${qmd_version}" "tobilu-qmd-${qmd_version}.tgz" - download_npm_package "mcporter" "${mcporter_version}" "mcporter-${mcporter_version}.tgz" download_npm_package "agent-browser" "${agent_browser_version}" "agent-browser-${agent_browser_version}.tgz" download_file \ diff --git a/docker/docs/3rd-party-interactive-solution.md b/docker/docs/3rd-party-interactive-solution.md index f407f40c5af4d6eccb6cefb820e996d7cb7d2574..e4a9d8c6d6664aa24356edbd178f1885ac7d8903 100644 --- a/docker/docs/3rd-party-interactive-solution.md +++ b/docker/docs/3rd-party-interactive-solution.md @@ -78,13 +78,11 @@ DA_S3_REGION=local DA_S3_ACCESS_KEY_ID=daedalus-agent DA_S3_SECRET_ACCESS_KEY= DA_S3_FORCE_PATH_STYLE=true -DA_S3_ADMIN_INBOX_BUCKET=daedalus-admin-inbox -DA_S3_ADMIN_OUTBOX_BUCKET=daedalus-admin-outbox DA_S3_AGENT_INBOX_BUCKET=daedalus-agent-inbox DA_S3_AGENT_OUTBOX_BUCKET=daedalus-agent-outbox ``` -bucket 初始化可重复执行。 +bucket 初始化可重复执行。S3 文件读写仅由 `extensions/clawhub-skills/daedalus-agent-transfer-file` 执行,用于 Agent 与 CUI 的文件交互;管理控制台只使用 `HeadBucket` 展示实时运行状态。 ## 验收点 diff --git a/docker/docs/enterprise-ai-gateway-refactoring-plan.md b/docker/docs/enterprise-ai-gateway-refactoring-plan.md index 4c1b77bc57958d3f675cde5f583c49f2378ccb7f..7be1285294705f008231fc755cb3fc8771ad27bf 100644 --- a/docker/docs/enterprise-ai-gateway-refactoring-plan.md +++ b/docker/docs/enterprise-ai-gateway-refactoring-plan.md @@ -84,7 +84,7 @@ OpenClaw Agent -> OpenAI-compatible Provider - 业务系统访问集中到 `daedalus-svc` 运行的 invoke service。 - 支持 Agent 主模型、Fallback 模型和每日 Token 限额。 - 已有业务调用日志、请求和响应哈希以及 artifact 存储结构。 -- 已安装 `mcporter`,具备 MCP 客户端和 MCP 聚合能力的技术基础。 +- OpenClaw 原生支持 MCP 客户端注册表和运行时接入。 - 已有 OpenClaw channel 插件,可得到 `AgentId`、`SessionKey` 和 `MessageThreadId`。 ### 2.2 阻止生产使用的问题 @@ -109,7 +109,7 @@ OpenClaw Agent -> OpenAI-compatible Provider - `daedalus-agent` 用户可以读取至少一个上游模型 API Key。 - OpenClaw 命令执行模式为 `full`。 - `18789` 上的 `/v1/models` 和 `/v1/chat/completions` 返回 HTML,不是 OpenAI 兼容 API。 -- `mcporter` 已安装,但没有配置文件,也没有运行中的 MCP 聚合服务。 +- MCP 接入尚未统一纳入管理控制台和 Agent 权限策略。 ## 3. 目标架构 diff --git a/docker/docs/environment-variables.md b/docker/docs/environment-variables.md index 021b03b2dc2548e68b066057efb2e3337da51c81..9d1e43d7bbda2960150603472744aaf4d9de2487 100644 --- a/docker/docs/environment-variables.md +++ b/docker/docs/environment-variables.md @@ -56,6 +56,7 @@ DAEDALUS_AGENT Docker 支持通过 `/var/daedalus-agent/env.json` 配置少量 | `DA_ONTOLOGY_DIR` | `/var/daedalus-agent/ontology` | ontology 持久化目录,management console 读写,OpenClaw 只读。 | | `DA_TMP_DIR` | `/var/daedalus-agent/.openclaw/tmp` | OpenClaw 可写临时目录。 | | `DA_WEB_DATA_DIR` | `/var/daedalus-agent/management/data` | 管理控制台数据库和数据目录。 | +| `DA_LOCAL_ARTIFACTS_DIR` | `/var/daedalus-agent/management/data/local-artifacts` | 模板、全局 Skill、Agent Skill、Agent workspace 归档和 Ontology 的本地制品目录,不使用 S3。 | OpenClaw 原生命令使用下列派生变量。它们不允许写入 `/var/daedalus-agent/env.json`,也不写入 `/var/daedalus-agent/generated/runtime-env.json`;启动脚本会在启动 OpenClaw gateway 时注入,管理控制台执行 `openclaw` CLI 时也会强制注入同一组值,避免命令落到调用用户的 `~/.openclaw`: diff --git a/docker/internal/kernel/vendor/npm/attention.md b/docker/internal/kernel/vendor/npm/attention.md index 9319a5044a00f9185a81b7a7dcd135ae81015cbe..eb3c809d619111a99bee2213c12e0cb1777d32c8 100644 --- a/docker/internal/kernel/vendor/npm/attention.md +++ b/docker/internal/kernel/vendor/npm/attention.md @@ -10,7 +10,6 @@ - `clawhub-0.23.0.tgz` - `tobilu-qmd-2.5.3.tgz` - `daedalus-agent-apps-invoke-cli-1.0.0.tgz` -- `mcporter-0.12.0.tgz` - `agent-browser-0.31.0.tgz` 也可以使用子目录形式,例如: diff --git a/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md b/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md index 03c0259f8c03604ae2816776072fa5f6574c39ed..f58ef558e4aaaa5d15f786cbc6ba4be08616d7f7 100644 --- a/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md +++ b/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md @@ -54,7 +54,7 @@ description: 通过兼容 AWS S3 的 SDK 为 OpenClaw 和 DAEDALUS_AGENT agent - 显式传入 `DA_S3_ACCESS_KEY_ID` 和 `DA_S3_SECRET_ACCESS_KEY` 作为访问凭证。 - 当 `DA_S3_FORCE_PATH_STYLE` 为真值时启用 path-style,例如 `1`、`true` 或 `TRUE`。 -与 `daedalus-agent-management-console` 保持一致,使用 TypeScript 或 JavaScript,并采用 AWS SDK for JavaScript v3,具体为 `@aws-sdk/client-s3`。 +本 Skill 是仓库内唯一负责 S3 文件读写的模块。使用 JavaScript 和 AWS SDK for JavaScript v3,具体为 `@aws-sdk/client-s3`;不要把 S3 文件读写复制到管理控制台、其他 Skill 或其他业务模块。 ## 内置脚本 @@ -68,7 +68,7 @@ node scripts/transfer_s3.mjs upload-file --source "/abs/path/to/file.bin" --work 内置脚本的行为: - 使用 `@aws-sdk/client-s3` 和上面的 DAEDALUS_AGENT S3 环境变量。 -- 与 `daedalus-agent-management-console` 保持同样风格:ESM、动态导入本地安装的包、显式构造 `S3Client`、显式传入凭证,并且 `DA_S3_FORCE_PATH_STYLE` 默认取 `true`。 +- 使用 ESM、动态导入本地安装的包、显式构造 `S3Client`、显式传入凭证,并且 `DA_S3_FORCE_PATH_STYLE` 默认取 `true`。 - 自动创建当前 `senderId` 下需要的 `upload` 或 `generated` 目录。 - 把 `file_input://...` URI 下载到 `/upload`。 - 把待发送文件复制到 `/generated`,重命名为要求的时间戳前缀格式,并上传到发件箱 bucket 根目录。 diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/DEV.md b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/DEV.md index 90f99191edc3e9b4adea73f3030a1405bffaec63..ade18fc84dd9ea4c0e010e0fe2062fcc6e53a331 100644 --- a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/DEV.md +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/DEV.md @@ -39,7 +39,7 @@ ## 技术架构 -- 运行时:Node.js >=22.19(本地开发会安装 openclaw@2026.5.28;建议 Node.js 24) +- 运行时:Node.js >=22.19(本地开发安装 `openclaw@2026.7.1`;建议 Node.js 24) - 语言:TypeScript 6 - 模块制式:ESM,`module=NodeNext` - 插件宿主:OpenClaw diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/README.md b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/README.md index e259f0188cb892eab3c835e62212bd3cd9096751..6013be4953a1a7976946b5604633accbd1fc73ac 100644 --- a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/README.md +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/README.md @@ -7,14 +7,14 @@ 说明: - npm 包名、插件 id、channel id 统一为 `daedalus-agent-mqtt-channel` -- 当前版本面向 OpenClaw `2026.5.x` 通道 SDK +- 兼容 OpenClaw `>=2026.5.0`;当前开发和测试依赖为 `2026.7.1` - 支持新的 `channel/setup` 接口和 `configured-state` 元数据 ## 功能 - 订阅 MQTT `inbound` topic,把消息送入 OpenClaw - 将 Agent 回复发布到 MQTT `outbound` topic -- 支持多账号配置,一个账号对应一个 MQTT 客户端 +- 支持多个逻辑账号复用同一个 MQTT 客户端 - 支持断线重连和自动重新订阅 - 支持 `mqtt://` 与 `mqtts://` - 支持 QoS `0 / 1 / 2` @@ -48,7 +48,19 @@ openclaw plugins update daedalus-agent-mqtt-channel ## 配置 -把下面内容加入 `~/.openclaw/openclaw.json`: +### 加载条件 + +把配置写入 `~/.openclaw/openclaw.json`。插件和通道分别有启用开关,只有插件被允许加载、插件条目启用、通道启用且至少存在一个带 `brokerUrl` 的启用账号时,账号才会启动。 + +| 属性 | 类型 / 默认值 | 含义和用途 | 限制与注意事项 | +| --- | --- | --- | --- | +| `plugins.allow` | `string[]` | OpenClaw 插件允许列表 | 使用允许列表时必须包含 `daedalus-agent-mqtt-channel` | +| `plugins.entries["daedalus-agent-mqtt-channel"].enabled` | `boolean` / 由 OpenClaw 决定 | 控制插件是否加载 | 设为 `false` 时,通道配置不会生效 | +| `channels["daedalus-agent-mqtt-channel"]` | `object` | MQTT 通道配置根节点 | 根节点只接受下表列出的通道属性和兼容属性 | +| `channels["daedalus-agent-mqtt-channel"].enabled` | `boolean` / `true` | 同时启用或停用该通道下的所有账号 | `false` 的优先级高于账号自己的 `enabled` | +| `channels["daedalus-agent-mqtt-channel"].accounts` | `object` / `{}` | 保存逻辑账号;键是账号 ID,值是账号配置 | 新配置应使用此写法;账号 ID 应使用稳定、非空的字符串 | + +推荐配置: ```json5 { @@ -57,9 +69,9 @@ openclaw plugins update daedalus-agent-mqtt-channel "enabled": true, "accounts": { "default": { + "enabled": true, "brokerUrl": "mqtts://your-server:8883", "username": "your-name", - "password": "your-password", "clientId": "openclaw-agent", "topics": { "inbound": "openclaw/inbound", @@ -71,23 +83,12 @@ openclaw plugins update daedalus-agent-mqtt-channel "enabled": true, "rejectUnauthorized": true } - }, - "lowcode": { - "brokerUrl": "mqtt://localhost:1884", - "topics": { - "inbound": "openclaw/inbound-lowcode", - "outbound": "openclaw/outbound-lowcode" - }, - "qos": 1, - "disableBlockStreaming": false } } } }, "plugins": { - "allow": [ - "daedalus-agent-mqtt-channel" - ], + "allow": ["daedalus-agent-mqtt-channel"], "entries": { "daedalus-agent-mqtt-channel": { "enabled": true @@ -97,111 +98,149 @@ openclaw plugins update daedalus-agent-mqtt-channel } ``` -字段说明: +示例没有把密码写入配置文件。生产环境建议通过 `MQTT_PASSWORD` 注入。 -- `channels["daedalus-agent-mqtt-channel"]` 是通道配置根节点 -- `accounts` 下每个键都是一个 OpenClaw 账号 id;插件会为每个账号启动一个 MQTT 客户端 -- `topics.inbound` 是发给 OpenClaw 的 topic -- `topics.outbound` 是 OpenClaw 回写回复的 topic -- `disableBlockStreaming` 控制该账号默认是否禁用 block streaming -- 旧版单账号写法仍然兼容,并会被视为 `default` 账号;新部署建议统一使用 `accounts` +### 账号属性 -也可以通过交互式命令配置: +下面的属性位于 `channels["daedalus-agent-mqtt-channel"].accounts.`。 -```bash -openclaw configure channels -``` +| 属性 | 类型 / 默认值 | 含义和用途 | 限制与注意事项 | +| --- | --- | --- | --- | +| `enabled` | `boolean` / `true` | 启用或停用当前逻辑账号 | 根节点 `enabled: false` 时,本属性设为 `true` 也不会启动账号 | +| `brokerUrl` | `string` / 无 | MQTT Broker 地址,也是判断账号是否已配置的必要字段 | 仅支持 `mqtt://` 和 `mqtts://`;建议显式写端口。手写 JSON 配置不会由插件补全或纠正协议 | +| `username` | `string` / 无 | Broker 用户名 | 可被全局环境变量 `MQTT_USERNAME` 覆盖;不要用它代替 Broker ACL | +| `password` | `string` / 无 | Broker 密码 | 可被全局环境变量 `MQTT_PASSWORD` 覆盖;不建议明文写入 `openclaw.json` | +| `clientId` | `string` / 自动生成 | MQTT 客户端 ID | 未配置时,每次进程启动生成形如 `openclaw-xxxxxxxx` 的 ID;同一 Broker 上重复的固定 ID 可能导致客户端互相踢下线 | +| `topics` | `object` / 默认 Topic | 当前账号的收发 Topic | 每个账号只支持一条入站订阅和一条出站发布 Topic | +| `topics.inbound` | `string` / `openclaw/inbound` | 插件订阅此 Topic,并把收到的 UTF-8 消息送入 OpenClaw | 支持 MQTT `+` 单层通配符和 `#` 多层通配符;插件不额外校验通配符位置。多个账号的订阅范围重叠时,同一消息可能被处理多次 | +| `topics.outbound` | `string` / `openclaw/outbound` | 插件把 Agent 回复发布到此 Topic | 必须是具体 Topic,不能包含 `+` 或 `#`;当前不支持按消息指定回复 Topic | +| `qos` | `0 \| 1 \| 2` / `1` | 设置当前账号的回复发布 QoS;首个启动账号的值还会成为共享客户端的入站订阅 QoS | 只接受 `0`、`1`、`2`;较高 QoS 会增加 Broker 状态和网络开销,但不能替代业务幂等处理 | +| `disableBlockStreaming` | `boolean` / `false` | 设置当前账号是否默认禁止 OpenClaw block streaming | `true` 表示尽量只返回最终结果,不保证宿主一定不产生中间块;入站消息可按请求覆盖 | +| `tls` | `object` / 无 | TLS 相关选项 | 是否使用 TLS 由 `brokerUrl` 的 `mqtts://` 协议决定;本对象用于控制证书校验选项 | +| `tls.enabled` | `boolean` / `false` | 允许插件向 MQTT.js 传入 TLS 校验选项 | 它不会把 `mqtt://` 自动升级成 TLS;使用 `mqtts://` 时建议显式设为 `true` | +| `tls.rejectUnauthorized` | `boolean` / `true` | 是否拒绝无法通过信任链校验的服务端证书 | 仅在 `tls.enabled: true` 时由插件显式传递;生产环境不应设为 `false` | +| `tls.ca` | `string` / 无 | 预留的自定义 CA 字段 | 当前实现不会读取该路径或把证书内容传给 MQTT.js,因此配置后不会改变证书信任链 | -配置完成后重启 OpenClaw 网关。若使用 systemd: +配置对象不支持未列出的扩展属性。Topic 字符串、账号 ID、`senderId` 和 `sessionId` 当前没有插件级长度限制,实际限制由 Broker、OpenClaw 和部署环境决定。 -```bash -systemctl restart openclaw -``` +### 多账号的当前边界 -## 环境变量 +`accounts` 表示多个 OpenClaw 逻辑账号,不表示多个独立 Broker 连接。当前运行时由所有账号复用一个 MQTT 客户端和一条物理连接: -以下环境变量会覆盖 `openclaw.json` 中对应字段,适合放置敏感信息: +- 所有账号必须指向同一个 Broker,并使用同一套用户名、密码、`clientId` 和 TLS 设置。 +- 首个启动账号的连接属性会用于建立共享连接;其他账号的连接属性不会创建第二条连接。不要依赖账号启动顺序来切换 Broker 或凭据。 +- 每个账号仍可配置自己的 `topics.inbound`、`topics.outbound`、出站 `qos`、`disableBlockStreaming`,并以自己的账号 ID 参与 OpenClaw 路由。所有入站订阅使用首个启动账号的 `qos`。 +- 存在名为 `default` 的账号时,它是默认账号;否则使用 `accounts` 中的第一个账号。当前没有单独的 `defaultAccount` 属性。 -```bash -MQTT_BROKER_URL -MQTT_USERNAME -MQTT_PASSWORD -MQTT_CLIENT_ID -MQTT_CA_PATH +需要连接不同 Broker 时,应运行彼此隔离的 OpenClaw / 插件实例。 + +### 旧版单账号配置 + +插件仍兼容把账号属性直接写在通道根节点的旧格式。该格式会被解析为账号 ID 为 `default` 的单账号: + +```json5 +{ + "channels": { + "daedalus-agent-mqtt-channel": { + "enabled": true, + "brokerUrl": "mqtt://localhost:1883", + "topics": { + "inbound": "openclaw/inbound", + "outbound": "openclaw/outbound" + }, + "qos": 1 + } + } +} ``` -说明: +只要 `accounts` 非空,插件就使用 `accounts`,不再读取根节点的旧版账号属性。新部署应统一使用 `accounts`,不要混用两种格式。 + +### 配置来源与优先级 + +账号发现顺序如下: + +1. 非空的 `accounts`。 +2. 通道根节点的旧版单账号属性。 +3. 仅当 `accounts` 不存在或为空,并且根节点没有任何旧版账号属性时,才由 `MQTT_BROKER_URL` 创建环境变量支持的 `default` 账号。 + +建立共享 MQTT 客户端时,`MQTT_*` 环境变量会覆盖连接配置中的对应字段;`topics`、`qos` 和 `disableBlockStreaming` 没有环境变量覆盖项。多账号模式下这些环境变量是进程级、全局的,不是按账号设置的。 + +| 环境变量 | 覆盖属性 | 含义和限制 | +| --- | --- | --- | +| `MQTT_BROKER_URL` | `brokerUrl` | Broker 地址;仅靠环境变量配置时必须提供,且应使用 `mqtt://` 或 `mqtts://` | +| `MQTT_USERNAME` | `username` | Broker 用户名 | +| `MQTT_PASSWORD` | `password` | Broker 密码;推荐用于注入敏感信息 | +| `MQTT_CLIENT_ID` | `clientId` | 共享物理连接的客户端 ID | +| `MQTT_CA_PATH` | `tls.ca` | 自定义 CA 路径字段;当前不会读取证书文件,因此暂不产生实际 TLS 效果 | -- 至少提供 `MQTT_BROKER_URL` 才能仅靠环境变量形成一个可用的默认账号 -- `MQTT_PASSWORD` 推荐只通过环境变量注入 -- `MQTT_CA_PATH` 当前只作为配置字段传递,代码尚未自动读取证书文件内容 +也可以通过交互式命令配置 `default` 账号: -## 用法 +```bash +openclaw configure channels +``` -### 会话路由与 `sessionId` +交互式配置会校验 Broker 地址以 `mqtt://` 或 `mqtts://` 开头,并在使用 `mqtts://` 时写入 TLS 选项。配置完成后重启 OpenClaw 网关;若使用 systemd: -- 路由依据是 MQTT 账号 id 加上入站消息里的 `senderId` -- 插件会以 `peer.id = senderId` 交给 OpenClaw 做 route 解析,再由 OpenClaw 根据 `bindings` 和 session 配置确定最终 `agentId` 与 `sessionKey` -- `sessionId` 只是请求级字段;如果入站 JSON 带了它,插件会在出站 JSON 中原样回显,便于客户端做请求和回复关联 -- `sessionId` 不会创建新会话,也不会直接修改记忆上下文 -- 如果入站 JSON 没有 `sessionId`,出站默认会写回 `"-1"` +```bash +systemctl restart openclaw +``` -如果你希望不同对话彼此隔离,应当使用不同的 `senderId`。 +## 消息协议 -### 发送入站消息 +### 入站消息 -发布到某个账号 `inbound` topic 的消息会被 OpenClaw 处理。支持纯文本和 JSON,推荐 JSON。 +客户端向账号的 `topics.inbound` 发布消息。Payload 按 UTF-8 解码;可以发送纯文本,也可以发送 JSON,推荐使用 JSON 对象。 ```bash # 纯文本 mosquitto_pub -t "openclaw/inbound" -m "Alert: Service down on playground" # JSON -mosquitto_pub -t "openclaw/inbound-lowcode" -m "{\"senderId\":\"pg-cli\",\"text\":\"hello\",\"sessionId\":\"abc-123\"}" +mosquitto_pub -t "openclaw/inbound" -m "{\"senderId\":\"pg-cli\",\"text\":\"hello\",\"sessionId\":\"abc-123\"}" ``` -入站 JSON 示例: +JSON 对象支持以下属性: -```json -{"senderId":"conn-test2","text":"What is your agent name?","sessionId":"conn-test2"} -``` - -按消息覆盖 `disableBlockStreaming`: +| 属性 | 类型 / 默认值 | 含义和用途 | 限制与注意事项 | +| --- | --- | --- | --- | +| `senderId` | `string` / 由实际入站 Topic 派生 | 发送方身份;插件将其作为 `peer.id` 交给 OpenClaw 路由,并用于区分对话记忆 | 应使用稳定、非空且可信的值。缺省时把实际 Topic 中的 `/` 替换为 `-`;插件不会验证或鉴权该值 | +| `text` | `string` / 原始 JSON 文本 | 发送给 Agent 的消息正文 | 应为字符串。缺省时,完整的原始 JSON 文本会作为正文;当前没有字段级错误响应 | +| `sessionId` | `string \| number` / 无 | 客户端自定义的请求与回复关联 ID | 会转为字符串并去除首尾空白;空字符串按未提供处理。它不会创建 OpenClaw 会话,也不会改变记忆隔离 | +| `disableBlockStreaming` | `boolean` / 继承账号配置 | 仅覆盖当前请求的 block streaming 行为 | 只有 JSON 布尔值 `true` 或 `false` 生效,字符串 `"true"` 等其他类型会被忽略 | -```json -{"senderId":"conn-test2","text":"Give me the final answer only","sessionId":"conn-test2","disableBlockStreaming":true} -``` +未知 JSON 属性会被忽略。无法解析为 JSON 对象的 Payload 会按纯文本处理。插件不会根据 MQTT retain 标志过滤消息,因此 Broker 在订阅时投递的 retained 消息也会像普通入站消息一样触发 Agent。 -说明: +路由和并发规则: -- `disableBlockStreaming` 只影响当前这一条 MQTT 请求 -- 如果消息里没有该字段,则继承账号级配置 -- `true` 表示尽量走非 block streaming 路径,优先得到 `final` 回复 -- `false` 表示允许本次请求使用 block streaming,即使账号默认关闭它 +- 路由输入是账号 ID 和 `senderId`。OpenClaw 再根据 `bindings` 与 session 配置解析最终 `agentId` 和 `sessionKey`。 +- 同一 `sessionKey` 的消息按接收顺序串行处理;不同 `sessionKey` 可以并行处理。 +- `sessionId` 只用于请求与回复关联。需要隔离记忆时应使用不同的 `senderId`,而不是只更换 `sessionId`。 -### 接收出站回复 +### 出站消息 -Agent 回复会发布到对应账号的 `outbound` topic,格式为 JSON: +Agent 回复会以紧凑 JSON 发布到账号的 `topics.outbound`: ```json -{"senderId":"openclaw","text":"...","kind":"final","sessionId":"conn-test2","ts":1700000000000} +{"senderId":"openclaw","text":"...","kind":"final","ts":1700000000000,"sessionId":"abc-123"} ``` +| 属性 | 类型 | 含义和用途 | 限制与注意事项 | +| --- | --- | --- | --- | +| `senderId` | `string` | 固定为 `openclaw`,表示消息来自插件侧 | 不是实际 `agentId`,不能据此区分多个 Agent | +| `text` | `string` | Agent 回复正文 | 空的中间 `block` 不发送;空的 `final` 仍会发送 | +| `kind` | `string` | OpenClaw 回复类型,常见值为 `block` 或 `final` | 开启 block streaming 时,客户端可能先收到多个 `block`,再收到 `final` | +| `ts` | `number` | 插件生成消息时的 Unix 毫秒时间戳 | 不是 Broker 时间戳,也不是入站消息时间戳 | +| `sessionId` | `string` | 回显规范化后的入站 `sessionId`,供客户端关联请求 | 入站未提供有效值时固定为 `"-1"`;该属性始终存在 | + +出站发布沿用账号的 `qos`,不设置 retain,且不支持媒体、reaction 或 thread。`outbound.sendText()` 也使用相同 JSON envelope,其中 `sessionId` 来自 OpenClaw 传入的 `threadId`,缺省时为 `"-1"`。 + 订阅示例: ```bash -mosquitto_sub -t "openclaw/outbound-lowcode" -v +mosquitto_sub -t "openclaw/outbound" -v ``` -说明: - -- `kind` 会原样透传 OpenClaw 的出站类型,常见值是 `block` 或 `final` -- 如果开启了 block streaming,可能先收到若干个 `block`,最后再收到 `final` -- 如果入站消息设置了 `disableBlockStreaming: true`,该请求会尽量避免 `block` -- 只有当入站 JSON 包含 `sessionId` 时,插件才会在出站中回显该值;否则默认是 `"-1"` -- 数字类型的 `sessionId` 会被规范化为字符串 -- `outbound.sendText()` 同样使用上述 JSON 包装格式 - ## 排障 先确认 OpenClaw 实际加载的是哪份插件: @@ -246,7 +285,7 @@ MQTT route resolved topic=openclaw/inbound-lowcode inboundAccount=lowcode routeA ```bash # 在 monorepo 根目录下 -cd plugins/daedalus-agent-mqtt-channel +cd extensions/openclaw-plugins/daedalus-agent-mqtt-channel npm install npm test diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package-lock.json b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package-lock.json index 3c84ad9bc8e5bb1d41c17eadb0bcb87a923f7472..f56063f33ee70f7584dcc9fd5ce5d57af35c7675 100644 --- a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package-lock.json +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package-lock.json @@ -17,6 +17,7 @@ "devDependencies": { "@types/jasmine": "^5.1.13", "@types/node": "25.8.0", + "c8": "^12.0.0", "jasmine": "^5.12.0", "openclaw": "2026.7.1", "tsx": "^4.22.0", @@ -43,6 +44,16 @@ "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -559,6 +570,44 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -576,6 +625,13 @@ "node": ">=14" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jasmine": { "version": "5.1.15", "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", @@ -660,6 +716,16 @@ "node": ">=8.0.0" } }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -692,6 +758,19 @@ "readable-stream": "^4.2.0" } }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/broker-factory": { "version": "3.1.14", "resolved": "https://registry.npmjs.org/broker-factory/-/broker-factory-3.1.14.tgz", @@ -734,6 +813,80 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "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/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -789,6 +942,13 @@ "node": ">= 6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -883,6 +1043,16 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/event-target-shim": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", @@ -914,6 +1084,23 @@ "node": ">=18.2.0" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -959,12 +1146,70 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/help-me": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -1017,6 +1262,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -1136,12 +1420,60 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "license": "ISC" }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -5168,6 +5500,38 @@ "zod": "^3.25.28 || ^4" } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -5175,6 +5539,16 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -5185,6 +5559,33 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/pino": { "version": "10.3.1", "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", @@ -5319,6 +5720,19 @@ "node": ">=10" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -5510,6 +5924,34 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -5585,6 +6027,21 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5648,6 +6105,24 @@ "worker-factory": "^7.0.49" } }, + "node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "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/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", @@ -5690,6 +6165,44 @@ "node": ">=8" } }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "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/ws": { "version": "8.20.1", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", @@ -5711,6 +6224,84 @@ } } }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.6.tgz", diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package.json b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package.json index bd9a12cbbe9af911345c7f75c5536bb7840d6b85..bc2ddc087a79c4a6a92b509721381d19503f41c2 100644 --- a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package.json +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/package.json @@ -61,6 +61,7 @@ "devDependencies": { "@types/jasmine": "^5.1.13", "@types/node": "25.8.0", + "c8": "^12.0.0", "jasmine": "^5.12.0", "openclaw": "2026.7.1", "tsx": "^4.22.0", @@ -79,6 +80,8 @@ "prepublishOnly": "npm run build", "test": "tsx --tsconfig tsconfig.spec.json test/run-jasmine.ts", "test:watch": "tsx watch --tsconfig tsconfig.spec.json test/run-jasmine.ts", + "coverage": "c8 --all --extension .ts --include \"src/**/*.ts\" --exclude \"src/**/*.test.ts\" --exclude \"src/__mocks__/**\" --exclude \"src/**/*.d.ts\" --exclude \"src/types.ts\" --reporter text --reporter json-summary --reports-dir \"../../../coverage/mqtt\" npm test", + "coverage:strict": "c8 --all --extension .ts --include \"src/**/*.ts\" --exclude \"src/**/*.test.ts\" --exclude \"src/__mocks__/**\" --exclude \"src/**/*.d.ts\" --exclude \"src/types.ts\" --check-coverage --branches 100 --statements 0 --functions 0 --lines 0 --reporter text --reporter json-summary --reports-dir \"../../../coverage/mqtt\" npm test", "lint": "eslint src/", "typecheck": "tsc --noEmit" } diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/channel.test.ts b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/channel.test.ts index 9c98f1993caef8b659877a4e226112427887fcd0..bb4f7adb95e4e4f5189181a302d4d8474414cf07 100644 --- a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/channel.test.ts +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/channel.test.ts @@ -1352,3 +1352,498 @@ describe("inbound message parsing", () => { await startPromise; }); }); + +describe("channel runtime delivery edge contracts", () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const cfg = { + channels: { + "daedalus-agent-mqtt-channel": { + accounts: { + default: { + brokerUrl: "mqtt://localhost:1883", + topics: { inbound: "edge/in", outbound: "edge/out" }, + qos: 1 as const, + }, + }, + }, + }, + }; + + async function start(runtime: any) { + setMqttRuntime(runtime); + const controller = new AbortController(); + const startPromise = mqttPlugin.gateway?.startAccount?.({ + cfg, + accountId: "default", + log: logger, + abortSignal: controller.signal, + } as any); + await new Promise((resolve) => setTimeout(resolve, 50)); + return { controller, startPromise }; + } + + function baseRuntime(dispatch: (args: any) => Promise) { + return { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "agent-edge", + sessionKey: "edge-session", + mainSessionKey: "edge-main", + }), + }, + reply: { + finalizeInboundContext: (value: any) => value, + dispatchReplyWithBufferedBlockDispatcher: dispatch, + }, + session: { + resolveStorePath: () => "/sessions/edge.json", + recordInboundSession: async () => undefined, + }, + }, + }; + } + + beforeEach(() => { + resetMock(); + vi.clearAllMocks(); + resetMqttClientsForTest(); + resetMqttChatLogForTest(); + prepareChatLogDir(); + setMqttModuleForTest({ connect }); + }); + + afterEach(async () => { + resetMqttModuleForTest(); + resetMqttClientsForTest(); + await cleanupChatLogDir(); + }); + + it("handles empty streaming chunks and dispatcher callbacks", async () => { + const runtime = baseRuntime(async ({ dispatcherOptions }: any) => { + await dispatcherOptions.deliver({}, { kind: "chunk" }); + await dispatcherOptions.deliver({}, { kind: "final" }); + dispatcherOptions.onSkip({}, { reason: "filtered" }); + dispatcherOptions.onError(new Error("reply failed"), { kind: "final" }); + }); + const { controller, startPromise } = await start(runtime); + getMockClient()?.simulateMessage("edge/in", "hello"); + await flushAsync(); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("skipping empty chunk")); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("skipped reply")); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("reply error")); + expect(getMockClient()?.published.at(-1)?.message).toContain('"text":""'); + controller.abort(); + await startPromise; + }); + + it("logs disconnected and publish failure paths", async () => { + const runtime = baseRuntime(async ({ dispatcherOptions }: any) => { + await dispatcherOptions.deliver({ text: "reply" }, { kind: "final" }); + }); + const { controller, startPromise } = await start(runtime); + const mock = getMockClient()!; + mock.connected = false; + mock.simulateMessage("edge/in", "offline"); + await flushAsync(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("not connected")); + + mock.connected = true; + mock.publish = () => { throw new Error("publish failed"); }; + mock.simulateMessage("edge/in", "publish-error"); + await flushAsync(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("failed to send reply")); + controller.abort(); + await startPromise; + }); + + it("uses the legacy session metadata callback and reports its failure", async () => { + const recordSessionMetaFromInbound = vi.fn(async () => { throw new Error("metadata failed"); }); + const runtime = baseRuntime(async () => undefined); + runtime.channel.session = { + resolveStorePath: () => "/sessions/edge.json", + recordSessionMetaFromInbound, + }; + const { controller, startPromise } = await start(runtime); + getMockClient()?.simulateMessage("edge/in", "legacy-session"); + await flushAsync(); + expect(recordSessionMetaFromInbound).toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("failed updating session meta")); + controller.abort(); + await startPromise; + }); +}); + +describe("channel runtime boundary paths", () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const cfg = { + channels: { + "daedalus-agent-mqtt-channel": { + accounts: { + default: { + brokerUrl: "mqtt://localhost:1883", + topics: { inbound: "boundary/in", outbound: "boundary/out" }, + qos: 1 as const, + }, + }, + }, + }, + }; + + async function start(runtime: any = { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "boundary-agent", + sessionKey: "boundary-session", + }), + }, + reply: { + finalizeInboundContext: (value: any) => value, + dispatchReplyWithBufferedBlockDispatcher: async () => undefined, + }, + session: { + resolveStorePath: () => undefined, + }, + }, + }, nextCfg: any = cfg) { + setMqttRuntime(runtime); + const controller = new AbortController(); + const startPromise = mqttPlugin.gateway?.startAccount?.({ + cfg: nextCfg, + accountId: "default", + log: logger, + abortSignal: controller.signal, + } as any); + await new Promise((resolve) => setTimeout(resolve, 50)); + return { controller, startPromise }; + } + + beforeEach(() => { + resetMock(); + vi.clearAllMocks(); + resetMqttClientsForTest(); + resetMqttChatLogForTest(); + prepareChatLogDir(); + setMqttModuleForTest({ connect }); + }); + + afterEach(async () => { + resetMqttModuleForTest(); + resetMqttClientsForTest(); + await cleanupChatLogDir(); + }); + + it("skips disabled accounts before creating a client", async () => { + const controller = new AbortController(); + await mqttPlugin.gateway?.startAccount?.({ + cfg: { + channels: { + "daedalus-agent-mqtt-channel": { + accounts: { default: { brokerUrl: "mqtt://disabled", enabled: false } }, + }, + }, + }, + accountId: "default", + log: logger, + abortSignal: controller.signal, + } as any); + expect(getMockClient()).toBeNull(); + expect(logger.debug).toHaveBeenCalledWith(expect.stringContaining("disabled")); + }); + + it("keeps a usable account after an initial connection failure", async () => { + setMqttModuleForTest({ + connect: () => { + throw new Error("broker unavailable"); + }, + }); + const { controller, startPromise } = await start(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("connection failed")); + controller.abort(); + await startPromise; + }); + + it("resets an existing account subscription and handles unsubscribe failures", async () => { + const first = await start(); + const mock = getMockClient()!; + const originalUnsubscribe = mock.unsubscribe.bind(mock); + mock.unsubscribe = (_topic: string, callback?: (err: Error | null) => void) => { + callback?.(new Error("unsubscribe reset failed")); + return mock; + }; + const second = await start(); + expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("failed to reset MQTT subscription")); + first.controller.abort(); + await first.startPromise; + mock.unsubscribe = originalUnsubscribe; + second.controller.abort(); + await second.startPromise; + }); + + it("logs outbound publish errors and uses dependency logger fallback", async () => { + const { controller, startPromise } = await start(); + const mock = getMockClient()!; + mock.publish = (_topic: string, _message: string, _opts: unknown, callback?: (err: Error | null) => void) => { + callback?.(new Error("outbound publish failed")); + return mock; + }; + await expectAsync(mqttPlugin.outbound.sendText?.({ + text: "boundary", + cfg, + accountId: "default", + deps: { agentId: "dependency-agent", log: logger }, + } as any)).toBeRejectedWithError("outbound publish failed"); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to publish")); + controller.abort(); + await startPromise; + }); + + it("handles inbound routes without session persistence and without a main session", async () => { + const runtime = { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "boundary-agent", + sessionKey: "boundary-session", + }), + }, + reply: { + finalizeInboundContext: (value: any) => value, + dispatchReplyWithBufferedBlockDispatcher: async () => undefined, + }, + session: { + resolveStorePath: () => undefined, + }, + }, + }; + const { controller, startPromise } = await start(runtime); + getMockClient()?.simulateMessage("boundary/in", JSON.stringify({ text: "no persistence" })); + await new Promise((resolve) => setTimeout(resolve, 20)); + controller.abort(); + await startPromise; + expect(logger.error).not.toHaveBeenCalledWith(expect.stringContaining("failed updating session meta")); + }); + + it("accepts a resolved account object and applies default account and topic names", async () => { + setMqttRuntime({ + channel: { + routing: { resolveAgentRoute: () => ({ accountId: "default", agentId: "agent", sessionKey: "session" }) }, + reply: { + finalizeInboundContext: (value: any) => value, + dispatchReplyWithBufferedBlockDispatcher: async () => undefined, + }, + }, + } as any); + const controller = new AbortController(); + const startPromise = mqttPlugin.gateway?.startAccount?.({ + cfg: {}, + account: { enabled: true, config: { brokerUrl: "mqtt://resolved-account" } }, + log: logger, + abortSignal: controller.signal, + } as any); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(getMockClient()?.subscriptions.has("openclaw/inbound")).toBe(true); + controller.abort(); + await startPromise; + }); + + it("normalizes blank and non-string outbound identifiers and logger fallbacks", async () => { + const { controller, startPromise } = await start(); + setMqttRuntime(undefined as any); + const blank = await mqttPlugin.outbound.sendText?.({ + text: "blank identifiers", + cfg, + accountId: "default", + agentId: { toString: () => " " }, + threadId: { toString: () => " " }, + deps: { ctx: { AgentId: "ctx-agent" } }, + } as any); + expect(blank?.meta.sessionId).toBe("-1"); + expect(getMockClient()?.published.at(-1)?.message).toContain('"sessionId":"-1"'); + + const numeric = await mqttPlugin.outbound.sendText?.({ + text: "numeric identifiers", + cfg, + accountId: "default", + agentId: 123, + threadId: 456, + deps: { runtime: logger }, + } as any); + expect(numeric?.meta.sessionId).toBe("456"); + controller.abort(); + await startPromise; + }); + + it("handles object payload fallbacks without a session service", async () => { + const dispatch = vi.fn(async () => undefined); + const runtime = { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "boundary-agent", + sessionKey: "boundary-session", + }), + }, + reply: { + finalizeInboundContext: (value: any) => value, + dispatchReplyWithBufferedBlockDispatcher: dispatch, + }, + }, + }; + const { controller, startPromise } = await start(runtime); + getMockClient()?.simulateMessage("boundary/in", "{}"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(dispatch).toHaveBeenCalled(); + expect(dispatch.mock.calls[0][0].ctx.Body).toBe("{}"); + expect(dispatch.mock.calls[0][0].ctx.SenderId).toBe("boundary-in"); + controller.abort(); + await startPromise; + }); + + it("falls back to routed session fields and reports record errors", async () => { + let recordArgs: any; + const runtime = { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "boundary-agent", + sessionKey: "route-session", + mainSessionKey: "route-main-session", + }), + }, + reply: { + finalizeInboundContext: (value: any) => ({ ...value, SessionKey: null, To: null }), + dispatchReplyWithBufferedBlockDispatcher: async () => undefined, + }, + session: { + resolveStorePath: () => "/sessions/boundary.json", + recordInboundSession: async (args: any) => { + recordArgs = args; + args.onRecordError(new Error("record failed")); + }, + }, + }, + }; + const { controller, startPromise } = await start(runtime, { + ...cfg, + session: { store: "configured-store" }, + }); + getMockClient()?.simulateMessage("boundary/in", JSON.stringify({ text: "record" })); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(recordArgs.sessionKey).toBe("route-session"); + expect(recordArgs.updateLastRoute.to).toBe("mqtt:default"); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("record failed")); + controller.abort(); + await startPromise; + }); + + it("omits last-route updates when the routed session has no main session", async () => { + let recordArgs: any; + const runtime = { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "boundary-agent", + sessionKey: "route-only-session", + }), + }, + reply: { + finalizeInboundContext: (value: any) => value, + dispatchReplyWithBufferedBlockDispatcher: async () => undefined, + }, + session: { + resolveStorePath: () => "/sessions/route-only.json", + recordInboundSession: async (args: any) => { recordArgs = args; }, + }, + }, + }; + const { controller, startPromise } = await start(runtime); + getMockClient()?.simulateMessage("boundary/in", "route only"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(recordArgs.updateLastRoute).toBeUndefined(); + controller.abort(); + await startPromise; + }); + + it("counts work queued while the same session is active", async () => { + let releaseFirst: (() => void) | undefined; + let dispatchCount = 0; + const runtime = { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "boundary-agent", + sessionKey: "active-session", + }), + }, + reply: { + finalizeInboundContext: (value: any) => value, + dispatchReplyWithBufferedBlockDispatcher: async () => { + dispatchCount += 1; + if (dispatchCount === 1) { + await new Promise((resolve) => { releaseFirst = resolve; }); + } + }, + }, + }, + }; + const { controller, startPromise } = await start(runtime); + getMockClient()?.simulateMessage("boundary/in", "first"); + await new Promise((resolve) => setTimeout(resolve, 10)); + getMockClient()?.simulateMessage("boundary/in", "second"); + await new Promise((resolve) => setTimeout(resolve, 10)); + releaseFirst?.(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(dispatchCount).toBe(2); + controller.abort(); + await startPromise; + }); + + it("uses the routed key for legacy session metadata and catches dispatcher failures", async () => { + let legacyArgs: any; + const runtime = { + channel: { + routing: { + resolveAgentRoute: () => ({ + accountId: "default", + agentId: "boundary-agent", + sessionKey: "legacy-route-session", + }), + }, + reply: { + finalizeInboundContext: (value: any) => ({ ...value, SessionKey: null }), + dispatchReplyWithBufferedBlockDispatcher: async () => { throw new Error("dispatcher failed"); }, + }, + session: { + resolveStorePath: () => "/sessions/legacy.json", + recordSessionMetaFromInbound: async (args: any) => { legacyArgs = args; }, + }, + }, + }; + const { controller, startPromise } = await start(runtime); + getMockClient()?.simulateMessage("boundary/in", "legacy failure"); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(legacyArgs.sessionKey).toBe("legacy-route-session"); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to process MQTT message")); + controller.abort(); + await startPromise; + }); +}); diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/client.test.ts b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/client.test.ts index 7b609128a155dd714aeab4d0ce79a25bcea7dbeb..8027b68236c7f3bb12abd46df42a12ade18f1567 100644 --- a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/client.test.ts +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/client.test.ts @@ -1,4 +1,4 @@ -import { resetMock, getMockClient, connect } from "./__mocks__/mqtt.js"; +import { MockMqttClient, resetMock, getMockClient, connect } from "./__mocks__/mqtt.js"; import { createMqttClient, resetMqttModuleForTest, setMqttModuleForTest } from "./client.js"; describe("MqttClientManager", () => { @@ -57,6 +57,38 @@ describe("MqttClientManager", () => { // Client ID is passed to mqtt.connect options expect(client.isConnected()).toBe(true); }); + + it("should pass credentials and TLS options to the MQTT module", async () => { + const config = { + ...defaultConfig, + username: "user", + password: "secret", + tls: { enabled: true, rejectUnauthorized: false, ca: "ca.pem" }, + }; + let options: any; + setMqttModuleForTest({ + connect: (_url: string, nextOptions: any) => { + options = nextOptions; + const mock = new MockMqttClient(); + setTimeout(() => mock.simulateConnect(), 0); + return mock; + }, + }); + const client = createMqttClient(config, mockLogger); + await client.connect(); + expect(options).toEqual(expect.objectContaining({ + username: "user", + password: "secret", + rejectUnauthorized: false, + })); + }); + + it("should share an in-flight connection promise", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await Promise.all([client.connect(), client.connect()]); + expect(client.isConnected()).toBe(true); + }); + }); describe("disconnect", () => { @@ -172,7 +204,7 @@ describe("MqttClientManager", () => { } }); - it("should use configured QoS", async () => { + it("should use configured QoS", async () => { const config = { ...defaultConfig, qos: 2 as const }; const client = createMqttClient(config, mockLogger); await client.connect(); @@ -182,6 +214,349 @@ describe("MqttClientManager", () => { expect(mock?.published[0].opts).toEqual({ qos: 2 }); }); }); + + it("should handle wildcard messages, duplicate subscriptions, and handler errors", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + const exact = vi.fn(); + const wildcard = vi.fn(); + const failing = vi.fn(() => { throw new Error("handler failed"); }); + + await client.connect(); + await client.subscribe("home/+/temp", wildcard); + await client.subscribe("home/living/temp", exact); + await client.subscribe("home/+/temp", failing); + const mock = getMockClient()!; + mock.simulateMessage("home/living/temp", "22C"); + + expect(exact).toHaveBeenCalledTimes(1); + expect(wildcard).toHaveBeenCalledTimes(1); + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Message handler error")); + }); + + it("should ignore messages with no exact or wildcard handlers", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + getMockClient()?.simulateMessage("unregistered/topic", "ignored"); + expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("Received message")); + }); + + it("should report resubscribe failures after reconnect", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + const handler = vi.fn(); + await client.subscribe("resubscribe/topic", handler); + await client.connect(); + const mock = getMockClient()!; + mock.subscribe = (_topic: string, _opts: { qos: number }, callback?: (err: Error | null) => void) => { + callback?.(new Error("resubscribe failed")); + return mock; + }; + mock.simulateConnect(); + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("Failed to subscribe")); + }); + + it("removes a duplicate handler without unsubscribing the topic", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + const handler = vi.fn(); + const secondHandler = vi.fn(); + await client.connect(); + await client.subscribe("topic", handler); + await client.subscribe("topic", secondHandler); + await client.unsubscribe("topic", secondHandler); + expect(getMockClient()?.subscriptions.has("topic")).toBe(true); + await client.unsubscribe("topic", handler); + expect(getMockClient()?.subscriptions.has("topic")).toBe(false); + await client.unsubscribe("missing", handler); + }); + + it("propagates publish, subscribe, and unsubscribe callback failures", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const mock = getMockClient()!; + const originalSubscribe = mock.subscribe.bind(mock); + mock.publish = (_topic: string, _message: string, _opts: unknown, callback?: (err: Error | null) => void) => { + callback?.(new Error("publish failed")); + return mock; + }; + await expectAsync(client.publish("topic", "body")).toBeRejectedWithError("publish failed"); + + mock.subscribe = (_topic: string, _opts: { qos: number }, callback?: (err: Error | null) => void) => { + callback?.(new Error("subscribe failed")); + return mock; + }; + await expectAsync(client.subscribe("new-topic", vi.fn())).toBeRejectedWithError("subscribe failed"); + expect(getMockClient()?.subscriptions.has("new-topic")).toBe(false); + + mock.subscribe = originalSubscribe; + const registered = vi.fn(); + await client.subscribe("bad-topic", registered); + mock.unsubscribe = (_topic: string, callback?: (err: Error | null) => void) => { + callback?.(new Error("unsubscribe failed")); + return mock; + }; + await expectAsync(client.unsubscribe("bad-topic", registered)).toBeRejectedWithError("unsubscribe failed"); + }); + + it("schedules reconnects for broker lifecycle events and handles reconnect errors", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const mock = getMockClient()!; + mock.reconnect = () => { throw new Error("reconnect failed"); }; + mock.emit("reconnect"); + mock.emit("error", new Error("broker error")); + mock.emit("close"); + mock.emit("offline"); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining("reconnect scheduled")); + await client.disconnect(); + }); + + it("settles an already-connected client and uses the default TLS verification policy", async () => { + let options: any; + const mock = new MockMqttClient(); + mock.connected = true; + setMqttModuleForTest({ + connect: (_url: string, nextOptions: any) => { + options = nextOptions; + return mock as any; + }, + }); + const client = createMqttClient({ + ...defaultConfig, + tls: { enabled: true }, + }, mockLogger); + + await client.connect(); + + expect(client.isConnected()).toBe(true); + expect(options.rejectUnauthorized).toBe(true); + await client.disconnect(); + }); + + it("settles only once when the grace timer wins the initial connection race", async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const pending = new Map void>(); + let timerId = 0; + let mock: MockMqttClient; + try { + globalThis.setTimeout = ((callback: () => void) => { + timerId += 1; + pending.set(timerId, callback); + return timerId as any; + }) as typeof setTimeout; + globalThis.clearTimeout = ((id: any) => { + pending.delete(Number(id)); + }) as typeof clearTimeout; + setMqttModuleForTest({ + connect: () => { + mock = new MockMqttClient(); + return mock as any; + }, + }); + const client = createMqttClient(defaultConfig, mockLogger); + const connecting = client.connect(); + expect(pending.size).toBe(1); + pending.values().next().value?.(); + await connecting; + mock!.simulateConnect(); + expect(client.isConnected()).toBe(true); + await client.disconnect(); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } + }); + + it("runs one scheduled reconnect and suppresses duplicate lifecycle timers", async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const pending = new Map void>(); + let timerId = 0; + let mock: MockMqttClient; + try { + globalThis.setTimeout = ((callback: () => void) => { + timerId += 1; + pending.set(timerId, callback); + return timerId as any; + }) as typeof setTimeout; + globalThis.clearTimeout = ((id: any) => { + pending.delete(Number(id)); + }) as typeof clearTimeout; + setMqttModuleForTest({ + connect: () => { + mock = new MockMqttClient(); + return mock as any; + }, + }); + const client = createMqttClient(defaultConfig, mockLogger); + const connecting = client.connect(); + mock!.simulateConnect(); + await connecting; + mock!.reconnect = () => { throw new Error("scheduled reconnect failed"); }; + mock!.emit("error", new Error("broker error")); + const [scheduledId, scheduled] = pending.entries().next().value as [number, () => void]; + mock!.emit("close"); + expect(pending.size).toBe(1); + pending.delete(scheduledId); + scheduled(); + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("MQTT reconnect error")); + expect(pending.size).toBe(1); + await client.disconnect(); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } + }); + + it("keeps later handlers when a delayed first subscription fails", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const mock = getMockClient()!; + let subscriptionCallback: ((error: Error | null) => void) | undefined; + mock.subscribe = (_topic: string, _opts: { qos: number }, callback?: (error: Error | null) => void) => { + subscriptionCallback = callback; + return mock; + }; + const first = vi.fn(); + const second = vi.fn(); + const firstSubscription = client.subscribe("delayed/topic", first); + await client.subscribe("delayed/topic", second); + subscriptionCallback?.(new Error("delayed subscribe failed")); + await expectAsync(firstSubscription).toBeRejectedWithError("delayed subscribe failed"); + mock.simulateMessage("delayed/topic", "payload"); + expect(first).not.toHaveBeenCalled(); + expect(second).toHaveBeenCalled(); + }); + + it("reconnects an existing disconnected client", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const mock = getMockClient()!; + mock.connected = false; + await client.connect(); + expect(mockLogger.info).toHaveBeenCalledWith("MQTT connect requested; reconnecting existing client"); + expect(client.isConnected()).toBe(true); + }); + + it("reports a synchronous reconnect failure for an existing client", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const mock = getMockClient()!; + mock.connected = false; + mock.reconnect = () => { throw new Error("direct reconnect failed"); }; + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const pending = new Map void>(); + let timerId = 0; + try { + globalThis.setTimeout = ((callback: () => void) => { + timerId += 1; + pending.set(timerId, callback); + return timerId as any; + }) as typeof setTimeout; + globalThis.clearTimeout = ((id: any) => { + pending.delete(Number(id)); + }) as typeof clearTimeout; + const reconnecting = client.connect(); + const graceTimer = [...pending.values()].at(-1)!; + graceTimer(); + await reconnecting; + expect(mockLogger.error).toHaveBeenCalledWith(expect.stringContaining("direct reconnect failed")); + await client.disconnect(); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } + }); + + it("ignores reconnect scheduling triggered during a manual disconnect", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const mock = getMockClient()!; + mock.end = (_force: boolean, _opts: unknown, callback?: () => void) => { + mock.emit("error", new Error("disconnect event")); + callback?.(); + return mock; + }; + await client.disconnect(); + expect(mockLogger.warn).not.toHaveBeenCalledWith(expect.stringContaining("disconnect event")); + }); + + it("ignores a stale reconnect timer after a manual disconnect", async () => { + const originalSetTimeout = globalThis.setTimeout; + const originalClearTimeout = globalThis.clearTimeout; + const pending = new Map void>(); + let timerId = 0; + let mock: MockMqttClient; + try { + globalThis.setTimeout = ((callback: () => void) => { + timerId += 1; + pending.set(timerId, callback); + return timerId as any; + }) as typeof setTimeout; + globalThis.clearTimeout = ((id: any) => { + pending.delete(Number(id)); + }) as typeof clearTimeout; + setMqttModuleForTest({ + connect: () => { + mock = new MockMqttClient(); + return mock as any; + }, + }); + const client = createMqttClient(defaultConfig, mockLogger); + const connecting = client.connect(); + mock!.simulateConnect(); + await connecting; + mock!.emit("error", new Error("schedule stale timer")); + const staleTimer = pending.values().next().value as () => void; + await client.disconnect(); + staleTimer(); + expect(mockLogger.info).not.toHaveBeenCalledWith("MQTT reconnecting..."); + } finally { + globalThis.setTimeout = originalSetTimeout; + globalThis.clearTimeout = originalClearTimeout; + } + }); + + it("handles a delayed subscription error after the handler map is removed", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const mock = getMockClient()!; + let subscriptionCallback: ((error: Error | null) => void) | undefined; + mock.subscribe = (_topic: string, _opts: { qos: number }, callback?: (error: Error | null) => void) => { + subscriptionCallback = callback; + return mock; + }; + const handler = vi.fn(); + const subscription = client.subscribe("removed/topic", handler); + await client.unsubscribe("removed/topic", handler); + subscriptionCallback?.(new Error("removed subscription failed")); + await expectAsync(subscription).toBeRejectedWithError("removed subscription failed"); + }); + + it("executes hash, short-topic, mismatched, and length-mismatch wildcard paths", async () => { + const client = createMqttClient(defaultConfig, mockLogger); + await client.connect(); + const hash = vi.fn(); + const short = vi.fn(); + const mismatch = vi.fn(); + const lengthMismatch = vi.fn(); + await client.subscribe("home/#", hash); + await client.subscribe("home/+/+", short); + await client.subscribe("home/+/tail", mismatch); + await client.subscribe("root/+/leaf", lengthMismatch); + const mock = getMockClient()!; + mock.simulateMessage("home/a/value", "hash"); + const shortCalls = short.mock.calls.length; + mock.simulateMessage("home/a", "short"); + expect(short.mock.calls.length).toBe(shortCalls); + const mismatchCalls = mismatch.mock.calls.length; + mock.simulateMessage("home/a/nope", "mismatch"); + expect(mismatch.mock.calls.length).toBe(mismatchCalls); + const lengthMismatchCalls = lengthMismatch.mock.calls.length; + mock.simulateMessage("root/a/leaf/extra", "length"); + expect(hash).toHaveBeenCalled(); + expect(lengthMismatch.mock.calls.length).toBe(lengthMismatchCalls); + }); }); describe("topicMatches", () => { diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/coverage-contract.test.ts b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/coverage-contract.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..2de6d22a787d661cd64010912b816c8d9da936b1 --- /dev/null +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/coverage-contract.test.ts @@ -0,0 +1,359 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { mqttChannelBase } from "./channel-base.js"; +import { + getMqttAccounts, + getMqttConfiguredState, + getRawMqttChannelConfig, + hasConfiguredMqttAccount, + hasConfiguredMqttEnv, + listMqttAccountIds, + resolveDefaultMqttAccountId, + resolveMqttAccount +} from "./channel-config.js"; +import { writeMqttChatLog, writeMqttFunctionalLog } from "./chat-log.js"; +import { ENV_VARS, mergeWithEnv } from "./env.js"; +import { getMqttRuntime, setMqttRuntime } from "./runtime.js"; +import { + applyMqttSetupAccountConfig, + buildMqttAccountConfig, + normalizeMqttAccountId +} from "./setup-config.js"; + +const ENV_KEYS = [ + ...Object.values(ENV_VARS), + "DA_MQTT_CHANNEL_LOG_DIR", + "DA_LOG_DIR", + "DA_ROOT", + "DA_DATA_DIR", + "DA_KERNEL_DATA_DIR", + "DA_OPENCLAW_HOME", + "OPENCLAW_HOME" +]; + +function withEnvironment(values: Record, action: () => void): void { + const original = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of ENV_KEYS) { + delete process.env[key]; + } + Object.assign(process.env, values); + try { + action(); + } finally { + for (const key of ENV_KEYS) { + if (original[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = original[key]; + } + } + } +} + +function channelConfig(value: Record): Record { + return { channels: { "daedalus-agent-mqtt-channel": value } }; +} + +function readEntries(directory: string, prefix: string): any[] { + return fs.readdirSync(directory) + .filter((name) => name.startsWith(prefix) && name.endsWith(".log")) + .flatMap((name) => fs.readFileSync(path.join(directory, name), "utf8").trim().split(/\r?\n/)) + .filter(Boolean) + .map((line) => JSON.parse(line)); +} + +describe("MQTT account configuration contracts", () => { + it("resolves explicit accounts, defaults, filtering, and enabled state", () => { + const cfg = channelConfig({ + enabled: false, + accounts: { + default: { brokerUrl: "mqtt://default", enabled: true }, + disabled: { brokerUrl: "mqtt://disabled", enabled: false }, + empty: {} + } + }); + + expect(getRawMqttChannelConfig(undefined)).toBeUndefined(); + expect(getRawMqttChannelConfig(cfg)).toBeDefined(); + expect(listMqttAccountIds(cfg)).toEqual(["default", "disabled"]); + expect(hasConfiguredMqttAccount(cfg)).toBeTrue(); + expect(resolveDefaultMqttAccountId(cfg)).toBe("default"); + expect(resolveMqttAccount(cfg)).toEqual(jasmine.objectContaining({ + accountId: "default", + enabled: false, + brokerUrl: "mqtt://default" + })); + expect(resolveMqttAccount(channelConfig({ accounts: { disabled: { brokerUrl: "mqtt://disabled", enabled: false } } }), "disabled").enabled).toBeFalse(); + expect(resolveMqttAccount(channelConfig({ accounts: { active: { brokerUrl: "mqtt://active" } } }), "active").enabled).toBeTrue(); + expect(resolveMqttAccount(cfg, "missing")).toEqual({ accountId: "missing", enabled: false }); + expect(resolveDefaultMqttAccountId(channelConfig({ accounts: { first: { brokerUrl: "mqtt://first" } } }))).toBe("first"); + expect(resolveDefaultMqttAccountId({})).toBe("default"); + }); + + it("supports legacy fields and environment-backed accounts", () => { + const legacy = { + enabled: true, + brokerUrl: "mqtt://legacy", + username: "user", + password: "secret", + clientId: "client", + topics: { inbound: "in", outbound: "out" }, + qos: 2, + disableBlockStreaming: true, + tls: { enabled: false } + }; + expect(getMqttAccounts(channelConfig(legacy))).toEqual({ default: legacy }); + expect(getMqttAccounts(channelConfig({ unrelated: true }))).toEqual({}); + + withEnvironment({ MQTT_BROKER_URL: " mqtts://broker " }, () => { + expect(getMqttAccounts({})).toEqual({ default: { enabled: true, brokerUrl: "mqtts://broker" } }); + expect(hasConfiguredMqttEnv()).toBeTrue(); + expect(getMqttConfiguredState({})).toBe("configured"); + }); + + withEnvironment({ + MQTT_BROKER_URL: "mqtts://broker", + MQTT_USERNAME: " user ", + MQTT_PASSWORD: "secret", + MQTT_CLIENT_ID: " client ", + MQTT_CA_PATH: " ca.pem " + }, () => { + expect(getMqttAccounts({}).default).toEqual({ + enabled: true, + brokerUrl: "mqtts://broker", + username: "user", + password: "secret", + clientId: "client", + tls: { enabled: true, rejectUnauthorized: true, ca: "ca.pem" } + }); + }); + + withEnvironment({ MQTT_BROKER_URL: "mqtt://broker", MQTT_CA_PATH: "ca.pem" }, () => { + expect(getMqttAccounts({}).default.tls?.enabled).toBeFalse(); + }); + + withEnvironment({ MQTT_BROKER_URL: " " }, () => { + expect(hasConfiguredMqttEnv()).toBeFalse(); + expect(getMqttConfiguredState({})).toBe("unconfigured"); + }); + withEnvironment({}, () => { + expect(getMqttConfiguredState(channelConfig({ brokerUrl: "mqtt://legacy" }))).toBe("configured"); + }); + }); + + it("merges channel config with environment overrides and TLS defaults", () => { + withEnvironment({}, () => { + expect(mergeWithEnv({})).toEqual({ + enabled: true, + brokerUrl: "", + username: undefined, + password: undefined, + clientId: undefined, + topics: { inbound: "openclaw/inbound", outbound: "openclaw/outbound" }, + disableBlockStreaming: false, + qos: 1, + tls: undefined + }); + expect(mergeWithEnv({ tls: { enabled: true, ca: "config-ca" } }).tls?.ca).toBe("config-ca"); + }); + + withEnvironment({ + MQTT_BROKER_URL: "mqtt://env", + MQTT_USERNAME: "env-user", + MQTT_PASSWORD: "env-secret", + MQTT_CLIENT_ID: "env-client", + MQTT_CA_PATH: "env-ca" + }, () => { + expect(mergeWithEnv({ + enabled: false, + brokerUrl: "mqtt://config", + username: "config-user", + password: "config-secret", + clientId: "config-client", + topics: { inbound: "custom/in", outbound: "custom/out" }, + disableBlockStreaming: true, + qos: 2, + tls: { enabled: true, ca: "config-ca" } + })).toEqual(jasmine.objectContaining({ + enabled: false, + brokerUrl: "mqtt://env", + username: "env-user", + password: "env-secret", + clientId: "env-client", + topics: { inbound: "custom/in", outbound: "custom/out" }, + disableBlockStreaming: true, + qos: 2, + tls: { enabled: true, ca: "env-ca" } + })); + }); + }); + + it("executes channel-base setup and account callbacks", () => { + const cfg = channelConfig({ accounts: { default: { brokerUrl: "mqtt://broker" } } }); + expect(mqttChannelBase.config?.isEnabled?.({ enabled: true } as any)).toBeTrue(); + expect(mqttChannelBase.config?.isEnabled?.({ enabled: false } as any)).toBeFalse(); + expect(mqttChannelBase.config?.isConfigured?.({ brokerUrl: "mqtt://broker" } as any)).toBeTrue(); + expect(mqttChannelBase.config?.isConfigured?.({} as any)).toBeFalse(); + expect(mqttChannelBase.setup?.resolveAccountId?.({ accountId: null } as any)).toBe("default"); + expect(mqttChannelBase.setup?.applyAccountConfig?.({ + cfg, + accountId: "default", + input: { brokerUrl: "mqtt://next" } + } as any)).toBeDefined(); + }); + + it("normalizes setup input and applies single or multiple account configs", () => { + expect(normalizeMqttAccountId()).toBe("default"); + expect(normalizeMqttAccountId(null)).toBe("default"); + expect(normalizeMqttAccountId(" ")).toBe("default"); + expect(normalizeMqttAccountId(" account ")).toBe("account"); + + expect(buildMqttAccountConfig({})).toEqual({ + enabled: true, + brokerUrl: "", + topics: { inbound: "openclaw/inbound", outbound: "openclaw/outbound" }, + qos: 1, + disableBlockStreaming: false + }); + expect(buildMqttAccountConfig({ + brokerUrl: " mqtts://broker ", + username: " user ", + password: "secret", + clientId: " client ", + inboundTopic: " custom/in ", + outboundTopic: " custom/out ", + qos: 0, + disableBlockStreaming: true, + rejectUnauthorized: false, + ca: " ca.pem " + })).toEqual({ + enabled: true, + brokerUrl: "mqtts://broker", + username: "user", + password: "secret", + clientId: "client", + topics: { inbound: "custom/in", outbound: "custom/out" }, + qos: 0, + disableBlockStreaming: true, + tls: { enabled: true, rejectUnauthorized: false, ca: "ca.pem" } + }); + expect(buildMqttAccountConfig({ + brokerUrl: "mqtt://broker", + password: "", + inbound: "legacy/in", + outbound: "legacy/out", + qos: 2, + tlsEnabled: true + })).toEqual(jasmine.objectContaining({ + brokerUrl: "mqtt://broker", + topics: { inbound: "legacy/in", outbound: "legacy/out" }, + qos: 2, + tls: { enabled: true, rejectUnauthorized: true } + })); + expect(buildMqttAccountConfig({ password: 123, qos: 9 }).password).toBeUndefined(); + + const initial = applyMqttSetupAccountConfig({ + cfg: {} as any, + accountId: " ", + input: { brokerUrl: "mqtt://default" } + }); + expect((initial.channels as any)["daedalus-agent-mqtt-channel"].accounts.default.brokerUrl).toBe("mqtt://default"); + + const extended = applyMqttSetupAccountConfig({ + cfg: channelConfig({ accounts: { default: { brokerUrl: "mqtt://default" } } }) as any, + accountId: " secondary ", + input: { brokerUrl: "mqtt://secondary" } + }); + expect((extended.channels as any)["daedalus-agent-mqtt-channel"].accounts).toEqual(jasmine.objectContaining({ + default: { brokerUrl: "mqtt://default" }, + secondary: jasmine.objectContaining({ brokerUrl: "mqtt://secondary" }) + })); + }); +}); + +describe("MQTT chat logging contracts", () => { + it("normalizes fields and honors explicit, root, data, kernel, and cwd log directories", () => { + const fixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "mqtt-chat-log-contract-")); + const originalDirectory = process.cwd(); + try { + const cases = [ + [{ DA_MQTT_CHANNEL_LOG_DIR: path.join(fixtureDirectory, "channel") }, path.join(fixtureDirectory, "channel")], + [{ DA_LOG_DIR: path.join(fixtureDirectory, "log") }, path.join(fixtureDirectory, "log")], + [{ DA_ROOT: path.join(fixtureDirectory, "root") }, path.join(fixtureDirectory, "root", "logs")], + [{ DA_DATA_DIR: path.join(fixtureDirectory, "data-parent", "data") }, path.join(fixtureDirectory, "data-parent", "logs")], + [{ DA_KERNEL_DATA_DIR: path.join(fixtureDirectory, "kernel-parent", "data") }, path.join(fixtureDirectory, "kernel-parent", "logs")] + ] as const; + + for (const [environment, expectedDirectory] of cases) { + withEnvironment(environment, () => writeMqttFunctionalLog("info", "line\nvalue")); + expect(readEntries(expectedDirectory, "daedalus-agent-mqtt-channel-")[0].message).toBe("line value"); + } + + process.chdir(fixtureDirectory); + withEnvironment({}, () => writeMqttChatLog({ + sender: " \r\n ", + session: "", + direction: "IN", + text: "hello\r\nworld", + queue: { sessionKey: "\n", depth: -1.2 } + })); + const entry = readEntries(path.join(fixtureDirectory, "logs"), "daedalus-agent-mqtt-channel-chat-")[0]; + expect(entry).toEqual(jasmine.objectContaining({ + mqtt: { sender: "-", session: "-1", direction: "IN" }, + queue: { sessionKey: "-", depth: 0 }, + message: "hello world" + })); + + withEnvironment({ DA_MQTT_CHANNEL_LOG_DIR: path.join(fixtureDirectory, "queue") }, () => { + writeMqttChatLog({ + sender: "sender", + session: "session", + direction: "OUT", + text: "text", + queue: { sessionKey: "key", depth: 2.9 } + }); + writeMqttChatLog({ sender: "sender", session: "session", direction: "OUT", text: "text" }); + }); + const queueEntries = readEntries(path.join(fixtureDirectory, "queue"), "daedalus-agent-mqtt-channel-chat-"); + expect(queueEntries[0].queue.depth).toBe(2); + expect(queueEntries[1].queue).toBeUndefined(); + } finally { + process.chdir(originalDirectory); + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } + }); + + it("falls back to OpenClaw home and tolerates fully unwritable targets", () => { + const fixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "mqtt-chat-log-fallback-")); + const blocked = path.join(fixtureDirectory, "blocked"); + const fallbackHome = path.join(fixtureDirectory, "home"); + fs.writeFileSync(blocked, "file", "utf8"); + + try { + withEnvironment({ DA_MQTT_CHANNEL_LOG_DIR: blocked, OPENCLAW_HOME: fallbackHome }, () => { + writeMqttFunctionalLog("warn", "fallback"); + }); + expect(readEntries(path.join(fallbackHome, "logs"), "daedalus-agent-mqtt-channel-")[0].message).toBe("fallback"); + + withEnvironment({ DA_MQTT_CHANNEL_LOG_DIR: blocked, DA_OPENCLAW_HOME: blocked }, () => { + expect(() => writeMqttFunctionalLog("error", "ignored")).not.toThrow(); + }); + withEnvironment({ DA_MQTT_CHANNEL_LOG_DIR: blocked }, () => { + expect(() => writeMqttFunctionalLog("error", "ignored")).not.toThrow(); + }); + } finally { + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } + }); +}); + +it("stores and returns the MQTT plugin runtime", async () => { + const isolatedRuntime = await import(`./runtime.js?coverage=${Date.now()}`); + expect(() => isolatedRuntime.getMqttRuntime()).toThrowError("MQTT runtime not initialized"); + + const value = { channel: {} } as any; + setMqttRuntime(value); + expect(getMqttRuntime()).toBe(value); +}); diff --git a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/setup-config.ts b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/setup-config.ts index ad0101889adb6ce10c3863d6ca1dbbfec76394b4..815ce746ae65815aa335943d2e82742b592d7efa 100644 --- a/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/setup-config.ts +++ b/extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/setup-config.ts @@ -1,7 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/channel-core"; import { - DEFAULT_DISABLE_BLOCK_STREAMING, DEFAULT_INBOUND_TOPIC, DEFAULT_OUTBOUND_TOPIC, DEFAULT_QOS, @@ -53,7 +52,7 @@ export function buildMqttAccountConfig(input: MqttSetupInput) { outbound, }, qos, - disableBlockStreaming: disableBlockStreaming ?? DEFAULT_DISABLE_BLOCK_STREAMING, + disableBlockStreaming, ...(tlsEnabled ? { tls: { diff --git a/packages/daedalus-agent-apps-invoke-cli/package-lock.json b/packages/daedalus-agent-apps-invoke-cli/package-lock.json index 59355ad7b30cb087ec31b66474a7e1bed47795e5..4cc0f2dab248cc9dadd56ffbdc6e0620e828da61 100644 --- a/packages/daedalus-agent-apps-invoke-cli/package-lock.json +++ b/packages/daedalus-agent-apps-invoke-cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "daedalus-agent-apps-invoke-cli", - "version": "0.1.2", + "version": "1.0.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daedalus-agent-apps-invoke-cli", - "version": "0.1.2", + "version": "1.0.3", "license": "MIT", "dependencies": { "dayjs": "^1.11.21", @@ -19,6 +19,7 @@ "devDependencies": { "@types/jasmine": "^5.1.13", "@types/node": "25.8.0", + "c8": "^12.0.0", "jasmine": "^5.12.0", "rimraf": "6.1.3", "tsx": "^4.22.0", @@ -28,6 +29,16 @@ "node": ">=20" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", @@ -488,6 +499,44 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@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.npmjs.org/@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.npmjs.org/@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/@pinojs/redact": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", @@ -505,6 +554,13 @@ "node": ">=14" } }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jasmine": { "version": "5.1.15", "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", @@ -580,6 +636,98 @@ "node": "18 || 20 || >=22" } }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "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/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "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/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -600,6 +748,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -677,6 +832,33 @@ "@esbuild/win32-x64": "0.28.0" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -709,6 +891,29 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -727,6 +932,23 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/huozige-app-integration": { "version": "0.1.5", "resolved": "https://registry.npmjs.org/huozige-app-integration/-/huozige-app-integration-0.1.5.tgz", @@ -753,6 +975,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -862,6 +1123,22 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -869,6 +1146,22 @@ "dev": true, "license": "ISC" }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -904,6 +1197,38 @@ "node": ">=14.0.0" } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -911,6 +1236,16 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -1045,6 +1380,19 @@ "node": ">=10" } }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -1203,6 +1551,34 @@ "node": ">=8" } }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -1261,6 +1637,21 @@ "dev": true, "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -1374,6 +1765,84 @@ "engines": { "node": ">=8" } + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/packages/daedalus-agent-apps-invoke-cli/package.json b/packages/daedalus-agent-apps-invoke-cli/package.json index 38c61947e22375f8a34f98b34f1166bd082802ef..34100e34cbb3296c47d2dc940e025815ab062e4e 100644 --- a/packages/daedalus-agent-apps-invoke-cli/package.json +++ b/packages/daedalus-agent-apps-invoke-cli/package.json @@ -42,7 +42,9 @@ "build": "npm run clean && tsc -p tsconfig.build.json", "prepare": "npm run build", "prepublishOnly": "npm test && npm run build", - "test": "tsx --tsconfig tsconfig.spec.json test/run-jasmine.ts" + "test": "tsx --tsconfig tsconfig.spec.json test/run-jasmine.ts", + "coverage": "c8 --all --extension .ts --include \"src/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/index.ts\" --exclude \"src/proxy/index.ts\" --exclude \"src/proxy/types.ts\" --reporter text --reporter json-summary --reports-dir \"../../coverage/cli\" npm test", + "coverage:strict": "c8 --all --extension .ts --include \"src/**/*.ts\" --exclude \"src/**/*.d.ts\" --exclude \"src/index.ts\" --exclude \"src/proxy/index.ts\" --exclude \"src/proxy/types.ts\" --check-coverage --branches 100 --statements 0 --functions 0 --lines 0 --reporter text --reporter json-summary --reports-dir \"../../coverage/cli\" npm test" }, "engines": { "node": ">=20" @@ -55,6 +57,7 @@ "devDependencies": { "@types/jasmine": "^5.1.13", "@types/node": "25.8.0", + "c8": "^12.0.0", "jasmine": "^5.12.0", "rimraf": "6.1.3", "tsx": "^4.22.0", diff --git a/packages/daedalus-agent-apps-invoke-cli/src/proxy/handler.ts b/packages/daedalus-agent-apps-invoke-cli/src/proxy/handler.ts index 78f31bb1763b7e4a89ff2d0a330ea9039a52d221..5dc4763785c75e57733ca0b3c7ddbc41945a5d50 100644 --- a/packages/daedalus-agent-apps-invoke-cli/src/proxy/handler.ts +++ b/packages/daedalus-agent-apps-invoke-cli/src/proxy/handler.ts @@ -185,8 +185,12 @@ export function createAppInvokeHandler( if (!applicationName) { throw badRequest("applicationName is required"); } + const sessionId = String(request.sessionId || "").trim(); + if (!sessionId) { + throw badRequest("sessionId is required"); + } - system = await webApiClient.findSystemForInvocation(applicationName); + system = await webApiClient.findSystemForInvocation(applicationName, sessionId); if (!system) { throw notFound(`Business system not found: ${applicationName}`); } @@ -207,10 +211,11 @@ export function createAppInvokeHandler( }); return response; } catch (error: unknown) { + const errorMessage = resolveErrorMessage(error, "App invoke failed"); response = newErrorResponse( request.traceId || randomUUID(), resolveErrorStatusCode(error), - resolveErrorMessage(error, "App invoke failed") + errorMessage ); await recordInvocationSafely({ trace_id: request.traceId, @@ -223,7 +228,7 @@ export function createAppInvokeHandler( response_payload: response, response_time_ms: Date.now() - startedAt, success: false, - error_message: response.message ?? "App invoke failed" + error_message: errorMessage }); return response; } diff --git a/packages/daedalus-agent-apps-invoke-cli/src/proxy/web-api-client.ts b/packages/daedalus-agent-apps-invoke-cli/src/proxy/web-api-client.ts index 9172af14c1ddcdbee48daadb76da17f61b9b2764..63d278dd3ecef11cd2f2aafcf1c908a9567bb68f 100644 --- a/packages/daedalus-agent-apps-invoke-cli/src/proxy/web-api-client.ts +++ b/packages/daedalus-agent-apps-invoke-cli/src/proxy/web-api-client.ts @@ -1,4 +1,4 @@ -import { notFound, serviceUnavailable } from "./errors.js"; +import { notFound, ServiceError } from "./errors.js"; import type { BusinessSystemRecord, ExternalCookieResponse, @@ -25,8 +25,9 @@ export class WebApiClient { this.baseUrl = options.baseUrl.replace(/\/+$/, ""); } - public async findSystemForInvocation(applicationName: string): Promise { - const url = `${this.baseUrl}/api/internal/app-invoke/system?applicationName=${encodeURIComponent(applicationName)}`; + public async findSystemForInvocation(applicationName: string, sessionId: string): Promise { + const params = new URLSearchParams({ sessionId, applicationName }); + const url = `${this.baseUrl}/api/internal/app-invoke/system?${params.toString()}`; const response = await fetch(url, { method: "GET", headers: this.buildHeaders() @@ -93,7 +94,7 @@ export class WebApiClient { return notFound(message, details); } - return serviceUnavailable(message, details); + return new ServiceError(response.status, message, details); } private async readErrorDetails(response: Response, url: string): Promise { diff --git a/packages/daedalus-agent-apps-invoke-cli/test/client-socket-errors.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/client-socket-errors.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..25f61484484e644b1cb3bb89d3f4e5dc3b2d2188 --- /dev/null +++ b/packages/daedalus-agent-apps-invoke-cli/test/client-socket-errors.test.ts @@ -0,0 +1,133 @@ +import assert from "node:assert/strict"; +import { createServer, type Socket } from "node:net"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { AppInvokeSocketClient } from "../src/client/socket-client.js"; +import { normalizeIpcPath } from "../src/proxy/ipc-path.js"; + +async function withSocketServer( + onConnection: (socket: import("node:net").Socket) => void +): Promise<{ socketPath: string; close: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "daedalus-agent-socket-errors-")); + const socketPath = join(directory, "invoke.sock"); + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + onConnection(socket); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(normalizeIpcPath(socketPath), () => { + server.off("error", reject); + resolve(); + }); + }); + return { + socketPath, + close: async () => { + for (const socket of sockets) { + socket.destroy(); + } + await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } + }; +} + +function sendRequest(socketPath: string, timeoutMs = 200): Promise { + return new AppInvokeSocketClient(socketPath).sendRequest( + "servercommand", + "session-1", + { applicationName: "demo", commandName: "list", method: "GET" }, + timeoutMs + ); +} + +test("socket client accepts a response split across chunks", async () => { + const server = await withSocketServer((socket) => { + socket.setEncoding("utf8"); + socket.on("data", () => { + socket.write('{"code":0,'); + setTimeout(() => socket.end('"message":"ok"}\n'), 5); + }); + }); + try { + const response = await sendRequest(server.socketPath); + assert.deepEqual(response, { code: 0, message: "ok" }); + } finally { + await server.close(); + } +}); + +test("socket client rejects malformed response payloads", async () => { + const server = await withSocketServer((socket) => { + socket.on("data", () => socket.end("not-json\n")); + }); + try { + await assert.rejects(() => sendRequest(server.socketPath), /failed to unmarshal response/); + } finally { + await server.close(); + } +}); + +test("socket client rejects when server closes before a response", async () => { + const server = await withSocketServer((socket) => { + socket.on("data", () => socket.end()); + }); + try { + await assert.rejects( + () => sendRequest(server.socketPath), + /closed the socket before sending a response/ + ); + } finally { + await server.close(); + } +}); + +test("socket client rejects request timeout when server stays silent", async () => { + const server = await withSocketServer((socket) => { + socket.on("end", () => socket.destroy()); + }); + try { + await assert.rejects(() => sendRequest(server.socketPath, 20), /request timeout after 20ms/); + } finally { + await server.close(); + } +}); + +test("socket client reports connection errors", async () => { + const missing = join(tmpdir(), `daedalus-agent-missing-${Date.now()}-${Math.random()}.sock`); + await assert.rejects(() => sendRequest(missing), /failed to connect app invoke service/); +}); + +test("socket client enforces configured request size limit", async () => { + const previous = process.env.DA_APP_INVOKE_MAX_PAYLOAD_BYTES; + process.env.DA_APP_INVOKE_MAX_PAYLOAD_BYTES = "10"; + try { + await assert.rejects( + () => sendRequest("unused-socket"), + /app invoke request payload is too large/ + ); + } finally { + if (previous === undefined) delete process.env.DA_APP_INVOKE_MAX_PAYLOAD_BYTES; + else process.env.DA_APP_INVOKE_MAX_PAYLOAD_BYTES = previous; + } +}); + +test("socket client reports request serialization failures", async () => { + const client = new AppInvokeSocketClient("unused-socket"); + const circular: Record = {}; + circular.self = circular; + await assert.rejects( + () => client.sendRequest("binding", "session-1", { + applicationName: "demo", + commandName: "tablebinding", + method: "POST", + body: circular + }, 100), + /failed to marshal request/ + ); +}); diff --git a/packages/daedalus-agent-apps-invoke-cli/test/commands-root-gaps.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/commands-root-gaps.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..99186c54610fbd52d392bbfe3fb10626b5d3548d --- /dev/null +++ b/packages/daedalus-agent-apps-invoke-cli/test/commands-root-gaps.test.ts @@ -0,0 +1,328 @@ +import assert from "node:assert/strict"; +import { createServer, type Socket } from "node:net"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +import { + executeBindingCommand, + executeServerCommand, + parseJSONStringMap, + runCli, + sendCommand +} from "../src/commands/root.js"; +import { DEFAULT_INVOKE_SOCKET_PATH } from "../src/config/config.js"; +import { normalizeIpcPath } from "../src/proxy/ipc-path.js"; + +const config = (socketPath: string) => ({ + socketPath, + webBaseUrl: "http://127.0.0.1:3030" +}); + +async function createResponseServer( + response: unknown, + requests: string[] = [], + requestedSocketPath?: string +): Promise<{ socketPath: string; close: () => Promise }> { + const directory = await mkdtemp(join(tmpdir(), "daedalus-agent-root-command-")); + const socketPath = requestedSocketPath || join(directory, "invoke.sock"); + const sockets = new Set(); + const server = createServer((socket) => { + sockets.add(socket); + socket.once("close", () => sockets.delete(socket)); + socket.setEncoding("utf8"); + let buffer = ""; + socket.on("data", (chunk) => { + buffer += chunk; + if (!buffer.includes("\n")) return; + requests.push(buffer.trim()); + socket.end(`${JSON.stringify(response)}\n`); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(normalizeIpcPath(socketPath), () => { + server.off("error", reject); + resolve(); + }); + }); + return { + socketPath, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + await rm(directory, { recursive: true, force: true }); + } + }; +} + +test("command layer sends GET query and POST body payloads", async () => { + const requests: string[] = []; + const server = await createResponseServer({ code: 0, data: { accepted: true } }, requests); + const stdout: string[] = []; + try { + await executeServerCommand( + config(server.socketPath), + ["demo", "search", "GET", '{"q":"one"}'], + "session-get", + {}, + { stdout: (value) => stdout.push(value), stderr: () => undefined } + ); + await executeServerCommand( + config(server.socketPath), + ["demo", "create", "POST", '{"value":1}'], + "session-post", + {}, + { stdout: (value) => stdout.push(value), stderr: () => undefined } + ); + const parsed = requests.map((request) => JSON.parse(request)); + assert.equal(parsed[0].parameters.query.q, "one"); + assert.deepEqual(parsed[1].parameters.body, { value: 1 }); + assert.match(stdout.join(""), /accepted/); + } finally { + await server.close(); + } +}); + +test("command layer supports body files and binding requests", async () => { + const directory = await mkdtemp(join(tmpdir(), "daedalus-agent-root-body-")); + const bodyFile = join(directory, "body.json"); + await writeFile(bodyFile, '{"CommandId":"cmd-1"}', "utf8"); + const requests: string[] = []; + const server = await createResponseServer({ code: 0, data: { ok: true } }, requests); + try { + await executeBindingCommand( + config(server.socketPath), + ["demo", "TableBinding", "POST"], + "session-binding", + { bodyFile } as never, + { stdout: () => undefined, stderr: () => undefined } + ); + const request = JSON.parse(requests[0]); + assert.equal(request.command, "binding"); + assert.deepEqual(request.parameters.body, { CommandId: "cmd-1" }); + } finally { + await server.close(); + await rm(directory, { recursive: true, force: true }); + } +}); + +test("command layer rejects conflicting sources and invalid methods", async () => { + const noServer = config(join(tmpdir(), "unused-command.sock")); + await assert.rejects( + () => executeServerCommand(noServer, ["demo", "cmd", "GET"], "s", { bodyFile: "body" } as never), + /GET servercommand accepts query sources only/ + ); + await assert.rejects( + () => executeServerCommand(noServer, ["demo", "cmd", "POST"], "s", { queryStdin: true } as never), + /POST servercommand accepts body sources only/ + ); + await assert.rejects( + () => executeServerCommand(noServer, ["demo", "cmd", "PATCH"], "s"), + /httpType must be GET or POST/ + ); + await assert.rejects( + () => executeBindingCommand(noServer, ["demo", "TableBinding", "POST", "{}"], "s", { queryFile: "query" } as never), + /binding accepts body sources only/ + ); +}); + +test("sendCommand reports provider error codes and omits null data", async () => { + const errorServer = await createResponseServer({ code: 7, message: "provider failed" }); + try { + await assert.rejects( + () => sendCommand(config(errorServer.socketPath), "binding", "s", { + applicationName: "demo", + commandName: "TableBinding", + method: "POST", + body: {} + }), + (error: unknown) => { + assert.equal((error as { exitCode?: number }).exitCode, 7); + assert.equal((error as Error).message, "provider failed"); + return true; + } + ); + } finally { + await errorServer.close(); + } + + const emptyServer = await createResponseServer({ code: 0, data: null }); + try { + const stdout: string[] = []; + await sendCommand(config(emptyServer.socketPath), "binding", "s", { + applicationName: "demo", + commandName: "TableBinding", + method: "POST", + body: {} + }, { stdout: (value) => stdout.push(value), stderr: () => undefined }); + assert.deepEqual(stdout, []); + } finally { + await emptyServer.close(); + } +}); + +test("CLI renders root and command help variants", async () => { + for (const argv of [ + [], + ["--help"], + ["help"], + ["help", "binding"], + ["help", "status"], + ["help", "serve"], + ["help", "service-status"], + ["help", "unknown"], + ["servercommand", "--help"], + ["servercommand", "-h"], + ["binding", "--help"], + ["binding", "-h"], + ["status", "--help"], + ["status", "-h"], + ["serve", "--help"], + ["serve", "-h"], + ["service-status", "--help"], + ["service-status", "-h"] + ]) { + const stdout: string[] = []; + const code = await runCli(argv, { stdout: (value) => stdout.push(value), stderr: () => undefined }); + assert.equal(code, 0); + assert.match(stdout.join(""), /DAEDALUS_AGENT apps invoke CLI|Usage:/); + } +}); + +test("CLI status succeeds and invalid command shapes return exit code one", async () => { + const stdout: string[] = []; + const stderr: string[] = []; + assert.equal(await runCli(["status"], { + stdout: (value) => stdout.push(value), + stderr: (value) => stderr.push(value) + }), 0); + assert.match(stdout.join(""), /Configuration status/); + + for (const argv of [ + ["status", "extra"], + ["service-status", "extra"], + ["unknown-command"], + ["binding", "--body-file"], + ["binding", "--body-stdin"] + ]) { + assert.equal(await runCli(argv, { + stdout: () => undefined, + stderr: (value) => stderr.push(value) + }), 1); + } + assert.match(stderr.join(""), /unknown command|flag needs an argument|accepts 0 arg/); +}); + +test("CLI executes server and binding commands through the default socket path", async () => { + const requests: string[] = []; + const server = await createResponseServer( + { code: 0, data: { ok: true } }, + requests, + DEFAULT_INVOKE_SOCKET_PATH + ); + try { + assert.equal(await runCli([ + "servercommand", "demo", "search", "GET", "{}", "-s", "session-server" + ], { stdout: () => undefined, stderr: () => undefined }), 0); + assert.equal(await runCli([ + "binding", "demo", "TableBinding", "POST", "{}", "-s", "session-binding" + ], { stdout: () => undefined, stderr: () => undefined }), 0); + assert.equal(requests.length, 2); + } finally { + await server.close(); + } + + const errorServer = await createResponseServer( + { code: 9, message: "provider error" }, + [], + DEFAULT_INVOKE_SOCKET_PATH + ); + try { + assert.equal(await runCli([ + "binding", "demo", "TableBinding", "POST", "{}", "-s", "session-error" + ], { stdout: () => undefined, stderr: () => undefined }), 9); + } finally { + await errorServer.close(); + } +}); + +test("binding aliases and validation gaps are exercised", async () => { + const requests: string[] = []; + const server = await createResponseServer({ code: 0, data: { ok: true } }, requests); + try { + for (const commandName of [ + "CandidatesBinding", + "CandidatesBindings", + "GetComboBindingOptions", + "GetMetadata", + "GetMetadata2", + "CalcBindingDataSource" + ]) { + await executeBindingCommand( + config(server.socketPath), + ["demo", commandName, "POST", "{}"], + "session-alias", + {}, + { stdout: () => undefined, stderr: () => undefined } + ); + } + const normalizedNames = requests.map((value) => JSON.parse(value).parameters.commandName); + assert.deepEqual(normalizedNames, [ + "GetComboBindingOptions", + "GetComboBindingOptions", + "GetComboBindingOptions", + "GetMetadata", + "GetMetadata2", + "CalcBindingDataSource" + ]); + } finally { + await server.close(); + } + + const noServer = config(join(tmpdir(), "unused-binding-validation.sock")); + await assert.rejects( + () => executeServerCommand(noServer, ["demo", "cmd"], "session"), + /usage:/ + ); + await assert.rejects( + () => executeBindingCommand(noServer, ["demo", "TableBinding", " "], "session"), + /method is required/ + ); + await assert.rejects( + () => executeBindingCommand( + noServer, + ["demo", "TableBinding", "POST", "{}"], + "session", + { bodyFile: "body.json" } as never + ), + /accepts only one source/ + ); +}); + +test("sendCommand normalizes missing messages and non-positive provider codes", async () => { + const server = await createResponseServer({ code: -5 }); + try { + await assert.rejects( + () => sendCommand(config(server.socketPath), "binding", "session", { + applicationName: "demo", + commandName: "TableBinding", + method: "POST", + body: {} + }), + (error: unknown) => { + assert.equal((error as { exitCode?: number }).exitCode, 1); + assert.equal((error as Error).message, ""); + return true; + } + ); + } finally { + await server.close(); + } +}); + +test("query maps reject malformed JSON and non-object values", () => { + assert.throws(() => parseJSONStringMap("{", "query"), /invalid JSON param for query/); + assert.throws(() => parseJSONStringMap("[]", "query"), /expected JSON object/); + assert.throws(() => parseJSONStringMap("null", "query"), /expected JSON object/); +}); diff --git a/packages/daedalus-agent-apps-invoke-cli/test/protocol-and-time.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/protocol-and-time.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0604905c301349010dd968eb0dcc3a45b10a065b --- /dev/null +++ b/packages/daedalus-agent-apps-invoke-cli/test/protocol-and-time.test.ts @@ -0,0 +1,97 @@ +import assert from "node:assert/strict"; + +import { + buildRequestEnvelope, + isWrappedResponse, + newErrorResponse, + newSuccessResponse, + parseResponsePayload +} from "../src/protocol/messages.js"; +import { toChinaISOString } from "../src/proxy/china-time.js"; +import { isWindowsPipePath, normalizeIpcPath } from "../src/proxy/ipc-path.js"; +import { + newErrorResponse as newProxyErrorResponse, + newSuccessResponse as newProxySuccessResponse +} from "../src/proxy/protocol.js"; + +function withPlatform(platform: NodeJS.Platform, action: () => void): void { + const descriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { configurable: true, value: platform }); + try { + action(); + } finally { + if (descriptor) { + Object.defineProperty(process, "platform", descriptor); + } + } +} + +test("formats China timestamps from dates, scalar values, and defaults", () => { + assert.equal(toChinaISOString(new Date("2026-01-02T03:04:05.006Z")), "2026-01-02T11:04:05.006+08:00"); + assert.equal(toChinaISOString("2026-12-31T20:00:00.000Z"), "2027-01-01T04:00:00.000+08:00"); + assert.equal(toChinaISOString(0), "1970-01-01T08:00:00.000+08:00"); + assert.match(toChinaISOString(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+08:00$/); + assert.throws(() => toChinaISOString("invalid"), /Invalid date value/); +}); + +test("normalizes IPC paths on Windows and preserves Unix paths", () => { + withPlatform("linux", () => { + assert.equal(normalizeIpcPath("/tmp/app.sock"), "/tmp/app.sock"); + }); + + withPlatform("win32", () => { + assert.equal(normalizeIpcPath("\\\\.\\pipe\\existing"), "\\\\.\\pipe\\existing"); + assert.equal(normalizeIpcPath("C:\\run folder\\app.sock"), "\\\\.\\pipe\\run-folder-app.sock"); + assert.equal(normalizeIpcPath(""), "\\\\.\\pipe\\app-invoke"); + }); + + assert.equal(isWindowsPipePath("\\\\.\\pipe\\named"), true); + assert.equal(isWindowsPipePath("\\\\?\\pipe\\named"), true); + assert.equal(isWindowsPipePath("/tmp/app.sock"), false); +}); + +test("builds CLI and proxy response envelopes", () => { + assert.deepEqual(newSuccessResponse({ ok: true }), { code: 0, data: { ok: true } }); + assert.deepEqual(newErrorResponse(400, "bad"), { code: 400, message: "bad" }); + assert.deepEqual(newProxySuccessResponse("trace-1", 1), { traceId: "trace-1", code: 0, data: 1 }); + assert.deepEqual(newProxyErrorResponse("trace-2", 503, "down"), { + traceId: "trace-2", + code: 503, + message: "down" + }); + assert.deepEqual(buildRequestEnvelope({ + traceId: "trace-1", + command: "binding", + sessionId: "session-1", + parameters: { key: "value" }, + timestamp: 123 + }), { + traceId: "trace-1", + command: "binding", + sessionId: "session-1", + parameters: { key: "value" }, + timestamp: 123 + }); +}); + +test("detects every supported wrapped response marker", () => { + for (const key of ["code", "data", "message", "traceId", "correlationId"]) { + assert.equal(isWrappedResponse({ [key]: key === "code" ? 0 : "value" }), true); + } + assert.equal(isWrappedResponse({ value: 1 }), false); +}); + +test("parses wrapped and raw JSON response payloads", () => { + assert.throws(() => parseResponsePayload(" "), /empty payload/); + assert.deepEqual(parseResponsePayload(Buffer.from('{"code":201,"message":"ok"}')), { + code: 201, + message: "ok" + }); + assert.deepEqual(parseResponsePayload(new TextEncoder().encode('{"data":{"ok":true}}')), { + code: 0, + data: { ok: true } + }); + assert.deepEqual(parseResponsePayload('{"value":1}'), { code: 0, data: { value: 1 } }); + assert.deepEqual(parseResponsePayload("[1,2]"), { code: 0, data: [1, 2] }); + assert.throws(() => parseResponsePayload("not-json"), SyntaxError); +}); diff --git a/packages/daedalus-agent-apps-invoke-cli/test/proxy/handler.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/proxy/handler.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d16575cbe0b1ba7a54c235e873fbce2f7f90f46c --- /dev/null +++ b/packages/daedalus-agent-apps-invoke-cli/test/proxy/handler.test.ts @@ -0,0 +1,268 @@ +import { ServiceError } from "../../src/proxy/errors.js"; +import { + createAppInvokeHandler, + normalizeRequestPayload, + parseRequestPayload +} from "../../src/proxy/handler.js"; + +function request(overrides: Record = {}) { + return { + traceId: "trace-1", + command: "servercommand", + sessionId: "session-1", + AgentName: "agent-1", + parameters: { + applicationName: " app ", + commandName: " command ", + method: "get", + query: { key: "value" } + }, + ...overrides + }; +} + +function expectBadRequest(payload: unknown, message: string): void { + expect(() => normalizeRequestPayload(payload)).toThrowError(message); +} + +test("normalizeRequestPayload rejects invalid envelopes and required fields", () => { + expectBadRequest(null, "Request payload must be a JSON object"); + expectBadRequest("payload", "Request payload must be a JSON object"); + expectBadRequest({}, "traceId is required"); + expectBadRequest({ traceId: 1 }, "traceId is required"); + expectBadRequest({ traceId: "trace", command: "unknown" }, "command must be one of servercommand,binding"); + expectBadRequest({ traceId: "trace", command: "servercommand" }, "applicationName is required"); + expectBadRequest(request({ parameters: { commandName: "command", method: "GET" } }), "applicationName is required"); + expectBadRequest(request({ parameters: { applicationName: "app", method: "GET" } }), "commandName is required"); + expectBadRequest(request({ parameters: { applicationName: "app", commandName: "command" } }), "servercommand method must be GET or POST"); + expectBadRequest(request({ parameters: { applicationName: "app", commandName: "command", method: "PATCH" } }), "servercommand method must be GET or POST"); +}); + +test("normalizeRequestPayload validates optional GET query objects", () => { + for (const query of [null, [], "query"]) { + expectBadRequest( + request({ parameters: { applicationName: "app", commandName: "command", method: "GET", query } }), + "servercommand query must be a JSON object" + ); + } + expectBadRequest( + request({ parameters: { applicationName: "app", commandName: "command", method: "GET", query: { count: 1 } } }), + 'servercommand query key "count" must map to string' + ); + + const withoutQuery = normalizeRequestPayload(request({ + parameters: { applicationName: "app", commandName: "command", method: "post" } + })); + expect(withoutQuery.parameters).toEqual({ applicationName: "app", commandName: "command", method: "POST" }); + + const withQuery = normalizeRequestPayload(request()); + expect(withQuery.parameters).toEqual({ + applicationName: "app", + commandName: "command", + method: "GET", + query: { key: "value" } + }); +}); + +test("normalizeRequestPayload normalizes supported binding commands and methods", () => { + const normalized = normalizeRequestPayload(request({ + command: "binding", + CurrentUser: "user", + timestamp: 1, + parameters: { applicationName: "app", commandName: "tablebinding" } + })); + expect(normalized).toEqual(jasmine.objectContaining({ + command: "binding", + CurrentUser: "user", + timestamp: 1, + parameters: { + applicationName: "app", + commandName: "GetTableDataWithOffset", + method: "POST" + } + })); + + const explicitMethod = normalizeRequestPayload(request({ + command: "binding", + parameters: { applicationName: "app", commandName: "candidatesbinding", method: "put" } + })); + expect(explicitMethod.parameters?.method).toBe("PUT"); + + expectBadRequest( + request({ command: "binding", parameters: { applicationName: "app", commandName: "unknown" } }), + "Unsupported binding command: unknown" + ); + expectBadRequest( + request({ command: "binding", parameters: { applicationName: "app", commandName: "getmetadata" } }), + "Unsupported binding command: GetMetadata" + ); +}); + +test("parseRequestPayload parses valid JSON and normalizes parse failures", () => { + const parsed = parseRequestPayload(Buffer.from(JSON.stringify(request()), "utf8")); + expect(parsed.parameters?.applicationName).toBe("app"); + + expect(() => parseRequestPayload(Buffer.from("{", "utf8"))).toThrowError(); + const invalidBuffer = { + toString() { + throw "not-an-error"; + } + } as Buffer; + expect(() => parseRequestPayload(invalidBuffer)).toThrowError("Invalid JSON payload"); +}); + +test("handler returns provider data and records a successful invocation", async () => { + const system = { + system_id: "system-1", + provider: "hzg" as const, + application_name: "app", + service_endpoint: "https://service", + session_endpoint: "https://session", + status: "active" + }; + const webApiClient = { + findSystemForInvocation: jasmine.createSpy().and.resolveTo(system), + recordInvocation: jasmine.createSpy().and.resolveTo(undefined), + getExternalCookie: jasmine.createSpy() + }; + const hzgProviderClient = { + execute: jasmine.createSpy().and.resolveTo({ data: { value: 1 } }) + }; + const handler = createAppInvokeHandler({ webApiClient, hzgProviderClient }, "http://unused"); + + const response = await handler.handleRequest(normalizeRequestPayload(request())); + + expect(response).toEqual({ traceId: "trace-1", code: 0, data: { value: 1 } }); + expect(webApiClient.findSystemForInvocation).toHaveBeenCalledWith("app", "session-1"); + expect(webApiClient.recordInvocation).toHaveBeenCalledWith(jasmine.objectContaining({ + sessionId: "session-1", + applicationName: "app", + commandName: "command", + method: "GET", + isOK: true, + errorMessage: undefined + })); +}); + +test("handler swallows logging failures after successful provider execution", async () => { + const webApiClient = { + findSystemForInvocation: jasmine.createSpy().and.resolveTo({ + system_id: "system-1", + provider: "hzg", + application_name: "app", + service_endpoint: "https://service", + session_endpoint: "https://session", + status: "active" + }), + recordInvocation: jasmine.createSpy().and.rejectWith(new Error("log failed")), + getExternalCookie: jasmine.createSpy() + }; + const handler = createAppInvokeHandler({ + webApiClient, + hzgProviderClient: { execute: jasmine.createSpy().and.resolveTo({ data: undefined }) } + }); + + await expectAsync(handler.handleRequest(normalizeRequestPayload(request()))).toBeResolvedTo({ + traceId: "trace-1", + code: 0, + data: undefined + }); +}); + +test("handler records empty optional fields for direct requests", async () => { + const recordInvocation = jasmine.createSpy().and.resolveTo(undefined); + const handler = createAppInvokeHandler({ + webApiClient: { + findSystemForInvocation: jasmine.createSpy().and.resolveTo({ + system_id: "system-1", + provider: "hzg", + application_name: "app", + service_endpoint: "https://service", + session_endpoint: "https://session", + status: "active" + }), + recordInvocation, + getExternalCookie: jasmine.createSpy() + }, + hzgProviderClient: { execute: jasmine.createSpy().and.resolveTo({ data: null }) } + }); + + await handler.handleRequest({ + traceId: "trace-1", + command: "servercommand", + sessionId: "session-1", + parameters: { applicationName: "app", commandName: "command" } + }); + + expect(recordInvocation).toHaveBeenCalledWith(jasmine.objectContaining({ + method: "", + isOK: true + })); +}); + +test("handler maps validation, missing system, and provider failures into responses and logs", async () => { + const recordInvocation = jasmine.createSpy().and.resolveTo(undefined); + const findSystemForInvocation = jasmine.createSpy(); + const execute = jasmine.createSpy(); + const handler = createAppInvokeHandler({ + webApiClient: { + findSystemForInvocation, + recordInvocation, + getExternalCookie: jasmine.createSpy() + }, + hzgProviderClient: { execute } + }); + + const missingApplication = await handler.handleRequest({ + traceId: "", + command: "servercommand", + parameters: {} + }); + expect(missingApplication.code).toBe(400); + expect(missingApplication.traceId).toMatch(/.+/); + + const missingSession = await handler.handleRequest(normalizeRequestPayload(request({ sessionId: " " }))); + expect(missingSession.code).toBe(400); + + const absentSession = await handler.handleRequest({ + traceId: "trace-1", + command: "servercommand", + parameters: { applicationName: "app", commandName: "command" } + }); + expect(absentSession.code).toBe(400); + + findSystemForInvocation.and.resolveTo(undefined); + const missingSystem = await handler.handleRequest(normalizeRequestPayload(request())); + expect(missingSystem.code).toBe(404); + + findSystemForInvocation.and.resolveTo({ + system_id: "system-1", + provider: "hzg", + application_name: "app", + service_endpoint: "https://service", + session_endpoint: "https://session", + status: "active" + }); + execute.and.rejectWith(new ServiceError(503, "provider unavailable")); + const providerFailure = await handler.handleRequest(normalizeRequestPayload(request())); + expect(providerFailure).toEqual(jasmine.objectContaining({ code: 503, message: "provider unavailable" })); + + expect(recordInvocation).toHaveBeenCalledWith(jasmine.objectContaining({ + isOK: false, + errorMessage: "provider unavailable" + })); +}); + +test("handler constructs default dependencies without performing I/O", () => { + expect(createAppInvokeHandler()).toEqual(jasmine.objectContaining({ handleRequest: jasmine.any(Function) })); + expect(createAppInvokeHandler({ + webApiClient: { + findSystemForInvocation: jasmine.createSpy(), + recordInvocation: jasmine.createSpy(), + getExternalCookie: jasmine.createSpy() + } + })).toEqual(jasmine.objectContaining({ handleRequest: jasmine.any(Function) })); + expect(createAppInvokeHandler({ + hzgProviderClient: { execute: jasmine.createSpy() } + })).toEqual(jasmine.objectContaining({ handleRequest: jasmine.any(Function) })); +}); diff --git a/packages/daedalus-agent-apps-invoke-cli/test/proxy/hzg-provider-client.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/proxy/hzg-provider-client.test.ts index 3eb9aa232dee865745a88ca07e58fe7f9ae41c70..0c4942c69b57deb7f09cbf3a34dee41859595804 100644 --- a/packages/daedalus-agent-apps-invoke-cli/test/proxy/hzg-provider-client.test.ts +++ b/packages/daedalus-agent-apps-invoke-cli/test/proxy/hzg-provider-client.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; import { ServiceError } from "../../src/proxy/errors.js"; -import { HzgProviderClient, normalizeTableBindingBody } from "../../src/proxy/hzg-provider-client.js"; +import { + HzgProviderClient, + normalizeBindingCommandName, + normalizeTableBindingBody +} from "../../src/proxy/hzg-provider-client.js"; import type { BusinessSystemRecord } from "../../src/proxy/types.js"; function assertBadRequest(action: () => unknown, expectedMessage: string): void { @@ -94,3 +98,369 @@ test("provider rejects an invalid table binding before invoking the SDK", async } ); }); + +test("normalizes binding aliases and validates all body shapes", () => { + expect(normalizeTableBindingBody).toBeDefined(); + for (const body of [null, [], { columns: [] }, { columns: [{}] }]) { + expect(() => normalizeTableBindingBody(body)).toThrowError(); + } +}); + +test("requires a configured web client and a provider cookie", async () => { + const system: BusinessSystemRecord = { + system_id: "system-1", + provider: "hzg", + application_name: "OASystem", + service_endpoint: "https://example.com/app", + session_endpoint: "https://example.com/session", + status: "active" + }; + const withoutClient = new HzgProviderClient(); + await expectAsync(withoutClient.createSessionCookie("s")).toBeRejectedWithError(/not configured/); + + const withoutCookie = new HzgProviderClient({ webApiClient: { + async getExternalCookie() { return {}; } + } }); + await expectAsync(withoutCookie.createSessionCookie("s")).toBeRejectedWithError(/Failed to resolve/); + await expectAsync(withoutCookie.execute(system, { + traceId: "trace", + command: "servercommand", + sessionId: " ", + parameters: {} + })).toBeRejectedWithError(/sessionId is required/); +}); + +test("rejects unsupported binding and missing command names before provider calls", async () => { + const client = new HzgProviderClient({ webApiClient: { + async getExternalCookie() { return { cookie: "cookie" }; } + } }); + const system: BusinessSystemRecord = { + system_id: "system-1", + provider: "hzg", + application_name: "OASystem", + service_endpoint: "https://example.com/app", + session_endpoint: "https://example.com/session", + status: "active" + }; + await expectAsync(client.execute(system, { + traceId: "trace", + command: "binding", + sessionId: "session", + parameters: { commandName: "GetMetadata" } + })).toBeRejectedWithError(/Unsupported binding command/); + await expectAsync(client.execute(system, { + traceId: "trace", + command: "binding", + sessionId: "session", + parameters: { commandName: "unknown" } + })).toBeRejectedWithError(/Unsupported HZG command/); + await expectAsync(client.execute(system, { + traceId: "trace", + command: "servercommand", + sessionId: "session", + parameters: {} + })).toBeRejectedWithError(/commandName is required/); +}); + +test("normalizes every supported binding alias", () => { + for (const alias of [ + "CandidatesBinding", + "CandidatesBindings", + "GetComboBindingOptions" + ]) { + assert.equal(normalizeBindingCommandName(alias), "GetComboBindingOptions"); + } + for (const alias of ["TableBinding", "TableBindings", "GetTableDataWithOffset"]) { + assert.equal(normalizeBindingCommandName(alias), "GetTableDataWithOffset"); + } + for (const alias of [ + "CalcBindingDataSource", + "DataBindingSource", + "DataSourceBinding", + "DataSourceBindings", + "data-source-binding", + "data-source-bindings" + ]) { + assert.equal(normalizeBindingCommandName(alias), "CalcBindingDataSource"); + } + assert.equal(normalizeBindingCommandName("GetMetadata"), "GetMetadata"); + assert.equal(normalizeBindingCommandName("GetMetadata2"), "GetMetadata2"); + assert.equal(normalizeBindingCommandName("unknown"), undefined); +}); + +test("table binding validates object columns and finite numeric fields", () => { + for (const body of [ + { columns: ["bad"] }, + { + columns: [{ "column-name": "name", guid: "guid" }], + "table-name": "table", + "view-name": "view", + "list-view-location": "page|view", + "page-name": "page", + "target-page": "1", + "page-limit-row-count": 10 + }, + { + columns: [{ "column-name": "name", guid: "guid" }], + "table-name": "table", + "view-name": "view", + "list-view-location": "page|view", + "page-name": "page", + "target-page": 1, + "page-limit-row-count": Number.POSITIVE_INFINITY + } + ]) { + assert.throws(() => normalizeTableBindingBody(body)); + } +}); + +test("provider executes server and binding calls through the SDK", async () => { + const originalFetch = globalThis.fetch; + const calls: Array<{ url: string; init?: RequestInit }> = []; + globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input); + calls.push({ url, init }); + if (url.includes("GetMetadata2")) { + return new Response(JSON.stringify({ + Page1: { + metaData: { + Cells: [{ + Row: 1, + Column: 2, + Binding: { + GUID: "calc-guid", + TableName: "Orders", + Params: ["CustomerId"] + } + }] + } + } + }), { status: 200 }); + } + if (url.includes("CalcBindingDataSource")) { + return new Response(JSON.stringify([{ ResultName: "calculated" }]), { status: 200 }); + } + if (url.includes("GetTableDataWithOffset")) { + return new Response(JSON.stringify({ table: { Data: [{ C0: "row-1" }] } }), { status: 200 }); + } + if (url.includes("GetComboBindingOptions")) { + return new Response(JSON.stringify({ Items: [{ Value: "1", DisplayValue: "One" }] }), { status: 200 }); + } + if (url.includes("raw-command")) { + return new Response("plain response", { status: 200 }); + } + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }) as typeof fetch; + + const system: BusinessSystemRecord = { + system_id: "system-1", + provider: "hzg", + application_name: "OASystem", + service_endpoint: "https://example.com/app", + session_endpoint: "https://example.com/session", + status: "active" + }; + const client = new HzgProviderClient({ + webApiClient: { async getExternalCookie() { return { cookie: "ForguncyServer=test" }; } } + }); + try { + const serverResult = await client.execute(system, { + traceId: "server", + command: "servercommand", + sessionId: "session", + parameters: { commandName: "server-command", method: "GET", query: { id: 1 } } + }); + assert.deepEqual(serverResult.data, { ok: true }); + + const rawResult = await client.execute(system, { + traceId: "raw", + command: "servercommand", + sessionId: "session", + parameters: { commandName: "raw-command", method: "POST", body: { id: 2 } } + }); + assert.equal(rawResult.data, "plain response"); + + const tableResult = await client.execute(system, { + traceId: "table", + command: "binding", + sessionId: "session", + parameters: { + commandName: "TableBinding", + body: { + columns: [{ "column-name": "Name", guid: "name-guid" }], + "table-name": "Orders", + "view-name": "OrdersView", + "list-view-location": "Page1|OrdersView", + "page-name": "Page1", + "target-page": 1, + "page-limit-row-count": 20 + } + } + }); + assert.deepEqual(tableResult.data, { data: [{ Name: "row-1" }] }); + + const comboResult = await client.execute(system, { + traceId: "combo", + command: "binding", + sessionId: "session", + parameters: { + commandName: "CandidatesBinding", + body: { + "table-name": "Orders", + "page-name": "Page1", + "id-column": { "column-name": "Id", guid: "id-guid" }, + "text-column": { "column-name": "Name", guid: "name-guid" } + } + } + }); + assert.deepEqual(comboResult.data, { data: [{ Name: "One", Id: "1" }] }); + + const calcResult = await client.execute(system, { + traceId: "calc", + command: "binding", + sessionId: "session", + parameters: { + commandName: "CalcBindingDataSource", + body: { + "page-name": "Page1", + "cell-location": "1,2", + "table-name": "Orders", + columns: [{ + "response-name": "ResultName", + "table-name": "Orders", + "column-name": "Result" + }], + "query-params": [{ "table-name": "Orders", "column-name": "CustomerId" }], + params: { "Orders.CustomerId": "customer-1" } + } + } + }); + assert.deepEqual(calcResult.data, { data: [{ Result: "calculated" }] }); + assert.ok(calls.some((call) => call.url.includes("id=1"))); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("provider validates calc binding body variants", async () => { + const client = new HzgProviderClient({ + webApiClient: { async getExternalCookie() { return { cookie: "cookie" }; } } + }); + const system: BusinessSystemRecord = { + system_id: "system-1", + provider: "hzg", + application_name: "OASystem", + service_endpoint: "https://example.com/app", + session_endpoint: "https://example.com/session", + status: "active" + }; + for (const body of [ + null, + {}, + { "page-name": "Page", "cell-location": "1,1", "table-name": "T", columns: [] }, + { "page-name": "Page", "cell-location": "1,1", "table-name": "T", columns: [null] }, + { + "page-name": "Page", + "cell-location": "1,1", + "table-name": "T", + columns: [{ "table-name": "T", "column-name": "C" }] + }, + { + "page-name": "Page", + "cell-location": "1,1", + "table-name": "T", + columns: [{ "display-name": "D", "table-name": "T", "column-name": "C" }], + "query-params": {} + }, + { + "page-name": "Page", + "cell-location": "1,1", + "table-name": "T", + columns: [{ "display-name": "D", "table-name": "T", "column-name": "C" }], + "query-params": [null] + }, + { + "page-name": "Page", + "cell-location": "1,1", + "table-name": "T", + columns: [{ "display-name": "D", "table-name": "T", "column-name": "C" }], + params: [] + } + ]) { + await assert.rejects(() => client.execute(system, { + traceId: "invalid-calc", + command: "binding", + sessionId: "session", + parameters: { commandName: "CalcBindingDataSource", body } + })); + } +}); + +test("provider covers parameter defaults and provider error normalization", async () => { + const originalFetch = globalThis.fetch; + const responses = [ + new Response(JSON.stringify({ message: "provider rejected" }), { status: 500 }), + new Response("", { status: 200 }), + ]; + globalThis.fetch = (async () => responses.shift()!) as typeof fetch; + const client = new HzgProviderClient({ + webApiClient: { async getExternalCookie() { return { cookie: "cookie" }; } } + }); + const system: BusinessSystemRecord = { + system_id: "system-1", + provider: "hzg", + application_name: "OASystem", + service_endpoint: "https://example.com/app", + session_endpoint: "https://example.com/session", + status: "active" + }; + try { + await assert.rejects(() => client.execute(system, { + traceId: "missing-session", + command: "servercommand", + sessionId: undefined as never, + parameters: { commandName: "cmd" } + }), /sessionId is required/); + await assert.rejects(() => client.execute(system, { + traceId: "missing-parameters", + command: "servercommand", + sessionId: "session", + parameters: undefined as never + }), /commandName is required/); + await assert.rejects(() => client.execute(system, { + traceId: "provider-error", + command: "servercommand", + sessionId: "session", + parameters: { commandName: "error-command", method: "GET" } + }), /HTTP 500/); + const empty = await client.execute(system, { + traceId: "empty-response", + command: "servercommand", + sessionId: "session", + parameters: { commandName: "empty-command", method: "GET" } + }); + assert.equal(empty.data, null); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("provider rejects a non-object calc binding body", async () => { + const client = new HzgProviderClient({ + webApiClient: { async getExternalCookie() { return { cookie: "cookie" }; } } + }); + const system: BusinessSystemRecord = { + system_id: "system-1", + provider: "hzg", + application_name: "OASystem", + service_endpoint: "https://example.com/app", + session_endpoint: "https://example.com/session", + status: "active" + }; + await assert.rejects(() => client.execute(system, { + traceId: "invalid-calc-string", + command: "binding", + sessionId: "session", + parameters: { commandName: "CalcBindingDataSource", body: "invalid" } + }), /body must be a JSON object/); +}); diff --git a/packages/daedalus-agent-apps-invoke-cli/test/proxy/logger.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/proxy/logger.test.ts index dff2c2843829f9fd527ea91960a0288004196a9b..54c6b9c0297f7e4eed55a21b6451bdf8c312e643 100644 --- a/packages/daedalus-agent-apps-invoke-cli/test/proxy/logger.test.ts +++ b/packages/daedalus-agent-apps-invoke-cli/test/proxy/logger.test.ts @@ -1,7 +1,11 @@ import assert from "node:assert/strict"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; import { serviceUnavailable } from "../../src/proxy/errors.js"; -import { errorDetails } from "../../src/proxy/logger.js"; +import { createLogger, errorDetails } from "../../src/proxy/logger.js"; +import { writeErrorLog } from "../../src/logger.js"; test("errorDetails preserves structured service error details", () => { const error = serviceUnavailable("Failed to record invocation", { @@ -28,3 +32,103 @@ test("errorDetails preserves structured service error details", () => { } }); }); + +test("errorDetails handles nested causes, plain objects, and primitive values", () => { + const cause = new Error("root cause"); + const error = Object.assign(new Error("outer"), { + statusCode: 502, + code: "upstream", + details: { retryable: true }, + cause + }); + + assert.deepEqual(errorDetails(error), { + name: "Error", + message: "outer", + stack: error.stack, + statusCode: 502, + code: "upstream", + details: { retryable: true }, + cause: { + name: "Error", + message: "root cause", + stack: cause.stack + } + }); + const plain = { reason: "plain" }; + assert.deepEqual(errorDetails(plain), { message: "[object Object]", details: plain }); + assert.deepEqual(errorDetails(null), { message: "null" }); +}); + +test("proxy logger writes to an explicitly configured daily file", () => { + const previous = process.env.DA_LOG_DIR; + const directory = mkdtempSync(join(tmpdir(), "daedalus-agent-cli-log-")); + process.env.DA_LOG_DIR = directory; + try { + createLogger("coverage-contract").info("configured log"); + const file = (require("node:fs") as typeof import("node:fs")).readdirSync(directory) + .find((name) => name.endsWith(".log")); + assert.ok(file); + assert.match(readFileSync(join(directory, file), "utf8"), /configured log/); + } finally { + if (previous === undefined) delete process.env.DA_LOG_DIR; + else process.env.DA_LOG_DIR = previous; + } +}); + +test("error logger supports root/data fallbacks and non-error payloads", () => { + const previous = { + log: process.env.DA_LOG_DIR, + root: process.env.DA_ROOT, + data: process.env.DA_DATA_DIR, + kernel: process.env.DA_KERNEL_DATA_DIR + }; + const root = mkdtempSync(join(tmpdir(), "daedalus-agent-cli-root-")); + const data = join(root, "data", "runtime"); + const messages: string[] = []; + try { + delete process.env.DA_LOG_DIR; + process.env.DA_ROOT = root; + delete process.env.DA_DATA_DIR; + delete process.env.DA_KERNEL_DATA_DIR; + writeErrorLog((line) => messages.push(line), { reason: "object" }, "root fallback"); + assert.ok(existsSync(join(root, "logs"))); + + delete process.env.DA_ROOT; + process.env.DA_DATA_DIR = data; + writeErrorLog((line) => messages.push(line), "primitive", "data fallback"); + assert.ok(existsSync(join(root, "data", "logs"))); + assert.equal(messages.length, 2); + } finally { + for (const [key, value] of Object.entries({ + DA_LOG_DIR: previous.log, + DA_ROOT: previous.root, + DA_DATA_DIR: previous.data, + DA_KERNEL_DATA_DIR: previous.kernel + })) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } +}); + +test("loggers tolerate an unusable log directory", () => { + const previous = process.env.DA_LOG_DIR; + const filePath = join(tmpdir(), `daedalus-agent-log-file-${Date.now()}`); + writeFileSync(filePath, "not a directory"); + const messages: string[] = []; + const originalError = process.stderr.write; + process.env.DA_LOG_DIR = filePath; + process.stderr.write = ((chunk: string) => { + messages.push(chunk); + return true; + }) as typeof process.stderr.write; + try { + writeErrorLog(() => undefined, new Error("bad log dir"), "fallback"); + assert.ok(messages.some((line) => line.includes("failed to initialize daily log file"))); + } finally { + process.stderr.write = originalError; + if (previous === undefined) delete process.env.DA_LOG_DIR; + else process.env.DA_LOG_DIR = previous; + } +}); diff --git a/packages/daedalus-agent-apps-invoke-cli/test/proxy/socket-service.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/proxy/socket-service.test.ts index f26e4d7db8c6096da92b262e478569047c1ef70c..a0b1119d1b4d0ece7895e1ce728f81be26b7c1f9 100644 --- a/packages/daedalus-agent-apps-invoke-cli/test/proxy/socket-service.test.ts +++ b/packages/daedalus-agent-apps-invoke-cli/test/proxy/socket-service.test.ts @@ -1,10 +1,12 @@ import assert from "node:assert/strict"; import { mkdtemp, rm } from "node:fs/promises"; +import { createConnection } from "node:net"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { AppInvokeSocketClient } from "../../src/client/socket-client.js"; import { createAppInvokeSocketService } from "../../src/proxy/socket-service.js"; +import { normalizeIpcPath } from "../../src/proxy/ipc-path.js"; async function createTempSocketPath(): Promise<{ dir: string; socketPath: string }> { const dir = await mkdtemp(join(tmpdir(), "daedalus-agent-invoke-")); @@ -26,8 +28,9 @@ test("socket service handles app invoke requests without transport env vars", as const logs: Array> = []; const service = createAppInvokeSocketService(createTestEnv(socketPath), { webApiClient: { - async findSystemForInvocation(applicationName: string) { + async findSystemForInvocation(applicationName: string, sessionId: string) { assert.equal(applicationName, "demo-app"); + assert.equal(sessionId, "thread-1"); return { system_id: "system-1", provider: "hzg", @@ -84,3 +87,51 @@ test("socket service handles app invoke requests without transport env vars", as await rm(dir, { recursive: true, force: true }); } }); + +test("socket service handles partial malformed input once and can stop repeatedly", async () => { + const { dir, socketPath } = await createTempSocketPath(); + const service = createAppInvokeSocketService(createTestEnv(socketPath)); + try { + assert.equal(service.getStatus().ipc.listening, false); + await service.start(); + assert.equal(service.getStatus().ipc.listening, true); + const response = await new Promise((resolve, reject) => { + const socket = createConnection(normalizeIpcPath(socketPath)); + socket.setEncoding("utf8"); + let buffer = ""; + socket.once("error", reject); + socket.on("data", (chunk) => { buffer += chunk; }); + socket.once("close", () => resolve(buffer)); + socket.once("connect", () => { + socket.write("{"); + setTimeout(() => { + socket.write("not-json}\nsecond-request\n"); + socket.write("ignored-after-first\n"); + }, 5); + }); + }); + const parsed = JSON.parse(response.trim()); + assert.equal(parsed.code, 500); + assert.equal(parsed.traceId, "unknown"); + await service.stop(); + assert.equal(service.getStatus().ipc.listening, false); + await service.stop(); + } finally { + await service.stop().catch(() => undefined); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("socket service rejects a conflicting listening address", async () => { + const { dir, socketPath } = await createTempSocketPath(); + const first = createAppInvokeSocketService(createTestEnv(socketPath)); + const second = createAppInvokeSocketService(createTestEnv(socketPath)); + try { + await first.start(); + await assert.rejects(() => second.start()); + } finally { + await second.stop().catch(() => undefined); + await first.stop().catch(() => undefined); + await rm(dir, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-apps-invoke-cli/test/proxy/web-api-client.test.ts b/packages/daedalus-agent-apps-invoke-cli/test/proxy/web-api-client.test.ts index b381751f329c5eb4547724f5ea222966d438db02..74df3fee7dfd701e1a301e169ce19c478d3a8fbd 100644 --- a/packages/daedalus-agent-apps-invoke-cli/test/proxy/web-api-client.test.ts +++ b/packages/daedalus-agent-apps-invoke-cli/test/proxy/web-api-client.test.ts @@ -32,15 +32,46 @@ test("WebApiClient resolves system without bearer token", async () => { baseUrl: "http://127.0.0.1:3030" }); - const system = await client.findSystemForInvocation("demo"); + const system = await client.findSystemForInvocation("demo", "session-1"); assert.equal(system?.system_id, "system-1"); - assert.equal(calls[0]?.url, "http://127.0.0.1:3030/api/internal/app-invoke/system?applicationName=demo"); + assert.equal(calls[0]?.url, "http://127.0.0.1:3030/api/internal/app-invoke/system?sessionId=session-1&applicationName=demo"); assert.equal((calls[0]?.headers as Record).Authorization, undefined); } finally { globalThis.fetch = originalFetch; } }); +test("WebApiClient preserves forbidden status when system access is denied", async () => { + const originalFetch = globalThis.fetch; + + globalThis.fetch = (async () => new Response(JSON.stringify({ + code: "forbidden", + message: "Agent is not allowed to access this business system" + }), { + status: 403, + statusText: "Forbidden", + headers: { + "Content-Type": "application/json" + } + })) as typeof fetch; + + try { + const client = new WebApiClient({ + baseUrl: "http://127.0.0.1:3030" + }); + + await assert.rejects( + () => client.findSystemForInvocation("demo", "session-1"), + (error: unknown) => { + assert.equal((error as { statusCode?: number }).statusCode, 403); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("WebApiClient resolves external cookie", async () => { const originalFetch = globalThis.fetch; const calls: Array<{ url: string; headers: HeadersInit | undefined }> = []; @@ -175,3 +206,108 @@ test("WebApiClient preserves full error response details when invocation logging globalThis.fetch = originalFetch; } }); + +test("WebApiClient returns undefined for a missing invocation system", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response(null, { status: 404 })) as typeof fetch; + try { + const client = new WebApiClient({ baseUrl: "http://127.0.0.1:3030///" }); + assert.equal(await client.findSystemForInvocation("missing", "session-1"), undefined); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("WebApiClient maps cookie 404 and plain-text server failures", async () => { + const originalFetch = globalThis.fetch; + const responses = [ + new Response("cookie missing", { status: 404, statusText: "Not Found" }), + new Response("cookie service failed", { status: 500, statusText: "Server Error" }), + ]; + globalThis.fetch = (async () => responses.shift()!) as typeof fetch; + try { + const client = new WebApiClient({ baseUrl: "http://127.0.0.1:3030" }); + await assert.rejects( + () => client.getExternalCookie("missing-session"), + (error: unknown) => { + assert.equal((error as { statusCode?: number }).statusCode, 404); + assert.match((error as Error).message, /Failed to resolve external cookie/); + return true; + } + ); + await assert.rejects( + () => client.getExternalCookie("failed-session"), + (error: unknown) => { + assert.equal((error as { statusCode?: number }).statusCode, 500); + assert.equal((error as any).details.body, "cookie service failed"); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("WebApiClient uses fallback messages for empty and non-string error payloads", async () => { + const originalFetch = globalThis.fetch; + const responses = [ + new Response(JSON.stringify({}), { + status: 500, + headers: { "Content-Type": "application/json" }, + }), + new Response(JSON.stringify({ message: 42 }), { + status: 503, + headers: { "Content-Type": "application/json" }, + }), + new Response(JSON.stringify({ message: " " }), { + status: 502, + headers: { "Content-Type": "application/json" }, + }), + ]; + globalThis.fetch = (async () => responses.shift()!) as typeof fetch; + try { + const client = new WebApiClient({ baseUrl: "http://127.0.0.1:3030" }); + await assert.rejects(() => client.findSystemForInvocation("demo", "s1"), /HTTP 500/); + await assert.rejects(() => client.findSystemForInvocation("demo", "s2"), /HTTP 503/); + await assert.rejects(() => client.recordInvocation({} as never), /HTTP 502/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("WebApiClient preserves response body read failures", async () => { + const originalFetch = globalThis.fetch; + const responses = [{ + ok: false, + status: 500, + statusText: "Read Failed", + headers: new Headers(), + text: async () => { throw "body read failed"; }, + }, { + ok: false, + status: 500, + statusText: "Read Failed", + headers: new Headers(), + text: async () => { throw new Error("body read error"); }, + }] as unknown as Response[]; + globalThis.fetch = (async () => responses.shift()!) as typeof fetch; + try { + const client = new WebApiClient({ baseUrl: "http://127.0.0.1:3030" }); + await assert.rejects( + () => client.recordInvocation({} as never), + (error: unknown) => { + assert.deepEqual((error as any).details.body, { readError: "body read failed" }); + return true; + } + ); + await assert.rejects( + () => client.recordInvocation({} as never), + (error: unknown) => { + assert.deepEqual((error as any).details.body, { readError: "body read error" }); + return true; + } + ); + } finally { + globalThis.fetch = originalFetch; + } +}); diff --git a/packages/daedalus-agent-management-web/README.md b/packages/daedalus-agent-management-web/README.md index 2aeca8cc58aca92d2de04e1a5f11d1397ec8e18f..f0dcef7e4f0f56d14643688fb1d8527b6e719c8d 100644 --- a/packages/daedalus-agent-management-web/README.md +++ b/packages/daedalus-agent-management-web/README.md @@ -24,7 +24,7 @@ server/src/ background/ 后台同步与启动协调 config/ 环境变量加载 db/ SQLite 初始化和表结构 - infra/ S3 / provider 适配 + infra/ 本地制品 / provider 适配 management/ 进程内 management action client services/ 业务服务 utils/ 通用工具 @@ -77,7 +77,7 @@ npm run test:full ## 工作区下载 -浏览器请求 `GET /api/agents/:slug/workspace.zip` 后,服务端通过进程内 management action runtime 执行 `agent.workspace.export`,把 zip 写入 `DA_S3_ADMIN_OUTBOX_BUCKET`,再从 S3 读取该对象并以附件形式返回浏览器。 +浏览器请求 `GET /api/agents/:slug/workspace.zip` 后,服务端通过进程内 management action runtime 执行 `agent.workspace.export`,把 zip 临时写入 `DA_LOCAL_ARTIFACTS_DIR`,读取后清理并以附件形式返回浏览器。该管理操作不使用 S3。 ## 同步计划 @@ -101,17 +101,28 @@ npm run test:full 面向 CUI: -- `GET /api/external/agents?userName=zhangsan` +- `GET /api/external/agents`(未传 `userName`/`id` 时仅返回“所有人”可用的 Agent) +- `GET /api/external/agents?userName=zhangsan`(也支持使用 `id` 作为身份参数) - `GET /api/external/session?userName=zhangsan&agentId=finance-agent` - `GET /api/external/context?sessionId=...` +API 文档: + +- `GET /doc`:本地 Swagger UI 文档页面。 +- `GET /doc/openapi.json`:OpenAPI 3.0 规范 JSON,仅包含 `/api/external/*` 对外接口。 + 内部 API: -- `GET /api/internal/app-invoke/system?provider=...&applicationName=...` +- `GET /api/internal/app-invoke/system?sessionId=...&applicationName=...` - `GET /api/internal/app-invoke/base-url?applicationName=...` - `POST /api/internal/app-invoke/logs` - `GET /api/internal/cui/agents` +Agent 业务系统权限: + +- `GET /api/agents/:id/business-systems` +- `PUT /api/agents/:id/business-systems` + localhost 请求会跳过鉴权;非本地请求需要配置好的 Bearer Token 或管理端登录 Token。 DAEDALUS_AGENT Docker 容器中 `daedalus-agent-apps-invoke-cli serve` 以 `daedalus-svc` 身份常驻运行,通过这些 API 解析业务系统配置、外部 cookie 和调用日志。OpenClaw/agent 侧只执行 CLI client,并通过本地 socket 把请求交给 service。 diff --git a/packages/daedalus-agent-management-web/package-lock.json b/packages/daedalus-agent-management-web/package-lock.json index 27cd5b65a02260a7cf98a1e91b60ec456b763bc0..84c7c9d66a37842251cae2633cd27a93a9ec2621 100644 --- a/packages/daedalus-agent-management-web/package-lock.json +++ b/packages/daedalus-agent-management-web/package-lock.json @@ -1,39 +1,56 @@ { "name": "daedalus-agent-management-web", - "version": "1.0.13", + "version": "1.0.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daedalus-agent-management-web", - "version": "1.0.13", + "version": "1.0.17", "dependencies": { "@ant-design/icons": "^6.0.0", "@ant-design/x": "^1.6.1", "@aws-sdk/client-s3": "^3.907.0", + "@monaco-editor/react": "^4.7.0", "adm-zip": "^0.5.17", "antd": "^5.27.0", "clawhub": "^0.22.0", "dayjs": "^1.11.21", "express": "^5.1.0", + "monaco-editor": "^0.56.0", "mqtt": "^5.15.1", "multer": "^2.0.2", "pino": "^10.3.1", "react": "^19.1.0", "react-dom": "^19.1.0", - "ws": "^8.21.0" + "swagger-ui-dist": "^5.32.13", + "ws": "^8.21.0", + "yaml": "^2.9.0" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", "@types/jasmine": "^5.1.13", "@types/node": "^25.8.0", "@vitejs/plugin-react": "^5.0.1", + "@vitest/coverage-v8": "^4.1.10", + "c8": "^12.0.0", "jasmine": "^5.13.0", + "jsdom": "^30.0.1", "playwright-core": "^1.60.0", "tsx": "^4.22.0", "typescript": "^6.0.3", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "^4.1.10" } }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@ant-design/colors": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", @@ -209,6 +226,59 @@ "integrity": "sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==", "license": "MIT" }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@aws-crypto/crc32": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", @@ -849,9 +919,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", "dev": true, "license": "MIT", "engines": { @@ -859,9 +929,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -893,13 +963,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", - "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.8.tgz", + "integrity": "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.8" }, "bin": { "parser": "bin/babel-parser.js" @@ -984,19 +1054,42 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@clack/core": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz", @@ -1025,6 +1118,146 @@ "node": ">= 20.12.0" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -1479,6 +1712,24 @@ "node": ">=18" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1497,6 +1748,16 @@ "node": ">=12" } }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1547,6 +1808,29 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@monaco-editor/loader": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", + "integrity": "sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==", + "license": "MIT", + "dependencies": { + "state-local": "^1.0.6" + } + }, + "node_modules/@monaco-editor/react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", + "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", + "license": "MIT", + "dependencies": { + "@monaco-editor/loader": "^1.5.0" + }, + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -2157,6 +2441,13 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@smithy/core": { "version": "3.24.3", "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.3.tgz", @@ -2277,6 +2568,106 @@ "node": ">=14.0.0" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2322,6 +2713,24 @@ "@babel/types": "^7.28.2" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2329,6 +2738,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/jasmine": { "version": "5.1.15", "resolved": "https://registry.npmjs.org/@types/jasmine/-/jasmine-5.1.15.tgz", @@ -2354,6 +2770,13 @@ "@types/node": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -2384,28 +2807,172 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "node_modules/@vitest/coverage-v8": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.10.tgz", + "integrity": "sha512-IM49HmthevbgAO4anp1hwtoT9wYe59w0LR00gr+eagHE+ZJ5lK4sLPeO0ubgoJcwLk6dehU3R24N+FbEEKDc8g==", + "dev": true, "license": "MIT", "dependencies": { - "event-target-shim": "^5.0.0" + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.10", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" }, - "engines": { - "node": ">=6.5" + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.10", + "vitest": "4.1.10" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, "license": "MIT", "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, - "engines": { + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/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/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { "node": ">= 0.6" } }, @@ -2555,6 +3122,16 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/arkregex": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.5.tgz", @@ -2575,6 +3152,35 @@ "arkregex": "0.0.5" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.5.tgz", + "integrity": "sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, "node_modules/atomic-sleep": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", @@ -2624,6 +3230,16 @@ "node": ">=6.0.0" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bl": { "version": "6.1.6", "resolved": "https://registry.npmjs.org/bl/-/bl-6.1.6.tgz", @@ -2772,6 +3388,40 @@ "node": ">= 0.8" } }, + "node_modules/c8": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/c8/-/c8-12.0.0.tgz", + "integrity": "sha512-4zpJvrd1nKWutnnKC2pXkFmb6iM1l+ffN//o1CzlTNwW7GSOs9a1xrLqkC48nU8oEkjmPZLPiwMsIaOvoF4Pqg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.1", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^3.1.1", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "test-exclude": "^8.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^18.0.0", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + }, + "peerDependencies": { + "monocart-coverage-reports": "^2" + }, + "peerDependenciesMeta": { + "monocart-coverage-reports": { + "optional": true + } + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -2822,6 +3472,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", @@ -2906,6 +3566,64 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "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/cliui/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "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/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -3056,12 +3774,62 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/dayjs": { "version": "1.11.21", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", @@ -3085,6 +3853,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3094,6 +3869,33 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/dompurify": { + "version": "3.4.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.8.tgz", + "integrity": "sha512-yb1cEmaOum7wFvOCSQxyfgVlv5D47Rc30iZWoMpbDIWTnJ6grDDQyu2KFJzB2k7u0pMuJcQ1zphH//fFnw2tjQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3144,6 +3946,19 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -3162,6 +3977,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -3232,6 +4054,16 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -3259,6 +4091,16 @@ "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", @@ -3421,6 +4263,23 @@ "url": "https://opencollective.com/express" } }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -3490,6 +4349,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-east-asian-width": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", @@ -3573,6 +4442,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -3603,15 +4482,35 @@ "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==", "license": "MIT" }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, "license": "MIT", "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" }, @@ -3668,6 +4567,16 @@ "node": ">= 4" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -3732,6 +4641,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -3757,6 +4673,45 @@ "dev": true, "license": "ISC" }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -3811,6 +4766,67 @@ "dev": true, "license": "MIT" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -3845,6 +4861,22 @@ "node": ">=6" } }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/log-symbols": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz", @@ -3871,6 +4903,80 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/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/magicast": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.4.tgz", + "integrity": "sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -3880,6 +4986,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/media-typer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", @@ -3953,6 +5066,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "9.0.9", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", @@ -3988,6 +5111,16 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/monaco-editor": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.56.0.tgz", + "integrity": "sha512-sXboRm3BeBeLm938eaiyLMe0OxzfXIlZvbv4ir/jVgQy1zDhWjgmny0WoN45fuDKhCCQsYMbBJrv/A6jd8aCUg==", + "license": "MIT", + "dependencies": { + "dompurify": "3.4.8", + "marked": "14.0.0" + } + }, "node_modules/mqtt": { "version": "5.15.1", "resolved": "https://registry.npmjs.org/mqtt/-/mqtt-5.15.1.tgz", @@ -4162,6 +5295,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obug": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz", + "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", @@ -4245,6 +5392,38 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/p-retry": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-8.0.0.tgz", @@ -4267,6 +5446,19 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -4276,6 +5468,16 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-expression-matcher": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", @@ -4335,6 +5537,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -4434,6 +5643,55 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -4478,6 +5736,16 @@ "node": ">= 0.10" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", @@ -5191,6 +6459,30 @@ "node": ">= 12.13.0" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resize-observer-polyfill": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", @@ -5315,6 +6607,19 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -5486,6 +6791,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -5556,15 +6868,35 @@ "node": ">= 10.x" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/state-local": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/state-local/-/state-local-1.0.7.tgz", + "integrity": "sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz", @@ -5703,6 +7035,19 @@ "node": ">=8" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strnum": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", @@ -5721,6 +7066,134 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.13", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.13.tgz", + "integrity": "sha512-qQobzb3DeC2LeK0j3E8812Ef4aIq1y9flJxvZkimkqUC/w4u7wS+yCc+VakqGJLweUUBrI24effhwo8OsAvNAw==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/test-exclude": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-8.0.0.tgz", + "integrity": "sha512-ZOffsNrXYggvU1mDGHk54I96r26P8SyMjO5slMKSc7+IWmtB/MQKnEC2fP51imB3/pT6YK5cT5E8f+Dd9KdyOQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^13.0.6", + "minimatch": "^10.2.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/test-exclude/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -5748,6 +7221,23 @@ "node": ">=12.22" } }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", @@ -5765,6 +7255,36 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyrainbow": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, "node_modules/toggle-selection": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", @@ -5780,6 +7300,32 @@ "node": ">=0.6" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -6401,6 +7947,21 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -6485,6 +8046,144 @@ } } }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6501,6 +8200,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/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/worker-factory": { "version": "7.0.49", "resolved": "https://registry.npmjs.org/worker-factory/-/worker-factory-7.0.49.tgz", @@ -6673,6 +8389,16 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xml-naming": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", @@ -6688,6 +8414,23 @@ "node": ">=16.0.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6695,6 +8438,89 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.1.0.tgz", + "integrity": "sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^8.2.1", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/yoctocolors": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz", diff --git a/packages/daedalus-agent-management-web/package.json b/packages/daedalus-agent-management-web/package.json index a09205d504b1102af1724b720e49d3515a6a0a2e..0ec98715f44796071e5f88d09bfbbc5883f2632d 100644 --- a/packages/daedalus-agent-management-web/package.json +++ b/packages/daedalus-agent-management-web/package.json @@ -16,9 +16,22 @@ "build:management": "node -e \"require('node:fs').rmSync('server/src/management-runtime', { recursive: true, force: true })\" && tsc -p server/management-src/tsconfig.json", "preview": "vite preview", "reset-password": "node scripts/reset-password.js", - "test": "npm run test:unit && npm run test:management", + "test": "npm run test:unit && npm run test:management && npm run test:frontend && npm run test:browser-coverage:contract && npm run test:backend-api", "test:unit": "node test/run-jasmine.js", "test:management": "tsx --tsconfig server/management-test/tsconfig.spec.json server/management-test/run-jasmine.ts", + "test:frontend": "vitest run --config vitest.config.js", + "test:browser-coverage:contract": "node --test test/browser-coverage.test.js", + "test:backend-api": "npm run typecheck:backend-api && tsx --tsconfig test/tsconfig.backend-api.json test/backend-api-regression.ts", + "typecheck:backend-api": "tsc -p test/tsconfig.backend-api.json", + "coverage:server": "c8 --all --include \"server/src/**/*.js\" --exclude \"server/src/management-runtime/**\" --reporter text --reporter json-summary --reports-dir \"../../coverage/web-server\" node test/run-server-coverage.js", + "coverage:server:strict": "c8 --all --include \"server/src/**/*.js\" --exclude \"server/src/management-runtime/**\" --check-coverage --branches 100 --statements 0 --functions 0 --lines 0 --reporter text --reporter json-summary --reports-dir \"../../coverage/web-server\" node test/run-server-coverage.js", + "coverage:management": "c8 --all --extension .ts --include \"server/management-src/**/*.ts\" --exclude \"server/management-src/**/*.d.ts\" --exclude \"server/management-src/index.ts\" --exclude \"server/management-src/types.ts\" --reporter text --reporter json-summary --reports-dir \"../../coverage/management\" npm run test:management", + "coverage:management:strict": "c8 --all --extension .ts --include \"server/management-src/**/*.ts\" --exclude \"server/management-src/**/*.d.ts\" --exclude \"server/management-src/index.ts\" --exclude \"server/management-src/types.ts\" --check-coverage --branches 100 --statements 0 --functions 0 --lines 0 --reporter text --reporter json-summary --reports-dir \"../../coverage/management\" npm run test:management", + "coverage:frontend:core": "vitest run --config vitest.config.js --coverage", + "coverage:frontend:all": "node test/run-browser-coverage.js report", + "coverage:frontend:all:strict": "node test/run-browser-coverage.js strict", + "coverage": "npm run test:browser-coverage:contract && npm run coverage:server && npm run coverage:management && npm run coverage:frontend:core && npm run coverage:frontend:all", + "coverage:strict": "npm run test:browser-coverage:contract && npm run coverage:server:strict && npm run coverage:management:strict && npm run coverage:frontend:core && npm run coverage:frontend:all:strict", "test:e2e-browser": "node test/run-browser-e2e.js", "test:fast": "node test/integration/fast.js", "test:full": "node test/integration/full.js", @@ -30,26 +43,36 @@ "@ant-design/icons": "^6.0.0", "@ant-design/x": "^1.6.1", "@aws-sdk/client-s3": "^3.907.0", + "@monaco-editor/react": "^4.7.0", "adm-zip": "^0.5.17", "antd": "^5.27.0", "clawhub": "^0.22.0", "dayjs": "^1.11.21", "express": "^5.1.0", + "monaco-editor": "^0.56.0", "mqtt": "^5.15.1", "multer": "^2.0.2", "pino": "^10.3.1", "react": "^19.1.0", "react-dom": "^19.1.0", - "ws": "^8.21.0" + "swagger-ui-dist": "^5.32.13", + "ws": "^8.21.0", + "yaml": "^2.9.0" }, "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.2", "@types/jasmine": "^5.1.13", "@types/node": "^25.8.0", "@vitejs/plugin-react": "^5.0.1", + "@vitest/coverage-v8": "^4.1.10", + "c8": "^12.0.0", "jasmine": "^5.13.0", + "jsdom": "^30.0.1", "playwright-core": "^1.60.0", "tsx": "^4.22.0", "typescript": "^6.0.3", - "vite": "^7.0.0" + "vite": "^7.0.0", + "vitest": "^4.1.10" } } diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts index ddb9880441f099338d6f6b307e6b143d7c536f09..5824b20285f83a055aa488b415d8bdc99a363732 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent-template/service.ts @@ -5,7 +5,8 @@ import path from "node:path"; import { ManagedStateStore } from "../agent/state-store.js"; import { ensureSharedLinkOwnership, ensureSharedPathPermissions, isMissingPathError } from "../shared-permissions.js"; -import { createS3ObjectStore, extractZipToDirectory, type S3ObjectStore } from "../../tools/s3-archive.js"; +import { extractZipToDirectory } from "../../tools/zip-archive.js"; +import { readLocalArtifact } from "../../tools/local-artifact.js"; import { toChinaISOString } from "../../china-time.js"; import { readBuiltInComponentNames } from "../built-in-components.js"; import type { @@ -27,17 +28,10 @@ function validateTemplateName(templateName: string): void { } export class TemplateManager { - private objectStore?: S3ObjectStore; - constructor( private readonly env: EnvironmentConfig, - private readonly stateStore: ManagedStateStore, - dependencies?: { - objectStore?: S3ObjectStore; - } - ) { - this.objectStore = dependencies?.objectStore; - } + private readonly stateStore: ManagedStateStore + ) {} async listTemplates(): Promise<{ root: string; items: AgentTemplateRecord[] }> { const root = this.getTemplatesRoot(); @@ -68,11 +62,8 @@ export class TemplateManager { async createTemplate(params: AgentTemplateCreateParams): Promise { validateTemplateName(params.templateName); - if (!params.bucket || params.bucket.trim().length === 0) { - throw new Error("agent.template.create requires bucket"); - } - if (!params.objectKey || params.objectKey.trim().length === 0) { - throw new Error("agent.template.create requires objectKey"); + if (!params.artifactPath || params.artifactPath.trim().length === 0) { + throw new Error("agent.template.create requires artifactPath"); } const templatesRoot = this.getTemplatesRoot(); @@ -84,7 +75,7 @@ export class TemplateManager { let backupCreated = false; try { - const zipPayload = await this.getObjectStore().getObject(params.bucket, params.objectKey); + const zipPayload = await readLocalArtifact(this.env.localArtifactsRoot, params.artifactPath); await extractZipToDirectory(zipPayload, stagingDir, "workspace templates root"); const agentsFile = await stat(path.join(stagingDir, "AGENTS.md")).catch(() => null); if (!agentsFile?.isFile()) { @@ -218,11 +209,4 @@ export class TemplateManager { } } - private getObjectStore(): S3ObjectStore { - if (!this.objectStore) { - this.objectStore = createS3ObjectStore(this.env); - } - - return this.objectStore; - } } diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/docs-restore-default.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/docs-restore-default.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c3f1c6468c7220a9174032f178f39007a652739 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/docs-restore-default.ts @@ -0,0 +1,6 @@ +import type { AgentDocsRestoreDefaultParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentDocsRestoreDefault: CapabilityHandler = async (request, context) => { + return await context.agentManager.restoreDefaultAgentDocs(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts index 339a211bcd3c97d0c209af1b370c2bd743cd110d..674faa72586dcf9e2a6a76ad765192666882d55e 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/lifecycle-service.ts @@ -1,7 +1,7 @@ import { mkdir, readFile } from "node:fs/promises"; import path from "node:path"; -import { CliRunner } from "../../tools/cli-runner.js"; +import { CliRunner, CommandError } from "../../tools/cli-runner.js"; import { buildOpenClawCommandEnv } from "../../tools/openclaw-command-env.js"; import { toChinaISOString } from "../../china-time.js"; import type { @@ -9,6 +9,8 @@ import type { AgentModelSetParams, AgentUpdateParams, EnvironmentConfig, + McpProbeParams, + McpSyncParams, ManagedAgentRecord } from "../../types.js"; import { @@ -411,6 +413,20 @@ export class AgentLifecycleService { }); } + private async probeMcpServerJson(slug: string): Promise { + try { + return await this.safeJson(["mcp", "probe", slug, "--json"]); + } catch (error: unknown) { + if (error instanceof CommandError) { + const detail = firstText(error.result.stderr, error.result.stdout); + if (detail) { + throw new Error(detail); + } + } + throw error; + } + } + private async readOpenClawConfig(): Promise> { let raw = "{}"; try { @@ -517,6 +533,65 @@ export class AgentLifecycleService { await this.writeOpenClawConfig(config); } + async syncMcpConfiguration(params: McpSyncParams): Promise<{ + projections: Array<{ agentId: string; denyPatterns: string[] }>; + }> { + const config = await this.readOpenClawConfig(); + config.mcp ??= {}; + config.mcp.servers = Object.fromEntries(params.servers.map((server) => [server.slug, server.config])); + + const serverSlugs = params.servers.map((server) => server.slug); + const configuredAgents = Array.isArray(config.agents?.list) ? config.agents.list : []; + const projections = []; + for (const projection of params.agents) { + const agent = configuredAgents.find((entry: { id?: string }) => entry?.id === projection.agentId); + if (!agent) { + throw new Error(`Agent config not found: ${projection.agentId}`); + } + const selected = new Set(projection.serverSlugs); + const denyPatterns = projection.mode === "all" + ? [] + : projection.serverSlugs.length === 0 + ? ["bundle-mcp"] + : serverSlugs.filter((slug) => !selected.has(slug)).map((slug) => `${slug}__*`); + agent.tools ??= {}; + const existingDeny = Array.isArray(agent.tools.deny) ? agent.tools.deny : []; + const previous = new Set(projection.previousDenyPatterns); + agent.tools.deny = [...new Set([ + ...existingDeny.filter((item: unknown) => !previous.has(String(item))), + ...denyPatterns + ])]; + if (agent.tools.deny.length === 0) { + delete agent.tools.deny; + } + if (Object.keys(agent.tools).length === 0) { + delete agent.tools; + } + projections.push({ agentId: projection.agentId, denyPatterns }); + } + + await this.writeOpenClawConfig(config); + return { projections }; + } + + async probeMcpServer(params: McpProbeParams): Promise { + if (!params.config) { + return await this.probeMcpServerJson(params.slug); + } + + const snapshot = await this.snapshotOpenClawConfig(); + const config = structuredClone(snapshot); + config.mcp ??= {}; + config.mcp.servers ??= {}; + config.mcp.servers[params.slug] = params.config; + await this.writeOpenClawConfig(config); + try { + return await this.probeMcpServerJson(params.slug); + } finally { + await this.restoreOpenClawConfig(snapshot); + } + } + async updateManagedTopology(record: ManagedAgentRecord, params: AgentUpdateParams): Promise { const config = await this.readOpenClawConfig(); const agents = Array.isArray(config.agents?.list) ? config.agents.list : []; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-delete.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..d058a06cbe0b55a7b02393a0adf655564351b0b6 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-delete.ts @@ -0,0 +1,6 @@ +import type { AgentMarkdownDeleteParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentMarkdownDelete: CapabilityHandler = async (request, context) => { + return await context.agentManager.deleteAgentMarkdown(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-list.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..7d754fec0c74d7ca3550ceb66f31fea95284266f --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-list.ts @@ -0,0 +1,6 @@ +import type { AgentMarkdownListParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentMarkdownList: CapabilityHandler = async (request, context) => { + return await context.agentManager.listAgentMarkdown(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-read.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-read.ts new file mode 100644 index 0000000000000000000000000000000000000000..14ea52eb9918cb6a1a352eb9226467f33a4cb550 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-read.ts @@ -0,0 +1,6 @@ +import type { AgentMarkdownReadParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentMarkdownRead: CapabilityHandler = async (request, context) => { + return await context.agentManager.readAgentMarkdown(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-rename.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-rename.ts new file mode 100644 index 0000000000000000000000000000000000000000..28b2b8a2b70cc5e72764378f507a798c16c4f239 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-rename.ts @@ -0,0 +1,6 @@ +import type { AgentMarkdownRenameParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentMarkdownRename: CapabilityHandler = async (request, context) => { + return await context.agentManager.renameAgentMarkdown(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-write.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-write.ts new file mode 100644 index 0000000000000000000000000000000000000000..b1ff04d76203b45e76d278a718e5e6eff7b0e32f --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/markdown-write.ts @@ -0,0 +1,6 @@ +import type { AgentMarkdownWriteParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentMarkdownWrite: CapabilityHandler = async (request, context) => { + return await context.agentManager.writeAgentMarkdown(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts index 776e0beb75cee9f9c7f56dbf4446db91b7c77c4b..501ea13917cc7a0dda5d4d2367cfa21074a447e5 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/service.ts @@ -4,9 +4,19 @@ import { CliRunner } from "../../tools/cli-runner.js"; import type { AgentCreateParams, AgentDocsUpdateParams, + AgentDocsRestoreDefaultParams, AgentGetParams, + AgentMarkdownDeleteParams, + AgentMarkdownListParams, + AgentMarkdownReadParams, + AgentMarkdownRenameParams, + AgentMarkdownWriteParams, AgentNameSetParams, AgentModelSetParams, + AgentSkillDeleteParams, + AgentSkillExportParams, + AgentSkillInstallLocalParams, + AgentSkillListParams, AgentUpdateParams, AgentMutationParams, AgentWorkspaceExportParams, @@ -25,9 +35,11 @@ import type { LlmProviderDeleteParams, LlmProviderTestParams, LlmProviderUpdateParams, - ManagedAgentRecord + ManagedAgentRecord, + McpProbeParams, + McpSyncParams } from "../../types.js"; -import { createS3ObjectStore, type S3ObjectStore } from "../../tools/s3-archive.js"; +import { writeLocalArtifact } from "../../tools/local-artifact.js"; import { chinaDateKey, toChinaISOString } from "../../china-time.js"; import { AgentLifecycleService } from "./lifecycle-service.js"; import { AgentOperationsService } from "./operations-service.js"; @@ -43,8 +55,6 @@ export class OpenClawManager { private readonly skillsService: AgentSkillsService; private readonly operationsService: AgentOperationsService; private readonly llmModelConfigService: LlmModelConfigService; - private objectStore?: S3ObjectStore; - constructor( cli: CliRunner, doctorCli: CliRunner, @@ -53,7 +63,6 @@ export class OpenClawManager { dependencies?: { clawhubCli?: CliRunner; clawhubBin?: string; - objectStore?: S3ObjectStore; } ) { const clawhubCli = dependencies?.clawhubCli ?? new CliRunner(process.execPath); @@ -61,10 +70,7 @@ export class OpenClawManager { this.lifecycleService = new AgentLifecycleService(cli, env); this.workspaceService = new AgentWorkspaceService(env); - this.objectStore = dependencies?.objectStore; - this.skillsService = new AgentSkillsService(clawhubCli, clawhubBin, env, { - objectStore: dependencies?.objectStore - }); + this.skillsService = new AgentSkillsService(clawhubCli, clawhubBin, env); this.operationsService = new AgentOperationsService( cli, doctorCli, @@ -286,29 +292,53 @@ export class OpenClawManager { return await this.workspaceService.updateAgentDocs(record.workspace, params.content); } - async exportAgentWorkspace(params: AgentWorkspaceExportParams): Promise { + async listAgentMarkdown(params: AgentMarkdownListParams): Promise { const record = await this.requireManagedAgent(params.agentId); - const bucket = firstText(params.bucket, this.env.adminOutboxBucket); - if (!bucket) { - throw new Error("Missing S3 admin outbox bucket for agent workspace export"); - } + return { items: await this.workspaceService.listMarkdownFiles(record.workspace) }; + } + + async readAgentMarkdown(params: AgentMarkdownReadParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.workspaceService.readMarkdownFile(record.workspace, params.path); + } - const objectKey = firstText( - params.objectKey, - `agent-workspace/${chinaDateKey()}/${randomUUID()}-${sanitizeFileName(record.id)}.zip` + async writeAgentMarkdown(params: AgentMarkdownWriteParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.workspaceService.writeMarkdownFile(record.workspace, params.path, params.content, { + etag: params.etag, + create: params.create + }); + } + + async renameAgentMarkdown(params: AgentMarkdownRenameParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.workspaceService.renameMarkdownFile( + record.workspace, + params.sourcePath, + params.destinationPath, + params.etag ); - const archive = await this.workspaceService.buildWorkspaceExportArchive(record.workspace); - const objectStore = this.getObjectStore(); - if (!objectStore.putObject) { - throw new Error("S3 object store does not support uploads"); - } + } - await objectStore.putObject(bucket, objectKey, archive.buffer, "application/zip"); + async deleteAgentMarkdown(params: AgentMarkdownDeleteParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.workspaceService.deleteMarkdownFile(record.workspace, params.path, params.etag); + } + + async restoreDefaultAgentDocs(params: AgentDocsRestoreDefaultParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.workspaceService.restoreDefaultAgentDocs(record.workspace, params.etag); + } + + async exportAgentWorkspace(params: AgentWorkspaceExportParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + const artifactPath = `agent-workspace/${chinaDateKey()}/${randomUUID()}-${sanitizeFileName(record.id)}.zip`; + const archive = await this.workspaceService.buildWorkspaceExportArchive(record.workspace); + await writeLocalArtifact(this.env.localArtifactsRoot, artifactPath, archive.buffer); return { agentId: record.id, - bucket, - objectKey, + artifactPath, fileName: `${sanitizeFileName(record.id)}-workspace.zip`, byteSize: archive.buffer.byteLength, entries: archive.entries, @@ -320,6 +350,26 @@ export class OpenClawManager { return await this.skillsService.listGlobalSkills(); } + async listAgentSkills(params: AgentSkillListParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.skillsService.listAgentSkills(record.workspace); + } + + async installAgentSkillFromLocal(params: AgentSkillInstallLocalParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.skillsService.installAgentSkillFromLocal(record.workspace, params); + } + + async deleteAgentSkill(params: AgentSkillDeleteParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.skillsService.deleteAgentSkill(record.workspace, params); + } + + async exportAgentSkill(params: AgentSkillExportParams): Promise { + const record = await this.requireManagedAgent(params.agentId); + return await this.skillsService.exportAgentSkill(record.workspace, params); + } + async installGlobalSkillFromLocal(params: GlobalSkillsInstallLocalParams): Promise { return await this.skillsService.installGlobalSkillFromLocal(params); } @@ -340,6 +390,22 @@ export class OpenClawManager { return await this.operationsService.restartGateway(params); } + async syncMcpConfiguration(params: McpSyncParams): Promise { + const snapshot = await this.lifecycleService.snapshotOpenClawConfig(); + try { + const result = await this.lifecycleService.syncMcpConfiguration(params); + await this.operationsService.maybeRefreshGatewayAfterManagedTopologyChange(params.restart, "mcp.sync"); + return result; + } catch (error) { + await this.lifecycleService.restoreOpenClawConfig(snapshot).catch(() => undefined); + throw error; + } + } + + async probeMcpServer(params: McpProbeParams): Promise { + return await this.lifecycleService.probeMcpServer(params); + } + private async requireManagedAgent(agentId: string): Promise { const record = (await this.reconcileManagedState()).find((item) => item.id === agentId); if (!record) { @@ -358,23 +424,6 @@ export class OpenClawManager { return derived; } - private getObjectStore(): S3ObjectStore { - if (!this.objectStore) { - this.objectStore = createS3ObjectStore(this.env); - } - - return this.objectStore; - } -} - -function firstText(...values: Array): string { - for (const value of values) { - if (typeof value === "string" && value.trim()) { - return value.trim(); - } - } - - return ""; } function sanitizeFileName(fileName: string): string { diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-delete.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-delete.ts new file mode 100644 index 0000000000000000000000000000000000000000..5d5a4f05b2d08c91c95998287b760e500c46ff62 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-delete.ts @@ -0,0 +1,6 @@ +import type { AgentSkillDeleteParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentSkillsDelete: CapabilityHandler = async (request, context) => { + return await context.agentManager.deleteAgentSkill(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-export.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-export.ts new file mode 100644 index 0000000000000000000000000000000000000000..a4334b84614b62c02aa8f73db6170072802abbc8 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-export.ts @@ -0,0 +1,6 @@ +import type { AgentSkillExportParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentSkillsExport: CapabilityHandler = async (request, context) => { + return await context.agentManager.exportAgentSkill(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-install-local.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-install-local.ts new file mode 100644 index 0000000000000000000000000000000000000000..b38f2a35e6c7462e680901d074f4395a4a083877 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-install-local.ts @@ -0,0 +1,6 @@ +import type { AgentSkillInstallLocalParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentSkillsInstallLocal: CapabilityHandler = async (request, context) => { + return await context.agentManager.installAgentSkillFromLocal(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-list.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-list.ts new file mode 100644 index 0000000000000000000000000000000000000000..7126f852af10ee8d478d4aed33e872744bba98f9 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-list.ts @@ -0,0 +1,6 @@ +import type { AgentSkillListParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const agentSkillsList: CapabilityHandler = async (request, context) => { + return await context.agentManager.listAgentSkills(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-service.ts index d31bb3a687b018aa942cfc63dea0015f425db83b..545b53ce6de813e2deb3fbe4cc747fb52c417691 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/skills-service.ts @@ -1,13 +1,22 @@ import { execFile } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; import { cp, chmod, chown, lchown, lstat, mkdtemp, mkdir, readFile, readdir, readlink, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; +import AdmZip from "adm-zip"; + import { CliRunner } from "../../tools/cli-runner.js"; import { CommandError } from "../../tools/cli-runner.js"; -import { createS3ObjectStore, extractZipToDirectory, type S3ObjectStore } from "../../tools/s3-archive.js"; +import { extractZipToDirectory } from "../../tools/zip-archive.js"; +import { readLocalArtifact } from "../../tools/local-artifact.js"; +import { writeLocalArtifact } from "../../tools/local-artifact.js"; import type { EnvironmentConfig, + AgentSkillDeleteParams, + AgentSkillExportParams, + AgentSkillInstallLocalParams, + AgentSkillRecord, GlobalSkillRecord, GlobalSkillsListResult, GlobalSkillsDeleteParams, @@ -18,6 +27,13 @@ import { readBuiltInComponentNames } from "../built-in-components.js"; const VALID_SKILL_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; const FRONTMATTER_PATTERN = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const DEFAULT_SEED_SKILLS_ROOT = "/opt/daedalus-agent-seed/home/.openclaw/skills"; +const SKILL_ARCHIVE_LIMITS = { + maxEntries: 2000, + maxFileBytes: 10 * 1024 * 1024, + maxTotalBytes: 100 * 1024 * 1024, + maxCompressionRatio: 100, + maxPathBytes: 512 +} as const; interface SkillManifestMetadata { hasFrontmatter: boolean; @@ -42,18 +58,11 @@ interface SharedOwnershipOptions { } export class AgentSkillsService { - private objectStore?: S3ObjectStore; - constructor( private readonly clawhubCli: CliRunner, private readonly clawhubBin: string, - private readonly env: EnvironmentConfig, - dependencies?: { - objectStore?: S3ObjectStore; - } - ) { - this.objectStore = dependencies?.objectStore; - } + private readonly env: EnvironmentConfig + ) {} async listGlobalSkills(): Promise { const root = this.env.globalSkillsRoot; @@ -93,19 +102,17 @@ export class AgentSkillsService { } async installGlobalSkillFromLocal(params: GlobalSkillsInstallLocalParams): Promise { - const install = await this.prepareLocalSkillInstall({ + const install = await this.prepareGlobalSkillInstall({ commandName: "skills.global.install.local", slug: params.slug, - bucket: params.bucket, - objectKey: params.objectKey + artifactPath: params.artifactPath }); try { const targetDir = await this.installPreparedGlobalSkill({ skillDir: install.skillDir, slug: install.slug, - bucket: params.bucket, - objectKey: params.objectKey, + artifactPath: params.artifactPath, force: params.force }); return { @@ -149,11 +156,279 @@ export class AgentSkillsService { }; } + async listAgentSkills(workspace: string): Promise<{ items: AgentSkillRecord[] }> { + const root = await this.resolveAgentSkillsRoot(workspace); + await mkdir(root, { recursive: true, mode: 0o2770 }); + const entries = await readdir(root, { withFileTypes: true }); + const items = await Promise.all(entries + .filter((entry) => !entry.name.startsWith(".")) + .map((entry) => this.buildAgentSkillRecord(root, entry.name))); + return { + items: items.sort((left, right) => left.slug.localeCompare(right.slug)) + }; + } + + async installAgentSkillFromLocal( + workspace: string, + params: AgentSkillInstallLocalParams + ): Promise<{ skill: AgentSkillRecord }> { + const commandName = "agent.skills.install.local"; + const slug = this.validateSkillSlug(commandName, params.slug); + await this.ensureAgentSkillSlugDoesNotCollideWithGlobal(commandName, slug); + const install = await this.prepareLocalSkillInstall({ + commandName, + slug, + artifactPath: params.artifactPath + }); + try { + const root = await this.resolveAgentSkillsRoot(workspace); + await this.installPreparedAgentSkill(root, { + skillDir: install.skillDir, + slug, + artifactPath: params.artifactPath, + force: params.force, + etag: params.etag + }); + return { skill: await this.buildAgentSkillRecord(root, slug) }; + } finally { + await install.cleanup?.(); + } + } + + async deleteAgentSkill(workspace: string, params: AgentSkillDeleteParams): Promise<{ slug: string }> { + const slug = this.validateSkillSlug("agent.skills.delete", params.slug); + const root = await this.resolveAgentSkillsRoot(workspace); + const target = path.join(root, slug); + const record = await this.buildAgentSkillRecord(root, slug); + if (record.etag !== params.etag) { + throw new Error("Agent skill was modified by another operation"); + } + const recycleRoot = path.join(this.env.dataRoot, "recyclebin", "agent-skill"); + await mkdir(recycleRoot, { recursive: true, mode: 0o2770 }); + await rename(target, path.join(recycleRoot, `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}-${slug}`)); + return { slug }; + } + + async exportAgentSkill( + workspace: string, + params: AgentSkillExportParams + ): Promise<{ slug: string; artifactPath: string; fileName: string; byteSize: number }> { + const slug = this.validateSkillSlug("agent.skills.export", params.slug); + const root = await this.resolveAgentSkillsRoot(workspace); + const record = await this.buildAgentSkillRecord(root, slug); + if (record.etag !== params.etag) { + throw new Error("Agent skill was modified by another operation"); + } + const artifactPath = `agent-skill/${new Date().toISOString().slice(0, 10)}/${randomUUID()}-${slug}.zip`; + const payload = await this.buildAgentSkillArchive(path.join(root, slug)); + await writeLocalArtifact(this.env.localArtifactsRoot, artifactPath, payload); + return { + slug, + artifactPath, + fileName: `${slug}.zip`, + byteSize: payload.byteLength + }; + } + private isNotInstalledError(error: CommandError, slug: string): boolean { const stderr = String(error.result.stderr || "").trim(); return stderr === `Error: Not installed: ${slug}`; } + private async resolveAgentSkillsRoot(workspace: string): Promise { + const workspaceRoot = path.resolve(workspace); + const workspacesRoot = path.resolve(this.env.workspacesRoot); + const relative = path.relative(workspacesRoot, workspaceRoot); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative.includes(path.sep)) { + throw new Error("Agent workspace is outside the managed workspaces root"); + } + const workspaceInfo = await lstat(workspaceRoot); + if (workspaceInfo.isSymbolicLink() || !workspaceInfo.isDirectory()) { + throw new Error("Agent workspace is not a regular directory"); + } + const root = path.join(workspaceRoot, "skills"); + try { + const info = await lstat(root); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error("Agent skills root is not a regular directory"); + } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } + return root; + } + + private async ensureAgentSkillSlugDoesNotCollideWithGlobal(commandName: string, slug: string): Promise { + const globals = await this.listGlobalSkills(); + const collision = globals.items.find((item) => this.skillSlugEquals(item.slug, slug)); + if (collision) { + throw new Error(`${commandName} cannot install ${slug}: a global skill uses the same slug`); + } + } + + private async buildAgentSkillRecord(root: string, slug: string): Promise { + if (!this.isSafeSkillSlug(slug)) { + throw new Error(`Invalid Agent skill directory: ${slug}`); + } + const skillPath = path.join(root, slug); + let info; + try { + info = await lstat(skillPath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new Error(`Agent skill does not exist: ${slug}`); + } + throw error; + } + if (info.isSymbolicLink() || !info.isDirectory()) { + return { + slug, + name: slug, + description: "", + status: "invalid", + invalidReason: "skill path is not a regular directory", + etag: createHash("sha256").update(`invalid:${slug}`).digest("hex"), + updatedAt: info.mtime.toISOString() + }; + } + const manifest = await this.readSkillManifestMetadata(skillPath); + const etag = await this.hashAgentSkillDirectory(skillPath); + const valid = Boolean( + manifest?.hasFrontmatter + && manifest.name?.trim() + && manifest.description?.trim() + && this.skillSlugEquals(manifest.name, slug) + ); + return { + slug, + name: manifest?.name?.trim() || slug, + description: manifest?.description?.trim() || "", + status: valid ? "ready" : "invalid", + invalidReason: valid ? undefined : "SKILL.md is missing or its frontmatter does not match the skill slug", + etag, + updatedAt: info.mtime.toISOString() + }; + } + + private async hashAgentSkillDirectory(skillPath: string): Promise { + const hash = createHash("sha256"); + const visit = async (directory: string, relativeDirectory = ""): Promise => { + const entries = (await readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; + const info = await lstat(absolutePath); + if (info.isSymbolicLink()) { + throw new Error(`Agent skill cannot contain symbolic links: ${relativePath}`); + } + hash.update(relativePath); + if (info.isDirectory()) { + await visit(absolutePath, relativePath); + } else if (info.isFile()) { + hash.update(await readFile(absolutePath)); + } else { + throw new Error(`Agent skill contains an unsupported entry: ${relativePath}`); + } + } + }; + await visit(skillPath); + return hash.digest("hex"); + } + + private async installPreparedAgentSkill( + root: string, + params: { + skillDir: string; + slug: string; + artifactPath: string; + force?: boolean; + etag?: string; + } + ): Promise { + await mkdir(root, { recursive: true, mode: 0o2770 }); + const target = path.join(root, params.slug); + let existing: AgentSkillRecord | undefined; + try { + existing = await this.buildAgentSkillRecord(root, params.slug); + } catch (error: unknown) { + if (!String((error as Error)?.message || "").includes("does not exist")) { + throw error; + } + } + if (existing && !params.force) { + throw new Error(`Agent skill already exists: ${params.slug}`); + } + if (existing && (!params.etag || existing.etag !== params.etag)) { + throw new Error("Agent skill was modified by another operation"); + } + + const staging = await mkdtemp(path.join(root, `.${params.slug}.install-`)); + const backup = path.join(root, `.${params.slug}.backup-${randomUUID()}`); + let existingMoved = false; + let stagingActivated = false; + try { + await cp(params.skillDir, staging, { recursive: true }); + await rm(path.join(staging, ".clawhub"), { recursive: true, force: true }); + await rm(path.join(staging, ".clawdhub"), { recursive: true, force: true }); + await mkdir(path.join(staging, ".openclaw"), { recursive: true }); + await writeFile(path.join(staging, ".openclaw", "source-origin.json"), `${JSON.stringify({ + version: 1, + source: "daedalus-agent-agent-upload", + spec: `local://${params.artifactPath.trim()}`, + slug: params.slug + }, null, 2)}\n`, "utf8"); + if (existing) { + await rename(target, backup); + existingMoved = true; + } + await rename(staging, target); + stagingActivated = true; + await this.normalizeSharedSkillTree(target); + if (existing) { + const revisionRoot = path.join(this.env.dataRoot, "agent-skill-revisions"); + await mkdir(revisionRoot, { recursive: true, mode: 0o2770 }); + await rename(backup, path.join(revisionRoot, `${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}-${params.slug}`)); + } + } catch (error) { + await rm(staging, { recursive: true, force: true }); + if (stagingActivated) { + await rm(target, { recursive: true, force: true }); + } + if (existingMoved) { + await rename(backup, target).catch(() => undefined); + } + throw error; + } + } + + private async buildAgentSkillArchive(skillPath: string): Promise { + const zip = new AdmZip(); + const visit = async (directory: string, relativeDirectory = ""): Promise => { + const entries = (await readdir(directory, { withFileTypes: true })) + .sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const absolutePath = path.join(directory, entry.name); + const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; + const info = await lstat(absolutePath); + if (info.isSymbolicLink()) { + throw new Error(`Agent skill cannot contain symbolic links: ${relativePath}`); + } + if (info.isDirectory()) { + await visit(absolutePath, relativePath); + } else if (info.isFile()) { + zip.addFile(relativePath.replaceAll("\\", "/"), await readFile(absolutePath)); + } else { + throw new Error(`Agent skill contains an unsupported entry: ${relativePath}`); + } + } + }; + await visit(skillPath); + return zip.toBuffer(); + } + private async reconcileGlobalSkillDrift(slug: string, workdir: string): Promise { await rm(path.join(this.env.globalSkillsRoot, slug), { recursive: true, force: true }); @@ -311,19 +586,31 @@ export class AgentSkillsService { } } - private getObjectStore(): S3ObjectStore { - if (!this.objectStore) { - this.objectStore = createS3ObjectStore(this.env); + private async prepareLocalSkillInstall(params: { + commandName: string; + slug: string; + artifactPath: string; + }): Promise<{ + slug: string; + skillDir: string; + manifest: SkillManifestMetadata; + cleanup?: () => Promise; + }> { + const slug = this.validateSkillSlug(params.commandName, params.slug); + await this.ensureSlugDoesNotCollideWithBuiltIn(params.commandName, slug); + + if (!params.artifactPath?.trim()) { + throw new Error(`${params.commandName} requires artifactPath`); } - return this.objectStore; + const zipPayload = await readLocalArtifact(this.env.localArtifactsRoot, params.artifactPath); + return await this.prepareSkillInstallFromArchive(params.commandName, slug, zipPayload); } - private async prepareLocalSkillInstall(params: { + private async prepareGlobalSkillInstall(params: { commandName: string; slug: string; - bucket: string; - objectKey: string; + artifactPath: string; }): Promise<{ slug: string; skillDir: string; @@ -332,23 +619,29 @@ export class AgentSkillsService { }> { const slug = this.validateSkillSlug(params.commandName, params.slug); await this.ensureSlugDoesNotCollideWithBuiltIn(params.commandName, slug); - - const bucket = params.bucket?.trim(); - const objectKey = params.objectKey?.trim(); - if (!bucket) { - throw new Error(`${params.commandName} requires bucket`); - } - if (!objectKey) { - throw new Error(`${params.commandName} requires objectKey`); + if (!params.artifactPath?.trim()) { + throw new Error(`${params.commandName} requires artifactPath`); } + const zipPayload = await readLocalArtifact(this.env.localArtifactsRoot, params.artifactPath); + return await this.prepareSkillInstallFromArchive(params.commandName, slug, zipPayload); + } + private async prepareSkillInstallFromArchive( + commandName: string, + slug: string, + zipPayload: Buffer + ): Promise<{ + slug: string; + skillDir: string; + manifest: SkillManifestMetadata; + cleanup?: () => Promise; + }> { const tempRoot = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skill-install-")); try { const skillDir = path.join(tempRoot, "skill"); - const zipPayload = await this.getObjectStore().getObject(bucket, objectKey); - await extractZipToDirectory(zipPayload, skillDir, "skills temp root"); + await extractZipToDirectory(zipPayload, skillDir, "skills temp root", SKILL_ARCHIVE_LIMITS); await this.ensureTopLevelSkillManifest(skillDir); - const manifest = await this.validateUploadedSkillManifest(params.commandName, skillDir, slug); + const manifest = await this.validateUploadedSkillManifest(commandName, skillDir, slug); return { slug, skillDir, @@ -366,8 +659,7 @@ export class AgentSkillsService { private async installPreparedGlobalSkill(params: { skillDir: string; slug: string; - bucket: string; - objectKey: string; + artifactPath: string; force?: boolean; }): Promise { const root = this.env.globalSkillsRoot; @@ -399,7 +691,7 @@ export class AgentSkillsService { await writeFile(path.join(stagingDir, ".openclaw", "source-origin.json"), `${JSON.stringify({ version: 1, source: "daedalus-agent-management-upload", - spec: `s3://${params.bucket.trim()}/${params.objectKey.trim()}`, + spec: `local-artifact:${params.artifactPath.trim()}`, slug: params.slug }, null, 2)}\n`, "utf8"); diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts index 7eb58dc6ccf2bd9ec5e0ad3f2d7186570d715eee..168df0031daf42770a8180ba06ee3fa308e9dc8d 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/agent/workspace-service.ts @@ -1,5 +1,6 @@ import { constants as fsConstants } from "node:fs"; -import { copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat } from "node:fs/promises"; +import { createHash, randomUUID } from "node:crypto"; +import { copyFile, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import AdmZip from "adm-zip"; @@ -13,11 +14,61 @@ import { } from "./support.js"; import { ensureSharedMode, ensureSharedPathPermissions } from "../shared-permissions.js"; import { atomicWriteFile } from "../../tools/atomic-file.js"; +import type { + AgentMarkdownContent, + AgentMarkdownRecord +} from "../../types.js"; const WORKSPACE_TEMPLATE_METADATA_FILES = new Set([ ".daedalus-agent-builtin.fingerprint" ]); const DIRECTORY_ZIP_ATTR = 0o40755 << 16; +const AGENTS_FILE_NAME = "AGENTS.md"; +const MAX_MARKDOWN_BYTES = 10 * 1024 * 1024; +const MAX_MARKDOWN_PATH_BYTES = 512; +const MAX_MARKDOWN_REVISIONS = 20; +const MARKDOWN_ROOTS = new Set(["knowledge", ".learnings", "skills"]); + +function sha256(content: string | Buffer): string { + return createHash("sha256").update(content).digest("hex"); +} + +function isAgentsFile(relativePath: string): boolean { + return relativePath.toLowerCase() === AGENTS_FILE_NAME.toLowerCase(); +} + +function normalizeMarkdownPath(value: string): string { + const raw = String(value ?? "").normalize("NFC"); + if (!raw || raw.includes("\\") || raw.startsWith("/") || /^[A-Za-z]:/.test(raw)) { + throw new Error("Markdown path must be a relative POSIX path"); + } + if (Buffer.byteLength(raw, "utf8") > MAX_MARKDOWN_PATH_BYTES) { + throw new Error(`Markdown path exceeds ${MAX_MARKDOWN_PATH_BYTES} bytes`); + } + const segments = raw.split("/"); + for (const segment of segments) { + if ( + !segment + || segment === "." + || segment === ".." + || /^[. ]+$/.test(segment) + || /[\u0000-\u001f\u007f<>:"|?*]/.test(segment) + || /[. ]$/.test(segment) + ) { + throw new Error(`Invalid Markdown path segment: ${segment || "(empty)"}`); + } + } + if (path.posix.extname(raw).toLowerCase() !== ".md") { + throw new Error("Only Markdown files are supported"); + } + if (segments.length > 1 && !MARKDOWN_ROOTS.has(segments[0]!)) { + throw new Error(`Markdown path is outside managed roots: ${raw}`); + } + if (segments[0] === "skills" && (segments.length < 3 || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(segments[1]!))) { + throw new Error(`Invalid Agent skill Markdown path: ${raw}`); + } + return segments.join("/"); +} export class AgentWorkspaceService { constructor(private readonly env: EnvironmentConfig) {} @@ -36,7 +87,9 @@ export class AgentWorkspaceService { throw error; } + await this.requireRegularAgentsFile(templateDir, "Workspace template"); await this.copyDirectoryContents(templateDir, workspace, options); + await this.requireRegularAgentsFile(workspace, "Agent workspace"); } async updateAgentDocs(workspace: string, content: string): Promise<{ path: string }> { @@ -69,6 +122,179 @@ export class AgentWorkspaceService { await this.updateAgentDocs(workspace, content); } + async listMarkdownFiles(workspace: string): Promise { + const workspaceRoot = await this.requireWorkspaceRoot(workspace); + const records: AgentMarkdownRecord[] = []; + const rootEntries = await readdir(workspaceRoot, { withFileTypes: true }); + + for (const entry of rootEntries) { + if (entry.isFile() && path.extname(entry.name).toLowerCase() === ".md") { + records.push(await this.readMarkdownRecord(workspaceRoot, entry.name)); + } + } + + for (const rootName of MARKDOWN_ROOTS) { + const rootPath = path.join(workspaceRoot, rootName); + let info; + try { + info = await lstat(rootPath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + continue; + } + throw error; + } + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error(`Managed Markdown root is not a regular directory: ${rootName}`); + } + await this.collectMarkdownRecords(workspaceRoot, rootName, records); + } + + return records.sort((left, right) => left.path.localeCompare(right.path)); + } + + async readMarkdownFile(workspace: string, filePath: string): Promise { + const workspaceRoot = await this.requireWorkspaceRoot(workspace); + const relativePath = normalizeMarkdownPath(filePath); + const targetPath = await this.resolveMarkdownPath(workspaceRoot, relativePath, { requireFile: true }); + const content = await readFile(targetPath, "utf8"); + const record = await this.readMarkdownRecord(workspaceRoot, relativePath, content); + return { ...record, content }; + } + + async writeMarkdownFile( + workspace: string, + filePath: string, + content: string, + options: { etag?: string; create?: boolean } = {} + ): Promise { + const workspaceRoot = await this.requireWorkspaceRoot(workspace); + const relativePath = normalizeMarkdownPath(filePath); + const buffer = Buffer.from(String(content ?? ""), "utf8"); + if (buffer.byteLength > MAX_MARKDOWN_BYTES) { + throw new Error("Markdown file cannot exceed 10 MiB"); + } + + const targetPath = await this.resolveMarkdownPath(workspaceRoot, relativePath, { allowMissing: true }); + if (options.create && isAgentsFile(relativePath)) { + throw new Error(`${AGENTS_FILE_NAME} must be edited or restored through its protected file operation`); + } + let previous: Buffer | undefined; + try { + previous = await readFile(targetPath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } + if (options.create && previous) { + throw new Error(`Markdown file already exists: ${relativePath}`); + } + if (!options.create && !previous && !isAgentsFile(relativePath)) { + throw new Error(`Markdown file does not exist: ${relativePath}`); + } + if (isAgentsFile(relativePath) && !previous && !options.create) { + throw new Error(`${AGENTS_FILE_NAME} is missing; restore the system default first`); + } + if (previous && options.etag && sha256(previous) !== options.etag) { + throw new Error("Markdown file was modified by another operation"); + } + if (previous && !options.etag) { + throw new Error("Markdown write requires the current etag"); + } + + if (previous) { + await this.backupMarkdownFile(workspaceRoot, relativePath, previous); + } + const workspaceGroupId = await this.resolveWorkspaceGroupId(workspaceRoot); + await this.ensureMarkdownParentDirectories(workspaceRoot, relativePath, workspaceGroupId); + await atomicWriteFile(targetPath, buffer, {}, SHARED_AGENT_DOCS_FILE_MODE); + await ensureSharedPathPermissions(targetPath, SHARED_AGENT_DOCS_FILE_MODE, workspaceGroupId); + return await this.readMarkdownFile(workspaceRoot, relativePath); + } + + async renameMarkdownFile( + workspace: string, + sourcePath: string, + destinationPath: string, + etag: string + ): Promise { + const workspaceRoot = await this.requireWorkspaceRoot(workspace); + const sourceRelativePath = normalizeMarkdownPath(sourcePath); + const destinationRelativePath = normalizeMarkdownPath(destinationPath); + if (isAgentsFile(sourceRelativePath) || isAgentsFile(destinationRelativePath)) { + throw new Error(`${AGENTS_FILE_NAME} cannot be renamed, moved, or overwritten`); + } + const source = await this.resolveMarkdownPath(workspaceRoot, sourceRelativePath, { requireFile: true }); + const destination = await this.resolveMarkdownPath(workspaceRoot, destinationRelativePath, { allowMissing: true }); + const previous = await readFile(source); + if (sha256(previous) !== etag) { + throw new Error("Markdown file was modified by another operation"); + } + try { + await lstat(destination); + throw new Error(`Markdown destination already exists: ${destinationRelativePath}`); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } + await this.backupMarkdownFile(workspaceRoot, sourceRelativePath, previous); + const workspaceGroupId = await this.resolveWorkspaceGroupId(workspaceRoot); + await this.ensureMarkdownParentDirectories(workspaceRoot, destinationRelativePath, workspaceGroupId); + await rename(source, destination); + await ensureSharedPathPermissions(destination, SHARED_AGENT_DOCS_FILE_MODE, workspaceGroupId); + return await this.readMarkdownFile(workspaceRoot, destinationRelativePath); + } + + async deleteMarkdownFile(workspace: string, filePath: string, etag: string): Promise<{ path: string }> { + const workspaceRoot = await this.requireWorkspaceRoot(workspace); + const relativePath = normalizeMarkdownPath(filePath); + if (isAgentsFile(relativePath)) { + throw new Error(`${AGENTS_FILE_NAME} cannot be deleted`); + } + const targetPath = await this.resolveMarkdownPath(workspaceRoot, relativePath, { requireFile: true }); + const previous = await readFile(targetPath); + if (sha256(previous) !== etag) { + throw new Error("Markdown file was modified by another operation"); + } + await this.backupMarkdownFile(workspaceRoot, relativePath, previous); + await rm(targetPath, { force: false }); + return { path: relativePath }; + } + + async restoreDefaultAgentDocs(workspace: string, etag?: string): Promise { + const workspaceRoot = await this.requireWorkspaceRoot(workspace); + const defaultPath = path.join(this.env.workspaceTemplatesRoot, "default", AGENTS_FILE_NAME); + const defaultInfo = await lstat(defaultPath); + if (defaultInfo.isSymbolicLink() || !defaultInfo.isFile()) { + throw new Error(`System default ${AGENTS_FILE_NAME} is not a regular file`); + } + const content = await readFile(defaultPath, "utf8"); + const targetPath = path.join(workspaceRoot, AGENTS_FILE_NAME); + let previous: Buffer | undefined; + try { + previous = await readFile(targetPath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + } + if (previous && etag && sha256(previous) !== etag) { + throw new Error("Markdown file was modified by another operation"); + } + if (previous && !etag) { + throw new Error("Restoring AGENTS.md requires the current etag"); + } + if (previous) { + await this.backupMarkdownFile(workspaceRoot, AGENTS_FILE_NAME, previous); + } + const workspaceGroupId = await this.resolveWorkspaceGroupId(workspaceRoot); + await atomicWriteFile(targetPath, content, {}, SHARED_AGENT_DOCS_FILE_MODE); + await ensureSharedPathPermissions(targetPath, SHARED_AGENT_DOCS_FILE_MODE, workspaceGroupId); + return await this.readMarkdownFile(workspaceRoot, AGENTS_FILE_NAME); + } + async buildWorkspaceExportArchive(workspace: string): Promise<{ buffer: Buffer; entries: string[]; @@ -141,6 +367,158 @@ export class AgentWorkspaceService { await this.copyDirectoryContentsWithGroup(sourceDir, targetDir, workspaceGroupId, options); } + private async requireRegularAgentsFile(root: string, ownerLabel: string): Promise { + const agentsPath = path.join(root, AGENTS_FILE_NAME); + let info; + try { + info = await lstat(agentsPath); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new Error(`${ownerLabel} must contain top-level ${AGENTS_FILE_NAME}`); + } + throw error; + } + if (info.isSymbolicLink() || !info.isFile()) { + throw new Error(`${ownerLabel} ${AGENTS_FILE_NAME} must be a regular file`); + } + } + + private async requireWorkspaceRoot(workspace: string): Promise { + const workspaceRoot = path.resolve(workspace); + const relative = path.relative(path.resolve(this.env.workspacesRoot), workspaceRoot); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative) || relative.includes(path.sep)) { + throw new Error("Agent workspace is outside the managed workspaces root"); + } + const info = await lstat(workspaceRoot); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error("Agent workspace is not a regular directory"); + } + return workspaceRoot; + } + + private async ensureMarkdownParentDirectories( + workspaceRoot: string, + relativePath: string, + workspaceGroupId: number | undefined + ): Promise { + const segments = relativePath.split("/").slice(0, -1); + let current = workspaceRoot; + for (const segment of segments) { + current = path.join(current, segment); + try { + const info = await lstat(current); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error(`Markdown parent path is not a regular directory: ${relativePath}`); + } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code !== "ENOENT") { + throw error; + } + await mkdir(current, { mode: SHARED_DIRECTORY_MODE }); + } + await ensureSharedPathPermissions(current, SHARED_DIRECTORY_MODE, workspaceGroupId); + } + } + + private async resolveMarkdownPath( + workspaceRoot: string, + relativePath: string, + options: { allowMissing?: boolean; requireFile?: boolean } = {} + ): Promise { + const targetPath = path.resolve(workspaceRoot, ...relativePath.split("/")); + const relative = path.relative(workspaceRoot, targetPath); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Markdown path escapes the Agent workspace"); + } + + const segments = relativePath.split("/"); + let current = workspaceRoot; + for (let index = 0; index < segments.length; index += 1) { + current = path.join(current, segments[index]!); + let info; + try { + info = await lstat(current); + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT" && options.allowMissing) { + break; + } + throw error; + } + if (info.isSymbolicLink()) { + throw new Error(`Markdown path cannot contain symbolic links: ${relativePath}`); + } + if (index < segments.length - 1 && !info.isDirectory()) { + throw new Error(`Markdown parent path is not a directory: ${relativePath}`); + } + if (index === segments.length - 1 && options.requireFile && !info.isFile()) { + throw new Error(`Markdown path is not a file: ${relativePath}`); + } + } + return targetPath; + } + + private async collectMarkdownRecords( + workspaceRoot: string, + relativeDirectory: string, + records: AgentMarkdownRecord[] + ): Promise { + const directoryPath = path.join(workspaceRoot, ...relativeDirectory.split("/")); + const entries = await readdir(directoryPath, { withFileTypes: true }); + for (const entry of entries) { + const relativePath = `${relativeDirectory}/${entry.name}`; + const absolutePath = path.join(directoryPath, entry.name); + const info = await lstat(absolutePath); + if (info.isSymbolicLink()) { + throw new Error(`Managed Markdown tree cannot contain symbolic links: ${relativePath}`); + } + if (info.isDirectory()) { + await this.collectMarkdownRecords(workspaceRoot, relativePath, records); + } else if (info.isFile() && path.extname(entry.name).toLowerCase() === ".md") { + records.push(await this.readMarkdownRecord(workspaceRoot, relativePath)); + } + } + } + + private async readMarkdownRecord( + workspaceRoot: string, + relativePath: string, + knownContent?: string + ): Promise { + const absolutePath = path.join(workspaceRoot, ...relativePath.split("/")); + const [info, content] = await Promise.all([ + stat(absolutePath), + knownContent === undefined ? readFile(absolutePath) : Promise.resolve(Buffer.from(knownContent, "utf8")) + ]); + return { + path: relativePath, + name: path.posix.basename(relativePath), + parentPath: path.posix.dirname(relativePath) === "." ? "" : path.posix.dirname(relativePath), + size: content.byteLength, + etag: sha256(content), + protected: isAgentsFile(relativePath), + readonly: false, + updatedAt: info.mtime.toISOString() + }; + } + + private async backupMarkdownFile(workspaceRoot: string, relativePath: string, content: Buffer): Promise { + const workspaceId = path.basename(workspaceRoot).replace(/[^A-Za-z0-9._-]+/g, "-"); + const pathKey = sha256(relativePath).slice(0, 16); + const revisionRoot = path.join(this.env.dataRoot, "agent-workspace-revisions", workspaceId); + await mkdir(revisionRoot, { recursive: true, mode: SHARED_DIRECTORY_MODE }); + const fileName = `${pathKey}-${new Date().toISOString().replace(/[:.]/g, "-")}-${randomUUID()}.bak`; + await writeFile(path.join(revisionRoot, fileName), content, { mode: SHARED_AGENT_DOCS_FILE_MODE }); + + const revisions = (await readdir(revisionRoot, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.startsWith(`${pathKey}-`)) + .map((entry) => entry.name) + .sort() + .reverse(); + await Promise.all(revisions.slice(MAX_MARKDOWN_REVISIONS).map((entry) => ( + rm(path.join(revisionRoot, entry), { force: true }) + ))); + } + private resolveArchiveSourcePath(workspaceRoot: string, archivePath: string): string { const sourcePath = path.resolve(workspaceRoot, archivePath); const relative = path.relative(workspaceRoot, sourcePath); diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts index 6f8be12db9885665273db3ae00ff77911afc347d..c03888de7c841c1445b45daedccef3110af17eda 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/apps/service.ts @@ -61,8 +61,7 @@ function operationRecord(params: { ontologyPackage?: string; description?: string; status?: "active" | "disabled"; - bucket?: string; - objectKey?: string; + artifactPath?: string; lastImportedAt?: string; operationAt?: string; }): BusinessSystemRecord { @@ -72,11 +71,10 @@ function operationRecord(params: { name: normalizeOptionalText(params.name) || params.systemId, provider: normalizeSystemProvider(params.provider || "hzg", "apps operation"), ontologyName: normalizeOptionalText(params.ontologyName) || params.systemId, - ontologyPackage: normalizeOptionalText(params.ontologyPackage) || normalizeOptionalText(params.objectKey), + ontologyPackage: normalizeOptionalText(params.ontologyPackage) || normalizeOptionalText(params.artifactPath), description: normalizeOptionalText(params.description), status: params.status === "disabled" ? "disabled" : "active", - bucket: normalizeOptionalText(params.bucket), - objectKey: normalizeOptionalText(params.objectKey), + artifactPath: normalizeOptionalText(params.artifactPath), createdAt: now, updatedAt: now, lastImportedAt: params.lastImportedAt @@ -100,18 +98,13 @@ export class BusinessSystemManager { const systemId = normalizeSystemId(params.systemId, name); const provider = normalizeSystemProvider(params.provider, "apps.create"); const ontologyName = normalizeOptionalText(params.ontologyName) || systemId; - const bucket = normalizeOptionalText(params.bucket); - const objectKey = normalizeOptionalText(params.objectKey); - if ((bucket && !objectKey) || (!bucket && objectKey)) { - throw new Error("apps.create requires bucket and objectKey together"); - } + const artifactPath = normalizeOptionalText(params.artifactPath); let operationAt: string | undefined; - if (bucket && objectKey) { + if (artifactPath) { const ontology = await this.ontologyManager.createOntology({ name: ontologyName, - bucket, - objectKey, + artifactPath, replace: params.replaceOntology ?? true }); await this.assertBusinessOntologyIndex(ontology.path); @@ -124,8 +117,7 @@ export class BusinessSystemManager { name, provider, ontologyName, - bucket, - objectKey, + artifactPath, lastImportedAt: operationAt, operationAt }); diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts index 2e387defcfb8f7b3debcc4d72258523555f80465..43c13cab37716685ef3b735f81f0740a108b157b 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/context.ts @@ -1,9 +1,19 @@ import type { AgentCreateParams, AgentDocsUpdateParams, + AgentDocsRestoreDefaultParams, AgentGetParams, + AgentMarkdownDeleteParams, + AgentMarkdownListParams, + AgentMarkdownReadParams, + AgentMarkdownRenameParams, + AgentMarkdownWriteParams, AgentNameSetParams, AgentModelSetParams, + AgentSkillDeleteParams, + AgentSkillExportParams, + AgentSkillInstallLocalParams, + AgentSkillListParams, AgentMutationParams, AgentUpdateParams, AgentWorkspaceExportParams, @@ -30,6 +40,8 @@ import type { LlmProviderTestParams, LlmProviderUpdateParams, ManagedAgentRecord, + McpProbeParams, + McpSyncParams, ManagementRequest, OntologyRecord } from "../types.js"; @@ -45,6 +57,12 @@ export interface AgentCapacityService { setAgentModel(params: AgentModelSetParams): Promise; setAgentName(params: AgentNameSetParams): Promise; updateAgentDocs(params: AgentDocsUpdateParams): Promise<{ path: string }>; + listAgentMarkdown(params: AgentMarkdownListParams): Promise; + readAgentMarkdown(params: AgentMarkdownReadParams): Promise; + writeAgentMarkdown(params: AgentMarkdownWriteParams): Promise; + renameAgentMarkdown(params: AgentMarkdownRenameParams): Promise; + deleteAgentMarkdown(params: AgentMarkdownDeleteParams): Promise; + restoreDefaultAgentDocs(params: AgentDocsRestoreDefaultParams): Promise; updateAgent(params: AgentUpdateParams): Promise; exportAgentWorkspace(params: AgentWorkspaceExportParams): Promise; listLlmProviders(): Promise; @@ -58,11 +76,17 @@ export interface AgentCapacityService { updateLlmModel(params: LlmModelUpdateParams): Promise; deleteLlmModel(params: LlmModelDeleteParams): Promise; listGlobalSkills(): Promise; + listAgentSkills(params: AgentSkillListParams): Promise; + installAgentSkillFromLocal(params: AgentSkillInstallLocalParams): Promise; + deleteAgentSkill(params: AgentSkillDeleteParams): Promise; + exportAgentSkill(params: AgentSkillExportParams): Promise; installGlobalSkillFromLocal(params: GlobalSkillsInstallLocalParams): Promise; deleteGlobalSkill(params: GlobalSkillsDeleteParams): Promise; runDiagnostics(params?: DiagnosticsRunParams): Promise<{ output: string; backend: string }>; gatewayStatus(): Promise; restartGateway(params?: GatewayRestartParams): Promise; + syncMcpConfiguration(params: McpSyncParams): Promise; + probeMcpServer(params: McpProbeParams): Promise; } export interface TemplateCapacityService { diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/mcp/probe.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/mcp/probe.ts new file mode 100644 index 0000000000000000000000000000000000000000..d12021de240804be647dd166c4865c6c51317bae --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/mcp/probe.ts @@ -0,0 +1,6 @@ +import type { McpProbeParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const mcpProbe: CapabilityHandler = async (request, context) => { + return await context.agentManager.probeMcpServer(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/mcp/sync.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/mcp/sync.ts new file mode 100644 index 0000000000000000000000000000000000000000..4eb57366b38c8ff795d991ef97590b28f2ba3bd1 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/mcp/sync.ts @@ -0,0 +1,6 @@ +import type { McpSyncParams } from "../../types.js"; +import { readCapabilityParams, type CapabilityHandler } from "../context.js"; + +export const mcpSync: CapabilityHandler = async (request, context) => { + return await context.agentManager.syncMcpConfiguration(readCapabilityParams(request)); +}; diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts index 3f54013190e2f37d9c302b683a1ac27e757b8994..be212faaf3a91346c97b8e58b711cc4ac70b1468 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/ontology/service.ts @@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto"; import { access, mkdir, rename, rm, stat } from "node:fs/promises"; import path from "node:path"; -import { createS3ObjectStore, extractZipToDirectory, type S3ObjectStore } from "../../tools/s3-archive.js"; +import { extractZipToDirectory } from "../../tools/zip-archive.js"; +import { readLocalArtifact } from "../../tools/local-artifact.js"; import type { EnvironmentConfig, OntologyCreateParams, @@ -18,16 +19,7 @@ function validateOntologyName(name: string): void { } export class OntologyManager { - private objectStore?: S3ObjectStore; - - constructor( - private readonly env: EnvironmentConfig, - dependencies?: { - objectStore?: S3ObjectStore; - } - ) { - this.objectStore = dependencies?.objectStore; - } + constructor(private readonly env: EnvironmentConfig) {} async listOntologies(): Promise<{ items: OntologyRecord[] }> { await mkdir(this.env.ontologyRoot, { recursive: true }); @@ -47,11 +39,8 @@ export class OntologyManager { async createOntology(params: OntologyCreateParams): Promise<{ path: string }> { validateOntologyName(params.name); - if (!params.bucket || params.bucket.trim().length === 0) { - throw new Error("ontology.create requires bucket"); - } - if (!params.objectKey || params.objectKey.trim().length === 0) { - throw new Error("ontology.create requires objectKey"); + if (!params.artifactPath || params.artifactPath.trim().length === 0) { + throw new Error("ontology.create requires artifactPath"); } await mkdir(this.env.ontologyRoot, { recursive: true }); @@ -62,7 +51,7 @@ export class OntologyManager { let backupCreated = false; try { - const zipPayload = await this.getObjectStore().getObject(params.bucket, params.objectKey); + const zipPayload = await readLocalArtifact(this.env.localArtifactsRoot, params.artifactPath); await extractZipToDirectory(zipPayload, stagingDir, "ontology root"); await this.assertTopLevelIndex(stagingDir); @@ -109,14 +98,6 @@ export class OntologyManager { return { path: targetDir }; } - private getObjectStore(): S3ObjectStore { - if (!this.objectStore) { - this.objectStore = createS3ObjectStore(this.env); - } - - return this.objectStore; - } - private async assertTopLevelIndex(ontologyPath: string): Promise { const indexPath = path.join(ontologyPath, "index.md"); try { diff --git a/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts b/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts index 70f9001a414268a5b287dfcb5567773c6925bd0e..afdcf22186fffc16845635434d0bb32a975009e4 100644 --- a/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts +++ b/packages/daedalus-agent-management-web/server/management-src/capabilities/registry.ts @@ -2,11 +2,21 @@ import { agentCreate } from "./agent/create.js"; import { agentDelete } from "./agent/delete.js"; import { agentDisable } from "./agent/disable.js"; import { agentDocsUpdate } from "./agent/docs-update.js"; +import { agentDocsRestoreDefault } from "./agent/docs-restore-default.js"; import { agentEnable } from "./agent/enable.js"; import { agentGet } from "./agent/get.js"; import { agentModelSet } from "./agent/model-set.js"; import { agentNameSet } from "./agent/name-set.js"; import { agentUsageList } from "./agent/usage-list.js"; +import { agentMarkdownDelete } from "./agent/markdown-delete.js"; +import { agentMarkdownList } from "./agent/markdown-list.js"; +import { agentMarkdownRead } from "./agent/markdown-read.js"; +import { agentMarkdownRename } from "./agent/markdown-rename.js"; +import { agentMarkdownWrite } from "./agent/markdown-write.js"; +import { agentSkillsDelete } from "./agent/skills-delete.js"; +import { agentSkillsExport } from "./agent/skills-export.js"; +import { agentSkillsInstallLocal } from "./agent/skills-install-local.js"; +import { agentSkillsList } from "./agent/skills-list.js"; import { agentUpdate } from "./agent/update.js"; import { agentWorkspaceExport } from "./agent/workspace-export.js"; import { agentTemplateCreate } from "./agent-template/create.js"; @@ -29,6 +39,8 @@ import { llmProviderDelete } from "./llm/provider-delete.js"; import { llmProviderTest } from "./llm/provider-test.js"; import { llmProviderUpdate } from "./llm/provider-update.js"; import { servicePing } from "./service/ping.js"; +import { mcpProbe } from "./mcp/probe.js"; +import { mcpSync } from "./mcp/sync.js"; import { skillsGlobalDelete } from "./skills-global/delete.js"; import { skillsGlobalInstallLocal } from "./skills-global/install-local.js"; import { skillsGlobalList } from "./skills-global/list.js"; @@ -48,8 +60,18 @@ export const capabilityHandlers: Record = { "agent.model.set": agentModelSet, "agent.name.set": agentNameSet, "agent.docs.update": agentDocsUpdate, + "agent.docs.restore-default": agentDocsRestoreDefault, "agent.update": agentUpdate, + "agent.workspace.markdown.list": agentMarkdownList, + "agent.workspace.markdown.read": agentMarkdownRead, + "agent.workspace.markdown.write": agentMarkdownWrite, + "agent.workspace.markdown.rename": agentMarkdownRename, + "agent.workspace.markdown.delete": agentMarkdownDelete, "agent.workspace.export": agentWorkspaceExport, + "agent.skills.list": agentSkillsList, + "agent.skills.install.local": agentSkillsInstallLocal, + "agent.skills.delete": agentSkillsDelete, + "agent.skills.export": agentSkillsExport, "agent.template.create": agentTemplateCreate, "agent.template.delete": agentTemplateDelete, "llm.provider.create": llmProviderCreate, @@ -68,6 +90,8 @@ export const capabilityHandlers: Record = { "apps.disable": appsDisable, "apps.delete": appsDelete, "apps.log.record": appsLogRecord, + "mcp.sync": mcpSync, + "mcp.probe": mcpProbe, "diagnostics.run": operationsDiagnosticsRun, "gateway.status": operationsGatewayStatus, "gateway.restart": operationsGatewayRestart diff --git a/packages/daedalus-agent-management-web/server/management-src/env.ts b/packages/daedalus-agent-management-web/server/management-src/env.ts index 6d849bd685a4c94a826f1774d853e01ee2a86574..a882312858141a8bdb87615beb67a75b1894ff88 100644 --- a/packages/daedalus-agent-management-web/server/management-src/env.ts +++ b/packages/daedalus-agent-management-web/server/management-src/env.ts @@ -49,41 +49,6 @@ function ensureBrokerUrl(name: string, value: string): string { return value; } -function ensureOptionalUrl(name: string, value?: string): string | undefined { - if (!value) { - return undefined; - } - - try { - const url = new URL(value); - if (!url.protocol || !url.hostname) { - throw new Error("missing protocol or hostname"); - } - } catch (error: unknown) { - const reason = error instanceof Error ? error.message : String(error); - throw new Error(`${name} must be a valid URL: ${reason}`); - } - - return value; -} - -function readBooleanEnv(name: string, fallback: boolean): boolean { - const value = process.env[name]; - if (!value || value.trim().length === 0) { - return fallback; - } - - const normalized = value.trim().toLowerCase(); - if (normalized === "true" || normalized === "1" || normalized === "yes") { - return true; - } - if (normalized === "false" || normalized === "0" || normalized === "no") { - return false; - } - - throw new Error(`${name} must be one of true,false,1,0,yes,no`); -} - export function renderTopic(template: string, agentId: string): string { return template.replaceAll("{agentId}", agentId); } @@ -123,6 +88,7 @@ function readOptionalPathEnv(name: string, fallback?: string): string | undefine export function loadEnvironmentConfig(): EnvironmentConfig { const dataRoot = readRequiredPathEnv("DA_DATA_DIR"); + const webDataRoot = readOptionalPathEnv("DA_WEB_DATA_DIR", dataRoot) ?? dataRoot; const openclawHome = readRequiredPathEnv("DA_OPENCLAW_HOME"); const openclawStateDir = openclawHome; const openclawConfigPath = joinResolvedPath(openclawHome, "openclaw.json"); @@ -151,14 +117,9 @@ export function loadEnvironmentConfig(): EnvironmentConfig { doctorBin: readOptionalEnv("DOCTOR_COMMAND", DEFAULT_DOCTOR_COMMAND) ?? DEFAULT_DOCTOR_BIN, workspacesRoot: joinResolvedPath(openclawHome, "workspaces"), workspaceTemplatesRoot: readRequiredPathEnv("DA_WORKSPACE_TEMPLATES_DIR"), + localArtifactsRoot: readOptionalPathEnv("DA_LOCAL_ARTIFACTS_DIR", joinResolvedPath(webDataRoot, "local-artifacts")) + ?? joinResolvedPath(webDataRoot, "local-artifacts"), ontologyRoot: readRequiredPathEnv("DA_ONTOLOGY_DIR"), - ontologyS3Endpoint: ensureOptionalUrl("DA_S3_ENDPOINT", readOptionalEnv("DA_S3_ENDPOINT")), - ontologyS3Region: readOptionalEnv("DA_S3_REGION", "local") ?? "local", - ontologyS3AccessKeyId: readOptionalEnv("DA_S3_ACCESS_KEY_ID"), - ontologyS3SecretAccessKey: readOptionalEnv("DA_S3_SECRET_ACCESS_KEY"), - ontologyS3ForcePathStyle: readBooleanEnv("DA_S3_FORCE_PATH_STYLE", true), - adminInboxBucket: readOptionalEnv("DA_S3_ADMIN_INBOX_BUCKET"), - adminOutboxBucket: readOptionalEnv("DA_S3_ADMIN_OUTBOX_BUCKET"), globalSkillsRoot: joinResolvedPath(openclawHome, "skills"), stateFile: joinResolvedPath(dataRoot, "openclaw-management", "managed-agents.json"), mqttBrokerUrl: ensureBrokerUrl("DA_MQTT_CHANNEL_BROKER", managedBroker), diff --git a/packages/daedalus-agent-management-web/server/management-src/tools/local-artifact.ts b/packages/daedalus-agent-management-web/server/management-src/tools/local-artifact.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f92d55cb4c7339a09b4f96fed39bc72ada041f4 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/tools/local-artifact.ts @@ -0,0 +1,108 @@ +import { lstat, mkdir, readFile, realpath, rename, rm, writeFile } from "node:fs/promises"; +import { randomUUID } from "node:crypto"; +import path from "node:path"; + +function normalizeRelativePath(value: string): string { + const raw = String(value || "").trim(); + if (!raw || raw.includes("\\") || path.posix.isAbsolute(raw)) { + throw new Error("Invalid local artifact path"); + } + const segments = raw.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new Error("Invalid local artifact path"); + } + return segments.join("/"); +} + +export async function readLocalArtifact(rootDir: string, relativePath: string): Promise { + const root = path.resolve(rootDir); + const normalized = normalizeRelativePath(relativePath); + const rootInfo = await lstat(root).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new Error(`Local artifact root does not exist: ${root}`); + } + throw error; + }); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new Error(`Local artifact root is not a physical directory: ${root}`); + } + const target = path.resolve(root, ...normalized.split("/")); + const relative = path.relative(root, target); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Local artifact path escapes the configured root"); + } + + let current = root; + for (const segment of normalized.split("/")) { + current = path.join(current, segment); + const info = await lstat(current).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + throw new Error(`Local artifact does not exist: ${normalized}`); + } + throw error; + }); + if (info.isSymbolicLink()) { + throw new Error(`Local artifact path cannot contain symbolic links: ${normalized}`); + } + } + + const resolvedRoot = await realpath(root).catch(() => root); + const resolvedTarget = await realpath(target); + const resolvedRelative = path.relative(resolvedRoot, resolvedTarget); + if (!resolvedRelative || resolvedRelative.startsWith("..") || path.isAbsolute(resolvedRelative)) { + throw new Error("Local artifact path escapes the configured root"); + } + const info = await lstat(resolvedTarget); + if (!info.isFile()) { + throw new Error(`Local artifact is not a file: ${normalized}`); + } + return await readFile(resolvedTarget); +} + +export async function writeLocalArtifact(rootDir: string, relativePath: string, content: Buffer): Promise { + const root = path.resolve(rootDir); + const normalized = normalizeRelativePath(relativePath); + const target = path.resolve(root, ...normalized.split("/")); + const relative = path.relative(root, target); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Local artifact path escapes the configured root"); + } + await mkdir(root, { recursive: true }); + const rootInfo = await lstat(root); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new Error(`Local artifact root is not a physical directory: ${root}`); + } + let current = root; + for (const segment of normalized.split("/").slice(0, -1)) { + current = path.join(current, segment); + const existing = await lstat(current).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return undefined; + } + throw error; + }); + if (!existing) { + await mkdir(current); + } + const info = existing ?? await lstat(current); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error(`Local artifact parent is not a physical directory: ${normalized}`); + } + } + const targetInfo = await lstat(target).catch((error: unknown) => { + if ((error as NodeJS.ErrnoException)?.code === "ENOENT") { + return undefined; + } + throw error; + }); + if (targetInfo?.isSymbolicLink() || (targetInfo && !targetInfo.isFile())) { + throw new Error(`Local artifact target is not a physical file: ${normalized}`); + } + const staging = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(staging, content, { mode: 0o660 }); + await rename(staging, target); + } finally { + await rm(staging, { force: true }).catch(() => undefined); + } +} diff --git a/packages/daedalus-agent-management-web/server/management-src/tools/s3-archive.ts b/packages/daedalus-agent-management-web/server/management-src/tools/s3-archive.ts deleted file mode 100644 index 1bfd5c58005537f2ebe116757b4e79989e953f0c..0000000000000000000000000000000000000000 --- a/packages/daedalus-agent-management-web/server/management-src/tools/s3-archive.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { Readable } from "node:stream"; - -import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; - -import type { EnvironmentConfig } from "../types.js"; - -export interface S3ObjectStore { - getObject(bucket: string, objectKey: string): Promise; - putObject?(bucket: string, objectKey: string, body: Buffer, contentType?: string): Promise; -} - -function requireS3Config(env: EnvironmentConfig): { - endpoint?: string; - region: string; - accessKeyId: string; - secretAccessKey: string; - forcePathStyle: boolean; -} { - if (!env.ontologyS3AccessKeyId) { - throw new Error("Missing required environment variable: DA_S3_ACCESS_KEY_ID"); - } - if (!env.ontologyS3SecretAccessKey) { - throw new Error("Missing required environment variable: DA_S3_SECRET_ACCESS_KEY"); - } - - return { - endpoint: env.ontologyS3Endpoint, - region: env.ontologyS3Region, - accessKeyId: env.ontologyS3AccessKeyId, - secretAccessKey: env.ontologyS3SecretAccessKey, - forcePathStyle: env.ontologyS3ForcePathStyle - }; -} - -async function bodyToBuffer(body: unknown): Promise { - if (!body) { - throw new Error("S3 object body is empty"); - } - - if ( - typeof body === "object" - && "transformToByteArray" in body - && typeof body.transformToByteArray === "function" - ) { - return Buffer.from(await body.transformToByteArray()); - } - - if (body instanceof Readable) { - const chunks: Buffer[] = []; - for await (const chunk of body) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks); - } - - if (body instanceof Uint8Array) { - return Buffer.from(body); - } - - throw new Error("Unsupported S3 object body type"); -} - -export function createS3ObjectStore(env: EnvironmentConfig): S3ObjectStore { - const config = requireS3Config(env); - const client = new S3Client({ - endpoint: config.endpoint, - region: config.region, - forcePathStyle: config.forcePathStyle, - credentials: { - accessKeyId: config.accessKeyId, - secretAccessKey: config.secretAccessKey - } - }); - - return { - async getObject(bucket: string, objectKey: string): Promise { - const response = await client.send(new GetObjectCommand({ - Bucket: bucket, - Key: objectKey - })); - return await bodyToBuffer(response.Body); - }, - - async putObject( - bucket: string, - objectKey: string, - body: Buffer, - contentType = "application/octet-stream" - ): Promise { - await client.send(new PutObjectCommand({ - Bucket: bucket, - Key: objectKey, - Body: body, - ContentType: contentType - })); - } - }; -} - -export async function extractZipToDirectory( - zipPayload: Buffer, - targetDir: string, - rootLabel: string -): Promise { - const AdmZip = (await import("adm-zip")).default; - const zip = new AdmZip(zipPayload); - - await mkdir(targetDir, { recursive: true }); - - for (const entry of zip.getEntries()) { - const normalized = path.normalize(entry.entryName).replace(/^(\.\.(\/|\\|$))+/, ""); - const destination = path.join(targetDir, normalized); - const relative = path.relative(targetDir, destination); - if (relative.startsWith("..") || path.isAbsolute(relative)) { - throw new Error(`Zip entry escapes ${rootLabel}: ${entry.entryName}`); - } - - if (entry.isDirectory) { - await mkdir(destination, { recursive: true }); - continue; - } - - await mkdir(path.dirname(destination), { recursive: true }); - await writeFile(destination, entry.getData()); - } -} diff --git a/packages/daedalus-agent-management-web/server/management-src/tools/zip-archive.ts b/packages/daedalus-agent-management-web/server/management-src/tools/zip-archive.ts new file mode 100644 index 0000000000000000000000000000000000000000..395a879aeb4a6cd77b14a50ce3187e58deaf61cf --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-src/tools/zip-archive.ts @@ -0,0 +1,104 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; + +export interface ZipExtractionLimits { + maxEntries?: number; + maxFileBytes?: number; + maxTotalBytes?: number; + maxCompressionRatio?: number; + maxPathBytes?: number; +} + +export async function extractZipToDirectory( + zipPayload: Buffer, + targetDir: string, + rootLabel: string, + limits: ZipExtractionLimits = {} +): Promise { + const AdmZip = (await import("adm-zip")).default; + const zip = new AdmZip(zipPayload); + const entries = zip.getEntries(); + if (limits.maxEntries !== undefined && entries.length > limits.maxEntries) { + throw new Error(`Zip contains more than ${limits.maxEntries} entries for ${rootLabel}`); + } + + const inspected: Array<{ entry: (typeof entries)[number]; relativePath: string }> = []; + const pathKeys = new Set(); + let totalBytes = 0; + for (const entry of entries) { + const rawName = String(entry.entryName || ""); + const relativePath = rawName.replace(/\/+$/, ""); + if ( + !relativePath + || rawName.includes("\\") + || rawName.startsWith("/") + || /^[A-Za-z]:/.test(rawName) + || (limits.maxPathBytes !== undefined && Buffer.byteLength(relativePath, "utf8") > limits.maxPathBytes) + ) { + throw new Error(`Invalid zip entry for ${rootLabel}: ${rawName || "(empty)"}`); + } + const segments = relativePath.split("/"); + if (segments.some((segment) => ( + !segment + || segment === "." + || segment === ".." + || /^[. ]+$/.test(segment) + || /[\u0000-\u001f\u007f<>:"|?*]/.test(segment) + || /[. ]$/.test(segment) + ))) { + throw new Error(`Invalid zip entry for ${rootLabel}: ${rawName}`); + } + + const pathKey = relativePath.toLocaleLowerCase("en-US"); + if (pathKeys.has(pathKey)) { + throw new Error(`Duplicate or case-conflicting zip entry for ${rootLabel}: ${relativePath}`); + } + pathKeys.add(pathKey); + + const unixMode = (Number(entry.attr || 0) >>> 16) & 0xf000; + if (unixMode === 0xa000) { + throw new Error(`Zip entry cannot be a symbolic link for ${rootLabel}: ${relativePath}`); + } + + const size = Number(entry.header?.size || 0); + const compressedSize = Number(entry.header?.compressedSize || 0); + if (!entry.isDirectory && limits.maxFileBytes !== undefined && size > limits.maxFileBytes) { + throw new Error(`Zip entry exceeds the file size limit for ${rootLabel}: ${relativePath}`); + } + if ( + !entry.isDirectory + && limits.maxCompressionRatio !== undefined + && compressedSize > 0 + && size / compressedSize > limits.maxCompressionRatio + ) { + throw new Error(`Zip entry compression ratio is too high for ${rootLabel}: ${relativePath}`); + } + totalBytes += size; + if (limits.maxTotalBytes !== undefined && totalBytes > limits.maxTotalBytes) { + throw new Error(`Zip expands beyond the total size limit for ${rootLabel}`); + } + inspected.push({ entry, relativePath }); + } + + await mkdir(targetDir, { recursive: true }); + + for (const { entry, relativePath } of inspected) { + const destination = path.resolve(targetDir, ...relativePath.split("/")); + const relative = path.relative(targetDir, destination); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error(`Zip entry escapes ${rootLabel}: ${entry.entryName}`); + } + + if (entry.isDirectory) { + await mkdir(destination, { recursive: true }); + continue; + } + + await mkdir(path.dirname(destination), { recursive: true }); + const content = entry.getData(); + if (limits.maxFileBytes !== undefined && content.byteLength > limits.maxFileBytes) { + throw new Error(`Zip entry exceeds the file size limit for ${rootLabel}: ${relativePath}`); + } + await writeFile(destination, content); + } +} diff --git a/packages/daedalus-agent-management-web/server/management-src/types.ts b/packages/daedalus-agent-management-web/server/management-src/types.ts index 01d34a28a2a2f8a24e2ace18ea8354212c747d1c..7f2cd97146c5b5752796876ebacf3d4a42feb3cf 100644 --- a/packages/daedalus-agent-management-web/server/management-src/types.ts +++ b/packages/daedalus-agent-management-web/server/management-src/types.ts @@ -18,14 +18,8 @@ export interface EnvironmentConfig { doctorBin: string; workspacesRoot: string; workspaceTemplatesRoot: string; + localArtifactsRoot: string; ontologyRoot: string; - ontologyS3Endpoint?: string; - ontologyS3Region: string; - ontologyS3AccessKeyId?: string; - ontologyS3SecretAccessKey?: string; - ontologyS3ForcePathStyle: boolean; - adminInboxBucket?: string; - adminOutboxBucket?: string; globalSkillsRoot: string; stateFile: string; mqttBrokerUrl: string; @@ -74,6 +68,29 @@ export interface ManagedState { agents: ManagedAgentRecord[]; } +export interface McpSyncServer { + slug: string; + config: Record; +} + +export interface McpAgentProjection { + agentId: string; + mode: "all" | "selected"; + serverSlugs: string[]; + previousDenyPatterns: string[]; +} + +export interface McpSyncParams { + servers: McpSyncServer[]; + agents: McpAgentProjection[]; + restart?: boolean; +} + +export interface McpProbeParams { + slug: string; + config?: Record; +} + export interface GlobalSkillRecord { slug: string; path: string; @@ -214,16 +231,90 @@ export interface AgentDocsUpdateParams { content: string; } +export interface AgentMarkdownListParams { + agentId: string; +} + +export interface AgentMarkdownReadParams { + agentId: string; + path: string; +} + +export interface AgentMarkdownWriteParams extends AgentMarkdownReadParams { + content: string; + etag?: string; + create?: boolean; +} + +export interface AgentMarkdownRenameParams { + agentId: string; + sourcePath: string; + destinationPath: string; + etag: string; +} + +export interface AgentMarkdownDeleteParams extends AgentMarkdownReadParams { + etag: string; +} + +export interface AgentDocsRestoreDefaultParams { + agentId: string; + etag?: string; +} + +export interface AgentMarkdownRecord { + path: string; + name: string; + parentPath: string; + size: number; + etag: string; + protected: boolean; + readonly: boolean; + updatedAt: string; +} + +export interface AgentMarkdownContent extends AgentMarkdownRecord { + content: string; +} + +export interface AgentSkillRecord { + slug: string; + name: string; + description: string; + status: "ready" | "invalid"; + etag: string; + updatedAt: string; + invalidReason?: string; +} + +export interface AgentSkillListParams { + agentId: string; +} + +export interface AgentSkillInstallLocalParams { + agentId: string; + slug: string; + artifactPath: string; + force?: boolean; + etag?: string; +} + +export interface AgentSkillDeleteParams { + agentId: string; + slug: string; + etag: string; +} + +export interface AgentSkillExportParams extends AgentSkillDeleteParams { +} + export interface AgentWorkspaceExportParams { agentId: string; - bucket?: string; - objectKey?: string; } export interface AgentWorkspaceExportResult { agentId: string; - bucket: string; - objectKey: string; + artifactPath: string; fileName: string; byteSize: number; entries: string[]; @@ -246,8 +337,7 @@ export interface OntologyRecord { export interface AgentTemplateCreateParams { templateName: string; - bucket: string; - objectKey: string; + artifactPath: string; replace?: boolean; } @@ -257,8 +347,7 @@ export interface AgentTemplateDeleteParams { export interface OntologyCreateParams { name: string; - bucket: string; - objectKey: string; + artifactPath: string; replace?: boolean; } @@ -276,8 +365,7 @@ export interface BusinessSystemRecord { applicationEndpoint?: string; sessionServiceEndpoint?: string; status: "active" | "disabled"; - bucket?: string; - objectKey?: string; + artifactPath?: string; createdAt: string; updatedAt: string; lastImportedAt?: string; @@ -293,8 +381,7 @@ export interface BusinessSystemCreateParams { applicationEndpoint?: string; sessionServiceEndpoint?: string; status?: "active" | "disabled"; - bucket?: string; - objectKey?: string; + artifactPath?: string; replaceOntology?: boolean; } @@ -308,8 +395,7 @@ export interface BusinessSystemUpdateParams { applicationEndpoint?: string; sessionServiceEndpoint?: string; status?: "active" | "disabled"; - bucket?: string; - objectKey?: string; + artifactPath?: string; replaceOntology?: boolean; } @@ -350,8 +436,7 @@ export interface BusinessSystemLogRecordParams { export interface GlobalSkillsInstallLocalParams { slug: string; - bucket: string; - objectKey: string; + artifactPath: string; force?: boolean; } diff --git a/packages/daedalus-agent-management-web/server/management-test/agent-service-coverage.test.ts b/packages/daedalus-agent-management-web/server/management-test/agent-service-coverage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b843d5d613bf1df7967705933251bc14fd1126c --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/agent-service-coverage.test.ts @@ -0,0 +1,148 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { OpenClawManager } from "../management-src/capabilities/agent/service.js"; +import { ManagedStateStore } from "../management-src/capabilities/agent/state-store.js"; +import type { EnvironmentConfig, ManagedAgentRecord } from "../management-src/types.js"; + +function createEnv(root: string): EnvironmentConfig { + const openclawHome = path.join(root, ".openclaw"); + return { + gatewayRestartMode: "container", + dataRoot: root, + openclawHome, + openclawStateDir: openclawHome, + openclawConfigPath: path.join(openclawHome, "openclaw.json"), + tmpDir: path.join(root, "tmp"), + openclawCliTmpDir: path.join(root, "cli-tmp"), + gatewayAuthToken: "test-token", + openclawBin: "openclaw", + doctorBin: "doctor", + workspacesRoot: path.join(openclawHome, "workspaces"), + workspaceTemplatesRoot: path.join(root, "workspace-templates"), + localArtifactsRoot: path.join(root, "local-artifacts"), + ontologyRoot: path.join(root, "ontology"), + globalSkillsRoot: path.join(openclawHome, "skills"), + stateFile: path.join(root, "managed-agents.json"), + mqttBrokerUrl: "mqtts://broker.example.com:8883", + mqttUsername: "mqtt-user", + mqttPassword: "mqtt-pass", + managedMqttBrokerUrl: "mqtts://broker.example.com:8883", + managedMqttUsername: "managed-user", + managedMqttPassword: "managed-pass", + inboundTopicTemplate: "agents/{agentId}/inbound", + outboundTopicTemplate: "agents/{agentId}/outbound" + }; +} + +function createRecord(id: string): ManagedAgentRecord { + return { + id, + accountId: id, + workspace: `/workspaces/${id}`, + inboundTopic: `agents/${id}/inbound`, + outboundTopic: `agents/${id}/outbound`, + enabled: true, + templateName: "default", + updatedAt: "2026-08-17T00:00:00.000Z" + }; +} + +function createManager(env: EnvironmentConfig, stateStore: ManagedStateStore): OpenClawManager { + return new OpenClawManager( + {} as never, + {} as never, + env, + stateStore, + { clawhubCli: {} as never, clawhubBin: "clawhub" } + ); +} + +test("OpenClawManager rejects missing agents and both duplicate sources", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-service-validation-")); + const env = createEnv(root); + const stateStore = new ManagedStateStore(env.stateFile); + const manager = createManager(env, stateStore); + + try { + await mkdir(path.dirname(env.openclawConfigPath), { recursive: true }); + await writeFile(env.openclawConfigPath, "{}", "utf8"); + await assert.rejects(manager.getAgent({ agentId: "missing" }), /Managed agent not found/); + + const existing = createRecord("stored-agent"); + await stateStore.upsertAgent(existing); + await assert.rejects(manager.createAgent({ agentId: existing.id }), /Managed agent already exists/); + await stateStore.removeAgent(existing.id); + + await writeFile(env.openclawConfigPath, JSON.stringify({ + agents: { list: [{ id: "kernel-agent" }] } + }), "utf8"); + await assert.rejects(manager.createAgent({ agentId: "kernel-agent" }), /OpenClaw agent already exists/); + await assert.rejects(manager.enableAgent({ agentId: "missing" }), /Managed agent not found/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("OpenClawManager restores state after topology and MCP failures", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-service-recovery-")); + const env = createEnv(root); + const stateStore = new ManagedStateStore(env.stateFile); + const manager = createManager(env, stateStore); + const record = createRecord("agent-a"); + let restoreConfigCalls = 0; + let restoreWorkspaceCalls = 0; + + (manager as any).reconcileManagedState = async () => [record]; + (manager as any).lifecycleService = { + async snapshotOpenClawConfig() { + return { snapshot: true }; + }, + async restoreOpenClawConfig() { + restoreConfigCalls += 1; + }, + async enableManagedTopology() { + throw new Error("enable failed"); + }, + async disableManagedTopology() { + throw new Error("disable failed"); + }, + async deleteManagedTopology() {}, + async syncMcpConfiguration() { + throw new Error("sync failed"); + } + }; + (manager as any).workspaceService = { + async moveWorkspaceToRecycleBin() { + return { path: path.join(root, "recycle", "agent-a") }; + }, + async restoreWorkspaceFromRecycleBin() { + restoreWorkspaceCalls += 1; + } + }; + (manager as any).operationsService = { + async maybeRefreshGatewayAfterManagedTopologyChange() { + throw new Error("refresh failed"); + } + }; + + try { + await stateStore.upsertAgent(record); + await assert.rejects(manager.enableAgent({ agentId: record.id }), /enable failed/); + await assert.rejects(manager.disableAgent({ agentId: record.id }), /disable failed/); + await assert.rejects(manager.deleteAgent({ agentId: record.id }), /refresh failed/); + await assert.rejects(manager.syncMcpConfiguration({ servers: [], agents: [] }), /sync failed/); + + (manager as any).lifecycleService.syncMcpConfiguration = async () => ({ projections: [] }); + (manager as any).operationsService.maybeRefreshGatewayAfterManagedTopologyChange = async () => undefined; + assert.deepEqual(await manager.syncMcpConfiguration({ servers: [], agents: [] }), { projections: [] }); + + assert.equal(restoreConfigCalls, 4); + assert.equal(restoreWorkspaceCalls, 1); + assert.deepEqual(await stateStore.getAgent(record.id), record); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts b/packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts index 907ac87660fe17eb73ad6b2d02d367e75e4418f2..870978ae03edb76db6686be6044dc2cd98f48dff 100644 --- a/packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/atomic-file.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -20,4 +20,50 @@ test("atomicWriteFile preserves the previous file when the final rename fails", ); assert.equal(await readFile(targetPath, "utf8"), "{\"version\":1}\n"); + await rm(root, { recursive: true, force: true }); +}); + +test("atomicWriteFile writes buffers with a custom mode", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-atomic-success-")); + const targetPath = path.join(root, "nested", "state.bin"); + try { + await atomicWriteFile(targetPath, Buffer.from("content"), {}, 0o600); + assert.equal(await readFile(targetPath, "utf8"), "content"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("atomicWriteFile closes and removes a temporary file after write failure", async () => { + const calls: string[] = []; + const failure = new Error("write failed"); + const handle = { + async writeFile() { + throw failure; + }, + async sync() { + calls.push("sync"); + }, + async close() { + calls.push("close"); + throw new Error("close failed"); + } + }; + + await assert.rejects(atomicWriteFile("/tmp/state.json", "content", { + async mkdir() { + calls.push("mkdir"); + return undefined; + }, + async open() { + calls.push("open"); + return handle as any; + }, + async rm() { + calls.push("rm"); + throw new Error("rm failed"); + } + }), failure); + + assert.deepEqual(calls, ["mkdir", "open", "close", "rm"]); }); diff --git a/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts index 48a0917aead83f8e5e703cf463409bb434971077..66076eceae3af39ee7f273ecaa0ada246050c293 100644 --- a/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/business-system-manager.test.ts @@ -22,12 +22,8 @@ function createEnv(overrides: Partial = {}): EnvironmentConfi doctorBin: "doctor", workspacesRoot: path.join(baseRoot, ".openclaw", "workspaces"), workspaceTemplatesRoot: path.join(baseRoot, "workspace-templates"), + localArtifactsRoot: path.join(baseRoot, "local-artifacts"), ontologyRoot: path.join(baseRoot, "ontology"), - ontologyS3Endpoint: "http://minio.internal:9000", - ontologyS3Region: "local", - ontologyS3AccessKeyId: "minio-access", - ontologyS3SecretAccessKey: "minio-secret", - ontologyS3ForcePathStyle: true, globalSkillsRoot: path.join(baseRoot, ".openclaw", "skills"), stateFile: path.join(baseRoot, "managed-agents.json"), mqttBrokerUrl: "mqtts://channel-broker:8883", @@ -77,7 +73,7 @@ test("BusinessSystemManager removes obsolete generated JSON state files", async test("BusinessSystemManager imports ontology without persisting systems or endpoints", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-")); const stateRoot = path.join(root, "state"); - let imported: { name: string; bucket: string; objectKey: string; replace?: boolean } | undefined; + let imported: { name: string; artifactPath: string; replace?: boolean } | undefined; const manager = new BusinessSystemManager( createEnv({ stateFile: path.join(stateRoot, "managed-agents.json") }), { @@ -100,14 +96,12 @@ test("BusinessSystemManager imports ontology without persisting systems or endpo provider: "HZG", applicationEndpoint: "http://legacy.example.com:8081/wms", sessionServiceEndpoint: "http://legacy.example.com:8080/chat", - bucket: "ontology-artifacts", - objectKey: "crm/crm.zip" + artifactPath: "ontology/crm.zip" }); assert.deepEqual(imported, { name: "crm", - bucket: "ontology-artifacts", - objectKey: "crm/crm.zip", + artifactPath: "ontology/crm.zip", replace: true }); assert.equal(created.provider, "hzg"); @@ -138,8 +132,7 @@ test("BusinessSystemManager rejects imported ontologies without top-level index. systemId: "crm", name: "CRM", provider: "hzg", - bucket: "ontology-artifacts", - objectKey: "crm/crm.zip" + artifactPath: "ontology/crm.zip" }), /top-level index\.md/); }); @@ -184,7 +177,7 @@ test("BusinessSystemManager keeps mutation actions as stateless compatibility ad const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-lifecycle-")); const stateRoot = path.join(root, "state"); let deletedOntologyName: string | undefined; - let imported: { name: string; bucket: string; objectKey: string; replace?: boolean } | undefined; + let imported: { name: string; artifactPath: string; replace?: boolean } | undefined; const manager = new BusinessSystemManager( createEnv({ stateFile: path.join(stateRoot, "managed-agents.json") }), { @@ -205,8 +198,7 @@ test("BusinessSystemManager keeps mutation actions as stateless compatibility ad const updated = await manager.updateSystem({ systemId: "crm", description: "updated", - bucket: "new-bucket", - objectKey: "crm/crm.zip", + artifactPath: "ontology/crm.zip", replaceOntology: true }); const disabled = await manager.disableSystem({ systemId: "crm" }); @@ -215,8 +207,7 @@ test("BusinessSystemManager keeps mutation actions as stateless compatibility ad assert.deepEqual(imported, { name: "crm", - bucket: "new-bucket", - objectKey: "crm/crm.zip", + artifactPath: "ontology/crm.zip", replace: true }); assert.equal(updated.description, "updated"); @@ -227,3 +218,51 @@ test("BusinessSystemManager keeps mutation actions as stateless compatibility ad assert.equal(deletedOntologyName, "crm"); await assertFileDoesNotExist(path.join(stateRoot, "business-systems.json")); }); + +test("BusinessSystemManager validates fallback ids, log timing, and ontology index types", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-system-coverage-")); + const validOntology = path.join(root, "valid-ontology"); + const directoryIndexOntology = path.join(root, "directory-index-ontology"); + const nonDirectoryOntology = path.join(root, "ontology-file"); + await mkdir(validOntology, { recursive: true }); + await writeFile(path.join(validOntology, "index.md"), "# Valid\n", "utf8"); + await mkdir(path.join(directoryIndexOntology, "index.md"), { recursive: true }); + await writeFile(nonDirectoryOntology, "not a directory", "utf8"); + + const manager = new BusinessSystemManager( + createEnv({ stateFile: path.join(root, "state", "managed-agents.json") }), + { + async createOntology(params) { + if (params.artifactPath === "directory-index.zip") return { path: directoryIndexOntology }; + if (params.artifactPath === "not-directory.zip") return { path: nonDirectoryOntology }; + return { path: validOntology }; + }, + async deleteOntology() { + return { path: "" }; + } + } as never + ); + + await assert.rejects(manager.createSystem({ name: " ", provider: "hzg" }), /requires name/); + await assert.rejects(manager.createSystem({ name: "bad id", provider: "hzg" }), /Invalid system id/); + await assert.rejects(manager.enableSystem({ systemId: "" }), /Invalid system id/); + assert.equal((await manager.createSystem({ name: "crm", provider: "hzg" })).id, "crm"); + await assert.rejects(manager.recordLog({ + systemId: "crm", + provider: "hzg", + applicationName: "crm", + commandName: "search", + responseTimeMs: Number.NaN, + success: false + }), /requires responseTimeMs/); + await assert.rejects(manager.createSystem({ + name: "directory-index", + provider: "hzg", + artifactPath: "directory-index.zip" + }), /top-level index\.md/); + await assert.rejects(manager.createSystem({ + name: "not-directory", + provider: "hzg", + artifactPath: "not-directory.zip" + }), /top-level index\.md/); +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts b/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts index cf41a765ea9775ad0800901c2c3ae00a52899691..346a5c86156f5379449508ea9b712015236a7158 100644 --- a/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/capabilities-registry.test.ts @@ -20,8 +20,18 @@ const expectedActions = [ "agent.model.set", "agent.name.set", "agent.docs.update", + "agent.docs.restore-default", "agent.update", "agent.workspace.export", + "agent.workspace.markdown.list", + "agent.workspace.markdown.read", + "agent.workspace.markdown.write", + "agent.workspace.markdown.rename", + "agent.workspace.markdown.delete", + "agent.skills.list", + "agent.skills.install.local", + "agent.skills.delete", + "agent.skills.export", "agent.template.create", "agent.template.delete", "llm.provider.create", @@ -40,6 +50,8 @@ const expectedActions = [ "apps.disable", "apps.delete", "apps.log.record", + "mcp.sync", + "mcp.probe", "diagnostics.run", "gateway.status", "gateway.restart" @@ -73,6 +85,12 @@ function createContext(calls: Array<{ method: string; args: unknown[] }>): Capab setAgentModel: respond("agentManager.setAgentModel"), setAgentName: respond("agentManager.setAgentName"), updateAgentDocs: respond("agentManager.updateAgentDocs") as never, + listAgentMarkdown: respond("agentManager.listAgentMarkdown"), + readAgentMarkdown: respond("agentManager.readAgentMarkdown"), + writeAgentMarkdown: respond("agentManager.writeAgentMarkdown"), + renameAgentMarkdown: respond("agentManager.renameAgentMarkdown"), + deleteAgentMarkdown: respond("agentManager.deleteAgentMarkdown"), + restoreDefaultAgentDocs: respond("agentManager.restoreDefaultAgentDocs"), updateAgent: respond("agentManager.updateAgent"), exportAgentWorkspace: respond("agentManager.exportAgentWorkspace") as never, listLlmProviders: respond("agentManager.listLlmProviders"), @@ -86,11 +104,17 @@ function createContext(calls: Array<{ method: string; args: unknown[] }>): Capab updateLlmModel: respond("agentManager.updateLlmModel"), deleteLlmModel: respond("agentManager.deleteLlmModel"), listGlobalSkills: respond("agentManager.listGlobalSkills") as never, + listAgentSkills: respond("agentManager.listAgentSkills"), + installAgentSkillFromLocal: respond("agentManager.installAgentSkillFromLocal"), + deleteAgentSkill: respond("agentManager.deleteAgentSkill"), + exportAgentSkill: respond("agentManager.exportAgentSkill"), installGlobalSkillFromLocal: respond("agentManager.installGlobalSkillFromLocal"), deleteGlobalSkill: respond("agentManager.deleteGlobalSkill"), runDiagnostics: respond("agentManager.runDiagnostics") as never, gatewayStatus: respond("agentManager.gatewayStatus"), restartGateway: respond("agentManager.restartGateway") + ,syncMcpConfiguration: respond("agentManager.syncMcpConfiguration") + ,probeMcpServer: respond("agentManager.probeMcpServer") }, templateManager: { listTemplates: respond("templateManager.listTemplates") as never, @@ -218,6 +242,12 @@ test("capability handlers delegate to the expected service methods", async () => expectedMethod: "agentManager.updateAgentDocs", expectedArgs: [{ agentId: "lowcode", content: "# AGENTS" }] }, + { + action: "agent.docs.restore-default", + params: { agentId: "lowcode", etag: "old-hash" }, + expectedMethod: "agentManager.restoreDefaultAgentDocs", + expectedArgs: [{ agentId: "lowcode", etag: "old-hash" }] + }, { action: "agent.update", params: { agentId: "lowcode", name: "Lowcode Agent Updated", enabled: false }, @@ -230,11 +260,65 @@ test("capability handlers delegate to the expected service methods", async () => expectedMethod: "agentManager.exportAgentWorkspace", expectedArgs: [{ agentId: "lowcode", bucket: "admin-out", objectKey: "agent-workspace/lowcode.zip" }] }, + { + action: "agent.workspace.markdown.list", + params: { agentId: "lowcode" }, + expectedMethod: "agentManager.listAgentMarkdown", + expectedArgs: [{ agentId: "lowcode" }] + }, + { + action: "agent.workspace.markdown.read", + params: { agentId: "lowcode", path: "SOUL.md" }, + expectedMethod: "agentManager.readAgentMarkdown", + expectedArgs: [{ agentId: "lowcode", path: "SOUL.md" }] + }, + { + action: "agent.workspace.markdown.write", + params: { agentId: "lowcode", path: "SOUL.md", content: "# Soul", etag: "old-hash" }, + expectedMethod: "agentManager.writeAgentMarkdown", + expectedArgs: [{ agentId: "lowcode", path: "SOUL.md", content: "# Soul", etag: "old-hash" }] + }, + { + action: "agent.workspace.markdown.rename", + params: { agentId: "lowcode", sourcePath: "SOUL.md", destinationPath: "PROFILE.md", etag: "old-hash" }, + expectedMethod: "agentManager.renameAgentMarkdown", + expectedArgs: [{ agentId: "lowcode", sourcePath: "SOUL.md", destinationPath: "PROFILE.md", etag: "old-hash" }] + }, + { + action: "agent.workspace.markdown.delete", + params: { agentId: "lowcode", path: "PROFILE.md", etag: "old-hash" }, + expectedMethod: "agentManager.deleteAgentMarkdown", + expectedArgs: [{ agentId: "lowcode", path: "PROFILE.md", etag: "old-hash" }] + }, + { + action: "agent.skills.list", + params: { agentId: "lowcode" }, + expectedMethod: "agentManager.listAgentSkills", + expectedArgs: [{ agentId: "lowcode" }] + }, + { + action: "agent.skills.install.local", + params: { agentId: "lowcode", slug: "report", artifactPath: "agent-skill/report.zip" }, + expectedMethod: "agentManager.installAgentSkillFromLocal", + expectedArgs: [{ agentId: "lowcode", slug: "report", artifactPath: "agent-skill/report.zip" }] + }, + { + action: "agent.skills.delete", + params: { agentId: "lowcode", slug: "report", etag: "skill-hash" }, + expectedMethod: "agentManager.deleteAgentSkill", + expectedArgs: [{ agentId: "lowcode", slug: "report", etag: "skill-hash" }] + }, + { + action: "agent.skills.export", + params: { agentId: "lowcode", slug: "report", etag: "skill-hash" }, + expectedMethod: "agentManager.exportAgentSkill", + expectedArgs: [{ agentId: "lowcode", slug: "report", etag: "skill-hash" }] + }, { action: "agent.template.create", - params: { templateName: "sales", bucket: "bucket", objectKey: "sales.zip" }, + params: { templateName: "sales", artifactPath: "template/sales.zip" }, expectedMethod: "templateManager.createTemplate", - expectedArgs: [{ templateName: "sales", bucket: "bucket", objectKey: "sales.zip" }] + expectedArgs: [{ templateName: "sales", artifactPath: "template/sales.zip" }] }, { action: "agent.template.delete", @@ -297,9 +381,9 @@ test("capability handlers delegate to the expected service methods", async () => }, { action: "skills.global.install.local", - params: { slug: "private-calendar-helper", bucket: "admin-in", objectKey: "skills/calendar.zip", force: true }, + params: { slug: "private-calendar-helper", artifactPath: "skills/calendar.zip", force: true }, expectedMethod: "agentManager.installGlobalSkillFromLocal", - expectedArgs: [{ slug: "private-calendar-helper", bucket: "admin-in", objectKey: "skills/calendar.zip", force: true }] + expectedArgs: [{ slug: "private-calendar-helper", artifactPath: "skills/calendar.zip", force: true }] }, { action: "skills.global.delete", @@ -356,6 +440,18 @@ test("capability handlers delegate to the expected service methods", async () => expectedMethod: "agentManager.runDiagnostics", expectedArgs: [{}] }, + { + action: "mcp.sync", + params: { servers: [], agents: [], restart: false }, + expectedMethod: "agentManager.syncMcpConfiguration", + expectedArgs: [{ servers: [], agents: [], restart: false }] + }, + { + action: "mcp.probe", + params: { slug: "docs", config: { enabled: true, url: "https://mcp.example.com" } }, + expectedMethod: "agentManager.probeMcpServer", + expectedArgs: [{ slug: "docs", config: { enabled: true, url: "https://mcp.example.com" } }] + }, { action: "gateway.status", expectedMethod: "agentManager.gatewayStatus", diff --git a/packages/daedalus-agent-management-web/server/management-test/coverage-contract.test.ts b/packages/daedalus-agent-management-web/server/management-test/coverage-contract.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f1ac18958101cedfcacba7b4423ece3f03866a1 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/coverage-contract.test.ts @@ -0,0 +1,640 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, writeFile, mkdir } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { chinaDateKey, toChinaISOString } from "../management-src/china-time.js"; +import { readBuiltInComponentNames } from "../management-src/capabilities/built-in-components.js"; +import { readCapabilityParams } from "../management-src/capabilities/context.js"; +import { catalogSnapshot } from "../management-src/capabilities/catalog/snapshot.js"; +import { + mqttAccountPath, + normalizeModelConfig, + quoteConfigKey, + resolveAgentCreateOptions, + resolveBundledClawhubBin, + validateAgentId, + validateTemplateName +} from "../management-src/capabilities/agent/support.js"; +import { agentUsageList } from "../management-src/capabilities/agent/usage-list.js"; +import { extractZipToDirectory } from "../management-src/tools/zip-archive.js"; +import { readLocalArtifact } from "../management-src/tools/local-artifact.js"; +import { versionList } from "../management-src/capabilities/version/list.js"; +import { + buildHealthReport, + buildOpenClawCliEnv, + collectConfigIssues, + createManagementActionRuntime, + shouldBypassManagementQueue +} from "../management-src/service.js"; +import { loadEnvironmentConfig, renderTopic } from "../management-src/env.js"; +import { createLogger, errorDetails } from "../management-src/logger.js"; +import { capabilityHandlers } from "../management-src/capabilities/registry.js"; +import { + ensureSharedLinkOwnership, + ensureSharedMode, + ensureSharedPathPermissions, + isIgnorableSharedChmodError, + isMissingPathError +} from "../management-src/capabilities/shared-permissions.js"; + +test("formats China time values and rejects invalid dates", () => { + assert.equal(toChinaISOString(new Date("2026-01-02T03:04:05.006Z")), "2026-01-02T11:04:05.006+08:00"); + assert.equal(toChinaISOString("2026-12-31T20:00:00.000Z"), "2027-01-01T04:00:00.000+08:00"); + assert.equal(toChinaISOString(0), "1970-01-01T08:00:00.000+08:00"); + assert.match(toChinaISOString(), /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+08:00$/); + assert.equal(chinaDateKey(new Date("2026-01-02T03:04:05.006Z")), "2026-01-02"); + assert.match(chinaDateKey(), /^\d{4}-\d{2}-\d{2}$/); + assert.throws(() => toChinaISOString("invalid"), /Invalid date value/); +}); + +test("validates and normalizes agent support parameters", () => { + assert.doesNotThrow(() => validateAgentId("agent_01-A")); + assert.throws(() => validateAgentId("bad id"), /Invalid agent id/); + assert.throws(() => validateAgentId("main"), /Reserved agent id/); + assert.throws(() => validateAgentId("cli"), /Reserved agent id/); + assert.doesNotThrow(() => validateTemplateName("template_01-A")); + assert.throws(() => validateTemplateName("bad template"), /Invalid template name/); + + assert.deepEqual(normalizeModelConfig("provider/model"), { primary: "provider/model" }); + assert.deepEqual(normalizeModelConfig({ primary: "primary" }), { primary: "primary", fallbacks: [] }); + assert.deepEqual(normalizeModelConfig({ primary: "primary", fallbacks: ["fallback"] }), { + primary: "primary", + fallbacks: ["fallback"] + }); + assert.equal(quoteConfigKey('a\\b"c'), '["a\\\\b\\"c"]'); + assert.equal(mqttAccountPath("account"), 'channels["daedalus-agent-mqtt-channel"].accounts["account"]'); + assert.match(resolveBundledClawhubBin(), /clawhub/); +}); + +test("resolves default and explicit agent creation options", () => { + const env = { + workspacesRoot: "/workspaces", + inboundTopicTemplate: "agents/{agentId}/in", + outboundTopicTemplate: "agents/{agentId}/out" + } as any; + + assert.deepEqual(resolveAgentCreateOptions(env, { agentId: "alpha" } as any), { + agentId: "alpha", + name: "alpha", + accountId: "alpha", + workspace: path.join("/workspaces", "alpha"), + templateName: "default", + inboundTopic: "agents/alpha/in", + outboundTopic: "agents/alpha/out", + model: undefined + }); + + assert.deepEqual(resolveAgentCreateOptions(env, { + agentId: "beta", + agentName: " Beta Agent ", + name: "ignored", + accountId: "account-beta", + workspace: "/custom/workspace", + templateName: " custom ", + inboundTopic: "custom/in", + outboundTopic: "custom/out", + model: "provider/model" + } as any), { + agentId: "beta", + name: "Beta Agent", + accountId: "account-beta", + workspace: "/custom/workspace", + templateName: "custom", + inboundTopic: "custom/in", + outboundTopic: "custom/out", + model: "provider/model" + }); + + assert.equal(resolveAgentCreateOptions(env, { + agentId: "gamma", + name: " Gamma Name " + } as any).name, "Gamma Name"); + assert.equal(resolveAgentCreateOptions(env, { + agentId: "delta", + agentName: " ", + name: "ignored" + } as any).name, "delta"); +}); + +test("reads and filters built-in component manifests", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "management-built-ins-")); + const manifestPath = path.join(root, "build-in-component.json"); + const env = { dataRoot: root } as any; + + try { + assert.deepEqual([...await readBuiltInComponentNames(env, "skill")], []); + await writeFile(manifestPath, "invalid", "utf8"); + assert.deepEqual([...await readBuiltInComponentNames(env, "skill")], []); + await writeFile(manifestPath, JSON.stringify({ skill: "not-array" }), "utf8"); + assert.deepEqual([...await readBuiltInComponentNames(env, "skill")], []); + await writeFile(manifestPath, JSON.stringify({ skill: [" one ", "", 1, null, "two"] }), "utf8"); + assert.deepEqual([...await readBuiltInComponentNames(env, "skill")], ["one", "two"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("defaults capability params and forwards agent usage queries", async () => { + assert.deepEqual(readCapabilityParams({ params: { key: "value" } } as any), { key: "value" }); + assert.deepEqual(readCapabilityParams({} as any), {}); + + const calls: unknown[] = []; + const context = { + agentManager: { + async queryAllUsage(params: unknown) { + calls.push(params); + return { ok: true }; + } + } + } as any; + assert.deepEqual(await agentUsageList({ params: { agentIds: ["alpha"] } } as any, context), { ok: true }); + assert.deepEqual(await agentUsageList({} as any, context), { ok: true }); + assert.deepEqual(calls, [{ agentIds: ["alpha"] }, {}]); +}); + +test("maps catalog snapshots across empty, legacy, and model variants", async () => { + const result = await catalogSnapshot({} as any, { + agentManager: { + async listLlmProviders() { + return { + items: [ + { + providerId: "provider-a", + api: "openai-completions", + baseUrl: "", + models: [{ modelId: "model-a", modelRef: "", contextTokens: undefined, maxTokens: undefined }] + }, + { providerId: "provider-b", models: null } + ] + }; + }, + async listAgents() { + return { + items: [ + { agentId: "string-model", model: " provider/model " }, + { agentId: "fallback-model", model: { primary: "", fallbacks: ["", "fallback/model"] } }, + { agentId: "empty-model", model: { primary: "", fallbacks: [] } }, + { agentId: "invalid-fallbacks", model: { primary: "", fallbacks: null } }, + { agentId: "no-model", model: null } + ] + }; + }, + async listGlobalSkills() { + return { items: [{ slug: "", isBuiltIn: false, "is-built-in": true }] }; + } + }, + templateManager: { + async listTemplates() { + return { items: [{ templateName: "legacy", isBuiltIn: false, "is-built-in": true }] }; + } + }, + ontologyManager: { + async listOntologies() { + return { items: [{ name: "legacy", isBuiltIn: false, "is-built-in": true }] }; + } + } + } as any) as any; + + assert.equal(result.catalogs.llmProviders.items[0].api, "openaiCompletions"); + assert.deepEqual(result.catalogs.llmProviders.items[0].models[0].limits, { + contextTokens: null, + maxOutputTokens: null + }); + assert.deepEqual(result.catalogs.llmProviders.items[1].models, []); + assert.equal(result.catalogs.agents.items[0].modelRef, "provider/model"); + assert.equal(result.catalogs.agents.items[1].fallbackModelRef, "fallback/model"); + assert.equal(result.catalogs.agents.items[2].fallbackModelRef, ""); + assert.equal(result.catalogs.agents.items[3].fallbackModelRef, ""); + assert.equal(result.catalogs.agents.items[4].modelRef, ""); + assert.equal(result.catalogs.templates.items[0].builtIn, true); + assert.equal(result.catalogs.skills.items[0].builtIn, true); + assert.equal(result.catalogs.ontologies.items[0].builtIn, true); +}); + +test("rejects unsafe and oversized zip entries before extraction", async () => { + const AdmZip = (await import("adm-zip")).default; + const root = await mkdtemp(path.join(os.tmpdir(), "management-zip-contract-")); + const makeZip = (name: string, content = "x") => { + const zip = new AdmZip(); + zip.addFile(name, Buffer.from(content, "utf8")); + return zip.toBuffer(); + }; + + try { + await assert.rejects(extractZipToDirectory(makeZip(""), path.join(root, "out"), "test"), /\(empty\)/); + await assert.rejects(extractZipToDirectory(makeZip("C:drive"), path.join(root, "out"), "test"), /Invalid zip entry/); + await assert.rejects(extractZipToDirectory(makeZip("bad:name"), path.join(root, "out"), "test"), /Invalid zip entry/); + await assert.rejects(extractZipToDirectory(makeZip("name "), path.join(root, "out"), "test"), /Invalid zip entry/); + await assert.rejects(extractZipToDirectory(makeZip("same", "a"), path.join(root, "out"), "test", { maxEntries: 0 }), /more than 0 entries/); + await assert.rejects(extractZipToDirectory(makeZip("long", "1234"), path.join(root, "out"), "test", { maxFileBytes: 2 }), /file size limit/); + await assert.rejects(extractZipToDirectory(makeZip("long", "1234"), path.join(root, "out"), "test", { maxTotalBytes: 2 }), /total size limit/); + await assert.rejects(extractZipToDirectory(makeZip("long", "1234"), path.join(root, "out"), "test", { maxPathBytes: 2 }), /Invalid zip entry/); + + const duplicate = new AdmZip(); + duplicate.addFile("README.md", Buffer.from("first", "utf8")); + duplicate.addFile("readme.md", Buffer.from("second", "utf8")); + await assert.rejects( + extractZipToDirectory(duplicate.toBuffer(), path.join(root, "duplicate"), "test"), + /Duplicate or case-conflicting zip entry/ + ); + + const symbolicLink = new AdmZip(); + symbolicLink.addFile("link", Buffer.from("target", "utf8")); + symbolicLink.getEntry("link")!.attr = (0xa000 << 16) >>> 0; + await assert.rejects( + extractZipToDirectory(symbolicLink.toBuffer(), path.join(root, "symlink"), "test"), + /cannot be a symbolic link/ + ); + + const compressed = new AdmZip(); + compressed.addFile("large.txt", Buffer.alloc(10_000, 65)); + await assert.rejects( + extractZipToDirectory(compressed.toBuffer(), path.join(root, "compressed"), "test", { maxCompressionRatio: 2 }), + /compression ratio is too high/ + ); + + const valid = new AdmZip(); + valid.addFile("nested/data.txt", Buffer.from("payload", "utf8")); + valid.addFile("empty/", Buffer.alloc(0)); + valid.getEntry("nested/data.txt")!.attr = 0; + await extractZipToDirectory(valid.toBuffer(), path.join(root, "valid"), "test"); + assert.equal(await readFile(path.join(root, "valid", "nested", "data.txt"), "utf8"), "payload"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("enforces local artifact root boundaries and symlink protections", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "management-artifact-contract-")); + try { + await mkdir(path.join(root, "nested"), { recursive: true }); + await writeFile(path.join(root, "nested", "ok.txt"), "ok", "utf8"); + assert.equal((await readLocalArtifact(root, "nested/ok.txt")).toString(), "ok"); + await assert.rejects(readLocalArtifact(root, "../outside.txt"), /artifact path/i); + await assert.rejects(readLocalArtifact(root, ""), /artifact path/i); + await assert.rejects(readLocalArtifact(root, "nested/../missing.txt"), /artifact/i); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("version capability reports gateway and plugin status variants", async () => { + const originalHome = process.env.DA_OPENCLAW_HOME; + const root = await mkdtemp(path.join(os.tmpdir(), "management-version-contract-")); + try { + process.env.DA_OPENCLAW_HOME = root; + await mkdir(root, { recursive: true }); + await writeFile(path.join(root, "openclaw.json"), JSON.stringify({ + plugins: { + allow: ["daedalus-agent-mqtt-channel"], + load: { paths: ["/plugins/daedalus-agent-mqtt-channel"] }, + entries: { "daedalus-agent-mqtt-channel": { enabled: true } } + } + }), "utf8"); + + const normal = await versionList({} as any, { + agentManager: { async gatewayStatus() { return { ok: true, plugins: [{ id: "daedalus-agent-mqtt-channel", loaded: true }] }; } } + } as any); + assert.equal((normal.items as any[]).some((item) => item.status === "normal"), true); + + const unhealthy = await versionList({} as any, { + agentManager: { async gatewayStatus() { return { status: "down", plugins: { "daedalus-agent-mqtt-channel": { enabled: false } } }; } } + } as any); + assert.equal((unhealthy.items as any[]).find((item) => item.packageName === "openclaw")?.status, "error"); + + const missingStatus = await versionList({} as any, { + agentManager: { async gatewayStatus() { return null; } } + } as any); + assert.equal((missingStatus.items as any[]).find((item) => item.packageName === "openclaw")?.status, "error"); + + const rpcReady = await versionList({} as any, { + agentManager: { async gatewayStatus() { return { rpc: { status: "ready" } }; } } + } as any); + assert.equal((rpcReady.items as any[]).find((item) => item.packageName === "openclaw")?.status, "normal"); + + const rpcFailed = await versionList({} as any, { + agentManager: { async gatewayStatus() { return { rpc: { healthy: false } }; } } + } as any); + assert.equal((rpcFailed.items as any[]).find((item) => item.packageName === "openclaw")?.status, "error"); + + const pluginBoolean = await versionList({} as any, { + agentManager: { + async gatewayStatus() { + return { ok: true, plugins: { "daedalus-agent-mqtt-channel": true } }; + } + } + } as any); + assert.equal((pluginBoolean.items as any[]).find((item) => item.packageName === "daedalus-agent-mqtt-channel")?.status, "normal"); + + const pluginPrimitive = await versionList({} as any, { + agentManager: { + async gatewayStatus() { + return { healthy: true, nested: ["daedalus-agent-mqtt-channel"] }; + } + } + } as any); + assert.equal((pluginPrimitive.items as any[]).find((item) => item.packageName === "daedalus-agent-mqtt-channel")?.status, "normal"); + + await writeFile(path.join(root, "openclaw.json"), "invalid json", "utf8"); + const malformedConfig = await versionList({} as any, { + agentManager: { + async gatewayStatus() { + return { status: "healthy", plugins: { "daedalus-agent-mqtt-channel": false } }; + } + } + } as any); + assert.equal((malformedConfig.items as any[]).find((item) => item.packageName === "daedalus-agent-mqtt-channel")?.status, "error"); + + const failed = await versionList({} as any, { + agentManager: { async gatewayStatus() { throw new Error("openclaw unavailable"); } } + } as any); + assert.match((failed.items as any[]).find((item) => item.packageName === "openclaw")?.statusMessage, /内核/); + + const failedWithoutError = await versionList({} as any, { + agentManager: { async gatewayStatus() { throw "unavailable"; } } + } as any); + assert.equal((failedWithoutError.items as any[]).find((item) => item.packageName === "openclaw")?.statusMessage, "内核状态检查失败"); + } finally { + if (originalHome === undefined) delete process.env.DA_OPENCLAW_HOME; + else process.env.DA_OPENCLAW_HOME = originalHome; + await rm(root, { recursive: true, force: true }); + } +}); + +test("treats missing and platform-specific permission errors as ignorable", async () => { + assert.equal(isMissingPathError({ code: "ENOENT" }), true); + assert.equal(isMissingPathError({ code: "EACCES" }), false); + assert.equal(isIgnorableSharedChmodError({ code: "EPERM" }), true); + assert.equal(isIgnorableSharedChmodError({ code: "EINVAL" }), true); + assert.equal(isIgnorableSharedChmodError({ code: "EIO" }), false); + assert.equal(isIgnorableSharedChmodError({}), false); + + await ensureSharedMode(path.join(os.tmpdir(), "definitely-missing-permission-target"), 0o640); + await ensureSharedPathPermissions(path.join(os.tmpdir(), "definitely-missing-permission-target"), 0o640); + await ensureSharedLinkOwnership(path.join(os.tmpdir(), "definitely-missing-permission-target")); + + const ownedPath = await mkdtemp(path.join(os.tmpdir(), "management-shared-permissions-")); + const originalGetuid = process.getuid; + try { + Object.defineProperty(process, "getuid", { + configurable: true, + value: () => 0 + }); + await ensureSharedPathPermissions(ownedPath, 0o770, 0); + await ensureSharedLinkOwnership(ownedPath, 0); + await assert.rejects(ensureSharedMode(ownedPath, -1), /mode|range/i); + } finally { + if (originalGetuid) { + Object.defineProperty(process, "getuid", { configurable: true, value: originalGetuid }); + } else { + delete (process as NodeJS.Process & { getuid?: () => number }).getuid; + } + await rm(ownedPath, { recursive: true, force: true }); + } +}); + +test("loads management environment defaults and path variants", () => { + const previous = { ...process.env }; + const root = path.join(os.tmpdir(), "management-env-contract"); + try { + process.env.DA_DATA_DIR = root; + process.env.DA_OPENCLAW_HOME = "~/openclaw-contract"; + process.env.DA_TMP_DIR = "~/tmp-contract"; + process.env.DA_WORKSPACE_TEMPLATES_DIR = "~/templates-contract"; + process.env.DA_ONTOLOGY_DIR = "C:\\ontology-contract"; + process.env.DA_MQTT_CHANNEL_BROKER = "mqtt://broker.example:1883"; + process.env.DA_MQTT_CHANNEL_USERNAME = " user "; + process.env.DA_MQTT_CHANNEL_PASSWORD = " pass "; + process.env.DA_CHANNEL_INBOUND_TOPIC_TEMPLATE = "in/{agentId}"; + process.env.DA_CHANNEL_OUTBOUND_TOPIC_TEMPLATE = "out/{agentId}"; + delete process.env.DA_WEB_DATA_DIR; + delete process.env.DA_LOCAL_ARTIFACTS_DIR; + delete process.env.DA_OPENCLAW_CLI_TMP_DIR; + delete process.env.OPENCLAW_COMMAND; + delete process.env.DOCTOR_COMMAND; + + const env = loadEnvironmentConfig(); + assert.equal(env.mqttUsername, "user"); + assert.equal(env.openclawBin, "/opt/daedalus-agent-toolchain/bin/openclaw"); + assert.equal(env.doctorBin, "/opt/daedalus-agent-toolchain/bin/doctor"); + assert.equal(env.inboundTopicTemplate, "in/{agentId}"); + assert.equal(renderTopic(env.inboundTopicTemplate, "agent-1"), "in/agent-1"); + assert.match(env.ontologyRoot, /ontology-contract/); + assert.match(env.localArtifactsRoot, /local-artifacts/); + + process.env.DA_WEB_DATA_DIR = ""; + process.env.DA_OPENCLAW_CLI_TMP_DIR = ""; + process.env.DA_LOCAL_ARTIFACTS_DIR = ""; + process.env.OPENCLAW_COMMAND = ""; + process.env.DOCTOR_COMMAND = ""; + process.env.DA_TMP_DIR = "~"; + const fallbackEnv = loadEnvironmentConfig(); + assert.equal(fallbackEnv.tmpDir, os.homedir()); + assert.equal(fallbackEnv.openclawCliTmpDir, "/tmp/daedalus-agent-openclaw-cli"); + assert.equal(fallbackEnv.openclawBin, "openclaw"); + assert.equal(fallbackEnv.doctorBin, "doctor"); + assert.match(fallbackEnv.localArtifactsRoot, /local-artifacts/); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in previous)) delete process.env[key]; + } + Object.assign(process.env, previous); + } +}); + +test("rejects invalid management environment values", () => { + const previous = { ...process.env }; + try { + for (const key of [ + "DA_DATA_DIR", "DA_OPENCLAW_HOME", "DA_TMP_DIR", "DA_WORKSPACE_TEMPLATES_DIR", + "DA_ONTOLOGY_DIR", "DA_MQTT_CHANNEL_BROKER", "DA_MQTT_CHANNEL_USERNAME", + "DA_MQTT_CHANNEL_PASSWORD", "DA_CHANNEL_INBOUND_TOPIC_TEMPLATE", "DA_CHANNEL_OUTBOUND_TOPIC_TEMPLATE" + ]) delete process.env[key]; + assert.throws(() => loadEnvironmentConfig(), /Missing required environment variable/); + + process.env.DA_DATA_DIR = "/data"; + process.env.DA_OPENCLAW_HOME = "/home/openclaw"; + process.env.DA_TMP_DIR = "/tmp"; + process.env.DA_WORKSPACE_TEMPLATES_DIR = "/templates"; + process.env.DA_ONTOLOGY_DIR = "/ontology"; + process.env.DA_MQTT_CHANNEL_BROKER = "not-a-url"; + process.env.DA_MQTT_CHANNEL_USERNAME = "u"; + process.env.DA_MQTT_CHANNEL_PASSWORD = "p"; + process.env.DA_CHANNEL_INBOUND_TOPIC_TEMPLATE = "inbound"; + process.env.DA_CHANNEL_OUTBOUND_TOPIC_TEMPLATE = "out/{agentId}"; + assert.throws(() => loadEnvironmentConfig(), /valid MQTT broker URL/); + + process.env.DA_MQTT_CHANNEL_BROKER = "mqtt:/path"; + assert.throws(() => loadEnvironmentConfig(), /missing protocol or hostname/); + + const OriginalURL = globalThis.URL; + globalThis.URL = class { + constructor() { + throw "url parser failed"; + } + } as any; + try { + process.env.DA_MQTT_CHANNEL_BROKER = "mqtt://broker"; + assert.throws(() => loadEnvironmentConfig(), /url parser failed/); + } finally { + globalThis.URL = OriginalURL; + } + + process.env.DA_MQTT_CHANNEL_BROKER = "mqtt://broker"; + assert.throws(() => loadEnvironmentConfig(), /must contain \{agentId\}/); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in previous)) delete process.env[key]; + } + Object.assign(process.env, previous); + } +}); + +test("management logger resolves log roots and degrades after initialization failures", async () => { + const previous = { ...process.env }; + const root = await mkdtemp(path.join(os.tmpdir(), "management-logger-contract-")); + try { + process.env.DA_LOG_LEVEL = "info"; + for (const key of ["DA_LOG_DIR", "DA_ROOT", "DA_DATA_DIR", "DA_KERNEL_DATA_DIR"]) delete process.env[key]; + createLogger("no-file", 1).info("without file logging"); + + const configuredLogDir = path.join(root, "configured-logs"); + process.env.DA_LOG_DIR = configuredLogDir; + const configuredLogger = createLogger("configured", 1); + configuredLogger.info("first configured log"); + configuredLogger.info("second configured log"); + assert.equal((await readdir(configuredLogDir)).length, 1); + + delete process.env.DA_LOG_DIR; + process.env.DA_ROOT = path.join(root, "root-mode"); + createLogger("root", 1).info("root log"); + assert.equal((await readdir(path.join(root, "root-mode", "logs"))).length, 1); + + delete process.env.DA_ROOT; + process.env.DA_DATA_DIR = path.join(root, "data-mode", "data"); + createLogger("data", 1).info("data log"); + assert.equal((await readdir(path.join(root, "data-mode", "logs"))).length, 1); + + process.env.DA_DATA_DIR = ""; + process.env.DA_KERNEL_DATA_DIR = path.join(root, "kernel-mode", "data"); + createLogger("kernel", 1).info("kernel log"); + assert.equal((await readdir(path.join(root, "kernel-mode", "logs"))).length, 1); + + const invalidLogDir = path.join(root, "not-a-directory"); + await writeFile(invalidLogDir, "file", "utf8"); + process.env.DA_LOG_DIR = invalidLogDir; + const failingLogger = createLogger("failing", 1); + failingLogger.info("first failure"); + failingLogger.info("second failure"); + + assert.deepEqual(errorDetails("plain failure"), { message: "plain failure" }); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in previous)) delete process.env[key]; + } + Object.assign(process.env, previous); + await rm(root, { recursive: true, force: true }); + } +}); + +test("covers management runtime health, bypass actions, and failures", async () => { + const baseEnv = { + openclawHome: "/home/openclaw/.openclaw", + openclawStateDir: "/home/openclaw/.openclaw", + openclawConfigPath: "/home/openclaw/.openclaw/openclaw.json", + openclawCliTmpDir: "/tmp/openclaw-cli", + gatewayAuthToken: "token", + inboundTopicTemplate: "in/{agentId}", + outboundTopicTemplate: "in/{agentId}", + managedMqttBrokerUrl: "mqtt://broker", + managedMqttUsername: " user " + } as any; + expect(collectConfigIssues(baseEnv)).toEqual([ + "mqtt.inboundTopicTemplate and mqtt.outboundTopicTemplate must be different" + ]); + expect(shouldBypassManagementQueue("service.ping")).toBeTrue(); + expect(shouldBypassManagementQueue("agent.create")).toBeFalse(); + expect(buildHealthReport(baseEnv, { started: false }, ["issue"]).status).toBe("error"); + expect(buildHealthReport({ ...baseEnv, inboundTopicTemplate: "in/{agentId}", outboundTopicTemplate: "out/{agentId}" }, { started: true }, []).status).toBe("ok"); + expect(buildOpenClawCliEnv(baseEnv, { PATH: "base" }).PATH).toBe("base"); + + const previous = { ...process.env }; + const root = await mkdtemp(path.join(os.tmpdir(), "management-runtime-contract-")); + try { + Object.assign(process.env, { + DA_DATA_DIR: root, + DA_OPENCLAW_HOME: path.join(root, "openclaw"), + DA_TMP_DIR: path.join(root, "tmp"), + DA_WORKSPACE_TEMPLATES_DIR: path.join(root, "templates"), + DA_ONTOLOGY_DIR: path.join(root, "ontology"), + DA_MQTT_CHANNEL_BROKER: "mqtt://broker", + DA_MQTT_CHANNEL_USERNAME: "user", + DA_MQTT_CHANNEL_PASSWORD: "pass", + DA_CHANNEL_INBOUND_TOPIC_TEMPLATE: "in/{agentId}", + DA_CHANNEL_OUTBOUND_TOPIC_TEMPLATE: "out/{agentId}" + }); + const runtime = createManagementActionRuntime(); + expect(runtime.isConfigured()).toBeTrue(); + await runtime.start(); + expect(runtime.getHealthReport().status).toBe("ok"); + const ping = await runtime.executeRequest({ action: "service.ping" } as any); + expect(ping.ok).toBeTrue(); + expect(typeof ping.requestId).toBe("string"); + + const originalPingHandler = capabilityHandlers["service.ping"]; + capabilityHandlers["service.ping"] = (async () => { + throw "string failure"; + }) as any; + try { + const stringFailure = await runtime.executeRequest({ action: "service.ping", requestId: "string-failure" } as any); + expect(stringFailure.ok).toBeFalse(); + expect(stringFailure.error?.message).toBe("string failure"); + } finally { + capabilityHandlers["service.ping"] = originalPingHandler!; + } + + const response = await runtime.executeRequest({ action: "unsupported.action", requestId: "req-1" } as any); + expect(response.ok).toBeFalse(); + expect(response.error?.code).toBe("command_failed"); + await runtime.stop(); + expect(runtime.getHealthReport().status).toBe("error"); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in previous)) delete process.env[key]; + } + Object.assign(process.env, previous); + await rm(root, { recursive: true, force: true }); + } +}); + +test("management runtime rejects invalid configuration before dispatch", async () => { + const previous = { ...process.env }; + const root = await mkdtemp(path.join(os.tmpdir(), "management-runtime-invalid-config-")); + try { + Object.assign(process.env, { + DA_DATA_DIR: root, + DA_OPENCLAW_HOME: path.join(root, "openclaw"), + DA_TMP_DIR: path.join(root, "tmp"), + DA_WORKSPACE_TEMPLATES_DIR: path.join(root, "templates"), + DA_ONTOLOGY_DIR: path.join(root, "ontology"), + DA_MQTT_CHANNEL_BROKER: "mqtt://broker", + DA_MQTT_CHANNEL_USERNAME: "user", + DA_MQTT_CHANNEL_PASSWORD: "pass", + DA_CHANNEL_INBOUND_TOPIC_TEMPLATE: "same/{agentId}", + DA_CHANNEL_OUTBOUND_TOPIC_TEMPLATE: "same/{agentId}" + }); + const runtime = createManagementActionRuntime(); + expect(runtime.isConfigured()).toBeFalse(); + const response = await runtime.executeRequest({ action: "service.ping" } as any); + expect(response.ok).toBeFalse(); + expect(response.error?.code).toBe("invalid_config"); + await expectAsync(runtime.start()).toBeRejectedWithError(/must be different/); + } finally { + for (const key of Object.keys(process.env)) { + if (!(key in previous)) delete process.env[key]; + } + Object.assign(process.env, previous); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/lifecycle-service-coverage.test.ts b/packages/daedalus-agent-management-web/server/management-test/lifecycle-service-coverage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..aef9e43927fbdb0c32af40f4ee88ce81dbe81f06 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/lifecycle-service-coverage.test.ts @@ -0,0 +1,324 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { AgentLifecycleService } from "../management-src/capabilities/agent/lifecycle-service.js"; +import { CommandError } from "../management-src/tools/cli-runner.js"; +import type { EnvironmentConfig, ManagedAgentRecord } from "../management-src/types.js"; + +class QueueCliRunner { + public readonly runJsonCalls: string[][] = []; + + constructor(private readonly queue: unknown[] = []) {} + + async runJson(args: string[]): Promise { + this.runJsonCalls.push(args); + const next = this.queue.shift(); + if (next instanceof Error) { + throw next; + } + return next as T; + } +} + +function createEnv(root: string, overrides: Partial = {}): EnvironmentConfig { + const openclawHome = path.join(root, ".openclaw"); + return { + gatewayRestartMode: "container", + dataRoot: root, + openclawHome, + openclawStateDir: openclawHome, + openclawConfigPath: path.join(openclawHome, "openclaw.json"), + tmpDir: path.join(root, "tmp"), + openclawCliTmpDir: path.join(root, "cli-tmp"), + gatewayAuthToken: "test-token", + openclawBin: "openclaw", + doctorBin: "doctor", + workspacesRoot: path.join(openclawHome, "workspaces"), + workspaceTemplatesRoot: path.join(root, "workspace-templates"), + localArtifactsRoot: path.join(root, "local-artifacts"), + ontologyRoot: path.join(root, "ontology"), + globalSkillsRoot: path.join(openclawHome, "skills"), + stateFile: path.join(root, "managed-agents.json"), + mqttBrokerUrl: "mqtts://broker.example.com:8883", + mqttUsername: "mqtt-user", + mqttPassword: "mqtt-pass", + managedMqttBrokerUrl: "mqtts://broker.example.com:8883", + managedMqttUsername: "managed-user", + managedMqttPassword: "managed-pass", + inboundTopicTemplate: "agents/{agentId}/inbound", + outboundTopicTemplate: "agents/{agentId}/outbound", + ...overrides + }; +} + +async function writeConfig(configPath: string, config: Record): Promise { + await mkdir(path.dirname(configPath), { recursive: true }); + await writeFile(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8"); +} + +function createManagedRecord(id: string, enabled: boolean): ManagedAgentRecord { + return { + id, + accountId: id, + workspace: `/managed/${id}`, + inboundTopic: `agents/${id}/inbound`, + outboundTopic: `agents/${id}/outbound`, + enabled, + templateName: "default", + updatedAt: "2026-08-17T00:00:00.000Z" + }; +} + +test("lifecycle agent listing covers malformed ids, config defaults, and disabled records", async () => { + const root = await mkdtemp(path.join(tmpdir(), "lifecycle-list-coverage-")); + const env = createEnv(root); + await writeConfig(env.openclawConfigPath, { + agents: { + list: [ + { name: "Missing id" }, + { id: "config-only", name: "Configured", workspace: "/configured", model: { primary: "openai/test" } }, + { id: "no-workspace" }, + { id: "disabled", name: "Disabled config" } + ] + } + }); + const service = new AgentLifecycleService(new QueueCliRunner() as never, env); + + try { + const result = await service.listAgents([createManagedRecord("disabled", false)]) as { + items: Array>; + }; + + assert.deepEqual(result.items.map((item) => item.agentId), ["config-only", "disabled", "no-workspace"]); + assert.equal(result.items[0]?.workspace, "/configured"); + assert.equal(result.items[0]?.status, "active"); + assert.equal(result.items[0]?.health, "normal"); + assert.equal(result.items[1]?.workspace, "/managed/disabled"); + assert.equal(result.items[1]?.status, "disabled"); + assert.equal(result.items[1]?.health, "disabled"); + assert.equal(result.items[2]?.name, "no-workspace"); + assert.equal(result.items[2]?.workspace, ""); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle usage accepts aggregate and session payload variants", async () => { + const root = await mkdtemp(path.join(tmpdir(), "lifecycle-usage-coverage-")); + const env = createEnv(root); + const cli = new QueueCliRunner([ + { + aggregates: { + byAgent: [ + null, + { agentId: " ", totals: { totalTokens: 10 } }, + { id: "alpha", totalTokens: null }, + { agent_id: "alpha", totals: { totalTokens: 2 } }, + { agentId: "missing", totals: null }, + { agentId: "missing", totals: {} } + ] + } + }, + { + sessions: [ + null, + { agentId: " ", usage: { totalTokens: 1 } }, + { agent_id: "alpha", usage: null, totals: { totalTokens: 2 } }, + { agentId: "alpha", usage: 7 }, + { agentId: "beta", usage: { totals: {} } }, + { agentId: "beta", usage: { totals: 1 } }, + { agentId: "beta", usage: { totals: { totalTokens: null } } }, + { agentId: "gamma", totalTokens: 3 } + ], + cacheStatus: 0 + } + ]); + const service = new AgentLifecycleService(cli as never, env, () => new Date("2026-08-17T09:30:00.000Z")); + + try { + const aggregateResult = await service.queryAllUsage([ + " alpha ", + "", + "alpha", + "missing", + "zero" + ]) as { items: Array<{ agentId: string; usage: { daily: number } }>; cacheStatus?: unknown }; + assert.deepEqual(aggregateResult.items, [ + { agentId: "alpha", usage: { daily: 2 } }, + { agentId: "missing", usage: { daily: 0 } }, + { agentId: "zero", usage: { daily: 0 } } + ]); + assert.equal("cacheStatus" in aggregateResult, false); + + const sessionResult = await service.queryAllUsage(["alpha", "beta", "gamma", "missing"]) as { + items: Array<{ agentId: string; usage: { daily: number } }>; + cacheStatus?: unknown; + }; + assert.deepEqual(sessionResult.items, [ + { agentId: "alpha", usage: { daily: 2 } }, + { agentId: "beta", usage: { daily: 0 } }, + { agentId: "gamma", usage: { daily: 3 } }, + { agentId: "missing", usage: { daily: 0 } } + ]); + assert.equal("cacheStatus" in sessionResult, false); + assert.equal(cli.runJsonCalls.length, 2); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle usage handles empty configuration and rejects invalid payloads", async () => { + const root = await mkdtemp(path.join(tmpdir(), "lifecycle-usage-errors-")); + const env = createEnv(root); + + try { + await writeConfig(env.openclawConfigPath, { agents: { list: [{ id: 0 }] } }); + const emptyIdService = new AgentLifecycleService(new QueueCliRunner() as never, env); + const emptyIdResult = await emptyIdService.queryAllUsage([]) as { generatedAt: string; items: unknown[] }; + assert.equal(typeof emptyIdResult.generatedAt, "string"); + assert.deepEqual(emptyIdResult.items, []); + + await writeConfig(env.openclawConfigPath, { agents: { list: {} } }); + const emptyListService = new AgentLifecycleService(new QueueCliRunner() as never, env); + const emptyList = await emptyListService.queryAllUsage(); + assert.deepEqual((emptyList as { items: unknown[] }).items, []); + + const invalidService = new AgentLifecycleService(new QueueCliRunner([null, {}]) as never, env); + await assert.rejects(invalidService.queryAllUsage(["alpha"]), /empty payload/); + await assert.rejects(invalidService.queryAllUsage(["alpha"]), /per-agent usage fields/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle config mutations cover missing lists and validation errors", async () => { + const root = await mkdtemp(path.join(tmpdir(), "lifecycle-mutation-coverage-")); + const env = createEnv(root); + const service = new AgentLifecycleService(new QueueCliRunner() as never, env); + const record = createManagedRecord("agent-a", true); + + try { + await writeConfig(env.openclawConfigPath, { + agents: { list: {} }, + bindings: {} + }); + await service.deleteManagedTopology(record); + const deletedConfig = JSON.parse(await readFile(env.openclawConfigPath, "utf8")); + assert.deepEqual(deletedConfig.agents.list, []); + assert.deepEqual(deletedConfig.bindings, []); + + await writeConfig(env.openclawConfigPath, { agents: { list: {} } }); + await assert.rejects( + service.setAgentModel({ agentId: "missing", model: "openai/test" }), + /Agent config not found/ + ); + await assert.rejects( + service.setAgentName({ agentId: "missing", name: " " }), + /Agent name is required/ + ); + await assert.rejects( + service.setAgentName({ agentId: "missing", name: "Missing" }), + /Agent config not found/ + ); + + await service.createManagedTopology({ + agentId: "created", + name: "Created agent", + accountId: "created-account", + workspace: path.join(root, "workspaces", "created"), + templateName: "default", + inboundTopic: "agents/created/inbound", + outboundTopic: "agents/created/outbound" + }); + const createdConfig = JSON.parse(await readFile(env.openclawConfigPath, "utf8")); + assert.deepEqual(createdConfig.agents.list.map((item: { id: string }) => item.id), ["created"]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle preserves config read and MCP probe errors", async () => { + const root = await mkdtemp(path.join(tmpdir(), "lifecycle-error-coverage-")); + const badConfigPath = path.join(root, "config-directory"); + await mkdir(badConfigPath); + const badConfigService = new AgentLifecycleService( + new QueueCliRunner() as never, + createEnv(root, { openclawConfigPath: badConfigPath }) + ); + const noDetailError = new CommandError( + "probe command failed", + ["openclaw", "mcp", "probe"], + { stdout: "", stderr: "", exitCode: 1 } + ); + const probeService = new AgentLifecycleService( + new QueueCliRunner([new Error("plain probe failure"), noDetailError]) as never, + createEnv(root) + ); + + try { + await assert.rejects(badConfigService.snapshotOpenClawConfig()); + await assert.rejects(probeService.probeMcpServer({ slug: "plain" }), /plain probe failure/); + await assert.rejects(probeService.probeMcpServer({ slug: "no-detail" }), /probe command failed/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("lifecycle MCP sync covers all, none, and missing-agent projections", async () => { + const root = await mkdtemp(path.join(tmpdir(), "lifecycle-mcp-coverage-")); + const env = createEnv(root); + const service = new AgentLifecycleService(new QueueCliRunner() as never, env); + + try { + await writeConfig(env.openclawConfigPath, { + agents: { + list: [ + { id: "all-agent", tools: { deny: ["retired__*"] } }, + { id: "none-agent" } + ] + } + }); + const result = await service.syncMcpConfiguration({ + servers: [{ slug: "docs", config: { enabled: true } }], + agents: [ + { + agentId: "all-agent", + mode: "all", + serverSlugs: [], + previousDenyPatterns: ["retired__*"] + }, + { + agentId: "none-agent", + mode: "selected", + serverSlugs: [], + previousDenyPatterns: [] + } + ] + }); + assert.deepEqual(result.projections, [ + { agentId: "all-agent", denyPatterns: [] }, + { agentId: "none-agent", denyPatterns: ["bundle-mcp"] } + ]); + const syncedConfig = JSON.parse(await readFile(env.openclawConfigPath, "utf8")); + assert.equal("tools" in syncedConfig.agents.list[0], false); + assert.deepEqual(syncedConfig.agents.list[1].tools.deny, ["bundle-mcp"]); + + await writeConfig(env.openclawConfigPath, { agents: { list: {} } }); + await assert.rejects( + service.syncMcpConfiguration({ + servers: [], + agents: [{ + agentId: "missing", + mode: "all", + serverSlugs: [], + previousDenyPatterns: [] + }] + }), + /Agent config not found/ + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/llm-model-config-service.test.ts b/packages/daedalus-agent-management-web/server/management-test/llm-model-config-service.test.ts index d7847c007537dde3eba4fcc7df1cedb999b3acb5..532ab9f57eb8c51b09409eeed67f566f2a368f84 100644 --- a/packages/daedalus-agent-management-web/server/management-test/llm-model-config-service.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/llm-model-config-service.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -21,12 +21,8 @@ function createEnv(root: string): EnvironmentConfig { doctorBin: "doctor", workspacesRoot: path.join(root, ".openclaw", "workspaces"), workspaceTemplatesRoot: path.join(root, "workspace-templates"), + localArtifactsRoot: path.join(root, "local-artifacts"), ontologyRoot: path.join(root, "ontology"), - ontologyS3Endpoint: "http://minio.internal:9000", - ontologyS3Region: "local", - ontologyS3AccessKeyId: "minio-access", - ontologyS3SecretAccessKey: "minio-secret", - ontologyS3ForcePathStyle: true, globalSkillsRoot: path.join(root, ".openclaw", "skills"), stateFile: path.join(root, "managed-agents.json"), mqttBrokerUrl: "mqtts://channel-broker:8883", @@ -73,6 +69,15 @@ function createService(env: EnvironmentConfig): LlmModelConfigService { return new LlmModelConfigService(env, operations); } +function createServiceWithOperations(env: EnvironmentConfig, calls: Array): LlmModelConfigService { + const operations = { + async maybeRefreshGatewayAfterManagedTopologyChange(restart: unknown, operation: string) { + calls.push({ restart, operation }); + } + } as unknown as AgentOperationsService; + return new LlmModelConfigService(env, operations); +} + test("createModel treats empty token limits as unset", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-create-")); const env = await seedConfig(root); @@ -228,3 +233,324 @@ test("testModel calls OpenAI compatible chat completions with OpenClaw context a globalThis.fetch = originalFetch; } }); + +test("provider and model CRUD covers validation, filtering, and reference protection", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-crud-")); + const env = await seedConfig(root, [{ id: "existing", name: "Existing" }]); + const calls: unknown[] = []; + const service = createServiceWithOperations(env, calls); + + await assert.rejects(service.createProvider({ providerId: "bad id", baseUrl: "https://api.example.com", apiKey: "secret" }), /Invalid provider id/); + await assert.rejects(service.createProvider({ providerId: "new-provider", baseUrl: "ftp://api.example.com", apiKey: "secret" }), /Invalid baseUrl/); + await assert.rejects(service.createProvider({ providerId: "new-provider", baseUrl: "https://api.example.com", apiKey: " " }), /apiKey is required/); + + const created = await service.createProvider({ + providerId: "new-provider", + baseUrl: "https://api.example.com/v1/chat/completions/", + apiKey: "short", + restart: true + }) as Record; + assert.equal(created.baseUrl, "https://api.example.com/v1"); + assert.equal(created.apiKeyMask, "sh****rt"); + assert.equal(created.apiKeyConfigured, true); + await assert.rejects(service.createProvider({ providerId: "new-provider", baseUrl: "https://api.example.com", apiKey: "secret" }), /already exists/); + + const updated = await service.updateProvider({ + providerId: "new-provider", + baseUrl: "https://new.example.com/completions", + apiKey: "long-secret-token", + restart: false + }) as Record; + assert.equal(updated.baseUrl, "https://new.example.com"); + assert.equal(updated.apiKeyMask, "long-s****oken"); + + const providers = await service.listProviders(); + assert.equal(providers.items.length, 2); + assert.equal((await service.listModels({ providerId: "new-provider" })).items.length, 0); + await assert.rejects(service.listModels({ providerId: "missing provider" }), /Invalid provider id/); + + await assert.rejects(service.deleteModel({ providerId: "corp-openai", modelId: "missing" }), /not found/); + await assert.rejects(service.deleteProvider({ providerId: "corp-openai" }), /still has models/); + const config = await readConfig(env); + config.agents.list = [{ id: "agent-1", model: { primary: "new-provider/none", fallbacks: ["new-provider/none"] } }]; + await writeFile(env.openclawConfigPath, `${JSON.stringify(config)}\n`, "utf8"); + await assert.rejects(service.deleteProvider({ providerId: "new-provider" }), /used by agents/); + config.agents.list = []; + await writeFile(env.openclawConfigPath, `${JSON.stringify(config)}\n`, "utf8"); + assert.deepEqual(await service.deleteProvider({ providerId: "new-provider", restart: true }), { providerId: "new-provider" }); + assert.equal((calls as any[]).length, 3); + assert.deepEqual((calls as any[]).map((entry) => entry.operation), [ + "llm.provider.create:new-provider", + "llm.provider.update:new-provider", + "llm.provider.delete:new-provider" + ]); +}); + +test("model operations validate refs and preserve agent references", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-model-edge-")); + const env = await seedConfig(root, [{ id: "existing", name: "Existing" }]); + const service = createService(env); + + await assert.rejects(service.createModel({ providerId: "corp openai", modelId: "x" }), /Invalid provider id/); + await assert.rejects(service.createModel({ providerId: "corp-openai", modelId: "bad model" }), /Invalid model id/); + await assert.rejects(service.createModel({ providerId: "corp-openai", modelId: "existing" }), /already exists/); + await assert.rejects(service.createModel({ providerId: "corp-openai", modelId: "bad-number", contextTokens: "NaN" as any }), /finite number/); + + const created = await service.createModel({ + providerId: "corp-openai", + modelId: "created", + name: " ", + contextTokens: 100, + maxTokens: 20, + restart: false + }) as Record; + assert.equal(created.name, "created"); + assert.equal(created.contextTokens, 100); + assert.equal(created.maxTokens, 20); + + const updated = await service.updateModel({ + modelRef: "corp-openai/created", + name: "Renamed", + contextTokens: "", + maxTokens: 30, + restart: false + }) as Record; + assert.equal(updated.name, "Renamed"); + assert.equal(updated.contextTokens, null); + assert.equal(updated.maxTokens, 30); + await assert.rejects(service.updateModel({ modelRef: "invalid-ref" }), /Invalid model ref/); + await assert.rejects(service.updateModel({ modelRef: "corp-openai/missing" }), /not found/); + + const config = await readConfig(env); + config.agents.list = [{ id: "agent-1", model: "corp-openai/created" }]; + await writeFile(env.openclawConfigPath, `${JSON.stringify(config)}\n`, "utf8"); + await assert.rejects(service.deleteModel({ modelRef: "corp-openai/created" }), /used by agents/); + config.agents.list = [{ id: "agent-1", model: { primary: "", fallbacks: ["corp-openai/existing", ""] } }]; + await writeFile(env.openclawConfigPath, `${JSON.stringify(config)}\n`, "utf8"); + assert.deepEqual(await service.deleteModel({ modelRef: "corp-openai/created" }), { + providerId: "corp-openai", + modelId: "created", + modelRef: "corp-openai/created" + }); +}); + +test("testModel returns structured errors for provider failures and network errors", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-errors-")); + const env = await seedConfig(root, [{ id: "model", contextWindow: 4096 }]); + const service = createService(env); + const originalFetch = globalThis.fetch; + + try { + globalThis.fetch = (async () => new Response(JSON.stringify({ error: { message: "provider rejected" } }), { + status: 429, + statusText: "Too Many Requests" + })) as typeof fetch; + const rejected = await service.testModel({ modelRef: "corp-openai/model", timeoutMs: 10 }) as Record; + assert.equal(rejected.ok, false); + assert.equal(rejected.errorMessage, "provider rejected"); + assert.equal(rejected.status, 429); + + globalThis.fetch = (async () => new Response("not-json", { status: 500, statusText: "Error" })) as typeof fetch; + const plain = await service.testProvider({ providerId: "corp-openai", modelId: "model" }) as Record; + assert.equal(plain.response, "not-json"); + assert.equal(plain.errorMessage, "not-json"); + + globalThis.fetch = (async () => { throw new Error("network down"); }) as typeof fetch; + const network = await service.testModel({ providerId: "corp-openai", modelId: "model", timeoutMs: 0 }) as Record; + assert.equal(network.ok, false); + assert.equal(network.timeout, false); + assert.equal(network.errorMessage, "network down"); + + globalThis.fetch = (async () => { const error = new Error("aborted"); error.name = "AbortError"; throw error; }) as typeof fetch; + const timeout = await service.testModel({ providerId: "corp-openai", modelId: "model", timeoutMs: 1 }) as Record; + assert.equal(timeout.timeout, true); + assert.match(timeout.errorMessage, /timed out/); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("LLM service normalizes missing values and tolerates legacy config shapes", async () => { + const missingRoot = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-missing-config-")); + const missingService = createService(createEnv(missingRoot)); + assert.deepEqual(await missingService.listProviders(), { items: [] }); + assert.deepEqual(await missingService.listModels(), { items: [] }); + + await assert.rejects(missingService.createProvider({ + providerId: undefined as any, + baseUrl: "https://api.example.com", + apiKey: "secret" + }), /providerId is required/); + await assert.rejects(missingService.createProvider({ + providerId: "provider", + baseUrl: undefined as any, + apiKey: "secret" + }), /baseUrl is required/); + await assert.rejects(missingService.createProvider({ + providerId: "provider", + baseUrl: "https://api.example.com", + apiKey: undefined as any + }), /apiKey is required/); + await assert.rejects(missingService.createProvider({ + providerId: "provider", + baseUrl: "https://api.example.com", + apiKey: 0 as any + }), /apiKey is required/); + + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-legacy-shapes-")); + const env = createEnv(root); + await mkdir(path.dirname(env.openclawConfigPath), { recursive: true }); + await writeFile(env.openclawConfigPath, JSON.stringify({ + models: { + providers: { + odd: { + api: "openai-completions", + baseUrl: "https://odd.example.com", + apiKey: { id: "vault-key" }, + models: [ + {}, + { id: "odd-model", contextTokens: "invalid", maxTokens: "invalid" } + ] + }, + "no-key": { + api: "openai-completions", + baseUrl: "https://no-key.example.com", + models: null + }, + "update-null": { + api: "openai-completions", + baseUrl: "https://update-null.example.com", + apiKey: "secret", + models: null + }, + primitive: "invalid" + } + }, + agents: { list: "invalid" } + }), "utf8"); + const service = createService(env); + + const providers = await service.listProviders() as { items: Array> }; + const odd = providers.items.find((item) => item.providerId === "odd")!; + assert.equal(odd.apiKeyMask, "ref:vault-key"); + assert.equal(odd.models.length, 1); + assert.equal(odd.models[0].contextTokens, null); + assert.equal(odd.models[0].maxTokens, null); + assert.equal(providers.items.find((item) => item.providerId === "no-key")?.apiKeyMask, ""); + assert.equal((await service.listModels()).items.length, 1); + + await service.updateProvider({ providerId: "odd", baseUrl: "https://updated.example.com" }); + await assert.rejects(service.updateProvider({ providerId: "primitive" }), /provider not found/); + await assert.rejects(service.createModel({ providerId: "odd", modelId: undefined as any }), /modelId is required/); + const created = await service.createModel({ providerId: "no-key", modelId: "created" }) as Record; + assert.equal(created.modelId, "created"); + const updatedCreated = await service.updateModel({ + providerId: "no-key", + modelId: "created", + contextTokens: 64 + }) as Record; + assert.equal(updatedCreated.contextTokens, 64); + await assert.rejects(service.updateModel({ + providerId: "update-null", + modelId: "missing" + }), /model not found/); + await assert.rejects(service.deleteModel({ + providerId: "update-null", + modelId: "missing" + }), /model not found/); + + const config = await readConfig(env); + config.models.providers.used = { + api: "openai-completions", + baseUrl: "https://used.example.com", + apiKey: "secret", + models: [] + }; + config.agents.list = [ + { id: "null-model", model: null }, + { id: "numeric-model", model: 123 }, + { model: { primary: "used/model", fallbacks: null } } + ]; + await writeFile(env.openclawConfigPath, JSON.stringify(config), "utf8"); + await assert.rejects(service.deleteProvider({ providerId: "used" }), /-:used\/model/); + + await writeFile(env.openclawConfigPath, "", "utf8"); + assert.deepEqual(await service.listProviders(), { items: [] }); + await writeFile(env.openclawConfigPath, "invalid json", "utf8"); + await assert.rejects(service.listProviders(), /JSON/); +}); + +test("LLM model tests cover missing configuration and nonstandard responses", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-response-shapes-")); + const env = createEnv(root); + await mkdir(path.dirname(env.openclawConfigPath), { recursive: true }); + await writeFile(env.openclawConfigPath, JSON.stringify({ + models: { + providers: { + "no-base": { apiKey: "secret", models: [{ id: "model" }] }, + "no-key": { baseUrl: "https://api.example.com", models: [{ id: "model" }] }, + "no-models": { baseUrl: "https://api.example.com", apiKey: "secret", models: null }, + good: { + baseUrl: "https://api.example.com", + apiKey: "secret", + models: [{}, { id: "model", contextWindow: "invalid", contextTokens: "invalid", maxTokens: "invalid" }] + } + } + }, + agents: { list: [] } + }), "utf8"); + const service = createService(env); + + await assert.rejects(service.testModel({ providerId: "no-base", modelId: "model" }), /missing baseUrl/); + await assert.rejects(service.testModel({ providerId: "no-key", modelId: "model" }), /missing apiKey/); + await assert.rejects(service.testModel({ providerId: "no-models", modelId: "model" }), /model not found/); + await assert.rejects(service.testProvider({ providerId: "no-models" }), /has no models to test/); + + const originalFetch = globalThis.fetch; + const responses: Array<() => Promise> = [ + async () => new Response("", { status: 500, statusText: "Empty" }), + async () => new Response("123", { status: 500, statusText: "Numeric" }), + async () => { throw "network string"; } + ]; + globalThis.fetch = (async () => await responses.shift()!()) as typeof fetch; + try { + const empty = await service.testProvider({ providerId: "good" }) as Record; + assert.equal(empty.response, null); + assert.equal(empty.errorMessage, ""); + assert.equal(empty.outputTokens, 32); + + const numeric = await service.testProvider({ providerId: "good" }) as Record; + assert.equal(numeric.response, 123); + assert.equal(numeric.errorMessage, "123"); + + const thrown = await service.testProvider({ providerId: "good" }) as Record; + assert.equal(thrown.errorMessage, "network string"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("LLM services handle non-array models and clean up failed atomic writes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-llm-write-errors-")); + const env = await seedConfig(root); + const service = createService(env); + + try { + const config = await readConfig(env); + config.models.providers["corp-openai"].models = null; + await writeFile(env.openclawConfigPath, JSON.stringify(config), "utf8"); + assert.deepEqual(await service.listModels(), { items: [] }); + assert.deepEqual(await service.deleteProvider({ providerId: "corp-openai" }), { providerId: "corp-openai" }); + + const directoryTarget = path.join(root, "config-directory"); + await mkdir(directoryTarget); + const failingService = createService({ ...env, openclawConfigPath: directoryTarget }); + await assert.rejects((failingService as any).writeOpenClawConfig({ models: { providers: {} } })); + assert.equal( + (await readdir(root)).some((entry) => entry.startsWith("config-directory.tmp-")), + false + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/local-artifact.test.ts b/packages/daedalus-agent-management-web/server/management-test/local-artifact.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ca66f4a52215689fbace4c56a46b2ee7e5947b3 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/local-artifact.test.ts @@ -0,0 +1,85 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { readLocalArtifact, writeLocalArtifact } from "../management-src/tools/local-artifact.js"; + +const DIRECTORY_LINK_TYPE = process.platform === "win32" ? "junction" : "dir"; + +test("writeLocalArtifact writes atomically inside the configured root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-local-artifact-")); + await writeLocalArtifact(root, "agent-skill/2026-08-12/report.zip", Buffer.from("archive", "utf8")); + assert.equal(await readFile(path.join(root, "agent-skill", "2026-08-12", "report.zip"), "utf8"), "archive"); +}); + +test("writeLocalArtifact rejects symbolic-link parents", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-local-artifact-link-")); + const outside = await mkdtemp(path.join(tmpdir(), "management-local-artifact-outside-")); + await mkdir(root, { recursive: true }); + await symlink(outside, path.join(root, "agent-skill"), DIRECTORY_LINK_TYPE); + + await assert.rejects( + writeLocalArtifact(root, "agent-skill/report.zip", Buffer.from("archive", "utf8")), + /physical directory/i + ); +}); + +test("local artifact access rejects non-physical roots and targets", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-local-artifact-boundaries-")); + const outside = await mkdtemp(path.join(tmpdir(), "management-local-artifact-boundaries-outside-")); + const rootLink = `${root}-link`; + const rootFile = `${root}-file`; + try { + await writeFile(rootFile, "not-a-directory", "utf8"); + await assert.rejects(readLocalArtifact(`${root}-missing`, "artifact.zip"), /root does not exist/i); + await assert.rejects(readLocalArtifact(rootFile, "artifact.zip"), /physical directory/i); + + await symlink(outside, rootLink, DIRECTORY_LINK_TYPE); + await assert.rejects(readLocalArtifact(rootLink, "artifact.zip"), /physical directory/i); + await assert.rejects( + writeLocalArtifact(rootLink, "artifact.zip", Buffer.from("archive", "utf8")), + /physical directory/i + ); + + await mkdir(path.join(root, "folder")); + await writeFile(path.join(outside, "outside.txt"), "outside", "utf8"); + await symlink(outside, path.join(root, "linked-folder"), DIRECTORY_LINK_TYPE); + await assert.rejects(readLocalArtifact(root, "linked-folder/outside.txt"), /symbolic links/i); + await assert.rejects(readLocalArtifact(root, "folder"), /not a file/i); + + await writeFile(path.join(root, "parent-file"), "parent", "utf8"); + await assert.rejects( + writeLocalArtifact(root, "parent-file/artifact.zip", Buffer.from("archive", "utf8")), + /parent is not a physical directory/i + ); + await assert.rejects( + writeLocalArtifact(root, "folder", Buffer.from("archive", "utf8")), + /target is not a physical file/i + ); + await assert.rejects( + writeLocalArtifact(root, "linked-folder", Buffer.from("archive", "utf8")), + /target is not a physical file/i + ); + } finally { + await rm(rootLink, { recursive: true, force: true }); + await rm(rootFile, { force: true }); + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}); + +test("local artifact access preserves non-missing filesystem errors", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-local-artifact-errors-")); + try { + await assert.rejects(readLocalArtifact("\u0000", "artifact.zip")); + await assert.rejects( + writeLocalArtifact(root, "bad\u0000-parent/artifact.zip", Buffer.from("archive", "utf8")) + ); + await assert.rejects( + writeLocalArtifact(root, "bad\u0000-target.zip", Buffer.from("archive", "utf8")) + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts b/packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b2cd437f31c1f48039d78efa59b6188ff9a39a70 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/mcp-sync.test.ts @@ -0,0 +1,151 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { AgentLifecycleService } from "../management-src/capabilities/agent/lifecycle-service.js"; +import { CommandError } from "../management-src/tools/cli-runner.js"; + +describe("AgentLifecycleService MCP projection", () => { + it("replaces only management-owned MCP deny patterns", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-sync-")); + const configPath = path.join(rootDir, "openclaw.json"); + fs.writeFileSync(configPath, JSON.stringify({ + agents: { + list: [{ + id: "agent-a", + tools: { + deny: ["exec", "retired__*"] + } + }] + }, + mcp: { + servers: { + retired: { url: "https://old.example.com" } + } + } + }), "utf8"); + + const service = new AgentLifecycleService({} as never, { + openclawHome: rootDir, + openclawConfigPath: configPath + } as never); + + try { + const result = await service.syncMcpConfiguration({ + servers: [ + { slug: "docs", config: { enabled: true, url: "https://mcp.example.com", transport: "streamable-http" } }, + { slug: "search", config: { enabled: true, command: "npx", args: ["search-mcp"] } } + ], + agents: [{ + agentId: "agent-a", + mode: "selected", + serverSlugs: ["docs"], + previousDenyPatterns: ["retired__*"] + }] + }); + + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(config.mcp.servers).toEqual({ + docs: { enabled: true, url: "https://mcp.example.com", transport: "streamable-http" }, + search: { enabled: true, command: "npx", args: ["search-mcp"] } + }); + expect(config.agents.list[0].tools.deny).toEqual(["exec", "search__*"]); + expect(result.projections).toEqual([{ agentId: "agent-a", denyPatterns: ["search__*"] }]); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("temporarily projects an unsaved server while probing and restores the config", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-probe-")); + const configPath = path.join(rootDir, "openclaw.json"); + const originalConfig = { + mcp: { + servers: { + existing: { enabled: true, url: "https://existing.example.com" } + } + } + }; + fs.writeFileSync(configPath, JSON.stringify(originalConfig), "utf8"); + const cli = { + async runJson(args: string[]) { + expect(args).toEqual(["mcp", "probe", "draft-docs", "--json"]); + const projectedConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(projectedConfig.mcp.servers).toEqual({ + existing: originalConfig.mcp.servers.existing, + "draft-docs": { + enabled: true, + url: "https://mcp.example.com/draft", + transport: "streamable-http" + } + }); + return { tools: [{ name: "search_docs" }] }; + } + }; + const service = new AgentLifecycleService(cli as never, { + openclawHome: rootDir, + openclawConfigPath: configPath + } as never); + + try { + const result = await service.probeMcpServer({ + slug: "draft-docs", + config: { + enabled: true, + url: "https://mcp.example.com/draft", + transport: "streamable-http" + } + }); + + expect(result).toEqual({ tools: [{ name: "search_docs" }] }); + expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toEqual(originalConfig); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); + + it("restores the MCP config and exposes stderr when a probe fails", async () => { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-probe-error-")); + const configPath = path.join(rootDir, "openclaw.json"); + const originalConfig = { + mcp: { + servers: { + "phx-dev": { + enabled: false, + url: "http://127.0.0.1:7469/mcp", + transport: "streamable-http" + } + } + } + }; + fs.writeFileSync(configPath, JSON.stringify(originalConfig), "utf8"); + const cli = { + async runJson(args: string[]) { + const projectedConfig = JSON.parse(fs.readFileSync(configPath, "utf8")); + expect(projectedConfig.mcp.servers["phx-dev"].enabled).toBeTrue(); + throw new CommandError( + `Command failed: openclaw ${args.join(" ")}`, + ["openclaw", ...args], + { stdout: "", stderr: "MCP handshake failed", exitCode: 1 } + ); + } + }; + const service = new AgentLifecycleService(cli as never, { + openclawHome: rootDir, + openclawConfigPath: configPath + } as never); + + try { + await expectAsync(service.probeMcpServer({ + slug: "phx-dev", + config: { + ...originalConfig.mcp.servers["phx-dev"], + enabled: true + } + })).toBeRejectedWithError("MCP handshake failed"); + expect(JSON.parse(fs.readFileSync(configPath, "utf8"))).toEqual(originalConfig); + } finally { + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts index 0463b13ddba60cfe9314b90e7e2ede14c94a6d1d..128df76c3f2f896cf0ebd7d1b082a1ee5803a357 100644 --- a/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/ontology-manager.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { access, mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -22,12 +22,8 @@ function createEnv(overrides: Partial = {}): EnvironmentConfi doctorBin: "doctor", workspacesRoot: path.join(baseRoot, ".openclaw", "workspaces"), workspaceTemplatesRoot: path.join(baseRoot, "workspace-templates"), + localArtifactsRoot: path.join(baseRoot, "local-artifacts"), ontologyRoot: path.join(baseRoot, "ontology"), - ontologyS3Endpoint: "http://minio.internal:9000", - ontologyS3Region: "us-east-1", - ontologyS3AccessKeyId: "minio-access", - ontologyS3SecretAccessKey: "minio-secret", - ontologyS3ForcePathStyle: true, globalSkillsRoot: path.join(baseRoot, ".openclaw", "skills"), stateFile: path.join(baseRoot, "managed-agents.json"), mqttBrokerUrl: "mqtts://management-broker:8883", @@ -48,25 +44,17 @@ test("OntologyManager creates, lists, and deletes ontologies", async () => { const zip = new AdmZip(); zip.addFile("index.md", Buffer.from("# CRM", "utf8")); zip.addFile("schema.json", Buffer.from("{\"name\":\"crm\"}", "utf8")); - const manager = new OntologyManager( - createEnv({ - ontologyRoot: path.join(root, "ontology") - }), - { - objectStore: { - async getObject(bucket: string, objectKey: string): Promise { - assert.equal(bucket, "ontology-bucket"); - assert.equal(objectKey, "crm/crm.zip"); - return zip.toBuffer(); - } - } - } - ); + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "ontology"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "ontology", "crm.zip"), zip.toBuffer()); + const manager = new OntologyManager(createEnv({ + ontologyRoot: path.join(root, "ontology"), + localArtifactsRoot + })); const created = await manager.createOntology({ name: "crm", - bucket: "ontology-bucket", - objectKey: "crm/crm.zip" + artifactPath: "ontology/crm.zip" }); assert.equal(created.path, path.join(root, "ontology", "crm")); @@ -91,29 +79,25 @@ test("OntologyManager creates, lists, and deletes ontologies", async () => { ]); }); -test("OntologyManager preserves the current ontology when replacement download fails", async () => { +test("OntologyManager preserves the current ontology when the local artifact is missing", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-ontology-download-failure-")); const ontologyRoot = path.join(root, "ontology"); const currentRoot = path.join(ontologyRoot, "crm"); await mkdir(currentRoot, { recursive: true }); await writeFile(path.join(currentRoot, "index.md"), "# Current\n", "utf8"); - const manager = new OntologyManager(createEnv({ ontologyRoot }), { - objectStore: { - async getObject(): Promise { - throw new Error("injected download failure"); - } - } - }); + const manager = new OntologyManager(createEnv({ + ontologyRoot, + localArtifactsRoot: path.join(root, "local-artifacts") + })); await assert.rejects( manager.createOntology({ name: "crm", - bucket: "ontology-bucket", - objectKey: "crm/crm.zip", + artifactPath: "ontology/missing.zip", replace: true }), - /injected download failure/ + /local artifact/i ); assert.equal(await readFile(path.join(currentRoot, "index.md"), "utf8"), "# Current\n"); }); @@ -126,20 +110,16 @@ test("OntologyManager rejects an invalid replacement before switching directorie await writeFile(path.join(currentRoot, "index.md"), "# Current\n", "utf8"); const invalidZip = new (await import("adm-zip")).default(); invalidZip.addFile("schema.json", Buffer.from("{}", "utf8")); + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "ontology"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "ontology", "invalid.zip"), invalidZip.toBuffer()); - const manager = new OntologyManager(createEnv({ ontologyRoot }), { - objectStore: { - async getObject(): Promise { - return invalidZip.toBuffer(); - } - } - }); + const manager = new OntologyManager(createEnv({ ontologyRoot, localArtifactsRoot })); await assert.rejects( manager.createOntology({ name: "crm", - bucket: "ontology-bucket", - objectKey: "crm/crm.zip", + artifactPath: "ontology/invalid.zip", replace: true }), /top-level index\.md/ @@ -170,3 +150,45 @@ test("OntologyManager marks built-in ontologies from manifest", async () => { ] }); }); + +test("OntologyManager validates inputs and replaces an existing ontology atomically", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-ontology-replace-")); + const localArtifactsRoot = path.join(root, "local-artifacts"); + const artifactRoot = path.join(localArtifactsRoot, "ontology"); + const ontologyRoot = path.join(root, "ontology"); + const AdmZip = (await import("adm-zip")).default; + const makeZip = (content: string) => { + const zip = new AdmZip(); + zip.addFile("index.md", Buffer.from(content, "utf8")); + return zip.toBuffer(); + }; + + try { + await mkdir(artifactRoot, { recursive: true }); + await writeFile(path.join(artifactRoot, "first.zip"), makeZip("# First\n")); + await writeFile(path.join(artifactRoot, "second.zip"), makeZip("# Second\n")); + const directoryIndex = new AdmZip(); + directoryIndex.addFile("index.md/", Buffer.alloc(0)); + await writeFile(path.join(artifactRoot, "directory-index.zip"), directoryIndex.toBuffer()); + + const manager = new OntologyManager(createEnv({ ontologyRoot, localArtifactsRoot })); + await assert.rejects(manager.createOntology({ name: "bad name", artifactPath: "ontology/first.zip" }), /Invalid ontology name/); + await assert.rejects(manager.deleteOntology({ name: "bad name" }), /Invalid ontology name/); + await assert.rejects(manager.createOntology({ name: "crm", artifactPath: " " }), /requires artifactPath/); + + await manager.createOntology({ name: "crm", artifactPath: "ontology/first.zip" }); + await assert.rejects( + manager.createOntology({ name: "crm", artifactPath: "ontology/second.zip", replace: false }), + /already exists/ + ); + await manager.createOntology({ name: "crm", artifactPath: "ontology/second.zip", replace: true }); + assert.equal(await readFile(path.join(ontologyRoot, "crm", "index.md"), "utf8"), "# Second\n"); + await assert.rejects( + manager.createOntology({ name: "directory-index", artifactPath: "ontology/directory-index.zip" }), + /top-level index\.md/ + ); + await assert.rejects((manager as any).pathExists("\u0000")); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts index 86ce9fb33a3aaa23ac5a09c353d78a455b1d4964..92e08e7c8bfb4dcebc3f0c60aba99e567ea20c5d 100644 --- a/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/openclaw-manager.test.ts @@ -15,7 +15,6 @@ import { buildHealthReport, buildOpenClawCliEnv, collectConfigIssues } from "../ import { CommandError } from "../management-src/tools/cli-runner.js"; import type { CliRunOptions, CommandResult } from "../management-src/tools/cli-runner.js"; import type { EnvironmentConfig } from "../management-src/types.js"; -import type { S3ObjectStore } from "../management-src/tools/s3-archive.js"; const require = createRequire(import.meta.url); const { version: packageVersion } = require("../../package.json") as { version: string }; @@ -84,14 +83,8 @@ function createEnv(overrides: Partial = {}): EnvironmentConfi doctorBin: "doctor", workspacesRoot: path.join(baseRoot, ".openclaw", "workspaces"), workspaceTemplatesRoot: path.join(baseRoot, "workspace-templates"), + localArtifactsRoot: path.join(baseRoot, "local-artifacts"), ontologyRoot: path.join(baseRoot, "ontology"), - ontologyS3Endpoint: "http://minio.internal:9000", - ontologyS3Region: "local", - ontologyS3AccessKeyId: "minio-access", - ontologyS3SecretAccessKey: "minio-secret", - ontologyS3ForcePathStyle: true, - adminInboxBucket: "admin-in", - adminOutboxBucket: "admin-out", globalSkillsRoot: path.join(baseRoot, ".openclaw", "skills"), stateFile: path.join(baseRoot, "managed-agents.json"), mqttBrokerUrl: "mqtts://channel-broker:8883", @@ -950,6 +943,35 @@ test("createAgent overwrites existing workspace seed files with selected templat assert.equal(await readFile(path.join(workspaceDir, "AGENTS.md"), "utf8"), "# Finance template\n"); }); +test("createAgent rejects a workspace template without top-level AGENTS.md", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-create-missing-agents-")); + const configPath = path.join(root, ".openclaw", "openclaw.json"); + const templateDir = path.join(root, "workspace-templates", "incomplete"); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + await writeOpenClawConfig(configPath, {}); + await mkdir(templateDir, { recursive: true }); + await writeFile(path.join(templateDir, "SOUL.md"), "# Incomplete template\n", "utf8"); + + const manager = new OpenClawManager( + new MockCliRunner() as never, + new MockCliRunner() as never, + createEnv({ + openclawHome: path.join(root, ".openclaw"), + openclawConfigPath: configPath, + workspacesRoot: path.join(root, "workspaces"), + workspaceTemplatesRoot: path.join(root, "workspace-templates") + }), + stateStore + ); + + await assert.rejects( + manager.createAgent({ agentId: "incomplete", templateName: "incomplete", restart: false }), + /AGENTS\.md/ + ); + assert.equal(await stateStore.getAgent("incomplete"), undefined); + assert.deepEqual((await readOpenClawConfig(configPath)).agents?.list ?? [], []); +}); + test("createAgent skips gateway restart when restart is false", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-create-container-no-restart-")); const templateDir = path.join(root, "workspace-templates", "default"); @@ -1165,6 +1187,181 @@ test("getAgent, setAgentName, setAgentModel, and updateAgentDocs follow the capa }); }); +test("agent workspace markdown supports edit, rename, delete, and default AGENTS restore", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-markdown-")); + const workspace = path.join(root, "workspaces", "lowcode"); + const workspaceTemplatesRoot = path.join(root, "workspace-templates"); + await mkdir(path.join(workspaceTemplatesRoot, "default"), { recursive: true }); + await mkdir(path.join(workspace, "knowledge"), { recursive: true }); + await mkdir(path.join(workspace, "sender-a", "memory"), { recursive: true }); + await writeFile(path.join(workspaceTemplatesRoot, "default", "AGENTS.md"), "# System default\n", "utf8"); + await writeFile(path.join(workspace, "AGENTS.md"), "# Template rules\n", "utf8"); + await writeFile(path.join(workspace, "SOUL.md"), "# Soul\n", "utf8"); + await writeFile(path.join(workspace, "knowledge", "policy.md"), "# Policy\n", "utf8"); + await writeFile(path.join(workspace, "sender-a", "MEMORY.md"), "private\n", "utf8"); + + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + await stateStore.upsertAgent({ + id: "lowcode", + accountId: "lowcode", + workspace, + inboundTopic: "daedalus-agent/agent/lowcode/inbound", + outboundTopic: "daedalus-agent/agent/lowcode/outbound", + enabled: true, + templateName: "default", + updatedAt: "2026-05-13T00:00:00.000Z" + }); + const manager = new OpenClawManager( + new MockCliRunner() as never, + new MockCliRunner() as never, + createEnv({ dataRoot: root, workspacesRoot: path.join(root, "workspaces"), workspaceTemplatesRoot }), + stateStore + ); + + const listed = await manager.listAgentMarkdown({ agentId: "lowcode" }); + assert.deepEqual(listed.items.map((item) => item.path), ["AGENTS.md", "knowledge/policy.md", "SOUL.md"]); + assert.equal(listed.items[0]?.protected, true); + assert.equal(listed.items[0]?.readonly, false); + await assert.rejects( + manager.writeAgentMarkdown({ + agentId: "lowcode", + path: "agents.md", + content: "# Spoofed rules\n", + create: true + }), + /AGENTS\.md/ + ); + + const soul = await manager.readAgentMarkdown({ agentId: "lowcode", path: "SOUL.md" }); + const saved = await manager.writeAgentMarkdown({ + agentId: "lowcode", + path: "SOUL.md", + content: "# Updated soul\n", + etag: soul.etag + }); + assert.equal(await readFile(path.join(workspace, "SOUL.md"), "utf8"), "# Updated soul\n"); + await assert.rejects( + manager.writeAgentMarkdown({ + agentId: "lowcode", + path: "SOUL.md", + content: "stale\n", + etag: soul.etag + }), + /modified by another operation/ + ); + + const created = await manager.writeAgentMarkdown({ + agentId: "lowcode", + path: ".learnings/reviews/incident.md", + content: "# Incident review\n", + create: true + }); + assert.equal(created.path, ".learnings/reviews/incident.md"); + assert.equal( + await readFile(path.join(workspace, ".learnings", "reviews", "incident.md"), "utf8"), + "# Incident review\n" + ); + if (process.platform !== "win32") { + assert.equal((await stat(path.join(workspace, ".learnings"))).mode & 0o7777, 0o2770); + assert.equal((await stat(path.join(workspace, ".learnings", "reviews"))).mode & 0o7777, 0o2770); + } + + const renamed = await manager.renameAgentMarkdown({ + agentId: "lowcode", + sourcePath: "SOUL.md", + destinationPath: "PROFILE.md", + etag: saved.etag + }); + await manager.deleteAgentMarkdown({ agentId: "lowcode", path: "PROFILE.md", etag: renamed.etag }); + await assert.rejects( + manager.renameAgentMarkdown({ + agentId: "lowcode", + sourcePath: "AGENTS.md", + destinationPath: "RULES.md", + etag: listed.items[0]?.etag + }), + /AGENTS\.md/ + ); + await assert.rejects( + manager.deleteAgentMarkdown({ agentId: "lowcode", path: "AGENTS.md", etag: listed.items[0]?.etag }), + /AGENTS\.md/ + ); + + const restored = await manager.restoreDefaultAgentDocs({ + agentId: "lowcode", + etag: listed.items[0]?.etag + }); + assert.equal(restored.content, "# System default\n"); + assert.equal(await readFile(path.join(workspace, "AGENTS.md"), "utf8"), "# System default\n"); +}); + +test("agent private skills can be installed and deleted without changing global skills", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-skill-")); + const workspace = path.join(root, "workspaces", "lowcode"); + const globalSkillsRoot = path.join(root, ".openclaw", "skills"); + await mkdir(workspace, { recursive: true }); + await mkdir(path.join(globalSkillsRoot, "global-report"), { recursive: true }); + await writeFile( + path.join(globalSkillsRoot, "global-report", "SKILL.md"), + "---\nname: global-report\ndescription: Global report\n---\n", + "utf8" + ); + const skillZip = new AdmZip(); + skillZip.addFile( + "SKILL.md", + Buffer.from("---\nname: agent-report\ndescription: Agent report\n---\n\n# Agent report\n", "utf8") + ); + const localArtifactsRoot = path.join(root, "local-artifacts"); + const skillArtifactPath = "agent-skill/agent-report.zip"; + await mkdir(path.dirname(path.join(localArtifactsRoot, skillArtifactPath)), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, skillArtifactPath), skillZip.toBuffer()); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + await stateStore.upsertAgent({ + id: "lowcode", + accountId: "lowcode", + workspace, + inboundTopic: "daedalus-agent/agent/lowcode/inbound", + outboundTopic: "daedalus-agent/agent/lowcode/outbound", + enabled: true, + templateName: "default", + updatedAt: "2026-05-13T00:00:00.000Z" + }); + const manager = new OpenClawManager( + new MockCliRunner() as never, + new MockCliRunner() as never, + createEnv({ + dataRoot: root, + workspacesRoot: path.join(root, "workspaces"), + globalSkillsRoot, + localArtifactsRoot + }), + stateStore + ); + + const installed = await manager.installAgentSkillFromLocal({ + agentId: "lowcode", + slug: "agent-report", + artifactPath: skillArtifactPath + }); + assert.equal(installed.skill.slug, "agent-report"); + const listed = await manager.listAgentSkills({ agentId: "lowcode" }); + assert.equal(listed.items[0]?.slug, "agent-report"); + + const exported = await manager.exportAgentSkill({ + agentId: "lowcode", + slug: "agent-report", + etag: listed.items[0]!.etag + }); + assert.equal(exported.slug, "agent-report"); + assert.match(exported.artifactPath, /^agent-skill\/\d{4}-\d{2}-\d{2}\//); + const exportedZip = new AdmZip(await readFile(path.join(localArtifactsRoot, ...exported.artifactPath.split("/")))); + assert.equal(exportedZip.readAsText("SKILL.md").includes("# Agent report"), true); + + await manager.deleteAgentSkill({ agentId: "lowcode", slug: "agent-report", etag: listed.items[0]!.etag }); + assert.deepEqual((await manager.listAgentSkills({ agentId: "lowcode" })).items, []); + assert.equal((await stat(path.join(globalSkillsRoot, "global-report"))).isDirectory(), true); +}); + test("shared chmod errors are treated as non-fatal for existing kernel files", () => { assert.equal(isIgnorableSharedChmodError(Object.assign(new Error("chmod failed"), { code: "EPERM" })), true); assert.equal(isIgnorableSharedChmodError(Object.assign(new Error("chmod failed"), { code: "EACCES" })), true); @@ -1444,7 +1641,7 @@ test("deleteAgent issues the expected openclaw commands", async () => { assert.equal(await stateStore.getAgent("lowcode"), undefined); }); -test("exportAgentWorkspace uploads the complete workspace to S3", async () => { +test("exportAgentWorkspace writes the complete workspace to local artifacts", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-agent-export-")); const workspace = path.join(root, "workspaces", "lowcode"); const senderRoot = path.join(workspace, "daedalus-agent-management-web"); @@ -1479,41 +1676,28 @@ test("exportAgentWorkspace uploads the complete workspace to S3", async () => { updatedAt: "2026-05-13T00:00:00.000Z" }); - const uploads: Array<{ bucket: string; objectKey: string; body: Buffer; contentType?: string }> = []; - const objectStore: S3ObjectStore = { - async getObject(): Promise { - throw new Error("export should not read from S3"); - }, - async putObject(bucket: string, objectKey: string, body: Buffer, contentType?: string): Promise { - uploads.push({ bucket, objectKey, body, contentType }); - } - }; + const localArtifactsRoot = path.join(root, "local-artifacts"); const manager = new OpenClawManager( new MockCliRunner() as never, new MockCliRunner() as never, createEnv({ dataRoot: root, workspacesRoot: path.join(root, "workspaces"), - adminOutboxBucket: "admin-out" + localArtifactsRoot }), - stateStore, - { objectStore } + stateStore ); const result = await manager.exportAgentWorkspace({ - agentId: "lowcode", - bucket: "admin-out", - objectKey: "agent-workspace/lowcode.zip" + agentId: "lowcode" }); - assert.equal(uploads.length, 1); - assert.equal(uploads[0]!.bucket, "admin-out"); - assert.equal(uploads[0]!.objectKey, "agent-workspace/lowcode.zip"); - assert.equal(uploads[0]!.contentType, "application/zip"); - assert.equal(result.byteSize, uploads[0]!.body.byteLength); + assert.match(result.artifactPath, /^agent-workspace\/\d{4}-\d{2}-\d{2}\//); + const archivePayload = await readFile(path.join(localArtifactsRoot, ...result.artifactPath.split("/"))); + assert.equal(result.byteSize, archivePayload.byteLength); assert.deepEqual(result.missing, []); - const zip = new AdmZip(uploads[0]!.body); + const zip = new AdmZip(archivePayload); const names = zip.getEntries().filter((entry) => !entry.isDirectory).map((entry) => entry.entryName).sort(); assert.deepEqual(names, [ ".learnings/LEARNINGS.md", @@ -1544,27 +1728,21 @@ test("createTemplate preserves learning files from exported workspace zip", asyn zip.addFile(".learnings/LEARNINGS.md", Buffer.from("# Learnings\n", "utf8")); zip.addFile("skills/planner/SKILL.md", Buffer.from("# Planner\n", "utf8")); const zipPayload = zip.toBuffer(); - - const objectStore: S3ObjectStore = { - async getObject(bucket: string, objectKey: string): Promise { - assert.equal(bucket, "admin-in"); - assert.equal(objectKey, "agent-workspace/lowcode.zip"); - return zipPayload; - } - }; + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "agent-workspace"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "agent-workspace", "lowcode.zip"), zipPayload); const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); const templateManager = new TemplateManager( createEnv({ - workspaceTemplatesRoot: path.join(root, "workspace-templates") + workspaceTemplatesRoot: path.join(root, "workspace-templates"), + localArtifactsRoot }), - stateStore, - { objectStore } + stateStore ); const record = await templateManager.createTemplate({ templateName: "lowcode", - bucket: "admin-in", - objectKey: "agent-workspace/lowcode.zip" + artifactPath: "agent-workspace/lowcode.zip" }); assert.equal(record.templateName, "lowcode"); @@ -1622,19 +1800,19 @@ test("global skills delete reconciles drift when clawhub says the skill is not i ["node", ...args], { stdout: "", - stderr: "Error: Not installed: mcporter", + stderr: "Error: Not installed: legacy-helper", exitCode: 1 } ); } }; - await mkdir(path.join(skillsRoot, "mcporter"), { recursive: true }); + await mkdir(path.join(skillsRoot, "legacy-helper"), { recursive: true }); await mkdir(path.join(root, ".openclaw", ".clawhub"), { recursive: true }); await writeFile(path.join(root, ".openclaw", ".clawhub", "lock.json"), `${JSON.stringify({ version: 1, skills: { - mcporter: { + "legacy-helper": { version: "0.1.0", installedAt: 1779852800910 }, @@ -1658,16 +1836,16 @@ test("global skills delete reconciles drift when clawhub says the skill is not i } ); - const result = await manager.deleteGlobalSkill({ slug: "mcporter" }) as { + const result = await manager.deleteGlobalSkill({ slug: "legacy-helper" }) as { root: string; output: string; reconciled: boolean; }; assert.equal(result.root, skillsRoot); - assert.equal(result.output, "Error: Not installed: mcporter"); + assert.equal(result.output, "Error: Not installed: legacy-helper"); assert.equal(result.reconciled, true); - await assert.rejects(stat(path.join(skillsRoot, "mcporter")), /ENOENT/); + await assert.rejects(stat(path.join(skillsRoot, "legacy-helper")), /ENOENT/); const lock = JSON.parse(await readFile(path.join(root, ".openclaw", ".clawhub", "lock.json"), "utf8")) as { version: number; @@ -1704,33 +1882,27 @@ test("global skill install from local zip writes uploaded zip with private slug" zip.addFile("tools/search.md", Buffer.from("search\n", "utf8")); zip.addFile("scripts/install-app.js", Buffer.from("import { spawnSync } from 'node:child_process';\nspawnSync('true');\n", "utf8")); const zipPayload = zip.toBuffer(); - - const objectStore: S3ObjectStore = { - async getObject(bucket: string, objectKey: string): Promise { - assert.equal(bucket, "admin-in"); - assert.equal(objectKey, "skills/demo.zip"); - return zipPayload; - } - }; + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "skills"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "skills", "demo.zip"), zipPayload); const manager = new OpenClawManager( cli as never, doctor as never, createEnv({ - globalSkillsRoot: path.join(root, ".openclaw", "skills") + globalSkillsRoot: path.join(root, ".openclaw", "skills"), + localArtifactsRoot }), stateStore, { clawhubCli: clawhub as never, - clawhubBin: "/mock/bin/clawhub.js", - objectStore + clawhubBin: "/mock/bin/clawhub.js" } ); await manager.installGlobalSkillFromLocal({ slug: "private-demo-skill", - bucket: "admin-in", - objectKey: "skills/demo.zip", + artifactPath: "skills/demo.zip", force: true }); @@ -1755,7 +1927,7 @@ test("global skill install from local zip writes uploaded zip with private slug" assert.deepEqual(JSON.parse(await readFile(path.join(installedDir, ".openclaw", "source-origin.json"), "utf8")), { version: 1, source: "daedalus-agent-management-upload", - spec: "s3://admin-in/skills/demo.zip", + spec: "local-artifact:skills/demo.zip", slug: "private-demo-skill" }); }); @@ -1768,26 +1940,21 @@ test("global skill install from local zip requires top-level SKILL.md", async () const zip = new AdmZip(); zip.addFile("nested/SKILL.md", Buffer.from("# Nested Skill\n", "utf8")); + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "skills"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "skills", "demo.zip"), zip.toBuffer()); const manager = new OpenClawManager( cli as never, doctor as never, - createEnv(), - stateStore, - { - objectStore: { - async getObject(): Promise { - return zip.toBuffer(); - } - } - } + createEnv({ localArtifactsRoot }), + stateStore ); await assert.rejects( manager.installGlobalSkillFromLocal({ slug: "private-demo-skill", - bucket: "admin-in", - objectKey: "skills/demo.zip" + artifactPath: "skills/demo.zip" }), /Uploaded skill zip must contain top-level SKILL.md/ ); @@ -1802,26 +1969,21 @@ test("global skill install from local zip rejects SKILL.md without AgentSkills f const zip = new AdmZip(); zip.addFile("SKILL.md", Buffer.from("# Demo Skill\n\nNo frontmatter here.\n", "utf8")); + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "skills"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "skills", "demo.zip"), zip.toBuffer()); const manager = new OpenClawManager( cli as never, doctor as never, - createEnv(), - stateStore, - { - objectStore: { - async getObject(): Promise { - return zip.toBuffer(); - } - } - } + createEnv({ localArtifactsRoot }), + stateStore ); await assert.rejects( manager.installGlobalSkillFromLocal({ slug: "private-demo-skill", - bucket: "admin-in", - objectKey: "skills/demo.zip" + artifactPath: "skills/demo.zip" }), /SKILL\.md must contain AgentSkills frontmatter/ ); @@ -1844,26 +2006,21 @@ test("global skill install from local zip requires SKILL.md frontmatter name to "# Demo Skill", "" ].join("\n"), "utf8")); + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "skills"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "skills", "demo.zip"), zip.toBuffer()); const manager = new OpenClawManager( cli as never, doctor as never, - createEnv(), - stateStore, - { - objectStore: { - async getObject(): Promise { - return zip.toBuffer(); - } - } - } + createEnv({ localArtifactsRoot }), + stateStore ); await assert.rejects( manager.installGlobalSkillFromLocal({ slug: "private-demo-skill", - bucket: "admin-in", - objectKey: "skills/demo.zip" + artifactPath: "skills/demo.zip" }), /frontmatter name must match slug private-demo-skill/ ); @@ -1877,7 +2034,7 @@ test("global skill install from local zip rejects built-in skill slug collisions const doctor = new MockCliRunner(); await writeFile(path.join(root, "build-in-component.json"), `${JSON.stringify({ - skill: ["mcporter"] + skill: ["built-in-helper"] }, null, 2)}\n`, "utf8"); const manager = new OpenClawManager( @@ -1886,24 +2043,16 @@ test("global skill install from local zip rejects built-in skill slug collisions createEnv({ dataRoot: root }), - stateStore, - { - objectStore: { - async getObject(): Promise { - throw new Error("built-in slug should be rejected before reading S3"); - } - } - } + stateStore ); await assert.rejects( manager.installGlobalSkillFromLocal({ - slug: "mcporter", - bucket: "admin-in", - objectKey: "skills/mcporter.zip", + slug: "built-in-helper", + artifactPath: "skills/built-in-helper.zip", force: true }), - /slug is reserved by built-in skill mcporter/ + /slug is reserved by built-in skill built-in-helper/ ); assert.equal(cli.runCalls.length, 0); }); @@ -1912,7 +2061,7 @@ test("global skill install from local zip rejects seed symlink built-in slug col const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skill-upload-seed-link-")); const skillsRoot = path.join(root, ".openclaw", "skills"); const seedSkillsRoot = path.join(root, "seed", "skills"); - const seedSkillDir = path.join(seedSkillsRoot, "mcporter"); + const seedSkillDir = path.join(seedSkillsRoot, "seed-helper"); const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); const cli = new MockCliRunner(); const doctor = new MockCliRunner(); @@ -1922,7 +2071,7 @@ test("global skill install from local zip rejects seed symlink built-in slug col process.env.DA_SEED_SKILLS_ROOT = seedSkillsRoot; await mkdir(seedSkillDir, { recursive: true }); await mkdir(skillsRoot, { recursive: true }); - await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), DIRECTORY_LINK_TYPE); + await symlink(seedSkillDir, path.join(skillsRoot, "seed-helper"), DIRECTORY_LINK_TYPE); const manager = new OpenClawManager( cli as never, @@ -1931,21 +2080,13 @@ test("global skill install from local zip rejects seed symlink built-in slug col dataRoot: root, globalSkillsRoot: skillsRoot }), - stateStore, - { - objectStore: { - async getObject(): Promise { - throw new Error("seed symlink slug should be rejected before reading S3"); - } - } - } + stateStore ); await assert.rejects( manager.installGlobalSkillFromLocal({ - slug: "mcporter", - bucket: "admin-in", - objectKey: "skills/mcporter.zip", + slug: "seed-helper", + artifactPath: "skills/seed-helper.zip", force: true }), /slug is reserved by built-in seed skill link/ @@ -2046,15 +2187,15 @@ test("listGlobalSkills marks built-in skills declared in DA_DATA_DIR", async () const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); await mkdir(path.join(skillsRoot, "agent-browser-clawdbot"), { recursive: true }); - await mkdir(path.join(skillsRoot, "mcporter"), { recursive: true }); + await mkdir(path.join(skillsRoot, "built-in-helper"), { recursive: true }); await mkdir(path.join(skillsRoot, "markdown-converter"), { recursive: true }); await mkdir(path.join(skillsRoot, "zeta"), { recursive: true }); await writeFile(path.join(root, "build-in-component.json"), `${JSON.stringify({ "openclaw-version": "2026.05.22", "agent-template": ["default"], ontology: [], - skill: ["agent-browser-clawdbot", "mcporter", "markdown-converter"], - npm: ["mcporter"], + skill: ["agent-browser-clawdbot", "built-in-helper", "markdown-converter"], + npm: ["built-in-helper"], pip: ["uv", "markitdown[all]"] }, null, 2)}\n`, "utf8"); @@ -2092,8 +2233,8 @@ test("listGlobalSkills marks built-in skills declared in DA_DATA_DIR", async () invalidReason: "SKILL.md is missing", isBuiltIn: true }, { - slug: "markdown-converter", - path: path.join(skillsRoot, "markdown-converter"), + slug: "built-in-helper", + path: path.join(skillsRoot, "built-in-helper"), source: "built-in", installed: true, pathExists: true, @@ -2102,8 +2243,8 @@ test("listGlobalSkills marks built-in skills declared in DA_DATA_DIR", async () invalidReason: "SKILL.md is missing", isBuiltIn: true }, { - slug: "mcporter", - path: path.join(skillsRoot, "mcporter"), + slug: "markdown-converter", + path: path.join(skillsRoot, "markdown-converter"), source: "built-in", installed: true, pathExists: true, @@ -2128,7 +2269,7 @@ test("listGlobalSkills includes symlinked seed built-in skills with OpenClaw loa const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skills-built-in-link-")); const skillsRoot = path.join(root, ".openclaw", "skills"); const seedSkillsRoot = path.join(root, "seed", "skills"); - const seedSkillDir = path.join(seedSkillsRoot, "mcporter"); + const seedSkillDir = path.join(seedSkillsRoot, "seed-helper"); const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); const previousSeedSkillsRoot = process.env.DA_SEED_SKILLS_ROOT; @@ -2138,14 +2279,14 @@ test("listGlobalSkills includes symlinked seed built-in skills with OpenClaw loa await mkdir(skillsRoot, { recursive: true }); await writeFile(path.join(seedSkillDir, "SKILL.md"), [ "---", - "name: McPorter Test", + "name: Seed Helper Test", "description: Built-in skill exposed through a seed symlink.", "---", "", - "# McPorter", + "# Seed Helper", "" ].join("\n"), "utf8"); - await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), DIRECTORY_LINK_TYPE); + await symlink(seedSkillDir, path.join(skillsRoot, "seed-helper"), DIRECTORY_LINK_TYPE); const manager = new OpenClawManager( new MockCliRunner() as never, @@ -2172,9 +2313,9 @@ test("listGlobalSkills includes symlinked seed built-in skills with OpenClaw loa invalidReason: result.items[0]!.invalidReason, isBuiltIn: result.items[0]!["is-built-in"] }, { - slug: "mcporter", - path: path.join(skillsRoot, "mcporter"), - name: "McPorter Test", + slug: "seed-helper", + path: path.join(skillsRoot, "seed-helper"), + name: "Seed Helper Test", source: "built-in", installed: true, pathExists: true, @@ -2303,3 +2444,102 @@ test("runDiagnostics and gatewayStatus use the expected operational commands", a ["gateway", "status", "--json"] ]); }); + +test("AgentOperationsService covers direct diagnostics and restart failure variants", async () => { + const env = createEnv(); + const doctorCalls: string[][] = []; + const doctor = { + async run(args: string[]): Promise { + doctorCalls.push(args); + return { stdout: "doctor stdout", stderr: "doctor stderr", exitCode: 0 }; + } + }; + const unusedCli = { + async run(): Promise { + throw new Error("fallback should not run"); + }, + async runJson(): Promise { + return { ok: true }; + } + }; + const direct = new AgentOperationsService(unusedCli as never, doctor as never, env); + assert.deepEqual(await direct.runDiagnostics(), { + output: "doctor stdout\ndoctor stderr", + backend: "doctor" + }); + assert.deepEqual(doctorCalls, [[]]); + + const forcedCalls: string[][] = []; + const forcedCli = { + async run(args: string[]): Promise { + forcedCalls.push(args); + return { stdout: JSON.stringify({ restarted: true }), stderr: "", exitCode: 0 }; + }, + async runJson(): Promise { + return { rpc: { ok: true } }; + } + }; + const forced = new AgentOperationsService(forcedCli as never, doctor as never, env); + assert.deepEqual(await forced.restartGateway({ safe: false, force: true, skipDeferral: true }), { restarted: true }); + assert.deepEqual(forcedCalls[0], ["gateway", "restart", "--skip-deferral", "--force", "--json"]); + + const originalReadyTimeout = (AgentOperationsService as any).GATEWAY_READY_TIMEOUT_MS; + const originalReadyPoll = (AgentOperationsService as any).GATEWAY_READY_POLL_MS; + (AgentOperationsService as any).GATEWAY_READY_TIMEOUT_MS = 5; + (AgentOperationsService as any).GATEWAY_READY_POLL_MS = 1; + try { + const unavailable = (result: CommandResult, readyStatus?: unknown) => ({ + async run(): Promise { + return result; + }, + async runJson(): Promise { + if (readyStatus !== undefined) return readyStatus; + throw new Error("not ready"); + } + }); + + const completedWithoutJson = new AgentOperationsService( + unavailable({ stdout: "", stderr: "notice", exitCode: 0 }) as never, + doctor as never, + env + ); + await assert.rejects( + completedWithoutJson.restartGateway({ safe: false }), + /gateway restart completed but gateway did not become ready/ + ); + + const completed = new AgentOperationsService( + unavailable({ stdout: "", stderr: "notice", exitCode: 0 }, { rpc: { ok: true } }) as never, + doctor as never, + env + ); + assert.deepEqual(await completed.restartGateway({ force: true }), { + ok: true, + result: "restart-completed-without-restart-json", + restart: { exitCode: 0, stderr: "notice" }, + gatewayStatus: { rpc: { ok: true } } + }); + + const recovered = new AgentOperationsService( + unavailable({ stdout: "", stderr: "timeout", exitCode: 1 }, { rpc: { ok: true } }) as never, + doctor as never, + env + ); + assert.deepEqual(await recovered.restartGateway({ safe: false }), { + ok: true, + result: "restart-completed-after-gateway-recovery", + restart: { exitCode: 1, stderr: "timeout" }, + gatewayStatus: { rpc: { ok: true } } + }); + + const failed = new AgentOperationsService( + unavailable({ stdout: "", stderr: "", exitCode: 1 }) as never, + doctor as never, + env + ); + await assert.rejects(failed.restartGateway({ safe: false }), /failed with exit code 1/); + } finally { + (AgentOperationsService as any).GATEWAY_READY_TIMEOUT_MS = originalReadyTimeout; + (AgentOperationsService as any).GATEWAY_READY_POLL_MS = originalReadyPoll; + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/skills-service-coverage.test.ts b/packages/daedalus-agent-management-web/server/management-test/skills-service-coverage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..341017e56660157fef88fcf88799716c107651e7 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/skills-service-coverage.test.ts @@ -0,0 +1,509 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { AgentSkillsService } from "../management-src/capabilities/agent/skills-service.js"; +import { CommandError } from "../management-src/tools/cli-runner.js"; +import type { EnvironmentConfig } from "../management-src/types.js"; + +const DIRECTORY_LINK_TYPE = process.platform === "win32" ? "junction" : "dir"; + +function createEnv(root: string): EnvironmentConfig { + return { + gatewayRestartMode: "container", + dataRoot: path.join(root, "data"), + openclawHome: path.join(root, ".openclaw"), + openclawStateDir: path.join(root, ".openclaw"), + openclawConfigPath: path.join(root, ".openclaw", "openclaw.json"), + tmpDir: path.join(root, "tmp"), + openclawCliTmpDir: path.join(root, "cli-tmp"), + gatewayAuthToken: "test-token", + openclawBin: "openclaw", + doctorBin: "doctor", + workspacesRoot: path.join(root, "workspaces"), + workspaceTemplatesRoot: path.join(root, "templates"), + localArtifactsRoot: path.join(root, "local-artifacts"), + ontologyRoot: path.join(root, "ontology"), + globalSkillsRoot: path.join(root, ".openclaw", "skills"), + stateFile: path.join(root, "managed-agents.json"), + mqttBrokerUrl: "mqtt://broker", + mqttUsername: "user", + mqttPassword: "pass", + managedMqttBrokerUrl: "mqtt://broker", + managedMqttUsername: "user", + managedMqttPassword: "pass", + inboundTopicTemplate: "in/{agentId}", + outboundTopicTemplate: "out/{agentId}" + }; +} + +function createService(env: EnvironmentConfig, run?: (args: string[]) => Promise): AgentSkillsService { + return new AgentSkillsService({ + async run(args: string[]) { + if (run) return await run(args); + return { stdout: "", stderr: "", exitCode: 0 }; + } + } as never, "clawhub", env); +} + +async function writeManifest(skillDir: string, name: string, description = "Skill description"): Promise { + await mkdir(skillDir, { recursive: true }); + await writeFile(path.join(skillDir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n`, "utf8"); +} + +test("AgentSkillsService parses and validates uploaded skill manifests", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-skills-manifest-")); + const env = createEnv(root); + const service = createService(env); + const privateService = service as any; + + try { + assert.throws(() => privateService.validateSkillSlug("test", undefined), /requires slug/); + for (const slug of ["bad/slug", "bad\\slug", "bad..slug", "bad slug"]) { + assert.throws(() => privateService.validateSkillSlug("test", slug), /invalid skill slug/); + } + assert.equal(privateService.validateSkillSlug("test", " demo-skill "), "demo-skill"); + + assert.deepEqual(privateService.parseSkillManifest("# no frontmatter\n"), { hasFrontmatter: false }); + assert.deepEqual(privateService.parseSkillManifest([ + "---", + "# comment", + "invalid line", + "name: \"demo-skill\"", + "description: 'Quoted description'", + "other: ignored", + "---", + "" + ].join("\n")), { + hasFrontmatter: true, + name: "demo-skill", + description: "Quoted description" + }); + assert.equal(privateService.unquoteFrontmatterValue(" plain "), "plain"); + + const missing = path.join(root, "missing"); + await mkdir(missing); + await assert.rejects(privateService.validateUploadedSkillManifest("test", missing, "demo"), /must contain AgentSkills frontmatter/); + + const noName = path.join(root, "no-name"); + await mkdir(noName); + await writeFile(path.join(noName, "SKILL.md"), "---\ndescription: present\n---\n", "utf8"); + await assert.rejects(privateService.validateUploadedSkillManifest("test", noName, "demo"), /must contain name/); + + const noDescription = path.join(root, "no-description"); + await mkdir(noDescription); + await writeFile(path.join(noDescription, "SKILL.md"), "---\nname: demo\n---\n", "utf8"); + await assert.rejects(privateService.validateUploadedSkillManifest("test", noDescription, "demo"), /must contain description/); + + const mismatch = path.join(root, "mismatch"); + await writeManifest(mismatch, "other"); + await assert.rejects(privateService.validateUploadedSkillManifest("test", mismatch, "demo"), /must match slug demo/); + + const valid = path.join(root, "valid"); + await writeManifest(valid, "Demo", " Description "); + assert.deepEqual(await privateService.validateUploadedSkillManifest("test", valid, "demo"), { + hasFrontmatter: true, + name: "Demo", + description: "Description" + }); + + const exact = path.join(root, "exact"); + await writeManifest(exact, "exact"); + await privateService.ensureTopLevelSkillManifest(exact); + const lowercase = path.join(root, "lowercase"); + await mkdir(lowercase); + await writeFile(path.join(lowercase, "skill.md"), "---\nname: lowercase\ndescription: Lowercase\n---\n", "utf8"); + await privateService.ensureTopLevelSkillManifest(lowercase); + assert.match(await readFile(path.join(lowercase, "SKILL.md"), "utf8"), /name: lowercase/); + const noManifest = path.join(root, "no-manifest"); + await mkdir(noManifest); + await assert.rejects(privateService.ensureTopLevelSkillManifest(noManifest), /top-level SKILL\.md/); + const directoryManifest = path.join(root, "directory-manifest"); + await mkdir(path.join(directoryManifest, "SKILL.md"), { recursive: true }); + await assert.rejects(privateService.ensureTopLevelSkillManifest(directoryManifest), /top-level SKILL\.md/); + + await assert.rejects(privateService.prepareLocalSkillInstall({ + commandName: "agent.skills.install.local", + slug: "local", + artifactPath: " " + }), /requires artifactPath/); + await assert.rejects(privateService.prepareGlobalSkillInstall({ + commandName: "skills.global.install.local", + slug: "global", + artifactPath: " " + }), /requires artifactPath/); + await assert.rejects( + privateService.prepareSkillInstallFromArchive("test", "demo", Buffer.from("not a zip")), + /Invalid|zip/i + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("AgentSkillsService reports global skill source and invalid variants", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-skills-global-records-")); + const env = createEnv(root); + const service = createService(env); + const privateService = service as any; + const lockPath = path.join(path.dirname(env.globalSkillsRoot), ".clawhub", "lock.json"); + + try { + await mkdir(path.dirname(lockPath), { recursive: true }); + await writeFile(lockPath, JSON.stringify({ skills: null }), "utf8"); + await mkdir(env.globalSkillsRoot, { recursive: true }); + assert.deepEqual(await service.listGlobalSkills(), { root: env.globalSkillsRoot, items: [] }); + + const fileSkill = path.join(env.globalSkillsRoot, "file-skill"); + await writeFile(fileSkill, "file", "utf8"); + const fileRecord = await privateService.buildGlobalSkillRecord("file-skill", env.globalSkillsRoot, new Set()); + assert.equal(fileRecord.invalidReason, "skill path is not a directory"); + + const missingManifest = path.join(env.globalSkillsRoot, "missing-manifest"); + await mkdir(missingManifest); + assert.equal( + (await privateService.buildGlobalSkillRecord("missing-manifest", env.globalSkillsRoot, new Set())).invalidReason, + "SKILL.md is missing" + ); + + const noFrontmatter = path.join(env.globalSkillsRoot, "no-frontmatter"); + await mkdir(noFrontmatter); + await writeFile(path.join(noFrontmatter, "SKILL.md"), "# Plain\n", "utf8"); + assert.equal( + (await privateService.buildGlobalSkillRecord("no-frontmatter", env.globalSkillsRoot, new Set())).invalidReason, + "SKILL.md frontmatter is missing" + ); + + const noDescription = path.join(env.globalSkillsRoot, "no-description"); + await mkdir(noDescription); + await writeFile(path.join(noDescription, "SKILL.md"), "---\nname: no-description\n---\n", "utf8"); + assert.equal( + (await privateService.buildGlobalSkillRecord("no-description", env.globalSkillsRoot, new Set())).invalidReason, + "SKILL.md frontmatter description is missing" + ); + + const uploaded = path.join(env.globalSkillsRoot, "uploaded"); + await writeManifest(uploaded, "uploaded"); + await mkdir(path.join(uploaded, ".openclaw")); + await writeFile(path.join(uploaded, ".openclaw", "source-origin.json"), JSON.stringify({ source: " custom-source " }), "utf8"); + assert.equal( + (await privateService.buildGlobalSkillRecord("uploaded", env.globalSkillsRoot, new Set())).source, + "custom-source" + ); + await assert.rejects( + privateService.ensureAgentSkillSlugDoesNotCollideWithGlobal("agent.skills.install.local", "UPLOADED"), + /global skill uses the same slug/ + ); + + const clawhub = path.join(env.globalSkillsRoot, "clawhub"); + await writeManifest(clawhub, "clawhub"); + await mkdir(path.join(clawhub, ".clawhub")); + await writeFile(path.join(clawhub, ".clawhub", "origin.json"), JSON.stringify({ source: "registry" }), "utf8"); + assert.equal( + (await privateService.buildGlobalSkillRecord("clawhub", env.globalSkillsRoot, new Set())).source, + "clawhub" + ); + + assert.equal(privateService.resolveSkillSource({ isBuiltIn: true, pathExists: true }), "built-in"); + assert.equal(privateService.resolveSkillSource({ isBuiltIn: true, pathExists: false }), "missing-built-in"); + assert.equal(privateService.resolveSkillSource({ isBuiltIn: false, pathExists: true }), "openclaw-managed"); + assert.equal(privateService.resolveSkillInvalidReason({ + isBuiltIn: false, + entryInfo: { pathExists: false, isDirectory: false, isSymlink: false } + }), "skill directory is missing"); + assert.equal(privateService.resolveSkillInvalidReason({ + isBuiltIn: true, + entryInfo: { pathExists: false, isDirectory: false, isSymlink: false } + }), "built-in skill directory is not linked"); + assert.equal(privateService.resolveSkillInvalidReason({ + isBuiltIn: false, + entryInfo: { pathExists: true, isDirectory: true, isSymlink: false }, + manifest: { hasFrontmatter: true }, + description: "description" + }), undefined); + + assert.deepEqual(await privateService.readSkillEntryInfo(path.join(root, "missing-entry")), { + pathExists: false, + isSymlink: false, + isDirectory: false + }); + await writeFile(path.join(root, "invalid-json"), "invalid", "utf8"); + assert.equal(await privateService.readJson(path.join(root, "invalid-json")), undefined); + + const workdir = path.dirname(env.globalSkillsRoot); + await writeFile(lockPath, "invalid", "utf8"); + await privateService.reconcileGlobalSkillDrift("missing", workdir); + await writeFile(lockPath, JSON.stringify({ skills: { other: {} } }), "utf8"); + await privateService.reconcileGlobalSkillDrift("missing", workdir); + await writeFile(lockPath, JSON.stringify({ skills: { remove: {} } }), "utf8"); + await privateService.reconcileGlobalSkillDrift("remove", workdir); + assert.deepEqual(JSON.parse(await readFile(lockPath, "utf8")), { version: 1, skills: {} }); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("AgentSkillsService validates private skill roots, etags, archives, and replacements", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-skills-agent-")); + const env = createEnv(root); + const service = createService(env); + const privateService = service as any; + const workspace = path.join(env.workspacesRoot, "agent-a"); + const skillsRoot = path.join(workspace, "skills"); + + try { + await mkdir(workspace, { recursive: true }); + await assert.rejects(privateService.resolveAgentSkillsRoot(env.workspacesRoot), /outside the managed workspaces root/); + await assert.rejects(privateService.resolveAgentSkillsRoot(path.join(workspace, "nested")), /outside the managed workspaces root/); + await assert.rejects(privateService.resolveAgentSkillsRoot(path.join(root, "outside")), /outside the managed workspaces root/); + + const workspaceFile = path.join(env.workspacesRoot, "file-agent"); + await writeFile(workspaceFile, "file", "utf8"); + await assert.rejects(privateService.resolveAgentSkillsRoot(workspaceFile), /workspace is not a regular directory/); + + const outside = path.join(root, "outside-dir"); + const linkedWorkspace = path.join(env.workspacesRoot, "linked-agent"); + await mkdir(outside); + await symlink(outside, linkedWorkspace, DIRECTORY_LINK_TYPE); + await assert.rejects(privateService.resolveAgentSkillsRoot(linkedWorkspace), /workspace is not a regular directory/); + + await writeFile(skillsRoot, "file", "utf8"); + await assert.rejects(privateService.resolveAgentSkillsRoot(workspace), /skills root is not a regular directory/); + await rm(skillsRoot); + await mkdir(skillsRoot); + + await assert.rejects(privateService.buildAgentSkillRecord(skillsRoot, "../bad"), /Invalid Agent skill directory/); + await assert.rejects(privateService.buildAgentSkillRecord(skillsRoot, "missing"), /does not exist/); + await writeFile(path.join(skillsRoot, "file-skill"), "file", "utf8"); + assert.equal((await privateService.buildAgentSkillRecord(skillsRoot, "file-skill")).status, "invalid"); + await mkdir(path.join(skillsRoot, "missing-manifest")); + assert.equal((await privateService.buildAgentSkillRecord(skillsRoot, "missing-manifest")).status, "invalid"); + + const demo = path.join(skillsRoot, "demo"); + await writeManifest(demo, "demo"); + await writeFile(path.join(demo, "data.txt"), "payload", "utf8"); + const record = await privateService.buildAgentSkillRecord(skillsRoot, "demo"); + assert.equal(record.status, "ready"); + await assert.rejects(service.deleteAgentSkill(workspace, { agentId: "agent-a", slug: "demo", etag: "wrong" }), /modified by another operation/); + await assert.rejects(service.exportAgentSkill(workspace, { agentId: "agent-a", slug: "demo", etag: "wrong" }), /modified by another operation/); + const exported = await service.exportAgentSkill(workspace, { agentId: "agent-a", slug: "demo", etag: record.etag }); + assert.equal(exported.fileName, "demo.zip"); + assert.ok(exported.byteSize > 0); + + const linkedSkill = path.join(skillsRoot, "linked-skill"); + await writeManifest(linkedSkill, "linked-skill"); + await symlink(outside, path.join(linkedSkill, "link"), DIRECTORY_LINK_TYPE); + await assert.rejects(privateService.hashAgentSkillDirectory(linkedSkill), /cannot contain symbolic links/); + await assert.rejects(privateService.buildAgentSkillArchive(linkedSkill), /cannot contain symbolic links/); + + const replacement = path.join(root, "replacement"); + await writeManifest(replacement, "demo", "Replacement"); + await assert.rejects(privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: replacement, + slug: "../bad", + artifactPath: "skills/bad.zip" + }), /Invalid Agent skill directory/); + await assert.rejects(privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: replacement, + slug: "demo", + artifactPath: "skills/demo.zip" + }), /already exists/); + await assert.rejects(privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: replacement, + slug: "demo", + artifactPath: "skills/demo.zip", + force: true + }), /modified by another operation/); + await privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: replacement, + slug: "demo", + artifactPath: "skills/demo.zip", + force: true, + etag: record.etag + }); + assert.match(await readFile(path.join(skillsRoot, "demo", "SKILL.md"), "utf8"), /Replacement/); + + const newSkill = path.join(root, "new-skill-source"); + await writeManifest(newSkill, "new-skill"); + await privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: newSkill, + slug: "new-skill", + artifactPath: "skills/new-skill.zip" + }); + assert.equal((await privateService.buildAgentSkillRecord(skillsRoot, "new-skill")).status, "ready"); + await assert.rejects(privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: path.join(root, "missing-source"), + slug: "failed-skill", + artifactPath: "skills/failed-skill.zip" + })); + await assert.rejects(privateService.buildAgentSkillRecord("\u0000", "demo")); + + const rollbackNewSource = path.join(root, "rollback-new-source"); + await writeManifest(rollbackNewSource, "rollback-new"); + const rollbackExisting = path.join(skillsRoot, "rollback-existing"); + await writeManifest(rollbackExisting, "rollback-existing", "Original"); + const rollbackRecord = await privateService.buildAgentSkillRecord(skillsRoot, "rollback-existing"); + const rollbackExistingSource = path.join(root, "rollback-existing-source"); + await writeManifest(rollbackExistingSource, "rollback-existing", "Replacement"); + const originalNormalizeSharedSkillTree = privateService.normalizeSharedSkillTree; + privateService.normalizeSharedSkillTree = async () => { + throw new Error("normalization failed"); + }; + try { + await assert.rejects(privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: rollbackNewSource, + slug: "rollback-new", + artifactPath: "skills/rollback-new.zip" + }), /normalization failed/); + await assert.rejects(privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: rollbackExistingSource, + slug: "rollback-existing", + artifactPath: "skills/rollback-existing.zip", + force: true, + etag: rollbackRecord.etag + }), /normalization failed/); + } finally { + privateService.normalizeSharedSkillTree = originalNormalizeSharedSkillTree; + } + await assert.rejects(readFile(path.join(skillsRoot, "rollback-new", "SKILL.md"))); + assert.match(await readFile(path.join(rollbackExisting, "SKILL.md"), "utf8"), /Original/); + + const originalBuildAgentSkillRecord = privateService.buildAgentSkillRecord; + privateService.buildAgentSkillRecord = async () => { + throw "string failure"; + }; + try { + await assert.rejects( + privateService.installPreparedAgentSkill(skillsRoot, { + skillDir: newSkill, + slug: "string-failure", + artifactPath: "skills/string-failure.zip" + }), + (error: unknown) => error === "string failure" + ); + } finally { + privateService.buildAgentSkillRecord = originalBuildAgentSkillRecord; + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("AgentSkillsService covers global replacement, CLI errors, and permission helpers", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-skills-helpers-")); + const env = createEnv(root); + const source = path.join(root, "source"); + await writeManifest(source, "demo"); + const privateService = createService(env) as any; + const previousUser = process.env.DA_OPENCLAW_USER; + const previousGroup = process.env.DA_OPENCLAW_GROUP; + const previousSeedRoot = process.env.DA_SEED_SKILLS_ROOT; + const previousOpenClawSeedRoot = process.env.DA_SEED_OPENCLAW_SKILLS_ROOT; + + try { + await assert.rejects(privateService.installPreparedGlobalSkill({ + skillDir: source, + slug: "../escape", + artifactPath: "skills/demo.zip" + }), /invalid skill target path/); + + await mkdir(path.join(env.globalSkillsRoot, "demo"), { recursive: true }); + await assert.rejects(privateService.installPreparedGlobalSkill({ + skillDir: source, + slug: "demo", + artifactPath: "skills/demo.zip" + }), /already exists/); + await privateService.installPreparedGlobalSkill({ + skillDir: source, + slug: "demo", + artifactPath: "skills/demo.zip", + force: true + }); + await privateService.installPreparedGlobalSkill({ + skillDir: source, + slug: "new-global", + artifactPath: "skills/new-global.zip" + }); + await assert.rejects(privateService.installPreparedGlobalSkill({ + skillDir: path.join(root, "missing-global-source"), + slug: "failed-global", + artifactPath: "skills/failed-global.zip" + })); + + const mismatchedCli = createService(env, async () => { + throw new CommandError("failed", ["clawhub"], { stdout: "", stderr: "", exitCode: 1 }); + }); + await assert.rejects(mismatchedCli.deleteGlobalSkill({ slug: "demo" }), CommandError); + const regularFailureCli = createService(env, async () => { + throw new Error("regular failure"); + }); + await assert.rejects(regularFailureCli.deleteGlobalSkill({ slug: "demo" }), /regular failure/); + + assert.equal(await privateService.resolveUserId(" "), undefined); + assert.equal(await privateService.resolveUserId("123"), 123); + assert.equal(await privateService.resolveUserId("definitely-missing-user"), undefined); + assert.equal(await privateService.resolveGroupId(" "), undefined); + assert.equal(await privateService.resolveGroupId("456"), 456); + assert.equal(await privateService.resolveGroupId("definitely-missing-group"), undefined); + const originalExecFileTrim = privateService.execFileTrim; + privateService.execFileTrim = async (command: string) => command === "id" ? "789" : "group:x:654:"; + assert.equal(await privateService.resolveUserId("named-user"), 789); + assert.equal(await privateService.resolveGroupId("named-group"), 654); + privateService.execFileTrim = originalExecFileTrim; + assert.equal( + await privateService.execFileTrim(process.execPath, ["-e", "process.stdout.write(' 42 ')"]), + "42" + ); + assert.equal(await privateService.execFileTrim(process.execPath, ["-e", ""]), undefined); + process.env.DA_OPENCLAW_USER = "0"; + process.env.DA_OPENCLAW_GROUP = "0"; + assert.deepEqual(await privateService.resolveSharedOwnership({ includeUid: true }), { uid: 0, gid: 0 }); + assert.deepEqual(await privateService.resolveSharedOwnership({ includeUid: false }), { uid: undefined, gid: 0 }); + + const target = path.join(root, "permission-target"); + await writeFile(target, "target", "utf8"); + await privateService.bestEffortChown(target, {}); + await privateService.bestEffortChown(target, { uid: 0, gid: 0 }); + await privateService.bestEffortChown(target, { uid: 0, gid: undefined }); + await privateService.bestEffortChmod(path.join(root, "missing-permission-target"), 0o660); + await privateService.normalizeSharedSkillPath(path.join(root, "missing-tree"), {}); + await chmod(target, 0o755); + await privateService.normalizeSharedSkillPath(target, {}); + + const outside = path.join(root, "outside"); + const link = path.join(root, "permission-link"); + await mkdir(outside); + await symlink(outside, link, DIRECTORY_LINK_TYPE); + await privateService.bestEffortLchown(link, { uid: 0, gid: 0 }); + await privateService.bestEffortLchown(link, {}); + await privateService.bestEffortLchown(link, { uid: 0, gid: undefined }); + await privateService.normalizeSharedSkillPath(link, { uid: 0, gid: 0 }); + + const seedRoot = path.join(root, "seed-skills"); + process.env.DA_SEED_SKILLS_ROOT = seedRoot; + assert.equal(privateService.isBuiltInSeedSkillLink({ isSymlink: false }), false); + assert.equal(privateService.isBuiltInSeedSkillLink({ isSymlink: true, targetPath: path.join(seedRoot, "demo") }), true); + process.env.DA_SEED_SKILLS_ROOT = ""; + process.env.DA_SEED_OPENCLAW_SKILLS_ROOT = path.join(root, "openclaw-seed-skills"); + assert.equal(privateService.isBuiltInSeedSkillLink({ + isSymlink: true, + targetPath: path.join(process.env.DA_SEED_OPENCLAW_SKILLS_ROOT, "demo") + }), true); + process.env.DA_SEED_OPENCLAW_SKILLS_ROOT = ""; + assert.equal(privateService.isBuiltInSeedSkillLink({ + isSymlink: true, + targetPath: "/opt/daedalus-agent-seed/home/.openclaw/skills/demo" + }), true); + } finally { + if (previousUser === undefined) delete process.env.DA_OPENCLAW_USER; + else process.env.DA_OPENCLAW_USER = previousUser; + if (previousGroup === undefined) delete process.env.DA_OPENCLAW_GROUP; + else process.env.DA_OPENCLAW_GROUP = previousGroup; + if (previousSeedRoot === undefined) delete process.env.DA_SEED_SKILLS_ROOT; + else process.env.DA_SEED_SKILLS_ROOT = previousSeedRoot; + if (previousOpenClawSeedRoot === undefined) delete process.env.DA_SEED_OPENCLAW_SKILLS_ROOT; + else process.env.DA_SEED_OPENCLAW_SKILLS_ROOT = previousOpenClawSeedRoot; + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/state-store.test.ts b/packages/daedalus-agent-management-web/server/management-test/state-store.test.ts index 941f79df0d52198e8573c530f20a904603f52990..b28c5cb6b1679d4c26079a3f54c780eccd97e7cc 100644 --- a/packages/daedalus-agent-management-web/server/management-test/state-store.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/state-store.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -26,4 +26,44 @@ test("ManagedStateStore upserts and removes agents", async () => { await store.removeAgent("lowcode"); const agentsAfterDelete = await store.listAgents(); assert.equal(agentsAfterDelete.length, 0); + await rm(root, { recursive: true, force: true }); +}); + +test("ManagedStateStore handles missing, malformed, and replaceable state", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-state-contract-")); + const filePath = path.join(root, "managed-agents.json"); + const store = new ManagedStateStore(filePath); + + try { + assert.deepEqual(await store.read(), { version: 1, agents: [] }); + await writeFile(filePath, JSON.stringify({ version: 99, agents: "invalid" }), "utf8"); + assert.deepEqual(await store.read(), { version: 1, agents: [] }); + await writeFile(filePath, "invalid", "utf8"); + await assert.rejects(store.read(), SyntaxError); + + await writeFile(filePath, JSON.stringify({ + version: 1, + agents: [ + { id: "zeta", accountId: "zeta", workspace: "/zeta", inboundTopic: "z/in", outboundTopic: "z/out", enabled: true, updatedAt: "1" }, + { id: "alpha", accountId: "alpha", workspace: "/alpha", inboundTopic: "a/in", outboundTopic: "a/out", enabled: true, updatedAt: "1" } + ] + }), "utf8"); + assert.deepEqual((await store.listAgents()).map((agent) => agent.id), ["alpha", "zeta"]); + assert.equal((await store.getAgent("alpha"))?.workspace, "/alpha"); + assert.equal(await store.getAgent("missing"), undefined); + + await store.upsertAgent({ + id: "alpha", + accountId: "alpha-next", + workspace: "/alpha-next", + inboundTopic: "a/in", + outboundTopic: "a/out", + enabled: false, + updatedAt: "2" + }); + assert.equal((await store.getAgent("alpha"))?.workspace, "/alpha-next"); + assert.match(await readFile(filePath, "utf8"), /\n$/); + } finally { + await rm(root, { recursive: true, force: true }); + } }); diff --git a/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts b/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts index ec1e402f00072d691fe726daf5f627ef475f24f4..78bedb39820544d25ed1ca1c118243105be4d76b 100644 --- a/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts +++ b/packages/daedalus-agent-management-web/server/management-test/template-manager.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, stat, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -7,6 +7,8 @@ import { ManagedStateStore } from "../management-src/capabilities/agent/state-st import { TemplateManager } from "../management-src/capabilities/agent-template/service.js"; import type { EnvironmentConfig } from "../management-src/types.js"; +const DIRECTORY_LINK_TYPE = process.platform === "win32" ? "junction" : "dir"; + function createEnv(overrides: Partial = {}): EnvironmentConfig { const baseRoot = path.join(tmpdir(), "agent-platform-management-console-template-test"); @@ -23,12 +25,8 @@ function createEnv(overrides: Partial = {}): EnvironmentConfi doctorBin: "doctor", workspacesRoot: path.join(baseRoot, ".openclaw", "workspaces"), workspaceTemplatesRoot: path.join(baseRoot, "workspace-templates"), + localArtifactsRoot: path.join(baseRoot, "local-artifacts"), ontologyRoot: path.join(baseRoot, "ontology"), - ontologyS3Endpoint: "http://minio.internal:9000", - ontologyS3Region: "local", - ontologyS3AccessKeyId: "minio-access", - ontologyS3SecretAccessKey: "minio-secret", - ontologyS3ForcePathStyle: true, globalSkillsRoot: path.join(baseRoot, ".openclaw", "skills"), stateFile: path.join(baseRoot, "managed-agents.json"), mqttBrokerUrl: "mqtts://channel-broker:8883", @@ -62,27 +60,21 @@ test("TemplateManager creates, lists, and deletes templates with agent usage", a zip.addFile("AGENTS.md", Buffer.from("# sales\n", "utf8")); zip.addFile("USER.md", Buffer.from("# user\n", "utf8")); zip.addFile("nested/info.txt", Buffer.from("ok\n", "utf8")); + const localArtifactsRoot = path.join(root, "local-artifacts"); + await mkdir(path.join(localArtifactsRoot, "template"), { recursive: true }); + await writeFile(path.join(localArtifactsRoot, "template", "sales.zip"), zip.toBuffer()); const manager = new TemplateManager( createEnv({ - workspaceTemplatesRoot: path.join(root, "workspace-templates") + workspaceTemplatesRoot: path.join(root, "workspace-templates"), + localArtifactsRoot }), - stateStore, - { - objectStore: { - async getObject(bucket: string, objectKey: string): Promise { - assert.equal(bucket, "template-bucket"); - assert.equal(objectKey, "workspace/sales.zip"); - return zip.toBuffer(); - } - } - } + stateStore ); const created = await manager.createTemplate({ templateName: "sales", - bucket: "template-bucket", - objectKey: "workspace/sales.zip" + artifactPath: "template/sales.zip" }); assert.equal(created.templateName, "sales"); @@ -145,7 +137,7 @@ test("TemplateManager marks built-in templates from manifest", async () => { ]); }); -test("TemplateManager preserves the current template when replacement download fails", async () => { +test("TemplateManager preserves the current template when the local artifact is missing", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-template-failure-")); const templatesRoot = path.join(root, "workspace-templates"); const templateRoot = path.join(templatesRoot, "sales"); @@ -153,22 +145,149 @@ test("TemplateManager preserves the current template when replacement download f await writeFile(path.join(templateRoot, "AGENTS.md"), "# Current\n", "utf8"); const manager = new TemplateManager( - createEnv({ workspaceTemplatesRoot: templatesRoot }), - new ManagedStateStore(path.join(root, "managed-state.json")), - { - objectStore: { - async getObject(): Promise { - throw new Error("injected template download failure"); - } - } - } + createEnv({ + workspaceTemplatesRoot: templatesRoot, + localArtifactsRoot: path.join(root, "local-artifacts") + }), + new ManagedStateStore(path.join(root, "managed-state.json")) ); await assert.rejects(manager.createTemplate({ templateName: "sales", - bucket: "template-bucket", - objectKey: "workspace/sales.zip", + artifactPath: "template/missing.zip", replace: true - }), /injected template download failure/); + }), /local artifact/i); assert.equal(await readFile(path.join(templateRoot, "AGENTS.md"), "utf8"), "# Current\n"); }); + +test("TemplateManager rejects artifact paths outside the local artifact root", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-template-boundary-")); + const manager = new TemplateManager( + createEnv({ + workspaceTemplatesRoot: path.join(root, "workspace-templates"), + localArtifactsRoot: path.join(root, "local-artifacts") + }), + new ManagedStateStore(path.join(root, "managed-state.json")) + ); + + await assert.rejects(manager.createTemplate({ + templateName: "sales", + artifactPath: "../outside.zip" + }), /local artifact path/i); +}); + +test("TemplateManager rejects symbolic links in local artifact paths", async () => { + if (process.platform === "win32") { + return; + } + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-template-link-")); + const localArtifactsRoot = path.join(root, "local-artifacts"); + const outside = path.join(root, "outside"); + await mkdir(localArtifactsRoot, { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeFile(path.join(outside, "sales.zip"), "not-a-zip"); + const { symlink } = await import("node:fs/promises"); + await symlink(outside, path.join(localArtifactsRoot, "template"), "dir"); + + const manager = new TemplateManager( + createEnv({ + workspaceTemplatesRoot: path.join(root, "workspace-templates"), + localArtifactsRoot + }), + new ManagedStateStore(path.join(root, "managed-state.json")) + ); + + await assert.rejects(manager.createTemplate({ + templateName: "sales", + artifactPath: "template/sales.zip" + }), /symbolic links/i); +}); + +test("TemplateManager validates archives, replaces templates, and normalizes path variants", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-template-coverage-")); + const templatesRoot = path.join(root, "workspace-templates"); + const localArtifactsRoot = path.join(root, "local-artifacts"); + const artifactRoot = path.join(localArtifactsRoot, "template"); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + const AdmZip = (await import("adm-zip")).default; + const makeZip = (agentsContent: string) => { + const zip = new AdmZip(); + zip.addFile("AGENTS.md", Buffer.from(agentsContent, "utf8")); + return zip.toBuffer(); + }; + + try { + await mkdir(artifactRoot, { recursive: true }); + await writeFile(path.join(artifactRoot, "first.zip"), makeZip("# First\n")); + await writeFile(path.join(artifactRoot, "second.zip"), makeZip("# Second\n")); + const missingAgents = new AdmZip(); + missingAgents.addFile("README.md", Buffer.from("missing", "utf8")); + await writeFile(path.join(artifactRoot, "missing-agents.zip"), missingAgents.toBuffer()); + const directoryAgents = new AdmZip(); + directoryAgents.addFile("AGENTS.md/", Buffer.alloc(0)); + await writeFile(path.join(artifactRoot, "directory-agents.zip"), directoryAgents.toBuffer()); + + const manager = new TemplateManager( + createEnv({ workspaceTemplatesRoot: templatesRoot, localArtifactsRoot }), + stateStore + ); + const missingRootManager = new TemplateManager( + createEnv({ workspaceTemplatesRoot: path.join(root, "missing-root") }), + stateStore + ); + + await assert.rejects(manager.createTemplate({ templateName: "bad name", artifactPath: "template/first.zip" }), /Invalid template name/); + await assert.rejects(manager.deleteTemplate({ templateName: "bad name" }), /Invalid template name/); + await assert.rejects(manager.createTemplate({ templateName: "sales", artifactPath: " " }), /requires artifactPath/); + await assert.rejects(manager.createTemplate({ + templateName: "missing-agents", + artifactPath: "template/missing-agents.zip" + }), /top-level AGENTS\.md/); + await assert.rejects(manager.createTemplate({ + templateName: "directory-agents", + artifactPath: "template/directory-agents.zip" + }), /top-level AGENTS\.md/); + + await manager.createTemplate({ templateName: "sales", artifactPath: "template/first.zip" }); + await assert.rejects(manager.createTemplate({ + templateName: "sales", + artifactPath: "template/second.zip", + replace: false + }), /already exists/); + await manager.createTemplate({ + templateName: "sales", + artifactPath: "template/second.zip", + replace: true + }); + assert.equal(await readFile(path.join(templatesRoot, "sales", "AGENTS.md"), "utf8"), "# Second\n"); + + assert.equal(await (missingRootManager as any).resolveTemplatesGroupId(), undefined); + await (manager as any).normalizeTemplatePath(path.join(root, "missing-path"), undefined); + const executable = path.join(root, "executable.sh"); + await writeFile(executable, "echo ok\n", "utf8"); + await chmod(executable, 0o755); + await (manager as any).normalizeTemplatePath(executable, undefined); + const outside = path.join(root, "outside"); + const link = path.join(root, "template-link"); + await mkdir(outside); + await symlink(outside, link, DIRECTORY_LINK_TYPE); + await (manager as any).normalizeTemplatePath(link, undefined); + await assert.rejects((manager as any).readTemplatePathInfo("\u0000")); + await assert.rejects((manager as any).pathExists("\u0000")); + + const privateManager = manager as any; + const originalReadTemplatePathInfo = privateManager.readTemplatePathInfo; + privateManager.readTemplatePathInfo = async () => ({ + isSymbolicLink: () => false, + isDirectory: () => false, + mode: 0o111 + }); + try { + await privateManager.normalizeTemplatePath(executable, undefined); + } finally { + privateManager.readTemplatePathInfo = originalReadTemplatePathInfo; + } + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/version-list-coverage.test.ts b/packages/daedalus-agent-management-web/server/management-test/version-list-coverage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..acd89b8441385cf04165ad02fc29710b0a4281f9 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/version-list-coverage.test.ts @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { createRequire, syncBuiltinESMExports } from "node:module"; +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { versionList } from "../management-src/capabilities/version/list.js"; + +const require = createRequire(import.meta.url); +const MQTT_PLUGIN_ID = "daedalus-agent-mqtt-channel"; + +class DuplicateEventSocket extends EventEmitter { + setEncoding(): this { + return this; + } + + write(): boolean { + queueMicrotask(() => { + this.emit("error", new Error("")); + this.emit("close"); + }); + return true; + } + + override off(): this { + return this; + } + + destroy(): this { + return this; + } +} + +test("version status covers fallback plugin configuration and duplicate socket completion", async () => { + const root = await mkdtemp(path.join(tmpdir(), "version-list-coverage-")); + const configPath = path.join(root, "openclaw.json"); + const previousHome = process.env.DA_OPENCLAW_HOME; + const previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; + const netModule = require("node:net") as { createConnection: (...args: unknown[]) => unknown }; + const originalCreateConnection = netModule.createConnection; + netModule.createConnection = () => { + const socket = new DuplicateEventSocket(); + queueMicrotask(() => socket.emit("connect")); + return socket; + }; + syncBuiltinESMExports(); + process.env.DA_OPENCLAW_HOME = root; + process.env.OPENCLAW_CONFIG_PATH = configPath; + + const context = { + agentManager: { + async gatewayStatus(): Promise { + return { ok: true }; + } + } + } as any; + + try { + await writeFile(configPath, "[]", "utf8"); + const arrayConfig = await versionList({} as any, context) as { items: any[] }; + assert.equal( + (arrayConfig.items as any[]).find((item) => item.packageName === MQTT_PLUGIN_ID)?.status, + "error" + ); + assert.equal( + (arrayConfig.items as any[]).find((item) => item.packageName === "daedalus-agent-apps-invoke-cli")?.statusMessage, + "调用服务未启动" + ); + + await writeFile(configPath, JSON.stringify({ plugins: "invalid" }), "utf8"); + const primitivePlugins = await versionList({} as any, context) as { items: any[] }; + assert.equal( + (primitivePlugins.items as any[]).find((item) => item.packageName === MQTT_PLUGIN_ID)?.status, + "error" + ); + + await writeFile(configPath, JSON.stringify({ + plugins: { + allow: [MQTT_PLUGIN_ID], + load: { paths: [null, `/plugins/${MQTT_PLUGIN_ID}`] }, + entries: { [MQTT_PLUGIN_ID]: { enabled: true } } + } + }), "utf8"); + const configuredPlugin = await versionList({} as any, context) as { items: any[] }; + assert.equal( + (configuredPlugin.items as any[]).find((item) => item.packageName === MQTT_PLUGIN_ID)?.status, + "normal" + ); + + await writeFile(configPath, "{}", "utf8"); + const namedPlugin = await versionList({} as any, { + agentManager: { + async gatewayStatus(): Promise { + return { ok: true, nested: { package_name: MQTT_PLUGIN_ID } }; + } + } + } as any) as { items: any[] }; + assert.equal( + (namedPlugin.items as any[]).find((item) => item.packageName === MQTT_PLUGIN_ID)?.status, + "normal" + ); + } finally { + netModule.createConnection = originalCreateConnection; + syncBuiltinESMExports(); + if (previousHome === undefined) delete process.env.DA_OPENCLAW_HOME; + else process.env.DA_OPENCLAW_HOME = previousHome; + if (previousConfigPath === undefined) delete process.env.OPENCLAW_CONFIG_PATH; + else process.env.OPENCLAW_CONFIG_PATH = previousConfigPath; + await mkdir(root, { recursive: true }); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/management-test/workspace-service-coverage.test.ts b/packages/daedalus-agent-management-web/server/management-test/workspace-service-coverage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..380bd2334b8ffff57d479a48d08b69ec373b2778 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/management-test/workspace-service-coverage.test.ts @@ -0,0 +1,356 @@ +import assert from "node:assert/strict"; +import { chmod, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import AdmZip from "adm-zip"; + +import { AgentWorkspaceService } from "../management-src/capabilities/agent/workspace-service.js"; +import type { EnvironmentConfig } from "../management-src/types.js"; + +const DIRECTORY_LINK_TYPE = process.platform === "win32" ? "junction" : "dir"; + +function createEnv(root: string, overrides: Partial = {}): EnvironmentConfig { + return { + gatewayRestartMode: "container", + dataRoot: path.join(root, "data"), + openclawHome: path.join(root, ".openclaw"), + openclawStateDir: path.join(root, ".openclaw"), + openclawConfigPath: path.join(root, ".openclaw", "openclaw.json"), + tmpDir: path.join(root, "tmp"), + openclawCliTmpDir: path.join(root, "cli-tmp"), + gatewayAuthToken: "test-token", + openclawBin: "openclaw", + doctorBin: "doctor", + workspacesRoot: path.join(root, "workspaces"), + workspaceTemplatesRoot: path.join(root, "templates"), + localArtifactsRoot: path.join(root, "local-artifacts"), + ontologyRoot: path.join(root, "ontology"), + globalSkillsRoot: path.join(root, ".openclaw", "skills"), + stateFile: path.join(root, "managed-agents.json"), + mqttBrokerUrl: "mqtt://broker", + mqttUsername: "user", + mqttPassword: "pass", + managedMqttBrokerUrl: "mqtt://broker", + managedMqttUsername: "user", + managedMqttPassword: "pass", + inboundTopicTemplate: "in/{agentId}", + outboundTopicTemplate: "out/{agentId}", + ...overrides + }; +} + +test("AgentWorkspaceService validates Markdown paths and optimistic writes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-workspace-markdown-")); + const env = createEnv(root); + const workspace = path.join(env.workspacesRoot, "agent-a"); + const outside = path.join(root, "outside"); + const service = new AgentWorkspaceService(env); + + try { + await mkdir(path.join(workspace, "knowledge"), { recursive: true }); + await mkdir(path.join(workspace, "skills", "demo"), { recursive: true }); + await mkdir(outside, { recursive: true }); + await writeFile(path.join(workspace, "AGENTS.md"), "# Agent\n", "utf8"); + await writeFile(path.join(outside, "linked.md"), "linked\n", "utf8"); + await assert.rejects(service.readAgentDocs("\u0000")); + await assert.rejects(service.readMarkdownFile(env.workspacesRoot, "AGENTS.md"), /outside the managed workspaces root/); + const workspaceFile = path.join(env.workspacesRoot, "file-agent"); + await writeFile(workspaceFile, "file", "utf8"); + await assert.rejects(service.readMarkdownFile(workspaceFile, "AGENTS.md"), /regular directory/); + + for (const invalidPath of [ + undefined, + "bad\\path.md", + "/absolute.md", + "C:/drive.md", + `${"a".repeat(510)}.md`, + "knowledge//file.md", + "knowledge/../file.md", + "knowledge/bad?.md", + "knowledge/file.txt", + "docs/file.md", + "skills/demo.md", + "skills/Bad/file.md" + ]) { + await assert.rejects( + service.readMarkdownFile(workspace, invalidPath as string), + /Markdown|Only Markdown|managed roots|skill/i + ); + } + + const empty = await service.writeMarkdownFile(workspace, "knowledge/empty.md", undefined as any, { create: true }); + assert.equal(empty.size, 0); + await assert.rejects( + service.writeMarkdownFile(workspace, "knowledge/large.md", "x".repeat(10 * 1024 * 1024 + 1), { create: true }), + /10 MiB/ + ); + await assert.rejects( + service.writeMarkdownFile(workspace, "knowledge/empty.md", "duplicate", { create: true }), + /already exists/ + ); + await assert.rejects( + service.writeMarkdownFile(workspace, "knowledge/missing.md", "missing"), + /does not exist/ + ); + await assert.rejects( + service.writeMarkdownFile(workspace, "AGENTS.md", "changed"), + /current etag/ + ); + await assert.rejects( + service.writeMarkdownFile(workspace, "AGENTS.md", "changed", { etag: "wrong" }), + /modified by another operation/ + ); + await assert.rejects( + service.writeMarkdownFile(workspace, "AGENTS.md", "new", { create: true }), + /protected file operation/ + ); + + await rm(path.join(workspace, "AGENTS.md")); + await assert.rejects( + service.writeMarkdownFile(workspace, "AGENTS.md", "changed"), + /restore the system default/ + ); + await service.updateAgentDocs(workspace, "# Restored"); + assert.equal(await service.readAgentDocs(workspace), "# Restored\n"); + + const source = await service.writeMarkdownFile(workspace, "knowledge/source.md", "source", { create: true }); + await service.writeMarkdownFile(workspace, "knowledge/destination.md", "destination", { create: true }); + await assert.rejects( + service.renameMarkdownFile(workspace, "knowledge/source.md", "knowledge/renamed.md", "wrong"), + /modified by another operation/ + ); + await assert.rejects( + service.renameMarkdownFile(workspace, "knowledge/source.md", "knowledge/destination.md", source.etag), + /destination already exists/ + ); + await assert.rejects( + service.deleteMarkdownFile(workspace, "knowledge/source.md", "wrong"), + /modified by another operation/ + ); + + await writeFile(path.join(workspace, "knowledge", "parent-file"), "file", "utf8"); + await assert.rejects( + service.readMarkdownFile(workspace, "knowledge/parent-file/child.md"), + /parent path is not a directory/ + ); + await mkdir(path.join(workspace, "knowledge", "directory.md")); + await assert.rejects( + service.readMarkdownFile(workspace, "knowledge/directory.md"), + /path is not a file/ + ); + await assert.rejects(service.writeMarkdownFile(workspace, "knowledge/directory.md", "content")); + await symlink(outside, path.join(workspace, "knowledge", "linked"), DIRECTORY_LINK_TYPE); + await assert.rejects( + (service as any).ensureMarkdownParentDirectories(workspace, "knowledge/linked/new.md", undefined), + /regular directory/ + ); + await assert.rejects( + service.readMarkdownFile(workspace, "knowledge/linked/linked.md"), + /symbolic links/ + ); + await rm(path.join(workspace, "knowledge", "linked"), { recursive: true, force: true }); + + await symlink(outside, path.join(workspace, ".learnings"), DIRECTORY_LINK_TYPE); + await assert.rejects(service.listMarkdownFiles(workspace), /regular directory/); + await rm(path.join(workspace, ".learnings"), { recursive: true, force: true }); + await symlink(outside, path.join(workspace, "knowledge", "tree-link"), DIRECTORY_LINK_TYPE); + await assert.rejects(service.listMarkdownFiles(workspace), /cannot contain symbolic links/); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("AgentWorkspaceService validates templates and preserves overwrite behavior", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-workspace-template-")); + const env = createEnv(root); + const service = new AgentWorkspaceService(env); + const workspace = path.join(env.workspacesRoot, "agent-a"); + + try { + await assert.rejects(service.ensureWorkspaceSeeded(workspace, "missing"), /template not found/); + + await mkdir(env.workspaceTemplatesRoot, { recursive: true }); + await writeFile(path.join(env.workspaceTemplatesRoot, "file-template"), "file", "utf8"); + await assert.rejects(service.ensureWorkspaceSeeded(workspace, "file-template"), /not a directory/); + + await mkdir(path.join(env.workspaceTemplatesRoot, "missing-agents")); + await assert.rejects(service.ensureWorkspaceSeeded(workspace, "missing-agents"), /top-level AGENTS\.md/); + + await mkdir(path.join(env.workspaceTemplatesRoot, "directory-agents", "AGENTS.md"), { recursive: true }); + await assert.rejects(service.ensureWorkspaceSeeded(workspace, "directory-agents"), /must be a regular file/); + + const validTemplate = path.join(env.workspaceTemplatesRoot, "valid"); + await mkdir(path.join(validTemplate, "nested"), { recursive: true }); + await writeFile(path.join(validTemplate, "AGENTS.md"), "# Template\n", "utf8"); + await writeFile(path.join(validTemplate, "nested", "note.txt"), "template", "utf8"); + await writeFile(path.join(validTemplate, ".daedalus-agent-builtin.fingerprint"), "skip", "utf8"); + await mkdir(workspace, { recursive: true }); + await writeFile(path.join(workspace, "AGENTS.md"), "# Current\n", "utf8"); + await mkdir(path.join(workspace, "nested")); + await writeFile(path.join(workspace, "nested", "note.txt"), "current", "utf8"); + + await service.ensureWorkspaceSeeded(workspace, "valid"); + assert.equal(await readFile(path.join(workspace, "AGENTS.md"), "utf8"), "# Current\n"); + assert.equal(await readFile(path.join(workspace, "nested", "note.txt"), "utf8"), "current"); + await service.ensureWorkspaceSeeded(workspace, "valid", { overwrite: true }); + assert.equal(await readFile(path.join(workspace, "AGENTS.md"), "utf8"), "# Template\n"); + assert.equal(await readFile(path.join(workspace, "nested", "note.txt"), "utf8"), "template"); + + const unsupportedTemplate = path.join(env.workspaceTemplatesRoot, "unsupported"); + const outside = path.join(root, "outside"); + await mkdir(unsupportedTemplate); + await mkdir(outside); + await writeFile(path.join(unsupportedTemplate, "AGENTS.md"), "# Unsupported\n", "utf8"); + await symlink(outside, path.join(unsupportedTemplate, "linked"), DIRECTORY_LINK_TYPE); + await assert.rejects( + service.ensureWorkspaceSeeded(path.join(env.workspacesRoot, "unsupported"), "unsupported"), + /Unsupported workspace template entry/ + ); + + const workspaceFile = path.join(root, "workspace-file"); + await writeFile(workspaceFile, "file", "utf8"); + assert.deepEqual(await service.moveWorkspaceToRecycleBin(workspaceFile, "file-agent"), {}); + assert.deepEqual(await service.moveWorkspaceToRecycleBin(path.join(root, "missing-workspace"), "missing-agent"), {}); + await assert.rejects(service.moveWorkspaceToRecycleBin("\u0000", "invalid")); + + const recycleWorkspace = path.join(env.workspacesRoot, "recycle-agent"); + await mkdir(recycleWorkspace, { recursive: true }); + const recycled = await service.moveWorkspaceToRecycleBin(recycleWorkspace, "recycle-agent", new Date("2026-01-02T03:04:05.006Z")); + assert.ok(recycled.path); + await service.restoreWorkspaceFromRecycleBin(undefined, recycleWorkspace); + await service.restoreWorkspaceFromRecycleBin(path.join(root, "missing-recycle"), recycleWorkspace); + await assert.rejects(service.restoreWorkspaceFromRecycleBin("\u0000", recycleWorkspace)); + await service.restoreWorkspaceFromRecycleBin(recycled.path, recycleWorkspace); + assert.equal(await readFile(path.join(workspace, "AGENTS.md"), "utf8"), "# Template\n"); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("AgentWorkspaceService covers default restore and archive entry variants", async () => { + const root = await mkdtemp(path.join(tmpdir(), "management-workspace-archive-")); + const env = createEnv(root); + const service = new AgentWorkspaceService(env); + const workspace = path.join(env.workspacesRoot, "agent-a"); + const defaultRoot = path.join(env.workspaceTemplatesRoot, "default"); + + try { + await mkdir(workspace, { recursive: true }); + await mkdir(path.join(defaultRoot, "AGENTS.md"), { recursive: true }); + await assert.rejects(service.restoreDefaultAgentDocs(workspace), /not a regular file/); + await rm(path.join(defaultRoot, "AGENTS.md"), { recursive: true, force: true }); + await writeFile(path.join(defaultRoot, "AGENTS.md"), "# Default\n", "utf8"); + await mkdir(path.join(workspace, "AGENTS.md")); + await assert.rejects(service.restoreDefaultAgentDocs(workspace)); + await rm(path.join(workspace, "AGENTS.md"), { recursive: true, force: true }); + await writeFile(path.join(workspace, "AGENTS.md"), "# Current\n", "utf8"); + const current = await service.readMarkdownFile(workspace, "AGENTS.md"); + await assert.rejects(service.restoreDefaultAgentDocs(workspace), /current etag/); + await assert.rejects(service.restoreDefaultAgentDocs(workspace, "wrong"), /modified by another operation/); + const restored = await service.restoreDefaultAgentDocs(workspace, current.etag); + assert.equal(restored.content, "# Default\n"); + await service.restoreAgentDocs(workspace, undefined); + assert.equal(await service.readAgentDocs(workspace), undefined); + await service.restoreAgentDocs(workspace, "# Explicit\n"); + assert.equal(await service.readAgentDocs(workspace), "# Explicit\n"); + + const workspaceFile = path.join(root, "archive-file"); + await writeFile(workspaceFile, "file", "utf8"); + await assert.rejects(service.buildWorkspaceExportArchive(workspaceFile), /not a directory/); + + await mkdir(path.join(workspace, "empty")); + await writeFile(path.join(workspace, "file.txt"), "file", "utf8"); + const archive = await service.buildWorkspaceExportArchive(workspace); + assert.equal(archive.entries.includes("empty/"), true); + assert.equal(new AdmZip(archive.buffer).getEntry("file.txt")?.getData().toString("utf8"), "file"); + + const outside = path.join(root, "outside"); + await mkdir(outside); + const rootLink = path.join(workspace, "root-link"); + await symlink(outside, rootLink, DIRECTORY_LINK_TYPE); + await assert.rejects(service.buildWorkspaceExportArchive(workspace), /Unsupported agent workspace archive entry/); + await rm(rootLink, { recursive: true, force: true }); + await mkdir(path.join(workspace, "nested")); + await symlink(outside, path.join(workspace, "nested", "nested-link"), DIRECTORY_LINK_TYPE); + await assert.rejects(service.buildWorkspaceExportArchive(workspace), /Unsupported agent workspace archive entry/); + + const privateService = service as any; + const zip = new AdmZip(); + const entries: string[] = []; + const missing: string[] = []; + await assert.rejects( + privateService.addWorkspaceFileToArchive({ zip, workspaceRoot: workspace, archivePath: "empty", entries, missing }), + /not a file/ + ); + await assert.rejects( + privateService.addWorkspaceDirectoryToArchive({ zip, workspaceRoot: workspace, archivePath: "file.txt", entries, missing }), + /not a directory/ + ); + await privateService.addWorkspaceFileToArchive({ + zip, + workspaceRoot: workspace, + archivePath: "optional-missing.txt", + entries, + missing + }); + assert.equal(missing.includes("optional-missing.txt"), true); + await assert.rejects(privateService.addWorkspaceFileToArchive({ + zip, + workspaceRoot: workspace, + archivePath: "required-missing.txt", + required: true, + entries, + missing + }), /missing required file/); + await assert.rejects(privateService.addWorkspaceFileToArchive({ + zip, + workspaceRoot: workspace, + archivePath: "bad\u0000-file", + entries, + missing + })); + await assert.rejects(privateService.addWorkspaceDirectoryToArchive({ + zip, + workspaceRoot: workspace, + archivePath: "bad\u0000-directory", + entries, + missing + })); + await privateService.addWorkspaceDirectoryToArchive({ + zip, + workspaceRoot: workspace, + archivePath: "optional-missing-directory", + entries, + missing + }); + assert.equal(missing.includes("optional-missing-directory"), true); + assert.throws( + () => privateService.resolveArchiveSourcePath(workspace, "../outside"), + /escapes workspace/ + ); + await assert.rejects(privateService.requireRegularAgentsFile("\u0000", "Test workspace")); + await assert.rejects(privateService.resolveMarkdownPath(workspace, "../outside.md"), /escapes/); + await assert.rejects(privateService.resolveMarkdownPath(workspace, "bad\u0000.md")); + + const copySource = path.join(root, "copy-source"); + const copyTarget = path.join(root, "copy-target"); + await mkdir(copySource); + await mkdir(path.join(copyTarget, "conflict"), { recursive: true }); + await writeFile(path.join(copySource, "conflict"), "source", "utf8"); + await assert.rejects( + privateService.copyDirectoryContentsWithGroup(copySource, copyTarget, undefined, { overwrite: true }) + ); + + await chmod(path.join(workspace, "file.txt"), 0o755); + assert.equal(typeof await privateService.resolveWorkspaceGroupId(path.join(workspace, "nested", "child")), "number"); + assert.equal(typeof await privateService.resolveWorkspaceGroupId(path.join(env.workspacesRoot, "missing-agent", "child")), "number"); + const fallbackService = new AgentWorkspaceService(createEnv(root, { + workspacesRoot: path.join(root, "missing-workspaces-root") + })) as any; + assert.equal(typeof await fallbackService.resolveWorkspaceGroupId(outside), "number"); + assert.equal(await fallbackService.resolveWorkspaceGroupId(path.join(root, "missing-target")), undefined); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/src/api/routes/index.js b/packages/daedalus-agent-management-web/server/src/api/routes/index.js index 3bed25f8da8b345b06b39220f6dcf766be06b88e..d94f6fd26a4600a6330b30169f292ab65d486e4c 100644 --- a/packages/daedalus-agent-management-web/server/src/api/routes/index.js +++ b/packages/daedalus-agent-management-web/server/src/api/routes/index.js @@ -136,6 +136,7 @@ function getRuntimeEnvConfig(services) { const networkConfig = runtimeEnvConfigService?.getNetworkAllowlistConfig?.() || {}; return { admin_inbound_topic: services.env?.mqtt?.adminInboundTopic || "", + mcp_cli_allowed_commands: services.env?.mcpCliAllowedCommands || [], ...runtimeEnvConfigService?.getRuntimeCredentials(), agent_internal_network_allowlist: networkConfig.agent_internal_network_allowlist || "", huozige_server_ip: networkConfig.huozige_server_ip || "" @@ -294,21 +295,16 @@ export function createRoutes(services) { })); router.get("/internal/app-invoke/system", requireInternal, asyncRoute(async (req, res) => { + const sessionId = String(req.query.sessionId || ""); const applicationName = String(req.query.applicationName || ""); + if (!sessionId) { + throw badRequest("缺少 sessionId 参数"); + } if (!applicationName) { throw badRequest("缺少 applicationName 参数"); } - const system = services.systemService.findSystemForInvocation(applicationName); - if (!system) { - res.status(404).json({ - code: "not_found", - message: `未找到业务系统:${applicationName}` - }); - return; - } - - res.json(system); + res.json(services.externalService.resolveAuthorizedSystem(sessionId, applicationName)); })); router.get("/internal/app-invoke/base-url", requireInternal, asyncRoute(async (req, res) => { @@ -339,8 +335,9 @@ export function createRoutes(services) { })); router.get("/external/agents", requireServiceApiAuth, asyncRoute(async (req, res) => { + const userName = req.query.userName ?? req.query.id; res.json({ - items: await services.externalService.listAgentsForUser(req.query.userName) + items: await services.externalService.listAgentsForUser(userName) }); })); @@ -458,22 +455,136 @@ export function createRoutes(services) { res.json(services.portalService.listTemplates()); })); - router.post("/templates", upload.single("artifact"), asyncRoute(async (req, res) => { + router.get("/templates/:templateName/archive", asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); - if (!req.file) { - throw badRequest("请上传模板文件"); + const archive = await services.templateService.downloadTemplateArchive(req.params.templateName); + writeAuditLog(services, req, "下载模板", `下载模板:${req.params.templateName}`); + res.setHeader("Content-Type", archive.contentType); + res.setHeader("Content-Disposition", `attachment; filename="${archive.fileName}"`); + res.setHeader("Cache-Control", "no-store"); + res.send(archive.buffer); + })); + + router.get("/templates/:templateName/tree", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + res.json(await services.templateService.listTemplateTree(req.params.templateName)); + })); + + router.get("/templates/:templateName/details", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + res.json(await services.templateService.templateDetails(req.params.templateName)); + })); + + router.get("/templates/:templateName/files/content", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const file = await services.templateService.readTemplateFile(req.params.templateName, req.query.path); + if (req.query.download === "true") { + res.setHeader("Content-Type", "application/octet-stream"); + res.setHeader("Content-Disposition", `attachment; filename="${encodeURIComponent(file.name)}"`); + res.setHeader("Cache-Control", "no-store"); + res.send(file.buffer); + return; } - const result = await services.portalService.createTemplate({ + const { buffer: _buffer, ...result } = file; + res.json(result); + })); + + router.put("/templates/:templateName/files/content", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.writeTemplateFile(req.params.templateName, req.body || {}, req.currentUser.id); + writeAuditLog(services, req, "保存模板文件", `模板 ${req.params.templateName} 保存文件:${req.body.path}`); + res.json(result); + })); + + router.post("/templates/:templateName/folders", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.createFolder(req.params.templateName, req.body || {}, req.currentUser.id); + writeAuditLog(services, req, "新建模板目录", `模板 ${req.params.templateName} 新建目录:${req.body.path}`); + res.status(201).json(result); + })); + + router.post("/templates/:templateName/files", upload.single("file"), asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = req.file + ? await services.templateService.uploadFile(req.params.templateName, { + targetPath: req.body.target_path, + file: req.file, + createdBy: req.currentUser.id + }) + : await services.templateService.writeTemplateFile(req.params.templateName, req.body || {}, req.currentUser.id); + writeAuditLog(services, req, "新增模板文件", `模板 ${req.params.templateName} 新增文件:${req.file?.originalname || req.body.path}`); + res.status(201).json(result); + })); + + router.post("/templates/:templateName/agents-md/restore-default", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.restoreDefaultAgentsFile(req.params.templateName, req.currentUser.id); + writeAuditLog(services, req, "恢复模板 AGENTS.md 默认值", `模板 ${req.params.templateName} 恢复系统默认 AGENTS.md`); + res.json(result); + })); + + router.post("/templates/:templateName/entries/move", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.moveEntry(req.params.templateName, req.body || {}, req.currentUser.id); + writeAuditLog(services, req, "移动模板文件", `模板 ${req.params.templateName}:${req.body.source_path} -> ${req.body.destination_path}`); + res.json(result); + })); + + router.delete("/templates/:templateName/entries", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const filePath = req.body?.path || req.query.path; + const result = await services.templateService.deleteEntry(req.params.templateName, filePath, req.currentUser.id); + writeAuditLog(services, req, "删除模板文件", `模板 ${req.params.templateName} 删除:${filePath}`); + res.json(result); + })); + + router.post("/templates/:templateName/skills", upload.single("artifact"), asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.uploadSkill(req.params.templateName, { + file: req.file, + replace: req.body.replace === "true", + createdBy: req.currentUser.id + }); + writeAuditLog(services, req, "上传模板 Skill", `模板 ${req.params.templateName} 上传 Skill:${result.skill.slug}`); + res.status(201).json(result); + })); + + router.delete("/templates/:templateName/skills/:slug", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.deleteSkill(req.params.templateName, req.params.slug, req.currentUser.id); + writeAuditLog(services, req, "删除模板 Skill", `模板 ${req.params.templateName} 删除 Skill:${req.params.slug}`); + res.json(result); + })); + + router.put("/templates/:templateName/business-systems", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = services.templateService.updateBusinessSystems(req.params.templateName, req.body || {}); + writeAuditLog(services, req, "编辑模板业务系统权限", `模板 ${req.params.templateName};模式:${result.mode}`); + res.json(result); + })); + + router.put("/templates/:templateName/mcp", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = services.templateService.updateMcpServers(req.params.templateName, req.body || {}); + writeAuditLog(services, req, "编辑模板 MCP 权限", `模板 ${req.params.templateName};模式:${result.mode}`); + res.json(result); + })); + + router.post("/templates/:templateName/validate", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + res.json(await services.templateService.validateTemplate(req.params.templateName)); + })); + + router.post("/templates", upload.single("artifact"), asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.createTemplate({ + mode: req.body.mode, templateName: req.body.template_name, - defaultLlm: { - default_llm_provider_id: req.body.default_llm_provider_id, - default_llm_model_id: req.body.default_llm_model_id, - default_llm_model_ref: req.body.default_llm_model_ref - }, + sourceTemplateName: req.body.source_template_name, file: req.file, createdBy: req.currentUser.id }); - writeAuditLog(services, req, "上传模板", `上传模板:${formatAuditTarget({ + writeAuditLog(services, req, "创建模板", `创建模板:${formatAuditTarget({ id: result.id, name: result.template_name })}`); @@ -482,7 +593,7 @@ export function createRoutes(services) { router.put("/templates/:templateName/default-llm", asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); - const result = services.portalService.updateTemplateDefaultLlm(req.params.templateName, req.body); + const result = services.templateService.updateDefaultLlm(req.params.templateName, req.body); writeAuditLog(services, req, "编辑模板默认大语言模型", `编辑模板默认大语言模型:${formatAuditTarget({ id: result.id, name: result.template_name @@ -490,6 +601,13 @@ export function createRoutes(services) { res.json(result); })); + router.put("/templates/:templateName/rename", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.templateService.renameTemplate(req.params.templateName, req.body || {}); + writeAuditLog(services, req, "重命名模板", `模板 ${req.params.templateName} 重命名为 ${result.template_name}`); + res.json(result); + })); + router.delete("/templates/:templateName", asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); const templates = services.portalService.listTemplates(); @@ -649,6 +767,100 @@ export function createRoutes(services) { res.json(await services.agentService.listAgents()); })); + router.get("/agents/:id/business-systems", asyncRoute(async (req, res) => { + res.json(services.agentService.getBusinessSystemPolicy(Number(req.params.id))); + })); + + router.get("/agents/:id/user-policy", asyncRoute(async (req, res) => { + res.json(services.agentService.getAgentUserPolicy(Number(req.params.id))); + })); + + router.put("/agents/:id/user-policy", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = services.agentService.setAgentUserPolicy(Number(req.params.id), req.body || {}); + const agent = services.agentService.getAgentRowById(Number(req.params.id)); + writeAuditLog( + services, + req, + "编辑数字员工用户权限", + `数字员工 ${agent?.slug || req.params.id};模式:${result.mode};用户:${result.mode === "all" + ? "所有人" + : result.permission_usernames.join(", ") || "无"}` + ); + res.json(result); + })); + + router.put("/agents/:id/business-systems", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = services.agentService.setBusinessSystemPolicy(Number(req.params.id), req.body || {}); + const agent = services.agentService.getAgentRowById(Number(req.params.id)); + writeAuditLog(services, req, "编辑数字员工业务系统权限", `编辑数字员工业务系统权限:${formatAuditTarget({ + id: req.params.id, + name: agent?.slug + })};模式:${result.mode};业务系统:${result.mode === "all" + ? "全部" + : result.business_systems.map((item) => item.application_name).join(", ") || "无"}`); + res.json(result); + })); + + router.get("/agents/:id/mcp", asyncRoute(async (req, res) => { + res.json(services.mcpService.getAgentPolicy(Number(req.params.id))); + })); + + router.put("/agents/:id/mcp", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.mcpService.setAgentPolicy(Number(req.params.id), req.body || {}); + const agent = services.agentService.getAgentRowById(Number(req.params.id)); + writeAuditLog(services, req, "编辑数字员工 MCP 权限", `数字员工 ${agent?.slug || req.params.id};模式:${result.mode}`); + res.json(result); + })); + + router.get("/mcp-servers", asyncRoute(async (_req, res) => { + res.json(services.mcpService.listServers()); + })); + + router.post("/mcp-servers", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.mcpService.createServer(req.body || {}); + writeAuditLog(services, req, "新增 MCP 服务", `MCP 服务:${result.slug}`); + res.status(201).json(result); + })); + + router.put("/mcp-servers/:id", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.mcpService.updateServer(Number(req.params.id), req.body || {}); + writeAuditLog(services, req, "编辑 MCP 服务", `MCP 服务:${result.slug}`); + res.json(result); + })); + + router.post("/mcp-servers/test", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.mcpService.testServer(req.body || {}); + writeAuditLog(services, req, "测试 MCP 配置", `MCP 服务:${String(req.body?.slug || "").trim()}`); + res.json(result); + })); + + router.post("/mcp-servers/:id/status", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.mcpService.setStatus(Number(req.params.id), req.body?.status); + writeAuditLog(services, req, result.status === "active" ? "启用 MCP 服务" : "停用 MCP 服务", `MCP 服务:${result.slug}`); + res.json(result); + })); + + router.post("/mcp-servers/:id/probe", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.mcpService.probeServer(Number(req.params.id)); + writeAuditLog(services, req, "测试 MCP 服务", `MCP 服务:${result.server.slug}`); + res.json(result); + })); + + router.delete("/mcp-servers/:id", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.mcpService.deleteServer(Number(req.params.id)); + writeAuditLog(services, req, "删除 MCP 服务", `MCP 服务:${result.slug}`); + res.json(result); + })); + router.get("/agents/by-slug/:slug", asyncRoute(async (req, res) => { res.json(await services.agentService.getAgentBySlug(req.params.slug)); })); @@ -663,6 +875,115 @@ export function createRoutes(services) { res.send(archive.buffer); })); + router.get("/agents/:slug/workspace/markdown", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + res.json(await services.agentService.listAgentMarkdown(req.params.slug)); + })); + + router.get("/agents/:slug/workspace/markdown/content", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + res.json(await services.agentService.readAgentMarkdown(req.params.slug, req.query.path)); + })); + + router.put("/agents/:slug/workspace/markdown/content", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.agentService.writeAgentMarkdown(req.params.slug, req.body || {}); + writeAuditLog(services, req, "保存数字员工 Markdown", `数字员工 ${req.params.slug} 保存:${result.path};摘要:${result.etag}`); + res.json(result); + })); + + router.post("/agents/:slug/workspace/markdown", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.agentService.writeAgentMarkdown(req.params.slug, { + ...(req.body || {}), + create: true + }); + writeAuditLog(services, req, "新建数字员工 Markdown", `数字员工 ${req.params.slug} 新建:${result.path};摘要:${result.etag}`); + res.status(201).json(result); + })); + + router.post("/agents/:slug/workspace/markdown/rename", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.agentService.renameAgentMarkdown(req.params.slug, req.body || {}); + writeAuditLog( + services, + req, + "重命名数字员工 Markdown", + `数字员工 ${req.params.slug}:${req.body?.source_path} -> ${result.path};摘要:${result.etag}` + ); + res.json(result); + })); + + router.delete("/agents/:slug/workspace/markdown", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const payload = { + path: req.body?.path || req.query.path, + etag: req.body?.etag || req.query.etag + }; + const result = await services.agentService.deleteAgentMarkdown(req.params.slug, payload); + writeAuditLog(services, req, "删除数字员工 Markdown", `数字员工 ${req.params.slug} 删除:${result.path}`); + res.json(result); + })); + + router.post("/agents/:slug/workspace/agents-md/restore-default", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.agentService.restoreDefaultAgentDocs(req.params.slug, req.body || {}); + writeAuditLog( + services, + req, + "恢复数字员工 AGENTS.md 默认值", + `数字员工 ${req.params.slug} 恢复系统默认 AGENTS.md;摘要:${result.etag}` + ); + res.json(result); + })); + + router.get("/agents/:slug/skills", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + res.json(await services.agentService.listAgentSkills(req.params.slug)); + })); + + router.post("/agents/:slug/skills", upload.single("artifact"), asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.agentService.installAgentSkill(req.params.slug, { + file: req.file, + replace: req.body.replace === "true", + etag: req.body.etag || undefined, + expectedSlug: req.body.slug || undefined + }); + writeAuditLog( + services, + req, + req.body.replace === "true" ? "替换数字员工 Skill" : "上传数字员工 Skill", + `数字员工 ${req.params.slug} Skill:${result.skill.slug};摘要:${result.skill.etag}` + ); + res.status(201).json(result); + })); + + router.delete("/agents/:slug/skills/:skillSlug", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const result = await services.agentService.deleteAgentSkill( + req.params.slug, + req.params.skillSlug, + req.body?.etag || req.query.etag + ); + writeAuditLog(services, req, "删除数字员工 Skill", `数字员工 ${req.params.slug} 删除 Skill:${result.slug}`); + res.json(result); + })); + + router.get("/agents/:slug/skills/:skillSlug/archive", asyncRoute(async (req, res) => { + services.authService.assertAdmin(req.currentUser); + const archive = await services.agentService.exportAgentSkillArchive( + req.params.slug, + req.params.skillSlug, + req.query.etag + ); + writeAuditLog(services, req, "下载数字员工 Skill", `数字员工 ${req.params.slug} 下载 Skill:${req.params.skillSlug}`); + res.setHeader("Content-Type", archive.contentType); + res.setHeader("Content-Disposition", `attachment; filename="${archive.fileName}"`); + res.setHeader("Cache-Control", "no-store"); + res.send(archive.buffer); + })); + router.post("/topics/admin/ping", asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); const result = await services.topicPingService.pingAdmin(); diff --git a/packages/daedalus-agent-management-web/server/src/app.js b/packages/daedalus-agent-management-web/server/src/app.js index 833bdba58928b1f42571c775f6cff675ce4d2850..6106aca4ecca166a150f0f8dd7daaf7a8203fc0b 100644 --- a/packages/daedalus-agent-management-web/server/src/app.js +++ b/packages/daedalus-agent-management-web/server/src/app.js @@ -2,13 +2,14 @@ import fs from "node:fs"; import path from "node:path"; import express from "express"; +import { getAbsoluteFSPath } from "swagger-ui-dist"; import { createRoutes } from "./api/routes/index.js"; import { loadEnv } from "./config/env.js"; import { db } from "./db/index.js"; import { ManagementActionClient } from "./management/management-action-client.js"; import { HzgProviderClient } from "./infra/providers/hzg-provider-client.js"; -import { ObjectStorage } from "./infra/s3/object-storage.js"; +import { LocalArtifactStore } from "./infra/local/local-artifact-store.js"; import { AgentService } from "./services/agent-service.js"; import { AuditLogService } from "./services/audit-log-service.js"; import { AuthService } from "./services/auth-service.js"; @@ -16,20 +17,52 @@ import { CatalogSyncService } from "./services/catalog-sync-service.js"; import { ContainerService } from "./services/container-service.js"; import { ExternalService } from "./services/external-service.js"; import { LargeLanguageModelService } from "./services/large-language-model-service.js"; +import { McpService } from "./services/mcp-service.js"; import { PortalService } from "./services/portal-service.js"; import { UpdateNotifier } from "./realtime/update-notifier.js"; import { RuntimeEnvConfigService } from "./services/runtime-env-config-service.js"; import { RuntimeComponentHealthService } from "./services/runtime-component-health-service.js"; import { SystemService } from "./services/system-service.js"; +import { TemplateService } from "./services/template-service.js"; import { TopicPingService } from "./services/topic-ping-service.js"; import { AppError } from "./utils/errors.js"; import { createLogger, errorDetails } from "./utils/logger.js"; +import { getOpenApiDocument } from "./docs/openapi.js"; const logger = createLogger("management-web-app"); +const swaggerUiAssetsDir = getAbsoluteFSPath(); +const swaggerUiHtml = ` + + + + + Daedalus Agent API 文档 + + + +
+ + + + +`; + export function createServerApp() { const env = loadEnv(); - const objectStorage = new ObjectStorage(env); + const localArtifactStore = new LocalArtifactStore(env.localArtifactsDir); const authService = new AuthService({ db, env }); const managementClient = new ManagementActionClient({ env, db }); const updateNotifier = new UpdateNotifier({ authService }); @@ -39,12 +72,14 @@ export function createServerApp() { const runtimeComponentHealthService = new RuntimeComponentHealthService({ env }); const containerService = new ContainerService({ env }); const catalogSyncService = new CatalogSyncService({ db, managementClient, auditLogService, env }); - const portalService = new PortalService({ db, objectStorage, managementClient, authService }); + const portalService = new PortalService({ db, localArtifactStore, managementClient, authService }); + const templateService = new TemplateService({ db, portalService, env }); portalService.cacheLocalVersionInfo(); const largeLanguageModelService = new LargeLanguageModelService({ db, managementClient }); + const mcpService = new McpService({ db, managementClient, env }); const topicPingService = new TopicPingService({ db, env, managementClient }); - const agentService = new AgentService({ db, managementClient, objectStorage, env }); - const systemService = new SystemService({ db, objectStorage, managementClient, runtimeEnvConfigService, env }); + const agentService = new AgentService({ db, managementClient, localArtifactStore, env, mcpService }); + const systemService = new SystemService({ db, localArtifactStore, managementClient, runtimeEnvConfigService, env }); const externalService = new ExternalService({ db, agentService, @@ -57,7 +92,9 @@ export function createServerApp() { env, authService, portalService, + templateService, largeLanguageModelService, + mcpService, agentService, topicPingService, auditLogService, @@ -76,6 +113,13 @@ export function createServerApp() { app.use(express.json({ limit: "5mb" })); app.use(express.urlencoded({ extended: true })); app.use("/api", createRoutes(services)); + app.get("/doc/openapi.json", (_req, res) => { + res.json(getOpenApiDocument()); + }); + app.use("/doc/assets", express.static(swaggerUiAssetsDir, { index: false })); + app.get(["/doc", "/doc/"], (_req, res) => { + res.type("html").send(swaggerUiHtml); + }); const distDir = path.resolve("dist"); if (fs.existsSync(distDir)) { diff --git a/packages/daedalus-agent-management-web/server/src/background/index.js b/packages/daedalus-agent-management-web/server/src/background/index.js index e32a2ecdb80bb99f8c901a7a980978f84ed5f5e6..94d65eb8249500f858bee7b9f2a9622689d98d1e 100644 --- a/packages/daedalus-agent-management-web/server/src/background/index.js +++ b/packages/daedalus-agent-management-web/server/src/background/index.js @@ -111,14 +111,12 @@ export function startBackgroundServices({ env, services }) { catalogTimer = setTimeout(async () => { catalogTimer = null; try { - logger.info({ trigger: "scheduled" }, "Scheduled catalog sync starting"); const result = await services.catalogSyncService.syncCatalogTask({ trigger: "scheduled" }); notifyUpdate(services, "catalog.updated", { trigger: "scheduled", status: result?.status, finished_at: result?.finished_at }); - logger.info({ trigger: "scheduled" }, "Scheduled catalog sync finished"); } catch (error) { logger.error({ error: errorDetails(error) }, "Scheduled catalog sync failed"); } finally { @@ -132,7 +130,6 @@ export function startBackgroundServices({ env, services }) { usageTimer = setTimeout(async () => { usageTimer = null; try { - logger.info({ trigger: "scheduled" }, "Scheduled agent usage sync starting"); const result = await services.catalogSyncService.refreshAgentUsage({ trigger: "scheduled" }); notifyUpdate(services, "usage.updated", { trigger: "scheduled", @@ -140,7 +137,6 @@ export function startBackgroundServices({ env, services }) { finished_at: result?.finished_at, summary: result?.summary }); - logger.info({ trigger: "scheduled" }, "Scheduled agent usage sync finished"); } catch (error) { logger.error({ error: errorDetails(error) }, "Scheduled agent usage sync failed"); } finally { @@ -175,7 +171,6 @@ export function startBackgroundServices({ env, services }) { return; } - logger.info({ trigger }, "System status refresh starting"); const results = await Promise.allSettled(tasks.map((task) => task.promise)); const targets = []; results.forEach((result, index) => { @@ -195,7 +190,6 @@ export function startBackgroundServices({ env, services }) { trigger, targets }); - logger.info({ trigger }, "System status refresh finished"); } function scheduleSystemStatusRefresh(delayMs) { diff --git a/packages/daedalus-agent-management-web/server/src/config/env.js b/packages/daedalus-agent-management-web/server/src/config/env.js index 23dcafe9f086c040e285354f10cd20b8dc35519e..a712a467d24e84c9a96ebdb41655355c35acd1e3 100644 --- a/packages/daedalus-agent-management-web/server/src/config/env.js +++ b/packages/daedalus-agent-management-web/server/src/config/env.js @@ -48,12 +48,18 @@ function readEnvNumber(name, fallback) { return Number.isFinite(value) && value > 0 ? value : fallback; } +function readEnvList(name, fallback = []) { + const value = readEnvValue(name, fallback.join(",")); + return [...new Set(value.split(",").map((item) => item.trim()).filter(Boolean))]; +} + export function loadEnv() { const productRoot = readEnvValue("DA_ROOT", ""); const defaultDataDir = productRoot ? path.join(productRoot, "management", "data") : path.join("data"); const dataDir = path.resolve(readEnvValue("DA_WEB_DATA_DIR", defaultDataDir)); + const localArtifactsDir = path.resolve(readEnvValue("DA_LOCAL_ARTIFACTS_DIR", path.join(dataDir, "local-artifacts"))); const defaultDaedalusAgentDataDir = productRoot ? path.join(productRoot, "data") : path.join("data"); @@ -65,6 +71,9 @@ export function loadEnv() { const defaultLogDir = productRoot ? path.join(productRoot, "logs") : path.join("logs"); + const defaultWorkspaceTemplatesDir = productRoot + ? path.join(productRoot, "workspace-templates") + : path.join("..", "..", "docker", "assets", "workspace-templates"); const logDir = path.resolve(readEnvValue("DA_LOG_DIR", defaultLogDir)); const defaultUserEnvJsonPath = productRoot ? path.join(productRoot, "env.json") @@ -76,10 +85,13 @@ export function loadEnv() { return { port: readEnvNumber("DA_WEB_PORT", defaultPort), + runMode: readEnvValue("DA_RUN_MODE", ""), dataDir, + localArtifactsDir, productDataDir, runDir, logDir, + workspaceTemplatesDir: path.resolve(readEnvValue("DA_WORKSPACE_TEMPLATES_DIR", defaultWorkspaceTemplatesDir)), userEnvJsonPath: path.resolve(readEnvValue("DA_USER_ENV_JSON", defaultUserEnvJsonPath)), credentialsJsonPath: path.resolve(readEnvValue("DA_CREDENTIALS_JSON", defaultCredentialsJsonPath)), containerRestartRequestPath: path.resolve( @@ -87,6 +99,8 @@ export function loadEnv() { ), sessionTtlHours: Number(readEnvValue("DA_WEB_SESSION_TTL_HOURS", "12")), managementTimeoutMs: readEnvNumber("DA_MANAGEMENT_WEBSITE_TIMEOUT", 120) * 1000, + mcpCliAllowedCommands: readEnvList("DA_MCP_CLI_ALLOWED_COMMANDS", ["node", "npx", "uvx", "python", "python3"]), + mcpCliAllowedCwdRoots: readEnvList("DA_MCP_CLI_ALLOWED_CWD_ROOTS", productRoot ? [productRoot] : []).map((item) => path.resolve(item)), startupSyncReadyTimeoutMs: readEnvNumber("DA_STARTUP_SYNC_READY_TIMEOUT_MS", 240000), startupSyncPingTimeoutMs: readEnvNumber("DA_STARTUP_SYNC_PING_TIMEOUT_MS", 1000), startupSyncPollIntervalMs: readEnvNumber("DA_STARTUP_SYNC_POLL_INTERVAL_MS", 10000), @@ -114,8 +128,6 @@ export function loadEnv() { accessKeyId: readEnvValue("DA_S3_ACCESS_KEY_ID"), secretAccessKey: readEnvValue("DA_S3_SECRET_ACCESS_KEY"), forcePathStyle: readEnvBoolean("DA_S3_FORCE_PATH_STYLE", true), - adminInboxBucket: readEnvValue("DA_S3_ADMIN_INBOX_BUCKET"), - adminOutboxBucket: readEnvValue("DA_S3_ADMIN_OUTBOX_BUCKET"), agentInboxBucket: readEnvValue("DA_S3_AGENT_INBOX_BUCKET"), agentOutboxBucket: readEnvValue("DA_S3_AGENT_OUTBOX_BUCKET") } diff --git a/packages/daedalus-agent-management-web/server/src/db/index.js b/packages/daedalus-agent-management-web/server/src/db/index.js index ac2e0a42620e72f86f97b8eaee1202582a2accc1..fa6706db2ee9abdad0d8129554425b7bd78db465 100644 --- a/packages/daedalus-agent-management-web/server/src/db/index.js +++ b/packages/daedalus-agent-management-web/server/src/db/index.js @@ -8,7 +8,7 @@ import { loadEnv } from "../config/env.js"; import { hashPassword } from "../utils/security.js"; const env = loadEnv(); -const SCHEMA_VERSION = 12; +const SCHEMA_VERSION = 17; const DEFAULT_ACCESS_TOKEN = "sk-1234567890qwertyuiop"; const DEFAULT_PORTAL_NAME = "智能体集群管理控制台"; const DEFAULT_BRAND_SUBTITLE = "Agents Management Console"; @@ -579,6 +579,224 @@ function migrateV11ToV12() { `); } +function migrateV12ToV13() { + db.exec(` + CREATE TABLE IF NOT EXISTS agent_business_system_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + policy_version INTEGER NOT NULL DEFAULT 1 CHECK (policy_version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS agent_business_system_access ( + agent_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, business_system_id), + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY (business_system_id) REFERENCES business_systems(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_agent_business_system_access_system_id + ON agent_business_system_access(business_system_id); + `); + + if (!hasTable("agents")) { + return; + } + + const now = toChinaISOString(); + db.prepare(` + INSERT OR IGNORE INTO agent_business_system_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) + SELECT id, 'all', 1, ?, ? + FROM agents + `).run(now, now); +} + +function createTemplateBusinessPolicyTables() { + return ` + CREATE TABLE IF NOT EXISTS template_business_system_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (template_id) REFERENCES agent_templates(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS template_business_system_access ( + template_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (template_id, business_system_id), + FOREIGN KEY (template_id) REFERENCES agent_templates(id) ON DELETE CASCADE, + FOREIGN KEY (business_system_id) REFERENCES business_systems(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_template_business_system_access_system_id + ON template_business_system_access(business_system_id); + `; +} + +function migrateV13ToV14() { + if (!hasTable("agent_templates")) { + return; + } + db.exec(createTemplateBusinessPolicyTables()); + const now = toChinaISOString(); + db.prepare(` + INSERT OR IGNORE INTO template_business_system_policies ( + template_id, mode, created_at, updated_at + ) + SELECT id, 'all', ?, ? + FROM agent_templates + `).run(now, now); +} + +function createMcpTables() { + return ` + CREATE TABLE IF NOT EXISTS mcp_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL CHECK (mode IN ('http', 'cli')), + transport TEXT NOT NULL CHECK (transport IN ('streamable-http', 'sse', 'stdio')), + url TEXT NOT NULL DEFAULT '', + command TEXT NOT NULL DEFAULT '', + args_json TEXT NOT NULL DEFAULT '[]', + cwd TEXT NOT NULL DEFAULT '', + env_json TEXT NOT NULL DEFAULT '{}', + headers_json TEXT NOT NULL DEFAULT '{}', + auth_type TEXT NOT NULL DEFAULT 'none' CHECK (auth_type IN ('none', 'oauth', 'headers')), + oauth_scope TEXT NOT NULL DEFAULT '', + tool_include_json TEXT NOT NULL DEFAULT '[]', + tool_exclude_json TEXT NOT NULL DEFAULT '[]', + connection_timeout_ms INTEGER, + request_timeout_ms INTEGER, + supports_parallel_tool_calls INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')), + last_probe_status TEXT NOT NULL DEFAULT 'unknown' CHECK (last_probe_status IN ('unknown', 'success', 'failed')), + last_probe_result_json TEXT NOT NULL DEFAULT '{}', + last_probed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE IF NOT EXISTS agent_mcp_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + policy_version INTEGER NOT NULL DEFAULT 1 CHECK (policy_version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS agent_mcp_access ( + agent_id INTEGER NOT NULL, + mcp_server_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, mcp_server_id), + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY (mcp_server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_agent_mcp_access_server_id + ON agent_mcp_access(mcp_server_id); + + CREATE TABLE IF NOT EXISTS template_mcp_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (template_id) REFERENCES agent_templates(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS template_mcp_access ( + template_id INTEGER NOT NULL, + mcp_server_id INTEGER NOT NULL, + PRIMARY KEY (template_id, mcp_server_id), + FOREIGN KEY (template_id) REFERENCES agent_templates(id) ON DELETE CASCADE, + FOREIGN KEY (mcp_server_id) REFERENCES mcp_servers(id) ON DELETE CASCADE + ); + + CREATE TABLE IF NOT EXISTS mcp_runtime_projections ( + agent_id INTEGER PRIMARY KEY, + deny_patterns_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + `; +} + +function migrateV14ToV15() { + db.exec(createMcpTables()); + const now = toChinaISOString(); + if (hasTable("agents")) { + db.prepare(` + INSERT OR IGNORE INTO agent_mcp_policies (agent_id, mode, policy_version, created_at, updated_at) + SELECT id, 'all', 1, ?, ? FROM agents + `).run(now, now); + } + if (hasTable("agent_templates")) { + db.prepare(` + INSERT OR IGNORE INTO template_mcp_policies (template_id, mode, created_at, updated_at) + SELECT id, 'all', ?, ? FROM agent_templates + `).run(now, now); + } +} + +function migrateV15ToV16() { + if (hasTable("agent_templates") && !hasTableColumn("agent_templates", "skill_count")) { + db.exec("ALTER TABLE agent_templates ADD COLUMN skill_count INTEGER NOT NULL DEFAULT 0;"); + } +} + +function createAgentUserPolicyTable() { + return ` + CREATE TABLE IF NOT EXISTS agent_user_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + policy_version INTEGER NOT NULL DEFAULT 1 CHECK (policy_version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + `; +} + +function migrateV16ToV17() { + db.exec(createAgentUserPolicyTable()); + if (!hasTable("agents")) { + return; + } + + const now = toChinaISOString(); + // 历史 agent 保持原有的“显式用户绑定”语义,避免迁移后意外放开访问。 + db.prepare(` + INSERT OR IGNORE INTO agent_user_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) + SELECT id, 'selected', 1, ?, ? + FROM agents + `).run(now, now); +} + +function addTemplateFallbackLlmColumns() { + if (!hasTable("agent_templates")) { + return; + } + if (!hasTableColumn("agent_templates", "fallback_llm_provider_id")) { + db.exec("ALTER TABLE agent_templates ADD COLUMN fallback_llm_provider_id TEXT;"); + } + if (!hasTableColumn("agent_templates", "fallback_llm_model_id")) { + db.exec("ALTER TABLE agent_templates ADD COLUMN fallback_llm_model_id TEXT;"); + } + if (!hasTableColumn("agent_templates", "fallback_llm_model_ref")) { + db.exec("ALTER TABLE agent_templates ADD COLUMN fallback_llm_model_ref TEXT;"); + } +} + function addAgentCreatorColumn() { if (!hasTable("agents")) { return; @@ -731,6 +949,10 @@ function createSchema() { default_llm_provider_id TEXT, default_llm_model_id TEXT, default_llm_model_ref TEXT, + fallback_llm_provider_id TEXT, + fallback_llm_model_id TEXT, + fallback_llm_model_ref TEXT, + skill_count INTEGER NOT NULL DEFAULT 0, is_builtin INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, @@ -768,6 +990,8 @@ function createSchema() { FOREIGN KEY (agent_user_id) REFERENCES agent_users(id) ON DELETE CASCADE ); + ${createAgentUserPolicyTable()} + CREATE TABLE business_systems ( id INTEGER PRIMARY KEY AUTOINCREMENT, provider TEXT NOT NULL CHECK (provider IN ('hzg', 'phx')), @@ -785,6 +1009,30 @@ function createSchema() { FOREIGN KEY (ontology_artifact_id) REFERENCES artifacts(id) ON DELETE SET NULL ); + CREATE TABLE agent_business_system_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + policy_version INTEGER NOT NULL DEFAULT 1 CHECK (policy_version > 0), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE TABLE agent_business_system_access ( + agent_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, business_system_id), + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY (business_system_id) REFERENCES business_systems(id) ON DELETE CASCADE + ); + + CREATE INDEX idx_agent_business_system_access_system_id + ON agent_business_system_access(business_system_id); + + ${createTemplateBusinessPolicyTables()} + + ${createMcpTables()} + CREATE TABLE audit_logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER, @@ -953,10 +1201,16 @@ function migrate() { migrateV8ToV9, migrateV9ToV10, migrateV10ToV11, - migrateV11ToV12 + migrateV11ToV12, + migrateV12ToV13, + migrateV13ToV14, + migrateV14ToV15, + migrateV15ToV16, + migrateV16ToV17 ]; if (version >= 1 && version < SCHEMA_VERSION) { + db.exec("PRAGMA foreign_keys = OFF;"); db.exec("BEGIN"); try { for (let current = version; current < SCHEMA_VERSION; current += 1) { @@ -967,6 +1221,8 @@ function migrate() { } catch (error) { db.exec("ROLLBACK"); throw error; + } finally { + db.exec("PRAGMA foreign_keys = ON;"); } } else if (version !== SCHEMA_VERSION) { db.exec("PRAGMA foreign_keys = OFF;"); @@ -992,6 +1248,11 @@ function migrate() { migrateV8ToV9(); } migrateV11ToV12(); + migrateV13ToV14(); + migrateV14ToV15(); + migrateV15ToV16(); + migrateV16ToV17(); + addTemplateFallbackLlmColumns(); ensureCoreRows(); } diff --git a/packages/daedalus-agent-management-web/server/src/docs/openapi.js b/packages/daedalus-agent-management-web/server/src/docs/openapi.js new file mode 100644 index 0000000000000000000000000000000000000000..e45252e9426c377cf88574b2710e376df76aa55b --- /dev/null +++ b/packages/daedalus-agent-management-web/server/src/docs/openapi.js @@ -0,0 +1,744 @@ +const objectSchema = (properties = {}, description = "JSON 对象") => ({ + type: "object", + description, + properties, + additionalProperties: true +}); + +const textField = (description, options = {}) => ({ + type: "string", + description, + ...options +}); + +const integerField = (description, options = {}) => ({ + type: "integer", + description, + ...options +}); + +const booleanField = (description, options = {}) => ({ + type: "boolean", + description, + ...options +}); + +const jsonBody = (description, properties = {}, required = []) => ({ + required: required.length > 0, + description, + content: { + "application/json": { + schema: { + ...objectSchema(properties, description), + ...(required.length > 0 ? { required } : {}) + } + } + } +}); + +const multipartBody = (description, properties = {}, required = [], fileField = "file") => ({ + required: required.length > 0, + description, + content: { + "multipart/form-data": { + schema: { + type: "object", + description, + properties: { + ...properties, + [fileField]: { + type: "string", + format: "binary", + description: `${fileField} 上传文件;服务端按具体资源限制文件类型、数量和大小。` + } + }, + ...(required.length > 0 ? { required } : {}) + } + } + } +}); + +const queryParam = (name, description, schema = { type: "string" }, required = false) => ({ + name, + in: "query", + required, + description, + schema +}); + +const pathParam = (name, description) => ({ + name, + in: "path", + required: true, + description, + schema: { type: "string", minLength: 1 } +}); + +const response = (description, schema = { $ref: "#/components/schemas/JsonObject" }, contentType = "application/json") => ({ + description, + ...(contentType ? { + content: { + [contentType]: { schema } + } + } : {}) +}); + +const fileResponse = (description, contentType = "application/octet-stream") => response( + description, + { type: "string", format: "binary" }, + contentType +); + +const route = (method, path, tag, summary, description, options = {}) => ({ + method, + path, + tag, + summary, + description, + ...options +}); + +const providerUpdateBody = jsonBody("Provider 更新字段;省略字段保持原值。", { + base_url: textField("OpenAI-compatible API base URL,可选。", { format: "uri" }), + api_key: textField("新的 Provider API Key,可选;留空不修改。", { format: "password" }), + restart: booleanField("是否请求运行时重启;管理端通常传 false。") +}); +const modelUpdateBody = jsonBody("Model 更新字段;省略字段保持原值。", { + name: textField("展示名称,可选。"), + context_tokens: integerField("上下文 Token 上限,可选,必须为正数。", { minimum: 1 }), + max_tokens: integerField("最大输出 Token 数,可选,必须为正数。", { minimum: 1 }), + restart: booleanField("是否请求运行时重启;管理端通常传 false。") +}); +const modelTestBody = jsonBody("Model 测试参数。", { + timeout_ms: integerField("测试超时时间,单位毫秒;不传使用服务端默认值。", { minimum: 1 }), + timeoutMs: integerField("timeout_ms 的兼容驼峰别名。", { minimum: 1 }) +}); +const mcpServerBody = jsonBody("MCP 服务配置。HTTP 与 CLI 模式使用不同字段;数组和键值对象按服务端限制校验。", { + slug: textField("唯一 slug;小写字母开头,只能包含小写字母、数字和中划线,最长 30 个字符。"), + name: textField("展示名称,不能为空。"), + description: textField("描述,可选。"), + mode: textField("接入模式。", { enum: ["http", "cli"] }), + transport: textField("HTTP transport;仅支持 streamable-http 或 sse。", { enum: ["streamable-http", "sse"] }), + url: textField("HTTP MCP 地址;必须是 http/https URL。", { format: "uri" }), + command: textField("CLI 可执行文件;必须在服务端白名单中。"), + args: { type: "array", items: { type: "string" }, description: "CLI 参数列表,可选。" }, + cwd: textField("CLI 工作目录,可选;必须是允许根目录下的绝对路径。"), + env: { type: "object", description: "CLI 环境变量键值对象;键名和空值受限。", additionalProperties: { type: "string" } }, + headers: { type: "object", description: "HTTP Header 键值对象;仅 auth_type=headers 时使用。", additionalProperties: { type: "string" } }, + auth_type: textField("HTTP 认证方式。", { enum: ["none", "oauth", "headers"] }), + oauth_scope: textField("OAuth scope,可选。"), + tool_include: { type: "array", items: { type: "string" }, description: "只允许调用的 Tool 名称列表,可选。" }, + tool_exclude: { type: "array", items: { type: "string" }, description: "禁止调用的 Tool 名称列表,可选。" }, + connection_timeout_ms: integerField("连接超时,单位毫秒,可选。", { minimum: 1 }), + request_timeout_ms: integerField("请求超时,单位毫秒,可选。", { minimum: 1 }), + supports_parallel_tool_calls: booleanField("是否支持并行 Tool 调用。"), + status: textField("服务状态。", { enum: ["active", "disabled"] }) +}); +const agentBody = jsonBody("数字员工配置。创建时需要 id/agent_name/template_name 和模型;更新时省略字段保持原值。", { + id: textField("Agent slug;创建时必填,必须符合 slug 规则。"), + agent_name: textField("展示名称;不能为空。"), + description: textField("描述,可选。"), + template_name: textField("模板名称;创建时必须存在。"), + llm_provider_id: textField("主模型 Provider ID。"), + llm_model_id: textField("主模型 ID。"), + fallback_llm_provider_id: textField("备选模型 Provider ID,可选;需与备选 Model 成对。"), + fallback_llm_model_id: textField("备选模型 ID,可选;不能与主模型相同。"), + daily_limit: integerField("每日 Token 限额;负数表示不限。"), + status: textField("更新时的状态。", { enum: ["normal", "disabled", "overlimit"] }), + docs_content: textField("AGENTS.md 或运行时说明文本,可选。"), + tags: { type: "array", items: { type: "string" }, description: "标签列表,可选。" }, + restart: booleanField("是否在远端变更后重启运行时;默认按服务端策略执行。") +}); +const settingsBody = jsonBody("门户基础设置。", { + portal_name: textField("门户名称,不能为空。"), + brand_subtitle: textField("门户副标题,不能为空。"), + theme_color: textField("主题色;平台托管主题时会按平台值保存。") +}); +const networkAllowlistBody = jsonBody("运行时网络配置。", { + agent_internal_network_allowlist: textField("Agent 可访问的内网 IP/CIDR 列表;可用逗号、空格或分号分隔。"), + huozige_server_ip: textField("活字格服务器 IP 或域名;不能包含路径、查询参数或片段。") +}); + +const ROUTES = [ + route("post", "/auth/login", "认证与门户", "管理员登录", "管理控制台登录场景。成功后返回会话 Token;密码按服务端认证策略校验。", { + public: true, + body: jsonBody("登录凭据。用户名和密码不能为空。", { + username: textField("管理员用户名。", { minLength: 1 }), + password: textField("管理员密码。", { minLength: 1, format: "password" }) + }, ["username", "password"]), + success: response("登录成功,返回 Token 与当前用户。") + }), + route("get", "/portal", "认证与门户", "获取门户信息", "登录页和门户壳读取品牌名称、副标题、主题色和 Logo 状态。无需管理权限。", { + public: true, + success: response("门户基础信息。") + }), + route("get", "/portal/logo", "认证与门户", "获取门户 Logo", "浏览器展示门户 Logo。未设置 Logo 时返回 404;响应为图片二进制。", { + public: true, + success: fileResponse("Logo 图片。", "image/*"), + errors: { 404: "Logo 未设置" } + }), + route("get", "/internal/app-invoke/system", "内部调用", "解析会话可访问的业务系统", "invoke service 在执行命令前校验会话、Agent 状态、业务系统状态和 Agent 业务系统策略。仅内部请求可用。", { + authDescription: "需要内部请求校验;非本机请求必须携带服务 Bearer Token。", + query: [ + queryParam("sessionId", "外部会话 ID;必须是已创建且仍有权限的会话。", {}, true), + queryParam("applicationName", "业务系统应用名;必须是活跃的 HZG 业务系统。", {}, true) + ] + }), + route("get", "/internal/app-invoke/base-url", "内部调用", "获取业务系统调用基址", "invoke service 根据应用名解析系统配置的 base URL,不接受调用方直接覆盖 host。", { + authDescription: "需要内部请求校验;非本机请求必须携带服务 Bearer Token。", + query: [queryParam("applicationName", "业务系统应用名;不能为空。", {}, true)] + }), + route("post", "/internal/app-invoke/logs", "内部调用", "记录业务系统调用日志", "invoke service 写入业务系统调用日志,适用于内部调用链路的统一审计。", { + authDescription: "需要内部请求校验;非本机请求必须携带服务 Bearer Token。", + body: jsonBody("调用日志。字段应与 invoke service 协议一致,例如 session_id、application_name、command_name、request_payload、response_payload、success。", { + session_id: textField("外部会话 ID。"), + application_name: textField("业务系统应用名。"), + command_name: textField("Server Command 或 Binding 命令名。"), + request_payload: { type: "object", description: "请求参数摘要或 JSON。", additionalProperties: true }, + response_payload: { type: "object", description: "响应摘要或 JSON。", additionalProperties: true }, + success: booleanField("调用是否成功。") + }) + }), + route("get", "/external/cookie", "外部服务", "获取业务系统 Cookie", "CUI 或 invoke service 为外部会话获取并缓存 HZG Cookie;每次调用前重新校验会话授权。", { + authDescription: "需要服务 API Bearer Token;本机请求按服务鉴权策略处理。", + query: [ + queryParam("sessionId", "外部会话 ID;不能为空。", {}, true), + queryParam("applicationName", "可选业务系统应用名;不传时按 Agent 业务系统策略选择。") + ] + }), + route("post", "/external/invoke/logging", "外部服务", "记录外部调用日志", "外部调用方在完成业务系统调用后写入统一调用日志和 Payload 摘要。", { + authDescription: "需要服务 API Bearer Token。", + successStatus: 204, + body: jsonBody("外部调用日志。sessionId、applicationName、commandName 不能为空;Payload 会按服务端大小和摘要策略处理。", { + sessionId: textField("外部会话 ID。"), + applicationName: textField("业务系统应用名。"), + commandName: textField("命令名。"), + method: textField("HTTP 方法,可选。"), + paramaters: textField("兼容现有协议的请求参数 JSON 字符串。"), + response: textField("响应 JSON 字符串或文本。"), + durationInMS: integerField("调用耗时,单位毫秒。", { minimum: 0 }), + isOK: booleanField("调用是否成功。"), + errorMessage: textField("失败原因,可选。") + }) + }), + route("get", "/external/agents", "外部服务", "查询外部 Agent 目录", "CUI 展示可用数字员工。传 `userName` 或 `id` 时返回该用户有权限的 Agent;两者都不传时仅返回用户范围为“所有人”的 Agent。每项包含 `is_public`。", { + authDescription: "需要服务 API Bearer Token。", + query: [ + queryParam("userName", "用户身份,用于筛选指定用户可访问的 Agent;与 `id` 二选一。"), + queryParam("id", "用户身份别名;与 `userName` 二选一。") + ], + successSchema: { type: "object", properties: { items: { type: "array", items: { $ref: "#/components/schemas/ExternalAgentDirectoryItem" } } } } + }), + route("get", "/external/session", "外部服务", "创建或复用外部会话", "按用户与 Agent 复用会话;创建时校验 Agent 状态、用户授权和必要 Cookie。", { + authDescription: "需要服务 API Bearer Token。", + query: [ + queryParam("userName", "外部用户名称;不能为空。", {}, true), + queryParam("agentId", "Agent slug;不能为空且必须存在。", {}, true), + queryParam("cookie", "调用方已有的 HZG Cookie,可选;长度不能超过 16 KiB。") + ] + }), + route("get", "/external/context", "外部服务", "获取外部会话上下文", "根据会话 ID 返回 Agent Topic、Agent 名称和用户名称,供 CUI 建立消息通道。", { + authDescription: "需要服务 API Bearer Token。", + query: [queryParam("sessionId", "外部会话 ID;不能为空且必须存在。", {}, true)] + }), + route("get", "/internal/cui/agents", "内部调用", "查询内部 CUI Agent 目录", "供内部 CUI 查询当前状态为 normal 的 Agent;已停用或超限 Agent 不返回。", { + authDescription: "需要内部请求校验;非本机请求必须携带服务 Bearer Token。" + }), + route("post", "/auth/logout", "认证与门户", "退出登录", "使当前管理会话 Token 失效。", { successStatus: 204 }), + route("get", "/auth/me", "认证与门户", "获取当前用户", "获取当前管理会话对应的用户信息。"), + route("post", "/auth/change-password", "认证与门户", "修改当前用户密码", "当前用户修改自己的登录密码。旧密码、新密码等校验由认证服务执行。", { + body: jsonBody("密码修改请求。新密码必须满足服务端密码策略。", { + old_password: textField("当前密码。", { format: "password" }), + password: textField("新密码。", { format: "password", minLength: 1 }) + }, ["password"]) + }), + route("get", "/bootstrap", "认证与门户", "获取控制台启动数据", "页面启动时一次性获取当前用户、门户设置、访问令牌、版本信息、同步状态和运行配置。"), + route("get", "/dashboard", "仪表盘", "获取仪表盘统计", "返回数字员工、模板、Skill、业务系统和用量等总览统计。"), + route("get", "/audit-logs", "审计与日志", "查询审计日志", "管理员分页查看管理操作审计记录。页码从 1 开始;pageSize 默认 50。", { + query: [ + queryParam("page", "页码,最小值 1。", { type: "integer", minimum: 1 }), + queryParam("pageSize", "每页条数,默认 50;建议不超过 200。", { type: "integer", minimum: 1, maximum: 200 }) + ] + }), + route("get", "/users", "用户管理", "查询管理用户", "管理员用户列表,可按角色筛选。", { + query: [queryParam("role", "角色筛选,可选,例如 daedalus-admin。")] + }), + route("post", "/users", "用户管理", "创建管理用户", "管理员创建新的控制台用户。用户名唯一;密码和角色按用户服务规则校验。", { + body: jsonBody("用户字段。username、password、role 为核心字段;status 可选 active/disabled。", { + username: textField("唯一用户名。", { minLength: 1 }), + password: textField("初始密码。", { format: "password", minLength: 1 }), + role: textField("角色;当前主要为 daedalus-admin。", { enum: ["daedalus-admin"] }), + display_name: textField("显示名称,可选。"), + status: textField("状态,可选 active/disabled。", { enum: ["active", "disabled"] }), + tags: { type: "array", items: { type: "string" }, description: "用户标签,可选。" } + }, ["username", "password", "role"]) + }), + route("put", "/users/{id}", "用户管理", "编辑管理用户", "管理员更新用户资料、状态或标签;不能绕过管理员权限边界。", { + query: [], + body: jsonBody("要更新的用户字段;未传字段保持原值。", { + display_name: textField("显示名称,可选。"), + status: textField("状态,可选 active/disabled。", { enum: ["active", "disabled"] }), + tags: { type: "array", items: { type: "string" }, description: "用户标签,可选。" } + }) + }), + route("delete", "/users/{id}", "用户管理", "删除管理用户", "管理员删除指定用户;不能删除当前正在使用的管理员账号。", { successStatus: 204 }), + route("post", "/users/{id}/reset-password", "用户管理", "重置管理用户密码", "管理员重置指定用户密码;未提供 password 时使用系统默认密码。", { + body: jsonBody("可选密码。省略 password 使用默认值。", { password: textField("新密码,可选。", { format: "password" }) }) + }), + route("get", "/templates", "模板管理", "查询 Agent 模板", "返回模板列表及模板摘要。"), + route("get", "/templates/{templateName}/archive", "模板管理", "下载模板归档", "下载模板 workspace ZIP;适合备份或跨环境迁移。", { + success: fileResponse("模板 ZIP 归档。", "application/zip") + }), + route("get", "/templates/{templateName}/tree", "模板管理", "查询模板文件树", "返回模板 workspace 的受控文件树。"), + route("get", "/templates/{templateName}/details", "模板管理", "查询模板详情", "返回模板概览、文件、Skill、模型和权限策略摘要。"), + route("get", "/templates/{templateName}/files/content", "模板工作区", "读取模板文件", "读取模板 workspace 文件内容;传 download=true 时返回附件。路径必须位于模板 workspace 内。", { + query: [ + queryParam("path", "模板内相对路径;禁止绝对路径和路径穿越。", {}, true), + queryParam("download", "是否下载原始文件,只有 true 时返回附件。", { type: "string", enum: ["true", "false"] }) + ] + }), + route("put", "/templates/{templateName}/files/content", "模板工作区", "保存模板文件", "保存文本或受控文件内容;服务端会校验路径和并发版本。", { + body: jsonBody("文件保存请求。path 为模板内相对路径;content 为文本内容;etag 用于乐观并发校验。", { + path: textField("模板内相对路径;禁止路径穿越。", { minLength: 1 }), + content: textField("文件文本内容。"), + etag: textField("可选并发版本摘要。") + }, ["path"]) + }), + route("post", "/templates/{templateName}/folders", "模板工作区", "新建模板目录", "在模板 workspace 创建目录;路径必须是安全的相对路径。", { + body: jsonBody("目录创建请求。", { path: textField("新目录相对路径。", { minLength: 1 }) }, ["path"]), + successStatus: 201 + }), + route("post", "/templates/{templateName}/files", "模板工作区", "上传或创建模板文件", "multipart 上传文件,或在未上传 file 时使用 JSON 文本字段创建文件。单包大小、路径和文件类型受服务端限制。", { + body: multipartBody("模板文件上传。target_path 指定目标目录;也可使用 path/content 创建文本文件。", { + target_path: textField("目标目录相对路径,可选。"), + path: textField("文本文件相对路径;无 file 时使用。"), + content: textField("文本内容;无 file 时使用。") + }) + }), + route("post", "/templates/{templateName}/agents-md/restore-default", "模板工作区", "恢复模板 AGENTS.md", "恢复系统默认 AGENTS.md;适合清理误修改的模板入口说明。"), + route("post", "/templates/{templateName}/entries/move", "模板工作区", "移动或重命名模板条目", "在模板 workspace 内移动文件或目录;源路径和目标路径必须安全且不能越界。", { + body: jsonBody("移动请求。", { + source_path: textField("源相对路径。", { minLength: 1 }), + destination_path: textField("目标相对路径。", { minLength: 1 }), + etag: textField("可选并发版本摘要。") + }, ["source_path", "destination_path"]) + }), + route("delete", "/templates/{templateName}/entries", "模板工作区", "删除模板条目", "删除模板 workspace 内文件或目录;关键文件 AGENTS.md 受保护。", { + query: [queryParam("path", "待删除条目的相对路径;也可从 JSON body 传入。", {}, false)], + body: jsonBody("可选 JSON body。path 与 query 参数二选一。", { path: textField("待删除相对路径。") }) + }), + route("post", "/templates/{templateName}/skills", "模板管理", "上传模板 Skill", "上传 Skill ZIP 并安装到模板;必须包含合法 SKILL.md,包路径和大小受限制。", { + body: multipartBody("Skill ZIP。replace=true 时替换同名 Skill。", { + replace: textField("是否替换同名 Skill,字符串 true/false。", { enum: ["true", "false"] }) + }, ["artifact"], "artifact"), + successStatus: 201 + }), + route("delete", "/templates/{templateName}/skills/{slug}", "模板管理", "删除模板 Skill", "从模板中删除指定 Skill;内置资源按删除保护策略处理。"), + route("put", "/templates/{templateName}/business-systems", "模板权限", "设置模板业务系统权限", "设置模板对业务系统的全部或选定访问范围;策略会影响由模板创建的 Agent。", { + body: jsonBody("业务系统访问策略。mode 为 all 或 selected;selected 时提供 business_system_ids。", { + mode: textField("访问模式。", { enum: ["all", "selected"] }), + business_system_ids: { type: "array", items: integerField("业务系统 ID。", { minimum: 1 }), description: "selected 模式下的业务系统 ID 列表。" }, + policy_version: integerField("乐观并发版本;不传时由当前接口兼容处理。", { minimum: 1 }) + }, ["mode"]) + }), + route("put", "/templates/{templateName}/mcp", "模板权限", "设置模板 MCP 权限", "设置模板对 MCP 服务的全部或选定访问范围;策略会影响由模板创建的 Agent。", { + body: jsonBody("MCP 访问策略。mode 为 all 或 selected;selected 时提供 mcp_server_ids。", { + mode: textField("访问模式。", { enum: ["all", "selected"] }), + mcp_server_ids: { type: "array", items: integerField("MCP 服务 ID。", { minimum: 1 }), description: "selected 模式下的 MCP 服务 ID 列表。" }, + policy_version: integerField("乐观并发版本,可选。", { minimum: 1 }) + }, ["mode"]) + }), + route("post", "/templates/{templateName}/validate", "模板管理", "校验模板", "校验模板 workspace、AGENTS.md 和 Skill 结构,返回文件与 Skill 摘要。"), + route("post", "/templates", "模板管理", "创建模板", "创建空白模板、导入 ZIP 或克隆已有模板。导入 ZIP 必须通过路径、大小、条目和顶层 AGENTS.md 校验。", { + body: multipartBody("模板创建请求。mode 可选 blank/import/clone;导入或克隆时填写对应来源。", { + mode: textField("创建模式。", { enum: ["blank", "import", "clone"] }), + template_name: textField("模板名称;必须唯一且符合名称规则。"), + source_template_name: textField("clone 模式的来源模板名称。"), + description: textField("模板描述,可选。") + }, [], "artifact"), + successStatus: 201 + }), + route("put", "/templates/{templateName}/default-llm", "模板管理", "设置模板默认模型", "设置模板创建 Agent 时继承的主模型和备选模型;Provider/Model 必须成对存在。", { + body: jsonBody("默认模型选择。", { + default_llm_provider_id: textField("主 Provider ID。"), + default_llm_model_id: textField("主 Model ID。"), + fallback_llm_provider_id: textField("备选 Provider ID,可选。"), + fallback_llm_model_id: textField("备选 Model ID,可选,不能与主模型相同。") + }) + }), + route("put", "/templates/{templateName}/rename", "模板管理", "重命名模板", "同步更新模板名称及已有 Agent 的模板引用;远端动作失败时执行补偿。", { + body: jsonBody("重命名请求。", { template_name: textField("新的模板名称;必须符合名称规则。", { minLength: 1 }) }, ["template_name"]) + }), + route("delete", "/templates/{templateName}", "模板管理", "删除模板", "删除自定义模板及关联制品;内置模板受删除保护。"), + route("get", "/llm/providers", "大语言模型", "查询模型 Provider", "返回已配置的 OpenAI-compatible Provider;API Key 仅返回掩码或摘要。"), + route("post", "/llm/providers", "大语言模型", "创建模型 Provider", "创建 Provider;base_url 必须是受支持的 HTTP/HTTPS 地址,API Key 不会明文回显。", { + body: jsonBody("Provider 配置。", { + provider_id: textField("唯一 Provider ID。", { minLength: 1 }), + base_url: textField("OpenAI-compatible API base URL。", { format: "uri" }), + api_key: textField("Provider API Key;服务端保存掩码/指纹。", { format: "password" }) + }, ["provider_id", "base_url"]), + successStatus: 201 + }), + route("put", "/llm/providers/{providerId}", "大语言模型", "编辑模型 Provider", "更新 Provider 地址或密钥;不能破坏仍被模型引用的标识。", { body: providerUpdateBody }), + route("post", "/llm/providers/{providerId}/test", "大语言模型", "测试 Provider 连接", "调用 Provider 的兼容接口验证网络、认证和响应格式。", { + body: jsonBody("连接测试参数,可选。", { model_id: textField("可选测试模型 ID。") }) + }), + route("delete", "/llm/providers/{providerId}", "大语言模型", "删除模型 Provider", "删除未被模型或 Agent 引用的 Provider;存在引用时拒绝删除。"), + route("get", "/llm/models", "大语言模型", "查询模型", "按 Provider 查询模型列表。", { + query: [queryParam("provider_id", "可选 Provider ID 筛选。"), queryParam("providerId", "兼容别名。")] + }), + route("post", "/llm/models", "大语言模型", "创建模型", "创建 Provider 下的模型配置。模型 ID 在同一 Provider 下必须唯一。", { + body: jsonBody("模型配置。", { + provider_id: textField("所属 Provider ID。", { minLength: 1 }), + model_id: textField("远端模型 ID。", { minLength: 1 }), + name: textField("展示名称,可选。"), + context_tokens: integerField("上下文 Token 上限,可选。", { minimum: 1 }) + }, ["provider_id", "model_id"]), + successStatus: 201 + }), + route("put", "/llm/providers/{providerId}/models/{modelId}", "大语言模型", "编辑模型", "更新模型名称和上下文配置;Provider/Model 标识用于 Agent 引用。", { body: modelUpdateBody }), + route("post", "/llm/providers/{providerId}/models/{modelId}/test", "大语言模型", "测试模型连接", "使用指定 Provider/Model 执行 OpenAI-compatible chat completion 测试。", { body: modelTestBody }), + route("delete", "/llm/providers/{providerId}/models/{modelId}", "大语言模型", "删除模型", "删除未被 Agent 或模板引用的模型。"), + route("get", "/skills", "全局 Skill", "查询全局 Skill", "返回内置与管理员上传的全局 Skill 及安装状态。"), + route("post", "/skills/inspect", "全局 Skill", "预检 Skill ZIP", "解析 Skill ZIP 的 frontmatter、路径和结构;不执行安装。", { + body: multipartBody("待检查的 Skill ZIP。必须包含 SKILL.md,且 name、description frontmatter 有效。", {}, ["artifact"], "artifact"), + successStatus: 200 + }), + route("post", "/skills", "全局 Skill", "上传全局 Skill", "上传并安装全局 Skill;路径穿越、符号链接、条目数量和大小均会校验。", { + body: multipartBody("全局 Skill ZIP。replace=true 时替换同名 Skill。", { + replace: textField("是否替换,字符串 true/false。", { enum: ["true", "false"] }) + }, ["artifact"], "artifact"), + successStatus: 201 + }), + route("put", "/skills/{id}", "全局 Skill", "替换全局 Skill", "替换管理员上传的全局 Skill;内置 Skill 不能替换或删除。", { + body: multipartBody("新的 Skill ZIP。", { replace: textField("兼容字段,可选。", { enum: ["true", "false"] }) }, ["artifact"], "artifact") + }), + route("delete", "/skills/{id}", "全局 Skill", "删除全局 Skill", "删除管理员上传的全局 Skill;内置 Skill 受删除保护。"), + route("get", "/agents", "数字员工", "查询数字员工", "返回 Agent 列表、状态、模型、限额、权限和远端同步摘要。"), + route("get", "/agents/{id}/business-systems", "数字员工权限", "查询 Agent 业务系统策略", "读取 Agent 的全部/选定业务系统访问范围和策略版本。"), + route("get", "/agents/{id}/user-policy", "数字员工权限", "查询 Agent 用户策略", "读取 Agent 的 all/selected 用户范围、策略版本及用户列表。"), + route("put", "/agents/{id}/user-policy", "数字员工权限", "设置 Agent 用户策略", "管理员配置“所有人”或“指定用户”访问范围;policy_version 用于乐观并发控制。", { + body: jsonBody("用户访问策略。mode 为 all 或 selected;selected 时提供 permission_usernames。", { + mode: textField("用户访问模式。", { enum: ["all", "selected"] }), + permission_usernames: { type: "array", items: { type: "string" }, description: "selected 模式下的用户名列表。" }, + policy_version: integerField("当前策略版本;过期时返回冲突。", { minimum: 1 }) + }, ["mode", "policy_version"]) + }), + route("put", "/agents/{id}/business-systems", "数字员工权限", "设置 Agent 业务系统策略", "管理员配置 Agent 对业务系统的全部/选定访问范围;策略版本支持乐观并发控制。", { + body: jsonBody("业务系统策略。", { + mode: textField("访问模式。", { enum: ["all", "selected"] }), + business_system_ids: { type: "array", items: integerField("业务系统 ID。", { minimum: 1 }), description: "selected 模式的业务系统 ID。" }, + policy_version: integerField("当前策略版本。", { minimum: 1 }) + }, ["mode", "policy_version"]) + }), + route("get", "/agents/{id}/mcp", "数字员工权限", "查询 Agent MCP 策略", "读取 Agent 的 MCP 全部/选定访问范围。"), + route("put", "/agents/{id}/mcp", "数字员工权限", "设置 Agent MCP 策略", "管理员配置 Agent 对 MCP 服务的访问范围;无权限服务会投影为 deny。", { + body: jsonBody("MCP 策略。", { + mode: textField("访问模式。", { enum: ["all", "selected"] }), + mcp_server_ids: { type: "array", items: integerField("MCP 服务 ID。", { minimum: 1 }), description: "selected 模式的 MCP 服务 ID。" }, + policy_version: integerField("当前策略版本。", { minimum: 1 }) + }, ["mode", "policy_version"]) + }), + route("get", "/mcp-servers", "MCP 服务", "查询 MCP 服务", "返回集中注册的 MCP 服务、接入类型、启用状态和探测摘要。"), + route("post", "/mcp-servers", "MCP 服务", "创建 MCP 服务", "创建 HTTP 或 CLI/stdio MCP 服务;CLI 可执行文件和工作目录受白名单限制。", { body: mcpServerBody, successStatus: 201 }), + route("put", "/mcp-servers/{id}", "MCP 服务", "编辑 MCP 服务", "更新 MCP 地址、认证、超时、Tool 过滤和并行调用配置。", { body: mcpServerBody }), + route("post", "/mcp-servers/test", "MCP 服务", "测试 MCP 配置", "保存前测试 MCP 连接并返回 Tool 摘要,不一定持久化配置。", { body: mcpServerBody }), + route("post", "/mcp-servers/{id}/status", "MCP 服务", "启用或停用 MCP 服务", "切换 MCP 服务状态并同步运行时配置。", { + body: jsonBody("状态请求。", { status: textField("目标状态。", { enum: ["active", "disabled"] }) }, ["status"]) + }), + route("post", "/mcp-servers/{id}/probe", "MCP 服务", "探测 MCP 服务", "读取 MCP Tool 列表并保存最近一次探测结果。"), + route("delete", "/mcp-servers/{id}", "MCP 服务", "删除 MCP 服务", "删除 MCP 服务并同步运行时;被策略引用时按服务端保护规则处理。"), + route("get", "/agents/by-slug/{slug}", "数字员工工作区", "按 slug 获取 Agent", "读取 Agent 详情及运行时摘要。"), + route("get", "/agents/{slug}/workspace.zip", "数字员工工作区", "下载 Agent 工作区", "导出 Agent workspace ZIP;临时制品下载后清理。", { success: fileResponse("Agent workspace ZIP。", "application/zip") }), + route("get", "/agents/{slug}/workspace/markdown", "数字员工工作区", "查询 Agent Markdown 文件树", "列出 Agent workspace 内受管 Markdown 文件。"), + route("get", "/agents/{slug}/workspace/markdown/content", "数字员工工作区", "读取 Agent Markdown", "读取 Markdown 文本和 etag;path 必须位于 Agent workspace 内。", { + query: [queryParam("path", "Markdown 相对路径;禁止绝对路径和路径穿越。", {}, true)] + }), + route("put", "/agents/{slug}/workspace/markdown/content", "数字员工工作区", "保存 Agent Markdown", "保存 Markdown 内容并使用 etag 做乐观并发校验。", { + body: jsonBody("Markdown 保存请求。", { + path: textField("Markdown 相对路径。", { minLength: 1 }), + content: textField("Markdown 文本。"), + etag: textField("读取时返回的 etag。") + }, ["path", "content"]) + }), + route("post", "/agents/{slug}/workspace/markdown", "数字员工工作区", "新建 Agent Markdown", "创建 workspace Markdown 文件或目录内文件。", { + body: jsonBody("Markdown 创建请求。", { + path: textField("目标相对路径。", { minLength: 1 }), + content: textField("初始文本,可选。") + }, ["path"]), + successStatus: 201 + }), + route("post", "/agents/{slug}/workspace/markdown/rename", "数字员工工作区", "移动或重命名 Agent Markdown", "在 Agent workspace 内移动/重命名 Markdown,并校验源 etag。", { + body: jsonBody("移动请求。", { + source_path: textField("源相对路径。", { minLength: 1 }), + destination_path: textField("目标相对路径。", { minLength: 1 }), + etag: textField("源文件 etag,可选。") + }, ["source_path", "destination_path"]) + }), + route("delete", "/agents/{slug}/workspace/markdown", "数字员工工作区", "删除 Agent Markdown", "删除 Markdown 文件;AGENTS.md 等受保护文件不能直接删除。", { + query: [queryParam("path", "待删除 Markdown 相对路径;也可从 JSON body 传入。", {}, false)], + body: jsonBody("可选 JSON body。", { path: textField("待删除相对路径。"), etag: textField("并发版本摘要,可选。") }) + }), + route("post", "/agents/{slug}/workspace/agents-md/restore-default", "数字员工工作区", "恢复 Agent AGENTS.md", "恢复 Agent 的系统默认 AGENTS.md。", { body: jsonBody("可选 etag 并发校验。", { etag: textField("当前文件 etag,可选。") }) }), + route("get", "/agents/{slug}/skills", "数字员工 Skill", "查询 Agent Skill", "返回 Agent 专属 Skill 列表及 etag。"), + route("post", "/agents/{slug}/skills", "数字员工 Skill", "上传 Agent Skill", "上传或替换 Agent 专属 Skill ZIP;必须通过 SKILL.md、路径和大小校验。", { + body: multipartBody("Agent Skill ZIP。replace=true 替换同名 Skill;etag 用于并发控制。", { + replace: textField("是否替换,字符串 true/false。", { enum: ["true", "false"] }), + etag: textField("待替换 Skill 的 etag,可选。"), + slug: textField("期望替换的 Skill slug,可选。") + }, ["artifact"], "artifact"), + successStatus: 201 + }), + route("delete", "/agents/{slug}/skills/{skillSlug}", "数字员工 Skill", "删除 Agent Skill", "删除 Agent 专属 Skill;可从 query 或 body 提供 etag。", { + query: [queryParam("etag", "并发版本摘要,可选。")], + body: jsonBody("可选并发校验。", { etag: textField("Skill etag,可选。") }) + }), + route("get", "/agents/{slug}/skills/{skillSlug}/archive", "数字员工 Skill", "下载 Agent Skill", "下载指定 Agent Skill ZIP。", { + query: [queryParam("etag", "并发版本摘要,可选。")], + success: fileResponse("Skill ZIP。", "application/zip") + }), + route("post", "/topics/admin/ping", "运维与探测", "探测管理 Topic", "管理员向管理入站 Topic 发送探测消息并返回响应耗时。"), + route("post", "/agents/{slug}/ping", "运维与探测", "探测 Agent Topic", "管理员探测指定 Agent 的入站/出站 Topic。"), + route("post", "/agents/{slug}/test-message", "运维与探测", "测试 Agent 对话", "管理员向指定 Agent 发送测试消息并返回回复和步骤耗时。", { + body: jsonBody("测试消息。", { + message: textField("测试文本。", { minLength: 1 }), + text: textField("兼容字段;未传 message 时可使用。"), + timeout_ms: integerField("超时时间,单位毫秒;受服务端上限限制。", { minimum: 1 }), + session_id: textField("可选测试会话 ID。") + }) + }), + route("get", "/agent-users", "Agent 用户", "查询 Agent 平台用户", "管理员分页查询 Agent 用户及其使用统计。q 用于用户名模糊搜索。", { + query: [ + queryParam("q", "用户名模糊搜索,可选。"), + queryParam("page", "页码,最小值 1,默认 1。", { type: "integer", minimum: 1 }), + queryParam("limit", "每页条数,范围 1-200,默认 50。", { type: "integer", minimum: 1, maximum: 200 }) + ] + }), + route("post", "/agent-users", "Agent 用户", "新增 Agent 平台用户", "创建可被 Agent 授权的业务用户。用户名不能为空且必须唯一。", { + body: jsonBody("用户创建请求。", { username: textField("唯一用户名。", { minLength: 1 }) }, ["username"]), + successStatus: 201 + }), + route("post", "/agent-users/import", "Agent 用户", "批量导入 Agent 用户", "按换行批量导入用户名;空行会被忽略,重复用户名会去重。", { + body: jsonBody("批量导入内容。", { content: textField("每行一个用户名;受请求体大小限制。", { minLength: 1 }) }, ["content"]) + }), + route("delete", "/agent-users/{id}", "Agent 用户", "删除 Agent 平台用户", "删除用户及其 Agent 权限关联;历史会话按外部会话约束处理。"), + route("post", "/agents", "数字员工", "创建数字员工", "创建 Agent 并同步远端运行时;支持模板继承、模型、限额和用户访问策略。失败时执行本地/远端补偿。", { body: agentBody, successStatus: 201 }), + route("put", "/agents/{id}", "数字员工", "编辑数字员工", "更新 Agent 名称、描述、模板、模型、限额、状态和授权用户;远端先行并提供失败补偿。", { body: agentBody }), + route("delete", "/agents/{id}", "数字员工", "删除数字员工", "删除 Agent 远端运行时、本地记录和关联制品;可选 restart 控制运行时重启。", { body: jsonBody("删除选项。", { restart: booleanField("是否重启运行时,默认按服务策略执行。") }) }), + route("get", "/systems", "业务系统", "查询业务系统", "返回 HZG 业务系统列表、状态和连通性摘要。"), + route("post", "/systems", "业务系统", "创建业务系统", "创建 HZG 业务系统并上传 Ontology ZIP;ZIP 顶层必须包含 index.md。", { + body: multipartBody("业务系统表单。scheme、port、application_name 必须有效;ontology 为可选或必需的业务系统包,按当前服务端规则校验。", { + application_name: textField("唯一应用名。", { minLength: 1 }), + description: textField("描述,可选。"), + scheme: textField("协议。", { enum: ["http", "https"] }), + port: integerField("端口,1-65535。", { minimum: 1, maximum: 65535 }), + status: textField("初始状态,可选 active/disabled。", { enum: ["active", "disabled"] }) + }, [], "ontology"), + successStatus: 201 + }), + route("put", "/systems/{id}", "业务系统", "编辑业务系统", "更新 HZG 业务系统配置或 Ontology;失败时执行数据库和制品补偿。", { + body: multipartBody("业务系统更新表单。未传字段保持原值;ontology 可选。", { + application_name: textField("业务系统应用名,可选。"), + description: textField("描述,可选。"), + scheme: textField("协议,可选。", { enum: ["http", "https"] }), + port: integerField("端口,可选,1-65535。", { minimum: 1, maximum: 65535 }), + status: textField("状态,可选 active/disabled。", { enum: ["active", "disabled"] }) + }, [], "ontology") + }), + route("post", "/systems/{id}/test", "业务系统", "测试业务系统连通性", "对指定业务系统执行 GET 连通性测试并记录最近结果。"), + route("post", "/systems/{id}/status", "业务系统", "切换业务系统状态", "启用或停用业务系统;停用后外部调用和权限解析不会使用该系统。", { + body: jsonBody("状态请求。", { status: textField("目标状态。", { enum: ["active", "disabled"] }) }, ["status"]) + }), + route("delete", "/systems/{id}", "业务系统", "删除业务系统", "删除业务系统、Ontology 运行时和本地制品;仍被权限策略引用时按服务端规则处理。"), + route("get", "/systems/logs", "业务系统", "查询业务调用日志", "按应用名或 Agent 筛选业务系统调用日志。", { + query: [queryParam("application_name", "按业务系统应用名筛选。"), queryParam("agent_slug", "按 Agent slug 筛选。")] + }), + route("get", "/systems/logs/{traceId}/artifacts/{direction}", "业务系统", "下载调用 Payload 附件", "下载调用日志关联的受控请求或响应附件。direction 仅允许 request/response。", { + success: fileResponse("Payload 附件。") + }), + route("get", "/systems/stats", "业务系统", "查询业务调用统计", "按应用名或 Agent 查询调用次数、耗时和成功率。", { + query: [queryParam("application_name", "按业务系统应用名筛选。"), queryParam("agent_slug", "按 Agent slug 筛选。")] + }), + route("get", "/kernel/logs", "审计与日志", "查询管理动作日志", "管理员分页查询管理端到内核的动作请求和响应摘要。", { + query: [ + queryParam("page", "页码,最小值 1。", { type: "integer", minimum: 1 }), + queryParam("pageSize", "每页条数,默认 50。", { type: "integer", minimum: 1, maximum: 200 }) + ] + }), + route("get", "/system-logs/types", "审计与日志", "查询系统日志类型", "返回可下载的 MQTT、MinIO、内核、应用和管理端日志类型。"), + route("get", "/system-logs/download", "审计与日志", "下载系统日志", "按日志类型和日期下载日志文件;日期使用 YYYYMMDD,文件不存在返回 404。", { + query: [ + queryParam("type", "日志类型;必须来自 /api/system-logs/types。", {}, true), + queryParam("date", "日志日期,格式 YYYYMMDD。", { type: "string", pattern: "^[0-9]{8}$" }, true) + ], + success: fileResponse("日志文件。", "application/octet-stream") + }), + route("get", "/settings", "系统设置", "查询系统设置", "读取门户基础设置。"), + route("put", "/settings", "系统设置", "保存系统设置", "管理员保存门户名称、副标题等基础设置;主题色由平台托管时不会接受任意覆盖。", { body: settingsBody }), + route("put", "/settings/logo", "系统设置", "上传门户 Logo", "上传 jpeg/jpg/png/bmp Logo,文件大小不能超过 1 MiB。", { + body: multipartBody("Logo 文件。支持 jpeg、jpg、png、bmp;最大 1 MiB。", {}, ["file"]) + }), + route("delete", "/settings/logo", "系统设置", "恢复默认 Logo", "清除自定义 Logo,门户恢复默认标识。"), + route("get", "/settings/network-allowlist", "系统设置", "查询网络白名单", "读取 Agent 内网访问白名单和活字格服务器地址。"), + route("put", "/settings/network-allowlist", "系统设置", "保存网络白名单", "更新 Agent 可访问的内网地址白名单;配置通常在容器重启后对 Agent 生效。", { body: networkAllowlistBody }), + route("get", "/settings/runtime-health", "系统设置", "查询运行时健康状态", "读取 MQTT、S3 等运行时组件健康缓存;可选 refresh/force 触发主动检查。", { + query: [ + queryParam("refresh", "是否刷新健康缓存。", { type: "string", enum: ["true", "false"] }), + queryParam("force", "兼容的强制刷新参数。", { type: "string", enum: ["true", "false"] }), + queryParam("timeout_ms", "单项检查超时时间,范围 5000-30000 毫秒。", { type: "integer", minimum: 5000, maximum: 30000 }) + ] + }), + route("get", "/settings/access-tokens", "系统设置", "查询管理 Access Token", "管理员查询已创建的服务访问令牌摘要。"), + route("post", "/settings/access-tokens", "系统设置", "创建管理 Access Token", "创建随机服务 Bearer Token;完整 Token 仅在创建响应中返回。", { successStatus: 201 }), + route("delete", "/settings/access-tokens/{token}", "系统设置", "删除管理 Access Token", "删除指定 Token,删除后立即失效。"), + route("post", "/settings/catalog-sync", "系统设置", "同步目录和用量", "管理员先同步 catalog.snapshot,再同步 agent.usage.list;返回两个任务状态和摘要。"), + route("post", "/settings/server-status", "系统设置", "查询服务状态", "调用 gateway.status 获取运行时服务状态;默认记录审计,可用 audit=false 关闭本次审计。", { + query: [ + queryParam("timeout_ms", "超时时间,默认 120000 毫秒。", { type: "integer", minimum: 1 }), + queryParam("audit", "是否记录审计,只有 false 时关闭。", { type: "string", enum: ["true", "false"] }) + ], + body: jsonBody("可选超时配置。", { timeout_ms: integerField("超时时间,单位毫秒。", { minimum: 1 }) }) + }), + route("post", "/settings/server-restart", "系统设置", "重启服务", "异步通知运行时重启 Gateway;重启期间管理端可能短暂不可用。"), + route("post", "/settings/container-restart", "系统设置", "请求重启容器", "创建容器重启请求文件;整个管理容器会短暂不可用。"), + route("post", "/settings/command", "系统设置", "执行已注册管理指令", "管理员执行任意已注册 management action;params 必须是 JSON 对象,超时时间上限为 180000 毫秒。", { + query: [queryParam("timeout_ms", "指令超时时间;服务端最大 180000 毫秒。", { type: "integer", minimum: 1, maximum: 180000 })], + body: jsonBody("管理指令请求。action 不能为空,params 必须是 JSON 对象。", { + action: textField("已注册管理指令名称,例如 service.ping。", { minLength: 1 }), + params: { type: "object", description: "指令参数对象;不能是数组。", additionalProperties: true }, + timeout_ms: integerField("指令超时时间,最大 180000 毫秒。", { minimum: 1, maximum: 180000 }) + }, ["action"]) + }), + route("post", "/settings/server-diagnostics", "系统设置", "运行服务器诊断", "管理员运行 diagnostics.run,返回服务器诊断摘要;耗时上限为 180000 毫秒。") +]; + +const PARAMETER_DESCRIPTIONS = { + id: "资源数字 ID;必须是正整数,且必须对应现存资源。", + slug: "Agent slug;仅允许小写字母、数字和中划线,且不能以中划线开头或结尾。", + templateName: "模板名称;必须是现存模板名称,路径参数会按 URL 编码解析。", + providerId: "模型 Provider ID;必须是现存 Provider。", + modelId: "模型 ID;必须属于对应 Provider。", + skillSlug: "Skill slug;必须是现存 Skill 的安全 slug。", + traceId: "调用日志 trace ID;必须对应已记录的调用。", + direction: "附件方向;只允许 request 或 response。", + token: "完整 Access Token;建议使用 URL 编码,删除后立即失效。" +}; + +function pathParameterNames(path) { + return [...path.matchAll(/\{([^}]+)\}/g)].map((match) => match[1]); +} + +function buildOperation(item) { + const pathParameters = pathParameterNames(item.path).map((name) => pathParam(name, PARAMETER_DESCRIPTIONS[name] || `路径参数 ${name};不能为空。`)); + const operation = { + tags: [item.tag], + summary: item.summary, + description: [ + `应用场景:${item.description}`, + item.authDescription || (item.public ? "鉴权:无需登录。" : "鉴权:需要管理端 Bearer Token;权限不足时返回 403。"), + "错误:常见错误统一返回 {code, message, details};具体状态码以服务端响应为准。" + ].join("\n\n"), + operationId: `${item.method}_${item.path.replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_|_$/g, "")}`, + parameters: [...pathParameters, ...(item.query || [])], + responses: { + 200: item.successSchema + ? response("请求成功。", item.successSchema) + : response("请求成功。"), + 400: response("请求参数不合法。", { $ref: "#/components/schemas/Error" }), + 401: response("缺少或无效的认证信息。", { $ref: "#/components/schemas/Error" }), + 403: response("当前身份没有执行该操作的权限。", { $ref: "#/components/schemas/Error" }), + 404: response("目标资源不存在。", { $ref: "#/components/schemas/Error" }), + 409: response("资源版本或唯一性冲突。", { $ref: "#/components/schemas/Error" }), + 500: response("服务端或下游服务错误。", { $ref: "#/components/schemas/Error" }) + }, + ...(item.public ? {} : { security: [{ bearerAuth: [] }] }), + ...(item.body ? { requestBody: item.body } : {}) + }; + + if (item.success) { + operation.responses[200] = item.success; + } + if (item.successStatus === 201) { + operation.responses[201] = response("资源创建成功。"); + delete operation.responses[200]; + } + if (item.successStatus === 204) { + operation.responses[204] = response("请求成功,无响应体。", undefined, null); + delete operation.responses[200]; + } + if (item.errors) { + for (const [status, description] of Object.entries(item.errors)) { + operation.responses[status] = response(description, { $ref: "#/components/schemas/Error" }); + } + } + return operation; +} + +function buildPaths() { + return ROUTES.filter((item) => item.path.startsWith("/external/")).reduce((paths, item) => { + const openApiPath = item.path.replace(/:([a-zA-Z0-9_]+)/g, "{$1}"); + paths[openApiPath] = paths[openApiPath] || {}; + paths[openApiPath][item.method] = buildOperation({ ...item, path: openApiPath }); + return paths; + }, {}); +} + +export const OPENAPI_DOCUMENT = { + openapi: "3.0.3", + info: { + title: "Daedalus Agent Management API", + version: "1.0.0", + description: "数字员工管理控制台 `/api/external/*` 对外接口文档。文档中的操作场景、参数限制和权限说明以当前服务端实现为准。" + }, + servers: [ + { + url: "/api", + description: "当前管理控制台" + } + ], + externalDocs: { + description: "OpenAPI 规范", + url: "https://swagger.io/specification/" + }, + tags: [ + { name: "外部服务", description: "供 CUI 和外部调用服务使用的 Agent 会话、目录、Cookie 和调用日志接口。" } + ], + paths: buildPaths(), + components: { + securitySchemes: { + bearerAuth: { + type: "http", + scheme: "bearer", + bearerFormat: "Token", + description: "管理端登录 Token、Access Token 或服务 Bearer Token,具体取决于接口所属边界。本机受信请求按服务端策略可免 Token。" + } + }, + schemas: { + JsonObject: objectSchema({}, "接口返回的 JSON 对象;具体字段由操作决定。"), + Error: objectSchema({ + code: textField("机器可读错误码。"), + message: textField("中文错误消息。"), + details: { type: "object", description: "可选结构化错误详情。", additionalProperties: true } + }, "统一错误响应。"), + ExternalAgentDirectoryItem: objectSchema({ + agent_id: textField("Agent slug。"), + agent_name: textField("Agent 展示名称。"), + status: textField("当前状态:normal、disabled 或 overlimit。", { enum: ["normal", "disabled", "overlimit"] }), + inbound_topic: textField("Agent 入站 Topic。"), + outbound_topic: textField("Agent 出站 Topic。"), + users: { type: "array", items: { type: "string" }, description: "当前目录项关联的用户列表。" }, + is_public: booleanField("是否为“所有人”可用的 Agent。") + }, "外部 Agent 目录项。"), + Pagination: objectSchema({ + page: integerField("当前页码。", { minimum: 1 }), + pageSize: integerField("每页条数。", { minimum: 1 }), + total: integerField("总条数。", { minimum: 0 }), + items: { type: "array", description: "当前页数据。", items: { type: "object", additionalProperties: true } } + }, "分页响应。") + } + } +}; + +export function getOpenApiDocument() { + return OPENAPI_DOCUMENT; +} diff --git a/packages/daedalus-agent-management-web/server/src/infra/local/local-artifact-store.js b/packages/daedalus-agent-management-web/server/src/infra/local/local-artifact-store.js new file mode 100644 index 0000000000000000000000000000000000000000..57b5bb0ae363a03fd5c8a8fdb326befa688e3d45 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/src/infra/local/local-artifact-store.js @@ -0,0 +1,124 @@ +import { randomUUID } from "node:crypto"; +import { lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { chinaDateKey } from "../../utils/china-time.js"; + +function sanitizeFileName(fileName) { + return String(fileName || "artifact.zip").replace(/[^A-Za-z0-9._-]+/g, "-"); +} + +function normalizeRelativePath(value) { + const raw = String(value || "").trim(); + if (!raw || raw.includes("\\") || path.posix.isAbsolute(raw)) { + throw new Error("Invalid local artifact path"); + } + const segments = raw.split("/"); + if (segments.some((segment) => !segment || segment === "." || segment === "..")) { + throw new Error("Invalid local artifact path"); + } + return segments.join("/"); +} + +export class LocalArtifactStore { + constructor(rootDir) { + this.rootDir = path.resolve(rootDir); + } + + resolve(relativePath) { + const normalized = normalizeRelativePath(relativePath); + const target = path.resolve(this.rootDir, ...normalized.split("/")); + const relative = path.relative(this.rootDir, target); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Local artifact path escapes the configured root"); + } + return { relativePath: normalized, target }; + } + + async ensurePhysicalParent(relativePath) { + const { relativePath: normalized, target } = this.resolve(relativePath); + await mkdir(this.rootDir, { recursive: true }); + const rootInfo = await lstat(this.rootDir); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new Error("Local artifact root is not a physical directory"); + } + let current = this.rootDir; + for (const segment of normalized.split("/").slice(0, -1)) { + current = path.join(current, segment); + const existing = await lstat(current).catch((error) => { + if (error?.code === "ENOENT") { + return null; + } + throw error; + }); + if (!existing) { + await mkdir(current); + } + const info = existing || await lstat(current); + if (info.isSymbolicLink() || !info.isDirectory()) { + throw new Error("Local artifact path must contain only physical directories and files"); + } + } + return { relativePath: normalized, target }; + } + + async assertPhysicalPath(relativePath, { includeLeaf = true, allowMissingLeaf = false } = {}) { + const { relativePath: normalized, target } = this.resolve(relativePath); + const rootInfo = await lstat(this.rootDir); + if (rootInfo.isSymbolicLink() || !rootInfo.isDirectory()) { + throw new Error("Local artifact root is not a physical directory"); + } + const segments = normalized.split("/"); + const inspectedSegments = includeLeaf ? segments : segments.slice(0, -1); + let current = this.rootDir; + for (let index = 0; index < inspectedSegments.length; index += 1) { + current = path.join(current, inspectedSegments[index]); + const info = await lstat(current).catch((error) => { + if (allowMissingLeaf && includeLeaf && index === inspectedSegments.length - 1 && error?.code === "ENOENT") { + return null; + } + throw error; + }); + if (!info) { + return { relativePath: normalized, target, exists: false }; + } + const isLeaf = includeLeaf && index === inspectedSegments.length - 1; + if (info.isSymbolicLink() || (isLeaf ? !info.isFile() : !info.isDirectory())) { + throw new Error("Local artifact path must contain only physical directories and files"); + } + } + return { relativePath: normalized, target, exists: true }; + } + + async uploadAdminArtifact({ kind, file }) { + const safeKind = normalizeRelativePath(kind); + const objectKey = `${safeKind}/${chinaDateKey()}/${randomUUID()}-${sanitizeFileName(file?.originalname)}`; + const { target } = await this.ensurePhysicalParent(objectKey); + const staging = `${target}.${process.pid}.${randomUUID()}.tmp`; + try { + await writeFile(staging, file.buffer, { mode: 0o660 }); + await rename(staging, target); + } finally { + await rm(staging, { force: true }).catch(() => undefined); + } + return { bucket: "local", objectKey }; + } + + async deleteObject(bucket, objectKey) { + if (bucket !== "local" || !objectKey) { + return; + } + const { target, exists } = await this.assertPhysicalPath(objectKey, { allowMissingLeaf: true }); + if (!exists) { + return; + } + await rm(target, { force: true }); + } + + async downloadObject(bucket, objectKey) { + if (bucket !== "local") { + throw new Error("Local artifact store only accepts the local bucket"); + } + const { target } = await this.assertPhysicalPath(objectKey); + return await readFile(target); + } +} diff --git a/packages/daedalus-agent-management-web/server/src/infra/s3/object-storage.js b/packages/daedalus-agent-management-web/server/src/infra/s3/object-storage.js deleted file mode 100644 index c0dee32d4399ff3f3584cae829c3866c068aff0c..0000000000000000000000000000000000000000 --- a/packages/daedalus-agent-management-web/server/src/infra/s3/object-storage.js +++ /dev/null @@ -1,108 +0,0 @@ -import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { randomUUID } from "node:crypto"; -import { Readable } from "node:stream"; -import { chinaDateKey } from "../../utils/china-time.js"; - -import { serviceUnavailable } from "../../utils/errors.js"; - -function sanitizeFileName(fileName) { - return fileName.replace(/[^A-Za-z0-9._-]+/g, "-"); -} - -async function bodyToBuffer(body) { - if (!body) { - throw serviceUnavailable("S3 object body is empty"); - } - - if (typeof body === "object" && typeof body.transformToByteArray === "function") { - return Buffer.from(await body.transformToByteArray()); - } - - if (body instanceof Readable) { - const chunks = []; - for await (const chunk of body) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - } - return Buffer.concat(chunks); - } - - if (body instanceof Uint8Array) { - return Buffer.from(body); - } - - throw serviceUnavailable("Unsupported S3 object body type"); -} - -export class ObjectStorage { - constructor(env) { - this.env = env; - this.client = null; - } - - isConfigured() { - return Boolean( - this.env.s3.endpoint && - this.env.s3.accessKeyId && - this.env.s3.secretAccessKey && - this.env.s3.adminInboxBucket - ); - } - - getClient() { - if (!this.isConfigured()) { - throw serviceUnavailable("S3 artifact storage is not configured"); - } - - if (!this.client) { - this.client = new S3Client({ - endpoint: this.env.s3.endpoint, - region: this.env.s3.region, - forcePathStyle: this.env.s3.forcePathStyle, - credentials: { - accessKeyId: this.env.s3.accessKeyId, - secretAccessKey: this.env.s3.secretAccessKey - } - }); - } - - return this.client; - } - - async uploadAdminArtifact({ kind, file }) { - const objectKey = `${kind}/${chinaDateKey()}/${randomUUID()}-${sanitizeFileName(file.originalname)}`; - await this.getClient().send( - new PutObjectCommand({ - Bucket: this.env.s3.adminInboxBucket, - Key: objectKey, - Body: file.buffer, - ContentType: file.mimetype || "application/octet-stream" - }) - ); - - return { - bucket: this.env.s3.adminInboxBucket, - objectKey - }; - } - - async downloadObject(bucket, objectKey) { - const response = await this.getClient().send( - new GetObjectCommand({ - Bucket: bucket, - Key: objectKey - }) - ); - - return await bodyToBuffer(response.Body); - } - - async deleteObject(bucket, objectKey) { - if (!bucket || !objectKey) { - return; - } - await this.getClient().send(new DeleteObjectCommand({ - Bucket: bucket, - Key: objectKey - })); - } -} diff --git a/packages/daedalus-agent-management-web/server/src/services/agent-service.js b/packages/daedalus-agent-management-web/server/src/services/agent-service.js index 6abbf2543efd4797fed0ff9581c1b525bf862416..5532def68af6ee019b5dcca860ed5c8095bbc204 100644 --- a/packages/daedalus-agent-management-web/server/src/services/agent-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/agent-service.js @@ -1,5 +1,4 @@ -import { randomUUID } from "node:crypto"; -import { toChinaISOString, chinaDateKey } from "../utils/china-time.js"; +import { toChinaISOString } from "../utils/china-time.js"; import { jsonParse, jsonStringify } from "../db/index.js"; import { badRequest, conflict, notFound, serviceUnavailable } from "../utils/errors.js"; @@ -9,6 +8,7 @@ import { normalizeAgentStatus, normalizeUsage } from "./agent-quota.js"; +import { normalizeSkillArchive } from "./portal-service.js"; const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; @@ -33,6 +33,30 @@ function resolvePermissionUsernames(payload) { return [...new Set(normalizeStringList(payload?.permission_usernames ?? payload?.users))]; } +function normalizePermissionMode(value, fallback = "selected") { + const normalized = String(value || "").trim(); + return normalized === "all" || normalized === "selected" ? normalized : fallback; +} + +function hasPermissionPayload(payload) { + return hasOwnValue(payload, "permission_usernames") + || hasOwnValue(payload, "users") + || hasOwnValue(payload, "permission_mode") + || hasOwnValue(payload, "user_mode"); +} + +function normalizeBusinessSystemIds(value) { + if (!Array.isArray(value)) { + return []; + } + + const ids = value.map((item) => Number(item)); + if (ids.some((item) => !Number.isSafeInteger(item) || item <= 0)) { + throw badRequest("business_system_ids must contain positive integers"); + } + return [...new Set(ids)].sort((left, right) => left - right); +} + function hasOwnValue(payload, key) { return Boolean(payload) && Object.prototype.hasOwnProperty.call(payload, key); } @@ -59,10 +83,6 @@ function sanitizeFileName(fileName) { return String(fileName || "agent").replace(/[^A-Za-z0-9._-]+/g, "-"); } -function buildWorkspaceExportObjectKey(agentSlug) { - return `agent-workspace/${chinaDateKey()}/${randomUUID()}-${sanitizeFileName(agentSlug)}.zip`; -} - function buildModelRef(providerId, modelId) { return providerId && modelId ? `${providerId}/${modelId}` : ""; } @@ -176,9 +196,9 @@ function effectiveAgentStatus(row) { return computeHealth(row, usage); } -function agentDirectoryItem(row, users) { +function agentDirectoryItem(row, users, isPublic) { const remote = normalizeRemoteState(jsonParse(row.remote_state_json, {})); - return { + const item = { agent_id: row.slug, agent_name: firstText(row.agent_name, row.slug), status: effectiveAgentStatus(row), @@ -186,6 +206,10 @@ function agentDirectoryItem(row, users) { outbound_topic: remote.outboundTopic || "", users }; + if (typeof isPublic !== "undefined") { + item.is_public = Boolean(isPublic); + } + return item; } function mergeRemoteStatus(currentStatus, remoteStatus) { @@ -231,11 +255,12 @@ function runInTransaction(database, fn) { } export class AgentService { - constructor({ db, managementClient, objectStorage, env }) { + constructor({ db, managementClient, localArtifactStore, env, mcpService }) { this.db = db; this.managementClient = managementClient; - this.objectStorage = objectStorage; + this.localArtifactStore = localArtifactStore; this.env = env; + this.mcpService = mcpService; } localRows() { @@ -380,6 +405,300 @@ export class AgentService { `).all(agentId); } + getUserPolicy(agentId) { + let policy; + try { + policy = this.db.prepare(` + SELECT mode, policy_version + FROM agent_user_policies + WHERE agent_id = ? + `).get(agentId); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + } + return policy + ? { + mode: normalizePermissionMode(policy.mode), + policy_version: Number(policy.policy_version) + } + : { + // 兼容旧测试数据库和未完成迁移的旧记录,按历史显式绑定语义处理。 + mode: "selected", + policy_version: 1 + }; + } + + getAgentUserPolicy(agentId) { + const agent = this.getAgentRowById(agentId); + if (!agent) { + throw notFound("数字员工不存在"); + } + + const policy = this.getUserPolicy(agentId); + const permissions = this.getPermissions(agentId); + return { + ...policy, + permission_usernames: permissions.map((item) => item.username), + permissions + }; + } + + ensureAgentUserPolicy(agentId, now = toChinaISOString()) { + try { + this.db.prepare(` + INSERT INTO agent_user_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES (?, 'selected', 1, ?, ?) + ON CONFLICT(agent_id) DO NOTHING + `).run(agentId, now, now); + } catch (error) { + // 允许旧的内存测试数据库继续覆盖仅有 agent_permissions 的行为。 + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + } + } + + setAgentUserPolicy(agentId, payload) { + const agent = this.getAgentRowById(agentId); + if (!agent) { + throw notFound("数字员工不存在"); + } + + const mode = String(payload?.mode || payload?.permission_mode || "").trim(); + if (mode !== "all" && mode !== "selected") { + throw badRequest("mode must be all or selected"); + } + + const policyVersion = Number(payload?.policy_version); + if (!Number.isSafeInteger(policyVersion) || policyVersion <= 0) { + throw badRequest("policy_version must be a positive integer"); + } + + const usernames = resolvePermissionUsernames(payload); + runInTransaction(this.db, () => { + let current; + try { + current = this.db.prepare(` + SELECT policy_version + FROM agent_user_policies + WHERE agent_id = ? + `).get(agentId); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + } + + if (current && Number(current.policy_version) !== policyVersion) { + throw conflict("Agent user policy was updated by another request"); + } + + if (current) { + const now = toChinaISOString(); + this.db.prepare(` + UPDATE agent_user_policies + SET mode = ?, policy_version = policy_version + 1, updated_at = ? + WHERE agent_id = ? AND policy_version = ? + `).run(mode, now, agentId, policyVersion); + } else { + try { + const now = toChinaISOString(); + this.db.prepare(` + INSERT INTO agent_user_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES (?, ?, 1, ?, ?) + ON CONFLICT(agent_id) DO NOTHING + `).run(agentId, mode, now, now); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + } + } + + // all 模式不保留旧的显式绑定;切回 selected 时只使用本次提交的列表。 + this.replacePermissions(agentId, mode === "selected" ? usernames : []); + }); + + return this.getAgentUserPolicy(agentId); + } + + applyAgentUserAssignment(agentId, payload) { + this.ensureAgentUserPolicy(agentId); + const mode = normalizePermissionMode( + payload?.permission_mode ?? payload?.user_mode, + "selected" + ); + this.replacePermissions(agentId, mode === "selected" ? resolvePermissionUsernames(payload) : []); + try { + this.db.prepare(` + UPDATE agent_user_policies + SET mode = ?, policy_version = policy_version + 1, updated_at = ? + WHERE agent_id = ? + `).run(mode, toChinaISOString(), agentId); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + } + } + + listAgentUsernamesForAgent(agentId) { + const policy = this.getUserPolicy(agentId); + if (policy.mode === "all") { + return this.db.prepare(` + SELECT username + FROM agent_users + ORDER BY username + `).all().map((item) => item.username); + } + return this.getPermissions(agentId).map((item) => item.username); + } + + ensureBusinessSystemPolicy(agentId, now = toChinaISOString()) { + this.db.prepare(` + INSERT INTO agent_business_system_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(agent_id) DO NOTHING + `).run(agentId, "all", 1, now, now); + } + + applyTemplateBusinessSystemPolicy(agentId, templateId, now = toChinaISOString()) { + let templatePolicy = null; + try { + templatePolicy = this.db.prepare(` + SELECT mode + FROM template_business_system_policies + WHERE template_id = ? + `).get(templateId); + } catch { + this.ensureBusinessSystemPolicy(agentId, now); + return; + } + + const mode = templatePolicy?.mode === "selected" ? "selected" : "all"; + this.db.prepare(` + INSERT INTO agent_business_system_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?) + ON CONFLICT(agent_id) DO UPDATE SET + mode = excluded.mode, + policy_version = agent_business_system_policies.policy_version + 1, + updated_at = excluded.updated_at + `).run(agentId, mode, 1, now, now); + this.db.prepare("DELETE FROM agent_business_system_access WHERE agent_id = ?").run(agentId); + if (mode === "selected") { + this.db.prepare(` + INSERT INTO agent_business_system_access (agent_id, business_system_id) + SELECT ?, business_system_id + FROM template_business_system_access + WHERE template_id = ? + `).run(agentId, templateId); + } + } + + getBusinessSystemPolicy(agentId) { + const agent = this.getAgentRowById(agentId); + if (!agent) { + throw notFound("Agent does not exist"); + } + + const policy = this.db.prepare(` + SELECT mode, policy_version + FROM agent_business_system_policies + WHERE agent_id = ? + `).get(agentId); + if (!policy) { + throw serviceUnavailable("Agent business system policy is missing"); + } + + const businessSystems = policy.mode === "selected" + ? this.db.prepare(` + SELECT bs.id, bs.provider, bs.application_name, bs.description, bs.status + FROM agent_business_system_access access + JOIN business_systems bs ON bs.id = access.business_system_id + WHERE access.agent_id = ? + ORDER BY bs.id + `).all(agentId) + : []; + + return { + mode: policy.mode, + policy_version: Number(policy.policy_version), + business_system_ids: businessSystems.map((item) => Number(item.id)), + business_systems: businessSystems + }; + } + + setBusinessSystemPolicy(agentId, payload) { + const agent = this.getAgentRowById(agentId); + if (!agent) { + throw notFound("Agent does not exist"); + } + + const mode = String(payload?.mode || "").trim(); + if (mode !== "all" && mode !== "selected") { + throw badRequest("mode must be all or selected"); + } + + const policyVersion = Number(payload?.policy_version); + if (!Number.isSafeInteger(policyVersion) || policyVersion <= 0) { + throw badRequest("policy_version must be a positive integer"); + } + + const businessSystemIds = mode === "selected" + ? normalizeBusinessSystemIds(payload?.business_system_ids) + : []; + if (businessSystemIds.length > 0) { + const placeholders = businessSystemIds.map(() => "?").join(", "); + const rows = this.db.prepare(` + SELECT id + FROM business_systems + WHERE id IN (${placeholders}) + `).all(...businessSystemIds); + if (rows.length !== businessSystemIds.length) { + throw badRequest("One or more business systems do not exist"); + } + } + + runInTransaction(this.db, () => { + const current = this.db.prepare(` + SELECT policy_version + FROM agent_business_system_policies + WHERE agent_id = ? + `).get(agentId); + if (!current) { + throw serviceUnavailable("Agent business system policy is missing"); + } + if (Number(current.policy_version) !== policyVersion) { + throw conflict("Agent business system policy was updated by another request"); + } + + const now = toChinaISOString(); + this.db.prepare(` + UPDATE agent_business_system_policies + SET mode = ?, policy_version = policy_version + 1, updated_at = ? + WHERE agent_id = ? AND policy_version = ? + `).run(mode, now, agentId, policyVersion); + this.db.prepare("DELETE FROM agent_business_system_access WHERE agent_id = ?").run(agentId); + if (mode === "selected") { + const insert = this.db.prepare(` + INSERT INTO agent_business_system_access (agent_id, business_system_id) + VALUES (?, ?) + `); + for (const businessSystemId of businessSystemIds) { + insert.run(agentId, businessSystemId); + } + } + }); + + return this.getBusinessSystemPolicy(agentId); + } + async listAgents() { const rows = this.localRows(); return rows.map((row) => { @@ -391,6 +710,7 @@ export class AgentService { tags: jsonParse(row.tags_json), usage, permissions, + user_policy: this.getUserPolicy(row.id), llm_model: row.llm_model_ref ? this.getLlmModelByRef(row.llm_model_ref) || null : null, fallback_llm_model: row.fallback_llm_model_ref ? this.getLlmModelByRef(row.fallback_llm_model_ref) || null : null, remote_state: remote, @@ -422,31 +742,141 @@ export class AgentService { async exportAgentWorkspaceArchive(slug) { const agent = await this.getAgentBySlug(slug); - const bucket = firstText(this.env?.s3?.adminOutboxBucket); - if (!bucket) { - throw serviceUnavailable("S3 admin outbox bucket is not configured"); + const exportResult = await this.managementClient.call("agent.workspace.export", { + agentId: agent.slug + }, this.env?.managementTimeoutMs); + const artifactPath = firstText(exportResult?.artifactPath); + if (!artifactPath) { + throw serviceUnavailable("Agent workspace export did not return a local artifact path"); } + try { + return { + buffer: await this.localArtifactStore.downloadObject("local", artifactPath), + fileName: firstText(exportResult?.fileName, `${sanitizeFileName(agent.slug)}-workspace.zip`), + contentType: "application/zip", + exportResult: { + ...exportResult, + artifactPath + } + }; + } finally { + await this.localArtifactStore.deleteObject("local", artifactPath).catch(() => undefined); + } + } - const objectKey = buildWorkspaceExportObjectKey(agent.slug); - const exportResult = await this.managementClient.call("agent.workspace.export", { + async listAgentMarkdown(slug) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.workspace.markdown.list", { + agentId: agent.slug + }, this.env?.managementTimeoutMs); + } + + async readAgentMarkdown(slug, filePath) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.workspace.markdown.read", { agentId: agent.slug, - bucket, - objectKey + path: filePath }, this.env?.managementTimeoutMs); - const exportBucket = firstText(exportResult?.bucket, bucket); - const exportObjectKey = firstText(exportResult?.objectKey, objectKey); - const buffer = await this.objectStorage.downloadObject(exportBucket, exportObjectKey); + } - return { - buffer, - fileName: firstText(exportResult?.fileName, `${sanitizeFileName(agent.slug)}-workspace.zip`), - contentType: "application/zip", - exportResult: { - ...exportResult, - bucket: exportBucket, - objectKey: exportObjectKey - } - }; + async writeAgentMarkdown(slug, payload) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.workspace.markdown.write", { + agentId: agent.slug, + path: payload?.path, + content: payload?.content, + etag: payload?.etag, + create: payload?.create === true + }, this.env?.managementTimeoutMs); + } + + async renameAgentMarkdown(slug, payload) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.workspace.markdown.rename", { + agentId: agent.slug, + sourcePath: payload?.source_path, + destinationPath: payload?.destination_path, + etag: payload?.etag + }, this.env?.managementTimeoutMs); + } + + async deleteAgentMarkdown(slug, payload) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.workspace.markdown.delete", { + agentId: agent.slug, + path: payload?.path, + etag: payload?.etag + }, this.env?.managementTimeoutMs); + } + + async restoreDefaultAgentDocs(slug, payload) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.docs.restore-default", { + agentId: agent.slug, + etag: payload?.etag + }, this.env?.managementTimeoutMs); + } + + async listAgentSkills(slug) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.skills.list", { + agentId: agent.slug + }, this.env?.managementTimeoutMs); + } + + async installAgentSkill(slug, { file, replace = false, etag, expectedSlug } = {}) { + const agent = await this.getAgentBySlug(slug); + const normalizedArchive = normalizeSkillArchive(file); + const normalizedExpectedSlug = String(expectedSlug || "").trim(); + if (normalizedExpectedSlug && normalizedArchive.metadata.slug !== normalizedExpectedSlug) { + throw badRequest(`替换包中的 Skill slug 必须为 ${normalizedExpectedSlug}`); + } + const upload = await this.localArtifactStore.uploadAdminArtifact({ + kind: "agent-skill", + file: normalizedArchive.file + }); + try { + return await this.managementClient.call("agent.skills.install.local", { + agentId: agent.slug, + slug: normalizedArchive.metadata.slug, + artifactPath: upload.objectKey, + force: replace, + etag + }, this.env?.managementTimeoutMs); + } finally { + await this.localArtifactStore.deleteObject?.(upload.bucket, upload.objectKey).catch(() => undefined); + } + } + + async deleteAgentSkill(slug, skillSlug, etag) { + const agent = await this.getAgentBySlug(slug); + return await this.managementClient.call("agent.skills.delete", { + agentId: agent.slug, + slug: skillSlug, + etag + }, this.env?.managementTimeoutMs); + } + + async exportAgentSkillArchive(slug, skillSlug, etag) { + const agent = await this.getAgentBySlug(slug); + const result = await this.managementClient.call("agent.skills.export", { + agentId: agent.slug, + slug: skillSlug, + etag + }, this.env?.managementTimeoutMs); + const artifactPath = firstText(result?.artifactPath); + if (!artifactPath) { + throw serviceUnavailable("Agent skill export did not return a local artifact path"); + } + try { + return { + buffer: await this.localArtifactStore.downloadObject("local", artifactPath), + fileName: firstText(result?.fileName, `${sanitizeFileName(skillSlug)}.zip`), + contentType: "application/zip" + }; + } finally { + await this.localArtifactStore.deleteObject("local", artifactPath).catch(() => undefined); + } } async listActiveAgentDirectory() { @@ -458,21 +888,58 @@ export class AgentService { return rows .filter((row) => effectiveAgentStatus(row) === "normal") - .map((row) => agentDirectoryItem(row, this.getPermissions(row.id).map((item) => item.username))); + .map((row) => agentDirectoryItem(row, this.listAgentUsernamesForAgent(row.id))); + } + + async listPublicAgentDirectory() { + let rows; + try { + rows = this.db.prepare(` + SELECT ag.*, 1 AS is_public + FROM agents ag + JOIN agent_user_policies policy ON policy.agent_id = ag.id + WHERE policy.mode = 'all' + ORDER BY ag.slug + `).all(); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + return []; + } + + return rows.map((row) => agentDirectoryItem(row, this.listAgentUsernamesForAgent(row.id), true)); } async listAgentDirectoryForUser(username) { const normalizedUsername = String(username || "").trim(); - const rows = this.db.prepare(` - SELECT ag.* - FROM agents ag - JOIN agent_permissions ap ON ap.agent_id = ag.id - JOIN agent_users d ON d.id = ap.agent_user_id - WHERE d.username = ? - ORDER BY ag.slug - `).all(normalizedUsername); + let rows; + try { + rows = this.db.prepare(` + SELECT ag.*, CASE WHEN COALESCE(policy.mode, 'selected') = 'all' THEN 1 ELSE 0 END AS is_public + FROM agents ag + JOIN agent_users d + LEFT JOIN agent_user_policies policy ON policy.agent_id = ag.id + LEFT JOIN agent_permissions ap ON ap.agent_id = ag.id AND ap.agent_user_id = d.id + WHERE d.username = ? + AND (COALESCE(policy.mode, 'selected') = 'all' OR ap.agent_id IS NOT NULL) + ORDER BY ag.slug + `).all(normalizedUsername); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + rows = this.db.prepare(` + SELECT ag.*, 0 AS is_public + FROM agents ag + JOIN agent_permissions ap ON ap.agent_id = ag.id + JOIN agent_users d ON d.id = ap.agent_user_id + WHERE d.username = ? + ORDER BY ag.slug + `).all(normalizedUsername); + } - return rows.map((row) => agentDirectoryItem(row, [normalizedUsername])); + return rows.map((row) => agentDirectoryItem(row, [normalizedUsername], row.is_public)); } buildLocalRemoteState(slug, agentName, templateName) { @@ -543,22 +1010,52 @@ export class AgentService { } getAgentUserUsageCount(agentUserId) { - const row = this.db.prepare(` - SELECT COUNT(*) AS count - FROM agent_permissions - WHERE agent_user_id = ? - `).get(agentUserId); + let row; + try { + row = this.db.prepare(` + SELECT COUNT(*) AS count + FROM agents ag + LEFT JOIN agent_user_policies policy ON policy.agent_id = ag.id + LEFT JOIN agent_permissions ap ON ap.agent_id = ag.id AND ap.agent_user_id = ? + WHERE policy.mode = 'all' OR ap.agent_id IS NOT NULL + `).get(agentUserId); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + row = this.db.prepare(` + SELECT COUNT(*) AS count + FROM agent_permissions + WHERE agent_user_id = ? + `).get(agentUserId); + } return Number(row?.count || 0); } getAgentUserAssignedAgents(agentUserId) { - return this.db.prepare(` - SELECT ag.id, ag.slug, ag.agent_name - FROM agent_permissions ap - JOIN agents ag ON ag.id = ap.agent_id - WHERE ap.agent_user_id = ? - ORDER BY ag.slug - `).all(agentUserId).map((item) => ({ + let rows; + try { + rows = this.db.prepare(` + SELECT DISTINCT ag.id, ag.slug, ag.agent_name + FROM agents ag + LEFT JOIN agent_user_policies policy ON policy.agent_id = ag.id + LEFT JOIN agent_permissions ap ON ap.agent_id = ag.id AND ap.agent_user_id = ? + WHERE policy.mode = 'all' OR ap.agent_id IS NOT NULL + ORDER BY ag.slug + `).all(agentUserId); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + rows = this.db.prepare(` + SELECT ag.id, ag.slug, ag.agent_name + FROM agent_permissions ap + JOIN agents ag ON ag.id = ap.agent_id + WHERE ap.agent_user_id = ? + ORDER BY ag.slug + `).all(agentUserId); + } + return rows.map((item) => ({ id: item.id, slug: item.slug, agent_name: item.agent_name @@ -698,16 +1195,29 @@ export class AgentService { payload.llm_model_id, payload.model_id )); - const llmPayload = !hasExplicitLlmSelection && templateRow.default_llm_model_ref - ? { - ...payload, + const hasExplicitFallbackSelection = Boolean(firstText( + payload.fallback_llm_model_ref, + payload.fallback_model_ref, + payload.fallback_llm_provider_id, + payload.fallback_provider_id, + payload.fallback_llm_model_id, + payload.fallback_model_id + )); + const llmPayload = { + ...payload, + ...(!hasExplicitLlmSelection && templateRow.default_llm_model_ref ? { llm_provider_id: templateRow.default_llm_provider_id, llm_model_id: templateRow.default_llm_model_id, llm_model_ref: templateRow.default_llm_model_ref - } - : payload; + } : {}), + ...(hasExplicitFallbackSelection || !templateRow.fallback_llm_model_ref ? {} : { + fallback_llm_provider_id: templateRow.fallback_llm_provider_id, + fallback_llm_model_id: templateRow.fallback_llm_model_id, + fallback_llm_model_ref: templateRow.fallback_llm_model_ref + }) + }; const llmSelection = this.resolveLlmSelection(llmPayload, null, { required: true }); - const fallbackLlmSelection = this.resolveFallbackLlmSelection(payload); + const fallbackLlmSelection = this.resolveFallbackLlmSelection(llmPayload); this.validateFallbackSelection(llmSelection, fallbackLlmSelection); const modelConfig = buildAgentModelConfig(llmSelection, fallbackLlmSelection); @@ -717,7 +1227,7 @@ export class AgentService { agentName, templateName, model: modelConfig, - restart: payload.restart !== false + restart: this.mcpService ? false : payload.restart !== false }); if (payload.docs_content) { @@ -789,8 +1299,12 @@ export class AgentService { existingAgent.id ); - if (hasOwnValue(payload, "permission_usernames") || hasOwnValue(payload, "users")) { - this.replacePermissions(existingAgent.id, resolvePermissionUsernames(payload)); + this.ensureBusinessSystemPolicy(existingAgent.id, now); + this.mcpService?.ensureAgentPolicy(existingAgent.id, now); + this.ensureAgentUserPolicy(existingAgent.id, now); + + if (hasPermissionPayload(payload)) { + this.applyAgentUserAssignment(existingAgent.id, payload); } return existingAgent.id; @@ -826,8 +1340,11 @@ export class AgentService { ); const insertedAgentId = result.lastInsertRowid; - if (hasOwnValue(payload, "permission_usernames") || hasOwnValue(payload, "users")) { - this.replacePermissions(insertedAgentId, resolvePermissionUsernames(payload)); + this.applyTemplateBusinessSystemPolicy(insertedAgentId, templateRow.id, now); + this.mcpService?.applyTemplatePolicy(insertedAgentId, templateRow.id, now); + this.ensureAgentUserPolicy(insertedAgentId, now); + if (hasPermissionPayload(payload)) { + this.applyAgentUserAssignment(insertedAgentId, payload); } return insertedAgentId; @@ -842,6 +1359,20 @@ export class AgentService { throw error; } + if (this.mcpService) { + try { + await this.mcpService.syncRuntime({ restart: payload.restart !== false }); + } catch (error) { + try { + this.db.prepare("DELETE FROM agents WHERE id = ?").run(agentId); + } catch {} + await this.managementClient.call("agent.delete", { + agentId: slug, + restart: payload.restart !== false + }).catch(() => undefined); + throw error; + } + } return (await this.listAgents()).find((item) => item.id === agentId); } @@ -930,8 +1461,8 @@ export class AgentService { agentId ); - if (hasOwnValue(payload, "permission_usernames") || hasOwnValue(payload, "users")) { - this.replacePermissions(agentId, resolvePermissionUsernames(payload)); + if (hasPermissionPayload(payload)) { + this.applyAgentUserAssignment(agentId, payload); } }); } catch (error) { diff --git a/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js b/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js index 76df81e628662a31c4ab92be7e990f51c7bc4819..66a105ab0d63146466062f1b351b3c5cff852d4a 100644 --- a/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/catalog-sync-service.js @@ -615,6 +615,16 @@ export class CatalogSyncService { WHERE id = ? `); const remove = this.db.prepare("DELETE FROM agents WHERE id = ?"); + const insertBusinessSystemPolicy = this.db.prepare(` + INSERT INTO agent_business_system_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES (?, 'all', 1, ?, ?) + `); + const insertMcpPolicy = this.db.prepare(` + INSERT INTO agent_mcp_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES (?, 'all', 1, ?, ?) + `); for (const item of items) { const slug = String(item?.id || "").trim(); @@ -659,7 +669,7 @@ export class CatalogSyncService { continue; } - insert.run( + const result = insert.run( slug, agentName, "", @@ -679,6 +689,8 @@ export class CatalogSyncService { now, now ); + insertBusinessSystemPolicy.run(result.lastInsertRowid, now, now); + insertMcpPolicy.run(result.lastInsertRowid, now, now); } for (const row of existingRows) { @@ -792,6 +804,11 @@ export class CatalogSyncService { WHERE id = ? `); const remove = this.db.prepare("DELETE FROM agent_templates WHERE id = ?"); + const insertMcpPolicy = this.db.prepare(` + INSERT INTO template_mcp_policies ( + template_id, mode, created_at, updated_at + ) VALUES (?, 'all', ?, ?) + `); for (const item of items) { const templateName = String(item?.id || "").trim(); @@ -820,7 +837,7 @@ export class CatalogSyncService { continue; } - insert.run( + const result = insert.run( templateName, artifactId, "ready", @@ -829,6 +846,7 @@ export class CatalogSyncService { now, now ); + insertMcpPolicy.run(result.lastInsertRowid, now, now); } for (const row of existingRows) { diff --git a/packages/daedalus-agent-management-web/server/src/services/external-service.js b/packages/daedalus-agent-management-web/server/src/services/external-service.js index f5aa38b51c3051126e58a026e98abd0631d8f6eb..9315374cee225c916c19ec55c7b78b250d1b843e 100644 --- a/packages/daedalus-agent-management-web/server/src/services/external-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/external-service.js @@ -1,7 +1,7 @@ import { randomInt, randomUUID } from "node:crypto"; import { toChinaISOString } from "../utils/china-time.js"; -import { badRequest, internalError, notFound } from "../utils/errors.js"; +import { badRequest, forbidden, internalError, notFound } from "../utils/errors.js"; import { normalizeUsage, statusAfterUsageRefresh } from "./agent-quota.js"; const MAX_COOKIE_LENGTH = 16 * 1024; @@ -99,6 +99,46 @@ export class ExternalService { this.auditLogService = auditLogService; } + hasAgentPermission(agentId, { agentUserId, username } = {}) { + try { + if (username) { + return Boolean(this.db.prepare(` + SELECT 1 + FROM agent_users d + LEFT JOIN agent_permissions ap ON ap.agent_id = ? AND ap.agent_user_id = d.id + LEFT JOIN agent_user_policies policy ON policy.agent_id = ? + WHERE d.username = ? + AND (COALESCE(policy.mode, 'selected') = 'all' OR ap.agent_id IS NOT NULL) + `).get(agentId, agentId, username)); + } + return Boolean(this.db.prepare(` + SELECT 1 + FROM agents ag + LEFT JOIN agent_user_policies policy ON policy.agent_id = ag.id + LEFT JOIN agent_permissions ap ON ap.agent_id = ag.id AND ap.agent_user_id = ? + WHERE ag.id = ? + AND (COALESCE(policy.mode, 'selected') = 'all' OR ap.agent_id IS NOT NULL) + `).get(agentUserId, agentId)); + } catch (error) { + if (!String(error?.message || "").toLowerCase().includes("no such table")) { + throw error; + } + if (username) { + return Boolean(this.db.prepare(` + SELECT 1 + FROM agent_permissions ap + JOIN agent_users d ON d.id = ap.agent_user_id + WHERE ap.agent_id = ? AND d.username = ? + `).get(agentId, username)); + } + return Boolean(this.db.prepare(` + SELECT 1 + FROM agent_permissions + WHERE agent_id = ? AND agent_user_id = ? + `).get(agentId, agentUserId)); + } + } + upsertSessionCookie(sessionId, cookie) { const now = toChinaISOString(); const normalizedCookie = normalizeCookie(cookie); @@ -113,6 +153,10 @@ export class ExternalService { } listAgentsForUser(username) { + if (username === undefined || username === null) { + return this.agentService.listPublicAgentDirectory(); + } + const normalizedUsername = String(username || "").trim(); if (!normalizedUsername) { throw badRequest("userName 不能为空"); @@ -145,13 +189,7 @@ export class ExternalService { }); } - const hasPermission = this.db.prepare(` - SELECT 1 - FROM agent_permissions ap - JOIN agent_users d ON d.id = ap.agent_user_id - WHERE ap.agent_id = ? AND d.username = ? - `).get(agent.id, normalizedUserName); - if (!hasPermission) { + if (!this.hasAgentPermission(agent.id, { username: normalizedUserName })) { throw notFound(`用户 ${normalizedUserName} 无权访问数字员工 ${normalizedAgentId}`); } @@ -207,45 +245,112 @@ export class ExternalService { }; } - async getCookie(sessionId, applicationName = "") { + resolveAuthorizedSystem(sessionId, applicationName = "") { const normalizedSessionId = String(sessionId || "").trim(); - const normalizedProvider = FIXED_BUSINESS_SYSTEM_PROVIDER; const normalizedApplicationName = String(applicationName || "").trim().toLowerCase(); if (!normalizedSessionId) { - throw badRequest("sessionId 不能为空"); - } - - const existing = this.db.prepare(` - SELECT cookie - FROM external_session_cookies - WHERE session_id = ? AND provider = ? - `).get(normalizedSessionId, normalizedProvider); - if (existing?.cookie) { - return { - cookie: existing.cookie - }; + throw badRequest("sessionId is required"); } const session = this.db.prepare(` SELECT es.session_id, - ag.slug AS agent_slug + es.agent_id, + es.agent_user_id, + d.username AS user_name, + ag.slug AS agent_slug, + ag.status, + ag.daily_limit, + ag.usage_snapshot_json FROM external_sessions es + JOIN agent_users d ON d.id = es.agent_user_id JOIN agents ag ON ag.id = es.agent_id WHERE es.session_id = ? `).get(normalizedSessionId); if (!session) { - throw notFound(`会话不存在:${normalizedSessionId} 。请确保格式正确。`); + throw forbidden("Session is invalid"); + } + + if (!this.hasAgentPermission(session.agent_id, { agentUserId: session.agent_user_id })) { + throw forbidden("The user no longer has access to this agent"); + } + + const agentStatus = effectiveAgentStatus(session); + if (agentStatus !== "normal") { + throw forbidden(`Agent is not available: ${agentStatus}`); + } + + const policy = this.db.prepare(` + SELECT mode + FROM agent_business_system_policies + WHERE agent_id = ? + `).get(session.agent_id); + if (!policy) { + throw forbidden("Agent business system policy is missing"); + } + + let resolvedApplicationName = normalizedApplicationName; + if (!resolvedApplicationName && policy.mode === "selected") { + resolvedApplicationName = String(this.db.prepare(` + SELECT bs.application_name + FROM agent_business_system_access access + JOIN business_systems bs ON bs.id = access.business_system_id + WHERE access.agent_id = ? + AND bs.provider = ? + AND bs.status = 'active' + ORDER BY bs.updated_at DESC, bs.id DESC + LIMIT 1 + `).get(session.agent_id, FIXED_BUSINESS_SYSTEM_PROVIDER)?.application_name || ""); } - const system = normalizedApplicationName - ? this.systemService.findSystemForInvocation(normalizedApplicationName) + const system = resolvedApplicationName + ? this.systemService.findSystemForInvocation(resolvedApplicationName) : this.systemService.findLatestActiveSystem(); if (!system) { - throw notFound("未找到可用业务系统"); + throw notFound(resolvedApplicationName + ? `Business system does not exist or is disabled: ${resolvedApplicationName}` + : "No active business system is available"); + } + + if (policy.mode === "selected") { + const allowed = this.db.prepare(` + SELECT 1 + FROM agent_business_system_access access + JOIN business_systems bs ON bs.id = access.business_system_id + WHERE access.agent_id = ? AND bs.application_name = ? + `).get(session.agent_id, system.application_name); + if (!allowed) { + throw forbidden(`Agent ${session.agent_slug} cannot access business system ${system.application_name}`); + } + } else if (policy.mode !== "all") { + throw forbidden("Agent business system policy is invalid"); + } + + return system; + } + + async getCookie(sessionId, applicationName = "") { + const normalizedSessionId = String(sessionId || "").trim(); + const normalizedProvider = FIXED_BUSINESS_SYSTEM_PROVIDER; + const normalizedApplicationName = String(applicationName || "").trim().toLowerCase(); + if (!normalizedSessionId) { + throw badRequest("sessionId 不能为空"); } - const cookie = await this.hzgProviderClient.createSessionCookie(system.base_url, normalizedSessionId); + const authorizedSystem = this.resolveAuthorizedSystem(normalizedSessionId, normalizedApplicationName); + + const existing = this.db.prepare(` + SELECT cookie + FROM external_session_cookies + WHERE session_id = ? AND provider = ? + `).get(normalizedSessionId, normalizedProvider); + if (existing?.cookie) { + return { + cookie: existing.cookie + }; + } + + const cookie = await this.hzgProviderClient.createSessionCookie(authorizedSystem.base_url, normalizedSessionId); this.upsertSessionCookie(normalizedSessionId, cookie); return { @@ -307,11 +412,6 @@ export class ExternalService { throw notFound(`会话不存在:${sessionId}`); } - const system = this.systemService.findSystemForInvocation(applicationName); - if (!system) { - throw notFound(`未找到业务系统:${applicationName}`); - } - await this.systemService.recordInvocation({ trace_id: randomUUID(), agent_slug: session.agent_slug, diff --git a/packages/daedalus-agent-management-web/server/src/services/mcp-service.js b/packages/daedalus-agent-management-web/server/src/services/mcp-service.js new file mode 100644 index 0000000000000000000000000000000000000000..eca7d7f0fbfe37db8c29243ab86dbb15f77440d0 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/src/services/mcp-service.js @@ -0,0 +1,387 @@ +import path from "node:path"; + +import { toChinaISOString } from "../utils/china-time.js"; +import { badRequest, conflict, notFound, serviceUnavailable } from "../utils/errors.js"; + +const SLUG_PATTERN = /^[a-z][a-z0-9-]{0,29}$/; + +function parseJson(value, fallback) { + try { + return JSON.parse(value || ""); + } catch { + return fallback; + } +} + +function normalizeStringList(value, field) { + if (value === undefined || value === null || value === "") return []; + if (!Array.isArray(value)) throw badRequest(`${field} 必须是数组`); + return [...new Set(value.map((item) => String(item || "").trim()).filter(Boolean))]; +} + +function normalizeStringMap(value, field) { + if (value === undefined || value === null || value === "") return {}; + if (typeof value !== "object" || Array.isArray(value)) throw badRequest(`${field} 必须是键值对象`); + const result = {}; + for (const [rawKey, rawValue] of Object.entries(value)) { + const key = String(rawKey || "").trim(); + const normalizedValue = String(rawValue || "").trim(); + if (!/^[A-Za-z_][A-Za-z0-9_-]*$/.test(key)) throw badRequest(`${field} 包含无效键名:${key || "空键"}`); + if (!normalizedValue) throw badRequest(`${field}.${key} 不能为空`); + result[key] = normalizedValue; + } + return result; +} + +function normalizePositiveInteger(value, field) { + if (value === undefined || value === null || value === "") return null; + const normalized = Number(value); + if (!Number.isSafeInteger(normalized) || normalized <= 0) throw badRequest(`${field} 必须是正整数`); + return normalized; +} + +function normalizeIds(value, field) { + if (!Array.isArray(value)) return []; + const ids = value.map(Number); + if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) throw badRequest(`${field} 只能包含正整数`); + return [...new Set(ids)].sort((left, right) => left - right); +} + +function extractToolNames(value) { + const names = new Set(); + const visited = new Set(); + + const visit = (item) => { + if (!item || typeof item !== "object" || visited.has(item)) return; + visited.add(item); + for (const [key, nested] of Object.entries(item)) { + if (key === "tools" && Array.isArray(nested)) { + for (const tool of nested) { + const name = typeof tool === "string" ? tool.trim() : String(tool?.name || "").trim(); + if (name) names.add(name); + } + } + if (nested && typeof nested === "object") visit(nested); + } + }; + + visit(value); + return [...names].sort((left, right) => left.localeCompare(right)); +} + +function runTransaction(db, callback) { + db.exec("BEGIN"); + try { + const result = callback(); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +function isPathWithinRoot(targetPath, rootPath) { + const relative = path.relative(rootPath, targetPath); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +} + +export class McpService { + constructor({ db, managementClient, env = {} }) { + this.db = db; + this.managementClient = managementClient; + this.allowedCommands = new Set((env.mcpCliAllowedCommands || []).map((item) => String(item).trim()).filter(Boolean)); + this.allowedCwdRoots = (env.mcpCliAllowedCwdRoots || []).map((item) => path.resolve(String(item))); + } + + normalizeServer(payload, current = null) { + const slug = String(payload?.slug ?? current?.slug ?? "").trim(); + if (!SLUG_PATTERN.test(slug)) throw badRequest("slug 必须以小写字母开头,只能包含小写字母、数字和中划线,且最长 30 个字符"); + if (current && slug !== current.slug) throw badRequest("MCP 服务 slug 创建后不可修改"); + const name = String(payload?.name ?? current?.name ?? "").trim(); + if (!name) throw badRequest("MCP 服务名称不能为空"); + const mode = String(payload?.mode ?? current?.mode ?? "http").trim(); + if (mode !== "http" && mode !== "cli") throw badRequest("mode 必须是 http 或 cli"); + const status = String(payload?.status ?? current?.status ?? "active").trim(); + if (status !== "active" && status !== "disabled") throw badRequest("status 必须是 active 或 disabled"); + const authType = mode === "http" ? String(payload?.auth_type ?? current?.auth_type ?? "none").trim() : "none"; + if (!["none", "oauth", "headers"].includes(authType)) throw badRequest("HTTP 认证方式不受支持"); + + let transport; + let url = ""; + let command = ""; + let args = []; + let cwd = ""; + let env = {}; + let headers = {}; + if (mode === "http") { + transport = String(payload?.transport ?? current?.transport ?? "streamable-http").trim(); + if (!["streamable-http", "sse"].includes(transport)) throw badRequest("HTTP transport 必须是 streamable-http 或 sse"); + url = String(payload?.url ?? current?.url ?? "").trim(); + let parsedUrl; + try { parsedUrl = new URL(url); } catch { throw badRequest("MCP HTTP 地址无效"); } + if (!["http:", "https:"].includes(parsedUrl.protocol)) throw badRequest("MCP HTTP 地址只支持 http 或 https"); + headers = authType === "headers" ? normalizeStringMap(payload?.headers ?? parseJson(current?.headers_json, {}), "headers") : {}; + } else { + transport = "stdio"; + command = String(payload?.command ?? current?.command ?? "").trim(); + if (!command) throw badRequest("CLI 可执行文件不能为空"); + if (!this.allowedCommands.has(command)) throw badRequest(`CLI 可执行文件不在白名单中:${command}`); + args = normalizeStringList(payload?.args ?? parseJson(current?.args_json, []), "args"); + cwd = String(payload?.cwd ?? current?.cwd ?? "").trim(); + if (cwd) { + if (!path.isAbsolute(cwd)) throw badRequest("CLI 工作目录必须是绝对路径"); + const resolvedCwd = path.resolve(cwd); + if (!this.allowedCwdRoots.some((root) => isPathWithinRoot(resolvedCwd, root))) { + throw badRequest("CLI 工作目录不在允许范围内"); + } + cwd = resolvedCwd; + } + env = normalizeStringMap(payload?.env ?? parseJson(current?.env_json, {}), "env"); + } + + return { + slug, + name, + description: String(payload?.description ?? current?.description ?? "").trim(), + mode, + transport, + url, + command, + args, + cwd, + env, + headers, + auth_type: authType, + oauth_scope: authType === "oauth" ? String(payload?.oauth_scope ?? current?.oauth_scope ?? "").trim() : "", + tool_include: normalizeStringList(payload?.tool_include ?? parseJson(current?.tool_include_json, []), "tool_include"), + tool_exclude: normalizeStringList(payload?.tool_exclude ?? parseJson(current?.tool_exclude_json, []), "tool_exclude"), + connection_timeout_ms: normalizePositiveInteger(payload?.connection_timeout_ms ?? current?.connection_timeout_ms, "connection_timeout_ms"), + request_timeout_ms: normalizePositiveInteger(payload?.request_timeout_ms ?? current?.request_timeout_ms, "request_timeout_ms"), + supports_parallel_tool_calls: Boolean(payload?.supports_parallel_tool_calls ?? current?.supports_parallel_tool_calls), + status + }; + } + + mapServer(row) { + return { + ...row, + id: Number(row.id), + args: parseJson(row.args_json, []), + env: parseJson(row.env_json, {}), + headers: parseJson(row.headers_json, {}), + tool_include: parseJson(row.tool_include_json, []), + tool_exclude: parseJson(row.tool_exclude_json, []), + supports_parallel_tool_calls: Boolean(row.supports_parallel_tool_calls), + last_probe_result: parseJson(row.last_probe_result_json, {}) + }; + } + + listServers() { + return this.db.prepare("SELECT * FROM mcp_servers ORDER BY slug").all().map((row) => this.mapServer(row)); + } + + getServer(id) { + const row = this.db.prepare("SELECT * FROM mcp_servers WHERE id = ?").get(Number(id)); + if (!row) throw notFound("MCP 服务不存在"); + return this.mapServer(row); + } + + writeServer(server, id = null) { + const now = toChinaISOString(); + if (id === null) { + return this.db.prepare(` + INSERT INTO mcp_servers ( + slug, name, description, mode, transport, url, command, args_json, cwd, + env_json, headers_json, auth_type, oauth_scope, tool_include_json, + tool_exclude_json, connection_timeout_ms, request_timeout_ms, + supports_parallel_tool_calls, status, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + server.slug, server.name, server.description, server.mode, server.transport, server.url, + server.command, JSON.stringify(server.args), server.cwd, JSON.stringify(server.env), + JSON.stringify(server.headers), server.auth_type, server.oauth_scope, + JSON.stringify(server.tool_include), JSON.stringify(server.tool_exclude), + server.connection_timeout_ms, server.request_timeout_ms, + server.supports_parallel_tool_calls ? 1 : 0, server.status, now, now + ).lastInsertRowid; + } + this.db.prepare(` + UPDATE mcp_servers SET name = ?, description = ?, mode = ?, transport = ?, url = ?, + command = ?, args_json = ?, cwd = ?, env_json = ?, headers_json = ?, auth_type = ?, + oauth_scope = ?, tool_include_json = ?, tool_exclude_json = ?, connection_timeout_ms = ?, + request_timeout_ms = ?, supports_parallel_tool_calls = ?, status = ?, updated_at = ? + WHERE id = ? + `).run( + server.name, server.description, server.mode, server.transport, server.url, server.command, + JSON.stringify(server.args), server.cwd, JSON.stringify(server.env), JSON.stringify(server.headers), + server.auth_type, server.oauth_scope, JSON.stringify(server.tool_include), JSON.stringify(server.tool_exclude), + server.connection_timeout_ms, server.request_timeout_ms, server.supports_parallel_tool_calls ? 1 : 0, + server.status, now, id + ); + return id; + } + + async createServer(payload) { + const server = this.normalizeServer(payload); + if (this.db.prepare("SELECT 1 FROM mcp_servers WHERE slug = ?").get(server.slug)) throw conflict("MCP 服务 slug 已存在"); + const id = this.writeServer(server); + try { await this.syncRuntime(); } catch (error) { this.db.prepare("DELETE FROM mcp_servers WHERE id = ?").run(id); throw error; } + return this.getServer(id); + } + + async updateServer(id, payload) { + const current = this.getServer(id); + const server = this.normalizeServer(payload, current); + this.writeServer(server, current.id); + try { await this.syncRuntime(); } catch (error) { this.writeServer(current, current.id); throw error; } + return this.getServer(current.id); + } + + async deleteServer(id) { + const current = this.getServer(id); + await this.syncRuntime({ excludedServerId: current.id }); + try { + this.db.prepare("DELETE FROM mcp_servers WHERE id = ?").run(current.id); + } catch (error) { + await this.syncRuntime().catch(() => undefined); + throw error; + } + return { ok: true, id: current.id, slug: current.slug }; + } + + async setStatus(id, status) { return await this.updateServer(id, { status }); } + + async probeServer(id) { + const server = this.getServer(id); + const config = this.buildProbeServer(server); + const result = await this.managementClient.call("mcp.probe", { slug: server.slug, config }); + return { ok: true, result, tools: extractToolNames(result), server }; + } + + async testServer(payload) { + const server = this.normalizeServer(payload); + const config = this.buildProbeServer(server); + const result = await this.managementClient.call("mcp.probe", { slug: server.slug, config }); + return { ok: true, result, tools: extractToolNames(result) }; + } + + buildProbeServer(server) { + return this.buildRuntimeServer({ + ...server, + status: "active", + tool_include: [], + tool_exclude: [] + }); + } + + getAgentPolicy(agentId) { + const agent = this.db.prepare("SELECT id FROM agents WHERE id = ?").get(Number(agentId)); + if (!agent) throw notFound("数字员工不存在"); + const policy = this.db.prepare("SELECT mode, policy_version FROM agent_mcp_policies WHERE agent_id = ?").get(agent.id); + if (!policy) throw serviceUnavailable("数字员工 MCP 权限策略不存在"); + const servers = policy.mode === "selected" ? this.db.prepare(` + SELECT server.* FROM agent_mcp_access access + JOIN mcp_servers server ON server.id = access.mcp_server_id + WHERE access.agent_id = ? ORDER BY server.slug + `).all(agent.id).map((row) => this.mapServer(row)) : []; + return { mode: policy.mode, policy_version: Number(policy.policy_version), mcp_server_ids: servers.map((server) => server.id), mcp_servers: servers }; + } + + assertServerIds(ids) { + if (ids.length === 0) return; + const placeholders = ids.map(() => "?").join(", "); + const found = this.db.prepare(`SELECT id FROM mcp_servers WHERE id IN (${placeholders})`).all(...ids); + if (found.length !== ids.length) throw badRequest("一个或多个 MCP 服务不存在"); + } + + replaceAgentPolicy(agentId, mode, nextVersion, ids) { + runTransaction(this.db, () => { + this.db.prepare("UPDATE agent_mcp_policies SET mode = ?, policy_version = ?, updated_at = ? WHERE agent_id = ?") + .run(mode, nextVersion, toChinaISOString(), agentId); + this.db.prepare("DELETE FROM agent_mcp_access WHERE agent_id = ?").run(agentId); + const insert = this.db.prepare("INSERT INTO agent_mcp_access (agent_id, mcp_server_id) VALUES (?, ?)"); + for (const id of ids) insert.run(agentId, id); + }); + } + + async setAgentPolicy(agentId, payload) { + const current = this.getAgentPolicy(agentId); + const mode = String(payload?.mode || "").trim(); + if (!["all", "selected"].includes(mode)) throw badRequest("mode 必须是 all 或 selected"); + const version = Number(payload?.policy_version); + if (!Number.isSafeInteger(version) || version <= 0) throw badRequest("policy_version 必须是正整数"); + if (current.policy_version !== version) throw conflict("数字员工 MCP 权限已被其他请求更新"); + const ids = mode === "selected" ? normalizeIds(payload?.mcp_server_ids, "mcp_server_ids") : []; + this.assertServerIds(ids); + this.replaceAgentPolicy(Number(agentId), mode, version + 1, ids); + try { await this.syncRuntime(); } catch (error) { this.replaceAgentPolicy(Number(agentId), current.mode, current.policy_version, current.mcp_server_ids); throw error; } + return this.getAgentPolicy(agentId); + } + + ensureAgentPolicy(agentId, now = toChinaISOString()) { + this.db.prepare(`INSERT INTO agent_mcp_policies (agent_id, mode, policy_version, created_at, updated_at) + VALUES (?, 'all', 1, ?, ?) ON CONFLICT(agent_id) DO NOTHING`).run(agentId, now, now); + } + + applyTemplatePolicy(agentId, templateId, now = toChinaISOString()) { + const policy = this.db.prepare("SELECT mode FROM template_mcp_policies WHERE template_id = ?").get(templateId); + const mode = policy?.mode === "selected" ? "selected" : "all"; + this.db.prepare(`INSERT INTO agent_mcp_policies (agent_id, mode, policy_version, created_at, updated_at) + VALUES (?, ?, 1, ?, ?) ON CONFLICT(agent_id) DO UPDATE SET mode = excluded.mode, + policy_version = agent_mcp_policies.policy_version + 1, updated_at = excluded.updated_at`).run(agentId, mode, now, now); + this.db.prepare("DELETE FROM agent_mcp_access WHERE agent_id = ?").run(agentId); + if (mode === "selected") this.db.prepare(`INSERT INTO agent_mcp_access (agent_id, mcp_server_id) + SELECT ?, mcp_server_id FROM template_mcp_access WHERE template_id = ?`).run(agentId, templateId); + } + + buildRuntimeServer(server) { + const config = { enabled: server.status === "active" }; + if (server.mode === "http") { + Object.assign(config, { url: server.url, transport: server.transport }); + if (server.auth_type === "oauth") { + config.auth = "oauth"; + if (server.oauth_scope) config.oauth = { scope: server.oauth_scope }; + } else if (server.auth_type === "headers" && Object.keys(server.headers).length > 0) config.headers = server.headers; + } else { + config.command = server.command; + if (server.args.length > 0) config.args = server.args; + if (server.cwd) config.cwd = server.cwd; + if (Object.keys(server.env).length > 0) config.env = server.env; + } + if (server.connection_timeout_ms) config.connectionTimeoutMs = server.connection_timeout_ms; + if (server.request_timeout_ms) config.requestTimeoutMs = server.request_timeout_ms; + if (server.supports_parallel_tool_calls) config.supportsParallelToolCalls = true; + if (server.tool_include.length > 0 || server.tool_exclude.length > 0) { + config.toolFilter = {}; + if (server.tool_include.length > 0) config.toolFilter.include = server.tool_include; + if (server.tool_exclude.length > 0) config.toolFilter.exclude = server.tool_exclude; + } + return config; + } + + async syncRuntime({ restart = true, excludedServerId = null } = {}) { + const servers = this.listServers().filter((server) => server.id !== Number(excludedServerId)); + const agents = this.db.prepare(`SELECT agent.id, agent.slug, policy.mode, + COALESCE(projection.deny_patterns_json, '[]') AS deny_patterns_json FROM agents agent + JOIN agent_mcp_policies policy ON policy.agent_id = agent.id + LEFT JOIN mcp_runtime_projections projection ON projection.agent_id = agent.id ORDER BY agent.id`).all().map((agent) => ({ + databaseId: Number(agent.id), agentId: agent.slug, mode: agent.mode, + serverSlugs: agent.mode === "selected" ? this.db.prepare(`SELECT server.slug FROM agent_mcp_access access + JOIN mcp_servers server ON server.id = access.mcp_server_id + WHERE access.agent_id = ? AND server.id != ? ORDER BY server.slug`) + .all(agent.id, Number(excludedServerId) || -1).map((item) => item.slug) : [], + previousDenyPatterns: parseJson(agent.deny_patterns_json, []) + })); + const result = await this.managementClient.call("mcp.sync", { + servers: servers.map((server) => ({ slug: server.slug, config: this.buildRuntimeServer(server) })), + agents: agents.map(({ databaseId, ...agent }) => agent), restart + }); + const projectionByAgent = new Map((result?.projections || []).map((item) => [item.agentId, item.denyPatterns || []])); + const upsert = this.db.prepare(`INSERT INTO mcp_runtime_projections (agent_id, deny_patterns_json, updated_at) + VALUES (?, ?, ?) ON CONFLICT(agent_id) DO UPDATE SET deny_patterns_json = excluded.deny_patterns_json, updated_at = excluded.updated_at`); + const now = toChinaISOString(); + for (const agent of agents) upsert.run(agent.databaseId, JSON.stringify(projectionByAgent.get(agent.agentId) || []), now); + return result; + } +} diff --git a/packages/daedalus-agent-management-web/server/src/services/portal-service.js b/packages/daedalus-agent-management-web/server/src/services/portal-service.js index 77bfba41db91d403917b24698071e5d7cb28af0d..0d961b99fe99782fb2ae80f3268cd01698caf1d9 100644 --- a/packages/daedalus-agent-management-web/server/src/services/portal-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/portal-service.js @@ -13,6 +13,12 @@ import { hashPassword } from "../utils/security.js"; const SLUG_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const FRONTMATTER_PATTERN = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; const MAX_LOGO_BYTES = 1024 * 1024; +const MAX_SKILL_ARCHIVE_BYTES = 20 * 1024 * 1024; +const MAX_SKILL_FILE_BYTES = 10 * 1024 * 1024; +const MAX_SKILL_TOTAL_BYTES = 100 * 1024 * 1024; +const MAX_SKILL_ENTRIES = 2000; +const MAX_SKILL_PATH_BYTES = 512; +const MAX_SKILL_COMPRESSION_RATIO = 100; const LOGO_MIME_TYPES = new Map([ ["image/jpeg", "jpg"], ["image/png", "png"], @@ -249,10 +255,76 @@ function rewriteSkillManifestName(raw, slug) { return `${text.slice(0, match.index)}---\n${frontmatter}\n---\n${body}`; } -function normalizeSkillArchive(file) { +function inspectSkillArchiveEntries(zip) { + const entries = zip.getEntries(); + if (entries.length > MAX_SKILL_ENTRIES) { + throw badRequest(`技能 ZIP 文件和目录总数不能超过 ${MAX_SKILL_ENTRIES}`); + } + + const pathKeys = new Set(); + let totalBytes = 0; + for (const entry of entries) { + const rawName = String(entry.entryName || ""); + const relativePath = rawName.replace(/\/+$/, ""); + if ( + !relativePath + || rawName.includes("\\") + || rawName.startsWith("/") + || /^[A-Za-z]:/.test(rawName) + || Buffer.byteLength(relativePath, "utf8") > MAX_SKILL_PATH_BYTES + ) { + throw badRequest(`技能 ZIP 包含非法路径:${rawName || "(empty)"}`); + } + const segments = relativePath.split("/"); + if (segments.some((segment) => ( + !segment + || segment === "." + || segment === ".." + || /^[. ]+$/.test(segment) + || /[\u0000-\u001f\u007f<>:"|?*]/.test(segment) + || /[. ]$/.test(segment) + ))) { + throw badRequest(`技能 ZIP 包含非法路径:${rawName}`); + } + + const pathKey = relativePath.toLocaleLowerCase("en-US"); + if (pathKeys.has(pathKey)) { + throw badRequest(`技能 ZIP 包含重复或大小写冲突的路径:${relativePath}`); + } + pathKeys.add(pathKey); + + const unixMode = (Number(entry.attr || 0) >>> 16) & 0xf000; + if (unixMode === 0xa000) { + throw badRequest(`技能 ZIP 不允许包含符号链接:${relativePath}`); + } + + const size = Number(entry.header?.size || 0); + const compressedSize = Number(entry.header?.compressedSize || 0); + if (!entry.isDirectory && size > MAX_SKILL_FILE_BYTES) { + throw badRequest(`技能 ZIP 单文件不能超过 10 MiB:${relativePath}`); + } + if ( + !entry.isDirectory + && compressedSize > 0 + && size / compressedSize > MAX_SKILL_COMPRESSION_RATIO + ) { + throw badRequest(`技能 ZIP 条目压缩比过高:${relativePath}`); + } + totalBytes += size; + if (totalBytes > MAX_SKILL_TOTAL_BYTES) { + throw badRequest("技能 ZIP 展开后的总大小不能超过 100 MiB"); + } + } + return entries; +} + +export function normalizeSkillArchive(file) { if (!file) { throw badRequest("请上传技能 zip 文件"); } + if (!Buffer.isBuffer(file.buffer) || file.buffer.length > MAX_SKILL_ARCHIVE_BYTES) { + throw badRequest("技能 ZIP 不能超过 20 MiB"); + } let zip; try { @@ -262,8 +334,8 @@ function normalizeSkillArchive(file) { message: error instanceof Error ? error.message : String(error) }); } - const entries = zip.getEntries().filter((entry) => !entry.isDirectory); - const manifest = entries.find((entry) => entry.entryName.replace(/^\/+/, "") === "SKILL.md"); + const entries = inspectSkillArchiveEntries(zip).filter((entry) => !entry.isDirectory); + const manifest = entries.find((entry) => entry.entryName === "SKILL.md"); if (!manifest) { throw badRequest("Zip 包中必须包含顶层文件 SKILL.md"); } @@ -378,17 +450,38 @@ function normalizeLogoFile(file) { } export class PortalService { - constructor({ db, objectStorage, managementClient, authService }) { + constructor({ db, localArtifactStore, managementClient, authService }) { this.db = db; - this.objectStorage = objectStorage; + this.localArtifactStore = localArtifactStore; this.managementClient = managementClient; this.authService = authService; + this.ensureTemplateFallbackLlmColumns(); this.versionInfoEntries = []; this.versionInfoCache = buildVersionInfoCache(this.versionInfoEntries); this.localVersionCacheLoaded = false; this.kernelVersionCacheLoaded = false; } + ensureTemplateFallbackLlmColumns() { + try { + const columns = this.db.prepare("PRAGMA table_info(agent_templates)").all().map((item) => item.name); + if (columns.length === 0) { + return; + } + if (!columns.includes("fallback_llm_provider_id")) { + this.db.exec("ALTER TABLE agent_templates ADD COLUMN fallback_llm_provider_id TEXT;"); + } + if (!columns.includes("fallback_llm_model_id")) { + this.db.exec("ALTER TABLE agent_templates ADD COLUMN fallback_llm_model_id TEXT;"); + } + if (!columns.includes("fallback_llm_model_ref")) { + this.db.exec("ALTER TABLE agent_templates ADD COLUMN fallback_llm_model_ref TEXT;"); + } + } catch { + // 测试或只读数据库可能没有模板表,具体操作时再按原有错误处理。 + } + } + getSettings() { const row = this.db.prepare(` SELECT id, portal_name, brand_subtitle, theme_color, logo_mime_type, created_at, updated_at @@ -724,7 +817,10 @@ export class PortalService { } async persistArtifact({ kind, file, createdBy }) { - const upload = await this.objectStorage.uploadAdminArtifact({ kind, file }); + if (!this.localArtifactStore) { + throw new Error("Local artifact storage is not configured"); + } + const upload = await this.localArtifactStore.uploadAdminArtifact({ kind, file }); try { const result = this.db.prepare(` INSERT INTO artifacts ( @@ -745,7 +841,7 @@ export class PortalService { ...upload }; } catch (error) { - await this.objectStorage.deleteObject?.(upload.bucket, upload.objectKey).catch(() => undefined); + await this.localArtifactStore.deleteObject(upload.bucket, upload.objectKey).catch(() => undefined); throw error; } } @@ -782,21 +878,54 @@ export class PortalService { } } const objectKey = artifact.object_key || artifact.objectKey; - if (artifact.bucket && objectKey && this.objectStorage.deleteObject) { - await this.objectStorage.deleteObject(artifact.bucket, objectKey).catch(() => undefined); + if (artifact.bucket === "local" && objectKey && this.localArtifactStore) { + await this.localArtifactStore.deleteObject(artifact.bucket, objectKey).catch(() => undefined); } } listTemplates() { - return this.db.prepare(` - SELECT - t.*, - a.original_name AS artifact_name - FROM agent_templates t - JOIN artifacts a ON a.id = t.artifact_id - ORDER BY t.updated_at DESC - `).all().map((row) => ({ + let rows; + try { + rows = this.db.prepare(` + SELECT + t.*, + a.original_name AS artifact_name, + COALESCE(p.mode, 'all') AS business_system_mode, + COALESCE(mp.mode, 'all') AS mcp_mode, + CASE + WHEN COALESCE(p.mode, 'all') = 'selected' THEN ( + SELECT COUNT(*) + FROM template_business_system_access access + WHERE access.template_id = t.id + ) + ELSE NULL + END AS business_system_count, + CASE + WHEN COALESCE(mp.mode, 'all') = 'selected' THEN ( + SELECT COUNT(*) + FROM template_mcp_access access + WHERE access.template_id = t.id + ) + ELSE NULL + END AS mcp_count + FROM agent_templates t + JOIN artifacts a ON a.id = t.artifact_id + LEFT JOIN template_business_system_policies p ON p.template_id = t.id + LEFT JOIN template_mcp_policies mp ON mp.template_id = t.id + ORDER BY t.updated_at DESC + `).all(); + } catch { + rows = this.db.prepare(` + SELECT t.*, a.original_name AS artifact_name + FROM agent_templates t + JOIN artifacts a ON a.id = t.artifact_id + ORDER BY t.updated_at DESC + `).all(); + } + return rows.map((row) => ({ ...row, + business_system_mode: row.business_system_mode || "all", + mcp_mode: row.mcp_mode || "all", is_builtin: Boolean(row.is_builtin), remote_result: jsonParse(row.remote_result_json, {}), assigned_agents: this.listAgentsByTemplateName(row.template_name), @@ -866,13 +995,42 @@ export class PortalService { }; } - async createTemplate({ templateName, file, createdBy, defaultLlm = {} }) { + resolveTemplateFallbackLlm(payload = {}, primarySelection = { providerId: null, modelId: null }) { + const explicitModelRef = firstText(payload.fallback_llm_model_ref, payload.fallback_model_ref); + const explicitProviderId = firstText(payload.fallback_llm_provider_id, payload.fallback_provider_id); + const explicitModelId = firstText(payload.fallback_llm_model_id, payload.fallback_model_id); + const modelRef = explicitModelRef || buildModelRef(explicitProviderId, explicitModelId); + + if (!modelRef) { + if (explicitProviderId || explicitModelId) { + throw badRequest("备选大语言模型需要同时选择 Provider 和 Model"); + } + return { providerId: null, modelId: null, modelRef: null }; + } + + const model = this.getLlmModelByRef(modelRef); + if (!model) { + throw badRequest(`备选大语言模型不存在:${modelRef}`); + } + if (model.provider_id === primarySelection.providerId && model.model_id === primarySelection.modelId) { + throw badRequest("备选模型不能与主模型相同"); + } + + return { + providerId: model.provider_id, + modelId: model.model_id, + modelRef: model.model_ref + }; + } + + async createTemplate({ templateName, file, createdBy, defaultLlm = {}, retainPreviousArtifact = false }) { templateName = validateUbuntuDirName(templateName, "模板名称"); if (!file) { throw badRequest("模板名称和模板文件不能为空"); } const defaultSelection = this.resolveTemplateDefaultLlm(defaultLlm); + const fallbackSelection = this.resolveTemplateFallbackLlm(defaultLlm, defaultSelection); ensureZipContains(file.buffer, "AGENTS.md"); const existing = this.db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(templateName); const previousArtifact = this.getArtifactStorageInfo(existing?.artifact_id); @@ -881,44 +1039,63 @@ export class PortalService { try { const remoteResult = await this.managementClient.call("agent.template.create", { templateName, - bucket: artifact.bucket, - objectKey: artifact.objectKey, + artifactPath: artifact.objectKey, replace: true }); remoteApplied = true; const now = toChinaISOString(); - this.db.prepare(` - INSERT INTO agent_templates ( - template_name, artifact_id, remote_status, remote_result_json, - default_llm_provider_id, default_llm_model_id, default_llm_model_ref, - created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(template_name) DO UPDATE SET - artifact_id = excluded.artifact_id, - remote_status = excluded.remote_status, - remote_result_json = excluded.remote_result_json, - default_llm_provider_id = excluded.default_llm_provider_id, - default_llm_model_id = excluded.default_llm_model_id, - default_llm_model_ref = excluded.default_llm_model_ref, - updated_at = excluded.updated_at - `).run( - templateName, - artifact.id, - "ready", - JSON.stringify(remoteResult ?? {}), - defaultSelection.providerId, - defaultSelection.modelId, - defaultSelection.modelRef, - now, - now - ); + this.db.exec("BEGIN"); + try { + this.db.prepare(` + INSERT INTO agent_templates ( + template_name, artifact_id, remote_status, remote_result_json, + default_llm_provider_id, default_llm_model_id, default_llm_model_ref, + fallback_llm_provider_id, fallback_llm_model_id, fallback_llm_model_ref, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(template_name) DO UPDATE SET + artifact_id = excluded.artifact_id, + remote_status = excluded.remote_status, + remote_result_json = excluded.remote_result_json, + default_llm_provider_id = excluded.default_llm_provider_id, + default_llm_model_id = excluded.default_llm_model_id, + default_llm_model_ref = excluded.default_llm_model_ref, + fallback_llm_provider_id = excluded.fallback_llm_provider_id, + fallback_llm_model_id = excluded.fallback_llm_model_id, + fallback_llm_model_ref = excluded.fallback_llm_model_ref, + updated_at = excluded.updated_at + `).run( + templateName, + artifact.id, + "ready", + JSON.stringify(remoteResult ?? {}), + defaultSelection.providerId, + defaultSelection.modelId, + defaultSelection.modelRef, + fallbackSelection.providerId, + fallbackSelection.modelId, + fallbackSelection.modelRef, + now, + now + ); + const template = this.db.prepare("SELECT id FROM agent_templates WHERE template_name = ?").get(templateName); + this.db.prepare(` + INSERT INTO template_mcp_policies ( + template_id, mode, created_at, updated_at + ) VALUES (?, 'all', ?, ?) + ON CONFLICT(template_id) DO NOTHING + `).run(template.id, now, now); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + throw error; + } } catch (error) { if (remoteApplied) { if (previousArtifact) { await this.managementClient.call("agent.template.create", { templateName, - bucket: previousArtifact.bucket, - objectKey: previousArtifact.object_key, + artifactPath: previousArtifact.object_key, replace: true }).catch(() => undefined); } else { @@ -928,7 +1105,7 @@ export class PortalService { await this.discardArtifact(artifact); throw error; } - if (previousArtifact?.id !== artifact.id) { + if (!retainPreviousArtifact && previousArtifact?.id !== artifact.id) { await this.discardArtifact(previousArtifact); } return this.listTemplates().find((item) => item.template_name === templateName); @@ -942,17 +1119,24 @@ export class PortalService { } const defaultSelection = this.resolveTemplateDefaultLlm(payload); + const fallbackSelection = this.resolveTemplateFallbackLlm(payload, defaultSelection); this.db.prepare(` UPDATE agent_templates SET default_llm_provider_id = ?, default_llm_model_id = ?, default_llm_model_ref = ?, + fallback_llm_provider_id = ?, + fallback_llm_model_id = ?, + fallback_llm_model_ref = ?, updated_at = ? WHERE template_name = ? `).run( defaultSelection.providerId, defaultSelection.modelId, defaultSelection.modelRef, + fallbackSelection.providerId, + fallbackSelection.modelId, + fallbackSelection.modelRef, toChinaISOString(), normalizedTemplateName ); @@ -960,6 +1144,61 @@ export class PortalService { return this.listTemplates().find((item) => item.template_name === normalizedTemplateName); } + async renameTemplate(templateName, nextTemplateName) { + const currentName = validateUbuntuDirName(templateName, "模板名称"); + const normalizedNextName = validateUbuntuDirName(nextTemplateName, "模板名称"); + const existing = this.db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(currentName); + if (!existing) { + throw notFound("模板不存在"); + } + if (normalizedNextName === currentName) { + return this.listTemplates().find((item) => item.template_name === currentName); + } + if (this.db.prepare("SELECT 1 FROM agent_templates WHERE template_name = ?").get(normalizedNextName)) { + throw conflict("模板名称已存在"); + } + + const artifact = this.getArtifactStorageInfo(existing.artifact_id); + if (!artifact?.object_key) { + throw conflict("模板制品不可用,无法重命名"); + } + + const remoteResult = await this.managementClient.call("agent.template.create", { + templateName: normalizedNextName, + artifactPath: artifact.object_key, + replace: false + }); + try { + await this.managementClient.call("agent.template.delete", { templateName: currentName }); + } catch (error) { + await this.managementClient.call("agent.template.delete", { templateName: normalizedNextName }).catch(() => undefined); + throw error; + } + + try { + this.db.exec("BEGIN"); + this.db.prepare(` + UPDATE agent_templates + SET template_name = ?, remote_result_json = ?, updated_at = ? + WHERE id = ? + `).run(normalizedNextName, JSON.stringify(remoteResult ?? {}), toChinaISOString(), existing.id); + this.db.prepare("UPDATE agents SET template_name = ? WHERE template_name = ?") + .run(normalizedNextName, currentName); + this.db.exec("COMMIT"); + } catch (error) { + this.db.exec("ROLLBACK"); + await this.managementClient.call("agent.template.create", { + templateName: currentName, + artifactPath: artifact.object_key, + replace: true + }).catch(() => undefined); + await this.managementClient.call("agent.template.delete", { templateName: normalizedNextName }).catch(() => undefined); + throw error; + } + + return this.listTemplates().find((item) => item.template_name === normalizedNextName); + } + async deleteTemplate(templateName) { const existing = this.db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(templateName); if (!existing) { @@ -970,10 +1209,6 @@ export class PortalService { throw conflict("内置模板不允许删除"); } - if (this.countAgentsByTemplateName(templateName) > 0) { - throw conflict("该模板仍有数字员工使用,无法删除"); - } - const artifact = this.getArtifactStorageInfo(existing.artifact_id); await this.managementClient.call("agent.template.delete", { templateName }); try { @@ -982,8 +1217,7 @@ export class PortalService { if (artifact) { await this.managementClient.call("agent.template.create", { templateName, - bucket: artifact.bucket, - objectKey: artifact.object_key, + artifactPath: artifact.object_key, replace: true }).catch(() => undefined); } @@ -1031,8 +1265,7 @@ export class PortalService { try { await this.managementClient.call("skills.global.install.local", { slug, - bucket: artifact.bucket, - objectKey: artifact.objectKey, + artifactPath: artifact.objectKey, force: false }); remoteApplied = true; @@ -1086,8 +1319,7 @@ export class PortalService { try { await this.managementClient.call("skills.global.install.local", { slug, - bucket: artifact.bucket, - objectKey: artifact.objectKey, + artifactPath: artifact.objectKey, force: true }); remoteApplied = true; @@ -1107,8 +1339,7 @@ export class PortalService { if (remoteApplied && previousArtifact) { await this.managementClient.call("skills.global.install.local", { slug, - bucket: previousArtifact.bucket, - objectKey: previousArtifact.object_key, + artifactPath: previousArtifact.object_key, force: true }).catch(() => undefined); } @@ -1150,8 +1381,7 @@ export class PortalService { if (current.remote_status === "installed" && artifact) { await this.managementClient.call("skills.global.install.local", { slug: current.slug, - bucket: artifact.bucket, - objectKey: artifact.object_key, + artifactPath: artifact.object_key, force: true }).catch(() => undefined); } diff --git a/packages/daedalus-agent-management-web/server/src/services/runtime-component-health-service.js b/packages/daedalus-agent-management-web/server/src/services/runtime-component-health-service.js index c5d8167c04d14fb1ff86ca20f0cd2b115507bf15..c41be9fa6c23fe3c31fffc2c46fcf36014685ba0 100644 --- a/packages/daedalus-agent-management-web/server/src/services/runtime-component-health-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/runtime-component-health-service.js @@ -195,8 +195,6 @@ export class RuntimeComponentHealthService { const accessKeyId = firstText(s3Env.accessKeyId); const secretAccessKey = firstText(s3Env.secretAccessKey); const bucket = firstText( - s3Env.adminInboxBucket, - s3Env.adminOutboxBucket, s3Env.agentInboxBucket, s3Env.agentOutboxBucket ); diff --git a/packages/daedalus-agent-management-web/server/src/services/system-service.js b/packages/daedalus-agent-management-web/server/src/services/system-service.js index 66a46b1ee0924fb317524dd229ed2882c80e580f..3fae0d8a7fbed1112cf50227980f943b7b787892 100644 --- a/packages/daedalus-agent-management-web/server/src/services/system-service.js +++ b/packages/daedalus-agent-management-web/server/src/services/system-service.js @@ -194,9 +194,9 @@ function isPathInside(rootPath, targetPath) { } export class SystemService { - constructor({ db, objectStorage, managementClient, runtimeEnvConfigService, env = {} }) { + constructor({ db, localArtifactStore, managementClient, runtimeEnvConfigService, env = {} }) { this.db = db; - this.objectStorage = objectStorage; + this.localArtifactStore = localArtifactStore; this.managementClient = managementClient; this.runtimeEnvConfigService = runtimeEnvConfigService; this.productDataDir = path.resolve(env.productDataDir || process.env.DA_DATA_DIR || "data"); @@ -360,9 +360,8 @@ export class SystemService { ontologyName: system.application_name, description: system.description || "", status: system.status || "active", - bucket: artifact?.bucket, - objectKey, - replaceOntology: Boolean(artifact?.bucket && objectKey) + artifactPath: artifact?.bucket === "local" ? objectKey : undefined, + replaceOntology: Boolean(artifact?.bucket === "local" && objectKey) }; } @@ -387,8 +386,8 @@ export class SystemService { } } const objectKey = artifact.object_key || artifact.objectKey; - if (artifact.bucket && objectKey && this.objectStorage.deleteObject) { - await this.objectStorage.deleteObject(artifact.bucket, objectKey).catch(() => undefined); + if (artifact.bucket === "local" && objectKey && this.localArtifactStore) { + await this.localArtifactStore.deleteObject(artifact.bucket, objectKey).catch(() => undefined); } } @@ -480,7 +479,10 @@ export class SystemService { throw badRequest("Ontology 压缩包第一层必须包含 index.md"); } - const artifact = await this.objectStorage.uploadAdminArtifact({ kind: "ontology", file }); + if (!this.localArtifactStore) { + throw new Error("Local artifact storage is not configured"); + } + const artifact = await this.localArtifactStore.uploadAdminArtifact({ kind: "ontology", file }); try { const result = this.db.prepare(` INSERT INTO artifacts ( @@ -502,7 +504,7 @@ export class SystemService { ...artifact }; } catch (error) { - await this.objectStorage.deleteObject?.(artifact.bucket, artifact.objectKey).catch(() => undefined); + await this.localArtifactStore.deleteObject(artifact.bucket, artifact.objectKey).catch(() => undefined); throw error; } } diff --git a/packages/daedalus-agent-management-web/server/src/services/template-service.js b/packages/daedalus-agent-management-web/server/src/services/template-service.js new file mode 100644 index 0000000000000000000000000000000000000000..6d928569aa5df83a636611c0a2b5fdf42f4aa234 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/src/services/template-service.js @@ -0,0 +1,827 @@ +import { randomUUID } from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; + +import AdmZip from "adm-zip"; +import YAML from "yaml"; + +import { toChinaISOString } from "../utils/china-time.js"; +import { badRequest, conflict, notFound } from "../utils/errors.js"; + +const MAX_FILE_BYTES = 10 * 1024 * 1024; +const MAX_TEMPLATE_BYTES = 100 * 1024 * 1024; +const MAX_TEMPLATE_ENTRIES = 2000; +const MAX_PATH_BYTES = 512; +const MAX_COMPRESSION_RATIO = 100; +const TEXT_PREVIEW_BYTES = 2 * 1024 * 1024; +const PROTECTED_FILE_NAME = "AGENTS.md"; +const VALID_CREATE_MODES = new Set(["blank", "import", "clone"]); +const TEMPLATE_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const TEXT_EXTENSIONS = new Set([ + "", ".c", ".conf", ".cpp", ".css", ".csv", ".env", ".go", ".h", ".html", + ".ini", ".java", ".js", ".json", ".jsx", ".log", ".md", ".mjs", ".properties", + ".py", ".rb", ".rs", ".sh", ".sql", ".svg", ".toml", ".ts", ".tsx", ".txt", + ".xml", ".yaml", ".yml" +]); + +function normalizeTemplateName(value) { + const name = String(value || "").trim(); + if (!TEMPLATE_NAME_PATTERN.test(name)) { + throw badRequest("模板名称只能包含小写字母、数字和中划线"); + } + return name; +} + +function normalizeIdList(value, fieldName) { + if (!Array.isArray(value)) { + throw badRequest(`${fieldName} 必须是数组`); + } + const ids = [...new Set(value.map(Number))]; + if (ids.some((id) => !Number.isSafeInteger(id) || id <= 0)) { + throw badRequest(`${fieldName} 只能包含正整数`); + } + return ids.sort((left, right) => left - right); +} + +function normalizeRelativePath(value, { allowRoot = false } = {}) { + const raw = String(value ?? "").normalize("NFC"); + if (!raw && allowRoot) { + return ""; + } + if (!raw || raw.includes("\\") || raw.startsWith("/") || /^[A-Za-z]:/.test(raw)) { + throw badRequest("路径必须是模板内的相对 POSIX 路径"); + } + if (Buffer.byteLength(raw, "utf8") > MAX_PATH_BYTES) { + throw badRequest(`路径长度不能超过 ${MAX_PATH_BYTES} bytes`); + } + const segments = raw.split("/"); + for (const segment of segments) { + if (!segment || segment === "." || segment === "..") { + throw badRequest("路径包含非法目录段"); + } + if (/^[. ]+$/.test(segment) || /[\u0000-\u001f\u007f<>:"|?*]/.test(segment) || /[. ]$/.test(segment)) { + throw badRequest(`路径包含不可移植的文件名:${segment}`); + } + } + return segments.join("/"); +} + +function isProtectedPath(relativePath) { + return relativePath.toLowerCase() === PROTECTED_FILE_NAME.toLowerCase(); +} + +function assertMovablePath(relativePath) { + if (isProtectedPath(relativePath)) { + throw conflict(`${PROTECTED_FILE_NAME} 不允许删除、移动、重命名或覆盖`); + } +} + +function isTextBuffer(buffer, filePath) { + return buffer.length <= TEXT_PREVIEW_BYTES + && !buffer.includes(0) + && TEXT_EXTENSIONS.has(path.posix.extname(filePath).toLowerCase()); +} + +function readSkillManifest(buffer) { + const text = buffer.toString("utf8").replace(/^\uFEFF/, ""); + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) { + throw badRequest("SKILL.md 必须包含 YAML frontmatter"); + } + let frontmatter; + try { + frontmatter = YAML.parse(match[1]); + } catch (error) { + throw badRequest(`SKILL.md frontmatter 无法解析:${error.message}`); + } + const name = String(frontmatter?.name || "").trim(); + const description = String(frontmatter?.description || "").trim(); + if (!name || !description) { + throw badRequest("SKILL.md frontmatter 必须包含 name 和 description"); + } + const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""); + if (!slug || !TEMPLATE_NAME_PATTERN.test(slug)) { + throw badRequest("Skill name 无法转换为合法目录名"); + } + return { slug, name, description }; +} + +function inspectArchive(buffer, { requireAgents = false, requireSkill = false } = {}) { + let archive; + try { + archive = new AdmZip(buffer); + } catch (error) { + throw badRequest(`ZIP 文件无法读取:${error.message}`); + } + const entries = []; + const pathKeys = new Set(); + let totalBytes = 0; + let agentsEntry = null; + let skillEntry = null; + for (const entry of archive.getEntries()) { + const rawName = String(entry.entryName || "").replace(/\/+$/, ""); + if (!rawName) { + continue; + } + const relativePath = normalizeRelativePath(rawName); + const key = relativePath.toLowerCase(); + if (pathKeys.has(key)) { + throw badRequest(`ZIP 包含重复或大小写冲突的路径:${relativePath}`); + } + pathKeys.add(key); + const unixMode = (Number(entry.attr || 0) >>> 16) & 0xf000; + if (unixMode === 0xa000) { + throw badRequest(`ZIP 不允许包含符号链接:${relativePath}`); + } + const size = Number(entry.header?.size || 0); + const compressedSize = Number(entry.header?.compressedSize || 0); + if (!entry.isDirectory && size > MAX_FILE_BYTES) { + throw badRequest(`单文件不能超过 10 MiB:${relativePath}`); + } + if (!entry.isDirectory && compressedSize > 0 && size / compressedSize > MAX_COMPRESSION_RATIO) { + throw badRequest(`ZIP 条目压缩比过高:${relativePath}`); + } + totalBytes += size; + if (totalBytes > MAX_TEMPLATE_BYTES) { + throw badRequest("模板展开后的总大小不能超过 100 MiB"); + } + if (isProtectedPath(relativePath)) { + if (relativePath !== PROTECTED_FILE_NAME || entry.isDirectory) { + throw badRequest(`模板根目录必须使用准确文件名 ${PROTECTED_FILE_NAME}`); + } + agentsEntry = entry; + } + if (relativePath === "SKILL.md" && !entry.isDirectory) { + skillEntry = entry; + } + entries.push({ entry, relativePath }); + } + if (entries.length > MAX_TEMPLATE_ENTRIES) { + throw badRequest(`模板文件和目录总数不能超过 ${MAX_TEMPLATE_ENTRIES}`); + } + if (requireAgents && !agentsEntry) { + throw badRequest(`模板 ZIP 顶层必须包含 ${PROTECTED_FILE_NAME}`); + } + if (requireSkill && !skillEntry) { + throw badRequest("Skill ZIP 顶层必须包含 SKILL.md"); + } + return { entries, agentsEntry, skillEntry, totalBytes }; +} + +function extractArchive(buffer, destination, options = {}) { + const inspected = inspectArchive(buffer, options); + fs.mkdirSync(destination, { recursive: true }); + for (const { entry, relativePath } of inspected.entries) { + const target = path.join(destination, ...relativePath.split("/")); + if (entry.isDirectory) { + fs.mkdirSync(target, { recursive: true }); + } else { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, entry.getData()); + } + } + return inspected; +} + +function buildArchive(workspacePath) { + const archive = new AdmZip(); + const directories = []; + const files = []; + const visit = (directory, relativeDirectory = "") => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const relativePath = relativeDirectory ? `${relativeDirectory}/${entry.name}` : entry.name; + const absolutePath = path.join(directory, entry.name); + if (entry.isSymbolicLink()) { + throw badRequest(`模板不允许包含符号链接:${relativePath}`); + } + if (entry.isDirectory()) { + directories.push(relativePath); + visit(absolutePath, relativePath); + } else if (entry.isFile()) { + files.push({ relativePath, absolutePath }); + } + } + }; + visit(workspacePath); + const stableDate = new Date("1980-01-01T00:00:00.000Z"); + for (const directoryPath of directories.sort()) { + const entryName = `${directoryPath}/`; + archive.addFile(entryName, Buffer.alloc(0)); + archive.getEntry(entryName).header.time = stableDate; + } + for (const file of files.sort((left, right) => left.relativePath.localeCompare(right.relativePath))) { + archive.addFile(file.relativePath, fs.readFileSync(file.absolutePath)); + archive.getEntry(file.relativePath).header.time = stableDate; + } + return archive.toBuffer(); +} + +function listWorkspaceEntries(workspacePath) { + const entries = []; + const visit = (directory, relativeDirectory = "") => { + const children = fs.readdirSync(directory, { withFileTypes: true }) + .sort((left, right) => Number(right.isDirectory()) - Number(left.isDirectory()) || left.name.localeCompare(right.name)); + for (const child of children) { + const relativePath = relativeDirectory ? `${relativeDirectory}/${child.name}` : child.name; + const absolutePath = path.join(directory, child.name); + if (child.isSymbolicLink()) { + throw conflict(`模板包含不允许的符号链接:${relativePath}`); + } + const stats = fs.statSync(absolutePath); + entries.push({ + name: child.name, + path: relativePath, + parent_path: relativeDirectory, + type: child.isDirectory() ? "directory" : "file", + size: child.isFile() ? stats.size : 0, + protected: isProtectedPath(relativePath), + readonly: false, + updated_at: stats.mtime.toISOString() + }); + if (child.isDirectory()) { + visit(absolutePath, relativePath); + } + } + }; + visit(workspacePath); + return entries; +} + +function listWorkspaceSkills(workspacePath) { + const skillsPath = path.join(workspacePath, "skills"); + if (!fs.existsSync(skillsPath)) { + return []; + } + return fs.readdirSync(skillsPath, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => { + const manifestPath = path.join(skillsPath, entry.name, "SKILL.md"); + if (!fs.existsSync(manifestPath)) { + return { slug: entry.name, name: entry.name, description: "", status: "invalid" }; + } + try { + const manifest = readSkillManifest(fs.readFileSync(manifestPath)); + return { ...manifest, slug: entry.name, status: manifest.slug === entry.name ? "ready" : "invalid" }; + } catch (error) { + return { slug: entry.name, name: entry.name, description: error.message, status: "invalid" }; + } + }) + .sort((left, right) => left.slug.localeCompare(right.slug)); +} + +function validateWorkspace(workspacePath) { + const errors = []; + const warnings = []; + let entries = []; + try { + entries = listWorkspaceEntries(workspacePath); + } catch (error) { + errors.push({ code: "tree_invalid", message: error.message }); + } + const agentsPath = path.join(workspacePath, PROTECTED_FILE_NAME); + if (!fs.existsSync(agentsPath)) { + errors.push({ code: "agents_missing", path: PROTECTED_FILE_NAME, message: `${PROTECTED_FILE_NAME} 不存在` }); + } else if (!fs.lstatSync(agentsPath).isFile() || fs.lstatSync(agentsPath).isSymbolicLink()) { + errors.push({ code: "agents_invalid", path: PROTECTED_FILE_NAME, message: `${PROTECTED_FILE_NAME} 必须是普通文件` }); + } + const totalBytes = entries.reduce((total, entry) => total + Number(entry.size || 0), 0); + if (entries.length > MAX_TEMPLATE_ENTRIES) { + errors.push({ code: "entry_limit", message: `文件和目录总数不能超过 ${MAX_TEMPLATE_ENTRIES}` }); + } + if (totalBytes > MAX_TEMPLATE_BYTES) { + errors.push({ code: "size_limit", message: "模板总大小不能超过 100 MiB" }); + } + const skills = listWorkspaceSkills(workspacePath); + for (const skill of skills) { + if (skill.status !== "ready") { + errors.push({ code: "skill_invalid", path: `skills/${skill.slug}/SKILL.md`, message: `Skill ${skill.slug} 校验失败` }); + } + } + for (const entry of entries.filter((item) => item.type === "file")) { + const buffer = fs.readFileSync(path.join(workspacePath, ...entry.path.split("/"))); + if (!isTextBuffer(buffer, entry.path)) { + warnings.push({ code: "binary_file", path: entry.path, message: "二进制文件无法在线预览" }); + } + } + return { + valid: errors.length === 0, + checked_at: toChinaISOString(), + summary: { + files: entries.filter((entry) => entry.type === "file").length, + directories: entries.filter((entry) => entry.type === "directory").length, + total_bytes: totalBytes, + skills: skills.length + }, + errors, + warnings + }; +} + +function runTransaction(db, callback) { + db.exec("BEGIN"); + try { + const result = callback(); + db.exec("COMMIT"); + return result; + } catch (error) { + db.exec("ROLLBACK"); + throw error; + } +} + +export class TemplateService { + constructor({ db, portalService, env = {} }) { + this.db = db; + this.portalService = portalService; + this.workingRoot = path.resolve(env.dataDir || process.env.DA_WEB_DATA_DIR || "data", "template-working"); + this.workspaceTemplatesDir = path.resolve( + env.workspaceTemplatesDir + || process.env.DA_WORKSPACE_TEMPLATES_DIR + || path.join("..", "..", "docker", "assets", "workspace-templates") + ); + this.defaultAgentsPath = path.join(this.workspaceTemplatesDir, "default", PROTECTED_FILE_NAME); + this.templateQueues = new Map(); + fs.mkdirSync(this.workingRoot, { recursive: true }); + } + + templateRow(templateName) { + const normalizedName = normalizeTemplateName(templateName); + const row = this.db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(normalizedName); + if (!row) { + throw notFound("模板不存在"); + } + return row; + } + + readDefaultAgentsFile() { + if (!fs.existsSync(this.defaultAgentsPath)) { + throw notFound(`系统默认 ${PROTECTED_FILE_NAME} 不存在`); + } + const stats = fs.lstatSync(this.defaultAgentsPath); + if (stats.isSymbolicLink() || !stats.isFile()) { + throw conflict(`系统默认 ${PROTECTED_FILE_NAME} 不是普通文件`); + } + return fs.readFileSync(this.defaultAgentsPath); + } + + async loadTemplateArchive(templateRow) { + const artifact = this.portalService.getArtifactStorageInfo?.(templateRow.artifact_id); + if (artifact && this.portalService.localArtifactStore?.downloadObject) { + try { + return await this.portalService.localArtifactStore.downloadObject(artifact.bucket, artifact.object_key); + } catch {} + } + const templatePath = path.resolve(this.workspaceTemplatesDir, normalizeTemplateName(templateRow.template_name)); + const relative = path.relative(this.workspaceTemplatesDir, templatePath); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw conflict("本地模板目录不在允许的存储范围内"); + } + if (!fs.existsSync(templatePath) || !fs.lstatSync(templatePath).isDirectory()) { + throw notFound(`模板 ${templateRow.template_name} 没有可用的制品或本地目录`); + } + return buildArchive(templatePath); + } + + getTemplatePolicy(templateId) { + const policy = this.db.prepare("SELECT mode FROM template_business_system_policies WHERE template_id = ?").get(templateId); + const mode = policy?.mode === "selected" ? "selected" : "all"; + const ids = mode === "selected" + ? this.db.prepare(` + SELECT business_system_id FROM template_business_system_access + WHERE template_id = ? ORDER BY business_system_id + `).all(templateId).map((item) => Number(item.business_system_id)) + : []; + return { mode, business_system_ids: ids }; + } + + getTemplateMcpPolicy(templateId) { + const policy = this.db.prepare("SELECT mode FROM template_mcp_policies WHERE template_id = ?").get(templateId); + const mode = policy?.mode === "selected" ? "selected" : "all"; + const ids = mode === "selected" + ? this.db.prepare(` + SELECT mcp_server_id FROM template_mcp_access + WHERE template_id = ? ORDER BY mcp_server_id + `).all(templateId).map((item) => Number(item.mcp_server_id)) + : []; + return { mode, mcp_server_ids: ids }; + } + + async templateDetails(templateName) { + const row = this.templateRow(templateName); + const archive = await this.loadTemplateArchive(row); + const workspacePath = path.join(this.workingRoot, randomUUID()); + try { + extractArchive(archive, workspacePath, { requireAgents: true }); + const businessPolicy = this.getTemplatePolicy(row.id); + const mcpPolicy = this.getTemplateMcpPolicy(row.id); + return { + ...row, + business_system_mode: businessPolicy.mode, + business_system_ids: businessPolicy.business_system_ids, + mcp_mode: mcpPolicy.mode, + mcp_server_ids: mcpPolicy.mcp_server_ids, + skills: listWorkspaceSkills(workspacePath), + is_builtin: Boolean(row.is_builtin) + }; + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + } + } + + enqueueTemplateWrite(templateName, operation) { + const key = normalizeTemplateName(templateName); + const previous = this.templateQueues.get(key) || Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + const tracked = current.finally(() => { + if (this.templateQueues.get(key) === tracked) { + this.templateQueues.delete(key); + } + }); + this.templateQueues.set(key, tracked); + return tracked; + } + + async withEditableWorkspace(templateName, createdBy, mutation) { + return await this.enqueueTemplateWrite(templateName, async () => { + const row = this.templateRow(templateName); + const previousArtifact = this.portalService.getArtifactStorageInfo?.(row.artifact_id); + const workspacePath = path.join(this.workingRoot, randomUUID()); + try { + extractArchive(await this.loadTemplateArchive(row), workspacePath, { requireAgents: true }); + const result = await mutation(workspacePath, row); + const validation = validateWorkspace(workspacePath); + if (!validation.valid) { + throw conflict("模板校验未通过", validation); + } + const archiveBuffer = buildArchive(workspacePath); + const template = await this.portalService.createTemplate({ + templateName: row.template_name, + file: { + originalname: `${row.template_name}.zip`, + mimetype: "application/zip", + size: archiveBuffer.length, + buffer: archiveBuffer + }, + createdBy, + retainPreviousArtifact: true, + defaultLlm: { + default_llm_provider_id: row.default_llm_provider_id, + default_llm_model_id: row.default_llm_model_id, + default_llm_model_ref: row.default_llm_model_ref, + fallback_llm_provider_id: row.fallback_llm_provider_id, + fallback_llm_model_id: row.fallback_llm_model_id, + fallback_llm_model_ref: row.fallback_llm_model_ref + } + }); + this.db.prepare("UPDATE agent_templates SET skill_count = ? WHERE id = ?") + .run(validation.summary.skills, template.id); + if (previousArtifact && previousArtifact.id !== template.artifact_id) { + await this.portalService.discardArtifact(previousArtifact); + } + return { template: { ...template, skill_count: validation.summary.skills }, validation, result }; + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + } + }); + } + + async createTemplate({ mode, templateName, sourceTemplateName, file, createdBy }) { + const normalizedMode = String(mode || (file ? "import" : "blank")).trim(); + if (!VALID_CREATE_MODES.has(normalizedMode)) { + throw badRequest("mode 必须是 blank、import 或 clone"); + } + const normalizedName = normalizeTemplateName(templateName); + if (this.db.prepare("SELECT 1 FROM agent_templates WHERE template_name = ?").get(normalizedName)) { + throw conflict("模板名称已存在"); + } + let sourceTemplate = null; + let archiveBuffer; + if (normalizedMode === "import") { + if (!file?.buffer) { + throw badRequest("请上传模板 ZIP 文件"); + } + inspectArchive(file.buffer, { requireAgents: true }); + archiveBuffer = file.buffer; + } else if (normalizedMode === "clone") { + sourceTemplate = this.templateRow(sourceTemplateName); + archiveBuffer = await this.loadTemplateArchive(sourceTemplate); + } else { + const archive = new AdmZip(); + archive.addFile(PROTECTED_FILE_NAME, this.readDefaultAgentsFile()); + archiveBuffer = archive.toBuffer(); + } + const workspacePath = path.join(this.workingRoot, randomUUID()); + try { + extractArchive(archiveBuffer, workspacePath, { requireAgents: true }); + const validation = validateWorkspace(workspacePath); + if (!validation.valid) { + throw conflict("模板校验未通过", validation); + } + const template = await this.portalService.createTemplate({ + templateName: normalizedName, + file: { + originalname: `${normalizedName}.zip`, + mimetype: "application/zip", + size: archiveBuffer.length, + buffer: archiveBuffer + }, + createdBy, + defaultLlm: sourceTemplate ? { + default_llm_provider_id: sourceTemplate.default_llm_provider_id, + default_llm_model_id: sourceTemplate.default_llm_model_id, + default_llm_model_ref: sourceTemplate.default_llm_model_ref, + fallback_llm_provider_id: sourceTemplate.fallback_llm_provider_id, + fallback_llm_model_id: sourceTemplate.fallback_llm_model_id, + fallback_llm_model_ref: sourceTemplate.fallback_llm_model_ref + } : {} + }); + const businessPolicy = sourceTemplate ? this.getTemplatePolicy(sourceTemplate.id) : { mode: "all", business_system_ids: [] }; + const mcpPolicy = sourceTemplate ? this.getTemplateMcpPolicy(sourceTemplate.id) : { mode: "all", mcp_server_ids: [] }; + runTransaction(this.db, () => { + this.db.prepare("UPDATE agent_templates SET skill_count = ? WHERE id = ?").run(validation.summary.skills, template.id); + this.replaceBusinessPolicy(template.id, businessPolicy.mode, businessPolicy.business_system_ids); + this.replaceMcpPolicy(template.id, mcpPolicy.mode, mcpPolicy.mcp_server_ids); + }); + return await this.templateDetails(normalizedName); + } catch (error) { + const created = this.db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(normalizedName); + if (created) { + await this.portalService.deleteTemplate(normalizedName).catch(() => undefined); + } + throw error; + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + } + } + + resolveWorkspacePath(workspacePath, relativePath, options = {}) { + const normalized = normalizeRelativePath(relativePath, options); + const absolutePath = normalized + ? path.resolve(workspacePath, ...normalized.split("/")) + : workspacePath; + const relative = path.relative(workspacePath, absolutePath); + if (normalized && (!relative || relative.startsWith("..") || path.isAbsolute(relative))) { + throw badRequest("路径超出模板目录"); + } + return { relativePath: normalized, absolutePath }; + } + + async listTemplateTree(templateName) { + const row = this.templateRow(templateName); + const inspected = inspectArchive(await this.loadTemplateArchive(row), { requireAgents: true }); + const entries = new Map(); + for (const { entry, relativePath } of inspected.entries) { + const segments = relativePath.split("/"); + for (let index = 1; index < segments.length; index += 1) { + const directoryPath = segments.slice(0, index).join("/"); + if (!entries.has(directoryPath)) { + entries.set(directoryPath, { + name: segments[index - 1], path: directoryPath, + parent_path: segments.slice(0, index - 1).join("/"), + type: "directory", size: 0, protected: false, readonly: false + }); + } + } + entries.set(relativePath, { + name: segments.at(-1), path: relativePath, + parent_path: segments.slice(0, -1).join("/"), + type: entry.isDirectory ? "directory" : "file", + size: entry.isDirectory ? 0 : Number(entry.header?.size || 0), + protected: isProtectedPath(relativePath), + readonly: false + }); + } + return [...entries.values()].sort((left, right) => ( + Number(right.type === "directory") - Number(left.type === "directory") || left.path.localeCompare(right.path) + )); + } + + async readTemplateFile(templateName, filePath) { + const row = this.templateRow(templateName); + const normalizedPath = normalizeRelativePath(filePath); + const inspected = inspectArchive(await this.loadTemplateArchive(row), { requireAgents: true }); + const matched = inspected.entries.find((item) => item.relativePath === normalizedPath && !item.entry.isDirectory); + if (!matched) { + throw notFound("模板文件不存在"); + } + const buffer = matched.entry.getData(); + const text = isTextBuffer(buffer, normalizedPath); + return { + path: normalizedPath, + name: path.posix.basename(normalizedPath), + size: buffer.length, + protected: isProtectedPath(normalizedPath), + readonly: false, + binary: !text, + content: text ? buffer.toString("utf8") : null, + buffer + }; + } + + async writeTemplateFile(templateName, payload, createdBy) { + return await this.withEditableWorkspace(templateName, createdBy, (workspacePath) => { + const resolved = this.resolveWorkspacePath(workspacePath, payload?.path); + const buffer = Buffer.from(String(payload?.content ?? ""), "utf8"); + if (buffer.length > MAX_FILE_BYTES) { + throw badRequest("单文件不能超过 10 MiB"); + } + if (fs.existsSync(resolved.absolutePath) && fs.statSync(resolved.absolutePath).isDirectory()) { + throw conflict("同名目录已经存在"); + } + fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true }); + fs.writeFileSync(resolved.absolutePath, buffer); + return { path: resolved.relativePath }; + }); + } + + async createFolder(templateName, payload, createdBy) { + return await this.withEditableWorkspace(templateName, createdBy, (workspacePath) => { + const resolved = this.resolveWorkspacePath(workspacePath, payload?.path); + assertMovablePath(resolved.relativePath); + if (fs.existsSync(resolved.absolutePath)) { + throw conflict("同名文件或目录已经存在"); + } + fs.mkdirSync(resolved.absolutePath); + return { path: resolved.relativePath }; + }); + } + + async uploadFile(templateName, { targetPath, file, createdBy }) { + if (!file?.buffer) { + throw badRequest("请上传文件"); + } + if (file.buffer.length > MAX_FILE_BYTES) { + throw badRequest("单文件不能超过 10 MiB"); + } + return await this.withEditableWorkspace(templateName, createdBy, (workspacePath) => { + const parentPath = normalizeRelativePath(targetPath || "", { allowRoot: true }); + const fileName = normalizeRelativePath(String(file.originalname || "").replaceAll("/", "")); + const relativePath = parentPath ? `${parentPath}/${fileName}` : fileName; + assertMovablePath(relativePath); + const resolved = this.resolveWorkspacePath(workspacePath, relativePath); + if (fs.existsSync(resolved.absolutePath)) { + throw conflict("同名文件已经存在"); + } + fs.mkdirSync(path.dirname(resolved.absolutePath), { recursive: true }); + fs.writeFileSync(resolved.absolutePath, file.buffer); + return { path: relativePath }; + }); + } + + async moveEntry(templateName, payload, createdBy) { + return await this.withEditableWorkspace(templateName, createdBy, (workspacePath) => { + const source = this.resolveWorkspacePath(workspacePath, payload?.source_path); + const destination = this.resolveWorkspacePath(workspacePath, payload?.destination_path); + assertMovablePath(source.relativePath); + assertMovablePath(destination.relativePath); + if (!fs.existsSync(source.absolutePath)) { + throw notFound("待移动的文件或目录不存在"); + } + if (fs.existsSync(destination.absolutePath)) { + throw conflict("目标路径已经存在"); + } + if (destination.absolutePath.startsWith(`${source.absolutePath}${path.sep}`)) { + throw badRequest("目录不能移动到自身内部"); + } + fs.mkdirSync(path.dirname(destination.absolutePath), { recursive: true }); + fs.renameSync(source.absolutePath, destination.absolutePath); + return { path: destination.relativePath }; + }); + } + + async deleteEntry(templateName, filePath, createdBy) { + return await this.withEditableWorkspace(templateName, createdBy, (workspacePath) => { + const resolved = this.resolveWorkspacePath(workspacePath, filePath); + assertMovablePath(resolved.relativePath); + if (!fs.existsSync(resolved.absolutePath)) { + throw notFound("文件或目录不存在"); + } + fs.rmSync(resolved.absolutePath, { recursive: true, force: false }); + return { path: resolved.relativePath }; + }); + } + + async restoreDefaultAgentsFile(templateName, createdBy) { + return await this.withEditableWorkspace(templateName, createdBy, (workspacePath) => { + fs.writeFileSync(path.join(workspacePath, PROTECTED_FILE_NAME), this.readDefaultAgentsFile()); + return { path: PROTECTED_FILE_NAME }; + }); + } + + async uploadSkill(templateName, { file, replace = false, createdBy }) { + if (!file?.buffer) { + throw badRequest("请上传 Skill ZIP 文件"); + } + const inspected = inspectArchive(file.buffer, { requireSkill: true }); + const manifest = readSkillManifest(inspected.skillEntry.getData()); + const result = await this.withEditableWorkspace(templateName, createdBy, (workspacePath) => { + const destination = path.join(workspacePath, "skills", manifest.slug); + if (fs.existsSync(destination) && !replace) { + throw conflict(`模板中已存在 Skill:${manifest.slug}`); + } + fs.rmSync(destination, { recursive: true, force: true }); + extractArchive(file.buffer, destination, { requireSkill: true }); + return { skill: { ...manifest, status: "ready" } }; + }); + return { ...result, skill: result.result.skill }; + } + + async deleteSkill(templateName, slug, createdBy) { + return await this.deleteEntry(templateName, `skills/${normalizeTemplateName(slug)}`, createdBy); + } + + replaceBusinessPolicy(templateId, mode, ids) { + const now = toChinaISOString(); + this.db.prepare(` + INSERT INTO template_business_system_policies (template_id, mode, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(template_id) DO UPDATE SET mode = excluded.mode, updated_at = excluded.updated_at + `).run(templateId, mode, now, now); + this.db.prepare("DELETE FROM template_business_system_access WHERE template_id = ?").run(templateId); + const insert = this.db.prepare("INSERT INTO template_business_system_access (template_id, business_system_id) VALUES (?, ?)"); + for (const id of ids) { + insert.run(templateId, id); + } + } + + replaceMcpPolicy(templateId, mode, ids) { + const now = toChinaISOString(); + this.db.prepare(` + INSERT INTO template_mcp_policies (template_id, mode, created_at, updated_at) + VALUES (?, ?, ?, ?) + ON CONFLICT(template_id) DO UPDATE SET mode = excluded.mode, updated_at = excluded.updated_at + `).run(templateId, mode, now, now); + this.db.prepare("DELETE FROM template_mcp_access WHERE template_id = ?").run(templateId); + const insert = this.db.prepare("INSERT INTO template_mcp_access (template_id, mcp_server_id) VALUES (?, ?)"); + for (const id of ids) { + insert.run(templateId, id); + } + } + + updateDefaultLlm(templateName, payload) { + this.templateRow(templateName); + return this.portalService.updateTemplateDefaultLlm(templateName, payload); + } + + async renameTemplate(templateName, payload) { + this.templateRow(templateName); + const nextTemplateName = normalizeTemplateName(payload?.template_name); + return await this.portalService.renameTemplate(templateName, nextTemplateName); + } + + updateBusinessSystems(templateName, payload) { + const row = this.templateRow(templateName); + const mode = String(payload?.mode || "").trim(); + if (mode !== "all" && mode !== "selected") { + throw badRequest("mode 必须是 all 或 selected"); + } + const ids = mode === "selected" ? normalizeIdList(payload?.business_system_ids, "business_system_ids") : []; + if (ids.length > 0) { + const placeholders = ids.map(() => "?").join(", "); + const found = this.db.prepare(`SELECT id FROM business_systems WHERE id IN (${placeholders})`).all(...ids); + if (found.length !== ids.length) { + throw badRequest("一个或多个业务系统不存在"); + } + } + runTransaction(this.db, () => this.replaceBusinessPolicy(row.id, mode, ids)); + return { mode, business_system_ids: ids }; + } + + updateMcpServers(templateName, payload) { + const row = this.templateRow(templateName); + const mode = String(payload?.mode || "").trim(); + if (mode !== "all" && mode !== "selected") { + throw badRequest("mode 必须是 all 或 selected"); + } + const ids = mode === "selected" ? normalizeIdList(payload?.mcp_server_ids, "mcp_server_ids") : []; + if (ids.length > 0) { + const placeholders = ids.map(() => "?").join(", "); + const found = this.db.prepare(`SELECT id FROM mcp_servers WHERE id IN (${placeholders})`).all(...ids); + if (found.length !== ids.length) { + throw badRequest("一个或多个 MCP 服务不存在"); + } + } + runTransaction(this.db, () => this.replaceMcpPolicy(row.id, mode, ids)); + return { mode, mcp_server_ids: ids }; + } + + async validateTemplate(templateName) { + const row = this.templateRow(templateName); + const workspacePath = path.join(this.workingRoot, randomUUID()); + try { + extractArchive(await this.loadTemplateArchive(row), workspacePath, { requireAgents: true }); + return validateWorkspace(workspacePath); + } finally { + fs.rmSync(workspacePath, { recursive: true, force: true }); + } + } + + async downloadTemplateArchive(templateName) { + const row = this.templateRow(templateName); + return { + fileName: `${row.template_name}.zip`, + contentType: "application/zip", + buffer: await this.loadTemplateArchive(row) + }; + } +} diff --git a/packages/daedalus-agent-management-web/server/test/agent-business-system-policy.test.js b/packages/daedalus-agent-management-web/server/test/agent-business-system-policy.test.js new file mode 100644 index 0000000000000000000000000000000000000000..ee99d54070846f3485606b3fa7ad79beeae2e2d1 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/agent-business-system-policy.test.js @@ -0,0 +1,169 @@ +import { DatabaseSync } from "node:sqlite"; + +import { AgentService } from "../src/services/agent-service.js"; + +function createDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE agents ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE + ); + + CREATE TABLE business_systems ( + id INTEGER PRIMARY KEY, + provider TEXT NOT NULL, + application_name TEXT NOT NULL UNIQUE, + description TEXT NOT NULL, + status TEXT NOT NULL + ); + + CREATE TABLE agent_business_system_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + policy_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE TABLE agent_business_system_access ( + agent_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, business_system_id), + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY (business_system_id) REFERENCES business_systems(id) ON DELETE CASCADE + ); + + CREATE TABLE template_business_system_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE template_business_system_access ( + template_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (template_id, business_system_id), + FOREIGN KEY (business_system_id) REFERENCES business_systems(id) ON DELETE CASCADE + ); + + INSERT INTO agents (id, slug) VALUES (1, 'agent-a'); + INSERT INTO business_systems (id, provider, application_name, description, status) + VALUES + (1, 'hzg', 'crm-app', 'CRM', 'active'), + (2, 'hzg', 'erp-app', 'ERP', 'disabled'); + INSERT INTO agent_business_system_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES ( + 1, 'all', 1, '2026-08-10T00:00:00.000Z', '2026-08-10T00:00:00.000Z' + ); + `); + return db; +} + +function createService(db) { + return new AgentService({ + db, + managementClient: {}, + objectStorage: {} + }); +} + +it("reads and replaces an agent business system policy", () => { + const db = createDb(); + const service = createService(db); + + expect(service.getBusinessSystemPolicy(1)).toEqual({ + mode: "all", + policy_version: 1, + business_system_ids: [], + business_systems: [] + }); + + const updated = service.setBusinessSystemPolicy(1, { + mode: "selected", + policy_version: 1, + business_system_ids: [2, 1, 1] + }); + + expect(updated).toEqual({ + mode: "selected", + policy_version: 2, + business_system_ids: [1, 2], + business_systems: [ + jasmine.objectContaining({ id: 1, application_name: "crm-app" }), + jasmine.objectContaining({ id: 2, application_name: "erp-app" }) + ] + }); +}); + +it("rejects stale policy versions", () => { + const service = createService(createDb()); + + expect(() => service.setBusinessSystemPolicy(1, { + mode: "all", + policy_version: 2, + business_system_ids: [] + })).toThrowMatching((error) => error.status === 409 && error.code === "conflict"); +}); + +it("rejects unknown business systems", () => { + const service = createService(createDb()); + + expect(() => service.setBusinessSystemPolicy(1, { + mode: "selected", + policy_version: 1, + business_system_ids: [999] + })).toThrowMatching((error) => error.status === 400 && error.code === "bad_request"); +}); + +it("clears selected systems when switching back to all", () => { + const db = createDb(); + const service = createService(db); + service.setBusinessSystemPolicy(1, { + mode: "selected", + policy_version: 1, + business_system_ids: [1] + }); + + const updated = service.setBusinessSystemPolicy(1, { + mode: "all", + policy_version: 2, + business_system_ids: [1] + }); + + expect(updated).toEqual({ + mode: "all", + policy_version: 3, + business_system_ids: [], + business_systems: [] + }); + expect(db.prepare("SELECT COUNT(*) AS count FROM agent_business_system_access WHERE agent_id = 1").get().count).toBe(0); +}); + +it("copies selected business systems from a template policy", () => { + const db = createDb(); + const service = createService(db); + db.prepare(` + INSERT INTO template_business_system_policies ( + template_id, mode, created_at, updated_at + ) VALUES (7, 'selected', '2026-08-11T00:00:00.000Z', '2026-08-11T00:00:00.000Z') + `).run(); + db.prepare(` + INSERT INTO template_business_system_access (template_id, business_system_id) + VALUES (7, 2) + `).run(); + + service.applyTemplateBusinessSystemPolicy(1, 7, "2026-08-11T01:00:00.000Z"); + + expect(service.getBusinessSystemPolicy(1)).toEqual({ + mode: "selected", + policy_version: 2, + business_system_ids: [2], + business_systems: [jasmine.objectContaining({ id: 2, application_name: "erp-app" })] + }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/agent-quota-and-protocol.test.js b/packages/daedalus-agent-management-web/server/test/agent-quota-and-protocol.test.js new file mode 100644 index 0000000000000000000000000000000000000000..5ea14593ce8113ed2a576e26021e853fcf37e5fa --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/agent-quota-and-protocol.test.js @@ -0,0 +1,59 @@ +import { + AGENT_STATUSES, + findQuotaViolation, + isAgentStatus, + isOverLimit, + normalizeAgentStatus, + normalizeUsage, + statusAfterUsageRefresh +} from "../src/services/agent-quota.js"; +import { newErrorResponse, newSuccessResponse } from "../src/background/protocol.js"; + +it("normalizes agent statuses and all supported usage payload shapes", () => { + expect(AGENT_STATUSES).toEqual(["normal", "disabled", "overlimit"]); + expect(isAgentStatus("normal")).toBeTrue(); + expect(isAgentStatus("unknown")).toBeFalse(); + expect(normalizeAgentStatus("disabled")).toBe("disabled"); + expect(normalizeAgentStatus("overlimit")).toBe("normal"); + + expect(normalizeUsage(null)).toEqual({ daily: 0 }); + expect(normalizeUsage("invalid")).toEqual({ daily: 0 }); + expect(normalizeUsage({ usage: { daily: 1 } })).toEqual({ daily: 1 }); + expect(normalizeUsage({ tokens: { day: 2 } })).toEqual({ daily: 2 }); + expect(normalizeUsage({ dailyTokens: 3 })).toEqual({ daily: 3 }); + expect(normalizeUsage({ today: 4 })).toEqual({ daily: 4 }); + expect(normalizeUsage({ totalTokens: 5 })).toEqual({ daily: 5 }); + expect(normalizeUsage({})).toEqual({ daily: 0 }); +}); + +it("finds quota violations and derives status after usage refresh", () => { + expect(findQuotaViolation(null, null)).toBeNull(); + expect(findQuotaViolation({ daily_limit: -1 }, { daily: 999 })).toBeNull(); + expect(findQuotaViolation({ daily_limit: 10 }, { daily: 9 })).toBeNull(); + expect(findQuotaViolation({ daily_limit: 10 }, { daily: 10 })).toEqual({ + period: "daily", + limit: 10, + current: 10, + limitKey: "daily_limit", + usageKey: "daily" + }); + expect(findQuotaViolation({ daily_limit: 0 }, {})).toEqual(jasmine.objectContaining({ current: 0 })); + expect(isOverLimit({ daily_limit: 1 }, { daily: 1 })).toBeTrue(); + expect(isOverLimit({ daily_limit: 2 }, { daily: 1 })).toBeFalse(); + expect(statusAfterUsageRefresh({ status: "disabled" }, { daily: 999 })).toBe("disabled"); + expect(statusAfterUsageRefresh({ daily_limit: 1 }, { daily: 1 })).toBe("overlimit"); + expect(statusAfterUsageRefresh({ daily_limit: 2 }, { daily: 1 })).toBe("normal"); +}); + +it("builds success and error responses for the background protocol", () => { + expect(newSuccessResponse("trace-1", { ok: true })).toEqual({ + traceId: "trace-1", + code: 0, + data: { ok: true } + }); + expect(newErrorResponse("trace-2", 503, "unavailable")).toEqual({ + traceId: "trace-2", + code: 503, + message: "unavailable" + }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/agent-service-alignment.test.js b/packages/daedalus-agent-management-web/server/test/agent-service-alignment.test.js index 7cdb0356313a76b074419157ded6fd11795167e7..f2de766ea2a780420d72b79faca15241f2fc8131 100644 --- a/packages/daedalus-agent-management-web/server/test/agent-service-alignment.test.js +++ b/packages/daedalus-agent-management-web/server/test/agent-service-alignment.test.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import AdmZip from "adm-zip"; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-web-agent-test-")); process.env.DA_WEB_DATA_DIR = dataDir; @@ -199,9 +200,185 @@ it("finds a single agent by slug", async () => { expect(agent.status).toBe("normal"); }); -it("exports an agent workspace through the admin outbox S3 bucket", async () => { +it("rejects replacing an Agent skill with an archive for another slug", async () => { + const archive = new AdmZip(); + archive.addFile("SKILL.md", Buffer.from([ + "---", + "name: another-skill", + "description: Another skill", + "---", + "" + ].join("\n"), "utf8")); + let uploadCount = 0; + const service = new AgentService({ + db: createDb({ + id: 3, + slug: "normal-agent", + agent_name: "Normal Agent", + description: "", + docs_content: "", + template_name: "default", + status: "normal", + tags_json: "[]", + daily_limit: -1, + usage_snapshot_json: JSON.stringify({ usage: { daily: 0, weekly: 0, monthly: 0, total: 0 } }), + remote_state_json: JSON.stringify({ agentId: "normal-agent", status: "normal" }), + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }), + managementClient: {}, + objectStorage: { + async uploadAdminArtifact() { + uploadCount += 1; + return { bucket: "admin-in", objectKey: "agent-skill/another-skill.zip" }; + } + } + }); + + await expectAsync(service.installAgentSkill("normal-agent", { + file: { + buffer: archive.toBuffer(), + originalname: "another-skill.zip", + mimetype: "application/zip", + size: archive.toBuffer().length + }, + replace: true, + etag: "existing-etag", + expectedSlug: "existing-skill" + })).toBeRejectedWithError(/existing-skill/); + expect(uploadCount).toBe(0); +}); + +it("installs an Agent skill through a local artifact and cleans up the upload", async () => { + const archive = new AdmZip(); + archive.addFile("SKILL.md", Buffer.from([ + "---", + "name: report-skill", + "description: Report skill", + "---", + "" + ].join("\n"), "utf8")); + const actionCalls = []; + const deleteCalls = []; + const service = new AgentService({ + db: createDb({ + id: 3, + slug: "normal-agent", + agent_name: "Normal Agent", + description: "", + docs_content: "", + template_name: "default", + status: "normal", + tags_json: "[]", + daily_limit: -1, + usage_snapshot_json: "{}", + remote_state_json: "{}", + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }), + managementClient: { + async call(action, params) { + actionCalls.push({ action, params }); + return { skill: { slug: params.slug } }; + } + }, + localArtifactStore: { + async uploadAdminArtifact() { + return { bucket: "local", objectKey: "agent-skill/report-skill.zip" }; + }, + async deleteObject(bucket, objectKey) { + deleteCalls.push({ bucket, objectKey }); + } + } + }); + + await service.installAgentSkill("normal-agent", { + file: { + buffer: archive.toBuffer(), + originalname: "report-skill.zip", + mimetype: "application/zip", + size: archive.toBuffer().length + } + }); + + expect(actionCalls).toEqual([{ + action: "agent.skills.install.local", + params: { + agentId: "normal-agent", + slug: "report-skill", + artifactPath: "agent-skill/report-skill.zip", + force: false, + etag: undefined + } + }]); + expect(deleteCalls).toEqual([{ + bucket: "local", + objectKey: "agent-skill/report-skill.zip" + }]); +}); + +it("downloads an Agent skill from local artifacts and cleans up the export", async () => { + const archiveBuffer = Buffer.from("skill zip"); + const actionCalls = []; + const deleteCalls = []; + const service = new AgentService({ + db: createDb({ + id: 3, + slug: "normal-agent", + agent_name: "Normal Agent", + description: "", + docs_content: "", + template_name: "default", + status: "normal", + tags_json: "[]", + daily_limit: -1, + usage_snapshot_json: "{}", + remote_state_json: "{}", + created_at: "2026-05-26T00:00:00.000Z", + updated_at: "2026-05-26T00:00:00.000Z" + }), + managementClient: { + async call(action, params) { + actionCalls.push({ action, params }); + return { + artifactPath: "agent-skill/2026-08-12/report-skill.zip", + fileName: "report-skill.zip" + }; + } + }, + localArtifactStore: { + async downloadObject(bucket, objectKey) { + expect(bucket).toBe("local"); + expect(objectKey).toBe("agent-skill/2026-08-12/report-skill.zip"); + return archiveBuffer; + }, + async deleteObject(bucket, objectKey) { + deleteCalls.push({ bucket, objectKey }); + } + } + }); + + const result = await service.exportAgentSkillArchive("normal-agent", "report-skill", "skill-etag"); + + expect(result.buffer).toBe(archiveBuffer); + expect(actionCalls).toEqual([{ + action: "agent.skills.export", + params: { + agentId: "normal-agent", + slug: "report-skill", + etag: "skill-etag" + } + }]); + expect(deleteCalls).toEqual([{ + bucket: "local", + objectKey: "agent-skill/2026-08-12/report-skill.zip" + }]); +}); + +it("exports an agent workspace through local artifacts and cleans up the archive", async () => { const actionCalls = []; const downloadCalls = []; + const deleteCalls = []; const archiveBuffer = Buffer.from("workspace zip"); const service = new AgentService({ db: createDb({ @@ -226,23 +403,22 @@ it("exports an agent workspace through the admin outbox S3 bucket", async () => actionCalls.push({ action, params, timeoutMs }); return { agentId: params.agentId, - bucket: params.bucket, - objectKey: params.objectKey, + artifactPath: "agent-workspace/2026-08-12/normal-agent.zip", fileName: "normal-agent-workspace.zip" }; } }, - objectStorage: { + localArtifactStore: { async downloadObject(bucket, objectKey) { downloadCalls.push({ bucket, objectKey }); return archiveBuffer; + }, + async deleteObject(bucket, objectKey) { + deleteCalls.push({ bucket, objectKey }); } }, env: { - managementTimeoutMs: 45000, - s3: { - adminOutboxBucket: "admin-out" - } + managementTimeoutMs: 45000 } }); @@ -253,13 +429,14 @@ it("exports an agent workspace through the admin outbox S3 bucket", async () => expect(actionCalls.length).toBe(1); expect(actionCalls[0].action).toBe("agent.workspace.export"); expect(actionCalls[0].timeoutMs).toBe(45000); - expect(actionCalls[0].params.agentId).toBe("normal-agent"); - expect(actionCalls[0].params.bucket).toBe("admin-out"); - expect(actionCalls[0].params.objectKey).toMatch(/^agent-workspace\/\d{4}-\d{2}-\d{2}\//); - expect(actionCalls[0].params.objectKey.endsWith("-normal-agent.zip")).toBeTrue(); + expect(actionCalls[0].params).toEqual({ agentId: "normal-agent" }); expect(downloadCalls).toEqual([{ - bucket: "admin-out", - objectKey: actionCalls[0].params.objectKey + bucket: "local", + objectKey: "agent-workspace/2026-08-12/normal-agent.zip" + }]); + expect(deleteCalls).toEqual([{ + bucket: "local", + objectKey: "agent-workspace/2026-08-12/normal-agent.zip" }]); }); diff --git a/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js b/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js index 7c671c5a8210b1b2aadd62d3b6abd8f563a79e39..b1d69d464e6cd5c9ed43f2ba1621ed32b466b833 100644 --- a/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js +++ b/packages/daedalus-agent-management-web/server/test/agent-service-create.test.js @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-web-agent-create-test-")); process.env.DA_WEB_DATA_DIR = dataDir; @@ -26,6 +27,7 @@ const FALLBACK_MODEL_REF = "backup-openai/qwen-fallback"; function createDb({ failAgentInsert = false, failAgentUpdate = false, templates = ["default"] } = {}) { const insertedRows = []; const permissions = []; + const businessSystemPolicies = []; const llmModels = new Map([ [DEFAULT_MODEL_REF, { provider_id: "corp-openai", @@ -61,7 +63,10 @@ function createDb({ failAgentInsert = false, failAgentUpdate = false, templates template_name: templateName, default_llm_provider_id: template.default_llm_provider_id || null, default_llm_model_id: template.default_llm_model_id || null, - default_llm_model_ref: template.default_llm_model_ref || null + default_llm_model_ref: template.default_llm_model_ref || null, + fallback_llm_provider_id: template.fallback_llm_provider_id || null, + fallback_llm_model_id: template.fallback_llm_model_id || null, + fallback_llm_model_ref: template.fallback_llm_model_ref || null, } ]; })); @@ -73,6 +78,7 @@ function createDb({ failAgentInsert = false, failAgentUpdate = false, templates return { insertedRows, permissions, + businessSystemPolicies, prepare(sql) { if (sql.includes("SELECT 1 FROM agents WHERE slug = ?")) { return { @@ -218,6 +224,23 @@ function createDb({ failAgentInsert = false, failAgentUpdate = false, templates }; } + if (sql.includes("INSERT INTO agent_business_system_policies")) { + return { + run: (agentId, mode, policyVersion, createdAt, updatedAt) => { + if (!businessSystemPolicies.some((item) => Number(item.agent_id) === Number(agentId))) { + businessSystemPolicies.push({ + agent_id: agentId, + mode, + policy_version: policyVersion, + created_at: createdAt, + updated_at: updatedAt + }); + } + return { changes: 1 }; + } + }; + } + if (sql.includes("UPDATE agents")) { return { run: (...params) => { @@ -325,6 +348,93 @@ function createDb({ failAgentInsert = false, failAgentUpdate = false, templates }; } +function createSqliteAgentDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE agent_templates ( + id INTEGER PRIMARY KEY, + template_name TEXT NOT NULL UNIQUE, + default_llm_provider_id TEXT, + default_llm_model_id TEXT, + default_llm_model_ref TEXT + ); + CREATE TABLE llm_providers ( + provider_id TEXT PRIMARY KEY, + base_url TEXT NOT NULL, + api_key_mask TEXT NOT NULL + ); + CREATE TABLE llm_models ( + model_ref TEXT PRIMARY KEY, + provider_id TEXT NOT NULL, + model_id TEXT NOT NULL, + name TEXT NOT NULL + ); + CREATE TABLE agents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + agent_name TEXT NOT NULL, + description TEXT NOT NULL, + docs_content TEXT NOT NULL DEFAULT '', + template_name TEXT NOT NULL, + llm_provider_id TEXT, + llm_model_id TEXT, + llm_model_ref TEXT, + fallback_llm_provider_id TEXT, + fallback_llm_model_id TEXT, + fallback_llm_model_ref TEXT, + status TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]', + daily_limit INTEGER NOT NULL, + usage_snapshot_json TEXT NOT NULL DEFAULT '{}', + remote_state_json TEXT NOT NULL DEFAULT '{}', + created_by INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE agent_users ( + id INTEGER PRIMARY KEY, + username TEXT NOT NULL UNIQUE + ); + CREATE TABLE agent_permissions ( + agent_id INTEGER NOT NULL, + agent_user_id INTEGER NOT NULL + ); + CREATE TABLE agent_business_system_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + policy_version INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE agent_business_system_access ( + agent_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL + ); + CREATE TABLE template_business_system_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE template_business_system_access ( + template_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL + ); + + INSERT INTO llm_providers (provider_id, base_url, api_key_mask) + VALUES ('corp-openai', 'https://api.example.com/v1', 'sk****test'); + INSERT INTO llm_models (model_ref, provider_id, model_id, name) + VALUES ('corp-openai/gpt-4.1-mini', 'corp-openai', 'gpt-4.1-mini', 'GPT 4.1 Mini'); + INSERT INTO agent_templates ( + id, template_name, default_llm_provider_id, default_llm_model_id, default_llm_model_ref + ) VALUES (1, 'edited-template', 'corp-openai', 'gpt-4.1-mini', 'corp-openai/gpt-4.1-mini'); + INSERT INTO template_business_system_policies ( + template_id, mode, created_at, updated_at + ) VALUES (1, 'all', '2026-08-12T00:00:00.000Z', '2026-08-12T00:00:00.000Z'); + `); + return db; +} + it("requires llm provider and model during agent creation", async () => { const actionCalls = []; const service = new AgentService({ @@ -355,14 +465,17 @@ it("requires llm provider and model during agent creation", async () => { expect(actionCalls).toEqual([]); }); -it("uses template default llm model during agent creation", async () => { +it("uses template primary and fallback llm models during agent creation", async () => { const actionCalls = []; const db = createDb({ templates: [{ template_name: "default", default_llm_provider_id: "corp-openai", default_llm_model_id: "gpt-4.1-mini", - default_llm_model_ref: DEFAULT_MODEL_REF + default_llm_model_ref: DEFAULT_MODEL_REF, + fallback_llm_provider_id: "backup-openai", + fallback_llm_model_id: "qwen-fallback", + fallback_llm_model_ref: FALLBACK_MODEL_REF }] }); const service = new AgentService({ @@ -392,11 +505,22 @@ it("uses template default llm model during agent creation", async () => { expect(created.llm_provider_id).toBe("corp-openai"); expect(created.llm_model_id).toBe("gpt-4.1-mini"); expect(created.llm_model_ref).toBe(DEFAULT_MODEL_REF); + expect(created.fallback_llm_provider_id).toBe("backup-openai"); + expect(created.fallback_llm_model_id).toBe("qwen-fallback"); + expect(created.fallback_llm_model_ref).toBe(FALLBACK_MODEL_REF); + expect(db.businessSystemPolicies).toEqual([ + jasmine.objectContaining({ + agent_id: 1, + mode: "all", + policy_version: 1 + }) + ]); expect(actionCalls[0]).toEqual({ action: "agent.create", params: jasmine.objectContaining({ model: { - primary: DEFAULT_MODEL_REF + primary: DEFAULT_MODEL_REF, + fallbacks: [FALLBACK_MODEL_REF] } }) }); @@ -902,6 +1026,85 @@ it("deletes remote agent when local insert fails after agent.create succeeds", a }); }); +it("creates an agent from an edited template against the current sqlite schema", async () => { + const db = createSqliteAgentDb(); + try { + const service = new AgentService({ + db, + managementClient: { + async call() { + return { ok: true }; + } + }, + objectStorage: {} + }); + + const created = await service.createAgent({ + slug: "edited-template-agent", + agent_name: "Edited Template Agent", + template_name: "edited-template" + }); + + expect(created).toEqual(jasmine.objectContaining({ + slug: "edited-template-agent", + template_name: "edited-template", + llm_model_ref: DEFAULT_MODEL_REF + })); + expect(db.prepare("SELECT COUNT(*) AS count FROM agents").get().count).toBe(1); + } finally { + db.close(); + } +}); + +it("compensates the remote and local agent when MCP projection fails", async () => { + const db = createDb(); + const actionCalls = []; + const mcpCalls = []; + const service = new AgentService({ + db, + managementClient: { + async call(action, params) { + actionCalls.push({ action, params }); + return { ok: true }; + } + }, + mcpService: { + applyTemplatePolicy() {}, + async syncRuntime(params) { + mcpCalls.push(params); + throw new Error("mcp projection failed"); + } + }, + objectStorage: {}, + env: { + mqtt: { + agentInboundTopicTemplate: "topic/in/{agentId}", + agentOutboundTopicTemplate: "topic/out/{agentId}" + } + } + }); + + await expectAsync(service.createAgent({ + slug: "agent-a", + agent_name: "Agent A", + ...DEFAULT_LLM_SELECTION, + template_name: "default", + restart: true + })).toBeRejectedWithError("mcp projection failed"); + + expect(db.insertedRows).toEqual([]); + expect(mcpCalls).toEqual([{ restart: true }]); + expect(actionCalls.map((item) => item.action)).toEqual([ + "agent.create", + "agent.delete" + ]); + expect(actionCalls[0].params.restart).toBeFalse(); + expect(actionCalls[1].params).toEqual({ + agentId: "agent-a", + restart: true + }); +}); + it("passes restart false when deleting an agent without immediate restart", async () => { const db = createDb(); const actionCalls = []; diff --git a/packages/daedalus-agent-management-web/server/test/agent-update-audit.test.js b/packages/daedalus-agent-management-web/server/test/agent-update-audit.test.js index 3c0a1cb98b8fc7474eba4c4453a1f743248b51ff..cec1162b4ab0907ffe38eb6f6957c311653f4691 100644 --- a/packages/daedalus-agent-management-web/server/test/agent-update-audit.test.js +++ b/packages/daedalus-agent-management-web/server/test/agent-update-audit.test.js @@ -61,3 +61,26 @@ it("includes assignment diff when base agent fields and assignments change toget detail: "编辑数字员工:ID=1,名称=agent-a;人员分配添加:wangwu;人员分配移除:zhangsan" }); }); + +it("records unchanged assignment payloads without a false diff", () => { + const audit = buildAgentUpdateAudit(makeAgent(), makeAgent(), { + users: [" lisi ", "lisi", "zhangsan", ""] + }); + + expect(audit).toEqual({ + action: "编辑数字员工人员分配", + detail: "编辑数字员工人员分配:ID=1,名称=agent-a;添加:无;移除:无" + }); +}); + +it("normalizes empty audit targets and non-array assignments", () => { + const audit = buildAgentUpdateAudit( + { permissions: [{ username: null }] }, + { id: null, slug: " ", agent_name: "", status: "", daily_limit: null }, + { permission_usernames: "not-an-array" } + ); + + expect(audit.action).toBe("编辑数字员工人员分配"); + expect(audit.detail).toContain("ID=-,名称=-"); + expect(audit.detail).toContain("添加:无;移除:无"); +}); diff --git a/packages/daedalus-agent-management-web/server/test/agent-user-policy.test.js b/packages/daedalus-agent-management-web/server/test/agent-user-policy.test.js new file mode 100644 index 0000000000000000000000000000000000000000..534d1136f0a9578ef8954e8410814fa636224654 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/agent-user-policy.test.js @@ -0,0 +1,98 @@ +import { DatabaseSync } from "node:sqlite"; + +import { AgentService } from "../src/services/agent-service.js"; + +function createDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE agents ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL UNIQUE, + agent_name TEXT NOT NULL, + status TEXT NOT NULL, + daily_limit INTEGER NOT NULL DEFAULT -1, + usage_snapshot_json TEXT NOT NULL DEFAULT '{}', + remote_state_json TEXT NOT NULL DEFAULT '{}' + ); + CREATE TABLE agent_users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT '', + updated_at TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE agent_permissions ( + agent_id INTEGER NOT NULL, + agent_user_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, agent_user_id) + ); + CREATE TABLE agent_user_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL CHECK (mode IN ('all', 'selected')), + policy_version INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + INSERT INTO agents (id, slug, agent_name, status) + VALUES (1, 'assistant', 'Assistant', 'normal'); + INSERT INTO agent_users (id, username) VALUES (1, 'alice'), (2, 'bob'); + INSERT INTO agent_user_policies (agent_id, mode, policy_version, created_at, updated_at) + VALUES (1, 'selected', 1, '', ''); + INSERT INTO agent_permissions (agent_id, agent_user_id) VALUES (1, 1); + `); + return db; +} + +function createService(db) { + return new AgentService({ + db, + managementClient: {}, + env: {} + }); +} + +it("applies all-user access to current and future agent users", async () => { + const db = createDb(); + const service = createService(db); + + const policy = service.setAgentUserPolicy(1, { + mode: "all", + policy_version: 1 + }); + + expect(policy.mode).toBe("all"); + expect(policy.policy_version).toBe(2); + expect(await service.listAgentDirectoryForUser("bob")).toHaveSize(1); + + service.createAgentUser("carol"); + expect(await service.listAgentDirectoryForUser("carol")).toHaveSize(1); + expect(db.prepare("SELECT COUNT(*) AS count FROM agent_permissions WHERE agent_id = 1").get().count).toBe(0); +}); + +it("lists only all-user agents as public directory entries", async () => { + const db = createDb(); + db.prepare("INSERT INTO agents (id, slug, agent_name, status) VALUES (?, ?, ?, ?)") + .run(2, "public-agent", "Public Agent", "normal"); + db.prepare("INSERT INTO agent_user_policies (agent_id, mode, policy_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") + .run(2, "all", 1, "", ""); + + const service = createService(db); + expect(await service.listPublicAgentDirectory()).toEqual([{ + agent_id: "public-agent", + agent_name: "Public Agent", + status: "normal", + inbound_topic: "", + outbound_topic: "", + users: ["alice", "bob"], + is_public: true + }]); +}); + +it("rejects stale agent user policy versions", () => { + const service = createService(createDb()); + + expect(() => service.setAgentUserPolicy(1, { + mode: "all", + policy_version: 2 + })).toThrowError(/updated by another request/); +}); diff --git a/packages/daedalus-agent-management-web/server/test/app-smoke.test.js b/packages/daedalus-agent-management-web/server/test/app-smoke.test.js new file mode 100644 index 0000000000000000000000000000000000000000..36d348a85b917ee6f9e3aa640f0bf6dc050b6044 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/app-smoke.test.js @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const { createServerApp } = await import("../src/app.js"); + +function listen(app) { + return new Promise((resolve) => { + const server = app.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => error ? reject(error) : resolve()); + }); +} + +it("assembles the management server and maps application and parser errors", async () => { + const { app, env, services } = createServerApp(); + expect(env).toBeDefined(); + expect(services.authService).toBeDefined(); + expect(services.updateNotifier).toBeDefined(); + + const server = await listen(app); + const address = server.address(); + const baseUrl = `http://127.0.0.1:${address.port}`; + + try { + const docsPage = await fetch(`${baseUrl}/doc`); + expect(docsPage.status).toBe(200); + expect(docsPage.headers.get("content-type")).toContain("text/html"); + expect(await docsPage.text()).toContain("/doc/openapi.json"); + + const docsAsset = await fetch(`${baseUrl}/doc/assets/swagger-ui.css`); + expect(docsAsset.status).toBe(200); + expect(docsAsset.headers.get("content-type")).toContain("text/css"); + + const docsJsonResponse = await fetch(`${baseUrl}/doc/openapi.json`); + expect(docsJsonResponse.status).toBe(200); + const docsJson = await docsJsonResponse.json(); + expect(docsJson.openapi).toBe("3.0.3"); + const documentedOperationCount = Object.values(docsJson.paths) + .reduce((count, pathItem) => count + Object.keys(pathItem).length, 0); + expect(documentedOperationCount).toBe(5); + expect(Object.keys(docsJson.paths).every((pathName) => pathName.startsWith("/external/"))).toBe(true); + expect(docsJson.paths["/external/agents"].get.parameters).toEqual( + jasmine.arrayContaining([ + jasmine.objectContaining({ name: "userName", in: "query", required: false }), + jasmine.objectContaining({ name: "id", in: "query", required: false }) + ]) + ); + expect(docsJson.components.schemas.ExternalAgentDirectoryItem.properties.is_public).toEqual( + jasmine.objectContaining({ type: "boolean" }) + ); + + const badLogin = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}" + }); + expect(badLogin.status).toBe(400); + expect(await badLogin.json()).toEqual(jasmine.objectContaining({ code: "bad_request" })); + + const invalidJson = await fetch(`${baseUrl}/api/auth/login`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{" + }); + expect(invalidJson.status).toBe(500); + expect(await invalidJson.json()).toEqual(jasmine.objectContaining({ code: "internal_error" })); + + services.portalService.getSettings = () => { + throw {}; + }; + const unknownError = await fetch(`${baseUrl}/api/portal`); + expect(unknownError.status).toBe(500); + expect(await unknownError.json()).toEqual({ + code: "internal_error", + message: "服务器内部错误" + }); + } finally { + await close(server); + } +}); + +it("supports anonymous public external agent directories and id identity aliases", async () => { + const { app, services } = createServerApp(); + const identities = []; + services.externalService.listAgentsForUser = async (identity) => { + identities.push(identity); + return [{ agent_id: "public-agent", is_public: true }]; + }; + + const server = await listen(app); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + try { + const anonymousResponse = await fetch(`${baseUrl}/api/external/agents`); + expect(anonymousResponse.status).toBe(200); + expect(await anonymousResponse.json()).toEqual({ + items: [{ agent_id: "public-agent", is_public: true }] + }); + + const idResponse = await fetch(`${baseUrl}/api/external/agents?id=alice`); + expect(idResponse.status).toBe(200); + expect(identities).toEqual([undefined, "alice"]); + } finally { + await close(server); + } +}); + +it("serves the frontend bundle when a dist directory exists", async () => { + const originalDirectory = process.cwd(); + const fixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-app-dist-")); + fs.mkdirSync(path.join(fixtureDirectory, "dist")); + fs.writeFileSync(path.join(fixtureDirectory, "dist", "index.html"), "test", "utf8"); + let server; + + try { + process.chdir(fixtureDirectory); + server = await listen(createServerApp().app); + const response = await fetch(`http://127.0.0.1:${server.address().port}/`); + expect(response.status).toBe(200); + expect(await response.text()).toContain("test"); + } finally { + if (server) { + await close(server); + } + process.chdir(originalDirectory); + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/auth-audit-services.test.js b/packages/daedalus-agent-management-web/server/test/auth-audit-services.test.js new file mode 100644 index 0000000000000000000000000000000000000000..66ae0f36fd3c475fbcff51753786c12e72102c96 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/auth-audit-services.test.js @@ -0,0 +1,229 @@ +import { DatabaseSync } from "node:sqlite"; + +import { AuditLogService } from "../src/services/audit-log-service.js"; +import { AuthService } from "../src/services/auth-service.js"; +import { hashPassword, verifyPassword } from "../src/utils/security.js"; +import { toChinaISOString } from "../src/utils/china-time.js"; + +function createDatabase() { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE users ( + id INTEGER PRIMARY KEY, + role TEXT NOT NULL, + username TEXT NOT NULL UNIQUE, + display_name TEXT NOT NULL, + status TEXT NOT NULL, + password_hash TEXT NOT NULL, + must_change_password INTEGER NOT NULL DEFAULT 0, + is_builtin INTEGER NOT NULL DEFAULT 0, + tags_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL DEFAULT '' + ); + CREATE TABLE sessions ( + token TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL + ); + CREATE TABLE audit_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + username TEXT NOT NULL, + action TEXT NOT NULL, + detail TEXT NOT NULL, + created_at TEXT NOT NULL + ); + `); + return db; +} + +function insertUser(db, overrides = {}) { + const user = { + id: 1, + role: "daedalus-admin", + username: "admin", + displayName: "Administrator", + status: "active", + passwordHash: hashPassword("secret"), + mustChangePassword: 0, + isBuiltin: 0, + tagsJson: '["ops"]', + ...overrides + }; + db.prepare(` + INSERT INTO users ( + id, role, username, display_name, status, password_hash, + must_change_password, is_builtin, tags_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + user.id, + user.role, + user.username, + user.displayName, + user.status, + user.passwordHash, + user.mustChangePassword, + user.isBuiltin, + user.tagsJson + ); + return user; +} + +function expectStatus(action, status) { + let error; + try { + action(); + } catch (caught) { + error = caught; + } + expect(error?.status).toBe(status); +} + +describe("AuthService", () => { + let db; + let service; + + beforeEach(() => { + db = createDatabase(); + service = new AuthService({ db, env: { sessionTtlHours: 12 } }); + }); + + afterEach(() => { + db.close(); + }); + + it("maps users and resolves local API users through the documented fallback order", () => { + expect(service.mapUser(null)).toBeNull(); + + insertUser(db, { tagsJson: "invalid", isBuiltin: 1, mustChangePassword: 1 }); + expect(service.getLocalApiUser()).toEqual({ + id: 1, + role: "daedalus-admin", + username: "admin", + display_name: "Administrator", + status: "active", + is_builtin: true, + must_change_password: true, + tags: [] + }); + + db.prepare("DELETE FROM users").run(); + insertUser(db, { id: 2, username: "fallback", isBuiltin: 0, tagsJson: "[]" }); + expect(service.getLocalApiUser().username).toBe("fallback"); + + db.prepare("DELETE FROM users").run(); + expect(service.getLocalApiUser()).toBeNull(); + }); + + it("creates, validates, expires, and removes sessions", () => { + insertUser(db); + const token = service.createSession(1); + expect(token).toMatch(/^[a-f0-9]{64}$/); + expect(service.getSessionUser(token).username).toBe("admin"); + + db.prepare("INSERT INTO sessions (token, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)") + .run("expired", 1, "2000-01-01T00:00:00.000+08:00", toChinaISOString()); + service.cleanupExpiredSessions(); + expect(db.prepare("SELECT token FROM sessions WHERE token = ?").get("expired")).toBeUndefined(); + + service.logout(token); + expectStatus(() => service.getSessionUser(token), 401); + }); + + it("rejects every invalid login condition and returns a session for valid credentials", () => { + insertUser(db); + insertUser(db, { id: 2, username: "wrong-role", role: "viewer" }); + insertUser(db, { id: 3, username: "disabled", status: "disabled" }); + insertUser(db, { id: 4, username: "bad-hash", passwordHash: "invalid" }); + + expectStatus(() => service.login({ username: "", password: "secret" }), 400); + expectStatus(() => service.login({ username: "admin", password: "" }), 400); + expectStatus(() => service.login({ username: "missing", password: "secret" }), 401); + expectStatus(() => service.login({ username: "wrong-role", password: "secret" }), 401); + expectStatus(() => service.login({ username: "disabled", password: "secret" }), 401); + expectStatus(() => service.login({ username: "bad-hash", password: "secret" }), 401); + expectStatus(() => service.login({ username: "admin", password: "wrong" }), 401); + + const result = service.login({ username: "admin", password: "secret" }); + expect(result.user.username).toBe("admin"); + expect(result.token).toMatch(/^[a-f0-9]{64}$/); + }); + + it("enforces administrator password-change and reset policies", () => { + insertUser(db); + insertUser(db, { id: 2, username: "viewer", role: "viewer" }); + insertUser(db, { id: 3, username: "builtin", isBuiltin: 1 }); + insertUser(db, { id: 4, username: "admin-alias" }); + insertUser(db, { id: 5, username: "resettable" }); + + expectStatus(() => service.assertAdmin(null), 403); + expectStatus(() => service.assertAdmin({ role: "viewer" }), 403); + expect(() => service.assertAdmin({ role: "daedalus-admin" })).not.toThrow(); + + expectStatus(() => service.changePassword(999, { currentPassword: "secret", nextPassword: "next" }), 404); + expectStatus(() => service.changePassword(2, { currentPassword: "secret", nextPassword: "next" }), 403); + expectStatus(() => service.changePassword(1, { currentPassword: "wrong", nextPassword: "next" }), 400); + expect(service.changePassword(1, { currentPassword: "secret", nextPassword: "next" })).toEqual({ ok: true }); + const changed = db.prepare("SELECT * FROM users WHERE id = ?").get(1); + expect(verifyPassword("next", changed.password_hash)).toBeTrue(); + expect(changed.must_change_password).toBe(0); + + expectStatus(() => service.resetPassword(999, "reset"), 404); + expectStatus(() => service.resetPassword(3, "reset"), 409); + db.prepare("UPDATE users SET username = 'admin-original' WHERE id = 1").run(); + db.prepare("UPDATE users SET username = 'admin', is_builtin = 0 WHERE id = 4").run(); + expectStatus(() => service.resetPassword(4, "reset"), 409); + expect(service.resetPassword(5, "reset")).toEqual({ ok: true }); + const reset = db.prepare("SELECT * FROM users WHERE id = ?").get(5); + expect(verifyPassword("reset", reset.password_hash)).toBeTrue(); + expect(reset.must_change_password).toBe(1); + }); +}); + +describe("AuditLogService", () => { + let db; + let service; + + beforeEach(() => { + db = createDatabase(); + service = new AuditLogService({ db }); + }); + + afterEach(() => { + db.close(); + }); + + it("writes explicit and defaulted audit fields", () => { + service.write({ userId: 7, username: "admin", action: "create", detail: "item" }); + service.write({ userId: 0, username: "", action: "delete", detail: "" }); + + const rows = db.prepare("SELECT * FROM audit_logs ORDER BY id").all(); + expect(rows[0]).toEqual(jasmine.objectContaining({ + user_id: 7, + username: "admin", + action: "create", + detail: "item" + })); + expect(rows[1]).toEqual(jasmine.objectContaining({ + user_id: null, + username: "", + action: "delete", + detail: "" + })); + }); + + it("normalizes audit pagination and caps page size", () => { + for (let index = 0; index < 3; index += 1) { + service.write({ action: `action-${index}` }); + } + + expect(service.list()).toEqual(jasmine.objectContaining({ page: 1, pageSize: 50, total: 3 })); + expect(service.list({})).toEqual(jasmine.objectContaining({ page: 1, pageSize: 50 })); + expect(service.list({ page: 0, pageSize: 0 })).toEqual(jasmine.objectContaining({ page: 1, pageSize: 50 })); + expect(service.list({ page: -2, pageSize: 500 })).toEqual(jasmine.objectContaining({ page: 1, pageSize: 200 })); + const secondPage = service.list({ page: "2", pageSize: "1" }); + expect(secondPage).toEqual(jasmine.objectContaining({ page: 2, pageSize: 1, total: 3 })); + expect(secondPage.items.length).toBe(1); + }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/auth-middleware.test.js b/packages/daedalus-agent-management-web/server/test/auth-middleware.test.js index 16d4ee05c9fd8eed73a06bb6801fce68c5d027aa..b6b148e3db12c195c36011f30bc12cec22f47a0a 100644 --- a/packages/daedalus-agent-management-web/server/test/auth-middleware.test.js +++ b/packages/daedalus-agent-management-web/server/test/auth-middleware.test.js @@ -16,6 +16,15 @@ function runMiddleware(middleware, { headers = {}, remoteAddress = "" } = {}) { }); } +function runMiddlewareWithRequest(middleware, req) { + return new Promise((resolve) => { + middleware(req, {}, (error) => resolve({ + error: error ?? null, + req + })); + }); +} + it("injects local api user for localhost requests", async () => { const middleware = createAuthMiddleware({ getLocalApiUser() { @@ -69,3 +78,44 @@ it("requires bearer token for non-localhost requests", async () => { expect(ok.error).toBeNull(); expect(ok.req.authToken).toBe("token-1"); }); + +it("falls back to request IP and forwards malformed or rejected credentials", async () => { + const rejected = new Error("expired"); + const middleware = createAuthMiddleware({ + getLocalApiUser() { + return { id: 1 }; + }, + getSessionUser(token) { + if (token === "expired") { + throw rejected; + } + return { id: 2 }; + } + }); + + const local = await runMiddlewareWithRequest(middleware, { + headers: {}, + ip: "::1" + }); + expect(local.error).toBeNull(); + expect(local.req.authToken).toBe("localhost"); + + const malformed = await runMiddlewareWithRequest(middleware, { + headers: { authorization: "Basic token" }, + ip: "10.0.0.8" + }); + expect(malformed.error.status).toBe(401); + + const failed = await runMiddlewareWithRequest(middleware, { + headers: { authorization: "Bearer expired" }, + socket: {}, + ip: "10.0.0.8" + }); + expect(failed.error).toBe(rejected); + + const emptyAddress = await runMiddlewareWithRequest(middleware, { + headers: { authorization: "Bearer valid" } + }); + expect(emptyAddress.error).toBeNull(); + expect(emptyAddress.req.currentUser).toEqual({ id: 2 }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/background-services.test.js b/packages/daedalus-agent-management-web/server/test/background-services.test.js index 26eac63f79392dd568c6bf880d2ec5707c3510cf..8b9af949ec20a56ccd9e5418df1a2974cfa49cb0 100644 --- a/packages/daedalus-agent-management-web/server/test/background-services.test.js +++ b/packages/daedalus-agent-management-web/server/test/background-services.test.js @@ -194,6 +194,74 @@ it("refreshes system status one minute after startup and then every minute", asy } }); +it("keeps successful scheduled background work quiet", async () => { + const stderrWrite = spyOn(process.stderr, "write").and.callFake(() => true); + const timers = installFakeTimers(); + const calls = []; + const taskFactory = (label) => async ({ trigger }) => { + calls.push(`${label}:${trigger}`); + return { status: "success" }; + }; + const background = startBackgroundServices({ + env: {}, + services: createServices({ calls, taskFactory }) + }); + + try { + await flushMicrotasks(); + stderrWrite.calls.reset(); + + await timers.runTimer(findActiveTimerByDelay(timers, 60 * 1000)); + await timers.runTimer(findActiveTimerByDelay(timers, 60 * 1000)); + await timers.runTimer(findActiveTimerByDelay(timers, 2 * 60 * 1000)); + + const output = stderrWrite.calls.allArgs().map(([value]) => String(value)).join("\n"); + expect(output).not.toContain("System status refresh starting"); + expect(output).not.toContain("System status refresh finished"); + expect(output).not.toContain("Scheduled agent usage sync starting"); + expect(output).not.toContain("Scheduled agent usage sync finished"); + expect(output).not.toContain("Scheduled catalog sync starting"); + expect(output).not.toContain("Scheduled catalog sync finished"); + } finally { + await background.stop(); + timers.restore(); + } +}); + +it("still logs scheduled background failures", async () => { + const stderrWrite = spyOn(process.stderr, "write").and.callFake(() => true); + const timers = installFakeTimers(); + const calls = []; + const taskFactory = (label) => async ({ trigger }) => { + calls.push(`${label}:${trigger}`); + if (trigger === "scheduled") { + throw new Error(`${label} failed`); + } + return { status: "success" }; + }; + const background = startBackgroundServices({ + env: {}, + services: createServices({ calls, taskFactory }) + }); + + try { + await flushMicrotasks(); + stderrWrite.calls.reset(); + + await timers.runTimer(findActiveTimerByDelay(timers, 60 * 1000)); + await timers.runTimer(findActiveTimerByDelay(timers, 60 * 1000)); + await timers.runTimer(findActiveTimerByDelay(timers, 2 * 60 * 1000)); + + const output = stderrWrite.calls.allArgs().map(([value]) => String(value)).join("\n"); + expect(output).toContain("System status refresh target failed"); + expect(output).toContain("Scheduled agent usage sync failed"); + expect(output).toContain("Scheduled catalog sync failed"); + } finally { + await background.stop(); + timers.restore(); + } +}); + it("does not schedule token sync when startup catalog sync fails", async () => { spyOn(console, "error").and.stub(); diff --git a/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js b/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js index 779583c01ed2fc89aea0bfafbea3644ad6e37038..5c7eeb729e319df50f50f961de36ffdae381faed 100644 --- a/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/catalog-sync-service.test.js @@ -295,6 +295,40 @@ function createDb() { updated_at TEXT NOT NULL ); + CREATE TABLE agent_business_system_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + policy_version INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE TABLE agent_business_system_access ( + agent_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, business_system_id), + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE, + FOREIGN KEY (business_system_id) REFERENCES business_systems(id) ON DELETE CASCADE + ); + + CREATE TABLE agent_mcp_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + policy_version INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE + ); + + CREATE TABLE template_mcp_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (template_id) REFERENCES agent_templates(id) ON DELETE CASCADE + ); + `); for (const tableName of [ @@ -530,11 +564,23 @@ it("syncs startup catalog snapshot with one management action runtime call", asy inboundTopic: "daedalus-agent/agent/alpha/inbound", outboundTopic: "daedalus-agent/agent/alpha/outbound" }); + expect(db.prepare(` + SELECT policy.mode, policy.policy_version + FROM agent_mcp_policies policy + JOIN agents agent ON agent.id = policy.agent_id + WHERE agent.slug = 'alpha' + `).get()).toEqual({ mode: "all", policy_version: 1 }); expect(db.prepare("SELECT template_name, is_builtin FROM agent_templates ORDER BY template_name").all()).toEqual([ jasmine.objectContaining({ template_name: "default", is_builtin: 1 }) ]); const syncedTemplate = db.prepare("SELECT remote_result_json FROM agent_templates WHERE template_name = ?").get("default"); expect(JSON.parse(syncedTemplate.remote_result_json)).toEqual({ builtIn: true }); + expect(db.prepare(` + SELECT policy.mode + FROM template_mcp_policies policy + JOIN agent_templates template ON template.id = policy.template_id + WHERE template.template_name = 'default' + `).get()).toEqual({ mode: "all" }); expect(db.prepare("SELECT slug, is_builtin FROM skills ORDER BY slug").all()).toEqual([ jasmine.objectContaining({ slug: "global-skill", is_builtin: 1 }) ]); @@ -545,6 +591,15 @@ it("syncs startup catalog snapshot with one management action runtime call", asy jasmine.objectContaining({ application_name: "crm", is_builtin: 1 }), jasmine.objectContaining({ application_name: "legacy", is_builtin: 0 }) ]); + expect(db.prepare(` + SELECT p.mode, p.policy_version + FROM agent_business_system_policies p + JOIN agents a ON a.id = p.agent_id + WHERE a.slug = 'alpha' + `).get()).toEqual({ + mode: "all", + policy_version: 1 + }); expect(db.prepare("SELECT provider, scheme, port, status FROM business_systems WHERE application_name = ?").get("crm")).toEqual({ provider: "hzg", scheme: "http", diff --git a/packages/daedalus-agent-management-web/server/test/coverage-gaps.test.js b/packages/daedalus-agent-management-web/server/test/coverage-gaps.test.js new file mode 100644 index 0000000000000000000000000000000000000000..50bd2cbc0cc248731f2cd9f9a1236a65fe6ed774 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/coverage-gaps.test.js @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { LocalArtifactStore } from "../src/infra/local/local-artifact-store.js"; +import { ManagementActionClient } from "../src/management/management-action-client.js"; + +function tempDirectory(prefix) { + return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); +} + +function createDb() { + const statements = []; + return { + statements, + prepare(sql) { + return { + run: (...args) => { + statements.push({ sql, args }); + return {}; + } + }; + } + }; +} + +describe("coverage boundary contracts", () => { + it("keeps local artifact paths physical and handles missing objects", async () => { + const root = tempDirectory("local-artifact-gap-"); + const store = new LocalArtifactStore(root); + try { + expect(() => store.resolve("")).toThrowError("Invalid local artifact path"); + expect(() => store.resolve("../escape.zip")).toThrowError("Invalid local artifact path"); + expect(() => store.resolve("nested\\escape.zip")).toThrowError("Invalid local artifact path"); + + const uploaded = await store.uploadAdminArtifact({ + kind: "templates", + file: { originalname: "hello world.zip", buffer: Buffer.from("archive") } + }); + expect(uploaded.bucket).toBe("local"); + expect(uploaded.objectKey).toMatch(/^templates\/\d{4}-\d{2}-\d{2}\/[0-9a-f-]+-hello-world\.zip$/); + expect(await store.downloadObject("local", uploaded.objectKey)).toEqual(Buffer.from("archive")); + await store.deleteObject("s3", uploaded.objectKey); + await store.deleteObject("local", `${uploaded.objectKey.split("/").slice(0, -1).join("/")}/missing.zip`); + await store.deleteObject("local", uploaded.objectKey); + await expectAsync(store.downloadObject("local", uploaded.objectKey)).toBeRejected(); + await expectAsync(store.downloadObject("other", "anything")).toBeRejectedWithError(/only accepts/); + await store.deleteObject("local", ""); + await store.deleteObject("other", ""); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("covers local artifact root and parent physicality checks", async () => { + const rootFile = path.join(os.tmpdir(), `local-artifact-root-${Date.now()}.file`); + fs.writeFileSync(rootFile, "file"); + try { + const store = new LocalArtifactStore(rootFile); + await expectAsync(store.uploadAdminArtifact({ + kind: "templates", + file: { buffer: Buffer.from("x") } + })).toBeRejectedWithError(/EEXIST|already exists/); + await expectAsync(store.downloadObject("local", "templates/file.zip")) + .toBeRejectedWithError(/root is not a physical directory/); + } finally { + fs.rmSync(rootFile, { force: true }); + } + + const root = tempDirectory("local-artifact-parent-"); + try { + fs.writeFileSync(path.join(root, "templates"), "not-a-directory"); + const store = new LocalArtifactStore(root); + await expectAsync(store.uploadAdminArtifact({ + kind: "templates", + file: { originalname: "ok.zip", buffer: Buffer.from("x") } + })).toBeRejectedWithError(/physical directories and files/); + const parentResult = await store.assertPhysicalPath("templates", { includeLeaf: false }); + expect(parentResult.exists).toBeTrue(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("uses a safe default name and rejects symbolic artifact roots", async () => { + const root = tempDirectory("local-artifact-default-"); + const outside = tempDirectory("local-artifact-root-outside-"); + const link = path.join(root, "link-root"); + try { + const store = new LocalArtifactStore(root); + const artifact = await store.uploadAdminArtifact({ + kind: "templates", + file: { buffer: Buffer.from("default") } + }); + expect(artifact.objectKey).toMatch(/-artifact\.zip$/); + + fs.rmSync(root, { recursive: true, force: true }); + fs.mkdirSync(root, { recursive: true }); + fs.symlinkSync(outside, link, process.platform === "win32" ? "junction" : "dir"); + const symlinkStore = new LocalArtifactStore(link); + await expectAsync(symlinkStore.uploadAdminArtifact({ + kind: "templates", + file: { originalname: "link.zip", buffer: Buffer.from("link") } + })).toBeRejectedWithError(/root is not a physical directory/); + await expectAsync(symlinkStore.downloadObject("local", "missing.zip")) + .toBeRejectedWithError(/physical directory/); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } + }); + + it("covers management client cached runtimes and failed responses", async () => { + const db = createDb(); + const runtime = { + isConfigured: () => false, + start: async () => undefined, + stop: async () => undefined, + executeRequest: async (request) => ({ + requestId: request.requestId, + action: request.action, + ok: false, + error: request.action === "notify-fail" ? { message: "notify failed" } : { message: "call failed" } + }) + }; + const client = new ManagementActionClient({ + env: { managementTimeoutMs: 50 }, + db, + runtimeFactory: () => runtime + }); + + expect(client.isConfigured()).toBeTrue(); + await client.start(); + expect(client.isConfigured()).toBeFalse(); + expect(await client.loadRuntime()).toBe(runtime); + expect(await client.loadRuntime()).toBe(runtime); + await expectAsync(client.call("call-fail", {})).toBeRejectedWithError(/call failed/); + await client.notify("notify-fail", {}); + await new Promise((resolve) => setTimeout(resolve, 0)); + await client.stop(); + await client.stop(); + expect(db.statements.some((item) => item.sql.includes("error_json"))).toBeTrue(); + }); + + it("loads the default runtime and handles runtimes without lifecycle methods", async () => { + const db = createDb(); + const runtimeModulePath = "data:text/javascript,export function createManagementActionRuntime(){return {executeRequest:async r=>({requestId:r.requestId,action:r.action,ok:true})}}"; + const client = new ManagementActionClient({ + env: {}, + db, + runtimeModulePath + }); + + expect(client.isConfigured()).toBeTrue(); + await client.start(); + await client.stop(); + await client.stop(); + expect(await client.call("no-result")).toBeUndefined(); + }); + + it("covers fallback logging and returned management errors", async () => { + const db = createDb(); + const runtime = { + executeRequest: async (request) => ({ requestId: request.requestId, action: request.action, ok: false }) + }; + const client = new ManagementActionClient({ env: {}, db, runtimeFactory: () => runtime }); + + client.failLog("manual", null); + await expectAsync(client.call("error-without-message")).toBeRejectedWithError(/returned an error/); + + const accepted = await client.notify("notify-ok"); + expect(accepted.accepted).toBeTrue(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(db.statements.some((item) => item.sql.includes("UPDATE management_requests"))).toBeTrue(); + }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js b/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js index a0c22a7b3f6bdf1f0e2daf9d77ecf7f74b238934..2330b1fbe711feaf4fcf6e2550465321c4de573b 100644 --- a/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js +++ b/packages/daedalus-agent-management-web/server/test/db-reset-migration.test.js @@ -57,7 +57,7 @@ it("resets drifted legacy schemas to the latest database structure", async () => key TEXT PRIMARY KEY, value TEXT NOT NULL ); - INSERT INTO schema_meta (key, value) VALUES ('schema_version', '14'); + INSERT INTO schema_meta (key, value) VALUES ('schema_version', '999'); CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -151,6 +151,21 @@ it("resets drifted legacy schemas to the latest database structure", async () => FROM sqlite_master WHERE type = 'table' AND name = 'agent_skill_bindings' `).get(); + const agentBusinessSystemPolicyTable = db.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'agent_business_system_policies' + `).get(); + const agentBusinessSystemAccessTable = db.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'agent_business_system_access' + `).get(); + const agentUserPolicyTable = db.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name = 'agent_user_policies' + `).get(); const sessionIdIndexes = db.prepare(` SELECT name FROM sqlite_master @@ -170,7 +185,7 @@ it("resets drifted legacy schemas to the latest database structure", async () => const settingsColumns = db.prepare("PRAGMA table_info(settings)").all().map((row) => row.name); - expect(schemaVersion?.value).toBe("12"); + expect(schemaVersion?.value).toBe("17"); expect(settingsColumns).toContain("logo_mime_type"); expect(settingsColumns).toContain("logo_data"); expect(agentUsersTable?.name).toBe("agent_users"); @@ -221,6 +236,9 @@ it("resets drifted legacy schemas to the latest database structure", async () => expect(sessionCookieColumns.find((column) => column.name === "cookie")?.notnull).toBe(1); expect(externalSessionColumns.find((column) => column.name === "session_id")?.pk).toBe(1); expect(agentSkillBindingsTable).toBeUndefined(); + expect(agentBusinessSystemPolicyTable?.name).toBe("agent_business_system_policies"); + expect(agentBusinessSystemAccessTable?.name).toBe("agent_business_system_access"); + expect(agentUserPolicyTable?.name).toBe("agent_user_policies"); expect(sessionIdIndexes?.name).toBe("idx_system_invocation_logs_session_id"); expect(artifactTraceIdIndexes?.name).toBe("idx_system_invocation_artifacts_trace_id"); expect(() => db.prepare(` @@ -343,7 +361,7 @@ it("migrates v1 agents to template relationships without dropping data", async ( ORDER BY slug `).all(); - expect(schemaVersion?.value).toBe("12"); + expect(schemaVersion?.value).toBe("17"); expect(agentColumns).toContain("template_name"); expect(agentColumns).toContain("llm_model_ref"); expect(agentColumns).toContain("fallback_llm_model_ref"); @@ -465,7 +483,7 @@ it("migrates v3 templates to default llm columns and removes template descriptio const templateColumns = db.prepare("PRAGMA table_info(agent_templates)").all().map((row) => row.name); const template = db.prepare("SELECT template_name, default_llm_model_ref FROM agent_templates WHERE template_name = ?").get("default"); - expect(schemaVersion?.value).toBe("12"); + expect(schemaVersion?.value).toBe("17"); expect(templateColumns).toContain("default_llm_provider_id"); expect(templateColumns).toContain("default_llm_model_id"); expect(templateColumns).toContain("default_llm_model_ref"); @@ -549,7 +567,7 @@ it("migrates v4 settings through uploaded logo columns without dropping data", a const schemaVersion = db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get(); const settings = db.prepare("SELECT portal_name, brand_subtitle, theme_color, logo_mime_type, logo_data FROM settings WHERE id = 1").get(); - expect(schemaVersion?.value).toBe("12"); + expect(schemaVersion?.value).toBe("17"); expect(settings).toEqual({ portal_name: "智能体集群管理控制台", brand_subtitle: "Agents Management Console", @@ -690,7 +708,7 @@ it("migrates v5 templates by dropping template description without dropping data WHERE template_name = ? `).get("default"); - expect(schemaVersion?.value).toBe("12"); + expect(schemaVersion?.value).toBe("17"); expect(templateColumns).not.toContain("description"); expect(template).toEqual({ id: 1, @@ -798,7 +816,7 @@ it("migrates v10 users and portal settings to the daedalus-agent identity", asyn `).get(); const user = db.prepare("SELECT id, role, username, display_name, password_hash FROM users WHERE id = 1").get(); - expect(schemaVersion?.value).toBe("12"); + expect(schemaVersion?.value).toBe("17"); expect(settings).toEqual({ portal_name: "智能体集群管理控制台", brand_subtitle: "Agents Management Console", @@ -879,7 +897,7 @@ it("migrates the configured huozige host from v11 into runtime settings", async WHERE application_name = 'crm' `).get(); - expect(schemaVersion?.value).toBe("12"); + expect(schemaVersion?.value).toBe("17"); expect(systemColumns).not.toContain("host"); expect(runtimeHost?.setting_value).toBe("configured.example.com"); expect(system).toEqual({ @@ -903,3 +921,49 @@ it("migrates the configured huozige host from v11 into runtime settings", async restoreEnvironmentValue("DA_USER_ENV_JSON", previousUserEnvJson); } }); + +it("migrates v12 agents to default all business system access", async () => { + const previousDataDir = process.env.DA_WEB_DATA_DIR; + const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-web-db-v12-v13-test-")); + const dbFile = path.join(dataDir, "management-console.db"); + + try { + process.env.DA_WEB_DATA_DIR = dataDir; + const seedModuleUrl = new URL(`../src/db/index.js?case=${Date.now()}-v12-seed`, import.meta.url); + const { db: seedDb } = await import(seedModuleUrl.href); + seedDb.prepare(` + INSERT INTO agents ( + slug, agent_name, description, docs_content, template_name, + status, tags_json, daily_limit, usage_snapshot_json, remote_state_json, + created_at, updated_at + ) VALUES (?, ?, '', '', 'default', 'normal', '[]', -1, '{}', '{}', ?, ?) + `).run("legacy-agent", "Legacy Agent", "2026-08-10T00:00:00.000Z", "2026-08-10T00:00:00.000Z"); + seedDb.close(); + + const setupDb = new DatabaseSync(dbFile); + setupDb.exec(` + DROP TABLE agent_business_system_access; + DROP TABLE agent_business_system_policies; + UPDATE schema_meta SET value = '12' WHERE key = 'schema_version'; + `); + setupDb.close(); + + const moduleUrl = new URL(`../src/db/index.js?case=${Date.now()}-v12-v13`, import.meta.url); + const { db } = await import(moduleUrl.href); + const policy = db.prepare(` + SELECT p.mode, p.policy_version + FROM agent_business_system_policies p + JOIN agents a ON a.id = p.agent_id + WHERE a.slug = 'legacy-agent' + `).get(); + + expect(db.prepare("SELECT value FROM schema_meta WHERE key = 'schema_version'").get()?.value).toBe("17"); + expect(policy).toEqual({ + mode: "all", + policy_version: 1 + }); + db.close(); + } finally { + restoreDataDir(previousDataDir); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/env-config.test.js b/packages/daedalus-agent-management-web/server/test/env-config.test.js index e8e26de17868fad3028bdf2ec87f754c1c264c46..c59a2baf8e82767363c4ecbc2a8743933a0a1d68 100644 --- a/packages/daedalus-agent-management-web/server/test/env-config.test.js +++ b/packages/daedalus-agent-management-web/server/test/env-config.test.js @@ -48,6 +48,7 @@ it("defaults web data dir under DA_ROOT management directory", () => { try { const env = loadEnv(); expect(env.dataDir).toBe(path.resolve("/var/daedalus-agent/management/data")); + expect(env.localArtifactsDir).toBe(path.resolve("/var/daedalus-agent/management/data/local-artifacts")); } finally { if (originalRoot === undefined) { delete process.env.DA_ROOT; diff --git a/packages/daedalus-agent-management-web/server/test/external-service.test.js b/packages/daedalus-agent-management-web/server/test/external-service.test.js index f7f5ad1d443c0790b6070415daf839b4e33f15d7..5bbae9f6b2b5cbe9bdcfb0a20a99605a207ee44f 100644 --- a/packages/daedalus-agent-management-web/server/test/external-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/external-service.test.js @@ -59,6 +59,20 @@ function createDb() { updated_at TEXT NOT NULL DEFAULT '2026-05-26T00:00:00.000Z' ); + CREATE TABLE agent_business_system_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + policy_version INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE agent_business_system_access ( + agent_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, business_system_id) + ); + INSERT INTO agent_users (id, username) VALUES (1, 'zhangsan'); INSERT INTO agents (id, slug, agent_name, status, remote_state_json) VALUES ( @@ -72,6 +86,14 @@ function createDb() { INSERT INTO business_systems ( id, provider, application_name, scheme, port, status ) VALUES (1, 'hzg', 'crm-app', 'https', 443, 'active'); + INSERT INTO business_systems ( + id, provider, application_name, scheme, port, status + ) VALUES (2, 'hzg', 'erp-app', 'https', 443, 'active'); + INSERT INTO agent_business_system_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES ( + 1, 'all', 1, '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z' + ); `); return db; @@ -83,6 +105,9 @@ function createService(db, overrides = {}) { agentService: { async listAgentDirectoryForUser() { return []; + }, + async listPublicAgentDirectory() { + return []; } }, systemService: { @@ -140,6 +165,32 @@ it("creates and reuses external session ids per user-agent pair and returns topi expect(auditWrites[0].detail).toContain("agent状态=normal"); }); +it("allows current and future agent users when the user policy is all", () => { + const db = createDb(); + db.exec(` + CREATE TABLE agent_user_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + policy_version INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO agent_user_policies ( + agent_id, mode, policy_version, created_at, updated_at + ) VALUES (1, 'all', 1, '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z'); + INSERT INTO agent_users (id, username) VALUES (2, 'lisi'); + `); + + const service = createService(db); + const session = service.getOrCreateSession({ + userName: "lisi", + agentId: "agent-a" + }); + + expect(session.sessionId).toMatch(/^s-[1-9]{30}$/); + expect(() => service.resolveAuthorizedSystem(session.sessionId, "crm-app")).not.toThrow(); +}); + it("returns topics from catalog snapshot shaped remote state", () => { const db = createDb(); db.prepare(` @@ -227,21 +278,47 @@ it("lists every assigned agent for a user with status", async () => { async listAgentDirectoryForUser(username) { expect(username).toBe("zhangsan"); return [ - { agent_id: "agent-a", agent_name: "Agent Alpha", status: "normal" }, - { agent_id: "agent-disabled", agent_name: "Agent Disabled", status: "disabled" }, - { agent_id: "agent-overlimit", agent_name: "Agent Overlimit", status: "overlimit" } + { agent_id: "agent-a", agent_name: "Agent Alpha", status: "normal", is_public: true }, + { agent_id: "agent-disabled", agent_name: "Agent Disabled", status: "disabled", is_public: false }, + { agent_id: "agent-overlimit", agent_name: "Agent Overlimit", status: "overlimit", is_public: false } ]; } } }); expect(await service.listAgentsForUser("zhangsan")).toEqual([ - { agent_id: "agent-a", agent_name: "Agent Alpha", status: "normal" }, - { agent_id: "agent-disabled", agent_name: "Agent Disabled", status: "disabled" }, - { agent_id: "agent-overlimit", agent_name: "Agent Overlimit", status: "overlimit" } + { agent_id: "agent-a", agent_name: "Agent Alpha", status: "normal", is_public: true }, + { agent_id: "agent-disabled", agent_name: "Agent Disabled", status: "disabled", is_public: false }, + { agent_id: "agent-overlimit", agent_name: "Agent Overlimit", status: "overlimit", is_public: false } ]); }); +it("lists only public agents when the external directory user is omitted", async () => { + const db = createDb(); + const service = createService(db, { + agentService: { + async listPublicAgentDirectory() { + return [{ + agent_id: "public-agent", + agent_name: "Public Agent", + status: "normal", + is_public: true + }]; + }, + async listAgentDirectoryForUser() { + throw new Error("user directory should not be queried"); + } + } + }); + + expect(await service.listAgentsForUser()).toEqual([{ + agent_id: "public-agent", + agent_name: "Public Agent", + status: "normal", + is_public: true + }]); +}); + it("persists caller cookie on session creation and reuses it for external cookie lookup", async () => { const db = createDb(); const service = createService(db, { @@ -330,6 +407,83 @@ it("loads cached cookie and lazily creates missing hzg cookie", async () => { expect(createCount).toBe(1); }); +it("authorizes all active business systems by default", () => { + const db = createDb(); + db.prepare(` + INSERT INTO external_sessions (session_id, agent_user_id, agent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run("s-all", 1, 1, "2026-05-26T00:00:00.000Z", "2026-05-26T00:00:00.000Z"); + const service = createService(db); + + expect(service.resolveAuthorizedSystem("s-all", "erp-app")).toEqual(jasmine.objectContaining({ + application_name: "erp-app" + })); +}); + +it("allows only selected business systems", () => { + const db = createDb(); + db.prepare("UPDATE agent_business_system_policies SET mode = 'selected' WHERE agent_id = 1").run(); + db.prepare(` + INSERT INTO agent_business_system_access (agent_id, business_system_id) + VALUES (1, 1) + `).run(); + db.prepare(` + INSERT INTO external_sessions (session_id, agent_user_id, agent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run("s-selected", 1, 1, "2026-05-26T00:00:00.000Z", "2026-05-26T00:00:00.000Z"); + const service = createService(db); + + expect(service.resolveAuthorizedSystem("s-selected", "crm-app")).toEqual(jasmine.objectContaining({ + application_name: "crm-app" + })); + expect(() => service.resolveAuthorizedSystem("s-selected", "erp-app")).toThrowMatching((error) => ( + error.status === 403 && error.code === "forbidden" + )); +}); + +it("rechecks authorization before returning a cached cookie", async () => { + const db = createDb(); + const service = createService(db); + const session = service.getOrCreateSession({ + userName: "zhangsan", + agentId: "agent-a", + cookie: "ForguncyServer=cached-cookie" + }); + db.prepare("DELETE FROM agent_permissions WHERE agent_id = 1 AND agent_user_id = 1").run(); + + await expectAsync(service.getCookie(session.sessionId, "crm-app")).toBeRejectedWith( + jasmine.objectContaining({ status: 403, code: "forbidden" }) + ); +}); + +it("fails closed when the agent business system policy is missing", () => { + const db = createDb(); + db.prepare("DELETE FROM agent_business_system_policies WHERE agent_id = 1").run(); + db.prepare(` + INSERT INTO external_sessions (session_id, agent_user_id, agent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run("s-no-policy", 1, 1, "2026-05-26T00:00:00.000Z", "2026-05-26T00:00:00.000Z"); + const service = createService(db); + + expect(() => service.resolveAuthorizedSystem("s-no-policy", "crm-app")).toThrowMatching((error) => ( + error.status === 403 && error.code === "forbidden" + )); +}); + +it("rejects old sessions after the agent is disabled", () => { + const db = createDb(); + db.prepare(` + INSERT INTO external_sessions (session_id, agent_user_id, agent_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `).run("s-disabled", 1, 1, "2026-05-26T00:00:00.000Z", "2026-05-26T00:00:00.000Z"); + db.prepare("UPDATE agents SET status = 'disabled' WHERE id = 1").run(); + const service = createService(db); + + expect(() => service.resolveAuthorizedSystem("s-disabled", "crm-app")).toThrowMatching((error) => ( + error.status === 403 && error.code === "forbidden" + )); +}); + it("loads external context from session and agent records", async () => { const db = createDb(); db.prepare(` @@ -424,3 +578,127 @@ it("rejects oversized caller cookies before persisting sessions", () => { expect(db.prepare("SELECT COUNT(*) AS count FROM external_sessions").get().count).toBe(0); }); + +it("rejects invalid external session inputs and missing authorization records", async () => { + const db = createDb(); + const service = createService(db); + + expect(() => service.listAgentsForUser(" ")).toThrowError(/userName 不能为空/); + expect(() => service.getOrCreateSession({ userName: "", agentId: "agent-a" })) + .toThrowError(/userName 和 agentId 不能为空/); + expect(() => service.getOrCreateSession({ userName: "zhangsan", agentId: "missing" })) + .toThrowError(/数字员工不存在/); + expect(() => service.getOrCreateSession({ userName: "nobody", agentId: "agent-a" })) + .toThrowError(/无权访问/); + + db.prepare("DELETE FROM agent_users WHERE username = ?").run("zhangsan"); + expect(() => service.getOrCreateSession({ userName: "zhangsan", agentId: "agent-a" })) + .toThrowError(/无权访问/); + expect(() => service.resolveAuthorizedSystem("")) + .toThrowError(/sessionId is required/); + expect(() => service.resolveAuthorizedSystem("missing")) + .toThrowError(/Session is invalid/); + await expectAsync(service.getCookie("")) + .toBeRejectedWithError(/sessionId 不能为空/); + expect(() => service.getContext("")) + .toThrowError(/sessionId 不能为空/); +}); + +it("handles malformed snapshots and all remote topic aliases", () => { + const snapshots = [ + "not-json", + JSON.stringify({ "inbound-topic": "in-a", "outbound-topic": "out-a" }), + JSON.stringify({ topics: { inboundTopic: "in-b", outboundTopic: "out-b" } }), + JSON.stringify({ topics: { "inbound-topic": "in-c", "outbound-topic": "out-c" } }), + JSON.stringify({ topics: null }) + ]; + + for (const [index, snapshot] of snapshots.entries()) { + const db = createDb(); + db.prepare("UPDATE agents SET remote_state_json = ?, status = 'normal' WHERE slug = ?") + .run(snapshot, "agent-a"); + const service = createService(db); + const session = service.getOrCreateSession({ + userName: "zhangsan", + agentId: "agent-a", + cookie: index === 0 ? "" : undefined + }); + expect(session.sessionId).toMatch(/^s-[1-9]{30}$/); + } +}); + +it("rejects invalid business-system policies", () => { + const db = createDb(); + db.prepare("INSERT INTO external_sessions (session_id, agent_user_id, agent_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") + .run("s-invalid-policy", 1, 1, "now", "now"); + db.prepare("UPDATE agent_business_system_policies SET mode = 'invalid' WHERE agent_id = 1").run(); + const service = createService(db); + expect(() => service.resolveAuthorizedSystem("s-invalid-policy", "crm-app")) + .toThrowError(/policy is invalid/); +}); + +it("resolves selected policy defaults and reports unavailable systems", () => { + const db = createDb(); + db.prepare("UPDATE agent_business_system_policies SET mode = 'selected' WHERE agent_id = 1").run(); + db.prepare("INSERT INTO agent_business_system_access (agent_id, business_system_id) VALUES (1, 1)").run(); + db.prepare("INSERT INTO external_sessions (session_id, agent_user_id, agent_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") + .run("s-selected-default", 1, 1, "now", "now"); + const service = createService(db); + expect(service.resolveAuthorizedSystem("s-selected-default")).toEqual(jasmine.objectContaining({ + application_name: "crm-app" + })); + + const unavailable = createService(db, { + systemService: { + findSystemForInvocation() { return null; }, + findLatestActiveSystem() { return null; } + } + }); + expect(() => unavailable.resolveAuthorizedSystem("s-selected-default", "missing-app")) + .toThrowError(/does not exist/); +}); + +it("normalizes malformed invocation payloads and rejects unknown sessions", async () => { + const db = createDb(); + const recorded = []; + db.prepare("INSERT INTO external_sessions (session_id, agent_user_id, agent_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?)") + .run("s-record", 1, 1, "now", "now"); + const service = createService(db, { + systemService: { + async recordInvocation(value) { recorded.push(value); } + } + }); + + await service.recordInvocation({ + sessionId: "s-record", + applicationName: "CRM-App", + commandName: "Ping", + paramaters: "bad-json", + response: "bad-json", + method: "", + durationInMS: 0, + isOK: true + }); + await service.recordInvocation({ + sessionId: "s-record", + applicationName: "CRM-App", + commandName: "Ping", + paramaters: { raw: true }, + response: { raw: true } + }); + expect(recorded[0].request_payload).toEqual({}); + expect(recorded[0].response_payload).toBe("bad-json"); + expect(recorded[1].request_payload).toEqual({}); + expect(recorded[1].response_payload).toEqual({}); + + await expectAsync(service.recordInvocation({ + sessionId: "missing", + applicationName: "crm", + commandName: "Ping" + })).toBeRejectedWithError(/会话不存在/); + await expectAsync(service.recordInvocation({ + sessionId: "s-record", + applicationName: "", + commandName: "Ping" + })).toBeRejectedWithError(/不能为空/); +}); diff --git a/packages/daedalus-agent-management-web/server/test/hzg-provider-client.test.js b/packages/daedalus-agent-management-web/server/test/hzg-provider-client.test.js index a120dc33f7913c7c93c7a5ec581735b02a0db044..f3533d9e42655c96f61ea8373ab7a756b39dbe24 100644 --- a/packages/daedalus-agent-management-web/server/test/hzg-provider-client.test.js +++ b/packages/daedalus-agent-management-web/server/test/hzg-provider-client.test.js @@ -48,3 +48,34 @@ it("falls back to a cookie field in the response body when header is missing", a expect(result).toBe("ForguncyServer=session-cookie"); }); + +it("normalizes a trailing slash and rejects unavailable fetch or missing cookies", async () => { + const unavailable = new HzgProviderClient({ fetchImpl: null }); + await expectAsync(unavailable.createSessionCookie("", "thread-1")) + .toBeRejectedWith(jasmine.objectContaining({ status: 503 })); + + const calls = []; + const invalidJson = new HzgProviderClient({ + fetchImpl: async (url) => { + calls.push(url); + return { + headers: { get: () => "" }, + text: async () => "not-json" + }; + } + }); + await expectAsync(invalidJson.createSessionCookie("https://example.com/app/", "thread-1")) + .toBeRejectedWith(jasmine.objectContaining({ status: 503 })); + expect(calls).toEqual(["https://example.com/app/ServerCommand/"]); + + const missingCookie = new HzgProviderClient({ + fetchImpl: async () => ({ + headers: { get: () => null }, + text: async () => "{}" + }) + }); + await expectAsync(missingCookie.createSessionCookie("https://example.com/app", "thread-1")) + .toBeRejectedWith(jasmine.objectContaining({ status: 503 })); + + expect(new HzgProviderClient().fetchImpl).toBe(globalThis.fetch); +}); diff --git a/packages/daedalus-agent-management-web/server/test/large-language-model-service.test.js b/packages/daedalus-agent-management-web/server/test/large-language-model-service.test.js index fdbf83584c0dce53e82212287c2438b395fe0740..2fa90c8a634749426ec242a4e6e65f5cd536cf8b 100644 --- a/packages/daedalus-agent-management-web/server/test/large-language-model-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/large-language-model-service.test.js @@ -359,3 +359,136 @@ it("tests model through management action with local context and output limits", db.close(); } }); + +it("validates provider and model identifiers and lookup failures", async () => { + const db = createDb(); + const { service } = createService(db, () => ({})); + try { + expect(() => service.listModels("bad id")).toThrowError(/Provider ID/); + expect(() => service.getProvider("missing")).toThrowError(/Provider 不存在/); + expect(() => service.getModel("corp-openai", "missing-model")).toThrowError(/Model 不存在/); + await expectAsync(service.createProvider({ provider_id: "", base_url: "https://example.com", api_key: "x" })) + .toBeRejectedWithError(/Provider ID 不能为空/); + await expectAsync(service.createProvider({ provider_id: "bad id", base_url: "https://example.com", api_key: "x" })) + .toBeRejectedWithError(/Provider ID/); + await expectAsync(service.createProvider({ provider_id: "valid", base_url: "", api_key: "x" })) + .toBeRejectedWithError(/Base URL 不能为空/); + await expectAsync(service.createProvider({ provider_id: "valid", base_url: "ftp://example.com", api_key: "x" })) + .toBeRejectedWithError(/Base URL 格式/); + await expectAsync(service.createProvider({ provider_id: "valid", base_url: "https://example.com", api_key: "" })) + .toBeRejectedWithError(/SK 不能为空/); + await expectAsync(service.createModel({ provider_id: "corp-openai", model_id: "with space" })) + .toBeRejectedWithError(/Model ID 不能包含空白/); + } finally { + db.close(); + } +}); + +it("refreshes provider projections and removes stale catalog records", async () => { + const db = createDb(); + const { service, calls } = createService(db, () => ({ + catalogs: { + llmProviders: { + items: [{ + id: "corp-openai", + baseUrl: "https://catalog.example.com", + api: "openaiCompletions", + secret: { mask: "catalog-mask", fingerprint: "catalog-fingerprint" }, + models: [ + { id: "new-model", name: "New Model", limits: { contextTokens: 8, maxOutputTokens: 4 } }, + { id: "", name: "ignored" } + ] + }] + } + } + })); + try { + const refreshed = await service.refreshProviderProjection("corp-openai"); + expect(refreshed.provider_id).toBe("corp-openai"); + expect(service.getModel("corp-openai", "new-model").context_tokens).toBe(8); + expect(service.listModels("corp-openai").some((item) => item.model_id === "gpt-4.1-mini")).toBeFalse(); + expect(calls).toEqual([{ action: "catalog.snapshot", payload: {} }]); + + const missing = createService(db, async () => ({ catalogs: { llmProviders: { items: [] } } })); + expect(await missing.service.refreshProviderProjection("corp-openai")).toBeNull(); + } finally { + db.close(); + } +}); + +it("returns normalized model-test failures and provider test defaults", async () => { + const db = createDb(); + const { service } = createService(db, () => { + const error = new Error(""); + error.details = { code: "timeout" }; + throw error; + }); + try { + const result = await service.testModel("corp-openai", "gpt-4.1-mini", { timeoutMs: 12 }); + expect(result.ok).toBeFalse(); + expect(result.error_message).toBe("Model 测试失败"); + expect(result.error_details).toEqual({ code: "timeout" }); + const providerResult = await service.testProvider("corp-openai", { modelId: "gpt-4.1-mini" }); + expect(providerResult.ok).toBeFalse(); + } finally { + db.close(); + } +}); + +it("uses projection recovery when remote upserts fail", async () => { + const db = createDb(); + const { service, calls } = createService(db, (action) => { + if (action === "catalog.snapshot") { + return { catalogs: { llmProviders: { items: [] } } }; + } + return { + providerId: "recovered", + baseUrl: "https://example.com/v1", + apiKeyMask: "mask", + apiKeyFingerprint: "fingerprint", + api: "openai-completions" + }; + }); + try { + const originalProviderUpsert = service.upsertProvider; + service.upsertProvider = () => { throw new Error("provider upsert failed"); }; + service.refreshProviderProjection = async () => ({ provider_id: "recovered" }); + expect(await service.createProvider({ + provider_id: "recovered", + base_url: "https://example.com", + api_key: "sk-test" + })).toEqual({ provider_id: "recovered" }); + service.upsertProvider = originalProviderUpsert; + + service.upsertProvider = () => { throw new Error("provider update failed"); }; + service.refreshProviderProjection = async () => { throw new Error("projection failed"); }; + await expectAsync(service.updateProvider("corp-openai", { base_url: "https://example.com/v1" })) + .toBeRejectedWithError(/provider update failed/); + expect(calls.some((call) => call.action === "llm.provider.update")).toBeTrue(); + } finally { + db.close(); + } +}); + +it("recovers model create and update failures through projection", async () => { + const db = createDb(); + const { service } = createService(db, (action) => { + if (action === "catalog.snapshot") { + return { catalogs: { llmProviders: { items: [] } } }; + } + return { providerId: "corp-openai", modelId: "recovered", modelRef: "corp-openai/recovered", name: "Recovered" }; + }); + try { + service.upsertModel = () => { throw new Error("model upsert failed"); }; + service.refreshProviderProjection = async () => { throw new Error("projection failed"); }; + await expectAsync(service.createModel({ provider_id: "corp-openai", model_id: "recovered" })) + .toBeRejectedWithError(/model upsert failed/); + + service.refreshProviderProjection = async () => { throw new Error("projection failed"); }; + service.getModel = () => ({ model_ref: "corp-openai/gpt-4.1-mini" }); + await expectAsync(service.updateModel("corp-openai", "gpt-4.1-mini", { context_tokens: null, max_tokens: null })) + .toBeRejectedWithError(/model upsert failed/); + } finally { + db.close(); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/local-artifact-store.test.js b/packages/daedalus-agent-management-web/server/test/local-artifact-store.test.js new file mode 100644 index 0000000000000000000000000000000000000000..0601e94745c6d7f1c9a464f527a0eab68105ea41 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/local-artifact-store.test.js @@ -0,0 +1,59 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { LocalArtifactStore } from "../src/infra/local/local-artifact-store.js"; + +it("stores and deletes local artifacts inside the configured root", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "local-artifact-store-")); + try { + const store = new LocalArtifactStore(root); + const artifact = await store.uploadAdminArtifact({ + kind: "template", + file: { + originalname: "sales template.zip", + buffer: Buffer.from("template", "utf8") + } + }); + + expect(artifact.bucket).toBe("local"); + expect(artifact.objectKey).toMatch(/^template\/\d{4}-\d{2}-\d{2}\//); + const filePath = path.join(root, ...artifact.objectKey.split("/")); + expect(fs.readFileSync(filePath, "utf8")).toBe("template"); + expect((await store.downloadObject(artifact.bucket, artifact.objectKey)).toString("utf8")).toBe("template"); + expect(fs.readdirSync(path.dirname(filePath)).some((name) => name.endsWith(".tmp"))).toBeFalse(); + + await store.deleteObject(artifact.bucket, artifact.objectKey); + expect(fs.existsSync(filePath)).toBeFalse(); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +it("rejects local artifact paths outside the configured root", () => { + const store = new LocalArtifactStore(path.join(os.tmpdir(), "local-artifact-store-boundary")); + expect(() => store.resolve("../outside.zip")).toThrowError(/local artifact path/i); + expect(() => store.resolve("template\\outside.zip")).toThrowError(/local artifact path/i); +}); + +it("rejects non-local buckets when reading local artifacts", async () => { + const store = new LocalArtifactStore(path.join(os.tmpdir(), "local-artifact-store-bucket")); + await expectAsync(store.downloadObject("admin-out", "agent-skill/demo.zip")) + .toBeRejectedWithError(/local bucket/i); +}); + +it("rejects symbolic links when reading local artifacts", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "local-artifact-store-link-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "local-artifact-store-outside-")); + try { + fs.writeFileSync(path.join(outside, "secret.zip"), "secret", "utf8"); + const linkType = process.platform === "win32" ? "junction" : "dir"; + fs.symlinkSync(outside, path.join(root, "agent-skill"), linkType); + const store = new LocalArtifactStore(root); + await expectAsync(store.downloadObject("local", "agent-skill/secret.zip")) + .toBeRejectedWithError(/physical directories and files/i); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/logger.test.js b/packages/daedalus-agent-management-web/server/test/logger.test.js new file mode 100644 index 0000000000000000000000000000000000000000..b6570d07c6d8e674f7de1d007dbb3ca86928ee69 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/logger.test.js @@ -0,0 +1,89 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { createLogger, errorDetails } from "../src/utils/logger.js"; + +const LOG_ENV_KEYS = ["DA_LOG_DIR", "DA_ROOT", "DA_DATA_DIR", "DA_KERNEL_DATA_DIR", "DA_LOG_LEVEL"]; + +function withLogEnvironment(values, action) { + const original = Object.fromEntries(LOG_ENV_KEYS.map((key) => [key, process.env[key]])); + for (const key of LOG_ENV_KEYS) { + delete process.env[key]; + } + Object.assign(process.env, values); + + try { + return action(); + } finally { + for (const key of LOG_ENV_KEYS) { + if (original[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = original[key]; + } + } + } +} + +function logFiles(directory) { + return fs.readdirSync(directory).filter((name) => name.endsWith(".log")); +} + +it("writes daily logs using each supported directory source", () => { + const fixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-logger-")); + + try { + const explicitDirectory = path.join(fixtureDirectory, "explicit"); + withLogEnvironment({ DA_LOG_DIR: ` ${explicitDirectory} `, DA_LOG_LEVEL: "debug" }, () => { + const logger = createLogger("explicit"); + logger.debug("first"); + logger.info("second"); + }); + expect(logFiles(explicitDirectory).length).toBe(1); + expect(fs.readFileSync(path.join(explicitDirectory, logFiles(explicitDirectory)[0]), "utf8")).toContain("second"); + + const root = path.join(fixtureDirectory, "root"); + withLogEnvironment({ DA_ROOT: ` ${root} ` }, () => createLogger("root").info("root-log")); + expect(logFiles(path.join(root, "logs")).length).toBe(1); + + const dataDirectory = path.join(fixtureDirectory, "data-parent", "data"); + withLogEnvironment({ DA_DATA_DIR: dataDirectory }, () => createLogger("data").info("data-log")); + expect(logFiles(path.join(fixtureDirectory, "data-parent", "logs")).length).toBe(1); + + const kernelDirectory = path.join(fixtureDirectory, "kernel-parent", "data"); + withLogEnvironment({ DA_KERNEL_DATA_DIR: kernelDirectory }, () => createLogger("kernel").info("kernel-log")); + expect(logFiles(path.join(fixtureDirectory, "kernel-parent", "logs")).length).toBe(1); + + withLogEnvironment({}, () => expect(() => createLogger("stderr-only").info("stderr-only")).not.toThrow()); + } finally { + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } +}); + +it("warns once when a configured log directory cannot be initialized", () => { + const fixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-logger-error-")); + const blockedPath = path.join(fixtureDirectory, "blocked"); + fs.writeFileSync(blockedPath, "file", "utf8"); + const stderrWrite = spyOn(process.stderr, "write").and.callFake(() => true); + + try { + withLogEnvironment({ DA_LOG_DIR: blockedPath }, () => { + const logger = createLogger("failure"); + logger.info("first"); + logger.info("second"); + }); + const warnings = stderrWrite.calls.allArgs() + .map(([value]) => String(value)) + .filter((value) => value.includes("failed to initialize daily log file")); + expect(warnings.length).toBe(1); + } finally { + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } +}); + +it("normalizes error details", () => { + const error = new Error("failure"); + expect(errorDetails(error)).toEqual({ message: "failure", stack: error.stack }); + expect(errorDetails("failure")).toEqual({ message: "failure" }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/mcp-service.test.js b/packages/daedalus-agent-management-web/server/test/mcp-service.test.js new file mode 100644 index 0000000000000000000000000000000000000000..23ef9d47501fd71d6e2f2ccbab13a8ad46961a13 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/mcp-service.test.js @@ -0,0 +1,583 @@ +import { DatabaseSync } from "node:sqlite"; +import path from "node:path"; + +import { McpService } from "../src/services/mcp-service.js"; + +function createDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE agents ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL + ); + + CREATE TABLE mcp_servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + mode TEXT NOT NULL, + transport TEXT NOT NULL, + url TEXT NOT NULL DEFAULT '', + command TEXT NOT NULL DEFAULT '', + args_json TEXT NOT NULL DEFAULT '[]', + cwd TEXT NOT NULL DEFAULT '', + env_json TEXT NOT NULL DEFAULT '{}', + headers_json TEXT NOT NULL DEFAULT '{}', + auth_type TEXT NOT NULL DEFAULT 'none', + oauth_scope TEXT NOT NULL DEFAULT '', + tool_include_json TEXT NOT NULL DEFAULT '[]', + tool_exclude_json TEXT NOT NULL DEFAULT '[]', + connection_timeout_ms INTEGER, + request_timeout_ms INTEGER, + supports_parallel_tool_calls INTEGER NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'active', + last_probe_status TEXT NOT NULL DEFAULT 'unknown', + last_probe_result_json TEXT NOT NULL DEFAULT '{}', + last_probed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE agent_mcp_policies ( + agent_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + policy_version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + + CREATE TABLE agent_mcp_access ( + agent_id INTEGER NOT NULL, + mcp_server_id INTEGER NOT NULL, + PRIMARY KEY (agent_id, mcp_server_id) + ); + + CREATE TABLE mcp_runtime_projections ( + agent_id INTEGER PRIMARY KEY, + deny_patterns_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL + ); + + INSERT INTO agents (id, slug) VALUES (1, 'agent-a'); + INSERT INTO agent_mcp_policies (agent_id, mode, policy_version, created_at, updated_at) + VALUES (1, 'all', 1, '2026-08-12T00:00:00.000Z', '2026-08-12T00:00:00.000Z'); + `); + return db; +} + +function createService(db, envOverrides = {}) { + const calls = []; + return { + calls, + service: new McpService({ + db, + managementClient: { + async call(action, params) { + calls.push({ action, params }); + if (action === "mcp.probe") { + return { + server: params.slug, + tools: [ + { name: "search_docs", description: "Search documentation" }, + { name: "read_doc", description: "Read a document" } + ] + }; + } + return { + projections: params.agents.map((agent) => ({ + agentId: agent.agentId, + denyPatterns: agent.mode === "all" + ? [] + : agent.serverSlugs.length === 0 + ? ["bundle-mcp"] + : params.servers + .filter((server) => !agent.serverSlugs.includes(server.slug)) + .map((server) => `${server.slug}__*`) + })) + }; + } + }, + env: { + mcpCliAllowedCommands: ["node", "npx", "uvx"], + mcpCliAllowedCwdRoots: [path.resolve("mcp-workspaces")], + ...envOverrides + } + }) + }; +} + +describe("McpService", () => { + it("creates HTTP and CLI servers and projects native OpenClaw definitions", async () => { + const db = createDb(); + const fixture = createService(db); + + await fixture.service.createServer({ + slug: "docs", + name: "Docs", + mode: "http", + transport: "streamable-http", + url: "https://mcp.example.com/mcp", + auth_type: "oauth", + oauth_scope: "docs.read", + status: "active" + }); + await fixture.service.createServer({ + slug: "local-tools", + name: "Local tools", + mode: "cli", + command: "npx", + args: ["-y", "@example/mcp"], + env: { ACCESS_TOKEN: "${MCP_ACCESS_TOKEN}" }, + status: "disabled" + }); + + expect(fixture.service.listServers()).toEqual([ + jasmine.objectContaining({ slug: "docs", mode: "http", transport: "streamable-http" }), + jasmine.objectContaining({ slug: "local-tools", mode: "cli", transport: "stdio" }) + ]); + expect(fixture.calls.at(-1)).toEqual(jasmine.objectContaining({ + action: "mcp.sync", + params: jasmine.objectContaining({ + servers: [ + jasmine.objectContaining({ + slug: "docs", + config: jasmine.objectContaining({ + url: "https://mcp.example.com/mcp", + transport: "streamable-http", + auth: "oauth" + }) + }), + jasmine.objectContaining({ + slug: "local-tools", + config: jasmine.objectContaining({ + enabled: false, + command: "npx", + args: ["-y", "@example/mcp"], + env: { ACCESS_TOKEN: "${MCP_ACCESS_TOKEN}" } + }) + }) + ] + }) + })); + }); + + it("enables new servers by default and probes unsaved configuration", async () => { + const fixture = createService(createDb()); + const payload = { + slug: "draft-docs", + name: "Draft docs", + mode: "http", + transport: "streamable-http", + url: "https://mcp.example.com/draft" + }; + + const normalized = fixture.service.normalizeServer(payload); + expect(normalized.status).toBe("active"); + + const result = await fixture.service.testServer(payload); + expect(result).toEqual({ + ok: true, + result: jasmine.objectContaining({ server: "draft-docs" }), + tools: ["read_doc", "search_docs"] + }); + expect(fixture.calls.at(-1)).toEqual({ + action: "mcp.probe", + params: { + slug: "draft-docs", + config: jasmine.objectContaining({ + enabled: true, + url: "https://mcp.example.com/draft", + transport: "streamable-http" + }) + } + }); + expect(fixture.service.listServers()).toEqual([]); + }); + + it("temporarily enables a saved disabled server while probing", async () => { + const db = createDb(); + const fixture = createService(db); + const server = await fixture.service.createServer({ + slug: "phx-dev", + name: "Phoenix dev", + mode: "http", + transport: "streamable-http", + url: "http://127.0.0.1:7469/mcp", + status: "disabled" + }); + fixture.calls.length = 0; + const beforeProbe = db.prepare(` + SELECT last_probe_status, last_probe_result_json, last_probed_at, updated_at + FROM mcp_servers WHERE id = ? + `).get(server.id); + + const result = await fixture.service.probeServer(server.id); + + expect(result).toEqual(jasmine.objectContaining({ + ok: true, + result: jasmine.objectContaining({ server: "phx-dev" }), + tools: ["read_doc", "search_docs"] + })); + expect(fixture.calls).toEqual([{ + action: "mcp.probe", + params: { + slug: "phx-dev", + config: jasmine.objectContaining({ + enabled: true, + url: "http://127.0.0.1:7469/mcp", + transport: "streamable-http" + }) + } + }]); + expect(fixture.service.getServer(server.id).status).toBe("disabled"); + expect(db.prepare(` + SELECT last_probe_status, last_probe_result_json, last_probed_at, updated_at + FROM mcp_servers WHERE id = ? + `).get(server.id)).toEqual(beforeProbe); + }); + + it("does not persist a failed saved-server probe", async () => { + const db = createDb(); + const fixture = createService(db); + const server = await fixture.service.createServer({ + slug: "unavailable", + name: "Unavailable", + mode: "http", + transport: "streamable-http", + url: "https://mcp.example.com/unavailable" + }); + const beforeProbe = db.prepare(` + SELECT last_probe_status, last_probe_result_json, last_probed_at, updated_at + FROM mcp_servers WHERE id = ? + `).get(server.id); + spyOn(fixture.service.managementClient, "call").and.rejectWith(new Error("probe failed")); + + await expectAsync(fixture.service.probeServer(server.id)).toBeRejectedWithError("probe failed"); + + expect(db.prepare(` + SELECT last_probe_status, last_probe_result_json, last_probed_at, updated_at + FROM mcp_servers WHERE id = ? + `).get(server.id)).toEqual(beforeProbe); + }); + + it("keeps HTTP loopback URLs unchanged in container runtime configuration", async () => { + const fixture = createService(createDb(), { runMode: "container" }); + + const server = await fixture.service.createServer({ + slug: "phoenix", + name: "Phoenix", + mode: "http", + transport: "streamable-http", + url: "http://127.0.0.1:7469/mcp" + }); + + expect(server.url).toBe("http://127.0.0.1:7469/mcp"); + expect(fixture.calls.at(-1).params.servers).toEqual([ + jasmine.objectContaining({ + slug: "phoenix", + config: jasmine.objectContaining({ + url: "http://127.0.0.1:7469/mcp" + }) + }) + ]); + + await fixture.service.testServer({ + slug: "phoenix-draft", + name: "Phoenix draft", + mode: "http", + transport: "streamable-http", + url: "http://localhost:7469/mcp" + }); + expect(fixture.calls.at(-1).params.config.url).toBe("http://localhost:7469/mcp"); + + const ipv6Server = fixture.service.normalizeServer({ + slug: "phoenix-ipv6", + name: "Phoenix IPv6", + mode: "http", + transport: "streamable-http", + url: "http://[::1]:7469/mcp" + }); + expect(fixture.service.buildRuntimeServer(ipv6Server).url).toBe("http://[::1]:7469/mcp"); + }); + + it("accepts and persists literal credential values", async () => { + const fixture = createService(createDb()); + + const httpServer = await fixture.service.createServer({ + slug: "literal-header", + name: "Literal header", + mode: "http", + transport: "sse", + url: "https://mcp.example.com/sse", + auth_type: "headers", + headers: { Authorization: "Bearer secret" } + }); + const cliServer = await fixture.service.createServer({ + slug: "literal-env", + name: "Literal env", + mode: "cli", + command: "node", + env: { ACCESS_TOKEN: "plain-secret" } + }); + + expect(httpServer.headers).toEqual({ Authorization: "Bearer secret" }); + expect(cliServer.env).toEqual({ ACCESS_TOKEN: "plain-secret" }); + expect(fixture.calls.at(-1).params.servers).toEqual([ + jasmine.objectContaining({ + slug: "literal-env", + config: jasmine.objectContaining({ env: { ACCESS_TOKEN: "plain-secret" } }) + }), + jasmine.objectContaining({ + slug: "literal-header", + config: jasmine.objectContaining({ headers: { Authorization: "Bearer secret" } }) + }) + ]); + }); + + it("rejects unsafe CLI commands and working directories", async () => { + const fixture = createService(createDb()); + + await expectAsync(fixture.service.createServer({ + slug: "unsafe", + name: "Unsafe", + mode: "cli", + command: "powershell", + args: ["-Command", "Write-Host bad"] + })).toBeRejectedWithError(/白名单/); + + await expectAsync(fixture.service.createServer({ + slug: "unsafe-cwd", + name: "Unsafe cwd", + mode: "cli", + command: "node", + cwd: path.resolve("outside-mcp-workspaces") + })).toBeRejectedWithError(/工作目录/); + }); + + it("updates an agent MCP policy and synchronizes selected and empty selections", async () => { + const db = createDb(); + const fixture = createService(db); + await fixture.service.createServer({ + slug: "docs", + name: "Docs", + mode: "http", + transport: "sse", + url: "https://mcp.example.com/sse" + }); + await fixture.service.createServer({ + slug: "search", + name: "Search", + mode: "http", + transport: "streamable-http", + url: "https://mcp.example.com/search" + }); + + const selected = await fixture.service.setAgentPolicy(1, { + mode: "selected", + policy_version: 1, + mcp_server_ids: [1] + }); + expect(selected).toEqual(jasmine.objectContaining({ + mode: "selected", + policy_version: 2, + mcp_server_ids: [1] + })); + expect(fixture.calls.at(-1).params.agents).toEqual([ + jasmine.objectContaining({ agentId: "agent-a", mode: "selected", serverSlugs: ["docs"] }) + ]); + + const empty = await fixture.service.setAgentPolicy(1, { + mode: "selected", + policy_version: 2, + mcp_server_ids: [] + }); + expect(empty.mcp_server_ids).toEqual([]); + expect(JSON.parse(db.prepare(` + SELECT deny_patterns_json + FROM mcp_runtime_projections + WHERE agent_id = 1 + `).get().deny_patterns_json)).toEqual(["bundle-mcp"]); + }); + + it("preserves the server and agent access when deletion projection fails", async () => { + const db = createDb(); + const fixture = createService(db); + const server = await fixture.service.createServer({ + slug: "docs", + name: "Docs", + mode: "http", + transport: "streamable-http", + url: "https://mcp.example.com/mcp" + }); + await fixture.service.setAgentPolicy(1, { + mode: "selected", + policy_version: 1, + mcp_server_ids: [server.id] + }); + fixture.service.managementClient = { + async call(action, params) { + expect(action).toBe("mcp.sync"); + expect(params.agents).toEqual([ + jasmine.objectContaining({ + agentId: "agent-a", + mode: "selected", + serverSlugs: [] + }) + ]); + throw new Error("runtime sync failed"); + } + }; + + await expectAsync(fixture.service.deleteServer(server.id)) + .toBeRejectedWithError("runtime sync failed"); + + expect(fixture.service.getServer(server.id).slug).toBe("docs"); + expect(fixture.service.getAgentPolicy(1)).toEqual(jasmine.objectContaining({ + mode: "selected", + policy_version: 2, + mcp_server_ids: [server.id] + })); + }); + + it("rejects invalid server definitions and policy payloads", async () => { + const fixture = createService(createDb()); + const invalidPayloads = [ + [{ slug: "Bad", name: "Name", mode: "http", url: "https://example.com" }, /slug/], + [{ slug: "valid", name: "", mode: "http", url: "https://example.com" }, /名称/], + [{ slug: "valid", name: "Name", mode: "bad", url: "https://example.com" }, /mode/], + [{ slug: "valid", name: "Name", mode: "http", status: "bad", url: "https://example.com" }, /status/], + [{ slug: "valid", name: "Name", mode: "http", transport: "bad", url: "https://example.com" }, /transport/], + [{ slug: "valid", name: "Name", mode: "http", url: "ftp://example.com" }, /只支持/], + [{ slug: "valid", name: "Name", mode: "http", auth_type: "headers", headers: [] , url: "https://example.com" }, /键值/], + [{ slug: "valid", name: "Name", mode: "cli", command: "node", args: "bad" }, /args/] + ]; + for (const [payload, message] of invalidPayloads) { + await expectAsync(fixture.service.createServer(payload)).toBeRejectedWithError(message); + } + await expectAsync(fixture.service.createServer({ + slug: "valid", + name: "Name", + mode: "cli", + command: "node", + cwd: "relative" + })).toBeRejectedWithError(/绝对路径/); + await expectAsync(fixture.service.createServer({ + slug: "valid", + name: "Name", + mode: "cli", + command: "node", + connection_timeout_ms: 0 + })).toBeRejectedWithError(/正整数/); + + await expectAsync(fixture.service.setAgentPolicy(1, { mode: "bad", policy_version: 1 })) + .toBeRejectedWithError(/mode 必须/); + await expectAsync(fixture.service.setAgentPolicy(1, { mode: "all", policy_version: 0 })) + .toBeRejectedWithError(/policy_version/); + await expectAsync(fixture.service.setAgentPolicy(1, { mode: "all", policy_version: 2 })) + .toBeRejectedWithError(/已被其他请求更新/); + await expectAsync(fixture.service.setAgentPolicy(1, { + mode: "selected", + policy_version: 1, + mcp_server_ids: [0] + })).toBeRejectedWithError(/正整数/); + }); + + it("covers HTTP and CLI runtime configuration branches", () => { + const fixture = createService(createDb()); + const oauth = fixture.service.normalizeServer({ + slug: "oauth", + name: "OAuth", + mode: "http", + url: "https://example.com", + auth_type: "oauth", + oauth_scope: "read", + connection_timeout_ms: 1000, + request_timeout_ms: 2000, + supports_parallel_tool_calls: true, + tool_include: ["a"], + tool_exclude: ["b"] + }); + expect(fixture.service.buildRuntimeServer(oauth)).toEqual(jasmine.objectContaining({ + auth: "oauth", + oauth: { scope: "read" }, + connectionTimeoutMs: 1000, + requestTimeoutMs: 2000, + supportsParallelToolCalls: true, + toolFilter: { include: ["a"], exclude: ["b"] } + })); + const headers = fixture.service.normalizeServer({ + slug: "headers", + name: "Headers", + mode: "http", + url: "https://example.com", + auth_type: "headers", + headers: { Authorization: "token" } + }); + expect(fixture.service.buildRuntimeServer(headers).headers).toEqual({ Authorization: "token" }); + const cli = fixture.service.normalizeServer({ + slug: "cli", + name: "CLI", + mode: "cli", + command: "node", + args: ["script.js"], + cwd: path.resolve("mcp-workspaces"), + env: { TOKEN: "secret" } + }); + expect(fixture.service.buildRuntimeServer(cli)).toEqual(jasmine.objectContaining({ + command: "node", + args: ["script.js"], + cwd: path.resolve("mcp-workspaces"), + env: { TOKEN: "secret" } + })); + expect(() => fixture.service.normalizeServer({ ...cli, slug: "changed" }, cli)) + .toThrowError(/不可修改/); + }); + + it("rolls back create and update operations when runtime synchronization fails", async () => { + const db = createDb(); + const fixture = createService(db); + spyOn(fixture.service, "syncRuntime").and.rejectWith(new Error("sync failed")); + await expectAsync(fixture.service.createServer({ + slug: "rollback", + name: "Rollback", + mode: "http", + url: "https://example.com" + })).toBeRejectedWithError("sync failed"); + expect(fixture.service.listServers()).toEqual([]); + + fixture.service.syncRuntime.and.resolveTo({}); + const server = await fixture.service.createServer({ + slug: "update-rollback", + name: "Before", + mode: "http", + url: "https://example.com" + }); + fixture.service.syncRuntime.and.rejectWith(new Error("update sync failed")); + await expectAsync(fixture.service.updateServer(server.id, { name: "After" })) + .toBeRejectedWithError("update sync failed"); + expect(fixture.service.getServer(server.id).name).toBe("Before"); + }); + + it("handles policy lookup, template application, and projection updates", async () => { + const db = createDb(); + db.exec(` + CREATE TABLE template_mcp_policies (template_id INTEGER PRIMARY KEY, mode TEXT); + CREATE TABLE template_mcp_access (template_id INTEGER, mcp_server_id INTEGER); + `); + const fixture = createService(db); + expect(() => fixture.service.getAgentPolicy(99)).toThrowError(/数字员工不存在/); + db.prepare("DELETE FROM agent_mcp_policies WHERE agent_id = 1").run(); + expect(() => fixture.service.getAgentPolicy(1)).toThrowError(/策略不存在/); + fixture.service.ensureAgentPolicy(1); + db.prepare("INSERT INTO template_mcp_policies (template_id, mode) VALUES (?, ?)").run(1, "selected"); + db.prepare("INSERT INTO template_mcp_access (template_id, mcp_server_id) VALUES (?, ?)").run(1, 4); + fixture.service.applyTemplatePolicy(1, 1); + expect(fixture.service.getAgentPolicy(1).mode).toBe("selected"); + db.prepare("UPDATE template_mcp_policies SET mode = 'other' WHERE template_id = 1").run(); + fixture.service.applyTemplatePolicy(1, 1); + expect(fixture.service.getAgentPolicy(1).mode).toBe("all"); + const result = await fixture.service.syncRuntime({ restart: false, excludedServerId: 99 }); + expect(result).toBeDefined(); + }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js b/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js index 6d657e4b86ec6490c16c927e56b1bb0c9a1d6ccc..c448ff2bede7a2152c72a75eefb50cf1f0a3dc68 100644 --- a/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js +++ b/packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js @@ -6,7 +6,7 @@ import AdmZip from "adm-zip"; const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-web-portal-test-")); process.env.DA_WEB_DATA_DIR = dataDir; -const { PortalService } = await import("../src/services/portal-service.js"); +const { PortalService, normalizeSkillArchive } = await import("../src/services/portal-service.js"); function createDb() { const statements = []; @@ -79,6 +79,84 @@ function createSkillZip({ name = "skill-a", description = "Skill A description" return zip.toBuffer(); } +function createUploadedSkillFile(buffer) { + return { + buffer, + originalname: "demo.zip", + mimetype: "application/zip", + size: buffer.length + }; +} + +function createTemplateZip() { + const zip = new AdmZip(); + zip.addFile("AGENTS.md", Buffer.from("# Agent template\n", "utf8")); + return zip.toBuffer(); +} + +function createTemplateUploadDb() { + const db = new DatabaseSync(":memory:"); + db.exec(` + PRAGMA foreign_keys = ON; + + CREATE TABLE artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + bucket TEXT NOT NULL, + object_key TEXT NOT NULL, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL + ); + + CREATE TABLE agent_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_name TEXT NOT NULL UNIQUE, + artifact_id INTEGER NOT NULL, + remote_status TEXT NOT NULL, + remote_result_json TEXT, + default_llm_provider_id TEXT, + default_llm_model_id TEXT, + default_llm_model_ref TEXT, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (artifact_id) REFERENCES artifacts(id) + ); + + CREATE TABLE template_mcp_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (template_id) REFERENCES agent_templates(id) ON DELETE CASCADE + ); + `); + return db; +} + +function replaceZipEntryName(buffer, sourceName, targetName) { + if (Buffer.byteLength(sourceName) !== Buffer.byteLength(targetName)) { + throw new Error("ZIP test entry names must have the same byte length"); + } + const result = Buffer.from(buffer); + const source = Buffer.from(sourceName, "utf8"); + const target = Buffer.from(targetName, "utf8"); + let offset = 0; + let replacements = 0; + while ((offset = result.indexOf(source, offset)) >= 0) { + target.copy(result, offset); + offset += target.length; + replacements += 1; + } + if (replacements < 2) { + throw new Error(`ZIP test entry was not found in local and central headers: ${sourceName}`); + } + return result; +} + it("returns dashboard placeholders before agent and usage startup sync complete", () => { const scalarValues = new Map([ ["SELECT status, last_success_at FROM agent_sync_state WHERE id = 1", { status: "running", last_success_at: null }], @@ -614,17 +692,60 @@ it("requires uploaded global skill zip to contain top-level SKILL.md", async () expect(actionCalls.length).toBe(0); }); +it("rejects unsafe skill zip paths, links, duplicates, and compression bombs", () => { + const manifest = [ + "---", + "name: skill-a", + "description: Skill A description", + "---", + "" + ].join("\n"); + const unsafeArchives = []; + + for (const [sourceName, targetName] of [ + ["xx/escape.md", "../escape.md"], + ["xabsolute.md", "/absolute.md"], + ["nested/evil.md", "nested\\evil.md"] + ]) { + const zip = new AdmZip(); + zip.addFile("SKILL.md", Buffer.from(manifest, "utf8")); + zip.addFile(sourceName, Buffer.from("unsafe", "utf8")); + unsafeArchives.push(replaceZipEntryName(zip.toBuffer(), sourceName, targetName)); + } + + const duplicateZip = new AdmZip(); + duplicateZip.addFile("SKILL.md", Buffer.from(manifest, "utf8")); + duplicateZip.addFile("docs/Guide.md", Buffer.from("one", "utf8")); + duplicateZip.addFile("docs/guide.md", Buffer.from("two", "utf8")); + unsafeArchives.push(duplicateZip.toBuffer()); + + const symlinkZip = new AdmZip(); + symlinkZip.addFile("SKILL.md", Buffer.from(manifest, "utf8")); + symlinkZip.addFile("linked.md", Buffer.from("target.md", "utf8")); + symlinkZip.getEntry("linked.md").attr = 0o120777 * 0x10000; + unsafeArchives.push(symlinkZip.toBuffer()); + + const bombZip = new AdmZip(); + bombZip.addFile("SKILL.md", Buffer.from(manifest, "utf8")); + bombZip.addFile("large.txt", Buffer.alloc(1024 * 1024, 0)); + unsafeArchives.push(bombZip.toBuffer()); + + for (const archive of unsafeArchives) { + expect(() => normalizeSkillArchive(createUploadedSkillFile(archive))).toThrow(); + } +}); + it("installs uploaded global skill through skills.global.install.local", async () => { const db = createDb(); const actionCalls = []; const uploadedArtifacts = []; const service = new PortalService({ db, - objectStorage: { + localArtifactStore: { async uploadAdminArtifact({ file }) { uploadedArtifacts.push(file); return { - bucket: "admin-in", + bucket: "local", objectKey: "skill/demo.zip" }; } @@ -675,8 +796,7 @@ it("installs uploaded global skill through skills.global.install.local", async ( expect(actionCalls[0].action).toBe("skills.global.install.local"); expect(actionCalls[0].params).toEqual({ slug: preview.slug, - bucket: "admin-in", - objectKey: "skill/demo.zip", + artifactPath: "skill/demo.zip", force: false }); const uploadedZip = new AdmZip(uploadedArtifacts[0].buffer); @@ -765,9 +885,9 @@ it("deletes an uploaded object when the artifact row cannot be persisted", async }; } }, - objectStorage: { + localArtifactStore: { async uploadAdminArtifact() { - return { bucket: "admin-in", objectKey: "skill/new.zip" }; + return { bucket: "local", objectKey: "skill/new.zip" }; }, async deleteObject(bucket, objectKey) { deleted.push({ bucket, objectKey }); @@ -787,7 +907,7 @@ it("deletes an uploaded object when the artifact row cannot be persisted", async }, createdBy: 1 })).toBeRejectedWithError(/injected artifact insert failure/); - expect(deleted).toEqual([{ bucket: "admin-in", objectKey: "skill/new.zip" }]); + expect(deleted).toEqual([{ bucket: "local", objectKey: "skill/new.zip" }]); }); it("deletes installed global skill through skills.global.delete using skill slug only", async () => { @@ -872,6 +992,173 @@ it("rejects deleting built-in templates", async () => { expect(actionCalls).toEqual([]); }); +it("renames built-in templates while preserving built-in deletion protection", async () => { + const actionCalls = []; + const db = new DatabaseSync(":memory:"); + db.exec(` + CREATE TABLE artifacts ( + id INTEGER PRIMARY KEY, + bucket TEXT NOT NULL, + object_key TEXT NOT NULL, + original_name TEXT NOT NULL + ); + CREATE TABLE agent_templates ( + id INTEGER PRIMARY KEY, + template_name TEXT NOT NULL UNIQUE, + artifact_id INTEGER NOT NULL, + remote_result_json TEXT, + is_builtin INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE template_business_system_policies (template_id INTEGER PRIMARY KEY, mode TEXT NOT NULL); + CREATE TABLE template_business_system_access (template_id INTEGER NOT NULL, business_system_id INTEGER NOT NULL); + CREATE TABLE template_mcp_policies (template_id INTEGER PRIMARY KEY, mode TEXT NOT NULL); + CREATE TABLE template_mcp_access (template_id INTEGER NOT NULL, mcp_server_id INTEGER NOT NULL); + CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, display_name TEXT); + CREATE TABLE agents ( + id INTEGER PRIMARY KEY, + slug TEXT NOT NULL, + agent_name TEXT NOT NULL, + template_name TEXT NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + INSERT INTO artifacts (id, bucket, object_key, original_name) + VALUES (1, 'local', 'templates/default.zip', 'default.zip'); + INSERT INTO agent_templates ( + id, template_name, artifact_id, remote_result_json, is_builtin, created_at, updated_at + ) VALUES (1, 'default', 1, '{}', 1, '2026-08-11T00:00:00+08:00', '2026-08-11T00:00:00+08:00'); + INSERT INTO agents (id, slug, agent_name, template_name, created_at, updated_at) + VALUES (1, 'agent-a', 'Agent A', 'default', '2026-08-11T00:00:00+08:00', '2026-08-11T00:00:00+08:00'); + `); + const service = new PortalService({ + db, + objectStorage: {}, + managementClient: { + async call(action, params) { + actionCalls.push({ action, params }); + return { ok: true }; + } + }, + authService: {} + }); + + try { + const renamed = await service.renameTemplate("default", "default-edited"); + + expect(renamed).toEqual(jasmine.objectContaining({ template_name: "default-edited", is_builtin: true })); + expect(db.prepare("SELECT template_name FROM agents WHERE id = 1").get().template_name).toBe("default-edited"); + await expectAsync(service.deleteTemplate("default-edited")).toBeRejectedWithError(/内置模板不允许删除/); + expect(actionCalls).toEqual([ + { + action: "agent.template.create", + params: { + templateName: "default-edited", + artifactPath: "templates/default.zip", + replace: false + } + }, + { action: "agent.template.delete", params: { templateName: "default" } } + ]); + } finally { + db.close(); + } +}); + +it("creates an all MCP policy when uploading a new template", async () => { + const db = createTemplateUploadDb(); + const buffer = createTemplateZip(); + const service = new PortalService({ + db, + localArtifactStore: { + async uploadAdminArtifact() { + return { bucket: "local", objectKey: "templates/custom.zip" }; + } + }, + managementClient: { + async call() { + return { ok: true }; + } + }, + authService: {} + }); + service.listTemplates = () => [{ template_name: "custom" }]; + + await service.createTemplate({ + templateName: "custom", + file: { + buffer, + originalname: "custom.zip", + mimetype: "application/zip", + size: buffer.length + }, + createdBy: 1, + retainPreviousArtifact: true + }); + + expect(db.prepare(` + SELECT policy.mode + FROM template_mcp_policies policy + JOIN agent_templates template ON template.id = policy.template_id + WHERE template.template_name = ? + `).get("custom")).toEqual({ mode: "all" }); + db.close(); +}); + +it("preserves the MCP policy when replacing an uploaded template", async () => { + const db = createTemplateUploadDb(); + const oldArtifact = db.prepare(` + INSERT INTO artifacts ( + kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at + ) VALUES ('template', 'local', 'templates/old.zip', 'old.zip', 'application/zip', 1, 1, '2026-05-26T00:00:00.000Z') + `).run(); + const template = db.prepare(` + INSERT INTO agent_templates ( + template_name, artifact_id, remote_status, remote_result_json, created_at, updated_at + ) VALUES ('custom', ?, 'ready', '{}', '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z') + `).run(oldArtifact.lastInsertRowid); + db.prepare(` + INSERT INTO template_mcp_policies ( + template_id, mode, created_at, updated_at + ) VALUES (?, 'selected', '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z') + `).run(template.lastInsertRowid); + + const buffer = createTemplateZip(); + const service = new PortalService({ + db, + localArtifactStore: { + async uploadAdminArtifact() { + return { bucket: "local", objectKey: "templates/new.zip" }; + } + }, + managementClient: { + async call() { + return { ok: true }; + } + }, + authService: {} + }); + service.listTemplates = () => [{ template_name: "custom" }]; + + await service.createTemplate({ + templateName: "custom", + file: { + buffer, + originalname: "custom.zip", + mimetype: "application/zip", + size: buffer.length + }, + createdBy: 1, + retainPreviousArtifact: true + }); + + expect(db.prepare("SELECT mode FROM template_mcp_policies WHERE template_id = ?") + .get(template.lastInsertRowid)).toEqual({ mode: "selected" }); + db.close(); +}); + it("lists templates with assigned agents", () => { const db = new DatabaseSync(":memory:"); db.exec(` @@ -1058,12 +1345,24 @@ it("updates template default llm model", () => { remote_state_json, created_at, updated_at ) VALUES ('corp-openai', 'https://api.example.com/v1', 'sk****test', 'fingerprint', 'openai-completions', '{}', '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z') `).run(); + db.prepare(` + INSERT INTO llm_providers ( + provider_id, base_url, api_key_mask, api_key_fingerprint, api, + remote_state_json, created_at, updated_at + ) VALUES ('backup-openai', 'https://backup.example.com/v1', 'sk****backup', 'backup-fingerprint', 'openai-completions', '{}', '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z') + `).run(); db.prepare(` INSERT INTO llm_models ( model_ref, provider_id, model_id, name, context_tokens, max_tokens, remote_state_json, created_at, updated_at ) VALUES ('corp-openai/gpt-4.1-mini', 'corp-openai', 'gpt-4.1-mini', 'GPT 4.1 Mini', 128000, 4096, '{}', '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z') `).run(); + db.prepare(` + INSERT INTO llm_models ( + model_ref, provider_id, model_id, name, context_tokens, max_tokens, + remote_state_json, created_at, updated_at + ) VALUES ('backup-openai/qwen-fallback', 'backup-openai', 'qwen-fallback', 'Qwen Fallback', 128000, 4096, '{}', '2026-05-26T00:00:00.000Z', '2026-05-26T00:00:00.000Z') + `).run(); const service = new PortalService({ db, objectStorage: {}, @@ -1073,14 +1372,19 @@ it("updates template default llm model", () => { const updated = service.updateTemplateDefaultLlm("default", { default_llm_provider_id: "corp-openai", - default_llm_model_id: "gpt-4.1-mini" + default_llm_model_id: "gpt-4.1-mini", + fallback_llm_provider_id: "backup-openai", + fallback_llm_model_id: "qwen-fallback" }); expect(updated.default_llm_model_ref).toBe("corp-openai/gpt-4.1-mini"); - expect(db.prepare("SELECT default_llm_provider_id, default_llm_model_id, default_llm_model_ref FROM agent_templates WHERE template_name = ?").get("default")).toEqual({ + expect(db.prepare("SELECT default_llm_provider_id, default_llm_model_id, default_llm_model_ref, fallback_llm_provider_id, fallback_llm_model_id, fallback_llm_model_ref FROM agent_templates WHERE template_name = ?").get("default")).toEqual({ default_llm_provider_id: "corp-openai", default_llm_model_id: "gpt-4.1-mini", - default_llm_model_ref: "corp-openai/gpt-4.1-mini" + default_llm_model_ref: "corp-openai/gpt-4.1-mini", + fallback_llm_provider_id: "backup-openai", + fallback_llm_model_id: "qwen-fallback", + fallback_llm_model_ref: "backup-openai/qwen-fallback" }); const cleared = service.updateTemplateDefaultLlm("default", {}); @@ -1088,7 +1392,7 @@ it("updates template default llm model", () => { db.close(); }); -it("rejects deleting templates used by agents", async () => { +it("deletes a custom template even when existing agents reference its name", async () => { const actionCalls = []; const db = { prepare(sql) { @@ -1124,15 +1428,11 @@ it("rejects deleting templates used by agents", async () => { authService: {} }); - try { - await service.deleteTemplate("default"); - fail("Expected deleteTemplate to reject"); - } catch (error) { - expect(error.status).toBe(409); - expect(error.code).toBe("conflict"); - expect(error.message).toContain("仍有数字员工使用"); - } - expect(actionCalls).toEqual([]); + await expectAsync(service.deleteTemplate("default")).toBeResolvedTo({ ok: true }); + expect(actionCalls).toEqual([{ + action: "agent.template.delete", + params: { templateName: "default" } + }]); }); it("rejects deleting built-in global skills", async () => { diff --git a/packages/daedalus-agent-management-web/server/test/runtime-component-health-service.test.js b/packages/daedalus-agent-management-web/server/test/runtime-component-health-service.test.js index 14ba4ad834b14b7824411f5cec597ea0e0f11af7..41f74b0b18cdce668121319f16c59b53b2a2d176 100644 --- a/packages/daedalus-agent-management-web/server/test/runtime-component-health-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/runtime-component-health-service.test.js @@ -26,7 +26,7 @@ function createEnv() { accessKeyId: "s3-user", secretAccessKey: "s3-pass", forcePathStyle: true, - adminInboxBucket: "admin-in" + agentInboxBucket: "agent-in" } }; } @@ -62,7 +62,7 @@ it("checks MQTT and S3 runtime component connectivity", async () => { expect(result.checks.mqtt.status).toBe("ok"); expect(result.checks.mqtt.ok).toBeTrue(); expect(result.checks.s3.status).toBe("ok"); - expect(result.checks.s3.bucket).toBe("admin-in"); + expect(result.checks.s3.bucket).toBe("agent-in"); expect(mqttConnectOptions[0].username).toBe("mqtt-user"); expect(mqttConnectOptions[0].password).toBe("mqtt-pass"); expect(mqttClient.ended).toBeTrue(); @@ -70,7 +70,7 @@ it("checks MQTT and S3 runtime component connectivity", async () => { accessKeyId: "s3-user", secretAccessKey: "s3-pass" }); - expect(s3Commands[0].input.Bucket).toBe("admin-in"); + expect(s3Commands[0].input.Bucket).toBe("agent-in"); }); it("reports skipped runtime components when they are not configured", async () => { @@ -143,3 +143,96 @@ it("returns an error status when MQTT cannot connect", async () => { expect(result.status).toBe("error"); expect(result.message).toContain("auth failed"); }); + +it("handles MQTT close, timeout, and function-style factories", async () => { + const closeClient = new MockMqttClient(); + const closeService = new RuntimeComponentHealthService({ + env: { mqtt: { brokerUrl: "mqtt://broker" }, s3: {} }, + mqttFactory: () => { + queueMicrotask(() => closeClient.emit("close")); + return closeClient; + } + }); + const closed = await closeService.checkMqtt(1000); + expect(closed.status).toBe("error"); + expect(closed.message).toContain("关闭"); + + const timeoutClient = { once() {}, off() {}, end() {} }; + const timeoutService = new RuntimeComponentHealthService({ + env: { mqtt: { brokerUrl: "mqtt://broker" } }, + mqttFactory: () => timeoutClient + }); + const timedOut = await timeoutService.checkMqtt(1); + expect(timedOut.status).toBe("error"); + expect(timedOut.message).toContain("超时"); +}); + +it("handles S3 bucket absence, connection errors, and timeout cleanup", async () => { + const missingBucket = new RuntimeComponentHealthService({ + env: { + s3: { + endpoint: "http://minio", + accessKeyId: "user", + secretAccessKey: "pass" + } + } + }); + expect((await missingBucket.checkS3()).status).toBe("skipped"); + + let destroyed = false; + const errorService = new RuntimeComponentHealthService({ + env: { + s3: { + endpoint: "http://minio", + accessKeyId: "user", + secretAccessKey: "pass", + agentInboxBucket: "bucket", + forcePathStyle: false + } + }, + s3ClientFactory(config) { + expect(config.forcePathStyle).toBeFalse(); + return { + send() { + const error = new Error(""); + error.name = "AbortError"; + return Promise.reject(error); + }, + destroy() { destroyed = true; } + }; + } + }); + const failed = await errorService.checkS3(1000); + expect(failed.status).toBe("error"); + expect(failed.message).toBe("检查超时"); + expect(destroyed).toBeTrue(); + + const timeoutService = new RuntimeComponentHealthService({ + env: { + s3: { + endpoint: "http://minio", + accessKeyId: "user", + secretAccessKey: "pass", + agentOutboxBucket: "bucket" + } + }, + s3ClientFactory() { + return { + send() { return new Promise(() => {}); }, + destroy() { destroyed = true; } + }; + } + }); + const timeoutResult = await timeoutService.checkS3(1); + expect(timeoutResult.message).toContain("超时"); +}); + +it("returns independent empty cache snapshots and normalizes timeout limits", async () => { + const service = new RuntimeComponentHealthService({ env: { mqtt: {}, s3: {} } }); + const first = service.getHealth(); + first.checks.extra = true; + expect(service.getHealth().checks.extra).toBeUndefined(); + const result = await service.checkAll({ timeoutMs: "not-a-number" }); + expect(result.checks.mqtt.status).toBe("skipped"); + expect(result.checks.s3.status).toBe("skipped"); +}); diff --git a/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js b/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js index ea0c9fea8672e8d84b76e62d1c1b5e31f6fbf04c..fe5f4e4772065c42158fe05c48c15f6c25bd0f58 100644 --- a/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/runtime-env-config-service.test.js @@ -294,3 +294,69 @@ it("writes a container restart request file", () => { fs.rmSync(tempDir, { recursive: true, force: true }); } }); + +it("handles missing databases, invalid host values, and malformed JSON files", () => { + const tempDir = createTempDir(); + const invalidJsonPath = path.join(tempDir, "invalid.json"); + const arrayJsonPath = path.join(tempDir, "array.json"); + fs.writeFileSync(invalidJsonPath, "{", "utf8"); + fs.writeFileSync(arrayJsonPath, "[]", "utf8"); + try { + const noDb = new RuntimeEnvConfigService({ envPath: path.join(tempDir, "missing-env.json") }); + expect(() => noDb.setHuozigeServerHost("host.example.com")) + .toThrowError(/database is not configured/); + expect(() => noDb.updateNetworkAllowlistConfig({})) + .toThrowError(/database is not configured/); + expect(() => new RuntimeEnvConfigService({ db: createRuntimeSettingsDb(), envPath: arrayJsonPath })) + .toThrowError(/必须是 JSON 对象/); + expect(() => new RuntimeEnvConfigService({ db: createRuntimeSettingsDb(), envPath: invalidJsonPath })) + .toThrowError(/不是合法 JSON/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +it("normalizes array and JSON allowlists and preserves existing settings", () => { + const tempDir = createTempDir(); + try { + const db = createRuntimeSettingsDb(); + const service = new RuntimeEnvConfigService({ + db, + envPath: path.join(tempDir, "missing-env.json") + }); + service.setHuozigeServerHost("apps.example.com"); + service.updateNetworkAllowlistConfig({ + agent_internal_network_allowlist: ["10.0.0.1", "", "10.0.0.1"] + }); + expect(service.getInternalNetworkAllowlist()).toBe("10.0.0.1"); + const unchanged = service.updateNetworkAllowlistConfig(null); + expect(unchanged.huozige_server_ip).toBe("apps.example.com"); + expect(unchanged.network_allowlist_changed).toBeFalse(); + + const jsonEnvPath = path.join(tempDir, "json-env.json"); + fs.writeFileSync(jsonEnvPath, JSON.stringify({ + DA_INTERNAL_NETWORK_ALLOWLIST: '["10.0.0.2", "10.0.0.2"]' + }), "utf8"); + const jsonService = new RuntimeEnvConfigService({ db: createRuntimeSettingsDb(), envPath: jsonEnvPath }); + expect(jsonService.getInternalNetworkAllowlist()).toContain("10.0.0.2"); + expect(() => jsonService.setHuozigeServerHost("https://bad.example.com")) + .toThrowError(/格式不合法/); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); + +it("rolls back migration writes when runtime settings persistence fails", () => { + const tempDir = createTempDir(); + const envPath = path.join(tempDir, "env.json"); + fs.writeFileSync(envPath, JSON.stringify({ DA_HUOZIGE_SERVER_IP: "10.0.0.3" }), "utf8"); + try { + const db = createRuntimeSettingsDb(); + db.exec(`CREATE TRIGGER fail_migration BEFORE INSERT ON runtime_settings BEGIN SELECT RAISE(ABORT, 'migration failure'); END;`); + expect(() => new RuntimeEnvConfigService({ db, envPath })) + .toThrowError(/migration failure/); + expect(db.prepare("SELECT COUNT(*) AS count FROM runtime_settings").get().count).toBe(0); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js b/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js index 4c2cdd5f0c76a1d94adea234e3f928f6fac3a99a..b699b9e54986f2b0b27c4776a07c0bce50b6035b 100644 --- a/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js +++ b/packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js @@ -94,6 +94,9 @@ it("includes the env allowlist config in bootstrap", async () => { const app = express(); app.use(express.json()); app.use("/api", createRoutes(createBaseServices({ + env: { + mcpCliAllowedCommands: ["node", "uvx"] + }, runtimeEnvConfigService: { getRuntimeCredentials() { return { @@ -130,6 +133,7 @@ it("includes the env allowlist config in bootstrap", async () => { expect(response.body.env_config.mqtt_broker_password).toBe("mqtt-pass"); expect(response.body.env_config.s3_api_username).toBe("s3-user"); expect(response.body.env_config.s3_api_password).toBe("s3-pass"); + expect(response.body.env_config.mcp_cli_allowed_commands).toEqual(["node", "uvx"]); } finally { await close(server); } diff --git a/packages/daedalus-agent-management-web/server/test/system-log-file-download-route.test.js b/packages/daedalus-agent-management-web/server/test/system-log-file-download-route.test.js index fc1019a0f590bc0e17c542881dc8740871dba8e2..233a52dd8a5fd30e83e3c79f9f2f8ab28e2450ba 100644 --- a/packages/daedalus-agent-management-web/server/test/system-log-file-download-route.test.js +++ b/packages/daedalus-agent-management-web/server/test/system-log-file-download-route.test.js @@ -181,3 +181,25 @@ it("rejects unsupported system log types", async () => { fs.rmSync(logDir, { recursive: true, force: true }); } }); + +it("rejects malformed and impossible system log dates", async () => { + const logDir = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-agent-system-log-date-")); + const app = express(); + app.use(express.json()); + app.use("/api", createRoutes(createBaseServices(logDir))); + useJsonErrorHandler(app); + const server = await listen(app); + try { + const address = server.address(); + for (const date of ["2026/07/01", "202607", "2026-02-30", "20261301"]) { + const response = await requestRaw( + `http://127.0.0.1:${address.port}/api/system-logs/download?type=kernel&date=${encodeURIComponent(date)}` + ); + expect(response.status).toBe(400); + expect(response.json.message).toMatch(/日期/); + } + } finally { + await close(server); + fs.rmSync(logDir, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js b/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js index dae7b87772ebf83519cd6b49b0214d49bf6bc3c5..a75c41c494536bdc04457a446b9d476d385c3813 100644 --- a/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js +++ b/packages/daedalus-agent-management-web/server/test/system-service-alignment.test.js @@ -20,7 +20,7 @@ function createDb({ currentSystem = null, createdSystem = null } = {}) { get: (...args) => { if (sql.includes("FROM artifacts")) { return currentSystem?.ontology_artifact_id ? { - bucket: "admin-in", + bucket: "local", object_key: "ontology/existing.zip" } : null; } @@ -113,17 +113,17 @@ it("maps website fields to management action runtime apps.create params", async }; const db = createDb({ createdSystem }); const actionCalls = []; - const objectStorage = { + const localArtifactStore = { async uploadAdminArtifact() { return { - bucket: "admin-in", + bucket: "local", objectKey: "ontology/demo.zip" }; } }; const service = new SystemService({ db, - objectStorage, + localArtifactStore, managementClient: { async call(action, params) { actionCalls.push({ action, params }); @@ -156,7 +156,7 @@ it("maps website fields to management action runtime apps.create params", async service.persistOntologyArtifact = async () => ({ id: 9, - bucket: "admin-in", + bucket: "local", objectKey: "ontology/demo.zip" }); @@ -192,8 +192,7 @@ it("maps website fields to management action runtime apps.create params", async ontologyName: "demo-app", description: "desc", status: "active", - bucket: "admin-in", - objectKey: "ontology/demo.zip", + artifactPath: "ontology/demo.zip", replaceOntology: true } }); @@ -261,8 +260,7 @@ it("recreates remote system with the configured huozige server host", async () = ontologyName: "demo-app", description: "new desc", status: "active", - bucket: "admin-in", - objectKey: "ontology/existing.zip", + artifactPath: "ontology/existing.zip", replaceOntology: true } }); @@ -363,9 +361,9 @@ it("deletes an uploaded ontology object when artifact persistence fails", async }; } }, - objectStorage: { + localArtifactStore: { async uploadAdminArtifact() { - return { bucket: "admin-in", objectKey: "ontology/new.zip" }; + return { bucket: "local", objectKey: "ontology/new.zip" }; }, async deleteObject(bucket, objectKey) { deleted.push({ bucket, objectKey }); @@ -381,7 +379,7 @@ it("deletes an uploaded ontology object when artifact persistence fails", async file: { buffer, originalname: "new.zip", mimetype: "application/zip", size: buffer.length }, createdBy: 1 })).toBeRejectedWithError(/injected artifact insert failure/); - expect(deleted).toEqual([{ bucket: "admin-in", objectKey: "ontology/new.zip" }]); + expect(deleted).toEqual([{ bucket: "local", objectKey: "ontology/new.zip" }]); }); it("stores invocation payload files under DAEDALUS_AGENT data logs invoke date folders", async () => { diff --git a/packages/daedalus-agent-management-web/server/test/template-routes-authorization.test.js b/packages/daedalus-agent-management-web/server/test/template-routes-authorization.test.js new file mode 100644 index 0000000000000000000000000000000000000000..f11dd2a42dd70f99b250acb95f8c4ee5f68ee502 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/template-routes-authorization.test.js @@ -0,0 +1,100 @@ +import express from "express"; +import { request } from "node:http"; + +import { createRoutes } from "../src/api/routes/index.js"; + +function listen(app) { + return new Promise((resolve) => { + const server = app.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +function close(server) { + return new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +function requestJson(url) { + return new Promise((resolve, reject) => { + const target = new URL(url); + const req = request({ + hostname: target.hostname, + port: target.port, + path: `${target.pathname}${target.search}`, + method: "GET", + headers: { Connection: "close" } + }, (res) => { + const chunks = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf8"); + resolve({ status: res.statusCode, body: text ? JSON.parse(text) : null }); + }); + }); + req.on("error", reject); + req.end(); + }); +} + +it("requires administrator access for template workbench read routes", async () => { + let adminChecks = 0; + const app = express(); + app.use(express.json()); + app.use("/api", createRoutes({ + authService: { + getLocalApiUser() { + return { id: 2, username: "portal-user", role: "daedalus-user" }; + }, + assertAdmin() { + adminChecks += 1; + const error = new Error("管理员权限不足"); + error.status = 403; + error.code = "forbidden"; + throw error; + } + }, + auditLogService: { write() {} }, + portalService: {}, + catalogSyncService: {}, + templateService: { + listTemplateTree() { + throw new Error("template service must not be called"); + }, + readTemplateFile() { + throw new Error("template service must not be called"); + } + }, + env: {} + })); + app.use((error, _req, res, _next) => { + res.status(error.status || 500).json({ + code: error.code || "internal_error", + message: error.message + }); + }); + + const server = await listen(app); + try { + const address = server.address(); + const paths = [ + "/api/templates/default/details", + "/api/templates/default/tree", + "/api/templates/default/files/content?path=AGENTS.md" + ]; + for (const requestPath of paths) { + const response = await requestJson(`http://127.0.0.1:${address.port}${requestPath}`); + expect(response.status).toBe(403); + expect(response.body.code).toBe("forbidden"); + } + expect(adminChecks).toBe(paths.length); + } finally { + await close(server); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/template-service.test.js b/packages/daedalus-agent-management-web/server/test/template-service.test.js new file mode 100644 index 0000000000000000000000000000000000000000..b2ea53c484f80192d64ba4abac35276e09bc1034 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/template-service.test.js @@ -0,0 +1,306 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; + +import AdmZip from "adm-zip"; + +import { TemplateService } from "../src/services/template-service.js"; + +function createArchive(files) { + const archive = new AdmZip(); + for (const [filePath, content] of Object.entries(files)) { + archive.addFile(filePath, Buffer.from(content, "utf8")); + } + return archive.toBuffer(); +} + +function createDatabase() { + const db = new DatabaseSync(":memory:"); + db.exec("PRAGMA foreign_keys = ON;"); + db.exec(` + CREATE TABLE artifacts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + kind TEXT NOT NULL, + bucket TEXT NOT NULL, + object_key TEXT NOT NULL, + original_name TEXT NOT NULL, + mime_type TEXT NOT NULL, + byte_size INTEGER NOT NULL, + created_by INTEGER, + created_at TEXT NOT NULL + ); + CREATE TABLE agent_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + template_name TEXT NOT NULL UNIQUE, + artifact_id INTEGER NOT NULL, + remote_status TEXT NOT NULL DEFAULT 'pending', + remote_result_json TEXT, + default_llm_provider_id TEXT, + default_llm_model_id TEXT, + default_llm_model_ref TEXT, + fallback_llm_provider_id TEXT, + fallback_llm_model_id TEXT, + fallback_llm_model_ref TEXT, + skill_count INTEGER NOT NULL DEFAULT 0, + is_builtin INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE business_systems (id INTEGER PRIMARY KEY); + CREATE TABLE template_business_system_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE template_business_system_access ( + template_id INTEGER NOT NULL, + business_system_id INTEGER NOT NULL, + PRIMARY KEY (template_id, business_system_id) + ); + CREATE TABLE mcp_servers (id INTEGER PRIMARY KEY); + CREATE TABLE template_mcp_policies ( + template_id INTEGER PRIMARY KEY, + mode TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE template_mcp_access ( + template_id INTEGER NOT NULL, + mcp_server_id INTEGER NOT NULL, + PRIMARY KEY (template_id, mcp_server_id) + ); + `); + db.exec("INSERT INTO business_systems (id) VALUES (1), (2); INSERT INTO mcp_servers (id) VALUES (1), (2);"); + return db; +} + +function createFixture() { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "template-service-")); + const workspaceTemplatesDir = path.join(rootDir, "workspace-templates"); + fs.mkdirSync(path.join(workspaceTemplatesDir, "default"), { recursive: true }); + fs.writeFileSync(path.join(workspaceTemplatesDir, "default", "AGENTS.md"), "# System default\n", "utf8"); + + const db = createDatabase(); + const objects = new Map(); + const initialArchive = createArchive({ + "AGENTS.md": "# Built-in instructions\n", + "README.md": "# Default template\n" + }); + objects.set("artifact-1.zip", initialArchive); + db.prepare(` + INSERT INTO artifacts ( + id, kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at + ) VALUES (1, 'template', 'templates', 'artifact-1.zip', 'default.zip', 'application/zip', ?, 1, '2026-08-11T00:00:00+08:00') + `).run(initialArchive.length); + db.prepare(` + INSERT INTO agent_templates ( + id, template_name, artifact_id, remote_status, remote_result_json, + default_llm_provider_id, default_llm_model_id, default_llm_model_ref, + fallback_llm_provider_id, fallback_llm_model_id, fallback_llm_model_ref, + skill_count, is_builtin, created_at, updated_at + ) VALUES (1, 'default', 1, 'ready', '{}', 'openai', 'gpt-5', 'openai/gpt-5', + 'anthropic', 'claude-3', 'anthropic/claude-3', 0, 1, + '2026-08-11T00:00:00+08:00', '2026-08-11T00:00:00+08:00') + `).run(); + db.exec(` + INSERT INTO template_business_system_policies (template_id, mode, created_at, updated_at) + VALUES (1, 'selected', '2026-08-11T00:00:00+08:00', '2026-08-11T00:00:00+08:00'); + INSERT INTO template_business_system_access (template_id, business_system_id) VALUES (1, 1); + INSERT INTO template_mcp_policies (template_id, mode, created_at, updated_at) + VALUES (1, 'selected', '2026-08-11T00:00:00+08:00', '2026-08-11T00:00:00+08:00'); + INSERT INTO template_mcp_access (template_id, mcp_server_id) VALUES (1, 2); + `); + + let nextArtifactId = 1; + const portalService = { + localArtifactStore: { + async downloadObject(_bucket, objectKey) { + const buffer = objects.get(objectKey); + if (!buffer) { + throw new Error(`Missing object ${objectKey}`); + } + return buffer; + } + }, + getArtifactStorageInfo(artifactId) { + return db.prepare("SELECT * FROM artifacts WHERE id = ?").get(artifactId); + }, + async createTemplate({ templateName, file, createdBy, defaultLlm = {} }) { + nextArtifactId += 1; + const objectKey = `artifact-${nextArtifactId}.zip`; + objects.set(objectKey, Buffer.from(file.buffer)); + const now = `2026-08-11T00:0${nextArtifactId}:00+08:00`; + db.prepare(` + INSERT INTO artifacts ( + id, kind, bucket, object_key, original_name, mime_type, byte_size, created_by, created_at + ) VALUES (?, 'template', 'templates', ?, ?, 'application/zip', ?, ?, ?) + `).run(nextArtifactId, objectKey, file.originalname, file.buffer.length, createdBy, now); + db.prepare(` + INSERT INTO agent_templates ( + template_name, artifact_id, remote_status, remote_result_json, + default_llm_provider_id, default_llm_model_id, default_llm_model_ref, + fallback_llm_provider_id, fallback_llm_model_id, fallback_llm_model_ref, + is_builtin, created_at, updated_at + ) VALUES (?, ?, 'ready', '{}', ?, ?, ?, ?, ?, ?, 0, ?, ?) + ON CONFLICT(template_name) DO UPDATE SET + artifact_id = excluded.artifact_id, + default_llm_provider_id = excluded.default_llm_provider_id, + default_llm_model_id = excluded.default_llm_model_id, + default_llm_model_ref = excluded.default_llm_model_ref, + fallback_llm_provider_id = excluded.fallback_llm_provider_id, + fallback_llm_model_id = excluded.fallback_llm_model_id, + fallback_llm_model_ref = excluded.fallback_llm_model_ref, + updated_at = excluded.updated_at + `).run( + templateName, + nextArtifactId, + defaultLlm.default_llm_provider_id || null, + defaultLlm.default_llm_model_id || null, + defaultLlm.default_llm_model_ref || null, + defaultLlm.fallback_llm_provider_id || null, + defaultLlm.fallback_llm_model_id || null, + defaultLlm.fallback_llm_model_ref || null, + now, + now + ); + return db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(templateName); + }, + async discardArtifact(artifact) { + if (!artifact) return; + objects.delete(artifact.object_key); + db.prepare("DELETE FROM artifacts WHERE id = ?").run(artifact.id); + }, + async deleteTemplate(templateName) { + db.prepare("DELETE FROM agent_templates WHERE template_name = ?").run(templateName); + }, + updateTemplateDefaultLlm(templateName, payload) { + db.prepare(` + UPDATE agent_templates SET + default_llm_provider_id = ?, default_llm_model_id = ?, default_llm_model_ref = ? + WHERE template_name = ? + `).run( + payload.default_llm_provider_id || null, + payload.default_llm_model_id || null, + payload.default_llm_provider_id && payload.default_llm_model_id + ? `${payload.default_llm_provider_id}/${payload.default_llm_model_id}` + : null, + templateName + ); + return db.prepare("SELECT * FROM agent_templates WHERE template_name = ?").get(templateName); + } + }; + const service = new TemplateService({ db, portalService, env: { dataDir: rootDir, workspaceTemplatesDir } }); + + return { + db, + objects, + service, + cleanup() { + db.close(); + fs.rmSync(rootDir, { recursive: true, force: true }); + } + }; +} + +describe("TemplateService", () => { + it("creates a blank template immediately without draft state", async () => { + const fixture = createFixture(); + try { + const template = await fixture.service.createTemplate({ + mode: "blank", + templateName: "sales-agent", + createdBy: 1 + }); + expect(template.template_name).toBe("sales-agent"); + expect(template.revision).toBeUndefined(); + expect(template.status).toBeUndefined(); + expect(await fixture.service.readTemplateFile("sales-agent", "AGENTS.md")) + .toEqual(jasmine.objectContaining({ content: "# System default\n", readonly: false, protected: true })); + expect(fixture.db.prepare("SELECT name FROM sqlite_master WHERE name LIKE 'template_draft%'").all()).toEqual([]); + } finally { + fixture.cleanup(); + } + }); + + it("clones current content and settings directly into a new template", async () => { + const fixture = createFixture(); + try { + const template = await fixture.service.createTemplate({ + mode: "clone", + templateName: "support-agent", + sourceTemplateName: "default", + createdBy: 1 + }); + expect(template.default_llm_model_ref).toBe("openai/gpt-5"); + expect(template.fallback_llm_model_ref).toBe("anthropic/claude-3"); + expect(template.business_system_ids).toEqual([1]); + expect(template.mcp_server_ids).toEqual([2]); + expect((await fixture.service.readTemplateFile("support-agent", "README.md")).content) + .toBe("# Default template\n"); + } finally { + fixture.cleanup(); + } + }); + + it("replaces the current artifact on every file save and restore", async () => { + const fixture = createFixture(); + try { + await fixture.service.createTemplate({ mode: "clone", templateName: "finance-agent", sourceTemplateName: "default", createdBy: 1 }); + const before = fixture.db.prepare("SELECT artifact_id FROM agent_templates WHERE template_name = 'finance-agent'").get(); + await fixture.service.writeTemplateFile("finance-agent", { path: "README.md", content: "# Finance\n" }, 1); + const after = fixture.db.prepare("SELECT artifact_id FROM agent_templates WHERE template_name = 'finance-agent'").get(); + expect(after.artifact_id).not.toBe(before.artifact_id); + expect((await fixture.service.readTemplateFile("finance-agent", "README.md")).content).toBe("# Finance\n"); + await fixture.service.restoreDefaultAgentsFile("finance-agent", 1); + expect((await fixture.service.readTemplateFile("finance-agent", "AGENTS.md")).content).toBe("# System default\n"); + } finally { + fixture.cleanup(); + } + }); + + it("saves template policies and skills without policy or content versions", async () => { + const fixture = createFixture(); + try { + await fixture.service.createTemplate({ mode: "blank", templateName: "ops-agent", createdBy: 1 }); + expect(fixture.service.updateBusinessSystems("ops-agent", { mode: "selected", business_system_ids: [2, 1] })) + .toEqual({ mode: "selected", business_system_ids: [1, 2] }); + expect(fixture.service.updateMcpServers("ops-agent", { mode: "selected", mcp_server_ids: [2] })) + .toEqual({ mode: "selected", mcp_server_ids: [2] }); + const skillArchive = createArchive({ + "SKILL.md": "---\nname: order-query\ndescription: 查询订单\n---\n\n# Order Query\n" + }); + await fixture.service.uploadSkill("ops-agent", { + file: { originalname: "order-query.zip", buffer: skillArchive }, + createdBy: 1 + }); + const details = await fixture.service.templateDetails("ops-agent"); + expect(details.skills.map((item) => item.slug)).toEqual(["order-query"]); + expect((await fixture.service.readTemplateFile("ops-agent", "skills/order-query/SKILL.md")).content).toContain("查询订单"); + const policyColumns = fixture.db.prepare("PRAGMA table_info(template_business_system_policies)").all().map((row) => row.name); + expect(policyColumns).not.toContain("policy_version"); + } finally { + fixture.cleanup(); + } + }); + + it("allows editing built-in templates while preserving their built-in marker", async () => { + const fixture = createFixture(); + try { + await fixture.service.writeTemplateFile("default", { path: "README.md", content: "changed" }, 1); + expect(await fixture.service.readTemplateFile("default", "README.md")) + .toEqual(jasmine.objectContaining({ content: "changed", readonly: false })); + expect((await fixture.service.listTemplateTree("default")).every((entry) => entry.readonly === false)).toBeTrue(); + expect(fixture.service.updateBusinessSystems("default", { mode: "all" })) + .toEqual({ mode: "all", business_system_ids: [] }); + expect(fixture.service.updateMcpServers("default", { mode: "all" })) + .toEqual({ mode: "all", mcp_server_ids: [] }); + expect((await fixture.service.templateDetails("default")).is_builtin).toBeTrue(); + expect(fixture.db.prepare("SELECT is_builtin FROM agent_templates WHERE template_name = ?").get("default").is_builtin) + .toBe(1); + } finally { + fixture.cleanup(); + } + }); +}); diff --git a/packages/daedalus-agent-management-web/server/test/time-and-container-service.test.js b/packages/daedalus-agent-management-web/server/test/time-and-container-service.test.js new file mode 100644 index 0000000000000000000000000000000000000000..9aaf76504e19dd04f2547b4e36d6abebc3b9bf50 --- /dev/null +++ b/packages/daedalus-agent-management-web/server/test/time-and-container-service.test.js @@ -0,0 +1,41 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { ContainerService } from "../src/services/container-service.js"; +import { chinaDateKey, toChinaISOString } from "../src/utils/china-time.js"; + +it("formats China timestamps from dates, strings, and default current time", () => { + expect(toChinaISOString(new Date("2026-01-02T03:04:05.006Z"))).toBe("2026-01-02T11:04:05.006+08:00"); + expect(toChinaISOString("2026-12-31T20:00:00.000Z")).toBe("2027-01-01T04:00:00.000+08:00"); + expect(toChinaISOString()).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}\+08:00$/); + expect(chinaDateKey(new Date("2026-01-02T03:04:05.006Z"))).toBe("2026-01-02"); + expect(chinaDateKey()).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(() => toChinaISOString("invalid")).toThrowError("Invalid date value"); +}); + +it("writes restart requests with explicit and default requesters", () => { + const fixtureDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "daedalus-container-service-")); + const requestPath = path.join(fixtureDirectory, "nested", "container-restart.request"); + + try { + expect(new ContainerService().restartRequestPath).toBe(path.resolve("run", "container-restart.request")); + expect(new ContainerService({}).restartRequestPath).toBe(path.resolve("run", "container-restart.request")); + + const service = new ContainerService({ env: { containerRestartRequestPath: requestPath } }); + const defaultResult = service.requestRestart(); + expect(defaultResult.requested).toBeTrue(); + expect(JSON.parse(fs.readFileSync(requestPath, "utf8"))).toEqual({ + requested_at: defaultResult.requested_at, + requested_by: "" + }); + + const explicitResult = service.requestRestart({ requestedBy: "admin" }); + expect(JSON.parse(fs.readFileSync(requestPath, "utf8"))).toEqual({ + requested_at: explicitResult.requested_at, + requested_by: "admin" + }); + } finally { + fs.rmSync(fixtureDirectory, { recursive: true, force: true }); + } +}); diff --git a/packages/daedalus-agent-management-web/server/test/topic-ping-service.test.js b/packages/daedalus-agent-management-web/server/test/topic-ping-service.test.js index 0137d03ac580a04fc08ce75d54edc6055c79e493..5dd8450ac97f08f69e2b22a9ce5ff87871520832 100644 --- a/packages/daedalus-agent-management-web/server/test/topic-ping-service.test.js +++ b/packages/daedalus-agent-management-web/server/test/topic-ping-service.test.js @@ -450,3 +450,163 @@ it("returns accumulated block replies when final reply is not observed", async ( expect(result.replyMessage.payload.text).toBe("streamed reply"); expect(result.replyMessages.length).toBe(1); }); + +function createTopicService(overrides = {}) { + return new TopicPingService({ + db: { + prepare() { + return { + get() { + return { + slug: "agent-a", + remote_state_json: JSON.stringify({}) + }; + } + }; + } + }, + env: { + mqtt: { + brokerUrl: "mqtt://broker", + adminInboundTopic: "admin/in", + adminOutboundTopic: "admin/out", + agentInboundTopicTemplate: "agent/{agentId}/in", + agentOutboundTopicTemplate: "agent/{agentId}/out" + } + }, + ...overrides + }); +} + +it("validates topic ping configuration and agent records", async () => { + const unconfigured = createTopicService({ env: { mqtt: {} } }); + expect(unconfigured.isConfigured()).toBeFalse(); + await expectAsync(unconfigured.pingAdmin()).toBeRejectedWithError(/缺少 admin/); + await expectAsync(unconfigured.pingAgent("" )).toBeRejectedWithError(/缺少数字员工 ID/); + await expectAsync(unconfigured.testAgentMessage("agent-a", { text: "hello" })) + .toBeRejectedWithError(/MQTT broker 未配置/); + + const missing = createTopicService({ + db: { prepare() { return { get() { return null; } }; } } + }); + await expectAsync(missing.pingAgent("missing")).toBeRejectedWithError(/数字员工不存在/); + expect(() => missing.resolveAgentTopics("" )).toThrowError(/缺少数字员工 ID/); + expect(() => missing.resolveAgentTopics("missing")).toThrowError(/数字员工不存在/); + await expectAsync(missing.waitForAgentRuntimeReady("missing")) + .toBeRejectedWithError(/数字员工不存在/); +}); + +it("falls back to topic templates and rejects incomplete agent topics", async () => { + const service = createTopicService(); + expect(service.resolveAgentTopics("agent-a")).toEqual(jasmine.objectContaining({ + inboundTopic: "agent/agent-a/in", + outboundTopic: "agent/agent-a/out" + })); + + const incomplete = createTopicService({ + db: { + prepare() { + return { get() { return { slug: "agent-a", remote_state_json: "not-json" }; } }; + } + }, + env: { mqtt: { brokerUrl: "mqtt://broker" } } + }); + expect(() => incomplete.resolveAgentTopics("agent-a")) + .toThrowError(/Unexpected token/); +}); + +it("returns agent readiness timeout and validates runtime listener setup", async () => { + const service = createTopicService(); + service.pingAgent = async () => { throw new Error("not ready"); }; + await expectAsync(service.waitForAgentReady("agent-a", { timeoutMs: 3, intervalMs: 1 })) + .toBeRejectedWithError(/超时时间内仍未就绪/); + + const noListener = createTopicService(); + await expectAsync(noListener.waitForAgentRuntimeReady("agent-a", { timeoutMs: 1 })) + .toBeRejectedWithError(/outbound 监听未配置/); + + const listeners = new Map(); + const timeoutService = createTopicService({ + managementClient: { + on(event, handler) { listeners.set(event, handler); }, + off(event) { listeners.delete(event); } + } + }); + const wait = timeoutService.waitForAgentRuntimeReady("agent-a", { timeoutMs: 2 }); + listeners.get("outbound_message")?.({ request: { action: "other", params: {} } }); + await expectAsync(wait).toBeRejectedWithError(/仍未进入运行态/); +}); + +it("maps MQTT connect, subscription, publish, malformed, and timeout failures", async () => { + const connectError = new EventEmitter(); + connectError.off = connectError.removeListener.bind(connectError); + connectError.end = (_force, _options, callback) => callback?.(); + const connectFailure = createTopicService({ mqttFactory: { + connect() { + queueMicrotask(() => connectError.emit("error", new Error("connect failed"))); + return connectError; + } + } }); + await expectAsync(connectFailure.pingAdmin(50)).toBeRejectedWithError(/connect failed/); + + const subscribeClient = new EventEmitter(); + subscribeClient.end = (_force, _options, callback) => callback?.(); + subscribeClient.subscribe = (_topic, _options, callback) => callback?.(new Error("subscribe failed")); + subscribeClient.publish = () => {}; + const subscribeFailure = createTopicService({ mqttFactory: { + connect() { + queueMicrotask(() => subscribeClient.emit("connect")); + return subscribeClient; + } + } }); + await expectAsync(subscribeFailure.pingAdmin(50)).toBeRejectedWithError(/subscribe failed/); + + const publishClient = new EventEmitter(); + publishClient.end = (_force, _options, callback) => callback?.(); + publishClient.subscribe = (_topic, _options, callback) => callback?.(null); + publishClient.publish = (_topic, _payload, _options, callback) => callback?.(new Error("publish failed")); + const publishFailure = createTopicService({ mqttFactory: { + connect() { + queueMicrotask(() => publishClient.emit("connect")); + return publishClient; + } + } }); + await expectAsync(publishFailure.pingAdmin(50)).toBeRejectedWithError(/发送 ping 失败/); + + const timeoutClient = new EventEmitter(); + timeoutClient.end = (_force, _options, callback) => callback?.(); + timeoutClient.subscribe = (_topic, _options, callback) => callback?.(null); + timeoutClient.publish = (topic, payload, options, callback) => { + callback?.(null); + queueMicrotask(() => timeoutClient.emit("message", topic, Buffer.from("not-json"))); + queueMicrotask(() => timeoutClient.emit("message", topic, Buffer.from(JSON.stringify({ sessionId: "wrong", text: "PONG WRONG" })))); + }; + const timeoutService = createTopicService({ mqttFactory: { + connect() { + queueMicrotask(() => timeoutClient.emit("connect")); + return timeoutClient; + } + } }); + await expectAsync(timeoutService.pingAdmin(2)).toBeRejectedWithError(/Ping 超时/); +}); + +it("validates agent test messages and maps subscribe failures", async () => { + const service = createTopicService(); + await expectAsync(service.testAgentMessage("agent-a", { text: " " })) + .toBeRejectedWithError(/测试消息不能为空/); + + const client = new EventEmitter(); + client.off = client.removeListener.bind(client); + client.end = (_force, _options, callback) => callback?.(); + client.subscribe = (_topic, _options, callback) => callback?.(new Error("subscribe failed")); + client.publish = () => {}; + const subscribeFailure = createTopicService({ mqttFactory: { + connect() { + queueMicrotask(() => client.emit("connect")); + return client; + } + } }); + const result = await subscribeFailure.testAgentMessage("agent-a", { text: "hello" }); + expect(result.ok).toBeFalse(); + expect(result.error_message).toContain("subscribe failed"); +}); diff --git a/packages/daedalus-agent-management-web/server/test/update-notifier.test.js b/packages/daedalus-agent-management-web/server/test/update-notifier.test.js index 3f8c74e67605e18792f72204e7814e331465a06a..7912f3154ccc9574af9410e9fea8a7c82e5ba907 100644 --- a/packages/daedalus-agent-management-web/server/test/update-notifier.test.js +++ b/packages/daedalus-agent-management-web/server/test/update-notifier.test.js @@ -1,4 +1,5 @@ import { createServer } from "node:http"; +import { EventEmitter } from "node:events"; import WebSocket from "ws"; @@ -119,3 +120,103 @@ it("broadcasts realtime update messages to websocket clients", async () => { await closeServer(server); } }); + +it("covers authorization fallbacks and query-string tokens", () => { + const tokens = []; + const notifier = new UpdateNotifier({ + authService: { + getSessionUser(token) { + tokens.push(token); + return { id: 1 }; + } + }, + path: "/custom-updates" + }); + + expect(notifier.path).toBe("/custom-updates"); + expect(notifier.isAuthorized({ socket: { remoteAddress: "127.0.0.1" }, headers: {} }, new URL("http://test/custom-updates"))).toBeTrue(); + expect(notifier.isAuthorized({ headers: {} }, new URL("http://test/custom-updates?token=query-token"))).toBeTrue(); + expect(tokens).toEqual(["query-token"]); + + expect(new UpdateNotifier().isAuthorized(createUpgradeRequest(), new URL("http://test/ws/updates"))).toBeFalse(); + expect(new UpdateNotifier({ authService: {} }).isAuthorized(createUpgradeRequest({ protocol: "other, values" }), new URL("http://test/ws/updates"))).toBeFalse(); +}); + +it("rejects unauthorized upgrades, ignores other paths, and attaches only once", () => { + const server = new EventEmitter(); + const notifier = new UpdateNotifier(); + notifier.attach(server); + const firstWss = notifier.wss; + notifier.attach(server); + expect(notifier.wss).toBe(firstWss); + + const ignoredSocket = { + write: jasmine.createSpy(), + destroy: jasmine.createSpy() + }; + server.emit("upgrade", { url: "/other", socket: { remoteAddress: "10.0.0.2" }, headers: {} }, ignoredSocket, Buffer.alloc(0)); + server.emit("upgrade", { socket: { remoteAddress: "10.0.0.2" }, headers: {} }, ignoredSocket, Buffer.alloc(0)); + expect(ignoredSocket.write).not.toHaveBeenCalled(); + + const rejectedSocket = { + write: jasmine.createSpy(), + destroy: jasmine.createSpy() + }; + server.emit("upgrade", { url: "/ws/updates", socket: { remoteAddress: "10.0.0.2" }, headers: {} }, rejectedSocket, Buffer.alloc(0)); + expect(rejectedSocket.write).toHaveBeenCalledWith("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + expect(rejectedSocket.destroy).toHaveBeenCalled(); + + firstWss.close(); +}); + +it("handles closed and failing clients while broadcasting and closing", () => { + const handlers = {}; + const openSocket = { + readyState: WebSocket.OPEN, + on(event, handler) { + handlers[event] = handler; + }, + send: jasmine.createSpy(), + close: jasmine.createSpy() + }; + const notifier = new UpdateNotifier(); + notifier.handleConnection(openSocket); + expect(openSocket.send).toHaveBeenCalled(); + expect(notifier.clients.has(openSocket)).toBeTrue(); + + handlers.error("socket failed"); + expect(notifier.clients.has(openSocket)).toBeFalse(); + notifier.clients.add(openSocket); + handlers.close(); + expect(notifier.clients.has(openSocket)).toBeFalse(); + + const closedSocket = { + readyState: WebSocket.CLOSED, + close: jasmine.createSpy() + }; + expect(notifier.send(closedSocket, {})).toBeFalse(); + + const failingSocket = { + readyState: WebSocket.OPEN, + send() { + throw new Error("send failed"); + }, + close: jasmine.createSpy() + }; + expect(notifier.send(failingSocket, {})).toBeFalse(); + + notifier.clients.add(openSocket); + notifier.clients.add(closedSocket); + expect(notifier.broadcast("empty-payload")).toBe(1); + + notifier.wss = { close: jasmine.createSpy() }; + notifier.attached = true; + notifier.close(); + expect(openSocket.close).toHaveBeenCalled(); + expect(closedSocket.close).toHaveBeenCalled(); + expect(notifier.clients.size).toBe(0); + expect(notifier.wss).toBeNull(); + expect(notifier.attached).toBeFalse(); + + new UpdateNotifier().close(); +}); diff --git a/packages/daedalus-agent-management-web/src/app/App.jsx b/packages/daedalus-agent-management-web/src/app/App.jsx index 7e85fa66bec6992b05c17201d5f66314cab60220..8958728adf294176ce2d2b16247751ee6aef069d 100644 --- a/packages/daedalus-agent-management-web/src/app/App.jsx +++ b/packages/daedalus-agent-management-web/src/app/App.jsx @@ -8,6 +8,7 @@ import { FileTextOutlined, LockOutlined, SettingOutlined, + ShareAltOutlined, TeamOutlined, ToolOutlined } from "@ant-design/icons"; @@ -22,6 +23,7 @@ import { AgentsPage } from "../pages/AgentsPage.jsx"; import { DashboardPage } from "../pages/DashboardPage.jsx"; import { LargeLanguageModelsPage } from "../pages/LargeLanguageModelsPage.jsx"; import { LoginPage } from "../pages/LoginPage.jsx"; +import { McpServersPage } from "../pages/McpServersPage.jsx"; import { SettingsPage } from "../pages/SettingsPage.jsx"; import { SkillsPage } from "../pages/SkillsPage.jsx"; import { SystemLogsPage } from "../pages/SystemLogsPage.jsx"; @@ -77,8 +79,9 @@ const menuItems = [ label: "平台资源", type: "group", children: [ + { key: "systems", label: "业务系统", icon: }, { key: "skills", label: "全局技能", icon: }, - { key: "systems", label: "业务系统", icon: } + { key: "mcpServers", label: "MCP 服务", icon: } ] }, { @@ -100,6 +103,7 @@ export default function App() { const [selectedKey, setSelectedKey] = useState("dashboard"); const [dashboardRefreshKey, setDashboardRefreshKey] = useState(0); const [agentsRefreshKey, setAgentsRefreshKey] = useState(0); + const [agentCreateTemplateName, setAgentCreateTemplateName] = useState(""); const [runtimeHealth, setRuntimeHealth] = useState(null); const [runtimeHealthLoading, setRuntimeHealthLoading] = useState(false); const [messageApi, contextHolder] = message.useMessage(); @@ -206,6 +210,7 @@ export default function App() { const startupSyncMask = getPageStartupSyncMask(selectedKey, boot); const handleSelectMenu = (key) => { + setAgentCreateTemplateName(""); setSelectedKey(key); if (key === "dashboard") { setDashboardRefreshKey((current) => current + 1); @@ -344,15 +349,30 @@ export default function App() { case "userManagement": return ; case "templates": - return ; + return ( + { + setAgentCreateTemplateName(templateName); + setSelectedKey("agents"); + }} + /> + ); case "skills": return ; case "agents": - return ; + return ( + setAgentCreateTemplateName("")} + /> + ); case "largeLanguageModels": return ; case "systems": return ; + case "mcpServers": + return ; case "systemLogs": return ; case "settings": @@ -463,7 +483,7 @@ export default function App() { default: return null; } - }, [accessTokens, agentSync, agentsRefreshKey, currentUser, dashboardRefreshKey, envConfig, llmSync, messageApi, refreshRuntimeHealth, runtimeHealth, runtimeHealthLoading, selectedKey, settings, skillSync, systemSync, templateSync, usageRefresh, versionInfo]); + }, [accessTokens, agentCreateTemplateName, agentSync, agentsRefreshKey, currentUser, dashboardRefreshKey, envConfig, llmSync, messageApi, refreshRuntimeHealth, runtimeHealth, runtimeHealthLoading, selectedKey, settings, skillSync, systemSync, templateSync, usageRefresh, versionInfo]); return ( = 200 && status < 300, payload = null, jsonError = null } = {}) { + return { + status, + ok, + async json() { + if (jsonError) { + throw jsonError; + } + return payload; + } + }; +} + +beforeEach(() => { + const values = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key) => values.get(key) ?? null), + setItem: vi.fn((key, value) => values.set(key, value)), + removeItem: vi.fn((key) => values.delete(key)) + }); + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +it("stores, reads, and clears the authentication token", () => { + expect(authTokenStore.get()).toBe(""); + authTokenStore.set("token"); + expect(authTokenStore.get()).toBe("token"); + authTokenStore.set(""); + expect(authTokenStore.get()).toBe(""); +}); + +describe("normalizeError", () => { + it("preserves Error instances and fills only missing context", () => { + const error = new Error("failure"); + expect(api.normalizeError(error, "fallback", "GET /one")).toBe(error); + expect(error.context).toBe("GET /one"); + + const contextual = Object.assign(new Error("failure"), { context: "original" }); + expect(api.normalizeError(contextual, "fallback", "GET /two").context).toBe("original"); + }); + + it("creates errors from strings and fallbacks with optional context", () => { + expect(api.normalizeError("failure", "fallback", "GET /one")).toMatchObject({ + message: "failure", + context: "GET /one" + }); + const fallback = api.normalizeError(null, "fallback"); + expect(fallback.message).toBe("fallback"); + expect("context" in fallback).toBe(false); + }); +}); + +it("sends authenticated JSON requests and supports every API verb", async () => { + authTokenStore.set("token"); + fetch.mockResolvedValue(jsonResponse({ payload: { ok: true } })); + + await expect(api.get("/get")).resolves.toEqual({ ok: true }); + await expect(api.post("/post", { value: 1 })).resolves.toEqual({ ok: true }); + await expect(api.put("/put", { value: 2 })).resolves.toEqual({ ok: true }); + await expect(api.delete("/delete")).resolves.toEqual({ ok: true }); + + const getOptions = fetch.mock.calls[0][1]; + expect(getOptions.method).toBe("GET"); + expect(getOptions.headers.get("Authorization")).toBe("Bearer token"); + expect(getOptions.body).toBeUndefined(); + + const postOptions = fetch.mock.calls[1][1]; + expect(postOptions.headers.get("Content-Type")).toBe("application/json"); + expect(postOptions.body).toBe('{"value":1}'); + expect(fetch.mock.calls[2][1].method).toBe("PUT"); + expect(fetch.mock.calls[3][1].method).toBe("DELETE"); +}); + +it("preserves explicit authorization and sends multipart bodies without JSON headers", async () => { + authTokenStore.set("token"); + fetch.mockResolvedValue(jsonResponse({ payload: { ok: true } })); + const formData = new FormData(); + formData.set("name", "value"); + + await api.get("/custom", { headers: { Authorization: "Custom token" } }); + await api.multipart("/upload", formData, "PUT"); + + expect(fetch.mock.calls[0][1].headers.get("Authorization")).toBe("Custom token"); + expect(fetch.mock.calls[1][1]).toMatchObject({ method: "PUT", body: formData }); + expect(fetch.mock.calls[1][1].headers.has("Content-Type")).toBe(false); +}); + +it("returns null for empty and malformed successful responses", async () => { + fetch + .mockResolvedValueOnce(jsonResponse({ status: 204 })) + .mockResolvedValueOnce(jsonResponse({ payload: null, jsonError: new Error("not json") })); + + await expect(api.get("/empty")).resolves.toBeNull(); + await expect(api.get("/text")).resolves.toBeNull(); +}); + +it("normalizes response errors by message, code, and status", async () => { + fetch + .mockResolvedValueOnce(jsonResponse({ status: 400, ok: false, payload: { message: "message-error" } })) + .mockResolvedValueOnce(jsonResponse({ status: 401, ok: false, payload: { code: "code-error" } })) + .mockResolvedValueOnce(jsonResponse({ status: 500, ok: false, jsonError: new Error("not json") })); + + await expect(api.get("/message")).rejects.toMatchObject({ message: "message-error", context: "GET /message" }); + await expect(api.get("/code")).rejects.toMatchObject({ message: "code-error", context: "GET /code" }); + await expect(api.get("/status")).rejects.toMatchObject({ message: "请求失败 (500)", context: "GET /status" }); +}); + +it("normalizes network failures from Error and non-Error values", async () => { + fetch + .mockRejectedValueOnce(new Error("offline")) + .mockRejectedValueOnce("offline-text"); + + await expect(api.get("/error")).rejects.toMatchObject({ message: "offline", context: "GET /error" }); + await expect(api.get("/text-error")).rejects.toMatchObject({ message: "offline-text", context: "GET /text-error" }); +}); + +it("supports disabled timeouts, explicit signals, and timeout aborts", async () => { + fetch.mockResolvedValue(jsonResponse({ payload: { ok: true } })); + await api.get("/disabled-timeout", { timeoutMs: 0 }); + expect(fetch.mock.calls[0][1].signal).toBeUndefined(); + + const explicitController = new AbortController(); + await api.get("/explicit-signal", { timeoutMs: 100, signal: explicitController.signal }); + expect(fetch.mock.calls[1][1].signal).toBe(explicitController.signal); + + vi.useFakeTimers(); + fetch.mockImplementationOnce((_path, options) => new Promise((_resolve, reject) => { + options.signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); + })); + const request = api.get("/timeout", { timeoutMs: 10 }); + const rejection = request.catch((error) => error); + await vi.advanceTimersByTimeAsync(10); + await expect(rejection).resolves.toMatchObject({ message: "aborted", context: "GET /timeout" }); +}); + +it("works when AbortController is unavailable or timeout is invalid", async () => { + vi.stubGlobal("AbortController", undefined); + fetch.mockResolvedValue(jsonResponse({ payload: { ok: true } })); + + await api.get("/no-controller", { timeoutMs: 10 }); + await api.get("/invalid-timeout", { timeoutMs: "bad" }); + expect(fetch.mock.calls[0][1].signal).toBeUndefined(); + expect(fetch.mock.calls[1][1].signal).toBeUndefined(); +}); + +describe("download", () => { + it("returns a blob and sends authentication only when present", async () => { + const blob = new Blob(["content"]); + fetch.mockResolvedValue({ ok: true, blob: vi.fn().mockResolvedValue(blob) }); + + await expect(api.download("/public")).resolves.toBe(blob); + expect(fetch.mock.calls[0][1].headers.has("Authorization")).toBe(false); + + authTokenStore.set("token"); + await expect(api.download("/private")).resolves.toBe(blob); + expect(fetch.mock.calls[1][1].headers.get("Authorization")).toBe("Bearer token"); + }); + + it("normalizes network and JSON response failures", async () => { + fetch + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValueOnce(jsonResponse({ status: 400, ok: false, payload: { message: "message-error" } })) + .mockResolvedValueOnce(jsonResponse({ status: 400, ok: false, payload: { code: "code-error" } })) + .mockResolvedValueOnce(jsonResponse({ status: 500, ok: false, payload: {} })) + .mockResolvedValueOnce(jsonResponse({ status: 500, ok: false, jsonError: new Error("not json") })); + + await expect(api.download("/offline")).rejects.toMatchObject({ message: "offline", context: "GET /offline" }); + await expect(api.download("/message")).rejects.toMatchObject({ message: "message-error", context: "GET /message" }); + await expect(api.download("/code")).rejects.toMatchObject({ message: "code-error", context: "GET /code" }); + await expect(api.download("/empty")).rejects.toMatchObject({ message: "请求失败", context: "GET /empty" }); + await expect(api.download("/fallback")).rejects.toMatchObject({ message: "请求失败", context: "GET /fallback" }); + }); +}); diff --git a/packages/daedalus-agent-management-web/src/components/LocalMonacoEditor.jsx b/packages/daedalus-agent-management-web/src/components/LocalMonacoEditor.jsx new file mode 100644 index 0000000000000000000000000000000000000000..c3c2f888346e637258fec8a841f2928fe7a19f27 --- /dev/null +++ b/packages/daedalus-agent-management-web/src/components/LocalMonacoEditor.jsx @@ -0,0 +1,17 @@ +import React from "react"; +import Editor, { loader } from "@monaco-editor/react"; +import * as monaco from "monaco-editor/editor/editor.api"; +import "monaco-editor/basic-languages/monaco.contribution"; +import EditorWorker from "monaco-editor/editor/editor.worker?worker"; + +globalThis.MonacoEnvironment = { + getWorker() { + return new EditorWorker(); + } +}; + +loader.config({ monaco }); + +export function LocalMonacoEditor(props) { + return ; +} diff --git a/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx b/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx index a940fea3afb387cd1c3924056392864ebdef6903..c132f5349643d2188589654058e7be404e317354 100644 --- a/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx +++ b/packages/daedalus-agent-management-web/src/pages/AgentsPage.jsx @@ -7,10 +7,10 @@ import { EditOutlined, PlusOutlined, PoweroffOutlined, - SaveOutlined, - TeamOutlined + SaveOutlined } from "@ant-design/icons"; import { + Alert, Button, Card, Col, @@ -22,6 +22,7 @@ import { Modal, Progress, Row, + Segmented, Select, Spin, Space, @@ -35,6 +36,7 @@ import { api } from "../app/api-client.js"; import { CardTitleWithReload } from "../components/CardTitleWithReload.jsx"; import { DeleteActionButton } from "../components/DeleteActionButton.jsx"; import { AgentTestPanel } from "./AgentTestPanel.jsx"; +import { AgentWorkspaceWorkbench } from "./agents/AgentWorkspaceWorkbench.jsx"; import { formatDateTime, formatTokenCount } from "../utils/format.js"; import { PLATFORM_RESTART_NOTICE, @@ -208,7 +210,7 @@ function getAgentDetailItems(agent) { ]; } -export function AgentsPage({ refreshKey = 0 }) { +export function AgentsPage({ refreshKey = 0, createTemplateName = "", onCreateTemplateHandled }) { const [items, setItems] = useState([]); const [templates, setTemplates] = useState([]); const [llmProviders, setLlmProviders] = useState([]); @@ -221,8 +223,20 @@ export function AgentsPage({ refreshKey = 0 }) { const [permissionAgent, setPermissionAgent] = useState(null); const [permissionOpen, setPermissionOpen] = useState(false); const [permissionSaving, setPermissionSaving] = useState(false); + const [businessSystemAgent, setBusinessSystemAgent] = useState(null); + const [businessSystemItems, setBusinessSystemItems] = useState([]); + const [businessSystemOpen, setBusinessSystemOpen] = useState(false); + const [businessSystemLoading, setBusinessSystemLoading] = useState(false); + const [businessSystemSaving, setBusinessSystemSaving] = useState(false); + const [mcpAgent, setMcpAgent] = useState(null); + const [mcpItems, setMcpItems] = useState([]); + const [mcpOpen, setMcpOpen] = useState(false); + const [mcpLoading, setMcpLoading] = useState(false); + const [mcpSaving, setMcpSaving] = useState(false); const [detailAgent, setDetailAgent] = useState(null); const [detailOpen, setDetailOpen] = useState(false); + const [detailEditing, setDetailEditing] = useState(false); + const [renameOpen, setRenameOpen] = useState(false); const [workspaceDownloadingSlug, setWorkspaceDownloadingSlug] = useState(""); const [testAgent, setTestAgent] = useState(null); const [testOpen, setTestOpen] = useState(false); @@ -235,9 +249,15 @@ export function AgentsPage({ refreshKey = 0 }) { const [restartingOpenClaw, setRestartingOpenClaw] = useState(false); const [form] = Form.useForm(); const [permissionForm] = Form.useForm(); + const [businessSystemForm] = Form.useForm(); + const [mcpForm] = Form.useForm(); + const [renameForm] = Form.useForm(); const [messageApi, contextHolder] = message.useMessage(); const selectedProviderId = Form.useWatch("llm_provider_id", form); const fallbackSelectedProviderId = Form.useWatch("fallback_llm_provider_id", form); + const businessSystemMode = Form.useWatch("mode", businessSystemForm); + const mcpMode = Form.useWatch("mode", mcpForm); + const permissionMode = Form.useWatch("mode", permissionForm); const modelOptions = useMemo(() => llmModels .filter((item) => !selectedProviderId || item.provider_id === selectedProviderId) .map((item) => ({ @@ -316,6 +336,29 @@ export function AgentsPage({ refreshKey = 0 }) { }; }; + useEffect(() => { + if (!createTemplateName || templates.length === 0) { + return; + } + + const template = templates.find((item) => item.template_name === createTemplateName); + if (!template) { + messageApi.error(`模板 ${createTemplateName} 不存在或已不可用`); + onCreateTemplateHandled?.(); + return; + } + + setEditing(null); + form.setFieldsValue({ + ...getInitialValues(), + template_name: template.template_name, + llm_provider_id: template.default_llm_provider_id || undefined, + llm_model_id: template.default_llm_model_id || undefined + }); + setOpen(true); + onCreateTemplateHandled?.(); + }, [createTemplateName, templates, form, messageApi, onCreateTemplateHandled]); + useEffect(() => { if (!open || editing || templates.length === 0) { return; @@ -346,19 +389,152 @@ export function AgentsPage({ refreshKey = 0 }) { } }; - const openPermissionDrawer = (agent) => { + const openPermissionDrawer = async (agent) => { setPermissionAgent(agent); - permissionForm.setFieldsValue({ - permission_usernames: (agent.permissions || []).map((item) => item.username) - }); setPermissionOpen(true); + try { + const policy = await api.get(`/api/agents/${agent.id}/user-policy`); + permissionForm.setFieldsValue({ + mode: policy.mode || "selected", + policy_version: policy.policy_version, + permission_usernames: policy.permission_usernames || [] + }); + } catch { + permissionForm.setFieldsValue({ + mode: agent.user_policy?.mode || "selected", + policy_version: agent.user_policy?.policy_version || 1, + permission_usernames: (agent.permissions || []).map((item) => item.username) + }); + } + }; + + const openBusinessSystemModal = async (agent) => { + setBusinessSystemAgent(agent); + setBusinessSystemOpen(true); + setBusinessSystemLoading(true); + try { + const [policy, systems] = await Promise.all([ + api.get(`/api/agents/${agent.id}/business-systems`), + api.get("/api/systems") + ]); + setBusinessSystemItems(systems || []); + businessSystemForm.setFieldsValue({ + mode: policy.mode, + policy_version: policy.policy_version, + business_system_ids: policy.business_system_ids || [] + }); + } catch (error) { + setBusinessSystemOpen(false); + setBusinessSystemAgent(null); + messageApi.error(error.message || "加载业务系统权限失败"); + } finally { + setBusinessSystemLoading(false); + } + }; + + const saveBusinessSystemPolicy = async () => { + try { + const values = await businessSystemForm.validateFields(); + setBusinessSystemSaving(true); + await api.put(`/api/agents/${businessSystemAgent.id}/business-systems`, { + mode: values.mode, + policy_version: values.policy_version, + business_system_ids: values.mode === "selected" ? values.business_system_ids || [] : [] + }); + messageApi.success(`数字员工 ${businessSystemAgent.slug} 的业务系统权限已保存`); + setBusinessSystemOpen(false); + setBusinessSystemAgent(null); + businessSystemForm.resetFields(); + } catch (error) { + if (!error?.errorFields) { + messageApi.error(error.message || "保存业务系统权限失败"); + } + } finally { + setBusinessSystemSaving(false); + } + }; + + const openMcpModal = async (agent) => { + setMcpAgent(agent); + setMcpOpen(true); + setMcpLoading(true); + try { + const [policy, servers] = await Promise.all([ + api.get(`/api/agents/${agent.id}/mcp`), + api.get("/api/mcp-servers") + ]); + setMcpItems(servers || []); + mcpForm.setFieldsValue({ + mode: policy.mode, + policy_version: policy.policy_version, + mcp_server_ids: policy.mcp_server_ids || [] + }); + } catch (error) { + setMcpOpen(false); + setMcpAgent(null); + messageApi.error(error.message || "加载 MCP 权限失败"); + } finally { + setMcpLoading(false); + } + }; + + const saveMcpPolicy = async () => { + try { + const values = await mcpForm.validateFields(); + setMcpSaving(true); + await api.put(`/api/agents/${mcpAgent.id}/mcp`, { + mode: values.mode, + policy_version: values.policy_version, + mcp_server_ids: values.mode === "selected" ? values.mcp_server_ids || [] : [] + }); + messageApi.success(`数字员工 ${mcpAgent.slug} 的 MCP 权限已保存`); + setMcpOpen(false); + setMcpAgent(null); + mcpForm.resetFields(); + } catch (error) { + if (!error?.errorFields) messageApi.error(error.message || "保存 MCP 权限失败"); + } finally { + setMcpSaving(false); + } }; - const openDetailDrawer = (agent) => { + const openDetailDrawer = (agent, editingMode = false) => { setDetailAgent(agent); + setDetailEditing(editingMode); setDetailOpen(true); }; + const submitRename = async () => { + const values = await renameForm.validateFields(); + if (!detailAgent) { + return; + } + setSubmitLoading(true); + try { + const updated = await api.put(`/api/agents/${detailAgent.id}`, { + agent_name: String(values.agent_name || "").trim(), + llm_provider_id: detailAgent.llm_provider_id, + llm_model_id: detailAgent.llm_model_id, + fallback_llm_provider_id: detailAgent.fallback_llm_provider_id || "", + fallback_llm_model_id: detailAgent.fallback_llm_model_id || "", + status: detailAgent.status, + daily_limit: detailAgent.daily_limit, + restart: false + }); + setRenameOpen(false); + renameForm.resetFields(); + setDetailAgent(updated); + await load(); + messageApi.success("数字员工已重命名"); + } catch (error) { + if (!error?.errorFields) { + messageApi.error(error.message || "重命名数字员工失败"); + } + } finally { + setSubmitLoading(false); + } + }; + const openTestDrawer = (agent) => { setTestAgent(agent); setTestOpen(true); @@ -551,19 +727,21 @@ export function AgentsPage({ refreshKey = 0 }) { columns={[ { title: "ID", - dataIndex: "slug", + dataIndex: "slug" + }, + { + title: "名称", render: (value, row) => ( { openDetailDrawer(row); }} > - {value} + {row.agent_name || "-"} ) }, - { title: "名称", dataIndex: "agent_name", render: (value) => value || "-" }, { title: "使用模板", dataIndex: "template_name", render: (value) => value || "-" }, { title: "大语言模型", render: (_, row) => renderAgentModelSummary(row) }, { @@ -582,37 +760,17 @@ export function AgentsPage({ refreshKey = 0 }) { { title: "操作", render: (_, row) => ( - + - + , + disabled: businessSystemLoading, + "data-testid": "agent-business-systems-submit" + }} + cancelButtonProps={{ icon: }} + onCancel={() => { + if (businessSystemSaving) { + return; + } + setBusinessSystemOpen(false); + setBusinessSystemAgent(null); + businessSystemForm.resetFields(); + }} + onOk={() => { + void saveBusinessSystemPolicy(); + }} + > + +
+ + + + + {businessSystemMode === "selected" ? ( + + + + + + {mcpMode === "selected" ? ( + + + + + + {permissionMode === "all" ? ( + + ) : null} + {permissionMode !== "all" ? ( + ) : null}
+ { + if (!submitLoading) { + setRenameOpen(false); + renameForm.resetFields(); + } + }} + onOk={() => void submitRename()} + > +
+ + + +
+
+ + {detailEditing ? ( + + ) : null}