#!/usr/bin/env node 'use strict'; // 使用隔离会话、原生 rollout 和 mock 运行时,在真实 Firefox 中验证历史恢复。 const assert = require('node:assert/strict'); const fs = require('node:fs'); const http = require('node:http'); const net = require('node:net'); const os = require('node:os'); const path = require('node:path'); const { spawn } = require('node:child_process'); const { WebSocket, WebSocketServer } = require('ws'); const { createCodexRolloutStore } = require('../lib/codex-rollouts'); const REPO = path.resolve(__dirname, '..'); const FIREFOX = process.env.CC_WEB_BROWSER_FIREFOX || '/snap/firefox/current/usr/lib/firefox/firefox'; const GECKODRIVER = process.env.CC_WEB_BROWSER_GECKODRIVER || '/snap/firefox/current/usr/lib/firefox/geckodriver'; const QUESTION = '没参考其他的页面么,WMSA51 是你这么写的么?'; const ARCHITECTURE = '先按项目规范核对现有页面、任务票和负责会话,再把样式返工交回原实现负责人。'; const BOUNDARY = '这不是单纯的样式问题,而是 T-003 的页面职责和流程边界做错了。'; const STREAM = '当前正在生成的消息:正在核对页面职责与流程边界。'; const SESSION_ID = '11111111-2222-4333-8444-555555555555'; const THREAD_ID = '11111111-2222-4333-8444-666666666666'; const PASSWORD = 'HistoryBrowserFixture!234'; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); async function freePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(0, '127.0.0.1', () => { const port = server.address().port; server.close(() => resolve(port)); }); }); } async function waitFor(predicate, description, timeout = 12000) { const start = Date.now(); let lastError; while (Date.now() - start < timeout) { try { const result = await predicate(); if (result) return result; } catch (error) { lastError = error; } await sleep(80); } throw new Error(`${description}${lastError ? `:${lastError.message}` : ''}`); } async function stopChild(child) { if (!child || child.exitCode !== null || child.signalCode) return; child.kill('SIGTERM'); await Promise.race([new Promise((resolve) => child.once('exit', resolve)), sleep(1000)]); if (child.exitCode === null && !child.signalCode) child.kill('SIGKILL'); } class FirefoxDriver { constructor(port) { this.port = port; this.sessionId = ''; } async request(method, endpoint, body) { const response = await fetch(`http://127.0.0.1:${this.port}${endpoint}`, { method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const payload = await response.json(); if (!response.ok || payload.value?.error) throw new Error(JSON.stringify(payload.value || payload)); return payload.value; } async start() { const value = await this.request('POST', '/session', { capabilities: { alwaysMatch: { browserName: 'firefox', 'moz:firefoxOptions': { binary: FIREFOX, args: ['-headless'] }, } } }); this.sessionId = value.sessionId; await this.request('POST', `/session/${this.sessionId}/window/rect`, { width: 1280, height: 1050 }); return value.capabilities; } execute(script, args = []) { return this.request('POST', `/session/${this.sessionId}/execute/sync`, { script, args }); } navigate(url) { return this.request('POST', `/session/${this.sessionId}/url`, { url }); } refresh() { return this.request('POST', `/session/${this.sessionId}/refresh`, {}); } async screenshot(file) { const data = await this.request('GET', `/session/${this.sessionId}/screenshot`); fs.writeFileSync(file, Buffer.from(data, 'base64')); } async close() { if (this.sessionId) await this.request('DELETE', `/session/${this.sessionId}`); } } function createFixture(root) { const home = path.join(root, 'fixture-home'); const sessions = path.join(root, 'sessions'); const nativeDir = path.join(home, '.codex', 'sessions', '2026', '09', '21'); for (const folder of [sessions, nativeDir, path.join(root, 'config'), path.join(root, 'logs')]) fs.mkdirSync(folder, { recursive: true }); const rows = [{ type: 'session_meta', payload: { id: THREAD_ID, cwd: root } }]; function turn(index, user, textParts) { const stamp = new Date(Date.UTC(2026, 8, 21, 0, 0, index)).toISOString(); const turnId = `browser-turn-${index}`; rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_started', turn_id: turnId } }); rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'user_message', message: user, id: `browser-user-${index}` } }); textParts.forEach((text) => rows.push({ type: 'response_item', timestamp: stamp, payload: { type: 'message', role: 'assistant', internal_chat_message_metadata_passthrough: { turn_id: turnId }, content: [{ type: 'output_text', text }], } })); rows.push({ type: 'event_msg', timestamp: stamp, payload: { type: 'task_complete', turn_id: turnId } }); } for (let index = 0; index < 72; index++) turn(index, `早期问题 ${index}`, [`早期回答 ${index}`]); const store = createCodexRolloutStore({ codexSessionsDir: nativeDir, sessionsDir: sessions, normalizeSession: (v) => v, sanitizeToolInput: (_name, v) => v }); const prefix = store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages; const snapshot = prefix.slice(-64); snapshot.push( { id: 'snapshot-latest-user', clientMessageId: 'snapshot-latest-user', role: 'user', timestamp: '2026-09-21T01:00:00.000Z', content: QUESTION }, { id: 'snapshot-architecture', role: 'assistant', timestamp: '2026-09-21T01:00:01.000Z', content: ARCHITECTURE }, { id: 'snapshot-boundary', role: 'assistant', timestamp: '2026-09-21T01:00:02.000Z', content: BOUNDARY }, { id: 'snapshot-child-reply', role: 'assistant', timestamp: '2026-09-21T01:00:03.000Z', content: '已按现有 WMSA51 页面完成职责核对。', crossConversation: { reply: true, replyToRequestId: 'fixture-child-request', sourceTitle: '页面实现负责人', sourceSessionId: '77777777-2222-4333-8444-555555555555', } }, ); turn(73, QUESTION, Array.from({ length: 48 }, (_, index) => `native 不应覆盖当前快照的助手片段 ${index}`)); for (let index = 74; index < 242; index++) turn(index, `native 内部输入 ${index}`, [`native 内部回答 ${index}`]); rows.push({ type: 'event_msg', timestamp: '2026-09-21T02:00:00.000Z', payload: { type: 'user_message', message: 'native 内部输入尾部', turn_id: 'browser-native-tail', } }); const rollout = path.join(nativeDir, `rollout-2026-09-21T00-00-00-${THREAD_ID}.jsonl`); fs.writeFileSync(rollout, rows.map((row) => JSON.stringify(row)).join('\n') + '\n'); const notice = { role: 'system', content: '历史消息过多,cc-web 本地快照只保留最近 68 条……', ccwebPersistenceNotice: true, snapshotMessageCount: 68, snapshotFirstMessageId: snapshot[0].id, snapshotLastMessageId: snapshot.at(-1).id, nativeThreadId: THREAD_ID, truncatedAt: '2026-09-21T01:00:04.000Z', }; fs.writeFileSync(path.join(sessions, `${SESSION_ID}.json`), JSON.stringify({ id: SESSION_ID, title: '历史恢复浏览器夹具', agent: 'codexapp', codexAppThreadId: THREAD_ID, cwd: root, created: '2026-09-21T00:00:00.000Z', updated: '2026-09-21T01:00:04.000Z', permissionMode: 'yolo', historySnapshotBaseIndex: 80, historySnapshotCount: 68, messages: [notice, ...snapshot], }, null, 2)); // 只在隔离子进程中替换 homedir;不覆盖真实 HOME / CODEX_HOME,也不访问生产 rollout。 const preload = path.join(root, 'isolated-home.cjs'); fs.writeFileSync(preload, ` const fs = require('node:fs'); const Module = require('node:module'); const originalLoader = Module._extensions['.js']; require('node:os').homedir = () => ${JSON.stringify(home)}; Module._extensions['.js'] = (module, filename) => { if (filename !== ${JSON.stringify(path.join(REPO, 'server.js'))}) return originalLoader(module, filename); const source = fs.readFileSync(filename, 'utf8'); const original = "const CODEX_SESSIONS_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', '.codex', 'sessions');"; if (!source.includes(original)) throw new Error('原生历史目录注入点已变更,请更新浏览器夹具'); module._compile(source.replace(original, 'const CODEX_SESSIONS_DIR = ' + ${JSON.stringify(JSON.stringify(path.join(home, '.codex', 'sessions')))} + ';'), filename); }; `); const mock = path.join(root, 'mock-codex.js'); const completeFlag = path.join(root, 'complete-turn'); const existingMock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8'); const timerCode = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);'; assert(existingMock.includes(timerCode), 'mock 运行时启动入口已变更'); const browserMock = existingMock.replace(timerCode, ` send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'browser-stream',delta:${JSON.stringify(STREAM)}}}); thread.timer = setInterval(() => { if (!require('fs').existsSync(${JSON.stringify(completeFlag)})) return; clearInterval(thread.timer); send({method:'turn/completed',params:{threadId:thread.id,turn:{id:turnId,status:'completed',items:[]}}}); thread.activeTurnId = null; thread.timer = null; }, 80); `).replace("path.join(__dirname, 'mock-codex.js')", JSON.stringify(path.join(REPO, 'scripts/mock-codex.js'))); fs.writeFileSync(mock, browserMock, { mode: 0o755 }); return { home, sessions, rollout, preload, mock, completeFlag, snapshotCount: snapshot.length, nativeCount: store.parseCodexRolloutLines(rows.map((row) => JSON.stringify(row))).messages.length }; } async function startRelay(port, targetPort, evidence) { const relay = http.createServer((request, response) => { const upstream = http.request({ hostname: '127.0.0.1', port: targetPort, path: request.url, method: request.method, headers: request.headers }, (result) => { response.writeHead(result.statusCode, result.headers); result.pipe(response); }); upstream.on('error', () => response.destroy()); request.pipe(upstream); }); const sockets = new Set(); const wsServer = new WebSocketServer({ noServer: true }); let connections = 0; relay.on('upgrade', (request, socket, head) => wsServer.handleUpgrade(request, socket, head, (client) => { connections++; sockets.add(client); const upstream = new WebSocket(`ws://127.0.0.1:${targetPort}/ws`); const pending = []; upstream.on('open', () => { pending.splice(0).forEach((data) => upstream.send(data)); }); client.on('message', (data) => { if (upstream.readyState === WebSocket.OPEN) upstream.send(data); else pending.push(data); }); upstream.on('message', (data) => { try { const msg = JSON.parse(String(data)); if (['session_info', 'resume_session_result', 'session_history_chunk'].includes(msg.type)) evidence.protocol.push({ type: msg.type, historySource: msg.historySource, historyTotal: msg.historyTotal, isRunning: msg.isRunning, ids: msg.messages?.map((message) => message.id), }); } catch {} if (client.readyState === WebSocket.OPEN) client.send(data, { binary: false }); }); client.on('close', () => { sockets.delete(client); upstream.terminate(); }); upstream.on('close', () => { if (client.readyState === WebSocket.OPEN) client.close(); }); upstream.on('error', () => client.terminate()); client.on('error', () => upstream.terminate()); })); await new Promise((resolve) => relay.listen(port, '127.0.0.1', resolve)); return { connectionCount: () => connections, disconnect: () => { for (const socket of sockets) socket.terminate(); }, close: async () => { for (const socket of sockets) socket.terminate(); wsServer.close(); relay.closeAllConnections(); await new Promise((resolve) => relay.close(resolve)); }, }; } async function capture(driver, stage, root, requireStream = true) { await waitFor(() => driver.execute(`return [...document.querySelectorAll('#messages .msg')].some(node => node.textContent.includes(arguments[0]))`, [requireStream ? STREAM : QUESTION]), `${stage} 消息未恢复`); const result = await driver.execute(`return { messages: [...document.querySelectorAll('#messages > .msg')].map(node => ({ role: node.classList.contains('user') ? 'user' : node.classList.contains('assistant') ? 'assistant' : 'system', id: node.dataset.messageId || '', key: node.dataset.messageKey || '', text: node.innerText, source: node.querySelector('.cross-conversation-label')?.innerText || '', streaming: node.id === 'streaming-msg', })), running: document.querySelector('#chat-runtime-state')?.textContent || '', };`); const find = (text) => result.messages.filter((message) => message.text.includes(text)); for (const text of [QUESTION, ARCHITECTURE, BOUNDARY]) assert.equal(find(text).length, 1, `${stage}:消息重复或消失:${text}`); const userIndex = result.messages.findIndex((message) => message.text.includes(QUESTION)); assert.equal(result.messages[userIndex].role, 'user'); assert(result.messages[userIndex + 1].text.includes(ARCHITECTURE), `${stage}:用户与对应回复失去邻接关系`); assert(result.messages[userIndex + 2].text.includes(BOUNDARY)); assert.equal(result.messages[userIndex + 3].role, 'assistant'); assert.equal(result.messages[userIndex + 3].source, '来自「页面实现负责人」的回复'); assert(!result.messages.some((message) => message.text.includes('native 不应覆盖') || message.text.includes('native 内部输入'))); if (requireStream) assert.equal(find(STREAM).length, 1, `${stage}:当前输出重复或丢失`); const keys = result.messages.map((message) => message.key).filter(Boolean); assert.equal(new Set(keys).size, keys.length, `${stage}:稳定 ID 重复渲染`); await driver.execute(`const box=document.querySelector('#messages');box.scrollTop=box.scrollHeight;`); // 等待浏览器提交滚动与消息淡入,截图必须展示真实可见内容。 await sleep(500); result.visibleTail = await driver.execute(`return [...document.querySelectorAll('#messages > .msg')].slice(-6).map(node => { const box=node.getBoundingClientRect(), style=getComputedStyle(node); return {text:node.innerText, top:box.top, bottom:box.bottom, height:box.height, opacity:style.opacity, visibility:style.visibility, display:style.display}; });`); const visibleQuestion = result.visibleTail.find((message) => message.text.includes(QUESTION)); assert(visibleQuestion && visibleQuestion.top >= 0 && visibleQuestion.height > 0 && Number(visibleQuestion.opacity) > 0.9, `${stage}:目标问题没有实际显示在视口`); await driver.screenshot(path.join(root, `${stage}.png`)); return result; } async function main() { assert(fs.existsSync(FIREFOX) && fs.existsSync(GECKODRIVER), '需要本机 Firefox 和 geckodriver,可通过 CC_WEB_BROWSER_* 指定'); const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-history-browser-')); const fixture = createFixture(root); const evidence = { root, fixture, protocol: [], stages: {}, kind: '真实 server.js / Firefox / 隔离持久化与 native rollout / mock Codex App' }; const [serverPort, relayPort, driverPort] = await Promise.all([freePort(), freePort(), freePort()]); let server, gecko, relay, driver; try { server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], { cwd: REPO, env: { ...process.env, PORT: String(serverPort), CC_WEB_PASSWORD: PASSWORD, CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_SESSIONS_DIR: fixture.sessions, CC_WEB_LOGS_DIR: path.join(root, 'logs'), CODEX_PATH: fixture.mock, CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'), CC_WEB_TASK_BOARD_ENABLED: '0', }, stdio: ['ignore', 'pipe', 'pipe'], }); const serverLog = fs.createWriteStream(path.join(root, 'server.log')); server.stdout.pipe(serverLog); server.stderr.pipe(serverLog); await waitFor(async () => (await fetch(`http://127.0.0.1:${serverPort}/`)).ok, '隔离服务启动失败'); relay = await startRelay(relayPort, serverPort, evidence); gecko = spawn(GECKODRIVER, ['--port', String(driverPort)], { stdio: ['ignore', 'pipe', 'pipe'] }); const geckoLog = fs.createWriteStream(path.join(root, 'geckodriver.log')); gecko.stdout.pipe(geckoLog); gecko.stderr.pipe(geckoLog); await waitFor(async () => (await fetch(`http://127.0.0.1:${driverPort}/status`)).ok, 'geckodriver 启动失败'); driver = new FirefoxDriver(driverPort); evidence.browser = await driver.start(); await driver.navigate(`http://127.0.0.1:${relayPort}/`); await waitFor(async () => { const ready = await driver.execute(`return !document.querySelector('#app').hidden && !!document.querySelector('.session-item')`); if (ready) return true; await driver.execute(`document.querySelector('#login-password').value=arguments[0];document.querySelector('#login-form').requestSubmit();`, [PASSWORD]); return false; }, '登录或会话列表未就绪'); await driver.execute(`document.querySelector('.session-item').click();`); evidence.stages.loaded = await capture(driver, 'loaded', root, false); await driver.execute(`const input=document.querySelector('#msg-input');input.value='继续核对页面职责与流程边界';input.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#send-btn').click();`); evidence.stages.running = await capture(driver, 'running', root); await driver.refresh(); evidence.stages.refreshed = await capture(driver, 'refreshed', root); const previousCount = relay.connectionCount(); relay.disconnect(); await waitFor(() => relay.connectionCount() > previousCount, '真实 WebSocket 断线后没有重连'); await waitFor(() => evidence.protocol.some((message) => message.type === 'resume_session_result'), '没有收到恢复会话结果'); evidence.stages.reconnected = await capture(driver, 'reconnected', root); fs.writeFileSync(fixture.completeFlag, 'complete\n'); await waitFor(() => driver.execute(`return !document.querySelector('#streaming-msg')`), '完成事件后流式气泡未收束'); evidence.stages.completed = await capture(driver, 'completed', root); await driver.refresh(); evidence.stages.completedRefreshed = await capture(driver, 'completed-refreshed', root); assert(evidence.protocol.some((message) => message.historySource === 'merged'), '没有验证到服务端真实 native 前缀合并'); evidence.ok = true; console.log(JSON.stringify({ ok: true, browser: evidence.browser.browserVersion, root, stages: Object.keys(evidence.stages), nativeCount: fixture.nativeCount, snapshotCount: fixture.snapshotCount }, null, 2)); } catch (error) { evidence.ok = false; evidence.error = error.stack; if (driver?.sessionId) await driver.screenshot(path.join(root, 'failure.png')).catch(() => {}); throw new Error(`${error.message}\n证据:${root}`, { cause: error }); } finally { fs.writeFileSync(path.join(root, 'evidence.json'), JSON.stringify(evidence, null, 2)); if (driver) await driver.close().catch(() => {}); if (relay) await relay.close(); await stopChild(gecko); await stopChild(server); } } main().catch((error) => { console.error(error); process.exitCode = 1; });