diff --git a/package.json b/package.json index 05ecf53..740a141 100755 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "install:tools": "bash scripts/install-tools.sh", "mcp": "tsx src/mcp-server.ts", "mcp:prod": "node dist/mcp-server.js", - "test": "vitest run src", + "test": "vitest run src && node scripts/test-ops-preflight.mjs", "test:watch": "vitest src", "lint": "eslint src/**/*.ts", "typecheck": "tsc --noEmit", @@ -80,6 +80,8 @@ "capability": "node scripts/capability-smoke.mjs", "gauntlet": "node scripts/capability-smoke.mjs --gauntlet", "test:gate": "npm run build --silent && node scripts/test-gate.mjs", + "ops:preflight": "node scripts/ops-preflight.mjs", + "test:ops-preflight": "node scripts/test-ops-preflight.mjs", "update": "node scripts/update.mjs", "update:dry": "node scripts/update.mjs --dry-run", "update:hard": "node scripts/update.mjs --hard", diff --git a/scripts/ops-preflight.mjs b/scripts/ops-preflight.mjs new file mode 100644 index 0000000..5e26770 --- /dev/null +++ b/scripts/ops-preflight.mjs @@ -0,0 +1,219 @@ +#!/usr/bin/env node +import { access, constants } from 'node:fs/promises'; +import { posix, resolve, win32 } from 'node:path'; +import process from 'node:process'; +import { pathToFileURL } from 'node:url'; + +const rawBaseUrl = process.env.T3MP3ST_API_URL || 'http://127.0.0.1:3333'; +const jsonMode = process.argv.includes('--json'); +const strictMode = process.argv.includes('--strict'); +const checks = []; + +function check(name, passed, detail = '', severity = 'block') { + checks.push({ name, passed: Boolean(passed), detail, severity }); +} + +export function validateBaseUrl(value) { + try { + const url = new URL(value); + const hostname = url.hostname.toLowerCase().replace(/^\[|\]$/g, ''); + const loopbackIpv4 = /^127(?:\.\d{1,3}){3}$/.test(hostname) + && hostname.split('.').every(part => Number(part) <= 255); + const loopback = hostname === 'localhost' || hostname === '::1' || loopbackIpv4; + const rootPath = url.pathname === '' || url.pathname === '/'; + if (!['http:', 'https:'].includes(url.protocol)) throw new Error('protocol must be http or https'); + if (!loopback) throw new Error('host must be loopback (localhost, 127.0.0.0/8, or ::1)'); + if (url.username || url.password) throw new Error('credentials are not allowed in the API URL'); + if (!rootPath || url.search || url.hash) throw new Error('API URL must be an origin without a path, query, or fragment'); + return { ok: true, url: url.origin }; + } catch (error) { + return { ok: false, url: value, error: error instanceof Error ? error.message : String(error) }; + } +} + +function commandCandidates(binary, platform, env) { + const windows = platform === 'win32'; + const pathApi = windows ? win32 : posix; + const separator = windows ? ';' : ':'; + const pathValue = env.PATH || env.Path || env.path || ''; + const directories = pathValue.split(separator).map(item => item.trim().replace(/^"|"$/g, '')).filter(Boolean); + const hasExtension = windows && win32.extname(binary) !== ''; + const extensions = windows && !hasExtension + ? (env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean) + : ['']; + return directories.flatMap(directory => extensions.map(extension => pathApi.join(directory, `${binary}${extension}`))); +} + +export async function commandExists(binary, options = {}) { + const platform = options.platform || process.platform; + const env = options.env || process.env; + const accessFn = options.accessFn || access; + const mode = platform === 'win32' ? constants.F_OK : constants.X_OK; + for (const candidate of commandCandidates(binary, platform, env)) { + try { + await accessFn(candidate, mode); + return true; + } catch { + // Keep searching PATH candidates. + } + } + return false; +} + +async function apiGet(baseUrl, path) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 3000); + try { + const response = await fetch(`${baseUrl}${path}`, { + signal: controller.signal, + redirect: 'error', + }); + const text = await response.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch { + data = { raw: text }; + } + return { ok: response.ok, status: response.status, data }; + } catch (error) { + return { + ok: false, + status: 0, + data: {}, + error: error instanceof Error ? error.message : String(error), + }; + } finally { + clearTimeout(timer); + } +} + +function apiFailure(result) { + if (result.status) return `HTTP ${result.status}`; + return result.error || 'request failed'; +} + +function finish(startedAt, baseUrl, forcedPass) { + const blockers = checks.filter(item => !item.passed && item.severity === 'block'); + const warnings = checks.filter(item => !item.passed && item.severity === 'warn'); + const passed = forcedPass ?? (blockers.length === 0 && (!strictMode || warnings.length === 0)); + const result = { + tool: 't3mp3st-ops-preflight', + startedAt, + finishedAt: new Date().toISOString(), + baseUrl, + passed, + summary: { + checks: checks.length, + passed: checks.filter(item => item.passed).length, + warnings: warnings.length, + blockers: blockers.length, + strict: strictMode, + }, + checks, + }; + + if (jsonMode) { + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(`T3MP3ST ops preflight: ${passed ? 'PASS' : 'FAIL'}`); + console.log(`${result.summary.passed}/${result.summary.checks} checks passed, ${warnings.length} warning(s), ${blockers.length} blocker(s)`); + for (const item of checks) { + const marker = item.passed ? 'ok' : item.severity === 'warn' ? 'warn' : 'block'; + console.log(`- ${marker.padEnd(5)} ${item.name}${item.detail ? ` - ${item.detail}` : ''}`); + } + } + + process.exitCode = passed ? 0 : 1; + return result; +} + +export async function main() { + const startedAt = new Date().toISOString(); + const baseUrlResult = validateBaseUrl(rawBaseUrl); + check('Local API URL', baseUrlResult.ok, baseUrlResult.ok ? baseUrlResult.url : baseUrlResult.error, 'block'); + if (!baseUrlResult.ok) return finish(startedAt, rawBaseUrl, false); + const baseUrl = baseUrlResult.url; + + const health = await apiGet(baseUrl, '/health'); + const apiReachable = health.ok; + check('API health endpoint', apiReachable, + apiReachable ? `HTTP ${health.status}` : `${apiFailure(health)} - run npm run server`, 'block'); + + if (!apiReachable) { + check('Node runtime', true, `${process.execPath} (${process.version})`, 'warn'); + const npmAvailable = Boolean(process.env.npm_execpath) || await commandExists('npm'); + check('npm available', npmAvailable, npmAvailable ? 'available' : 'not found on PATH', 'warn'); + return finish(startedAt, baseUrl, false); + } + + check('API reports operational', health.data?.status === 'operational', + `status: ${health.data?.status || 'unknown'}`, 'block'); + + const mission = await apiGet(baseUrl, '/api/mission/status'); + const missionShapeValid = mission.ok && typeof mission.data?.active === 'boolean'; + check('Mission status available', missionShapeValid, + missionShapeValid ? 'verified' : mission.ok ? 'invalid response shape' : apiFailure(mission), 'block'); + if (missionShapeValid) { + const missionActive = mission.data.active === true; + const activeMission = mission.data.mission || {}; + const activeDetail = [ + `mission ${activeMission.id || mission.data.name || 'unknown'}`, + activeMission.currentPhase ? `phase ${activeMission.currentPhase}` : '', + ].filter(Boolean).join(', '); + check('No active mission in progress', !missionActive, missionActive ? activeDetail : 'idle', 'block'); + } + + const approvals = await apiGet(baseUrl, '/api/approvals?status=pending'); + const approvalsShapeValid = approvals.ok && Array.isArray(approvals.data?.approvals); + check('Approval receipt status available', approvalsShapeValid, + approvalsShapeValid ? 'verified' : approvals.ok ? 'invalid response shape' : apiFailure(approvals), 'block'); + if (approvalsShapeValid) { + const pendingCount = approvals.data.approvals.length; + check('No pending action receipts', pendingCount === 0, + pendingCount > 0 ? `${pendingCount} pending approval(s) - review before launch` : 'none pending', 'warn'); + } + + const llmStatus = health.data?.llm; + const serverBackendAvailable = llmStatus?.configured === true || llmStatus?.connected === true; + const localAgent = await apiGet(baseUrl, '/api/agents/local/status'); + const localAgentShapeValid = localAgent.ok && Array.isArray(localAgent.data?.connected); + const connectedAgents = localAgentShapeValid ? localAgent.data.connected : []; + const backendAvailable = serverBackendAvailable || connectedAgents.length > 0; + let backendDetail; + if (serverBackendAvailable) { + backendDetail = `${llmStatus.provider || 'server'} backend ${llmStatus.connected ? 'connected' : 'configured'}`; + } else if (connectedAgents.length > 0) { + const firstAgent = connectedAgents[0]; + backendDetail = `${firstAgent.name || firstAgent.id || 'local agent'} connected`; + } else if (!localAgentShapeValid) { + backendDetail = `server LLM unavailable; local-agent status ${localAgent.ok ? 'has invalid shape' : apiFailure(localAgent)}`; + } else { + backendDetail = 'no server LLM or connected local agent - configure War Room Settings'; + } + check('LLM backend available', backendAvailable, backendDetail, 'warn'); + + const arsenal = await apiGet(baseUrl, '/api/arsenal/status'); + const arsenalShapeValid = arsenal.ok + && Number.isFinite(arsenal.data?.summary?.installedCommandReady) + && Number.isFinite(arsenal.data?.summary?.commandReady); + check('Arsenal status available', arsenalShapeValid, + arsenalShapeValid ? 'verified' : arsenal.ok ? 'invalid response shape' : apiFailure(arsenal), 'warn'); + if (arsenalShapeValid) { + const installed = arsenal.data.summary.installedCommandReady; + const total = arsenal.data.summary.commandReady; + check('Arsenal tools available', installed > 0, + `${installed}/${total} command-ready tools installed`, 'warn'); + } + + return finish(startedAt, baseUrl); +} + +const invokedAsScript = process.argv[1] + && import.meta.url === pathToFileURL(resolve(process.argv[1])).href; +if (invokedAsScript) { + main().catch(error => { + console.error(`ops-preflight failed: ${error?.stack || error?.message || error}`); + process.exitCode = 1; + }); +} diff --git a/scripts/test-ops-preflight.mjs b/scripts/test-ops-preflight.mjs new file mode 100644 index 0000000..93b9a90 --- /dev/null +++ b/scripts/test-ops-preflight.mjs @@ -0,0 +1,246 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { commandExists, validateBaseUrl } from './ops-preflight.mjs'; + +const scriptDir = dirname(fileURLToPath(import.meta.url)); +const preflightPath = join(scriptDir, 'ops-preflight.mjs'); +let passed = 0; +let failed = 0; + +function healthyRoutes(overrides = {}) { + return { + '/health': { + status: 'operational', + llm: { configured: true, connected: true, provider: 'mock' }, + }, + '/api/mission/status': { active: false, progress: [], tasks: [] }, + '/api/approvals?status=pending': { approvals: [] }, + '/api/agents/local/status': { connected: [] }, + '/api/arsenal/status': { + summary: { installedCommandReady: 2, commandReady: 4 }, + }, + ...overrides, + }; +} + +async function withServer(routes, fn) { + const server = createServer((req, res) => { + const route = routes[req.url]; + if (!route) { + res.writeHead(404, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ error: 'not found' })); + return; + } + const status = route.httpStatus || 200; + const headers = { 'content-type': 'application/json', ...(route.headers || {}) }; + res.writeHead(status, headers); + res.end(route.raw ?? JSON.stringify(route.body ?? route)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + const url = `http://127.0.0.1:${address.port}`; + try { + return await fn(url); + } finally { + await new Promise(resolve => server.close(resolve)); + } +} + +function runPreflight(baseUrl, args = []) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [preflightPath, '--json', ...args], { + env: { ...process.env, T3MP3ST_API_URL: baseUrl }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', chunk => { stdout += chunk; }); + child.stderr.on('data', chunk => { stderr += chunk; }); + child.once('error', reject); + child.once('close', code => { + let result; + try { + result = JSON.parse(stdout); + } catch (error) { + reject(new Error(`invalid JSON output (exit ${code}): ${error.message}\nstdout=${stdout}\nstderr=${stderr}`)); + return; + } + resolve({ code, result, stderr }); + }); + }); +} + +function findCheck(result, name) { + const item = result.checks.find(check => check.name === name); + assert(item, `missing check: ${name}`); + return item; +} + +async function test(name, fn) { + try { + await fn(); + passed++; + console.log(` OK ${name}`); + } catch (error) { + failed++; + console.error(` FAIL ${name}: ${error.stack || error.message || error}`); + } +} + +console.log('\n======== ops preflight tests ========\n'); + +await test('accepts loopback origins and rejects non-local or credentialed URLs', async () => { + assert.equal(validateBaseUrl('http://127.0.0.1:3333').ok, true); + assert.equal(validateBaseUrl('https://localhost:3333/').ok, true); + assert.equal(validateBaseUrl('http://[::1]:3333').ok, true); + assert.equal(validateBaseUrl('https://example.com').ok, false); + assert.equal(validateBaseUrl('https://127.example.com').ok, false); + assert.equal(validateBaseUrl('http://user:secret@127.0.0.1:3333').ok, false); + assert.equal(validateBaseUrl('http://127.0.0.1:3333/api').ok, false); +}); + +await test('resolves command candidates portably without invoking a shell', async () => { + const windowsAttempts = []; + const windowsFound = await commandExists('npm', { + platform: 'win32', + env: { PATH: 'C:\\Tools;D:\\Node', PATHEXT: '.EXE;.CMD' }, + accessFn: async candidate => { + windowsAttempts.push(candidate); + if (candidate.toLowerCase() === 'd:\\node\\npm.cmd') return; + throw new Error('missing'); + }, + }); + assert.equal(windowsFound, true); + assert(windowsAttempts.some(candidate => candidate.toLowerCase() === 'd:\\node\\npm.cmd')); + + const posixFound = await commandExists('npm', { + platform: 'linux', + env: { PATH: '/usr/bin:/opt/node/bin' }, + accessFn: async candidate => { + if (candidate === '/opt/node/bin/npm') return; + throw new Error('missing'); + }, + }); + assert.equal(posixFound, true); +}); + +await test('passes a healthy idle War Room using server-authoritative backend state', async () => { + await withServer(healthyRoutes(), async url => { + const { code, result, stderr } = await runPreflight(url); + assert.equal(stderr, ''); + assert.equal(code, 0); + assert.equal(result.passed, true); + assert.equal(findCheck(result, 'LLM backend available').passed, true); + assert.equal(findCheck(result, 'No active mission in progress').detail, 'idle'); + }); +}); + +await test('blocks an active mission and reports current nested mission fields', async () => { + const routes = healthyRoutes({ + '/api/mission/status': { + active: true, + mission: { id: 'mission-123', currentPhase: 'recon' }, + }, + }); + await withServer(routes, async url => { + const { code, result } = await runPreflight(url); + assert.equal(code, 1); + const item = findCheck(result, 'No active mission in progress'); + assert.equal(item.passed, false); + assert.match(item.detail, /mission-123/); + assert.match(item.detail, /recon/); + }); +}); + +await test('warns for pending approvals and strict mode fails the warning', async () => { + const routes = healthyRoutes({ + '/api/approvals?status=pending': { approvals: [{ id: 'approval-1' }] }, + }); + await withServer(routes, async url => { + const normal = await runPreflight(url); + assert.equal(normal.code, 0); + assert.equal(findCheck(normal.result, 'No pending action receipts').passed, false); + const strict = await runPreflight(url, ['--strict']); + assert.equal(strict.code, 1); + assert.equal(strict.result.passed, false); + }); +}); + +await test('blocks when mission state cannot be verified', async () => { + const routes = healthyRoutes({ + '/api/mission/status': { httpStatus: 503, body: { error: 'unavailable' } }, + }); + await withServer(routes, async url => { + const { code, result } = await runPreflight(url); + assert.equal(code, 1); + assert.equal(findCheck(result, 'Mission status available').passed, false); + }); +}); + +await test('blocks when approval state is malformed instead of assuming zero', async () => { + const routes = healthyRoutes({ + '/api/approvals?status=pending': { total: 0 }, + }); + await withServer(routes, async url => { + const { code, result } = await runPreflight(url); + assert.equal(code, 1); + assert.equal(findCheck(result, 'Approval receipt status available').passed, false); + }); +}); + +await test('uses health LLM state when the local-agent endpoint is unavailable', async () => { + const routes = healthyRoutes({ + '/api/agents/local/status': { httpStatus: 500, body: { error: 'unavailable' } }, + }); + await withServer(routes, async url => { + const { code, result } = await runPreflight(url); + assert.equal(code, 0); + assert.equal(findCheck(result, 'LLM backend available').passed, true); + }); +}); + +await test('reports a missing backend and strict mode treats it as a failure', async () => { + const routes = healthyRoutes({ + '/health': { + status: 'operational', + llm: { configured: false, connected: false, provider: 'openrouter' }, + }, + }); + await withServer(routes, async url => { + const normal = await runPreflight(url); + assert.equal(normal.code, 0); + assert.equal(findCheck(normal.result, 'LLM backend available').passed, false); + const strict = await runPreflight(url, ['--strict']); + assert.equal(strict.code, 1); + }); +}); + +await test('reports arsenal endpoint failure and strict mode treats it as a failure', async () => { + const routes = healthyRoutes({ + '/api/arsenal/status': { httpStatus: 500, body: { error: 'unavailable' } }, + }); + await withServer(routes, async url => { + const normal = await runPreflight(url); + assert.equal(normal.code, 0); + assert.equal(findCheck(normal.result, 'Arsenal status available').passed, false); + const strict = await runPreflight(url, ['--strict']); + assert.equal(strict.code, 1); + }); +}); + +await test('rejects a non-loopback API URL before making a request', async () => { + const { code, result } = await runPreflight('https://example.com'); + assert.equal(code, 1); + assert.equal(result.checks.length, 1); + assert.equal(findCheck(result, 'Local API URL').passed, false); +}); + +console.log(`\n======== ${failed ? `${failed} FAILED` : 'ALL PASS'} (${passed} passed) ========\n`); +if (failed) process.exitCode = 1;