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.
226 lines
11 KiB
JavaScript
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');
|
|
});
|