import { leasePort } from '../../../scripts/testing/lane-fixtures.mjs'; import { after as laneAfter } from 'node:test'; import test from 'node:test'; import assert from 'node:assert/strict'; import { createServer } from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; import { once } from 'node:events'; import { readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmp, fixture } from '../../cli/tests/helpers.mjs'; import { TESTER_CLI, HUMAN_CLI } from '../../cli/src/transport.mjs'; import { testerFixture } from './tester-fixture.mjs'; import { request } from 'node:http'; import { testerSocketPath, TESTER_ORIGIN } from '../src/tester-socket.mjs'; const CLI = new URL('../src/cli.mjs', import.meta.url).pathname; const get = (port, path) => new Promise((resolve, reject) => { const q = request({ socketPath: port, path, headers: { host: new URL(TESTER_ORIGIN).host } }, r => { let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text })); }); q.on('error', reject); q.end(); }); async function freePort() { return leasePort({after:laneAfter}); } test('tester-port validates range tunnel main port and requires configured bus before listening', t => { const root = tmp(t, 'tester-cli-invalid-'), f = fixture(root); for (const flags of [['--tester-port', '7332'], ['--port', '7444', '--tester-port', '7444'], ['--tester-port', '7444', '--port', '7444'], ...['1023', '65536', '1.5', '-1', 'nope'].map(p => ['--tester-port', p])]) { const r = spawnSync(process.execPath, [CLI, 'serve', ...flags], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 }); assert.equal(r.status, 2); assert.match(r.stderr, /tester.port.*retired/i); } const r = spawnSync(process.execPath, [CLI, 'serve', '--port', '0', '--tester-socket'], { encoding: 'utf8', env: { ...f.env, MOSAIC_CONFIG: join(root, 'missing.json') }, timeout: 5000 }); assert.equal(r.status, 2); assert.match(r.stderr, /tester-socket needs the bus configured/); }); // P19 #876: about 20 Console reads and chat calls in a row, each one TESTER_CLI round trip of two // node processes (the shim re-executes itself to carry its nonce). That is about 3 s idle and // 10 s at load 20 on 16 cores; 30 s left too little room on a busy machine, so the guard is 60 s. test('CLI tester listener binds a private Unix socket and spawns TESTER_CLI for every Console bus read', { timeout: 60000 }, async t => { const f = await testerFixture(t), port = testerSocketPath(f.f.dataRoot), log = join(f.root, 'spawn.jsonl'); await new Promise(r=>f.web.close(r)); const hook = join(f.root, 'spawn-hook.mjs'); writeFileSync(log, ''); writeFileSync(hook, `import cp from 'node:child_process';import {syncBuiltinESMExports} from 'node:module';import {appendFileSync} from 'node:fs';const original=cp.spawn;cp.spawn=function(cmd,args,options){if(Array.isArray(args)&&args[0]?.endsWith('-cli.mjs'))appendFileSync(${JSON.stringify(log)},JSON.stringify({cli:args[0]})+'\\n');return original.call(this,cmd,args,options);};syncBuiltinESMExports();`); const child = spawn(process.execPath, [CLI, 'serve', '--board', 'http://127.0.0.1:9', '--port', '0', '--tester-socket', '--business', 'acme', '--public-host', 'console.example', '--auth-header', 'x-user', '--allow-user', 'jason'], { env: { ...f.f.env, NODE_OPTIONS: `--import=${hook}` }, stdio: ['ignore', 'pipe', 'pipe'] }); let output = '', error = ''; child.stdout.on('data', c => output += c); child.stderr.on('data', c => error += c); t.after(async () => { if (child.exitCode === null) { child.kill('SIGTERM'); await once(child, 'close'); } }); await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(Error('startup did not name tester: ' + output + error)), 5000); const check = () => { if (output.includes(`Tester Console: unix:${port}`)) { clearTimeout(timer); resolve(); } }; child.stdout.on('data', check); child.once('exit', code => { clearTimeout(timer); reject(Error('CLI exit ' + code + ': ' + output + error)); }); check(); }); assert.match(output, /binds (the )?tester/); const paths = ['inbox', 'tasks', 'agents', 'trail?subject=coder', 'decisions?list=roles', 'credentials', 'replies?id=absent', 'messages', 'usage', 'limits', 'names', 'instances', 'routes'].map(p => '/api/bus/' + p); paths.push('/api/project-files?project=stack', '/api/project-file?project=stack&path=spec%2Fnorth-star.md'); for (const path of paths) { const before = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).length; const r = await get(port, path); const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); assert.equal(calls.length, before + 1, path); assert.equal(calls.at(-1).cli, TESTER_CLI, path); assert.equal(calls.some(c => c.cli === HUMAN_CLI), false); assert.ok(![401, 403, 500, 502, 503].includes(r.status), path + ': ' + r.body); } // Row 177 (#1675): the tester port's chat reaches the bus through TESTER_CLI as well. const { revision, plugins } = await f.call('plugins'); await f.call('plugins.set', { revision, plugins: { ...plugins, channel: ['cli', 'webui'] } }); const send = (path, value) => new Promise((resolve, reject) => { const q = request({ socketPath: port, path, method: 'POST', headers: { host: new URL(TESTER_ORIGIN).host, origin: TESTER_ORIGIN, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' } }, r => { let text = ''; r.on('data', c => text += c); r.on('end', () => resolve({ status: r.statusCode, body: text })); }); q.on('error', reject); q.end(JSON.stringify(value)); }); const spawns = async (label, go) => { const before = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).length; const r = await go(); const calls = readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse).slice(before); assert.ok(calls.length > 0, label); assert.ok(calls.every(c => c.cli === TESTER_CLI), label); return r; }; for (const path of ['/api/chat/projects', '/api/chat/threads']) assert.equal((await spawns(path, () => get(port, path))).status, 200, path); const project = await spawns('create project', () => send('/api/chat/projects', { name: 'tester work' })); assert.equal(project.status, 200, project.body); // The fixture business has no assistant instance, so the thread's refusal comes from the bus. const thread = await spawns('create thread', () => send('/api/chat/threads', { project: JSON.parse(project.body).id, name: 'probe' })); assert.equal(JSON.parse(thread.body).error, 'thread-instance-missing', thread.body); });