fix(cli): f6c review round, released start locks, ENOENT and path cut (#1873)

P3-1: releasing a console start lock marks it released through its own
descriptor and removes the path only while it is still that file; a
released lock put back by a racing clear is cleared by the next start or
stop.
P3-2: org definitions reports unknown-organization only when the
organization file itself is missing; another missing file names the
operation.
P3-3: the path cut also covers a path after ':' or inside backticks.
This commit is contained in:
jason.woltje committed 2026-11-13 20:14:21 -06:00
1 parent 23e504f331
commit 163972e08e
5 files changed
+50 -7

No files matched your search

+1 -1
View File
@@ -348,7 +348,7 @@ The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
Refusal lines (E2E6, #1873). A refusal that has a next step prints it on a second line, `mosaic: next: ...`, after the unchanged message, so callers that match the message keep working. `mosaic bus status`, `start`, `stop` and `restart` on a stale bus host lock exit 3 with `mosaic: stale bus host lock ...` and `mosaic: next: if the bus host died, clear it with mosaic bus recover ORG; recover refuses while the host still runs`. A human-transport verb whose bus is not running (no socket, or the connect refused before anything was sent) exits 3 with `bus-not-running: the bus for ORG is not running, so the request was not sent` and a next line naming `mosaic bus start ORG` and `mosaic bus recover ORG`; `outcome-unknown` (exit 1) stays for a request that left and got no answer. `mosaic org definitions` with an unknown organization, an unknown or malformed name, or a bad id prints one refusal line (exit 3) or, for a bad id, the usage (exit 4), with no stack trace and no host path. Pack, board and workflow refusals carry their next step as `template save` does. `mosaic console start` and `console stop` clear a `console/start.lock` whose named supervisor (pid and start time) is gone, once, with a `cleared a console start lock left by process PID, which is gone` warning; a lock whose holder still runs, or that names no holder, refuses and stays. An `autonomy-precondition` refusal from the bus carries the check that failed (owner notices off, no tested reverse, a resolver without the right, no `review.verdict` holder, no enforced budget, out of the loop only from near the loop, the trail review or near-loop decision missing); the human-transport child writes only the code, so a terminal `org autonomy set` shows the Console's message. `mosaic org template save` writes free text verbatim: it never rewrites prose, and the preview lists every field that mentions the organization id, a project id, an agent name or the owner's account, with the names it mentions, for the owner to edit before sharing. Saving under a name already saved for the account refuses `template-exists` (exit 2, nothing written) with a next line to save under another name or list the saved ones with `mosaic org template list ORG`.
Refusal lines (E2E6, #1873). A refusal that has a next step prints it on a second line, `mosaic: next: ...`, after the unchanged message, so callers that match the message keep working. `mosaic bus status`, `start`, `stop` and `restart` on a stale bus host lock exit 3 with `mosaic: stale bus host lock ...` and `mosaic: next: if the bus host died, clear it with mosaic bus recover ORG; recover refuses while the host still runs`. A human-transport verb whose bus is not running (no socket, or the connect refused before anything was sent) exits 3 with `bus-not-running: the bus for ORG is not running, so the request was not sent` and a next line naming `mosaic bus start ORG` and `mosaic bus recover ORG`; `outcome-unknown` (exit 1) stays for a request that left and got no answer. `mosaic org definitions` with an unknown organization, an unknown or malformed name, or a bad id prints one refusal line (exit 3; `unknown-organization` only when the organization file itself is missing) or, for a bad id, the usage (exit 4), with no stack trace and no host path. Pack, board and workflow refusals carry their next step as `template save` does. `mosaic console start` and `console stop` clear a `console/start.lock` whose named supervisor (pid and start time) is gone, once, with a `cleared a console start lock left by process PID, which is gone` warning; a lock whose holder still runs, or that names no holder, refuses and stays. A start or stop releasing its lock marks it released through its own descriptor and removes the path only while it is still that file, so a lock put back after its release is cleared the same way. An `autonomy-precondition` refusal from the bus carries the check that failed (owner notices off, no tested reverse, a resolver without the right, no `review.verdict` holder, no enforced budget, out of the loop only from near the loop, the trail review or near-loop decision missing); the human-transport child writes only the code, so a terminal `org autonomy set` shows the Console's message. `mosaic org template save` writes free text verbatim: it never rewrites prose, and the preview lists every field that mentions the organization id, a project id, an agent name or the owner's account, with the names it mentions, for the owner to edit before sharing. Saving under a name already saved for the account refuses `template-exists` (exit 2, nothing written) with a next line to save under another name or list the saved ones with `mosaic org template list ORG`.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
+18 -4
View File
@@ -3,7 +3,7 @@ import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
import { constants, closeSync, fstatSync, ftruncateSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
@@ -62,7 +62,8 @@ function removeStale(ctx, s, warn) {
}
// E2E6 fix 4: what a start.lock that already exists names. Null when it names no holder: unreadable,
// empty (a holder between its open and its write), not a regular file of this user, or a bad pid or
// start time. Such a lock is never cleared.
// start time. Such a lock is never cleared. A lock its holder marked released ({"released":true},
// written through the holder's own descriptor) names no live holder and is cleared (f6c review P3-1).
function lockHolder(path) {
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
@@ -71,6 +72,7 @@ function lockHolder(path) {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || st.nlink !== 1 || st.size > 4096) return null;
const data = JSON.parse(readFileSync(fd, 'utf8'));
if (data?.released === true && Object.keys(data).length === 1) return { released: true, dev: st.dev, ino: st.ino };
if (!Number.isSafeInteger(data?.pid) || data.pid <= 0 || typeof data.startTime !== 'string' || !/^\d+$/.test(data.startTime)) return null;
return { pid: data.pid, startTime: data.startTime, dev: st.dev, ino: st.ino };
} catch { return null; } finally { closeSync(fd); }
@@ -78,6 +80,7 @@ function lockHolder(path) {
// True when the process the lock names (pid and start time) is gone. A /proc read that cannot settle
// it counts as live, so an uncertain holder is never cleared.
function holderGone(holder) {
if (holder.released) return true;
let p;
try { p = processState(holder.pid); } catch { return false; }
return !p.alive || p.startTime !== holder.startTime;
@@ -111,14 +114,25 @@ function takeLock(ctx, warn = () => {}) {
if (!holder) fail('console start lock exists and names no holder; remove console/start.lock under the data root once no console start or stop runs');
if (!holderGone(holder)) fail(`console start lock is held by process ${holder.pid}, which still runs; wait for its start or stop to finish`);
clearLock(path, holder);
warn(`mosaic: cleared a console start lock left by process ${holder.pid}, which is gone\n`);
warn(holder.released ? 'mosaic: cleared a console start lock its holder had released\n' : `mosaic: cleared a console start lock left by process ${holder.pid}, which is gone\n`);
cleared = true;
}
}
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
// supervisor it killed (clearDeadLock), and a later start or stop can clear it once that holder is gone.
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
return () => { closeSync(fd); rmSync(path, { force: true }); };
// f6c review P3-1: the release marks its own file released, through its own descriptor, and removes
// the path only while the path is still that file. A clear that moved the lock aside and puts it back
// after this release puts back a released lock, which the next start or stop clears; and the release
// never removes a lock another start took meanwhile.
return () => {
try {
ftruncateSync(fd, 0);
writeSync(fd, JSON.stringify({ released: true }), 0);
const own = fstatSync(fd), now = lstatSync(path);
if (own.dev === now.dev && own.ino === now.ino) rmSync(path, { force: true });
} catch { /* gone already, or moved aside by a clear */ } finally { closeSync(fd); }
};
}
// Removes start.lock only when it names this exact process (pid and start time) and that process
// is gone. A lock that is unreadable, unnamed or another holder's stays.
+7 -2
View File
@@ -4,6 +4,7 @@
// credential store, whose held-secret check every definition write needs (R1), and no other process
// holds the tokens it looks for. Each write prints its receipt.
import { readFile } from 'node:fs/promises';
import { basename } from 'node:path';
import { configDir } from '../../organization/src/organization-config.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
@@ -65,12 +66,16 @@ function refusal(code, verb, name, rev, read) {
// filesystem error is named by its operation and code, and any absolute path left in a message is cut.
const NAME_SHOWN = /^[a-z0-9][a-z0-9._-]{0,63}$/;
function missing(id, name) { return `${NAME_SHOWN.test(name ?? '') ? `definitions declares no ${name}` : 'definitions declares no definition by that name'}; list them with mosaic org definitions ${id} list`; }
function pathFree(message) { return String(message).replace(/(^|[\s'"(=])\/[^\s'"),]*/g, '$1<path>'); }
// f6c review P3-3: a path after ':' or a backtick is cut too.
function pathFree(message) { return String(message).replace(/(^|[\s'"(=:`])\/[^\s'"),`]*/g, '$1<path>'); }
export function definitionsFailure(e, id, name) {
if (e instanceof CliError) return e;
const coded = (code, text, exit = 3) => Object.assign(new CliError(`${code}: ${text}`, exit), { code });
if (typeof e?.syscall === 'string' && typeof e.code === 'string' && /^E[A-Z0-9]+$/.test(e.code)) {
if (e.code === 'ENOENT') return coded('unknown-organization', `this instance has no organization ${id}; list them with mosaic org list`);
// f6c review P3-2: only a missing organization file means no organization; another missing file
// (the roles directory for `templates`, a role template) says which operation failed.
if (e.code === 'ENOENT' && (typeof e.path !== 'string' || basename(e.path) === `${id}.json`)) return coded('unknown-organization', `this instance has no organization ${id}; list them with mosaic org list`);
if (id === 'templates' || id === undefined) return coded('definitions-unreadable', `cannot ${e.syscall} a file the definitions command reads (${e.code})`);
return coded('organization-unreadable', `cannot ${e.syscall} the file of organization ${id} (${e.code})`);
}
const message = typeof e?.message === 'string' ? e.message : '';
+11
View File
@@ -461,5 +461,16 @@ test('E2E6 a start.lock whose holder is gone is cleared by stop and by start; a
await assert.rejects(consoleStop(ctx), /console start lock exists and names no holder/);
assert.equal(readFileSync(lock, 'utf8'), body);
}
// f6c review P3-1: a lock its holder marked released (a clear put it back after the release) is
// cleared; a released marker with anything else in it names no holder and stays.
writeFileSync(lock, JSON.stringify({ released: true }), { mode: 0o600 });
warnings.length = 0;
assert.deepEqual(await consoleStop(ctx, { warn: s => warnings.push(s) }), { stopped: false });
assert.deepEqual(warnings, ['mosaic: cleared a console start lock its holder had released\n']);
assert.equal(existsSync(lock), false);
const odd = JSON.stringify({ released: true, pid: 1 });
writeFileSync(lock, odd, { mode: 0o600 });
await assert.rejects(consoleStop(ctx), /console start lock exists and names no holder/);
assert.equal(readFileSync(lock, 'utf8'), odd);
rmSync(lock);
});
@@ -174,4 +174,17 @@ test('E2E6 definitionsFailure: a raw filesystem error is named by its operation
assert.equal(e.message, 'organization-invalid: organization file <path> is not valid');
e = definitionsFailure(new TypeError('Cannot read properties of undefined'), 'acme');
assert.deepEqual([e.code, e.exitCode, e.message], ['definitions-failed', 1, 'definitions-failed: the definitions command failed']);
// f6c review P3-2: only the organization file missing means no organization.
e = definitionsFailure(fsError('ENOENT', 'open'), 'acme');
assert.deepEqual([e.code, e.message], ['unknown-organization', 'unknown-organization: this instance has no organization acme; list them with mosaic org list']);
const rolesGone = Object.assign(new Error("ENOENT: no such file or directory, scandir '/home/someone/roles'"), { code: 'ENOENT', syscall: 'scandir', path: '/home/someone/roles' });
e = definitionsFailure(rolesGone, 'templates');
assert.deepEqual([e.code, e.exitCode, e.message], ['definitions-unreadable', 3, 'definitions-unreadable: cannot scandir a file the definitions command reads (ENOENT)']);
e = definitionsFailure(rolesGone, 'acme');
assert.equal(e.code, 'organization-unreadable');
// f6c review P3-3: a path after ':' or inside backticks is cut.
e = definitionsFailure(Object.assign(new Error('organization file:/srv/x/acme.json is not valid'), { code: 'organization-invalid' }), 'acme');
assert.equal(e.message, 'organization-invalid: organization file:<path> is not valid');
e = definitionsFailure(Object.assign(new Error('bad role at `/srv/roles/x.json`'), { code: 'organization-invalid' }), 'acme');
assert.equal(e.message, 'organization-invalid: bad role at `<path>`');
});