Files
stack/packages/seat/tests/path-redaction-labels.test.mjs
jason.woltje 87db40e0cd fix(cli,seat,discord,organization,control-board,harness,plugins,webui): review round: launcher reasons, session, discord reads, mosaic-config and keys by label (#1873)
Review "changes 8" fix round for org-redact6.

- launcher: a harness module's throw names its system code or class,
  never its message; preflight, loader, bundle, checkout, sandbox and
  route text is relayed with host paths replaced (relay()).
- claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the
  repository's node_modules, never a path; persona and skill bundle
  errors and sandboxProblem name their file or the sandbox by label.
- seat session: thread store, legacy store, overlap, busy lock and
  newRun refusals name the place under the data root.
- discord: unknown keys, binding names and turn ids are described when
  not plain; approvals, journal and repository read errors name the file
  and code; git drops stderr lines that carry a host path and a spawn
  failure names its code only.
- seat, control-board: registration fields and seen keys are described
  when not plain; invalid seat name and layout state the constraint.
- organization: the registry names the config directory and registry
  file by label; --project is quoted only when it is a plain name;
  systemConfigProblem moves to its own module.
- scripts/mosaic-config.mjs: the config by where it came from, the data
  root and its marker by role, keys and arguments only when plain, and
  error codes in place of raw messages. Exit codes unchanged.
- webui sso-config: an unknown console key is named only when plain.

Sentinel tests in each package's path-redaction-labels file fail on
BASE; pins that quoted a path now assert the label and its absence.
2026-11-11 08:53:31 -06:00

121 lines
6.3 KiB
JavaScript

// #1873 (redact6): a seat refusal names the config, the seat or the registration by label,
// never a host path. The sentinel is a component of every scratch path, so a refusal that
// quotes a path quotes the sentinel too; each test also checks the scratch root and the label.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { SeatError, loadDataRoot, readRegistration, registrationPath, resolveSeat } from '../src/seat.mjs';
import { lockSessionWorkspace, newRun, readSessions, sessionStore, sessionWorkspace, sessionsFile, threadStore } from '../src/session.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r6-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, secret);
mkdirSync(dir);
return { root, dir };
}
function labelled(label, root) {
return error => {
assert.ok(error instanceof SeatError, error.stack);
assert.equal(error.message, label);
assert.ok(!error.message.includes(root), error.message);
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
return true;
};
}
function write(file, text) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text);
return file;
}
test('config refusals name the config, not its path', t => {
const { root, dir } = scratch(t);
assert.throws(() => loadDataRoot(join(dir, 'config.json')), labelled('config not found', root));
assert.throws(() => loadDataRoot(write(join(dir, 'array.json'), '[]')), labelled('config is not an object', root));
assert.throws(() => loadDataRoot(write(join(dir, 'relative.json'), '{"dataRoot":"data"}')), labelled('config.dataRoot must be an absolute path', root));
});
test('seat directory refusals name the seat, not its directory', t => {
const { root, dir } = scratch(t);
assert.throws(() => resolveSeat(join(dir, 'agents', 'builder')), labelled('no such seat directory: seat builder', root));
mkdirSync(join(dir, 'agents', 'builder'), { recursive: true });
assert.throws(() => resolveSeat('builder', { repo: dir }), labelled('seat has no launch.sh: seat builder', root));
write(join(dir, 'agents', 'builder', 'launch.sh'), '#!/bin/sh\n');
chmodSync(join(dir, 'agents', 'builder', 'launch.sh'), 0o644);
assert.throws(() => resolveSeat('builder', { repo: dir }), labelled('launch script is not executable: launch.sh of seat builder', root));
});
test('a registration that does not validate is named by its layout and seat', t => {
const { root, dir } = scratch(t);
write(registrationPath(dir, 'builder', 'repo'), '{}');
assert.throws(() => readRegistration(dir, 'builder', 'repo'), error => {
assert.ok(error instanceof SeatError, error.stack);
assert.match(error.message, /: registration repo\/builder$/);
assert.ok(!error.message.includes(root), error.message);
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
return true;
});
});
test('an unreadable or malformed sessions file is named by its place under the data root', t => {
const { root, dir } = scratch(t);
write(sessionsFile(dir), 'not json');
assert.throws(() => readSessions(dir), labelled('sessions file is unreadable: bus-host/sessions.json under the data root', root));
write(sessionsFile(dir), '{"sessionsVersion":0}');
assert.throws(() => readSessions(dir), labelled('sessions file is malformed: bus-host/sessions.json under the data root', root));
});
// Review round (#1873): session stores, workspaces, locks and run directories are named under
// the data root by their checked ids, never by host path; codes and exits are unchanged.
function coded(label, root, code) {
return error => labelled(label, root)(error) && error.code === code && error.exitCode === 2;
}
test('an unsafe or unmakeable thread store directory is named under the data root, never by path', t => {
const { root, dir } = scratch(t);
mkdirSync(join(dir, 'threads'), { mode: 0o755 });
chmodSync(join(dir, 'threads'), 0o755);
assert.throws(() => threadStore(dir, 'acme', 't1'), coded('thread store: under the data root, threads is unsafe', root, 'thread-store-refused'));
// A system error keeps its code; its message names the directory by label.
assert.throws(() => threadStore(join(dir, 'absent'), 'acme', 't1'), error => {
assert.equal(error.code, 'ENOENT');
assert.equal(error.message, 'thread store: cannot mkdir under the data root, threads (ENOENT)');
assert.ok(!error.message.includes(root), error.message);
return true;
});
});
test('a legacy session store left behind is named under the data root, never by path', t => {
const { root, dir } = scratch(t);
mkdirSync(join(dir, 'threads', 'acme', 't1', 'session'), { recursive: true, mode: 0o700 });
assert.throws(() => sessionStore(dir, 'acme', 't1', 's1'), coded('thread store: under the data root, threads/acme/t1/session holds history no legacy session maps', root, 'legacy-store-unmapped'));
});
test('a session workspace that overlaps a live one is named under the data root, never by path', t => {
const { root, dir } = scratch(t);
sessionWorkspace(dir, 'acme', 'i1', 's1');
assert.throws(() => sessionWorkspace(dir, 'acme', 'i1', 's2', { live: [join(dir, 'session-workspaces', 'acme')] }), coded('session workspace: under the data root, session-workspaces/acme/i1/s2 overlaps a live workspace', root, 'session-workspace-overlap'));
});
test('a busy session workspace lock names the session, never the lock path', t => {
const { root, dir } = scratch(t);
const ws = join(dir, 's1');
writeFileSync(`${ws}.lock`, 'not json');
assert.throws(() => lockSessionWorkspace(ws, {}), coded('session workspace s1 is busy: its lock is unreadable', root, 'session-workspace-busy'));
});
test('a data root too long for a socket names the run, never its directory', t => {
const { root, dir } = scratch(t);
assert.throws(() => newRun(join(dir, 'a'.repeat(70)), 'acme'), error => {
assert.ok(error instanceof SeatError, error.stack);
assert.equal(error.exitCode, 2);
assert.match(error.message, /^data root path too long for a socket: run r[0-9a-f]{12}$/);
assert.ok(!error.message.includes(root), error.message);
return true;
});
});