Files
stack/packages/cli/src/org-bus.mjs
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

212 lines
12 KiB
JavaScript

// Organization-scoped bus lifecycle. Registry resolution precedes every action.
import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { constants, closeSync, lstatSync, mkdirSync, openSync, readFileSync, fstatSync, unlinkSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { resolveOrg, withOrgLifecycle } from '../../organization/src/org-registry.mjs';
import { orgPaths } from './config.mjs';
import { cmdlineOf, hostFile, hostLock, hostStatus, readHostState, startTimeOf, stopHost } from './host.mjs';
import { CliError } from './errors.mjs';
import { readBootStatus } from './boot-status.mjs';
const CLI = fileURLToPath(new URL('./cli.mjs', import.meta.url));
const fail = message => { throw new CliError(message, 3); };
async function target(ctx, id, allowDeleting = false) {
let org;
try { org = await resolveOrg(ctx, id); }
catch (error) {
if (['ENOENT', 'EACCES', 'ENOTDIR'].includes(error.code)) fail('organization registry is inaccessible');
throw error;
}
if (org.state === 'archived' && !allowDeleting) fail(`org-archived: organization ${id} is archived`);
if (org.state === 'deleting' && !allowDeleting) fail(`organization ${id} is being deleted`);
if (org.state !== 'active' && !(allowDeleting && ['deleting', 'archived'].includes(org.state))) fail(`organization ${id} is not active`);
return { ...org, paths: orgPaths(org.root) };
}
function checkedState(org) {
const s = hostStatus(org.root);
let lock = null;
try { lock = JSON.parse(readFileSync(hostLock(org.root), 'utf8')); }
catch (e) { if (e.code !== 'ENOENT') fail('bus host lock is unreadable'); }
if (lock && (!Number.isSafeInteger(lock.pid) || typeof lock.startTime !== 'string' || startTimeOf(lock.pid) !== lock.startTime)) fail('stale bus host lock');
if (s.host && (!s.host.live || s.host.business !== org.id)) fail('stale or mismatched bus host state');
if (s.host) {
const args = cmdlineOf(s.host.pid) ?? [], i = args.indexOf(CLI);
if (i < 0 || args[i + 1] !== 'bus' || args[i + 2] !== 'start' || args[i + 3] !== org.id) fail('bus host identity mismatch');
}
if (lock && (!s.host || lock.pid !== s.host.pid || lock.business !== org.id)) fail('bus host lock does not match host state');
if (s.writerLock && !s.writerLock.live) fail('stale bus writer lock');
if (!s.host && s.writerLock) fail('bus writer lock has no matching host');
if (s.host && !lock) fail('bus host lock is missing');
return s;
}
export function probeBusSocket(path, { signal, deadline } = {}) {
return new Promise(resolve => {
const remaining = deadline === undefined ? 1000 : Math.min(1000, deadline - Date.now());
if (signal?.aborted || remaining <= 0) return resolve(false);
let done = false, data = '';
const socket = connect(path);
const abort = () => finish(false);
const finish = value => { if (done) return; done = true; signal?.removeEventListener('abort', abort); socket.destroy(); resolve(value); };
signal?.addEventListener('abort', abort, { once: true });
if (signal?.aborted) return abort();
socket.setTimeout(remaining, () => finish(false));
socket.on('error', () => finish(false));
socket.on('connect', () => socket.write('{}\n'));
socket.on('data', chunk => {
data += chunk;
if (data.length > 4096) return finish(false);
if (data.includes('\n')) {
try { const r = JSON.parse(data.split('\n')[0]); finish(r.ok === false && r.error === 'invalid-envelope'); }
catch { finish(false); }
}
});
socket.on('end', () => finish(false));
});
}
export async function busStatus(ctx, id, bound) {
const org = await target(ctx, id), state = checkedState(org);
const healthy = Boolean(state.host?.live && state.socket && await probeBusSocket(org.paths.socket, bound));
return { org: id, instance: ctx.instance, root: org.root, socket: org.paths.socket, healthy, pid: state.host?.pid ?? null, boot: readBootStatus(org.root) };
}
export async function busStop(ctx, id) {
const org = await target(ctx, id, true);
checkedState(org);
return stopHost(org.root, { expectedBusiness: id });
}
// Deletion refuses stale or ambiguous host state and any listening socket.
export async function busStopForDelete(ctx, id) {
const org = await target(ctx, id, true);
for (const [part, directory] of [['bus-host', true], ['bus-host/host.json', false], ['bus-host/host.lock', false], ['bus', true], ['bus/writer.lock', false], ['bus/broker.sock', false]]) {
try {
const st = lstatSync(join(org.root, part));
if (st.isSymbolicLink()) fail('symlink in bus state; inspect it before resuming deletion');
if (directory ? !st.isDirectory() : part === 'bus/broker.sock' ? !st.isSocket() : !st.isFile()) fail('unsafe bus state type');
} catch (e) { if (e.code !== 'ENOENT') throw e; }
}
checkedState(org);
const stopped = await stopHost(org.root, { expectedBusiness: id });
const state = checkedState(org);
if (state.host || state.writerLock) fail('bus stop is uncertain; inspect the host before resuming deletion');
const listening = await new Promise(resolve => {
const socket = connect(org.paths.socket); let done = false;
const finish = value => { if (done) return; done = true; socket.destroy(); resolve(value); };
socket.setTimeout(1000, () => finish(true));
socket.on('connect', () => finish(true)); socket.on('error', e => finish(!['ENOENT', 'ECONNREFUSED'].includes(e.code)));
});
if (listening) fail('bus socket still answers; inspect the host before resuming deletion');
return stopped;
}
export async function busStart(ctx, id, { detach = false, devLocalOwner = false, pm = false, noLaunch = false } = {}) {
if (pm && noLaunch) fail('bus start takes --pm or --no-launch, not both');
// Protect parent-side directory preparation; the child takes the same fence
// for admission and boot, so the parent must release it before spawning.
const { org, fd } = await withOrgLifecycle(ctx, id, async () => {
const org = await target(ctx, id);
checkedState(org);
if (readHostState(org.root)?.live) fail(`bus already runs for ${id}`);
mkdirSync(org.paths.hostDir, { recursive: true, mode: 0o700 });
const st = lstatSync(org.paths.hostDir);
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o700) fail('unsafe bus host directory');
const fd = openSync(join(org.paths.hostDir, 'host.log'), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
return { org, fd };
});
let child;
try {
child = spawn(process.execPath, [CLI, 'bus', 'start', id, '--internal-host', ...(devLocalOwner ? ['--dev-local-owner'] : []), ...(pm ? ['--pm'] : []), ...(noLaunch ? ['--no-launch'] : [])], {
env: ctx.env ?? process.env, detached: detach, stdio: ['ignore', fd, fd, 'ipc'],
});
} finally { closeSync(fd); }
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
let exited = false, ready = false;
child.on('message', message => { if (message?.orgReady === id) ready = true; });
done.then(() => { exited = true; });
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !exited) {
// During boot the host holds its lock before its state is published.
const state = readHostState(org.root);
if (ready && state?.live && state.pid === child.pid && state.business === id && await probeBusSocket(org.paths.socket)) {
child.disconnect();
if (detach) child.unref();
const receipt = { org: id, pid: child.pid, socket: org.paths.socket, root: org.root };
Object.defineProperty(receipt, 'done', { value: done });
return receipt;
}
await new Promise(r => setTimeout(r, 50));
}
if (!exited) { child.kill('SIGTERM'); await done; }
// Name the log by its place in the organization's runtime directory, never by host path (PR #1916);
// resolveOrg admitted `id` only after the registry's id check.
throw new CliError(`bus failed to start; see bus-host/host.log in the runtime directory of organization ${id}`, 3);
}
export async function busRestart(ctx, id) {
const org = await target(ctx, id);
checkedState(org);
const state = readHostState(org.root);
const options = state?.options ?? {};
await busStop(ctx, id);
return busStart(ctx, id, { detach: true, ...options });
}
// Recover only while the org lifecycle fence excludes new starts and deletion.
export async function busRecover(ctx, id) {
return withOrgLifecycle(ctx, id, async () => {
const org = await target(ctx, id, true);
const inspect = (path, socket = false) => {
let st; try { st = lstatSync(path); } catch (e) { if (e.code === 'ENOENT') return null; throw e; }
if (st.isSymbolicLink() || st.uid !== process.getuid() || (socket ? !st.isSocket() : !st.isFile() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1)) fail('unsafe bus recovery state');
if (socket) return { path, st };
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const actual = fstatSync(fd);
if (actual.ino !== st.ino || actual.dev !== st.dev) fail('bus recovery state changed');
let value; try { value = JSON.parse(readFileSync(fd, 'utf8')); } catch { fail('unreadable bus recovery state'); }
return { path, st, value };
} finally { closeSync(fd); }
};
for (const dir of [org.paths.hostDir, join(org.root, 'bus')]) {
let st; try { st = lstatSync(dir); } catch (e) { if (e.code === 'ENOENT') continue; throw e; }
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('unsafe bus recovery directory');
}
const state = inspect(hostFile(org.root)), lock = inspect(hostLock(org.root));
const writer = inspect(join(org.root, 'bus', 'writer.lock')), socket = inspect(org.paths.socket, true);
if (!state) fail('bus host state is missing');
const h = state.value;
if (!Number.isSafeInteger(h?.pid) || h.pid <= 0 || typeof h.startTime !== 'string' || !/^\d+$/.test(h.startTime) || typeof h.startedAt !== 'string' || !Number.isFinite(Date.parse(h.startedAt)) || h.business !== id) fail('invalid or mismatched bus host state');
let p; try { p = processState(h.pid); } catch { fail('bus host identity is uncertain'); }
if (p.alive && p.startTime === h.startTime) {
const argv = cmdlineOf(h.pid); if (!argv || !argv.length) fail('bus host identity is uncertain');
const i = argv.indexOf(CLI);
if (i >= 0 && argv[i + 1] === 'bus' && argv[i + 2] === 'start' && argv[i + 3] === id) fail(`bus host pid ${h.pid} is alive`);
}
if (lock && (lock.value?.pid !== h.pid || lock.value?.startTime !== h.startTime || lock.value?.business !== id || lock.value?.startedAt !== h.startedAt)) fail('bus host lock does not match host state');
if (writer) {
let w; try { w = processState(writer.value?.pid); } catch { fail('bus writer identity is uncertain'); }
if (w.alive) fail(`bus writer pid ${writer.value.pid} is alive`);
}
if (socket) {
const occupied = await new Promise(resolve => {
const client = connect(org.paths.socket); let done = false;
const finish = value => { if (!done) { done = true; client.destroy(); resolve(value); } };
client.setTimeout(1000, () => finish(true));
client.on('connect', () => finish(true));
client.on('error', e => finish(!['ENOENT', 'ECONNREFUSED'].includes(e.code)));
});
if (occupied) fail('bus socket answers or is uncertain');
}
const entries = [state, lock, writer, socket].filter(Boolean);
for (const entry of entries) {
const current = lstatSync(entry.path);
if (current.dev !== entry.st.dev || current.ino !== entry.st.ino) fail('bus recovery state changed');
}
const at = new Date().toISOString();
const receipt = join(org.paths.hostDir, `recovery-${at.replace(/[:.]/g, '-')}-${randomBytes(4).toString('hex')}.json`);
writeFileSync(receipt, JSON.stringify({ action: 'bus.recover', at, org: id, stopped: true, host: h, lock: lock?.value ?? null, writer: writer?.value ?? null, socket: Boolean(socket) }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
for (const entry of entries.slice().reverse()) unlinkSync(entry.path);
return { org: id, recovered: true, pid: h.pid, receipt };
});
}