Author SHA1 Message Date
jiang 80cfc1f2ab feat(agent): sandbox conversation analysis
Generic Container CI/CD / test-build-publish (push) Successful in 2m44s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m44s
2026-08-25 16:08:33 +08:00
jiang ce04704af2 fix(agent): isolate conversation workspaces
Generic Container CI/CD / test-build-publish (push) Successful in 2m2s
Agent CI/CD v2 / build-test-publish-and-deploy (push) Successful in 2m2s
2026-08-25 13:18:39 +08:00
37 changed files with 2941 additions and 106 deletions
-1
View File
@@ -1,7 +1,6 @@
node_modules/
__pycache__/
.opencode/node_modules/
.opencode/skills/
.local.env
.vscode
docker-compose.yml
+9 -23
View File
@@ -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` 或硬编码认证环境变量
**触发时机**
- 用户明确说"保存/沉淀/记录工作流"
+186
View File
@@ -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`(整数分钟,必填)。
@@ -0,0 +1,150 @@
---
name: hydraulic-bottleneck-analysis
description: 基于实时水力数据的管网水力瓶颈识别与改造建议。复合评分法(流速×水头损失)定位瓶颈管段,输出分级改造方案。
---
# 水力瓶颈分析工作流
## 概述
本工作流通过复合评分法(流速分级 × 水头损失百分位)从全管网管道中识别水力瓶颈管段,并结合节点压力、管径、粗糙系数给出分级改造建议。
适用场景:管网运行评估、管网改造优先级排序、泵站阀站运行诊断。
## 评分方法论
### 双维度复合评分
| 维度 | 判定标准 | 分值 |
|------|----------|------|
| **流速** | >3.0 m/s = 极危 | 3 |
| | 2.03.0 m/s = 严重 | 2 |
| | 1.52.0 m/s = 偏高 | 1 |
| | <1.5 m/s = 正常 | 0 |
| **水头损失** | >P90 = 严重 | 2 |
| | P80P90 = 中度 | 1 |
| | <P80 = 正常 | 0 |
**瓶颈判定**`(流速≥1 且 水损≥1)``(流速≥2)` —— 即双侧超标或单侧流速严重。
**复合评分** = 流速分值 + 水损分值(最高 5 分),按降序排列。
### 辅助指标
| 指标 | 阈值 | 含义 |
|------|------|------|
| 节点压力 < 20m | — | 低压区域,需增压 |
| 节点压力 20–25m | — | 压力偏低 |
| roughness > 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` 可覆盖 12 个时间步。脚本内部按 `--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 <skill_dir>/scripts/bottleneck_analysis.py \
--pipe-props <data_file.file_path-①> \
--realtime <data_file.file_path-②> \
--node-pressures <data_file.file_path-③> \
--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,需确保执行环境有足够内存。
@@ -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 <T> --end-time <T+15min>
```
返回字段:
| 字段 | 类型 | 说明 | 分析用途 |
|------|------|------|----------|
| 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 <T> --end-time <T+15min>
```
返回字段:
| 字段 | 类型 | 说明 | 分析用途 |
|------|------|------|----------|
| 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`
@@ -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()
@@ -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. 预期覆盖 **8590%** 节点
### 第三步:无向拓扑修正
有向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 覆盖 12 个时间步。脚本按 `--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 <skill_dir>/scripts/service_area_partition.py \
--pipe-props <data_file.file_path-①> \
--reservoirs <data_file.file_path-②> \
--links <data_file.file_path-③> \
--nodes <data_file.file_path-④> \
--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=<output-file>)
# 渲染节点分区
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` 阈值过滤极低流量管段,若管网有长期小流量管段可能漏判方向
@@ -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()
+53
View File
@@ -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;
},
});
+24 -5
View File
@@ -3,12 +3,25 @@ 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 ?? "";
const importDirectory =
process.env.RESULT_REF_IMPORT_DIR ?? "./data/result-imports";
type StoreRenderRefArgs = {
file_path?: unknown;
filePath?: unknown;
};
export function resolveStoreRenderFilePath(args: StoreRenderRefArgs): string {
if (typeof args.file_path === "string" && args.file_path.trim() !== "") {
return args.file_path;
}
if (typeof args.filePath === "string" && args.filePath.trim() !== "") {
return args.filePath;
}
throw new Error("file_path is required");
}
export default tool({
description:
`导入 ${importDirectory} 下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受目录内的真实文件,不接受目录外路径或指向目录外的符号链接。`,
"导入当前对话工作目录下的受控 JSON 包装文件并返回 render_ref。文件必须是 { metadata: object, location: { file_path: string }, data: { node_area_map, area_ids?, area_colors? } }location.file_path 必须与传入的绝对路径完全一致。只接受当前对话工作目录内的真实文件,不接受其他对话目录、目录外路径或指向目录外的符号链接。",
args: {
reason: tool.schema
.string()
@@ -17,11 +30,17 @@ export default tool({
),
file_path: tool.schema
.string()
.optional()
.describe(
`位于 ${importDirectory} 内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 datadata 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。`,
"位于当前对话工作目录内的包装 JSON 文件绝对路径。必须包含 metadata、location.file_path 和 datadata 才是 render_junctions 使用的 { node_area_map, area_ids?, area_colors? }。",
),
filePath: tool.schema
.string()
.optional()
.describe("兼容旧调用的参数名;新调用应优先使用 file_path。"),
},
async execute(args, context) {
const filePath = resolveStoreRenderFilePath(args);
const response = await fetch(
`${internalBaseUrl}/internal/tools/store-render-ref`,
{
@@ -32,7 +51,7 @@ export default tool({
},
body: JSON.stringify({
session_id: context.sessionID,
file_path: args.file_path,
file_path: filePath,
}),
},
);
+7
View File
@@ -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,
}),
},
+1 -1
View File
@@ -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/<conversation-id>/` directory is the only `data/` exception; access must still reject symlinks and every other conversation directory.
+5 -1
View File
@@ -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
+8 -4
View File
@@ -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/` 路径仍由静态配置明确禁止,三种整体模式都不能绕过这些拒绝规则
单次权限请求支持“允许一次”“保存授权”和“拒绝”。“保存授权”使用 OpenCode 的 `always` 回复,仅保存 OpenCode 为本次请求建议的权限范围,并只在当前 OpenCode 会话内生效。任意外部目录默认仍由静态配置禁止;`.env`普通 `data/``logs/` 和其他对话目录保持禁止。真实聊天会话使用 `data/conversation-workspaces/<随机目录>/` 作为独立工作目录。普通 `rm <文件>``rmdir` 和非强制递归删除可在沙箱内执行,`rm -rf`/`rm -fr` 及等价的递归强制删除形式会在执行前拒绝
`store_render_ref` 只会从 `RESULT_REF_IMPORT_DIR`(默认 `./data/result-imports`)导入包装格式 JSON。文件必须包含 `metadata``location.file_path``data`,且真实路径不能越出导入目录;单文件默认上限为 128 MiB,成功导入后源包装文件会被删除
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 缓存均位于当前对话目录
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_*` 文件
`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 字节)仅决定内联还是落盘:较大结果写入当前对话的 `tool-data/` 并返回 `data_file.file_path`,不会因为超过 12000 字节杀掉 CLI;分析脚本需要文件输入时可由 `tjwater_cli(store_result=true)` 强制落盘小结果。会话暂存数据不自动删除。OpenCode 自身为其他工具生成的全局 `tool-output` 仍按 `RESULT_REF_TTL_HOURS`(默认 7 天)清理,但沙箱命令不能访问该目录。
## 配置与安全
+7 -7
View File
@@ -44,6 +44,12 @@
"bash": {
"*": "ask",
"rm *": "ask",
"rm -rf *": "deny",
"rm -fr *": "deny",
"rm -r -f *": "deny",
"rm -f -r *": "deny",
"rm --recursive --force *": "deny",
"rm --force --recursive *": "deny",
"rmdir *": "ask",
"mv *": "ask",
"chmod *": "ask",
@@ -51,13 +57,7 @@
"sudo *": "ask",
"curl *": "ask",
"wget *": "ask",
"*.env*": "deny",
"*data/*": "deny",
"* data": "deny",
"*/data": "deny",
"*logs/*": "deny",
"* logs": "deny",
"*/logs": "deny"
"*.env*": "deny"
},
"question": "allow",
"task": "deny",
+332
View File
@@ -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)
+10 -2
View File
@@ -12,6 +12,7 @@ export type SessionBinding = {
clientSessionId: string;
sessionId: string;
startedAt: number;
workspaceDirectory?: string;
};
export type SessionContext = {
@@ -52,20 +53,26 @@ export class ChatSessionBridge {
await this.abortActiveRuntime(requestContext.clientSessionId, existingSessionId);
let sessionId = existingSessionId;
let runtimeSession;
let created = false;
if (!sessionId) {
const session = await this.runtime.createSession();
sessionId = session.id;
runtimeSession = await this.runtime.createSession(undefined, {
conversationWorkspace: true,
});
sessionId = runtimeSession.id;
requestContext = {
...requestContext,
clientSessionId: sessionId,
};
created = true;
} else {
runtimeSession = await this.runtime.getSession(sessionId);
}
const binding: SessionBinding = {
clientSessionId: requestContext.clientSessionId,
sessionId,
startedAt: Date.now(),
workspaceDirectory: runtimeSession.directory,
};
setRuntimeSessionContext({
accessToken: requestContext.accessToken,
@@ -79,6 +86,7 @@ export class ChatSessionBridge {
sessionId,
tokenExpiresAt: requestContext.tokenExpiresAt,
traceId: requestContext.traceId,
workspaceDirectory: runtimeSession.directory,
});
return { binding, requestContext, created };
+5 -1
View File
@@ -7,6 +7,8 @@ import {
parseAgentModelOptions,
} from "./chat/modelConfig.js";
export const RESULT_REF_IMPORT_DIRECTORY = "./data/conversation-workspaces";
// 本地开发可在项目根目录放 .local.env;已存在的系统环境变量优先级更高。
dotenv.config({ path: ".local.env", override: false });
@@ -116,7 +118,9 @@ const envSchema = z
// result_ref 持久化存储目录。
RESULT_REF_STORAGE_DIR: z.string().default("./data/result-refs"),
// 仅允许 store_render_ref 从该目录导入受控 JSON 包装文件。
RESULT_REF_IMPORT_DIR: z.string().default("./data/result-imports"),
RESULT_REF_IMPORT_DIR: z
.literal(RESULT_REF_IMPORT_DIRECTORY)
.default(RESULT_REF_IMPORT_DIRECTORY),
// 单个渲染包装 JSON 的最大导入字节数。
RESULT_REF_IMPORT_MAX_BYTES: z.coerce
.number()
+19 -24
View File
@@ -1,6 +1,9 @@
import { 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,
@@ -68,9 +71,19 @@ export class ResultReferenceResolver {
async registerRenderPayloadFile(
filePath: string,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion">,
input: Omit<RegisterResultReferenceInput, "data" | "kind" | "schemaVersion"> & {
workspaceDirectory: string;
},
) {
const resolvedFilePath = await resolvePathInsideRoot(filePath, this.importRoot);
const resolvedWorkspaceDirectory = await resolveConversationWorkspace(
input.workspaceDirectory,
this.importRoot,
);
const resolvedFilePath = await resolveExistingPathInsideRoot(
filePath,
resolvedWorkspaceDirectory,
"render payload file must be inside the current conversation workspace",
);
const fileStat = await stat(resolvedFilePath);
if (!fileStat.isFile()) {
throw new Error("render payload path must point to a regular file");
@@ -95,8 +108,9 @@ export class ResultReferenceResolver {
throw new Error("render payload file does not contain a valid junction render payload");
}
const { workspaceDirectory: _workspaceDirectory, ...registrationInput } = input;
const record = await this.register({
...input,
...registrationInput,
data: payload,
kind: RESULT_REFERENCE_KIND.renderJunctionsPayload,
schemaVersion: 1,
@@ -186,25 +200,6 @@ export const extractRenderJunctionPayload = (
};
};
const resolvePathInsideRoot = async (filePath: string, rootPath: string) => {
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("render payload file must be inside RESULT_REF_IMPORT_DIR");
}
return resolvedFilePath;
};
const normalizeDataForKind = (
kind: ResultReferenceKind,
data: unknown,
+7 -2
View File
@@ -155,7 +155,9 @@ export const buildChatRouter = (
const actorKey = toActorKey(userId);
const projectKey = toProjectKey(projectId);
const requestedSessionId = parsed.data.session_id?.trim();
const sessionId = requestedSessionId || (await runtime.createSession()).id;
const sessionId =
requestedSessionId ||
(await runtime.createSession(undefined, { conversationWorkspace: true })).id;
const { record, created } = await sessionMetadataStore.ensure({
actorKey,
@@ -451,7 +453,9 @@ export const buildChatRouter = (
res.status(404).json({ message: "source session not found" });
return;
}
const forkSession = await runtime.createSession();
const forkSession = await runtime.createSession(undefined, {
conversationWorkspace: true,
});
const { record: targetSessionRecord } = await sessionMetadataStore.ensure({
actorKey,
parentSessionId: sourceSessionId,
@@ -874,6 +878,7 @@ export const buildChatRouter = (
traceId: requestContext.traceId,
projectId: requestContext.projectId,
signal: abortController.signal,
workspaceRoot: binding.workspaceDirectory,
write: (event, data) => {
publish(event, data);
},
+210 -12
View File
@@ -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;
}
@@ -62,9 +70,19 @@ export const resolvePermissionApproval = (
permission: string,
context: PermissionApprovalContext = {},
) => {
if (isDirectRecursiveForceRemove(permission, context)) {
return {
autoApprove: false,
autoReject: true,
title: "已拒绝递归强制删除",
detail: "当前安全策略禁止直接执行带 recursive 和 force 参数的 rm 命令。",
} as const;
}
if (approvalMode === "always") {
return {
autoApprove: true,
autoReject: false,
title: "已按始终允许模式放行",
detail:
"当前会话处于始终允许模式,已放行本次权限请求;明确禁止的权限仍由 OpenCode 拒绝。",
@@ -74,6 +92,7 @@ export const resolvePermissionApproval = (
if (approvalMode === "auto" && canAutoApprovePermission(permission, context)) {
return {
autoApprove: true,
autoReject: false,
title: "已自动批准低风险权限",
detail: "当前批准模式允许自动执行低风险工具,已放行本次请求。",
} as const;
@@ -81,11 +100,119 @@ export const resolvePermissionApproval = (
return {
autoApprove: false,
autoReject: false,
title: "等待权限确认",
detail: undefined,
} as const;
};
const isDirectRecursiveForceRemove = (
permission: string,
context: PermissionApprovalContext,
): boolean => {
if (normalizePermission(permission) !== "bash") {
return false;
}
const command =
typeof context.metadata?.command === "string"
? context.metadata.command
: context.patterns?.join("\n");
if (!command) {
return false;
}
return splitShellCommandSegments(command).some((segment) => {
const words = tokenizeShellSegment(segment);
let commandIndex = 0;
while (commandIndex < words.length) {
const word = words[commandIndex]!;
const executable = word.split("/").at(-1)?.toLowerCase();
if (word === "!" || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) {
commandIndex += 1;
continue;
}
if (executable === "command") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-") && words[commandIndex] !== "--") {
commandIndex += 1;
}
if (words[commandIndex] === "--") commandIndex += 1;
continue;
}
if (executable === "env") {
commandIndex += 1;
while (commandIndex < words.length) {
const envWord = words[commandIndex]!;
if (envWord === "--") {
commandIndex += 1;
break;
}
if (envWord === "-u" || envWord === "--unset") {
commandIndex += 2;
continue;
}
if (
envWord.startsWith("-") ||
/^[A-Za-z_][A-Za-z0-9_]*=/.test(envWord)
) {
commandIndex += 1;
continue;
}
break;
}
continue;
}
if (executable === "sudo" || executable === "doas") {
commandIndex += 1;
while (words[commandIndex]?.startsWith("-")) {
commandIndex += 1;
}
continue;
}
if (executable === "busybox" && words[commandIndex + 1] === "rm") {
commandIndex += 1;
}
break;
}
const executable = words[commandIndex]?.split("/").at(-1)?.toLowerCase();
if (executable !== "rm") {
return false;
}
let recursive = false;
let force = false;
for (const word of words.slice(commandIndex + 1)) {
if (word === "--") {
break;
}
if (word === "--recursive") {
recursive = true;
} else if (word === "--force") {
force = true;
} else if (/^-[^-]/.test(word)) {
recursive ||= /[rR]/.test(word.slice(1));
force ||= word.slice(1).includes("f");
}
}
return recursive && force;
});
};
const splitShellCommandSegments = (command: string): string[] =>
command.split(/&&|\|\||[;|()\n]/u);
const tokenizeShellSegment = (segment: string): string[] =>
(segment.match(/(?:[^\s"'\\]+|"(?:\\.|[^"])*"|'[^']*')+/gu) ?? []).map(
(word) => {
const quoted = word.match(/^(?:"([\s\S]*)"|'([\s\S]*)')$/u);
return (quoted ? (quoted[1] ?? quoted[2] ?? "") : word).replace(
/(["'])|\\(.)/gu,
"$2",
);
},
);
const isSafeWorkspaceSearch = (
permission: "glob" | "grep",
context: PermissionApprovalContext,
@@ -133,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;
}
@@ -156,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()) {
@@ -175,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;
}
@@ -183,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()) {
@@ -213,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;
}
};
+22 -10
View File
@@ -71,6 +71,7 @@ type StreamPromptOptions = {
traceId?: string;
projectId?: string;
signal?: AbortSignal;
workspaceRoot?: string;
write: (event: string, data: Record<string, unknown>) => void;
};
@@ -157,6 +158,7 @@ export const streamPromptResponse = async ({
traceId,
projectId,
signal,
workspaceRoot,
write,
}: StreamPromptOptions): Promise<{
aborted: boolean;
@@ -397,7 +399,7 @@ export const streamPromptResponse = async ({
{
metadata: event.properties.metadata,
patterns: event.properties.patterns,
workspaceRoot: process.cwd(),
workspaceRoot,
},
);
logDevelopmentDebug("permission request received", {
@@ -410,20 +412,25 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoApprove ? "completed" : "running",
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionDetail(event),
});
if (permissionApproval.autoApprove) {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "once",
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "once" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}
@@ -448,7 +455,7 @@ export const streamPromptResponse = async ({
{
metadata: event.properties.metadata,
patterns: event.properties.resources,
workspaceRoot: process.cwd(),
workspaceRoot,
},
);
logDevelopmentDebug("permission v2 request received", {
@@ -461,20 +468,25 @@ export const streamPromptResponse = async ({
emitProgress({
id: `permission-${event.properties.id}`,
phase: "permission",
status: permissionApproval.autoApprove ? "completed" : "running",
status: permissionApproval.autoReject
? "error"
: permissionApproval.autoApprove
? "completed"
: "running",
title: permissionApproval.title,
detail: permissionApproval.detail ?? buildPermissionV2Detail(event),
});
if (permissionApproval.autoApprove) {
if (permissionApproval.autoApprove || permissionApproval.autoReject) {
const reply = permissionApproval.autoReject ? "reject" : "once";
await runtime.replyPermission({
requestId: event.properties.id,
sessionId,
reply: "once",
reply,
});
write("permission_response", {
session_id: clientSessionId,
request_id: event.properties.id,
reply: "once" satisfies PermissionReply,
reply: reply satisfies PermissionReply,
});
continue;
}
+137
View File
@@ -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<StagedConversationFile> => {
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,
};
};
+40 -1
View File
@@ -2,11 +2,15 @@ import {
createOpencode,
type OpencodeClient,
} from "@opencode-ai/sdk/v2";
import { randomUUID } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import { chmod, mkdir, rmdir } from "node:fs/promises";
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,
@@ -126,12 +130,47 @@ export class OpencodeRuntimeAdapter {
}
}
async createSession(title?: string) {
async createSession(
title?: string,
options: {
conversationWorkspace?: boolean;
workspaceRoot?: string;
} = {},
) {
const client = await this.ensureClient();
if (!options.conversationWorkspace) {
const response = await client.session.create({ title });
return requireData(response.data, "session.create");
}
const workspaceRoot = resolve(
options.workspaceRoot ?? config.RESULT_REF_IMPORT_DIR,
);
const directory = resolve(workspaceRoot, `conversation-${randomUUID()}`);
await ensureDirectory(workspaceRoot);
await chmod(workspaceRoot, 0o700);
await mkdir(directory, { mode: 0o700 });
await setSandboxOwnership(directory);
try {
const response = await client.session.create({
directory,
title,
permission: [
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
],
});
return requireData(response.data, "session.create");
} catch (error) {
await rmdir(directory).catch(() => undefined);
throw error;
}
}
async getSession(sessionId: string) {
const client = await this.ensureClient();
const response = await client.session.get({ sessionID: sessionId });
return requireData(response.data, "session.get");
}
async sendPrompt(sessionId: string, text: string) {
+1
View File
@@ -15,6 +15,7 @@ export type RuntimeSessionContext = {
sessionId: string;
tokenExpiresAt?: string;
traceId: string;
workspaceDirectory?: string;
};
const contexts = new Map<string, RuntimeSessionContext>();
+47
View File
@@ -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<ToolDataFilePayload | null> => {
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,
});
+219
View File
@@ -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<SandboxExecutionResult> => {
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<typeof setTimeout> | 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<SandboxProbe> => {
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<SandboxExecutionResult> => {
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,
});
};
+124
View File
@@ -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" });
@@ -342,6 +457,13 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
res.status(400).json({ message: "file_path is required" });
return;
}
if (!context.workspaceDirectory) {
res.status(400).json({
message: "conversation workspace is required",
detail: "create a new conversation before importing render data",
});
return;
}
try {
const record = await resultReferenceResolver.registerRenderPayloadFile(filePath, {
@@ -352,6 +474,7 @@ app.post("/internal/tools/store-render-ref", async (req, res) => {
sessionId: context.clientSessionId,
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: context.traceId,
workspaceDirectory: context.workspaceDirectory,
});
res.json({
ok: true,
@@ -618,6 +741,7 @@ app.use(
);
const bootstrap = async () => {
sandboxProbe = await probeLandlockSandbox();
await Promise.all([
sessionMetadataStore.initialize(),
sessionUiStateStore.initialize(),
+70 -6
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { mkdtemp, rm, stat, writeFile } from "node:fs/promises";
import { mkdir, mkdtemp, rm, stat, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -12,13 +12,18 @@ import {
describe("ResultReferenceResolver", () => {
let tempDir: string;
let importRoot: string;
let conversationWorkspace: string;
let store: ResultReferenceStore;
let resolver: ResultReferenceResolver;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "tjwater-result-ref-"));
store = new ResultReferenceStore(tempDir, 60_000);
resolver = new ResultReferenceResolver(store, tempDir, 1024 * 1024);
importRoot = join(tempDir, "conversation-workspaces");
conversationWorkspace = join(importRoot, "conversation-1");
await mkdir(conversationWorkspace, { recursive: true });
store = new ResultReferenceStore(join(tempDir, "refs"), 60_000);
resolver = new ResultReferenceResolver(store, importRoot, 1024 * 1024);
await store.initialize();
});
@@ -127,7 +132,7 @@ describe("ResultReferenceResolver", () => {
});
it("registers render refs from local wrapper files and normalizes payloads", async () => {
const filePath = join(tempDir, "render-wrapper.json");
const filePath = join(conversationWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify(
@@ -166,6 +171,7 @@ describe("ResultReferenceResolver", () => {
sessionId: "session-3",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-3",
workspaceDirectory: conversationWorkspace,
});
expect(record.kind).toBe(RESULT_REFERENCE_KIND.renderJunctionsPayload);
@@ -218,6 +224,7 @@ describe("ResultReferenceResolver", () => {
sessionId: "session-4",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-4",
workspaceDirectory: outsideDir,
}),
).rejects.toThrow("RESULT_REF_IMPORT_DIR");
} finally {
@@ -226,9 +233,9 @@ describe("ResultReferenceResolver", () => {
});
it("rejects oversized render payload files before parsing", async () => {
const filePath = join(tempDir, "oversized.json");
const filePath = join(conversationWorkspace, "oversized.json");
await writeFile(filePath, "x".repeat(128), "utf8");
const sizeLimitedResolver = new ResultReferenceResolver(store, tempDir, 64);
const sizeLimitedResolver = new ResultReferenceResolver(store, importRoot, 64);
await expect(
sizeLimitedResolver.registerRenderPayloadFile(filePath, {
@@ -238,8 +245,65 @@ describe("ResultReferenceResolver", () => {
sessionId: "session-5",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-5",
workspaceDirectory: conversationWorkspace,
}),
).rejects.toThrow("RESULT_REF_IMPORT_MAX_BYTES");
});
it("rejects render payload files owned by another conversation workspace", async () => {
const otherWorkspace = join(importRoot, "conversation-2");
await mkdir(otherWorkspace);
const filePath = join(otherWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-6",
clientSessionId: "client-6",
projectKey: "project-key-6",
sessionId: "session-6",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-6",
workspaceDirectory: conversationWorkspace,
}),
).rejects.toThrow("current conversation workspace");
});
it("rejects a conversation workspace that is itself a symbolic link", async () => {
const targetWorkspace = join(importRoot, "conversation-target");
const linkedWorkspace = join(importRoot, "conversation-linked");
await mkdir(targetWorkspace);
await symlink(targetWorkspace, linkedWorkspace, "dir");
const filePath = join(targetWorkspace, "render-wrapper.json");
await writeFile(
filePath,
JSON.stringify({
metadata: {},
location: { file_path: filePath },
data: { node_area_map: { J1: "DMA-1" } },
}),
"utf8",
);
await expect(
resolver.registerRenderPayloadFile(filePath, {
actorKey: "actor-7",
clientSessionId: "client-7",
projectKey: "project-key-7",
sessionId: "session-7",
source: RESULT_REFERENCE_SOURCE.agentGenerated,
traceId: "trace-7",
workspaceDirectory: linkedWorkspace,
}),
).rejects.toThrow("symbolic link");
});
});
+68
View File
@@ -129,4 +129,72 @@ describe("permission approval policy", () => {
title: "已按始终允许模式放行",
});
});
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",
"rm -Rf ./target",
"/bin/rm -rf ./target",
"command rm --force --recursive ./target",
"env LANG=C rm -r -f ./target",
"SAFE=1 rm -rf ./target",
"env -u HOME rm -rf ./target",
"r\"\"m -rf ./target",
"(rm -rf ./target)",
"! rm -rf ./target",
"npm test && rm --recursive --force ./target",
])("rejects direct recursive force removal in always mode: %s", (command) => {
expect(
resolvePermissionApproval("always", "bash", {
metadata: { command },
patterns: [command],
}),
).toMatchObject({
autoApprove: false,
autoReject: true,
title: "已拒绝递归强制删除",
});
});
it.each(["rm tmp.txt", "rm -f tmp.txt", "rm -r tmp-dir", "echo 'rm -rf tmp'"])(
"allows non-force-recursive or non-executed removal text in always mode: %s",
(command) => {
expect(
resolvePermissionApproval("always", "bash", {
metadata: { command },
patterns: [command],
}),
).toMatchObject({ autoApprove: true, autoReject: false });
},
);
});
+48
View File
@@ -435,6 +435,54 @@ describe("streamPromptResponse", () => {
expect(events.some((item) => item.event === "permission_request")).toBe(false);
});
it("rejects recursive force removal even in always mode", async () => {
const replies: Array<Record<string, unknown>> = [];
const runtime = {
subscribeEvents: async () =>
createEventStream([
{
type: "permission.asked",
properties: {
id: "perm-always-rm-rf",
sessionID: "runtime-session-1",
permission: "bash",
patterns: ["/bin/rm -rf ./target"],
metadata: { command: "/bin/rm -rf ./target" },
always: ["/bin/rm -rf ./target"],
},
},
{ type: "session.idle", properties: { sessionID: "runtime-session-1" } },
]),
prompt: async () => undefined,
messages: async () => [],
replyPermission: async (options: Record<string, unknown>) => replies.push(options),
} as unknown as OpencodeRuntimeAdapter;
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
await streamPromptResponse({
runtime,
sessionId: "runtime-session-1",
clientSessionId: "client-session-1",
message: "delete recursively",
approvalMode: "always",
write: (event, data) => events.push({ event, data }),
});
expect(replies).toEqual([
{
requestId: "perm-always-rm-rf",
sessionId: "runtime-session-1",
reply: "reject",
},
]);
expect(events.some((item) => item.event === "permission_request")).toBe(false);
expect(events.find((item) => item.event === "permission_response")?.data).toEqual({
session_id: "client-session-1",
request_id: "perm-always-rm-rf",
reply: "reject",
});
});
it("forwards opencode v2 permission requests as SSE payloads", async () => {
const runtime = {
subscribeEvents: async () =>
+76
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "bun:test";
import { type OpencodeClient } from "@opencode-ai/sdk/v2";
import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join, relative } from "node:path";
import { config } from "../../src/config.js";
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
@@ -87,6 +90,79 @@ describe("OpencodeRuntimeAdapter.ensureClient", () => {
});
});
describe("OpencodeRuntimeAdapter.createSession", () => {
it("creates a real chat session inside a dedicated conversation workspace", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
const calls: Array<Record<string, unknown>> = [];
const client = {
session: {
create: async (input: Record<string, unknown>) => {
calls.push(input);
return {
data: {
id: "runtime-session-1",
directory: input.directory,
},
};
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
try {
const session = await runtime.createSession("chat", {
conversationWorkspace: true,
workspaceRoot,
});
const directory = String(calls[0]?.directory);
expect(relative(workspaceRoot, directory).startsWith("..")).toBe(false);
const workspaceStat = await stat(directory);
expect(workspaceStat.isDirectory()).toBe(true);
expect(workspaceStat.mode & 0o777).toBe(0o700);
expect(session.directory).toBe(directory);
expect(calls[0]?.permission).toEqual([
{ permission: "read", pattern: `${directory}/**`, action: "allow" },
{ permission: "edit", pattern: `${directory}/**`, action: "ask" },
]);
} finally {
await rm(workspaceRoot, { force: true, recursive: true });
}
});
it("removes an empty conversation workspace when session creation fails", async () => {
const workspaceRoot = await mkdtemp(join(tmpdir(), "tjwater-conversations-"));
const client = {
session: {
create: async () => {
throw new Error("session creation failed");
},
},
} as unknown as OpencodeClient;
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
clientPromise: null,
closeServer: null,
ensureClient: async () => client,
}) as OpencodeRuntimeAdapter;
try {
await expect(
runtime.createSession("chat", {
conversationWorkspace: true,
workspaceRoot,
}),
).rejects.toThrow("session creation failed");
expect(await readdir(workspaceRoot)).toEqual([]);
} finally {
await rm(workspaceRoot, { force: true, recursive: true });
}
});
});
describe("OpencodeRuntimeAdapter.warmup", () => {
it("initializes the project session and model tools before reporting ready", async () => {
const calls: string[] = [];
+156 -2
View File
@@ -1,7 +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<string, string>;
};
const toolPackage = JSON.parse(toolPackageText) as {
dependencies?: Record<string, string>;
};
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<string, string | Record<string, string>>;
@@ -23,8 +45,140 @@ describe("internal OpenCode permissions", () => {
expect(edit?.["data/**"]).toBe("deny");
expect(edit?.["**/logs/**"]).toBe("deny");
expect(bash?.["*"]).toBe("ask");
expect(bash?.["rm *"]).toBe("ask");
expect(bash?.["rm -rf *"]).toBe("deny");
expect(bash?.["rm -fr *"]).toBe("deny");
expect(bash?.["rm -r -f *"]).toBe("deny");
expect(bash?.["rm -f -r *"]).toBe("deny");
expect(bash?.["rm --recursive --force *"]).toBe("deny");
expect(bash?.["rm --force --recursive *"]).toBe("deny");
expect(bash?.["*.env*"]).toBe("deny");
expect(bash?.["*data/*"]).toBe("deny");
expect(bash?.["*logs/*"]).toBe("deny");
expect(bash?.["*data/*"]).toBeUndefined();
expect(bash?.["*logs/*"]).toBeUndefined();
});
});
describe("store_render_ref arguments", () => {
it("accepts the observed camelCase alias without changing snake_case precedence", () => {
expect(
resolveStoreRenderFilePath({
filePath: "/app/data/conversation-workspaces/chat-1/partition.json",
}),
).toBe("/app/data/conversation-workspaces/chat-1/partition.json");
expect(
resolveStoreRenderFilePath({
file_path: "/app/data/conversation-workspaces/chat-1/preferred.json",
filePath: "/app/data/conversation-workspaces/chat-1/compatibility.json",
}),
).toBe("/app/data/conversation-workspaces/chat-1/preferred.json");
});
it("forwards a camelCase compatibility argument as file_path", 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('{"render_ref":"res-test"}');
}) as unknown as typeof fetch;
try {
const definition = storeRenderRef as unknown as {
args: Record<string, unknown>;
execute: (args: unknown, context: unknown) => Promise<unknown>;
};
expect(definition.args.filePath).toBeDefined();
await definition.execute(
{
reason: "regression test",
filePath: "/app/data/conversation-workspaces/chat-1/partition.json",
},
{ sessionID: "session-test" } as never,
);
} finally {
globalThis.fetch = originalFetch;
}
expect(requestBody).toEqual({
session_id: "session-test",
file_path: "/app/data/conversation-workspaces/chat-1/partition.json",
});
});
});
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<unknown>;
};
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<unknown>;
};
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,
});
});
});
+4
View File
@@ -19,6 +19,7 @@ describe("runtime session context", () => {
projectKey: "project-1",
sessionId: "runtime-session-1",
traceId: "trace-1",
workspaceDirectory: "/app/data/conversation-workspaces/chat-session-1",
});
const runtimeContext = getRuntimeSessionContext("runtime-session-1");
@@ -27,6 +28,9 @@ describe("runtime session context", () => {
expect(runtimeContext?.clientSessionId).toBe("chat-session-1");
expect(runtimeContext?.network).toBe("fengyang");
expect(runtimeContext?.sessionId).toBe("runtime-session-1");
expect(runtimeContext?.workspaceDirectory).toBe(
"/app/data/conversation-workspaces/chat-session-1",
);
removeRuntimeSessionContext("runtime-session-1");
expect(getRuntimeSessionContext("runtime-session-1")).toBeNull();
+98
View File
@@ -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<RuntimeSessionContext> => {
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");
});
});
+94
View File
@@ -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");
}
});
});
+73
View File
@@ -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<string, string> };
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" });
});
});