Files
stack/packages/discord/tests/native-fs-labels.test.mjs
jason.woltje 63e2a574af 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.
2026-11-11 10:11:11 -06:00

226 lines
11 KiB
JavaScript

// #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');
});