Review "changes 8" fix round for org-redact6. - launcher: a harness module's throw names its system code or class, never its message; preflight, loader, bundle, checkout, sandbox and route text is relayed with host paths replaced (relay()). - claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the repository's node_modules, never a path; persona and skill bundle errors and sandboxProblem name their file or the sandbox by label. - seat session: thread store, legacy store, overlap, busy lock and newRun refusals name the place under the data root. - discord: unknown keys, binding names and turn ids are described when not plain; approvals, journal and repository read errors name the file and code; git drops stderr lines that carry a host path and a spawn failure names its code only. - seat, control-board: registration fields and seen keys are described when not plain; invalid seat name and layout state the constraint. - organization: the registry names the config directory and registry file by label; --project is quoted only when it is a plain name; systemConfigProblem moves to its own module. - scripts/mosaic-config.mjs: the config by where it came from, the data root and its marker by role, keys and arguments only when plain, and error codes in place of raw messages. Exit codes unchanged. - webui sso-config: an unknown console key is named only when plain. Sentinel tests in each package's path-redaction-labels file fail on BASE; pins that quoted a path now assert the label and its absence.
243 lines
15 KiB
JavaScript
243 lines
15 KiB
JavaScript
// #1873 (redact6): a cli refusal names the file, the instance or the field, never a host path,
|
|
// and never relays mosaic-config's stderr or a Node system error's message. The sentinel is a
|
|
// component of every scratch path, so a refusal that quotes a path quotes the sentinel too; each
|
|
// test also checks the scratch root and the label. The launcher cases need no bus: a bus socket
|
|
// under a sentinel path would pass the Unix socket length limit, so they use a stub host.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { join } from 'node:path';
|
|
import { createLauncher, relay } from '../src/launcher.mjs';
|
|
import { CliError } from '../src/errors.mjs';
|
|
import { loadSystem } from '../src/config.mjs';
|
|
import { main } from '../src/cli.mjs';
|
|
import { initializeControl } from '../../processing-control/src/state.mjs';
|
|
import { launchLogFile } from '../../seat/src/session.mjs';
|
|
import { fakeHarnesses, fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
|
|
|
|
const secret = 'sk-test-NOT-A-SECRET-1234';
|
|
function scratch(t) {
|
|
const root = tmp(t, 'r6-');
|
|
const dir = join(root, secret);
|
|
mkdirSync(dir, { mode: 0o700 });
|
|
return { root, dir };
|
|
}
|
|
function clean(text, root) {
|
|
assert.ok(!text.includes(root), text);
|
|
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
|
}
|
|
const PI = JSON.stringify(new URL('../../plugins/harness/pi/index.mjs', import.meta.url).href);
|
|
|
|
function stubLauncher(t, { op = async () => ({}) } = {}) {
|
|
const { root, dir } = scratch(t);
|
|
const f = fixture(dir, 'acme');
|
|
initializeControl(f.dataRoot);
|
|
withoutHostCredentials(f.env, { piKey: true });
|
|
const system = JSON.parse(readFileSync(f.config, 'utf8'));
|
|
const host = { business: 'acme', beforeClose: () => {}, op };
|
|
return { root, f, make: () => createLauncher({ host, system, env: f.env, bwrap: '/usr/bin/true', log: () => {} }) };
|
|
}
|
|
|
|
test('an unreadable sessions file refuses exit 3 naming bus-host/sessions.json under the data root, never its path', t => {
|
|
const s = stubLauncher(t);
|
|
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
|
|
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), '{not json');
|
|
assert.throws(() => s.make(), e => {
|
|
assert.ok(e instanceof CliError, e.stack);
|
|
assert.equal(e.exitCode, 3);
|
|
assert.equal(e.message, 'sessions file is unreadable: bus-host/sessions.json under the data root; check which sessions still run, then move the file aside');
|
|
clean(e.message, s.root);
|
|
return true;
|
|
});
|
|
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), JSON.stringify({ sessionsVersion: 999, sessions: [] }));
|
|
assert.throws(() => s.make(), e => e.exitCode === 3 && e.message.startsWith('sessions file is malformed: bus-host/sessions.json under the data root;') && !e.message.includes(s.root));
|
|
});
|
|
|
|
test('a launch socket path that is not a socket refuses exit 3 by label, never its path', async t => {
|
|
const s = stubLauncher(t);
|
|
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
|
|
writeFileSync(join(s.f.dataRoot, 'bus-host', 'launch.sock'), 'in the way\n');
|
|
const launcher = s.make();
|
|
t.after(() => launcher.close?.());
|
|
await assert.rejects(launcher.listen(), e => {
|
|
assert.ok(e instanceof CliError, e.stack);
|
|
assert.equal(e.exitCode, 3);
|
|
assert.equal(e.message, "launch socket path exists and isn't a socket");
|
|
clean(e.message, s.root);
|
|
return true;
|
|
});
|
|
});
|
|
|
|
// The launch-routes pattern: a stub host stops each launch at authorizeLaunch, and the launch
|
|
// log's refused event carries the reason.
|
|
async function routed(t, harness) {
|
|
const { root, dir } = scratch(t);
|
|
const f = fixture(dir, 'acme', doc => {
|
|
doc.roles.coder.vars = { harness, model: 'claude-sonnet-5-5', thinking: 'high' };
|
|
doc.plugins = { ...doc.plugins, harness: ['pi', 'claude-code', harness] };
|
|
return doc;
|
|
});
|
|
initializeControl(f.dataRoot);
|
|
withoutHostCredentials(f.env, { piKey: true });
|
|
const host = { business: 'acme', beforeClose: () => {}, op: async x => {
|
|
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
|
if (x.op === 'mayLaunch') return { allow: true };
|
|
if (x.op === 'authorizeLaunch') throw Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
|
return {};
|
|
} };
|
|
const absent = join(dir, 'absent.json');
|
|
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null }, write => {
|
|
// fsthrows: its preflight reads a missing file, so the error's message quotes the path.
|
|
write('harness', 'fsthrows', `export * from ${PI};\nimport { readFileSync } from "node:fs";\nexport function preflight() { readFileSync(${JSON.stringify(absent)}); }\n`);
|
|
// plainthrows: a plain Error whose message quotes a host path, as a module's own text.
|
|
write('harness', 'plainthrows', `export * from ${PI};\nexport function preflight() { throw new Error(${JSON.stringify(`cannot read ${absent}`)}); }\n`);
|
|
// pathfails: a preflight that returns a failure whose message quotes a host path.
|
|
write('harness', 'pathfails', `export * from ${PI};\nexport function preflight() { return { code: "harness-binary-missing", message: ${JSON.stringify(`no binary at ${absent}; see file://${absent}`)} }; }\n`);
|
|
});
|
|
const system = JSON.parse(readFileSync(f.config, 'utf8'));
|
|
const launcher = createLauncher({ host, system, env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
|
|
const last = () => readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').map(l => JSON.parse(l)).at(-1);
|
|
return { root, f, launch: () => launcher.fromSession('fixture-cap', 'coder'), last };
|
|
}
|
|
|
|
test('a harness whose preflight throws a plain Error naming a path refuses harness-failed by its class, never its message', async t => {
|
|
const s = await routed(t, 'plainthrows');
|
|
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
|
|
assert.equal(s.last().why, 'harness plainthrows preflight: threw Error without a system code');
|
|
clean(JSON.stringify(s.last()), s.root);
|
|
});
|
|
|
|
test('a preflight failure whose message quotes a path keeps its code and relays the text with the path replaced', async t => {
|
|
const s = await routed(t, 'pathfails');
|
|
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-binary-missing' });
|
|
assert.equal(s.last().why, 'no binary at a host path; see a host path');
|
|
clean(JSON.stringify(s.last()), s.root);
|
|
});
|
|
|
|
test('the claude-code preflight names MOSAIC_CLAUDE_BIN, never its value', async t => {
|
|
const { root, dir } = scratch(t);
|
|
const f = fixture(dir, 'acme', doc => {
|
|
doc.roles.coder.vars = { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'high' };
|
|
return doc;
|
|
});
|
|
initializeControl(f.dataRoot);
|
|
withoutHostCredentials(f.env);
|
|
f.env.CLAUDE_CODE_OAUTH_TOKEN = 'fixture-only';
|
|
f.env.MOSAIC_CLAUDE_BIN = join(dir, 'absent-claude');
|
|
const host = { business: 'acme', beforeClose: () => {}, op: async x => x.op === 'identity' ? { business: 'acme', role: 'pm' } : x.op === 'mayLaunch' ? { allow: true } : {} };
|
|
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null });
|
|
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
|
|
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'harness-binary-missing' });
|
|
const last = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
|
assert.equal(last.why, "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path");
|
|
clean(JSON.stringify(last), root);
|
|
});
|
|
|
|
test('relay replaces absolute, home and file: paths and keeps the rest of the text', () => {
|
|
assert.equal(relay(`bad ${'/srv/x/' + secret} and ~/y, (file:///z) here`), 'bad a host path and a host path, (a host path) here');
|
|
assert.equal(relay('fatal: not a git repository: .git; see https://example.test/a'), 'fatal: not a git repository: .git; see https://example.test/a');
|
|
});
|
|
|
|
test('a harness whose preflight throws a system error refuses harness-failed naming the call and code, never the path', async t => {
|
|
const s = await routed(t, 'fsthrows');
|
|
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
|
|
assert.equal(s.last().why, 'harness fsthrows preflight: cannot open a host file (ENOENT)');
|
|
clean(JSON.stringify(s.last()), s.root);
|
|
});
|
|
|
|
test('a harness the loader refuses names its code only, never the loader message or a path', async t => {
|
|
const s = await routed(t, 'absent');
|
|
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-refused' });
|
|
assert.match(s.last().why, /^harness absent: [a-z][a-z0-9-]*$/);
|
|
clean(JSON.stringify(s.last()), s.root);
|
|
});
|
|
|
|
test('the halt gate refusal names the instance, never its data root, and keeps exit 1 and the count', async t => {
|
|
const { root, dir } = scratch(t);
|
|
const result = { version: 1, generation: '11111111-1111-4111-8111-111111111111', operation: '11111111-1111-4111-8111-111111111111', business: 'demo', human: 'jason', at: '2026-10-20T00:00:00.000Z', recovered: false, halted: true, quiescent: false, remaining: 2, stopped: 0 };
|
|
const x = io();
|
|
await assert.rejects(main(['halt', '--business', 'demo'], x, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => result }), e => {
|
|
assert.equal(e.exitCode, 1);
|
|
assert.equal(e.message, 'halt gate is on for this instance; 2 stops unconfirmed. Use mosaic processing status');
|
|
clean(e.message, root);
|
|
return true;
|
|
});
|
|
const odd = io();
|
|
await assert.rejects(main(['halt', '--business', 'demo'], odd, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => ({ ...result, remaining: join(dir, 'x') }) }), e => {
|
|
assert.equal(e.message, 'halt gate is on for this instance; unknown stops unconfirmed. Use mosaic processing status');
|
|
clean(e.message, root);
|
|
return true;
|
|
});
|
|
});
|
|
|
|
test('a system config problem names the config by label and never relays mosaic-config stderr', t => {
|
|
const { root, dir } = scratch(t);
|
|
// Missing: the label for where it came from, with the bootstrap hint.
|
|
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir, MOSAIC_CONFIG: join(dir, 'absent.json') } }), e => {
|
|
assert.ok(e instanceof CliError, e.stack);
|
|
assert.equal(e.exitCode, 3);
|
|
assert.equal(e.message, 'system config problem (mosaic-config exit 3): configuration not found: the system config MOSAIC_CONFIG names (run scripts/bootstrap.sh to create it)');
|
|
clean(e.message, root);
|
|
return true;
|
|
});
|
|
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir } }), e => {
|
|
assert.match(e.message, /^system config problem \(mosaic-config exit 3\): configuration not found: the default system config \(run scripts\/bootstrap\.sh to create it\)$/);
|
|
clean(e.message, root);
|
|
return true;
|
|
});
|
|
// A field whose value quotes the sentinel: the field and its constraint, never the value.
|
|
const f = fixture(dir, 'acme');
|
|
const doc = JSON.parse(readFileSync(f.config, 'utf8'));
|
|
writeFileSync(f.config, JSON.stringify({ ...doc, dataRoot: `relative/${secret}` }));
|
|
assert.throws(() => loadSystem({ env: f.env }), e => {
|
|
assert.equal(e.exitCode, 3);
|
|
assert.match(e.message, /^system config problem \(mosaic-config exit \d+\): dataRoot: expected a canonical absolute instance data path: the system config MOSAIC_CONFIG names$/);
|
|
clean(e.message, root);
|
|
return true;
|
|
});
|
|
});
|
|
|
|
// scripts/mosaic-config.mjs run standalone: every refusal names the config by where it came from,
|
|
// the data root and its marker by their roles, and a key only when it is a plain name. Exit codes
|
|
// are unchanged: 2 invalid, 3 missing or unclaimed, 4 usage.
|
|
const CONFIG_SCRIPT = new URL('../../../scripts/mosaic-config.mjs', import.meta.url).pathname;
|
|
function mosaicConfig(home, env, ...args) {
|
|
return spawnSync(process.execPath, [CONFIG_SCRIPT, ...args], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: home, ...env } });
|
|
}
|
|
function refusedBy(r, status, text, root) {
|
|
assert.equal(r.status, status, r.stderr);
|
|
assert.equal(r.stdout, '');
|
|
assert.ok(r.stderr.includes(text), r.stderr);
|
|
clean(r.stderr, root);
|
|
}
|
|
|
|
test('mosaic-config refusals name the config, data root and keys by label, never a host path or value', t => {
|
|
const { root, dir } = scratch(t);
|
|
const file = join(dir, 'config.json');
|
|
const env = { MOSAIC_CONFIG: file };
|
|
writeFileSync(file, 'not json');
|
|
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'mosaic-config: configuration is not valid JSON (the system config MOSAIC_CONFIG names)', root);
|
|
const good = { configVersion: 1, environment: 'development', dataRoot: join(dir, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash' } };
|
|
writeFileSync(file, JSON.stringify({ ...good, [join(dir, 'k')]: 1 }));
|
|
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'unsupported configuration key: that is not a plain name', root);
|
|
writeFileSync(file, JSON.stringify({ ...good, console: { [join(dir, 'k')]: 1 } }));
|
|
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'console has a key that is not a plain name', root);
|
|
writeFileSync(file, JSON.stringify({ ...good, dataRoot: dir }));
|
|
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'execution "dataRoot" must not be or contain the home directory', root);
|
|
writeFileSync(join(dir, 'real.json'), JSON.stringify(good));
|
|
const link = join(dir, 'link.json');
|
|
symlinkSync(join(dir, 'real.json'), link);
|
|
refusedBy(mosaicConfig(dir, { MOSAIC_CONFIG: link }, 'validate'), 2, 'configuration path must be a regular, non-symbolic-link file: the system config MOSAIC_CONFIG names', root);
|
|
// A named instance: MOSAIC_CONFIG naming another file is usage (4); an unmarked root is 3.
|
|
refusedBy(mosaicConfig(dir, env, 'validate', '--instance', 'alpha'), 4, 'instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file', root);
|
|
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', join(dir, 'x')), 4, 'instance-name-invalid: that name must match', root);
|
|
const named = join(dir, '.config', 'mosaic-alpha');
|
|
mkdirSync(named, { recursive: true, mode: 0o700 });
|
|
mkdirSync(join(dir, '.mosaic-alpha'), { mode: 0o700 });
|
|
writeFileSync(join(named, 'config.json'), JSON.stringify({ ...good, dataRoot: join(dir, '.mosaic-alpha') }));
|
|
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, 'instance-root-unclaimed: the data root holds no instance.json naming alpha', root);
|
|
mkdirSync(join(dir, '.mosaic-alpha', 'instance.json'));
|
|
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, "instance-root-unclaimed: the data root's instance marker must be a regular, non-symbolic-link file", root);
|
|
});
|