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(); });