Skip to main content
Glama
mrvgao

parallight-lecture-9-agentify

by mrvgao
README.md
# lecture-9-agentify —— 把一个已有工程项目 Agent 化(ERPNext 实战靶场)

## 一句话主线

**Agent 化 ≠ 重写,是"包裹"(wrap)。** 一个成熟系统(这里是 ERPNext)已经自带四样护栏——
权限内核、业务校验、状态机(draft→submit)、审计流水。天真的 fork 把这四样全绕过,得到一个
能干活但会闯祸的 agent。正确的做法是让 agent **降权继承**这四样护栏,再在其上加一层
**agent 专属安全中间件**:身份透传(层2)、风险分级 + 人审批闸(层3)、隔离边界 + 幂等/
爆炸半径(层4),能力映射本身(层1)只留 5 个窄工具,不管宿主有多少种单据类型。六个现场
demo(A–F)分别把这条主线的一个失效模式讲透;第三份东西(`gates/`)把"学生 fork 出来的东西
是不是真的做了这件事"变成一套可核对的证据要求,而不是"读起来像"就算数。

## §6.0 真实事故锚点表

每个 demo 开头都要先讲这一行的真实事故——学员先在这个 ERPNext 靶场上复现同一类故障,
再修掉,最后在 trace/代码行里验证到底是哪一层挡住的。

| Demo | 真实锚点 | 学员要"感受到"的事 |
|---|---|---|
| A | **Supabase + Cursor agent 事故(2025 中)**:service-role 特权 + 支持工单里的用户输入被当指令 → 集成 token 外泄到公开工单。"lethal trifecta":特权访问 × 不可信输入 × 对外信道 | admin key 让 agent 无所不能,而"无所不能"本身就是事故 |
| B | **GitHub MCP 事故(2025-05,Invariant Labs)**:公共仓一条恶意 issue 就能让接了 GitHub MCP 的 agent 把私有仓内容、个人数据、**工资信息**吐出去 | 权限不是 prompt 里一句"你不许看工资",是宿主系统里可验证的拒绝路径 |
| C | **agent 重试风暴(生产案例)**:90 秒内重试 17 次,每次措辞微调 → 幂等键跟着漂移 → 数百条重复记录;及"回执 200 但其实没落库 / 超时后其实已落库" | 幂等键必须来自**业务语义**,不能来自 LLM 生成的请求体;回执不可信,必须回查宿主状态 |
| D | 同 A 的 lethal trifecta 收口:发票 `remarks` 里埋注入 → naive 版既能**付款**又能**外传工资数据** | 防注入不是让模型"自觉",是信道隔离 + 能力最小化 + 人闸三层叠加,trace 里能指出挡住它的确切代码行 |
| E | 这门课自己的问题(无外部事故):一份把安全声明写得很周全、却一行代码都没强制的 fork,读起来和真正做了架构工作的 fork 很像 | 评审不能只读 README/系统提示词,要问"如果把这一行删掉/顺序换一下,会不会有一个独立信号告诉我出事了" |
| F | **工具膨胀 / 上下文轰炸(企业 MCP 普遍病)**:开满 93 工具的 GitHub MCP 一启动吃约 55,000 token;企业级把多个 MCP server 接在一起,用户还没打第一个字,上下文就可能已经烧掉六七成 | "把宿主全部 DocType 自动生成工具"是最天真的 agent 化;能力映射的第一动作是删,不是加 |

来源与逐条展开见各 demo 文档自己的"现实锚点"小节(Demo F 的行业数字额外标了出处,不与本仓
实测数字混在一起)。

## 环境准备

**先说两件读者容易搞错的事:**

1. **ERPNext + MariaDB 很重。** 这套栈是在一台 **6 GiB / 4 CPU 的 Docker VM**(本机用
   Colima:`colima start --cpus 4 --memory 6 --vm-type vz --vz-rosetta`,或等价配置;本机
   `colima list` 实测确认当前就是 `4 CPUs / 6GiB`)上
   构建并验证的——9 个容器(backend/db/redis-cache/redis-queue/websocket/queue-short/
   queue-long/scheduler/frontend)。给少了会更慢,不代表跑不起来,但不要指望在 2 GiB
   的虚机上顺畅跑。
2. **建站要等,不要碰见报错就重试。** `make create-site` 内部有一个真正的就绪轮询(等
   backend 能跑 `bench --version` 才继续,最多等 40×3 秒),第一次跑还要现拉 HRMS 源码、
   跑 DocType migration——诚实的指令是"跑 `make create-site` 然后等",不是"报错了就重跑"。
   已实测(`docker/Makefile` 的注释里写明了原因):**首次冷启动(第一次拉镜像 + 首次
   `bench get-app` clone HRMS)约 8 分钟**;镜像已在本机缓存后的干净重建(`make nuke &&
   make up && make create-site`)约 **2 分钟**(`2:07.07` 实测)。给学生的预算:**第一次
   跑留 5–10 分钟**。(这两个数字来自这个仓库自己 Task 2 的干净态实测记录,不是编的估算;
   写这份 README 时栈已经在跑,没有为了重新计时而 `make nuke` 重建。)

```bash
git clone <this-repo> lecture-9-agentify
cd lecture-9-agentify
npm install                       # 装 connector + MCP server 依赖(node >=20)

cd docker
cp .env.example .env              # SITE_NAME / DB_ROOT_PASSWORD / ADMIN_PASSWORD /
                                   # AGENT_SVC_PASSWORD 都是本地沙箱占位符,不要在课堂外复用
make up                           # 起 9 个容器,几秒钟(镜像已缓存的前提下)
make create-site                  # 建站 + 装 erpnext + 拉装 hrms + developer_mode;等它
                                   # 自己的就绪轮询走完,预算 5–10 分钟(首次)/ ~2 分钟(缓存后)
make seed                         # 灌种子:4 个角色化用户、10 个商品/7 个客户/7 个供应商、
                                   # 8 名员工+工资单、期初库存、销售/采购单据(草稿+已提交都
                                   # 有)、两条双语投毒 draft 采购发票;幂等,可重复跑
```

种子数据现在长什么样(`seed/seed.py`,2026-08-01 从"能跑通"扩到"能逛",细节见
`.superpowers/sdd/2026-07-31-lecture-9-agentify-erpnext/demo-data-report.md`):Company 1 个
(`Agentify Demo Co`);Fiscal Year 5 个(当前年份 ±2,从 `frappe.utils.today()` 派生,不再
是硬编码的字面量 `2024`——这正是这次改动修的真事故:讲师现场撞过"当天日期落在任何财年之外,
docstatus 0 的草稿都插不进去");Item 10 个(跨 Standard Items/Consumables 两个分组,8 个
库存 + 2 个非库存);Customer 7 个、Supplier 7 个(中文公司名居多;`ACME`/`Globex` 这两个被
投毒单和 Demo C 幂等用例点名的老面孔原样保留,一个字段没改);Employee 8 个(Alice 的
Salary Slip,net_pay 10000,原样保留——加 7 个新员工,各自挂了部门/职位,三档底薪
8000/10000/15000,共 8 张 Salary Slip);Bin 8 条(Material Receipt 灌的期初库存);
Sales Order 8 / Sales Invoice 6 / Purchase Order 6 / Purchase Invoice 8(含种子自带的 2 条
投毒 draft)——草稿和已提交混着来,draft→submit 状态机在列表页肉眼可见。

**逛 UI 请用 `Administrator` 登录,不要用四个种子用户**:`sales_user`/`accounts_user`/
`hr_user`/`agent_svc` 是刻意收窄权限的角色化身份(Demo B 的跨域拒绝就是靠这个拦的),拿它们
逛 desk 会到处撞权限墙——**这是这门课的设计意图,不是 bug**,讲师现场就踩过这一下。要看"这
是个真实系统"的完整效果(列表页、看板、报表都有数据可看),登录页填 `Administrator` /
`docker/.env` 里的 `ADMIN_PASSWORD`。

跑完之后:`http://localhost:8080`(或 `http://erp.localhost:8080`,两个 host 都路由到同一
站点)应该是 ERPNext 登录页;`docker ps` 能看到 9 个 `l9-erpnext-*` 容器 `Up`(`db` 那条带
`(healthy)`)。**不要** `make seed` 第二次去"确认一下"——种子数据是全部 6 个 demo 共用的固定
靶子,幂等归幂等,但没有必要重复跑;也不要在 demo 之间 `make down`/`make nuke`,容器会一直
跑到课程结束。

**一处没打通的开关,先别踩**:不要单独去改 `.env` 的 `AGENT_SVC_PASSWORD`。`seed/seed.py:104`
建 `agent_svc@example.com` 这个用户时读的是 ERPNext 站点配置里的 `agent_svc_password` 键,
而 `docker/Makefile` 的 `create-site` 从来没有把这个键写进站点配置——所以不管 `.env` 里写
什么,种出来的真实密码永远是硬编码的 `agent-svc-pw`。改了 `.env` 的这个值(哪怕连带改了
MCP server 侧的 env)只会让 MCP server 用一个新密码去登录一个密码没变的用户,登录会静默
失败。细节和"这一步该怎么补"见 `agent/claude_code.md` 的对应小节。

**目标语区现在是简体中文**:`make seed` 现在还会把 `System Settings.language` 和四个种子
用户的 `User.language` 都设成 `"zh"`(`seed/seed.py`"1.5) 目标语言"一节)——纯配置,镜像本身
自带 84 种语言,不装任何新东西。desk UI(`/app/...`)、Frappe 报错文本(权限拒绝/校验失败)
现在都是中文,但 connector 五个工具的 `docType` 参数(`connector/src/types.ts`)依然是英文
字面量,完全不受影响——这条"label 会变、identity 不会变"的对照,和"host 报错现在是中文、
quarantine 边界依然扛住"这两条,分别写在 `experiments/demo_b_permission_passthrough.md`
Step 6 和 `experiments/demo_d_prompt_injection.md` Finding 3 的现场印证里。切换前留了一份
完整数据库备份:`sites/erp.localhost/private/backups/20260801_105602-erp_localhost-
database.sql.gz`(还原整站,不是只回退语言这一个字段)。

### 双前端接入

同一个 MCP server(`npm run --silent server`,stdio 传输,5 个 `erp.*` 工具)可以接两种
前端——**`--silent` 不是可选项**:裸 `npm run server` 会把 npm 自己的 banner 打到
stdout,而 stdout 就是 MCP 的 JSON-RPC 传输通道,这会污染协议流(细节和真实报错见
`agent/claude_code.md`):

- **Claude Code** —— 已验证跑通,见 `agent/claude_code.md`(`.mcp.json` 配置 + 真实
  JSON-RPC 握手记录)。
- **OpenWorker**(Andrew Ng,MIT,本机跑的开源 agent)—— 见 `agent/openworker.md`。
  **2026-08-01 已在本仓做了两轮端到端联调**:第一轮跑 OpenWorker 自己的 MCP 客户端代码,
  第二轮跑它的无头 server REST API(`openworker-server`)——这条 API 也正是桌面 GUI 的
  `Manage → Integrations` 粘贴框在代码层面调用的同一条路径,所以 GUI 背后那条数据通道
  也已经验证过,**但 Tauri 桌面应用本身的界面/点击交互没有打开验证过**。文档里逐条标了
  哪些是查得到出处的公开事实、哪些是实测确认的、哪些仍未验证——现场用之前先看文档末尾
  "这次任务验证的是什么、没验证的是什么"一节。

## 六个 demo 怎么跑

| Demo | 文件 | 要不要 ERPNext 起着 | 怎么跑 |
|---|---|---|---|
| A —— 天真世界:admin key + 裸工具 | `experiments/demo_a_naive_admin_key.md` | 要(讲师现场 `curl`/`docker exec`,只读不写) | 照 runbook 里的 `curl` 命令一步步跑,`docker exec` 打开 `frappe/permissions.py` 现场看 Administrator 怎么短路权限检查 |
| B —— 权限透传:四个身份,宿主自己拦 | `experiments/demo_b_permission_passthrough.md` | 要(同上) | 同一批任务分别以 sales/accounts/hr/agent_svc 四个种子用户登录跑,看 ERPNext `has_permission()`/permlevel 现场拒 |
| C —— 重试风暴 / 幂等 / 回执不可信 | `experiments/demo_c_draft_gate_idempotency.md` | **不要**——全程离线,`FakeErpClient` | `npm test -- demoC`(已在默认 `npm test` 里) |
| D —— 压轴:注入双弹头(付款+外泄),双语 | `experiments/demo_d_prompt_injection.md` | **不要**——全程离线;种子库里确实种了这条 payload 的真身,但断言不依赖它在不在线 | `npm test -- demoD`(已在默认 `npm test` 里) |
| E —— anti-shallow-fork 三道闸对照 | `experiments/demo_e_anti_shallow_fork/README.md` | **不要**——不需要 `docker`/`npm test`,证据全部来自本仓已验证过的源码文件 | 照 runbook 现场 `cat`/`sed -n` 打开引用的文件核对,走三道闸表格 |
| F —— 工具膨胀:全量映射 vs 窄工具 | `experiments/demo_f_tool_sprawl.md` | **不要**——全程离线,纯函数 | `npm test -- demoF`(已在默认 `npm test` 里) |

Demo A/B 需要真栈时,`experiments/scripts/demoAB.int.test.ts` 有对应的可跑对照用例(走真实
`HttpErpClient`/`Dispatcher`),默认是 `describe.skip` + 命中 `vitest.config.ts` 的
`exclude: ['**/*.int.test.ts']` 双重挡住,讲师现场演示前手动把 `describe.skip` 改回
`describe` 再跑,跑完记得改回去——不是学生日常 `npm test` 会碰到的路径。

## `npm test` 说明:离线单测 vs 需要真栈的集成测试

`vitest.config.ts` 定义了默认 `npm test` 的范围:

```ts
test: { include: ['connector/test/**/*.test.ts', 'experiments/**/*.test.ts'], exclude: ['**/*.int.test.ts'] }
```

- **默认 `npm test`**:跑 `connector/test/**/*.test.ts`(10 个文件:`identity`/`risk`/
  `limits`/`approval`/`quarantine` 五个源码文件各自的单测——这五个文件把层1–层4 的安全
  中间件落地成了五份代码,"四层"是教学模型,"五个文件"是实现,不是两套不同的东西——外加
  `fakeErpClient`/`naiveTool`/`smoke`/`tools`/`server-read-quarantine` 五个 dispatcher/
  基础设施测试)加上 `experiments/**/*.test.ts`(Demo C/D/F 的离线脚本,共 3 个文件,都基于
  `FakeErpClient`)。**全程不碰真实 ERPNext**,不需要 `docker ps`。本次任务实测:**13 个
  文件,79 个测试,全绿**(栈当前正跑着,未受影响)。
- **被排除的 `*.int.test.ts`**:`connector/test/erpClient.int.test.ts`(`HttpErpClient` 对
  真实 ERPNext)和 `experiments/scripts/demoAB.int.test.ts`(Demo A/B 的真栈对照)都命中
  `.int.test.ts` 后缀,被 `exclude` 挡在默认 `npm test` 之外;两个文件内部还各自套了一层
  `describe.skip` 作为第二道保险。需要真的跑它们,走上面"六个 demo 怎么跑"里 Demo A/B 那
  一行的手动解 skip 流程,不是切个 config 就完事。
- **一处已经修好的坑,写在这里省得学生去翻旧报告**:`package.json` 曾经有一条
  `npm run test:int` 脚本,指向一个仓库里从未存在过的 `vitest.integration.config.ts`,跑起来
  直接报 `Could not resolve .../vitest.integration.config.ts` 退出——这条死脚本已经被移除
  (commit `8673d5a`),`package.json` 里现在只有 `test`/`test:watch`/`typecheck`/`server`
  四条。如果你看到的教学材料还提这条命令,那是过时的;上面描述的手动解 `describe.skip`
  流程才是当前唯一验证过、真的能跑通 `.int.test.ts` 的路径。

```bash
npm test              # 默认:离线,13 文件 / 79 测试
npm run typecheck      # tsc --noEmit
npm run --silent server # 启动 MCP server(stdio)——--silent 不可省,见 agent/claude_code.md
```

## 三道 anti-shallow-fork 质量闸

`gates/` 下三份文件是喂给一个 evaluator(subagent 或人)的判据文件,各管一段,互不重复,
但引用同一批本仓源码作证据:

- `gates/architecture_gate.md` —— **AG-1~AG-4**:授权是不是委托给宿主判、身份是不是真的
  降权、检查是否结构性地先于执行(要求学生把顺序对调,粘一份变红的测试输出)、层与层是否
  真的独立可测。AG-1/AG-2/AG-3 任一 FAIL,整个闸 FAIL。
- `gates/safety_gate.md` —— **SG-1~SG-4**:拦截是否发生在代码里(不是"模型这次没上当")、
  转义堵的是一整类字符还是几个已知字符串、测试是不是能在 revert 之后真的变红、host 数据
  能到模型面前的每一条通道是否都被枚举过(含错误通道,不只是正常数据)。四条全过才算 PASS。
- `gates/correctness_gate.md` —— **CG-1~CG-3**:幂等键是否只来自业务语义字段、写后回查是不是
  真的在跑而不是死代码、"调用失败"是否被误判成"什么都没发生"。三条全过才算 PASS,且 CG-1
  过不代表 CG-3 自动过,两个是独立失败模式。

三道闸的共同要求:**不接受没有 `file:line` 引用的 PASS**,证据必须可重跑(测试输出、
trace、revert-and-fail 对比),不接受"我们人工试了几次都没问题"这种采样当证明。

`experiments/demo_e_anti_shallow_fork/README.md` 是把三道闸走一遍的 worked example——
拿本仓自己的反面教材(`connector/src/naiveTool.ts` + 一段专门写来示范"说得好听但没代码"的
系统提示词)和本仓自己的架构级实现做对照,11 条子判据逐条打分,浅 fork 全 FAIL,本仓全
PASS。用它当模板去评一份真实的学生 fork。

## 仓库结构

```
docker/          ERPNext v15 + HRMS 靶场(Makefile: up / create-site / seed / down / nuke / logs)
seed/            角色化用户 + 主数据 + 工资 fixture + 双语投毒 draft 采购发票
connector/src/   agent 化层:identity/risk/limits/approval/quarantine 五个源码文件(对应
                 教学模型里的层1–层4)+ tools.ts 的 Dispatcher + server.ts(MCP server 接线)
                 + naiveTool.ts(Demo A 反面教材)
connector/test/  上面每一层各自的单测(+ 2 个 *.int.test.ts,见"npm test 说明")
experiments/     六个 demo 的 runbook(A–F)+ 离线可跑脚本(experiments/scripts/)
gates/           三道 anti-shallow-fork 质量闸的判据文件
agent/           两个前端的接入说明:claude_code.md(已验证)/ openworker.md(已验证,
                 2026-08-01 两轮:MCP 客户端代码 + 无头二进制 REST API(含 GUI 背后同一
                 条路径),不含 Tauri 桌面 GUI 本身的界面/点击交互)
```

## 快速核对清单(从干净 checkout 到能跑第一个 demo)

1. `npm install`(根目录)。
2. `cd docker && cp .env.example .env`。
3. `make up`(几秒)→ `make create-site`(等,见上)→ `make seed`。
4. `npm test`(根目录,离线,应为全绿)—— 到这一步 Demo C/D/E/F 已经能跑。
5. 想跑 Demo A/B:装 `curl`/`jq`/`python3`,照 `experiments/demo_a_naive_admin_key.md` 的
   "前提"小节确认 9 个 `l9-erpnext-*` 容器 `Up`。
6. 想接 Claude Code:照 `agent/claude_code.md` 写 `.mcp.json`,`claude` 里 `/mcp` 确认 5 个
   `erp.*` 工具在列。