114 lines
7.2 KiB
JavaScript
114 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
// Real Settings UI + persisted config + live CSP. Only loopback test services.
|
|
import assert from 'node:assert/strict';
|
|
import { createServer } from 'node:http';
|
|
import { once } from 'node:events';
|
|
import { mkdtemp, mkdir, copyFile, writeFile, readFile, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join, resolve } from 'node:path';
|
|
import { spawn } from 'node:child_process';
|
|
import { chromium } from 'playwright';
|
|
const root = resolve(import.meta.dirname, '../..');
|
|
const temp = await mkdtemp(join(tmpdir(), 'maestro-egress-settings-'));
|
|
const delay = ms => new Promise(resolveDelay => setTimeout(resolveDelay, ms));
|
|
let worker, browser;
|
|
let output = '';
|
|
const servers = [];
|
|
async function listen(server) { server.listen(0, '127.0.0.1'); await once(server, 'listening'); servers.push(server); return `http://127.0.0.1:${server.address().port}`; }
|
|
async function stop() {
|
|
if (!worker || worker.exitCode !== null || worker.signalCode !== null) return;
|
|
const done = once(worker, 'exit'); worker.kill('SIGTERM');
|
|
const timeout = setTimeout(() => worker?.kill('SIGKILL'), 5000);
|
|
try { await done; } finally { clearTimeout(timeout); worker = undefined; }
|
|
}
|
|
try {
|
|
await mkdir(join(temp, 'dist/db'), { recursive: true });
|
|
await copyFile(join(root, 'dist/db/schema.sql'), join(temp, 'dist/db/schema.sql'));
|
|
let hits = 0;
|
|
const canary = await listen(createServer((_req, res) => { hits++; res.setHeader('Access-Control-Allow-Origin', '*'); res.end('ok'); }));
|
|
const llm = await listen(createServer((_req, res) => { res.setHeader('Content-Type', 'application/json'); res.end('{"data":[{"id":"test-model"}],"models":[]}'); }));
|
|
const reserve = createServer(); reserve.listen(0, '127.0.0.1'); await once(reserve, 'listening');
|
|
const port = reserve.address().port; await new Promise(resolveClose => reserve.close(resolveClose));
|
|
const base = `http://127.0.0.1:${port}`;
|
|
const config = join(temp, 'config.yaml');
|
|
await writeFile(config, 'safety:\n bash_allow_network: true\n');
|
|
const env = { PATH: process.env.PATH, HOME: temp, NODE_ENV: 'test', AAO_MODE: 'worker', AAO_CONFIG: config,
|
|
PORT: String(port), DB_PATH: join(temp, 'test.db'), WORKTREE_DIR: join(temp, 'workspaces'),
|
|
UI_DIST_PATH: join(root, 'ui/dist'), OLLAMA_BASE_URL: `${llm}/v1`, OLLAMA_MODEL: 'test-model', LOG_LEVEL: 'info' };
|
|
async function start(extra = {}) {
|
|
output = '';
|
|
worker = spawn(process.execPath, [join(root, 'dist/main.js')], { cwd: temp, env: { ...env, ...extra }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
const capture = value => { output += value.toString(); if (output.length > 2_000_000) worker?.kill('SIGTERM'); };
|
|
worker.stdout.on('data', capture); worker.stderr.on('data', capture);
|
|
worker.on('error', error => { output += error.message; });
|
|
for (let i = 0; i < 300; i++) {
|
|
if (worker.exitCode !== null) throw new Error(`worker startup failed: ${output.slice(-5000)}`);
|
|
try { if ((await fetch(`${base}/ui/`, { signal: AbortSignal.timeout(1000) })).ok) return; } catch {}
|
|
await delay(100);
|
|
}
|
|
throw new Error('worker did not start');
|
|
}
|
|
await start();
|
|
browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
|
|
const context = await browser.newContext({ locale: 'ja-JP', serviceWorkers: 'block' });
|
|
context.setDefaultTimeout(15000);
|
|
const external = [];
|
|
await context.route('**/*', route => {
|
|
const origin = new URL(route.request().url()).origin;
|
|
if ([base, canary].includes(origin)) return route.continue();
|
|
external.push(origin); return route.abort();
|
|
});
|
|
await context.routeWebSocket('**/*', route => {
|
|
if (new URL(route.url()).origin.replace(/^ws/, 'http') === base) route.connectToServer();
|
|
else { external.push(new URL(route.url()).origin); route.close(); }
|
|
});
|
|
const page = await context.newPage();
|
|
await page.goto(`${base}/ui/?page=settings§ion=egress-policy`);
|
|
const mode = page.getByRole('combobox', { name: 'ブラウザの通信', exact: true });
|
|
const observe = page.getByRole('checkbox', { name: 'サーバー通信の接続先を記録', exact: true });
|
|
const connectOrigins = page.getByRole('textbox', { name: '接続の許可先', exact: true });
|
|
await mode.waitFor();
|
|
assert.equal(await mode.inputValue(), 'off');
|
|
const pid = worker.pid;
|
|
async function save() {
|
|
const response = page.waitForResponse(r => r.url() === `${base}/api/config` && r.request().method() === 'PUT');
|
|
await page.getByRole('button', { name: '保存して反映', exact: true }).click();
|
|
assert.equal((await response).status(), 200, 'settings save succeeds');
|
|
assert.equal(worker.pid, pid, 'settings apply without restarting worker');
|
|
}
|
|
async function attempt() { return page.evaluate(url => fetch(url).then(r => r.ok, () => false), canary); }
|
|
await mode.selectOption('report-only'); await observe.check(); await save();
|
|
assert((await fetch(`${base}/ui/`)).headers.get('content-security-policy-report-only')?.includes('connect-src'));
|
|
await page.reload(); assert.equal(await attempt(), true, 'report-only does not block');
|
|
assert(output.includes('Node destination observation enabled'), 'Node monitoring enabled live');
|
|
await mode.selectOption('enforce'); await save();
|
|
await page.reload(); const beforeBlocked = hits;
|
|
assert.equal(await attempt(), false, 'enforce blocks unlisted destination');
|
|
assert.equal(hits, beforeBlocked, 'blocked request never reaches destination');
|
|
await connectOrigins.fill(canary); await save();
|
|
await page.reload(); assert.equal(await attempt(), true, 'adding an allowed origin works');
|
|
const onDisk = await readFile(config, 'utf8');
|
|
assert(onDisk.includes('observe_node: true') && onDisk.includes('mode: enforce'), 'settings persisted to YAML');
|
|
await stop(); await start(); await page.reload();
|
|
assert.equal(await mode.inputValue(), 'enforce');
|
|
assert.equal(await connectOrigins.inputValue(), canary);
|
|
assert.equal(await observe.isChecked(), true);
|
|
assert.equal(await attempt(), true, 'saved policy survives restart');
|
|
const locked = join(temp, 'external.json');
|
|
await writeFile(locked, JSON.stringify({ version: 1, observeNode: false, browser: { mode: 'report-only', connectOrigins: [], imageOrigins: [], frameOrigins: [] } }));
|
|
await stop(); await start({ AAO_EGRESS_POLICY: locked }); await page.reload();
|
|
assert.equal(await mode.inputValue(), 'report-only');
|
|
assert.equal(await mode.isDisabled(), true); assert.equal(await observe.isDisabled(), true); assert.equal(await connectOrigins.isDisabled(), true);
|
|
const beforeWrite = await readFile(config, 'utf8');
|
|
const rejected = await fetch(`${base}/api/config`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ egress: { browser: { mode: 'off' } } }) });
|
|
assert.equal(rejected.status, 400, 'generic API cannot bypass external lock');
|
|
assert.equal(await readFile(config, 'utf8'), beforeWrite);
|
|
assert.deepEqual(external, []);
|
|
await context.close();
|
|
console.log('Settings UI: live report/enforce/allowlist, Node monitoring, persistence, external lock passed');
|
|
} finally {
|
|
await stop(); await browser?.close();
|
|
for (const server of servers) { server.closeAllConnections(); server.close(); }
|
|
await rm(temp, { recursive: true, force: true });
|
|
}
|