/** * 28 号改造第二轮 —— agent-chat 链路的「公司段被丢弃」与「cwd 深度不一致」。 * * ── 锁的是哪两个回归 ────────────────────────────────────────────────── * 改造后 `agent-chat.js` 里只剩一个 `getAgentDir(id) = bodyDir(parseAddress(id).agentId)`, * 返回值同时被当成三样东西用:CLI 的 cwd、本体只读根、私有可写根。公司段在第一步就没了,于是: * * ① `/task/dispatch` 派给 `co-alpha:ceo` 和 `co-beta:ceo` 的任务行,追加进**同一份** * `system/agents/ceo/todo.md`。agent 自读时看到两家公司的任务混编,前端面板也是。 * ② `PUT/DELETE /:agentId/file/*` 把本体当可写根:按 `co-alpha:ceo` 寻址就能覆写 * `system/agents/ceo/agent.md`,co-beta 下一次调用立刻吃到 —— 跨租户写越权。 * ③ CLI 的 cwd 是本体目录(深度 1),而 agent-manager / graph 链路是 `{aid}/{cid}/{taskId}` * (深度 3)。两套深度并存时,agent.md 里 `../../../soul.md` 这类相对引用必有一半是错的, * 而读不到本体是**静默降级**成通用 Base Agent 文本,不报错。 * * 三条全都写在 gitignore 挡住的路径上(本体根上的 todo.md、本体下的所有公司态子目录), * `git status` 一片干净 —— 这正是它们能活到第二轮验收的原因。所以断言只看**磁盘上的落点**, * 不看响应码:响应码在三个 bug 下全都是 200。 * * ── 隔离 ─────────────────────────────────────────────────────────────── * 端点的根目录是硬编码的 `/system/agents`(无 env 覆盖口),所以 `zz-tenant-*` 这两份 * 本体是**真的建在注册表里**的常驻条目:28 号裁决「测试建出来的 agent 也有价值,不删、进版本库」, * 于是 afterAll 只清**公司态**(本体下的 {cid}/ 子树)与公司侧目录,本体目录本身留下。 * 顺带治了一个 flaky:本体被建了又删时,别的用例正在遍历整棵 `system/agents/`, * 撞上「列到了名字、进去时已经没了」→ `ENOENT: scandir system/agents/zz-tenant-boss`。 * 不用 `_` 前缀:那是 `_global` / `_base` 的保留段,会被 isReservedCompanyId 认成非公司项。 * * **图库必须另开一份**(下面 GRAPH_DB_PATH)。批 5 把 `/task/dispatch` 的存储从 * `system/tasklog/*.json` 换进了 `tasks` 表,而 afterAll 当时只会清那个目录 —— * 于是本文件每跑一次就往**共享的生产库** `data/graph.sqlite` 落 2 条 goal + 2 条 task, * 测试照样全绿。实测残留 25 条 `_global` goal 才被发现。 * * 这是本仓库第四次踩「测试污染共享库」(前三次 BENCHMARK_DB_PATH / USERS_DB_PATH / * SKILLS_DIR,记在 test-isolation-env.test.js 的文件头),但换了个方向进来: * 前三次是「设了 env、生产代码不读」,这次是**压根没设 env** —— 那条守卫查的是前者, * 没有可查的东西就查不出来。现由 graph-db.getDb() 的运行期闸门兜底(见该函数)。 */ process.env.LEMONBOSS_CLAUDE_MODE = 'mock'; // 必须在 require 路由之前:acp-core 的 mock 开关是模块级常量 import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import fs from 'fs'; import path from 'path'; import os from 'os'; import express from 'express'; import { createRequire } from 'node:module'; const require_ = createRequire(import.meta.url); const ROOT = path.resolve(__dirname, '../../..'); const AGENTS_ROOT = path.join(ROOT, 'system', 'agents'); // 一次性图库 —— 必须在 require 路由**之前**赋值:graph-db.js 的 DB_PATH 是模块级 const, // 晚一步就已经绑到生产库上了。目录用 mkdtempSync,不用时间戳拼名(见 test-id-stability)。 const TMP_DB_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'lemonboss-tenant-iso-')); process.env.GRAPH_DB_PATH = path.join(TMP_DB_DIR, 'graph.sqlite'); // 任务**文本**带一次性 nonce(不是 id):/task/dispatch 有 30 分钟去重窗口,文本固定的话 // 同一次跑里第二条会被 409 拦掉。(跨次跑已经不会撞了 —— 库是一次性的。) // 它拼的是自然语言,不进任何注册表,所以与「id 必须固定」那条规矩不冲突(见 test-id-stability)。 const RUN = `r${Date.now().toString(36)}`; const CEO = 'zz-tenant-ceo'; // 被两家公司共用的那一份本体 const BOSS = 'zz-tenant-boss'; // 派单方 const ALPHA = 'zz-tenant-alpha'; const BETA = 'zz-tenant-beta'; const bodyDir = (aid) => path.join(AGENTS_ROOT, aid); const companyDir = (aid, cid) => path.join(AGENTS_ROOT, aid, cid); const addr = (cid, aid) => `${cid}:${aid}`; const BODY_AGENT_MD = '# 本体 agent.md — 全系统只有这一份\n'; /** 常驻本体:每次跑按同样内容重写一遍(跨次跑不漂),三份定义写齐 = 注册表里是完整本体 */ function seedBody(aid) { fs.mkdirSync(bodyDir(aid), { recursive: true }); fs.writeFileSync(path.join(bodyDir(aid), 'config.json'), JSON.stringify({ id: aid, type: 'skill', name: aid, status: 'active' }, null, 2)); fs.writeFileSync(path.join(bodyDir(aid), 'identity.md'), `# ${aid}\n`); fs.writeFileSync(path.join(bodyDir(aid), 'agent.md'), BODY_AGENT_MD); fs.writeFileSync(path.join(bodyDir(aid), 'soul.md'), `# ${aid} 的人格\n\n单测用的常驻本体。\n`); // 上一次跑的公司态(含 ④ 往本体根写的那份「别家公司」user.md)先清干净, // 否则 ① 的 todo.md 断言验的是历史残留而不是这一次的落点 clearRuntimeState(); } /** 清运行态:本体下的公司态子树 + ④ 故意写在本体根上的历史遗留 user.md。本体定义不动。 */ function clearRuntimeState() { for (const aid of [CEO, BOSS]) { for (const cid of [ALPHA, BETA]) { fs.rmSync(companyDir(aid, cid), { recursive: true, force: true }); } fs.rmSync(path.join(bodyDir(aid), 'user.md'), { force: true }); fs.rmSync(path.join(bodyDir(aid), 'todo.md'), { force: true }); } } describe('agent-chat · 公司段不能丢(todo/plan 落点、文件写入根、CLI cwd)', () => { let base; let srv; beforeAll(async () => { for (const a of [CEO, BOSS]) seedBody(a); // 38 号文档 Phase 3:agent-chat.js 的 :agentId 现在会按 "companyId:role" 前缀反查公司 // 是不是真实存在、当前用户碰不碰得到——ALPHA/BETA 只是这份测试里的字符串标签, // 从没真的建过公司,不补的话请求会在归属校验这一关就被 404 挡下,走不到本文件真正 // 要测的"公司段有没有丢"这几条断言。 for (const cid of [ALPHA, BETA]) require_('../helpers/ensure-test-company').ensureTestCompany(cid); const app = express(); app.use(express.json()); app.use('/api/agent', require_('../../routes/agent-chat')); await new Promise((resolve) => { srv = app.listen(0, '127.0.0.1', () => { base = `http://127.0.0.1:${srv.address().port}`; resolve(); }); }); }); afterAll(async () => { if (srv) await new Promise((r) => srv.close(r)); // 本体留下(正式注册表条目),只清这次跑写出来的运行态 clearRuntimeState(); // 公司侧账单日志(data/companies/{cid}/logs/billing-*.log)也是本用例跑出来的残留 for (const cid of [ALPHA, BETA]) { fs.rmSync(path.join(ROOT, 'data', 'companies', cid), { recursive: true, force: true }); } // /task/dispatch 的 goal/task 行落在图库里,整份库是一次性的 —— 连目录一起删掉即可, // 不需要再按 to 字段挑行(那是 tasklog 时代的清理法,存储换进表之后就失效了)。 fs.rmSync(TMP_DB_DIR, { recursive: true, force: true }); }); const dispatch = (cid, task) => fetch(`${base}/api/agent/task/dispatch`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, // autoExecute:false —— 否则端点会 HTTP 自调用 /chat 去真跑一轮,本用例只关心落盘 body: JSON.stringify({ from: addr(cid, BOSS), to: addr(cid, CEO), task, autoExecute: false }), }); it('① 两家公司派给同一个本体的任务,落在各自的公司态目录里(本体上一个字都不写)', async () => { const ra = await dispatch(ALPHA, `alpha 的活:写季度战略 ${RUN}`); const rb = await dispatch(BETA, `beta 的活:整理竞品清单 ${RUN}`); expect(ra.status).toBe(200); expect(rb.status).toBe(200); const { taskId: alphaTask } = await ra.json(); const { taskId: betaTask } = await rb.json(); expect(alphaTask).not.toBe(betaTask); const alphaTodo = fs.readFileSync(path.join(companyDir(CEO, ALPHA), 'todo.md'), 'utf-8'); const betaTodo = fs.readFileSync(path.join(companyDir(CEO, BETA), 'todo.md'), 'utf-8'); // 各自看得到自己的活 expect(alphaTodo).toContain(alphaTask); expect(betaTodo).toContain(betaTask); // 且**看不到**对方的 —— 这一条才是 bug 的精确形态:混编时两个 taskId 会同时出现 expect(alphaTodo).not.toContain(betaTask); expect(betaTodo).not.toContain(alphaTask); // 本体上不该出现 todo.md:出现了就说明公司段又被丢了 expect(fs.existsSync(path.join(bodyDir(CEO), 'todo.md'))).toBe(false); }); it('① GET /todo 读的是写侧同一个落点(读写不同源 = 面板永远显示别人的活)', async () => { const r = await fetch(`${base}/api/agent/${addr(ALPHA, CEO)}/todo`); const { content } = await r.json(); expect(content).toContain('alpha 的活'); expect(content).not.toContain('beta 的活'); }); it('② PUT /file/* 的可写根在公司态目录下,改不到共享本体', async () => { const r = await fetch(`${base}/api/agent/${addr(ALPHA, CEO)}/file/agent.md`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: 'PWNED-BY-ALPHA' }), }); expect(r.status).toBe(200); // 本体一个字没变 —— co-beta 的 ceo 用的是同一份文件 expect(fs.readFileSync(path.join(bodyDir(CEO), 'agent.md'), 'utf-8')).toBe(BODY_AGENT_MD); // 内容确实写进了 alpha 自己的公司态目录(不是被静默丢弃) expect(fs.readFileSync(path.join(companyDir(CEO, ALPHA), 'agent.md'), 'utf-8')).toBe('PWNED-BY-ALPHA'); }); // `..` 走 URL 会被 WHATWG 解析器在客户端就归一化掉,所以整段用 %2e%2e%2f 编成单个 segment, // 让 express 在 decode 参数时才还原成 `../` —— 这才是攻击者真正发得出的形态。 const escaped = (rel) => rel.replace(/\.\./g, '%2e%2e').replace(/\//g, '%2f'); it('② PUT 的 `../` 逃逸被拒(path.join + startsWith 拦不住它)', async () => { for (const rel of ['../agent.md', '../../../package.json']) { const r = await fetch(`${base}/api/agent/${addr(ALPHA, CEO)}/file/${escaped(rel)}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content: 'ESCAPED' }), }); expect(r.status, `PUT ${rel} 应被拒`).toBe(403); } expect(fs.readFileSync(path.join(bodyDir(CEO), 'agent.md'), 'utf-8')).toBe(BODY_AGENT_MD); }); it('② DELETE 同样拦住逃逸,且删得掉自己公司态目录里的文件', async () => { const r = await fetch(`${base}/api/agent/${addr(ALPHA, CEO)}/file/${escaped('../agent.md')}`, { method: 'DELETE' }); expect(r.status).toBe(403); expect(fs.existsSync(path.join(bodyDir(CEO), 'agent.md'))).toBe(true); const own = await fetch(`${base}/api/agent/${addr(ALPHA, CEO)}/file/agent.md`, { method: 'DELETE' }); expect(own.status).toBe(200); expect(fs.existsSync(path.join(companyDir(CEO, ALPHA), 'agent.md'))).toBe(false); }); it('③ CLI 的 cwd 是深度 3 的任务态目录 {aid}/{cid}/{convId},不是本体目录', async () => { const convId = 'conv-zz-tenant-cwd'; const r = await fetch(`${base}/api/agent/${addr(ALPHA, CEO)}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '你好', conversationId: convId }), }); await r.text(); // SSE 流跑完(mock 模式不起真子进程) // 起 CLI 前会先把 cwd 建出来 —— 这是 cwd 推导唯一的可观测副作用 const cwd = path.join(companyDir(CEO, ALPHA), convId); expect(fs.existsSync(cwd), `期望 cwd 目录存在: ${cwd}`).toBe(true); // 深度必须恰好是 3:本体在 cwd 的 ../../../,agent.md 里的相对引用全按这个常量算 const rel = path.relative(AGENTS_ROOT, cwd).split(path.sep); expect(rel).toEqual([CEO, ALPHA, convId]); }); /** * ④ `user.md`(汇报关系)是**公司态**文件(28 §4.1 四层归属表),写侧是 * company-service._initCompanyState → `system/agents/{aid}/{cid}/user.md`。 * buildSystemPrompt 原来把它跟 agent.md / soul.md 一起从**本体目录**读,读写不同源。 * * 这条不是理论风险:本体根上真躺着 28 份 git 跟踪的历史 user.md, * `system/agents/claude-youtube-skills/user.md` 里写的是「所属公司: test」—— * idearun 的同一个 agent 在 chat 里会同时收到「你隶属于 idearun」和「你的公司是 test」, * 两段互相矛盾的归属,而正确的那份 `.../claude-youtube-skills/idearun/user.md` * 一个字都进不了提示词。两种都不报错,所以断言只能落在提示词文本上。 */ it('④ user.md 读的是公司态那份,不是本体上的历史遗留', async () => { const { ClaudeCliProtocol } = require_('../../../packages/acp-core/protocol/ClaudeCliProtocol'); const orig = ClaudeCliProtocol.prototype.sendPrompt; let seen = ''; ClaudeCliProtocol.prototype.sendPrompt = function (text) { seen = String(text); return orig.call(this, text); }; // 本体根上放一份「别家公司」的 user.md(= 仓库现状那 28 份的形态) fs.writeFileSync(path.join(bodyDir(CEO), 'user.md'), '# 与上级的关系\n\n## 所属公司\n- **公司**: zz-other-co\n'); // 公司态那份才是 hire 时按本公司写出来的 fs.mkdirSync(companyDir(CEO, ALPHA), { recursive: true }); fs.writeFileSync(path.join(companyDir(CEO, ALPHA), 'user.md'), `# 与上级的关系\n\n## 所属公司\n- **公司**: ${ALPHA}\n`); try { const r = await fetch(`${base}/api/agent/${addr(ALPHA, CEO)}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ message: '你好', conversationId: 'conv-zz-tenant-user' }), }); await r.text(); } finally { ClaudeCliProtocol.prototype.sendPrompt = orig; } expect(seen).toContain(`- **公司**: ${ALPHA}`); expect(seen).not.toContain('zz-other-co'); }); });