Compare commits

...
13 changed files with 268 additions and 25 deletions

No files matched your search

+1 -1
View File
@@ -11,7 +11,7 @@ import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e.message}`, 2); } }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
try {
const p = parse(args); const words = p.pos; let result; let accepted = [];
@@ -0,0 +1,59 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { loadSystem } from '../src/config.mjs';
import { tmp } from './helpers.mjs';
const repo = resolve(import.meta.dirname, '../../..');
const configScript = join(repo, 'scripts/mosaic-config.mjs');
const cli = join(repo, 'packages/cli/src/cli.mjs');
const secret = 'sk-test-NOT-A-SECRET-1234';
function fixture(t) {
const root = tmp(t, 'jr-'), home = join(root, 'home');
const file = join(home, '.config/mosaic-redacttest/config.json');
const env = { PATH: process.env.PATH, HOME: home, TMPDIR: process.env.TMPDIR, LANG: 'C.UTF-8', MOSAIC_CONFIG: file };
const run = (script, ...args) => spawnSync(process.execPath, [script, ...args], { env, cwd: repo, encoding: 'utf8' });
const boot = () => {
const result = run(configScript, 'bootstrap', '--instance', 'redacttest');
assert.equal(result.status, 0, result.stderr);
};
return { root, file, env, run, boot };
}
function redacted(result, file, status) {
assert.equal(result.status, status, result.stderr);
assert.equal(result.stdout, '');
assert.ok(result.stderr.includes(file), result.stderr);
assert.match(result.stderr, /not valid JSON/);
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
}
test('system config parse refusal and CLI relay omit parser bytes', t => {
const f = fixture(t);
mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, secret);
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2);
assert.throws(() => loadSystem({ env: f.env, instance: 'redacttest' }), error => {
assert.equal(error.exitCode, 3);
assert.ok(error.message.includes(f.file));
assert.match(error.message, /not valid JSON/);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
return true;
});
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
});
test('named instance marker parse refusal omits parser bytes', t => {
const f = fixture(t); f.boot();
const { dataRoot } = JSON.parse(readFileSync(f.file, 'utf8'));
const marker = join(dataRoot, 'instance.json'); writeFileSync(marker, secret);
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3);
});
for (const operation of ['validate', 'create', 'update']) test(`org ${operation} document refusal omits parser bytes`, t => {
const f = fixture(t); f.boot();
const file = join(f.root, 'alpha.json'); writeFileSync(file, secret);
const args = operation === 'validate' ? ['validate', file]
: [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])];
redacted(f.run(cli, 'org', ...args), file, 2);
});
+1 -1
View File
@@ -98,7 +98,7 @@ export function sessionModel(resolved, models = null, which = "primary", { proxy
});
if (models === null) return { harness, provider: null, model, thinking };
const entry = Object.hasOwn(models, model) ? models[model] : null;
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection ${model}`);
if (!entry) throw new BundleError(`instance ${resolved.instance}: unknown model table selection for ${ROUTE_VARS[which][1]} var`);
const transport = entry.provider === "proxy" || proxy ? checkedTransport(entry, null, resolver) : null;
return { harness, provider: entry.provider, model: entry.model, modelKey: model, thinking,
...(transport ? { proxyRoute: transport.routeId, proxyTransport: transport } : {}) };
+14 -1
View File
@@ -125,7 +125,20 @@ test("sessionModel: the route is the instance's own vars, and a missing one refu
const fb = resolvedFor(t, "coder", (d) => (d.roles.coder.vars = { harness: "claude-code", model: "claude-opus-5-5", thinking: "max", "fallback.harness": "pi", "fallback.model": "glm-5.3-flash", "fallback.thinking": "minimal" }));
assert.deepEqual(sessionModel(fb, null, "fallback"), { harness: "pi", provider: null, model: "glm-5.3-flash", thinking: "minimal" });
assert.throws(() => sessionModel(resolvedFor(t, "pm"), null, "fallback"), (e) => e.code === "no-route" && e.missing === "fallback.harness");
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection glm-5.3-flash/);
assert.throws(() => sessionModel(resolvedFor(t, "coder"), models), /unknown model table selection for model var/);
});
for (const [route, name] of [["primary", "model"], ["fallback", "fallback.model"]]) test(`unknown ${route} model selection names the variable without its value`, (t) => {
const secret = "sk-test-NOT-A-SECRET-1234";
const resolved = resolvedFor(t, "coder");
const vars = { ...resolved.vars, [name]: secret, "fallback.harness": "pi", "fallback.thinking": "high" };
assert.throws(() => sessionModel({ ...resolved, vars }, {}, route), error => {
assert.ok(error instanceof BundleError);
assert.equal(error.code, "bundle-refused");
assert.equal(error.message, `instance coder: unknown model table selection for ${name} var`);
assert.ok(!error.message.includes(secret));
return true;
});
});
test("a pi bundle: prompt, policy, tools and manifest, 0600 in a 0700 directory", (t) => {
+1 -1
View File
@@ -87,7 +87,7 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
if (error?.name !== "BusinessError") throw error;
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
// The read's messages carry the host path; the Console shows this one, so it names the file only.
// A parse refusal also quotes the parser, which can quote the file's bytes: say only that it is not JSON.
// Keep parse refusals file-only here too, without the host path.
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
}
+3 -2
View File
@@ -4,6 +4,7 @@ import { dirname, isAbsolute, join, normalize, parse, resolve } from 'node:path'
import { createHash, randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { parseJsonFileText } from './util.mjs';
export class OrgError extends Error {
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (path) this.path = path; }
@@ -22,7 +23,7 @@ async function safeDir(path, create = false) {
}
async function readPrivate(file) {
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return JSON.parse(await fd.readFile('utf8')); } finally { await fd.close(); }
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return parseJsonFileText(await fd.readFile('utf8'), file, 'metadata file'); } finally { await fd.close(); }
}
async function privateDir(path) { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
function validateRegistry(doc, file) {
@@ -86,7 +87,7 @@ export function withRegistryWriter(configDir, fn) {
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
registry = validateRegistry(JSON.parse(readFileSync(fd, 'utf8')), file);
registry = validateRegistry(parseJsonFileText(readFileSync(fd, 'utf8'), file, 'registry file'), file);
} finally { closeSync(fd); }
}
const publish = () => {
+2
View File
@@ -10,6 +10,7 @@ export const RIGHTS = record({
'board.read': flag(true),
'task.write': flag(true),
decide: flag(true),
'org.hire': flag(true),
'request.write': flag(false),
'members.manage': flag(false),
'org.setup': flag(false),
@@ -65,6 +66,7 @@ export const VERB_RIGHTS = record({
'tasks.delete': null,
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'hire.resolve.console': 'org.hire',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
+9 -6
View File
@@ -9,6 +9,7 @@ import { ID_PATTERN } from './vocabulary.mjs';
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
import { loadInstalled } from '../../plugins/src/loader.mjs';
import { BINDING_NAME } from '../../discord/src/binding.mjs';
import { parseJsonFileText } from './util.mjs';
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
@@ -87,7 +88,8 @@ export async function templateRender(id, { fixture, preset, thinking } = {}) {
if (fixture !== undefined && preset !== undefined) fail('org-invalid', 'choose fixture or preset');
if (fixture === 'local') preset = 'fixture';
checkId(id);
const doc = JSON.parse(await fs.readFile(new URL('./org-template.json', import.meta.url), 'utf8'));
const template = new URL('./org-template.json', import.meta.url);
const doc = parseJsonFileText(await fs.readFile(template, 'utf8'), fileURLToPath(template), 'organization template file');
doc.id = id; doc.projects.work.root = `/__mosaic_org__/${id}/projects/work`;
if (preset !== undefined) {
if (typeof preset !== 'string') fail('org-invalid', 'unknown preset');
@@ -126,13 +128,14 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
const found = await resolveOrg(ctx, id);
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
const principal = JSON.parse(await privateRead(join(found.root, 'owner.json'))); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
const ownerFile = join(found.root, 'owner.json');
const principal = parseJsonFileText(await privateRead(ownerFile), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
// From here the caller is the verified owner. A failure carries that proof so a list may
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
try {
const entry = (await readRegistry(ctx)).orgs[id]; const raw = await privateRead(join(ctx.configDir, 'businesses', `${id}.json`));
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file);
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
const doc = JSON.parse(raw); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
const doc = parseJsonFileText(raw, file, 'organization file'); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
}
// Readers do not take the writer lock. Require unchanged registry snapshots and
@@ -174,7 +177,7 @@ export async function notificationShow(ctx, id) {
const file = join(current.root, 'notify', id, 'notify.json');
const st = await fs.lstat(file);
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
const binding = JSON.parse(await privateRead(file));
const binding = parseJsonFileText(await privateRead(file), file, 'notifier configuration file');
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
@@ -325,7 +328,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
try {
await deleteSafeRoot(ctx.dataRoot);
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
try { result = JSON.parse(await privateRead(receiptFile)); }
try { result = parseJsonFileText(await privateRead(receiptFile), receiptFile, 'deletion receipt file'); }
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
if (!result.bus) {
+5 -5
View File
@@ -7,7 +7,7 @@ import {
import { join, dirname } from "node:path";
import { randomUUID } from "node:crypto";
import { businessFilePath, validateBusinessDocument } from "./organization-config.mjs";
import { requireId, sha256, canonicalJson } from "./util.mjs";
import { requireId, sha256, canonicalJson, parseJsonFileText } from "./util.mjs";
import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
@@ -153,7 +153,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
function read(id) {
const file = businessFilePath(id, dir);
const bytes = regular(file);
const document = JSON.parse(bytes);
const document = parseJsonFileText(bytes, file, "organization file");
validateBusinessDocument(document, file, { rolesDir });
return { document, revision: sha256(bytes) };
}
@@ -243,7 +243,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
throw e;
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const entries = text.trim() ? text.trim().split("\n").map(JSON.parse) : [];
const entries = text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : [];
const pending = new Map();
for (const e of entries) {
if (e.state === "prepared") pending.set(e.transaction, e);
@@ -287,7 +287,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
fail('org-mismatch', 'organization document and registry disagree');
}
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
const old = JSON.parse(oldBytes);
const old = parseJsonFileText(oldBytes, file, "organization file");
if (typeof document === "function") document = document(old);
guard(old, document);
replyLimitsFence(old, document, actor);
@@ -421,7 +421,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}
if (text && !text.endsWith("\n")) fail("organization-uncertain", "torn organization receipt");
const last = new Map();
for (const e of text.trim() ? text.trim().split("\n").map(JSON.parse) : []) last.set(e.transaction, e);
for (const e of text.trim() ? text.trim().split("\n").map(line => parseJsonFileText(line, logFile(id), "organization receipt file")) : []) last.set(e.transaction, e);
const hash = sha256(regular(businessFilePath(id, dir)));
for (const e of last.values()) {
const d = e.detail;
+13 -3
View File
@@ -58,6 +58,16 @@ export function requireDate(value, where) {
return value;
}
// Parser diagnostics can quote file bytes. Keep the existing SyntaxError refusal,
// but never attach the parser's message or cause to an error that callers can log.
export function parseJsonFileText(text, file, what = "file") {
try {
return JSON.parse(text);
} catch {
throw new SyntaxError(`${what} is not valid JSON (${file})`);
}
}
// Read a JSON file that must be a regular file, not a symbolic link.
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
// opened without following a link, serves every check and the read.
@@ -81,9 +91,9 @@ export function readJsonFile(file, what, checkStat) {
closeSync(fd);
}
try {
return JSON.parse(text);
} catch (error) {
refuse(`${what} is not valid JSON (${file}): ${error.message}`);
return parseJsonFileText(text, file, what);
} catch {
refuse(`${what} is not valid JSON (${file})`);
}
}
@@ -0,0 +1,130 @@
import { test } from 'node:test';
import fsPromises from 'node:fs/promises';
import { syncBuiltinESMExports } from 'node:module';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmp, writeJson, REPO_ROLES } from './helpers.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { loadBusiness as legacyLoadBusiness } from '../../business/src/business.mjs';
import { loadProject } from '../src/project.mjs';
import { loadRootAgents } from '../src/agent-levels.mjs';
import { readJsonFile } from '../src/util.mjs';
import { organizationWriter } from '../src/organization.mjs';
import { readRegistry, withRegistryWriter } from '../src/org-registry.mjs';
import { createOrg, deleteOrg, notificationShow, showOrg, templateRender } from '../src/org-service.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
const malformed = secret;
function fixture(t) {
const root = tmp();
t.after(() => rmSync(root, { recursive: true, force: true }));
const ctx = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test',
principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
return ctx;
}
function safe(file, { name = 'SyntaxError', code, exitCode, prefix } = {}) {
return error => {
assert.equal(error.name, name);
assert.equal(error.code, code);
assert.equal(error.exitCode, exitCode);
assert.ok(error.message.includes(file), error.message);
assert.match(error.message, /not valid JSON/);
assert.doesNotMatch(error.message, /sk-test|private|broken|Unexpected|position|line \d/);
assert.ok(!error.stack.includes(secret));
assert.equal(error.cause, undefined);
if (prefix) assert.ok(error.message.startsWith(prefix));
return true;
};
}
for (const [label, load, relative] of [
['shared reader', file => readJsonFile(file, 'config file'), 'config.json'],
['organization loader', (_file, ctx) => loadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
['legacy business loader', (_file, ctx) => legacyLoadBusiness('alpha', { dir: ctx.configDir, rolesDir: REPO_ROLES }), 'businesses/alpha.json'],
['project loader', (_file, ctx) => loadProject(ctx.configDir), '.mosaic/project.json'],
]) test(`${label} names malformed JSON's file without quoting bytes`, t => {
const ctx = fixture(t), file = writeJson(join(ctx.configDir, relative), malformed);
assert.throws(() => load(file, ctx), safe(file, { name: 'BusinessError', exitCode: 2 }));
});
test('root agents keep their existing file-only refusal', t => {
const ctx = fixture(t); writeJson(join(ctx.configDir, 'agents.json'), malformed);
assert.throws(() => loadRootAgents(ctx.configDir, { rolesDir: REPO_ROLES }), safe('agents.json', { name: 'BusinessError', exitCode: 2 }));
});
for (const operation of ['read', 'write', 'recover', 'findAddition']) test(`organization writer ${operation} does not quote malformed file bytes`, async t => {
const ctx = fixture(t);
await createOrg(ctx, 'alpha', await templateRender('alpha'));
const writer = organizationWriter({ dir: ctx.configDir, rolesDir: REPO_ROLES });
const current = writer.read('alpha');
const receipt = ['recover', 'findAddition'].includes(operation);
const file = receipt ? writer.logFile('alpha') : join(ctx.configDir, 'businesses/alpha.json');
if (receipt) mkdirSync(join(ctx.configDir, 'businesses/.history/alpha'), { recursive: true, mode: 0o700 });
writeJson(file, malformed + '\n');
if (operation === 'write') {
// Exercise the parse after both the revision and registry digest fences.
current.revision = createHash('sha256').update(readFileSync(file)).digest('hex');
const registryFile = join(ctx.configDir, 'org-runtime.json');
const registry = JSON.parse(readFileSync(registryFile)); registry.orgs.alpha.digest = current.revision;
writeJson(registryFile, registry);
}
const invoke = () => operation === 'write'
? writer.write('alpha', current.document, { revision: current.revision, actor: ctx.principal.sub })
: writer[operation]('alpha', {});
assert.throws(invoke, safe(file));
});
for (const operation of ['async', 'sync']) test(`${operation} registry read names the file without parser bytes`, async t => {
const ctx = fixture(t), file = writeJson(join(ctx.configDir, 'org-runtime.json'), malformed);
if (operation === 'async') await assert.rejects(readRegistry(ctx), safe(file, { name: 'OrgError', code: 'org-mismatch', prefix: 'invalid registry: ' }));
else assert.throws(() => withRegistryWriter(ctx.configDir, () => assert.fail('must refuse')), safe(file));
});
for (const kind of ['owner', 'organization', 'notifier', 'deletion receipt']) test(`service ${kind} refuses malformed JSON without file bytes`, async t => {
const ctx = fixture(t);
const created = await createOrg(ctx, 'alpha', await templateRender('alpha'));
let file;
if (kind === 'owner') file = join(created.root, 'owner.json');
if (kind === 'organization') file = join(ctx.configDir, 'businesses/alpha.json');
if (kind === 'notifier') file = join(created.root, 'notify/alpha/notify.json');
if (kind === 'deletion receipt') {
const registryFile = join(ctx.configDir, 'org-runtime.json');
const registry = JSON.parse(readFileSync(registryFile));
registry.orgs.alpha.state = 'deleting';
registry.orgs.alpha.deletion = { actor: ctx.principal, time: new Date().toISOString(), revision: 1, folder: 'alpha-redaction' };
writeJson(registryFile, registry);
const destination = join(ctx.dataRoot, 'deleted/alpha-redaction');
mkdirSync(destination, { recursive: true, mode: 0o700 });
file = join(destination, 'receipt.json');
}
writeJson(file, malformed);
if (kind === 'organization') {
const registryFile = join(ctx.configDir, 'org-runtime.json');
const registry = JSON.parse(readFileSync(registryFile));
registry.orgs.alpha.digest = createHash('sha256').update(malformed).digest('hex');
writeJson(registryFile, registry);
}
if (kind === 'notifier') await assert.rejects(notificationShow(ctx, 'alpha'), safe(file));
else if (kind === 'deletion receipt') await assert.rejects(deleteOrg(ctx, 'alpha', { confirm: 'alpha', ifRevision: 1, busStop: async () => assert.fail('must refuse first') }), safe(file));
else await assert.rejects(showOrg(ctx, 'alpha'), safe(file));
});
// The packaged template is immutable at runtime; substitute only its read result
// to exercise a corrupt template without editing repository data.
test('template reader refuses malformed JSON without quoting template bytes', async t => {
const original = fsPromises.readFile;
t.mock.method(fsPromises, 'readFile', function(file, ...args) {
if (file instanceof URL && file.pathname.endsWith('/org-template.json')) return Promise.resolve(malformed);
return original.call(this, file, ...args);
});
syncBuiltinESMExports();
try {
await assert.rejects(templateRender('alpha'), safe('org-template.json'));
} finally {
t.mock.restoreAll();
syncBuiltinESMExports();
}
});
@@ -12,13 +12,14 @@ const RIGHTS = {
'board.read': { grantable: true },
'task.write': { grantable: true },
decide: { grantable: true },
'org.hire': { grantable: true },
'request.write': { grantable: false },
'members.manage': { grantable: false },
'org.setup': { grantable: false },
'org.lifecycle': { grantable: false },
};
const BUNDLES = {
owner: ['board.read', 'decide', 'members.manage', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
owner: ['board.read', 'decide', 'members.manage', 'org.hire', 'org.lifecycle', 'org.setup', 'org.view', 'request.write', 'task.write', 'thread.read', 'thread.write'],
member: ['board.read', 'org.view', 'thread.read'],
collaborator: ['board.read', 'org.view', 'task.write', 'thread.read', 'thread.write'],
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
@@ -58,6 +59,7 @@ const VERBS = {
'tasks.delete': null,
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'hire.resolve.console': 'org.hire',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
@@ -70,10 +72,32 @@ const VERBS = {
'project.remote.set.console': 'org.setup',
};
test('M1 the ten rights pin the grantable flags, including compulsory org.view', () => {
test('M1/R1 the eleven rights pin the grantable flags, including compulsory org.view', () => {
assert.deepEqual({ ...policy.RIGHTS }, RIGHTS);
});
test('R1 org.hire belongs to owner and no non-owner template', () => {
assert.ok(policy.ROLE_RIGHTS.owner.includes('org.hire'));
for (const role of ['member', 'collaborator', 'approver']) {
assert.ok(!policy.ROLE_RIGHTS[role].includes('org.hire'), role);
}
});
test('R1 an owner can grant org.hire to a member without widening the template', () => {
const rights = policy.normalizeRights(policy.ROLE_RIGHTS.member, { add: ['org.hire'] });
assert.deepEqual(rights, ['board.read', 'org.hire', 'org.view', 'thread.read']);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, rights), true);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.member, ['org.hire']), false);
assert.equal(policy.canGrant(policy.ROLE_RIGHTS.owner, ['org.hire', 'future.right']), false);
assert.throws(() => policy.normalizeRights(rights, { add: ['future.right'] }), { code: 'unknown-right' });
assert.deepEqual(policy.ROLE_RIGHTS.member, ['board.read', 'org.view', 'thread.read']);
});
test('R1 hire resolution requires org.hire while generic decision resolution keeps decide', () => {
assert.equal(policy.VERB_RIGHTS['hire.resolve.console'], 'org.hire');
assert.equal(policy.VERB_RIGHTS['decision.resolve.console'], 'decide');
});
for (const [role, rights] of Object.entries(BUNDLES)) {
test(`M1 ${role} has exactly its reviewed rights, expanded and sorted`, () => {
assert.deepEqual(policy.ROLE_RIGHTS[role], rights);
@@ -161,6 +185,7 @@ test('M1 every actual console, user, board and person-task verb has a reviewed a
}
assert.deepEqual(Object.keys(VERBS).sort(), [...new Set([
...CONSOLE_VERBS, ...USER_VERBS, ...BOARD_VERBS, ...PERSON_TASK_VERBS, 'plugins', 'plugins.set', 'limits',
'hire.resolve.console', // R1 reserves the policy mapping; A4 implements the verb.
])].sort());
});
+3 -3
View File
@@ -133,7 +133,7 @@ function readMarker(dataRoot) {
try {
marker = JSON.parse(fs.readFileSync(file, "utf8"));
} catch (error) {
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error.message})`);
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error instanceof SyntaxError ? 'not valid JSON' : error.message})`);
}
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
@@ -382,8 +382,8 @@ async function load(file, instance = null) {
let document;
try {
document = JSON.parse(raw);
} catch (error) {
fail(2, `configuration is not valid JSON (${file}): ${error.message}`);
} catch {
fail(2, `configuration is not valid JSON (${file})`);
}
return await validate(document, file);
}