diff --git a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs index 5cf30e444..b19e2be5b 100644 --- a/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-runtime-real-e2e.mjs @@ -2,10 +2,10 @@ import { spawn } from 'node:child_process'; import { createHash, randomUUID } from 'node:crypto'; import { createReadStream } from 'node:fs'; import fs from 'node:fs/promises'; -import { createConnection } from 'node:net'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { buildProcessSessionFixtureSource } from './process-session-real-e2e-fixture.mjs'; const appRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const repoRoot = path.resolve(appRoot, '../..'); @@ -29,7 +29,6 @@ const commandRootErrorMarker = `real-e2e-root-${randomUUID().replaceAll('-', '') const commandRootErrorLine = 170; const commandDiagnosticLineCount = 240; const processFixtureScriptPath = 'fixtures/process-session-service.mjs'; -const processFixtureStatePath = '.agent/runtime/real-e2e-process-fixture.json'; const processReadyPrefix = 'GENARRATIVE_PROCESS_READY'; const processEchoPrefix = 'GENARRATIVE_PROCESS_ECHO'; const processStoppedMarker = 'GENARRATIVE_PROCESS_STOPPED'; @@ -133,11 +132,12 @@ const state = { challengeSeenInContext: false, readinessSeenInContext: false, echoSeenInContext: false, + stoppedSeenInContext: false, oldRunnerBootId: null, newRunnerBootId: null, processOwnerBootId: null, - fixturePid: null, - fixturePort: null, + projectCwdProcessSeen: false, + projectCwdProcessCleanupConfirmed: false, reportLeakCount: 0, }, evidence: emptyEvidence(), @@ -218,6 +218,7 @@ try { state.process.challenge, state.process.readyLine, state.process.echoLine, + processStoppedMarker, ].filter(Boolean), ); state.evidence.processReportLeakCount = state.process.reportLeakCount; @@ -649,70 +650,11 @@ async function seedProcessSessionDisposableProject() { } function processSessionFixtureSource() { - return [ - "import { randomBytes } from 'node:crypto';", - "import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';", - "import { createServer } from 'node:net';", - "import path from 'node:path';", - '', - `const statePath = ${JSON.stringify(processFixtureStatePath)};`, - `const readyPrefix = ${JSON.stringify(processReadyPrefix)};`, - `const echoPrefix = ${JSON.stringify(processEchoPrefix)};`, - `const stoppedMarker = ${JSON.stringify(processStoppedMarker)};`, - 'mkdirSync(path.dirname(statePath), { recursive: true });', - 'let previous = {};', - "try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch {}", - 'const launchCount = Number(previous.launchCount ?? 0) + 1;', - "const challenge = randomBytes(18).toString('hex');", - 'let fixturePort = null;', - 'let echoed = false;', - 'let stopping = false;', - '', - 'function writeState(status) {', - ' writeFileSync(', - ' statePath,', - " JSON.stringify({ schemaVersion: 'genarrative-process-fixture.v1', launchCount, pid: process.pid, port: fixturePort, status, updatedAt: Date.now() }) + '\\n',", - ' { mode: 0o600 },', - ' );', - '}', - '', - "writeState('booting');", - "if (launchCount !== 1) { console.error('fixture launched more than once'); process.exit(71); }", - "const server = createServer((socket) => socket.end('fixture-alive\\n'));", - 'function stop() {', - ' if (stopping) return;', - ' stopping = true;', - ' server.close(() => {', - " writeState('stopped');", - ' console.log(stoppedMarker);', - ' process.exit(0);', - ' });', - ' setTimeout(() => process.exit(72), 1_200).unref();', - '}', - "for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);", - "server.on('error', () => { writeState('failed'); process.exit(73); });", - "server.listen(0, '127.0.0.1', () => {", - ' fixturePort = server.address().port;', - " writeState('ready');", - " console.log(readyPrefix + ' challenge=' + challenge + ' port=' + fixturePort);", - '});', - '', - "process.stdin.setEncoding('utf8');", - "let buffered = '';", - "process.stdin.on('data', (chunk) => {", - ' buffered += chunk;', - ' const lines = buffered.split(/\\r?\\n/u);', - " buffered = lines.pop() ?? '';", - ' for (const line of lines) {', - ' if (!echoed && line === challenge) {', - ' echoed = true;', - " console.log(echoPrefix + ' ' + challenge);", - ' }', - ' }', - '});', - 'process.stdin.resume();', - '', - ].join('\n'); + return buildProcessSessionFixtureSource({ + readyPrefix: processReadyPrefix, + echoPrefix: processEchoPrefix, + stoppedMarker: processStoppedMarker, + }); } async function initializeDisposableGitRepository( @@ -801,7 +743,7 @@ function buildTaskPrompt(suite) { } function buildProcessSessionTaskPrompt() { - return `完成当前 disposable 项目的真实交互服务验收:先从项目清单确认唯一服务,整个验收最多启动一个进程;启动后只沿同一会话等待 readiness、按服务给出的一次性 challenge 完成一次交互并确认精确回显,随后干净停止服务,不得为探测、试错、重试或停止另起进程。只有服务形成可信终态后才能简短报告完成,不得修改项目文件,也不要在最终回复中复述 challenge、回显或其他私有进程输出。`; + return `完成当前 disposable 项目的真实交互服务验收:先从项目清单确认唯一服务,整个验收最多启动一个进程;启动后只沿同一会话等待 readiness、按服务给出的一次性 challenge 完成一次交互并确认精确回显,challenge 必须原样作为单独一行输入,并且只有观察到精确回显后才可终止服务,不得为探测、试错、重试或停止另起进程。只有服务形成可信终态后才能简短报告完成,不得修改项目文件,也不要在最终回复中复述 challenge、回显或其他私有进程输出。`; } function assertProcessSessionTaskPrompt(task) { @@ -1102,26 +1044,28 @@ async function driveProcessRuntimeToQuiescence() { async function driveProcessRunnerKillScenario() { const deadline = Date.now() + 120_000; let runningRecord = null; - let fixture = null; while (Date.now() < deadline) { await captureProcessSessionContextEvidence(); await confirmPendingActions(new Set(['command.start'])); const records = await readProcessSessionRecords(); + if (records.length > 1) { + throw codedError('process-runner-kill-record-count-invalid'); + } runningRecord = records.find((record) => record.status === 'running'); - fixture = await readProcessFixtureState().catch(() => null); - const readinessPersisted = runningRecord + const transcript = runningRecord ? await captureProcessTranscriptReadiness(runningRecord) - : false; - if ( - runningRecord && - readinessPersisted && - fixture?.status === 'ready' && - fixture.launchCount === 1 && - isValidFixtureEndpoint(fixture) && - isProcessAlive(fixture.pid) && - (await canConnectToPort(fixture.port)) - ) { - break; + : null; + if (runningRecord && transcript) { + const agentDb = await readJsonl( + path.join(state.projectRoot, '.agent/agent.db'), + ).catch(() => []); + const launchEvidence = processLaunchEvidence( + agentDb, + runningRecord, + transcript, + ); + assertProcessLaunchEvidenceIsNotDuplicated(launchEvidence); + if (isCompleteProcessLaunchEvidence(launchEvidence)) break; } const snapshot = await readTaskSnapshot(); const initial = snapshot.latest.find( @@ -1134,10 +1078,6 @@ async function driveProcessRunnerKillScenario() { await sleep(50); } assert(Boolean(runningRecord), 'process-runner-kill-running-record-missing'); - assert( - fixture?.status === 'ready' && isValidFixtureEndpoint(fixture), - 'process-runner-kill-fixture-not-ready', - ); const beforeKill = await readRunnerStatus(); const oldBootId = runnerBootId(beforeKill); @@ -1147,11 +1087,16 @@ async function driveProcessRunnerKillScenario() { ); state.process.oldRunnerBootId = oldBootId; state.process.processOwnerBootId = runningRecord.ownerBootId; - state.process.fixturePid = fixture.pid; - state.process.fixturePort = fixture.port; + const projectCwdProcessCount = await countProjectCwdProcesses(); + assert( + projectCwdProcessCount > 0, + 'process-runner-kill-project-cwd-process-missing', + ); + state.process.projectCwdProcessSeen = true; await killRunnerOnce(); - await waitForFixtureEndpointToDisappear(fixture.pid, fixture.port); + await waitForProjectCwdProcessesToDisappear(); + state.process.projectCwdProcessCleanupConfirmed = true; await runCli(['--agent-resume', state.projectRoot], { timeoutMs: 120_000 }); state.resumed = true; @@ -1358,18 +1303,13 @@ async function validateProcessSessionEvidence() { const transcript = await readProcessSessionTranscript(record); registerProcessPrivateOutput(transcript.output, true); validateProcessSessionRecord(record, transcript, true); - - const fixture = await readProcessFixtureState(); - assert( - fixture.schemaVersion === 'genarrative-process-fixture.v1' && - fixture.launchCount === 1 && - fixture.status === 'stopped' && - isValidFixtureEndpoint(fixture), - 'process-fixture-terminal-state-invalid', + const launchEvidence = validateUniqueProcessLaunchEvidence( + persistence.agentDb, + record, + transcript, ); - state.process.fixturePid = fixture.pid; - state.process.fixturePort = fixture.port; - await waitForFixtureEndpointToDisappear(fixture.pid, fixture.port); + await waitForProjectCwdProcessesToDisappear(); + state.process.projectCwdProcessCleanupConfirmed = true; const toolEvidence = validateProcessToolEvidence(persistence.agentDb, record); const finalization = validateCompletedProcessFinalization(persistence); @@ -1387,10 +1327,11 @@ async function validateProcessSessionEvidence() { ); assert( state.process.challengeSeenInContext && - state.process.readinessSeenInContext && - state.process.echoSeenInContext, - 'process-private-context-evidence-missing', + state.process.readinessSeenInContext, + 'process-context-readiness-missing', ); + assert(state.process.echoSeenInContext, 'process-context-echo-missing'); + assert(state.process.stoppedSeenInContext, 'process-context-stopped-missing'); state.lureLeakCount = await countLureLeaks(); assert(state.lureLeakCount === 0, 'sensitive-lure-leak-detected'); @@ -1409,15 +1350,16 @@ async function validateProcessSessionEvidence() { processStdinActionCount: toolEvidence.stdinActionCount, processTerminateActionCount: toolEvidence.terminateActionCount, processPollCursorAdvanceCount: toolEvidence.cursorAdvanceCount, - processLaunchCount: fixture.launchCount, + processLaunchCount: launchEvidence.launchCount, + processReadinessMarkerCount: launchEvidence.readinessMarkerCount, processTerminalCount: 1, processTranscriptChallengeCount: countOccurrences( transcript.output, state.process.challenge, ), processContextChallengeSeen: true, - processPidAliveAfterTerminal: false, - processPortReachableAfterTerminal: false, + processProjectCwdCleanupConfirmed: + state.process.projectCwdProcessCleanupConfirmed, completedProjectionCount: finalization.completedProjectionCount, finalAssistantAuditCount: finalization.finalAssistantAuditCount, finalAssistantCount: finalization.finalAssistantCount, @@ -1462,16 +1404,17 @@ async function validateProcessRunnerKillEvidence() { const transcript = await readProcessSessionTranscript(record); registerProcessPrivateOutput(transcript.output, false); validateProcessSessionRecord(record, transcript, false); - - const fixture = await readProcessFixtureState(); - assert( - fixture.launchCount === 1 && - fixture.pid === state.process.fixturePid && - fixture.port === state.process.fixturePort && - isValidFixtureEndpoint(fixture), - 'process-runner-kill-launch-count-invalid', + const launchEvidence = validateUniqueProcessLaunchEvidence( + persistence.agentDb, + record, + transcript, + ); + assert( + state.process.projectCwdProcessSeen && + state.process.projectCwdProcessCleanupConfirmed && + (await countProjectCwdProcesses()) === 0, + 'process-runner-kill-project-cwd-cleanup-invalid', ); - await waitForFixtureEndpointToDisappear(fixture.pid, fixture.port); const toolActions = processToolActionIds(persistence.agentDb); assert( toolActions.start.size === 1 && @@ -1522,12 +1465,13 @@ async function validateProcessRunnerKillEvidence() { processPollActionCount: toolActions.poll.size, processStdinActionCount: toolActions.stdin.size, processTerminateActionCount: toolActions.terminate.size, - processLaunchCount: fixture.launchCount, + processLaunchCount: launchEvidence.launchCount, + processReadinessMarkerCount: launchEvidence.readinessMarkerCount, processReconciliationCount: 1, processOldBootReconciled: true, - processPidReconnectCount: 0, - processPidAliveAfterRunnerKill: false, - processPortReachableAfterRunnerKill: false, + processReconnectCount: 0, + processProjectCwdCleanupConfirmed: + state.process.projectCwdProcessCleanupConfirmed, completedProjectionCount: noFinal.completedProjectionCount, finalAssistantAuditCount: noFinal.finalAssistantAuditCount, finalAssistantCount: noFinal.finalAssistantCount, @@ -1641,13 +1585,33 @@ function validateProcessToolEvidence(records, processRecord) { !Object.hasOwn(stdinAudits[0], 'content'), 'process-stdin-audit-invalid', ); - const contextOutputs = [...state.process.contextPolls.values()].map( - (poll) => poll.output, + const pollStages = pollAudits.map((audit) => ({ + audit, + output: + state.process.contextPolls.get( + `${audit.processId}\0${audit.cursor}\0${audit.nextCursor}`, + )?.output ?? '', + })); + const readinessPoll = pollStages.find(({ output }) => + processOutputLines(output).includes(state.process.readyLine), ); + const echoPoll = pollStages.find(({ output }) => + processOutputLines(output).includes(state.process.echoLine), + ); + const terminalPoll = pollStages.find( + ({ audit, output }) => + isTerminalProcessStatus(audit.status) && + processOutputLines(output).includes(processStoppedMarker), + ); + assert(Boolean(readinessPoll), 'process-poll-readiness-missing'); + assert(Boolean(echoPoll), 'process-poll-echo-missing'); + assert(Boolean(terminalPoll), 'process-poll-stopped-missing'); assert( - contextOutputs.some((output) => output.includes(state.process.readyLine)) && - contextOutputs.some((output) => output.includes(state.process.echoLine)), - 'process-poll-private-output-missing', + records.indexOf(readinessPoll.audit) < records.indexOf(stdinAudits[0]) && + records.indexOf(stdinAudits[0]) < records.indexOf(echoPoll.audit) && + records.indexOf(echoPoll.audit) < records.indexOf(terminateAudits[0]) && + records.indexOf(terminateAudits[0]) < records.indexOf(terminalPoll.audit), + 'process-interaction-audit-order-invalid', ); const processExecutions = [ @@ -1883,6 +1847,7 @@ function validateProcessPublicLeakBoundary(persistence) { state.process.challenge, state.process.readyLine, state.process.echoLine, + processStoppedMarker, ].filter(Boolean); const receipts = persistence.agentDb.filter( (record) => record.recordType === 'agent.runtime.action_receipt', @@ -1956,25 +1921,23 @@ async function captureProcessSessionContextEvidence() { ) { state.process.echoSeenInContext = true; } + if (processOutputLines(detail.output).includes(processStoppedMarker)) { + state.process.stoppedSeenInContext = true; + } } } function registerProcessPrivateOutput(output, requireEcho) { - const lines = String(output) - .split(/\r?\n/u) - .map((line) => line.trim()) - .filter(Boolean); + const lines = processOutputLines(output); const readyLine = lines.find((line) => line.startsWith(`${processReadyPrefix} challenge=`), ); if (readyLine) { const match = readyLine.match( - /^GENARRATIVE_PROCESS_READY challenge=([0-9a-f]{36}) port=([1-9][0-9]{0,4})$/u, + /^GENARRATIVE_PROCESS_READY challenge=([0-9a-f]{36})$/u, ); assert(Boolean(match), 'process-readiness-line-invalid'); const challenge = match[1]; - const port = Number(match[2]); - assert(port <= 65_535, 'process-readiness-port-invalid'); if (state.process.challenge) { assert( state.process.challenge === challenge && @@ -1987,21 +1950,30 @@ function registerProcessPrivateOutput(output, requireEcho) { state.process.echoLine = `${processEchoPrefix} ${challenge}`; } } - if (requireEcho === true) { - assert( - isNonEmptyString(state.process.challenge) && - lines.includes(state.process.readyLine) && - lines.includes(state.process.echoLine) && - lines.includes(processStoppedMarker), - 'process-transcript-interaction-evidence-missing', - ); - } else if (requireEcho === false) { + if (requireEcho !== null) { assert( isNonEmptyString(state.process.challenge) && lines.includes(state.process.readyLine), - 'process-transcript-readiness-evidence-missing', + 'process-transcript-readiness-missing', ); } + if (requireEcho === true) { + assert( + lines.includes(state.process.echoLine), + 'process-transcript-echo-missing', + ); + assert( + lines.includes(processStoppedMarker), + 'process-transcript-stopped-missing', + ); + } +} + +function processOutputLines(output) { + return String(output) + .split(/\n/u) + .map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line)) + .filter((line) => line.length > 0); } async function readProcessSessionRecords() { @@ -2030,16 +2002,16 @@ async function readProcessSessionTranscript(record) { } async function captureProcessTranscriptReadiness(record) { - if (!isNonEmptyString(record?.outputRef)) return false; + if (!isNonEmptyString(record?.outputRef)) return null; const transcript = await readProcessSessionTranscript(record).catch( () => null, ); - if (!transcript || typeof transcript.output !== 'string') return false; + if (!transcript || typeof transcript.output !== 'string') return null; registerProcessPrivateOutput(transcript.output, null); - return ( - isNonEmptyString(state.process.readyLine) && - transcript.output.includes(state.process.readyLine) - ); + return isNonEmptyString(state.process.readyLine) && + processOutputLines(transcript.output).includes(state.process.readyLine) + ? transcript + : null; } function validateProcessSessionRecord(record, transcript, terminalExpected) { @@ -2150,61 +2122,86 @@ function hasExpectedWorkspaceSandboxMetadata(record) { ); } -async function readProcessFixtureState() { - const fixture = await readJson( - path.join(state.projectRoot, processFixtureStatePath), - ); +function processLaunchEvidence(records, processRecord, transcript) { + const actionIds = processToolActionIds(records); + const startAudits = processDedicatedAudits(records, 'command.start'); + const startAudit = startAudits[0]; + const readinessMarkerCount = isNonEmptyString(state.process.readyLine) + ? processOutputLines(transcript.output).filter( + (line) => line === state.process.readyLine, + ).length + : 0; + return { + startActionCount: actionIds.start.size, + startAuditCount: startAudits.length, + readinessMarkerCount, + identityMatches: + startAudits.length === 1 && + startAudit.processId === processRecord.processId && + startAudit.actionId === processRecord.startActionId && + startAudit.actionFingerprint === processRecord.startActionFingerprint && + startAudit.status === 'running' && + hasExpectedWorkspaceSandboxMetadata(startAudit), + }; +} + +function assertProcessLaunchEvidenceIsNotDuplicated(evidence) { assert( - fixture?.schemaVersion === 'genarrative-process-fixture.v1', - 'process-fixture-state-invalid', + evidence.startActionCount <= 1 && + evidence.startAuditCount <= 1 && + evidence.readinessMarkerCount <= 1, + 'process-launch-evidence-duplicated', ); - return fixture; } -function isValidFixtureEndpoint(fixture) { +function isCompleteProcessLaunchEvidence(evidence) { return ( - Number.isSafeInteger(fixture?.pid) && - fixture.pid > 1 && - Number.isSafeInteger(fixture?.port) && - fixture.port > 0 && - fixture.port <= 65_535 + evidence.startActionCount === 1 && + evidence.startAuditCount === 1 && + evidence.readinessMarkerCount === 1 && + evidence.identityMatches ); } -function isProcessAlive(pid) { - if (!Number.isSafeInteger(pid) || pid <= 1) return false; - try { - process.kill(pid, 0); - return true; - } catch { - return false; +function validateUniqueProcessLaunchEvidence( + records, + processRecord, + transcript, +) { + const evidence = processLaunchEvidence(records, processRecord, transcript); + assertProcessLaunchEvidenceIsNotDuplicated(evidence); + assert( + isCompleteProcessLaunchEvidence(evidence), + 'process-launch-evidence-incomplete', + ); + return { + launchCount: 1, + readinessMarkerCount: evidence.readinessMarkerCount, + }; +} + +async function countProjectCwdProcesses() { + assert(process.platform === 'linux', 'process-cwd-evidence-unsupported'); + const projectRoot = await fs.realpath(state.projectRoot); + const entries = await fs.readdir('/proc', { withFileTypes: true }); + let count = 0; + for (const entry of entries) { + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + const cwd = await fs + .readlink(path.join('/proc', entry.name, 'cwd')) + .catch(() => null); + if (cwd && path.resolve(cwd) === projectRoot) count += 1; } + return count; } -async function canConnectToPort(port) { - if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) return false; - return new Promise((resolve) => { - const socket = createConnection({ host: '127.0.0.1', port }); - let settled = false; - const finish = (connected) => { - if (settled) return; - settled = true; - socket.destroy(); - resolve(connected); - }; - socket.setTimeout(500, () => finish(false)); - socket.once('connect', () => finish(true)); - socket.once('error', () => finish(false)); - }); -} - -async function waitForFixtureEndpointToDisappear(pid, port) { +async function waitForProjectCwdProcessesToDisappear() { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { - if (!isProcessAlive(pid) && !(await canConnectToPort(port))) return; + if ((await countProjectCwdProcesses()) === 0) return; await sleep(50); } - throw codedError('process-fixture-endpoint-still-alive'); + throw codedError('process-project-cwd-process-still-alive'); } async function waitForRunnerBootChange(oldBootId) { @@ -3776,7 +3773,9 @@ function emptyProcessEvidence() { processTerminalCount: 0, processReconciliationCount: 0, processPollCursorAdvanceCount: 0, - processPidReconnectCount: 0, + processReadinessMarkerCount: 0, + processReconnectCount: 0, + processProjectCwdCleanupConfirmed: false, completedProjectionCount: 0, finalAssistantAuditCount: 0, finalAssistantCount: 0, diff --git a/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs new file mode 100644 index 000000000..2caf00aea --- /dev/null +++ b/apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs @@ -0,0 +1,55 @@ +export function buildProcessSessionFixtureSource({ + readyPrefix, + echoPrefix, + stoppedMarker, +}) { + for (const [name, value] of Object.entries({ + readyPrefix, + echoPrefix, + stoppedMarker, + })) { + if ( + typeof value !== 'string' || + value.length === 0 || + /[\r\n]/u.test(value) + ) { + throw new Error(`invalid process fixture marker: ${name}`); + } + } + + return [ + "import { randomBytes } from 'node:crypto';", + '', + `const readyPrefix = ${JSON.stringify(readyPrefix)};`, + `const echoPrefix = ${JSON.stringify(echoPrefix)};`, + `const stoppedMarker = ${JSON.stringify(stoppedMarker)};`, + "const challenge = randomBytes(18).toString('hex');", + 'let echoed = false;', + 'let stopping = false;', + '', + 'function stop() {', + ' if (stopping) return;', + ' stopping = true;', + ' console.log(stoppedMarker);', + ' process.exit(0);', + '}', + "for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) process.on(signal, stop);", + "console.log(readyPrefix + ' challenge=' + challenge);", + '', + "process.stdin.setEncoding('utf8');", + "let buffered = '';", + "process.stdin.on('data', (chunk) => {", + ' buffered += chunk;', + ' const lines = buffered.split(/\\r?\\n/u);', + " buffered = lines.pop() ?? '';", + ' for (const line of lines) {', + ' if (!echoed && line === challenge) {', + ' echoed = true;', + " console.log(echoPrefix + ' ' + challenge);", + ' }', + ' }', + '});', + 'process.stdin.resume();', + '', + ].join('\n'); +} diff --git a/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts new file mode 100644 index 000000000..7ec261fbc --- /dev/null +++ b/apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts @@ -0,0 +1,117 @@ +import { spawn } from 'node:child_process'; +import { once } from 'node:events'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; +import { afterEach, describe, expect, it } from 'vitest'; +import { buildProcessSessionFixtureSource } from '../scripts/process-session-real-e2e-fixture.mjs'; + +const readyPrefix = 'GENARRATIVE_PROCESS_READY'; +const echoPrefix = 'GENARRATIVE_PROCESS_ECHO'; +const stoppedMarker = 'GENARRATIVE_PROCESS_STOPPED'; +const temporaryRoots = new Set(); + +afterEach(async () => { + await Promise.all( + [...temporaryRoots].map((root) => + fs.rm(root, { recursive: true, force: true }), + ), + ); + temporaryRoots.clear(); +}); + +describe('process-session real E2E fixture', () => { + it('runs as a pure PTY-style protocol without project control files or sockets', async () => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), 'genarrative-process-fixture-test-'), + ); + temporaryRoots.add(root); + const fixturePath = path.join(root, 'fixture.mjs'); + await fs.writeFile( + fixturePath, + buildProcessSessionFixtureSource({ + readyPrefix, + echoPrefix, + stoppedMarker, + }), + ); + + const child = spawn(process.execPath, [fixturePath], { + cwd: root, + stdio: ['pipe', 'pipe', 'pipe'], + }); + const exitPromise = once(child, 'exit'); + const output = readline.createInterface({ input: child.stdout }); + const lines: string[] = []; + const waiters = new Set<{ + predicate: (line: string) => boolean; + resolve: (line: string) => void; + }>(); + output.on('line', (line) => { + const normalized = line.endsWith('\r') ? line.slice(0, -1) : line; + lines.push(normalized); + for (const waiter of [...waiters]) { + if (waiter.predicate(normalized)) waiter.resolve(normalized); + } + }); + + const waitForLine = ( + predicate: (line: string) => boolean, + label: string, + ): Promise => { + const existing = lines.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + let timer: ReturnType; + const waiter = { + predicate, + resolve: (line) => { + clearTimeout(timer); + waiters.delete(waiter); + resolve(line); + }, + }; + timer = setTimeout(() => { + waiters.delete(waiter); + reject(new Error(`process fixture did not emit ${label}`)); + }, 3_000); + waiters.add(waiter); + }); + }; + + try { + const readyLine = await waitForLine( + (line) => line.startsWith(`${readyPrefix} challenge=`), + 'readiness', + ); + const match = readyLine.match( + /^GENARRATIVE_PROCESS_READY challenge=([0-9a-f]{36})$/u, + ); + expect(match).not.toBeNull(); + if (!match?.[1]) throw new Error('process fixture challenge is missing'); + expect( + await fs.stat(path.join(root, '.agent')).catch(() => null), + ).toBeNull(); + + const challenge = match[1]; + child.stdin.write(`${challenge}\n`); + await expect( + waitForLine((line) => line === `${echoPrefix} ${challenge}`, 'echo'), + ).resolves.toBe(`${echoPrefix} ${challenge}`); + + expect(child.kill('SIGTERM')).toBe(true); + await expect( + waitForLine((line) => line === stoppedMarker, 'stopped marker'), + ).resolves.toBe(stoppedMarker); + const [exitCode, signal] = await exitPromise; + expect({ exitCode, signal }).toEqual({ exitCode: 0, signal: null }); + expect(await fs.readdir(root)).toEqual(['fixture.mjs']); + } finally { + output.close(); + child.stdin.destroy(); + if (child.exitCode === null && child.signalCode === null) + child.kill('SIGKILL'); + } + }, 10_000); +}); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index f8baa2ed7..8786bfc8e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4291,3 +4291,5 @@ - 审计与发布:process record v2 保存 launch 当时的 backend / mode / network / profile,后续 process 工具从 durable/live 身份读取,preflight 失败使用 unavailable / not-established,不能按平台静态宣称已建立。共享 `os-workspace-sandbox` capability 只标记 Linux;deb / rpm 声明 bubblewrap 依赖,AppImage 依赖宿主预装并保持 fail-closed。 - 长进程策略:`command.start` 只用于仓库清单确认的持续交互服务,短命令、探测、构建和测试走 `command.exec`;同一服务成功启动后只沿原 processId 操作。真实验收出现第二条 process record 时立即失败,防止模型主动重复 start 被误判成 Runtime 重放或一直等待总超时。 - 已知残余:项目 mount preflight 与真实 bwrap launch 是两次独立进程启动。第二次 setup 失败不会让目标程序脱离沙箱执行,但当前缺少 exec-ready 握手,revision 可能已推进且审计无法证明目标是否进入 exec;后续必须在 launcher 层补可信握手,当前文档和验收不得宣称该阶段具备原子保证。 +- V1.11.1 决策:bwrap `child-pid` 只作为 child-created,不作为 sandbox-ready。Linux launcher 必须以受信任 trampoline 和独立私有控制通道完成 `SANDBOX_READY -> durable commit -> COMMIT_EXEC -> EXEC_ESTABLISHED`;commit 前失败显式 kill/reap 且目标零执行,commit 后无 exec-ready 进入 launch-unknown reconciliation。PTY 控制帧不得混入 transcript。 +- 验收修正:V1.10 process fixture 写 `.agent`、启动 TCP 并跨 namespace 使用 PID/端口,与 V1.11 安全边界冲突。V1.11 真实复验改为纯 PTY readiness/challenge/echo/stopped 协议,以唯一 durable start、cursor 链、stdin hash 和宿主项目 cwd 进程清零证明;Provider 502 的零工具计划失败单独记为外部瞬态错误。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index e86ebb9fa..1a44c4d19 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2893,3 +2893,11 @@ - 处理:外部工具链环境根 canonicalize 后必须通过窄叶目录校验;用户 HOME、HOME 符号链接目标和类型不匹配目录全部在 mount preflight 阶段失败关闭。不要为了兼容任意自定义环境根放宽成“只读就安全”。 - 验证:直接 HOME、`.rustup -> HOME` 均返回错误且目标 program 零执行;真实 `.rustup` 叶目录仍可只读挂载,Cargo fixture build 继续通过。 - 关联:`apps/ai-game-creator-shell/src-tauri/src/command_sandbox.rs`、`command_exec.rs`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 + +## 沙箱内验收 fixture 不能依赖 Runtime 控制面或宿主 namespace 身份 + +- 现象:V1.10 process-session 真实验收在 V1.11 后报“缺少精确回显”,但确定性 PTY 和 sandbox 测试均通过;旧 fixture 在 readiness 前写 `.agent`,还启动 loopback server 并把进程内 PID / 端口交给宿主检查。 +- 原因:V1.11 正确地用 0000 空 mount 隐藏 `.agent`,并隔离 pid / network namespace。沙箱内 PID、loopback 端口和 Runtime 控制目录不再是宿主可观察事实;fixture 在输出 readiness 前即可能失败,统一的 interaction-evidence 错误又掩盖了真实阶段。 +- 处理:交互 fixture 只使用 PTY stdin/stdout/signal,不写 `.agent`、不监听 TCP、不持久化 PID/端口。唯一启动由 process record、start action/fingerprint、start audit 和唯一 readiness marker共同证明;Runner 强杀后的清理由 owner boot、reconciliation record 和宿主 `/proc/*/cwd` 项目进程归零证明。 +- 验证:独立真实 Node smoke 必须完成 readiness、challenge 单行原样输入、精确 echo、SIGTERM stopped,并确认项目未创建 `.agent`;E2E 分别报告 readiness / stdin hash / echo / stopped 缺失,严格检查 readiness poll -> stdin -> echo poll -> terminate -> terminal poll。Provider 在零工具计划阶段的 502/TLS 只记外部失败,不得归因到 fixture 或 Runtime。 +- 关联:`apps/ai-game-creator-shell/scripts/process-session-real-e2e-fixture.mjs`、`agent-runtime-real-e2e.mjs`、`apps/ai-game-creator-shell/tests/processSessionRealE2eFixture.test.ts`、`docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md`。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 8073e67ee..cd55ca913 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -573,9 +573,19 @@ V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变 - deb / rpm 发布包声明 `bubblewrap` 宿主依赖;AppImage 不携带 bubblewrap sidecar,发布页和安装检查必须明确要求受支持版本的系统 `/usr/bin/bwrap` 或 `/bin/bwrap`。缺失时命令工具安全失败关闭,但该 AppImage 不算具备可用的通用开发能力。 - 真实 Provider disposable E2E 不给固定 program、文件名或工具顺序,要求模型自行发现项目技术栈,运行构建、测试和 Git 检查,并用结构化审计证明所有命令都在 workspace-write / network-disabled 下执行。上述门禁通过前不得宣称 V1.11 完成。 +### V1.11.1 可信 launch 握手 + +V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persisted -> exec-established -> running/exited` 做成 launcher 状态机,不能再把 bwrap 进程 spawn 或 `--json-status-fd` 的 `child-pid` 当作 sandbox-ready。实测 `child-pid` 会在 `--block-fd` 放行前出现,此时目标程序尚未执行;它只能证明 namespace child 已创建。关闭 block writer 也不能作为 abort,因为 bwrap 会把 EOF 当作可读并继续执行,失败关闭必须显式 kill + wait/reap。 + +- Linux 最终 `COMMAND` 必须先进入受信任 trampoline,而不是直接进入用户目标。trampoline 通过与 PTY/transcript 分离的私有控制通道发送带随机 nonce 的 `SANDBOX_READY`,等待 Runtime 完成 revision / verification gate / process record 的 durable commit 后接收 `COMMIT_EXEC`,再用 exec-error pipe 启动目标并回报 `EXEC_ESTABLISHED` 或 `TARGET_EXEC_FAILED`。commit 前的 EOF、错 nonce、协议错误和持久化失败都必须杀死并回收整个 bwrap 树,目标零执行。 +- `command.exec` 只在 `SANDBOX_READY` 后推进 revision 和清除旧验证凭证,`EXEC_ESTABLISHED` 后才启动业务 timeout;target exec 失败发生在 durable commit 后,revision 保守保留。`command.start` 只在 sandbox-ready 后写 process v3 commit record,exec-established 后才注册 running 和返回 processId;快速退出仍返回同一 processId 的 terminal poll。`project.verify` 必须走同一 launcher,只有 exec-established 且退出码为 0 才签发 passed gate。 +- process record v3 增加 `sandboxEstablishment / targetExec / launchFailureKind / sandboxReadyAt / execEstablishedAt`。v2 活跃记录迁移为 unknown + needs-reconciliation;commit 前可确认 kill/reap 的失败不重放,commit 后缺少 exec-ready 的窗口统一进入 `launch-unknown + needs-reconciliation`。 +- portable-pty 会关闭额外 FD,不能把控制协议混入 PTY 输出。Linux process child wrapper 需要唯一专用控制通道,只经该通道接收私有 launch plan 和交换 ready/commit/exec 帧;目标只继承 PTY stdin/stdout/stderr,控制 FD、nonce、child-pid、宿主路径和完整 bwrap argv不得进入目标 argv/env、transcript、command log、record、receipt 或 Agent DB。 +- 门禁必须覆盖乱序/重复/错 nonce/EOF、block 未放行目标 marker 为零、sandbox-ready 后持久化失败、目标不存在或无权限、目标立即 exit 0/7、PTY 快速退出和 Runner 强杀窗口,并扫描 `/proc/self/fd`、argv、env 与全部公共持久面确认控制材料泄漏为零。Windows 继续按 CreateProcess + Job 语义单独建模,不能复用或宣称 Linux 握手。 + 2026-07-14 最新真实 `gpt-5.5` `llm-runtime` 已按新增 metadata 门禁通过:123 条 task、208 条 event、220 条 Agent DB、15 次成功工具执行、2 次 `command.exec`(先失败后成功)、1 次 `project.verify`、3 个隔离实例、双视口浏览器验证、唯一 completed / assistant;Runner 强杀后 run / session 身份稳定恢复,重复、副作用重放、密钥和诱饵泄漏均为 0。保留现场独立核对 2 条 command.exec 和 1 条 project.verify 审计均为 `bubblewrap / workspace-write / disabled / workspace-v1` 后按 sentinel 清理。 -同日追加的 `process-session` Provider 复验未计为通过:前三轮模型以不同 actionId / fingerprint 主动重复 start,现场同时存在大量把有限探测误用为 command.start 的失败动作;收紧策略后不再重复 start,但仍因没有形成 challenge 精确回显而以 `process-transcript-interaction-evidence-missing` 失败。13 项确定性 process session 测试和真实 PTY `setsid + chdir` 沙箱负例仍通过,process record / start / poll / stdin / terminate metadata 均正确;在新的 Provider 严格单 launch 交互套件 PASS 前,不更新 V1.10 的历史 process Provider PASS 结论,也不把本次失败描述成已验收。 +同日追加的 `process-session` Provider 复验未计为通过:前三轮模型以不同 actionId / fingerprint 主动重复 start;收紧策略后的旧 E2E 又暴露 V1.10 fixture 与 V1.11 sandbox 契约冲突,fixture 在 readiness 前写 `.agent`、启动 namespace 内 loopback 并把 namespace PID/端口当宿主事实,而 V1.11 正确隐藏 `.agent` 且隔离 pid/network namespace,因此 `process-transcript-interaction-evidence-missing` 不能直接归因于模型抄错 challenge。修复方向是纯 PTY fixture:不写 `.agent`、不启动 TCP、不跨 namespace 读取 PID/端口,以唯一 process record/start/readiness、连续 cursor、stdin hash、精确 echo、stopped 和宿主项目 cwd 进程清零作为事实。最新一次重跑在零工具计划阶段连续收到 Provider 502,只记外部瞬态失败,不用于判断 Runtime。新的纯 PTY Provider 套件 PASS 前,不更新 V1.10 历史结论,也不把本次失败描述成已验收。 ## 验收命令