Compare commits

..
Author SHA1 Message Date
jason.woltje ac63a187e8 Merge walkthrough fixes and LAN dev bind (org-stage) into refactor
org-walkthrough-fix 60f3b3d78 (Sol APPROVE 0/0/0) and org-lan-bind 95a5f7072 (Sol APPROVE 0/0/1) via org-stage 265f1767c.
2026-11-08 21:56:43 -06:00
jason.woltje 265f1767c0 Merge org-lan-bind into org-stage 2026-11-08 21:27:33 -06:00
jason.woltje 95a5f7072d Document the console LAN flags and console status 2026-11-08 21:13:56 -06:00
jason.woltje 665a5148d4 Open the dev owner Console to allowed LAN names
mosaic console start --dev-local-owner --dev-insecure-bind ADDR
--dev-allow-host NAME[,NAME...] binds 0.0.0.0 or an IPv4 address and
answers only NAME:<bound port> plus the loopback names. Both flags are
refused without the dev owner, the bind without an allowed name, and a
specific ADDR that is not itself a name. Start and console status print
the no-sign-in warning; the state file records bind and allowHosts.

serve.mjs takes devAllowHosts only with the dev owner; SSO, preview and
tester listeners stay loopback. Writes need Origin http://<same Host>.
Chromium sends no Fetch Metadata from a LAN http origin, so for an
allowed name a write with that exact Origin and no Sec-Fetch-Site counts
as same-origin; an explicit header is still judged by the routes.
chat.js builds its operation keys without crypto.randomUUID, which an
insecure context lacks.
2026-11-08 21:13:53 -06:00
jason.woltje 60f3b3d78f Offer New organization in the header menu, picker and empty state
Once organizations existed, Quick Setup was reachable only through
Settings > Organizations > type an ID > New organization. This tree
has no header organization switcher, so the owner of a Console with
organization setup now gets "New organization" in the header avatar
menu, under the Organization picker on Settings > Organization, and
next to the Settings link in the no-organization empty state. Each
opens #/settings/organizations?new=1, which focuses the ID field and
then drops the flag. The form itself is unchanged.

The new browser test covers the menu at 1400 and 390px, the picker
and the empty state. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:46 -06:00
jason.woltje 4b40c7f57b Keep project crumbs evenly spaced for short project names
"Projects / work /  / Conversations / init" had no missing label.
Every project crumb link had min-width:44px, so a short name such as
"work" was padded with blank space after its text, and the separators
around it read as an empty crumb. The 44px target width now comes
from an ::after overlay centred on the link, so the layout box fits
the text on every project route.

The new browser test measures text-to-separator gaps and hit width
on the overview, conversations and conversation routes at 1440 and
390px. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:40 -06:00
jason.woltje a1491a0163 Give organization Consoles a request read so request status resolves
The organization options in cli.mjs carried no requestRead, so
/api/orgs/<id>/config never offered requests and the project and
conversation pages counted "requests not offered" as a failed read:
"Request status unavailable." on a conversation whose message went
through. The owner now reads request status over the organization's
own bus socket, as the root Console does. Request writes stay off,
per the README Console actions inventory.

The new browser test runs the real cli.mjs serve --dev-local-owner
against a real organization record with only the bus transports
shimmed. It fails on 6ca7f3b65 and passes here.
2026-11-08 20:58:35 -06:00
19 changed files with 701 additions and 33 deletions

No files matched your search

+5 -1
View File
@@ -302,6 +302,8 @@ scripts/mosaic inbox --read <id>... | --read-all [--business <id>]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -320,6 +322,8 @@ scripts/bus-service.sh [--instance NAME] uninstall | status <business>
For local development on a named instance only, `mosaic bus start BUSINESS --dev-local-owner` admits the fixed local-dev Console owner and refuses the default instance. Launches are enabled unless `--no-launch` is supplied; `--pm` can start the business's PM. Launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only. Start the matching Console with `--dev-local-owner`; the usual Console wrapper, process proof and nonce checks still apply. This mode has no sign-in and is for local development only.
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
selected configuration's data root. The bus stays up for reads and human
@@ -1046,7 +1050,7 @@ including `console.sso` (exit 2 if missing). Without `--send` the unit is
read-only. The template is
`packages/webui/systemd/mosaic-webui.service.in`.
For direct local development (not the service renderer), `node packages/webui/src/cli.mjs serve --dev-local-owner --send` uses the owner without sign-in. It requires `MOSAIC_CONFIG` naming a non-default `~/.config/mosaic-NAME/config.json`, listens only on 127.0.0.1, and requires an exact local Host and same-origin writes. It refuses SSO, preview, proof, and tester flags. Use an absolute path to the Console CLI so the bus can verify its process path. Start the named instance bus with `--dev-local-owner` as above for decision and request operations. Anyone able to reach the local listener acts as the owner.
For direct local development (not the service renderer), `node packages/webui/src/cli.mjs serve --dev-local-owner --send` uses the owner without sign-in. It requires `MOSAIC_CONFIG` naming a non-default `~/.config/mosaic-NAME/config.json`, listens on 127.0.0.1 unless `--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens it to the LAN as described for `mosaic console start` above, and requires an exact local or allowed Host and same-origin writes. It refuses SSO, preview, proof, and tester flags. Use an absolute path to the Console CLI so the bus can verify its process path. Start the named instance bus with `--dev-local-owner` as above for decision and request operations. Anyone able to reach the local listener acts as the owner.
Under `--send` the Console's Docs page (row 148) can also save an existing
spec/north-star.md or docs/NAME.md of a project: one local commit authored by the
+17 -6
View File
@@ -28,7 +28,7 @@ import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
import { readNotifyConfig } from "./notify-config.mjs";
export { readNotifyConfig } from "./notify-config.mjs";
import { DEV_OWNER, devOwnerInstance } from "./dev-local-owner.mjs";
import { DEV_OWNER, devOwnerInstance, devLanOptions } from "./dev-local-owner.mjs";
import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../business/src/index.mjs";
@@ -41,7 +41,7 @@ import { readSessions, stopSession } from "../../seat/src/session.mjs";
import { SeatError } from "../../seat/src/seat.mjs";
import { OrgError, resolveOrg, withOrgLifecycle } from "../../business/src/org-registry.mjs";
import { busStart, busStatus, busStop, busRestart, busRecover } from "./org-bus.mjs";
import { consoleStart, consoleStop } from "./org-console.mjs";
import { consoleStart, consoleStatus, consoleStop } from "./org-console.mjs";
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
@@ -83,8 +83,8 @@ export const USAGE = `usage:
mosaic bus start <id> [--pm | --no-launch] [--dev-local-owner] [--detach]
mosaic bus stop|restart|recover <id>
mosaic bus status <id> [--json]
mosaic console start [--dev-local-owner] [--detach] [--port auto|N]
mosaic console stop
mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
mosaic console stop|status
${DEV_USAGE}
${RUN_USAGE}`;
@@ -555,10 +555,21 @@ async function consoleCommand(rest, io, deps) {
io.stdout.write(`${JSON.stringify(await consoleStop(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
if (sub === "status") {
if (args.length) throw usage("console status takes no arguments");
io.stdout.write(`${JSON.stringify(consoleStatus(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
if (sub !== "start") throw usage();
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port"] });
const parsed = parse(args, { flags: ["--dev-local-owner", "--detach"], values: ["--port", "--dev-insecure-bind", "--dev-allow-host"] });
if (parsed.positional.length) throw usage("console start takes no positional arguments");
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: parsed.flags.has("--dev-local-owner"), detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", warn: s => io.stderr.write(s) });
// org-lan-bind: refused here, before any config is read, unless both flags come with
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
let lan;
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
const code = await receipt.done;
+30
View File
@@ -1,4 +1,5 @@
import { realpathSync } from 'node:fs';
import { isIP } from 'node:net';
import { homedir } from 'node:os';
import { join, resolve } from 'node:path';
import { configDir } from '../../business/src/business.mjs';
@@ -17,3 +18,32 @@ export function devOwnerInstance(env = process.env) {
catch (err) { if (err.message === refusal) throw err; if (err.code !== 'ENOENT') throw new Error(refusal); }
return match[1];
}
// org-lan-bind: a name for --dev-allow-host is lowercase DNS labels or an IPv4 address, with no
// port; the Console then accepts the Host NAME:<bound port>.
const HOST_LABEL = /^(?!-)[a-z0-9-]{1,63}(?<!-)$/;
export function devHostName(name) {
if (typeof name !== 'string' || !name || name.length > 253) return false;
if (isIP(name) === 4) return true;
return !/^[0-9.]+$/.test(name) && name.split('.').every(label => HOST_LABEL.test(label));
}
// --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]: the dev owner Console on the LAN with
// no sign-in. `names` holds the raw --dev-allow-host values. Returns null when neither flag is
// given, else { bind, allowHosts }; throws the refusal otherwise.
export function devLanOptions({ devOwner = false, bind = null, names = [] } = {}) {
if (bind === null || bind === undefined) {
if (names.length) throw new Error('--dev-allow-host needs --dev-insecure-bind');
return null;
}
if (!devOwner) throw new Error('--dev-insecure-bind needs --dev-local-owner');
if (typeof bind !== 'string' || isIP(bind) !== 4) throw new Error('--dev-insecure-bind must be 0.0.0.0 or an IPv4 address');
if (!names.length) throw new Error('--dev-insecure-bind needs at least one --dev-allow-host');
const allowHosts = [];
for (const name of names.flatMap(value => typeof value === 'string' ? value.split(',') : [value])) {
if (!devHostName(name)) throw new Error('--dev-allow-host takes host names or IPv4 addresses without a port, comma-separated');
if (!allowHosts.includes(name)) allowHosts.push(name);
}
// The printed URL must pass the Host check, so a specific address is also an allowed name.
if (bind !== '0.0.0.0' && bind !== '127.0.0.1' && !allowHosts.includes(bind)) throw new Error('--dev-insecure-bind ADDR other than 0.0.0.0 must also be a --dev-allow-host name');
return { bind, allowHosts };
}
+34 -6
View File
@@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../business/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
import { CliError } from './errors.mjs';
import { devLanOptions } from './dev-local-owner.mjs';
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
@@ -25,8 +26,14 @@ function state(ctx) {
try { data = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('console state is unreadable'); }
} finally { closeSync(fd); }
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !data.startTime || typeof data.url !== 'string') fail('console state is invalid');
// org-lan-bind: a LAN-exposed Console records where it listens and the names it answers.
if (data.bind !== undefined || data.allowHosts !== undefined) {
try { if (!Array.isArray(data.allowHosts) || data.allowHosts.some(name => typeof name !== 'string' || name.includes(',')) || !devLanOptions({ devOwner: true, bind: data.bind, names: data.allowHosts })) throw new Error(); }
catch { fail('console state is invalid'); }
}
return data;
}
const lanWarning = (bind, port) => `WARNING: dev owner Console listens on ${bind}:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
function checkDir(ctx) {
mkdirSync(directory(ctx), { recursive: true, mode: 0o700 });
const st = lstatSync(directory(ctx));
@@ -57,9 +64,13 @@ function takeLock(ctx) {
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', warn = s => process.stderr.write(s) } = {}) {
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
const release = takeLock(ctx);
let child = null, done = null;
try {
@@ -68,7 +79,8 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
const org = (await listOrgs(ctx)).find(x => x.state === 'active');
if (org) await resolveOrg(ctx, org.id);
// The pre-S5 Console still accepts a business preselection.
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : [])];
const args = [WEBUI, 'serve', '--port', String(value), ...(org ? ['--business', org.id] : []), ...(devLocalOwner ? ['--dev-local-owner'] : []),
...(lan ? ['--dev-insecure-bind', lan.bind, '--dev-allow-host', lan.allowHosts.join(',')] : [])];
const log = join(directory(ctx), `console-${process.pid}-${Date.now()}.log`);
const fd = openSync(log, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
try { child = spawn(process.execPath, args, { env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd] }); }
@@ -78,14 +90,18 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
const text = readFileSync(log, 'utf8');
const match = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(text);
// The URL is loopback, or the specific address a LAN Console binds.
const match = /Mosaic Console: (http:\/\/([0-9.]+):(\d+)\/)/.exec(text);
if (match) {
if (forbidden.has(Number(match[2]))) fail('console selected a forbidden port');
if (match[2] !== '127.0.0.1' && match[2] !== lan?.bind) fail('console printed an unexpected address');
if (forbidden.has(Number(match[3]))) fail('console selected a forbidden port');
const startTime = startTimeOf(child.pid);
if (!startTime) fail('console exited during startup');
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1] }) + '\n', { mode: 0o600, flag: 'wx' });
writeFileSync(stateFile(ctx), JSON.stringify({ pid: child.pid, startTime, url: match[1], ...(lan ?? {}) }) + '\n', { mode: 0o600, flag: 'wx' });
if (detach) child.unref();
const result = { url: match[1], pid: child.pid };
// The child's stderr goes to the log; the person who started it sees the warning here.
if (lan) warn(lanWarning(lan.bind, match[3]));
const result = { url: match[1], pid: child.pid, ...(lan ?? {}) };
Object.defineProperty(result, 'done', { value: done });
return result;
}
@@ -121,3 +137,15 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
fail('console did not stop');
} finally { release(); }
}
// Read-only: what the state file says and whether that process still runs. It never moves a
// stale state file (start and stop do that under the start lock).
export function consoleStatus(ctx, { warn = s => process.stderr.write(s) } = {}) {
const s = state(ctx);
if (!s) return { running: false };
const running = liveConsole(s);
const bind = s.bind ?? '127.0.0.1';
const port = /:(\d+)\/$/.exec(s.url)?.[1] ?? 'unknown';
// The same warning start printed, for as long as a Console started with the LAN flags runs.
if (running && s.bind) warn(lanWarning(bind, port));
return { running, ...(running ? {} : { stale: true }), pid: s.pid, url: s.url, bind, ...(s.allowHosts ? { allowHosts: s.allowHosts } : {}) };
}
+84 -3
View File
@@ -1,13 +1,13 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
import { get } from 'node:http';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
@@ -49,3 +49,84 @@ test('mosaic console start and stop dispatch through the CLI', { timeout: 60000
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
});
// org-lan-bind: --dev-insecure-bind and --dev-allow-host. The Console binds 0.0.0.0 and the test
// reaches it on 127.0.0.1 with an allowed Host name, as a LAN browser would.
const hostGet = (port, host) => new Promise((resolve, reject) => {
request({ host: '127.0.0.1', port, path: '/', headers: { host } }, res => { res.resume(); resolve(res.statusCode); }).on('error', reject).end();
});
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 's1test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-s1test'), dataRoot: join(home, '.mosaic-s1test'), instance: 's1test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(async () => { try { await consoleStop(ctx); } catch {} rmSync(home, { recursive: true, force: true }); });
const mosaic = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env, encoding: 'utf8', timeout: 30000 });
return { ctx, mosaic };
}
test('a LAN console warns, records its bind and answers only its allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'], warn: s => warnings.push(s) });
const url = new URL(result.url), port = url.port;
assert.equal(url.hostname, '127.0.0.1');
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(port)));
const warning = `WARNING: dev owner Console listens on 0.0.0.0:${port} with no sign-in; anyone who can reach it acts as the owner.\n`;
assert.deepEqual(warnings, [warning]);
assert.equal(result.bind, '0.0.0.0'); assert.deepEqual(result.allowHosts, ['lan.test', '10.1.10.37']);
const saved = JSON.parse(readFileSync(join(ctx.dataRoot, 'console/console.json'), 'utf8'));
assert.deepEqual({ url: saved.url, bind: saved.bind, allowHosts: saved.allowHosts }, { url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
// The serve process prints the same warning on its own stderr, which the Console log keeps.
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^console-\d+-\d+\.log$/.test(x));
assert.equal(logs.length, 1);
assert.ok(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8').includes(warning), 'serve log has the warning');
assert.equal(await hostGet(port, `lan.test:${port}`), 200);
assert.equal(await hostGet(port, `10.1.10.37:${port}`), 200);
assert.equal(await hostGet(port, `127.0.0.1:${port}`), 200);
assert.equal(await hostGet(port, `other.test:${port}`), 403);
assert.equal(await hostGet(port, `lan.test:${Number(port) + 1}`), 403);
const statusWarnings = [];
const status = consoleStatus(ctx, { warn: s => statusWarnings.push(s) });
assert.deepEqual(status, { running: true, pid: result.pid, url: result.url, bind: '0.0.0.0', allowHosts: ['lan.test', '10.1.10.37'] });
assert.deepEqual(statusWarnings, [warning]);
const cli = mosaic(['console', 'status']);
assert.equal(cli.status, 0, cli.stderr);
assert.deepEqual(JSON.parse(cli.stdout), status);
assert.equal(cli.stderr, warning);
assert.equal((await consoleStop(ctx)).stopped, true);
assert.deepEqual(consoleStatus(ctx), { running: false });
// A state file whose LAN fields do not validate is refused, not trusted.
for (const bad of [{ bind: '0.0.0.0' }, { bind: '0.0.0.0', allowHosts: [] }, { bind: '0.0.0.0', allowHosts: ['a.test,b.test'] }, { bind: 'lan.test', allowHosts: ['lan.test'] }, { allowHosts: ['lan.test'] }]) {
writeFileSync(join(ctx.dataRoot, 'console/console.json'), JSON.stringify({ pid: 2147483647, startTime: '1', url: 'http://127.0.0.1:1/', ...bad }), { mode: 0o600 });
assert.throws(() => consoleStatus(ctx), /console state is invalid/, JSON.stringify(bad));
}
rmSync(join(ctx.dataRoot, 'console/console.json'));
});
test('mosaic console start refuses the LAN flags without the dev owner and allowed names', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
for (const [args, reason] of [
[['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/],
[['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', '10.1.10.37', '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'a.test', '--dev-allow-host', 'b.test'], /--dev-allow-host needs one value/],
]) {
const r = mosaic(['console', 'start', '--detach', '--port', 'auto', ...args]);
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, reason, args.join(' '));
assert.doesNotMatch(r.stderr, /WARNING: dev owner/);
assert.equal(r.stdout, '');
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false, args.join(' '));
}
await assert.rejects(consoleStart(ctx, { detach: true, bind: '0.0.0.0', allowHosts: ['lan.test'] }), /--dev-insecure-bind needs --dev-local-owner/);
assert.equal(existsSync(join(ctx.dataRoot, 'console')), false);
const none = mosaic(['console', 'status']);
assert.equal(none.status, 0, none.stderr);
assert.deepEqual(JSON.parse(none.stdout), { running: false });
assert.equal(mosaic(['console', 'status', '--json']).status, 4);
});
+3
View File
@@ -1333,3 +1333,6 @@ state, dead writer lock and inactive socket. It never sends a signal. Retry
`mosaic console start` and `mosaic console stop` move a dead or mismatched
Console state to `console.json.stale-<UTC timestamp>-<suffix>`, report the
move on stderr, and continue. A live matching Console blocks another start.
`mosaic console status` prints the recorded state (pid, URL, `bind`, and
`allowHosts` for a LAN Console) and whether that process still runs. It moves
nothing. `--dev-insecure-bind` and `--dev-allow-host` are in docs/TOOLS.md.
+23 -5
View File
@@ -18,18 +18,19 @@ import { showOrg } from '../../business/src/org-service.mjs';
import { configDir } from '../../business/src/business.mjs';
import { consoleReads, redactor } from './reads.mjs';
import { createDevOwner } from './dev-owner.mjs';
import { devOwnerInstance } from '../../cli/src/dev-local-owner.mjs';
import { devOwnerInstance, devLanOptions } from '../../cli/src/dev-local-owner.mjs';
import { createSso, readSlot } from './sso.mjs';
import { statSync } from 'node:fs';
import { isAbsolute } from 'node:path';
import { ID_PATTERN } from '../../business/src/vocabulary.mjs';
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
const options = { board: DEFAULT_BOARD, port: 7330 }, preview = {}, proofFlags = {};
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false;
let sendOn = false, ssoOn = false, testerSocket = false, devOwner = false, devBind = null;
const devNames = [];
while (args.length) {
const flag = args.shift();
// Row 76 (#1570): the one flag without a value.
@@ -63,9 +64,16 @@ try {
else if (flag === '--allow-user') preview.allowUser = value;
else if (flag === '--proof-header') proofFlags.proofHeader = value;
else if (flag === '--proof-file') proofFlags.proofFile = value;
// org-lan-bind: the dev owner on the LAN, no sign-in. Both flags or neither, and only with
// --dev-local-owner (devLanOptions below, before any config is read).
else if (flag === '--dev-insecure-bind') {
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
devBind = value;
} else if (flag === '--dev-allow-host') devNames.push(value);
else throw new Error(`unknown option: ${flag}`);
}
if (devOwner && (ssoOn || testerSocket || Object.keys(preview).length || Object.keys(proofFlags).length)) throw new Error('--dev-local-owner cannot be combined with --sso, preview, proof or --tester-socket');
const lan = devLanOptions({ devOwner, bind: devBind, names: devNames });
const instance = devOwner ? devOwnerInstance() : null;
// The local-development console command has no separate --send flag.
if (devOwner) sendOn = true;
@@ -130,13 +138,16 @@ try {
return {
...consoleBus(call, { send: sendOn, ...(session.user === undefined ? {} : { owner }) }),
decisionWrite: session.user === undefined && sendOn ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
// The owner reads request status on the selected organization's bus, as on the root Console. Request
// writes stay off: the README "Console actions" inventory marks them unavailable for the organizations release.
requestRead: session.user === undefined ? consoleCall({ dataRoot: root, socket: paths.socket, business: id }) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
}) : null;
let server;
try {
server = await startServer({ board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
server = await startServer({ ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
let testerServer = null;
if (testerSocket) {
@@ -162,8 +173,15 @@ try {
const health = sso?.health() ?? {};
const ssoLines = sso && !devOwner ? `\nSign-in: ${sso.publicOrigin}/ (--sso), ${send ? 'reads and human writes (--send), grant Approve and Deny' : 'read-only'}` + sso.summary().map(p => `\nSign-in provider: ${p.id} issuer ${p.issuer} subjects ${p.subjects} (${health[p.id]})`).join('') : '';
const queueLine = options.checkout ? '\nQueue: read from the --checkout directory' : '';
const port = server.address().port;
if (devOwner) console.log(`DEV LOCAL OWNER MODE: no sign-in, every local request acts as the owner (named instance ${instance})`);
console.log(`Mosaic Console: http://127.0.0.1:${server.address().port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
// 0.0.0.0 is reachable on loopback too; a specific address only at that address.
const urlHost = !lan || lan.bind === '0.0.0.0' ? '127.0.0.1' : lan.bind;
if (lan) {
console.error(`WARNING: dev owner Console listens on ${lan.bind}:${port} with no sign-in; anyone who can reach it acts as the owner.`);
console.log(`Listening: ${lan.bind}:${port}, allowed Host names ${lan.allowHosts.map(name => `${name}:${port}`).join(', ')}`);
}
console.log(`Mosaic Console: http://${urlHost}:${port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+7 -2
View File
@@ -227,6 +227,8 @@
const blockChip = text => `<span class="tag tag-block">${txt(text)}</span>`;
// An empty state says what would appear and where it comes from.
const empty = (what, from) => `<div class="empty"><b>${what}</b>${from ? `<p>${from}</p>` : ''}</div>`;
// "New organization" everywhere it is offered: the Organizations form with the ID field focused (org-create.js reads ?new=1).
const NEW_ORG = '#/settings/organizations?new=1';
const taskLink = ref => ref ? `<a href="#/tasks/${enc(ref)}">#${esc(String(ref).split('/')[1])}</a>` : '';
// Rows without an actor role come from the human (CLI outside any run, or the signed-in Console)
// or from the broker itself.
@@ -579,7 +581,7 @@
let html;
if (organizationError) html = `<div class="banner err" role="alert"><p>${txt(organizationError)}</p>${retry}</div>`;
else if (invalidOrganizations.length) html = `<div class="banner err" role="alert">${invalidOrganizations.map(row => `<p>${txt(row.id)}: invalid — ${txt(row.reason)}</p>`).join('')}${retry}</div>`;
else if (noOrganizations && err?.code === 'org-unavailable') html = empty('No organizations yet.', 'Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.');
else if (noOrganizations && err?.code === 'org-unavailable') html = `<div class="empty"><b>No organizations yet.</b><p>Create one in <a href="#/settings/organizations">Settings &gt; Organizations</a>.</p><p><a class="btn" href="${NEW_ORG}">New organization</a></p></div>`;
else if (err && (refusal(err) || !r)) html = `<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows no held roles rather than a guess.</p>${retry}</div>`;
else if (!r) return; // not read yet: the skeleton stays
else {
@@ -708,7 +710,8 @@
async function businessView(get) {
const b = (await get.api('business')).business || {};
const orgs = window.MosaicOrg ? await window.MosaicOrg.list : null;
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select></section>` : '';
if (orgs) await configRead; // orgSetup decides the "New organization" link
const picker = orgs ? `<section class="panel" aria-labelledby="org-picker-title"><h2 id="org-picker-title">Organization</h2><label for="org-picker">Current organization</label><select id="org-picker">${orgs.map(org => `<option value="${esc(org.id)}"${org.id === window.MosaicOrg.id ? ' selected' : ''}${org.invalid ? ' disabled' : ''}>${txt(org.label || org.id)}${org.invalid ? ' · Invalid' : org.busHealthy ? '' : ' · Bus unavailable'}</option>`).join('')}</select>${orgSetup ? `<p><a href="${NEW_ORG}">New organization</a></p>` : ''}</section>` : '';
const instances = Array.isArray(b.instances) ? b.instances : [], creds = Array.isArray(b.credentials) ? b.credentials : [], actions = Array.isArray(b.actions) ? b.actions : [];
const launch = b.launch || {};
const authKnown = instances.some(i => i.authority);
@@ -1657,6 +1660,8 @@
canSend = !isTester && c?.send === true;
chatSend = !isTester && c?.chatSend === true;
orgSetup = !isTester && c?.orgSetup === true;
// The header menu offers "New organization" to the owner of a Console with organizations; nobody else can create one.
if (orgSetup && !$('org-new-link')) { const a = document.createElement('a'); a.id = 'org-new-link'; a.href = NEW_ORG; a.setAttribute('role', 'menuitem'); a.textContent = 'New organization'; document.querySelector('#avatar-menu > a[href="#/settings"]')?.after(a); }
ssoOn = c?.sso === true;
canDecide = !isTester && c?.decide === true;
if (isTester) {
+11 -2
View File
@@ -2,6 +2,15 @@
(() => {
'use strict';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
// crypto.randomUUID exists only in a secure context; a dev owner Console opened by LAN name
// (http://NAME:PORT/) is not one, so build the same v4 form from getRandomValues there.
const opId = () => {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40; b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map(x => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
};
const esc = value => String(value ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
const inert = value => String(value ?? '').replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f\u202a-\u202e\u2066-\u2069]/g,
c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
@@ -982,7 +991,7 @@
} else if (event.target.id === 'chat-session-form') {
const key = 'session:' + current.id, f = form(key);
// Keep an ambiguous create's key and payload; editing requires a deliberate new attempt.
f.op ??= crypto.randomUUID();
f.op ??= opId();
f.payload ??= { name: f.name, op: f.op };
void write('createSession', key, f.payload, `threads/${current.id}/sessions`);
} else if (event.target.id === 'chat-operation-form') {
@@ -1091,7 +1100,7 @@
if (!window.confirm(`Clear context for "${selected.name}"? This ends this session after a safe stop and starts fresh context. History and the old checkout (including unpushed work) are retained. The successor uses a fresh approved checkout; old work is not copied. This can wait for safe stop or capacity.`)) return;
// A lost answer retains this key. Only status reads are allowed afterward.
if (f.op && !['cancelled', 'failed'].includes(f.record?.state)) { await checkClear(c.id, key, f); return; }
f.op = crypto.randomUUID(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
f.op = opId(); f.pending = true; f.receipt = `Operation ${f.op}: requesting clear.`; paint();
try {
const r = await request(`threads/${c.id}/clear`, { session: selected.id, revision: selected.revision, confirm: true, op: f.op });
if (out()) return;
+4 -1
View File
@@ -620,9 +620,12 @@
if (!alive) return;
canWrite = (config.orgSetup === true || config.send === true) && config.tester !== true && config.user !== true;
root.querySelectorAll('[data-write]').forEach(el => { el.disabled = !canWrite; });
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
// ?new=1 ("New organization" in the header menu, the Organization picker and the empty state): focus the ID field, then drop
// the flag so the same link focuses it again next time. This runs after the page heading took the navigation focus.
if (params.get('new') === '1' && !org) { $('org-new-id').focus(); setQuery(null, null); }
await refreshList();
// Resume: ?org=ID reopens the organization and its setup, read back from the server.
const params = query(), org = params.get('org'), setup = params.get('step') === 'setup', created = params.get('created') === '1';
if (org && ID.test(org)) {
await openExisting(org, { toSetup: setup });
if (created && st.setup?.notifications?.receipt) showReceipt('Organization created', st.setup.notifications);
+3 -1
View File
@@ -81,7 +81,9 @@
.room-chip[aria-pressed=true] { background:var(--nav-active); border-color:var(--nav-active-border); color:var(--strong); }
.project-card-icon svg,.project-header-icon svg,.project-tree-row svg { width:55%; height:55%; fill:none; stroke:currentColor; stroke-width:1.7; stroke-linejoin:round; }
.project-tree-row svg { width:16px; height:16px; flex:none; margin-right:6px; }
.breadcrumbs:has(.project-crumb-current) a { display:inline-flex; align-items:center; min-width:44px; min-height:44px; }
.breadcrumbs:has(.project-crumb-current) a { position:relative; display:inline-flex; align-items:center; min-height:44px; }
/* The 44px target width comes from an overlay, not the layout box: a short name such as "work" must not leave blank space that reads as an empty crumb. */
.breadcrumbs:has(.project-crumb-current) a::after { content:''; position:absolute; top:0; bottom:0; left:50%; width:max(100%,44px); transform:translateX(-50%); }
.chat-area:has(.project-header),.chat-area:has(.project-list-head) { max-width:none; margin-inline:0; }
.project-tabs { scrollbar-width:none; } .project-tabs::-webkit-scrollbar { display:none; }
@media (max-width:699px) { .project-tabs { mask-image:linear-gradient(to right, #000 calc(100% - 22px), transparent 100%); } }
+21 -4
View File
@@ -18,6 +18,7 @@ import { DECISION_ROUTE, decisionHTTP, decisionRefuse, createConfirmations } fro
import { REQUEST_ROUTE, requestHTTP, requestRefuse } from './request-http.mjs';
import { PROJECT_REMOTE_ROUTE, projectRemoteHTTP } from './project-remote-http.mjs';
import { orgAdminHTTP, defaultOrgAdmin } from './org-admin.mjs';
import { devHostName } from '../../cli/src/dev-local-owner.mjs';
export const DEFAULT_BOARD = 'http://127.0.0.1:7331';
export function isLoopback(host) {
@@ -691,9 +692,15 @@ async function libraryStatusRead(res, read, search) {
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
// tests; the field table is otherwise the committed packages/tasks/fields.json.
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, log = line => console.log(line), fieldsFile = undefined } = {}) {
if (sso?.devOwner && host !== '127.0.0.1') throw new Error('dev local owner must bind 127.0.0.1');
if (!isLoopback(host)) throw new Error('refusing to bind to non-loopback host');
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined } = {}) {
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
if (!Array.isArray(devAllowHosts) || !devAllowHosts.every(devHostName)) throw new Error('dev allowed hosts must be host names or IPv4 addresses without a port');
if (devAllowHosts.length && !sso?.devOwner) throw new Error('dev allowed hosts need the dev local owner');
const devLan = Boolean(sso?.devOwner) && devAllowHosts.length > 0;
if (sso?.devOwner && host !== '127.0.0.1' && !(devLan && isIP(host) === 4)) throw new Error(devLan ? 'dev local owner binds an IPv4 address' : 'dev local owner must bind 127.0.0.1');
if (!isLoopback(host) && !devLan) throw new Error('refusing to bind to non-loopback host');
if (host === 'localhost') host = '127.0.0.1';
if (tester && preview !== null) throw new Error('tester listener cannot use preview mode');
if (tester && sso !== null) throw new Error('tester listener cannot use sso');
@@ -751,10 +758,20 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
// never legitimate, so it is refused before any check reads the Host.
if ((req.headersDistinct.host?.length ?? 0) > 1) return json(res, sso?.devOwner ? 403 : 400, { error: 'repeated Host refused' });
// Preview mode: a request for the public name, exactly, as the proxy forwards it.
const devHost = sso?.devOwner && [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`].includes(req.headers.host);
const loopHost = sso?.devOwner && [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`].includes(req.headers.host);
const lanHost = devLan && !loopHost && devAllowHosts.some(name => `${name}:${boundPort}` === req.headers.host);
const devHost = loopHost || lanHost;
if (sso?.devOwner && !devHost) return json(res, 403, { error: 'non-local Host refused' });
const localOrigin = devHost ? `http://${req.headers.host}` : null;
if (devHost && req.method !== 'GET' && req.method !== 'HEAD' && (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== localOrigin)) return json(res, 403, { error: 'cross-origin request refused' });
// Chromium sends no Sec-Fetch-* to an http origin that is not loopback (not potentially
// trustworthy), so a LAN page's writes arrive with the exact Origin checked above and no Fetch
// Metadata. Only there, and only when the header is absent, the write counts as same-origin for
// the route checks below. A Sec-Fetch-Site the browser did send is left for them to judge.
if (lanHost && req.method !== 'GET' && req.method !== 'HEAD' && !req.headersDistinct['sec-fetch-site']) {
req.headers['sec-fetch-site'] = 'same-origin';
req.headersDistinct['sec-fetch-site'] = ['same-origin'];
}
const named = devHost || (preview !== null ? (req.headers.host ?? '').toLowerCase() === preview.host : sso !== null && (req.headers.host ?? '').toLowerCase() === sso.publicHost);
// The proof precedes every auth-header read, including the wrong-Host fence.
if (proof !== null) {
+3 -2
View File
@@ -18,11 +18,12 @@ process.once('exit', () => { for (const child of live) killGroup(child); });
// Row 95 (#1589): the Board's machine sessions are off by default. Most tests read the board scan,
// so by default every page this browser opens starts with Show machine sessions on, as if the
// person had turned it on before. Pass { machine: false } to see the page as a first visit does.
export async function browser({ machine = true } = {}) {
// `args` adds Chromium flags, for example --host-resolver-rules for a test host name.
export async function browser({ machine = true, args = [] } = {}) {
const profile = await mkdtemp(join(tmpdir(), 'dewey-brand-browser-'));
const child = spawn(process.env.CHROMIUM || '/usr/bin/chromium', [
'--headless', '--no-first-run', '--no-default-browser-check',
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`,
'--disable-dev-shm-usage', '--remote-debugging-pipe', `--user-data-dir=${profile}`, ...args,
], { detached: true, stdio: ['ignore', 'ignore', 'ignore', 'pipe', 'pipe'] });
live.add(child);
let id = 0, buffer = '', exited = false;
@@ -552,6 +552,20 @@ test('264e new session keeps its operation key after an unknown response and use
assert.equal(await b.evaluate('location.hash'),`#/chat/threads/${A}/sessions/${B}`);
});
// org-lan-bind: a Console opened by LAN name is not a secure context, so crypto.randomUUID is
// absent there. A new session still gets a random v4 operation key.
test('a new session gets a v4 operation key without crypto.randomUUID', async t => {
const {b,wait,set,click,route}=await setup(t,'','delete Crypto.prototype.randomUUID');
assert.equal(await b.evaluate('typeof crypto.randomUUID'),'undefined');
await route(A); await set('#chat-session-name','new context');
await b.evaluate(`window.__answers.push({body:{id:'${B}'}});window.__sessions['${A}']=[{id:'${A}',name:'default',default:true},{id:'${B}',name:'new context'}];true`);
await click('#chat-session-form button');
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
const write=await b.evaluate('window.__writes[0]');
assert.equal(write.path, `/api/chat/threads/${A}/sessions`);
assert.match(write.body.op,/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
});
test('264e cancel shows before/after retirement and never starts or replays work', async t => {
const {b,wait,set,click,route}=await setup(t);
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}];window.confirm=()=>true;true`);
+136
View File
@@ -0,0 +1,136 @@
// org-lan-bind: the dev owner Console on the LAN (--dev-insecure-bind ADDR --dev-allow-host NAME).
// "Remote" requests bind 0.0.0.0 on port 0 and connect to 127.0.0.1 with a LAN Host header.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { request } from 'node:http';
import { connect } from 'node:net';
import { fileURLToPath } from 'node:url';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { close } from './fixture.mjs';
process.chdir(fileURLToPath(new URL('../../..', import.meta.url)));
const LAN = '10.1.10.37';
function call(port, { host, method = 'GET', path = '/api/config', origin, site, body } = {}) {
const data = body === undefined ? undefined : JSON.stringify(body);
const headers = { host, ...(origin === undefined ? {} : { origin }), ...(site === undefined ? {} : { 'sec-fetch-site': site }),
...(data === undefined ? {} : { 'content-type': 'application/json', 'content-length': Buffer.byteLength(data) }) };
return new Promise((resolve, reject) => {
const req = request({ host: '127.0.0.1', port, path, method, headers }, res => {
let raw = ''; res.on('data', c => raw += c);
res.on('end', () => { let parsed = raw; try { parsed = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, type: res.headers['content-type'] ?? '', body: parsed }); });
});
req.on('error', reject); req.end(data);
});
}
// node:http will not send two Host lines, so the repeated-Host request is written by hand.
function raw(port, text) {
return new Promise((resolve, reject) => {
const socket = connect(port, '127.0.0.1', () => socket.end(text));
let out = ''; socket.on('data', c => out += c); socket.on('end', () => resolve(out)); socket.on('error', reject);
});
}
// A refusal must come before listening; a server that starts anyway is closed and fails the test.
async function refused(options, pattern) {
let server;
try { server = await startServer({ port: 0, log: () => {}, ...options }); }
catch (err) { assert.match(err.message, pattern); return; }
await close(server);
assert.fail(`startServer listened with ${JSON.stringify(Object.keys(options))}`);
}
test('a non-loopback bind is refused without the dev owner and its allowed names', async () => {
const sso = { publicHost: 'console.test', publicOrigin: 'https://console.test', session: () => null };
const preview = { host: 'console.test', header: 'x-auth', user: 'u' };
await refused({ host: '0.0.0.0' }, /refusing to bind to non-loopback host/);
await refused({ host: '0.0.0.0', devAllowHosts: [LAN] }, /dev allowed hosts need the dev local owner/);
await refused({ host: '0.0.0.0', sso: createDevOwner() }, /dev local owner must bind 127\.0\.0\.1/);
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [] }, /dev local owner must bind 127\.0\.0\.1/);
await refused({ host: 'localhost', sso: createDevOwner(), devAllowHosts: [LAN] }, /binds an IPv4 address/);
for (const name of [`${LAN}:80`, 'LAN.test', 'lan_test', '', '-lan', '1.2.3', 'http://lan.test'])
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: [name] }, /without a port/);
await refused({ host: '0.0.0.0', sso: createDevOwner(), devAllowHosts: 'lan.test' }, /without a port/);
// SSO, preview and tester listeners stay loopback-only, with or without allowed names.
for (const extra of [{ sso }, { preview }, { tester: true }]) {
await refused({ host: '0.0.0.0', ...extra }, /refusing to bind to non-loopback host/);
await refused({ host: '0.0.0.0', devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/);
await refused({ host: LAN, devAllowHosts: [LAN], ...extra }, /dev allowed hosts need the dev local owner/);
}
});
test('an allowed LAN name reads and writes with its own Origin only', async t => {
const sent = [];
const server = await startServer({ host: '0.0.0.0', port: 0, devAllowHosts: [LAN, 'lan.test'], sso: createDevOwner(), board: 'http://127.0.0.1:9',
send: async args => { sent.push(args); return { id: `m${sent.length}` }; }, log: () => {} });
t.after(() => close(server));
const port = server.address().port, host = `${LAN}:${port}`, message = { to: 'chief', body: 'hello' };
assert.equal(server.address().address, '0.0.0.0');
const shell = await call(port, { host, path: '/' });
assert.equal(shell.status, 200); assert.match(shell.type, /text\/html/);
const config = await call(port, { host });
assert.equal(config.status, 200); assert.equal(config.body.devOwner, true); assert.equal(config.body.send, true);
// A browser on a LAN http origin sends Origin but no Sec-Fetch-Site; the exact Origin is enough.
const write = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, body: message });
assert.equal(write.status, 200, JSON.stringify(write.body)); assert.equal(write.body.id, 'm1');
const named = await call(port, { host: `lan.test:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://lan.test:${port}`, body: message });
assert.equal(named.status, 200, JSON.stringify(named.body));
// A Fetch Metadata header the browser did send is still judged by the route.
const crossSite = await call(port, { host, method: 'POST', path: '/api/bus/message', origin: `http://${host}`, site: 'cross-site', body: message });
assert.equal(crossSite.status, 403); assert.equal(crossSite.body.error, 'cross-site request refused');
for (const origin of [undefined, `http://127.0.0.1:${port}`, `http://localhost:${port}`, 'http://evil.example', `http://lan.test:${port}`, `https://${host}`, `http://${LAN}:${port + 1}`, 'null']) {
const r = await call(port, { host, method: 'POST', path: '/api/bus/message', origin, site: 'same-origin', body: message });
assert.equal(r.status, 403, String(origin)); assert.equal(r.body.error, 'cross-origin request refused', String(origin));
}
for (const other of ['10.1.10.38', 'evil.example', `evil.example:${port}`, `${LAN}:${port + 1}`, LAN, `LAN.TEST:${port}`, `lan.test.:${port}`, `${LAN}:${port}.`]) {
const r = await call(port, { host: other });
assert.equal(r.status, 403, other); assert.equal(r.body.error, 'non-local Host refused', other);
}
const repeated = await raw(port, `GET /api/config HTTP/1.1\r\nHost: ${host}\r\nHost: ${host}\r\nConnection: close\r\n\r\n`);
assert.match(repeated, /^HTTP\/1\.1 403 /); assert.match(repeated, /repeated Host refused/);
// Loopback on the same server is unchanged: no Fetch Metadata stand-in there.
const loop = `127.0.0.1:${port}`;
assert.equal((await call(port, { host: loop })).status, 200);
const bare = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, body: message });
assert.equal(bare.status, 403); assert.equal(bare.body.error, 'cross-site request refused');
const loopWrite = await call(port, { host: loop, method: 'POST', path: '/api/bus/message', origin: `http://${loop}`, site: 'same-origin', body: message });
assert.equal(loopWrite.status, 200, JSON.stringify(loopWrite.body));
assert.equal(sent.length, 3);
});
test('a loopback dev owner without allowed names refuses a LAN Host', async t => {
const server = await startServer({ port: 0, sso: createDevOwner(), board: 'http://127.0.0.1:9', send: async () => ({ id: 'm1' }), log: () => {} });
t.after(() => close(server));
const port = server.address().port;
assert.equal(server.address().address, '127.0.0.1');
assert.equal((await call(port, { host: `${LAN}:${port}` })).status, 403);
const r = await call(port, { host: `${LAN}:${port}`, method: 'POST', path: '/api/bus/message', origin: `http://${LAN}:${port}`, body: { to: 'chief', body: 'x' } });
assert.equal(r.status, 403); assert.equal(r.body.error, 'non-local Host refused');
});
// The flags are refused before any configuration is read: an empty MOSAIC_CONFIG would otherwise
// fail with the named-instance refusal instead.
test('serve refuses the LAN flags unless they come together with --dev-local-owner', () => {
const run = args => spawnSync(process.execPath, ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...args], { encoding: 'utf8', env: { ...process.env, MOSAIC_CONFIG: '' }, timeout: 10000 });
for (const [args, reason] of [
[['--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-insecure-bind', '0.0.0.0'], /--dev-insecure-bind needs --dev-local-owner/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0'], /needs at least one --dev-allow-host/],
[['--dev-local-owner', '--dev-allow-host', 'lan.test'], /--dev-allow-host needs --dev-insecure-bind/],
[['--dev-local-owner', '--dev-insecure-bind', 'lan.test', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/],
[['--dev-local-owner', '--dev-insecure-bind', '::', '--dev-allow-host', 'lan.test'], /0\.0\.0\.0 or an IPv4 address/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test:7350'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test,'], /without a port/],
[['--dev-local-owner', '--dev-insecure-bind', LAN, '--dev-allow-host', 'lan.test'], /must also be a --dev-allow-host name/],
[['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /takes one address/],
[['--dev-local-owner', '--sso', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', 'lan.test'], /cannot be combined/],
]) {
const r = run(args);
assert.equal(r.status, 2, args.join(' ')); assert.match(r.stderr, reason, args.join(' '));
assert.doesNotMatch(r.stdout + r.stderr, /Mosaic Console:|WARNING: dev owner/, args.join(' '));
}
// With valid flags the next refusal is the named-instance check, so parsing accepted them.
const valid = run(['--dev-local-owner', '--dev-insecure-bind', '0.0.0.0', '--dev-allow-host', `lan.test,${LAN}`, '--dev-allow-host', 'other.lan']);
assert.equal(valid.status, 2); assert.match(valid.stderr, /--dev-local-owner requires a named instance/);
});
@@ -0,0 +1,44 @@
// org-lan-bind: a browser on another machine opens the dev owner Console by an allowed LAN name
// (http://lan.test:PORT/, mapped to 127.0.0.1 for the test) and creates a project. That origin
// is not a secure context, so Chromium sends no Fetch Metadata and has no crypto.randomUUID.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
const ID = '20000000-0000-4000-8000-000000000002';
test('an allowed LAN name loads the org page and creates a project', { timeout: 45000 }, async t => {
const projects = [], created = [];
const organizations = {
list: async () => [{ id: 'alpha', label: 'alpha', busHealthy: true }],
resolve: async id => id === 'alpha' ? {
busHealthy: true, send: async () => ({}),
reads: { business: async () => ({ business: { id, projects: ['work'] } }), busStatus: async () => ({ live: true }) },
bus: { inbox: async () => [], agents: async () => [], messages: async () => ({ messages: [] }), tasks: async () => [] },
chat: {
projects: async () => projects, threads: async () => [],
createProject: async args => { created.push(args); const row = { id: ID, ...args }; projects.push(row); return row; },
},
} : null,
};
const server = await startServer({ host: '0.0.0.0', port: 0, devAllowHosts: ['lan.test'], sso: createDevOwner(), organizations, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const port = server.address().port;
const b = await browser({ machine: false, args: ['--host-resolver-rules=MAP lan.test 127.0.0.1'] }); t.after(() => b.close());
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error(${JSON.stringify(expression)})})()`);
const input = (selector, value) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input',{bubbles:true}));e.dispatchEvent(new Event('change',{bubbles:true}));})()`);
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click()`);
await b.navigate(`http://lan.test:${port}/#/orgs/alpha/projects`);
assert.deepEqual(await b.evaluate('({ host: location.host, secure: isSecureContext, uuid: typeof crypto.randomUUID })'), { host: `lan.test:${port}`, secure: false, uuid: 'undefined' });
await wait('window.MosaicOrg?.id === "alpha" && !!document.querySelector("#chat-repo option[value=work]")');
await click('.project-create summary');
await input('#chat-project-name', 'Work'); await input('#chat-repo', 'work');
await click('#chat-project-form button[type=submit]');
await wait('/\\/projects\\/[0-9a-f-]{36}/i.test(location.hash)');
assert.equal(await b.evaluate('location.hash'), `#/orgs/alpha/projects/${ID}`);
assert.deepEqual(created, [{ name: 'Work', repo: 'work' }]);
assert.equal(await b.evaluate('location.host'), `lan.test:${port}`);
});
@@ -0,0 +1,86 @@
// Walkthrough defect 3 (Jason, 7350): once organizations exist, Quick Setup was reachable only through
// Settings > Organizations > type an ID > New organization. "New organization" is now offered in the header menu,
// in the organization picker and in the no-organization empty state. Each opens that form with the ID field focused.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { startServer } from '../src/serve.mjs';
import { createDevOwner } from '../src/dev-owner.mjs';
import { createOrgAdmin } from '../src/org-admin.mjs';
import { browser } from './browser.mjs';
import { close } from './fixture.mjs';
// An owner Console with organization setup, holding the given organizations (the org-switch-browser fixture).
async function consoleWith(t, ids) {
const makeDoc = id => ({ businessVersion: 1, id, human: 'local-dev-owner', arbiters: { delivery: 'chief', technical: 'chief' }, roles: { chief: { definition: 'chief-of-staff' } }, projects: { work: {} }, appearance: { theme: 'harbor', mode: 'dark' } });
const docs = new Map(ids.map(id => [id, makeDoc(id)]));
const missing = () => { throw Object.assign(Error('not set up'), { code: 'org-unavailable' }); };
const service = {
orgPresets: async () => [{ name: 'fixture', label: 'Test model', description: 'Canned replies', available: { ok: true }, thinking: { levels: ['off'], default: 'off' } }],
templateRender: async id => makeDoc(id), validateOrg: async () => ({ ok: true, errors: [] }),
showOrg: async (ctx, id) => ({ id, doc: docs.get(id), revision: 1, root: `/data/${id}`, principal: ctx.principal }),
notificationShow: missing, queueShow: missing, projectRepoShow: missing,
};
const registry = { listOrgs: async () => [...docs.keys()].map(id => ({ id, state: 'active' })) };
const organizations = {
list: async () => [...docs.keys()].map(id => ({ id, label: id, busHealthy: true })),
resolve: async id => docs.has(id) ? { busHealthy: true, send: async () => ({}), reads: { business: async () => ({ business: { id, projects: ['work'], appearance: docs.get(id).appearance } }), busStatus: async () => ({ live: true }) }, bus: {} } : null,
};
const orgAdmin = createOrgAdmin({ service, registry, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
const server = await startServer({ port: 0, sso: createDevOwner(), organizations, orgAdmin, send: async () => ({}), board: 'http://127.0.0.1:9', log: () => {} });
t.after(() => close(server));
const b = await browser({ machine: false }); t.after(() => b.close());
const wait = (expression, label = expression) => b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timed out: '+${JSON.stringify(label)}+'\\n'+document.body.innerText.slice(0,800))})()`);
const shown = selector => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(selector)});return !!e && getComputedStyle(e).visibility !== 'hidden' && e.getClientRects().length > 0})()`);
// The form is open, untouched, with the ID field focused, and the ?new=1 flag is gone so the link works again.
const formFocused = async (prefix, where) => {
await wait(`document.activeElement?.id === 'org-new-id'`, `${where}: ID field focused`);
assert.equal(await b.evaluate('location.hash'), `${prefix}/settings/organizations`, `${where}: the flag is dropped`);
assert.equal(await b.evaluate(`document.querySelector('#org-new-id').value`), '', `${where}: the form is not prefilled`);
assert.equal(await shown('#org-new-form [data-write]'), true, `${where}: New organization is shown`);
};
return { base: `http://127.0.0.1:${server.address().port}/`, b, wait, shown, formFocused };
}
for (const [width, height] of [[1400, 900], [390, 844]]) {
test(`the header menu offers New organization once organizations exist, at ${width}px`, { timeout: 60000 }, async t => {
const { base, b, wait, shown, formFocused } = await consoleWith(t, ['alpha']);
await b.viewport(width, height);
// From the dashboard, then again from the Organizations page itself.
for (const route of ['#/orgs/alpha/', '#/orgs/alpha/settings/organizations']) {
await b.navigate(`${base}?r=${encodeURIComponent(route)}${route}`);
await wait(`document.querySelector('#avatar-menu a#org-new-link')`, `${route}: menu entry`);
await b.evaluate(`document.querySelector('#avatar-button').click()`);
assert.equal(await shown('#org-new-link'), true, `${route}: the entry is visible in the open menu`);
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').textContent`), 'New organization');
assert.equal(await b.evaluate(`document.querySelector('#org-new-link').getAttribute('role')`), 'menuitem');
await b.evaluate(`document.querySelector('#org-new-link').click()`);
await formFocused('#/orgs/alpha', route);
assert.equal(await b.evaluate(`document.querySelector('#avatar-menu').hidden`), true, `${route}: the menu closes`);
}
});
}
test('the organization picker offers New organization', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, ['alpha', 'beta']);
await b.viewport(1400, 900);
await b.navigate(`${base}#/orgs/alpha/settings/business`);
const link = `document.querySelector('section[aria-labelledby="org-picker-title"] a[href="#/settings/organizations?new=1"]')`;
await wait(`document.querySelector('#org-picker') && ${link}`, 'picker link');
assert.equal(await b.evaluate(`${link}.textContent`), 'New organization');
await b.evaluate(`${link}.click()`);
await formFocused('#/orgs/alpha', 'picker');
});
test('the no-organization empty state offers New organization next to its Settings link', { timeout: 60000 }, async t => {
const { base, b, wait, formFocused } = await consoleWith(t, []);
await b.viewport(1400, 900);
await b.navigate(`${base}#/`);
const button = `document.querySelector('#held a.btn[href="#/settings/organizations?new=1"]')`;
await wait(button, 'empty state button');
const held = await b.evaluate(`document.querySelector('#held').textContent`);
assert.match(held, /No organizations yet/);
assert.equal(await b.evaluate(`!!document.querySelector('#held a[href="#/settings/organizations"]')`), true, 'the Settings link stays');
assert.equal(await b.evaluate(`${button}.textContent`), 'New organization');
await b.evaluate(`${button}.click()`);
await formFocused('#', 'empty state');
});
@@ -0,0 +1,130 @@
// Walkthrough defect 1 (Jason, 7350 Quick Setup): an organization conversation showed "Request status unavailable."
// although the message went through. The organization options built in cli.mjs carried no request read, so
// /api/orgs/<id>/config never offered requests and the project page counted that as a failed read.
// This runs the real cli.mjs serve --dev-local-owner against a real organization record. Only the bus
// transports cli.mjs builds are replaced (loader hook), so no bus or human proof is involved.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
import { createOrg } from '../../business/src/org-service.mjs';
import { orgPaths } from '../../cli/src/config.mjs';
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
import { browser } from './browser.mjs';
const P = '10000000-0000-4000-8000-0000000000a1', T = '20000000-0000-4000-8000-0000000000a1', S = '30000000-0000-4000-8000-0000000000a1';
// The correlation id a sent message carries. The org's request intake has no request under it.
const REQUEST = 'c0ffee00-0000-4000-8000-0000000000a1';
async function consoleFor(t) {
const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test');
mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir);
chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700);
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack';
const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc);
const log = join(root, 'calls.jsonl'); writeFileSync(log, '');
const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href;
// Each transport answers from fixed rows and logs (kind, business, socket, verb). Everything else in bus.mjs is real.
const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs');
writeFileSync(busShim, `import { appendFileSync } from 'node:fs';
import { BusReadError } from ${JSON.stringify(busURL)};
export * from ${JSON.stringify(busURL)};
const P=${JSON.stringify(P)}, T=${JSON.stringify(T)}, S=${JSON.stringify(S)}, REQUEST=${JSON.stringify(REQUEST)};
// The bus answers list verbs with bare arrays; the Console wraps them in rows.
const human = (verb, args) => ({
plugins: { plugins: { channel: ['cli', 'webui'] } },
'project.list': [{ id: P, name: 'work', repo: null }],
'thread.list': args?.shared ? [] : [{ id: T, project: P, name: 'init', instance: 'assistant' }],
'session.list': [{ id: S, name: 'Main', default: true, state: 'active', revision: 1 }],
'thread.messages': [
{ id: '40000000-0000-4000-8000-0000000000a1', from_role: 'human', body: 'hello world!', request: REQUEST, at: '2026-11-08T10:00:00Z' },
{ id: '40000000-0000-4000-8000-0000000000a2', from_role: 'assistant', body: 'PINEAPPLE', at: '2026-11-08T10:00:05Z' },
],
}[verb] ?? []);
const record = (kind, o, verb, args) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args }) + '\\n');
export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return human(verb, args); };
export const consoleCall = o => async (verb, args) => {
record('console', o, verb, args);
if (verb === 'request.get.console') throw new BusReadError('request-not-found', 'no request under this id');
throw new BusReadError('unavailable', 'fixture: ' + verb);
};
`);
writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`);
writeFileSync(hook, `import { registerHooks } from 'node:module';
registerHooks({ resolve(specifier, context, next) {
if (context.parentURL?.endsWith('/webui/src/cli.mjs')) {
if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true };
if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true };
}
return next(specifier, context);
} });
`);
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` };
const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', '--dev-local-owner'], { env, stdio: ['ignore', 'pipe', 'pipe'] });
let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c);
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) {
if (child.exitCode !== null) break;
await new Promise(r => setTimeout(r, 50));
}
const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.exec(out)?.[1];
assert.ok(base, `the Console did not start:\n${out}`);
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
// A failed wait reports the Console output and the last transport calls with the page text.
const diagnostics = () => `Console output:\n${out.slice(-1500)}\nLast calls:\n${readFileSync(log, 'utf8').trim().split('\n').slice(-12).join('\n')}`;
return { base, calls, org, diagnostics };
}
async function page(t, base, diagnostics) {
const b = await browser({ machine: false });
t.after(() => b.close());
await b.viewport(1440, 900);
const wait = async (expression, label = expression) => {
for (let i = 0; i < 400; i++) {
try { if (await b.evaluate(`Boolean(${expression})`)) return; } catch { /* navigation */ }
await new Promise(r => setTimeout(r, 50));
}
throw Error(`Timed out: ${label}\n${await b.evaluate('document.body.innerText.slice(0, 2000)').catch(() => 'page unavailable')}\n${diagnostics()}`);
};
return { b, wait };
}
test('an organization offers its request status, and a conversation whose message has no request says nothing is wrong', { timeout: 60000 }, async t => {
const { base, calls, org, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects/threads/${T}`);
await wait(`document.querySelector('.chat-log')?.textContent.includes('PINEAPPLE')`);
const config = await b.evaluate(`fetch('/api/orgs/alpha/config').then(r => r.json())`);
assert.equal(config.requests, true, `the organization config must offer request reads: ${JSON.stringify(config)}`);
// The page settles when the request read answered: the Tasks tab gets its count, or the status line says it failed.
const settled = `document.querySelector('.project-tabs a[href$="/tasks"] .project-count') || document.querySelector('[data-conversation-request-status]')?.textContent`;
await wait(settled, 'request status settled');
assert.equal(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`), '');
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Request status unavailable|Status unavailable/);
const reads = calls().filter(c => c.kind === 'console');
assert.ok(reads.length > 0, 'the request read reached the console transport');
for (const read of reads) {
assert.equal(read.verb, 'request.get.console');
assert.deepEqual(read.args, { request: REQUEST });
assert.equal(read.business, 'alpha', 'the read goes to the selected organization');
assert.equal(read.socket, orgPaths(org.root).socket, 'the read uses the organization bus socket');
}
});
test('an organization project overview reports no active request instead of unavailable', { timeout: 60000 }, async t => {
const { base, diagnostics } = await consoleFor(t);
const { b, wait } = await page(t, base, diagnostics);
await b.navigate(`${base}#/orgs/alpha/projects`);
await wait(`document.querySelector('[data-project-status="${P}"] .project-card-status-line')`);
assert.match(await b.evaluate(`document.querySelector('[data-project-status="${P}"]').textContent`), /^No active requests · 1 conversation/);
// The overview renders after its reads settle, so the metrics are final once they exist.
await b.navigate(`${base}?v=2#/orgs/alpha/projects/${P}`);
await wait(`document.querySelector('.project-metrics')`, 'project metrics');
const metrics = await b.evaluate(`[...document.querySelectorAll('.project-metrics a')].slice(0, 2).map(a => a.innerText.replace(/\\s+/g, ' ').trim())`);
assert.deepEqual(metrics, ['Requests in progress 0 No active requests', 'Needs your input 0 Nothing waiting']);
assert.doesNotMatch(await b.evaluate(`document.querySelector('main').innerText`), /Status unavailable|Request status unavailable|Request tasks unavailable/);
});
@@ -0,0 +1,46 @@
// Walkthrough defect 2 (Jason, 7350): the conversation breadcrumb read "Projects / work / / Conversations / init".
// No label was missing. Every project crumb link had min-width:44px, so a short project name such as "work" was
// padded with blank space after its text and the run of separators looked like an empty crumb.
// The crumbs must be evenly spaced whatever the name length, and each link keeps its 44px target.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
const ROUTES = [[`/projects/${P}`, 'Overview'], [`/projects/${P}/conversations`, 'Conversations'], [`/projects/threads/${T}`, 'Build offline timer']];
// For each crumb link: the space from its text to the next visible separator's text (null when the phone layout
// hides what follows), and how many pixels wide its hit area is along the text's centre line.
const MEASURE = `(() => {
const box = document.querySelector('#breadcrumbs');
const text = e => { const n = document.createTreeWalker(e, NodeFilter.SHOW_TEXT).nextNode(), r = document.createRange(); r.selectNodeContents(n); return r.getBoundingClientRect(); };
return [...box.querySelectorAll(':scope > a')].map(a => {
let sep = a.nextElementSibling; if (sep?.classList.contains('project-crumb-current')) sep = sep.firstElementChild;
const t = text(a), next = sep && text(sep), y = t.top + t.height / 2;
let target = 0; for (let x = Math.floor(t.left) - 30; x <= t.right + 30; x++) if (document.elementFromPoint(x, y)?.closest('a') === a) target++;
return { label: a.textContent.trim(), gap: next?.width ? Math.round(next.left - t.right) : null, target };
});
})()`;
for (const [width, height] of [[1440, 900], [390, 844]]) {
test(`project crumbs are evenly spaced for a short project name at ${width}px`, { timeout: 60000 }, async t => {
const f = await fixture(), b = await browser({ machine: false });
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + `;fixtureProjects[0].name='work';` });
await b.viewport(width, height);
let n = 0;
for (const [route, leaf] of ROUTES) {
await b.navigate(`${f.base}/?r=${n++}#${route}`);
const ready = `document.querySelector('#breadcrumbs .project-crumb-current [aria-current="page"]')?.textContent === ${JSON.stringify(leaf)}`;
await b.evaluate(`(async () => { for (let i = 0; i < 300; i++) { if (${ready}) return document.fonts.ready; await new Promise(r => setTimeout(r, 30)); } throw Error('Timed out: crumbs for ' + ${JSON.stringify(route)} + ': ' + document.querySelector('#breadcrumbs')?.innerText); })()`);
const crumbs = await b.evaluate(MEASURE);
assert.equal(crumbs[1]?.label, 'work', `${route}: the project crumb names the project`);
const reference = crumbs[0].gap;
for (const crumb of crumbs) {
if (crumb.gap !== null) assert.ok(Math.abs(crumb.gap - reference) <= 1, `${route}: "${crumb.label}" is followed by ${crumb.gap}px, "Projects" by ${reference}px`);
assert.ok(crumb.target >= 44, `${route}: "${crumb.label}" has a ${crumb.target}px target, not 44px`);
}
}
});
}