Files
node_server/tests/forward_integration.test.mjs
T
2508932142 6c69afb38a feat: add world voxel snapshot forwarding
Collect chunk voxel data from the bot world and stream snapshot,
chunk, and unwatch messages through the forward protocol.
2026-10-23 10:44:37 +08:00

734 lines
48 KiB
JavaScript

import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {EventEmitter} from 'node:events';
import {readFile, mkdtemp, mkdir, writeFile, rm, unlink} from 'node:fs/promises';
import {tmpdir} from 'node:os';
import path from 'node:path';
import {createRequire} from 'node:module';
import {setTimeout as delay} from 'node:timers/promises';
import {fileURLToPath} from 'node:url';
import test from 'node:test';
import {build} from 'esbuild';
import {io} from 'socket.io-client';
import {tsImport} from 'tsx/esm/api';
const root = fileURLToPath(new URL('../', import.meta.url));
const vectors = JSON.parse(await readFile(new URL('./fixtures/forward_vectors.json', import.meta.url), 'utf8'));
const compiled = await build({
stdin: {contents: `export * from './bot/forward/manager.ts';
export * from './bot/forward/stream.ts'; export * from './bot/forward/files.ts';
export * from './utils/forward_protocol.ts';`,
resolveDir: root, loader: 'ts'},
bundle: true, platform: 'node', format: 'cjs', packages: 'external', write: false, metafile: true,
plugins: [{name: 'isolated-default-bot-registry', setup(build) {
build.onResolve({filter: /^\.\.\/instance\/index\.ts$/}, () => ({path: 'registry', namespace: 'test'}));
build.onLoad({filter: /.*/, namespace: 'test'}, () => ({loader: 'js', contents:
'export const get_bot_context = () => undefined; export const observe_bot_context = () => () => {};'}));
}}],
});
const module = {exports: {}};
new Function('require', 'module', 'exports', compiled.outputFiles[0].text)(createRequire(import.meta.url), module, module.exports);
const {NodeForwardManager, BotStream, BotFileService, validate_command, validate_join, validate_rpc, success_data, error_ack} = module.exports;
const success = data => ({status: 'success', data});
const scope = () => ({forward_id: 1, forward_session_id: randomUUID(), node_session_id: 'main-socket-session', node_id: 2, bot_id: 3});
const command = (s = vectors.scope, overrides = {}) => ({...s, url: 'http://127.0.0.1:1', path: '/relay/socket.io',
namespace: '/forward', token: vectors.token, expires_at: Date.now() + 10_000, deadline_at: Date.now() + 2000, ...overrides});
const request = (method = 'bot.snapshot', payload = {}, request_id = randomUUID()) => ({request_id, method, payload});
const node_request = (s, overrides = {}) => ({...request(), correlation_id: randomUUID(),
forward_session_id: s.forward_session_id, deadline_at: Date.now() + 1000, ...overrides});
function code(ack, expected) {
assert.equal(ack.status, 'error');
assert.deepEqual(Object.keys(ack.data).sort(), ['code', 'message', 'retryable']);
assert.equal(ack.data.code, expected);
assert.equal(typeof ack.data.message, 'string');
assert.equal(typeof ack.data.retryable, 'boolean');
}
const rejects = (action, expected = 'INVALID_REQUEST') => assert.throws(action, error => error.code === expected);
function received(socket, event, predicate = () => true) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => { socket.off(event, listener); reject(new Error(`Missing ${event}`)); }, 2500);
const listener = value => {
if (!predicate(value)) return;
clearTimeout(timer); socket.off(event, listener); resolve(value);
};
socket.on(event, listener);
});
}
const ack = (socket, name, payload) => socket.timeout(2500).emitWithAck(name, payload);
class FakeSocket extends EventEmitter {
connected = false;
packets = [];
io = {engine: {writeBuffer: []}};
volatile = {emit: (event, value) => this.packets.push([event, value])};
constructor(cmd, responder) { super(); this.cmd = cmd; this.responder = responder; }
connect() { queueMicrotask(() => {this.connected = true; super.emit('connect');}); }
disconnect() { if (this.connected) {this.connected = false; super.emit('disconnect', 'test');} }
timeout() { return {emit: (event, payload, callback) => {
this.packets.push([event, payload]);
const data = event === 'forward_node_ready' ? {node_ready: true} : {
...Object.fromEntries(Object.keys(vectors.scope).map(key => [key, this.cmd[key]])),
role: 'node', node_ready: event === 'reauth', expires_at: this.cmd.expires_at,
};
if (this.responder) this.responder(event, success(data), callback);
else queueMicrotask(() => callback(null, success(data)));
}}; }
rpc(raw) { return new Promise(resolve => super.emit('relay_request', raw, resolve)); }
}
function fake_manager(t, cmd, responder, stream_factory) {
const sockets = [];
const manager = new NodeForwardManager(cmd.node_id, cmd.node_session_id, {
socket_factory: (_url, options) => {
const socket = new FakeSocket(cmd, responder); socket.options = options; sockets.push(socket); return socket;
}, stream_factory,
});
t.after(() => manager.stop());
return {manager, sockets};
}
test('independent Node bundle has no relay or token issuer imports; fixed token stays opaque and byte-identical', async t => {
assert.ok(Object.keys(compiled.metafile.inputs).every(path => !path.includes('forward_server')));
assert.doesNotMatch(compiled.outputFiles[0].text, /createCipheriv|createDecipheriv|encode_token|decode_token/);
const cmd = command();
const f = fake_manager(t, cmd);
assert.equal(validate_command(cmd).token, vectors.token);
const ready = await f.manager.ensure(cmd);
assert.deepEqual(ready, success({...vectors.scope, node_ready: true, lease_expires_at: cmd.expires_at}));
assert.equal(f.sockets[0].options.auth.token, vectors.token);
assert.equal(f.sockets[0].options.reconnection, false);
assert.deepEqual(f.sockets[0].packets, [
['join_room', {token: vectors.token}], ['forward_node_ready', {forward_session_id: cmd.forward_session_id}],
]);
// The copied token has Web claims and fixed historical times. Node never interprets them.
const opaque = command({...vectors.scope, forward_session_id: randomUUID()}, {token: 'opaque-not-a-token'});
const second = fake_manager(t, opaque);
assert.equal((await second.manager.ensure(opaque)).status, 'success');
assert.equal(second.sockets[0].packets[0][1].token, opaque.token);
});
test('local command validator enforces exact wire DTO, scope IDs, URL and numeric bounds', () => {
const valid = command();
for (const key of Object.keys(valid)) {
const missing = {...valid}; delete missing[key]; rejects(() => validate_command(missing));
}
for (const patch of [{extra: 'SECRET'}, {node_id: 0}, {bot_id: '2'}, {forward_id: -1},
{forward_session_id: 'session'}, {node_session_id: ' '}, {node_session_id: '\u754c'.repeat(86)},
{token: ''}, {token: 'x'.repeat(8193)}, {deadline_at: Infinity}, {expires_at: 1.5},
{url: 'file:///tmp/relay'}, {url: 'http://user:password@localhost'}, {url: 'http://localhost/path'},
{url: 'http://localhost?token=SECRET'}, {url: 'http://localhost#SECRET'}, {path: 'socket.io'}, {namespace: '/bad?x'}]) {
rejects(() => validate_command({...valid, ...patch}));
}
assert.equal(validate_command({...valid, forward_id: 0, node_session_id: 'x'.repeat(256)}).forward_id, 0);
assert.equal(validate_command({...valid, forward_session_id: '11111111-1111-1111-1111-111111111111'}).forward_session_id,
'11111111-1111-1111-1111-111111111111');
});
test('local Node RPC validator allows only exact public method payloads and relay correlation metadata', () => {
const valid = node_request(vectors.scope, {request_id: vectors.request_id});
for (const [method, payload] of [['bot.snapshot', {}], ['bot.logs', {limit: 200, stream_id: randomUUID(), after_seq: 0}],
['get_log_history', {}], ['get_context_info', {}], ['get_data_file_list', {}],
['get_file_data', {id: randomUUID()}], ['get_file_data', {id: randomUUID(), offset: 0, length: 262144}],
['tab_complete', {text: '/he'}], ['tab_complete', {text: ''}],
['bot.chat', {message: 'hello'}], ['execute_function', {function: 'inventory', name: 'left_click_slot', args: [9]}]]) assert.equal(validate_rpc({...valid, method, payload}).method, method);
for (const payload of [{}, {function: '../inventory', name: 'left_click_slot', args: []},
{function: 'inventory', name: '', args: []}, {function: 'inventory', name: 'x', args: {}},
{function: 'inventory', name: 'x', args: [], extra: true}])
rejects(() => validate_rpc({...valid, method: 'execute_function', payload}));
for (const payload of [{}, {text: 1}, {text: 'x'.repeat(257)}, {text: '/he\n'}, {text: '/he', extra: true}])
rejects(() => validate_rpc({...valid, method: 'tab_complete', payload}));
rejects(() => validate_rpc({...valid, method: 'get_log_history', payload: {limit: 21}}));
rejects(() => validate_rpc({...valid, method: 'get_context_info', payload: {name: 'health'}}));
rejects(() => validate_rpc({...valid, method: 'get_data_file_list', payload: {path: '../'}}));
for (const payload of [{id: '../secret'}, {id: randomUUID(), path: '/tmp'},
{id: randomUUID(), offset: -1}, {id: randomUUID(), offset: 0.5},
{id: randomUUID(), length: 0}, {id: randomUUID(), length: 262145}])
rejects(() => validate_rpc({...valid, method: 'get_file_data', payload}));
for (const key of Object.keys(valid)) {
const missing = {...valid}; delete missing[key]; rejects(() => validate_rpc(missing));
}
for (const patch of [{token: 'SECRET'}, {request_id: 'bad'}, {correlation_id: 'bad'}, {deadline_at: NaN},
{method: 'bot.delete'}, {method: 'bot.subscribe', payload: {events: ['log']}}, {payload: {config: 'SECRET'}},
{method: 'bot.logs', payload: {limit: 0}}, {method: 'bot.logs', payload: {limit: 201}},
{method: 'bot.logs', payload: {limit: 1, after_seq: 0}},
{method: 'bot.chat', payload: {message: 'hello', password: 'SECRET'}},
...['', ' ', 'x'.repeat(257), 'hello\nworld', 'hello\rworld', 'hello\0world'].map(message => ({method: 'bot.chat', payload: {message}}))]) {
rejects(() => validate_rpc({...valid, ...patch}));
}
});
test('local join/ACK validation binds every scope field, lease, role and readiness without exposing errors', () => {
const cmd = command();
const data = {...vectors.scope, role: 'node', node_ready: false, expires_at: cmd.expires_at};
assert.deepEqual(validate_join(success(data), cmd, true), data);
for (const patch of [{forward_id: 9}, {forward_session_id: randomUUID()}, {node_session_id: 'old'}, {node_id: 9},
{bot_id: 9}, {role: 'web'}, {node_ready: true}, {expires_at: cmd.expires_at + 1}, {secret: 'SECRET'}]) {
rejects(() => validate_join(success({...data, ...patch}), cmd, true), 'SESSION_STALE');
}
for (const value of [null, {}, {status: 'success'}, {status: 'success', data, extra: true},
{status: 'error', data: {code: 'SECRET', message: 'SECRET', retryable: false}}]) {
rejects(() => success_data(value), 'RELAY_UNAVAILABLE');
}
rejects(() => success_data(error_ack('TIMEOUT')), 'TIMEOUT');
});
test('manager rejects stale scopes and expired commands before creating a transport', async t => {
const cmd = command(); const f = fake_manager(t, cmd);
for (const [patch, expected] of [[{node_session_id: 'old'}, 'SESSION_STALE'], [{node_id: 99}, 'FORBIDDEN'],
[{deadline_at: Date.now() - 1}, 'TIMEOUT'], [{expires_at: Date.now() - 1}, 'TOKEN_EXPIRED']]) {
code(await f.manager.ensure({...cmd, ...patch}), expected);
}
assert.equal(f.sockets.length, 0);
f.manager.stop(); code(await f.manager.ensure(cmd), 'SESSION_STALE');
});
test('manager deadline rejects late join ACK and prevents readiness or later RPC side effects', async t => {
const cmd = command(vectors.scope, {deadline_at: Date.now() + 40});
let late;
const f = fake_manager(t, cmd, (_event, response, callback) => {late = () => callback(null, response);});
code(await f.manager.ensure(cmd), 'TIMEOUT');
late(); await delay(10);
assert.equal(f.sockets[0].connected, false);
assert.equal(f.sockets[0].packets.some(([name]) => name === 'forward_node_ready'), false);
assert.equal(f.sockets[0].listenerCount('relay_request'), 0);
});
test('manager independently enforces RPC deadlines, session binding, correlation replay and safe error ACKs', async t => {
const cmd = command(); let calls = 0;
const stream = {subscribe: () => () => {}, resync() {}, dispose() {}, snapshot: () => ({seq: ++calls}),
chat() {calls++; throw new Error('SECRET password/stack');}};
const f = fake_manager(t, cmd, undefined, () => stream);
assert.equal((await f.manager.ensure(cmd)).status, 'success');
const socket = f.sockets[0]; const rpc = node_request(cmd);
code(await socket.rpc({...rpc, deadline_at: Date.now() - 1}), 'TIMEOUT');
code(await socket.rpc({...rpc, forward_session_id: randomUUID()}), 'SESSION_STALE');
code(await socket.rpc({...rpc, method: 'bot.chat', payload: {message: 'hello', token: 'SECRET'}}), 'INVALID_REQUEST');
assert.equal(calls, 0);
assert.deepEqual(await socket.rpc(rpc), success({seq: 1}));
code(await socket.rpc(rpc), 'CANCELLED');
const failed = await socket.rpc(node_request(cmd, {method: 'bot.chat', payload: {message: 'hello'}}));
code(failed, 'INTERNAL_ERROR'); assert.equal(JSON.stringify(failed).includes('SECRET'), false);
assert.equal(calls, 2);
});
// The integration layer alone loads relay code. Issuing credentials is test-only.
test('disk history RPC discards responses after deadline or transport replacement', async t => {
const cmd = command();
let release;
const stream = {subscribe: () => () => {}, resync() {}, dispose() {},
get_log_history: () => new Promise(resolve => {release = resolve;})};
const f = fake_manager(t, cmd, undefined, () => stream);
assert.equal((await f.manager.ensure(cmd)).status, 'success');
const socket = f.sockets[0];
const pending = socket.rpc(node_request(cmd, {method: 'get_log_history', payload: {}, deadline_at: Date.now() + 15}));
await delay(25);
release({records: []});
code(await pending, 'TIMEOUT');
const stale = socket.rpc(node_request(cmd, {method: 'get_log_history', payload: {}}));
socket.disconnect();
release({records: []});
code(await stale, 'SESSION_STALE');
});
async function real_fixture(t, limits = {}) {
const [{start_forward_server}, {encode_token}] = await Promise.all([
tsImport('../forward_server/index.ts', import.meta.url),
tsImport('../forward_server/utils/token_utils.ts', import.meta.url),
]);
const key = 'node-forward-integration-test-secret-only';
const control = new EventEmitter(); const statuses = [];
control.on('forward_session_status', value => statuses.push(value));
const handle = await start_forward_server({forward_id: 1, key, host: '127.0.0.1', port: 0,
path: '/node-test/socket_io', namespace: '/node-test', allowed_origins: [], allow_no_origin: true, control,
limits: {rpc_timeout_ms: 250, unused_timeout_ms: 10_000, idle_timeout_ms: 10_000, clock_skew_ms: 0, ...limits}});
const url = `http://127.0.0.1:${handle.http_server.address().port}`;
const clients = []; const node_sockets = []; const managers = []; const sources = new Map(); const streams = new Map();
const packets = [];
handle.io.of(handle.namespace).on('connection', socket => socket.onAny((event, value) => packets.push({id: socket.id, event, value})));
t.after(async () => {for (const manager of managers) manager.stop(); for (const socket of clients) socket.disconnect(); await handle.stop();});
const control_ack = (event, value) => new Promise(resolve => control.emit(event, value, resolve));
const permissions = ['bot.snapshot', 'bot.logs', 'bot.subscribe', 'bot.unsubscribe', 'bot.chat', 'execute_function', 'bot.files'];
function token(s, role, expires_at, grant_id, granted = permissions) {
const now = Date.now();
return encode_token(key, {...s, v: 1, role, permissions: granted, issued_at: now,
join_before: Math.min(now + 1000, expires_at), expires_at, jti: randomUUID(),
...(role === 'web' ? {grant_id, user_id: 4} : {})});
}
function source(bot_id) {
if (!sources.has(bot_id)) {
let current; const listeners = new Set();
sources.set(bot_id, {get: () => current, observe(_id, listener) {listeners.add(listener); listener(current); return () => listeners.delete(listener);},
set(value) {current = value; for (const listener of listeners) listener(value);}, get count() {return listeners.size;}});
}
return sources.get(bot_id);
}
function manager(s, file_factory) {
const result = new NodeForwardManager(s.node_id, s.node_session_id, {
socket_factory: (...args) => {const socket = io(...args); node_sockets.push(socket); return socket;},
stream_factory: bot_id => {const stream = new BotStream(bot_id, {source: source(bot_id), capacity: 30}); streams.set(bot_id, stream); return stream;},
file_factory,
}); managers.push(result); return result;
}
async function open(s, expires_at = Date.now() + 20_000) {
assert.equal((await control_ack('forward_open_session', {...s, lease_expires_at: expires_at})).status, 'success');
return expires_at;
}
const cmd = (s, expires_at = Date.now() + 10_000) => command(s, {url, path: handle.path, namespace: handle.namespace,
expires_at, token: token(s, 'node', expires_at), deadline_at: Date.now() + 2000});
async function web(s, granted = permissions, grant_id = randomUUID(), expires_at = Date.now() + 10_000) {
assert.equal((await control_ack('forward_authorize_grant', {forward_session_id: s.forward_session_id,
grant_id, user_id: 4, permissions: granted, expires_at})).status, 'success');
const socket = io(`${url}${handle.namespace}`, {path: handle.path, autoConnect: false, reconnection: false, forceNew: true, transports: ['websocket']});
clients.push(socket); const connected = received(socket, 'connect'); socket.connect(); await connected;
const joined = await ack(socket, 'join_room', {token: token(s, 'web', expires_at, grant_id, granted)});
assert.equal(joined.status, 'success');
return {socket, grant_id, joined};
}
return {handle, control_ack, statuses, permissions, token, source, streams, manager, open, cmd, web, packets, node_sockets};
}
function online_context() {
const sent = [];
return Object.assign(new EventEmitter(), {bot_status: 'online', bot_instance: {chat: message => sent.push(message)},
sent, config: {password: 'SECRET'}, logger: {get_recent_log() {throw new Error('Disk access forbidden');}}});
}
test('file tree and binary downloads use scoped IDs without requiring an online Bot', async t => {
const directory = await mkdtemp(path.join(tmpdir(), 'forward-files-'));
t.after(() => rm(directory, {recursive: true, force: true}));
await mkdir(path.join(directory, 'log'));
await mkdir(path.join(directory, 'empty'));
const bytes = Buffer.alloc(600_123);
for (let index = 0; index < bytes.length; index++) bytes[index] = index % 256;
await writeFile(path.join(directory, 'binary.dat'), bytes);
await writeFile(path.join(directory, 'log', 'sample.log'), 'a log\n');
const f = await real_fixture(t, {rpc_timeout_ms: 1500});
const s = scope(); await f.open(s);
const manager = f.manager(s, bot_id => new BotFileService(bot_id, {root: directory}));
assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s, ['bot.files']);
const denied = await f.web(s, ['bot.snapshot']);
const listing = success_data(await ack(web.socket, 'relay_request', request('get_data_file_list')));
assert.equal(listing.partial, false);
assert.deepEqual(listing.children.map(entry => entry.name), ['binary.dat', 'empty', 'log']);
assert.equal(listing.children[1].type, 'directory');
assert.deepEqual(listing.children[1].children, []);
assert.equal(JSON.stringify(listing).includes(directory), false);
const file = listing.children[0];
assert.equal(file.size, bytes.length);
assert.match(file.id, /^[0-9a-f-]{36}$/);
const chunks = [];
let offset = 0;
while (true) {
const part = success_data(await ack(web.socket, 'relay_request', request('get_file_data', {id: file.id, offset})));
assert.equal(part.offset, offset);
assert.equal(part.total_bytes, bytes.length);
assert.equal(part.encoding, 'base64');
const chunk = Buffer.from(part.data, 'base64');
assert.equal(chunk.length, part.next_offset - offset);
assert.ok(chunk.length <= 262144);
chunks.push(chunk); offset = part.next_offset;
if (part.eof) break;
}
assert.deepEqual(Buffer.concat(chunks), bytes);
code(await ack(denied.socket, 'relay_request', request('get_data_file_list')), 'FORBIDDEN');
code(await ack(denied.socket, 'relay_request', request('get_file_data', {id: file.id})), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('get_file_data', {id: file.id, length: 262145})), 'INVALID_REQUEST');
code(await ack(web.socket, 'relay_request', request('get_file_data', {id: '../binary.dat'})), 'INVALID_REQUEST');
const another_scope = {...s, bot_id: s.bot_id + 1, forward_session_id: randomUUID()};
await f.open(another_scope);
assert.equal((await manager.ensure(f.cmd(another_scope))).status, 'success');
const another = await f.web(another_scope, ['bot.files']);
code(await ack(another.socket, 'relay_request', request('get_file_data', {id: file.id})), 'INVALID_REQUEST');
// Reusing a request ID must re-read disk instead of replaying a cached file body.
const repeated = request('get_file_data', {id: file.id, length: 10});
assert.equal(success_data(await ack(web.socket, 'relay_request', repeated)).data, bytes.subarray(0, 10).toString('base64'));
await unlink(path.join(directory, 'binary.dat'));
const removed = await ack(web.socket, 'relay_request', repeated);
assert.equal(removed.status, 'error');
assert.ok(['RESYNC_REQUIRED', 'INVALID_REQUEST'].includes(removed.data.code));
const expired = received(web.socket, 'relay_status', value => !value.node_ready);
manager.stop(); await expired;
code(await ack(web.socket, 'relay_request', request('get_data_file_list')), 'NODE_OFFLINE');
});
test('context info RPC and updates travel through actual relay with online and permission checks', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const bot = online_context();
let records = [{name: 'health', i18n: {lang: 'en', name: 'Health', description: ''}, value: 20}];
bot.info_manager = {get_all_info: () => records};
f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s, ['bot.snapshot', 'bot.logs']);
const denied = await f.web(s, ['bot.logs']);
const snapshot_only = await f.web(s, ['bot.snapshot']);
const no_info = []; denied.socket.on('info_update', value => no_info.push(value));
const rpc = request('get_context_info');
assert.deepEqual(success_data(await ack(web.socket, 'relay_request', rpc)), records);
assert.deepEqual(success_data(await ack(snapshot_only.socket, 'relay_request', request('get_context_info'))), records);
code(await ack(denied.socket, 'relay_request', request('get_context_info')), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('get_context_info', {name: 'health'})), 'INVALID_REQUEST');
const before = received(web.socket, 'bot_log');
bot.emit('log', [{message: 'before info', font_color: '#ffffff', font_style: ['normal']}]);
const first_log = await before;
const pushed = received(web.socket, 'info_update');
const snapshot_pushed = received(snapshot_only.socket, 'info_update');
records[0].value = 18;
bot.emit('info_update', {name: 'health', value: 18});
const update = await pushed;
assert.deepEqual((await snapshot_pushed).data, {name: 'health', value: 18});
assert.deepEqual(Object.keys(update).sort(), ['bot_id', 'context_generation', 'data', 'emitted_at', 'forward_session_id', 'seq', 'stream_id']);
assert.deepEqual(update.data, {name: 'health', value: 18});
assert.equal(update.bot_id, s.bot_id);
assert.equal(update.forward_session_id, s.forward_session_id);
assert.equal(update.stream_id, first_log.stream_id);
assert.ok(update.seq > first_log.seq);
const after = received(web.socket, 'bot_log');
bot.emit('log', [{message: 'after info', font_color: '#ffffff', font_style: ['normal']}]);
assert.ok((await after).seq > update.seq);
// A completed request ID must re-read the Node rather than replay a stale success.
bot.bot_status = 'offline';
code(await ack(web.socket, 'relay_request', rpc), 'BOT_NOT_ONLINE');
const updates_before = f.packets.filter(packet => packet.event === 'info_update').length;
bot.emit('info_update', {name: 'health', value: 0});
await ack(web.socket, 'relay_request', request());
assert.equal(f.packets.filter(packet => packet.event === 'info_update').length, updates_before);
assert.equal(no_info.length, 0);
const stale = bot.listeners('info_update')[0];
f.source(s.bot_id).set(undefined);
code(await ack(web.socket, 'relay_request', rpc), 'NO_CONTEXT');
stale({name: 'health', value: 1});
const replacement = online_context();
replacement.info_manager = {get_all_info: () => [{...records[0], value: 15}]};
f.source(s.bot_id).set(replacement);
assert.equal(success_data(await ack(web.socket, 'relay_request', rpc))[0].value, 15);
assert.equal(bot.listenerCount('info_update'), 0);
records = [{...records[0], value: NaN}];
replacement.info_manager = {get_all_info: () => records};
code(await ack(web.socket, 'relay_request', request('get_context_info')), 'INTERNAL_ERROR');
manager.stop();
assert.equal(replacement.listenerCount('info_update'), 0);
});
test('complex inventory from the real module survives Node query and push through the relay', async t => {
const {init: InventoryInfo} = await tsImport('../bot/info/bot/inventory.ts', import.meta.url);
const mf = createRequire(createRequire(import.meta.url).resolve('@wp2508/mineflayer'));
const registry = mf('prismarine-registry')('1.21.4');
const windows = mf('prismarine-windows')(registry);
const instance = new EventEmitter();
instance._client = new EventEmitter();
instance.inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory');
instance.currentWindow = windows.createWindow(1, 'minecraft:generic_9x6', 'Chest');
instance.quickBarSlot = 0;
let nbt = {type: 'string', value: 'deep content'};
for (let i = 0; i < 15; i++) nbt = {type: 'compound', value: {nested: nbt}};
instance.currentWindow.slots = Array.from({length: 90}, () => ({
type: 1, name: 'stone', displayName: 'Stone', count: 64, metadata: 0, nbt,
components: [
{type: 'custom_data', data: {description: 'x'.repeat(4096)}},
{type: 'custom_name', data: {type: 'compound', value: {text: {type: 'string', value: '菜单'}, color: {type: 'string', value: 'gold'}}}},
{type: 'lore', data: [{type: 'string', value: '说明'}]},
], enchants: [],
}));
const inventory = new InventoryInfo(instance);
t.after(() => inventory.dispose());
const f = await real_fixture(t); const s = scope(); await f.open(s);
const bot = online_context();
bot.info_manager = {get_all_info: () => [{name: inventory.name, i18n: inventory.i18n, value: inventory.get_info()}]};
const unsubscribe = inventory.on_update(value => bot.emit('info_update', {name: 'inventory', value}));
t.after(unsubscribe);
f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s, ['bot.snapshot']);
const records = success_data(await ack(web.socket, 'relay_request', request('get_context_info')));
assert.ok(Buffer.byteLength(JSON.stringify(records)) > 196_608);
assert.deepEqual(records[0], bot.info_manager.get_all_info()[0]);
const item = records[0].value.window.slots[0];
assert.equal(item.displayName[0].message, '菜单');
assert.equal(item.displayName[0].font_color, '#FFAA00');
assert.equal(item.lore[0][0].message, '说明');
assert.equal('components' in item, false);
assert.equal('nbt' in item, false);
const pushed = received(web.socket, 'info_update');
instance.currentWindow.slots[0].count = 32;
instance.currentWindow.emit('updateSlot');
const update = await pushed;
assert.equal(update.data.name, 'inventory');
assert.equal(update.data.value.window.slots[0].count, 32);
assert.equal(update.data.value.window.slots.length, 90);
assert.deepEqual(update.data.value, inventory.get_info());
});
test('execute_function traverses the relay with boolean results, permission checks and no replay', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s);
await manager.ensure(f.cmd(s));
const web = await f.web(s);
const reader = await f.web(s, ['bot.snapshot']);
const bot = online_context();
const calls = [];
bot.function_manager = {async run(path, name, args, before_run) {
before_run();
calls.push({path, name, args});
if (name === 'missing') throw new Error('Unknown function');
return name === 'left_click_slot';
}};
f.source(s.bot_id).set(bot);
const payload = {function: 'inventory', name: 'left_click_slot', args: [9]};
const rpc = request('execute_function', payload);
assert.equal(success_data(await ack(web.socket, 'relay_request', rpc)), true);
code(await ack(web.socket, 'relay_request', rpc), 'CANCELLED');
assert.equal(calls.length, 1);
assert.deepEqual(calls[0], {path: 'inventory', name: 'left_click_slot', args: [9]});
code(await ack(reader.socket, 'relay_request', request('execute_function', payload)), 'FORBIDDEN');
assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', {...payload, name: 'missing'}))), false);
assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', {...payload, name: 'right_click_slot'}))), false);
bot.bot_status = 'offline';
assert.equal(success_data(await ack(web.socket, 'relay_request', request('execute_function', payload))), false);
});
test('actual manager pushes lifecycle changes to snapshot readers without exposing other bot events', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); const cmd = f.cmd(s);
assert.deepEqual(await manager.ensure(cmd), success({...s, node_ready: true, lease_expires_at: cmd.expires_at}));
const node_id = f.node_sockets[0].id;
assert.deepEqual(f.packets.filter(p => p.id === node_id && ['join_room', 'forward_node_ready'].includes(p.event)).map(p => p.event),
['join_room', 'forward_node_ready']);
assert.ok(f.statuses.some(value => value.state === 'ready' && value.forward_session_id === s.forward_session_id));
const web = await f.web(s); const other = await f.web(s, ['bot.snapshot']);
const absent = success_data(await ack(web.socket, 'relay_request', request()));
assert.deepEqual(Object.keys(absent).sort(), ['bot_id', 'bot_status', 'captured_at', 'context_exists', 'context_generation', 'seq', 'stream_id']);
assert.equal(absent.context_exists, false); assert.equal(absent.context_generation, null); assert.equal(absent.bot_status, 'offline');
code(await ack(web.socket, 'relay_request', request('bot.chat', {message: 'hello'})), 'NO_CONTEXT');
let leaks = 0; other.socket.on('relay_request', () => leaks++);
const reader_events = []; other.socket.on('bot_event', event => reader_events.push(event));
assert.equal((await ack(web.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log', 'message', 'bot_error']}))).status, 'success');
const events = []; web.socket.on('bot_event', value => events.push(value));
const bot = online_context();
const initial_status = received(other.socket, 'bot_event', event => event.payload.status === 'online');
f.source(s.bot_id).set(bot);
await initial_status;
const snapshot = success_data(await ack(web.socket, 'relay_request', request()));
assert.equal(snapshot.context_exists, true); assert.equal(snapshot.bot_status, 'online');
assert.equal(snapshot.stream_id, absent.stream_id); assert.ok(snapshot.context_generation);
for (const state of ['offline', 'ready', 'connecting', 'online', 'disconnecting', 'reconnecting']) {
const pushed = received(web.socket, 'bot_event', event => event.payload.status === state);
const reader_pushed = received(other.socket, 'bot_event', event => event.payload.status === state);
bot.bot_status = state; bot.emit('status_update', state);
await Promise.all([pushed, reader_pushed]);
const current = success_data(await ack(web.socket, 'relay_request', request()));
assert.equal(current.context_exists, true);
assert.equal(current.bot_status, state); assert.equal(current.context_generation, snapshot.context_generation);
}
const back_online = received(web.socket, 'bot_event', event => event.payload.status === 'online');
bot.bot_status = 'online'; bot.emit('status_update', 'online');
await back_online;
const log_packet = received(web.socket, 'bot_log');
const metadata = {hoverEvent: {action: 'show_text', value: '悬停说明'},
clickEvent: {action: 'suggest_command', value: '/help'}, insertion: 'Alex'};
bot.emit('log', [{message: 'safe log', font_color: '#abcdef', font_style: ['normal'], ...metadata, password: 'SECRET'}]);
const live_log = await log_packet;
assert.equal(live_log.forward_session_id, s.forward_session_id);
assert.equal(live_log.payload.records[0].segments[0].message, 'safe log');
for (const [key, value] of Object.entries(metadata)) assert.deepEqual(live_log.payload.records[0].segments[0][key], value);
assert.equal(JSON.stringify(live_log).includes('SECRET'), false);
const logs = success_data(await ack(web.socket, 'relay_request', request('bot.logs', {limit: 3})));
assert.equal(logs.records[0].segments[0].message, 'safe log');
assert.deepEqual(logs.records[0].segments[0], live_log.payload.records[0].segments[0]);
for (let i = 0; i < 25; i++) bot.emit('log', [{message: `history ${i}`, font_color: '#ffffff', font_style: ['normal']}]);
bot.logger.read_recent_log = async count => Array.from({length: 25}, (_, i) => ({
id: randomUUID(), timestamp: Date.now(), log: [{message: `history ${i}`, font_color: '#ffffff', font_style: ['normal']}],
})).slice(-count);
const history = success_data(await ack(web.socket, 'relay_request', request('get_log_history', {})));
assert.equal(history.records.length, 20);
assert.deepEqual(history.records.map(record => record.segments[0].message),
Array.from({length: 20}, (_, i) => `history ${i + 5}`));
code(await ack(other.socket, 'relay_request', request('get_log_history', {})), 'FORBIDDEN');
code(await ack(web.socket, 'relay_request', request('get_log_history', {limit: 21})), 'INVALID_REQUEST');
assert.equal((await ack(web.socket, 'relay_request', request('bot.chat', {message: 'hello'}))).data.accepted, true);
assert.deepEqual(bot.sent, ['hello']);
bot.bot_instance.tabComplete = async text => {
assert.equal(text, '/he');
return ['help', {match: 'help'}, {match: 'hello'}];
};
assert.deepEqual(success_data(await ack(web.socket, 'relay_request', request('tab_complete', {text: '/he'}))),
{matches: ['help', 'hello'], start: 1, length: 2});
bot.bot_instance.tabComplete = async () => ['Alex', 'Alice', 'Alex'];
const input = '/tell Al';
const completion = success_data(await ack(web.socket, 'relay_request', request('tab_complete', {text: input})));
assert.deepEqual(completion, {matches: ['Alex', 'Alice'], start: 6, length: 2});
assert.equal(input.slice(0, completion.start) + completion.matches[0]
+ input.slice(completion.start + completion.length), '/tell Alex');
code(await ack(other.socket, 'relay_request', request('tab_complete', {text: '/he'})), 'FORBIDDEN');
bot.bot_status = 'offline'; code(await ack(web.socket, 'relay_request', request('bot.chat', {message: 'blocked'})), 'BOT_NOT_ONLINE');
assert.deepEqual(bot.sent, ['hello']);
bot.emit('message', {json: {text: 'public text', password: 'SECRET'}}, 'chat');
bot.emit('error', new Error('SECRET stack'));
assert.equal(JSON.stringify({snapshot, logs}).includes('SECRET'), false);
await ack(other.socket, 'relay_request', request()); assert.equal(leaks, 0);
assert.ok(events.length >= 7 && reader_events.length >= 7);
assert.ok([...events, ...reader_events].every(event => event.name === 'status_update'));
assert.ok(f.packets.some(packet => packet.event === 'bot_event'));
manager.stop(); assert.equal(f.source(s.bot_id).count, 0); assert.equal(bot.listenerCount('log'), 0);
});
test('actual manager reauth renews same transport; disconnect requires fresh token and preserves stream identity', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); const first = f.cmd(s, Date.now() + 3000);
assert.equal((await manager.ensure(first)).status, 'success');
const web = await f.web(s); const before = success_data(await ack(web.socket, 'relay_request', request()));
const renewed = f.cmd(s, Date.now() + 8000);
assert.equal((await manager.ensure(renewed)).data.lease_expires_at, renewed.expires_at);
assert.equal(f.node_sockets.length, 1); assert.ok(f.packets.some(p => p.event === 'reauth' && p.value.token === renewed.token));
const offline = received(web.socket, 'relay_status', value => !value.node_ready);
f.node_sockets[0].disconnect(); await offline;
code(await ack(web.socket, 'relay_request', request()), 'NODE_OFFLINE');
code(await manager.ensure({...renewed, deadline_at: Date.now() + 2000}), 'TOKEN_EXPIRED');
assert.equal(f.node_sockets.length, 1);
const fresh = f.cmd(s, Date.now() + 9000);
assert.equal((await manager.ensure(fresh)).status, 'success'); assert.equal(f.node_sockets.length, 2);
const after = success_data(await ack(web.socket, 'relay_request', request()));
assert.equal(after.stream_id, before.stream_id); assert.ok(after.seq > before.seq);
});
test('actual multi-grant request_id reuse is private, read-deduped and chat executes once per grant', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const bot = online_context(); f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const a = await f.web(s); const b = await f.web(s);
const id = randomUUID(); const reads = await Promise.all([a, b].map(web => ack(web.socket, 'relay_request', request('bot.snapshot', {}, id))));
assert.ok(reads.every(value => value.status === 'success'));
assert.deepEqual(await ack(a.socket, 'relay_request', request('bot.snapshot', {}, id)), reads[0]);
code(await ack(a.socket, 'relay_request', request('bot.logs', {limit: 1}, id)), 'INVALID_REQUEST');
const chat_id = randomUUID();
const chats = await Promise.all([a, b].map(web => ack(web.socket, 'relay_request', request('bot.chat', {message: web.grant_id}, chat_id))));
assert.ok(chats.every(value => value.status === 'success')); assert.deepEqual(new Set(bot.sent), new Set([a.grant_id, b.grant_id]));
code(await ack(a.socket, 'relay_request', request('bot.chat', {message: a.grant_id}, chat_id)), 'CANCELLED');
assert.equal(bot.sent.length, 2);
const closed = received(a.socket, 'forward_session_closed');
assert.equal((await f.control_ack('forward_revoke_grant', {forward_session_id: s.forward_session_id, grant_id: a.grant_id, reason: 'permission_removed'})).status, 'success');
await closed; assert.equal((await ack(b.socket, 'relay_request', request())).status, 'success');
code(await f.control_ack('forward_authorize_grant', {forward_session_id: s.forward_session_id, grant_id: a.grant_id,
user_id: 4, permissions: f.permissions, expires_at: Date.now() + 5000}), 'FORBIDDEN');
});
test('one Node manager isolates multiple Bot sessions and observes stop/start without closing Web transport', async t => {
const f = await real_fixture(t);
const a = scope();
const b = {...a, bot_id: a.bot_id + 1, forward_session_id: randomUUID()};
await f.open(a); await f.open(b);
const manager = f.manager(a);
const bot_a = online_context(); const bot_b = online_context();
f.source(a.bot_id).set(bot_a); f.source(b.bot_id).set(bot_b);
assert.equal((await manager.ensure(f.cmd(a))).status, 'success');
assert.equal((await manager.ensure(f.cmd(b))).status, 'success');
const web_a = await f.web(a); const web_b = await f.web(b);
const before = success_data(await ack(web_a.socket, 'relay_request', request()));
const other = success_data(await ack(web_b.socket, 'relay_request', request()));
assert.notEqual(before.stream_id, other.stream_id);
assert.equal(before.bot_id, a.bot_id); assert.equal(other.bot_id, b.bot_id);
await ack(web_a.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']}));
await ack(web_b.socket, 'relay_request', request('bot.subscribe', {events: ['status_update', 'log']}));
const events_b = []; web_b.socket.on('bot_event', event => events_b.push(event));
const events_a = []; web_a.socket.on('bot_event', event => events_a.push(event));
bot_a.emit('log', [{message: 'Bot A only', font_color: '#ffffff', font_style: ['normal']}]);
assert.equal(success_data(await ack(web_a.socket, 'relay_request', request('bot.logs', {limit: 10}))).records[0].segments[0].message, 'Bot A only');
assert.equal(success_data(await ack(web_b.socket, 'relay_request', request('bot.logs', {limit: 10}))).records.length, 0);
assert.equal(events_b.length, 0);
await ack(web_a.socket, 'relay_request', request('bot.chat', {message: 'a'}));
await ack(web_b.socket, 'relay_request', request('bot.chat', {message: 'b'}));
assert.deepEqual(bot_a.sent, ['a']); assert.deepEqual(bot_b.sent, ['b']);
const stopped = received(web_a.socket, 'bot_event', event => event.payload.status === 'offline');
f.source(a.bot_id).set(undefined);
await stopped;
const absent = success_data(await ack(web_a.socket, 'relay_request', request()));
assert.equal(absent.bot_status, 'offline'); assert.equal(absent.context_generation, null);
const replacement = online_context();
const started = received(web_a.socket, 'bot_event', event => event.payload.status === 'online');
f.source(a.bot_id).set(replacement);
await started;
const after = success_data(await ack(web_a.socket, 'relay_request', request()));
assert.equal(after.stream_id, before.stream_id); assert.notEqual(after.context_generation, before.context_generation);
assert.equal(web_a.socket.connected, true); assert.equal(f.node_sockets.length, 2);
assert.equal(events_b.length, 0);
assert.deepEqual(events_a.map(event => event.payload.status), ['offline', 'online']);
assert.equal(bot_a.listenerCount('log'), 0);
});
test('actual session revocation retires manager, closes Web and cannot revive stale scope after a new session opens', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s); const closed = received(web.socket, 'forward_session_closed');
const node_closed = received(f.node_sockets[0], 'forward_session_closed');
assert.equal((await f.control_ack('forward_revoke_session', {forward_session_id: s.forward_session_id, reason: 'ownership_changed'})).status, 'success');
assert.equal((await closed).code, 'SESSION_STALE'); await node_closed;
code(await manager.ensure(f.cmd(s)), 'SESSION_STALE');
code(await f.control_ack('forward_open_session', {...s, lease_expires_at: Date.now() + 5000}), 'SESSION_STALE');
const next = {...s, forward_session_id: randomUUID(), node_session_id: 'new-main-socket-session'}; await f.open(next);
code(await manager.ensure(f.cmd(next)), 'SESSION_STALE');
const replacement = f.manager(next); assert.equal((await replacement.ensure(f.cmd(next))).status, 'success');
const new_web = await f.web(next); assert.equal((await ack(new_web.socket, 'relay_request', request())).status, 'success');
code(await manager.ensure(f.cmd(s)), 'SESSION_STALE');
});
test('actual relay rejects wrong-scope token and manager lease expiry prevents session revival', async t => {
const f = await real_fixture(t); const s = scope(); await f.open(s);
const manager = f.manager(s); const wrong = f.cmd(s);
wrong.token = f.token({...s, bot_id: s.bot_id + 1}, 'node', wrong.expires_at);
code(await manager.ensure(wrong), 'SESSION_STALE');
const short = f.cmd(s, Date.now() + 200);
assert.equal((await manager.ensure(short)).status, 'success');
const web = await f.web(s);
await delay(Math.max(0, short.expires_at - Date.now()) + 25);
code(await manager.ensure(f.cmd(s)), 'SESSION_STALE');
code(await ack(web.socket, 'relay_request', request()), 'NODE_OFFLINE');
assert.equal(f.source(s.bot_id).count, 0);
});
test('actual relay deadline times out held chat; late manager delivery cannot send chat or replace the ACK', async t => {
const f = await real_fixture(t, {rpc_timeout_ms: 60}); const s = scope(); await f.open(s);
const bot = online_context(); f.source(s.bot_id).set(bot);
const manager = f.manager(s); assert.equal((await manager.ensure(f.cmd(s))).status, 'success');
const web = await f.web(s); const node = f.node_sockets[0];
const [handler] = node.listeners('relay_request');
let late; let arrived;
const held = new Promise(resolve => {arrived = resolve;});
node.off('relay_request', handler);
node.on('relay_request', (raw, callback) => {
late = () => new Promise(resolve => handler(raw, response => {callback(response); resolve(response);}));
arrived(raw);
});
const chat = request('bot.chat', {message: 'must never send'});
const response = ack(web.socket, 'relay_request', chat);
const delivered = await held;
assert.equal(delivered.forward_session_id, s.forward_session_id);
assert.notEqual(delivered.correlation_id, chat.request_id);
code(await response, 'TIMEOUT');
code(await late(), 'TIMEOUT'); assert.deepEqual(bot.sent, []);
code(await ack(web.socket, 'relay_request', chat), 'CANCELLED');
assert.deepEqual(bot.sent, []);
node.removeAllListeners('relay_request'); node.on('relay_request', handler);
assert.equal((await ack(web.socket, 'relay_request', request())).status, 'success');
});
test('public relay restart closes old scope; a fresh session and replacement manager restore Web RPC', async t => {
const first = await real_fixture(t); const s = scope(); await first.open(s);
const manager = first.manager(s); assert.equal((await manager.ensure(first.cmd(s))).status, 'success');
const web = await first.web(s);
const node_closed = received(first.node_sockets[0], 'forward_session_closed');
const web_closed = received(web.socket, 'forward_session_closed');
await first.handle.stop();
assert.equal((await node_closed).reason, 'relay_closed');
assert.equal((await web_closed).code, 'RELAY_UNAVAILABLE');
const restarted = await real_fixture(t);
code(await manager.ensure(restarted.cmd(s)), 'SESSION_STALE');
const next = {...s, forward_session_id: randomUUID()}; await restarted.open(next);
assert.equal((await manager.ensure(restarted.cmd(next))).status, 'success');
const current = await restarted.web(next);
const before = success_data(await ack(current.socket, 'relay_request', request()));
const offline = received(current.socket, 'relay_status', value => !value.node_ready);
manager.stop(); await offline;
const replacement = restarted.manager(next);
assert.equal((await replacement.ensure(restarted.cmd(next))).status, 'success');
const after = success_data(await ack(current.socket, 'relay_request', request()));
assert.notEqual(after.stream_id, before.stream_id);
assert.equal(after.context_exists, false);
});