Commit 0bbf3e4b0f22086c5cd0df496f4af40c10081306

Authored by yibo
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>
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(&#39;/templates/:templateId/files/content&#39;, (req, res) =&gt; {
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(&#39;/templates/:templateId/files/content&#39;, (req, res) =&gt; {
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(&#39;/:companyId/files/content&#39;, (req, res) =&gt; {
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(&#39;/:companyId/files/content&#39;, (req, res) =&gt; {
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(&#39;/:companyId/workspace/files/content&#39;, (req, res) =&gt; {
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(&#39;/:companyId/workspace/files/content&#39;, (req, res) =&gt; {
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
... ... @@ -133,6 +133,7 @@
133 133 :edges="selectedState.edges || []"
134 134 :files="selectedState.taskFiles || null"
135 135 :company-root="selectedState.companyRoot || null"
  136 + :company-id="companyId"
136 137 @dive="dive"
137 138 />
138 139 </aside>
... ...