agent-chat-tenant-isolation.test.js 14.9 KB
/**
 * 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');
  });
});