Files
stack/packages/organization/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

114 lines
6.0 KiB
JavaScript

// #1873 (redact6): an organization refusal names the field, the file or the root 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, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { checkCredentialRef, parseCredentialRef, storePath } from '../src/credentials.mjs';
import { OrgError, orgLock, quietMessage, readRegistry, withRegistryWriter } from '../src/org-registry.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, { mode: 0o700 });
return { root, dir };
}
function clean(text, root) {
assert.ok(!text.includes(root), text);
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
}
test('a credential problem names the field and the root by label, never the token file or the root', t => {
const { root, dir } = scratch(t);
const file = join(dir, 'gitea');
writeFileSync(file, 'token', { mode: 0o644 });
const ref = { service: 'gitea', file, rotateBy: '2999-01-01' };
const { problems } = checkCredentialRef(ref, { forbiddenRoots: [{ path: dir, label: 'the data root' }, root], where: 'roles.coder.credentials.gitea' });
assert.deepEqual(problems, [
'roles.coder.credentials.gitea file: mode 644 gives group or other access; use 600',
'roles.coder.credentials.gitea file: inside the data root; token files live outside the repository and dataRoot',
'roles.coder.credentials.gitea file: inside a forbidden root; token files live outside the repository and dataRoot',
]);
for (const p of problems) clean(p, root);
const missing = checkCredentialRef({ service: 'gitea', file: join(dir, 'absent'), rotateBy: '2999-01-01' });
assert.deepEqual(missing.problems, ['gitea file: not found']);
const store = checkCredentialRef({ service: 'gitea', store: true, file: join(dir, 'absent'), rotateBy: '2999-01-01' }, { where: 'roles.coder.credentials.gitea' });
assert.deepEqual(store.warnings, ['roles.coder.credentials.gitea store file: not issued']);
});
test('credential-not-yours names the field, not the file', t => {
const { root, dir } = scratch(t);
const store = { configDir: dir, business: 'acme', agent: 'coder' };
const other = storePath({ ...store, agent: 'pm' }, 'gitea');
assert.throws(() => parseCredentialRef({ file: other, rotateBy: '2999-01-01' }, 'gitea', 'roles.coder.credentials.gitea', { store }), error => {
assert.match(error.message, /^roles\.coder\.credentials\.gitea\.file credential-not-yours: the file is in the agents' store/);
clean(error.message, root);
return true;
});
});
test('an OrgError keeps a document field but drops a host path', t => {
const { dir } = scratch(t);
assert.equal(new OrgError('org-mismatch', 'unsafe file', join(dir, 'org.json')).path, undefined);
assert.equal(new OrgError('org-invalid', 'bad root', '$.projects.alpha.root').path, '$.projects.alpha.root');
const error = Object.assign(new Error(`ENOENT: no such file or directory, open '${join(dir, 'x')}'`), { code: 'ENOENT', syscall: 'open' });
assert.equal(quietMessage(error, 'the registry file'), 'cannot open the registry file (ENOENT)');
assert.equal(quietMessage(Object.assign(new Error('x'), { code: 'ENOENT', syscall: `spawn ${join(dir, 'git')}` }), 'git'), 'cannot spawn git (ENOENT)');
});
test('an unreadable registry names the registry file and the error code, never its path', async t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'real.json'), '{}', { mode: 0o600 });
symlinkSync(join(dir, 'real.json'), join(dir, 'org-runtime.json'));
await assert.rejects(readRegistry({ configDir: dir }), error => {
assert.ok(error instanceof OrgError, error.stack);
assert.equal(error.code, 'org-mismatch');
assert.equal(error.message, 'invalid registry: cannot open registry file (ELOOP)');
assert.equal(error.path, undefined);
clean(error.message, root);
clean(JSON.stringify(error), root);
return true;
});
});
test('an unreadable recovery fence names the fence in the config directory, never its path', async t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'org-lifecycle-acme.lock.clear'), '{}', { mode: 0o600 });
chmodSync(join(dir, 'org-lifecycle-acme.lock.clear'), 0o644);
await assert.rejects(orgLock({ configDir: dir }, 'acme', { clear: true }), error => {
assert.ok(error instanceof OrgError, error.stack);
assert.equal(error.code, 'org-locked');
assert.equal(error.message, 'unreadable recovery fence org-lifecycle-acme.lock.clear in the config directory; remove it by hand');
assert.equal(error.path, undefined);
clean(error.message, root);
return true;
});
});
// Review round (#1873): the registry's own filesystem calls name the directory or file by label.
test('a missing config directory names it by label and code, never its path, and keeps the system code', async t => {
const { root, dir } = scratch(t);
await assert.rejects(readRegistry({ configDir: join(dir, 'absent') }), error => {
assert.equal(error.code, 'ENOENT');
assert.equal(error.message, 'cannot lstat the config directory (ENOENT)');
clean(error.message, root);
return true;
});
});
test('the registry writer names the registry file by label when it cannot open it, never its path', t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'real'), '{}', { mode: 0o600 });
symlinkSync(join(dir, 'real'), join(dir, 'org-runtime.json'));
assert.throws(() => withRegistryWriter(dir, () => {}), error => {
assert.equal(error.code, 'ELOOP');
assert.equal(error.message, 'cannot open the registry file (ELOOP)');
clean(error.message, root);
return true;
});
});