agent-chat-tenant-isolation.test.js
14.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
/**
* 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。
*
* ── 隔离 ───────────────────────────────────────────────────────────────
* 端点的根目录是硬编码的 `<repo>/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');
});
});