Commit 0bbf3e4b0f22086c5cd0df496f4af40c10081306
1 parent
46c780ab
feat(console): 输入输出 tab 的文件路径可点开预览;修文件端点的越界防护
## 为什么加
「输入输出」tab 把产物的绝对路径摆出来了,但那是服务器本地路径 —— 浏览器里点不开,
走查时只能靠肉眼确认「路径长得对」,而真正要确认的是「这份 brd.md 里写了什么、
下游读到的是不是它」。
## 前端
新增 web/src/components/graph-console/FilePreviewModal.vue,输入/输出两处路径
改成可点按钮。渲染三档:.md 走 marked(已是既有依赖,没新增包)、.json 重新缩进、
其它原文。两处刻意的选择:
- .json 解析失败时**退回原文**并说明,不替它藏起来 —— 产物 json 写坏了本身就是要看的信息
- 超过 512KB 时后端回 truncated,界面如实说「不是完整内容」,不装作读全了
output_ref 那一行不做成可点:它是目录不是文件。
后端没有新造端点:复用现成的 GET /api/company/:cid/files/content,它的根目录正是
data/companies/<cid>,而 graph_files.rel_path 本来就是相对公司根的,正好对上。
## 修掉六处路径越界(真漏洞)
company.js 里 3 个 GET + 3 个 PUT(templates / company / workspace)用的是同一个写法:
if (!absPath.startsWith(safeBoundary)) → 403
「一个目录名是另一个的前缀」时它会放行:
cid = co-a,path = ../co-a-evil/company.json
→ abs = .../data/companies/co-a-evil/company.json
→ startsWith('.../data/companies/co-a') === true → 放行
于是公司 co-a 能读 co-a-evil 的文件;PUT 那三处是同一缝隙上的跨公司**写**。
抽 resolveInside() 用 path.relative 判断,六处共用一份(复制六份判据迟早会漂)。
新增 7 条用例走真 HTTP 验,PUT 那条断言的是**文件没被改**而不只是响应码。
## 顺带修掉一个被你的实际使用触发的 bug
跑全套时 graph-seed-service 的对账用例红了,报
`org_graph_nodes WHERE company_id='mycompany' expected 10 / actual 9`。
不是并发偶发 —— 单跑也红。原因:seedCompanyAgents 对 type='meta-bootstrap' 的角色
是 continue(既不落 org_graph_nodes 也不落 system_agents),而 verifyCounts
复算期望值时把 agents[] 全算进去。mycompany 的 10 个角色里 orggraph-planning
正是 meta-bootstrap → 差 1。
也就是说:**界面上点一遍新建公司就会踩到**。这条缺陷上一轮就被预告过
(当时那个 agent 说「不在我的独占文件里」没修),今天被真实使用兑现了。
修法是让 verifyCounts 复用同一个 isMetaBootstrap 判据,不复制那个 type 判断。
新增独立测试文件(不并进 graph-seed-service.test.js —— 往它的 hermetic 夹具里
加角色会连带改既有断言),用 GRAPH_SEED_ROOT 自建最小夹具。
那个文件里第二条用例是刻意的:只断言「对账干净」时,seed 一个都没种也会“通过”,
两种原因症状相同。第一版就是这么错的 —— 我误以为 runStartupSeed 会种公司角色
(它只做 reconcile + 全局 agent + 模板),是第二条断言把「夹具没种」和
「过滤没生效」分开的。
## 验证
871 全绿(87 文件)。3 处变异精确变红后逐字还原
(startsWith 回退 → 精确打红跨公司读/写两条;verifyCounts 去掉过滤 → 打红对账条)。
拿运行中的实例 + 用户真实公司 mycompany 走查过:读到真实 brd.md 内容,
`../idearun/company.json` 与 `/etc/hosts` 均 403。web:build 通过。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing
7 changed files
with
546 additions
and
22 deletions
server/routes/company.js
| ... | ... | @@ -16,6 +16,29 @@ const companyService = new CompanyService(); |
| 16 | 16 | const scheduleService = new ScheduleService(); |
| 17 | 17 | |
| 18 | 18 | /** |
| 19 | + * 把调用方给的相对路径解析到 baseDir 之内;越界返回 null。 | |
| 20 | + * | |
| 21 | + * **不能写成 `abs.startsWith(base)`** —— 那是本文件里六处文件读写检查原先的写法, | |
| 22 | + * 它在「一个目录名是另一个的前缀」时会放行: | |
| 23 | + * base = .../data/companies/foo | |
| 24 | + * path = ../foo-evil/company.json → abs = .../data/companies/foo-evil/company.json | |
| 25 | + * abs.startsWith(base) === true → 公司 foo 读到了公司 foo-evil 的文件 | |
| 26 | + * PUT 那几处更糟:同样的缝隙上是跨公司**写**。 | |
| 27 | + * | |
| 28 | + * path.relative 才是真的在问「abs 在不在 base 里面」:越界时结果以 `..` 开头, | |
| 29 | + * 指向别的盘/绝对路径时结果是绝对路径。绝对路径入参也被这一条挡住 | |
| 30 | + * (path.resolve(base, '/etc/passwd') === '/etc/passwd' → relative 以 .. 开头)。 | |
| 31 | + */ | |
| 32 | +function resolveInside(baseDir, rel) { | |
| 33 | + const base = path.resolve(baseDir); | |
| 34 | + const abs = path.resolve(base, rel || ''); | |
| 35 | + const r = path.relative(base, abs); | |
| 36 | + if (r === '') return abs; // 指向 base 自身 | |
| 37 | + if (r.startsWith('..') || path.isAbsolute(r)) return null; | |
| 38 | + return abs; | |
| 39 | +} | |
| 40 | + | |
| 41 | +/** | |
| 19 | 42 | * fire-and-forget 触发 orggraph-planning 规划这家公司的 workflow(27-P2 §4.4)。 |
| 20 | 43 | * 不 await —— 一轮真实 Claude CLI 对话是分钟级的,调用方必须立即返回。 |
| 21 | 44 | * 写法与本文件 /hire 里触发 agent-creator / innovator 的自调用保持一致。 |
| ... | ... | @@ -629,9 +652,8 @@ router.get('/templates/:templateId/files/content', (req, res) => { |
| 629 | 652 | |
| 630 | 653 | if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); |
| 631 | 654 | |
| 632 | - const absPath = path.resolve(DATA_DIR, req.params.templateId, filePath); | |
| 633 | - const safeBoundary = path.resolve(DATA_DIR, req.params.templateId); | |
| 634 | - if (!absPath.startsWith(safeBoundary)) { | |
| 655 | + const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath); | |
| 656 | + if (!absPath) { | |
| 635 | 657 | return res.status(403).json({ error: '路径越界' }); |
| 636 | 658 | } |
| 637 | 659 | |
| ... | ... | @@ -661,9 +683,8 @@ router.put('/templates/:templateId/files/content', (req, res) => { |
| 661 | 683 | |
| 662 | 684 | if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); |
| 663 | 685 | |
| 664 | - const absPath = path.resolve(DATA_DIR, req.params.templateId, filePath); | |
| 665 | - const safeBoundary = path.resolve(DATA_DIR, req.params.templateId); | |
| 666 | - if (!absPath.startsWith(safeBoundary)) { | |
| 686 | + const absPath = resolveInside(path.join(DATA_DIR, req.params.templateId), filePath); | |
| 687 | + if (!absPath) { | |
| 667 | 688 | return res.status(403).json({ error: '路径越界' }); |
| 668 | 689 | } |
| 669 | 690 | |
| ... | ... | @@ -1776,9 +1797,8 @@ router.get('/:companyId/files/content', (req, res) => { |
| 1776 | 1797 | if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); |
| 1777 | 1798 | |
| 1778 | 1799 | // 安全校验:防止路径穿越 |
| 1779 | - const absPath = path.resolve(DATA_DIR, req.params.companyId, filePath); | |
| 1780 | - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId); | |
| 1781 | - if (!absPath.startsWith(safeBoundary)) { | |
| 1800 | + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId), filePath); | |
| 1801 | + if (!absPath) { | |
| 1782 | 1802 | return res.status(403).json({ error: '路径越界' }); |
| 1783 | 1803 | } |
| 1784 | 1804 | |
| ... | ... | @@ -1805,9 +1825,8 @@ router.put('/:companyId/files/content', (req, res) => { |
| 1805 | 1825 | const filePath = req.body.path; |
| 1806 | 1826 | if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); |
| 1807 | 1827 | |
| 1808 | - const absPath = path.resolve(DATA_DIR, req.params.companyId, filePath); | |
| 1809 | - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId); | |
| 1810 | - if (!absPath.startsWith(safeBoundary)) { | |
| 1828 | + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId), filePath); | |
| 1829 | + if (!absPath) { | |
| 1811 | 1830 | return res.status(403).json({ error: '路径越界' }); |
| 1812 | 1831 | } |
| 1813 | 1832 | |
| ... | ... | @@ -1881,9 +1900,8 @@ router.get('/:companyId/workspace/files/content', (req, res) => { |
| 1881 | 1900 | const filePath = String(req.query.path || ''); |
| 1882 | 1901 | if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); |
| 1883 | 1902 | |
| 1884 | - const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); | |
| 1885 | - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); | |
| 1886 | - if (!absPath.startsWith(safeBoundary)) { | |
| 1903 | + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId, 'workspace'), filePath); | |
| 1904 | + if (!absPath) { | |
| 1887 | 1905 | return res.status(403).json({ error: '路径越界' }); |
| 1888 | 1906 | } |
| 1889 | 1907 | if (!fs.existsSync(absPath)) { |
| ... | ... | @@ -1913,9 +1931,8 @@ router.put('/:companyId/workspace/files/content', (req, res) => { |
| 1913 | 1931 | const filePath = req.body.path; |
| 1914 | 1932 | if (!filePath) return res.status(400).json({ error: 'path 参数为必填' }); |
| 1915 | 1933 | |
| 1916 | - const absPath = path.resolve(DATA_DIR, req.params.companyId, 'workspace', filePath); | |
| 1917 | - const safeBoundary = path.resolve(DATA_DIR, req.params.companyId, 'workspace'); | |
| 1918 | - if (!absPath.startsWith(safeBoundary)) { | |
| 1934 | + const absPath = resolveInside(path.join(DATA_DIR, req.params.companyId, 'workspace'), filePath); | |
| 1935 | + if (!absPath) { | |
| 1919 | 1936 | return res.status(403).json({ error: '路径越界' }); |
| 1920 | 1937 | } |
| 1921 | 1938 | ... | ... |
server/services/graph-seed-service.js
| ... | ... | @@ -530,7 +530,15 @@ function verifyCounts() { |
| 530 | 530 | } |
| 531 | 531 | |
| 532 | 532 | const roleNames = Array.isArray(companyJson.agents) ? companyJson.agents : []; |
| 533 | - const distinctRoles = new Set(roleNames); // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重 | |
| 533 | + // 必须镜像 seedCompanyAgents 的过滤:那边对 meta-bootstrap 角色是 `continue`(见 | |
| 534 | + // isMetaBootstrap 的调用点),既不落 org_graph_nodes 也不落 system_agents。 | |
| 535 | + // 这里以前把它算进期望值,于是**任何一家公司的 agents[] 里出现一个 meta-bootstrap | |
| 536 | + // 角色,对账就确定性变红**:实际发生过 —— mycompany 的 10 个角色里 orggraph-planning | |
| 537 | + // 是 meta-bootstrap,报 expected 10 / actual 9。 | |
| 538 | + // 复用同一个函数而不是复制那个 type 判断:两份判据迟早会漂,而漂了之后 | |
| 539 | + // 症状是「对账报错但库其实没问题」,比不对账更费人。 | |
| 540 | + const distinctRoles = new Set(roleNames.filter((r) => !isMetaBootstrap(companyId, r))); | |
| 541 | + // org_graph_nodes 按 (company_id, system_agent_id, role_name) 去重 | |
| 534 | 542 | expectedNodesPerCompany[companyId] = distinctRoles.size; |
| 535 | 543 | |
| 536 | 544 | for (const roleName of distinctRoles) { | ... | ... |
server/tests/unit/company-files-path-guard.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — 公司文件读写端点的路径越界防护 | |
| 3 | + * | |
| 4 | + * 修的是 `server/routes/company.js` 里六处(3 个 GET + 3 个 PUT,覆盖 | |
| 5 | + * templates / company / workspace)**同一个**写法: | |
| 6 | + * | |
| 7 | + * const absPath = path.resolve(DATA_DIR, cid, filePath); | |
| 8 | + * const safeBoundary = path.resolve(DATA_DIR, cid); | |
| 9 | + * if (!absPath.startsWith(safeBoundary)) → 403 | |
| 10 | + * | |
| 11 | + * `startsWith` 在「一个目录名是另一个的前缀」时会放行: | |
| 12 | + * cid = 'co-a',path = '../co-a-evil/company.json' | |
| 13 | + * → abs = .../data/companies/co-a-evil/company.json | |
| 14 | + * → abs.startsWith('.../data/companies/co-a') === true → 放行 | |
| 15 | + * 于是公司 co-a 能读 co-a-evil 的文件;PUT 那三处是同一个缝隙上的跨公司**写**。 | |
| 16 | + * | |
| 17 | + * 改成 path.relative 判断(越界时结果以 `..` 开头)。这组用例锁四件事: | |
| 18 | + * ① 正常相对路径读得到 | |
| 19 | + * ② `../<前缀兄弟目录>` 被拦(这是原 bug 的精确形态) | |
| 20 | + * ③ 绝对路径入参被拦 | |
| 21 | + * ④ PUT 也拦(写比读更要紧,且原代码两处是复制粘贴的同一个缝) | |
| 22 | + * | |
| 23 | + * 走真 HTTP:把 router 挂到真 express 上 listen(0) 再 fetch(同 devloop/routes.test.js)。 | |
| 24 | + * 不打桩 fs、不导出内部函数 —— 越界防护的正确性只在「真的读到了/没读到文件」这一侧成立。 | |
| 25 | + * | |
| 26 | + * ⚠️ 这个端点的根目录是**硬编码**的 `<repo>/data/companies`(无 env 覆盖口), | |
| 27 | + * 所以用例只能在真实目录下建两个 `__pathguard-*` 前缀的临时公司目录, | |
| 28 | + * afterAll 精确删掉自己建的那两个(不做通配清理,免得误删真公司)。 | |
| 29 | + */ | |
| 30 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | |
| 31 | +import fs from 'fs'; | |
| 32 | +import path from 'path'; | |
| 33 | +import express from 'express'; | |
| 34 | + | |
| 35 | +const ROOT = path.resolve(__dirname, '../../..'); | |
| 36 | +const COMPANIES_DIR = path.join(ROOT, 'data/companies'); | |
| 37 | + | |
| 38 | +// 前缀关系是本用例的核心:VICTIM 是 ATTACKER 的字符串前缀 | |
| 39 | +const VICTIM = '__pathguard-co'; | |
| 40 | +const SIBLING = '__pathguard-co-evil'; | |
| 41 | + | |
| 42 | +describe('公司文件端点:路径越界防护', () => { | |
| 43 | + let base; | |
| 44 | + let srv; | |
| 45 | + | |
| 46 | + beforeAll(async () => { | |
| 47 | + for (const c of [VICTIM, SIBLING]) { | |
| 48 | + fs.mkdirSync(path.join(COMPANIES_DIR, c), { recursive: true }); | |
| 49 | + } | |
| 50 | + fs.writeFileSync(path.join(COMPANIES_DIR, VICTIM, 'own.md'), '# 我自己的文件\n'); | |
| 51 | + fs.writeFileSync(path.join(COMPANIES_DIR, SIBLING, 'secret.md'), 'SIBLING-SECRET\n'); | |
| 52 | + | |
| 53 | + const app = express(); | |
| 54 | + app.use(express.json()); | |
| 55 | + app.use('/api/company', require('../../routes/company')); | |
| 56 | + await new Promise((resolve) => { | |
| 57 | + srv = app.listen(0, '127.0.0.1', () => { | |
| 58 | + base = `http://127.0.0.1:${srv.address().port}`; | |
| 59 | + resolve(); | |
| 60 | + }); | |
| 61 | + }); | |
| 62 | + }); | |
| 63 | + | |
| 64 | + afterAll(async () => { | |
| 65 | + if (srv) await new Promise((r) => srv.close(r)); | |
| 66 | + // 只删自己建的那两个目录,逐个具名 —— 不用通配 | |
| 67 | + for (const c of [VICTIM, SIBLING]) { | |
| 68 | + fs.rmSync(path.join(COMPANIES_DIR, c), { recursive: true, force: true }); | |
| 69 | + } | |
| 70 | + }); | |
| 71 | + | |
| 72 | + const get = (cid, p) => | |
| 73 | + fetch(`${base}/api/company/${cid}/files/content?path=${encodeURIComponent(p)}`); | |
| 74 | + | |
| 75 | + it('① 正常相对路径:读得到自己的文件', async () => { | |
| 76 | + const r = await get(VICTIM, 'own.md'); | |
| 77 | + expect(r.status).toBe(200); | |
| 78 | + const d = await r.json(); | |
| 79 | + expect(d.content).toContain('我自己的文件'); | |
| 80 | + }); | |
| 81 | + | |
| 82 | + it('② 前缀兄弟目录(原 bug 的精确形态)→ 403,且内容一个字都不回', async () => { | |
| 83 | + const r = await get(VICTIM, `../${SIBLING}/secret.md`); | |
| 84 | + expect(r.status).toBe(403); | |
| 85 | + const body = await r.text(); | |
| 86 | + // 不只看状态码:确认没有把内容顺带回出去 | |
| 87 | + expect(body).not.toContain('SIBLING-SECRET'); | |
| 88 | + }); | |
| 89 | + | |
| 90 | + it('② 更远的越界(爬到仓库根读源码)→ 403', async () => { | |
| 91 | + const r = await get(VICTIM, '../../../package.json'); | |
| 92 | + expect(r.status).toBe(403); | |
| 93 | + }); | |
| 94 | + | |
| 95 | + it('③ 绝对路径入参 → 403', async () => { | |
| 96 | + const r = await get(VICTIM, '/etc/hosts'); | |
| 97 | + expect(r.status).toBe(403); | |
| 98 | + }); | |
| 99 | + | |
| 100 | + it('④ PUT 同样拦住跨公司写(原代码那三处是同一个缝的复制粘贴)', async () => { | |
| 101 | + const target = path.join(COMPANIES_DIR, SIBLING, 'secret.md'); | |
| 102 | + const before = fs.readFileSync(target, 'utf-8'); | |
| 103 | + | |
| 104 | + const r = await fetch(`${base}/api/company/${VICTIM}/files/content`, { | |
| 105 | + method: 'PUT', | |
| 106 | + headers: { 'Content-Type': 'application/json' }, | |
| 107 | + body: JSON.stringify({ path: `../${SIBLING}/secret.md`, content: 'PWNED' }), | |
| 108 | + }); | |
| 109 | + | |
| 110 | + expect(r.status).toBe(403); | |
| 111 | + // 真正要断言的是**文件没被改**,而不是只看响应码 | |
| 112 | + expect(fs.readFileSync(target, 'utf-8')).toBe(before); | |
| 113 | + }); | |
| 114 | + | |
| 115 | + it('④ PUT 正常路径仍然写得进去(别把功能一起拦死)', async () => { | |
| 116 | + const r = await fetch(`${base}/api/company/${VICTIM}/files/content`, { | |
| 117 | + method: 'PUT', | |
| 118 | + headers: { 'Content-Type': 'application/json' }, | |
| 119 | + body: JSON.stringify({ path: 'nested/new.md', content: 'hello' }), | |
| 120 | + }); | |
| 121 | + expect(r.status).toBe(200); | |
| 122 | + expect(fs.readFileSync(path.join(COMPANIES_DIR, VICTIM, 'nested/new.md'), 'utf-8')).toBe('hello'); | |
| 123 | + }); | |
| 124 | + | |
| 125 | + it('workspace 端点走同一个 helper,同样拦住', async () => { | |
| 126 | + const r = await fetch( | |
| 127 | + `${base}/api/company/${VICTIM}/workspace/files/content?path=${encodeURIComponent(`../../${SIBLING}/secret.md`)}` | |
| 128 | + ); | |
| 129 | + expect(r.status).toBe(403); | |
| 130 | + }); | |
| 131 | +}); | ... | ... |
server/tests/unit/graph-seed-meta-bootstrap.test.js
0 → 100644
| 1 | +/** | |
| 2 | + * Unit Tests — verifyCounts 必须镜像 seedCompanyAgents 的 meta-bootstrap 过滤 | |
| 3 | + * | |
| 4 | + * `seedCompanyAgents` 对 `type: 'meta-bootstrap'` 的公司角色是 `continue`: | |
| 5 | + * 既不写 org_graph_nodes 也不写 system_agents。而 `verifyCounts` 复算期望值时 | |
| 6 | + * 原先把 `company.json` 的 `agents[]` **全部**算进去 —— 于是任何一家公司的 | |
| 7 | + * agents[] 里出现一个 meta-bootstrap 角色,对账就确定性变红。 | |
| 8 | + * | |
| 9 | + * 不是假设:用户手工走查建的 `mycompany` 有 10 个角色,其中 `orggraph-planning` | |
| 10 | + * 是 meta-bootstrap,对账报 `expected 10 / actual 9`。也就是说这条缺陷是被 | |
| 11 | + * **正常使用**触发的 —— 界面上点一遍新建公司就会踩到。 | |
| 12 | + * | |
| 13 | + * 这个文件单独存在(不并进 graph-seed-service.test.js)的原因:那边 hermetic 块的 | |
| 14 | + * 断言钉着精确的行数/节点数,往它的夹具里加一个角色会连带改掉既有断言。 | |
| 15 | + * 这里用 `GRAPH_SEED_ROOT` 自建一个只有两个角色的最小夹具,互不干扰。 | |
| 16 | + */ | |
| 17 | +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; | |
| 18 | +import fs from 'fs'; | |
| 19 | +import os from 'os'; | |
| 20 | +import path from 'path'; | |
| 21 | + | |
| 22 | +const MODULES = ['../../services/graph-db', '../../services/graph-seed-service']; | |
| 23 | + | |
| 24 | +describe('verifyCounts × meta-bootstrap 过滤', () => { | |
| 25 | + let seed; | |
| 26 | + let root; | |
| 27 | + let dbPath; | |
| 28 | + | |
| 29 | + const COMPANY = 'co-mb'; | |
| 30 | + const NORMAL_ROLE = 'org-planner'; | |
| 31 | + const BOOTSTRAP_ROLE = 'orggraph-planning'; | |
| 32 | + | |
| 33 | + beforeAll(() => { | |
| 34 | + root = fs.mkdtempSync(path.join(os.tmpdir(), 'seed-mb-root-')); | |
| 35 | + dbPath = path.join(root, 'graph.sqlite'); | |
| 36 | + | |
| 37 | + // 最小夹具:system/agents/<id>/agent.md + system/templates/_registry.json | |
| 38 | + // + data/companies/<cid>/{company.json, agents/<role>/config.json} | |
| 39 | + for (const id of [NORMAL_ROLE, BOOTSTRAP_ROLE]) { | |
| 40 | + const d = path.join(root, 'system', 'agents', id); | |
| 41 | + fs.mkdirSync(d, { recursive: true }); | |
| 42 | + fs.writeFileSync(path.join(d, 'agent.md'), `# ${id}\n`); | |
| 43 | + } | |
| 44 | + fs.mkdirSync(path.join(root, 'system', 'templates'), { recursive: true }); | |
| 45 | + fs.writeFileSync(path.join(root, 'system', 'templates', '_registry.json'), JSON.stringify({})); | |
| 46 | + | |
| 47 | + const companyDir = path.join(root, 'data', 'companies', COMPANY); | |
| 48 | + fs.mkdirSync(companyDir, { recursive: true }); | |
| 49 | + fs.writeFileSync( | |
| 50 | + path.join(companyDir, 'company.json'), | |
| 51 | + JSON.stringify({ id: COMPANY, name: COMPANY, agents: [NORMAL_ROLE, BOOTSTRAP_ROLE] }) | |
| 52 | + ); | |
| 53 | + // 一个普通角色 + 一个 meta-bootstrap 角色(后者是 seed 会跳过的那类) | |
| 54 | + for (const [role, type] of [[NORMAL_ROLE, 'executor'], [BOOTSTRAP_ROLE, 'meta-bootstrap']]) { | |
| 55 | + const rd = path.join(companyDir, 'agents', role); | |
| 56 | + fs.mkdirSync(rd, { recursive: true }); | |
| 57 | + fs.writeFileSync(path.join(rd, 'config.json'), JSON.stringify({ type })); | |
| 58 | + } | |
| 59 | + | |
| 60 | + process.env.GRAPH_SEED_ROOT = root; | |
| 61 | + process.env.GRAPH_DB_PATH = dbPath; | |
| 62 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 63 | + seed = require('../../services/graph-seed-service'); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + afterAll(() => { | |
| 67 | + delete process.env.GRAPH_SEED_ROOT; | |
| 68 | + delete process.env.GRAPH_DB_PATH; | |
| 69 | + try { fs.rmSync(root, { recursive: true, force: true }); } catch {} | |
| 70 | + for (const m of MODULES) delete require.cache[require.resolve(m)]; | |
| 71 | + }); | |
| 72 | + | |
| 73 | + it('种完之后对账干净:meta-bootstrap 角色不算进期望值', () => { | |
| 74 | + // runStartupSeed 只做 reconcile + 全局 agent + 模板,**不含** seedCompanyAgents | |
| 75 | + // (两个 server 入口启动时也不种公司角色)—— 所以公司那部分要显式调。 | |
| 76 | + // 少了这一行的话对账会报 expected 1 / actual 0,看着像本条要防的那个 bug, | |
| 77 | + // 其实是夹具没种东西:两种原因症状相同,所以下面第二条用例专门把它们分开。 | |
| 78 | + seed.runStartupSeed(); | |
| 79 | + seed.seedCompanyAgents(); | |
| 80 | + const v = seed.verifyCounts(); | |
| 81 | + // 期望值把 meta-bootstrap 算进去时,这里会是 | |
| 82 | + // [{check: "org_graph_nodes COUNT(*) WHERE company_id='co-mb'", expected: 2, actual: 1}] | |
| 83 | + expect(v.mismatches).toEqual([]); | |
| 84 | + expect(v.ok).toBe(true); | |
| 85 | + }); | |
| 86 | + | |
| 87 | + it('这个夹具确实覆盖到了那条分支(否则上一条是空跑)', () => { | |
| 88 | + const db = require('../../services/graph-db').getDb(); | |
| 89 | + const rows = db | |
| 90 | + .prepare(`SELECT role_name FROM org_graph_nodes WHERE company_id = ? ORDER BY role_name`) | |
| 91 | + .all(COMPANY) | |
| 92 | + .map((r) => r.role_name); | |
| 93 | + // 普通角色种进去了、meta-bootstrap 角色没有 —— 两侧都断言, | |
| 94 | + // 只断言「对账干净」的话,seed 一个都没种时它也会“通过” | |
| 95 | + expect(rows).toEqual([NORMAL_ROLE]); | |
| 96 | + }); | |
| 97 | +}); | ... | ... |
web/src/components/graph-console/FilePreviewModal.vue
0 → 100644
| 1 | +<!-- | |
| 2 | + 产物文件内容预览(R-19 输入输出 tab 的配套) | |
| 3 | + | |
| 4 | + 为什么需要它:「输入输出」tab 把产物的**绝对路径**摆出来了,但那是服务器本地路径 —— | |
| 5 | + 在浏览器里既点不开也拷不进 IDE 就能用,走查时只能靠肉眼确认「路径长得对」, | |
| 6 | + 而真正要确认的是「这份 brd.md 里到底写了什么、下游读到的是不是它」。 | |
| 7 | + | |
| 8 | + 后端复用现成的 `GET /api/company/:cid/files/content?path=<相对公司根>` | |
| 9 | + (没有新造端点;`graph_files.rel_path` 本来就是相对公司根的,正好对上)。 | |
| 10 | + 该端点有 512KB 上限,超了会回 `truncated: true`,这里如实显示而不是装作读全了。 | |
| 11 | + | |
| 12 | + 渲染分三档,刻意不做语法高亮之外的加工: | |
| 13 | + .md → marked(已是既有依赖,不新增包) | |
| 14 | + .json → JSON.parse 后重新缩进;**解析失败时退回原文**并说明 —— | |
| 15 | + 产物 json 写坏了本身就是要看的信息,替它藏起来等于删掉线索 | |
| 16 | + 其它 → 原文 <pre> | |
| 17 | +--> | |
| 18 | +<template> | |
| 19 | + <div v-if="open" class="mask" @click.self="close"> | |
| 20 | + <div class="modal" role="dialog" aria-modal="true" :aria-label="`预览 ${fileName}`"> | |
| 21 | + <header class="head"> | |
| 22 | + <div class="titles"> | |
| 23 | + <strong class="name">{{ fileName }}</strong> | |
| 24 | + <span v-if="kind" class="kind">{{ kind }}</span> | |
| 25 | + </div> | |
| 26 | + <div class="acts"> | |
| 27 | + <button class="mini" :disabled="!content" @click="copyPath"> | |
| 28 | + {{ copied === 'path' ? '已复制' : '复制路径' }} | |
| 29 | + </button> | |
| 30 | + <button class="mini" :disabled="!content" @click="copyBody"> | |
| 31 | + {{ copied === 'body' ? '已复制' : '复制内容' }} | |
| 32 | + </button> | |
| 33 | + <button class="mini close" aria-label="关闭" @click="close">✕</button> | |
| 34 | + </div> | |
| 35 | + </header> | |
| 36 | + | |
| 37 | + <p class="fullpath mono">{{ absPath }}</p> | |
| 38 | + | |
| 39 | + <div class="body"> | |
| 40 | + <p v-if="loading" class="muted">读取中…</p> | |
| 41 | + | |
| 42 | + <div v-else-if="error" class="err"> | |
| 43 | + <p><b>读不出来:</b>{{ error }}</p> | |
| 44 | + <p class="muted small"> | |
| 45 | + 这个端点的根目录是 <code>data/companies/{{ companyId }}</code>,只认相对它的路径。 | |
| 46 | + 索引里登记的 <code>rel_path</code> 正是这种形态;若这里报 404, | |
| 47 | + 说明文件已被移动或删除,而索引行还在(<code>graph_files</code> 不跟随文件系统变化)。 | |
| 48 | + </p> | |
| 49 | + </div> | |
| 50 | + | |
| 51 | + <template v-else> | |
| 52 | + <p v-if="truncated" class="warn small"> | |
| 53 | + ⚠️ 文件超过 512KB,后端未返回全文——下面显示的不是完整内容。 | |
| 54 | + </p> | |
| 55 | + <div v-if="mode === 'md'" class="md" v-html="rendered"></div> | |
| 56 | + <pre v-else class="code">{{ rendered }}</pre> | |
| 57 | + <p v-if="mode === 'json-raw'" class="warn small"> | |
| 58 | + ⚠️ 这份 <code>.json</code> 解析失败,上面是**原文**(没有替它格式化)。 | |
| 59 | + 产物 json 写坏了本身就是要看的信息。 | |
| 60 | + </p> | |
| 61 | + </template> | |
| 62 | + </div> | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | +</template> | |
| 66 | + | |
| 67 | +<script setup> | |
| 68 | +import { ref, computed, watch } from 'vue'; | |
| 69 | +import { marked } from 'marked'; | |
| 70 | + | |
| 71 | +const props = defineProps({ | |
| 72 | + open: { type: Boolean, default: false }, | |
| 73 | + companyId: { type: String, default: '' }, | |
| 74 | + relPath: { type: String, default: '' }, | |
| 75 | + absPath: { type: String, default: '' }, | |
| 76 | + kind: { type: String, default: '' }, | |
| 77 | +}); | |
| 78 | +const emit = defineEmits(['close']); | |
| 79 | + | |
| 80 | +const loading = ref(false); | |
| 81 | +const error = ref(''); | |
| 82 | +const content = ref(''); | |
| 83 | +const truncated = ref(false); | |
| 84 | +const copied = ref(''); | |
| 85 | + | |
| 86 | +const fileName = computed(() => String(props.relPath || props.absPath).split('/').pop() || '文件'); | |
| 87 | + | |
| 88 | +const mode = computed(() => { | |
| 89 | + const n = fileName.value.toLowerCase(); | |
| 90 | + if (n.endsWith('.md') || n.endsWith('.markdown')) return 'md'; | |
| 91 | + if (n.endsWith('.json')) return jsonOk.value ? 'json' : 'json-raw'; | |
| 92 | + return 'text'; | |
| 93 | +}); | |
| 94 | + | |
| 95 | +const jsonOk = ref(true); | |
| 96 | + | |
| 97 | +const rendered = computed(() => { | |
| 98 | + if (!content.value) return ''; | |
| 99 | + if (mode.value === 'md') return marked.parse(content.value); | |
| 100 | + if (mode.value === 'json') { | |
| 101 | + try { | |
| 102 | + return JSON.stringify(JSON.parse(content.value), null, 2); | |
| 103 | + } catch { | |
| 104 | + return content.value; | |
| 105 | + } | |
| 106 | + } | |
| 107 | + return content.value; | |
| 108 | +}); | |
| 109 | + | |
| 110 | +function close() { | |
| 111 | + emit('close'); | |
| 112 | +} | |
| 113 | + | |
| 114 | +async function load() { | |
| 115 | + loading.value = true; | |
| 116 | + error.value = ''; | |
| 117 | + content.value = ''; | |
| 118 | + truncated.value = false; | |
| 119 | + jsonOk.value = true; | |
| 120 | + copied.value = ''; | |
| 121 | + try { | |
| 122 | + const q = encodeURIComponent(props.relPath); | |
| 123 | + const r = await fetch(`/api/company/${props.companyId}/files/content?path=${q}`); | |
| 124 | + const d = await r.json().catch(() => ({})); | |
| 125 | + if (!r.ok) { | |
| 126 | + error.value = d.error || `HTTP ${r.status}`; | |
| 127 | + return; | |
| 128 | + } | |
| 129 | + content.value = d.content ?? ''; | |
| 130 | + truncated.value = Boolean(d.truncated); | |
| 131 | + if (fileName.value.toLowerCase().endsWith('.json')) { | |
| 132 | + try { JSON.parse(content.value); } catch { jsonOk.value = false; } | |
| 133 | + } | |
| 134 | + } catch (e) { | |
| 135 | + error.value = e.message; | |
| 136 | + } finally { | |
| 137 | + loading.value = false; | |
| 138 | + } | |
| 139 | +} | |
| 140 | + | |
| 141 | +async function copy(text, tag) { | |
| 142 | + try { | |
| 143 | + await navigator.clipboard.writeText(text); | |
| 144 | + copied.value = tag; | |
| 145 | + setTimeout(() => { if (copied.value === tag) copied.value = ''; }, 1500); | |
| 146 | + } catch { | |
| 147 | + // 剪贴板被拒(非 https / 无权限)时不假装成功 | |
| 148 | + copied.value = ''; | |
| 149 | + } | |
| 150 | +} | |
| 151 | +const copyPath = () => copy(props.absPath || props.relPath, 'path'); | |
| 152 | +const copyBody = () => copy(content.value, 'body'); | |
| 153 | + | |
| 154 | +// 打开时才拉;换文件也要重拉 | |
| 155 | +watch( | |
| 156 | + () => [props.open, props.relPath], | |
| 157 | + ([isOpen, rel]) => { if (isOpen && rel) load(); }, | |
| 158 | + { immediate: true } | |
| 159 | +); | |
| 160 | + | |
| 161 | +// Esc 关闭:弹框存在期间才挂监听 | |
| 162 | +watch(() => props.open, (isOpen) => { | |
| 163 | + const onKey = (e) => { if (e.key === 'Escape') close(); }; | |
| 164 | + if (isOpen) window.addEventListener('keydown', onKey, { once: true }); | |
| 165 | +}); | |
| 166 | +</script> | |
| 167 | + | |
| 168 | +<style scoped> | |
| 169 | +.mask { | |
| 170 | + position: fixed; inset: 0; z-index: 60; | |
| 171 | + background: rgba(0, 0, 0, .42); | |
| 172 | + display: flex; align-items: center; justify-content: center; padding: 24px; | |
| 173 | +} | |
| 174 | +.modal { | |
| 175 | + background: var(--bg-surface, #fff); color: var(--text-primary, #1a1a1a); | |
| 176 | + border: 1px solid var(--border-primary, #e2e2e6); border-radius: 10px; | |
| 177 | + width: min(900px, 100%); max-height: 84vh; display: flex; flex-direction: column; | |
| 178 | + box-shadow: 0 12px 40px rgba(0, 0, 0, .18); | |
| 179 | +} | |
| 180 | +.head { | |
| 181 | + display: flex; align-items: center; gap: 12px; padding: 12px 14px 10px; | |
| 182 | + border-bottom: 1px solid var(--border-primary, #eee); | |
| 183 | +} | |
| 184 | +.titles { display: flex; align-items: center; gap: 8px; min-width: 0; } | |
| 185 | +.name { font-size: 14px; word-break: break-all; } | |
| 186 | +.kind { | |
| 187 | + font-family: ui-monospace, Menlo, monospace; font-size: 11px; font-weight: 700; | |
| 188 | + background: #eaf0ff; color: #1d4ed8; border-radius: 4px; padding: 1px 6px; | |
| 189 | +} | |
| 190 | +.acts { margin-left: auto; display: flex; gap: 6px; flex-shrink: 0; } | |
| 191 | +.mini { | |
| 192 | + font-size: 12px; padding: 3px 9px; cursor: pointer; | |
| 193 | + border: 1px solid var(--border-primary, #ddd); border-radius: 5px; | |
| 194 | + background: var(--bg-overlay, #fafafa); color: inherit; | |
| 195 | +} | |
| 196 | +.mini:disabled { opacity: .5; cursor: not-allowed; } | |
| 197 | +.mini.close { font-size: 13px; padding: 3px 8px; } | |
| 198 | +.fullpath { | |
| 199 | + margin: 8px 14px 0; padding: 5px 8px; font-size: 11.5px; | |
| 200 | + background: #f7f7f9; border-radius: 4px; word-break: break-all; color: #555; | |
| 201 | +} | |
| 202 | +.body { padding: 12px 14px 16px; overflow: auto; } | |
| 203 | +.code { | |
| 204 | + font-family: ui-monospace, Menlo, monospace; font-size: 12.5px; line-height: 1.6; | |
| 205 | + background: #f7f7f9; border-radius: 6px; padding: 12px; margin: 0; | |
| 206 | + white-space: pre-wrap; word-break: break-word; | |
| 207 | +} | |
| 208 | +.md { font-size: 14px; line-height: 1.7; } | |
| 209 | +.md :deep(h1) { font-size: 20px; } | |
| 210 | +.md :deep(h2) { font-size: 17px; } | |
| 211 | +.md :deep(h3) { font-size: 15px; } | |
| 212 | +.md :deep(pre) { | |
| 213 | + background: #f7f7f9; border-radius: 6px; padding: 10px; overflow-x: auto; | |
| 214 | + font-size: 12.5px; | |
| 215 | +} | |
| 216 | +.md :deep(code) { font-family: ui-monospace, Menlo, monospace; font-size: .9em; } | |
| 217 | +.md :deep(table) { border-collapse: collapse; } | |
| 218 | +.md :deep(th), .md :deep(td) { border: 1px solid #e2e2e6; padding: 4px 8px; } | |
| 219 | +.mono { font-family: ui-monospace, Menlo, monospace; } | |
| 220 | +.muted { color: #888; } | |
| 221 | +.small { font-size: 12px; } | |
| 222 | +.warn { color: #b3480c; } | |
| 223 | +.err { color: #c0392b; font-size: 13px; } | |
| 224 | +.err .muted { color: #888; } | |
| 225 | +</style> | ... | ... |
web/src/components/graph-console/NodeDetailPanel.vue
| ... | ... | @@ -159,7 +159,12 @@ |
| 159 | 159 | </span> |
| 160 | 160 | <span v-else-if="i.source === 'task'" class="from">不受拓扑相邻性限制(R-9.3)</span> |
| 161 | 161 | </div> |
| 162 | - <p v-if="i.resolved" class="mono path">{{ i.path }}</p> | |
| 162 | + <button | |
| 163 | + v-if="i.resolved" | |
| 164 | + class="mono path as-link" | |
| 165 | + :title="`预览 ${i.file && i.file.relPath}`" | |
| 166 | + @click="preview(i.file, i.kind, i.path)" | |
| 167 | + >{{ i.path }}</button> | |
| 163 | 168 | <p v-else class="warn"> |
| 164 | 169 | ⚠️ 图上声明了这份输入,索引里查不到实例 —— 先确认是不是上游没产出 |
| 165 | 170 | </p> |
| ... | ... | @@ -189,7 +194,11 @@ |
| 189 | 194 | <span v-if="o.supersededBy" class="opt">已被新版本取代</span> |
| 190 | 195 | <span class="from">{{ o.createdAt || '' }}</span> |
| 191 | 196 | </div> |
| 192 | - <p class="mono path">{{ o.path }}</p> | |
| 197 | + <button | |
| 198 | + class="mono path as-link" | |
| 199 | + :title="`预览 ${o.relPath}`" | |
| 200 | + @click="preview(o, o.kind, o.path)" | |
| 201 | + >{{ o.path }}</button> | |
| 193 | 202 | </li> |
| 194 | 203 | </ul> |
| 195 | 204 | <p v-else-if="io.hasIndex && node.outputRef" class="muted small"> |
| ... | ... | @@ -289,13 +298,23 @@ |
| 289 | 298 | </WorkGraphCanvas> |
| 290 | 299 | </section> |
| 291 | 300 | </template> |
| 301 | + | |
| 302 | + <FilePreviewModal | |
| 303 | + :open="pv.open" | |
| 304 | + :company-id="companyId" | |
| 305 | + :rel-path="pv.relPath" | |
| 306 | + :abs-path="pv.absPath" | |
| 307 | + :kind="pv.kind" | |
| 308 | + @close="pv.open = false" | |
| 309 | + /> | |
| 292 | 310 | </div> |
| 293 | 311 | </template> |
| 294 | 312 | |
| 295 | 313 | <script setup> |
| 296 | -import { ref, computed, watch, onBeforeUnmount } from 'vue'; | |
| 314 | +import { ref, reactive, computed, watch, onBeforeUnmount } from 'vue'; | |
| 297 | 315 | import WorkGraphCanvas from '../WorkGraphCanvas.vue'; |
| 298 | 316 | import NodeRunLog from './NodeRunLog.vue'; |
| 317 | +import FilePreviewModal from './FilePreviewModal.vue'; | |
| 299 | 318 | import { nodeEvents, attemptOf, lastWhere, describeEvent, fmtDuration, since } from './node-signals'; |
| 300 | 319 | import { resolveNodeIo, IO_BACKEND_FIELDS } from './io-model'; |
| 301 | 320 | |
| ... | ... | @@ -310,12 +329,28 @@ const props = defineProps({ |
| 310 | 329 | edges: { type: Array, default: () => [] }, |
| 311 | 330 | files: { type: Array, default: null }, |
| 312 | 331 | companyRoot: { type: String, default: null }, |
| 332 | + // 文件预览要它拼端点(GET /api/company/:companyId/files/content)。 | |
| 333 | + // 不从 companyRoot 里截 basename:那是猜,公司 id 与目录名将来不一定同名。 | |
| 334 | + companyId: { type: String, default: '' }, | |
| 313 | 335 | }); |
| 314 | 336 | defineEmits(['dive']); |
| 315 | 337 | |
| 316 | 338 | const tab = ref('overview'); |
| 317 | 339 | const child = ref({ nodes: [], edges: [], loopRecords: [] }); |
| 318 | 340 | |
| 341 | +// 文件预览。relPath 是端点唯一认的入参(相对公司根),absPath 只用于展示与复制。 | |
| 342 | +const pv = reactive({ open: false, relPath: '', absPath: '', kind: '' }); | |
| 343 | +function preview(file, kind, abs) { | |
| 344 | + // 索引行里没有 relPath 就开不了:宁可不响应,也不要拿绝对路径去撞那个只认相对路径的端点 | |
| 345 | + const rel = file && (file.relPath || file.rel_path); | |
| 346 | + if (!rel) return; | |
| 347 | + pv.relPath = rel; | |
| 348 | + // abs 由调用方显式给:输入侧的绝对路径挂在**输入项**上(i.path),不在 i.file 里 | |
| 349 | + pv.absPath = abs || rel; | |
| 350 | + pv.kind = kind || file.kind || ''; | |
| 351 | + pv.open = true; | |
| 352 | +} | |
| 353 | + | |
| 319 | 354 | const hasSub = computed(() => props.node && props.node.nodeKind === 'subgraph' && props.node.childRunId); |
| 320 | 355 | |
| 321 | 356 | /** |
| ... | ... | @@ -562,6 +597,16 @@ h4 { font-size:11px; color:#888; text-transform:uppercase; margin:14px 0 6px; } |
| 562 | 597 | .kv dd { margin:0; word-break:break-all; } |
| 563 | 598 | .mono { font-family: ui-monospace, monospace; font-size:11px; } |
| 564 | 599 | .path { background:#f7f7f9; padding:5px 8px; border-radius:4px; word-break:break-all; } |
| 600 | +/* 路径改成按钮(可点开预览)但要保持原来那副「一行代码」的样子: | |
| 601 | + display:block + 左对齐 + 满宽,否则它会缩成一个居中的小按钮, | |
| 602 | + 和上下文里其它 .path 行对不齐 */ | |
| 603 | +button.path.as-link { | |
| 604 | + display:block; width:100%; text-align:left; margin:4px 0 0; | |
| 605 | + border:1px solid transparent; font:inherit; font-size:11.5px; | |
| 606 | + color:#1d4ed8; cursor:pointer; | |
| 607 | +} | |
| 608 | +button.path.as-link:hover { border-color:#c7d2fe; background:#eef2ff; } | |
| 609 | +button.path.as-link:focus-visible { outline:2px solid #1d4ed8; outline-offset:1px; } | |
| 565 | 610 | .badge { font-size:10px; padding:2px 8px; border-radius:9px; background:#eee; color:#555; text-transform:uppercase; } |
| 566 | 611 | .badge.running { background:#cce5ff; color:#004085; } |
| 567 | 612 | .badge.success { background:#d4edda; color:#155724; } | ... | ... |
web/src/views/GraphConsole.vue