Files
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00

249 lines
16 KiB
JavaScript

// `mosaic dev fixture init|status|stop`: the deterministic model fixture for a
// throwaway named instance (r4 §7). init starts the fixture provider's loopback
// endpoint (packages/plugins/model-provider/fixture/endpoint.mjs) and records a
// non-secret state file under the instance data root. It never writes the
// system config. A bus started with --dev-local-owner on that instance hands the
// endpoint to the launcher as its session proxy context (fixtureLauncherOptions),
// so a launch runs the real launcher, sandbox, runner and pi against it.
// Refuses: the default dev instance, an instance with a proxy configured, a
// provider credential in the environment, a non-loopback endpoint and a second
// fixture. A record whose endpoint is gone (its lifetime ended, or it crashed)
// is stale: init replaces it. Exit codes as errors.mjs: 2 invalid, 3 refused, 4 usage.
import { spawn } from 'node:child_process';
import { closeSync, constants, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { get } from 'node:http';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { CliError } from './errors.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { parseArgs } from './thread.mjs';
import { isProviderVariable } from '../../seat/src/session.mjs';
import { FIXTURE_ENTRY, FIXTURE_KEY, FIXTURE_KEY_NAME, FIXTURE_MODEL } from '../../plugins/model-provider/fixture/index.mjs';
import { TTL_DEFAULT_S, TTL_MAX_S, TTL_MIN_S } from '../../plugins/model-provider/fixture/endpoint.mjs';
export const ENDPOINT = resolve(dirname(fileURLToPath(import.meta.url)), '../../plugins/model-provider/fixture/endpoint.mjs');
export const DEV_USAGE = `mosaic dev fixture init --model deterministic [--instance NAME] [--bind 127.0.0.1] [--ttl SECONDS] [--json]
mosaic dev fixture status [--json]
mosaic dev fixture stop [--json]`;
export const fixtureDir = dataRoot => join(dataRoot, 'dev-fixture');
export const fixtureFile = dataRoot => join(fixtureDir(dataRoot), 'fixture.json');
const LOOPBACK = /^http:\/\/127\.0\.0\.1:([0-9]{1,5})$/;
const PRINCIPAL = `${DEV_OWNER.provider}:${DEV_OWNER.sub}`;
const refuse = (code, message) => Object.assign(new CliError(`${code}: ${message}`, 3), { code });
const invalid = message => new CliError(`${message}\nusage: ${DEV_USAGE}`, 4);
// Names, never values, of provider credential variables the environment holds.
export function credentialVariables(env) {
return Object.keys(env).filter(k => isProviderVariable(k) && Boolean(env[k])).sort();
}
function noCredentials(env) {
const held = credentialVariables(env);
if (held.length) throw refuse('fixture-credential-present', `the fixture runs without real credentials; unset ${held.join(', ')}`);
}
function ownedPrivate(path, directory) {
const st = lstatSync(path);
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile()) || st.uid !== process.getuid() || (st.mode & 0o077))
throw refuse('fixture-state-unsafe', `${directory ? 'dev-fixture directory' : 'fixture file'} is not a private ${directory ? 'directory' : 'file'} of this user`);
}
// The recorded fixture, or null when there is none. Anything malformed refuses.
export function readFixture(dataRoot) {
const file = fixtureFile(dataRoot);
try { ownedPrivate(fixtureDir(dataRoot), true); }
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
let fd;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `fixture.json can't be opened (${e.code})`); }
let doc;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', 'fixture.json is not a private file of this user');
doc = JSON.parse(readFileSync(fd, 'utf8'));
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', 'fixture.json is not JSON'); }
finally { closeSync(fd); }
const m = LOOPBACK.exec(doc?.endpoint ?? '');
if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:<port>');
if (doc.fixtureVersion !== 1 || doc.model !== 'deterministic' || Number(m[1]) !== doc.port || !Number.isSafeInteger(doc.pid) || doc.pid < 2
|| typeof doc.instance !== 'string' || JSON.stringify(doc.entry) !== JSON.stringify(FIXTURE_ENTRY))
throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record');
return doc;
}
const cmdline = pid => { try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0'); } catch { return null; } };
// Signal only the endpoint this command started: its argv[1] is ENDPOINT.
export const isEndpoint = pid => (cmdline(pid) ?? [])[1] === ENDPOINT;
function health(endpoint, timeoutMs = 2000) {
return new Promise(resolve => {
const req = get(`${endpoint}/health`, { timeout: timeoutMs }, res => {
let body = ''; res.setEncoding('utf8'); res.on('data', c => { if (body.length < 4096) body += c; });
res.on('end', () => { try { resolve(res.statusCode === 200 ? JSON.parse(body) : null); } catch { resolve(null); } });
});
req.on('timeout', () => req.destroy()); req.on('error', () => resolve(null));
});
}
function instanceOf(io, parsed) {
let instance;
try { instance = devOwnerInstance(io.env); }
catch (e) { throw refuse('fixture-instance-refused', `${e.message}; set MOSAIC_CONFIG to the named instance's config`); }
const named = parsed.values['--instance'];
if (named !== undefined && named !== instance) throw refuse('fixture-instance-mismatch', `--instance ${named} is not the instance MOSAIC_CONFIG names (${instance})`);
return instance;
}
// --ttl: the endpoint's lifetime in whole seconds, TTL_DEFAULT_S when absent. 0 is no expiry,
// for a long-running dev instance; it is safe only because this command runs on a named dev
// instance and nothing else starts the endpoint with 0.
function ttlOf(raw) {
if (raw === undefined) return TTL_DEFAULT_S;
const n = /^(0|[1-9][0-9]{0,6})$/.test(raw) ? Number(raw) : NaN;
if (n === 0 || (n >= TTL_MIN_S && n <= TTL_MAX_S)) return n;
throw invalid(`--ttl takes 0 (no expiry) or whole seconds from ${TTL_MIN_S} to ${TTL_MAX_S}`);
}
function startEndpoint(logFile, ttlSeconds, { timeoutMs = 10000 } = {}) {
const log = openSync(logFile, 'a', 0o600);
let child;
try {
child = spawn(process.execPath, [ENDPOINT], { detached: true, stdio: ['ignore', 'pipe', log], env: { PATH: process.env.PATH ?? '/usr/bin:/bin', MOSAIC_FIXTURE_TTL_S: String(ttlSeconds) } });
} finally { closeSync(log); }
return new Promise((resolve, reject) => {
let out = '';
const fail = why => { clearTimeout(clock); try { child.kill('SIGKILL'); } catch {} reject(refuse('fixture-start-failed', why)); };
const clock = setTimeout(() => fail('the endpoint did not report ready'), timeoutMs);
child.on('error', e => fail(e.message));
child.on('exit', code => fail(`the endpoint exited ${code}`));
child.stdout.setEncoding('utf8').on('data', s => {
out += s; const nl = out.indexOf('\n'); if (nl < 0) return;
clearTimeout(clock); child.removeAllListeners('exit'); child.stdout.destroy(); child.unref();
try { const ready = JSON.parse(out.slice(0, nl)); if (ready.ready !== true || !Number.isSafeInteger(ready.port)) throw Error(); resolve({ child, port: ready.port }); }
catch { fail('the endpoint printed something that is not a ready line'); }
});
});
}
// Remove the stale record this init inspected, and only that one. The record is taken by an
// atomic rename, then checked: if a concurrent init already replaced it with a live endpoint's
// record, that record goes back (link refuses to overwrite) and this init refuses fixture-exists.
// An unlink by path after the check would delete the other init's new record instead.
export function takeStale(dataRoot, existing, instance) {
const file = fixtureFile(dataRoot), aside = `${file}.stale-${process.pid}`;
try { renameSync(file, aside); } catch (e) { if (e.code === 'ENOENT') return; throw e; }
let taken = null;
try { taken = JSON.parse(readFileSync(aside, 'utf8')); } catch {}
if (taken?.pid === existing.pid && taken?.startedAt === existing.startedAt) { rmSync(aside, { force: true }); return; }
// EEXIST means a third init published meanwhile; that record stays and this init still refuses.
try { linkSync(aside, file); } catch {} finally { rmSync(aside, { force: true }); }
throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
}
const print = (io, json, value, line) => io.stdout.write(json ? `${JSON.stringify(value, null, 2)}\n` : `${line}\n`);
async function init(args, io, deps) {
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--model', '--instance', '--bind', '--ttl'] });
if (parsed.positional.length) throw invalid('fixture init takes no positional arguments');
if (parsed.values['--model'] !== 'deterministic') throw invalid('fixture init needs --model deterministic');
const ttlSeconds = ttlOf(parsed.values['--ttl']);
const bind = parsed.values['--bind'] ?? '127.0.0.1';
if (bind !== '127.0.0.1') throw refuse('fixture-endpoint-not-loopback', `the fixture binds 127.0.0.1 only, not ${bind}`);
const instance = instanceOf(io, parsed);
noCredentials(io.env);
const system = deps.system ?? loadSystem({ env: io.env, instance });
if (system.proxy) throw refuse('fixture-proxy-configured', `instance ${instance} has a model proxy configured; the fixture is for an instance without one`);
const existing = readFixture(system.dataRoot);
if (existing && isEndpoint(existing.pid)) throw refuse('fixture-exists', `instance ${instance} already has a fixture; mosaic dev fixture stop removes it`);
// P19 #882: an endpoint whose lifetime ended left its record, and init refused fixture-exists
// until `dev fixture stop`. A record whose pid is no longer the endpoint is stale; it goes.
// A second init racing this one still loses at the exclusive write of the new record.
if (existing) takeStale(system.dataRoot, existing, instance);
const replaced = existing ? { pid: existing.pid, startedAt: existing.startedAt ?? null } : null;
try { mkdirSync(fixtureDir(system.dataRoot), { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; }
ownedPrivate(fixtureDir(system.dataRoot), true);
const { child, port } = await startEndpoint(join(fixtureDir(system.dataRoot), 'endpoint.log'), ttlSeconds);
const endpoint = `http://127.0.0.1:${port}`;
const started = new Date();
const record = { fixtureVersion: 1, fixture: 'fixture', model: 'deterministic', modelKey: FIXTURE_KEY_NAME, entry: FIXTURE_ENTRY,
instance, endpoint, port, pid: child.pid, principal: PRINCIPAL, upstream: 'none', startedAt: started.toISOString(),
ttlSeconds, expiresAt: ttlSeconds ? new Date(started.getTime() + ttlSeconds * 1000).toISOString() : null };
try {
if (!(await health(endpoint))?.ok) throw refuse('fixture-start-failed', 'the endpoint did not answer its health check');
writeFileSync(fixtureFile(system.dataRoot), `${JSON.stringify(record, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
} catch (e) { try { process.kill(child.pid, 'SIGTERM'); } catch {} throw e; }
const receipt = { receipt: 'dev-fixture-init', ...record, replaced, loopback: true, external: false, state: fixtureFile(system.dataRoot) };
print(io, parsed.flags.has('--json'), receipt,
`fixture ready: instance ${instance}, model ${FIXTURE_MODEL} (launch.models key ${FIXTURE_KEY_NAME}), endpoint ${endpoint} (loopback, no upstream), principal ${PRINCIPAL}, pid ${child.pid}, ${ttlSeconds ? `expires ${record.expiresAt}` : 'no expiry'}${replaced ? `; replaced the stale record of pid ${replaced.pid}` : ''}`);
}
async function status(args, io, deps) {
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--instance'] });
if (parsed.positional.length) throw invalid('fixture status takes no positional arguments');
const instance = instanceOf(io, parsed);
const system = deps.system ?? loadSystem({ env: io.env, instance });
const record = readFixture(system.dataRoot);
if (!record) throw new CliError(`instance ${instance} has no fixture; run mosaic dev fixture init --model deterministic`, 2);
const alive = isEndpoint(record.pid);
const answer = alive ? await health(record.endpoint) : null;
const report = { instance, ready: Boolean(alive && answer?.ok && !system.proxy), endpoint: record.endpoint, loopback: true, upstream: 'none', external: false,
model: record.model, modelKey: record.modelKey, entry: record.entry, principal: record.principal, pid: record.pid, alive,
proxyConfigured: Boolean(system.proxy), health: answer, startedAt: record.startedAt, ttlSeconds: record.ttlSeconds ?? null, expiresAt: record.expiresAt ?? null };
print(io, parsed.flags.has('--json'), report, `fixture ${report.ready ? 'ready' : 'not ready'}: ${record.endpoint}, pid ${record.pid}${alive ? '' : ' (not running; init replaces the record)'}`);
}
async function stop(args, io, deps) {
const parsed = parseArgs(args, `usage: ${DEV_USAGE}`, { flags: ['--json'], values: ['--instance'] });
if (parsed.positional.length) throw invalid('fixture stop takes no positional arguments');
const instance = instanceOf(io, parsed);
const system = deps.system ?? loadSystem({ env: io.env, instance });
const record = readFixture(system.dataRoot);
if (!record) throw new CliError(`instance ${instance} has no fixture`, 2);
let signaled = false;
if (isEndpoint(record.pid)) {
process.kill(record.pid, 'SIGTERM'); signaled = true;
for (let i = 0; i < 50 && isEndpoint(record.pid); i++) await new Promise(r => setTimeout(r, 100));
if (isEndpoint(record.pid)) throw new CliError(`fixture endpoint pid ${record.pid} did not stop`, 1);
}
rmSync(fixtureFile(system.dataRoot));
print(io, parsed.flags.has('--json'), { receipt: 'dev-fixture-stop', instance, pid: record.pid, signaled, endpoint: record.endpoint },
`fixture stopped: instance ${instance}, pid ${record.pid}${signaled ? '' : ' (was not running)'}`);
}
export async function devCommand(rest, io, deps = {}) {
const [what, sub, ...args] = rest;
if (what !== 'fixture') throw invalid(`unknown dev command ${what ?? ''}`.trim());
if (sub === 'init') return init(args, io, deps);
if (sub === 'status') return status(args, io, deps);
if (sub === 'stop') return stop(args, io, deps);
throw invalid(`unknown dev fixture command ${sub ?? ''}`.trim());
}
// For `mosaic bus start --dev-local-owner` only: the fixture as the launcher's
// session proxy context, or nothing when the instance has no fixture. A fixture
// next to a configured proxy, or a credential in the bus environment, refuses.
// True when the organization file routes a launch to the fixture provider.
export function orgUsesFixture(doc) {
const providers = doc?.plugins?.['model-provider'];
if (Array.isArray(providers) && providers.includes('fixture')) return true;
return Object.values(doc?.launch?.models ?? {}).some(m => m?.module === 'fixture');
}
// A fixture organization's launches get no provider credential, so one instance can hold
// fixture organizations and real-model organizations side by side. Other organizations keep env.
export function fixtureLaunchEnv(doc, env) {
if (!orgUsesFixture(doc)) return env;
return Object.fromEntries(Object.entries(env).filter(([k]) => !credentialVariables({ [k]: env[k] }).length));
}
// `doc`, when given, is the organization file: an organization that does not use the fixture
// launches as configured, without the fixture proxy.
export function fixtureLauncherOptions({ system, env, instance, doc }) {
const record = readFixture(system.dataRoot);
if (!record) return {};
if (doc !== undefined && !orgUsesFixture(doc)) return {};
if (record.instance !== instance) throw refuse('fixture-instance-mismatch', `the fixture was started for instance ${record.instance}, not ${instance}`);
if (system.proxy) throw refuse('fixture-proxy-configured', 'the instance has both a model proxy and a fixture');
noCredentials(env);
return { testProxy: { baseUrl: record.endpoint, key: FIXTURE_KEY } };
}