import { spawn } from 'node:child_process'; import { randomUUID } from 'node:crypto'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = path.resolve(appRoot, '../..'); const manifestPath = path.join(appRoot, 'src-tauri/Cargo.toml'); const agentId = 'code-prototype'; const runId = `steer-real-${Date.now()}-${randomUUID().slice(0, 8)}`; const projectRoot = path.join(os.tmpdir(), `genarrative-${runId}`); const marker = `STEER_REAL_${randomUUID().replaceAll('-', '').toUpperCase()}`; const instruction = `运行中补充:继续保持只读,不调用写入、命令、预览或生成工具;最终回复必须原样包含 ${marker},并明确说明已按追加指令更新。`; const timeoutMs = 8 * 60 * 1000; const pollMs = 200; const options = parseArguments(process.argv.slice(2)); const evidence = { providerInterrupted: false, steerAttempts: 0, taskRunIds: [], steerStatuses: [], userMessageCount: 0, assistantMessageCount: 0, publicInstructionLeakCount: 0, loadedKeyLeakCount: 0, }; let status = 'FAIL'; let error = null; let binary = null; try { const config = await loadConfig(options.configDir); const secrets = collectSecrets(config); assert(isAgentConfigured(config, agentId), 'llm-not-configured'); binary = await prepareCliBinary(); await runCli( [ '--agent-enqueue', '--init', projectRoot, agentId, runId, '这是只读验收。不要修改文件、不要执行命令、不要预览或生成资产;请仔细分析后给出一句简短结论。', ], null, 120_000, ); const initial = await waitForRuntime( (runtime) => runtime.state.runId === runId && isSteerable(runtime.state), 60_000, ); const sessionId = initial.state.sessionId; assert(sessionId, 'missing-session-id'); let latestSteerId = null; for (let attempt = 1; attempt <= 3; attempt += 1) { const steerId = `steer-real-${attempt}-${randomUUID().slice(0, 8)}`; const result = await runCli( [ '--agent-steer', projectRoot, agentId, sessionId, runId, steerId, '--stdin', ], instruction, 120_000, ); assert(!result.stdout.includes(instruction), 'steer-body-stdout-leak'); const steer = parsePrefixedJson(result.stdout, 'steerJson='); evidence.steerAttempts = attempt; latestSteerId = steerId; evidence.providerInterrupted ||= steer.providerInterrupted === true; if (evidence.providerInterrupted) break; await waitForRuntime( (runtime) => runtime.state.runId === runId && Number(runtime.state.appliedSteerCursor ?? 0) >= attempt && isSteerable(runtime.state), 60_000, ); } assert(latestSteerId, 'steer-not-accepted'); assert(evidence.providerInterrupted, 'provider-not-interrupted'); const terminal = await waitForRuntime( (runtime) => runtime.recentTasks?.some( (task) => task.runId === runId && isTerminalTask(task), ) === true, timeoutMs, ); const terminalTask = terminal.recentTasks.find( (task) => task.runId === runId && isTerminalTask(task), ); assert(terminalTask?.status === 'completed', 'steered-run-not-completed'); const taskRecords = await readJsonl( path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`), ); evidence.taskRunIds = [...new Set(taskRecords.map((record) => record.runId))]; assert( evidence.taskRunIds.length === 1 && evidence.taskRunIds[0] === runId, 'steer-created-new-run', ); assert( !taskRecords.some((record) => JSON.stringify(record).includes(instruction)), 'steer-body-task-leak', ); const steerRecords = await readJsonl( path.join(projectRoot, '.agent/runtime/steers', agentId, `${runId}.jsonl`), ); evidence.steerStatuses = steerRecords.map((record) => record.status); assert(evidence.steerStatuses.includes('prepared'), 'steer-prepared-missing'); assert(evidence.steerStatuses.includes('queued'), 'steer-queued-missing'); assert(evidence.steerStatuses.includes('applied'), 'steer-applied-missing'); assert(evidence.steerStatuses.at(-1) === 'closed', 'steer-ledger-not-closed'); assert( steerRecords .filter((record) => record.status === 'prepared') .every((record) => typeof record.instruction === 'string'), 'steer-prepared-body-missing', ); assert( steerRecords .filter((record) => record.status !== 'prepared') .every((record) => record.instruction == null), 'steer-body-transition-leak', ); const conversationPath = sessionId === `agent-session-${agentId}` ? path.join( projectRoot, '.agent/conversations/agents', `${agentId}.jsonl`, ) : path.join( projectRoot, '.agent/conversations/agents', agentId, 'sessions', `${sessionId}.jsonl`, ); const messages = await readJsonl(conversationPath); evidence.userMessageCount = messages.filter( (message) => message.role === 'user', ).length; evidence.assistantMessageCount = messages.filter( (message) => message.role === 'assistant', ).length; assert( evidence.userMessageCount === evidence.steerAttempts + 1, 'steer-user-message-count-invalid', ); assert(evidence.assistantMessageCount === 1, 'assistant-count-invalid'); const assistant = messages.find((message) => message.role === 'assistant'); assert(assistant?.content.includes(marker), 'final-assistant-missing-marker'); const publicPaths = [ path.join(projectRoot, '.agent/runtime/tasks', `${agentId}.jsonl`), path.join(projectRoot, '.agent/runtime/events', `${agentId}.jsonl`), path.join(projectRoot, '.agent/runtime/agents', `${agentId}.json`), path.join(projectRoot, '.agent/agent.db'), ]; evidence.publicInstructionLeakCount = await countNeedleInFiles( publicPaths, instruction, ); assert(evidence.publicInstructionLeakCount === 0, 'steer-body-public-leak'); evidence.loadedKeyLeakCount = await countNeedlesInTree(projectRoot, secrets); assert(evidence.loadedKeyLeakCount === 0, 'loaded-key-project-leak'); status = 'PASS'; } catch (caught) { error = caught instanceof Error ? caught.message : String(caught); if (error === 'llm-not-configured') status = 'BLOCKED'; } finally { if (!options.keepProject) { await fs.rm(projectRoot, { recursive: true, force: true }); } process.stdout.write( `${JSON.stringify( { status, suite: 'steer', runId, projectKept: options.keepProject, evidence, error, }, null, 2, )}\n`, ); process.exitCode = status === 'PASS' ? 0 : status === 'BLOCKED' ? 2 : 1; } function parseArguments(args) { let configDir = null; let keepProject = false; for (let index = 0; index < args.length; index += 1) { if (args[index] === '--config-dir') { configDir = args[++index]; } else if (args[index] === '--keep-project') { keepProject = true; } else { throw new Error(`unknown-argument:${args[index]}`); } } assert(configDir && path.isAbsolute(configDir), 'config-dir-not-absolute'); const resolved = path.resolve(configDir); assert(!isInside(repoRoot, resolved), 'config-dir-inside-repository'); return { configDir: resolved, keepProject }; } async function loadConfig(configDir) { const content = await fs.readFile( path.join(configDir, 'game-creator.config.json'), 'utf8', ); return JSON.parse(content); } function isAgentConfigured(config, targetAgentId) { const effective = { apiKey: config.llm?.apiKey, baseUrl: config.llm?.baseUrl, model: config.llm?.model, ...(config.agentLlm?.[targetAgentId] ?? {}), }; return ['apiKey', 'baseUrl', 'model'].every( (key) => typeof effective[key] === 'string' && effective[key].trim(), ); } function collectSecrets(config) { const secrets = []; for (const candidate of [ config.llm?.apiKey, config.editorApi?.apiKey, ...Object.values(config.agentLlm ?? {}).map((agent) => agent?.apiKey), ]) { if (typeof candidate === 'string' && candidate.trim().length >= 8) { secrets.push(candidate.trim()); } } return [...new Set(secrets)]; } async function prepareCliBinary() { const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; await runProcess( cargo, ['build', '--quiet', '--manifest-path', manifestPath], null, 15 * 60 * 1000, ); const metadata = await runProcess( cargo, [ 'metadata', '--format-version', '1', '--no-deps', '--manifest-path', manifestPath, ], null, 120_000, ); const parsed = JSON.parse(metadata.stdout); return path.join( parsed.target_directory, 'debug', `genarrative-ai-game-creator-shell${process.platform === 'win32' ? '.exe' : ''}`, ); } async function runCli(args, input = null, commandTimeoutMs = 60_000) { return runProcess( binary, [...args, '--config-dir', options.configDir], input, commandTimeoutMs, ); } async function waitForRuntime(predicate, waitMs) { const deadline = Date.now() + waitMs; let latest = null; while (Date.now() < deadline) { const result = await runCli([ '--agent-runtime-status', projectRoot, agentId, ]); latest = parsePrefixedJson(result.stdout, 'runtimeJson='); if (predicate(latest)) return latest; await new Promise((resolve) => setTimeout(resolve, pollMs)); } throw new Error( `runtime-timeout:${latest?.state?.status ?? 'missing'}/${latest?.state?.phase ?? 'missing'}`, ); } function isSteerable(runtime) { return ( ['running', 'waiting-for-confirmation'].includes(runtime.status) && !['cancelling', 'finalizing', 'needs-reconciliation'].includes( runtime.phase, ) ); } function isTerminalTask(task) { return ['completed', 'failed', 'cancelled'].includes(task.status); } function parsePrefixedJson(stdout, prefix) { const line = stdout .split(/\r?\n/u) .find((candidate) => candidate.startsWith(prefix)); assert(line, `missing-output:${prefix}`); return JSON.parse(line.slice(prefix.length)); } async function readJsonl(file) { const content = await fs.readFile(file, 'utf8'); return content .split(/\r?\n/u) .filter((line) => line.trim()) .map((line) => JSON.parse(line)); } async function countNeedleInFiles(files, needle) { let count = 0; for (const file of files) { const content = await fs.readFile(file).catch(() => Buffer.alloc(0)); count += countNeedle(content, Buffer.from(needle)); } return count; } async function countNeedlesInTree(root, needles) { let count = 0; const entries = await fs.readdir(root, { withFileTypes: true }); for (const entry of entries) { const target = path.join(root, entry.name); if (entry.isDirectory()) { count += await countNeedlesInTree(target, needles); } else if (entry.isFile()) { const content = await fs.readFile(target); for (const needle of needles) count += countNeedle(content, Buffer.from(needle)); } } return count; } function countNeedle(haystack, needle) { if (needle.length === 0) return 0; let count = 0; let offset = 0; while (offset <= haystack.length - needle.length) { const index = haystack.indexOf(needle, offset); if (index < 0) break; count += 1; offset = index + needle.length; } return count; } function runProcess(program, args, input, commandTimeoutMs) { return new Promise((resolve, reject) => { const child = spawn(program, args, { cwd: appRoot, env: { ...process.env, NO_COLOR: '1', RUST_BACKTRACE: '0' }, stdio: ['pipe', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; const timer = setTimeout(() => { child.kill('SIGKILL'); }, commandTimeoutMs); child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); }); child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); }); child.on('error', reject); child.on('close', (code, signal) => { clearTimeout(timer); if (code === 0) { resolve({ stdout, stderr }); } else { reject(new Error(`command-failed:${code ?? signal}:${stderr.trim()}`)); } }); if (input === null) child.stdin.end(); else child.stdin.end(input); }); } function isInside(parent, target) { const relative = path.relative(parent, target); return ( relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) ); } function assert(condition, code) { if (!condition) throw new Error(code); }