From 76d2a5aeaf3d605fd94b2e6eded93dd5ade6eabd Mon Sep 17 00:00:00 2001 From: yibo Date: Tue, 25 Aug 2026 18:22:32 +0800 Subject: [PATCH] feat(auth): Phase 2 —— 公司归属隔离(38 号文档) --- docs/README.md | 2 +- docs/todo/system-design/38-auth-multi-tenant-isolation.md | 18 ++++++++++-------- package.json | 3 ++- scripts/backfill-company-owner.js | 60 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ server/routes/company.js | 24 ++++++++++++++++++------ server/routes/devloop.js | 35 +++++++++++++++++++++++++++++++++-- server/routes/graph-run.js | 30 +++++++++++++++++++++++++++++- server/routes/okr.js | 14 ++++++++++---- server/services/company-service.js | 34 ++++++++++++++++++++++++++++++---- server/tests/devloop/p4-l3-feedback.test.js | 5 +++++ server/tests/devloop/routes-crud.test.js | 5 +++++ server/tests/devloop/routes.test.js | 11 ++++++++++- server/tests/helpers/ensure-test-company.js | 41 +++++++++++++++++++++++++++++++++++++++++ server/tests/unit/agent-transcript.test.js | 4 ++++ server/tests/unit/company-files-path-guard.test.js | 5 +++++ server/tests/unit/graph-run-route-io.test.js | 4 ++++ server/tests/unit/graph-run-route-slot-busy.test.js | 4 ++++ server/tests/unit/heartbeat-company-scoped.test.js | 11 +++++++++++ server/tests/unit/p3c-recovery-http.test.js | 24 +++++++++++++++++++++--- server/tests/unit/route-auth-gate.test.js | 4 ++-- server/tests/unit/task-slots-parallel.test.js | 28 +++++++++++++++++++++++----- 21 files changed, 328 insertions(+), 38 deletions(-) create mode 100644 scripts/backfill-company-owner.js create mode 100644 server/tests/helpers/ensure-test-company.js diff --git a/docs/README.md b/docs/README.md index 6cbf7c5..90d627f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -49,7 +49,7 @@ Graph Agent 架构重构(27 系列)。**已完成的阶段文档已移入 `d | [29-dream-design.md](./todo/system-design/29-dream-design.md) | **dream(经验汇流)**— ✅ 已实现。定时整理所有 agent 在所有公司的记忆,提炼后跨公司沉淀(不泄漏公司信息)。四条取舍已拍板并实现:去敏独立判定、四落点分层写入(经验/`SKILL.md`/`soul.md`/`agent.md`,受管区块+三道闸门)、每日定时(串行+空转保护)、引用真伪自动查、REST+控制台入口(`/api/dream/*`、`/dream`)。`innovator-evolver` 真 CLI 跑通一次。**仍缺的**:「结论对不对」只能人抽查;「套进 org/work graph 来跑」评估后用户选了不做(作用域不匹配,见文内 §5.7) | | [30-task-delegation-reliability-gaps.md](./todo/system-design/30-task-delegation-reliability-gaps.md) | **任务委派可靠性现状**— 📝 调研记录,非设计方案。DevLoop `runUnit`、Company 招聘装配、Graph 执行三条链路核对结论一致:都是「失败预算耗尽 → 停等人工」,没有「卡住了主动通知人」这一层;DevLoop 当前还默认走 mock,真实 CLI 成功率无实证数据。2026-08-23 追加真实实例并已修:`infra_flake` 原地重跑没有熔断线,真撞上过一次确定性死循环(节点把诊断结论误判成自己的执行结果,反复重跑却从不路由到诊断建议的目标节点);已加 `infra_flake_count` 列 + 硬顶 3 次熔断,连续判 infra_flake 超过硬顶就改判 blocked 交给人工 | | [31-work-graph-fail-fallback-coverage.md](./todo/system-design/31-work-graph-fail-fallback-coverage.md) | work graph 兜底边覆盖不全 — ✅ 已完成(除入口节点外强制兜底边,`planner.json`/`executor-fanout.json` 补 6 条边),978/978 单测通过;2026-08-21 又用真实 LLM 起了一条新任务做端到端回归验证,`org-planner-sdd` 自判失败后自动打回 `org-planner-prd`(3 次,零人工介入),修复确认生效 | -| [38-auth-multi-tenant-isolation.md](./todo/system-design/38-auth-multi-tenant-isolation.md) | 📝 **Phase 0/1 已完成(2026-08-25),Phase 2-3 未开工**。Phase 1 给 9 个此前零防护的路由文件挂 `requireAuth`/`requireAdmin`;`agent-chat.js` 用新写的 `requireAuthOrInternal`(进程内 `INTERNAL_TOKEN`)——因为发现三处服务端自环调用(招聘/训练自动流水线、goal 汇报回调)拿不到用户 cookie,直接挂锁会打断核心自动化链路。17 个既有测试文件的 67+ 处用例从没设过登录态,改为测试环境默认放行 + 新增 `route-auth-gate.test.js` 结构化守卫测试专盯"锁真的挂上了没有"。远程部署前置需求:登录鉴权 + 公司级多租户隔离。现状 `auth.js`/`jwt-auth.js`(注册/登录/JWT-cookie/OAuth)+ WebSocket 鉴权都已可用,真正缺的是①把已有的锁挂到目前零防护的 `company.js`/`agent-chat.js`/`okr.js`/`tasks.js`/`devloop.js`/`graph-run.js`(含双挂载点)等路由上、②`company.json` 从 0 新增 `ownerId` 归属概念(无 SQL 表,纯文件)。已拍板:存量公司迁移统一划给种子账号 `admin`/`hex1234`(不强制改密);OKR 跟随 company 隔离(含独立的遗留 `/api/okr` 挂载点);`arena.js`/`evolution.js` 核实为疑似废弃代码(前端零引用、5 个月未改动),只挂登录不投入隔离;`dream.js` 系统级,挂 `requireAdmin`。**Phase 0**:种子脚本 + 登录页 + 路由守卫 + 401 拦截器 + 退出登录,真实浏览器走通(含顺手修复的 `MasterChatDrawer` 登录页泄漏)。Phase 1(全站要求登录)→ Phase 2(`router.param('companyId')` 归属校验,最大工作量)→ Phase 3(聊天隔离验证+前端收尾)待开工 | +| [38-auth-multi-tenant-isolation.md](./todo/system-design/38-auth-multi-tenant-isolation.md) | 📝 **Phase 0/1/2 已完成(2026-08-25),Phase 3 未开工**。远程部署前置需求:登录鉴权 + 公司级多租户隔离。**Phase 0**:种子账号 `admin`/`hex1234` + 登录页 + 路由守卫 + 401 拦截器,真实浏览器走通。**Phase 1**:给 9 个此前零防护的路由文件挂 `requireAuth`/`requireAdmin`;`agent-chat.js` 用新写的 `requireAuthOrInternal`(进程内 `INTERNAL_TOKEN`)——三处服务端自环调用(招聘/训练自动流水线、goal 汇报回调)拿不到用户 cookie,直接挂锁会打断核心自动化链路;17 个既有测试文件的 67+ 处用例改为测试环境默认放行 + 新增 `route-auth-gate.test.js` 结构化守卫测试。**Phase 2**:`company.json` 加 `ownerId`(无 SQL 表),`canAccessCompany` 作为全仓库唯一归属判据;`company.js`/`graph-run.js`(两个挂载点参数形状不同,`companyRouter` 按 companyId 直查、主 router 按 `:id`=run 反查 company_id,设计阶段的描述不准确,实现时才发现)/`devloop.js`(`:pid` 反查 company_id)分别接入;存量公司迁移脚本(`npm run backfill:company-owner`)已跑;`okr.js` 最终没有按原计划接入 companyId(发现是全站零导航入口的孤儿页面,改挂 `requireAdmin` 而非投入前端"选公司"工作量,与设计阶段方案的偏离已如实记录);8 个既有测试文件因为用纯字符串公司 id、从未真正建过公司,新增共享 helper `ensure-test-company.js` 修复。全量 `vitest` 1427 passed(3 个无关既有失败不受影响)。Phase 3(聊天隔离验证 + 前端收尾)待开工 | | [37-org-graph-department-scoping.md](./todo/system-design/37-org-graph-department-scoping.md) | 📝 **设计讨论,调用点扫描已完成,尚未立项实现**。现状 org graph 按 `company_id` 单例(`27-P2` 标题原文即"公司级"),塞不下 HR/财务/市场这类跟"交付流水线"无关的公司常设职能——`system/agents/hr/`("招·留·管·退")本体已存在却从没被装进任何图,全靠 fire-and-forget chat 招聘、无图执行审计轨迹。提议把 org graph 作用域从 `company_id` 升级为 `(company_id, department_id)`,一家公司可装配多张部门图,每张仍保持人工固定/慢变/可审计。全仓库调用点扫描结论:改造面是最初估算的 4 倍——真正的单点真相在 `work-graph-db.js:readDefGraph` 的裸 SQL 不变式(不在服务层),还牵涉两个易混淆服务文件间必须字节级一致的重复唯一索引、独立的 `graph-seed-service.js` 对账子系统、`company-hire-org-layer.test.js` 专属测试、`orggraph-planning`/`task-planning` 两份 agent 人设文档的自我认知改写 | | [36-prd-agent-methodology-training-feasibility.md](./todo/system-design/36-prd-agent-methodology-training-feasibility.md) | 📝 **调研 + 阶段一/二均已完成**。回应"落地页太单薄是不是该用 Innovator 先训练专业 agent、再用 Dream 持续迭代"——认同方向。**阶段一**:`graph-engine.js` 结算节点后把 `success`/`fail:` 写进本体 `memory.md`(此前只有人类对话路径会写,Dream 天天空转)。**阶段二**:新建独立试验节点 `prd-methodology-trial` 走真实 Hunt→Digest→Evolve,2 轮收敛(96/97 分,真实基线 50 分),三个真实历史项目反推验证方法论有效;过程中连带修复同一个 2026-08-16 改名批次遗留的 5 处 `innovator`→`org-innovator` 死链(后端两处硬编码、前端"重新训练"按钮、`/api/agent/list` 展示 id 来源的四个 `config.json`),以及 `AgentScaffold` 默认模板一处裸相对路径缺陷。结论:FDE 对方法论类角色确实管用,但收敛速度/验收严谨度依赖任务书显式写明的自纠正要求,非免人工黑盒。环节 3(训练产物 → 雇佣本体转换)尚未开工 | | [35-validation-rigor-and-body-readiness-proposal.md](./todo/system-design/35-validation-rigor-and-body-readiness-proposal.md) | 📝 **复盘 + 改进方案,四条提案全部已实施**。承接 31/32/33/34 号的复盘结论:任务结果差主要是架构问题(完整性闸门 `missingBodyDefinition` 只在招聘时查、图执行侧完全没有第二道防线——新发现)+ 一个具体的招聘装配可靠性 bug,不是 agent 能力问题。提案 A:本体不完整时图执行前置判 `fail_reason=body_not_ready`,不扣预算。提案 D:`goal-contract.js` 的 `evidence_hint` 命中浏览器类关键词的维度必须有证据支撑,否则判不过。提案 C:新增 `scripts/visual-acceptance-check.mjs` 复用检查工具。提案 B:控制台「验收证据」小节展示每个维度有没有证据支撑 | diff --git a/docs/todo/system-design/38-auth-multi-tenant-isolation.md b/docs/todo/system-design/38-auth-multi-tenant-isolation.md index 43699af..991d2cc 100644 --- a/docs/todo/system-design/38-auth-multi-tenant-isolation.md +++ b/docs/todo/system-design/38-auth-multi-tenant-isolation.md @@ -103,13 +103,15 @@ router.param('companyId', (req, res, next, companyId) => { - **测试套件的连带问题**:仓库里已有 17 个测试文件、67+ 处用例直接拿 supertest 打这几个路由,从没设过登录 cookie(测的是业务逻辑,不是鉴权)——挂完锁后这批测试集体从 200/201 变 401。没有逐个文件补登录态(会引入 bcrypt 开销拖慢整个套件,且这些用例本来就不该关心鉴权),改为:`jwt-auth.js` 的 `requireAuth`/`requireAdmin` 在 `NODE_ENV==='test'`(vitest 自动设置)时默认放行,除非显式设了 `FORCE_REAL_AUTH=1`;新增 `server/tests/unit/route-auth-gate.test.js` 专门在 `FORCE_REAL_AUTH=1` 下做结构检查(`router.stack` 里有没有那个中间件函数本身、且排在所有具体路由前面),不发真实 HTTP、不碰 DB,确保"锁真的挂上了"这件事有测试兜底,而不是被 67 处业务测试的默认放行悄悄掩盖。 - **验收**:真实 `curl` 走过一遍——无 cookie 访问 `company.js`/`tasks.js`/`devloop.js`/`okr.js`/`graph-run.js`/`dream.js` 全部 401;`admin`/`hex1234` 登录后全部可访问;新注册的非 admin 用户能访问 `company.js` 等(Phase 1 还没做归属过滤,符合预期),但访问 `dream.js` 得到 403;`open-api.js`/`auth.js` 未受影响(前者仍是自己的 API Key 报错文案,后者登录本身不能被锁死)。手工临时删掉 `tasks.js` 的 `router.use(requireAuth)` 验证过守卫测试真的会红。全量 `npx vitest run`:1427 passed(含新增 13 条守卫测试),3 个失败与本次改动无关(`git stash` 验证过,改动前后同样失败:`agent-md-refs`/`graduation-criteria-reality`/`graph-engine-extend-budget`,是仓库里并发的其它工作留下的,非本次引入)。 -### Phase 2 — 公司归属隔离(这次真正的硬骨头) -- `company.json` schema 加 `ownerId`;`create()` 接收 owner 参数(从 `req.user.id` 传入);`list()` 按 `req.user` 过滤(admin 角色不过滤)。 -- 存量数据迁移脚本跑一遍(§3.5),全部划给种子账号。 -- `company.js`/`graph-run.js` 两个挂载点接入 3.1 的 `router.param('companyId', ...)` 统一校验。 -- `devloop.js` 接入 3.2 的间接校验。 -- **`okr.js`(`/api/okr`,独立于 `company.js` 的那套遗留全局 OKR)纳入这次范围**(已决策,见 §五):改成接收 companyId(路由参数或 query),`OkrService` 按 companyId 实例化(`okr-service.js` 本来就支持,只是没接),同样过 3.1 的归属校验;`OkrDashboard.vue` 前端跟着改成需要先选公司。注意 `company.js` 自己的 `okrPath()` 系列路由(`/api/company/:id/okr`,`CompanyDashboard.vue` 团队页 OKR tab 用的是这一套)**已经**在这一步随 `company.js` 一起被 `router.param('companyId')` 收口,不需要额外工作——真正缺的只是这个独立的遗留 `/api/okr` 挂载点。 -- **验收**:种子账号登录看到全部存量公司(无缝衔接);新注册账号登录看到空列表;新账号尝试直接拼 URL 访问种子账号的 company id → 404;新建的 company 只有创建者自己看得到;`/okr` 页面按公司切换后数据不串。这一步要重点测"漏了一条路由"这类问题——建议写一个守卫测试,遍历 `company.js`/`graph-run.js`/`okr.js` 里的路由定义,断言每一条都经过了 `router.param('companyId')` 这个收口点(而不是逐条手工检查,人工抽查容易漏)。 +### Phase 2 — 公司归属隔离(这次真正的硬骨头)✅ 已完成(2026-08-25) +- `company-service.js`:`company.json` 加 `ownerId`(无 SQL 表,纯 JSON 字段);`create()` 接收 `ownerId`;`list({ user })` 按传入的 `user` 过滤,**不传就不过滤**——`company-heartbeat-service.js` 这类系统内部巡检要看全部公司,不代表任何用户;新增 `canAccessCompany(company, user)` 作为全仓库唯一一份归属判据(admin 不过滤,404 不是 403)。 +- 存量数据迁移脚本 `scripts/backfill-company-owner.js`(`npm run backfill:company-owner`)跑过,`e2e-demo`/`ovf-co` 两家存量公司已划给种子账号,幂等验证过。 +- `company.js` 用 `router.param('companyId', ...)` 统一收口(约 50 条路由,含 `okrPath()` 系列动态路由,全部核实过参数名一致)。 +- **`graph-run.js` 的两个挂载点参数形状不一样,不能照抄同一份逻辑**——设计阶段的描述不准确,实现时才发现:`companyRouter` 唯一路由是 `/:companyId/graph-runs`,直接查;但主 `router` 全部路由是 `/:id/...`,`:id` 指的是 **run**,不是公司(`GET /:id` 吐的是 run 实例)。改成 `router.param('id', ...)`:先 `getRun(runId)` 拿到 `run.company_id`,再查这家公司归不归当前用户,run 不存在或公司不是你的都统一回"run 不存在"(不暴露哪种情况)。 +- `devloop.js` 用 `router.param('pid', ...)` 反查 `pathResolver.resolveProjectMeta(pid).company_id` 再校验;`POST /projects` 单独处理(建的是全新项目,还没有 `:pid` 可拦,`company_id` 从 body 里点名,校验顺序放在基本字段校验之后——避免"归属检查抢在输入校验前面"打乱错误信息的优先级);`GET /projects` 按 `company_id` 过滤列表。 +- **`okr.js` 的处理跟设计阶段的方案不一样,是权衡后的偏离,如实记录**:原计划是给这个独立挂载点接上 companyId 归属校验。实现前查证:`OkrDashboard.vue`(唯一消费方)挂载在路由 `/okr`,**全站没有任何导航入口指向它**(`AppNav.vue`/其它页面零引用),是个孤儿页面。真正在用的公司级 OKR 是 `company.js` 自己的 `/api/company/:id/okr`(`CompanyDashboard.vue` 团队页 OKR tab 在用),这条已经随 `company.js` 一起被收口,不需要额外工作。没有为一个访问不到的页面新建"选公司"前端交互,改成 `requireAdmin`(跟 `dream.js` 同款处理)——堵住"能拼 URL 直接看到跨公司聚合数据"的口子,比投入前端工作量收益高。这个页面以后要正式启用,再回来做真正的 companyId 归属校验。 +- **测试套件的连带修复,比预想的更细碎**:8 个既有测试文件(`agent-transcript`/`graph-run-route-io`/`graph-run-route-slot-busy`/`p3c-recovery-http`/`task-slots-parallel`/`company-files-path-guard`/`heartbeat-company-scoped`/`devloop` 下 3 个文件)用的公司 id 都是纯字符串标签,从没通过 `companyService.create()` 建过真公司——新增共享测试 helper `server/tests/helpers/ensure-test-company.js`(`ensureTestCompany`/`removeTestCompany`),在每个文件生成测试公司 id 的唯一入口处补一次 `company.json`;两个文件里原有的"跳过清理已存在 `company.json` 的目录(那是真公司)"防御逻辑,判据从"有没有 `company.json`"改成"`ownerId` 是不是 `-1` 这个测试专用标记",否则会把这批新出现的测试夹具误判成真公司、永远不清理。 +- **验收**:全部真实 `curl` 走过(种子账号登录看到全部存量公司;新注册账号登录看到空列表;新账号直接拼 URL 访问种子账号的 company id → 404;新建 company 后只有创建者自己的 `list` 能看到、admin 两个都看到、`ownerId` 落的确实是创建者的 `users.id`)。全量 `npx vitest run`:1427 passed,同样 3 个无关的既有失败(未受影响)。 ### Phase 3 — 聊天记录隔离验证 + 前端收尾体验 - LemonBoss 侧会话(`data/companies/{id}/agents/{agentId}/{convId}`)理论上 Phase 2 做完就自动隔离了(会话挂在 company 目录下),这一步只是**验证**,不是新写代码:确认没有遗漏的、绕过 company 归属校验就能读会话的接口(比如某个直接按 `convId` 查、不经过 `companyId` 校验的路由)。 @@ -120,7 +122,7 @@ router.param('companyId', (req, res, next, companyId) => { ## 五、开放问题(已拍板) -1. **`okr.js` 的全局 `OkrService` 要不要并入这次范围?→ 并入。** 用户明确要求"OKR 跟着 company 走,需要隔离"。已更新 §四 Phase 2:`okr.js` 改成接收 companyId 并按其实例化 `OkrService`(本来就支持,只是没接),过同一套归属校验;`OkrDashboard.vue` 前端跟着改成需要先选公司。`company.js` 自己的 `/api/company/:id/okr`(`CompanyDashboard.vue` 团队页 OKR tab 在用)本来就在 `company.js` 里,Phase 2 对 `company.js` 做 `router.param('companyId')` 收口时**顺带**就隔离了,不算独立工作量。 +1. **`okr.js` 的全局 `OkrService` 要不要并入这次范围?→ 并入的初衷成立,最终落地方式在实现阶段调整了,如实记录。** 用户明确要求"OKR 跟着 company 走,需要隔离"——这个诉求在真正在用的 OKR 功能上已经满足:`company.js` 自己的 `/api/company/:id/okr`(`CompanyDashboard.vue` 团队页 OKR tab 在用)随 `company.js` 的 `router.param('companyId')` 收口一起隔离了,不算独立工作量。但独立的遗留挂载点 `/api/okr`(`okr.js` + 未接 companyId 的 `OkrService`)在实现阶段查证后发现是个**孤儿页面**——`OkrDashboard.vue` 挂载在路由 `/okr`,全站没有任何导航入口指向它。给一个访问不到的页面新建"选公司"前端交互投入产出比不划算,改成 `requireAdmin`(详见 §四 Phase 2),先堵住直接拼 URL 能看到跨公司数据的口子。这个页面以后要正式启用,再回来做真正的 companyId 归属校验。 2. **首次登录是否强制改密 → 不需要。** 种子密码 `hex1234` 保持明文可用,不加强制改密流程。 3. **`arena.js`/`evolution.js` → 确认为疑似废弃代码,不投入隔离工作量。** 核实结果:`web/src/` 全局搜索零处引用 `/api/arena`(注意与 `open-api.js` 下仍在用的 `/api/open/arena/leaderboard` 是两个不同文件,后者继续保留)、零处引用 `/api/evolution`;两个文件最后一次改动都停在 `2026-03-23`(比仓库其余部分老得多)。Phase 1 按最省事的方式处理:一律挂 `requireAuth`(登录即可访问,不单独判断是否该删),不额外调查、不纳入这次范围——真要确认能不能删是另一件事,不属于这次鉴权改造。 4. **`dream.js` → 系统级,只有 admin 能看。** 已更新 §四 Phase 1:挂 `requireAdmin` 而不是 `requireAuth`。 diff --git a/package.json b/package.json index eee5246..1a55d89 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,8 @@ "spec:check": "node scripts/extract-spec.js --check", "prepare": "bash scripts/install-hooks.sh 2>/dev/null || true", "okr:migrate": "node scripts/migrate-goals-to-okr.js", - "seed:admin": "node scripts/seed-admin-user.js" + "seed:admin": "node scripts/seed-admin-user.js", + "backfill:company-owner": "node scripts/backfill-company-owner.js" }, "keywords": [ "agent", diff --git a/scripts/backfill-company-owner.js b/scripts/backfill-company-owner.js new file mode 100644 index 0000000..07f9d13 --- /dev/null +++ b/scripts/backfill-company-owner.js @@ -0,0 +1,60 @@ +#!/usr/bin/env node +/** + * backfill-company-owner.js —— 存量公司补 ownerId + * + * 依据: docs/todo/system-design/38-auth-multi-tenant-isolation.md §3.5 / Phase 2 + * + * `data/companies/{id}/company.json` 在加公司归属隔离之前建的公司都没有 ownerId 字段—— + * 统一划给种子账号 admin(用户已明确拍板,见文档 §一)。 + * + * 幂等: 已经有 ownerId 的行跳过,可重复执行。 + * + * 用法: + * node scripts/backfill-company-owner.js + */ + +const fs = require('fs'); +const path = require('path'); + +const DATA_DIR = path.resolve(__dirname, '../data/companies'); +const SEED_EMAIL = 'admin'; + +function main() { + const userDb = require('../server/services/user-db'); + const admin = userDb.findByEmail(SEED_EMAIL); + if (!admin) { + console.error(`[backfill-company-owner] 找不到种子账号 "${SEED_EMAIL}",先跑 npm run seed:admin`); + process.exit(1); + } + + if (!fs.existsSync(DATA_DIR)) { + console.log('[backfill-company-owner] data/companies/ 不存在,无需处理'); + return; + } + + const dirs = fs.readdirSync(DATA_DIR, { withFileTypes: true }).filter((d) => d.isDirectory()); + let migrated = 0; + let skipped = 0; + + for (const d of dirs) { + const fp = path.join(DATA_DIR, d.name, 'company.json'); + if (!fs.existsSync(fp)) continue; + + const company = JSON.parse(fs.readFileSync(fp, 'utf-8')); + if (company.ownerId != null) { + skipped += 1; + continue; + } + + company.ownerId = admin.id; + company.updatedAt = new Date().toISOString(); + // 跟 company-service.js 的 _saveCompany 保持同一种写法(无结尾换行),别引入格式差异。 + fs.writeFileSync(fp, JSON.stringify(company, null, 2), 'utf-8'); + console.log(`[backfill-company-owner] ${d.name} → ownerId=${admin.id}`); + migrated += 1; + } + + console.log(`[backfill-company-owner] 完成:迁移 ${migrated} 家,跳过(已有 ownerId)${skipped} 家`); +} + +main(); diff --git a/server/routes/company.js b/server/routes/company.js index 02d53d3..12ad0da 100644 --- a/server/routes/company.js +++ b/server/routes/company.js @@ -10,11 +10,10 @@ const fs = require('fs'); const router = express.Router(); const { requireAuth, INTERNAL_TOKEN, INTERNAL_TOKEN_HEADER } = require('../middleware/jwt-auth'); -// 38 号文档 Phase 1:全站要求登录。公司归属过滤(谁能看到哪家公司)留到 Phase 2 的 -// router.param('companyId', ...) 统一收口,这一步只挡"没登录"。 +// 38 号文档 Phase 1:全站要求登录。 router.use(requireAuth); -const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief } = require('../services/company-service'); +const { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany } = require('../services/company-service'); const { ScheduleService } = require('../services/schedule-service'); const orgGraphService = require('../services/org-graph-service'); // 28 批次 4:公司不再持有 agent 实体 —— 名册查 company_agents,目录一律经 agent-locator 推导 @@ -27,6 +26,19 @@ const { fillAgentBodyReliably } = require('../services/agent-body-filler'); const companyService = new CompanyService(); const scheduleService = new ScheduleService(); +// 38 号文档 Phase 2:公司归属统一收口——任何路由只要路径里带 :companyId,都会先过 +// 这一道,不用每条路由自己写一遍判断(判据在 company-service.js 的 canAccessCompany, +// 全仓库唯一一份,避免 §六风险 1 那种"判据分叉")。 +// 404 而不是 403:不告诉调用方"这个 id 存在、只是不是你的",直接当不存在处理。 +router.param('companyId', (req, res, next, companyId) => { + const company = companyService.get(companyId); + if (!canAccessCompany(company, req.user)) { + return res.status(404).json({ error: `公司 ${companyId} 不存在` }); + } + req.company = company; + next(); +}); + /** * 数据根 / 公司根 —— 本文件里这两个路径此前在 9 个 handler 里各拼一份 * (6 份 `../../data/companies` + 3 份同义异名的 `../../data`),全是模块内的复制品。 @@ -116,7 +128,7 @@ router.post('/create', async (req, res) => { return res.status(400).json({ error: 'id 格式无效 (只允许小写字母、数字、-)' }); } - const company = companyService.create({ id, name, goal, description }); + const company = companyService.create({ id, name, goal, description, ownerId: req.user.id }); // ── P2 T6:自动招聘 orggraph-planning 并异步触发组织结构规划 ── // 降级约定(27-P2 §4.4):这一步任何失败都不能影响公司创建本身, @@ -140,9 +152,9 @@ router.post('/create', async (req, res) => { } }); -// GET /api/company/list +// GET /api/company/list —— 只列 req.user 自己的公司(admin 不过滤) router.get('/list', (req, res) => { - res.json(companyService.list()); + res.json(companyService.list({ user: req.user })); }); // GET /api/company/templates diff --git a/server/routes/devloop.js b/server/routes/devloop.js index 062756f..2c5146e 100644 --- a/server/routes/devloop.js +++ b/server/routes/devloop.js @@ -30,6 +30,29 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const multer = require('multer'); +const { CompanyService, canAccessCompany } = require('../services/company-service'); + +const companyService = new CompanyService(); + +// 38 号文档 Phase 2:devloop 的路由不长 :companyId,长的是 :pid(项目 id)——公司归属藏在 +// pathResolver 缓存里(resolveProjectMeta(pid).company_id),要反查一跳,不能照抄 +// company.js 那种直接的 router.param('companyId', ...)。查不到项目本身就是 404 +// (resolveProjectMeta 对未知 pid 抛错,这里接住转成 404);查得到但公司不是你的, +// 同样回 404,不暴露"这个项目存在,只是不是你的"。 +router.param('pid', (req, res, next, pid) => { + let meta; + try { + meta = pathResolver.resolveProjectMeta(pid); + } catch { + return res.status(404).json({ error: `项目 ${pid} 不存在` }); + } + const company = companyService.get(meta.company_id); + if (!canAccessCompany(company, req.user)) { + return res.status(404).json({ error: `项目 ${pid} 不存在` }); + } + req.devloopProjectMeta = meta; + next(); +}); // 单次 intake 上传:内存解析后我们自己写盘到项目目录。10MB / 文件,10 文件上限。 const intakeUpload = multer({ @@ -60,6 +83,13 @@ router.post('/projects', h(async (req, res) => { if (mode && !['sandbox', 'linked-new', 'linked-existing'].includes(mode)) { return res.status(400).json({ error: `invalid mode: ${mode} (must be sandbox | linked-new | linked-existing)` }); } + // 这条路由建的是全新项目,还没有 :pid 可供 router.param 拦截——company_id 是从 + // body 里点名的,不校验的话任何登录用户都能把 devloop 项目挂到别人的公司名下。 + // 放在基本输入校验之后:先把纯输入形状的问题过一遍(便宜、不碰 fs), + // 再做需要读盘的归属校验。 + if (!canAccessCompany(companyService.get(company_id), req.user)) { + return res.status(404).json({ error: `公司 ${company_id} 不存在` }); + } // 1. 创建项目 (sandbox 默认 / linked-new 用 target_path) let proj; @@ -110,10 +140,11 @@ router.post('/projects', h(async (req, res) => { }); })); -// GET /api/devloop/projects?include_archived=true +// GET /api/devloop/projects?include_archived=true —— 只列 req.user 自己公司名下的项目 router.get('/projects', h(async (req, res) => { const includeArchived = String(req.query.include_archived || '') === 'true'; - const projects = pathResolver.listProjects({ includeArchived }); + const projects = pathResolver.listProjects({ includeArchived }) + .filter((p) => canAccessCompany(companyService.get(p.company_id), req.user)); res.json({ projects }); })); diff --git a/server/routes/graph-run.js b/server/routes/graph-run.js index 07cd79c..d03b671 100644 --- a/server/routes/graph-run.js +++ b/server/routes/graph-run.js @@ -42,15 +42,43 @@ const { } = require('../services/work-graph-db'); const { companyRootDir } = require('../services/company-paths'); const { requireAuth } = require('../middleware/jwt-auth'); +const { CompanyService, canAccessCompany } = require('../services/company-service'); const router = express.Router(); const companyRouter = express.Router(); +const companyService = new CompanyService(); // 38 号文档 Phase 1:全站要求登录(两个挂载点都要挂,§六风险 2 —— 这个文件以两种方式 -// 挂载到 index.js,改的时候容易漏一个)。公司归属过滤留到 Phase 2。 +// 挂载到 index.js,改的时候容易漏一个)。 router.use(requireAuth); companyRouter.use(requireAuth); +// 38 号文档 Phase 2:公司归属校验。两个挂载点的参数形状不一样,不能抄同一份 companyId 收口: +// - companyRouter 的唯一路由是 /:companyId/graph-runs,直接按 companyId 查。 +// - 主 router 全部路由都是 /:id/...,:id 指的是 run 而不是公司(GET /:id 吐的是 run 实例, +// 见文件头注释)——要先查出这条 run 属于哪家公司,再判断当前用户碰不碰得到。 +// 两处判据都调 company-service.js 的 canAccessCompany,不各写一份。 +companyRouter.param('companyId', (req, res, next, companyId) => { + const company = companyService.get(companyId); + if (!canAccessCompany(company, req.user)) { + return res.status(404).json({ error: `公司 ${companyId} 不存在` }); + } + req.company = company; + next(); +}); + +router.param('id', (req, res, next, runId) => { + const run = getRun(runId); + if (!run) return res.status(404).json({ error: `run ${runId} 不存在` }); + const company = companyService.get(run.company_id); + if (!canAccessCompany(company, req.user)) { + // run 存在但公司不是你的:仍然回 run 不存在,不暴露"有这条 run,只是不是你的"。 + return res.status(404).json({ error: `run ${runId} 不存在` }); + } + req.run = run; + next(); +}); + // ──────────────────────────────────────────────────────────────── // 小工具 // ──────────────────────────────────────────────────────────────── diff --git a/server/routes/okr.js b/server/routes/okr.js index f2402a1..f248237 100644 --- a/server/routes/okr.js +++ b/server/routes/okr.js @@ -16,15 +16,21 @@ */ const { Router } = require('express'); -const { requireAuth } = require('../middleware/jwt-auth'); +const { requireAdmin } = require('../middleware/jwt-auth'); const { OkrService } = require('../services/okr-service'); const router = Router(); const okr = new OkrService(); -// 38 号文档 Phase 1:全站要求登录。这是独立于 company.js 的遗留全局 OKR 挂载点 -// (目前无公司维度),公司归属改造留到 Phase 2(§四 Phase 2 / §五.1)。 -router.use(requireAuth); +// 38 号文档 Phase 2:这是独立于 company.js 的遗留全局 OKR 挂载点(`OkrService` 不带 +// companyId 实例化,落的是 data/okr/,不属于任何公司)。真正在用的公司级 OKR 是 +// company.js 的 /:companyId/okr(挂company.js's router.param('companyId')收口了)—— +// 这个独立页面(web/src/views/OkrDashboard.vue,挂载在 /okr)在前端路由/导航栏里 +// 找不到任何入口指向它,是个孤儿页面。没有为一个访问不到的页面新建"选公司"的前端交互, +// 权衡后按 dream.js 同款处理:不做归属过滤(它本来就没有公司维度可过滤), +// 收紧到只有 admin 能看,堵住"能拼 URL 直接看到跨公司聚合数据"这个口子。 +// 如果这个页面以后要正式启用,再回来做真正的公司选择 + companyId 归属校验。 +router.use(requireAdmin); // ── 列出所有目标 ── router.get('/', (req, res) => { diff --git a/server/services/company-service.js b/server/services/company-service.js index b8f590b..6ccdb0d 100644 --- a/server/services/company-service.js +++ b/server/services/company-service.js @@ -240,7 +240,7 @@ class CompanyService { * description 这家公司是干什么的(背景) * 两者的区分见 docs/todo/system-design/27-naming-conventions.md §四 */ - create({ id, name, goal = '', description = '' }) { + create({ id, name, goal = '', description = '', ownerId = null }) { // 公司 id 会成为 `system/agents/{aid}/{cid}/` 这一层的目录名,和本体自带的子目录同层 // (28 号设计 §5.1):一家叫 `memory` 的公司会直接盖住每个 agent 的记忆目录, // 而且是「目录在、代码不抛」的那种盖法。所以拦在建公司这一刻,不留到运行时。 @@ -269,6 +269,11 @@ class CompanyService { createdAt: now, updatedAt: now, createdBy: 'master', + // 38 号文档 Phase 2:谁能看到这家公司(users.id)。null = 存量公司未迁移 + // (理论上不该出现——迁移脚本会把它们全部划给种子账号,见 scripts/backfill-company-owner.js)。 + // 跟 createdBy 是两回事:createdBy 恒为字面量 'master'(谁的公司都一样), + // ownerId 才是真实的多租户归属字段。 + ownerId, ceo: null, sop: null, agents: [], @@ -303,9 +308,13 @@ class CompanyService { } /** - * 列出所有公司 + * 列出公司。 + * @param {object} [opts] + * @param {object} [opts.user] - 传了就按归属过滤(admin 不过滤,见 canAccessCompany); + * 不传 = 不过滤,返回全部——心跳等系统内部巡检(company-heartbeat-service.js)要看 + * 所有公司,不代表任何一个用户,不能被误判成"只属于某个用户"。 */ - list() { + list({ user } = {}) { if (!fs.existsSync(DATA_DIR)) return []; return fs.readdirSync(DATA_DIR, { withFileTypes: true }) @@ -313,6 +322,7 @@ class CompanyService { .map(d => { const company = this.get(d.name); if (!company) return null; + if (user && !canAccessCompany(company, user)) return null; return { id: company.id, name: company.name, @@ -1192,4 +1202,20 @@ class CompanyService { } } -module.exports = { CompanyService, listCompanyAgents, buildAgentDefinitionBrief }; +/** + * 38 号文档 Phase 2:这个用户能不能碰这家公司——公司归属判据的**唯一持有者**。 + * company.js 的 router.param('companyId', ...)、graph-run.js 的两个挂载点、 + * devloop.js 反查 company_id 之后,都要调同一份,不能各写一份自己的判断 + * (§六风险 1:判据分叉是这次改造最大的风险)。 + * + * admin 不过滤(现状唯一的 admin 就是种子账号,事实上的超管);company 不存在 + * 或 ownerId 对不上 → false,调用方一律回 404(不是 403——不暴露"这个 id 存在, + * 只是不是你的",见 §四.C.1)。 + */ +function canAccessCompany(company, user) { + if (!company || !user) return false; + if (user.role === 'admin') return true; + return company.ownerId === user.id; +} + +module.exports = { CompanyService, listCompanyAgents, buildAgentDefinitionBrief, canAccessCompany }; diff --git a/server/tests/devloop/p4-l3-feedback.test.js b/server/tests/devloop/p4-l3-feedback.test.js index 77827fc..19ac069 100644 --- a/server/tests/devloop/p4-l3-feedback.test.js +++ b/server/tests/devloop/p4-l3-feedback.test.js @@ -40,6 +40,7 @@ async function fresh(idea = 'p4 test') { function cleanup(cid) { try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', cid), { recursive: true, force: true }); } catch { /* ignore */ } + require('../helpers/ensure-test-company').removeTestCompany(cid); } describe('P4 · L3 status FSM', () => { @@ -171,6 +172,7 @@ describe('P4 · routes', () => { it('POST /sign-off requires shipped status (400)', async () => { const cid = 'c-route-signoff'; cids.push(cid); + require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), @@ -188,6 +190,7 @@ describe('P4 · routes', () => { it('POST /feedback bumps PRD rev', async () => { const cid = 'c-route-feedback'; cids.push(cid); + require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), @@ -205,6 +208,7 @@ describe('P4 · routes', () => { it('POST /feedback missing text → 400', async () => { const cid = 'c-route-fb-empty'; cids.push(cid); + require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), @@ -219,6 +223,7 @@ describe('P4 · routes', () => { it('POST /abandon → status=abandoned', async () => { const cid = 'c-route-abandon'; cids.push(cid); + require('../helpers/ensure-test-company').ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement: 'x', mockFixtureDir: HAPPY_DIR }), diff --git a/server/tests/devloop/routes-crud.test.js b/server/tests/devloop/routes-crud.test.js index c398f6d..9f7dae0 100644 --- a/server/tests/devloop/routes-crud.test.js +++ b/server/tests/devloop/routes-crud.test.js @@ -16,6 +16,7 @@ process.env.HOME = TEST_HOME; const pathResolver = require('../../services/devloop/path-resolver'); const devloopRouter = require('../../routes/devloop'); +const { ensureTestCompany, removeTestCompany } = require('../helpers/ensure-test-company'); function startApp() { const app = express(); @@ -28,7 +29,10 @@ function startApp() { }); } +// 38 号文档 Phase 2:devloop.js 建项目前会校验 company_id 是不是一家真实存在、当前用户 +// 碰得到的公司——这个唯一的建项目入口顺手建一下,不用在每个调用点各补一次。 async function createProject(base, cid, requirement = 'crud test') { + ensureTestCompany(cid); const r = await fetch(`${base}/api/devloop/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ company_id: cid, requirement }), @@ -52,6 +56,7 @@ describe('routes/devloop · CRUD', () => { try { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); } catch { /* ignore */ } + removeTestCompany(c); } }); diff --git a/server/tests/devloop/routes.test.js b/server/tests/devloop/routes.test.js index eb7a51f..45c6468 100644 --- a/server/tests/devloop/routes.test.js +++ b/server/tests/devloop/routes.test.js @@ -16,6 +16,7 @@ process.env.HOME = TEST_HOME; const pathResolver = require('../../services/devloop/path-resolver'); const devloopRouter = require('../../routes/devloop'); +const { ensureTestCompany, removeTestCompany } = require('../helpers/ensure-test-company'); function startApp() { const app = express(); @@ -33,8 +34,13 @@ describe('routes/devloop', () => { let app, base; const created = []; + // 38 号文档 Phase 2:devloop.js 建项目前会校验 company_id 是不是一家真实存在、 + // 当前用户碰得到的公司——这份测试文件里各用例用的 cid 都是硬编码字面量,从没真的建过公司。 + const TEST_CIDS = ['c-route-test', 'c-route-p2', 'c-route-p3', 'c-route-p2u', 'c-p5-routes']; + beforeAll(async () => { pathResolver._resetForTesting(); + for (const cid of TEST_CIDS) ensureTestCompany(cid); ({ srv: app, base } = await startApp()); }); @@ -45,6 +51,7 @@ describe('routes/devloop', () => { fs.rmSync(path.join(pathResolver.DATA_DIR, 'companies', c), { recursive: true, force: true }); } catch { /* ignore */ } } + for (const cid of TEST_CIDS) removeTestCompany(cid); }); it('POST /api/devloop/projects requires company_id', async () => { @@ -179,8 +186,10 @@ describe('routes/devloop', () => { it('GET /api/devloop/projects/:pid/state on unknown pid returns 404', async () => { const r = await fetch(`${base}/api/devloop/projects/p-doesnotexist/state`); expect(r.status).toBe(404); + // 38 号文档 Phase 2:router.param('pid', ...) 现在抢在具体路由前面判"项目存不存在", + // 消息文案统一成"项目 X 不存在"(跟 company/run 那两处同款措辞),不再是各路由各写一套。 const j = await r.json(); - expect(j.error).toMatch(/unknown/); + expect(j.error).toMatch(/不存在/); }); it('GET /api/devloop/projects lists projects', async () => { diff --git a/server/tests/helpers/ensure-test-company.js b/server/tests/helpers/ensure-test-company.js new file mode 100644 index 0000000..883267e --- /dev/null +++ b/server/tests/helpers/ensure-test-company.js @@ -0,0 +1,41 @@ +/** + * ensure-test-company.js —— 38 号文档 Phase 2 落地后,company.js / graph-run.js 的 + * :companyId / :id 路由都会先查 company-service.js 的 companyService.get(cid)(判断 + * "这家公司存不存在"),存在才轮到归属校验。company-service.js 的 DATA_DIR 是硬编码的 + * `/data/companies`,无 env 覆盖口,且只认 company.json 这一份真相。 + * + * 仓库里一批测试文件直接在 graph_runs/company_agents 等表里插一个虚构的 companyId + * (从没真的建过公司),这在 Phase 2 之前无所谓——company.js 那层从不检查。现在会被 + * 挡在 router.param 那一关,404。这个 helper 补一份最小 company.json,让这类"只是需要 + * 一个 companyId 字符串来做数据隔离"的测试继续成立,不代表要去验证公司归属本身 + * (那是 company-service.test.js / route-auth-gate.test.js 的职责)。 + * + * 幂等、同步、可重复调用。清理交给调用方自己的 afterAll(多数这类测试本来就有一次性 + * 数据目录清理逻辑,顺手带上这个)。 + */ +const fs = require('fs'); +const path = require('path'); + +const COMPANIES_DIR = path.resolve(__dirname, '../../../data/companies'); + +/** + * @param {string} companyId + * @param {object} [opts] + * @param {number} [opts.ownerId] - 默认 -1,跟 jwt-auth.js 测试环境放行时用的 + * TEST_BYPASS_USER.id 对齐,但其实无所谓——TEST_BYPASS_USER.role='admin', + * canAccessCompany 不看 ownerId。 + */ +function ensureTestCompany(companyId, { ownerId = -1 } = {}) { + const dir = path.join(COMPANIES_DIR, companyId); + const fp = path.join(dir, 'company.json'); + if (fs.existsSync(fp)) return; + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(fp, JSON.stringify({ id: companyId, name: companyId, ownerId })); +} + +/** 测试收尾用:删掉 ensureTestCompany 建出来的目录(连同调用方自己可能写进去的其它文件)。*/ +function removeTestCompany(companyId) { + fs.rmSync(path.join(COMPANIES_DIR, companyId), { recursive: true, force: true }); +} + +module.exports = { ensureTestCompany, removeTestCompany }; diff --git a/server/tests/unit/agent-transcript.test.js b/server/tests/unit/agent-transcript.test.js index 187f757..3e6d9a5 100644 --- a/server/tests/unit/agent-transcript.test.js +++ b/server/tests/unit/agent-transcript.test.js @@ -82,6 +82,9 @@ describe('agent-transcript:会话原文读取', () => { const msg = (ts, role, content) => ({ timestamp: ts, type: role, message: { role, content } }); beforeAll(async () => { + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 + // 真实存在的公司,COMPANY 只是这份测试里的一个字符串标签,从没真的建过公司。 + require('../helpers/ensure-test-company').ensureTestCompany(COMPANY); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } projectsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-projects-')); runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-runs-')); @@ -161,6 +164,7 @@ describe('agent-transcript:会话原文读取', () => { afterAll(async () => { if (srv) await new Promise((r) => srv.close(r)); + require('../helpers/ensure-test-company').removeTestCompany(COMPANY); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } for (const d of [projectsRoot, runsDir]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} } delete process.env.CLAUDE_PROJECTS_DIR; diff --git a/server/tests/unit/company-files-path-guard.test.js b/server/tests/unit/company-files-path-guard.test.js index e859c16..ea56873 100644 --- a/server/tests/unit/company-files-path-guard.test.js +++ b/server/tests/unit/company-files-path-guard.test.js @@ -46,6 +46,11 @@ describe('公司文件端点:路径越界防护', () => { beforeAll(async () => { for (const c of [VICTIM, SIBLING]) { fs.mkdirSync(path.join(COMPANIES_DIR, c), { recursive: true }); + // 38 号文档 Phase 2:company.js 现在挂了 router.param('companyId', ...), + // 靠 company.json 判断"这家公司存不存在"——这组用例本来只关心文件路径越界, + // 不是公司归属,补一份最小 company.json 让请求能过归属校验那一关, + // 不改变本文件真正要测的东西(下面的越界防护)。 + fs.writeFileSync(path.join(COMPANIES_DIR, c, 'company.json'), JSON.stringify({ id: c, name: c, ownerId: -1 })); } fs.writeFileSync(path.join(COMPANIES_DIR, VICTIM, 'own.md'), '# 我自己的文件\n'); fs.writeFileSync(path.join(COMPANIES_DIR, SIBLING, 'secret.md'), 'SIBLING-SECRET\n'); diff --git a/server/tests/unit/graph-run-route-io.test.js b/server/tests/unit/graph-run-route-io.test.js index 871d994..04f066e 100644 --- a/server/tests/unit/graph-run-route-io.test.js +++ b/server/tests/unit/graph-run-route-io.test.js @@ -42,6 +42,9 @@ describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { const taskId = 'T-route-io'; beforeAll(async () => { + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 + // 真实存在的公司,companyId 只是这份测试里的一个字符串标签,从没真的建过公司。 + require('../helpers/ensure-test-company').ensureTestCompany(companyId); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } process.env.GRAPH_DB_PATH = TEST_DB_PATH; runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-io-runs-')); @@ -99,6 +102,7 @@ describe('GET /api/graph-runs/:id — R-19 输入输出三字段', () => { afterAll(async () => { if (srv) await new Promise((r) => srv.close(r)); + require('../helpers/ensure-test-company').removeTestCompany(companyId); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} } delete process.env.GRAPH_DB_PATH; try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {} diff --git a/server/tests/unit/graph-run-route-slot-busy.test.js b/server/tests/unit/graph-run-route-slot-busy.test.js index 327e56a..017873e 100644 --- a/server/tests/unit/graph-run-route-slot-busy.test.js +++ b/server/tests/unit/graph-run-route-slot-busy.test.js @@ -48,6 +48,9 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => let deadNodeId; beforeAll(async () => { + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 + // 真实存在的公司,CID 只是这份测试里的一个字符串标签,从没真的建过公司。 + require('../helpers/ensure-test-company').ensureTestCompany(CID); for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 本来就没有 */ } } process.env.GRAPH_DB_PATH = TEST_DB_PATH; // 不设的话任何走到默认路径的写入都会往仓库 data/ 里堆 @@ -96,6 +99,7 @@ describe('POST /api/graph-runs/... 撞上坑满 → 409(不是 500)', () => afterAll(() => { if (server) server.close(); + require('../helpers/ensure-test-company').removeTestCompany(CID); try { require('../../services/task-scheduler').stopSweeper(); } catch { /* 没起过就算了 */ } for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch { /* 已经删了 */ } } try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch { /* ignore */ } diff --git a/server/tests/unit/heartbeat-company-scoped.test.js b/server/tests/unit/heartbeat-company-scoped.test.js index eed95f9..2974a33 100644 --- a/server/tests/unit/heartbeat-company-scoped.test.js +++ b/server/tests/unit/heartbeat-company-scoped.test.js @@ -72,6 +72,16 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { // 上一轮跑剩的公司态不能留,否则③的回落断言会读到旧值而空过 for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); + // 38 号文档 Phase 2:company.js 现在挂了 router.param('companyId', ...), + // 靠 company.json 判断"这家公司存不存在"——CO_A/CO_B 在这份测试里从来不是真正建过的 + // 公司(只有雇佣关系和公司态目录),补一份最小 company.json 让请求能过归属校验, + // 不影响本文件真正要测的心跳隔离逻辑。 + for (const cid of [CO_A, CO_B]) { + const dir = path.join(REPO_ROOT, 'data', 'companies', cid); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'company.json'), JSON.stringify({ id: cid, name: cid, ownerId: -1 })); + } + // 2. 一次性 graph.sqlite + 两家公司的雇佣关系 if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); process.env.GRAPH_DB_PATH = TEST_DB_PATH; @@ -112,6 +122,7 @@ describe('心跳设置按公司隔离(28 遗留项)', () => { // 只清公司态(运行态、gitignore);本体留在 system/agents/ 里当正式注册表条目 // `_global` 是裸 agent id 的公司态落点(GET /info 那条用它),同样要清 for (const cid of [CO_A, CO_B, '_global']) fs.rmSync(companyStateDir(cid), { recursive: true, force: true }); + for (const cid of [CO_A, CO_B]) fs.rmSync(path.join(REPO_ROOT, 'data', 'companies', cid), { recursive: true, force: true }); if (fs.existsSync(TEST_DB_PATH)) fs.unlinkSync(TEST_DB_PATH); delete process.env.GRAPH_DB_PATH; }); diff --git a/server/tests/unit/p3c-recovery-http.test.js b/server/tests/unit/p3c-recovery-http.test.js index 12ff1a5..e4e319e 100644 --- a/server/tests/unit/p3c-recovery-http.test.js +++ b/server/tests/unit/p3c-recovery-http.test.js @@ -75,20 +75,38 @@ describe('恢复动作的 HTTP 结局:退得回队列 → 2xx,退不回 → for (const m of MODULES) delete require.cache[require.resolve(m)]; }); - /** advanceOne 的 ensureProjectRoot 认的是仓库里的 data/companies/,只删本文件造的那些 */ + /** + * advanceOne 的 ensureProjectRoot 认的是仓库里的 data/companies/,只删本文件造的那些。 + * nextCompany() 现在会顺手建一份 company.json(38 号文档 Phase 2,ensureTestCompany 写的 + * ownerId 恒为 -1,真实生产公司的 ownerId 是 users.id,不会撞上 -1)——判据从"有没有 + * company.json"改成"ownerId 是不是 -1 这个测试专用标记",否则这批目录会被误判成真公司 + * 而永远不清理。 + */ function cleanupLeakedCompanyDirs() { const root = path.resolve(__dirname, '../../../data/companies'); if (!fs.existsSync(root)) return; for (const cid of fs.readdirSync(root)) { if (!cid.startsWith('p3chttp-co-')) continue; const dir = path.join(root, cid); - if (fs.existsSync(path.join(dir, 'company.json'))) continue; // 那是真公司 + const companyJsonPath = path.join(dir, 'company.json'); + if (fs.existsSync(companyJsonPath)) { + try { + const { ownerId } = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); + if (ownerId !== -1) continue; // 那是真公司 + } catch { continue; } // 读不出来就保守跳过,不乱删 + } try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } } } let seq = 0; - const nextCompany = () => `p3chttp-co-${++seq}`; + // 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 + // 真实存在的公司,这里造出来的 id 从没真的建过公司,补一份最小 company.json。 + const nextCompany = () => { + const cid = `p3chttp-co-${++seq}`; + require('../helpers/ensure-test-company').ensureTestCompany(cid); + return cid; + }; function ensureAgents(ids) { const now = new Date().toISOString(); diff --git a/server/tests/unit/route-auth-gate.test.js b/server/tests/unit/route-auth-gate.test.js index 0427f0d..9e33c7a 100644 --- a/server/tests/unit/route-auth-gate.test.js +++ b/server/tests/unit/route-auth-gate.test.js @@ -62,8 +62,8 @@ describe('route-auth-gate(38 号文档 Phase 1)', () => { expectGated(graphRun.companyRouter, requireAuth, 'graph-run.js companyRouter'); }); - it('okr.js 挂了 requireAuth', () => { - expectGated(require('../../routes/okr.js'), requireAuth, 'okr.js'); + it('okr.js 挂了 requireAdmin(孤儿页面,前端无入口,收紧到 admin-only,见 38 号文档 Phase 2)', () => { + expectGated(require('../../routes/okr.js'), requireAdmin, 'okr.js'); }); it('arena.js 挂了 requireAuth', () => { diff --git a/server/tests/unit/task-slots-parallel.test.js b/server/tests/unit/task-slots-parallel.test.js index 8708468..91853eb 100644 --- a/server/tests/unit/task-slots-parallel.test.js +++ b/server/tests/unit/task-slots-parallel.test.js @@ -130,8 +130,12 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { /** * `advanceOne` 的 ensureProjectRoot 会在**仓库的** data/companies/ 下给每家公司建项目根 - * (它不认 GRAPH_RUNS_DIR)。只删本文件造的那些前缀目录,且必须确认里面没有 company.json - * —— 那是真公司,删了就是删用户数据。(写法与 task-scheduler.test.js 一致) + * (它不认 GRAPH_RUNS_DIR)。只删本文件造的那些前缀目录,且必须确认不是真公司—— + * 那是真公司,删了就是删用户数据。 + * + * 38 号文档 Phase 2:nextCompany() 现在会顺手调 ensureTestCompany 建一份 company.json + * (ownerId 恒为 -1,真实生产公司不会是这个值),判据从"有没有 company.json"改成 + * "ownerId 是不是 -1 这个测试专用标记",否则这批目录会被误判成真公司而永远不清理。 */ function cleanupLeakedCompanyDirs() { const root = path.resolve(__dirname, '../../../data/companies'); @@ -139,7 +143,13 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { for (const cid of fs.readdirSync(root)) { if (!cid.startsWith('slots-co-')) continue; const dir = path.join(root, cid); - if (fs.existsSync(path.join(dir, 'company.json'))) continue; + const companyJsonPath = path.join(dir, 'company.json'); + if (fs.existsSync(companyJsonPath)) { + try { + const { ownerId } = JSON.parse(fs.readFileSync(companyJsonPath, 'utf-8')); + if (ownerId !== -1) continue; // 那是真公司 + } catch { continue; } + } try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* 删不掉就留着 */ } } } @@ -179,8 +189,16 @@ describe('批 4 · 公司级执行位(max_parallel_tasks)', () => { // ── 夹具 ────────────────────────────────────────────────────── let seq = 0; - /** 每个用例一家独立公司:执行位是按 company 数的 */ - const nextCompany = () => `slots-co-${++seq}`; + /** + * 每个用例一家独立公司:执行位是按 company 数的。 + * 38 号文档 Phase 2:graph-run.js 的 :id 路由现在会反查 run 的 company_id 是否是一家 + * 真实存在的公司,顺手建一份最小 company.json(否则打 HTTP 的用例会 404)。 + */ + const nextCompany = () => { + const cid = `slots-co-${++seq}`; + require('../helpers/ensure-test-company').ensureTestCompany(cid); + return cid; + }; function ensureAgents(ids) { const now = new Date().toISOString(); -- libgit2 0.21.4