0957a9049a
- API:新增 GET /api/runs/:id/transcript,读 run.transcriptRef 指向的 jsonl,逐行解析成脱敏后的结构化消息数组。校验 runId 合法且关联任务/ 项目存在;路径只允许在 transcriptDir 下(防目录穿越)。 - 脱敏:只透传 assistant 文本 / tool_use(名+摘要参数) / tool_result(截断) / 结论 / 模型回退;命中 secret/token/env 等敏感键名的值剔除为 [已脱敏]。 - 看板 archiveModal:执行历史从"文件路径"换成可展开的消息流,按 type 分类渲染(文本/工具调用/工具结果/结论/回退),长内容折叠,复用 mdToHtml。 executor 与 reviewer/security run 并列可回放。 - Store:新增 getRun(runId)。 - 测试:transcript 解析(样例 jsonl/脱敏/截断/坏行容错/路径穿越)+ 端点 inject(合法/未知 runId/无转录/越界路径)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
184 lines
7.3 KiB
TypeScript
184 lines
7.3 KiB
TypeScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { readTranscript, assertWithinTranscriptDir, TranscriptError } from '../src/executor/transcript.js';
|
||
import { Store } from '../src/store/index.js';
|
||
import { buildServer } from '../src/api/server.js';
|
||
|
||
/** 临时数据目录 + transcripts 子目录;返回 {dir, transcriptsDir} */
|
||
function tmpData(): { dir: string; transcripts: string } {
|
||
const dir = mkdtempSync(join(tmpdir(), 'maestro-transcript-'));
|
||
const transcripts = join(dir, 'transcripts');
|
||
mkdirSync(transcripts, { recursive: true });
|
||
return { dir, transcripts };
|
||
}
|
||
|
||
/** 写一行原始 SDK 风格消息到 jsonl */
|
||
function jsonl(lines: unknown[]): string {
|
||
return lines.map((l) => JSON.stringify(l)).join('\n') + '\n';
|
||
}
|
||
|
||
const SAMPLE = [
|
||
{ type: 'system', subtype: 'init', session_id: 's1' },
|
||
{ type: 'assistant', session_id: 's1', message: { role: 'assistant', content: [
|
||
{ type: 'text', text: '我先看一下文件结构。' },
|
||
{ type: 'tool_use', id: 'tu1', name: 'Bash', input: { command: 'ls -la', description: '列目录' } },
|
||
] } },
|
||
{ type: 'user', session_id: 's1', message: { role: 'user', content: [
|
||
{ type: 'tool_result', tool_use_id: 'tu1', content: 'total 4\nfile.txt', is_error: false },
|
||
] } },
|
||
{ type: 'maestro.model_fallback', from: 'opusX', to: 'sonnetY', reason: 'not_found' },
|
||
{ type: 'result', subtype: 'success', is_error: false, result: '完成:改了一行。' },
|
||
];
|
||
|
||
test('readTranscript:样例 jsonl → 结构化消息数组', () => {
|
||
const { transcripts } = tmpData();
|
||
const ref = join(transcripts, 'run_abc.jsonl');
|
||
writeFileSync(ref, jsonl(SAMPLE));
|
||
|
||
const msgs = readTranscript(ref, transcripts);
|
||
const types = msgs.map((m) => m.type);
|
||
// system/init 不外传;其余按序结构化
|
||
assert.deepEqual(types, ['text', 'tool_use', 'tool_result', 'fallback', 'result']);
|
||
|
||
const text = msgs[0];
|
||
assert.equal(text.role, 'assistant');
|
||
assert.equal(text.text, '我先看一下文件结构。');
|
||
|
||
const toolUse = msgs[1];
|
||
assert.equal(toolUse.name, 'Bash');
|
||
assert.match(toolUse.input!, /ls -la/);
|
||
|
||
const toolResult = msgs[2];
|
||
assert.match(toolResult.text!, /file\.txt/);
|
||
assert.equal(toolResult.isError, false);
|
||
|
||
const fb = msgs[3];
|
||
assert.equal(fb.from, 'opusX');
|
||
assert.equal(fb.to, 'sonnetY');
|
||
|
||
assert.equal(msgs[4].text, '完成:改了一行。');
|
||
});
|
||
|
||
test('脱敏:含 secret/token/env 的工具参数被剔除', () => {
|
||
const { transcripts } = tmpData();
|
||
const ref = join(transcripts, 'run_secret.jsonl');
|
||
writeFileSync(ref, jsonl([
|
||
{ type: 'assistant', message: { content: [
|
||
{ type: 'tool_use', name: 'Deploy', input: {
|
||
host: 'example.com',
|
||
apiKey: 'sk-LIVE-should-not-leak',
|
||
token: 'ghp_secrettoken',
|
||
env: { ANTHROPIC_API_KEY: 'sk-ant-xxx' },
|
||
password: 'hunter2',
|
||
note: 'safe-to-show',
|
||
} },
|
||
] } },
|
||
]));
|
||
|
||
const msgs = readTranscript(ref, transcripts);
|
||
const input = msgs[0].input!;
|
||
assert.doesNotMatch(input, /should-not-leak/);
|
||
assert.doesNotMatch(input, /ghp_secrettoken/);
|
||
assert.doesNotMatch(input, /sk-ant-xxx/);
|
||
assert.doesNotMatch(input, /hunter2/);
|
||
assert.match(input, /\[已脱敏\]/);
|
||
// 非敏感字段保留
|
||
assert.match(input, /example\.com/);
|
||
assert.match(input, /safe-to-show/);
|
||
});
|
||
|
||
test('长内容被截断并标记 truncated', () => {
|
||
const { transcripts } = tmpData();
|
||
const ref = join(transcripts, 'run_long.jsonl');
|
||
const big = 'x'.repeat(10_000);
|
||
writeFileSync(ref, jsonl([
|
||
{ type: 'user', message: { content: [{ type: 'tool_result', content: big }] } },
|
||
]));
|
||
const msgs = readTranscript(ref, transcripts);
|
||
assert.equal(msgs[0].truncated, true);
|
||
assert.ok(msgs[0].text!.length < big.length);
|
||
});
|
||
|
||
test('路径穿越被拒绝(invalid_path)', () => {
|
||
const { transcripts } = tmpData();
|
||
assert.throws(
|
||
() => assertWithinTranscriptDir(join(transcripts, '..', '..', 'etc', 'passwd'), transcripts),
|
||
(e: unknown) => e instanceof TranscriptError && e.code === 'invalid_path',
|
||
);
|
||
// 合法路径放行
|
||
assert.doesNotThrow(() => assertWithinTranscriptDir(join(transcripts, 'run_ok.jsonl'), transcripts));
|
||
});
|
||
|
||
test('坏行容错:非法 JSON 行被跳过,不影响其余解析', () => {
|
||
const { transcripts } = tmpData();
|
||
const ref = join(transcripts, 'run_bad.jsonl');
|
||
writeFileSync(ref, 'not-json\n' + JSON.stringify({ type: 'result', result: 'ok' }) + '\n\n');
|
||
const msgs = readTranscript(ref, transcripts);
|
||
assert.equal(msgs.length, 1);
|
||
assert.equal(msgs[0].text, 'ok');
|
||
});
|
||
|
||
// ── 端点测试(Fastify inject)──
|
||
test('GET /api/runs/:id/transcript:合法 run → 结构化消息', async () => {
|
||
const { dir, transcripts } = tmpData();
|
||
process.env.MAESTRO_DATA_DIR = dir; // transcriptDir() 在调用时读取
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
|
||
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
|
||
const run = store.startRun(task.id, 'executor');
|
||
const ref = join(transcripts, run.id + '.jsonl');
|
||
writeFileSync(ref, jsonl(SAMPLE));
|
||
store.finishRun(run.id, 'succeeded', { transcriptRef: ref });
|
||
|
||
const app = buildServer({ store });
|
||
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
|
||
assert.equal(res.statusCode, 200);
|
||
const body = res.json();
|
||
assert.equal(body.runId, run.id);
|
||
assert.equal(body.kind, 'executor');
|
||
assert.ok(Array.isArray(body.messages));
|
||
assert.equal(body.messages.length, 5);
|
||
await app.close();
|
||
store.close();
|
||
});
|
||
|
||
test('GET /api/runs/:id/transcript:未知 runId → 404', async () => {
|
||
const store = new Store(':memory:');
|
||
const app = buildServer({ store });
|
||
const res = await app.inject({ method: 'GET', url: '/api/runs/run_nope/transcript' });
|
||
assert.equal(res.statusCode, 404);
|
||
await app.close();
|
||
store.close();
|
||
});
|
||
|
||
test('GET /api/runs/:id/transcript:无转录记录 → 404', async () => {
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
|
||
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
|
||
const run = store.startRun(task.id, 'executor'); // 未 finishRun,transcriptRef = null
|
||
const app = buildServer({ store });
|
||
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
|
||
assert.equal(res.statusCode, 404);
|
||
await app.close();
|
||
store.close();
|
||
});
|
||
|
||
test('GET /api/runs/:id/transcript:越界路径 → 400', async () => {
|
||
const { dir } = tmpData();
|
||
process.env.MAESTRO_DATA_DIR = dir;
|
||
const store = new Store(':memory:');
|
||
const p = store.createProject({ name: 't', repoPath: '/tmp/t-' + Math.random() });
|
||
const task = store.createTask({ projectId: p.id, title: 'demo', complexity: 'easy' });
|
||
const run = store.startRun(task.id, 'executor');
|
||
// 指向 transcriptDir 之外(穿越)
|
||
store.finishRun(run.id, 'failed', { transcriptRef: '/etc/passwd' });
|
||
const app = buildServer({ store });
|
||
const res = await app.inject({ method: 'GET', url: `/api/runs/${run.id}/transcript` });
|
||
assert.equal(res.statusCode, 400);
|
||
await app.close();
|
||
store.close();
|
||
});
|