diff --git a/.gitignore b/.gitignore index a4d32b1..ab0ea47 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ node_modules/ __pycache__/ .opencode/node_modules/ -.opencode/skills/ .local.env .vscode docker-compose.yml diff --git a/.opencode/agents/instruction.md b/.opencode/agents/instruction.md index 5e961ab..fd8e1b4 100644 --- a/.opencode/agents/instruction.md +++ b/.opencode/agents/instruction.md @@ -9,6 +9,7 @@ model: deepseek/deepseek-v4-flash - 工具执行期间不输出过程说明,全部完成后只回复最终结果 - 直接给出结论、关键数据和可执行建议,默认仅展示最重要的 Top 5;数据不足或任务失败时简要说明影响和下一步 +- 多步骤或预计超过 30 秒的任务,开始时使用 `todowrite` 给用户展示计划,并在每个里程碑更新状态;简单问答不创建计划 ## 工作流生命周期 @@ -49,6 +50,9 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际 4. 避免直接用 `Read` 或 `cat` 读取结果文件,尤其是大文件;优先用 `head`/`tail`/`rg` 截断查看,或用 Python 只向 stdout 输出精简 JSON,避免大文件冲击 stdin/stdout 5. 无可用数据时不得编造结果 6. 禁止使用 `task` 子代理;当前前端无法观测和干预子代理的具体工作过程 +7. Bash 始终运行在当前对话专属沙箱中:只能读写当前对话目录、读取 skills 和 Python 环境,不能联网,也不能访问 `/app` 源码、密钥、其他对话或全局 `tool-output` +8. 需要文件输入时,调用 `tjwater_cli(..., store_result=true)`,使用返回的 `data_file.file_path`;不要把 JSON 手工写到 `/tmp` +9. `store_render_ref` 的输入必须是 `{metadata, location: {file_path}, data}` 包装 JSON;若旧脚本输出裸数据,先在当前对话目录内包装,且 `location.file_path` 必须等于包装文件的绝对路径 ## 工作流沉淀(skill_manager) @@ -63,30 +67,12 @@ Skills 树是**动态生长的**——工作流不是预置的,而是从实际 目录入口也通过 `skill_manager` 维护:更新 `skills/workflow/SKILL.md` 时使用 `write_skill(skill_path="workflow", ...)`,更新根入口 `skills/SKILL.md` 时使用 `write_skill(skill_path="__root__", ...)`。 -**脚本编写要求——优先用 pipe 串联**: +**脚本编写要求——数据获取与本地分析分离**: -workflow skill 脚本应尽量用 shell pipe 在一次 subprocess 调用中串联多个 CLI 命令。减少 tool calling 次数,提升执行效率。 - -```python -import subprocess, os - -# env dict 仅用于当前子进程,不污染 os.environ,多用户安全 -env = {**os.environ, - "TJWATER_SERVER": auth["server"], - "TJWATER_ACCESS_TOKEN": auth["access_token"], ...} - -# 好:一次 shell 调用,pipe 串联 -cmd = "tjwater-cli net list-pipes | jq '...' | xargs tjwater-cli analysis calc" -result = subprocess.run(cmd, shell=True, env=env, capture_output=True, text=True) - -# 差:多次 subprocess.run -step1 = subprocess.run(["tjwater-cli", "net", "list-pipes"], ...) -step2 = subprocess.run(["tjwater-cli", "analysis", "calc"], ...) -``` - -管道场景下用子进程隔离的 env dict 传认证,释放 stdin 给管道数据流。不修改全局 `os.environ`。认证 JSON 由内部桥接注入,脚本不硬编码。 - -CLI **不增加** `--input/--output`,数据转换由 `jq`/`xargs` 在 shell 管道中完成。 +- 后端数据只能由 `tjwater_cli` 工具获取;认证与网络请求留在 Agent 主进程 +- 分析脚本接收 `data_file.file_path`,只处理当前对话目录内的本地文件 +- 多份互不依赖的数据可并行调用 `tjwater_cli(..., store_result=true)`,随后在一次沙箱 Bash 中运行 Python 分析 +- 脚本输出文件必须写入当前工作目录;禁止使用 `/tmp`、全局 `tool-output` 或硬编码认证环境变量 **触发时机**: - 用户明确说"保存/沉淀/记录工作流" diff --git a/.opencode/skills/tjwater-cli/SKILL.md b/.opencode/skills/tjwater-cli/SKILL.md new file mode 100644 index 0000000..865c431 --- /dev/null +++ b/.opencode/skills/tjwater-cli/SKILL.md @@ -0,0 +1,186 @@ +--- +name: tjwater-cli +description: tjwater-cli 命令行工具使用说明,涵盖命令发现、输出格式、命令族、错误处理及最佳实践。 +--- + +# tjwater-cli 使用说明 + +## 概述 + +`tjwater-cli` 是 TJWater 供水管网系统的命令行工具,用于与后端服务交互,支持数据查询、分析和工程操作。所有输出统一为 JSON 格式。 + +## 工具调用 + +通过 `tjwater_cli` 工具执行 CLI 命令: + +```json +{ + "reason": "说明调用原因", + "command": "project list", + "timeout": 120, + "store_result": false +} +``` + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `reason` | string | 是 | 调用原因 | +| `command` | string | 是 | CLI 子命令(不含二进制路径和 `--auth-context`) | +| `timeout` | number | 否 | 超时秒数,默认 120,大结果集建议 300+ | +| `store_result` | boolean | 否 | 强制保存到当前对话目录并返回 `data_file.file_path`;分析脚本需要文件输入时设为 true | + +认证上下文(token、server、project、network)由内部桥接自动注入,无需手动传参。 + +## 命令发现 + +Agent 通过 `help` 动态发现可用命令,而非依赖硬编码清单。 + +**重要:命令分为两类——触发动作与数据获取。** + +- **触发动作**(`simulation`、`analysis`):向服务端发起计算请求,返回任务状态/ID,**不直接返回分析结果**。 +- **数据获取**(`data timeseries`):所有计算结果(仿真压力、分析指标等)的唯一数据出口,需在触发动作完成后调用。 + +``` +simulation/analysis → 触发计算 → 返回状态/任务ID + ↓ + data timeseries → 获取计算结果 +``` + +通过 `help` 发现命令: + +``` +tjwater-cli help → 一级命令清单(含 commands 数组和 summary) +tjwater-cli help data timeseries → data timeseries 的子命令与参数详情 +tjwater-cli help simulation → simulation 的子命令与参数详情 +tjwater-cli help COMMAND → 子命令与参数详情 +``` + +`help` 返回 JSON 格式,Agent 可直接解析 `commands` 数组识别可用能力。 + +**严禁猜测命令或参数!** 所有命令路径、子命令和参数(名称、类型、必填/可选)均以 `help` 输出为准。执行任何命令前,必须先通过 `help` 确认其存在及参数签名,禁止凭经验拼写。 + +### 已知命令族 + +| 命令族 | 典型子命令 | 用途 | +|------|-----------|------| +| `project` | `list`, `db-health` | 项目管理、数据库健康检查 | +| `data` | `timeseries realtime links / nodes`, `timeseries scada query` | **时序数据查询**(实时/SCADA),所有分析结果的唯一获取渠道 | +| `simulation` | 通过 `help simulation` 发现 | **触发水力仿真计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) | +| `analysis` | 通过 `help analysis` 发现 | **触发分析计算**(执行成功返回状态,实际结果需走 `data timeseries` 获取) | +| `net` | `list-pipes` | 管网拓扑查询 | +| `help` | (无子命令) | 命令发现入口 | + +> 完整命令清单始终以 `tjwater-cli help` 实时输出为准。 + +## 输出格式 + +所有命令返回统一 JSON 结构: + +```json +{ + "schema_version": "tjwater-cli/v1", + "ok": true, + "data": { ... }, + "error": { + "code": "COMMAND_NOT_FOUND", + "message": "详细错误描述" + } +} +``` + +- `ok: true` — 成功,数据在 `data` 字段 +- `ok: false` — 失败,检查 `error.code` 和 `error.message` + +### 大结果集处理 + +超过内联阈值的结果不会终止 CLI,而是保存到当前对话的 `tool-data/` 目录并返回: + +```json +{ + "ok": true, + "data_file": { + "file_path": "/app/data/conversation-workspaces/conversation-.../tool-data/cli-....json", + "bytes": 38700000, + "content_type": "application/json" + } +} +``` + +禁止完整读取超大结果集。优先使用: +- 采样/截断参数(如 `--limit`、`--offset`) +- `--field` 按字段过滤 +- `store_result=true` 后用沙箱 Python 脚本按字段读取 + +## 错误码速查 + +| error.code | 含义 | 来源 | 处理建议 | +|------|------|------|------| +| `UNAUTHENTICATED` | 缺少 access token | CLI `core.py:162` | 检查认证上下文注入 | +| `SERVER_ERROR` | 后端返回 error 状态 | CLI `core.py:400` | 记录 `request_id`,结合后端日志排查 | +| `REQUEST_TIMEOUT` | CLI 请求后端超时 | CLI `core.py:445` | 增大 `timeout` 参数或检查后端负载 | +| `TIMEOUT` | bridge 层进程超时 | Agent `server.ts:199` | 增大 `tjwater_cli` 的 `timeout` 参数 | +| `COMMAND_NOT_FOUND` | 命令/子命令不存在 | CLI `helping.py:300` | 执行 `help` 确认命令拼写 | +| `INPUT_NOT_FOUND` | `--input` 文件不存在 | CLI `core.py:243` | 检查文件路径 | +| `REQUEST_FAILED` | 网络连接失败 | CLI `core.py:453` | 检查服务端可达性 | +| `AUTH_CONTEXT_INVALID` | 认证上下文格式错误 | CLI `core.py:111` | 检查 auth headers 格式 | + +## 最佳实践 + +1. **禁止猜测命令** — 执行任何命令前必须先 `tjwater_cli(command="help ...")` 确认命令存在及参数签名,参数均已写在 help 中,禁止凭经验拼写 +2. **reason 必填** — 每次调用必须说明具体理由 +3. **触发后取数据** — `simulation`/`analysis` 仅触发计算,结果必须从 `data timeseries` 获取,勿将触发返回的状态信息当作分析结果 +4. **文件分析** — workflow 脚本需要文件时使用 `store_result=true`,不得从 Bash 直接联网调用 CLI +5. **结果验证** — 始终检查 `ok` 字段,失败时先处理错误码再重试 +6. **大结果集** — 优先过滤/采样,不要一次性拉取全部数据 +7. **模拟时长控制** — 模拟(`simulation`)或方案模拟的 `--duration` 不宜过长,建议每次仿真时间跨度控制在一小时以内,避免计算耗时过长或结果数据量过大 + +## 示例 + +### 查询所有实时节点数据 +```json +{ + "reason": "获取最近1小时内全部节点的实时数据", + "command": "data timeseries realtime nodes --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00" +} +``` +> `data timeseries realtime nodes` 仅接受 `--start-time` / `--end-time`,返回全量节点数据。 + +### 按节点查询方案时序字段 +```json +{ + "reason": "查询节点 J-001 最近1小时的压力数据", + "command": "data timeseries scheme node-field --node J-001 --field pressure --start-time 2026-06-03T08:00:00+08:00 --end-time 2026-06-03T09:00:00+08:00" +} +``` + +### 查询 SCADA 时序数据 +```json +{ + "reason": "查询 SCADA 设备 170490 在指定时间范围的 monitored_value", + "command": "data timeseries scada query --device-id 170490 --field monitored_value --start-time 2026-06-02T00:00:00+08:00 --end-time 2026-06-03T00:00:00+08:00" +} +``` + +### 触发仿真并获取结果 + +通常系统会自动跑仿真,建议**先尝试获取结果**,若无数据再触发仿真: + +```json +// step 1: 先尝试获取仿真结果 +{ + "reason": "尝试获取节点 J-001 09:00 时刻的仿真压力", + "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" +} +// step 2: 若 step 1 无数据(ok: false 或 data 为空),触发仿真 +{ + "reason": "无已有仿真结果,触发1小时水力仿真", + "command": "simulation run --start-time 2026-06-03T08:00:00+08:00 --duration 60" +} +// step 3: 仿真完成后,再次获取结果(同 step 1) +{ + "reason": "获取仿真结果中节点 J-001 09:00 时刻的压力", + "command": "data timeseries realtime simulation-by-id-time --id J-001 --type junction --time 2026-06-03T09:00:00+08:00" +} +``` + +`simulation run` 仅接受 `--start-time`(RFC3339,必填)和 `--duration`(整数分钟,必填)。 diff --git a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md new file mode 100644 index 0000000..52529b7 --- /dev/null +++ b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/SKILL.md @@ -0,0 +1,150 @@ +--- +name: hydraulic-bottleneck-analysis +description: 基于实时水力数据的管网水力瓶颈识别与改造建议。复合评分法(流速×水头损失)定位瓶颈管段,输出分级改造方案。 +--- + +# 水力瓶颈分析工作流 + +## 概述 + +本工作流通过复合评分法(流速分级 × 水头损失百分位)从全管网管道中识别水力瓶颈管段,并结合节点压力、管径、粗糙系数给出分级改造建议。 + +适用场景:管网运行评估、管网改造优先级排序、泵站阀站运行诊断。 + +## 评分方法论 + +### 双维度复合评分 + +| 维度 | 判定标准 | 分值 | +|------|----------|------| +| **流速** | >3.0 m/s = 极危 | 3 | +| | 2.0–3.0 m/s = 严重 | 2 | +| | 1.5–2.0 m/s = 偏高 | 1 | +| | <1.5 m/s = 正常 | 0 | +| **水头损失** | >P90 = 严重 | 2 | +| | P80–P90 = 中度 | 1 | +| | 130 | — | 管壁粗糙,建议内衬修复 | + +> ⚠️ **setting 字段无效**:`data timeseries realtime links` 返回的 `setting` 字段为无效值,不可用于阀门节流或水泵出口判定。若需确定阀门/泵状态,应通过 `network get-link-properties` 逐条查询。 + +## 数据依赖 + +| 步骤 | 命令 | 数据量 | 超时 | 关键字段 | +|------|------|--------|------|----------| +| ① 管道属性 | `network get-all-pipes-properties` | ~11.7MB / 91K条 | 120s | id, node1, node2, length, diameter, roughness | +| ② 管道水力 | `data timeseries realtime links --start-time T --end-time T+15min` | ~39MB / 182K条 | 300s | id, flow, velocity, headloss, time | +| ③ 节点压力 | `data timeseries realtime nodes --start-time T --end-time T+15min` | ~28MB / 176K条 | 300s | id, pressure, time | + +> **时间窗口说明**:模拟步长 15 分钟,查询窗口取 `T~T+15min` 可覆盖 1–2 个时间步。脚本内部按 `--target-time` 精确筛选目标时刻的记录。 + +> **大结果集处理**:三份调用都使用 `store_result=true`,结果保存到当前对话的 `tool-data/` 目录。脚本直接读取每次返回的 `data_file.file_path`,不得访问全局 `tool-output/`。 + +## 执行步骤 + +### 第 1 步:拉取三份数据 + +并行发起 3 个 `tjwater_cli` 调用(互不依赖): + +```bash +# ① 管道静态属性 +tjwater_cli(command="network get-all-pipes-properties", timeout=120, store_result=true) + +# ② 目标时刻管道水力数据 +tjwater_cli(command="data timeseries realtime links --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true) + +# ③ 目标时刻节点压力数据 +tjwater_cli(command="data timeseries realtime nodes --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true) +``` + +### 第 2 步:运行分析脚本 + +```bash +python3 /scripts/bottleneck_analysis.py \ + --pipe-props \ + --realtime \ + --node-pressures \ + --target-time '2026-04-01T08:00:00+08:00' \ + --top 50 \ + 2>./bottleneck_report.txt +``` + +**脚本参数**: +- `--pipe-props`:管道属性 JSON 文件路径(必填) +- `--realtime`:实时管道水力 JSON 文件路径(必填) +- `--node-pressures`:实时节点压力 JSON 文件路径(必填) +- `--target-time`:目标时刻 ISO8601(必填,如 `2026-04-01T08:00:00+08:00`) +- `--top`:输出 Top N 瓶颈管段(默认 100) + +**输出**: +- **stderr**:文本摘要 + Top 30 表格(可重定向到文件查看) +- **stdout**:完整 JSON 结果,包含 `summary` 和 `top_bottlenecks`(含每条的建议 `suggestions`) + +### 第 3 步:结果解读与分类 + +按瓶颈严重程度和根因分类: + +| 类别 | 判定条件 | 优先处理方案 | +|------|----------|--------------| +| 🔴 极危 | 流速>3.0m/s 且 水损>P90 | 检查模型/扩容/分流 | +| 🔵 串联瓶颈 | 连续多根瓶颈管段共享节点 | 统一规划扩径,一次性解除 | +| 🟣 低压区 | 端节点压力<20m | 扩径 + 评估中途加压 | +| 🟡 高粗糙度 | roughness>130 的瓶颈管段 | 内衬修复降阻 | +| 🟢 短管异常 | length<10m 且 headloss>P95 | 检查模型是否存在局部阻塞 | + +### 第 4 步:可视化(可选) + +1. **地图定位**:将 Top N 瓶颈管段用 `locate_features` 高亮到地图 +2. **统计图表**:用 `show_chart` 展示管径分布柱状图 + 流速等级柱状图 +3. **样式渲染**:可对 pipes 图层按 velocity 或 headloss 属性做分层设色 + +## 改造建议生成逻辑 + +脚本自动为每条瓶颈管段生成建议,规则如下: + +``` +if velocity > 2.0 → "流速X.XXm/s过高,需扩容或分流" +elif velocity > 1.5 → "流速X.XXm/s偏高" + +if headloss > P95 → "水头损失X.XXm(>P95)严重超标" +elif headloss > P90 → "水头损失X.XXm(>P90)" + +if diameter < 100 → "管径Xmm偏小,建议扩径至≥150mm" +elif diameter < 200 → "管径Xmm,评估扩容至250-300mm" + +if roughness > 130 → "粗糙系数X偏高,建议内衬修复" + +if min_pressure < 20 → "端节点压力X.Xm(<20m),低压区域需增压" +elif min_pressure < 25 → "端节点压力X.Xm偏低" + +if length < 0.01 and headloss > 0.5 → "短管高水损,检查是否存在模型异常或局部阻塞" +``` + +## 参考数据规模(实测) + +基于 91,000 管段 / 88,000 节点规模的管网模型: + +| 指标 | 实测值 | +|------|--------| +| 管道属性数据量 | 91,052 条 / ~11.7MB | +| 实时管道数据量 | 182,108 条(2步)/ ~38.7MB | +| 实时节点数据量 | 175,814 条(2步)/ ~28.2MB | +| 分析脚本处理时间 | 约 10-20 秒 | +| 典型瓶颈数量 | 50-100 条(占总管数 0.05%-0.1%) | + +## 已知限制 + +- 水头损失百分位阈值(P80/P90)基于**全管网**统计,如果管网上游存在极端水损(如 400m+),会拉高整体 P 值,导致部分中高水损管段被漏判。极端场景下可考虑对水损做分位数裁剪(如排除 >P99.9 的离群值)后再计算 P80/P90。 +- **setting 字段不可用**:`data timeseries realtime links` 返回的 `setting` 值为无效数据,本工作流已移除所有基于 setting 的阀门节流 / 水泵出口判定。若需要此类判定,应通过 `network get-link-properties` 逐条获取属性中的 setting 作为替代。 +- 脚本读取全量 JSON 入内存,峰值内存约 200-300MB,需确保执行环境有足够内存。 diff --git a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/references/data-sources.md b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/references/data-sources.md new file mode 100644 index 0000000..af92777 --- /dev/null +++ b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/references/data-sources.md @@ -0,0 +1,80 @@ +# 数据源与字段映射 + +## CLI 命令清单 + +### ① 管道静态属性 + +```bash +tjwater-cli network get-all-pipes-properties +``` + +返回字段: + +| 字段 | 类型 | 说明 | 分析用途 | +|------|------|------|----------| +| id | string | 管段 ID | 主键,关联水力数据 | +| node1 | string | 起始节点 ID | 拓扑,定位压力 | +| node2 | string | 终止节点 ID | 拓扑,定位压力 | +| length | float | 管长 (m) | 短管高水损检测 | +| diameter | int | 管径 (mm) | 管径分级,扩容建议 | +| roughness | int | 粗糙系数 | 内衬修复判定 | +| minor_loss | float | 局部水头损失系数 | 暂未使用 | +| status | string | OPEN/CLOSED | 管道状态 | + +### ② 管道实时水力 + +```bash +tjwater-cli data timeseries realtime links --start-time --end-time +``` + +返回字段: + +| 字段 | 类型 | 说明 | 分析用途 | +|------|------|------|----------| +| time | string | 时间戳 ISO8601 | 筛选目标时刻 | +| id | string | 管段 ID | 关联静态属性 | +| flow | float | 流量 | 辅助参考 | +| velocity | float | 流速 (m/s) | **核心评分指标** | +| headloss | float | 水头损失 (m) | **核心评分指标** | +| setting | float | ⚠️ 无效值 | 时序 API 返回的 setting 为无效值,不可用于判定 | +| friction | float | 摩擦系数 | 暂未使用 | +| quality | float | 水质 | 暂未使用 | +| reaction | float | 反应速率 | 暂未使用 | +| status | string | OPEN/CLOSED | 管道状态 | + +### ③ 节点实时压力 + +```bash +tjwater-cli data timeseries realtime nodes --start-time --end-time +``` + +返回字段: + +| 字段 | 类型 | 说明 | 分析用途 | +|------|------|------|----------| +| time | string | 时间戳 ISO8601 | 筛选目标时刻 | +| id | string | 节点 ID | 关联管段端点 | +| pressure | float | 压力 (m) | **低压判定** | +| total_head | float | 总水头 (m) | 含高程信息 | +| actual_demand | float | 实际需水量 | 暂未使用 | +| quality | float | 水质 | 暂未使用 | + +## 数据合并逻辑 + +``` +管道属性 (pipe_map[id]) ←─id─→ 实时水力 (rt[time==TT]) + │ + node1, node2 + │ + ↓ + 节点压力 (node_pressure[id]) +``` + +合并时以**实时水力数据为主表**,左联管道属性,再通过 node1/node2 查找两端压力。 + +## 时间处理 + +- 模拟步长:15 分钟 +- 查询窗口建议:T 到 T+15min(覆盖 1-2 步) +- 脚本内精确筛选:`r.get('time') == TT` 严格匹配字符串 +- 若目标时刻(如 08:00)无数据,需先触发 `simulation run --start-time T --duration 15` diff --git a/.opencode/skills/workflow/hydraulic-bottleneck-analysis/scripts/bottleneck_analysis.py b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/scripts/bottleneck_analysis.py new file mode 100644 index 0000000..97fb352 --- /dev/null +++ b/.opencode/skills/workflow/hydraulic-bottleneck-analysis/scripts/bottleneck_analysis.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +""" +水力瓶颈管道综合分析 +数据源:管道属性 + 实时水力 + 节点压力 → 复合评分 → 改造建议 +注:realtime links 的 setting 字段为无效值,已移除所有基于 setting 的判定。 +""" +import json, sys, math, argparse +from collections import defaultdict + +VELOCITY_THRESHOLDS = {"critical": 3.0, "severe": 2.0, "high": 1.5} + +def pct(d, v): + if not d: return 0 + k = (v/100)*(len(d)-1); f=math.floor(k); c=math.ceil(k) + return d[f] if f==c else d[f]*(c-k)+d[c]*(k-f) + +def load_json(path): + with open(path, encoding='utf-8') as f: raw = f.read() + return json.loads(raw[raw.find('{'):]) + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--pipe-props', required=True) + ap.add_argument('--realtime', required=True) + ap.add_argument('--node-pressures', required=True) + ap.add_argument('--target-time', required=True) + ap.add_argument('--top', type=int, default=100) + args = ap.parse_args() + TT = args.target_time + + # 1. Load + print("[1/5] Loading data...", file=sys.stderr) + props = load_json(args.pipe_props).get('data', []) + pipe_map = {p['id']: p for p in props} + + rt = load_json(args.realtime).get('data', []) + rt = [r for r in rt if r.get('time') == TT] + + np = load_json(args.node_pressures).get('data', []) + np = [n for n in np if n.get('time') == TT] + node_pressure = {n['id']: n.get('pressure', n.get('value',0)) for n in np} + print(f" Pipes: {len(props)}, Realtime: {len(rt)}, Node pressures: {len(node_pressure)}", file=sys.stderr) + + # 2. Merge + print("[2/5] Merging...", file=sys.stderr) + merged = [] + for r in rt: + pid = r['id']; prop = pipe_map.get(pid) + if prop: + merged.append({**prop, **r, '_prop_id': prop['id'], '_rt_id': r['id']}) + print(f" Merged: {len(merged)}", file=sys.stderr) + + # 3. Score + print("[3/5] Scoring...", file=sys.stderr) + hl_vals = sorted([abs(m['headloss']) for m in merged]) + p80 = pct(hl_vals, 80); p90 = pct(hl_vals, 90); p95 = pct(hl_vals, 95) + + scored = [] + for m in merged: + vel = abs(m['velocity']); hl = abs(m['headloss']) + diam = m.get('diameter', 0) + length = m.get('length', 0); roughness = m.get('roughness', 0) + n1, n2 = m['node1'], m['node2'] + pid = m['id'] + + vs = 3 if vel>=3 else (2 if vel>=2 else (1 if vel>=1.5 else 0)) + vg = "极危" if vs==3 else ("严重" if vs==2 else ("偏高" if vs==1 else "正常")) + hs = 2 if hl>p90 else (1 if hl>p80 else 0) + hg = "严重" if hs==2 else ("中度" if hs==1 else "正常") + composite = vs + hs + is_bn = (vs>=1 and hs>=1) or (vs>=2) + + # Node pressures + p1 = node_pressure.get(n1); p2 = node_pressure.get(n2) + min_p = min(p1, p2) if (p1 is not None and p2 is not None) else None + + # Pipe category + if diam <= 50: dcat = "微型(≤50mm)" + elif diam <= 100: dcat = "小型(51-100mm)" + elif diam <= 200: dcat = "中型(101-200mm)" + elif diam <= 400: dcat = "大型(201-400mm)" + elif diam <= 800: dcat = "主干(401-800mm)" + else: dcat = "干管(>800mm)" + + scored.append({ + 'id': pid, 'node1': n1, 'node2': n2, + 'velocity': round(vel, 4), 'flow': round(m.get('flow',0), 4), + 'headloss': round(hl, 4), 'length': round(length, 4), + 'diameter': int(diam), 'roughness': int(roughness), + 'vel_grade': vg, 'hl_grade': hg, 'composite_score': composite, + 'is_bottleneck': is_bn, + 'n1_pressure': round(p1, 2) if p1 is not None else None, + 'n2_pressure': round(p2, 2) if p2 is not None else None, + 'min_pressure': round(min_p, 2) if min_p is not None else None, + 'diam_cat': dcat, + }) + + # 4. Filter & sort + print("[4/5] Filtering bottlenecks...", file=sys.stderr) + bn = [s for s in scored if s['is_bottleneck']] + bn.sort(key=lambda x: (-x['composite_score'], -x['velocity'])) + + critical = [b for b in bn if b['vel_grade']=='极危'] + severe = [b for b in bn if b['vel_grade']=='严重'] + high_vel = [b for b in bn if b['vel_grade']=='偏高'] + + low_p = sum(1 for b in bn if b['min_pressure'] and b['min_pressure'] < 20) + lowish_p = sum(1 for b in bn if b['min_pressure'] and 20 <= b['min_pressure'] < 25) + high_rough = sum(1 for b in bn if b['roughness'] > 130) + + # Diam distribution + dd = defaultdict(int) + for b in bn: + d = b['diameter'] + if d <= 50: dd['≤50mm']+=1 + elif d <= 100: dd['51-100mm']+=1 + elif d <= 200: dd['101-200mm']+=1 + elif d <= 400: dd['201-400mm']+=1 + elif d <= 800: dd['401-800mm']+=1 + else: dd['>800mm']+=1 + + # 5. Output + print("[5/5] Generating report...", file=sys.stderr) + + # Print text summary to stderr + print(f"\n{'='*70}", file=sys.stderr) + print(f" 水力瓶颈分析报告 - {TT}", file=sys.stderr) + print(f"{'='*70}", file=sys.stderr) + print(f" 总管道数: {len(scored)}", file=sys.stderr) + print(f" 瓶颈管道: {len(bn)} ({len(bn)/len(scored)*100:.1f}%)", file=sys.stderr) + print(f" 极危(>3.0m/s): {len(critical)} 条", file=sys.stderr) + print(f" 严重(2.0-3.0): {len(severe)} 条", file=sys.stderr) + print(f" 偏高(1.5-2.0): {len(high_vel)} 条", file=sys.stderr) + print(f"\n 水头损失阈值: P80={p80:.4f}m P90={p90:.4f}m P95={p95:.4f}m", file=sys.stderr) + print(f" 平均={sum(hl_vals)/len(hl_vals):.4f}m 最大={max(hl_vals):.4f}m", file=sys.stderr) + print(f"\n 低压节点(<20m): {low_p} 条, 偏低(20-25m): {lowish_p} 条", file=sys.stderr) + print(f" 高粗糙度(>130): {high_rough} 条", file=sys.stderr) + + print(f"\n 瓶颈管径分布:", file=sys.stderr) + for cat in ['≤50mm','51-100mm','101-200mm','201-400mm','401-800mm','>800mm']: + print(f" {cat}: {dd.get(cat,0)} 条", file=sys.stderr) + + # Text table (Top 30) to stderr + print(f"\n{'='*120}", file=sys.stderr) + print(f"{'排名':<5} {'管道ID':<10} {'流速(m/s)':<10} {'水损(m)':<10} {'管径(mm)':<9} {'管长(km)':<10} {'评分':<4} {'压力1':<8} {'压力2':<8} {'管径类别':<18}", file=sys.stderr) + print('-'*120, file=sys.stderr) + for i, b in enumerate(bn[:30]): + p1s = f"{b['n1_pressure']:.1f}" if b['n1_pressure'] is not None else "-" + p2s = f"{b['n2_pressure']:.1f}" if b['n2_pressure'] is not None else "-" + print(f"{i+1:<5} {b['id']:<10} {b['velocity']:<10.4f} {b['headloss']:<10.2f} {b['diameter']:<9} {b['length']:<10.4f} {b['composite_score']:<4} {p1s:<8} {p2s:<8} {b['diam_cat']:<18}", file=sys.stderr) + + # Generate suggestions for each bottleneck + for b in bn: + sug = [] + if b['velocity'] > 2.0: + sug.append(f"流速{b['velocity']:.2f}m/s过高,需扩容或分流") + elif b['velocity'] > 1.5: + sug.append(f"流速{b['velocity']:.2f}m/s偏高") + hl = b['headloss'] + if hl > p95: + sug.append(f"水头损失{hl:.2f}m(>P95)严重超标") + elif hl > p90: + sug.append(f"水头损失{hl:.2f}m(>P90)") + if b['diameter'] < 100: + sug.append(f"管径{b['diameter']}mm偏小,建议扩径至≥150mm") + elif b['diameter'] < 200: + sug.append(f"管径{b['diameter']}mm,评估扩容至250-300mm") + if b['roughness'] > 130: + sug.append(f"粗糙系数{b['roughness']}偏高,建议内衬修复") + if b['min_pressure'] is not None and b['min_pressure'] < 20: + sug.append(f"端节点压力{b['min_pressure']:.1f}m(<20m),低压区域需增压") + elif b['min_pressure'] is not None and b['min_pressure'] < 25: + sug.append(f"端节点压力{b['min_pressure']:.1f}m偏低") + if b['length'] < 0.01 and b['headloss'] > 0.5: + sug.append("短管高水损,检查是否存在模型异常或局部阻塞") + b['suggestions'] = sug + + result = { + 'target_time': TT, + 'summary': { + 'total_pipes': len(scored), + 'bottleneck_count': len(bn), + 'critical': len(critical), 'severe': len(severe), 'high_vel': len(high_vel), + 'low_pressure_nodes': low_p, 'lowish_pressure_nodes': lowish_p, + 'high_roughness_pipes': high_rough, + 'headloss_p80': round(p80,4), 'headloss_p90': round(p90,4), + 'headloss_p95': round(p95,4), + 'diameter_distribution': dict(dd), + }, + 'top_bottlenecks': bn[:args.top], + } + + print(json.dumps(result, ensure_ascii=False, indent=2)) + +if __name__ == '__main__': + main() diff --git a/.opencode/skills/workflow/service-area-analysis/SKILL.md b/.opencode/skills/workflow/service-area-analysis/SKILL.md new file mode 100644 index 0000000..790cf74 --- /dev/null +++ b/.opencode/skills/workflow/service-area-analysis/SKILL.md @@ -0,0 +1,146 @@ +--- +name: service-area-analysis +description: 基于实时水力模拟数据的水源追溯供水服务范围分区。通过管段流量确定水流方向,从水库BFS追溯服务节点,环网/零流量节点用无向拓扑补充分配,输出分区可视化。 +--- + +# 供水服务范围分区工作流 + +## 概述 + +本工作流基于指定时刻的水力模拟结果,通过**流向追溯法**将全部管网节点分配到各水库的服务范围。核心思路:利用管段流量符号判定水流方向,构建有向图从水库逐级追溯,对环网和零流量节点用无向拓扑修正。 + +适用场景:供水服务范围评估、DMA分区规划、多水源供水格局分析、管网调度策略评估。 + +## 分区方法 + +### 第一步:水流方向判定 + +对于每条管段,根据实时流量 `flow` 判定水流方向: + +| flow 值 | 水流方向 | 说明 | +|---------|----------|------| +| `flow > 1e-6` | node1 → node2 | 正向流量 | +| `flow < -1e-6` | node2 → node1 | 反向流量 | +| `|flow| ≤ 1e-6` | 无方向 | 零流量,不参与有向追溯 | + +### 第二步:多源有向BFS + +1. 以每个水库为根节点,沿水流方向执行 BFS +2. 遍历到的节点归属该水库的服务范围 +3. **先到先得**:一个节点首次被访问到的水库即为归属 +4. 预期覆盖 **85–90%** 节点 + +### 第三步:无向拓扑修正 + +有向BFS不可达节点(通常 10–15%)通过无向图邻近性补充分配: + +| 不可达原因 | 说明 | +|------------|------| +| 环状管网 | 水流回路中下游节点反向连回上游,有向遍历被阻断 | +| 零流量管段 | `flow≈0` 的管段无方向,其下游节点断开 | +| 多水源交汇 | 交汇区流向往复,非树状拓扑 | + +### 输出统计 + +每个分区输出: +- `node_count`:分区内节点总数 +- `total_demand`:总需水量(负数=净供水区) +- `avg_pressure`/`min_pressure`/`max_pressure`:压力统计 + +## 数据依赖 + +| 步骤 | 命令 | 数据量 | 超时 | 关键字段 | +|------|------|--------|------|----------| +| ① 管道拓扑 | `network get-all-pipes-properties` | ~11.7MB / 91K条 | 120s | id, node1, node2 | +| ② 水库属性 | `network get-all-reservoirs-properties` | ~小 | 120s | id, links | +| ③ 管段流量 | `data timeseries realtime links --start-time T --end-time T+15min` | ~39MB / 182K条 | 300s | id, flow, time | +| ④ 节点数据 | `data timeseries realtime nodes --start-time T --end-time T+15min` | ~28MB / 176K条 | 300s | id, pressure, actual_demand, time | + +> **时间窗口**:模拟步长 15 分钟,查询 T~T+15min 覆盖 1–2 个时间步。脚本按 `--target-time` 精确筛选。 + +> **文件输入**:四份调用都使用 `store_result=true`,包括结果较小的水库属性。脚本读取每次返回的 `data_file.file_path`;文件都属于当前对话,禁止使用 `/tmp` 或全局 `tool-output/`。 + +## 执行步骤 + +### 第 1 步:并行拉取数据 + +4 个 `tjwater_cli` 调用(互不依赖),可一次发起: + +```bash +# ① 管道静态拓扑 +tjwater_cli(command="network get-all-pipes-properties", timeout=120, store_result=true) + +# ② 水库属性 +tjwater_cli(command="network get-all-reservoirs-properties", timeout=120, store_result=true) + +# ③ 目标时刻管段流量 +tjwater_cli(command="data timeseries realtime links --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true) + +# ④ 目标时刻节点数据 +tjwater_cli(command="data timeseries realtime nodes --start-time 2026-04-01T08:00:00+08:00 --end-time 2026-04-01T08:15:00+08:00", timeout=300, store_result=true) +``` + +### 第 2 步:运行分区脚本 + +```bash +python3 /scripts/service_area_partition.py \ + --pipe-props \ + --reservoirs \ + --links \ + --nodes \ + --target-time '2026-04-01T08:00:00+08:00' \ + --output ./service_area_partition_wrapper.json +``` + +**脚本参数**: + +| 参数 | 说明 | 必填 | +|------|------|------| +| `--pipe-props` | 管道属性 JSON 文件路径 | 是 | +| `--reservoirs` | 水库属性 JSON 文件路径 | 是 | +| `--links` | 实时管段数据 JSON 文件路径 | 是 | +| `--nodes` | 实时节点数据 JSON 文件路径 | 是 | +| `--target-time` | 目标时刻 ISO8601 | 是 | +| `--output` | 分区结果输出路径 | 是 | + +**输出**: +- **stderr**:处理日志 + 各分区统计表格 +- **stdout**:紧凑 JSON 摘要(total_nodes, reservoirs, areas) +- **文件**:符合 `store_render_ref` 要求的 `{metadata, location, data}` 包装 JSON,其中 `data` 包含 `node_area_map`、`area_ids`、`area_colors` 和分析元数据 + +### 第 3 步:前端可视化 + +```bash +# 持久化分区结果 +store_render_ref(file_path=) + +# 渲染节点分区 +render_junctions(render_ref="res-xxxxxxxx-xxxx-xx") + +# 定位水库 +locate_features(ids=[...], feature_type="reservoir") + +# 展示统计图表 +show_chart(title="各水源分区节点数/压力对比", chart_type="bar", ...) +``` + +## 参考数据规模 + +基于 91,000 管段 / 88,000 节点规模的管网模型: + +| 指标 | 实测值 | +|------|--------| +| 管道拓扑数据量 | 91,052 条 | +| 水库数量 | 13 个 | +| 总节点数 | 87,907 | +| 有向BFS分配节点 | ~76,900 (87.5%) | +| 无向修正节点 | ~11,000 (12.5%) | +| 分区覆盖率 | 100% | +| 脚本处理时间 | ~15-30 秒 | +| 峰值内存 | ~400-500MB | + +## 已知限制 + +- **水库顺序敏感**:多源 BFS 中先遍历到的水库优先分配,不同水库启动顺序可能影响边界区域分配结果 +- **单时刻快照**:分区仅反映目标时刻的水力工况,不同时段的泵站启停、阀门切换可能导致分区边界变化 +- **零流量阈值**:`1e-6` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向 diff --git a/.opencode/skills/workflow/service-area-analysis/scripts/service_area_partition.py b/.opencode/skills/workflow/service-area-analysis/scripts/service_area_partition.py new file mode 100644 index 0000000..1a96a6a --- /dev/null +++ b/.opencode/skills/workflow/service-area-analysis/scripts/service_area_partition.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +供水服务范围分析与分区 — 可复用脚本 +基于实时水力数据,从水库沿水流方向追溯服务范围,自动发现水库并分区。 + +用法: + python3 service_area_partition.py \ + --pipe-props pipes.json \ + --reservoirs reservoirs.json \ + --links realtime_links.json \ + --nodes realtime_nodes.json \ + --target-time '2026-04-01T08:00:00+08:00' \ + --output ./service_area_partition_wrapper.json +""" + +import argparse +import json +import os +import sys +from collections import deque, defaultdict + +COLORS = [ + "rgba(31,119,180,0.7)", "rgba(255,127,14,0.7)", "rgba(44,160,44,0.7)", + "rgba(148,103,189,0.7)", "rgba(140,86,75,0.7)", "rgba(227,119,194,0.7)", + "rgba(127,127,127,0.7)", "rgba(188,189,34,0.7)", "rgba(23,190,207,0.7)", + "rgba(174,199,232,0.7)", "rgba(255,152,150,0.7)", "rgba(196,156,148,0.7)", + "rgba(219,64,82,0.7)", "rgba(153,204,153,0.7)", "rgba(255,204,102,0.7)", + "rgba(102,102,204,0.7)", "rgba(204,102,102,0.7)", "rgba(102,204,204,0.7)", + "rgba(204,153,204,0.7)", "rgba(153,153,153,0.7)" +] + +def load_json(path, label): + print(f"Loading {label}...", file=sys.stderr) + with open(path) as f: + return json.load(f) + +def main(): + parser = argparse.ArgumentParser(description="供水服务范围分区分析") + parser.add_argument("--pipe-props", required=True, help="管道属性 JSON 文件") + parser.add_argument("--reservoirs", required=True, help="水库属性 JSON 文件") + parser.add_argument("--links", required=True, help="实时管段数据 JSON 文件") + parser.add_argument("--nodes", required=True, help="实时节点数据 JSON 文件") + parser.add_argument("--target-time", required=True, help="目标时刻 ISO8601") + parser.add_argument("--output", required=True, help="分区结果输出 JSON 路径") + args = parser.parse_args() + + # --- Step 1: Load pipe topology --- + pdata = load_json(args.pipe_props, "pipe topology")["data"] + pipe_topology = {} + node_neighbors = defaultdict(set) + for p in pdata: + pid = p["id"] + n1, n2 = p["node1"], p["node2"] + pipe_topology[pid] = (n1, n2) + node_neighbors[n1].add(n2) + node_neighbors[n2].add(n1) + print(f" {len(pdata)} pipes, {len(node_neighbors)} unique nodes", file=sys.stderr) + + # --- Step 2: Discover reservoirs --- + rdata = load_json(args.reservoirs, "reservoirs")["data"] + reservoirs = [r["id"] for r in rdata] + print(f" {len(reservoirs)} reservoirs: {reservoirs}", file=sys.stderr) + + # --- Step 3: Load link flow at target time --- + ldata = load_json(args.links, "link flows")["data"] + target_links = [l for l in ldata if l["time"] == args.target_time] + flow_direction = {} + pipe_flow = {} + for l in target_links: + lid = l["id"] + flow_val = l["flow"] + pipe_flow[lid] = abs(flow_val) + if lid in pipe_topology: + n1, n2 = pipe_topology[lid] + if flow_val > 1e-6: + flow_direction[lid] = (n1, n2) + elif flow_val < -1e-6: + flow_direction[lid] = (n2, n1) + nonzero = len(flow_direction) + print(f" {len(target_links)} link records, {nonzero} with non-zero flow", file=sys.stderr) + + # --- Step 4: Load node data at target time --- + ndata = load_json(args.nodes, "node data")["data"] + target_nodes = [n for n in ndata if n["time"] == args.target_time] + node_pressure = {} + node_demand = {} + for n in target_nodes: + nid = n["id"] + node_pressure[nid] = n.get("pressure", 0) + node_demand[nid] = n.get("actual_demand", 0) + print(f" {len(target_nodes)} nodes", file=sys.stderr) + + # --- Step 5: Build downstream graph --- + downstream = defaultdict(set) + for _lid, (up, dn) in flow_direction.items(): + downstream[up].add(dn) + print(f" downstream graph: {len(downstream)} source nodes", file=sys.stderr) + + # --- Step 6: Multi-source BFS along flow direction --- + reservoir_area = {} + node_served_by = {} + queue = deque() + for rid in reservoirs: + reservoir_area[rid] = {rid} + node_served_by[rid] = rid + queue.append((rid, rid, 0)) + + while queue: + node, source, dist = queue.popleft() + for neighbor in downstream.get(node, set()): + if neighbor not in node_served_by: + node_served_by[neighbor] = source + reservoir_area[source].add(neighbor) + queue.append((neighbor, source, dist + 1)) + + directed_count = len(node_served_by) + unassigned = set(node_pressure.keys()) - set(node_served_by.keys()) + print(f" flow-tracing assigned: {directed_count}, unassigned: {len(unassigned)}", file=sys.stderr) + + # --- Step 7: Undirected proximity fallback --- + if unassigned: + print(" running proximity fallback...", file=sys.stderr) + ua_queue = deque() + ua_visited = {} + for nid, src in node_served_by.items(): + ua_visited[nid] = src + ua_queue.append((nid, src, 0)) + + while ua_queue: + node, source, dist = ua_queue.popleft() + for neighbor in node_neighbors.get(node, set()): + if neighbor not in ua_visited: + ua_visited[neighbor] = source + reservoir_area[source].add(neighbor) + ua_queue.append((neighbor, source, dist + 1)) + + still = set(node_pressure.keys()) - set(ua_visited.keys()) + if still: + print(f" WARNING: {len(still)} nodes still unassigned", file=sys.stderr) + node_served_by = ua_visited + + # --- Step 8: Compute statistics --- + print(f"\n=== 供水服务范围分区统计 ===\n", file=sys.stderr) + area_stats = [] + for rid in reservoirs: + nodes_in = reservoir_area.get(rid, set()) + pressures = [node_pressure[n] for n in nodes_in if n in node_pressure] + demands = [node_demand[n] for n in nodes_in if n in node_demand] + area_stats.append({ + "reservoir": rid, + "node_count": len(nodes_in), + "total_demand": round(sum(demands), 4), + "avg_pressure": round(sum(pressures)/len(pressures), 2) if pressures else 0, + "min_pressure": round(min(pressures), 2) if pressures else 0, + "max_pressure": round(max(pressures), 2) if pressures else 0, + }) + + area_stats.sort(key=lambda x: x["node_count"], reverse=True) + for s in area_stats: + print(f" 水源 {s['reservoir']:>8s}: {s['node_count']:>6d} 节点 | " + f"总需水={s['total_demand']:.2f} | " + f"压力 avg={s['avg_pressure']:.1f}m [{s['min_pressure']:.1f}–{s['max_pressure']:.1f}m]", + file=sys.stderr) + + # --- Step 9: Assign colors and write output --- + area_colors = {} + for i, rid in enumerate(reservoirs): + area_colors[rid] = COLORS[i % len(COLORS)] + + output = { + "node_area_map": node_served_by, + "area_ids": reservoirs, + "area_colors": area_colors, + "metadata": { + "analysis_time": args.target_time, + "total_nodes": len(node_served_by), + "reservoir_count": len(reservoirs), + "directed_assigned": directed_count, + "proximity_assigned": len(node_served_by) - directed_count, + "method": "flow-direction-source-tracing" + } + } + + absolute_output = os.path.abspath(args.output) + wrapper = { + "metadata": { + "generated_by": "service_area_partition.py", + "schema_version": 1, + }, + "location": {"file_path": absolute_output}, + "data": output, + } + with open(absolute_output, "w", encoding="utf-8") as f: + json.dump(wrapper, f, ensure_ascii=False) + + summary = { + "total_nodes": len(node_served_by), + "reservoirs": len(reservoirs), + "areas": area_stats, + "output_file": absolute_output + } + print(json.dumps(summary, ensure_ascii=False)) + +if __name__ == "__main__": + main() diff --git a/.opencode/tools/bash.ts b/.opencode/tools/bash.ts new file mode 100644 index 0000000..272e27d --- /dev/null +++ b/.opencode/tools/bash.ts @@ -0,0 +1,53 @@ +import { tool } from "@opencode-ai/plugin"; + +const internalBaseUrl = + process.env.TJWATER_AGENT_INTERNAL_BASE_URL ?? "http://127.0.0.1:8787"; +const internalToken = process.env.TJWATER_AGENT_INTERNAL_TOKEN ?? ""; + +export default tool({ + description: + "在当前对话专属的 Landlock 沙箱中运行 Shell 命令。只能读写当前对话工作区,不能访问网络、密钥、其他对话或应用源码。", + args: { + command: tool.schema.string().describe("需要在沙箱中执行的 Shell 命令。"), + description: tool.schema + .string() + .optional() + .describe("面向用户的简短命令说明。"), + timeout: tool.schema + .number() + .optional() + .describe("超时秒数,默认 120,最大 1800。"), + }, + async execute(args, context) { + await context.ask({ + permission: "bash", + patterns: [args.command], + always: [args.command], + metadata: { + command: args.command, + ...(args.description ? { description: args.description } : {}), + }, + }); + const response = await fetch( + `${internalBaseUrl}/internal/tools/sandbox-shell`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-agent-internal-token": internalToken, + }, + body: JSON.stringify({ + session_id: context.sessionID, + command: args.command, + description: args.description, + timeout: args.timeout, + }), + }, + ); + const text = await response.text(); + if (!response.ok) { + throw new Error(text); + } + return text; + }, +}); diff --git a/.opencode/tools/tjwater_cli.ts b/.opencode/tools/tjwater_cli.ts index 462d0eb..d85c02e 100644 --- a/.opencode/tools/tjwater_cli.ts +++ b/.opencode/tools/tjwater_cli.ts @@ -20,6 +20,12 @@ export default tool({ .number() .optional() .describe("超时秒数,默认 120。大结果集建议设 300+。"), + store_result: tool.schema + .boolean() + .optional() + .describe( + "是否强制把结果保存到当前对话工作区并返回 data_file。分析脚本需要文件输入时设为 true。", + ), }, async execute(args, context) { const response = await fetch( @@ -34,6 +40,7 @@ export default tool({ session_id: context.sessionID, reason: args.reason, command: args.command, + store_result: args.store_result, timeout: args.timeout, }), }, diff --git a/AGENTS.md b/AGENTS.md index 6367ec2..bcc3cc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -38,4 +38,4 @@ PRs should describe runtime behavior changes, list `bun run check` and any test Do not commit `.env`, logs, session transcripts, generated result references, or `node_modules/`. Keep registry and deploy credentials in Gitea secrets. -Automatic approval for `glob` and `grep` must remain limited to canonical paths inside an explicit safe workspace subtree. Broad workspace-root searches, symlink escapes, external paths, and `.env`, `data/`, or `logs/` targets must stay interactive. +Automatic approval for `glob` and `grep` must remain limited to canonical paths inside an explicit safe workspace subtree. Broad source-workspace searches, symlink escapes, external paths, and `.env`, ordinary `data/`, or `logs/` targets must stay interactive. The current session's canonical `data/conversation-workspaces//` directory is the only `data/` exception; access must still reject symlinks and every other conversation directory. diff --git a/Dockerfile b/Dockerfile index ee1c207..d28d03b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,7 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \ apt-get update && apt-get install -y --no-install-recommends \ curl \ jq \ + libseccomp2 \ unzip \ python3 \ python3-venv && \ @@ -43,6 +44,8 @@ RUN if [ -n "${UBUNTU_APT_MIRROR}" ]; then \ rich \ ipython \ pytest && \ + (getent group 10001 >/dev/null || groupadd --gid 10001 tjwater-sandbox) && \ + (getent passwd 10001 >/dev/null || useradd --uid 10001 --gid 10001 --no-create-home --shell /usr/sbin/nologin tjwater-sandbox) && \ rm -rf /var/lib/apt/lists/* FROM base AS deps @@ -59,9 +62,10 @@ WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules COPY package.json bun.lock ./ -COPY tsconfig.json opencode.json README.md .gitignore ./ +COPY tsconfig.json opencode.json README.md .gitignore Dockerfile ./ COPY src ./src COPY cli ./cli +COPY scripts ./scripts COPY .opencode ./.opencode RUN bun run check diff --git a/README.md b/README.md index 22e3453..2f4e921 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,8 @@ data/ 本地运行时数据,禁止提交 logs/ 本地日志,禁止提交 ``` +仓库跟踪 `.opencode/skills/` 中经过评审的默认工作流基线;部署环境仍可通过持久化卷保留 `skill_manager` 在运行中沉淀的增量内容。默认基线不得包含真实客户数据、认证信息或本地执行产物。 + ## 本地开发 项目使用 Bun: @@ -86,13 +88,15 @@ TJWATER_API_BASE_URL=http://127.0.0.1:8000 `opencode.json` 已启用 `experimental.continue_loop_on_deny`。用户拒绝权限请求后,OpenCode V1 会把拒绝结果交还给 Agent,让其尝试无需该权限的替代方案,而不是直接结束本轮执行。 -前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,其他权限请求逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill,以及真实路径位于工作区安全子树且不涉及 `.env`、`data/`、`logs/` 的 glob/grep;工作区根目录的宽泛搜索仍需确认。“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。 +前端提供三种整体权限模式:“请求批准”只执行 OpenCode 明确允许的白名单,Shell 和写操作逐次交给用户确认;“自动批准”额外自动放行低风险业务工具、skill、沙箱 Shell,以及真实路径位于当前 conversation workspace 内且通过 realpath/symlink 校验的 read/edit/glob/grep;“始终允许”自动放行当前对话中所有未被 OpenCode 明确禁止的权限请求。自动放行统一使用单次批准,切换整体模式后立即恢复对应策略,不会写入持久授权。 -单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。任意外部目录默认仍由静态配置禁止;`.env` 的直接访问以及通过结构化读写工具访问普通 `data/`、`logs/` 路径仍保持禁止。真实聊天会话使用 `data/conversation-workspaces/<随机目录>/` 作为独立工作目录,结果导入只能读取当前对话目录。普通 `rm <文件>` 仍可审批执行,常见的 `rm -rf`/`rm -fr` 递归强制删除形式会被静态拒绝。 +单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。任意外部目录默认仍由静态配置禁止;`.env`、普通 `data/`、`logs/` 和其他对话目录保持禁止。真实聊天会话使用 `data/conversation-workspaces/<随机目录>/` 作为独立工作目录。普通 `rm <文件>`、`rmdir` 和非强制递归删除可在沙箱内执行,`rm -rf`/`rm -fr` 及等价的递归强制删除形式会在执行前拒绝。 + +OpenCode 的内置 Bash 由同名自定义工具覆盖。命令经内部鉴权路由进入独立子进程,切换到专用非 root UID 后应用 Landlock 文件规则和 seccomp 网络规则:当前 conversation workspace 可读写,系统/Python/skills 只读,其他应用文件、其他对话和全局 `tool-output` 不可见;IPv4/IPv6 TCP 与 UDP socket 均被拒绝。启动时会探测 Landlock ABI(要求 ≥4)和 libseccomp,失败时 Agent 直接启动失败,不会回退到未沙箱化 Shell。Shell 环境不包含模型 key、内部 token 或用户 access token,`HOME`、`TMPDIR` 和 Python 缓存均位于当前对话目录。 `store_render_ref` 只会从当前对话绑定的工作目录导入包装格式 JSON;工作区根目录固定为项目内的 `./data/conversation-workspaces`,以确保 OpenCode 能继续发现项目配置和工具。文件必须包含 `metadata`、`location.file_path` 和 `data`,且真实路径不能越出当前对话目录;单文件默认上限为 128 MiB,成功导入后只删除这一份源包装文件。升级前已经存在的会话没有独立工作目录,需要新建对话后才能使用该导入能力。 -CLI 桥接层对 stdout 设置独立的 128 MiB 硬上限(`MAX_CLI_OUTPUT_BYTES`);stderr 最多保留 256 KiB(`MAX_CLI_STDERR_BYTES`),超出后截断但不会终止 CLI。`MAX_INLINE_RESULT_BYTES`(默认 12000 字节)只控制 OpenCode 的内联阈值,较大结果由 OpenCode 写入标准 `tool-output` 目录。Agent 启动时及后续定期清理其中超过 `RESULT_REF_TTL_HOURS`(默认 7 天)的 `tool_*` 文件。 +CLI 桥接层对 stdout 设置独立的 128 MiB 硬上限(`MAX_CLI_OUTPUT_BYTES`);stderr 最多保留 256 KiB(`MAX_CLI_STDERR_BYTES`),超出后截断但不会终止 CLI。`MAX_INLINE_RESULT_BYTES`(默认 12000 字节)仅决定内联还是落盘:较大结果写入当前对话的 `tool-data/` 并返回 `data_file.file_path`,不会因为超过 12000 字节杀掉 CLI;分析脚本需要文件输入时可由 `tjwater_cli(store_result=true)` 强制落盘小结果。会话暂存数据不自动删除。OpenCode 自身为其他工具生成的全局 `tool-output` 仍按 `RESULT_REF_TTL_HOURS`(默认 7 天)清理,但沙箱命令不能访问该目录。 ## 配置与安全 diff --git a/scripts/landlock_sandbox.py b/scripts/landlock_sandbox.py new file mode 100644 index 0000000..b185ad8 --- /dev/null +++ b/scripts/landlock_sandbox.py @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""Apply a Landlock + seccomp policy, drop privileges, then exec one shell command.""" + +from __future__ import annotations + +import argparse +import ctypes +import ctypes.util +import errno +import json +import os +import platform +import sys +from pathlib import Path + + +LANDLOCK_CREATE_RULESET_VERSION = 1 +LANDLOCK_RULE_PATH_BENEATH = 1 + +ACCESS_FS_EXECUTE = 1 << 0 +ACCESS_FS_WRITE_FILE = 1 << 1 +ACCESS_FS_READ_FILE = 1 << 2 +ACCESS_FS_READ_DIR = 1 << 3 +ACCESS_FS_REMOVE_DIR = 1 << 4 +ACCESS_FS_REMOVE_FILE = 1 << 5 +ACCESS_FS_MAKE_CHAR = 1 << 6 +ACCESS_FS_MAKE_DIR = 1 << 7 +ACCESS_FS_MAKE_REG = 1 << 8 +ACCESS_FS_MAKE_SOCK = 1 << 9 +ACCESS_FS_MAKE_FIFO = 1 << 10 +ACCESS_FS_MAKE_BLOCK = 1 << 11 +ACCESS_FS_MAKE_SYM = 1 << 12 +ACCESS_FS_REFER = 1 << 13 +ACCESS_FS_TRUNCATE = 1 << 14 + +ACCESS_NET_CONNECT_TCP = 1 << 0 +ACCESS_NET_BIND_TCP = 1 << 1 + +READ_ACCESS = ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE | ACCESS_FS_READ_DIR +WRITE_ACCESS = ( + ACCESS_FS_WRITE_FILE + | ACCESS_FS_REMOVE_DIR + | ACCESS_FS_REMOVE_FILE + | ACCESS_FS_MAKE_CHAR + | ACCESS_FS_MAKE_DIR + | ACCESS_FS_MAKE_REG + | ACCESS_FS_MAKE_SOCK + | ACCESS_FS_MAKE_FIFO + | ACCESS_FS_MAKE_BLOCK + | ACCESS_FS_MAKE_SYM + | ACCESS_FS_REFER + | ACCESS_FS_TRUNCATE +) +HANDLED_FS_ACCESS = READ_ACCESS | WRITE_ACCESS + +PR_SET_NO_NEW_PRIVS = 38 +AF_INET = 2 +AF_INET6 = 10 +SCMP_ACT_ALLOW = 0x7FFF0000 +SCMP_ACT_ERRNO = 0x00050000 | errno.EPERM +SCMP_CMP_EQ = 4 + + +class RulesetAttr(ctypes.Structure): + _fields_ = [ + ("handled_access_fs", ctypes.c_uint64), + ("handled_access_net", ctypes.c_uint64), + ] + + +class PathBeneathAttr(ctypes.Structure): + _fields_ = [ + ("allowed_access", ctypes.c_uint64), + ("parent_fd", ctypes.c_int32), + ] + + +class ScmpArgCmp(ctypes.Structure): + _fields_ = [ + ("arg", ctypes.c_uint32), + ("op", ctypes.c_uint32), + ("datum_a", ctypes.c_uint64), + ("datum_b", ctypes.c_uint64), + ] + + +def syscall_numbers() -> tuple[int, int, int]: + machine = platform.machine().lower() + if machine not in {"x86_64", "amd64", "aarch64", "arm64"}: + raise RuntimeError(f"unsupported architecture for Landlock syscalls: {machine}") + return 444, 445, 446 + + +def checked_syscall(libc: ctypes.CDLL, number: int, *args: object) -> int: + result = int(libc.syscall(number, *args)) + if result < 0: + error_number = ctypes.get_errno() + raise OSError(error_number, os.strerror(error_number)) + return result + + +def get_landlock_abi(libc: ctypes.CDLL) -> int: + create_ruleset, _, _ = syscall_numbers() + return checked_syscall( + libc, + create_ruleset, + ctypes.c_void_p(), + ctypes.c_size_t(0), + ctypes.c_uint32(LANDLOCK_CREATE_RULESET_VERSION), + ) + + +def load_seccomp() -> ctypes.CDLL: + library_name = ctypes.util.find_library("seccomp") or "libseccomp.so.2" + library = ctypes.CDLL(library_name, use_errno=True) + library.seccomp_init.argtypes = [ctypes.c_uint32] + library.seccomp_init.restype = ctypes.c_void_p + library.seccomp_release.argtypes = [ctypes.c_void_p] + library.seccomp_syscall_resolve_name.argtypes = [ctypes.c_char_p] + library.seccomp_syscall_resolve_name.restype = ctypes.c_int + library.seccomp_rule_add_array.argtypes = [ + ctypes.c_void_p, + ctypes.c_uint32, + ctypes.c_int, + ctypes.c_uint, + ctypes.POINTER(ScmpArgCmp), + ] + library.seccomp_rule_add_array.restype = ctypes.c_int + library.seccomp_load.argtypes = [ctypes.c_void_p] + library.seccomp_load.restype = ctypes.c_int + return library + + +def add_path_rule( + libc: ctypes.CDLL, + add_rule_number: int, + ruleset_fd: int, + path: str, + access: int, +) -> None: + resolved_path = os.path.realpath(path) + if not os.path.exists(resolved_path): + return + path_fd = os.open(resolved_path, os.O_PATH | os.O_CLOEXEC) + try: + allowed_access = access + if not Path(resolved_path).is_dir(): + allowed_access &= ACCESS_FS_EXECUTE | ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE | ACCESS_FS_TRUNCATE + attribute = PathBeneathAttr( + allowed_access=allowed_access, + parent_fd=path_fd, + ) + checked_syscall( + libc, + add_rule_number, + ctypes.c_int(ruleset_fd), + ctypes.c_int(LANDLOCK_RULE_PATH_BENEATH), + ctypes.byref(attribute), + ctypes.c_uint32(0), + ) + finally: + os.close(path_fd) + + +def build_landlock_ruleset( + libc: ctypes.CDLL, + abi: int, + workspace: str, + read_only_paths: list[str], +) -> int: + create_ruleset, add_rule, _ = syscall_numbers() + handled_network = ( + ACCESS_NET_CONNECT_TCP | ACCESS_NET_BIND_TCP if abi >= 4 else 0 + ) + ruleset_attribute = RulesetAttr( + handled_access_fs=HANDLED_FS_ACCESS, + handled_access_net=handled_network, + ) + ruleset_fd = checked_syscall( + libc, + create_ruleset, + ctypes.byref(ruleset_attribute), + ctypes.sizeof(ruleset_attribute), + ctypes.c_uint32(0), + ) + try: + add_path_rule( + libc, + add_rule, + ruleset_fd, + workspace, + HANDLED_FS_ACCESS, + ) + for path in read_only_paths: + add_path_rule(libc, add_rule, ruleset_fd, path, READ_ACCESS) + for device_path, access in ( + ("/dev/null", ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE), + ("/dev/zero", ACCESS_FS_READ_FILE | ACCESS_FS_WRITE_FILE), + ("/dev/urandom", ACCESS_FS_READ_FILE), + ("/dev/random", ACCESS_FS_READ_FILE), + ): + add_path_rule(libc, add_rule, ruleset_fd, device_path, access) + except Exception: + os.close(ruleset_fd) + raise + return ruleset_fd + + +def install_seccomp_network_filter(library: ctypes.CDLL) -> None: + context = library.seccomp_init(SCMP_ACT_ALLOW) + if not context: + raise RuntimeError("seccomp_init failed") + try: + socket_syscall = library.seccomp_syscall_resolve_name(b"socket") + if socket_syscall < 0: + raise RuntimeError("could not resolve socket syscall") + for domain in (AF_INET, AF_INET6): + comparison = ScmpArgCmp( + arg=0, + op=SCMP_CMP_EQ, + datum_a=domain, + datum_b=0, + ) + result = library.seccomp_rule_add_array( + context, + SCMP_ACT_ERRNO, + socket_syscall, + 1, + ctypes.byref(comparison), + ) + if result != 0: + raise OSError(-result, os.strerror(-result)) + result = library.seccomp_load(context) + if result != 0: + raise OSError(-result, os.strerror(-result)) + finally: + library.seccomp_release(context) + + +def set_no_new_privileges(libc: ctypes.CDLL) -> None: + result = libc.prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) + if result != 0: + error_number = ctypes.get_errno() + raise OSError(error_number, os.strerror(error_number)) + + +def restrict_process( + workspace: str, + read_only_paths: list[str], + uid: int, + gid: int, +) -> int: + libc = ctypes.CDLL(None, use_errno=True) + libc.syscall.restype = ctypes.c_long + libc.prctl.restype = ctypes.c_int + seccomp = load_seccomp() + abi = get_landlock_abi(libc) + if abi < 4: + raise RuntimeError(f"Landlock ABI 4 or newer is required; detected ABI {abi}") + + ruleset_fd = build_landlock_ruleset( + libc, + abi, + workspace, + read_only_paths, + ) + try: + if os.geteuid() == 0: + os.setgroups([]) + os.setgid(gid) + os.setuid(uid) + set_no_new_privileges(libc) + _, _, restrict_self = syscall_numbers() + checked_syscall( + libc, + restrict_self, + ctypes.c_int(ruleset_fd), + ctypes.c_uint32(0), + ) + install_seccomp_network_filter(seccomp) + finally: + os.close(ruleset_fd) + return abi + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--probe", action="store_true") + parser.add_argument("--workspace") + parser.add_argument("--read-only", action="append", default=[]) + parser.add_argument("--uid", type=int, default=10_001) + parser.add_argument("--gid", type=int, default=10_001) + parser.add_argument("--command") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + libc = ctypes.CDLL(None, use_errno=True) + libc.syscall.restype = ctypes.c_long + if args.probe: + abi = get_landlock_abi(libc) + load_seccomp() + print(json.dumps({"ok": True, "landlock_abi": abi, "seccomp": True})) + return 0 if abi >= 4 else 1 + + if not args.workspace or args.command is None: + raise RuntimeError("workspace and command are required") + workspace = os.path.realpath(args.workspace) + if not os.path.isdir(workspace): + raise RuntimeError("workspace must be an existing directory") + os.chdir(workspace) + restrict_process(workspace, args.read_only, args.uid, args.gid) + os.execve("/bin/bash", ["bash", "-c", args.command], dict(os.environ)) + return 127 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except Exception as error: + print( + json.dumps( + { + "ok": False, + "error": "SANDBOX_UNAVAILABLE", + "message": str(error), + } + ), + file=sys.stderr, + ) + raise SystemExit(125) diff --git a/src/results/resolver.ts b/src/results/resolver.ts index 9fff4a1..1abddc7 100644 --- a/src/results/resolver.ts +++ b/src/results/resolver.ts @@ -1,6 +1,9 @@ -import { lstat, realpath, stat } from "node:fs/promises"; -import { isAbsolute, relative } from "node:path"; +import { stat } from "node:fs/promises"; +import { + resolveConversationWorkspace, + resolveExistingPathInsideRoot, +} from "../runtime/conversationWorkspace.js"; import { readJsonFile, removeFileIfExists } from "../utils/fileStore.js"; import { type ResultReferenceKind, @@ -76,7 +79,7 @@ export class ResultReferenceResolver { input.workspaceDirectory, this.importRoot, ); - const resolvedFilePath = await resolvePathInsideRoot( + const resolvedFilePath = await resolveExistingPathInsideRoot( filePath, resolvedWorkspaceDirectory, "render payload file must be inside the current conversation workspace", @@ -197,54 +200,6 @@ export const extractRenderJunctionPayload = ( }; }; -const resolveConversationWorkspace = async ( - workspaceDirectory: string, - importRoot: string, -) => { - const workspaceLinkStat = await lstat(workspaceDirectory); - if (workspaceLinkStat.isSymbolicLink()) { - throw new Error("conversation workspace must not be a symbolic link"); - } - const resolvedImportRoot = await realpath(importRoot); - const resolvedWorkspaceDirectory = await resolvePathInsideRoot( - workspaceDirectory, - resolvedImportRoot, - "conversation workspace must be inside RESULT_REF_IMPORT_DIR", - ); - const relativeWorkspace = relative(resolvedImportRoot, resolvedWorkspaceDirectory); - if (!relativeWorkspace || relativeWorkspace.includes("/") || relativeWorkspace.includes("\\")) { - throw new Error("conversation workspace must be a direct child of RESULT_REF_IMPORT_DIR"); - } - const workspaceStat = await stat(resolvedWorkspaceDirectory); - if (!workspaceStat.isDirectory()) { - throw new Error("conversation workspace must point to a directory"); - } - return resolvedWorkspaceDirectory; -}; - -const resolvePathInsideRoot = async ( - filePath: string, - rootPath: string, - outsideMessage = "render payload file must be inside RESULT_REF_IMPORT_DIR", -) => { - if (!isAbsolute(filePath)) { - throw new Error("render payload file_path must be absolute"); - } - const [resolvedFilePath, resolvedRootPath] = await Promise.all([ - realpath(filePath), - realpath(rootPath), - ]); - const relativePath = relative(resolvedRootPath, resolvedFilePath); - if ( - relativePath === ".." || - relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || - isAbsolute(relativePath) - ) { - throw new Error(outsideMessage); - } - return resolvedFilePath; -}; - const normalizeDataForKind = ( kind: ResultReferenceKind, data: unknown, diff --git a/src/routes/chatPermissionPolicy.ts b/src/routes/chatPermissionPolicy.ts index 7ea172a..edcc344 100644 --- a/src/routes/chatPermissionPolicy.ts +++ b/src/routes/chatPermissionPolicy.ts @@ -1,5 +1,5 @@ -import { lstatSync, readdirSync, realpathSync } from "node:fs"; -import { isAbsolute, relative, resolve, sep } from "node:path"; +import { existsSync, lstatSync, readdirSync, realpathSync } from "node:fs"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; export type ApprovalMode = "request" | "auto" | "always"; @@ -46,6 +46,14 @@ export const canAutoApprovePermission = ( return isSafeWorkspaceSearch(normalized, context); } + if (normalized === "bash") { + return isConversationWorkspace(context.workspaceRoot); + } + + if (normalized === "read" || normalized === "edit" || normalized === "write") { + return isSafeConversationFileAccess(context); + } + if (lowRiskToolPermissions.has(normalized)) { return true; } @@ -71,19 +79,6 @@ export const resolvePermissionApproval = ( } as const; } - if ( - approvalMode === "always" && - normalizePermission(permission) === "bash" && - containsPotentialRemoveCommand(context) - ) { - return { - autoApprove: false, - autoReject: false, - title: "等待删除命令确认", - detail: "删除命令不会由始终允许模式代为批准,请确认本次具体操作。", - } as const; - } - if (approvalMode === "always") { return { autoApprove: true, @@ -204,22 +199,6 @@ const isDirectRecursiveForceRemove = ( }); }; -const containsPotentialRemoveCommand = ( - context: PermissionApprovalContext, -): boolean => { - const command = - typeof context.metadata?.command === "string" - ? context.metadata.command - : context.patterns?.join("\n"); - if (!command) { - return false; - } - const normalized = command - .replace(/\$\{[^}]*\}|\$[A-Za-z_][A-Za-z0-9_]*/gu, "") - .replace(/["'\\]/gu, ""); - return /(^|[^A-Za-z0-9_])(?:[^\s/]+\/)*rm(?=$|[^A-Za-z0-9_])/iu.test(normalized); -}; - const splitShellCommandSegments = (command: string): string[] => command.split(/&&|\|\||[;|()\n]/u); @@ -281,13 +260,14 @@ const isSafeWorkspaceSearch = ( isAbsolute(expression) || containsParentTraversal(expression) || containsAmbiguousGlobSyntax(expression) || - containsProtectedPath(expression), + containsProtectedPath(expression, isConversationWorkspace(root)), ) ) { return false; } - if (!relativePath) { + const conversationWorkspace = isConversationWorkspace(root); + if (!relativePath && !conversationWorkspace) { if (permission !== "glob") { return false; } @@ -304,16 +284,19 @@ const isSafeWorkspaceSearch = ( } return ( - relativePath !== "" && relativePath !== ".." && !relativePath.startsWith(`..${sep}`) && !isAbsolute(relativePath) && - !containsProtectedPath(relativePath) && - isSafeSearchTarget(searchRoot, relativePath) + !containsProtectedPath(relativePath, conversationWorkspace) && + isSafeSearchTarget(searchRoot, relativePath, conversationWorkspace) ); }; -const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean => { +const isSafeSearchTarget = ( + searchRoot: string, + relativePath: string, + conversationWorkspace: boolean, +): boolean => { try { const target = lstatSync(searchRoot); if (target.isFile()) { @@ -323,7 +306,10 @@ const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean = return false; } const topLevelName = relativePath.split(sep)[0]; - if (!topLevelName || !lowRiskSearchRootNames.has(topLevelName)) { + if ( + !conversationWorkspace && + (!topLevelName || !lowRiskSearchRootNames.has(topLevelName)) + ) { return false; } @@ -331,7 +317,10 @@ const isSafeSearchTarget = (searchRoot: string, relativePath: string): boolean = while (pending.length > 0) { const directory = pending.pop()!; for (const entry of readdirSync(directory, { withFileTypes: true })) { - if (entry.isSymbolicLink() || containsProtectedPath(entry.name)) { + if ( + entry.isSymbolicLink() || + containsProtectedPath(entry.name, conversationWorkspace) + ) { return false; } if (entry.isDirectory()) { @@ -361,10 +350,71 @@ const containsParentTraversal = (value: string): boolean => const containsAmbiguousGlobSyntax = (value: string): boolean => /[?[\]{}()!+@\\]/.test(value); -const containsProtectedPath = (value: string): boolean => { +const containsProtectedPath = ( + value: string, + conversationWorkspace = false, +): boolean => { const normalized = value.replaceAll("\\", "/").toLowerCase(); return ( normalized.includes(".env") || - /(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized) + (!conversationWorkspace && + /(?:^|[^a-z0-9_-])(?:data|logs)(?:$|[^a-z0-9_-])/.test(normalized)) ); }; + +const isConversationWorkspace = (workspaceRoot: string | undefined): boolean => { + if (!workspaceRoot?.trim()) return false; + try { + const root = realpathSync.native(resolve(workspaceRoot)); + return ( + basename(dirname(root)) === "conversation-workspaces" && + basename(root).startsWith("conversation-") && + lstatSync(root).isDirectory() + ); + } catch { + return false; + } +}; + +const isSafeConversationFileAccess = ( + context: PermissionApprovalContext, +): boolean => { + if (!isConversationWorkspace(context.workspaceRoot)) return false; + const requestedPath = [ + context.metadata?.path, + context.metadata?.file_path, + context.metadata?.filePath, + context.metadata?.filepath, + context.metadata?.file, + context.patterns?.[0], + ].find((value): value is string => typeof value === "string" && value.trim().length > 0); + if (!requestedPath || /[*?[\]{}]/.test(requestedPath)) return false; + try { + const root = realpathSync.native(resolve(context.workspaceRoot!)); + const target = resolve(root, requestedPath); + let existingPath = target; + while (!existsSync(existingPath)) { + const parent = dirname(existingPath); + if (parent === existingPath) return false; + existingPath = parent; + } + const resolvedExistingPath = realpathSync.native(existingPath); + const relativeExisting = relative(root, resolvedExistingPath); + if ( + relativeExisting === ".." || + relativeExisting.startsWith(`..${sep}`) || + isAbsolute(relativeExisting) + ) { + return false; + } + const relativeTarget = relative(root, target); + return ( + relativeTarget !== ".." && + !relativeTarget.startsWith(`..${sep}`) && + !isAbsolute(relativeTarget) && + !containsProtectedPath(relativeTarget, true) + ); + } catch { + return false; + } +}; diff --git a/src/routes/chatStream.ts b/src/routes/chatStream.ts index a5dc3dd..51a5634 100644 --- a/src/routes/chatStream.ts +++ b/src/routes/chatStream.ts @@ -399,7 +399,7 @@ export const streamPromptResponse = async ({ { metadata: event.properties.metadata, patterns: event.properties.patterns, - workspaceRoot: workspaceRoot ?? process.cwd(), + workspaceRoot, }, ); logDevelopmentDebug("permission request received", { @@ -455,7 +455,7 @@ export const streamPromptResponse = async ({ { metadata: event.properties.metadata, patterns: event.properties.resources, - workspaceRoot: workspaceRoot ?? process.cwd(), + workspaceRoot, }, ); logDevelopmentDebug("permission v2 request received", { diff --git a/src/runtime/conversationWorkspace.ts b/src/runtime/conversationWorkspace.ts new file mode 100644 index 0000000..61338f9 --- /dev/null +++ b/src/runtime/conversationWorkspace.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto"; +import { + chown, + chmod, + lstat, + mkdir, + realpath, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; + +export const SANDBOX_UID = 10_001; +export const SANDBOX_GID = 10_001; + +const isOutsideRoot = (root: string, candidate: string) => { + const relativePath = relative(root, candidate); + return ( + relativePath === ".." || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ); +}; + +export const setSandboxOwnership = async (path: string) => { + if (typeof process.getuid !== "function" || process.getuid() !== 0) { + return; + } + await chown(path, SANDBOX_UID, SANDBOX_GID); +}; + +export const resolveConversationWorkspace = async ( + workspaceDirectory: string, + importRoot: string, +) => { + const workspaceLinkStat = await lstat(workspaceDirectory); + if (workspaceLinkStat.isSymbolicLink()) { + throw new Error("conversation workspace must not be a symbolic link"); + } + const [resolvedImportRoot, resolvedWorkspaceDirectory] = await Promise.all([ + realpath(importRoot), + realpath(workspaceDirectory), + ]); + if (isOutsideRoot(resolvedImportRoot, resolvedWorkspaceDirectory)) { + throw new Error("conversation workspace must be inside RESULT_REF_IMPORT_DIR"); + } + const relativeWorkspace = relative( + resolvedImportRoot, + resolvedWorkspaceDirectory, + ); + if ( + !relativeWorkspace || + relativeWorkspace.includes("/") || + relativeWorkspace.includes("\\") + ) { + throw new Error( + "conversation workspace must be a direct child of RESULT_REF_IMPORT_DIR", + ); + } + const workspaceStat = await stat(resolvedWorkspaceDirectory); + if (!workspaceStat.isDirectory()) { + throw new Error("conversation workspace must point to a directory"); + } + return resolvedWorkspaceDirectory; +}; + +export const resolveExistingPathInsideRoot = async ( + filePath: string, + rootPath: string, + outsideMessage = "path must be inside the current conversation workspace", +) => { + if (!isAbsolute(filePath)) { + throw new Error("file_path must be absolute"); + } + const [resolvedFilePath, resolvedRootPath] = await Promise.all([ + realpath(filePath), + realpath(rootPath), + ]); + if (isOutsideRoot(resolvedRootPath, resolvedFilePath)) { + throw new Error(outsideMessage); + } + return resolvedFilePath; +}; + +export type StagedConversationFile = { + bytes: number; + contentType: string; + filePath: string; +}; + +export const stageConversationText = async ( + workspaceDirectory: string, + importRoot: string, + content: string, + options: { + contentType: string; + extension: string; + prefix: string; + }, +): Promise => { + const workspace = await resolveConversationWorkspace( + workspaceDirectory, + importRoot, + ); + const outputDirectory = resolve(workspace, "tool-data"); + if (isOutsideRoot(workspace, outputDirectory)) { + throw new Error("tool data directory escaped the conversation workspace"); + } + await mkdir(outputDirectory, { mode: 0o700, recursive: true }); + const resolvedOutputDirectory = await realpath(outputDirectory); + if (isOutsideRoot(workspace, resolvedOutputDirectory)) { + throw new Error("tool data directory escaped the conversation workspace"); + } + await chmod(resolvedOutputDirectory, 0o700); + await setSandboxOwnership(resolvedOutputDirectory); + + const fileName = `${options.prefix}-${randomUUID()}.${options.extension}`; + const filePath = join(resolvedOutputDirectory, fileName); + const temporaryPath = `${filePath}.${randomUUID()}.tmp`; + try { + await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 }); + await setSandboxOwnership(temporaryPath); + await rename(temporaryPath, filePath); + } catch (error) { + await rm(temporaryPath, { force: true }).catch(() => undefined); + throw error; + } + await chmod(filePath, 0o600); + await setSandboxOwnership(filePath); + return { + bytes: Buffer.byteLength(content), + contentType: options.contentType, + filePath, + }; +}; diff --git a/src/runtime/opencode.ts b/src/runtime/opencode.ts index 440a329..0b01421 100644 --- a/src/runtime/opencode.ts +++ b/src/runtime/opencode.ts @@ -10,6 +10,7 @@ import { resolve } from "node:path"; import { config } from "../config.js"; import { logger } from "../logger.js"; import { ensureDirectory } from "../utils/fileStore.js"; +import { setSandboxOwnership } from "./conversationWorkspace.js"; import { cleanupExpiredToolOutputs, resolveOpencodeToolOutputDirectory, @@ -149,6 +150,7 @@ export class OpencodeRuntimeAdapter { await ensureDirectory(workspaceRoot); await chmod(workspaceRoot, 0o700); await mkdir(directory, { mode: 0o700 }); + await setSandboxOwnership(directory); try { const response = await client.session.create({ directory, diff --git a/src/runtime/toolOutputStaging.ts b/src/runtime/toolOutputStaging.ts new file mode 100644 index 0000000..3ccbb7c --- /dev/null +++ b/src/runtime/toolOutputStaging.ts @@ -0,0 +1,47 @@ +import { config } from "../config.js"; +import { type RuntimeSessionContext } from "./sessionContext.js"; +import { stageConversationText } from "./conversationWorkspace.js"; + +export type ToolDataFilePayload = { + bytes: number; + content_type: string; + file_path: string; +}; + +export const stageLargeToolOutput = async ( + context: RuntimeSessionContext, + content: string, + options: { + contentType: string; + extension: string; + force?: boolean; + prefix: string; + }, +): Promise => { + if (!options.force && Buffer.byteLength(content) <= config.MAX_INLINE_RESULT_BYTES) { + return null; + } + if (!context.workspaceDirectory) { + throw new Error( + "large tool output requires a conversation workspace; create a new conversation", + ); + } + const staged = await stageConversationText( + context.workspaceDirectory, + config.RESULT_REF_IMPORT_DIR, + content, + options, + ); + return { + bytes: staged.bytes, + content_type: staged.contentType, + file_path: staged.filePath, + }; +}; + +export const buildLargeCliResult = (dataFile: ToolDataFilePayload) => ({ + ok: true, + schema_version: "tjwater-cli/v1", + summary: "CLI 结果已保存到当前对话工作区", + data_file: dataFile, +}); diff --git a/src/sandbox/landlockSandbox.ts b/src/sandbox/landlockSandbox.ts new file mode 100644 index 0000000..6d94314 --- /dev/null +++ b/src/sandbox/landlockSandbox.ts @@ -0,0 +1,219 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { config } from "../config.js"; +import { + resolveConversationWorkspace, + SANDBOX_GID, + SANDBOX_UID, + setSandboxOwnership, +} from "../runtime/conversationWorkspace.js"; + +export type SandboxProbe = { + landlockAbi: number; + seccomp: boolean; +}; + +export type SandboxExecutionResult = { + exitCode: number | null; + outcome: "completed" | "output_limit" | "timeout"; + signal: NodeJS.Signals | null; + stderr: string; + stderrTruncated: boolean; + stdout: string; +}; + +const sandboxRunnerPath = resolve("scripts/landlock_sandbox.py"); +const maxSandboxTimeoutSeconds = 30 * 60; + +const resolvePythonPath = () => { + const virtualEnvironment = process.env.VIRTUAL_ENV?.trim(); + const candidates = [ + virtualEnvironment ? resolve(virtualEnvironment, "bin/python") : "", + "/opt/venv/bin/python", + "/usr/bin/python3", + ]; + return candidates.find((candidate) => candidate && existsSync(candidate)) ?? "python3"; +}; + +const collectProcess = async ( + args: string[], + options: { + cwd?: string; + env?: NodeJS.ProcessEnv; + maxStderrBytes: number; + maxStdoutBytes: number; + timeoutMs: number; + }, +): Promise => { + const child = spawn(resolvePythonPath(), [sandboxRunnerPath, ...args], { + cwd: options.cwd, + detached: true, + env: options.env, + stdio: ["ignore", "pipe", "pipe"], + }); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + let stderrTruncated = false; + let outcome: SandboxExecutionResult["outcome"] = "completed"; + let terminationStarted = false; + let forceKillTimer: ReturnType | undefined; + + const killGroup = (signal: NodeJS.Signals) => { + if (child.pid) { + try { + process.kill(-child.pid, signal); + return; + } catch { + // Fall back to the direct child when process groups are unavailable. + } + } + child.kill(signal); + }; + const terminate = (nextOutcome: "output_limit" | "timeout") => { + if (terminationStarted) return; + terminationStarted = true; + outcome = nextOutcome; + killGroup("SIGTERM"); + forceKillTimer = setTimeout(() => killGroup("SIGKILL"), 1500); + }; + + const timeoutTimer = setTimeout(() => terminate("timeout"), options.timeoutMs); + child.stdout.on("data", (chunk: Buffer) => { + if (terminationStarted) return; + if (stdoutBytes + chunk.length > options.maxStdoutBytes) { + terminate("output_limit"); + return; + } + stdoutChunks.push(chunk); + stdoutBytes += chunk.length; + }); + child.stderr.on("data", (chunk: Buffer) => { + if (stderrTruncated) return; + const remaining = options.maxStderrBytes - stderrBytes; + if (chunk.length > remaining) { + if (remaining > 0) { + stderrChunks.push(chunk.subarray(0, remaining)); + stderrBytes += remaining; + } + stderrTruncated = true; + return; + } + stderrChunks.push(chunk); + stderrBytes += chunk.length; + }); + + return await new Promise((resolveResult, reject) => { + child.once("error", (error) => { + clearTimeout(timeoutTimer); + if (forceKillTimer) clearTimeout(forceKillTimer); + reject(error); + }); + child.once("close", (exitCode, signal) => { + clearTimeout(timeoutTimer); + if (forceKillTimer) clearTimeout(forceKillTimer); + resolveResult({ + exitCode, + outcome, + signal, + stderr: Buffer.concat(stderrChunks, stderrBytes).toString("utf8"), + stderrTruncated, + stdout: + outcome === "output_limit" + ? "" + : Buffer.concat(stdoutChunks, stdoutBytes).toString("utf8"), + }); + }); + }); +}; + +export const probeLandlockSandbox = async (): Promise => { + const result = await collectProcess(["--probe"], { + maxStderrBytes: 16 * 1024, + maxStdoutBytes: 16 * 1024, + timeoutMs: 5000, + }); + if (result.exitCode !== 0) { + throw new Error(result.stderr || "Landlock sandbox probe failed"); + } + const payload = JSON.parse(result.stdout) as { + landlock_abi?: unknown; + seccomp?: unknown; + }; + if ( + typeof payload.landlock_abi !== "number" || + payload.landlock_abi < 4 || + payload.seccomp !== true + ) { + throw new Error("Landlock ABI 4 and seccomp are required"); + } + return { landlockAbi: payload.landlock_abi, seccomp: true }; +}; + +const buildSandboxEnvironment = (workspace: string): NodeJS.ProcessEnv => ({ + HOME: workspace, + LANG: process.env.LANG ?? "C.UTF-8", + LC_ALL: process.env.LC_ALL ?? "C.UTF-8", + MPLCONFIGDIR: resolve(workspace, ".cache/matplotlib"), + PATH: "/opt/venv/bin:/usr/local/bin:/usr/bin:/bin", + PYTHONDONTWRITEBYTECODE: "1", + TMPDIR: resolve(workspace, "tmp"), + TZ: process.env.TZ ?? "Asia/Shanghai", + VIRTUAL_ENV: "/opt/venv", + XDG_CACHE_HOME: resolve(workspace, ".cache"), +}); + +export const executeSandboxCommand = async ( + workspaceDirectory: string, + command: string, + timeoutSeconds: number, +): Promise => { + const workspace = await resolveConversationWorkspace( + workspaceDirectory, + config.RESULT_REF_IMPORT_DIR, + ); + const normalizedTimeout = Math.min( + maxSandboxTimeoutSeconds, + Math.max(1, Math.trunc(timeoutSeconds)), + ); + for (const path of [resolve(workspace, "tmp"), resolve(workspace, ".cache")]) { + await mkdir(path, { mode: 0o700, recursive: true }); + await setSandboxOwnership(path); + } + + const readOnlyPaths = [ + "/usr", + "/bin", + "/lib", + "/lib64", + "/opt/venv", + "/etc/ld.so.cache", + "/etc/localtime", + "/etc/group", + "/etc/nsswitch.conf", + "/etc/passwd", + resolve(config.OPENCODE_SKILLS_ROOT_DIR), + ].filter(existsSync); + const args = [ + "--workspace", + workspace, + "--uid", + String(SANDBOX_UID), + "--gid", + String(SANDBOX_GID), + ...readOnlyPaths.flatMap((path) => ["--read-only", path]), + "--command", + command, + ]; + return collectProcess(args, { + cwd: workspace, + env: buildSandboxEnvironment(workspace), + maxStderrBytes: config.MAX_CLI_STDERR_BYTES, + maxStdoutBytes: config.MAX_CLI_OUTPUT_BYTES, + timeoutMs: normalizedTimeout * 1000, + }); +}; diff --git a/src/server.ts b/src/server.ts index d3a463c..b6bc32f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,11 +32,20 @@ import { import { buildChatRouter } from "./routes/chat.js"; import { buildAgentPublicRouter } from "./routes/publicApi.js"; import { opencodeRuntime } from "./runtime/opencode.js"; +import { + executeSandboxCommand, + probeLandlockSandbox, + type SandboxProbe, +} from "./sandbox/landlockSandbox.js"; import { getRuntimeSessionContext, markRuntimeSessionAuthExpired, type RuntimeSessionContext, } from "./runtime/sessionContext.js"; +import { + buildLargeCliResult, + stageLargeToolOutput, +} from "./runtime/toolOutputStaging.js"; import { ensureDirectory } from "./utils/fileStore.js"; import { SkillStore } from "./skills/store.js"; @@ -63,6 +72,7 @@ const resultReferenceResolver = new ResultReferenceResolver( ); const internalToken = config.AGENT_INTERNAL_TOKEN ?? randomUUID(); const credentialRefreshCoordinator = new CredentialRefreshCoordinator(); +let sandboxProbe: SandboxProbe | null = null; // 这个 token 只用于 OpenCode 子进程回调本服务的内部工具桥。 process.env.TJWATER_AGENT_INTERNAL_TOKEN = internalToken; @@ -78,6 +88,7 @@ app.get("/health", async (_req, res) => { ready: true, warmed_up: true, runtime, + sandbox: sandboxProbe, sessions: sessionBridge.count(), }); } catch (error) { @@ -215,6 +226,7 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { const timeoutSec = typeof req.body?.timeout === "number" && req.body.timeout > 0 ? req.body.timeout : 120; + const storeResult = req.body?.store_result === true; if (!context.network) { res.status(400).json({ @@ -309,6 +321,24 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { } if (result.stdout.trim()) { + try { + const dataFile = await stageLargeToolOutput(context, result.stdout, { + contentType: "application/json", + extension: "json", + force: storeResult, + prefix: "cli", + }); + if (dataFile) { + res.status(200).json(buildLargeCliResult(dataFile)); + return; + } + } catch (error) { + res.status(409).json({ + message: "large CLI result could not be staged", + detail: error instanceof Error ? error.message : String(error), + }); + return; + } res.status(200).type("application/json").send(result.stdout); return; } @@ -321,6 +351,91 @@ app.post("/internal/tools/tjwater-cli-call", async (req, res) => { }); }); +app.post("/internal/tools/sandbox-shell", async (req, res) => { + if (req.header("x-agent-internal-token") !== internalToken) { + res.status(403).json({ message: "forbidden" }); + return; + } + const sessionId = + typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; + const command = + typeof req.body?.command === "string" ? req.body.command.trim() : ""; + const context = sessionId ? getRuntimeSessionContext(sessionId) : null; + if (!context) { + res.status(404).json({ message: "session context not found", detail: sessionId }); + return; + } + if (!context.workspaceDirectory) { + res.status(400).json({ + message: "conversation workspace is required", + detail: "create a new conversation before running shell commands", + }); + return; + } + if (!command) { + res.status(400).json({ message: "command is required" }); + return; + } + const timeoutSeconds = + typeof req.body?.timeout === "number" && Number.isFinite(req.body.timeout) + ? req.body.timeout + : 120; + try { + const result = await executeSandboxCommand( + context.workspaceDirectory, + command, + timeoutSeconds, + ); + if (result.outcome === "timeout") { + res.status(504).json({ + ok: false, + error: { code: "TIMEOUT", message: "sandbox command timed out" }, + }); + return; + } + if (result.outcome === "output_limit") { + res.status(502).json({ + ok: false, + error: { + code: "OUTPUT_LIMIT_EXCEEDED", + message: `stdout exceeded ${config.MAX_CLI_OUTPUT_BYTES} bytes`, + }, + }); + return; + } + if (result.exitCode === 125) { + res.status(503).json({ + ok: false, + error: { code: "SANDBOX_UNAVAILABLE", message: result.stderr }, + }); + return; + } + const dataFile = result.stdout + ? await stageLargeToolOutput(context, result.stdout, { + contentType: "text/plain", + extension: "txt", + prefix: "shell", + }) + : null; + res.json({ + ok: result.exitCode === 0, + exit_code: result.exitCode, + signal: result.signal, + ...(dataFile ? { data_file: dataFile } : { stdout: result.stdout }), + stderr: result.stderr || undefined, + stderr_truncated: result.stderrTruncated || undefined, + }); + } catch (error) { + res.status(503).json({ + ok: false, + error: { + code: "SANDBOX_UNAVAILABLE", + message: error instanceof Error ? error.message : String(error), + }, + }); + } +}); + app.post("/internal/tools/store-render-ref", async (req, res) => { if (req.header("x-agent-internal-token") !== internalToken) { res.status(403).json({ message: "forbidden" }); @@ -626,6 +741,7 @@ app.use( ); const bootstrap = async () => { + sandboxProbe = await probeLandlockSandbox(); await Promise.all([ sessionMetadataStore.initialize(), sessionUiStateStore.initialize(), diff --git a/tests/routes/chatPermissionPolicy.test.ts b/tests/routes/chatPermissionPolicy.test.ts index 2622619..ff719d4 100644 --- a/tests/routes/chatPermissionPolicy.test.ts +++ b/tests/routes/chatPermissionPolicy.test.ts @@ -130,6 +130,36 @@ describe("permission approval policy", () => { }); }); + it("auto approves sandboxed shell and file access in a conversation workspace", async () => { + const root = await mkdtemp(join(tmpdir(), "permission-conversations-")); + const conversationRoot = join(root, "conversation-workspaces"); + const workspaceRoot = join(conversationRoot, "conversation-test"); + try { + await mkdir(workspaceRoot, { recursive: true }); + expect( + resolvePermissionApproval("auto", "bash", { + workspaceRoot, + metadata: { command: "python3 analysis.py" }, + }), + ).toMatchObject({ autoApprove: true, autoReject: false }); + expect( + canAutoApprovePermission("edit", { + workspaceRoot, + metadata: { filePath: join(workspaceRoot, "result.json") }, + }), + ).toBe(true); + expect( + canAutoApprovePermission("glob", { + workspaceRoot, + metadata: { path: workspaceRoot, pattern: "**/*.json" }, + patterns: ["**/*.json"], + }), + ).toBe(true); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + it.each([ "rm -rf ./target", "rm -rf ./target", @@ -157,14 +187,14 @@ describe("permission approval policy", () => { }); it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])( - "keeps non-recursive or non-executed removal text available for confirmation: %s", + "allows non-force-recursive or non-executed removal text in always mode: %s", (command) => { expect( resolvePermissionApproval("always", "bash", { metadata: { command }, patterns: [command], }), - ).toMatchObject({ autoApprove: false, autoReject: false }); + ).toMatchObject({ autoApprove: true, autoReject: false }); }, ); }); diff --git a/tests/runtime/opencodeConfig.test.ts b/tests/runtime/opencodeConfig.test.ts index 4ab9f0a..a076f55 100644 --- a/tests/runtime/opencodeConfig.test.ts +++ b/tests/runtime/opencodeConfig.test.ts @@ -1,10 +1,29 @@ import { describe, expect, it } from "bun:test"; import { readFile } from "node:fs/promises"; +import sandboxBash from "../../.opencode/tools/bash.js"; +import tjwaterCli from "../../.opencode/tools/tjwater_cli.js"; import storeRenderRef, { resolveStoreRenderFilePath, } from "../../.opencode/tools/store_render_ref.js"; describe("internal OpenCode permissions", () => { + it("pins the runtime, SDK, plugin, and image to OpenCode 1.18.13", async () => { + const [rootPackageText, toolPackageText, dockerfile] = await Promise.all([ + readFile("package.json", "utf8"), + readFile(".opencode/package.json", "utf8"), + readFile("Dockerfile", "utf8"), + ]); + const rootPackage = JSON.parse(rootPackageText) as { + dependencies?: Record; + }; + const toolPackage = JSON.parse(toolPackageText) as { + dependencies?: Record; + }; + expect(rootPackage.dependencies?.["@opencode-ai/sdk"]).toBe("1.18.13"); + expect(toolPackage.dependencies?.["@opencode-ai/plugin"]).toBe("1.18.13"); + expect(dockerfile.startsWith("FROM smanx/opencode:1.18.13@")).toBe(true); + }); + it("keeps protected paths denied in every approval mode", async () => { const config = JSON.parse(await readFile("opencode.json", "utf8")) as { permission?: Record>; @@ -89,3 +108,77 @@ describe("store_render_ref arguments", () => { }); }); }); + +describe("sandbox bash tool", () => { + it("forwards commands to the authenticated sandbox endpoint", async () => { + const originalFetch = globalThis.fetch; + const permissionRequests: unknown[] = []; + let requestUrl = ""; + let requestBody: unknown; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = String(input); + requestBody = JSON.parse(String(init?.body)); + return new Response('{"ok":true,"stdout":"done"}'); + }) as unknown as typeof fetch; + try { + const definition = sandboxBash as unknown as { + execute: (args: unknown, context: unknown) => Promise; + }; + await definition.execute( + { command: "python3 analysis.py", timeout: 300 }, + { + sessionID: "session-test", + ask: async (input: unknown) => permissionRequests.push(input), + } as never, + ); + } finally { + globalThis.fetch = originalFetch; + } + expect(requestUrl).toEndWith("/internal/tools/sandbox-shell"); + expect(permissionRequests).toEqual([ + { + permission: "bash", + patterns: ["python3 analysis.py"], + always: ["python3 analysis.py"], + metadata: { + command: "python3 analysis.py", + }, + }, + ]); + expect(requestBody).toEqual({ + session_id: "session-test", + command: "python3 analysis.py", + timeout: 300, + }); + }); +}); + +describe("tjwater_cli storage request", () => { + it("forwards store_result so small workflow inputs can be staged", async () => { + const originalFetch = globalThis.fetch; + let requestBody: unknown; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + requestBody = JSON.parse(String(init?.body)); + return new Response('{"ok":true,"data_file":{"file_path":"/tmp/test"}}'); + }) as unknown as typeof fetch; + try { + const definition = tjwaterCli as unknown as { + execute: (args: unknown, context: unknown) => Promise; + }; + await definition.execute( + { + command: "network get-all-reservoirs-properties", + reason: "prepare workflow input", + store_result: true, + }, + { sessionID: "session-test" } as never, + ); + } finally { + globalThis.fetch = originalFetch; + } + expect(requestBody).toMatchObject({ + session_id: "session-test", + store_result: true, + }); + }); +}); diff --git a/tests/runtime/toolOutputStaging.test.ts b/tests/runtime/toolOutputStaging.test.ts new file mode 100644 index 0000000..2d3aaf8 --- /dev/null +++ b/tests/runtime/toolOutputStaging.test.ts @@ -0,0 +1,98 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, readFile, rm, stat } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { config } from "../../src/config.js"; +import { type RuntimeSessionContext } from "../../src/runtime/sessionContext.js"; +import { + buildLargeCliResult, + stageLargeToolOutput, +} from "../../src/runtime/toolOutputStaging.js"; + +const createdPaths: string[] = []; + +afterEach(async () => { + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +const createContext = async (): Promise => { + const importRoot = resolve(config.RESULT_REF_IMPORT_DIR); + await mkdir(importRoot, { recursive: true }); + const workspaceDirectory = resolve( + importRoot, + `conversation-staging-${crypto.randomUUID()}`, + ); + await mkdir(workspaceDirectory, { mode: 0o700 }); + createdPaths.push(workspaceDirectory); + return { + actorKey: "actor-1", + clientSessionId: "client-1", + projectKey: "project-1", + sessionId: "runtime-1", + traceId: "trace-1", + workspaceDirectory, + }; +}; + +describe("tool output staging", () => { + it("keeps small output inline unless storage is forced", async () => { + const context = await createContext(); + await expect( + stageLargeToolOutput(context, '{"ok":true}', { + contentType: "application/json", + extension: "json", + prefix: "cli", + }), + ).resolves.toBeNull(); + + const stored = await stageLargeToolOutput(context, '{"ok":true}', { + contentType: "application/json", + extension: "json", + force: true, + prefix: "cli", + }); + expect(stored?.file_path).toContain("/tool-data/cli-"); + expect(await readFile(stored!.file_path, "utf8")).toBe('{"ok":true}'); + expect((await stat(stored!.file_path)).mode & 0o777).toBe(0o600); + }); + + it("stages output above the inline threshold and returns a compact descriptor", async () => { + const context = await createContext(); + const content = JSON.stringify({ + ok: true, + schema_version: "tjwater-cli/v1", + data: "x".repeat(config.MAX_INLINE_RESULT_BYTES), + }); + const dataFile = await stageLargeToolOutput(context, content, { + contentType: "application/json", + extension: "json", + prefix: "cli", + }); + expect(dataFile?.bytes).toBe(Buffer.byteLength(content)); + const stored = dataFile!; + expect(buildLargeCliResult(stored)).toEqual({ + ok: true, + schema_version: "tjwater-cli/v1", + summary: "CLI 结果已保存到当前对话工作区", + data_file: stored, + }); + }); + + it("does not stage large output for legacy sessions without a workspace", async () => { + await expect( + stageLargeToolOutput( + { + actorKey: "actor-1", + clientSessionId: "client-1", + projectKey: "project-1", + sessionId: "runtime-1", + traceId: "trace-1", + }, + "x".repeat(config.MAX_INLINE_RESULT_BYTES + 1), + { contentType: "text/plain", extension: "txt", prefix: "shell" }, + ), + ).rejects.toThrow("requires a conversation workspace"); + }); +}); diff --git a/tests/sandbox/landlockSandbox.test.ts b/tests/sandbox/landlockSandbox.test.ts new file mode 100644 index 0000000..3d90472 --- /dev/null +++ b/tests/sandbox/landlockSandbox.test.ts @@ -0,0 +1,94 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdir, rm } from "node:fs/promises"; +import { resolve } from "node:path"; + +import { config } from "../../src/config.js"; +import { + resolveConversationWorkspace, + setSandboxOwnership, +} from "../../src/runtime/conversationWorkspace.js"; +import { + executeSandboxCommand, + probeLandlockSandbox, +} from "../../src/sandbox/landlockSandbox.js"; + +const createdPaths: string[] = []; + +afterEach(async () => { + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +const createWorkspace = async (name: string = crypto.randomUUID()) => { + const importRoot = resolve(config.RESULT_REF_IMPORT_DIR); + await mkdir(importRoot, { recursive: true }); + const workspace = resolve(importRoot, `conversation-sandbox-${name}`); + await mkdir(workspace, { mode: 0o700 }); + await setSandboxOwnership(workspace); + createdPaths.push(workspace); + return workspace; +}; + +describe("Landlock sandbox", () => { + it("requires Landlock ABI 4 and seccomp", async () => { + const probe = await probeLandlockSandbox(); + expect(probe.landlockAbi).toBeGreaterThanOrEqual(4); + expect(probe.seccomp).toBe(true); + }); + + it("allows local Python analysis while denying filesystem escape, secrets, and network", async () => { + const workspace = await createWorkspace("primary"); + const sibling = await createWorkspace("sibling"); + await resolveConversationWorkspace(workspace, config.RESULT_REF_IMPORT_DIR); + + const allowed = await executeSandboxCommand( + workspace, + 'python3 -c "import json; open(\'result.json\', \'w\').write(json.__name__)" && cat result.json', + 10, + ); + expect(allowed).toMatchObject({ exitCode: 0, stdout: "json" }); + + const escaped = await executeSandboxCommand( + workspace, + `cat ${resolve("package.json")}`, + 10, + ); + expect(escaped.exitCode).not.toBe(0); + expect(escaped.stderr).toContain("Permission denied"); + + const siblingRead = await executeSandboxCommand( + workspace, + `ls ${sibling}`, + 10, + ); + expect(siblingRead.exitCode).not.toBe(0); + + const originalApiKey = process.env.DEEPSEEK_API_KEY; + process.env.DEEPSEEK_API_KEY = "must-not-leak"; + try { + const environment = await executeSandboxCommand( + workspace, + 'test -z "$DEEPSEEK_API_KEY"', + 10, + ); + expect(environment.exitCode).toBe(0); + } finally { + if (originalApiKey === undefined) { + delete process.env.DEEPSEEK_API_KEY; + } else { + process.env.DEEPSEEK_API_KEY = originalApiKey; + } + } + + for (const socketType of ["SOCK_STREAM", "SOCK_DGRAM"]) { + const network = await executeSandboxCommand( + workspace, + `python3 -c "import socket; socket.socket(socket.AF_INET, socket.${socketType})"`, + 10, + ); + expect(network.exitCode).not.toBe(0); + expect(network.stderr).toContain("Operation not permitted"); + } + }); +}); diff --git a/tests/skills/serviceAreaScript.test.ts b/tests/skills/serviceAreaScript.test.ts new file mode 100644 index 0000000..4a37903 --- /dev/null +++ b/tests/skills/serviceAreaScript.test.ts @@ -0,0 +1,73 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const createdPaths: string[] = []; + +afterEach(async () => { + await Promise.all( + createdPaths.splice(0).map((path) => rm(path, { force: true, recursive: true })), + ); +}); + +describe("service area workflow script", () => { + it("writes the wrapped render payload required by store_render_ref", async () => { + const directory = await mkdtemp(join(tmpdir(), "service-area-script-")); + createdPaths.push(directory); + const time = "2026-04-01T08:00:00+08:00"; + const inputs = { + pipes: { data: [{ id: "P1", node1: "R1", node2: "N1" }] }, + reservoirs: { data: [{ id: "R1" }] }, + links: { data: [{ id: "P1", flow: 1, time }] }, + nodes: { + data: [ + { id: "R1", pressure: 30, actual_demand: 0, time }, + { id: "N1", pressure: 28, actual_demand: 2, time }, + ], + }, + }; + const paths = Object.fromEntries( + await Promise.all( + Object.entries(inputs).map(async ([name, value]) => { + const path = join(directory, `${name}.json`); + await writeFile(path, JSON.stringify(value)); + return [name, path] as const; + }), + ), + ); + const outputPath = join(directory, "service-area-wrapper.json"); + await execFileAsync( + "python3", + [ + resolve( + ".opencode/skills/workflow/service-area-analysis/scripts/service_area_partition.py", + ), + "--pipe-props", + paths.pipes!, + "--reservoirs", + paths.reservoirs!, + "--links", + paths.links!, + "--nodes", + paths.nodes!, + "--target-time", + time, + "--output", + outputPath, + ], + { cwd: directory }, + ); + const wrapper = JSON.parse(await readFile(outputPath, "utf8")) as { + data: { node_area_map: Record }; + location: { file_path: string }; + metadata: { schema_version: number }; + }; + expect(wrapper.location.file_path).toBe(outputPath); + expect(wrapper.metadata.schema_version).toBe(1); + expect(wrapper.data.node_area_map).toMatchObject({ R1: "R1", N1: "R1" }); + }); +});