diff --git a/packages/discord/src/approvals.mjs b/packages/discord/src/approvals.mjs index 4814f5fa7..d714d9482 100644 --- a/packages/discord/src/approvals.mjs +++ b/packages/discord/src/approvals.mjs @@ -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) { diff --git a/packages/discord/src/cli.mjs b/packages/discord/src/cli.mjs index fda919ba0..11726a514 100755 --- a/packages/discord/src/cli.mjs +++ b/packages/discord/src/cli.mjs @@ -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); }); diff --git a/packages/discord/src/engine-pi.mjs b/packages/discord/src/engine-pi.mjs index 96b4aa118..087e4355e 100644 --- a/packages/discord/src/engine-pi.mjs +++ b/packages/discord/src/engine-pi.mjs @@ -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) => { diff --git a/packages/discord/src/errors.mjs b/packages/discord/src/errors.mjs index 7b5c968d3..dbeb46fe7 100644 --- a/packages/discord/src/errors.mjs +++ b/packages/discord/src/errors.mjs @@ -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) { diff --git a/packages/discord/src/journal.mjs b/packages/discord/src/journal.mjs index e0f5d97fa..ff1fb376b 100644 --- a/packages/discord/src/journal.mjs +++ b/packages/discord/src/journal.mjs @@ -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 --- diff --git a/packages/discord/tests/native-fs-labels.test.mjs b/packages/discord/tests/native-fs-labels.test.mjs new file mode 100644 index 000000000..93deda873 --- /dev/null +++ b/packages/discord/tests/native-fs-labels.test.mjs @@ -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'); +});