diff --git a/packages/cli/src/access.mjs b/packages/cli/src/access.mjs index 85f8755d8..9ecdb2d29 100644 --- a/packages/cli/src/access.mjs +++ b/packages/cli/src/access.mjs @@ -154,11 +154,13 @@ function pidAlive(pid) { } } +// Names the lock by label: its path sits beside the access file in the config +// directory, and a refusal never quotes a host path (PR #1916). function lockRefusal(lock) { const pid = lockPid(lock); if (pid !== null && pidAlive(pid)) return BUSY; - if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`; - return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`; + if (pid === null) return `refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again`; + return `refused: access file lock is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`; } function takeLock(lock, fs) { diff --git a/packages/cli/src/accounts.mjs b/packages/cli/src/accounts.mjs index 7a723973b..3cf04f25e 100644 --- a/packages/cli/src/accounts.mjs +++ b/packages/cli/src/accounts.mjs @@ -37,8 +37,10 @@ function parse(args) { function owns(user, owner, providers) { return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss); } -function readOwner(root) { - const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => { +// The registry checked `id` against the id pattern, so the label may name it; the +// root is a host path and never reaches a refusal (PR #1916). +function readOwner(root, id) { + const owner = readJsonFile(join(root, 'owner.json'), `organization owner of ${id}`, st => { if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3); }); rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner'); @@ -92,7 +94,7 @@ export async function accountsCommand(args, io, deps) { // writing owner.json, so any missing owner must block removal too. for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) { let owner; - try { owner = readOwner(entry.root); } + try { owner = readOwner(entry.root, entry.id); } catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; } if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3); } diff --git a/packages/cli/src/dev-fixture.mjs b/packages/cli/src/dev-fixture.mjs index 8f0a5f4d8..459067d33 100644 --- a/packages/cli/src/dev-fixture.mjs +++ b/packages/cli/src/dev-fixture.mjs @@ -46,7 +46,7 @@ function noCredentials(env) { 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', `${path} is not a private ${directory ? 'directory' : 'file'} of this user`); + 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. @@ -56,19 +56,19 @@ export function readFixture(dataRoot) { 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', `${file} can't be opened (${e.code})`); } + 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', `${file} is not a private file of this user`); + 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', `${file} is not JSON`); } + } 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', `${file} names an endpoint that is not http://127.0.0.1:`); + if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:'); 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', `${file} is not a fixture record`); + throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record'); return doc; } diff --git a/packages/cli/src/git-socket.mjs b/packages/cli/src/git-socket.mjs index 1c547292d..394cc8ea8 100644 --- a/packages/cli/src/git-socket.mjs +++ b/packages/cli/src/git-socket.mjs @@ -79,12 +79,13 @@ import { spawn } from "node:child_process"; import { randomBytes } from "node:crypto"; import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs"; import { createServer } from "node:net"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs"; import { gitSocketPath } from "../../seat/src/session.mjs"; import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs"; import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs"; import { CliError } from "./errors.mjs"; +import { ID_PATTERN } from "../../organization/src/vocabulary.mjs"; import { withProcessing } from '../../processing-control/src/effects.mjs'; const HEADER_MAX = 4096; @@ -385,7 +386,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env if (!p) throw new Refusal("unknown-project"); mkdirSync(logs, { recursive: true, mode: 0o700 }); prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) }); - if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`); + if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `project ${facts.project} isn't a mosaic-stack checkout with node_modules`); symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules")); } catch (e) { if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`)); @@ -398,7 +399,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) }); scope.guard(); if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved"); - if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`); + // The detail reaches the role, so the log is named inside the bus host directory, never by + // host path (PR #1916); its file name only when it passes the id pattern. + if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see land/${id}${ID_PATTERN.test(basename(r.log ?? "")) ? `/${basename(r.log)}` : ""} in the bus host directory`); await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) }); } finally { await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`)); @@ -503,7 +506,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env async function listen() { if (existsSync(path)) { - if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3); + if (!lstatSync(path).isSocket()) throw new CliError("git socket path exists and isn't a socket", 3); rmSync(path); } server = createServer((socket) => { diff --git a/packages/cli/src/host.mjs b/packages/cli/src/host.mjs index 8e851a01f..62bc7d3be 100644 --- a/packages/cli/src/host.mjs +++ b/packages/cli/src/host.mjs @@ -54,10 +54,10 @@ function readState(file, found = () => {}) { state = JSON.parse(readFileSync(file, "utf8")); } catch (e) { if (e.code === "ENOENT") return null; - throw new CliError(`bus host state is unreadable: ${file}`, 3); + throw new CliError("bus host state is unreadable", 3); } if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") { - throw new CliError(`bus host state is malformed: ${file}`, 3); + throw new CliError("bus host state is malformed", 3); } return { ...state, live: startTimeOf(state.pid) === state.startTime }; } @@ -94,7 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = () const prior = readState(file, found); if (!prior) continue; if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3); - if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3); + if (refuseStale) throw new CliError("stale bus host lock", 3); between(); try { renameSync(file, aside); @@ -111,7 +111,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = () } rmSync(aside, { force: true }); } - throw new CliError(`${file} kept changing; no bus host started`, 3); + throw new CliError("bus host lock kept changing; no bus host started", 3); } finally { rmSync(tmp, { force: true }); } diff --git a/packages/cli/src/instances.mjs b/packages/cli/src/instances.mjs index cf4dcc1d0..95c01ae4e 100644 --- a/packages/cli/src/instances.mjs +++ b/packages/cli/src/instances.mjs @@ -120,7 +120,9 @@ function setArgs(parsed) { try { args.vars = JSON.parse(readFileSync(values["--vars"], "utf8")); } catch (error) { - throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4); + // Name the flag and the error kind, never the file's path or bytes (PR #1916). + const code = typeof error?.code === "string" && /^E[A-Z0-9]+$/.test(error.code) ? error.code : "unreadable"; + throw new CliError(`--vars file: ${error instanceof SyntaxError ? "not valid JSON" : code}`, 4); } } if (launch !== undefined) args.launch = launch; diff --git a/packages/cli/src/notifier.mjs b/packages/cli/src/notifier.mjs index 2f34656c4..396a23fc2 100644 --- a/packages/cli/src/notifier.mjs +++ b/packages/cli/src/notifier.mjs @@ -162,28 +162,28 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = { mkdirSync(dir, { recursive: true, mode: 0o700 }); } catch (e) { // A dangling link in place of the directory fails mkdir with ENOENT. - if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3); - if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3); + if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3); + if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code})`, 3); if (e.code !== "EEXIST") throw e; } const ds = lstatSync(dir); - if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3); + if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3); if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) { - throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3); + throw new CliError(`notify journal directory must be mode 0700 and owned by this user`, 3); } try { accessSync(dir, constants.W_OK | constants.X_OK); } catch (e) { - if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code}): ${dir}`, 3); + if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code})`, 3); throw e; } let fd; try { fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600); } catch (e) { - if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3); - if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3); - if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3); + if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink`, 3); + if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code})`, 3); + if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3); throw e; } const sent = new Set(); @@ -204,7 +204,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = { try { const st = fstatSync(fd); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) { - throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3); + throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3); } const bytes = readFileSync(fd); const end = bytes.lastIndexOf(0x0a) + 1; @@ -218,7 +218,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = { r = null; } const bad = r === null ? "json" : badField(r); - if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad}): ${file}`, 3); + if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad})`, 3); count(r); }); if (end < bytes.length) { diff --git a/packages/cli/src/notify-config.mjs b/packages/cli/src/notify-config.mjs index 83599ae05..8eb2a874c 100644 --- a/packages/cli/src/notify-config.mjs +++ b/packages/cli/src/notify-config.mjs @@ -6,27 +6,28 @@ import { CliError } from "./errors.mjs"; // The notifier's private config: `/notify//notify.json`, // 0600, `{"notifyVersion": 1, "binding": "" | null}`. // Missing refuses, so a host never starts without someone deciding whether -// it DMs; null runs the host without a notifier. +// it DMs; null runs the host without a notifier. Refusals name the business +// and the path under the data root, never the host path (PR #1916). export function readNotifyConfig(dataRoot, business) { const file = join(dataRoot, "notify", business, "notify.json"); let st; try { st = lstatSync(file); } catch { - throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": ""} there, mode 0600, or "binding": null to run without DMs`, 3); + throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": ""} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3); } if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) { - throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3); + throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3); } let doc; try { doc = JSON.parse(readFileSync(file, "utf8")); } catch { - throw new CliError(`notifier config is not JSON: ${file}`, 3); + throw new CliError(`notifier config for ${business} is not JSON`, 3); } const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : ""; if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) { - throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": | null}: ${file}`, 3); + throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": | null}`, 3); } return doc.binding; } diff --git a/packages/cli/src/org-bus.mjs b/packages/cli/src/org-bus.mjs index 1f866ba5c..a955d05f5 100644 --- a/packages/cli/src/org-bus.mjs +++ b/packages/cli/src/org-bus.mjs @@ -138,7 +138,9 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false, await new Promise(r => setTimeout(r, 50)); } if (!exited) { child.kill('SIGTERM'); await done; } - throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3); + // 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); diff --git a/packages/cli/src/org-console.mjs b/packages/cli/src/org-console.mjs index 02b4a33b0..b7d3d9397 100644 --- a/packages/cli/src/org-console.mjs +++ b/packages/cli/src/org-console.mjs @@ -4,7 +4,7 @@ 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, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { basename, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs'; import { cmdlineOf, startTimeOf } from './host.mjs'; @@ -95,7 +95,8 @@ async function launch(ctx, args, lan, detach) { } await sleep(50); } - fail(`console failed to start; see ${log}`); + // The log is named under the data root, never by host path (PR #1916). + fail(`console failed to start; see console/${basename(log)} under the data root`); } catch (e) { if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; } throw e; @@ -193,11 +194,8 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } = const argv = cmdlineOf(s.pid); if (!argv || !argv.length) fail('console process identity is uncertain'); const i = argv.indexOf(WEBUI); - if (i < 0 || argv[i + 1] !== 'serve') { - const serve = argv.indexOf('serve'); - const entry = serve > 0 ? ` ${argv[serve - 1]}` : ''; - fail(`console was started from another path${entry}; stop it there`); - } + // The other entry script is a host path, so the refusal never names it (PR #1916). + if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there'); process.kill(s.pid, 'SIGTERM'); const deadline = Date.now() + 20000; while (Date.now() < deadline) { diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index 7315ae5e5..22503dff9 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -11,7 +11,9 @@ import { consoleBusRestart } from './org-console.mjs'; import { refuseInsideAgent } from './transport.mjs'; export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]'; function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; } -async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } } +// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916). +const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable'; +async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } } export async function orgCommand(args, io, deps = {}) { try { const p = parse(args); const words = p.pos; let result; let accepted = []; @@ -41,5 +43,11 @@ export async function orgCommand(args, io, deps = {}) { else throw new CliError(ORG_USAGE, 4); } else throw new CliError(ORG_USAGE, 4); io.stdout.write(JSON.stringify(result, null, 2) + '\n'); - } catch (e) { if (e instanceof CliError) throw e; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); } + } catch (e) { + if (e instanceof CliError) throw e; + // A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation + // and code, never by the host path in its message (PR #1916). + const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message; + throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2); + } } diff --git a/packages/cli/src/run-evidence.mjs b/packages/cli/src/run-evidence.mjs index fe4b63f44..9f2bc9fa6 100644 --- a/packages/cli/src/run-evidence.mjs +++ b/packages/cli/src/run-evidence.mjs @@ -63,7 +63,7 @@ const inside = (root, path) => { const r = relative(root, path); return r !== '' export function threadRecords(file, thread) { const log = fileEvidence(file); if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 }; - if (!log.ok) throw new CliError(`run-evidence-refused: launch log ${file}: ${log.problem}`, 3); + if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3); const launches = [], ends = []; let malformed = 0; log.bytes.toString('utf8').split('\n').forEach((line, i) => { if (!line) return; @@ -186,7 +186,7 @@ export async function runCommand(rest, io, deps = {}) { lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)'); io.stdout.write(`${lines.join('\n')}\n`); } - if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in ${log.file}`, 2); + if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2); if (verify && !verified) throw new CliError('run evidence failed verification', 1); return report; } diff --git a/packages/cli/tests/access.test.mjs b/packages/cli/tests/access.test.mjs index 30b6ccdc2..279a2ef0f 100644 --- a/packages/cli/tests/access.test.mjs +++ b/packages/cli/tests/access.test.mjs @@ -148,7 +148,7 @@ test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", a const dead = spawnSync(process.execPath, ["-e", ""]); writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 }); const before = readFileSync(s.file, "utf8"); - await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`)); + await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`)); assert.equal(readFileSync(s.file, "utf8"), before); assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`); assert.equal(s.receipts(), ""); @@ -165,13 +165,14 @@ test("a failed rename leaves the old bytes, removes the temp file, and writes no assert.equal(s.receipts(), ""); }); -test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => { +test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => { const s = setup(t); chmodSync(s.file, 0o664); const before = readFileSync(s.file, "utf8"); const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/); assert.equal(err.exitCode, 3); - assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))); + assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)"); + assert.ok(!err.message.includes(s.file), err.message); assert.equal(readFileSync(s.file, "utf8"), before); assert.equal(s.receipts(), ""); assert.equal(existsSync(`${s.file}.lock`), false); diff --git a/packages/cli/tests/accounts.test.mjs b/packages/cli/tests/accounts.test.mjs index 1b2dae7a6..cc81f6f87 100644 --- a/packages/cli/tests/accounts.test.mjs +++ b/packages/cli/tests/accounts.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs'; +import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { accountsCommand } from '../src/accounts.mjs'; @@ -101,3 +101,16 @@ test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any l chmodSync(parent, 0o1777); await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/); }); +// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked +// id, never by the organization root's host path. +test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => { + const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; + const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id; + const root = join(s.configDir, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, mode: 0o700 }); + await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{not json', { mode: 0o600 }); }); + const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; }; + await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/)); + rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json')); + await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/)); + assert.equal(s.read().users.length, 2, 'nothing was removed'); +}); diff --git a/packages/cli/tests/boot-status.test.mjs b/packages/cli/tests/boot-status.test.mjs index 6bd1dad97..5e5a9ee70 100644 --- a/packages/cli/tests/boot-status.test.mjs +++ b/packages/cli/tests/boot-status.test.mjs @@ -72,7 +72,9 @@ test("an unknown key and a JSON syntax error each leave the bus down with the re assert.equal(s.business, "acme"); assert.equal(s.file, file); assert.match(s.reason, reason); - assert.ok(s.reason.includes(file) || /surprise/.test(s.reason)); + // The reason names the file by label, never by host path (PR #1916); the path stays in `file`. + assert.ok(!s.reason.includes(file), s.reason); + assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason); // `bus status` shows it, in text and in JSON. const out = run(f, "bus", "status", "acme", "--json"); assert.equal(out.status, 0, out.stderr); diff --git a/packages/cli/tests/git-socket.test.mjs b/packages/cli/tests/git-socket.test.mjs index a5d4fb29d..38b9cf8d0 100644 --- a/packages/cli/tests/git-socket.test.mjs +++ b/packages/cli/tests/git-socket.test.mjs @@ -804,13 +804,27 @@ test("land: a failed gate is refused gate-failed with its step, told to the role assert.equal(ticket, TICKET); assert.equal(a.code, "gate-failed"); assert.equal(a.notify, true); - assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/); + assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12}\/pkg-bus\.log in the bus host directory$/); assert.equal(w.git("rev-parse", "refs/heads/main"), w.main); assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move"); assert.deepEqual(w.of("recordLand"), []); assert.equal(w.trees(), 0); }); +// PR #1916 (org-redact5): the detail reaches the role, so it never carries the log's host +// path; a log file name outside the id pattern is dropped too. +test("land: a failed gate's detail names the land directory, never the log's host path", async (t) => { + const SENTINEL = "sk-test-NOT-A-SECRET-1234"; + let log = null; + const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: (log = join(logs, SENTINEL, `${SENTINEL}.log`)) }) } }); + await w.land(); + await w.ended(); + const [[, a]] = w.of("landRefused"); + assert.equal(a.code, "gate-failed"); + assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12} in the bus host directory$/); + assert.ok(!a.detail.includes(SENTINEL) && !a.detail.includes(log) && !a.detail.includes(w.f.dataRoot), a.detail); +}); + test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => { const w = await landSetup(t); w.state.recordFails = "decision-consumed"; diff --git a/packages/cli/tests/host.test.mjs b/packages/cli/tests/host.test.mjs index f2343ca40..fa30ffc65 100644 --- a/packages/cli/tests/host.test.mjs +++ b/packages/cli/tests/host.test.mjs @@ -315,7 +315,7 @@ test("a host.lock that goes between the check and the read is tried again; one t assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]); writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 }); - assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`); + assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === "bus host state is unreadable"); assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json"); assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]); }); @@ -749,8 +749,14 @@ test("a stale organization writer lock refuses start and status without removing }); test("organization bus start refuses without a notifier config", async t => { + // PR #1916 (org-redact5): the refusal names the log inside the organization's + // runtime directory, never by host path. (A sentinel directory in the root would + // push the bus socket path past the socket length limit, so the check is on the + // fixture's own roots.) const f = await orgFixture(t, { notify: false }); - await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i); + await assert.rejects(busStart(f.ctx, "acme", { detach: true }), (e) => e.exitCode === 3 + && e.message === "bus failed to start; see bus-host/host.log in the runtime directory of organization acme" + && !e.message.includes(f.dataRoot) && !e.message.includes(f.instanceRoot)); assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/); assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false); }); diff --git a/packages/cli/tests/json-redaction.test.mjs b/packages/cli/tests/json-redaction.test.mjs index b028356ad..bbcffd626 100644 --- a/packages/cli/tests/json-redaction.test.mjs +++ b/packages/cli/tests/json-redaction.test.mjs @@ -21,10 +21,15 @@ function fixture(t) { }; return { root, file, env, run, boot }; } -function redacted(result, file, status) { +// label: the CLI refusal names the file by this label and never by host path (PR #1916). +// Without it, the refusal comes from scripts/mosaic-config.mjs, which still names the file. +function redacted(result, file, status, label) { assert.equal(result.status, status, result.stderr); assert.equal(result.stdout, ''); - assert.ok(result.stderr.includes(file), result.stderr); + if (label) { + assert.ok(!result.stderr.includes(file), result.stderr); + assert.ok(result.stderr.includes(label), result.stderr); + } else assert.ok(result.stderr.includes(file), result.stderr); assert.match(result.stderr, /not valid JSON/); assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/); } @@ -40,7 +45,7 @@ test('system config parse refusal and CLI relay omit parser bytes', t => { assert.doesNotMatch(error.message, /sk-test|Unexpected token/); return true; }); - redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3); + redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3, 'configuration is not valid JSON; expected readable JSON: the system config MOSAIC_CONFIG names'); }); test('named instance marker parse refusal omits parser bytes', t => { @@ -55,5 +60,5 @@ for (const operation of ['validate', 'create', 'update']) test(`org ${operation} const file = join(f.root, 'alpha.json'); writeFileSync(file, secret); const args = operation === 'validate' ? ['validate', file] : [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])]; - redacted(f.run(cli, 'org', ...args), file, 2); + redacted(f.run(cli, 'org', ...args), file, 2, 'mosaic: cannot read organization document: not valid JSON\n'); }); diff --git a/packages/cli/tests/last-values.test.mjs b/packages/cli/tests/last-values.test.mjs index 563d57602..f7e823f3c 100644 --- a/packages/cli/tests/last-values.test.mjs +++ b/packages/cli/tests/last-values.test.mjs @@ -46,14 +46,14 @@ for (const [field, edit] of configCases) test(`config ${field} omits the configu test('config-command refuses system config without relaying stderr file values', t => { const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d)); let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } }); - assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/); + assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(!err.includes(f.config), err); assert.match(err, /the system config MOSAIC_CONFIG names/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/); }); test('project id mismatch omits the file value in config-command', t => { const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json'); mkdirSync(join(f.doc.projects.stack.root, '.mosaic')); writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() })); let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } }); - assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); + assert.equal(status, 2); assert.ok(!err.includes(file), err); assert.match(err, /project file of project stack has id that does not match the declared project key/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); }); for (const [field, edit] of [ ['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }], @@ -80,7 +80,7 @@ cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdou const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)}); process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`; const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } }); - assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/); + assert.equal(r.status, 3, r.stderr); assert.ok(!r.stderr.includes(file), r.stderr); assert.match(r.stderr, /: the system config MOSAIC_CONFIG names\n$/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/); }); import { bootConfig, loadSystem } from '../src/config.mjs'; diff --git a/packages/cli/tests/notifier.test.mjs b/packages/cli/tests/notifier.test.mjs index 7514c1073..6de3da1d0 100644 --- a/packages/cli/tests/notifier.test.mjs +++ b/packages/cli/tests/notifier.test.mjs @@ -358,7 +358,7 @@ test("the journal: a line with a wrong type refuses with exit 3 and names the fi for (const [field, r] of bad) { const text = `${JSON.stringify(dm)}\n${JSON.stringify(r)}\n`; writeFileSync(file, text); - assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field}): ${file}`, `${field}: ${JSON.stringify(r)}`); + assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field})` && !e.message.includes(file), `${field}: ${JSON.stringify(r)}`); assert.equal(readFileSync(file, "utf8"), text, "a refusal changes nothing"); } const good = [dm, digest, { ...dm, outcome: "refused", messageId: null, status: 403 }, { ...dm, outcome: "gave-up", messageId: null }, { ...digest, outcome: "unknown", messageId: null }]; @@ -377,7 +377,7 @@ test("the journal: a symlinked directory refuses and says it is a link", (t) => const file = journalPath(root, "demo"); mkdirSync(dirname(dirname(file)), { mode: 0o700 }); symlinkSync(real, dirname(file)); - assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(file)}`); + assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(file))); assert.deepEqual(readdirSync(real), [], "nothing was created through the link"); }); @@ -386,13 +386,13 @@ test("the journal: a dangling directory link, a parent that is a file and a jour const dangling = journalPath(root, "demo"); mkdirSync(dirname(dirname(dangling)), { mode: 0o700 }); symlinkSync(join(root, "missing"), dirname(dangling)); - assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(dangling)}`); + assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(dangling))); const under = join(root, "plain", "notify", "demo", "sent.jsonl"); writeFileSync(join(root, "plain"), ""); - assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR): ${dirname(under)}`); + assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR)` && !e.message.includes(dirname(under))); const asDir = journalPath(root, "acme"); mkdirSync(asDir, { recursive: true, mode: 0o700 }); - assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user: ${asDir}`); + assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user` && !e.message.includes(asDir)); }); test("the journal: an append after the file was swapped for a symlink refuses and writes nothing through it", (t) => { @@ -414,18 +414,18 @@ test("the journal: a directory it cannot write or create refuses with exit 3 and // Modes are restored in finally: tmp()'s cleanup runs first among the after hooks. chmodSync(dirname(file), 0o500); try { - assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES): ${dirname(file)}`); + assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES)` && !e.message.includes(dirname(file))); } finally { chmodSync(dirname(file), 0o700); } openJournal(file); chmodSync(file, 0o400); - assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES): ${file}`); + assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES)` && !e.message.includes(file)); const parent = join(root, "notify"); const other = journalPath(root, "acme"); chmodSync(parent, 0o500); try { - assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES): ${dirname(other)}`); + assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES)` && !e.message.includes(dirname(other))); } finally { chmodSync(parent, 0o700); } diff --git a/packages/cli/tests/org-recovery.test.mjs b/packages/cli/tests/org-recovery.test.mjs index 1f9664d6e..e2309f03e 100644 --- a/packages/cli/tests/org-recovery.test.mjs +++ b/packages/cli/tests/org-recovery.test.mjs @@ -40,8 +40,10 @@ test('console stop quarantines dead and wrong-process states without signaling', }); test('live console from another entry path remains recorded and is not signalled', { timeout: 30000 }, async t => { - const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, 'console.mjs'); - mkdirSync(dir, { mode: 0o700 }); + // The other entry sits under the sentinel directory: the refusal must name neither (PR #1916). + const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; + const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, SENTINEL, 'console.mjs'); + mkdirSync(dir, { mode: 0o700 }); mkdirSync(join(f.root, SENTINEL), { mode: 0o700 }); symlinkSync(new URL('../../webui/src/cli.mjs', import.meta.url).pathname, alias); const child = spawn(process.execPath, [alias, 'serve', '--port', '0'], { env: humanEnv({ HOME: f.root, MOSAIC_CONFIG: join(f.ctx.configDir, 'config.json') }), stdio: ['ignore', 'pipe', 'pipe'] }); t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const ended = once(child, 'exit'); child.kill('SIGTERM'); await ended; } }); @@ -53,7 +55,7 @@ test('live console from another entry path remains recorded and is not signalled const record = { pid: child.pid, startTime: startTimeOf(child.pid), url }; json(join(dir, 'console.json'), record); await assert.rejects(consoleStart(f.ctx), /console state exists/); - await assert.rejects(consoleStop(f.ctx), e => /another path/.test(e.message) && e.message.includes(alias)); + await assert.rejects(consoleStop(f.ctx), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'console was started from another checkout; stop it there'); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(alias), e.message); return true; }); assert.deepEqual(JSON.parse(readFileSync(join(dir, 'console.json'), 'utf8')), record); assert.equal(startTimeOf(child.pid), record.startTime); assert.equal(readdirSync(dir).filter(x => x.startsWith('console.json.stale-')).length, 0); diff --git a/packages/cli/tests/path-redaction.test.mjs b/packages/cli/tests/path-redaction.test.mjs new file mode 100644 index 000000000..e2b17c737 --- /dev/null +++ b/packages/cli/tests/path-redaction.test.mjs @@ -0,0 +1,312 @@ +// PR #1916 redaction (org-redact5): a CLI refusal names the file by its label, +// never by host path. Every fixture here sits under a directory named for the +// sentinel, so a refusal that quoted the path would carry it. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createServer } from "node:net"; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { gitSocketPath } from "../../seat/src/session.mjs"; +import { withLock } from "../src/access.mjs"; +import { fixtureFile, readFixture } from "../src/dev-fixture.mjs"; +import { createGitSocket } from "../src/git-socket.mjs"; +import { consoleStart, consoleStop } from "../src/org-console.mjs"; +import { claimHostLock, hostLock } from "../src/host.mjs"; +import { journalPath, openJournal } from "../src/notifier.mjs"; +import { readNotifyConfig } from "../src/notify-config.mjs"; +import { orgCommand } from "../src/org.mjs"; +import { instancesCommand } from "../src/instances.mjs"; +import { FIXTURE_ENTRY } from "../../plugins/model-provider/fixture/index.mjs"; + +const SENTINEL = "sk-test-NOT-A-SECRET-1234"; +const ROOT_SKIP = process.getuid() === 0 && "root ignores the modes"; + +function under(t) { + const top = mkdtempSync(join(tmpdir(), "redact-")); + t.after(() => rmSync(top, { recursive: true, force: true })); + const root = join(top, SENTINEL); + mkdirSync(root, { mode: 0o700 }); + return root; +} + +// The refusal is exactly `message` (a string) or matches it (a RegExp), with exit +// code `exitCode`, and carries neither the sentinel nor any of `paths`. +function refusal(message, { exitCode = 3, paths = [], code } = {}) { + return (error) => { + assert.equal(error.name, "CliError"); + assert.equal(error.exitCode, exitCode); + if (code !== undefined) assert.equal(error.code, code); + if (message instanceof RegExp) assert.match(error.message, message); + else assert.equal(error.message, message); + assert.ok(!error.message.includes(SENTINEL), error.message); + for (const path of paths) assert.ok(!error.message.includes(path), error.message); + return true; + }; +} + +// --- access.json lock --- + +test("access lock: an unreadable lock is named by label, never by host path", (t) => { + const file = join(under(t), "access.json"); + writeFileSync(`${file}.lock`, "not-a-pid\n", { mode: 0o600 }); + assert.throws(() => withLock(file, () => assert.fail("must refuse")), + refusal("refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again", { paths: [file] })); +}); + +test("access lock: a lock left by a dead pid is named by label, never by host path", (t) => { + const file = join(under(t), "access.json"); + const dead = spawnSync(process.execPath, ["-e", ""]); + writeFileSync(`${file}.lock`, `${dead.pid}\n`, { mode: 0o600 }); + assert.throws(() => withLock(file, () => assert.fail("must refuse")), + refusal(`refused: access file lock is left by pid ${dead.pid}, which is not running; if no write is running, remove it by hand and try again`, { paths: [file] })); +}); + +// --- bus host lock --- + +const MINE = { pid: process.pid, startTime: "1", business: "acme", startedAt: "2026-11-10T00:00:00.000Z" }; +const STALE = { pid: 999999999, startTime: "1", business: "acme", startedAt: "2026-11-09T00:00:00.000Z" }; + +function hostRoot(t, lock) { + const dataRoot = join(under(t), "data"); + mkdirSync(join(dirname(hostLock(dataRoot))), { recursive: true, mode: 0o700 }); + if (lock !== undefined) writeFileSync(hostLock(dataRoot), lock, { mode: 0o600 }); + return dataRoot; +} + +test("host lock: an unreadable host.lock refuses by label, never by host path", (t) => { + const dataRoot = hostRoot(t, "{not json"); + assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is unreadable", { paths: [dataRoot] })); +}); + +test("host lock: a malformed host.lock refuses by label, never by host path", (t) => { + const dataRoot = hostRoot(t, JSON.stringify({ pid: "x" })); + assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is malformed", { paths: [dataRoot] })); +}); + +test("host lock: a stale host.lock refused on request is named by label, never by host path", (t) => { + const dataRoot = hostRoot(t, JSON.stringify(STALE)); + assert.throws(() => claimHostLock(dataRoot, MINE, { refuseStale: true }), refusal("stale bus host lock", { paths: [dataRoot] })); +}); + +test("host lock: a lock that keeps changing is named by label, never by host path", (t) => { + const dataRoot = hostRoot(t, JSON.stringify(STALE)); + let n = 0; + const race = () => writeFileSync(hostLock(dataRoot), JSON.stringify({ ...STALE, startedAt: `2026-11-09T00:00:${String(++n % 60).padStart(2, "0")}.000Z` }), { mode: 0o600 }); + assert.throws(() => claimHostLock(dataRoot, MINE, { between: race }), refusal("bus host lock kept changing; no bus host started", { paths: [dataRoot] })); +}); + +// --- notify journal --- + +test("notify journal: a directory link, dangling or not, is refused by label", (t) => { + const root = under(t); + const dangling = journalPath(root, "demo"); + mkdirSync(dirname(dirname(dangling)), { recursive: true, mode: 0o700 }); + symlinkSync(join(root, "missing"), dirname(dangling)); + assert.throws(() => openJournal(dangling), refusal("notify journal directory must not be a symlink", { paths: [dangling] })); + const real = join(root, "real"); + mkdirSync(real, { mode: 0o700 }); + const linked = journalPath(root, "acme"); + symlinkSync(real, dirname(linked)); + assert.throws(() => openJournal(linked), refusal("notify journal directory must not be a symlink", { paths: [linked] })); +}); + +test("notify journal: a parent that is a file is refused by its error code", (t) => { + const root = under(t); + writeFileSync(join(root, "plain"), ""); + const file = join(root, "plain", "notify", "demo", "sent.jsonl"); + assert.throws(() => openJournal(file), refusal("notify journal directory cannot be created (ENOTDIR)", { paths: [file] })); +}); + +test("notify journal: a directory of the wrong mode is refused by label", (t) => { + const file = journalPath(under(t), "demo"); + mkdirSync(dirname(file), { recursive: true, mode: 0o755 }); + chmodSync(dirname(file), 0o755); + assert.throws(() => openJournal(file), refusal("notify journal directory must be mode 0700 and owned by this user", { paths: [file] })); +}); + +test("notify journal: a directory it cannot write or create is refused by its error code", { skip: ROOT_SKIP }, (t) => { + const root = under(t); + const file = journalPath(root, "demo"); + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + chmodSync(dirname(file), 0o500); + try { + assert.throws(() => openJournal(file), refusal("notify journal directory is not writable (EACCES)", { paths: [file] })); + } finally { + chmodSync(dirname(file), 0o700); + } + const parent = dirname(dirname(file)); + const other = journalPath(root, "acme"); + chmodSync(parent, 0o500); + try { + assert.throws(() => openJournal(other), refusal("notify journal directory cannot be created (EACCES)", { paths: [other] })); + } finally { + chmodSync(parent, 0o700); + } +}); + +test("notify journal: a journal link, an unwritable journal and a directory journal are refused by label", { skip: ROOT_SKIP }, (t) => { + const root = under(t); + const file = journalPath(root, "demo"); + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + writeFileSync(join(root, "elsewhere.jsonl"), "", { mode: 0o600 }); + symlinkSync(join(root, "elsewhere.jsonl"), file); + assert.throws(() => openJournal(file), refusal("notify journal must not be a symlink", { paths: [file] })); + rmSync(file); + writeFileSync(file, "", { mode: 0o400 }); + chmodSync(file, 0o400); + assert.throws(() => openJournal(file), refusal("notify journal is not writable (EACCES)", { paths: [file] })); + const asDir = journalPath(root, "acme"); + mkdirSync(asDir, { recursive: true, mode: 0o700 }); + assert.throws(() => openJournal(asDir), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [asDir] })); +}); + +test("notify journal: a journal of the wrong mode and a malformed line are refused by label", (t) => { + const file = journalPath(under(t), "demo"); + mkdirSync(dirname(file), { recursive: true, mode: 0o700 }); + writeFileSync(file, "", { mode: 0o644 }); + chmodSync(file, 0o644); + assert.throws(() => openJournal(file), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [file] })); + chmodSync(file, 0o600); + writeFileSync(file, "not json\n"); + assert.throws(() => openJournal(file), refusal("notify journal line 1 is malformed (json)", { paths: [file] })); +}); + +// --- notifier config --- + +function notifyRoot(t, text, mode = 0o600) { + const dataRoot = join(under(t), "data"); + const dir = join(dataRoot, "notify", "acme"); + mkdirSync(dir, { recursive: true, mode: 0o700 }); + if (text !== undefined) { + writeFileSync(join(dir, "notify.json"), text, { mode }); + chmodSync(join(dir, "notify.json"), mode); + } + return dataRoot; +} + +test("notifier config: a missing file names the business and its place under the data root", (t) => { + const dataRoot = notifyRoot(t); + assert.throws(() => readNotifyConfig(dataRoot, "acme"), + refusal('no notifier config for acme: write {"notifyVersion": 1, "binding": ""} to notify/acme/notify.json under the data root, mode 0600, or "binding": null to run without DMs', { paths: [dataRoot] })); +}); + +test("notifier config: a file of the wrong mode names the business only", (t) => { + const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null }), 0o644); + assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme must be a regular file, mode 0600, owned by this user", { paths: [dataRoot] })); +}); + +test("notifier config: a file that is not JSON names the business only", (t) => { + const dataRoot = notifyRoot(t, "{not json"); + assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme is not JSON", { paths: [dataRoot] })); +}); + +test("notifier config: a file of the wrong shape names the business only", (t) => { + const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null, extra: 1 })); + assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal('notifier config for acme must be exactly {"notifyVersion": 1, "binding": | null}', { paths: [dataRoot] })); +}); + +// --- dev fixture --- + +const RECORD = { fixtureVersion: 1, model: "deterministic", endpoint: "http://127.0.0.1:45678", port: 45678, pid: 999999, instance: "fxtest", entry: FIXTURE_ENTRY }; + +function fixtureRoot(t, { record = RECORD, text = JSON.stringify(record), dirMode = 0o700, fileMode = 0o600 } = {}) { + const dataRoot = join(under(t), "data"); + const file = fixtureFile(dataRoot); + mkdirSync(dirname(file), { recursive: true, mode: dirMode }); + chmodSync(dirname(file), dirMode); + if (text !== null) { + writeFileSync(file, text, { mode: fileMode }); + chmodSync(file, fileMode); + } + return dataRoot; +} + +test("dev fixture: a shared fixture directory is refused by label", (t) => { + const dataRoot = fixtureRoot(t, { dirMode: 0o755 }); + assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: dev-fixture directory is not a private directory of this user", { code: "fixture-state-unsafe", paths: [dataRoot] })); +}); + +test("dev fixture: a linked fixture file is refused by label and error code", (t) => { + const dataRoot = fixtureRoot(t, { text: null }); + writeFileSync(join(dataRoot, "elsewhere.json"), JSON.stringify(RECORD), { mode: 0o600 }); + symlinkSync(join(dataRoot, "elsewhere.json"), fixtureFile(dataRoot)); + assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json can't be opened (ELOOP)", { code: "fixture-state-unsafe", paths: [dataRoot] })); +}); + +test("dev fixture: a shared fixture file is refused by label", (t) => { + const dataRoot = fixtureRoot(t, { fileMode: 0o644 }); + assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json is not a private file of this user", { code: "fixture-state-unsafe", paths: [dataRoot] })); +}); + +test("dev fixture: a fixture file that is not JSON is refused by label", (t) => { + const dataRoot = fixtureRoot(t, { text: "not json" }); + assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not JSON", { code: "fixture-state-invalid", paths: [dataRoot] })); +}); + +test("dev fixture: a non-loopback endpoint and a record of the wrong shape are refused by label", (t) => { + let dataRoot = fixtureRoot(t, { record: { ...RECORD, endpoint: "http://0.0.0.0:45678" } }); + assert.throws(() => readFixture(dataRoot), refusal("fixture-endpoint-not-loopback: fixture.json names an endpoint that is not http://127.0.0.1:", { code: "fixture-endpoint-not-loopback", paths: [dataRoot] })); + dataRoot = fixtureRoot(t, { record: { ...RECORD, port: 1 } }); + assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not a fixture record", { code: "fixture-state-invalid", paths: [dataRoot] })); +}); + +// --- git socket --- + +test("git socket: a path that exists and is not a socket is refused by label", async (t) => { + const dataRoot = join(under(t), "data"); + const path = gitSocketPath(dataRoot); + mkdirSync(dirname(path), { recursive: true, mode: 0o700 }); + writeFileSync(path, "", { mode: 0o600 }); + const sock = createGitSocket({ host: {}, business: { id: "acme", projects: {} }, dataRoot, env: {}, log: () => {} }); + await assert.rejects(sock.listen(), refusal("git socket path exists and isn't a socket", { paths: [dataRoot, path] })); +}); + +// --- console launch --- + +// The Console is given a port this test holds, so its listen fails and it exits at +// startup. Should it start anyway, it is stopped before the tree goes. +test("console: a Console that exits at startup is named by its log under the data root, never by host path", { timeout: 60000 }, async (t) => { + const root = under(t); + const ctx = { configDir: join(root, "config"), dataRoot: join(root, "data"), instance: "redact", env: { PATH: process.env.PATH, HOME: root } }; + for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 }); + const held = createServer(); + await new Promise((resolve, reject) => held.once("error", reject).listen(0, "127.0.0.1", resolve)); + try { + await assert.rejects(consoleStart(ctx, { detach: true, port: String(held.address().port), warn: () => {} }), + refusal(/^console failed to start; see console\/console-[0-9]+-[0-9]+\.log under the data root$/, { paths: [ctx.dataRoot] })); + } finally { + await consoleStop(ctx, { warn: () => {} }).catch(() => {}); + await new Promise((resolve) => held.close(resolve)); + } +}); + +// --- review round (org-redact5 Sol P2 #3): documents, --vars and raw fs errors --- + +const quietIo = () => ({ stdout: { write: () => true }, stderr: { write: () => true } }); + +test("org validate: a missing or invalid document is refused by kind, never by host path or bytes", async (t) => { + const root = under(t), missing = join(root, "missing.json"), bad = join(root, "bad.json"); + writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 }); + await assert.rejects(orgCommand(["validate", missing], quietIo()), refusal("cannot read organization document: ENOENT", { exitCode: 2, paths: [missing, root] })); + await assert.rejects(orgCommand(["validate", bad], quietIo()), refusal("cannot read organization document: not valid JSON", { exitCode: 2, paths: [bad, root] })); +}); + +test("org template render: a raw filesystem error is named by operation and code, never by host path", async (t) => { + const root = under(t), output = join(root, "alpha.json"); + writeFileSync(output, "{}\n", { mode: 0o600 }); + await assert.rejects(orgCommand(["template", "render", "alpha", "--output", output], quietIo()), + refusal("EEXIST: cannot open (EEXIST)", { exitCode: 2, paths: [output, root] })); +}); + +test("instance set: a missing or invalid --vars file is refused by flag and kind, never by host path or bytes", async (t) => { + const root = under(t), missing = join(root, "vars.json"), bad = join(root, "bad.json"); + writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 }); + const rev = "a".repeat(64); + const callFor = () => assert.fail("must refuse before any call"); + await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", missing, "--revision", rev], quietIo(), callFor), + refusal("--vars file: ENOENT", { exitCode: 4, paths: [missing, root] })); + await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", bad, "--revision", rev], quietIo(), callFor), + refusal("--vars file: not valid JSON", { exitCode: 4, paths: [bad, root] })); +}); diff --git a/packages/cli/tests/rename-b-commands.test.mjs b/packages/cli/tests/rename-b-commands.test.mjs index 7ac643447..a72605092 100644 --- a/packages/cli/tests/rename-b-commands.test.mjs +++ b/packages/cli/tests/rename-b-commands.test.mjs @@ -169,7 +169,7 @@ test("rename r2 B2: a draft file named like an installed ID: org validate reads rmSync(join(cwd, "acme")); const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd); assert.equal(noDraft.status, 2); - assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /); + assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n"); writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" })); rmSync(join(root, "config", "businesses", "acme.json")); const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd); diff --git a/packages/cli/tests/run-evidence.test.mjs b/packages/cli/tests/run-evidence.test.mjs index 6f63e9c23..43bad4412 100644 --- a/packages/cli/tests/run-evidence.test.mjs +++ b/packages/cli/tests/run-evidence.test.mjs @@ -21,11 +21,11 @@ const OTHER = '11111111-2222-4333-8444-555555555555'; let n = 0; // An instance with org alpha and one finished run for THREAD. -async function world({ run = 'r0123456789ab' } = {}) { +async function world({ run = 'r0123456789ab', under = HOME } = {}) { const name = `ev${++n}`; const env = { PATH: process.env.PATH, HOME, MOSAIC_CONFIG: join(HOME, '.config', `mosaic-${name}`, 'config.json') }; mkdirSync(dirname(env.MOSAIC_CONFIG), { recursive: true, mode: 0o700 }); - const dataRoot = join(HOME, `data-${name}`); mkdirSync(dataRoot, { mode: 0o700 }); + const dataRoot = join(under, `data-${name}`); mkdirSync(dataRoot, { recursive: true, mode: 0o700 }); const org = await reserveOrg({ configDir: dirname(env.MOSAIC_CONFIG), dataRoot, instance: name, principal: DEV_OWNER }, 'alpha'); const runDir = join(runsDir(org.root, 'alpha'), run); mkdirSync(join(runDir, 'bundle'), { recursive: true, mode: 0o700 }); @@ -132,6 +132,27 @@ test('no launch for the thread, an unknown org and a usage error', async () => { await assert.rejects(runCommand(['evidence', 'alpha', '--thread', THREAD], io({ PATH: process.env.PATH, HOME }), { system: { dataRoot: w.dataRoot } }), e => e.exitCode === 3 && e.code === 'run-evidence-instance-refused'); }); +// PR #1916 (org-redact5): a refusal names the launch log by label, never by host path. +const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; +const noPath = (e, w) => !e.message.includes(SENTINEL) && !e.message.includes(w.dataRoot) && !e.message.includes(w.log); + +test('a launch log of the wrong mode refuses by label, never by host path', async () => { + const w = await world({ under: join(HOME, SENTINEL) }); + chmodSync(w.log, 0o644); + const r = await evidence(w, ['--thread', THREAD, '--json']); + assert.equal(r.error?.exitCode, 3); + assert.equal(r.error.message, 'run-evidence-refused: launch log: mode 644, expected 600'); + assert.ok(noPath(r.error, w), r.error.message); +}); + +test('no launch for the thread names the launch log by label, never by host path', async () => { + const w = await world({ under: join(HOME, SENTINEL) }); + const r = await evidence(w, ['--thread', '99999999-2222-4333-8444-555555555555', '--json']); + assert.equal(r.error?.exitCode, 2); + assert.equal(r.error.message, 'no launch for thread 99999999-2222-4333-8444-555555555555 in the launch log'); + assert.ok(noPath(r.error, w), r.error.message); +}); + test('a thread name resolves through the bus with the org as business', async () => { const w = await world(); const seen = []; diff --git a/packages/discord/src/journal.mjs b/packages/discord/src/journal.mjs index 98e206cfe..a8a31dde7 100644 --- a/packages/discord/src/journal.mjs +++ b/packages/discord/src/journal.mjs @@ -130,9 +130,13 @@ export function writeTurn(dir, id, record) { return path; } +// A parse refusal names the turn by its id, never by host path (PR #1916); a +// listed file's id is named only when it passes TURN_ID. +const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record"); + export function readTurn(dir, id) { const path = turnPath(dir, id); - return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record"); + return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id))); } export function listTurns(dir) { @@ -140,7 +144,7 @@ export function listTurns(dir) { if (!existsSync(turns)) return []; return readdirSync(turns) .filter((f) => f.endsWith(".json")) - .map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record")); + .map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length)))); } // The daily ceiling counts admissions on the current UTC date. An admission diff --git a/packages/discord/tests/last-values.test.mjs b/packages/discord/tests/last-values.test.mjs index 1dd183c61..c91ab903a 100644 --- a/packages/discord/tests/last-values.test.mjs +++ b/packages/discord/tests/last-values.test.mjs @@ -28,9 +28,10 @@ for (const [field, edit] of [ }); test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 }))); for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => { - const root = scratch(t); mkdirSync(join(root, 'turns')); + const root = join(scratch(t), secret); mkdirSync(join(root, 'turns'), { recursive: true }); const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret); - assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError)); + // The refusal names the turn by its id, never by host path (PR #1916). + assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe('turn record turn-1 is not valid JSON', SyntaxError, {}, file)); }); for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => { diff --git a/packages/organization/src/access.mjs b/packages/organization/src/access.mjs index 76c18a3c3..f6b55ef39 100644 --- a/packages/organization/src/access.mjs +++ b/packages/organization/src/access.mjs @@ -167,8 +167,8 @@ export function checkAccess(document, context) { // other, as the business file is. export function loadAccess(file, context, { uid = process.getuid() } = {}) { const document = readJsonFile(file, "access file", (stat) => { - if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`); - if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`); + if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}`); + if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`); }); return checkAccess(document, context); } diff --git a/packages/organization/src/agent-levels.mjs b/packages/organization/src/agent-levels.mjs index dc881a4d2..d0f95b11d 100644 --- a/packages/organization/src/agent-levels.mjs +++ b/packages/organization/src/agent-levels.mjs @@ -86,9 +86,9 @@ export function loadRootAgents(configDir, { rolesDir } = {}) { } catch (error) { if (error?.name !== "BusinessError") throw error; if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null }); - // The read's messages carry the host path; the Console shows this one, so it names the file only. - // Keep parse refusals file-only here too, without the host path. - if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode); + // The Console shows this one, so it names the file only. The shared reader no longer quotes the + // host path; the replaceAll below stays as a second guard. + if (/ is not valid JSON$/.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode); refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode); } try { return deepFreeze({ state: "ok", ...checkRootAgents(document, { rolesDir }) }); } diff --git a/packages/organization/src/config-command.mjs b/packages/organization/src/config-command.mjs index 9fc01c902..2395f5aec 100644 --- a/packages/organization/src/config-command.mjs +++ b/packages/organization/src/config-command.mjs @@ -79,7 +79,8 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce ? "configuration is not valid JSON; expected readable JSON: " : field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: ` : "expected valid system configuration: "; - fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`); + // Name the config by where it came from, never by host path (PR #1916). + fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config"}${proc.error?.code ? ` (${proc.error.code})` : ""}`); } try { return JSON.parse(proc.stdout); @@ -119,7 +120,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce continue; } const project = loadProject(entry.root); - if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`); + if (project.id !== id) fail(2, `project file of project ${id} has id that does not match the declared project key`); for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance }); projects[id] = "valid"; } diff --git a/packages/organization/src/hosts.mjs b/packages/organization/src/hosts.mjs index 3d0d116dc..27e6f1b40 100644 --- a/packages/organization/src/hosts.mjs +++ b/packages/organization/src/hosts.mjs @@ -77,7 +77,9 @@ export function checkCommands(commands, where) { if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`); } if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`); - if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`); + // Name the command by its basename, which refusedCommand matched against a closed list or + // pattern, never by an absolute host path (PR #1916). + if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0].split("/").pop()}, a shell, runner or interpreter, which would grant any command`); const key = JSON.stringify(prefix); if (seen.has(key)) refuse(`${at} repeats a prefix`); seen.add(key); diff --git a/packages/organization/src/library-seats.mjs b/packages/organization/src/library-seats.mjs index 7e6dadeea..887ad6a21 100644 --- a/packages/organization/src/library-seats.mjs +++ b/packages/organization/src/library-seats.mjs @@ -24,18 +24,20 @@ export function defaultRolesDir(env = process.env) { export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) { // lstat first: only nothing at the path is an absent directory. Anything else // there (a file, a dangling symlink, a directory) must list, or it throws. + // The refusal names the directory by label and the error by its code only: + // err.message quotes the host path (PR #1916). const businesses = join(dir, "businesses"); try { lstatSync(businesses); } catch (err) { if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() }; - throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`); + throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`); } let names; try { names = readdirSync(businesses).filter((name) => name.endsWith(".json")); } catch (err) { - throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`); + throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`); } const librarySeats = new Set(); const creativeSeats = new Set(); diff --git a/packages/organization/src/org-service.mjs b/packages/organization/src/org-service.mjs index b5c6ae81c..4b384d9b6 100644 --- a/packages/organization/src/org-service.mjs +++ b/packages/organization/src/org-service.mjs @@ -19,15 +19,22 @@ const encode = doc => JSON.stringify(doc, null, 2) + '\n'; const projectPath = (root, project) => join(root, 'projects', project); function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-z][a-z0-9]{1,15}$/.test(ctx.instance) || ctx.instance === 'dev' || !same(ctx.principal, owner)) fail('org-forbidden', 'authenticated local development owner required'); } function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); } -async function privateRead(file) { const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } finally { await fd.close(); } } +// A filesystem error names the operation, the file by label and the error code, never the +// host path Node puts in its message (PR #1916). code, syscall and the other fields stay. +const quieted = new WeakSet(); +function quiet(e, what) { + if (e && typeof e === 'object' && typeof e.syscall === 'string' && typeof e.code === 'string' && !quieted.has(e)) { e.message = `cannot ${e.syscall} ${what} (${e.code})`; quieted.add(e); } + return e; +} +async function privateRead(file, what = 'organization file') { let fd; try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (e) { throw quiet(e, what); } try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } catch (e) { throw quiet(e, what); } finally { await fd.close(); } } async function atomic(file, content, exclusive = false) { const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`); - try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } finally { await fs.rm(tmp, { force: true }); } + try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } catch (e) { throw quiet(e, 'organization file'); } finally { await fs.rm(tmp, { force: true }); } } -async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; } const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); } +async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw quiet(e, 'organization directory'); } const st = await fs.lstat(path).catch(e => { throw quiet(e, 'organization directory'); }); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); } async function receipt(root, op, org, principal, fields = {}) { const result = { op, org, at: new Date().toISOString(), subject: { ...principal }, ...fields }; - const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600); + const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600).catch(e => { throw quiet(e, 'organization receipts'); }); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe receipts', file); await fd.writeFile(JSON.stringify(result) + '\n'); } finally { await fd.close(); } return result; } async function receipts(root) { @@ -129,11 +136,11 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) { if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived'); if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active'); const ownerFile = join(found.root, 'owner.json'); - const principal = parseJsonFileText(await privateRead(ownerFile), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required'); + const principal = parseJsonFileText(await privateRead(ownerFile, 'organization owner file'), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required'); // From here the caller is the verified owner. A failure carries that proof so a list may // show this org as broken to its owner; earlier failures prove nothing and stay hidden. try { - const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file); + const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file, 'organization file'); if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree'); const doc = parseJsonFileText(raw, file, 'organization file'); await checked(doc); return { ...found, revision: entry.revision, doc, principal }; } catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; } @@ -171,13 +178,13 @@ export async function showOrg(ctx, id, opts = {}) { authorize(ctx); const { root export async function notificationShow(ctx, id) { authorize(ctx); const current = await readCurrent(ctx, id); for (const path of [join(current.root, 'notify'), join(current.root, 'notify', id)]) { - const st = await fs.lstat(path); + const st = await fs.lstat(path).catch(e => { throw quiet(e, 'notifier directory'); }); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe notifier directory'); } const file = join(current.root, 'notify', id, 'notify.json'); - const st = await fs.lstat(file); + const st = await fs.lstat(file).catch(e => { throw quiet(e, 'notifier configuration file'); }); if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration'); - const binding = parseJsonFileText(await privateRead(file), file, 'notifier configuration file'); + const binding = parseJsonFileText(await privateRead(file, 'notifier configuration file'), file, 'notifier configuration file'); if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration'); const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal)); if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable'); @@ -198,7 +205,7 @@ function options({ mode, remote, acceptLocalLossRisk }) { } async function git(root, args) { const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' }; - return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', 'user.email=mosaic@localhost.invalid', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git failed: ${err.trim()}`))); }); + return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', 'user.email=mosaic@localhost.invalid', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git ${args[0]} failed (exit ${code})`))); }); } async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); } async function setup(ctx, id, project, opts) { @@ -273,6 +280,8 @@ export async function restoreOrg(ctx, id, opts = {}) { return lifecycleChange(ct // Deletion keeps a durable marker in the registry and a receipt beside the // moved data. It never opens the contents of organization secret trees. +// The deletion folder deleteOrg generates: `--<8 hex>`. +const deletionFolderPattern = (id) => new RegExp(`^${id.replace(/[.]/g, '\\.')}-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z-[0-9a-f]{8}$`); async function deleteInspect(path, { secret = false } = {}) { let count = 0; async function visit(p) { @@ -328,7 +337,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {}) try { await deleteSafeRoot(ctx.dataRoot); await ensurePrivateDir(deleted); await ensurePrivateDir(destination); - try { result = parseJsonFileText(await privateRead(receiptFile), receiptFile, 'deletion receipt file'); } + try { result = parseJsonFileText(await privateRead(receiptFile, 'deletion receipt file'), receiptFile, 'deletion receipt file'); } catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); } if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt'); if (!result.bus) { @@ -380,9 +389,12 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {}) }); registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result; } catch (e) { - e.deleteStep = phase; if (result) e.deleteReceipt = result; - if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; } - e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`; + e.deleteStep = phase; if (result) e.deleteReceipt = result; quiet(e, `a file in the deletion ${phase} step`); + // Name the receipt under the data root, never by host path (PR #1916). The folder comes + // from the registry, so it is named only when it has the shape this function generates. + const at = deletionFolderPattern(id).test(deletion.folder) ? `deleted/${deletion.folder}/receipt.json under the data root` : 'the deletion receipt under the data root'; + if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${at}`; throw e; } + e.message += `; deletion remains in progress. Inspect ${at} and rerun mosaic org delete ${id} --confirm ${id}`; throw e; } }); diff --git a/packages/organization/src/organization-config.mjs b/packages/organization/src/organization-config.mjs index 881efb102..1fe6b9620 100644 --- a/packages/organization/src/organization-config.mjs +++ b/packages/organization/src/organization-config.mjs @@ -358,7 +358,7 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo export function loadOrganization(id, { dir = configDir(), rolesDir, dataRoot = null }) { if (!rolesDir) throw new Error("loadBusiness needs rolesDir"); const file = organizationFilePath(id, dir); - const document = readJsonFile(file, "business file", (stat) => { + const document = readJsonFile(file, `business file ${id}.json`, (stat) => { if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`); if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`); }); diff --git a/packages/organization/src/project.mjs b/packages/organization/src/project.mjs index 6e6e9aab5..726d6e992 100644 --- a/packages/organization/src/project.mjs +++ b/packages/organization/src/project.mjs @@ -10,20 +10,23 @@ export function projectFilePath(root) { return join(root, ".mosaic", "project.json"); } +// Refusals name the project file by label: its path is the project root from +// the business file, a config value, so no refusal quotes it (PR #1916). An +// instance name is quoted only after requireId passed it. export function validateProjectDocument(document, file) { requireObject(document, "project file"); rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file"); - if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`); + if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1`); requireId(document.id, "project id"); - const vars = checkVars(document.vars ?? {}, "project", `${file} vars`); + const vars = checkVars(document.vars ?? {}, "project", "project file vars"); const roles = {}; if (document.roles !== undefined) { - requireObject(document.roles, `${file} roles`); + requireObject(document.roles, "project file roles"); for (const [instance, entry] of Object.entries(document.roles)) { - requireId(instance, `${file} role instance`); - requireObject(entry, `${file} roles.${instance}`); - rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`); - roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) }; + requireId(instance, "project file role instance"); + requireObject(entry, `project file roles.${instance}`); + rejectUnknownKeys(entry, ["vars"], `project file roles.${instance}`); + roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `project file roles.${instance}.vars`) }; } } return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles }); diff --git a/packages/organization/src/role.mjs b/packages/organization/src/role.mjs index 4776e8701..82a1aa749 100644 --- a/packages/organization/src/role.mjs +++ b/packages/organization/src/role.mjs @@ -92,7 +92,7 @@ function checkHireable(hireable, file) { if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`); const path = join(dir, `${name}.json`); if (!existsSync(path)) refuse(`hireable names a definition which has no role file`); - const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false }); + const named = validateRoleDocument(readJsonFile(path, `role file ${name}.json`), path, { hireable: false }); if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`); const outside = unhireableAction(named); if (outside !== null) refuse(`hireable names ${name}, which holds ${outside} outside the hireable ceiling`); @@ -230,7 +230,7 @@ export function validateRoleDocument(document, file, options = {}) { } export function loadRoleFile(file) { - return validateRoleDocument(readJsonFile(file, "role file"), file); + return validateRoleDocument(readJsonFile(file, /^[a-z0-9][a-z0-9._-]{0,63}\.json$/.test(basename(file)) ? `role file ${basename(file)}` : "role file"), file); } export function loadRole(rolesDir, name) { diff --git a/packages/organization/src/util.mjs b/packages/organization/src/util.mjs index 326190fe5..70e67f8fc 100644 --- a/packages/organization/src/util.mjs +++ b/packages/organization/src/util.mjs @@ -60,11 +60,14 @@ export function requireDate(value, where) { // Parser diagnostics can quote file bytes. Keep the existing SyntaxError refusal, // but never attach the parser's message or cause to an error that callers can log. +// The message names the file by its `what` label only: a refusal never quotes a +// host path (PR #1916). `file` stays in the signature so callers don't change; +// it is not used. export function parseJsonFileText(text, file, what = "file") { try { return JSON.parse(text); } catch { - throw new SyntaxError(`${what} is not valid JSON (${file})`); + throw new SyntaxError(`${what} is not valid JSON`); } } @@ -72,19 +75,21 @@ export function parseJsonFileText(text, file, what = "file") { // Missing or not a regular file is 4; unparseable is 2. One descriptor, // opened without following a link, serves every check and the read. // `checkStat` sees that descriptor's stat before the read, so the file -// can't be swapped between the check and the read. +// can't be swapped between the check and the read. Every refusal names the +// file by `what` and never by path; a caller whose label alone can't tell +// two files apart adds a name that already passed a strict pattern. export function readJsonFile(file, what, checkStat) { let fd; try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (error) { - if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4); - refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); + if (error.code === "ENOENT") refuse(`${what} not found`, 4); + refuse(`${what} must be a regular, non-symbolic-link file`, 4); } let text; try { const stat = fstatSync(fd); - if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4); + if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file`, 4); checkStat?.(stat); text = readFileSync(fd, "utf8"); } finally { @@ -93,7 +98,7 @@ export function readJsonFile(file, what, checkStat) { try { return parseJsonFileText(text, file, what); } catch { - refuse(`${what} is not valid JSON (${file})`); + refuse(`${what} is not valid JSON`); } } diff --git a/packages/organization/tests/case1-project-key.test.mjs b/packages/organization/tests/case1-project-key.test.mjs index fdc19ee9f..95292f003 100644 --- a/packages/organization/tests/case1-project-key.test.mjs +++ b/packages/organization/tests/case1-project-key.test.mjs @@ -59,7 +59,8 @@ test("row 325: the old mosaic-stack project key refuses against those same proje const r = x.validate(); assert.equal(r.status, 2, r.stdout + r.stderr); assert.equal(r.stdout, ""); - assert.match(r.stderr, /has id that does not match the declared project key/); - assert.ok(r.stderr.includes(x.projectFile)); + assert.match(r.stderr, /project file of project mosaic-stack has id that does not match the declared project key/); + // The refusal names the declared key, never the project root (PR #1916). + assert.ok(!r.stderr.includes(x.projectFile), r.stderr); assert.doesNotMatch(r.stderr, /no project file/); }); diff --git a/packages/organization/tests/cli.test.mjs b/packages/organization/tests/cli.test.mjs index 8371b4625..111632347 100644 --- a/packages/organization/tests/cli.test.mjs +++ b/packages/organization/tests/cli.test.mjs @@ -1,7 +1,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; -import { chmodSync, readFileSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { projectFilePath } from "../src/index.mjs"; import { businessDoc, REPO, rolesCopy, systemConfig, tmp, tokenFile, writeJson } from "./helpers.mjs"; @@ -87,11 +87,24 @@ test("validate: project files", () => { assert.equal(run(s, "validate", "acme").code, 2); }); +// PR #1916 (org-redact5): the project root is a config value, so a mismatched +// project file is named by its checked project key, never by path. +test("validate: a project file with another id names the project key, not the project root", () => { + const sentinel = "sk-test-NOT-A-SECRET-1234"; + const s = setup((doc, root) => { doc.projects.stack.root = join(root, sentinel, "project"); mkdirSync(doc.projects.stack.root, { recursive: true }); }); + writeJson(projectFilePath(s.doc.projects.stack.root), { projectVersion: 1, id: "other" }); + const r = run(s, "validate", "acme"); + assert.equal(r.code, 2); + assert.match(r.err, /project file of project stack has id that does not match the declared project key/); + assert.ok(!r.err.includes(sentinel), r.err); + assert.ok(!r.err.includes(s.doc.projects.stack.root), r.err); +}); + test("validate: missing files and a broken system config", () => { const s = setup(); const missing = run(s, "validate", "nobody"); assert.equal(missing.code, 4); - assert.match(missing.err, /business file not found/); + assert.match(missing.err, /business file nobody\.json not found/); assert.equal(run(s, "validate", "Bad!").code, 2); const noConfig = { ...s, env: { ...s.env, MOSAIC_CONFIG: join(s.root, "absent", "config.json") } }; diff --git a/packages/organization/tests/json-redaction.test.mjs b/packages/organization/tests/json-redaction.test.mjs index 16029dc73..582d5b3d8 100644 --- a/packages/organization/tests/json-redaction.test.mjs +++ b/packages/organization/tests/json-redaction.test.mjs @@ -17,42 +17,46 @@ import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from const secret = 'sk-test-NOT-A-SECRET-1234'; const malformed = secret; +// PR #1916 redaction (org-redact5): the sentinel is also a path component, so a +// refusal that quoted the host path would carry it. function fixture(t) { - const root = tmp(); - t.after(() => rmSync(root, { recursive: true, force: true })); + const top = tmp(); + t.after(() => rmSync(top, { recursive: true, force: true })); + const root = join(top, secret); + mkdirSync(root, { mode: 0o700 }); const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 }); return ctx; } -function safe(file, { name = 'SyntaxError', code, exitCode, prefix } = {}) { +// The refusal names the file by its label and never by path. +function safe(file, { name = 'SyntaxError', code, exitCode, prefix, label, suffix = '' } = {}) { return error => { assert.equal(error.name, name); assert.equal(error.code, code); assert.equal(error.exitCode, exitCode); - assert.ok(error.message.includes(file), error.message); - assert.match(error.message, /not valid JSON/); + assert.ok(!error.message.includes(file), error.message); + assert.equal(error.message, `${prefix ?? ''}${label} is not valid JSON${suffix}`); assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/); assert.ok(!error.stack.includes(secret)); assert.equal(error.cause, undefined); - if (prefix) assert.ok(error.message.startsWith(prefix)); return true; }; } -for (const [label, load, relative] of [ - ['shared reader', file => readJsonFile(file, 'config file'), 'config.json'], - ['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'], - ['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'], - ['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json'], +for (const [label, load, relative, labelText] of [ + ['shared reader', file => readJsonFile(file, 'config file'), 'config.json', 'config file'], + ['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'], + ['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json', 'business file alpha.json'], + ['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json', 'project file'], ]) test(`${label} names malformed JSON's file without quoting bytes`, t => { const ctx = fixture(t), file = writeJson(join(ctx.configDir, relative), malformed); - assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2 })); + assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2, label: labelText })); }); test('root agents keep their existing file-only refusal', t => { const ctx = fixture(t); writeJson(join(ctx.configDir, 'agents.json'), malformed); - assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe('agents.json', { name: 'BusinessError', exitCode: 2 })); + assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe(join(ctx.configDir, 'agents.json'), { name: 'BusinessError', exitCode: 2, label: 'root agent file', suffix: ' (agents.json)' })); }); for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => { @@ -74,13 +78,13 @@ for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`orga const invoke = () => operation === 'write' ? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub }) : writer[operation]('alpha', {}); - assert.throws(invoke, safe(file)); + assert.throws(invoke, safe(file, { label: receipt ? 'organization receipt file' : 'organization file' })); }); for (const operation of ['async', 'sync']) test(`${operation} registry read names the file without parser bytes`, async t => { const ctx = fixture(t), file = writeJson(join(ctx.configDir, 'org-runtime.json'), malformed); - if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ' })); - else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file)); + if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ', label: 'metadata file' })); + else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file, { label: 'registry file' })); }); for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => { @@ -107,9 +111,11 @@ for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) te registry.orgs.alpha.digest = createHash('sha256').update(malformed).digest('hex'); writeJson(registryFile, registry); } - if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file)); - else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), safe(file)); - else await assert.rejects(showOrg(ctx, 'alpha'), safe(file)); + const label = { owner: 'organization owner file', organization: 'organization file', notifier: 'notifier configuration file', 'deletion receipt': 'deletion receipt file' }[kind]; + if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file, { label })); + else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), + safe(file, { label, suffix: '; deletion remains in progress. Inspect the deletion receipt under the data root and rerun mosaic org delete alpha --confirm alpha' })); + else await assert.rejects(showOrg(ctx, 'alpha'), safe(file, { label })); }); // The packaged template is immutable at runtime; substitute only its read result @@ -122,7 +128,7 @@ test('template reader refuses malformed JSON without quoting template bytes', as }); syncBuiltinESMExports(); try { - await assert.rejects(templateRender('alpha'), safe('org-template.json')); + await assert.rejects(templateRender('alpha'), safe('/org-template.json', { label: 'organization template file' })); } finally { t.mock.restoreAll(); syncBuiltinESMExports(); diff --git a/packages/organization/tests/organization-config.test.mjs b/packages/organization/tests/organization-config.test.mjs index 9b77b7a2c..dcbeb937b 100644 --- a/packages/organization/tests/organization-config.test.mjs +++ b/packages/organization/tests/organization-config.test.mjs @@ -192,7 +192,7 @@ test("REQ-THREAD-2 REQ-LAUNCH-1 launch.threadIdleSeconds: absent is the default, test("loadBusiness: file checks", () => { const root = tmp(); const dir = join(root, "config"); - refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file not found/, 4); + refuses(() => loadBusiness("acme", { dir, rolesDir: REPO_ROLES }), /business file acme\.json not found/, 4); const file = writeJson(join(dir, "businesses", "acme.json"), businessDoc(root)); assert.equal(loadBusiness("acme", { dir, rolesDir: REPO_ROLES }).file, file); diff --git a/packages/organization/tests/path-redaction.test.mjs b/packages/organization/tests/path-redaction.test.mjs new file mode 100644 index 000000000..6b0bc661b --- /dev/null +++ b/packages/organization/tests/path-redaction.test.mjs @@ -0,0 +1,219 @@ +// PR #1916 redaction (org-redact5): a refusal names the file by its label, never +// by host path. Every fixture here sits under a directory named for the sentinel, +// so a refusal that quoted the path would carry it. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { chmodSync, copyFileSync, mkdirSync, readFileSync, renameSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { REPO_ROLES, tmp, writeJson } from './helpers.mjs'; +import { parseJsonFileText, readJsonFile } from '../src/util.mjs'; +import { loadAccess } from '../src/access.mjs'; +import { validateProjectDocument } from '../src/project.mjs'; +import { loadSeatSets } from '../src/library-seats.mjs'; +import { loadRole, loadRoleFile } from '../src/role.mjs'; +import { loadBusiness } from '../src/organization-config.mjs'; +import { createOrg, deleteOrg, notificationShow, templateRender } from '../src/org-service.mjs'; +import { checkCommands } from '../src/hosts.mjs'; +import { BUSINESS_CONFIG, runConfigCommand } from '../src/config-command.mjs'; + +const SENTINEL = 'sk-test-NOT-A-SECRET-1234'; + +function under(t) { + const top = tmp(); + t.after(() => rmSync(top, { recursive: true, force: true })); + const root = join(top, SENTINEL); + mkdirSync(root, { mode: 0o700 }); + return root; +} + +// The refusal is exactly `message`, with its exit code, and carries neither the +// sentinel nor any of `paths`. +function refusal(message, { exitCode, name = 'BusinessError', paths = [] } = {}) { + return (error) => { + assert.equal(error.name, name); + if (exitCode !== undefined) assert.equal(error.exitCode, exitCode); + assert.equal(error.message, message); + assert.ok(!error.message.includes(SENTINEL), error.message); + for (const path of paths) assert.ok(!error.message.includes(path), error.message); + return true; + }; +} + +test('readJsonFile: a missing file is refused by label, exit 4', (t) => { + const file = join(under(t), 'config.json'); + assert.throws(() => readJsonFile(file, 'config file'), refusal('config file not found', { exitCode: 4, paths: [file] })); +}); + +test('readJsonFile: a symbolic link is refused by label at open, exit 4', (t) => { + const root = under(t), file = join(root, 'config.json'); + writeJson(join(root, 'real.json'), {}); + symlinkSync(join(root, 'real.json'), file); + assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] })); +}); + +test('readJsonFile: a directory is refused by label after the stat, exit 4', (t) => { + const file = join(under(t), 'config.json'); + mkdirSync(file); + assert.throws(() => readJsonFile(file, 'config file'), refusal('config file must be a regular, non-symbolic-link file', { exitCode: 4, paths: [file] })); +}); + +test('readJsonFile: invalid JSON is refused by label, exit 2', (t) => { + const file = writeJson(join(under(t), 'config.json'), '{not json'); + assert.throws(() => readJsonFile(file, 'config file'), refusal('config file is not valid JSON', { exitCode: 2, paths: [file] })); +}); + +test('parseJsonFileText: the SyntaxError names the label only', (t) => { + const file = join(under(t), 'config.json'); + assert.throws(() => parseJsonFileText('{not json', file, 'config file'), refusal('config file is not valid JSON', { name: 'SyntaxError', paths: [file] })); +}); + +test('loadAccess: an access file of another uid is refused without its path', (t) => { + const file = writeJson(join(under(t), 'access.json'), {}); + const uid = process.getuid() + 1; + assert.throws(() => loadAccess(file, {}, { uid }), refusal(`access file must belong to uid ${uid}`, { exitCode: 2, paths: [file] })); +}); + +test('loadAccess: a group-writable access file is refused without its path', (t) => { + const file = writeJson(join(under(t), 'access.json'), {}, 0o620); + assert.throws(() => loadAccess(file, {}), refusal('access file must not be writable by group or other (mode 620)', { exitCode: 2, paths: [file] })); +}); + +for (const [what, document, message] of [ + ['projectVersion', { projectVersion: 2, id: 'stack' }, 'project file "projectVersion" must be 1'], + ['vars', { projectVersion: 1, id: 'stack', vars: [] }, 'project file vars must be a JSON object'], + ['roles', { projectVersion: 1, id: 'stack', roles: [] }, 'project file roles must be a JSON object'], + ['role instance', { projectVersion: 1, id: 'stack', roles: { 'Bad!': {} } }, 'project file role instance must match /^[a-z0-9][a-z0-9._-]{0,63}$/'], + ['roles entry', { projectVersion: 1, id: 'stack', roles: { coder: [] } }, 'project file roles.coder must be a JSON object'], + ['roles entry key', { projectVersion: 1, id: 'stack', roles: { coder: { extra: 1 } } }, 'unsupported project file roles.coder key: "extra"'], + ['roles entry vars', { projectVersion: 1, id: 'stack', roles: { coder: { vars: [] } } }, 'project file roles.coder.vars must be a JSON object'], +]) test(`validateProjectDocument: ${what} is refused by label, never by the project root`, (t) => { + const file = join(under(t), 'project', '.mosaic', 'project.json'); + assert.throws(() => validateProjectDocument(document, file), refusal(message, { exitCode: 2, paths: [file] })); +}); + +test('loadSeatSets: a config directory that is a file is refused by its error code', (t) => { + const dir = join(under(t), 'config'); + writeFileSync(dir, ''); + assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] })); +}); + +test('loadSeatSets: a businesses entry that does not list is refused by its error code', (t) => { + const dir = join(under(t), 'config'); + mkdirSync(dir); + writeFileSync(join(dir, 'businesses'), ''); + assert.throws(() => loadSeatSets({ dir, rolesDir: REPO_ROLES }), refusal('cannot list businesses in the config directory: ENOTDIR', { exitCode: 2, paths: [dir] })); +}); + +test('loadRole: a missing role file is named by its checked name', (t) => { + const dir = under(t); + assert.throws(() => loadRole(dir, 'coder'), refusal('role file coder.json not found', { exitCode: 4, paths: [dir] })); +}); + +test('loadRoleFile: a file name outside the id pattern is not quoted', (t) => { + const file = join(under(t), 'Not An Id.json'); + assert.throws(() => loadRoleFile(file), refusal('role file not found', { exitCode: 4, paths: [file] })); +}); + +test('loadRoleFile: a hireable entry whose file is invalid JSON is named by its checked name', (t) => { + const dir = join(under(t), 'roles'); + mkdirSync(dir); + const chief = JSON.parse(readFileSync(join(REPO_ROLES, 'chief-of-staff.json'), 'utf8')); + writeJson(join(dir, 'chief-of-staff.json'), { ...chief, hireable: ['coder'] }); + copyFileSync(join(REPO_ROLES, 'chief-of-staff.md'), join(dir, 'chief-of-staff.md')); + writeJson(join(dir, 'coder.json'), '{not json'); + assert.throws(() => loadRole(dir, 'chief-of-staff'), refusal('role file coder.json is not valid JSON', { exitCode: 2, paths: [dir] })); +}); + +test('loadBusiness: a missing business file is named by its checked id', (t) => { + const dir = join(under(t), 'config'); + assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json not found', { exitCode: 4, paths: [dir] })); +}); + +test('loadBusiness: a symbolic link business file is named by its checked id', (t) => { + const root = under(t), dir = join(root, 'config'); + writeJson(join(root, 'real.json'), {}); + mkdirSync(join(dir, 'businesses'), { recursive: true }); + symlinkSync(join(root, 'real.json'), join(dir, 'businesses', 'acme.json')); + assert.throws(() => loadBusiness('acme', { dir, rolesDir: REPO_ROLES }), refusal('business file acme.json must be a regular, non-symbolic-link file', { exitCode: 4, paths: [dir] })); +}); + +test('deleteOrg: a failed deletion names the generated receipt folder under the data root, never the host path', async (t) => { + const root = under(t); + const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', + principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; + for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 }); + await createOrg(ctx, 'alpha', await templateRender('alpha')); + const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd'; + const registryFile = join(ctx.configDir, 'org-runtime.json'); + const registry = JSON.parse(readFileSync(registryFile)); + registry.orgs.alpha.state = 'deleting'; + registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder }; + writeJson(registryFile, registry); + const destination = join(ctx.dataRoot, 'deleted', folder); + mkdirSync(destination, { recursive: true, mode: 0o700 }); + writeJson(join(destination, 'receipt.json'), '{not json'); + chmodSync(join(destination, 'receipt.json'), 0o600); + await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), + refusal(`deletion receipt file is not valid JSON; deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`, + { name: 'SyntaxError', paths: [ctx.dataRoot, destination] })); +}); + +// Review round (org-redact5 Sol P2 #1): a raw filesystem error from the organization +// service names the operation, the file by label and the error code, never the host path. +async function deletingOrg(t) { + const root = under(t); + const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', + principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } }; + for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 }); + await createOrg(ctx, 'alpha', await templateRender('alpha')); + return { root, ctx }; +} + +test('deleteOrg: a receipt that is a symbolic link is refused by label and error code, never by host path', async (t) => { + const { root, ctx } = await deletingOrg(t); + const folder = 'alpha-2026-11-10T00-00-00-000Z-0123abcd'; + const registryFile = join(ctx.configDir, 'org-runtime.json'); + const registry = JSON.parse(readFileSync(registryFile)); + registry.orgs.alpha.state = 'deleting'; + registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder }; + writeJson(registryFile, registry); + const destination = join(ctx.dataRoot, 'deleted', folder); + mkdirSync(destination, { recursive: true, mode: 0o700 }); + writeJson(join(root, 'elsewhere.json'), {}); + symlinkSync(join(root, 'elsewhere.json'), join(destination, 'receipt.json')); + await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), (error) => { + assert.equal(error.code, 'ELOOP'); + assert.equal(error.syscall, 'open'); + assert.equal(error.deleteStep, 'prepare'); + return refusal(`cannot open deletion receipt file (ELOOP); deletion remains in progress. Inspect deleted/${folder}/receipt.json under the data root and rerun mosaic org delete alpha --confirm alpha`, + { name: 'Error', paths: [ctx.dataRoot, destination, root] })(error); + }); +}); + +test('notificationShow: a missing notifier directory is refused by label and error code, never by host path', async (t) => { + const { root, ctx } = await deletingOrg(t); + const notify = join(ctx.dataRoot, 'org-runtime', 'alpha', 'notify'); + renameSync(notify, `${notify}.gone`); + await assert.rejects(notificationShow(ctx, 'alpha'), (error) => { + assert.equal(error.code, 'ENOENT'); + return refusal('cannot lstat notifier directory (ENOENT)', { name: 'Error', paths: [ctx.dataRoot, notify, root] })(error); + }); +}); + +// Sol P3 #4: a refused command given by absolute path is named by the basename refusedCommand matched. +test('checkCommands: a refused interpreter by absolute path is named by its basename, never by host path', (t) => { + const bin = join(under(t), 'bin'); + assert.throws(() => checkCommands([[join(bin, 'bash'), '-c']], 'grant.commands'), + refusal('grant.commands[0] starts with bash, a shell, runner or interpreter, which would grant any command', { exitCode: 2, paths: [bin] })); +}); + +// Sol P2 #3: the system config refusal names where the config came from, never its path. +test('config validate: a missing system config is named by where it came from, never by host path', (t) => { + const root = under(t), config = join(root, 'config.json'); + const err = { text: '' }, out = { text: '' }; + const streams = { stdout: { write: (s) => { out.text += s; return true; } }, stderr: { write: (s) => { err.text += s; return true; } } }; + assert.equal(runConfigCommand(['validate', 'acme'], { ...streams, env: { PATH: process.env.PATH, HOME: root, MOSAIC_CONFIG: config } }), 3); + assert.equal(out.text, ''); + assert.match(err.text, new RegExp(`^${BUSINESS_CONFIG.prefix}: system config problem \\(mosaic-config exit [1-9][0-9]*\\): .*the system config MOSAIC_CONFIG names\n$`)); + assert.ok(!err.text.includes(SENTINEL) && !err.text.includes(config), err.text); +}); diff --git a/packages/webui/tests/reads.test.mjs b/packages/webui/tests/reads.test.mjs index 850f57ed4..e4cbd609b 100644 --- a/packages/webui/tests/reads.test.mjs +++ b/packages/webui/tests/reads.test.mjs @@ -146,12 +146,13 @@ test('business: missing or invalid file refuses with the module\'s text, redacte const base = await serve(t, reads); let r = await get(base, '/api/business'); assert.equal(r.status, 503); - assert.deepEqual(JSON.parse(r.text), { error: 'not-configured', message: 'business file not found: /businesses/acme.json' }); + assert.deepEqual(JSON.parse(r.text), { error: 'not-configured', message: 'business file acme.json not found' }); + clean(r.text, s.base); // Invalid JSON whose parse error would quote a secret-looking value. writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}'); r = await get(base, '/api/business'); assert.equal(r.status, 503); - assert.match(JSON.parse(r.text).message, /^business file is not valid JSON \(\/businesses\/acme\.json\)/); + assert.match(JSON.parse(r.text).message, /^business file acme\.json is not valid JSON$/); assert.equal(r.text.includes('s3cret-value-xyz'), false); clean(r.text, s.base); // Valid JSON, invalid business: the validator's message names the file. @@ -194,7 +195,7 @@ test('settings: release, board, loopback address, notifier binding name only', a assert.equal(r.status, 200); const n = JSON.parse(r.text).notifier; assert.equal(n.refused, 'not-configured'); - assert.match(n.message, /^no notifier config at \/notify\/acme\/notify\.json/); + assert.match(n.message, /^no notifier config for acme: write .* to notify\/acme\/notify\.json under the data root/); clean(r.text, bare.base, repo.base); base = await serve(t, null); r = await get(base, '/api/settings'); diff --git a/packages/webui/tests/views.test.mjs b/packages/webui/tests/views.test.mjs index 40e22047a..908192a81 100644 --- a/packages/webui/tests/views.test.mjs +++ b/packages/webui/tests/views.test.mjs @@ -248,11 +248,11 @@ test('seeded secrets, Discord ids and paths reach no response and no page; busin rmSync(join(s.configDir, 'businesses', 'acme.json')); await go('#/settings/business', 'Nothing to read'); assert.equal(await text('#bus-view .banner.ref b code'), 'not-configured'); - assert.equal(await text('#bus-view .banner.ref .source'), 'business file not found: /businesses/acme.json'); + assert.equal(await text('#bus-view .banner.ref .source'), 'business file acme.json not found'); writeJson(join(s.configDir, 'businesses', 'acme.json'), '{"id": "acme", s3cret-value-xyz}'); await b.evaluate('document.querySelector("#refresh").click()'); await wait('/not valid JSON/.test(document.querySelector("#bus-view .banner.ref .source")?.textContent)'); - assert.match(await text('#bus-view .banner.ref .source'), /^business file is not valid JSON \(\/businesses\/acme\.json\)/); + assert.match(await text('#bus-view .banner.ref .source'), /^business file acme\.json is not valid JSON$/); assert.equal(await b.evaluate('document.documentElement.outerHTML.includes("s3cret-value-xyz") || window.bodies.some(x=>x.includes("s3cret-value-xyz"))'), false); await seen(); assert.deepEqual(await b.evaluate('window.errors'), []);