改用 HTTP 加载会话历史并更新 CentOS 7 发布包

This commit is contained in:
shiyue
2026-10-15 00:36:26 +08:00
parent 2b1c91488d
commit fb2c3360c9
22 changed files with 1466 additions and 175 deletions

View File

@@ -0,0 +1,219 @@
#!/usr/bin/env node
'use strict';
// 真实服务隔离回归,不读取或修改生产会话。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { spawn } = require('node:child_process');
const { WebSocket } = require('ws');
const { createSessionHistoryStore } = require('../lib/session-history-store');
const REPO = path.resolve(__dirname, '..');
const PASSWORD = 'SessionHttpFixture!234';
const STREAM = 'HTTP 夹具正在运行的完整输出';
const A = 'http-switch-a';
const B = 'http-switch-b';
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function waitFor(fn, label, timeout = 10000) {
const started = Date.now();
let lastError;
while (Date.now() - started < timeout) {
try { const result = await fn(); if (result) return result; } catch (error) { lastError = error; }
await sleep(40);
}
throw new Error(label + (lastError ? ':' + lastError.message : ''));
}
async function freePort() {
const listener = net.createServer();
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
const port = listener.address().port;
await new Promise(resolve => listener.close(resolve));
return port;
}
async function stopChild(child) {
if (!child || child.exitCode !== null || child.signalCode) return;
const exited = new Promise(resolve => child.once('exit', resolve));
child.kill('SIGTERM');
await Promise.race([exited, sleep(500)]);
if (child.exitCode === null && !child.signalCode) { child.kill('SIGKILL'); await exited; }
}
async function connect(port) {
const ws = new WebSocket('ws://127.0.0.1:' + port + '/ws');
const messages = [];
ws.on('message', data => messages.push(JSON.parse(String(data))));
await new Promise((resolve, reject) => { ws.once('open', resolve); ws.once('error', reject); });
ws.send(JSON.stringify({ type: 'auth', password: PASSWORD }));
const auth = await waitFor(() => messages.find(msg => msg.type === 'auth_result'), '隔离登录超时');
assert(auth.success);
return { ws, messages, token: auth.token };
}
function createFixture(root) {
const sessionsDir = path.join(root, 'sessions');
const homeDir = path.join(root, 'home');
fs.mkdirSync(sessionsDir, { recursive: true });
fs.mkdirSync(homeDir, { recursive: true });
const store = createSessionHistoryStore({ sessionsDir, chunkMaxMessages: 8 });
function seed(id, count = 57) {
const now = new Date().toISOString();
const session = { id, agent: 'codexapp', title: id, cwd: homeDir, created: now, updated: now,
permissionMode: 'yolo', hasUnread: false,
messages: Array.from({ length: count }, (_, index) => ({ id: id + '-' + index,
role: index % 2 ? 'assistant' : 'user', content: id + ' 保存消息 ' + index, timestamp: now })) };
store.save(session);
fs.writeFileSync(path.join(sessionsDir, id + '.json'), JSON.stringify(session));
return session;
}
seed(A); seed(B); seed('http-corrupt-chunk'); seed('http-corrupt-manifest');
const corruptPath = path.join(sessionsDir, '_history', 'http-corrupt-chunk');
const manifest = JSON.parse(fs.readFileSync(path.join(corruptPath, 'manifest.json'), 'utf8'));
fs.writeFileSync(path.join(corruptPath, 'chunks', manifest.entries.at(-1).file), '{破损块');
fs.writeFileSync(path.join(sessionsDir, '_history', 'http-corrupt-manifest', 'manifest.json'), '{破损清单');
const legacy = { ...seed('http-legacy'), id: 'http-legacy-only' };
fs.writeFileSync(path.join(sessionsDir, legacy.id + '.json'), JSON.stringify(legacy));
// 原生目录也隔离,不覆写 HOME 等系统变量。
const preload = path.join(root, 'isolated-home.cjs');
fs.writeFileSync(preload, [
"const fs = require('node:fs'); const Module = require('node:module');",
"require('node:os').homedir = () => " + JSON.stringify(homeDir) + ';',
// worker 也继承 preload;独立锁路径避免等待生产 app-server 的锁。
'const locks = require(' + JSON.stringify(path.join(REPO, 'lib/codex-home-lock')) + ');',
'const acquire = locks.acquireCodexHomeLock;',
'locks.acquireCodexHomeLock = (options = {}) => acquire({ ...options, lockPath: ' + JSON.stringify(path.join(root, 'codex-fixture.lock')) + ' });',
"const originalLoader = Module._extensions['.js'];",
"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 = " + JSON.stringify("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, ' + JSON.stringify('const CODEX_SESSIONS_DIR = ' + JSON.stringify(path.join(homeDir, '.codex', 'sessions')) + ';') + '), filename); };',
].join('\n'));
const completeFlag = path.join(root, 'complete-turn');
const mockPath = path.join(root, 'mock-codex.js');
const mock = fs.readFileSync(path.join(REPO, 'scripts/mock-codex-app-server.js'), 'utf8');
const timer = 'thread.timer = setTimeout(() => completeTurn(thread, turnId, text), delay);';
assert(mock.includes(timer));
fs.writeFileSync(mockPath, mock.replace(timer, [
"send({method:'item/agentMessage/delta',params:{threadId:thread.id,turnId,itemId:'agent-msg',delta:" + JSON.stringify(STREAM) + '}});',
"thread.timer = setInterval(() => { if (!require('fs').existsSync(" + JSON.stringify(completeFlag) + ')) return;',
'clearInterval(thread.timer); completeTurn(thread, turnId, text); }, 50);',
].join('\n')));
fs.chmodSync(mockPath, 0o700);
return { sessionsDir, homeDir, store, preload, completeFlag, mockPath };
}
async function runApi(port, fixture) {
let connection = await connect(port);
try {
const token = connection.token;
const closed = new Promise(resolve => connection.ws.once('close', resolve));
connection.ws.close();
await closed;
let sequence = 0;
async function query(id, suffix = '', authenticated = true) {
const requestId = 'api-' + ++sequence;
const separator = suffix.includes('?') ? '&' : '?';
const response = await fetch('http://127.0.0.1:' + port + '/api/sessions/' + id + suffix + separator + 'requestId=' + requestId, {
headers: authenticated ? { Authorization: 'Bearer ' + token } : {},
});
const payload = await response.json();
assert.equal(payload.requestId, requestId);
return { response, payload };
}
const first = await query(B);
assert.equal(first.response.status, 200);
assert.equal(first.payload.messages.length, 12);
assert.equal(first.payload.historyCursor, 45);
assert(first.response.headers.get('server-timing'));
const ids = first.payload.messages.map(message => message.id);
let cursor = first.payload.historyCursor;
while (cursor > 0) {
const page = await query(B, '/history?before=' + cursor);
assert.equal(page.response.status, 200);
assert(page.payload.messages.length <= 24);
ids.unshift(...page.payload.messages.map(message => message.id));
cursor = page.payload.historyCursor;
}
assert.deepEqual(ids, Array.from({ length: 57 }, (_, index) => B + '-' + index));
assert.equal((await query(B, '', false)).response.status, 401);
assert.equal((await query('does-not-exist')).response.status, 404);
assert.equal((await query('bad%2Fid')).response.status, 400);
assert.equal((await query('http_switch-a')).response.status, 400, '非法 ID 不能经 sanitizeId 别名到另一会话');
assert.equal((await query('%ZZ')).response.status, 400);
assert.equal((await query(B, '/history?before=-1')).response.status, 400);
assert.equal((await query(B, '/history?before=1.5')).response.status, 400);
for (const id of ['http-corrupt-chunk', 'http-corrupt-manifest']) {
const result = await query(id);
assert.equal(result.response.status, 500);
assert.equal(result.payload.code, 'history_read_failed');
assert.equal(result.payload.sessionId, id);
}
assert.equal((await query('http-legacy-only')).payload.messages.length, 12);
connection = await connect(port);
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: B,
requestId: 'subscribe-before-commit', historyRevision: first.payload.historyRevision }));
const unchanged = await waitFor(() => connection.messages.find(msg => msg.requestId === 'subscribe-before-commit'), '订阅结果缺失');
assert.equal(unchanged.type, 'session_subscribed');
assert.equal(unchanged.historyChanged, false);
assert.equal(unchanged.messages, undefined, '实时订阅不能重发历史');
const changed = fixture.store.loadMetadata(B);
changed.messages.push({ id: 'http-new-between-query-and-subscribe', role: 'assistant', content: '订阅间隙的新消息' });
fixture.store.save(changed);
connection.ws.send(JSON.stringify({ type: 'subscribe_session', sessionId: B,
requestId: 'subscribe-after-commit', historyRevision: first.payload.historyRevision }));
const advanced = await waitFor(() => connection.messages.find(msg => msg.requestId === 'subscribe-after-commit'), '新修订订阅结果缺失');
assert.equal(advanced.historyChanged, true);
assert.equal(advanced.messages, undefined);
assert.equal((await query(B)).payload.messages.at(-1).id, 'http-new-between-query-and-subscribe');
connection.ws.send(JSON.stringify({ type: 'message', sessionId: A, agent: 'codexapp', mode: 'yolo', text: 'HTTP 会话切换运行夹具' }));
const running = await waitFor(async () => {
const result = await query(A);
return result.payload.isRunning && result.payload.runtime?.text.includes(STREAM) ? result.payload : null;
}, 'HTTP 运行态快照缺失');
assert.equal(running.historyPending, false);
assert.equal(running.messages.length, 12);
assert.equal(running.runtime.type, 'resume_generating');
assert(!connection.messages.some(msg => msg.type === 'session_history_chunk'));
return { connection, token, query };
} catch (error) {
connection.ws.terminate();
throw error;
}
}
async function main() {
await require('./session-http-load-frontend-unit').runFrontendUnit();
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-session-http-'));
const fixture = createFixture(root);
const port = await freePort();
const server = spawn(process.execPath, ['-r', fixture.preload, path.join(REPO, 'server.js')], {
cwd: REPO, env: { ...process.env, PORT: String(port), CC_WEB_PASSWORD: PASSWORD,
CC_WEB_INTERNAL_MCP_TOKEN: 'HttpFixtureMcp!234', CC_WEB_TASK_BOARD_ENABLED: '0',
CC_WEB_CONFIG_DIR: path.join(root, 'config'), CC_WEB_LOGS_DIR: path.join(root, 'logs'),
CC_WEB_SESSIONS_DIR: fixture.sessionsDir, CODEX_PATH: fixture.mockPath,
CLAUDE_PATH: path.join(REPO, 'scripts/mock-claude.js'),
}, stdio: ['ignore', 'pipe', 'pipe'],
});
const log = fs.createWriteStream(path.join(root, 'server.log'));
server.stdout.pipe(log); server.stderr.pipe(log);
let api;
try {
await waitFor(async () => (await fetch('http://127.0.0.1:' + port + '/')).ok, '隔离服务启动失败');
api = await runApi(port, fixture);
console.log('HTTP 接口通过:WS 关闭后首屏12条、分页24条、顺序、鉴权、请求级错误、独立订阅、修订及运行快照');
if (process.argv.includes('--browser')) {
await require('./session-http-load-browser').runBrowser({ port, fixture, root, query: api.query,
A, B, PASSWORD, STREAM, waitFor, freePort, stopChild, sleep });
}
const processLog = fs.readFileSync(path.join(root, 'logs', 'process.log'), 'utf8');
assert(processLog.includes('session_http_query'));
assert(processLog.includes('session_http_query_failed'));
assert(!processLog.includes(STREAM), '诊断日志不得记录消息正文');
console.log('HTTP 耗时诊断通过;隔离证据:' + root);
} finally {
if (api) api.connection.ws.terminate();
await stopChild(server);
log.end();
}
}
main().catch(error => { console.error(error.stack || error); process.exitCode = 1; });