fix(discord): a native fs error never prints its path: CLI choke point and journal labels (#1873)

The discord CLI's top-level handler prints a native Node error that is not
already a labelled refusal as 'cannot <syscall> <label> (<CODE>)'. The journal
and approvals readers and writers name the file by label at the source, so
conversation, control-board and webui callers get the label too. The session
directory check moves to engine-pi.mjs as sessionContinues(). Exit codes and
refusal codes are unchanged.
This commit is contained in:
jason.woltje committed 2026-11-11 10:11:11 -06:00
1 parent d9023e28db
commit 63e2a574af
6 files changed
+383 -55

No files matched your search

+6 -2
View File
@@ -22,7 +22,7 @@
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DiscordError, quietRead } from "./errors.mjs";
import { DiscordError, quietFs, quietRead } from "./errors.mjs";
export const APPROVE_WORD = "approve";
export const CUSTOM_ID_PREFIX = "approve:";
@@ -95,7 +95,11 @@ export function appendApproval(dir, entry) {
if (!isObject(entry) || typeof entry.kind !== "string") throw new DiscordError("approvals: entry needs a kind", 1);
const line = JSON.stringify(entry);
if (line.includes("\n")) throw new DiscordError("approvals: line must not contain a newline", 1);
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
try {
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
} catch (e) {
throw quietFs(e, "approvals.jsonl", "append to");
}
}
export function readApprovals(dir) {
+10 -16
View File
@@ -46,17 +46,17 @@
// 3 refused by a brake (STOP present or the binding held; a supervisor must
// not retry); 4 usage.
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { DiscordError } from "./errors.mjs";
import { DiscordError, printable } from "./errors.mjs";
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
import { createRest } from "./rest.mjs";
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
import { createEngine, buildPiArgs, sessionContinues } from "./engine-pi.mjs";
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
import { createSetsparkApi } from "./setspark.mjs";
import { assembleContext } from "./context.mjs";
@@ -114,7 +114,7 @@ function checkLibraryEngine(configDirectory, binding) {
try {
sets = loadSeatSets({ dir: configDirectory });
} catch (err) {
throw new DiscordError(`discord-business-unreadable: ${err.message}`, 2);
throw new DiscordError(`discord-business-unreadable: ${printable(err, "a business file")}`, 2);
}
if (!sets.librarySeats.has(binding.seat)) return;
if (binding.engine.provider !== "mosaic-bus") {
@@ -234,7 +234,7 @@ async function run(opts) {
const promptFile = join(launch, "context.md");
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
const continueSession = existsSync(sessionDir) && statSync(sessionDir).isDirectory() && hasJsonl(sessionDir);
const continueSession = sessionContinues(sessionDir);
const rest = createRest({ token, log: warn });
const gw = await rest.getGatewayBot();
@@ -272,7 +272,7 @@ async function run(opts) {
clearInterval(processingWatch);
exitCode = code;
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${printable(err)}`)).finally(() => {
cleanupPid();
process.exit(exitCode);
});
@@ -317,8 +317,8 @@ async function run(opts) {
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
} catch (err) {
appendReload(journalDir, { at, outcome: "refused", error: err.message });
warn(`reload refused, binding unchanged: ${err.message}`);
appendReload(journalDir, { at, outcome: "refused", error: printable(err) });
warn(`reload refused, binding unchanged: ${printable(err)}`);
}
};
let reloadChain = Promise.resolve();
@@ -332,13 +332,6 @@ async function run(opts) {
}
}
function hasJsonl(dir) {
try {
return readdirSync(dir).some((f) => f.endsWith(".jsonl"));
} catch {
return false;
}
}
function stop(opts) {
const dataRoot = loadDataRoot(opts.config);
@@ -413,7 +406,8 @@ main().then((code) => {
if (code !== null) process.exit(code);
}).catch((err) => {
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
warn(err.message);
// Never a native error's message: it carries the path (#1873).
warn(printable(err));
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
process.exit(code);
});
+26 -4
View File
@@ -32,8 +32,9 @@
// without the connector noticing: the contract is start(), prompt(), stop().
import { spawn as nodeSpawn } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { DiscordError } from "./errors.mjs";
import { DiscordError, printable, quietFs } from "./errors.mjs";
import { enabledToolNames } from "./tools.mjs";
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
@@ -51,6 +52,25 @@ export const TOOLS_EXTENSION = fileURLToPath(new URL("../extension/tools.mjs", i
// Older name, kept for the tests and docs that still use it.
export const READONLY_TOOLS_EXTENSION = TOOLS_EXTENSION;
// Whether pi continues the session in sessionDir: it is a directory holding a
// .jsonl session file. A stat that fails names the binding's session directory,
// never its path (#1873); a listing that fails means a new session, as before.
export function sessionContinues(sessionDir) {
if (!existsSync(sessionDir)) return false;
let st;
try {
st = statSync(sessionDir);
} catch (e) {
throw quietFs(e, "the binding's session directory");
}
if (!st.isDirectory()) return false;
try {
return readdirSync(sessionDir).some((f) => f.endsWith(".jsonl"));
} catch {
return false;
}
}
export function buildPiArgs({ provider, model, thinking, sessionDir, appendSystemPromptFile, continueSession, tools = null }) {
const args = [...PI_FIXED_ARGS];
if (tools) args.push("--no-builtin-tools", "--extension", TOOLS_EXTENSION, "--tools", enabledToolNames(tools).join(","));
@@ -393,9 +413,11 @@ export function createEngine({
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
child.on("error", (err) => {
releaseRuntime();
log(`engine spawn error: ${err.message}`);
state.exited = { code: null, signal: null, error: err.message };
failAll("engine-down", `engine failed: ${err.message}`);
// A spawn error's message carries the command's path (#1873).
const why = printable(err, "pi");
log(`engine spawn error: ${why}`);
state.exited = { code: null, signal: null, error: why };
failAll("engine-down", `engine failed: ${why}`);
onExit(state.exited);
});
child.on("exit", (code, signal) => {
+31 -5
View File
@@ -9,13 +9,39 @@ export const plainKey = (k) => (/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(String(k))
// journal.mjs + errors.mjs (the queue test repo) stay self-contained. binding.mjs re-exports it.
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// A read that fails names the file by label and the error code, never the
// path Node puts in the message. The same error object is rethrown, so its
// type, code and the exit it maps to are unchanged (#1873).
export function quietRead(e, label) {
if (e && typeof e === "object" && typeof e.code === "string") e.message = `cannot read ${label} (${e.code})`;
// A filesystem call that fails names the file by label, the call and the
// error code, never the path Node puts in the message. The same error object
// is rethrown, so its type, code and the exit it maps to are unchanged, and
// it is marked so the CLI's top-level handler prints it as it is (#1873).
const QUIET = Symbol("discord.quiet");
const SYSCALL = /^[a-z][a-z0-9_]{0,31}$/;
const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
const callOf = (e) => {
const word = typeof e.syscall === "string" ? e.syscall.split(" ")[0] : "";
return SYSCALL.test(word) ? word : "access";
};
const codeOf = (e) => (typeof e.code === "string" && CODE.test(e.code) ? e.code : "UNKNOWN");
export function quietFs(e, label, verb = null) {
if (e && typeof e === "object" && typeof e.code === "string") {
e.message = `cannot ${verb ?? callOf(e)} ${label} (${codeOf(e)})`;
e[QUIET] = true;
}
return e;
}
export const quietRead = (e, label) => quietFs(e, label, "read");
// What a top-level handler prints for an error (#1873). Our refusals and an
// error already labelled at its source print their own message. A native
// Node error (it carries a syscall, or a code and a path) that slipped past
// every source label prints only its call and code, never its message,
// which carries the path; `label` names the file when the error has one.
export function printable(err, label = "a discord file") {
if (err instanceof DiscordError || (err && err[QUIET] === true)) return String(err.message);
if (err && typeof err === "object" && (typeof err.syscall === "string" || (typeof err.code === "string" && typeof err.path === "string"))) {
return typeof err.path === "string" ? `cannot ${callOf(err)} ${label} (${codeOf(err)})` : `cannot ${callOf(err)} (${codeOf(err)})`;
}
return err && typeof err.message === "string" ? err.message : String(err);
}
export class DiscordError extends Error {
constructor(message, exitCode = 2, details = undefined) {
+85 -28
View File
@@ -26,7 +26,7 @@ import {
unlinkSync, writeFileSync, writeSync,
} from "node:fs";
import { basename, join } from "node:path";
import { BINDING_NAME, DiscordError, quietRead } from "./errors.mjs";
import { BINDING_NAME, DiscordError, quietFs, quietRead } from "./errors.mjs";
// Where a refusal says a binding's journal is: its place under the data root
// when the directory carries a valid binding name (bindingDataDir() makes it
@@ -46,7 +46,11 @@ export function ensureJournal(dir) {
function appendLine(path, record) {
const line = JSON.stringify(record);
if (line.includes("\n")) throw new DiscordError("journal line must not contain a newline", 1);
appendFileSync(path, line + "\n", { mode: 0o600 });
try {
appendFileSync(path, line + "\n", { mode: 0o600 });
} catch (e) {
throw quietFs(e, basename(path), "append to");
}
}
function readLines(path) {
@@ -126,6 +130,10 @@ export function turnPath(dir, id) {
return join(dir, "turns", `${id}.json`);
}
// A parse refusal or a failed read or write names the turn by its id, never by
// host path (PR #1916, #1873); 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 writeTurn(dir, id, record) {
const path = turnPath(dir, id);
let fd;
@@ -133,31 +141,46 @@ export function writeTurn(dir, id, record) {
fd = openSync(path, "wx", 0o600);
} catch (err) {
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${id}`, 1);
throw err;
throw quietFs(err, turnLabel(String(id)), "write");
}
try {
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
} finally {
closeSync(fd);
try {
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
} finally {
closeSync(fd);
}
} catch (err) {
throw quietFs(err, turnLabel(String(id)), "write");
}
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");
function readTurnFile(path, id) {
let text;
try {
text = readFileSync(path, "utf8");
} catch (e) {
throw quietRead(e, turnLabel(id));
}
return parseJsonFileText(text, path, turnLabel(id));
}
export function readTurn(dir, id) {
const path = turnPath(dir, id);
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
return readTurnFile(turnPath(dir, id), String(id));
}
export function listTurns(dir) {
const turns = join(dir, "turns");
if (!existsSync(turns)) return [];
return readdirSync(turns)
let names;
try {
names = readdirSync(turns);
} catch (e) {
throw quietRead(e, `the turns directory in ${journalLabel(dir)}`);
}
return names
.filter((f) => f.endsWith(".json"))
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length))));
.map((f) => readTurnFile(join(turns, f), f.slice(0, -".json".length)));
}
// The daily ceiling counts admissions on the current UTC date. An admission
@@ -202,21 +225,38 @@ function stopLock(dir, action) {
const lock = join(dir, '.stop.lock');
try { mkdirSync(lock, { mode: 0o700 }); }
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
try { return action(); } finally { rmdirSync(lock); }
try { return action(); } finally {
try { rmdirSync(lock); } catch (e) { throw quietFs(e, `.stop.lock in ${journalLabel(dir)}`, "remove"); }
}
}
// A STOP write or removal that fails names STOP in the journal, never its path (#1873).
const stopLabel = (dir) => `STOP in ${journalLabel(dir)}`;
function appendStop(dir, reason) {
const path = stopPath(dir);
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
fsyncSync(fd);
} finally {
closeSync(fd);
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
fsyncSync(fd);
} finally {
closeSync(fd);
}
} catch (e) {
throw quietFs(e, stopLabel(dir), "write");
}
return path;
}
function removeStop(dir) {
try {
unlinkSync(stopPath(dir));
} catch (e) {
throw quietFs(e, stopLabel(dir), "remove");
}
}
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function processingStops(dir) {
let fd;
@@ -248,14 +288,13 @@ export function clearProcessingStop(dir, generation) {
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
new Date(r.at).toISOString() !== r.at)) return false;
unlinkSync(stopPath(dir));
removeStop(dir);
return true;
});
}
export function clearStop(dir) {
const path = stopPath(dir);
if (existsSync(path)) unlinkSync(path);
if (existsSync(stopPath(dir))) removeStop(dir);
}
// The STOP lines as written, oldest first. A line that does not parse is
@@ -263,7 +302,13 @@ export function clearStop(dir) {
// treated as ours.
export function readStop(dir) {
if (!stopRequested(dir)) return [];
return readFileSync(stopPath(dir), "utf8").split("\n").filter((l) => l.trim() !== "").map((l) => {
let text;
try {
text = readFileSync(stopPath(dir), "utf8");
} catch (e) {
throw quietRead(e, stopLabel(dir));
}
return text.split("\n").filter((l) => l.trim() !== "").map((l) => {
try {
const v = JSON.parse(l);
return v && typeof v === "object" && typeof v.reason === "string" ? v : { reason: null };
@@ -422,12 +467,16 @@ export function writePid(dir, pid, { now = Date.now(), identity = identityOf } =
try {
mkdirSync(lock, { mode: 0o700 });
} catch (err) {
if (err.code !== "EEXIST") throw err;
if (err.code !== "EEXIST") throw quietFs(err, `run.lock in ${journalLabel(dir)}`, "create");
throw lockRefusal(dir, { identity });
}
const tmp = join(lock, "owner.json.tmp");
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
renameSync(tmp, ownerPath(dir));
try {
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
renameSync(tmp, ownerPath(dir));
} catch (e) {
throw quietFs(e, `run.lock/owner.json in ${journalLabel(dir)}`, "write");
}
// The gate: STOP written before this point (by `stop` or `unlock`) means
// this claim must not stand, however it interleaved with an unlock.
if (stopRequested(dir)) {
@@ -454,7 +503,7 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
if (state === "unknown") throw new DiscordError(`refusing to unlock: pid ${rec.pid} is alive and its identity cannot be verified; nothing removed. Stop that process first`, 1);
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, 1);
if (beforeRemove) beforeRemove();
rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
removeLock(dir, { maxRetries: 10, retryDelay: 20 });
return rec;
}
@@ -502,7 +551,15 @@ export function stopTarget(dir, opts) {
export function clearPid(dir, pid) {
const rec = readPid(dir);
if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true });
if (rec !== null && !rec.invalid && rec.pid === pid) removeLock(dir);
}
function removeLock(dir, retry = {}) {
try {
rmSync(lockPath(dir), { recursive: true, force: true, ...retry });
} catch (e) {
throw quietFs(e, `run.lock in ${journalLabel(dir)}`, "remove");
}
}
// --- reloads ---
@@ -0,0 +1,225 @@
// #1873 (redact6, re-check 2): a native fs error never prints its path. The journal and
// approvals readers and writers name the file by label at the source, and the CLI's
// top-level handler prints any native error that slipped through as its call and code
// only. The sentinel is a component of every scratch path, so an error message that
// quotes a path quotes the sentinel too.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs, { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { appendApproval } from '../src/approvals.mjs';
import { DiscordError } from '../src/errors.mjs';
import {
appendInbox, clearStop, ensureJournal, listTurns, lockPath, readTurn, recover, requestStop, stopPath, turnPath, writePid, writeTurn,
} from '../src/journal.mjs';
import { makeDeployment, makeRepo } from './helpers.mjs';
import { initializeControl } from '../../processing-control/src/state.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
// A journal directory named like a binding, under a sentinel path component, so its
// label is "discord/seat under the data root".
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r6n-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, secret, 'seat');
mkdirSync(dir, { recursive: true });
return { root, dir };
}
const where = 'discord/seat under the data root';
function quiet(text, root) {
assert.ok(text.includes(root) === false, text);
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
}
function labelled(label, root) {
return error => {
assert.ok(error instanceof Error, String(error));
assert.equal(error instanceof DiscordError, false, 'a native fs error keeps its type');
assert.ok(error.message.includes(label), error.message);
quiet(error.message, root);
return true;
};
}
// Swap one node:fs function so the target goes away right before the real call, which
// then fails with a genuine error that names the path. One swap per test.
function race(t, fn, target, remove) {
const original = fs[fn];
const swapped = function (p, ...rest) {
if (String(p) === target) remove();
return original.call(this, p, ...rest);
};
if (original.native) swapped.native = original.native;
fs[fn] = swapped;
syncBuiltinESMExports();
t.after(() => {
fs[fn] = original;
syncBuiltinESMExports();
});
}
// --- the four named sites ---
test('recover: a STOP removed between its check and its read names STOP in the journal', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
writeFileSync(stopPath(dir), JSON.stringify({ at: new Date().toISOString(), reason: 'cli stop' }) + '\n', { mode: 0o600 });
race(t, 'readFileSync', stopPath(dir), () => rmSync(stopPath(dir)));
assert.throws(() => recover(dir), labelled(`cannot read STOP in ${where} (ENOENT)`, root));
});
test('readTurn: a turn record removed before its read names the turn by id', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
assert.throws(() => readTurn(dir, 't1'), labelled('cannot read turn record t1 (ENOENT)', root));
});
test('listTurns: a turns directory removed between its check and its listing names the turns directory', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
const turns = join(dir, 'turns');
race(t, 'readdirSync', turns, () => rmSync(turns, { recursive: true }));
assert.throws(() => listTurns(dir), labelled(`cannot read the turns directory in ${where} (ENOENT)`, root));
});
test('listTurns: a turn record removed between the listing and its read names the turn by id', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
assert.throws(() => listTurns(dir), labelled('cannot read turn record t1 (ENOENT)', root));
});
test("run: a session directory removed between its check and its stat names the binding's session directory", async t => {
const { root, dir } = scratch(t);
const { sessionContinues } = await import('../src/engine-pi.mjs');
assert.equal(typeof sessionContinues, 'function', 'engine-pi.mjs exports sessionContinues');
const session = join(dir, 'session');
assert.equal(sessionContinues(session), false, 'no directory: a new session');
mkdirSync(session);
assert.equal(sessionContinues(session), false, 'an empty directory: a new session');
writeFileSync(join(session, 'a.jsonl'), '');
assert.equal(sessionContinues(session), true, 'a .jsonl file: the session continues');
race(t, 'statSync', session, () => rmSync(session, { recursive: true }));
assert.throws(() => sessionContinues(session), labelled("cannot stat the binding's session directory (ENOENT)", root));
});
// --- journal and approvals writers (the connector logs err.message at runtime) ---
test('a journal line that cannot be appended names the journal file, never its path', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
mkdirSync(join(dir, 'inbox.jsonl'));
assert.throws(() => appendInbox(dir, { id: '1' }), labelled('cannot append to inbox.jsonl (EISDIR)', root));
});
test('an approvals line that cannot be appended names approvals.jsonl, never its path', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
mkdirSync(join(dir, 'approvals.jsonl'));
assert.throws(() => appendApproval(dir, { kind: 'opened' }), labelled('cannot append to approvals.jsonl (EISDIR)', root));
});
test('a turn record that cannot be created names the turn by id; an existing one keeps its refusal', t => {
const { root, dir } = scratch(t);
assert.throws(() => writeTurn(dir, 't1', {}), labelled('cannot write turn record t1 (ENOENT)', root));
ensureJournal(dir);
writeTurn(dir, 't1', {});
assert.throws(() => writeTurn(dir, 't1', {}), e => e instanceof DiscordError && e.exitCode === 1 && /turn record already exists: t1/.test(e.message));
});
test('a STOP that cannot be written names STOP in the journal; the lock is released', t => {
const { root, dir } = scratch(t);
mkdirSync(stopPath(dir));
assert.throws(() => requestStop(dir, 'cli stop'), labelled(`cannot write STOP in ${where} (EISDIR)`, root));
assert.equal(existsSync(join(dir, '.stop.lock')), false);
});
test('a STOP removed between its check and its removal names STOP in the journal', t => {
const { root, dir } = scratch(t);
writeFileSync(stopPath(dir), '{}\n', { mode: 0o600 });
race(t, 'unlinkSync', stopPath(dir), () => rmSync(stopPath(dir)));
assert.throws(() => clearStop(dir), labelled(`cannot remove STOP in ${where} (ENOENT)`, root));
});
test('a run.lock that cannot be created names run.lock in the journal', t => {
const { root, dir } = scratch(t);
const gone = join(dir, 'gone');
assert.throws(() => writePid(gone, process.pid), e => {
assert.ok(e.message.includes('cannot create run.lock in discord/gone under the data root (ENOENT)'), e.message);
quiet(e.message, root);
return true;
});
assert.equal(existsSync(lockPath(gone)), false);
});
test('engine: a pi that cannot be spawned is logged and recorded as "cannot spawn pi", never its path', async t => {
const { root, dir } = scratch(t);
const { createEngine } = await import('../src/engine-pi.mjs');
const logs = [];
let exited = null;
const engine = createEngine({ command: join(dir, 'pi'), args: [], cwd: dir, env: {}, log: (m) => logs.push(m), onExit: (e) => (exited = e) });
engine.start();
for (let i = 0; i < 200 && exited === null; i += 1) await new Promise((res) => setTimeout(res, 10));
assert.ok(exited, 'the spawn error reached onExit');
assert.equal(exited.error, 'cannot spawn pi (ENOENT)');
assert.ok(logs.includes('engine spawn error: cannot spawn pi (ENOENT)'), logs.join('\n'));
quiet(JSON.stringify({ logs, exited }), root);
});
// --- the choke point ---
test('printable: a native error prints its call and code, a refusal or a labelled error its own message', async () => {
const { printable, quietRead } = await import('../src/errors.mjs');
assert.equal(typeof printable, 'function', 'errors.mjs exports printable');
const path = `/x/${secret}/discord/seat/STOP`;
const native = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { code: 'ENOENT', syscall: 'open', path, errno: -2 });
assert.equal(printable(native), 'cannot open a discord file (ENOENT)');
const pathOnly = Object.assign(new Error(`EACCES ${path}`), { code: 'EACCES', path });
assert.equal(printable(pathOnly), 'cannot access a discord file (EACCES)');
const spawn = Object.assign(new Error(`spawn ${path} ENOENT`), { code: 'ENOENT', syscall: `spawn ${path}`, path });
assert.equal(printable(spawn), 'cannot spawn a discord file (ENOENT)');
const net = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:1'), { code: 'ECONNREFUSED', syscall: 'connect' });
assert.equal(printable(net), 'cannot connect (ECONNREFUSED)');
const odd = Object.assign(new Error(path), { code: `X ${path}`, syscall: path, path });
assert.equal(printable(odd), 'cannot access a discord file (UNKNOWN)');
const labelledRead = quietRead(Object.assign(new Error(path), { code: 'ENOENT', syscall: 'open', path }), 'token file');
assert.equal(printable(labelledRead), 'cannot read token file (ENOENT)');
assert.equal(printable(new DiscordError('STOP is present in discord/seat under the data root; remove it to run', 3)), 'STOP is present in discord/seat under the data root; remove it to run');
assert.equal(printable(new Error('plain refusal')), 'plain refusal');
for (const v of [native, pathOnly, spawn, odd]) assert.doesNotMatch(printable(v), /sk-test|NOT-A-SECRET/);
});
test('cli: a native fs error reaching the top-level handler prints its call and code, never the path, and exits 1', t => {
const { root } = scratch(t);
const base = join(root, secret, 'dep');
mkdirSync(base, { recursive: true });
const repo = makeRepo(base);
const dep = makeDeployment(base);
initializeControl(dep.dataRoot);
const run = (args) => spawnSync(process.execPath, [cli, ...args, '--config', dep.config, '--repo', repo], { encoding: 'utf8' });
// stop: the journal directory is a regular file, so ensureJournal's mkdir fails.
writeFileSync(dep.journalDir, 'not a directory');
let r = run(['stop', 'test-seat']);
assert.equal(r.status, 1, r.stderr);
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
quiet(r.stderr + r.stdout, root);
rmSync(dep.journalDir);
// run: the sessions directory is a regular file, so the claimed run's mkdir fails
// after the claim; the claim is released and the exit stays 1.
writeFileSync(join(dep.dataRoot, 'sessions'), 'not a directory');
r = run(['run', 'test-seat']);
assert.equal(r.status, 1, r.stderr);
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
quiet(r.stderr + r.stdout, root);
assert.equal(existsSync(lockPath(dep.journalDir)), false, 'the claim is released');
});