From 71e147d45a580eea88eefc0cecfb1fa805c3b944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= Date: Fri, 9 Oct 2026 18:05:11 +0800 Subject: [PATCH 1/3] fix: correct agent firewall address policy and remove port bans --- doc/firewall-mcp-validation-20261009.md | 98 +++++++++++ doc/system-feature-list.md | 8 +- docker/README.md | 4 + docker/assets/scripts/kernel-startup.sh | 212 +++-------------------- docker/assets/scripts/startup.sh | 1 - docker/docs/environment-variables.md | 4 +- docker/docs/spec.md | 2 +- test/README.md | 10 ++ test/case/upgrade/agent-firewall.test.js | 177 +++++++++++++++++++ test/common/netfilter-harness.sh | 91 ++++++++++ test/package.json | 3 +- 11 files changed, 414 insertions(+), 196 deletions(-) create mode 100644 doc/firewall-mcp-validation-20261009.md create mode 100644 test/case/upgrade/agent-firewall.test.js create mode 100644 test/common/netfilter-harness.sh diff --git a/doc/firewall-mcp-validation-20261009.md b/doc/firewall-mcp-validation-20261009.md new file mode 100644 index 0000000..cb9ddb1 --- /dev/null +++ b/doc/firewall-mcp-validation-20261009.md @@ -0,0 +1,98 @@ +# 防火墙与 MCP 容器验证记录 + +## 结论 + +2026-10-09 在 Docker Desktop 的 Linux 容器中完成验证。Agent 的 IPv4 / IPv6 内网访问按地址白名单生效,端口不影响允许或拒绝结果;公网 HTTP / HTTPS 可访问。启动流程能移除专用链中的旧版 `80` / `443` 封禁、过期白名单和重复跳转。 + +使用 `https://gcapi.cn/v1` 的 `deepseek-v4-flash` 模型,经本机 Model Gateway 和 MQTT Agent 通道,成功调用 GcExcel MCP 的 `overview`、`search`、`fetch` 三个工具。运行轨迹中三次工具结果均为 `isError=false`,Agent 最终回复包含 `GCEXCEL_MCP_OK`。 + +## 测试环境与范围 + +- Docker Desktop `4.91.0`,Docker Engine `29.8.0`,Linux `amd64`。 +- 基础镜像为本机 `daedalus-agent-x64:latest`,镜像 ID 为 `sha256:5218e9368e50b22e98fe0bfb3c02a7c738024a34566a92b22530594ea6e0bdf8`,构建时间为 `2026-09-02`。 +- 测试镜像 `daedalus-agent-x64:firewall-mcp-20261009` 在该基础镜像上替换本次修改的 `kernel-startup.sh` 和 `startup.sh`。本次验证覆盖修改后的启动脚本与现有应用镜像的集成,不是完整仓库镜像重建。 +- 容器内为 OpenClaw `2026.7.1`、Node.js `24.18.0`。应用使用独立数据卷、独立双栈网络和 `NET_ADMIN`;Agent 测试进程 UID 为 `996`,服务 UID 不受 Agent 策略限制。 +- 白名单为 `172.30.245.10`、`fd42:dae:245::10`;非白名单测试地址为 `172.30.245.11`、`fd42:dae:245::11`。 +- 使用独立测试网络中的 `198.51.100.10`、`2001:db8:245::10` 验证防火墙对公网分类地址的处理;真实互联网另以 `http://example.com`、`https://example.com` 和模型 / MCP 地址验证。未将测试网段的 IPv6 结果视为互联网 IPv6 链路验证。 +- 容器 DNS 为 `127.0.0.11`,解析模型和 MCP 域名成功。 + +容器内脚本与工作区脚本统一为 LF 后的 SHA-256 一致: + +| 脚本 | SHA-256 | +| --- | --- | +| `docker/assets/scripts/kernel-startup.sh` | `0435c08ca447a00e871734afd47a29968a0341a76dfc51f5570702040073d57a` | +| `docker/assets/scripts/startup.sh` | `779274b2b25b275521ce5becab08fc26ffd8d1ddb69b1d0a02fe8a71130f9097` | + +## 防火墙验证 + +| 场景 | 验证方法 | 结果 | +| --- | --- | --- | +| 网络基线 | 以 `daedalus-svc` 访问 6 个目标地址,各测试 TCP / UDP 的 `80`、`443`、`18080`,共 36 个组合 | 全部可达,排除测试服务或路由不可用造成的误判 | +| Agent 地址策略 | 以 `daedalus-agent` 执行同一组 36 个组合 | 白名单与公网分类目标全部可达,非白名单内网目标全部拒绝 | +| 旧规则迁移 | 注入 IPv4 / IPv6 的 `80` / `443` 拒绝规则、过期地址放行和重复 `OUTPUT` 跳转,在同一容器网络命名空间触发内核启动 | 启动日志记录专用链重置,旧规则全部消失,每个地址族仅保留一个 Agent 跳转 | +| 无关规则保护 | 另建 `DA_FW_TEST_UNRELATED` 链并添加 `2525` 拒绝规则 | 内核启动清理后保留 | +| 清理后的访问 | 旧规则迁移后重新执行 Agent 的 36 个组合 | 全部符合预期 | +| 容器重启 | 完整重启容器后检查专用链,再执行 36 个组合 | 无端口封禁或重复规则,全部符合预期 | +| 入站响应 | 非白名单测试容器向 Agent UID 监听的 `19090` 发起 IPv4 / IPv6 TCP / UDP 请求 | 4 个组合均收到正确响应 | +| 既有连接撤权 | Agent 建立到白名单 IPv4 / IPv6 的 TCP 连接,收到首次响应后移除地址放行,再在原连接发送请求 | 两个地址族均无法继续收到响应;测试结束后恢复规则并核对快照一致 | +| 真实公网 | Agent UID 请求 `http://example.com` 和 `https://example.com` | 均返回 HTTP `200` | + +## 模型与 MCP 验证 + +GcExcel MCP 地址为 `https://mcp.grapecity.com.cn/mcp/gcexcel`,认证使用名为 `token` 的 HTTP 请求头。凭据只用于临时测试环境,报告不包含凭据值。 + +1. Agent UID 直接完成 MCP `initialize` 和 `tools/list`,识别到 `GCEXCEL-MCP-Server 0.1.4` 及 `overview`、`search`、`fetch` 三个业务工具。 +2. 通过管理 API 配置并探测同一 MCP,结果为 `ok=true`,诊断列表为空。 +3. 通过管理 API 配置 `deepseek-v4-flash` 并执行模型测试,上游返回 HTTP `200`。 +4. 创建隔离测试 Agent,经 MQTT 发起文档查询。实际运行模型标识为 `daedalus-gateway` / `firewall-test/deepseek-v4-flash`。 +5. 运行轨迹依次记录 `firewall-mcp__overview`、`firewall-mcp__search`、`firewall-mcp__fetch`;搜索返回 `doc_id=demos_tutorial`,后续 `fetch` 使用该真实 ID,三个工具结果均非错误且内容非空。 +6. Agent 完成回复,说明了工作簿创建及单元格写入方法,并输出 `GCEXCEL_MCP_OK`。验证以实际工具调用及结果为依据。 + +初始 ModelScope MCP 地址返回 HTTP `410` 和 `Url is expired`,宿主机与容器结果一致。更换为上述 GcExcel MCP 后完成验证。 + +## 证据与测试中观察到的现象 + +详细证据位于 `E:\tmp\daedalus-firewall-mcp-20261009\reports`: + +- `network-baseline.json`、`network-agent.json`、`network-after-startup.json`、`network-after-container-restart.json`:逐地址、端口、协议的网络结果。 +- `rules-legacy-v4.txt`、`rules-legacy-v6.txt`、`rules-after-startup-v4.txt`、`rules-after-startup-v6.txt`、`startup-cleanup.json`:旧规则、启动清理与无关规则保护。 +- `inbound-replies.json`、`established-revocation.json`:入站响应与既有出站连接撤权。 +- `mcp-direct-tools.json`、`mcp-probe.json`、`model-test.json`、`agent-tool-evidence.json`:MCP 发现、应用探测、模型响应和实际工具调用证据。 + +为制造同一网络命名空间中的旧规则残留,测试主动终止了内核 supervisor。旧网关进程短暂残留,基础镜像缺少 `fuser`,导致新网关无法立即释放已占用端口。完整重启容器后恢复,后续网络和真实 MCP 验证全部通过。该现象与端口封禁清理结果分开记录。 + +管理端 `test-message` 接口在首段回复后以 `block-idle` 提前返回。因此本次继续检查 Agent 会话轨迹,确认三个 MCP 工具的实际调用、成功结果及最终回复后,才判定完整链路通过。 + +测试结束后已清理本次创建的三个容器、数据卷、两个网络、临时镜像标签和明文凭据文件;保留已脱敏的报告与验证脚本。原有应用容器和基础镜像未参与清理。 + +## 完整镜像重建与启动验证 + +分支拆分说明:以下为拆分前的历史验证记录。历史构建输入还包含 Model Gateway 构建上下文补全及对应契约测试;该项按本次要求归入 `upgrade-261009`,不包含在 `fix-firewall-policy-correction` 的防火墙提交中。因此,下述完整镜像结论不表示拆分后的防火墙分支已单独重新构建。 + +上述专项验证完成后,使用构建启动时工作区的 `docker/Dockerfile` 完整构建了 `linux/amd64` 镜像,并导入 Docker Desktop: + +- 标签:`daedalus-agent-x64:latest`、`daedalus-agent-x64:20261009-firewall`。 +- Docker 镜像 ID:`sha256:c13cfa11b81028144ee019735a582167e3358590b8a06a0b21260ab9d44510a9`。 +- 镜像大小:`11519469655` 字节,约 `11.52 GB`。 +- 构建版本:OpenClaw `2026.7.1`、Node.js `24.18.0`、ClawHub `0.23.0`、QMD `2.5.3`、agent-browser `0.31.0`。 +- 本次补充 Model Gateway 的 Docker 构建上下文条目,并增加检查镜像所需本仓库 npm 包是否包含在构建上下文中的契约测试;升级与防火墙测试合计 `18` 项通过。 + +交付检查时发现工作区的 `docker/Dockerfile` 和 `docker/build-x64.sh` 已出现另一组依赖升级修改,包括 OpenClaw `2026.9.7`、ClawHub `0.23.3`、QMD `2.8.3`、agent-browser `0.38.2` 和 Chrome `155.0.8059.39`。这些修改晚于本次构建输入快照,不包含在上述镜像和测试结论中;保留这些工作区修改,不回退或覆盖。 + +MinIO 上游镜像仓库返回 HTTP `401`,因此通过 BuildKit 命名上下文复用本机原镜像中的 `minio` 和 `mc` 可执行文件,应用代码仍由当前工作区构建。二者版本分别为 `RELEASE.2025-09-07T16-13-09Z` 和 `RELEASE.2025-08-13T08-35-41Z`。QMD 安装期间曾遇到依赖下载 DNS 失败,网络恢复后重试成功。 + +`docker/internal/kernel/vendor/qmd-models` 中的三个 `.gguf` 文件直接复制入镜像,逐文件 SHA-256 与本地一致;没有重新下载模型权重。构建中下载的是 `@tobilu/qmd@2.5.3` 程序及其 npm 原生依赖。 + +完整镜像的新增验证结果: + +| 检查 | 结果 | +| --- | --- | +| 预装工具离线检查 | Docker 构建中的 `verify-preinstalled-skills.sh` 在 `--network=none` 下通过 | +| 镜像内容 | Model Gateway、管理服务和前端产物齐全;两个启动脚本的 SHA-256 与上文一致,包含防火墙修复 | +| 独立数据卷首次启动 | 15 个启动步骤完成,OpenClaw 输出 `ready`,启动钩子输出 `all is ok` | +| 服务健康 | 管理页面、Model Gateway `/readyz`、MinIO 健康端点和两个本机服务 socket 均正常 | +| IPv4 / IPv6 防火墙 | 专用链无端口匹配规则,各有一个 Agent UID 跳转;保留入站响应放行,按地址拒绝内网,末尾放行公网 | +| Agent 公网访问 | UID `996` 访问公网 HTTP / HTTPS 均返回 `200` | +| 真实 MCP | UID `996` 使用 `token` 请求头完成 GcExcel MCP 初始化、工具发现及 `overview` 调用,`isError=false`,内容非空 | + +详细构建和验证证据位于 `E:\tmp\daedalus-image-build-20261009`,包括 `build-retry.log`、`build-metadata.json`、`image-info.json`、`minio-inputs.json`、`qmd-models.sha256`、`image-checks.log`、`smoke-startup.log`、`smoke-checks.log`、`mcp-live-check.json`。完整镜像验证没有重复上述已通过的付费模型会话测试。验证结束后清理独立测试容器、网络和数据卷,保留两个最终镜像标签。 diff --git a/doc/system-feature-list.md b/doc/system-feature-list.md index cb56bd8..ccabd90 100644 --- a/doc/system-feature-list.md +++ b/doc/system-feature-list.md @@ -12,7 +12,8 @@ - [x][T] 启动时校验 `env.json`、`config.json`;缺失时写入示例文件并要求用户检查后重启;拒绝用户覆盖由系统托管的内部路径、MQTT、MinIO、Topic 和 IPC 配置。 - [x][T] 自动生成并持久化 MQTT/MinIO 运行凭据;管理控制台可显示运行凭据,并支持管理员确认后重置两组密码和请求容器重启;容器启动时会将持久化 OpenClaw MQTT 账号密码同步为当前运行凭据。 - [x] 以 `daedalus-agent` 运行 OpenClaw/Agent,以 `daedalus-model-gw` 运行本机 Model Gateway,以 `daedalus-svc` 运行守护进程、Broker、MinIO、管理端和 invoke service,通过共享组控制只读目录、日志和 socket 访问。 -- [x][T] 对 Agent 进程实施内网访问白名单和 iptables 限制;管理控制台可维护白名单,IP 地址变更在容器重启后生效。 +- [x][T] 通过 `iptables` / `ip6tables` 按 Agent UID 实施 IPv4 / IPv6 内网地址白名单:白名单 IP / CIDR 放行全部端口和协议,其他内网目标拒绝访问,公网完全放行,不封禁 `80` / `443`;本机通信和入站连接的响应放行。管理控制台可维护白名单,地址变更在容器重启后生效。 +- [x][T] 启动时检测并清空已有 `DA_INTERNAL_BLOCK` / `DA_INTERNAL_BLOCK6` 专用链,移除旧版端口封禁、端口例外和过期白名单后重建规则;清除当前 Agent UID 的重复 `OUTPUT` 跳转,重复启动不累积规则,保留其他防火墙链。 - [x][T] 提供启动步骤耗时日志、子服务守护、服务重启、容器重启请求文件和快速重启保护。 - [x][T] 通过内置内容指纹执行 OpenClaw home、插件、Skill、模板和脚本的增量 seed/迁移;被替换内容先移入备份目录;Ontology 不参与 seed 覆盖。 - [x][T] 提供 `run_after_startup.sh` 启动后钩子,支持用户编辑并在重启后执行。 @@ -176,7 +177,8 @@ | invoke CLI | 参数、JSON/body/query 来源、Binding schema、socket 协议、超时、大小限制、Provider 错误和调用日志 | `packages/daedalus-agent-apps-invoke-cli/test/` | | MQTT 插件 | 账号配置、旧配置兼容、环境变量覆盖、连接/断线重连、通配符、入站路由、普通消息串行队列、停止与 steer/tell 控制队列、active-run 非阻塞 steer、运行时接收 Promise 未完成时的确认与原 final 投递、原始会话目标、OpenClaw deliver 的 text/kind 无加工透传、出站 envelope、日志、Model Gateway `llm_output` 用量落账、`before_agent_run` 配额阻断和待写入同步 | `extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/*test.ts`、`extensions/openclaw-plugins/daedalus-agent-mqtt-channel/index.test.ts` | | Docker 集成 | 登录、版本/同步、Provider/Model/模板/MCP/Agent 生命周期、模板继承、工作区/Skill、Ontology、MQTT `/stop`/`/steer`/`/tell` 的 final 确认及普通队列保留、重启(含排队重启等待和配置生效)、启动钩子、包持久化、文件输出、会话隔离和压测;指定镜像使用真实模型 Gateway 路径验证 Agent 实际调用 `integration-mcp__employee_template_lookup` MCP 工具,并验证 MCP deny 投影 | `test/case/integration/`、`test/suite/integration/`、`test/case/stress/`;报告 `E:\tmp\daedalus-agent-it-b2f7-fix\reports\playwright-container-flow-2026-09-02T10-07-12-545Z.json` | -| Docker 升级与品牌契约 | OpenClaw/Node 版本、插件 seed、迁移、重启保护、持久化 MQTT 账号密码与运行凭据同步、DA_ 环境变量前缀和视觉品牌 | `test/case/upgrade/openclaw-upgrade-contract.test.js`、`test/case/upgrade/`、`test/case/branding/` | +| Docker 防火墙与真实 MCP | Docker Desktop 双栈容器实测 Agent UID 的 TCP / UDP 地址白名单、旧端口规则与重复跳转清理、无关规则保护、重启后访问、入站响应、既有出站连接撤权,以及公网 HTTP / HTTPS;`deepseek-v4-flash` 经 Model Gateway 驱动 Agent,实际调用带 `token` 请求头的 GcExcel MCP `overview`、`search`、`fetch` 并核对成功结果;后续完整镜像重建通过离线工具检查、启动和服务健康检查、本地 QMD 模型校验、防火墙检查及 Agent 直接调用 MCP | `doc/firewall-mcp-validation-20261009.md`;专项证据 `E:\tmp\daedalus-firewall-mcp-20261009\reports\` 使用本地基础镜像替换启动脚本;完整构建及独立启动证据 `E:\tmp\daedalus-image-build-20261009\`,具体依赖版本和范围见报告 | +| Docker 升级与品牌契约 | OpenClaw/Node 版本、插件 seed、迁移、重启保护、持久化 MQTT 账号密码与运行凭据同步、DA_ 环境变量前缀和视觉品牌;执行真实启动函数并模拟规则存储,验证 IPv4 / IPv6 公网全端口放行、内网按地址拦截、DNS 无端口例外、本机及入站响应放行、旧端口规则清理、重复启动去重、白名单撤销后已建立出站连接仍受限,以及配置或规则操作失败时终止启动 | `test/case/upgrade/openclaw-upgrade-contract.test.js`、`test/case/upgrade/agent-firewall.test.js`、`test/common/netfilter-harness.sh`、`test/case/branding/`;入口 `npm --prefix test run test:upgrade-contract` 或 `npm --prefix test run test:firewall` | ## 8. 当前边界与使用前提 @@ -184,7 +186,7 @@ - [!] 用户和数字员工每日限额均以 Model Gateway `model_usage_events` 中中国自然日、`billable = 1` 的总 Token 为准;任一达到限额后,在下一个中国自然日开始前禁止外部会话、已有会话上下文获取、同步 Agent 调用、管理端 Agent 对话测试和业务系统解析,错误会明确指出超限用户或数字员工;`-1` 表示不限额。 - [!] 同步 Agent HTTP 接口是服务间调用接口,必须使用服务 Bearer Token(本机请求按现有服务鉴权策略处理);它只等待并返回最终结果,Model Gateway 用量事件直接计入每日配额,调用方仍需要自行处理 MQTT/Agent 超时或服务不可用错误。 - [!] 业务系统当前按 HZG/活字格模型接入,Ontology ZIP 和 `index.md` 是必要前提。 -- [!] Agent 只能访问配置白名单内的内网地址;白名单更新后必须重启容器。 +- [!] 除本机通信和入站连接的响应外,Agent 只能访问配置白名单内的内网地址,所有端口和协议采用同一地址策略;内网 DNS 服务器也需加入白名单,Docker 本机 DNS `127.0.0.11` 随本机通信放行。公网不受此策略限制;旧配置 `DA_AGENT_NETWORK_GATEWAY_ONLY` 不再生效。白名单更新后必须重启容器。 - [!] 内置模板、内置 Skill、内置 Ontology 受删除保护;内置 Skill 和内置 Ontology 不能按普通资源直接编辑。 - [!] 内置模板可从名称或编辑按钮进入详情,支持重命名以及修改文件、Skill、业务系统、MCP 和模型配置,但不支持删除;所有模板的重命名均依赖远端模板创建/删除动作成功。 - [!] 业务调用应经过 invoke CLI/service;配套规则禁止 Agent 直接向业务系统发 HTTP 请求或手工启动 invoke service。 diff --git a/docker/README.md b/docker/README.md index 22c1472..a354447 100644 --- a/docker/README.md +++ b/docker/README.md @@ -60,6 +60,10 @@ docker start daedalus-agent OpenClaw/agent 的局域网白名单通过管理控制台“系统设置 -> 基础设置”维护并保存在 SQLite;修改后重启容器生效。紧急运维时也可通过容器环境变量 `DA_INTERNAL_NETWORK_ALLOWLIST` 临时覆盖数据库值。 +IPv4 / IPv6 出站规则按目标 IP / CIDR 判断,不按端口判断:白名单内网地址放行全部端口和协议,其他内网地址拒绝,公网完全放行,包括 `80` / `443`。本机通信和入站连接的响应继续放行;内网 DNS 服务器也需加入白名单,Docker 本机 DNS `127.0.0.11` 无需额外配置。已建立的出站连接同样受当前白名单约束。 + +每次内核启动都会检测并重建 `DA_INTERNAL_BLOCK` / `DA_INTERNAL_BLOCK6` 专用链,自动移除其中遗留的 `80` / `443` 封禁、端口例外和过期白名单,去除当前 Agent UID 的重复 `OUTPUT` 跳转,保留其他防火墙规则。旧配置 `DA_AGENT_NETWORK_GATEWAY_ONLY` 已停用,即使保留为 `true` 也不会重新添加端口封禁。 + ## 运行数据 容器内挂载根固定为 `/var/daedalus-agent`,推荐由命名 Docker volume 提供,不再区分 persisted kernel/apps 目录。运行时会派生这些持久化路径: diff --git a/docker/assets/scripts/kernel-startup.sh b/docker/assets/scripts/kernel-startup.sh index a2c2aee..e422e72 100644 --- a/docker/assets/scripts/kernel-startup.sh +++ b/docker/assets/scripts/kernel-startup.sh @@ -58,7 +58,6 @@ 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}" -DA_AGENT_NETWORK_GATEWAY_ONLY="${DA_AGENT_NETWORK_GATEWAY_ONLY:-true}" 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=() @@ -440,9 +439,15 @@ reset_netfilter_chain() { local command_name="$1" local chain_name="$2" - "$command_name" -w -N "$chain_name" 2>/dev/null || true - "$command_name" -w -F "$chain_name" \ - || fail_agent_internal_network_blocker "${command_name} cannot reset ${chain_name}" + if "$command_name" -w -S "$chain_name" >/dev/null 2>&1; then + # 只清理本项目的专用链,同时移除旧版 80/443 封禁和过期白名单。 + "$command_name" -w -F "$chain_name" \ + || fail_agent_internal_network_blocker "${command_name} cannot reset ${chain_name}" + log "reset ${command_name} ${chain_name}; removed previous rules including legacy port restrictions" + else + "$command_name" -w -N "$chain_name" \ + || fail_agent_internal_network_blocker "${command_name} cannot create ${chain_name}" + fi } replace_agent_netfilter_jump() { @@ -459,95 +464,6 @@ replace_agent_netfilter_jump() { || fail_agent_internal_network_blocker "${command_name} cannot add OUTPUT jump" } -normalize_port() { - local raw="$1" - local port - - port="$(printf '%s' "$raw" | tr -d '[:space:]')" - if [[ "$port" =~ ^[0-9]+$ ]] && (( 10#$port >= 1 && 10#$port <= 65535 )); then - printf '%s\n' "$((10#$port))" - fi -} - -collect_agent_allowed_internal_tcp_endpoints() { - node <<'NODE' 2>/dev/null || true -const dns = require("node:dns").promises; -const net = require("node:net"); - -function mqttEndpoint() { - return { - name: "mqtt", - host: "127.0.0.1", - port: "1883", - }; -} - -function s3Endpoint() { - return { - name: "s3", - host: "127.0.0.1", - port: "9000", - }; -} - -function ipv4Scope(address) { - const parts = address.split(".").map((part) => Number(part)); - if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return "public"; - const [a, b] = parts; - if (a === 127) return "local"; - if (a === 10) return "internal"; - if (a === 100 && b >= 64 && b <= 127) return "internal"; - if (a === 169 && b === 254) return "internal"; - if (a === 172 && b >= 16 && b <= 31) return "internal"; - if (a === 192 && b === 168) return "internal"; - if (a === 198 && (b === 18 || b === 19)) return "internal"; - return "public"; -} - -function ipv6Scope(address) { - const lower = address.toLowerCase(); - if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return "local"; - if (lower.startsWith("fc") || lower.startsWith("fd")) return "internal"; - if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return "internal"; - return "public"; -} - -function scopeForAddress(address, family) { - return family === 4 ? ipv4Scope(address) : ipv6Scope(address); -} - -async function resolveEndpoint(endpoint) { - if (!endpoint.host || !endpoint.port) return []; - const ipFamily = net.isIP(endpoint.host); - if (ipFamily) { - return [{ address: endpoint.host, family: ipFamily }]; - } - - try { - return await dns.lookup(endpoint.host, { all: true, verbatim: true }); - } catch { - return []; - } -} - -(async () => { - const seen = new Set(); - for (const endpoint of [mqttEndpoint(), s3Endpoint()]) { - for (const result of await resolveEndpoint(endpoint)) { - const scope = scopeForAddress(result.address, result.family); - if (scope === "public") continue; - const family = result.family === 6 ? "ipv6" : "ipv4"; - const key = `${family}\t${result.address}\t${endpoint.port}\t${scope}\t${endpoint.name}`; - if (!seen.has(key)) { - seen.add(key); - console.log(key); - } - } - } -})().catch(() => {}); -NODE -} - collect_agent_internal_network_allowlist_cidrs() { node <<'NODE' const net = require("node:net"); @@ -711,70 +627,26 @@ try { NODE } -add_agent_dns_resolver_rules() { - local command_name="$1" - local chain_name="$2" - local endpoint_family="$3" - local resolver resolver_cidr resolver_family - - while read -r resolver; do - resolver="${resolver%%#*}" - resolver="${resolver%%$'\r'}" - resolver="${resolver%%$'\n'}" - resolver="${resolver%%\%*}" - if [[ -z "$resolver" ]]; then - continue - fi - - if [[ "$resolver" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]]; then - resolver_family="ipv4" - resolver_cidr="${resolver}/32" - elif [[ "$resolver" == *:* ]]; then - resolver_family="ipv6" - resolver_cidr="${resolver}/128" - else - continue - fi - - if [[ "$resolver_family" != "$endpoint_family" ]]; then - continue - fi - - "$command_name" -w -A "$chain_name" -p udp -d "$resolver_cidr" --dport 53 -j RETURN \ - || fail_agent_internal_network_blocker "${command_name} cannot add DNS UDP allow rule for ${resolver_cidr}" - "$command_name" -w -A "$chain_name" -p tcp -d "$resolver_cidr" --dport 53 -j RETURN \ - || fail_agent_internal_network_blocker "${command_name} cannot add DNS TCP allow rule for ${resolver_cidr}" - done < <(awk '/^[[:space:]]*nameserver[[:space:]]+/ { print $2 }' /etc/resolv.conf 2>/dev/null || true) -} - add_agent_netfilter_rules() { local command_name="$1" local chain_name="$2" local cidr_array_name="$3" - local allowed_endpoints="$4" - local allowed_cidrs="$5" - local endpoint_family="$6" - local cidr endpoint_address endpoint_port endpoint_scope endpoint_name endpoint_cidr + local allowed_cidrs="$4" + local endpoint_family="$5" + local cidr local allow_family allow_cidr allow_name local -n cidrs="$cidr_array_name" + # 仅放行入站连接的响应,已建立的出站连接仍须遵守当前地址白名单。 "$command_name" -w -A "$chain_name" \ - -m conntrack --ctstate RELATED,ESTABLISHED \ + -m conntrack --ctstate RELATED,ESTABLISHED --ctdir REPLY \ -j RETURN \ - || fail_agent_internal_network_blocker "${command_name} cannot add established connection allow rule" + || fail_agent_internal_network_blocker "${command_name} cannot add inbound connection reply allow rule" "$command_name" -w -A "$chain_name" -o lo -j RETURN \ || fail_agent_internal_network_blocker "${command_name} cannot add loopback allow rule" - if [[ "$command_name" == "iptables" ]]; then - "$command_name" -w -A "$chain_name" -m addrtype --dst-type LOCAL -j RETURN \ - || fail_agent_internal_network_blocker "${command_name} cannot add local destination allow rule" - "$command_name" -w -A "$chain_name" -p udp -d 127.0.0.11/32 --dport 53 -j RETURN \ - || fail_agent_internal_network_blocker "${command_name} cannot add Docker DNS UDP allow rule" - "$command_name" -w -A "$chain_name" -p tcp -d 127.0.0.11/32 --dport 53 -j RETURN \ - || fail_agent_internal_network_blocker "${command_name} cannot add Docker DNS TCP allow rule" - fi - - add_agent_dns_resolver_rules "$command_name" "$chain_name" "$endpoint_family" + "$command_name" -w -A "$chain_name" -m addrtype --dst-type LOCAL -j RETURN \ + || fail_agent_internal_network_blocker "${command_name} cannot add local destination allow rule" while IFS=$'\t' read -r allow_family allow_cidr allow_name; do if [[ -z "${allow_family:-}" || "$allow_family" != "$endpoint_family" || -z "${allow_cidr:-}" ]]; then @@ -785,37 +657,6 @@ add_agent_netfilter_rules() { || fail_agent_internal_network_blocker "${command_name} cannot add ${allow_name:-agent-allowlist} allow rule for ${allow_cidr}" done <<< "$allowed_cidrs" - while IFS=$'\t' read -r endpoint_family_line endpoint_address endpoint_port endpoint_scope endpoint_name; do - if [[ -z "${endpoint_family_line:-}" || "$endpoint_family_line" != "$endpoint_family" ]]; then - continue - fi - - if [[ "$endpoint_family_line" == "ipv6" ]]; then - endpoint_cidr="${endpoint_address}/128" - else - endpoint_cidr="${endpoint_address}/32" - fi - - endpoint_port="$(normalize_port "$endpoint_port")" - if [[ -z "$endpoint_port" ]]; then - continue - fi - - if [[ "$endpoint_scope" == "internal" || "$endpoint_scope" == "local" ]]; then - "$command_name" -w -A "$chain_name" \ - -p tcp -d "$endpoint_cidr" --dport "$endpoint_port" \ - -j RETURN \ - || fail_agent_internal_network_blocker "${command_name} cannot add ${endpoint_name} allow rule for ${endpoint_cidr}:${endpoint_port}" - fi - done <<< "$allowed_endpoints" - - if [[ "${DA_AGENT_NETWORK_GATEWAY_ONLY,,}" == "true" || "${DA_AGENT_NETWORK_GATEWAY_ONLY}" == "1" || "${DA_AGENT_NETWORK_GATEWAY_ONLY,,}" == "yes" ]]; then - for restricted_port in 80 443; do - "$command_name" -w -A "$chain_name" -p tcp --dport "$restricted_port" -j REJECT \ - || fail_agent_internal_network_blocker "${command_name} cannot block direct model provider traffic on port ${restricted_port}" - done - fi - for cidr in "${cidrs[@]}"; do "$command_name" -w -A "$chain_name" -d "$cidr" -j REJECT \ || fail_agent_internal_network_blocker "${command_name} cannot add internal network block for ${cidr}" @@ -830,35 +671,28 @@ configure_agent_netfilter_family() { local chain_name="$2" local agent_uid="$3" local cidr_array_name="$4" - local allowed_endpoints="$5" - local allowed_cidrs="$6" - local endpoint_family="$7" + local allowed_cidrs="$5" + local endpoint_family="$6" require_netfilter_command "$command_name" reset_netfilter_chain "$command_name" "$chain_name" - add_agent_netfilter_rules "$command_name" "$chain_name" "$cidr_array_name" "$allowed_endpoints" "$allowed_cidrs" "$endpoint_family" + add_agent_netfilter_rules "$command_name" "$chain_name" "$cidr_array_name" "$allowed_cidrs" "$endpoint_family" replace_agent_netfilter_jump "$command_name" "$agent_uid" "$chain_name" } enforce_agent_internal_network_block() { local agent_uid - local allowed_endpoints local allowed_cidrs agent_uid="$(id -u "$DA_OPENCLAW_USER" 2>/dev/null)" \ || fail_agent_internal_network_blocker "user ${DA_OPENCLAW_USER} does not exist" - allowed_endpoints="$(collect_agent_allowed_internal_tcp_endpoints)" allowed_cidrs="$(collect_agent_internal_network_allowlist_cidrs)" \ || fail_agent_internal_network_blocker "invalid DA_INTERNAL_NETWORK_ALLOWLIST" - configure_agent_netfilter_family iptables DA_INTERNAL_BLOCK "$agent_uid" AGENT_INTERNAL_IPV4_CIDRS "$allowed_endpoints" "$allowed_cidrs" ipv4 - configure_agent_netfilter_family ip6tables DA_INTERNAL_BLOCK6 "$agent_uid" AGENT_INTERNAL_IPV6_CIDRS "$allowed_endpoints" "$allowed_cidrs" ipv6 - if [[ -n "$allowed_endpoints" ]]; then - log "agent internal network block installed for ${DA_OPENCLAW_USER} uid ${agent_uid}; allowed endpoints: $(printf '%s' "$allowed_endpoints" | tr '\n' ';')" - else - log "agent internal network block installed for ${DA_OPENCLAW_USER} uid ${agent_uid}; no internal MQTT/S3 endpoints detected" - fi + configure_agent_netfilter_family iptables DA_INTERNAL_BLOCK "$agent_uid" AGENT_INTERNAL_IPV4_CIDRS "$allowed_cidrs" ipv4 + configure_agent_netfilter_family ip6tables DA_INTERNAL_BLOCK6 "$agent_uid" AGENT_INTERNAL_IPV6_CIDRS "$allowed_cidrs" ipv6 + log "agent internal network block installed for ${DA_OPENCLAW_USER} uid ${agent_uid}; internal access uses address allowlist on all ports; public access is unrestricted" if [[ -n "$allowed_cidrs" ]]; then log "allowed networks: $(printf '%s' "$allowed_cidrs" | tr '\n' ';')" fi diff --git a/docker/assets/scripts/startup.sh b/docker/assets/scripts/startup.sh index ec76ab1..043f48a 100644 --- a/docker/assets/scripts/startup.sh +++ b/docker/assets/scripts/startup.sh @@ -792,7 +792,6 @@ const shared = { DA_MODEL_GATEWAY_ADMIN_SOCKET: path.posix.join(productRoot, "run/model-gateway-admin.sock"), DA_MODEL_GATEWAY_RUNTIME_TOKEN: String(value("DA_MODEL_GATEWAY_RUNTIME_TOKEN", crypto.randomBytes(32).toString("base64url"))), DA_MODEL_GATEWAY_USAGE_ENDPOINT: "http://127.0.0.1:3030/api/internal/model-gateway/usage-events", - DA_AGENT_NETWORK_GATEWAY_ONLY: String(valueWithDefault("DA_AGENT_NETWORK_GATEWAY_ONLY", "true")), DA_INTERNAL_NETWORK_ALLOWLIST: process.env.DA_INTERNAL_NETWORK_ALLOWLIST ?? readRuntimeSetting("internal_network_allowlist") ?? valueWithDefault("DA_INTERNAL_NETWORK_ALLOWLIST", ""), diff --git a/docker/docs/environment-variables.md b/docker/docs/environment-variables.md index d74a548..9061c5f 100644 --- a/docker/docs/environment-variables.md +++ b/docker/docs/environment-variables.md @@ -37,7 +37,7 @@ Model Gateway 固定使用 `127.0.0.1:3040`,由启动脚本生成 `DA_MODEL_GA | 变量 | 默认值 | 说明 | | --- | --- | --- | -| `DA_INTERNAL_NETWORK_ALLOWLIST` | 数据库配置 | 临时覆盖 SQLite 中的内部网络白名单,支持逗号、空格、分号或 JSON 数组字符串;仅建议应急运维使用。 | +| `DA_INTERNAL_NETWORK_ALLOWLIST` | 数据库配置 | 临时覆盖 SQLite 中的内部网络白名单,支持 IPv4 / IPv6 地址和 CIDR,以逗号、空格、分号或 JSON 数组字符串分隔;按地址放行全部端口和协议,内网 DNS 也需加入白名单;仅建议应急运维使用。 | | `DA_SERVICE_RESTART_DELAY_SECONDS` | `2` | 顶层 MQTT、MinIO、OpenClaw supervisor、apps supervisor 异常退出后的重启间隔。 | | `DA_APPS_RESTART_DELAY_SECONDS` | `2` | apps supervisor 内部重启 management web 和 invoke service 的间隔。 | | `OPENCLAW_RESTART_DELAY_SECONDS` | `2` | OpenClaw gateway 重启间隔。 | @@ -46,6 +46,8 @@ Model Gateway 固定使用 `127.0.0.1:3040`,由启动脚本生成 `DA_MODEL_GA | `OPENCLAW_RAPID_RESTART_WINDOW_SECONDS` | `60` | 小于该运行时长的退出计为快速失败。 | | `DA_STARTUP_PROFILE` | `true` | 是否输出启动步骤耗时日志。该日志必须保留。 | +旧配置 `DA_AGENT_NETWORK_GATEWAY_ONLY` 已停用,不再写入生成的运行环境;即使通过旧文件或容器环境传入,也不会启用 `80` / `443` 端口封禁。公网访问完全放行。 + ## 只读系统变量 这些变量由镜像和启动脚本固定,进程可读取,用户不能在 `/var/daedalus-agent/env.json` 中覆盖: diff --git a/docker/docs/spec.md b/docker/docs/spec.md index 42040cd..82f40a9 100644 --- a/docker/docs/spec.md +++ b/docker/docs/spec.md @@ -17,7 +17,7 @@ ## 双用户机制 -- `daedalus-agent`:运行 OpenClaw gateway 和 agent 相关命令。局域网访问受 iptables 限制,只能访问本机、互联网和 `DA_INTERNAL_NETWORK_ALLOWLIST` 白名单中的局域网地址。 +- `daedalus-agent`:运行 OpenClaw gateway 和 agent 相关命令。IPv4 / IPv6 局域网出站访问受 `iptables` / `ip6tables` 地址白名单限制,`DA_INTERNAL_NETWORK_ALLOWLIST` 中的 IP / CIDR 放行全部端口和协议,其他内网目标拒绝,公网完全放行;本机通信和入站连接的响应放行,内网 DNS 也需加入白名单。启动时重建专用链,清除旧版 `80` / `443` 封禁、端口例外和过期白名单,去除当前 Agent UID 的重复跳转,保留其他防火墙规则。 - `daedalus-model-gw`:运行本机 Model Gateway,读取加密 Provider Key 并负责固定上游路由;不对外监听。 - `daedalus-svc`:运行 supervisor、MQTT、MinIO、management web、invoke service、启动 hook。可访问全部网络接口。 - 共享组统一为 `daedalus-agent`,用于只读共享目录和日志/socket 访问控制。 diff --git a/test/README.md b/test/README.md index 468290e..58ed4f6 100644 --- a/test/README.md +++ b/test/README.md @@ -8,6 +8,16 @@ - `suite/`: runnable test suites and their preset configs. - `assets/`: shared test assets. +## Firewall Regression + +```powershell +npm --prefix test run test:firewall +``` + +This suite runs the real startup firewall functions with simulated rule storage. It covers IPv4/IPv6 public access on all ports, address-only internal allowlists, DNS policy, local services and inbound replies, legacy port-rule cleanup, repeated startup, revoked outbound access, and startup failures. It does not modify the host firewall or require Docker. Use Bash 4.3+ and Node.js; on Windows the runner detects Git Bash, or accepts its executable path in `DA_TEST_BASH`. The suite also runs as part of `test:upgrade-contract`. Real container networking remains a separate integration check. + +The [Docker Desktop validation record](../doc/firewall-mcp-validation-20261009.md) covers real dual-stack traffic, startup cleanup, and a real `deepseek-v4-flash` Agent calling the authenticated GcExcel MCP tools. + ## Integration Suites Fast integration focuses on the catalog/agent rename regression and does not call a real LLM: diff --git a/test/case/upgrade/agent-firewall.test.js b/test/case/upgrade/agent-firewall.test.js new file mode 100644 index 0000000..39d2946 --- /dev/null +++ b/test/case/upgrade/agent-firewall.test.js @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { BlockList } from "node:net"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const repoRoot = fileURLToPath(new URL("../../../", import.meta.url)); +const readSource = (name) => readFileSync(path.join(repoRoot, name), "utf8").replace(/\r\n/g, "\n"); +const kernel = readSource("docker/assets/scripts/kernel-startup.sh"); +const harness = readSource("test/common/netfilter-harness.sh"); +const bash = process.env.DA_TEST_BASH || (process.platform === "win32" + ? [process.env.ProgramFiles, process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Programs")] + .filter(Boolean).map((root) => path.join(root, "Git/bin/bash.exe")).find(existsSync) + : "bash"); +assert.ok(bash, "Install Bash 4.3+ (Git Bash on Windows), or set DA_TEST_BASH"); + +// 只载入网络声明和函数,避免运行完整容器启动流程及其 trap。 +const arrays = kernel.match(/^AGENT_INTERNAL_IPV[46]_CIDRS=\([\s\S]*?^\)/gm); +assert.equal(arrays?.length, 2); +const functions = kernel.slice(kernel.indexOf("fail_agent_internal_network_blocker() {"), kernel.indexOf("sync_missing_tree() {")); +assert.ok(functions.includes("enforce_agent_internal_network_block() {")); + +function runFirewall({ allowlist = "", legacy = false, action = "enforce_agent_internal_network_block", failure = "" } = {}) { + return spawnSync(bash, ["--noprofile", "--norc", "-s"], { + cwd: repoRoot, + input: ["set -euo pipefail", 'DA_OPENCLAW_USER="daedalus-agent"', ...arrays, functions, harness, + "seed_firewall", 'DA_TEST_NETFILTER_FAILURE="$DA_TEST_FAILURE"', action, "dump_firewall"].join("\n"), + encoding: "utf8", + timeout: 30000, + env: { ...process.env, DA_INTERNAL_NETWORK_ALLOWLIST: allowlist, + DA_AGENT_NETWORK_GATEWAY_ONLY: "true", DA_TEST_SEED_LEGACY: String(legacy), + DA_TEST_FAILURE: failure, DA_TEST_NETFILTER_FAILURE: "" } + }); +} + +function configuredRules(options) { + const result = runFirewall(options); + assert.equal(result.status, 0, result.error?.message || result.stderr); + const rules = {}; + for (const line of result.stdout.split("\n").filter((line) => line.startsWith("STATE\t"))) { + const [, key, rule] = line.split("\t"); + (rules[key] ||= []).push(rule); + } + return rules; +} + +// 对生成的规则执行地址、协议、连接方向和 UID 匹配;不要求机器具备 NET_ADMIN。 +function verdict(rules, family, address, overrides = {}) { + const packet = { protocol: "tcp", port: 80, state: "NEW", direction: "ORIGINAL", uid: 1001, iface: "eth0", local: false, ...overrides }; + const command = family === "ipv4" ? "iptables" : "ip6tables"; + function visit(chain) { + for (const rule of rules[`${command}:${chain}`] || []) { + const tokens = rule.split(" "); + const value = (flag) => tokens.includes(flag) ? tokens[tokens.indexOf(flag) + 1] : undefined; + if (value("--uid-owner") && value("--uid-owner") !== String(packet.uid)) continue; + if (value("-p") && value("-p") !== packet.protocol) continue; + if (value("--dport") && Number(value("--dport")) !== packet.port) continue; + if (value("-o") && value("-o") !== packet.iface) continue; + if (value("--dst-type") === "LOCAL" && !packet.local) continue; + if (value("--ctstate") && !value("--ctstate").split(",").includes(packet.state)) continue; + if (value("--ctdir") && value("--ctdir") !== packet.direction) continue; + if (value("-d")) { + const [subnet, prefix] = value("-d").split("/"); + const blockList = new BlockList(); + blockList.addSubnet(subnet, Number(prefix), family); + if (!blockList.check(address, family)) continue; + } + const target = value("-j"); + if (["RETURN", "REJECT", "ACCEPT", "DROP"].includes(target)) return target; + const nested = visit(target); + if (nested !== "RETURN") return nested; + } + return "RETURN"; + } + const result = visit("OUTPUT"); + return result === "RETURN" ? "ACCEPT" : result; +} + +test("public IPv4 and IPv6 traffic is open regardless of port and the retired gateway-only setting", () => { + const rules = configuredRules(); + for (const [family, address] of [["ipv4", "1.1.1.1"], ["ipv6", "2606:4700:4700::1111"]]) { + for (const protocol of ["tcp", "udp", family === "ipv4" ? "icmp" : "ipv6-icmp"]) { + for (const port of [53, 80, 443, 8080, 65535]) { + assert.equal(verdict(rules, family, address, { protocol, port }), "ACCEPT", `${family} ${protocol}/${port}`); + } + } + } +}); + +test("internal destinations are controlled by address for all protocols and ports", () => { + const rules = configuredRules({ allowlist: '["10.20.0.7/16", "192.168.1.10", "fd12:3456::9/48"]' }); + const allowed = [["ipv4", "10.20.1.2"], ["ipv4", "192.168.1.10"], ["ipv6", "fd12:3456::abcd"]]; + const denied = [["ipv4", "10.21.0.1"], ["ipv4", "100.64.0.1"], ["ipv4", "169.254.1.1"], + ["ipv4", "172.16.0.1"], ["ipv4", "192.168.1.11"], ["ipv4", "198.18.0.1"], ["ipv6", "fd99::1"], ["ipv6", "fe80::1"]]; + for (const [entries, expected] of [[allowed, "ACCEPT"], [denied, "REJECT"]]) { + for (const [family, address] of entries) { + for (const protocol of ["tcp", "udp", family === "ipv4" ? "icmp" : "ipv6-icmp"]) { + for (const port of [53, 80, 443, 1883, 9000, 65535]) { + assert.equal(verdict(rules, family, address, { protocol, port }), expected, `${address} ${protocol}/${port}`); + } + } + } + } +}); + +test("internal DNS resolvers require an address allowlist entry too", () => { + const blocked = configuredRules(); + const allowed = configuredRules({ allowlist: "10.9.8.7; fd00:9::53" }); + for (const [family, address] of [["ipv4", "10.9.8.7"], ["ipv6", "fd00:9::53"]]) { + for (const protocol of ["udp", "tcp"]) { + for (const port of [53, 80, 443]) { + assert.equal(verdict(blocked, family, address, { protocol, port }), "REJECT"); + assert.equal(verdict(allowed, family, address, { protocol, port }), "ACCEPT"); + } + } + } +}); + +test("loopback, container-local services and replies to inbound connections remain reachable", () => { + const rules = configuredRules(); + for (const [family, address] of [["ipv4", "127.0.0.1"], ["ipv4", "127.0.0.11"], ["ipv6", "::1"]]) { + for (const port of [53, 1883, 3040, 9000]) { + assert.equal(verdict(rules, family, address, { iface: "lo", local: true, port }), "ACCEPT"); + } + } + assert.equal(verdict(rules, "ipv4", "172.17.0.2", { local: true }), "ACCEPT"); + assert.equal(verdict(rules, "ipv6", "fd00::2", { local: true }), "ACCEPT"); + for (const [family, address] of [["ipv4", "10.1.2.3"], ["ipv6", "fd99::1"]]) { + assert.equal(verdict(rules, family, address, { state: "ESTABLISHED", direction: "REPLY" }), "ACCEPT"); + assert.equal(verdict(rules, family, address, { uid: 1002 }), "ACCEPT"); + assert.equal(verdict(rules, family, address, { state: "ESTABLISHED" }), "REJECT"); + } +}); + +test("startup removes legacy port bans and duplicate jumps without touching unrelated rules", () => { + const allowlist = "10.20.0.7/16 10.20.0.0/16; fd12:3456::9/48,fd12:3456::/48"; + const rules = configuredRules({ allowlist, legacy: true, action: "enforce_agent_internal_network_block\nenforce_agent_internal_network_block" }); + assert.deepEqual(rules, configuredRules({ allowlist })); + for (const [command, chain] of [["iptables", "DA_INTERNAL_BLOCK"], ["ip6tables", "DA_INTERNAL_BLOCK6"]]) { + assert.deepEqual(rules[`${command}:OUTPUT`], [`-m owner --uid-owner 1001 -j ${chain}`, "-j USER_RULES"]); + assert.deepEqual(rules[`${command}:USER_RULES`], ["-p tcp --dport 2525 -j REJECT"]); + assert.ok(rules[`${command}:${chain}`].every((rule) => !rule.includes("--dport")), "managed rules must not depend on ports"); + assert.equal(new Set(rules[`${command}:${chain}`]).size, rules[`${command}:${chain}`].length); + } +}); + +test("reconfiguring drops stale allowlist entries including existing outbound connections", () => { + const rules = configuredRules({ allowlist: "10.1.2.3,fd99::1", action: [ + "enforce_agent_internal_network_block", "DA_INTERNAL_NETWORK_ALLOWLIST='10.2.3.4,fd88::1'", "enforce_agent_internal_network_block" + ].join("\n") }); + for (const [family, address] of [["ipv4", "10.1.2.3"], ["ipv6", "fd99::1"]]) { + for (const state of ["NEW", "ESTABLISHED", "RELATED"]) { + assert.equal(verdict(rules, family, address, { state }), "REJECT"); + } + } + for (const [family, address] of [["ipv4", "10.2.3.4"], ["ipv6", "fd88::1"]]) { + assert.equal(verdict(rules, family, address), "ACCEPT"); + } +}); + +test("invalid allowlists and failed firewall operations stop startup", () => { + const invalid = runFirewall({ allowlist: "not-an-ip" }); + assert.notEqual(invalid.status, 0); + assert.match(invalid.stderr, /invalid DA_INTERNAL_NETWORK_ALLOWLIST/); + for (const failure of ["-L", "-N", "-F", "-A", "-D", "-I"]) { + const result = runFirewall({ failure, legacy: failure !== "-N" }); + assert.notEqual(result.status, 0, failure); + assert.match(result.stderr, /unable to enforce agent internal network block/, failure); + } +}); + +test("startup still enforces the policy and no longer emits the retired gateway-only flag", () => { + assert.match(kernel, /profile_step "enforce_agent_internal_network_block" enforce_agent_internal_network_block/); + assert.equal(readSource("docker/assets/scripts/startup.sh").includes("DA_AGENT_NETWORK_GATEWAY_ONLY"), false); +}); diff --git a/test/common/netfilter-harness.sh b/test/common/netfilter-harness.sh new file mode 100644 index 0000000..3a17c09 --- /dev/null +++ b/test/common/netfilter-harness.sh @@ -0,0 +1,91 @@ +# 仅模拟规则存储;测试执行真实启动函数,不操作宿主机防火墙。 +declare -A firewall_rules=([iptables:OUTPUT]="" [ip6tables:OUTPUT]="") + +mock_netfilter() { + local command_name="$1" + shift + [[ "$1" == "-w" ]] && shift + local operation="$1" chain_name="$2" + shift 2 + local key="${command_name}:${chain_name}" rule line found=false next="" + if [[ "$operation" == "${DA_TEST_NETFILTER_FAILURE:-}" ]]; then + return 1 + fi + case "$operation" in + -L|-S) + [[ -v "firewall_rules[$key]" ]] || return 1 + printf '%s' "${firewall_rules[$key]}" + ;; + -N) + [[ ! -v "firewall_rules[$key]" ]] || return 1 + firewall_rules[$key]="" + ;; + -F) + [[ -v "firewall_rules[$key]" ]] || return 1 + firewall_rules[$key]="" + ;; + -A|-I|-C|-D) + [[ -v "firewall_rules[$key]" ]] || return 1 + if [[ "$operation" == "-I" ]]; then + [[ "$1" == "1" ]] || return 2 + shift + fi + rule="$*" + case "$operation" in + -A) firewall_rules[$key]+="$rule"$'\n' ;; + -I) firewall_rules[$key]="$rule"$'\n'"${firewall_rules[$key]}" ;; + -C|-D) + while IFS= read -r line; do + [[ -n "$line" ]] || continue + if [[ "$line" == "$rule" && "$found" == "false" ]]; then + found=true + else + next+="$line"$'\n' + fi + done <<< "${firewall_rules[$key]}" + [[ "$found" == "true" ]] || return 1 + [[ "$operation" != "-D" ]] || firewall_rules[$key]="$next" + ;; + esac + ;; + *) printf 'Unsupported netfilter operation: %s\n' "$operation" >&2; return 2 ;; + esac +} + +iptables() { mock_netfilter iptables "$@"; } +ip6tables() { mock_netfilter ip6tables "$@"; } +id() { printf '1001\n'; } +log() { printf '%s\n' "$*" >&2; } +startup_step_error() { printf '%s\n' "$*" >&2; } +# 为旧 DNS 例外提供固定的非白名单内网地址。 +awk() { printf '10.9.8.7\nfd00:9::53\n'; } + +seed_firewall() { + local command_name chain_name + for command_name in iptables ip6tables; do + chain_name=DA_INTERNAL_BLOCK + [[ "$command_name" != "ip6tables" ]] || chain_name=DA_INTERNAL_BLOCK6 + "$command_name" -w -N USER_RULES + "$command_name" -w -A USER_RULES -p tcp --dport 2525 -j REJECT + "$command_name" -w -A OUTPUT -j USER_RULES + if [[ "${DA_TEST_SEED_LEGACY:-false}" == "true" ]]; then + "$command_name" -w -N "$chain_name" + "$command_name" -w -A "$chain_name" -p tcp --dport 80 -j REJECT + "$command_name" -w -A "$chain_name" -p tcp --dport 443 -j REJECT + "$command_name" -w -A "$chain_name" -p udp --dport 53 -j RETURN + "$command_name" -w -A "$chain_name" -j RETURN + "$command_name" -w -I OUTPUT 1 -m owner --uid-owner 1001 -j "$chain_name" + "$command_name" -w -I OUTPUT 1 -m owner --uid-owner 1001 -j "$chain_name" + fi + done +} + +dump_firewall() { + local key line + for key in "${!firewall_rules[@]}"; do + while IFS= read -r line; do + [[ -n "$line" ]] || continue + printf 'STATE\t%s\t%s\n' "$key" "$line" + done <<< "${firewall_rules[$key]}" + done +} diff --git a/test/package.json b/test/package.json index 7d20b08..95c7aca 100644 --- a/test/package.json +++ b/test/package.json @@ -4,7 +4,8 @@ "type": "module", "scripts": { "test:brand": "node --test ./case/branding/daedalus-brand-contract.test.js", - "test:upgrade-contract": "node --test ./case/upgrade/openclaw-upgrade-contract.test.js", + "test:upgrade-contract": "node --test ./case/upgrade/*.test.js", + "test:firewall": "node --test ./case/upgrade/agent-firewall.test.js", "test:integration:contract": "node --test ./case/integration/*.test.js", "coverage:integration:contract": "c8 --all --include \"case/integration/file-output-content.js\" --include \"common/mock-openai-server-core.js\" --include \"suite/integration/runtime-config.js\" --check-coverage --branches 100 --statements 0 --functions 0 --lines 0 --reporter text --reporter json-summary --reports-dir \"../coverage/integration-contract\" npm run test:integration:contract", "coverage:report": "node ./suite/run-coverage-suite.js report", -- Gitee From c417e6f601cdcbf58ad0121323f8638912b53b8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= Date: Fri, 9 Oct 2026 19:22:08 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E6=9B=BF=E6=8D=A2=E4=B8=8D=E5=8F=AF?= =?UTF-8?q?=E7=94=A8=E7=9A=84minio=E5=AE=98=E6=96=B9=E9=95=9C=E5=83=8F?= =?UTF-8?q?=EF=BC=8C=E5=90=8C=E6=97=B6=E4=BF=AE=E6=94=B9=E4=B8=80=E4=B8=AA?= =?UTF-8?q?=E6=96=B0=E5=8F=91=E7=8E=B0=E7=9A=84=E5=B9=B6=E5=8F=91=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E9=A3=8E=E9=99=A9=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/build-validation-20261009.01.md | 110 ++++++ doc/system-feature-list.md | 11 +- docker/BUILD.md | 4 + docker/Dockerfile | 5 +- docker/Dockerfile.dockerignore | 2 + docker/build-arm64.zsh | 5 - .../daedalus-agent-transfer-file/README.md | 2 + .../daedalus-agent-transfer-file/SKILL.md | 15 +- .../scripts/transfer_s3.mjs | 18 +- test/README.md | 13 + .../integration/file-transfer-container.cjs | 334 ++++++++++++++++++ test/case/integration/playwright-flow.js | 1 - .../upgrade/openclaw-upgrade-contract.test.js | 23 ++ test/package.json | 1 + .../integration/run-file-transfer-suite.js | 89 +++++ 15 files changed, 607 insertions(+), 26 deletions(-) create mode 100644 doc/build-validation-20261009.01.md create mode 100644 test/case/integration/file-transfer-container.cjs create mode 100644 test/suite/integration/run-file-transfer-suite.js diff --git a/doc/build-validation-20261009.01.md b/doc/build-validation-20261009.01.md new file mode 100644 index 0000000..5c5a161 --- /dev/null +++ b/doc/build-validation-20261009.01.md @@ -0,0 +1,110 @@ +# 20261009.01 构建与测试记录 + +当前交付镜像为 `daedalus-agent-x64:20261009.01`(`linux/amd64`),已包含文件传输同名覆盖修复。最终镜像 ID 为 `sha256:f3f98e4ac23214f0fbeab08f522e0f210402809326fa613593c763cc33390ba7`,专项测试 21 项全部通过;此前完整真实 LLM 流程对应的旧镜像与范围在下文分别记录。 + +## 构建范围 + +- 分支:`fix-firewall-policy-correction`,基础提交 `71e147d`。 +- 目标镜像:`daedalus-agent-x64:20261009.01`,平台 `linux/amd64`。 +- 保留本分支 OpenClaw `2026.7.1`、Node.js `24.18.0`、ClawHub `0.23.0`、QMD `2.5.3` 和 agent-browser `0.31.0` 的构建声明。 +- 补齐 `docker/Dockerfile.dockerignore` 中遗漏的 Model Gateway 源码,并添加构建上下文契约测试。 +- 本地 Chrome ZIP 的实际版本为 `155.0.8059.39`。按用户最新要求,浏览器以正常运行为验收标准,保留当前构建输入,未调整 Chrome 版本。 +- 按本次要求将不可访问的 MinIO 官方镜像来源替换为固定版本和摘要的 Coollabs 社区镜像,MinIO 与 mc 来自同一镜像。ARM64 构建入口同步移除旧仓库的额外拉取操作;本次没有构建或运行 ARM64 应用镜像。 + +## MinIO 来源核对 + +采用 `coollabsio/minio:RELEASE.2025-10-15T17-29-55Z@sha256:69b55a1c1c5dc285ce04db96689f5b2102317fc77a50680a1874ca6efd1c87f9`。 + +- [构建源码](https://github.com/coollabsio/minio):公开 Dockerfile 和 GitHub Actions,按官方 release 源码编译 MinIO;mc 下载上游二进制并执行签名校验。 +- [Docker Hub](https://hub.docker.com/r/coollabsio/minio/tags):2026-10-09 查询到该版本发布于 2025-10-27,索引包含 `linux/amd64` 与 `linux/arm64`。 +- 镜像携带的 BuildKit 构建来源记录指向 [GitHub Actions 运行](https://github.com/coollabsio/minio/actions/runs/18846682762/attempts/1),两种架构的 `MINIO_VERSION` 参数均为 `RELEASE.2025-10-15T17-29-55Z`。这属于来源记录核对,未执行独立签名身份验证或源码可复现构建比对。 +- 拉取后实际运行版本:MinIO `RELEASE.2025-10-15T17-29-55Z`,mc `RELEASE.2025-08-13T08-35-41Z`。 +- MinIO 的 `--version` 输出中 commit-id 为 `DEVELOPMENT.GOGET`,版权年份为 `0000`;不能通过该输出确认源码提交,本次以固定镜像摘要记录实际输入。 +- 在独立、禁网、无宿主机数据挂载的容器中验证服务就绪、创建桶、对象写入/读取/删除,以及匿名下载策略的开启和关闭,全部通过。测试容器和临时凭据已清理。 +- 在隔离数据卷中,由本机原版应用镜像的 MinIO `RELEASE.2025-09-07T16-13-09Z` 创建对象和桶策略,再切换到本次镜像中的 MinIO `RELEASE.2025-10-15T17-29-55Z`。既有对象读取、策略保留及新增对象写入/读取均通过;该项不代表所有生产数据迁移场景均已覆盖。测试容器、数据卷和临时凭据已清理。 + +对比的另一来源是 [PGSTY Silo](https://github.com/pgsty/silo),其维护分支有近期发布和安全修复,但已由 `pgsty/minio` 更名为 Silo,属于下游产品分支。本次采用 Coollabs 原版 release 重打包来源;该镜像发布时间较久,不能据此推断持续安全更新已有保障。 + +## 已完成的本地测试 + +| 范围 | 结果 | +| --- | --- | +| Docker 升级、构建上下文、MinIO 来源和防火墙契约 | 19 项通过 | +| 集成测试辅助服务与配置契约 | 17 项通过 | +| 品牌与环境变量前缀 | 3 项通过 | +| Model Gateway | 10 项通过 | +| invoke CLI | 92 项通过 | +| MQTT 插件 | 类型检查通过,110 项测试通过;本地依赖已按 fix 分支 lockfile 重装 | +| 管理端服务单元测试 | 329 项通过 | +| 管理运行时 | 128 项通过 | +| 前端 | 8 个测试文件、37 项测试通过 | +| 浏览器覆盖率转换契约 | 2 项通过 | +| 后端 API | 类型检查通过,122 次回归调用通过 | + +## 镜像与容器验证 + +首次完整构建已通过禁网工具检查并成功启动。文件传输修复前的镜像 ID 为 `sha256:f5c0074ffa8f5134a736251bc5181ce86e4427226b88a905c898a68e85ef5bf4`,大小 `11567742187` 字节。以下初次容器及真实模型结果对应此镜像;`daedalus-agent-x64:20261009.01` 的最终重建结果见「文件传输专项补充」。 + +- 镜像内 Chrome 成功以 headless 模式渲染页面并执行 JavaScript,输出 `CHROME_RUNTIME_OK` 和 `data-executed="yes"`;不以版本号作为验收条件。 +- 3 个内置 QMD 模型权重的 SHA-256 与本地构建输入一致;镜像内防火墙启动脚本的 SHA-256 与 fix 分支一致。 +- 独立双栈容器完成 36 项服务 UID 网络基线探测,以及每轮 36 项、共 3 轮 Agent UID 网络策略探测,覆盖 IPv4/IPv6、内网白名单/非白名单/公网、TCP/UDP 和 `80`/`443`/`18080` 端口。 +- 旧端口规则与过期白名单清理、重复跳转去重、重复初始化、无关规则保留、入站响应和既有出站连接撤权均通过。 +- 完整容器重启后网络矩阵、服务健康、IPC socket、公网 HTTP/HTTPS 访问均通过。结果见 `firewall/result.json` 和 `firewall/run.log`。 + +## 真实模型集成 + +使用 `https://gcapi.cn/v1` 的 `deepseek-v4-flash`,密钥测试时仅保存在仓库外的临时配置及隔离容器中。单独启动的本地 fixture 只提供 MCP 工具,未作为 LLM 使用;结束时其 `chatCompletionCount=0`,证据见 `mcp-fixture-stats.json`。 + +首次运行完成管理登录、Provider/Model/模板/Agent 创建、MCP 探测、权限投影和工作区维护,但 Agent 实际调用 MCP 失败。日志显示 `getaddrinfo EAI_AGAIN host.docker.internal`:当前 Docker Desktop 的 DNS 为内网地址 `192.168.65.7`,当时未加入 Agent 白名单。将该地址加入本次隔离测试配置后,Agent UID 名称解析恢复;未修改防火墙地址策略。 + +随后修正 `test/case/integration/playwright-flow.js` 的过时断言:`/api/settings/catalog-sync` 当前只同步 LLM、Agent、Skill、模板、业务系统五类目录,用量由 Model Gateway 账本聚合,不再返回 `usage_refresh`。本次仅删除已移除状态的检查,保留五类目录均为 `success` 的断言;应用实现及镜像未因这项修正而变化。 + +最终完整流程退出码为 `0`、报告 `ok=true`。报告共 43 个步骤,其中 41 个执行步骤通过,2 个按既有测试设计跳过: + +- 已通过:管理页面操作、模板继承、工作区/Skill/MCP 生命周期、真实模型调用 MCP、启动钩子编辑与重启、管理端对话、MQTT 文件输出及 MinIO 对象内容、Ontology 真实业务调用及日志、npm/uv/pip 包安装与重启持久化、五类目录同步、重启后 Skill 识别,以及 sender 记忆隔离。 +- 未覆盖:MQTT `/stop`、`/steer`、`/tell` 的确定性时序测试仅支持 mock LLM,本次真实模型流程自动跳过;包安装记忆回忆用例在仓库中已暂时禁用。不能将这两项记为本次容器集成通过。 +- 首次启动耗时约 `16.4` 秒,启动钩子修改后的重启约 `12.1` 秒。该耗时仅代表本机此次运行。 +- 报告:`integration/reports/playwright-container-flow-2026-10-09T10-43-58-025Z.json`,日志:`integration-real-final.log`。 + +独立验证用户提供的 `https://mcp.grapecity.com.cn/mcp/gcexcel`,认证方式为 `headers`,请求头名称为 `token`。服务发现返回 `GCEXCEL-MCP-Server 0.1.4`;运行时配置中的请求头值与临时凭据一致,报告只记录核对结果,不记录密钥。 + +Agent 经真实 LLM 依次调用 `gcexcel-validation__overview`、`gcexcel-validation__search`、`gcexcel-validation__fetch`,三个工具结果均非错误。`search` 返回的 `doc_id` 与 `fetch` 参数及结果一致,读取到 358 字符的完整文档,最终回复包含 `GCEXCEL_MCP_OK`。证据见 `gcexcel-agent-result.json`;该结论依据实际会话轨迹,不仅依据管理端首段回复。 + +## 已观察到的限制 + +- 新增 GcExcel MCP 并更新 Agent 权限后的首次请求仍命中运行时旧的 `gcexcel-validation__*` deny 规则,工具不可见;后续刷新后的第二次请求完成上述三个调用。本次记录该热更新时序现象,未修改相关实现,也未验证其稳定性。 +- 启动与配置刷新日志仍出现 OpenClaw 模型目录权限 `770` 不满足私有目录校验的告警。此次真实模型调用成功,但不表示该告警已修复。 +- ARM64 应用镜像、生产规模数据迁移、高并发压测和前述两项跳过用例不在本次通过范围内。 + +## 文件传输专项补充 + +新增可重复执行的 `npm --prefix test run test:file-transfer`。使用指定完整应用镜像,在不发布端口、不能访问外网的新容器中运行真实 MinIO,以 `daedalus-agent` UID 调用镜像内 `transfer_s3.mjs`;不需要模型服务,不导出 S3 密钥。每次执行后清理专用容器和数据卷。 + +原镜像 `f5c0074ffa8f...` 的首轮 16 项检查通过,包含空文件、UTF-8 文本、二进制与 8 MiB 文件的输入下载/输出上传及 SHA-256 比对、中文/空格文件名、完整 key、按 URI bucket 下载、自动建桶、暂存清理、并发不同名文件、错误凭据/缺失对象/非法路径/存储不可达,以及重启持久化。追加同名并发回归后发现原传输脚本按秒命名:4 个 sender 的 `report.txt` 生成同一个对象 key,后写入的内容覆盖先前内容。该问题来自本仓库文件名生成逻辑,不能归为新 MinIO 的兼容性问题。 + +修复使用毫秒时间戳和 12 位随机数字组成的前缀,保持原有纯数字前缀协议和 basename。上传附加 `If-None-Match: *`;本地下载与上传暂存文件独占创建。即使时钟与随机数碰撞,已有对象和文件也不能被静默覆盖。Skill 文档和功能清单同步说明条件写入要求。 + +包含代码修复的中间镜像 `6b757360c258...` 已通过 21 项专项检查,其中传输阶段 18 项、重启后 3 项。强制碰撞用例固定时钟和随机数,确认第二次同名 S3 上传被条件写入拒绝、第二次同名本地下载以 `EEXIST` 失败,原内容保持不变。日志与报告见 `file-transfer-fixed/`;17 项既有集成辅助契约测试也通过。 + +同步 Skill 说明后再次完整构建,最终镜像 ID 为 `sha256:f3f98e4ac23214f0fbeab08f522e0f210402809326fa613593c763cc33390ba7`,大小 `11567719052` 字节,已更新本机 `daedalus-agent-x64:20261009.01` 标签。构建中的禁网预置工具验证通过,随后在最终镜像上复测,21 项专项检查全部通过: + +- 以实际 Agent UID `996` 执行,MinIO 为 `RELEASE.2025-10-15T17-29-55Z`,Skill 内 AWS SDK 为 `3.1046.0`,使用应用生成的 `local` region 与 path-style 配置。 +- 文本、空文件、二进制和 8 MiB 文件均核对完整字节数与 SHA-256,没有用文本 trim 或模型回复代替文件内容校验。 +- 4 个 sender 并发上传同名文件,各自产生独立对象且内容正确;同一 sender 并发下载同名对象,各自产生独立本地文件。 +- 强制对象名碰撞和本地文件名碰撞均被拒绝,原内容保持不变;错误凭据、匿名访问、对象不存在、非法 sender/源路径、缺少配置及存储不可达均符合预期,无成功 URI 输出。 +- 完整容器重启后,16 个远端对象及 15 个本地文件校验通过,并成功完成新的下载与上传。 +- 最终镜像内 `transfer_s3.mjs`、`SKILL.md`、`README.md` 的 SHA-256 均与当前源码相符。专用容器和数据卷已自动清理。 + +最终证据为 `file-transfer-final/result.json`、`file-transfer-final/transfer.json`、`file-transfer-final/after-restart.json`、`file-transfer-final-build.log`、`file-transfer-final-build-metadata.json`、`file-transfer-final-image.json` 和 `file-transfer-image-source-check.json`。旧版本覆盖问题的失败证据保存在 `file-transfer-same-name/`。 + +本次针对文件传输重测,没有重复真实 LLM 完整流程、双栈防火墙矩阵或旧数据迁移测试;此前这些结论仍对应原镜像 ID。专项最大文件为 8 MiB,并发规模为 4,不代表超大文件或高并发容量验证。 + +## 变更与交付状态 + +本次构建修复、MinIO 来源调整、文件传输防覆盖修复和测试脚本仍保留为 `fix-firewall-policy-correction` 工作区修改,未新增提交或推送。`doc/system-feature-list.md` 已同步构建机制、MinIO 来源、文件命名/条件写入行为和测试范围。文件传输实现与专项场景一并更新;管理 Web、CLI 和 MQTT 实现未变化,对应单元测试无需调整,保留前述已完成结果。本次文件传输复测直接执行镜像内脚本,没有以测试副本替换实现。 + +已清理本次创建的集成/防火墙测试容器、数据卷、fixture 进程与专用网络,保留最终镜像及脱敏证据。临时 LLM/MCP 凭据和运行凭据文件已删除,扫描当前仓库变更与文本证据未发现这些凭据;清理结果见 `credential-cleanup.json`。既有 `ikkyu` 容器仍运行,原有 stash 未修改。 + +## 证据位置 + +本次日志、构建元数据、隔离集成测试配置和容器防火墙探针位于 `E:\tmp\daedalus-build-20261009.01`。`build.log` 记录官方 MinIO 仓库返回 `401`;`build-local-minio.log` 是切换来源前已停止的构建;采用社区镜像的构建记录为 `build-coollabs.log`。`minio-source-checks.log` 和 `minio-provenance.json` 分别记录社区镜像运行检查及构建来源信息。 diff --git a/doc/system-feature-list.md b/doc/system-feature-list.md index ccabd90..6cb5bbb 100644 --- a/doc/system-feature-list.md +++ b/doc/system-feature-list.md @@ -6,7 +6,7 @@ ## 1. 系统定位与运行拓扑 -- [x][T] 提供一体化 Docker 部署,包含管理控制台、Model Gateway、OpenClaw Gateway、MQTT Broker(Aedes)、MinIO、应用调用 socket service;Model Gateway 使用独立 `daedalus-model-gw` UID,Runtime API 仅绑定 `127.0.0.1:3040`,Admin API 仅通过 `/var/daedalus-agent/run/model-gateway-admin.sock` 提供。 +- [x][T] 提供一体化 Docker 部署,包含管理控制台、Model Gateway、OpenClaw Gateway、MQTT Broker(Aedes)、MinIO、应用调用 socket service;Docker 构建上下文包含镜像复制的所有本仓库 npm 包源码,包括 Model Gateway。Model Gateway 使用独立 `daedalus-model-gw` UID,Runtime API 仅绑定 `127.0.0.1:3040`,Admin API 仅通过 `/var/daedalus-agent/run/model-gateway-admin.sock` 提供。 - [x][T] 默认开放管理端口 `3030`、预留服务端口 `3031-3039`、`3041-3050`(不暴露 Model Gateway `3040`)、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 配置。 @@ -15,6 +15,7 @@ - [x][T] 通过 `iptables` / `ip6tables` 按 Agent UID 实施 IPv4 / IPv6 内网地址白名单:白名单 IP / CIDR 放行全部端口和协议,其他内网目标拒绝访问,公网完全放行,不封禁 `80` / `443`;本机通信和入站连接的响应放行。管理控制台可维护白名单,地址变更在容器重启后生效。 - [x][T] 启动时检测并清空已有 `DA_INTERNAL_BLOCK` / `DA_INTERNAL_BLOCK6` 专用链,移除旧版端口封禁、端口例外和过期白名单后重建规则;清除当前 Agent UID 的重复 `OUTPUT` 跳转,重复启动不累积规则,保留其他防火墙链。 - [x][T] 提供启动步骤耗时日志、子服务守护、服务重启、容器重启请求文件和快速重启保护。 +- [x][T] MinIO 和 mc 从 Coollabs 社区镜像的固定 release 与多架构 SHA-256 摘要复制,支持构建阶段选择 amd64/arm64;最终镜像保留本仓库运行环境。来源、摘要与维护边界见 `docker/BUILD.md`,不再依赖 `quay.io/minio` 镜像仓库。 - [x][T] 通过内置内容指纹执行 OpenClaw home、插件、Skill、模板和脚本的增量 seed/迁移;被替换内容先移入备份目录;Ontology 不参与 seed 覆盖。 - [x][T] 提供 `run_after_startup.sh` 启动后钩子,支持用户编辑并在重启后执行。 - [x][T] 持久化 npm、pip/venv、uv 包安装目录,支持 Agent 运行时安装包并在容器重启后保留。 @@ -163,6 +164,7 @@ ## 6. 文件传输与可选 Skill - [x][T] `daedalus-agent-transfer-file` Skill 支持从 S3/MinIO 下载 `file_input://...` 到 `/upload`,以及将当前 sender 工作区文件上传到 outbox bucket。 +- [x][T] 传输文件名使用毫秒时间戳与 12 位随机数字组成的纯数字前缀,保留原 basename 并替换原有数字前缀;上传时空格转换为 `_`。本地下载/暂存文件独占创建,S3 上传使用 `If-None-Match: *`,碰撞时拒绝覆盖,失败时不输出成功 URI;S3 服务需要支持该条件写入。 - [x] 约定 `/upload`、`download`、`generated` 三类目录,隔离不同会话的文件。 - [x] 通过 `DA_S3_ENDPOINT/REGION/ACCESS_KEY_ID/SECRET_ACCESS_KEY/FORCE_PATH_STYLE` 和 inbox/outbox bucket 配置运行。 - [x] Skill 文档要求只读操作可自动执行;创建、更新、提交、审批、驳回、删除、触发等状态变更必须预览并取得人工确认。 @@ -176,9 +178,10 @@ | 管理 Web 前端 | API 客户端鉴权/超时/错误归一化、目录同步门控、重启轮询、格式化、浏览器覆盖契约、员工与模板列表操作精简、内置模板编辑、名称/编辑详情入口、详情 tabs 保存流程、员工概览主模型/备选模型/每日配额快捷修改及账本用量刷新、用户每日限额/今日用量/状态列表、用户新增/编辑/删除、Dashboard 用户/Agent 用量表 10 条分页与零用量过滤、员工技能/业务系统/MCP 与模板设置布局一致及内置 Skill 隐藏、编辑页不展示同步调用 URL、模板文本文件切换后的编辑器可见性、设置页 API 文档入口、运行凭据重置按钮/确认框及“刷新状态”按钮、Bootstrap 等待版本缓存后再展示设置页版本表 | `packages/daedalus-agent-management-web/src/**/*.test.js`、`packages/daedalus-agent-management-web/src/pages/AgentsPage.test.jsx`、`packages/daedalus-agent-management-web/src/pages/AgentUsersPage.test.jsx`、`packages/daedalus-agent-management-web/src/pages/DashboardPage.test.jsx`、`packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js`、`packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js`、`packages/daedalus-agent-management-web/test/browser-coverage.test.js`、`packages/daedalus-agent-management-web/test/run-browser-e2e.js`、`packages/daedalus-agent-management-web/test/integration/website-full.js` | | invoke CLI | 参数、JSON/body/query 来源、Binding schema、socket 协议、超时、大小限制、Provider 错误和调用日志 | `packages/daedalus-agent-apps-invoke-cli/test/` | | MQTT 插件 | 账号配置、旧配置兼容、环境变量覆盖、连接/断线重连、通配符、入站路由、普通消息串行队列、停止与 steer/tell 控制队列、active-run 非阻塞 steer、运行时接收 Promise 未完成时的确认与原 final 投递、原始会话目标、OpenClaw deliver 的 text/kind 无加工透传、出站 envelope、日志、Model Gateway `llm_output` 用量落账、`before_agent_run` 配额阻断和待写入同步 | `extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/*test.ts`、`extensions/openclaw-plugins/daedalus-agent-mqtt-channel/index.test.ts` | -| Docker 集成 | 登录、版本/同步、Provider/Model/模板/MCP/Agent 生命周期、模板继承、工作区/Skill、Ontology、MQTT `/stop`/`/steer`/`/tell` 的 final 确认及普通队列保留、重启(含排队重启等待和配置生效)、启动钩子、包持久化、文件输出、会话隔离和压测;指定镜像使用真实模型 Gateway 路径验证 Agent 实际调用 `integration-mcp__employee_template_lookup` MCP 工具,并验证 MCP deny 投影 | `test/case/integration/`、`test/suite/integration/`、`test/case/stress/`;报告 `E:\tmp\daedalus-agent-it-b2f7-fix\reports\playwright-container-flow-2026-09-02T10-07-12-545Z.json` | -| Docker 防火墙与真实 MCP | Docker Desktop 双栈容器实测 Agent UID 的 TCP / UDP 地址白名单、旧端口规则与重复跳转清理、无关规则保护、重启后访问、入站响应、既有出站连接撤权,以及公网 HTTP / HTTPS;`deepseek-v4-flash` 经 Model Gateway 驱动 Agent,实际调用带 `token` 请求头的 GcExcel MCP `overview`、`search`、`fetch` 并核对成功结果;后续完整镜像重建通过离线工具检查、启动和服务健康检查、本地 QMD 模型校验、防火墙检查及 Agent 直接调用 MCP | `doc/firewall-mcp-validation-20261009.md`;专项证据 `E:\tmp\daedalus-firewall-mcp-20261009\reports\` 使用本地基础镜像替换启动脚本;完整构建及独立启动证据 `E:\tmp\daedalus-image-build-20261009\`,具体依赖版本和范围见报告 | -| Docker 升级与品牌契约 | OpenClaw/Node 版本、插件 seed、迁移、重启保护、持久化 MQTT 账号密码与运行凭据同步、DA_ 环境变量前缀和视觉品牌;执行真实启动函数并模拟规则存储,验证 IPv4 / IPv6 公网全端口放行、内网按地址拦截、DNS 无端口例外、本机及入站响应放行、旧端口规则清理、重复启动去重、白名单撤销后已建立出站连接仍受限,以及配置或规则操作失败时终止启动 | `test/case/upgrade/openclaw-upgrade-contract.test.js`、`test/case/upgrade/agent-firewall.test.js`、`test/common/netfilter-harness.sh`、`test/case/branding/`;入口 `npm --prefix test run test:upgrade-contract` 或 `npm --prefix test run test:firewall` | +| Docker 集成 | 登录、版本/同步、Provider/Model/模板/MCP/Agent 生命周期、模板继承、工作区/Skill、Ontology、MQTT `/stop`/`/steer`/`/tell` 的 final 确认及普通队列保留、重启(含排队重启等待和配置生效)、启动钩子、包持久化、文件输出、会话隔离和压测;手动目录同步断言与现有五类目录状态一致,不再要求已移除的 `usage_refresh`;指定镜像使用真实模型 Gateway 路径验证 Agent 实际调用 `integration-mcp__employee_template_lookup` MCP 工具,并验证 MCP deny 投影 | `test/case/integration/`、`test/suite/integration/`、`test/case/stress/`;历史报告 `E:\tmp\daedalus-agent-it-b2f7-fix\reports\playwright-container-flow-2026-09-02T10-07-12-545Z.json`;`20261009.01` 镜像的真实模型流程及跳过项见 `doc/build-validation-20261009.01.md` | +| Docker 防火墙与真实 MCP | Docker Desktop 双栈容器实测 Agent UID 的 TCP / UDP 地址白名单、旧端口规则与重复跳转清理、无关规则保护、重启后访问、入站响应、既有出站连接撤权,以及公网 HTTP / HTTPS;`deepseek-v4-flash` 经 Model Gateway 驱动 Agent,实际调用带 `token` 请求头的 GcExcel MCP `overview`、`search`、`fetch` 并核对成功结果;后续完整镜像重建通过离线工具检查、启动和服务健康检查、本地 QMD 模型校验、防火墙检查及 Agent 直接调用 MCP;`20261009.01` 完整镜像另通过三轮双栈策略矩阵、Chrome 运行、MinIO S3 读写/桶策略和旧数据迁移冒烟检查 | `doc/firewall-mcp-validation-20261009.md`、`doc/build-validation-20261009.01.md`;历史专项证据 `E:\tmp\daedalus-firewall-mcp-20261009\reports\` 使用本地基础镜像替换启动脚本;完整构建证据 `E:\tmp\daedalus-image-build-20261009\`、`E:\tmp\daedalus-build-20261009.01\`,具体依赖版本和范围见报告 | +| Docker 升级与品牌契约 | OpenClaw/Node 版本、镜像所需本仓库 npm 包的构建上下文、MinIO/mc 社区镜像固定版本与摘要、构建入口不再访问旧 MinIO 仓库、插件 seed、迁移、重启保护、持久化 MQTT 账号密码与运行凭据同步、DA_ 环境变量前缀和视觉品牌;执行真实启动函数并模拟规则存储,验证 IPv4 / IPv6 公网全端口放行、内网按地址拦截、DNS 无端口例外、本机及入站响应放行、旧端口规则清理、重复启动去重、白名单撤销后已建立出站连接仍受限,以及配置或规则操作失败时终止启动 | `test/case/upgrade/openclaw-upgrade-contract.test.js`、`test/case/upgrade/agent-firewall.test.js`、`test/common/netfilter-harness.sh`、`test/case/branding/`;入口 `npm --prefix test run test:upgrade-contract` 或 `npm --prefix test run test:firewall` | +| MinIO 文件传输专项 | 在隔离应用容器中,以真实 Agent UID 调用内置传输脚本,检查输入/输出、空文件/文本/二进制/8 MiB 文件的字节数和 SHA-256、中文/空格文件名、URI bucket 与完整 key、自动建桶、并发同名传输、强制命名碰撞时拒绝覆盖、失败时无成功输出、sender 路径检查,以及重启持久化 | `test/case/integration/file-transfer-container.cjs`、`test/suite/integration/run-file-transfer-suite.js`;入口 `npm --prefix test run test:file-transfer`;结果与镜像 ID 见 `doc/build-validation-20261009.01.md` | ## 8. 当前边界与使用前提 diff --git a/docker/BUILD.md b/docker/BUILD.md index 5b99a2c..629c6cf 100644 --- a/docker/BUILD.md +++ b/docker/BUILD.md @@ -12,6 +12,10 @@ Docker 资产统一放在 `docker/assets`;`docker/internal` 只保留构建脚本和离线 vendor 资源。 +MinIO 和 mc 从 [Coollabs 社区镜像](https://github.com/coollabsio/minio) 复制。该项目公开 Dockerfile 和构建流程,MinIO 按官方 release 源码编译,mc 使用上游二进制并在构建时校验签名。当前固定为 `coollabsio/minio:RELEASE.2025-10-15T17-29-55Z@sha256:69b55a1c1c5dc285ce04db96689f5b2102317fc77a50680a1874ca6efd1c87f9`,该索引包含 `linux/amd64` 和 `linux/arm64`。最终镜像只复制 `/usr/bin/minio` 和 `/usr/bin/mc`,继续使用本仓库的运行环境与启动脚本。 + +该来源不依赖已返回 `401` 的 `quay.io/minio` 仓库。标签和摘要必须同时固定;更新摘要后需要验证二进制版本、S3 读写、桶策略与启动流程。2026-10-09 检查到该镜像最后发布于 2025-10-27,不能据此承诺持续安全维护。另有活跃的 [PGSTY Silo](https://github.com/pgsty/silo) 社区分支,但切换到该分支需要单独评估兼容性。 + `tool-builder` 阶段会优先使用 `docker/internal/kernel/vendor/npm` 中匹配包名和版本的本地 `.tgz`。例如可用 `npm pack openclaw@2026.7.1 --pack-destination docker/internal/kernel/vendor/npm` 预先放入本地包;未命中时才从 `DA_BUILD_NPM_REGISTRY` 下载。 ## 推荐脚本 diff --git a/docker/Dockerfile b/docker/Dockerfile index c2a8134..694194c 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -475,8 +475,9 @@ RUN set -eux; \ cp -a scripts /build/web/scripts; \ cp -a node_modules /build/web/node_modules -FROM quay.io/minio/minio:latest AS minio-image -FROM quay.io/minio/mc:latest AS mc-image +# 社区镜像按官方 release 编译;固定多架构摘要,MinIO 和 mc 来自同一镜像。 +FROM coollabsio/minio:RELEASE.2025-10-15T17-29-55Z@sha256:69b55a1c1c5dc285ce04db96689f5b2102317fc77a50680a1874ca6efd1c87f9 AS minio-image +FROM minio-image AS mc-image FROM runtime-base AS final diff --git a/docker/Dockerfile.dockerignore b/docker/Dockerfile.dockerignore index 192cdcc..c92106c 100644 --- a/docker/Dockerfile.dockerignore +++ b/docker/Dockerfile.dockerignore @@ -22,6 +22,8 @@ !packages/daedalus-agent-apps-invoke-cli/** !packages/daedalus-agent-management-web/ !packages/daedalus-agent-management-web/** +!packages/daedalus-agent-model-gateway/ +!packages/daedalus-agent-model-gateway/** !extensions/ !extensions/clawhub-skills/ !extensions/clawhub-skills/** diff --git a/docker/build-arm64.zsh b/docker/build-arm64.zsh index 81899fa..52b67ca 100644 --- a/docker/build-arm64.zsh +++ b/docker/build-arm64.zsh @@ -88,9 +88,4 @@ fi build_args+=("${repo_root}") echo "Building ${image_ref} for linux/arm64 from ${repo_root}" -if [[ "${pull}" == "true" ]]; then - echo "Pulling external base images for linux/arm64" - docker pull --platform linux/arm64 quay.io/minio/minio:latest - docker pull --platform linux/arm64 quay.io/minio/mc:latest -fi docker "${build_args[@]}" diff --git a/extensions/clawhub-skills/daedalus-agent-transfer-file/README.md b/extensions/clawhub-skills/daedalus-agent-transfer-file/README.md index 8b771ff..56eb41d 100644 --- a/extensions/clawhub-skills/daedalus-agent-transfer-file/README.md +++ b/extensions/clawhub-skills/daedalus-agent-transfer-file/README.md @@ -10,6 +10,8 @@ workspace 中的会话文件必须按 `senderId` 隔离: - `/download`:OpenClaw 通过互联网、第三方系统、业务应用等方式下载得到的文件。 - `/generated`:AI 自己生成、转换、分析、渲染或准备返回给用户的文件。 +传输文件名使用毫秒时间戳与 12 位随机数字组成的纯数字前缀,替换原有数字前缀;原文件名保留,上传时空格转为 `_`。本地文件独占创建,S3 上传附带 `If-None-Match: *`,并发同名传输不会静默覆盖已有对象。兼容 S3 的服务必须支持该条件写入;失败时脚本返回非零退出码,不输出 `file_output://`。 + ## 依赖要求 - Node.js diff --git a/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md b/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md index f58ef55..cf69077 100644 --- a/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md +++ b/extensions/clawhub-skills/daedalus-agent-transfer-file/SKILL.md @@ -71,7 +71,7 @@ node scripts/transfer_s3.mjs upload-file --source "/abs/path/to/file.bin" --work - 使用 ESM、动态导入本地安装的包、显式构造 `S3Client`、显式传入凭证,并且 `DA_S3_FORCE_PATH_STYLE` 默认取 `true`。 - 自动创建当前 `senderId` 下需要的 `upload` 或 `generated` 目录。 - 把 `file_input://...` URI 下载到 `/upload`。 -- 把待发送文件复制到 `/generated`,重命名为要求的时间戳前缀格式,并上传到发件箱 bucket 根目录。 +- 把待发送文件复制到 `/generated`,按文件名规则添加数字前缀,并上传到发件箱 bucket 根目录。 - 输出描述传输结果的 JSON。 - 支持 `--uri-only`,用于最终上传回复步骤。 @@ -103,8 +103,8 @@ node scripts/transfer_s3.mjs upload-file --source "/abs/path/to/file.bin" --work - key: `path/to/fname.bin` 3. 在下载目标元数据中保留完整 key。识别远端对象时,不要把它压缩成只有 basename。 4. 把对象下载到工作区当前 `/upload` 目录。 -5. 本地文件名使用原始 basename,但前面加上时间戳前缀,例如 `123456_fname.bin`。 -6. 如果 basename 本身已经带有时间戳前缀,则替换原有前缀,不要再叠加一个。 +5. 本地文件名保留原始 basename,添加由毫秒时间戳与随机数字组成的前缀,具体格式见「文件名规则」。 +6. 如果 basename 本身已经带有数字前缀,则替换原有前缀,不要再叠加一个。 7. 下载完成后,把本地绝对路径加入工作上下文,再继续处理用户请求。 实现要求: @@ -122,7 +122,7 @@ node scripts/transfer_s3.mjs upload-file --source "/abs/path/to/file.bin" --work 1. 先用普通 assistant 消息说明文件内容以及用户该如何使用。 2. 从 `DA_S3_AGENT_OUTBOX_BUCKET` 解析发件箱 bucket。 3. 确认源文件位于当前 `` 目录内;不得上传其他 `senderId` 或工作区根目录下的文件。 -4. 把文件重命名为带时间戳前缀的格式,例如 `123456_fname.bin`(如果原始 basename 已经有时间戳前缀,则替换原前缀,不要叠加),然后将最终文件名中的所有空格替换为 `_`,最后将该文件复制到工作区当前 `/generated` 目录。 +4. 按「文件名规则」生成带数字前缀的文件名(原始 basename 已经有数字前缀时替换,不叠加),将最终文件名中的所有空格替换为 `_`,再复制到工作区当前 `/generated` 目录。 5. 远端目标必须使用发件箱 bucket 根目录,不要额外添加子目录。 6. 如果发件箱 bucket 不存在,通过 SDK 先创建,再上传。 7. 使用 SDK 上传复制后的文件。 @@ -131,7 +131,7 @@ node scripts/transfer_s3.mjs upload-file --source "/abs/path/to/file.bin" --work 实现要求: -- 远端 key 必须始终精确等于 bucket 根目录下的时间戳文件名。 +- 远端 key 必须始终精确等于 bucket 根目录下带数字前缀的文件名。 - 说明性消息和 `file_output://...` 回复不能合并在同一条 assistant 消息里。 - 在上传成功之前,不要输出 `file_output://...`。 - 本地源文件和暂存文件必须始终位于当前 `` 目录内。 @@ -141,9 +141,10 @@ node scripts/transfer_s3.mjs upload-file --source "/abs/path/to/file.bin" --work 下载和上传都使用以下规则: -- 时间戳前缀格式:`_filename.ext` -- 如果文件名已经以 `_` 开头,则把这个前导数字前缀替换成新的时间戳。 +- 文件名前缀格式:`<毫秒时间戳><12 位随机数字>_filename.ext`。前缀仍为纯数字,避免不同进程或 sender 在同一秒传输同名文件时发生覆盖;不要把完整数字前缀直接解释为 Unix 秒时间戳。 +- 如果文件名已经以 `_` 开头,则把这个前导数字前缀替换成新生成的数字前缀。 - 除了上传时必须把空格替换为 `_` 之外,文件名其余部分保持原样。 +- 本地下载和上传暂存文件使用独占创建;S3 上传使用 `If-None-Match: *`。即使数字前缀碰撞也拒绝覆盖已有文件或对象,操作失败时不返回成功 URI。 ## SDK 行为 diff --git a/extensions/clawhub-skills/daedalus-agent-transfer-file/scripts/transfer_s3.mjs b/extensions/clawhub-skills/daedalus-agent-transfer-file/scripts/transfer_s3.mjs index 03bda07..67008cb 100644 --- a/extensions/clawhub-skills/daedalus-agent-transfer-file/scripts/transfer_s3.mjs +++ b/extensions/clawhub-skills/daedalus-agent-transfer-file/scripts/transfer_s3.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { copyFile, mkdir, rm, writeFile } from "node:fs/promises"; -import { appendFileSync, chmodSync, createReadStream, createWriteStream, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { appendFileSync, chmodSync, constants, createReadStream, createWriteStream, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { randomInt } from "node:crypto"; import path from "node:path"; import { Readable } from "node:stream"; import { pipeline } from "node:stream/promises"; @@ -157,7 +158,9 @@ function parseFileInputUri(rawUri) { function makeTimestampedName(name, { replaceSpaces }) { const normalized = (replaceSpaces ? name.replaceAll(" ", "_") : name).replace(TIMESTAMP_PREFIX_RE, ""); - return `${Math.floor(Date.now() / 1000)}_${normalized}`; + // 保留纯数字前缀协议;毫秒时间与随机数共同避免跨进程、跨 sender 的同名冲突。 + const prefix = `${Date.now()}${randomInt(1_000_000_000_000).toString().padStart(12, "0")}`; + return `${prefix}_${normalized}`; } function resolveOutboxEnvName() { @@ -258,22 +261,22 @@ async function writeBodyToFile(body, destination) { } if (typeof body.transformToWebStream === "function") { - await pipeline(Readable.fromWeb(body.transformToWebStream()), createWriteStream(destination)); + await pipeline(Readable.fromWeb(body.transformToWebStream()), createWriteStream(destination, { flags: "wx" })); return; } if (typeof body.transformToByteArray === "function") { - await writeFile(destination, Buffer.from(await body.transformToByteArray())); + await writeFile(destination, Buffer.from(await body.transformToByteArray()), { flag: "wx" }); return; } if (body instanceof Readable) { - await pipeline(body, createWriteStream(destination)); + await pipeline(body, createWriteStream(destination, { flags: "wx" })); return; } if (body instanceof Uint8Array) { - await writeFile(destination, body); + await writeFile(destination, body, { flag: "wx" }); return; } @@ -346,7 +349,7 @@ async function uploadFile(options) { const shouldCopy = sourcePath !== stagedPath; if (shouldCopy) { - await copyFile(sourcePath, stagedPath); + await copyFile(sourcePath, stagedPath, constants.COPYFILE_EXCL); } const clientBundle = await createS3Client(); @@ -354,6 +357,7 @@ async function uploadFile(options) { await clientBundle.client.send(new clientBundle.sdk.PutObjectCommand({ Bucket: bucket, Key: stagedName, + IfNoneMatch: "*", Body: createReadStream(stagedPath) })); diff --git a/test/README.md b/test/README.md index 58ed4f6..1ac763d 100644 --- a/test/README.md +++ b/test/README.md @@ -43,6 +43,19 @@ npm --prefix test run test:integration:full The file-output check asks the agent to create a random `hello-.txt` with content `ping`, reply with a `file_output://...` URI, and verifies the timestamp-prefixed object in the agent outbox bucket. +### MinIO 文件传输专项测试 + +```powershell +$env:DA_FILE_TRANSFER_IMAGE = "daedalus-agent-x64:20261009.01" +npm --prefix test run test:file-transfer +``` + +需要本机 Docker 和已构建的 Linux 镜像。测试自动新建不发布端口、不能访问外网的应用容器与数据卷,使用镜像内的文件传输脚本、AWS SDK 和真实 MinIO,以 Agent UID 执行传输,不需要 LLM 或业务凭据。测试结束后自动删除本次容器与数据卷,不操作其他容器。 + +覆盖输入下载和输出上传、UTF-8/空文件/二进制/8 MiB 文件的字节数与 SHA-256、中文和空格文件名、数字前缀替换、完整对象 key、URI bucket、自动建桶、成功后的暂存清理、不同 sender 路径、并发同名文件、故意制造的对象名/本地文件名碰撞、错误凭据、匿名访问拒绝、缺失对象/配置、非法路径、存储不可达,以及重启后的对象和本地文件持久化。失败用例必须返回非零退出码且不输出成功结果。 + +报告默认保存到 `test/.runtime/file-transfer//`;可通过 `DA_FILE_TRANSFER_REPORT_DIR` 指定目录。`result.json` 汇总阶段结果和清理状态,`transfer.json` 与 `after-restart.json` 记录各检查;不导出运行凭据。该专项验证真实文件传输与存储链路,Agent 选用 Skill 和 MQTT 消息行为由完整集成流程验证。 + The package-memory recall case is temporarily disabled while its non-converging agent behavior is investigated. Full runs record `14.skip-agent-page-package-memory` and continue to sender memory isolation. Set `focus` to `skill-context-contamination` for the targeted package-install, restart, and global-skill-description regression flow. It skips file-output, ontology, and memory scenarios, then stops after validating the post-restart skill description. diff --git a/test/case/integration/file-transfer-container.cjs b/test/case/integration/file-transfer-container.cjs new file mode 100644 index 0000000..da24f17 --- /dev/null +++ b/test/case/integration/file-transfer-container.cjs @@ -0,0 +1,334 @@ +// 仅由专项 runner 在新建隔离容器中执行;CLI 使用真实 Agent UID 和镜像内依赖。 +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const { createRequire } = require("node:module"); +const { createHash, randomBytes } = require("node:crypto"); +const { execFileSync, spawn } = require("node:child_process"); + +const phase = process.argv[2]; +assert.ok(["transfer", "after-restart"].includes(phase)); +const root = "/var/daedalus-agent"; +const workspace = `${root}/.openclaw/workspaces/file-transfer-regression`; +const manifestPath = `${root}/file-transfer-manifest.json`; +const skillDir = `${root}/.openclaw/skills/daedalus-agent-transfer-file`; +const cliPath = `${skillDir}/scripts/transfer_s3.mjs`; +const uid = Number(execFileSync("id", ["-u", "daedalus-agent"], { encoding: "utf8" }).trim()); +const gid = Number(execFileSync("id", ["-g", "daedalus-agent"], { encoding: "utf8" }).trim()); +const runtime = JSON.parse(fs.readFileSync(`${root}/generated/runtime-env.json`, "utf8")); +const skillRequire = createRequire(`${skillDir}/package.json`); +const sdk = skillRequire("@aws-sdk/client-s3"); +const env = { ...process.env, ...runtime, AWS_MAX_ATTEMPTS: "1" }; +const client = new sdk.S3Client({ + endpoint: runtime.DA_S3_ENDPOINT, region: runtime.DA_S3_REGION, forcePathStyle: true, maxAttempts: 1, + credentials: { accessKeyId: runtime.DA_S3_ACCESS_KEY_ID, secretAccessKey: runtime.DA_S3_SECRET_ACCESS_KEY } +}); +const report = { ok: false, phase, uid, region: runtime.DA_S3_REGION, cases: [] }; +const manifest = phase === "after-restart" ? JSON.parse(fs.readFileSync(manifestPath, "utf8")) : { objects: [], files: [] }; +const sha256 = data => createHash("sha256").update(data).digest("hex"); +const redact = text => String(text).replaceAll(runtime.DA_S3_SECRET_ACCESS_KEY, "[REDACTED]"); + +async function check(name, action) { + const started = Date.now(); + try { + const details = await action(); + report.cases.push({ name, ok: true, durationMs: Date.now() - started, ...details }); + } catch (error) { + report.cases.push({ name, ok: false, error: redact(error.message) }); + throw error; + } +} + +function agentDirectory(dir) { + fs.mkdirSync(dir, { recursive: true }); + // 只处理本套测试新建的工作区,避免改变应用既有目录的权限。 + let cursor = dir; + while (cursor === workspace || cursor.startsWith(`${workspace}/`)) { + fs.chownSync(cursor, uid, gid); + fs.chmodSync(cursor, 0o770); + cursor = path.dirname(cursor); + } +} + +function localFixture(sender, name, data) { + const dir = `${workspace}/${sender}/download`; + agentDirectory(dir); + const file = path.join(dir, name); + fs.writeFileSync(file, data, { mode: 0o600 }); + fs.chownSync(file, uid, gid); + return file; +} + +async function cli(args, overrides = {}) { + return await new Promise((resolve, reject) => { + const child = spawn(process.execPath, [cliPath, ...args, "--workspace", workspace], { + cwd: workspace, uid, gid, env: { ...env, ...overrides }, stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + const timer = setTimeout(() => { child.kill("SIGKILL"); reject(new Error("Transfer CLI exceeded 45 seconds")); }, 45000); + child.stdout.on("data", chunk => { stdout += chunk; }); + child.stderr.on("data", chunk => { stderr += chunk; }); + child.on("error", error => { clearTimeout(timer); reject(error); }); + child.on("close", code => { clearTimeout(timer); resolve({ code, stdout: stdout.trim(), stderr: redact(stderr) }); }); + }); +} + +async function success(args, overrides) { + const result = await cli(args, overrides); + assert.equal(result.code, 0, result.stderr); + return JSON.parse(result.stdout); +} + +async function failure(args, expectedError, overrides) { + const result = await cli(args, overrides); + assert.notEqual(result.code, 0); + assert.equal(result.stdout, "", "Failed transfer must not output a success URI or result"); + assert.match(result.stderr, expectedError); +} + +async function getObject(bucket, key) { + const result = await client.send(new sdk.GetObjectCommand({ Bucket: bucket, Key: key })); + return Buffer.from(await result.Body.transformToByteArray()); +} + +function verifyFile(file, expectedHash, bytes) { + assert.equal(fs.statSync(file).size, bytes); + assert.equal(sha256(fs.readFileSync(file)), expectedHash); + assert.equal(fs.statSync(file).uid, uid); +} + +async function verifyObject(bucket, key, expectedHash, bytes) { + const head = await client.send(new sdk.HeadObjectCommand({ Bucket: bucket, Key: key })); + assert.equal(head.ContentLength, bytes); + assert.equal(sha256(await getObject(bucket, key)), expectedHash); +} + +async function verifyUpload(result, source, data, sender) { + assert.equal(result.senderId, sender); + assert.equal(result.sourcePath, source); + assert.equal(result.uri, `file_output://${result.bucket}/${result.key}`); + assert.match(result.key, /^\d+_[^/ ]+$/u); + assert.equal(result.stagedRemoved, true); + assert.equal(fs.existsSync(result.stagedPath), false); + verifyFile(source, sha256(data), data.length); + await verifyObject(result.bucket, result.key, sha256(data), data.length); + manifest.objects.push({ bucket: result.bucket, key: result.key, sha256: sha256(data), bytes: data.length }); + manifest.files.push({ path: source, sha256: sha256(data), bytes: data.length }); +} + +async function roundTrip(label, filename, data) { + const bucket = runtime.DA_S3_AGENT_INBOX_BUCKET; + const key = `nested/${label}/1700000000_${filename}`; + await client.send(new sdk.PutObjectCommand({ Bucket: bucket, Key: key, Body: data })); + const inputUri = `file_input://${bucket}/${key}`; + const downloaded = await success(["download-uri", "--sender-id", "sender-a", "--uri", `${inputUri} attachment metadata`]); + assert.equal(downloaded.bucket, bucket); + assert.equal(downloaded.key, key); + assert.equal(downloaded.sourceUri, inputUri); + assert.equal(path.dirname(downloaded.localPath), `${workspace}/sender-a/upload`); + assert.equal(downloaded.localName.replace(/^\d+_/, ""), filename); + verifyFile(downloaded.localPath, sha256(data), data.length); + const uploaded = await success(["upload-file", "--sender-id", "sender-a", "--source", downloaded.localPath]); + assert.equal(uploaded.bucket, runtime.DA_S3_AGENT_OUTBOX_BUCKET); + assert.equal(uploaded.key.replace(/^\d+_/, ""), filename); + await verifyUpload(uploaded, downloaded.localPath, data, "sender-a"); + return { bytes: data.length, sha256: sha256(data), inputKey: key, outputKey: uploaded.key }; +} + +(async () => { + agentDirectory(workspace); + await check("runtime-minio-and-buckets", async () => { + assert.notEqual(uid, 0); + for (const bucket of [runtime.DA_S3_AGENT_INBOX_BUCKET, runtime.DA_S3_AGENT_OUTBOX_BUCKET]) { + await client.send(new sdk.HeadBucketCommand({ Bucket: bucket })); + } + return { + minioVersion: execFileSync("minio", ["--version"], { encoding: "utf8" }).split("\n")[0], + transferScriptSha256: sha256(fs.readFileSync(cliPath)), + sdkVersion: skillRequire("@aws-sdk/client-s3/package.json").version + }; + }); + if (phase === "after-restart") { + await check("objects-and-agent-files-persist-after-restart", async () => { + for (const object of manifest.objects) await verifyObject(object.bucket, object.key, object.sha256, object.bytes); + for (const file of manifest.files) verifyFile(file.path, file.sha256, file.bytes); + return { objects: manifest.objects.length, files: manifest.files.length }; + }); + await check("new-transfer-after-restart", () => roundTrip("restart", "重启验证.txt", Buffer.from("restart round trip\r\n重启后仍可传输\n"))); + } else { + for (const [label, filename, data] of [ + ["text", "文本_日本語.txt", Buffer.from("文件传输\r\nUnicode: 中文 日本語 🙂\n末尾空格 \n")], + ["empty", "empty.dat", Buffer.alloc(0)], + ["binary", "bytes.bin", Buffer.from(Array.from({ length: 65536 }, (_, i) => i % 256))], + ["large", "large.bin", randomBytes(8 * 1024 * 1024)] + ]) await check(`roundtrip-${label}`, () => roundTrip(label, filename, data)); + + await check("source-spaces-prefix-and-staging-cleanup", async () => { + const data = Buffer.from("spaces and unicode filename"); + const source = localFixture("sender-a", "1700000000_测试 报告 #1+%.txt", data); + const result = await success(["upload-file", "--sender-id", "sender-a", "--source", source]); + assert.equal(result.key.replace(/^\d+_/, ""), "测试_报告_#1+%.txt"); + await verifyUpload(result, source, data, "sender-a"); + return { outputKey: result.key }; + }); + + await check("generated-source-file-is-preserved", async () => { + const data = Buffer.from("agent generated content must remain local"); + const original = localFixture("sender-a", "generated-source.txt", data); + const dir = `${workspace}/sender-a/generated`; + agentDirectory(dir); + const source = path.join(dir, "generated-source.txt"); + fs.renameSync(original, source); + const result = await success(["upload-file", "--sender-id", "sender-a", "--source", source]); + assert.notEqual(result.stagedPath, source); + await verifyUpload(result, source, data, "sender-a"); + return { sourcePreserved: true, temporaryCopyRemoved: true }; + }); + + await check("uri-only-and-automatic-bucket-creation", async () => { + const bucket = "file-transfer-fresh-outbox"; + await assert.rejects(client.send(new sdk.HeadBucketCommand({ Bucket: bucket })), error => error.$metadata?.httpStatusCode === 404); + const data = Buffer.from("fresh bucket in runtime region local"); + const source = localFixture("sender-a", "uri-only.txt", data); + const result = await cli(["upload-file", "--sender-id", "sender-a", "--source", source, "--uri-only"], { DA_S3_AGENT_OUTBOX_BUCKET: bucket }); + assert.equal(result.code, 0, result.stderr); + assert.match(result.stdout, /^file_output:\/\/file-transfer-fresh-outbox\/\d+_uri-only\.txt$/); + const key = result.stdout.split("/").at(-1); + await verifyObject(bucket, key, sha256(data), data.length); + assert.equal(fs.existsSync(`${workspace}/sender-a/generated/${key}`), false); + manifest.objects.push({ bucket, key, sha256: sha256(data), bytes: data.length }); + return { bucket, uriOnly: true }; + }); + + await check("uri-bucket-and-full-key-with-sender-download-isolation", async () => { + const bucket = "file-transfer-alternate-inbox"; + await client.send(new sdk.CreateBucketCommand({ Bucket: bucket })); + const paths = []; + for (const [sender, directory, value] of [["sender-a", "first", "alpha"], ["sender-b", "second", "beta"]]) { + agentDirectory(`${workspace}/${sender}`); + const key = `${directory}/same-name.txt`; + await client.send(new sdk.PutObjectCommand({ Bucket: bucket, Key: key, Body: value })); + const result = await success(["download-uri", "--sender-id", sender, "--uri", `file_input://${bucket}/${key}`]); + assert.equal(result.bucket, bucket); + assert.equal(result.key, key); + assert.equal(path.dirname(result.localPath), `${workspace}/${sender}/upload`); + assert.equal(fs.readFileSync(result.localPath, "utf8"), value); + paths.push(result.localPath); + } + assert.notEqual(paths[0], paths[1]); + assert.equal(fs.readFileSync(paths[0], "utf8"), "alpha"); + return { distinctPaths: paths.length }; + }); + + await check("concurrent-transfers-distinct-files", async () => { + const jobs = Array.from({ length: 4 }, (_, index) => { + const sender = `parallel-${index}`; + const data = randomBytes(128 * 1024); + return { sender, data, source: localFixture(sender, `parallel-${index}.bin`, data) }; + }); + const results = await Promise.all(jobs.map(async ({ sender, data, source }) => { + const result = await success(["upload-file", "--sender-id", sender, "--source", source]); + await verifyUpload(result, source, data, sender); + const downloaded = await success(["download-uri", "--sender-id", sender, "--uri", result.uri.replace("file_output://", "file_input://")]); + verifyFile(downloaded.localPath, sha256(data), data.length); + return result.key; + })); + assert.equal(new Set(results).size, jobs.length); + return { concurrentTransfers: jobs.length, bytesEach: 128 * 1024 }; + }); + + await check("concurrent-same-name-files-do-not-overwrite", async () => { + const jobs = Array.from({ length: 4 }, (_, index) => { + const sender = `same-name-${index}`; + const data = Buffer.from(`private content for ${sender}`); + return { sender, data, source: localFixture(sender, "report.txt", data) }; + }); + const results = await Promise.all(jobs.map(({ sender, source }) => success(["upload-file", "--sender-id", sender, "--source", source]))); + assert.equal(new Set(results.map(result => result.key)).size, jobs.length, "Concurrent same-name uploads must have distinct object keys"); + for (const [index, result] of results.entries()) { + const { sender, data, source } = jobs[index]; + await verifyUpload(result, source, data, sender); + } + return { concurrentTransfers: jobs.length, distinctObjects: results.length }; + }); + + await check("concurrent-same-sender-downloads-do-not-overwrite", async () => { + const bucket = runtime.DA_S3_AGENT_INBOX_BUCKET; + const jobs = Array.from({ length: 4 }, (_, index) => ({ key: `parallel-input/${index}/report.txt`, data: Buffer.from(`input content ${index}`) })); + for (const job of jobs) await client.send(new sdk.PutObjectCommand({ Bucket: bucket, Key: job.key, Body: job.data })); + const results = await Promise.all(jobs.map(job => success(["download-uri", "--sender-id", "sender-a", "--uri", `file_input://${bucket}/${job.key}`]))); + assert.equal(new Set(results.map(result => result.localPath)).size, jobs.length); + for (const [index, result] of results.entries()) verifyFile(result.localPath, sha256(jobs[index].data), jobs[index].data.length); + return { concurrentTransfers: jobs.length, distinctLocalFiles: results.length }; + }); + + // 固定时钟和随机数,故意制造碰撞,验证 CLI 自身的覆盖保护。 + const collisionHook = `${workspace}/collision-hook.cjs`; + fs.writeFileSync(collisionHook, `Date.now = () => ${Date.now()}; require('node:crypto').randomInt = () => 42; require('node:module').syncBuiltinESMExports();\n`, { mode: 0o644 }); + const collisionEnv = { NODE_OPTIONS: `--require=${collisionHook}` }; + await check("forced-object-name-collision-does-not-overwrite", async () => { + const data = Buffer.from("original object must survive"); + const source = localFixture("collision-a", "collision.txt", data); + const original = await success(["upload-file", "--sender-id", "collision-a", "--source", source], collisionEnv); + await verifyUpload(original, source, data, "collision-a"); + const secondSource = localFixture("collision-b", "collision.txt", Buffer.from("must not replace original")); + await failure(["upload-file", "--sender-id", "collision-b", "--source", secondSource], /PreconditionFailed|pre-conditions|precondition/i, collisionEnv); + await verifyObject(original.bucket, original.key, sha256(data), data.length); + return { originalPreserved: true }; + }); + + await check("forced-local-name-collision-does-not-overwrite", async () => { + const bucket = runtime.DA_S3_AGENT_INBOX_BUCKET; + const key = "collision/input.txt"; + const data = Buffer.from("original local file must survive"); + await client.send(new sdk.PutObjectCommand({ Bucket: bucket, Key: key, Body: data })); + const args = ["download-uri", "--sender-id", "sender-a", "--uri", `file_input://${bucket}/${key}`]; + const original = await success(args, collisionEnv); + await client.send(new sdk.PutObjectCommand({ Bucket: bucket, Key: key, Body: "replacement remote content" })); + await failure(args, /EEXIST/, collisionEnv); + verifyFile(original.localPath, sha256(data), data.length); + return { originalPreserved: true }; + }); + + await check("missing-object-fails-without-output-file", async () => { + const dir = `${workspace}/sender-a/upload`; + const before = fs.readdirSync(dir).sort(); + await failure(["download-uri", "--sender-id", "sender-a", "--uri", `file_input://${runtime.DA_S3_AGENT_INBOX_BUCKET}/missing/not-found.bin`], /specified key does not exist|NoSuchKey/i); + assert.deepEqual(fs.readdirSync(dir).sort(), before); + }); + + await check("invalid-credentials-and-private-bucket", async () => { + const object = manifest.objects[0]; + const response = await fetch(`${runtime.DA_S3_ENDPOINT}/${object.bucket}/${encodeURIComponent(object.key)}`); + assert.equal(response.status, 403); + await response.arrayBuffer(); + const before = await client.send(new sdk.ListObjectsV2Command({ Bucket: object.bucket })); + const source = localFixture("sender-a", "denied-upload.txt", Buffer.from("must not be uploaded")); + await failure(["upload-file", "--sender-id", "sender-a", "--source", source], /signature|access.*denied|InvalidAccessKey/i, { DA_S3_SECRET_ACCESS_KEY: "wrong-secret-for-regression" }); + const after = await client.send(new sdk.ListObjectsV2Command({ Bucket: object.bucket })); + assert.deepEqual((after.Contents || []).map(x => x.Key).sort(), (before.Contents || []).map(x => x.Key).sort()); + await failure(["download-uri", "--sender-id", "sender-a", "--uri", `file_input://${object.bucket}/${object.key}`], /signature|access.*denied|InvalidAccessKey/i, { DA_S3_SECRET_ACCESS_KEY: "wrong-secret-for-regression" }); + return { anonymousStatus: response.status }; + }); + + await check("invalid-input-and-cross-sender-source-rejected", async () => { + const source = localFixture("sender-b", "private.txt", Buffer.from("sender-b only")); + await failure(["upload-file", "--sender-id", "sender-a", "--source", source], /--source must stay inside/); + await failure(["upload-file", "--source", source], /Missing --sender-id/); + await failure(["upload-file", "--sender-id", "../sender-b", "--source", source], /safe single path segment/); + await failure(["download-uri", "--sender-id", "sender-a", "--uri", "file_input://missing-key"], /Malformed file input URI/); + await failure(["upload-file", "--sender-id", "sender-b", "--source", source], /Missing required environment variable: DA_S3_SECRET_ACCESS_KEY/, { DA_S3_SECRET_ACCESS_KEY: "" }); + }); + + await check("unreachable-storage-does-not-report-success", async () => { + const source = localFixture("sender-a", "unreachable.txt", Buffer.from("storage unavailable")); + await failure(["upload-file", "--sender-id", "sender-a", "--source", source], /ECONNREFUSED/, { DA_S3_ENDPOINT: "http://127.0.0.1:1" }); + }); + fs.writeFileSync(manifestPath, JSON.stringify(manifest)); + } + report.ok = true; +})().catch(error => { report.error = redact(error.message); }).finally(() => { + client.destroy(); + console.log(JSON.stringify(report)); +}); diff --git a/test/case/integration/playwright-flow.js b/test/case/integration/playwright-flow.js index 51e2379..3b4ac27 100644 --- a/test/case/integration/playwright-flow.js +++ b/test/case/integration/playwright-flow.js @@ -79,7 +79,6 @@ const SENDER_ISOLATION_SENDER_ID = "daedalus-agent-integration-isolated"; const SCHEDULED_TASKS = [ { name: "llm-sync", key: "llm_sync" }, { name: "agent-sync", key: "agent_sync" }, - { name: "usage-refresh", key: "usage_refresh" }, { name: "skill-sync", key: "skill_sync" }, { name: "template-sync", key: "template_sync" }, { name: "system-sync", key: "system_sync" } diff --git a/test/case/upgrade/openclaw-upgrade-contract.test.js b/test/case/upgrade/openclaw-upgrade-contract.test.js index f133ea9..933da0f 100644 --- a/test/case/upgrade/openclaw-upgrade-contract.test.js +++ b/test/case/upgrade/openclaw-upgrade-contract.test.js @@ -15,6 +15,29 @@ function normalizeLineEndings(value) { return value.replace(/\r\n/g, "\n"); } +test("Docker build context includes every first-party package copied into the image", async () => { + const dockerfile = await readRepoFile("docker/Dockerfile"); + const ignoreRules = new Set(normalizeLineEndings(await readRepoFile("docker/Dockerfile.dockerignore")).split("\n")); + const packageDirs = new Set([...dockerfile.matchAll(/^COPY(?: --[^ ]+)* (packages\/[^/ ]+)/gm)].map((match) => match[1])); + + assert.ok(packageDirs.size > 0); + for (const packageDir of packageDirs) { + assert.ok(ignoreRules.has(`!${packageDir}/`), `${packageDir} directory must be included in the build context`); + assert.ok(ignoreRules.has(`!${packageDir}/**`), `${packageDir} source must be included in the build context`); + } +}); + +test("MinIO and mc come from one digest-pinned community image without retired registry pulls", async () => { + const dockerfile = await readRepoFile("docker/Dockerfile"); + assert.match(dockerfile, /^FROM coollabsio\/minio:RELEASE\.2025-10-15T17-29-55Z@sha256:69b55a1c1c5dc285ce04db96689f5b2102317fc77a50680a1874ca6efd1c87f9 AS minio-image$/m); + assert.match(dockerfile, /^FROM minio-image AS mc-image$/m); + assert.match(dockerfile, /COPY --from=minio-image \/usr\/bin\/minio \/usr\/local\/bin\/minio/); + assert.match(dockerfile, /COPY --from=mc-image \/usr\/bin\/mc \/usr\/local\/bin\/mc/); + for (const file of ["docker/Dockerfile", "docker/build-arm64.zsh", "docker/build-x64.sh", "docker/build-x64.ps1"]) { + assert.doesNotMatch(await readRepoFile(file), /quay\.io\/minio\//); + } +}); + test("Docker image pins the supported OpenClaw and Node versions", async () => { const dockerfile = await readRepoFile("docker/Dockerfile"); diff --git a/test/package.json b/test/package.json index 95c7aca..a503de2 100644 --- a/test/package.json +++ b/test/package.json @@ -19,6 +19,7 @@ "test:flow": "node ./suite/integration/run-integration-suite.js full", "test:integration:fast": "node ./suite/integration/run-integration-suite.js fast", "test:integration:full": "node ./suite/integration/run-integration-suite.js full", + "test:file-transfer": "node ./suite/integration/run-file-transfer-suite.js", "test:stress": "node ./suite/stress/run-100a-reuse-concurrency-matrix.js", "test:stress:contract": "node --test ./case/stress/100a-reuse-concurrency-matrix-contract.test.js", "test:stress:100a-matrix": "node ./suite/stress/run-100a-reuse-concurrency-matrix.js", diff --git a/test/suite/integration/run-file-transfer-suite.js b/test/suite/integration/run-file-transfer-suite.js new file mode 100644 index 0000000..7e0bf47 --- /dev/null +++ b/test/suite/integration/run-file-transfer-suite.js @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { fileURLToPath } from "node:url"; + +const testDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const image = process.env.DA_FILE_TRANSFER_IMAGE || "daedalus-agent-x64:20261009.01"; +const runId = `${Date.now()}-${randomBytes(4).toString("hex")}`; +const container = `daedalus-file-transfer-${runId}`; +const volume = `${container}-data`; +const reportDir = path.resolve(process.env.DA_FILE_TRANSFER_REPORT_DIR || path.join(testDir, ".runtime/file-transfer", runId)); +const report = { ok: false, image, startedAt: new Date().toISOString(), phases: [], cleanup: {} }; +let createdVolume = false; +let createdContainer = false; + +function docker(args, options = {}) { + return execFileSync("docker", args, { + encoding: "utf8", windowsHide: true, timeout: 60000, maxBuffer: 8 * 1024 * 1024, + stdio: ["pipe", "pipe", "pipe"], ...options + }).trim(); +} + +async function waitForReady(since) { + const deadline = Date.now() + 240000; + while (Date.now() < deadline) { + try { + const ready = docker(["exec", container, "curl", "-fsS", "--max-time", "2", "http://127.0.0.1:9000/minio/health/ready"]); + const logs = docker(["logs", "--since", since, "--tail", "100", container]); + if (ready === "" && logs.includes("all is ok")) return; + } catch {} + await new Promise(resolve => setTimeout(resolve, 1000)); + } + throw new Error("Isolated application did not become ready within 240 seconds"); +} + +function runPhase(phase) { + const output = docker(["exec", container, "node", "/tmp/file-transfer-container.cjs", phase], { timeout: 180000 }); + const result = JSON.parse(output); + fs.writeFileSync(path.join(reportDir, `${phase}.json`), `${JSON.stringify(result, null, 2)}\n`); + report.phases.push(result); + assert.equal(result.ok, true, `${phase}: ${result.error || "failed"}`); + console.log(`${phase}: ${result.cases.length} checks passed`); +} + +fs.mkdirSync(reportDir, { recursive: true }); +try { + report.imageId = docker(["image", "inspect", image, "--format", "{{.Id}}"]); + docker(["volume", "create", "--label", "daedalus.test=file-transfer", volume]); + createdVolume = true; + // 专用容器不发布端口、不能访问外网,也不读取任何现有容器或 LLM 配置。 + docker(["create", "--name", container, "--label", "daedalus.test=file-transfer", "--network", "none", "--cap-add", "NET_ADMIN", + "--mount", `type=volume,src=${volume},dst=/var/daedalus-agent`, image]); + createdContainer = true; + const envFile = path.join(reportDir, "env.json"); + const configFile = path.join(reportDir, "config.json"); + fs.writeFileSync(envFile, "{}\n"); + docker(["cp", envFile, `${container}:/var/daedalus-agent/env.json`]); + docker(["cp", `${container}:/opt/daedalus-agent-assets/config.json`, configFile]); + docker(["cp", configFile, `${container}:/var/daedalus-agent/config.json`]); + docker(["cp", path.join(testDir, "case/integration/file-transfer-container.cjs"), `${container}:/tmp/file-transfer-container.cjs`]); + const initialStart = new Date().toISOString(); + docker(["start", container]); + await waitForReady(initialStart); + runPhase("transfer"); + const restartStart = new Date().toISOString(); + docker(["restart", container]); + await waitForReady(restartStart); + runPhase("after-restart"); + report.ok = true; +} catch (error) { + // 不输出 docker exec 的完整环境或启动日志,报告只包含检查错误。 + report.error = error instanceof Error ? error.message : String(error); + console.error(report.error); +} finally { + if (createdContainer) { + try { docker(["rm", "-f", container]); report.cleanup.container = true; } + catch { report.cleanup.container = false; report.ok = false; } + } + if (createdVolume) { + try { docker(["volume", "rm", volume]); report.cleanup.volume = true; } + catch { report.cleanup.volume = false; report.ok = false; } + } + report.finishedAt = new Date().toISOString(); + fs.writeFileSync(path.join(reportDir, "result.json"), `${JSON.stringify(report, null, 2)}\n`); +} +console.log(path.join(reportDir, "result.json")); +process.exitCode = report.ok ? 0 : 1; -- Gitee From 0130625ffc488e81ddec42888fd0711ff9564060 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= Date: Mon, 12 Oct 2026 11:51:19 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E5=AE=8C=E6=88=90=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- doc/build-validation-20261009.01.md | 45 +++++++++++++++++++++--- doc/handoff.md | 35 ++++++++++++++++++ doc/install-guide.md | 2 +- doc/system-feature-list.md | 4 +-- test/case/integration/playwright-flow.js | 31 ++++++++++++---- 5 files changed, 103 insertions(+), 14 deletions(-) create mode 100644 doc/handoff.md diff --git a/doc/build-validation-20261009.01.md b/doc/build-validation-20261009.01.md index 5c5a161..0b46a0b 100644 --- a/doc/build-validation-20261009.01.md +++ b/doc/build-validation-20261009.01.md @@ -1,6 +1,6 @@ # 20261009.01 构建与测试记录 -当前交付镜像为 `daedalus-agent-x64:20261009.01`(`linux/amd64`),已包含文件传输同名覆盖修复。最终镜像 ID 为 `sha256:f3f98e4ac23214f0fbeab08f522e0f210402809326fa613593c763cc33390ba7`,专项测试 21 项全部通过;此前完整真实 LLM 流程对应的旧镜像与范围在下文分别记录。 +当前交付镜像为 `daedalus-agent-x64:20261009.01`(`linux/amd64`),已包含文件传输同名覆盖修复。最终镜像 ID 为 `sha256:f3f98e4ac23214f0fbeab08f522e0f210402809326fa613593c763cc33390ba7`,文件传输专项 21 项全部通过,真实模型完整集成流程 41 个执行步骤通过、2 个步骤跳过。旧镜像和最终镜像的验证范围分别记录如下。 ## 构建范围 @@ -51,7 +51,7 @@ - 旧端口规则与过期白名单清理、重复跳转去重、重复初始化、无关规则保留、入站响应和既有出站连接撤权均通过。 - 完整容器重启后网络矩阵、服务健康、IPC socket、公网 HTTP/HTTPS 访问均通过。结果见 `firewall/result.json` 和 `firewall/run.log`。 -## 真实模型集成 +## 旧镜像真实模型集成 使用 `https://gcapi.cn/v1` 的 `deepseek-v4-flash`,密钥测试时仅保存在仓库外的临时配置及隔离容器中。单独启动的本地 fixture 只提供 MCP 工具,未作为 LLM 使用;结束时其 `chatCompletionCount=0`,证据见 `mcp-fixture-stats.json`。 @@ -59,7 +59,7 @@ 随后修正 `test/case/integration/playwright-flow.js` 的过时断言:`/api/settings/catalog-sync` 当前只同步 LLM、Agent、Skill、模板、业务系统五类目录,用量由 Model Gateway 账本聚合,不再返回 `usage_refresh`。本次仅删除已移除状态的检查,保留五类目录均为 `success` 的断言;应用实现及镜像未因这项修正而变化。 -最终完整流程退出码为 `0`、报告 `ok=true`。报告共 43 个步骤,其中 41 个执行步骤通过,2 个按既有测试设计跳过: +旧镜像 `f5c0074ffa8f...` 的完整流程退出码为 `0`、报告 `ok=true`。报告共 43 个步骤,其中 41 个执行步骤通过,2 个按既有测试设计跳过: - 已通过:管理页面操作、模板继承、工作区/Skill/MCP 生命周期、真实模型调用 MCP、启动钩子编辑与重启、管理端对话、MQTT 文件输出及 MinIO 对象内容、Ontology 真实业务调用及日志、npm/uv/pip 包安装与重启持久化、五类目录同步、重启后 Skill 识别,以及 sender 记忆隔离。 - 未覆盖:MQTT `/stop`、`/steer`、`/tell` 的确定性时序测试仅支持 mock LLM,本次真实模型流程自动跳过;包安装记忆回忆用例在仓库中已暂时禁用。不能将这两项记为本次容器集成通过。 @@ -97,14 +97,49 @@ Agent 经真实 LLM 依次调用 `gcexcel-validation__overview`、`gcexcel-valid 最终证据为 `file-transfer-final/result.json`、`file-transfer-final/transfer.json`、`file-transfer-final/after-restart.json`、`file-transfer-final-build.log`、`file-transfer-final-build-metadata.json`、`file-transfer-final-image.json` 和 `file-transfer-image-source-check.json`。旧版本覆盖问题的失败证据保存在 `file-transfer-same-name/`。 -本次针对文件传输重测,没有重复真实 LLM 完整流程、双栈防火墙矩阵或旧数据迁移测试;此前这些结论仍对应原镜像 ID。专项最大文件为 8 MiB,并发规模为 4,不代表超大文件或高并发容量验证。 +文件传输专项最大文件为 8 MiB,并发规模为 4,不代表超大文件或高并发容量验证。双栈防火墙矩阵、Chrome 运行和旧数据迁移结论对应原镜像 `f5c0074ffa8f...`;最终镜像的真实模型复测另行记录。 + +## 最终镜像完整集成复测 + +使用最终镜像 `f3f98e4ac232...`、全新数据卷和真实 `deepseek-v4-flash` 执行完整流程。本地 `18184` fixture 只提供 MCP 工具;其统计结果中 `chatCompletionCount=0`,模型请求经 Model Gateway 访问 `https://gcapi.cn/v1`。 + +首轮在最后的 sender 记忆布局检查失败:包安装对话把记录写到了 workspace 根 `memory/`。检查源码发现,集成脚本创建 Agent 时传入两行 `docs_content`,管理端随后通过 `agent.docs.update` 覆盖导入模板的 `AGENTS.md`,使测试 Agent 丢失了模板中的 sender 隔离规则。QMD 的 `includeDefaultMemory=false` 仅关闭默认记忆索引,不能阻止模型通过文件工具写入根目录。 + +第二轮补充包安装提示中的根记忆禁止规则后通过。随后修正测试前置条件:创建 Agent 时不再覆盖模板 `AGENTS.md`,继承检查通过管理 API 读取 Agent 文件并与导入模板原文逐字比较;包安装提示继续要求仅使用当前 sender 的记忆目录。最终第三轮在新数据卷上通过,应用代码和镜像未因这些测试修改而变化。 + +- 流程退出码 `0`、报告 `ok=true`;共 43 个步骤,41 个执行步骤通过、2 个按既有设计跳过。 +- 已通过管理端操作、Provider/Model/模板/Agent/Skill/MCP 生命周期、模板规则继承、MCP deny 投影、真实 Agent 调用本地 MCP、业务系统 Ontology 调用及日志、MQTT 文件输出及 MinIO 对象内容、启动钩子、npm/uv/pip 安装与重启持久化、目录同步,以及重启后 Skill 识别。 +- sender 记忆文件均包含自身标记且不包含另一 sender 标记,根 `MEMORY.md` 与 `memory/` 均不存在。该结果证明本轮正常对话下的规则遵循,不代表文件系统对任意命令实施了 sender 级权限隔离。 +- 跳过 MQTT `/stop`、`/steer`、`/tell` 的 mock LLM 确定性时序场景,以及仓库暂时禁用的包记忆回忆场景;不将其计为通过。 +- 首次启动约 `18.6` 秒,包安装后的容器重启约 `11.6` 秒;仅代表本机本轮运行。 +- 集成辅助契约测试 17 项通过,流程脚本语法检查通过。 + +最终报告:`E:/tmp/daedalus-integration-final-20261009-03/runtime/reports/playwright-container-flow-2026-10-09T12-29-01-266Z.json`;同目录上级保留 `integration.log`、`runtime-state.json` 和 `mcp-fixture-stats.json`。首轮失败与第二轮提示调整证据分别保留在 `E:/tmp/daedalus-integration-final-20261009-01`、`E:/tmp/daedalus-integration-final-20261009-02`,不能替代第三轮的模板继承验证。 + +完整流程结束后,在同一最终镜像容器补验 GcExcel MCP。header `token` 投影核对一致,真实模型按顺序调用 `overview`、`search`、`fetch`,三个结果均非错误;`search` 的 `doc_id` 与 `fetch` 参数和返回一致,完整文档为 358 字符,最终回复包含 `GCEXCEL_MCP_OK`。独立会话轨迹核验结果为 `ok=true`,证据见 `E:/tmp/daedalus-integration-final-20261009-03/gcexcel-verified.json`,包含完整镜像 ID。 + +该补验增加的一次容器重启触发 OpenClaw 的重启保护:日志记录 300000 ms 内 3 次 `unclean boot`,自动启动 MQTT 通道被抑制。使用官方 Gateway `channels.start` 恢复测试账号 `public-demo` 后完成上述调用,未修改或关闭保护配置。此现象独立于此前已通过的完整流程,仍需评估容器停止和快速连续重启的稳定性;不能将本次有恢复操作的结果表述为 GcExcel 无干预启动通过。 + +## 2026-10-12 最终镜像复测 + +使用同一最终镜像 `f3f98e4ac232...` 和真实 `deepseek-v4-flash` 再次执行完整流程,配置来自仓库外的临时文件,结束后已删除。本轮修正了文件输出场景的测试提示:请求中显式给出当前通道 `senderId` 并传入 `--sender-id`,同时要求 Agent 在同一次运行内完成 Skill 的两段式回复(先说明文件,再单独发送 `file_output://` URI),避免只等待一次回复导致 URI 缺失。应用实现和镜像未变化。 + +- 报告 `ok=true`,共 43 个步骤,41 个执行步骤通过、2 个按既有设计跳过(`7a.skip-mqtt-control-instructions-without-mock-llm`、`14.skip-agent-page-package-memory`)。 +- 文件输出返回 `file_output://daedalus-agent-outbox/..._hello-a31afa08fa21.txt`,MinIO 对象长度 4 字节,内容为 `ping`;对象与回复均已脱敏保存。 +- 本地 MCP fixture 统计 `chatCompletionCount=0`、`mcpToolCallCount=3`,模型请求全部经 Model Gateway 访问 `https://gcapi.cn/v1`。 +- 首次启动约 `16.9` 秒,重启约 `11.6` 秒;仅代表本机本轮运行。 +- 文件传输专项在本轮镜像上单独复跑,21 项全部通过,容器和数据卷已清理。 + +证据:`E:/tmp/daedalus-integration-20261012-real-03/`(含 `runtime/reports/playwright-container-flow-2026-10-12T01-30-43-151Z.json`、`mcp-fixture-stats.json`)、`E:/tmp/daedalus-integration-20261012-file-transfer/result.json`。临时密钥文件已删除,本轮证据目录未检出密钥样式文本,未遗留测试容器、数据卷或 fixture 进程。 ## 变更与交付状态 -本次构建修复、MinIO 来源调整、文件传输防覆盖修复和测试脚本仍保留为 `fix-firewall-policy-correction` 工作区修改,未新增提交或推送。`doc/system-feature-list.md` 已同步构建机制、MinIO 来源、文件命名/条件写入行为和测试范围。文件传输实现与专项场景一并更新;管理 Web、CLI 和 MQTT 实现未变化,对应单元测试无需调整,保留前述已完成结果。本次文件传输复测直接执行镜像内脚本,没有以测试副本替换实现。 +当前分支为 `fix-firewall-policy-correction`,已有提交 `c417e6f` 包含 MinIO 来源和文件传输修复。本轮模板继承测试修正、提示补充和验证文档保留为未提交修改。`doc/system-feature-list.md` 已同步模板继承验证范围,并纠正仍声称封禁公网 `80/443` 的旧描述。管理 Web、CLI、MQTT 和文件传输实现未变化,对应单元测试无需调整;本轮通过真实集成验证应用行为。 已清理本次创建的集成/防火墙测试容器、数据卷、fixture 进程与专用网络,保留最终镜像及脱敏证据。临时 LLM/MCP 凭据和运行凭据文件已删除,扫描当前仓库变更与文本证据未发现这些凭据;清理结果见 `credential-cleanup.json`。既有 `ikkyu` 容器仍运行,原有 stash 未修改。 +最终镜像复测的资源和凭据清理另见 `E:/tmp/daedalus-integration-final-20261009-03/credential-cleanup.json`。本轮未创建新提交或推送,工作区保留在 `fix-firewall-policy-correction`。 + ## 证据位置 本次日志、构建元数据、隔离集成测试配置和容器防火墙探针位于 `E:\tmp\daedalus-build-20261009.01`。`build.log` 记录官方 MinIO 仓库返回 `401`;`build-local-minio.log` 是切换来源前已停止的构建;采用社区镜像的构建记录为 `build-coollabs.log`。`minio-source-checks.log` 和 `minio-provenance.json` 分别记录社区镜像运行检查及构建来源信息。 diff --git a/doc/handoff.md b/doc/handoff.md new file mode 100644 index 0000000..39a8ed6 --- /dev/null +++ b/doc/handoff.md @@ -0,0 +1,35 @@ +# 当前交接 + +更新时间:2026-10-09 + +## 当前进展 + +- 当前工作区为 `fix-firewall-policy-correction`,HEAD 为 `c417e6f`。此前防火墙与版本升级已分别保存到 `fix-firewall-policy-correction` 和 `upgrade-261009`;后续按用户要求回到 fix 分支构建与测试。 +- 最终镜像为 `daedalus-agent-x64:20261009.01`(`linux/amd64`),ID 为 `sha256:f3f98e4ac23214f0fbeab08f522e0f210402809326fa613593c763cc33390ba7`,包含 Coollabs MinIO 来源替换与文件传输防覆盖修复。 +- 最终镜像文件传输专项 21 项通过,覆盖空文件、文本、二进制、8 MiB、中文文件名、同名并发、防覆盖、失败场景与重启持久化。 +- 使用真实 `deepseek-v4-flash` 的最终完整流程通过:43 个步骤中 41 个执行步骤通过、2 个跳过,退出码 `0`。 +- 首轮失败定位为测试创建 Agent 时覆盖了模板 `AGENTS.md`,导致包安装对话缺少模板隔离规则。现已保留模板原文、逐字核验继承内容,并明确包安装记忆路径。第三轮使用新数据卷通过,根记忆路径不存在,两个 sender 标记互不混入。 +- 最新镜像补验 GcExcel MCP:header `token` 核对一致,真实模型顺序调用 `overview`、`search`、`fetch`,工具结果及 `doc_id` 一致性核验通过。补验前快速连续重启触发 OpenClaw 通道启动保护,通过官方 `channels.start` 恢复测试账号后完成验证。 +- 集成辅助契约测试 17 项通过。功能清单已同步模板继承验证范围,并纠正旧公网端口封禁描述;应用实现未变化,管理 Web、CLI、MQTT 单元测试无需调整。 + +## 剩余工作与限制 + +1. 调查短时间多次容器重启被 OpenClaw 记录为 `unclean boot` 并抑制 MQTT 自动启动的原因;本轮只完成测试通道恢复,没有修复实现。 +2. 独立 mock LLM 运行覆盖 MQTT `/stop`、`/steer`、`/tell` 确定性时序;本轮真实模型流程跳过这些场景。 +3. 排查并恢复仓库暂时禁用的包记忆回忆场景。sender 规则通过提示词约束模型行为,本轮结果不代表操作系统级文件隔离。 +4. 升级工作继续参照 `upgrade-261009` 分支的 `doc/handoff.md`:OpenClaw `2026.9.7` 旧配置键迁移和升级镜像构建仍待完成;本轮 fix 镜像保留 OpenClaw `2026.7.1`。 + +## 交付状态 + +- 本轮测试脚本修正、功能清单、验证文档及本交接记录为未提交修改;未新增提交或推送,工作区保留在 fix 分支。 +- 本轮测试容器、数据卷、fixture 进程与临时凭据在收尾时清理,保留脱敏报告和最终镜像;清理证明见下方文件。 +- 既有 `ikkyu` 容器与 `stash@{0}: On dev: handoff-all-work-20261009` 保留。 +- Chrome 按正常运行验收,不要求指定版本。ARM64、生产规模迁移、超大文件及高并发容量不属于本轮验证范围。 + +## 证据 + +- 最终完整报告:`E:/tmp/daedalus-integration-final-20261009-03/runtime/reports/playwright-container-flow-2026-10-09T12-29-01-266Z.json` +- 最新镜像 GcExcel 轨迹核验:`E:/tmp/daedalus-integration-final-20261009-03/gcexcel-verified.json` +- 清理记录:`E:/tmp/daedalus-integration-final-20261009-03/credential-cleanup.json` +- 构建、历史验证和最终文件传输专项:`E:/tmp/daedalus-build-20261009.01/` +- 各镜像验证范围及跳过项:`doc/build-validation-20261009.01.md` diff --git a/doc/install-guide.md b/doc/install-guide.md index 0dcfd34..446b099 100644 --- a/doc/install-guide.md +++ b/doc/install-guide.md @@ -15,7 +15,7 @@ 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)。 +`[版本号]` 请参考项目 Release,例如 `20261009.01`。运行数据保存在 Docker volume `daedalus-agent-data` 中;如需隔离多套环境,请替换为新的 volume 名称。详细说明见 [docker/README.md](./docker/README.md)。 ## 步骤2:登录管理控制台 diff --git a/doc/system-feature-list.md b/doc/system-feature-list.md index 6cb5bbb..e835c8d 100644 --- a/doc/system-feature-list.md +++ b/doc/system-feature-list.md @@ -126,7 +126,7 @@ - [x][T] 持久外部会话 ID 使用 `s-` 加 30 位数字格式;同一用户-Agent 复用会话;会话创建校验 Agent 状态和用户授权。同步调用使用独立的一次性 MQTT 关联 ID,不属于持久外部会话。 - [x][T] 外部调用在每次解析业务系统时重新校验会话有效性、Agent 状态、业务系统状态和 Agent 业务系统策略。 - [x][T] 内部 CUI API:查询可用 Agent 目录;内部 invoke API:解析授权业务系统、获取业务系统 base URL、记录调用日志;Model Gateway 用量内部 API 写入幂等事件并提供每日配额检查,Runtime Token 的无用户观测事件不计入账本统计;OpenClaw MQTT 插件通过 `llm_output` 落账并在 `before_agent_run` 阻断超限请求,提示明确指出超限用户或数字员工。 -- [x][T] OpenClaw 配置迁移后只保留 `daedalus-gateway` Provider、`http://127.0.0.1:3040/v1` 和 opaque Runtime Token;启动时将旧 OpenAI Provider 密钥迁移到网关加密存储并删除直连 Provider 配置;Agent 出口默认阻断外部 TCP `80/443` 直连模型 Provider。 +- [x][T] OpenClaw 配置迁移后只保留 `daedalus-gateway` Provider、`http://127.0.0.1:3040/v1` 和 opaque Runtime Token;启动时将旧 OpenAI Provider 密钥迁移到网关加密存储并删除直连 Provider 配置。网络出口采用第 1 节的地址策略,公网 `80/443` 放行,不以端口封禁强制模型请求经过网关。 - [x][T] 管理员用量 API:`GET /api/usage/summary` 支持按 Agent、senderId、Agent+senderId、模型和时间窗口聚合;`GET /api/usage/agents/{agentId}/senders` 查询指定 Agent 的 senderId 明细。 - [x][T] API 鉴权分层:管理端登录 Token、服务 Bearer Token、内部请求校验;localhost 请求按配置可走内部通道。 - [x][T] 外部 Cookie 缓存并按 `hzg` Provider 获取;业务系统调用结果写入统一调用日志和 Payload 制品。 @@ -178,7 +178,7 @@ | 管理 Web 前端 | API 客户端鉴权/超时/错误归一化、目录同步门控、重启轮询、格式化、浏览器覆盖契约、员工与模板列表操作精简、内置模板编辑、名称/编辑详情入口、详情 tabs 保存流程、员工概览主模型/备选模型/每日配额快捷修改及账本用量刷新、用户每日限额/今日用量/状态列表、用户新增/编辑/删除、Dashboard 用户/Agent 用量表 10 条分页与零用量过滤、员工技能/业务系统/MCP 与模板设置布局一致及内置 Skill 隐藏、编辑页不展示同步调用 URL、模板文本文件切换后的编辑器可见性、设置页 API 文档入口、运行凭据重置按钮/确认框及“刷新状态”按钮、Bootstrap 等待版本缓存后再展示设置页版本表 | `packages/daedalus-agent-management-web/src/**/*.test.js`、`packages/daedalus-agent-management-web/src/pages/AgentsPage.test.jsx`、`packages/daedalus-agent-management-web/src/pages/AgentUsersPage.test.jsx`、`packages/daedalus-agent-management-web/src/pages/DashboardPage.test.jsx`、`packages/daedalus-agent-management-web/server/test/runtime-settings-route.test.js`、`packages/daedalus-agent-management-web/server/test/portal-service-alignment.test.js`、`packages/daedalus-agent-management-web/test/browser-coverage.test.js`、`packages/daedalus-agent-management-web/test/run-browser-e2e.js`、`packages/daedalus-agent-management-web/test/integration/website-full.js` | | invoke CLI | 参数、JSON/body/query 来源、Binding schema、socket 协议、超时、大小限制、Provider 错误和调用日志 | `packages/daedalus-agent-apps-invoke-cli/test/` | | MQTT 插件 | 账号配置、旧配置兼容、环境变量覆盖、连接/断线重连、通配符、入站路由、普通消息串行队列、停止与 steer/tell 控制队列、active-run 非阻塞 steer、运行时接收 Promise 未完成时的确认与原 final 投递、原始会话目标、OpenClaw deliver 的 text/kind 无加工透传、出站 envelope、日志、Model Gateway `llm_output` 用量落账、`before_agent_run` 配额阻断和待写入同步 | `extensions/openclaw-plugins/daedalus-agent-mqtt-channel/src/*test.ts`、`extensions/openclaw-plugins/daedalus-agent-mqtt-channel/index.test.ts` | -| Docker 集成 | 登录、版本/同步、Provider/Model/模板/MCP/Agent 生命周期、模板继承、工作区/Skill、Ontology、MQTT `/stop`/`/steer`/`/tell` 的 final 确认及普通队列保留、重启(含排队重启等待和配置生效)、启动钩子、包持久化、文件输出、会话隔离和压测;手动目录同步断言与现有五类目录状态一致,不再要求已移除的 `usage_refresh`;指定镜像使用真实模型 Gateway 路径验证 Agent 实际调用 `integration-mcp__employee_template_lookup` MCP 工具,并验证 MCP deny 投影 | `test/case/integration/`、`test/suite/integration/`、`test/case/stress/`;历史报告 `E:\tmp\daedalus-agent-it-b2f7-fix\reports\playwright-container-flow-2026-09-02T10-07-12-545Z.json`;`20261009.01` 镜像的真实模型流程及跳过项见 `doc/build-validation-20261009.01.md` | +| Docker 集成 | 登录、版本/同步、Provider/Model/模板/MCP/Agent 生命周期、模板继承(逐字核对导入模板的 `AGENTS.md`,避免测试覆盖 sender 隔离规则)、工作区/Skill、Ontology、MQTT `/stop`/`/steer`/`/tell` 的 final 确认及普通队列保留、重启(含排队重启等待和配置生效)、启动钩子、包持久化、文件输出(请求中显式给出当前通道 senderId,并要求本次运行内完成 Skill 的两段式回复:先说明文件、再单独发送 file_output:// URI)、会话隔离和压测;手动目录同步断言与现有五类目录状态一致,不再要求已移除的 `usage_refresh`;指定镜像使用真实模型 Gateway 路径验证 Agent 实际调用 `integration-mcp__employee_template_lookup` MCP 工具,并验证 MCP deny 投影 | `test/case/integration/`、`test/suite/integration/`、`test/case/stress/`;历史报告 `E:\tmp\daedalus-agent-it-b2f7-fix\reports\playwright-container-flow-2026-09-02T10-07-12-545Z.json`;`20261009.01` 镜像的真实模型流程及跳过项见 `doc/build-validation-20261009.01.md` | | Docker 防火墙与真实 MCP | Docker Desktop 双栈容器实测 Agent UID 的 TCP / UDP 地址白名单、旧端口规则与重复跳转清理、无关规则保护、重启后访问、入站响应、既有出站连接撤权,以及公网 HTTP / HTTPS;`deepseek-v4-flash` 经 Model Gateway 驱动 Agent,实际调用带 `token` 请求头的 GcExcel MCP `overview`、`search`、`fetch` 并核对成功结果;后续完整镜像重建通过离线工具检查、启动和服务健康检查、本地 QMD 模型校验、防火墙检查及 Agent 直接调用 MCP;`20261009.01` 完整镜像另通过三轮双栈策略矩阵、Chrome 运行、MinIO S3 读写/桶策略和旧数据迁移冒烟检查 | `doc/firewall-mcp-validation-20261009.md`、`doc/build-validation-20261009.01.md`;历史专项证据 `E:\tmp\daedalus-firewall-mcp-20261009\reports\` 使用本地基础镜像替换启动脚本;完整构建证据 `E:\tmp\daedalus-image-build-20261009\`、`E:\tmp\daedalus-build-20261009.01\`,具体依赖版本和范围见报告 | | Docker 升级与品牌契约 | OpenClaw/Node 版本、镜像所需本仓库 npm 包的构建上下文、MinIO/mc 社区镜像固定版本与摘要、构建入口不再访问旧 MinIO 仓库、插件 seed、迁移、重启保护、持久化 MQTT 账号密码与运行凭据同步、DA_ 环境变量前缀和视觉品牌;执行真实启动函数并模拟规则存储,验证 IPv4 / IPv6 公网全端口放行、内网按地址拦截、DNS 无端口例外、本机及入站响应放行、旧端口规则清理、重复启动去重、白名单撤销后已建立出站连接仍受限,以及配置或规则操作失败时终止启动 | `test/case/upgrade/openclaw-upgrade-contract.test.js`、`test/case/upgrade/agent-firewall.test.js`、`test/common/netfilter-harness.sh`、`test/case/branding/`;入口 `npm --prefix test run test:upgrade-contract` 或 `npm --prefix test run test:firewall` | | MinIO 文件传输专项 | 在隔离应用容器中,以真实 Agent UID 调用内置传输脚本,检查输入/输出、空文件/文本/二进制/8 MiB 文件的字节数和 SHA-256、中文/空格文件名、URI bucket 与完整 key、自动建桶、并发同名传输、强制命名碰撞时拒绝覆盖、失败时无成功输出、sender 路径检查,以及重启持久化 | `test/case/integration/file-transfer-container.cjs`、`test/suite/integration/run-file-transfer-suite.js`;入口 `npm --prefix test run test:file-transfer`;结果与镜像 ID 见 `doc/build-validation-20261009.01.md` | diff --git a/test/case/integration/playwright-flow.js b/test/case/integration/playwright-flow.js index 3b4ac27..51afaa5 100644 --- a/test/case/integration/playwright-flow.js +++ b/test/case/integration/playwright-flow.js @@ -795,15 +795,18 @@ function createRandomOutputFileName() { return `hello-${randomBytes(6).toString("hex")}.txt`; } -function buildFileOutputPrompt(fileName) { +function buildFileOutputPrompt(fileName, senderId) { return [ "Create a text file in the current workspace.", `File name: ${fileName}`, "File content: ping", + `The current MQTT channel senderId is ${senderId}; this is the senderId in the inbound message.`, + `Create the file under this senderId's generated directory and pass --sender-id ${senderId} to the transfer script.`, "", "Then send the file to the user using the daedalus-agent-transfer-file skill.", "Use the built-in scripts/transfer_s3.mjs upload-file command from that skill.", - "The final reply must contain exactly one file_output:// URI and no other text." + "Complete the skill's two-message reply sequence during this request: explain the file first, then immediately send a separate final message containing only the file_output:// URI.", + "Do not stop after saying that a URI will be sent later; send the URI before ending this run." ].join("\n"); } @@ -1241,7 +1244,6 @@ async function createAgent(baseUrl, token) { agent_name: AGENT_ID, template_name: TEMPLATE_NAME, description: "Container integration public demo agent", - docs_content: "# public-demo\n\nContainer integration test agent.\n", tags: ["container-it"], daily_limit: -1, restart: true @@ -1253,11 +1255,23 @@ async function createAgent(baseUrl, token) { return created; } -async function verifyTemplateUsedByAgent(baseUrl, token, agentId) { +async function verifyTemplateUsedByAgent(baseUrl, token, agentId, workspaceDir) { const markdown = await nodeApi(baseUrl, token, "GET", `/api/agents/${encodeURIComponent(AGENT_ID)}/workspace/markdown`, undefined, 60000); const items = markdown?.items || []; assert.ok(items.some((item) => item.path === TEMPLATE_KNOWLEDGE_PATH), `Agent should inherit template knowledge: ${JSON.stringify(items)}`); + // 模板中的隔离规则必须真实进入 Agent 工作区,不能被测试说明覆盖。 + const inheritedAgents = await nodeApi( + baseUrl, + token, + "GET", + `/api/agents/${encodeURIComponent(AGENT_ID)}/workspace/markdown/content?path=AGENTS.md`, + undefined, + 60000 + ); + assert.equal(inheritedAgents.content, fs.readFileSync(path.join(workspaceDir, "AGENTS.md"), "utf8"), + "Agent should preserve the imported template AGENTS.md, including sender isolation rules"); + const inheritedKnowledge = await nodeApi( baseUrl, token, @@ -1277,6 +1291,7 @@ async function verifyTemplateUsedByAgent(baseUrl, token, agentId) { assert.deepEqual(mcpPolicy.mcp_servers.map((server) => server.slug), [MCP_SERVER_SLUG]); return { + agents: { path: inheritedAgents.path, etag: inheritedAgents.etag, matchesTemplate: true }, knowledge: { path: inheritedKnowledge.path, etag: inheritedKnowledge.etag }, inheritedSkill, mcpPolicy @@ -2641,9 +2656,10 @@ async function externalAgentFileOutput(baseUrl, serviceToken, envJsonPath, targe baseUrl, serviceToken, envJsonPath, - text: buildFileOutputPrompt(fileName), + text: buildFileOutputPrompt(fileName, DEFAULT_EXTERNAL_SENDER_ID), timeoutMs, matcher: (message) => Boolean(extractFileOutputUri(readReplyMessageText(message))), + senderId: DEFAULT_EXTERNAL_SENDER_ID, clientIdPrefix: "daedalus-agent-flow-file-output" }); @@ -2780,6 +2796,8 @@ async function agentPageInstallPackages(page, baseUrl, timeoutMs) { "pyfiglet --version", "PIP_ROOT=\"${PIP_PACKAGE_ROOT:-/var/daedalus-agent/.openclaw/packages/pip}\"; \"$PIP_ROOT/venv/bin/python3\" -c \"import humanize; assert humanize.__version__ == '4.9.0'\"", "", + "记忆隔离要求:禁止创建或使用 workspace 根目录的 MEMORY.md、memory/ 或 memory// 路径。", + "只允许把这条事实写入当前 senderId 目录下今天的 memory/YYYY-MM-DD.md;如果无法确认 senderId,停止写入。", "安装成功后,请把这条事实写入当前 senderId 目录下今天的 memory/YYYY-MM-DD.md:npm=is-odd@3.0.1;uv=pyfiglet@1.0.2;pip=humanize@4.9.0。", "完成后只回复这一行:npm=is-odd@3.0.1; uv=pyfiglet@1.0.2; pip=humanize@4.9.0" ].join("\n"), @@ -3944,8 +3962,9 @@ async function main() { const restartTargets = parseRestartTargets(); if (focus !== "agent-rename-sync") { - report.templateUsage = await verifyTemplateUsedByAgent(baseUrl, token, report.agent.id); + report.templateUsage = await verifyTemplateUsedByAgent(baseUrl, token, report.agent.id, artifacts.workspaceDir); addStep(report, "4a.use-employee-template-for-agent", true, { + agents: report.templateUsage.agents, knowledge: report.templateUsage.knowledge, inheritedSkill: report.templateUsage.inheritedSkill.slug, mcpMode: report.templateUsage.mcpPolicy.mode, -- Gitee