Compare commits

...
9 Commits
Author SHA1 Message Date
jason.woltje c3c192677d test(webui): the layout reload test loads the page through navigate (#1873)
The mark from the previous commit kept the wait off the old page, but
the wait could still start on it and be killed by the reload
("Inspected target navigated or closed", once in ten runs). navigate()
waits for the load; a query makes the same URL load again.
2026-11-10 16:01:01 -06:00
jason.woltje 95ee19cc71 fix(webui): a task dialog closed during the conflict read stays closed (#1873)
Review fix. dialog.close() clears `open` at once but queues the close
event that empties the dialog. A conflict read that resumed in between
still found the old form connected and reopened the dialog, and the
queued event then emptied it: an open modal with nothing in it. The
check is now the dialog's own `open` state and that it still holds the
form. A browser test closes the dialog and releases the read's body in
one task; it fails on the previous commit.
2026-11-10 16:01:01 -06:00
jason.woltje 07dba7f2f5 test(webui): the layout reload test waits for the new page (#1873)
The wait after location.reload() could pass on the board that was
unloading, and the next read then found no board (seen once in ten
runs under load). A mark set before the reload goes with the old page,
so the wait now passes only on the reloaded one.
2026-11-10 16:01:01 -06:00
jason.woltje ceee80d5b7 fix(webui): task board conflict shows the refusal only with the current task (#1873)
A product race, not only a test race. On task-conflict the dialog wrote
the refusal into the old form, turned its buttons back on and only then
read the current task. For the length of that read the form showed the
refusal beside the old task and a Save went out against the old digest.
The conflict test read the title in that window and failed under load.

The old form now stays disabled with "Reading the current task…" until
the read answers; the refusal and the reopened form appear together. A
read a poll overtook no longer reopens on the board's old read; the
dialog reads the task itself. If the task cannot be read the form keeps
only Cancel and takes no save. A dialog closed during the read stays
closed, and a form whose Save is disabled refuses a submit sent past it.
2026-11-10 16:01:01 -06:00
jason.woltje fa9bedb09a fix(webui): prefix project ids in side-by-side panes (#1873)
The requirements page drew project-request-select, project-north-star
and project-prd with fixed ids, so the same project in both panes put
each id in the document twice. mountContent takes the pane's idPrefix
(pane-a-, pane-b-) and every id and id lookup on the page follows it;
the full view passes none and keeps its ids.

The dashboard was already prefixed and the Agents page draws no ids.
A new browser test opens the same dashboard, Agents page and project
pages in both panes and checks for duplicate ids and for labels, ARIA
references and in-page links that leave their pane.
2026-11-10 16:01:01 -06:00
jason.woltje 67d6c9ed84 fix(organization): composition review round, receiveFrom and one JSON read (#1873)
Review finding 1: step 7 compares messaging.receiveFrom with the chief's as
well as sendTo, since the broker checks the recipient's list too.
Review finding 2: checkComposition reads the role and the chief once, as a JSON
copy; a getter, proxy, toJSON, cycle or BigInt that throws refuses with a fixed
message, so the thrown text never reaches the caller.
2026-11-10 15:50:15 -06:00
jason.woltje c8dbd3f6ec feat(organization): composition validator and primitives registry (#1873)
Roles round 2, slice P1. checkComposition(role, registry, chief, { assertClean })
runs the design's seven steps in order against a registry from buildRegistry
(core vocabulary plus a caller-passed plugin set, empty by default) and returns
the flagged lines: every decision.resolve.* action and everything above the
chief's own resolved role. Refusals name the field and position, quote only
registered names, and never quote instructions or personality text.

COMPOSITION_BOUNDS holds the length bounds the design leaves open. Nothing
calls checkComposition yet; HIRE_ALLOWED and unhireableAction stay until A4.
2026-11-10 15:50:15 -06:00
jason.woltje 8f7de8c4d3 fix(cli): redact config and organization JSON parse refusals (#1873) 2026-11-10 15:39:53 -06:00
jason.woltje 6f0c72a02f fix(organization): redact model selections and JSON parser diagnostics (#1873) 2026-11-10 15:39:53 -06:00
19 changed files with 1297 additions and 50 deletions

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/composition.mjs (rename design r2, stage B).
export * from "../../organization/src/composition.mjs";
+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);
}
+518
View File
@@ -0,0 +1,518 @@
// Roles round 2, slice P1 (#1873): the primitives registry and the composition validator.
// Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 3, 4 and 7, and the P1
// paragraph of section 13.
//
// A role is a composition of the primitives in section 4 and nothing else: tools, network,
// authority, credentials, messaging, skills, instructions and personality. checkComposition
// checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in
// the seven steps section 4 lists, in order, and returns the lines a human must see flagged.
//
// Nothing calls checkComposition yet. HIRE_ALLOWED and unhireableAction (vocabulary.mjs) still
// bound a hire, and role.mjs and the broker still use them. Slice A4 switches hire over to this
// validator and removes them. In P1 the plugin set is whatever the caller passes, empty by
// default; nothing here reads a plugin file (PL1 builds install and the manifest).
//
// A refusal names the field and the position at fault. It quotes a value only when that value is
// a registered name (a core or plugin action, tool, network, service or scope), so no role text,
// and no secret placed where a name belongs, reaches an error. Free text (instructions,
// personality) is never quoted.
import { refuse } from "./errors.mjs";
import {
ACTIONS, ROUTED, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
ID_PATTERN,
} from "./vocabulary.mjs";
import { NAME as PLUGIN_NAME } from "../../plugins/src/kinds.mjs";
import { deepFreeze } from "./util.mjs";
// The primitives (section 4). Every other key refuses (step 1).
export const COMPOSITION_KEYS = Object.freeze([
"tools", "network", "authority", "credentials", "messaging", "skills", "instructions", "personality",
]);
const REQUIRED = Object.freeze(["tools", "network", "authority", "credentials", "instructions"]);
const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]);
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
// largest shipped contract is about 8 KB; instructions get twice that. T1 stores personality
// with these same bounds.
export const COMPOSITION_BOUNDS = Object.freeze({
instructions: 16384,
summary: 280,
traits: 12,
trait: 40,
voice: 280,
messaging: 64,
skills: 64,
});
// Ruling 8 and R9: a hired agent can't hire or fire, whatever HIRE_ALLOWED says later.
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire"]);
// The role rails (section 3): an action that lets its holder decide for other agents.
export const RESOLVE_PREFIX = "decision.resolve.";
export const FLAG_RESOLVE = "resolve-right";
export const FLAG_ABOVE_CHIEF = "above-chief";
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
// A plugin's declared bare name: lower-case segments joined by dots. With the prefix it is
// <plugin>.<name>.
const DECLARED_NAME = /^[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*$/;
const DECLARED_SCOPE = /^[a-z][a-z0-9_.:-]*$/;
const NAME_MAX = 64;
const isPlain = (value) => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
};
// ---------------------------------------------------------------------------------------------
// The registry.
const BUILT = new WeakSet();
// The first segment of every core action, tool and service. A plugin may not take one as its
// name, so a prefixed plugin name can never read as a core name (git + push.protected).
const RESERVED_PLUGIN_NAMES = Object.freeze([...new Set([
...ACTIONS.map((a) => a.split(".")[0]), ...TOOLS, ...SERVICES, HOST_SERVICE, ...NETWORKS,
])]);
function declaredList(value, where, pattern) {
if (value === undefined) return [];
if (!Array.isArray(value)) refuse(`${where} must be an array`);
const seen = new Set();
value.forEach((entry, i) => {
if (typeof entry !== "string" || entry.length > NAME_MAX || !pattern.test(entry)) {
refuse(`${where}[${i}] must be a name matching ${pattern}`);
}
if (seen.has(entry)) refuse(`${where}[${i}] repeats an earlier entry`);
seen.add(entry);
});
return [...seen];
}
// One plugin's declared set: { name, tools?: [name], actions?: [name], services?: [{ name, scopes:
// [scope] }] }. Names are declared bare and registered with the plugin's prefix. A plugin action is
// always routed (section 4), so it may not name a hard wait, and no declared name may reuse a core
// name, bare or prefixed.
function checkPlugin(plugin, i, core) {
const where = `registry plugins[${i}]`;
if (!isPlain(plugin)) refuse(`${where} must be a JSON object`);
for (const key of Object.keys(plugin)) {
if (!PLUGIN_KEYS.includes(key)) refuse(`${where} has a key outside ${PLUGIN_KEYS.join(", ")}`);
}
const { name } = plugin;
if (typeof name !== "string" || name.length > 32 || !PLUGIN_NAME.test(name)) refuse(`${where}.name must match ${PLUGIN_NAME}`);
if (RESERVED_PLUGIN_NAMES.includes(name)) refuse(`${where}.name ${JSON.stringify(name)} is a core name`);
const full = (bare) => `${name}.${bare}`;
const coreName = (bare) => core.has(bare) || core.has(full(bare));
const tools = declaredList(plugin.tools, `${where}.tools`, DECLARED_NAME);
tools.forEach((bare, j) => {
if (coreName(bare)) refuse(`${where}.tools[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
});
const actions = declaredList(plugin.actions, `${where}.actions`, DECLARED_NAME);
actions.forEach((bare, j) => {
if (GATED_ONLY.includes(bare)) refuse(`${where}.actions[${j}] declares ${bare}, a hard wait; a plugin action is always routed`);
if (coreName(bare)) refuse(`${where}.actions[${j}] reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
});
if (plugin.services !== undefined && !Array.isArray(plugin.services)) refuse(`${where}.services must be an array`);
const services = (plugin.services ?? []).map((service, j) => {
const at = `${where}.services[${j}]`;
if (!isPlain(service)) refuse(`${at} must be a JSON object`);
for (const key of Object.keys(service)) {
if (!PLUGIN_SERVICE_KEYS.includes(key)) refuse(`${at} has a key outside ${PLUGIN_SERVICE_KEYS.join(", ")}`);
}
const [bare] = declaredList([service.name], `${at}.name`, DECLARED_NAME);
if (coreName(bare)) refuse(`${at}.name reuses the core name ${JSON.stringify(core.has(bare) ? bare : full(bare))}`);
const scopes = declaredList(service.scopes, `${at}.scopes`, DECLARED_SCOPE);
if (scopes.length === 0) refuse(`${at}.scopes must not be empty`);
return { name: full(bare), scopes };
});
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
const twice = names.find((n, j) => names.indexOf(n) !== j);
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
return { name, tools: tools.map(full), actions: actions.map(full), services };
}
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
// set is the caller's input and empty by default (PL1 passes the plugins enabled in the
// organization). checkComposition accepts only a registry built here.
export function buildRegistry({ plugins = [] } = {}) {
if (!Array.isArray(plugins)) refuse("registry plugins must be an array");
const core = new Set([...ACTIONS, ...TOOLS, ...SERVICES, HOST_SERVICE]);
const declared = plugins.map((plugin, i) => checkPlugin(plugin, i, core));
const pluginNames = declared.map((p) => p.name);
const twice = pluginNames.find((n, j) => pluginNames.indexOf(n) !== j);
if (twice !== undefined) refuse(`registry names plugin ${JSON.stringify(twice)} twice`);
const services = Object.create(null);
for (const name of SERVICES) services[name] = { kind: name };
services[HOST_SERVICE] = { kind: HOST_SERVICE };
for (const p of declared) for (const s of p.services) services[s.name] = { kind: "plugin", scopes: s.scopes };
const pluginActions = declared.flatMap((p) => p.actions);
const registry = deepFreeze({
tools: [...TOOLS, ...declared.flatMap((p) => p.tools)],
networks: [...NETWORKS],
actions: [...ACTIONS, ...pluginActions],
routed: [...ROUTED, ...pluginActions],
services,
plugins: declared,
});
BUILT.add(registry);
return registry;
}
// ---------------------------------------------------------------------------------------------
// The validator.
function keysWithin(object, allowed, where) {
Object.keys(object).forEach((key, i) => {
if (!allowed.includes(key)) refuse(`composition ${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
});
}
// A list of distinct strings. Entries are named by position only: this runs before anything is
// known to be a registered name.
function stringList(value, where, { nonEmpty = false } = {}) {
if (!Array.isArray(value)) refuse(`composition ${where} must be an array`);
if (nonEmpty && value.length === 0) refuse(`composition ${where} must not be empty`);
const seen = new Set();
for (let i = 0; i < value.length; i += 1) {
if (typeof value[i] !== "string" || value[i].includes("\0")) refuse(`composition ${where}[${i}] must be a string`);
if (seen.has(value[i])) refuse(`composition ${where}[${i}] repeats an earlier entry`);
seen.add(value[i]);
}
return [...seen];
}
function text(value, where) {
if (typeof value !== "string" || value.includes("\0")) refuse(`composition ${where} must be a string`);
return value;
}
// Step 1: the shape, with every unknown key refused. Returns a fresh copy; the input is not read
// again after this.
function shape(role) {
if (!isPlain(role)) refuse("composition must be a JSON object");
keysWithin(role, COMPOSITION_KEYS, "role");
for (const key of REQUIRED) {
if (role[key] === undefined) refuse(`composition requires "${key}"`);
}
const out = {};
out.tools = stringList(role.tools, "tools", { nonEmpty: true });
if (typeof role.network !== "string") refuse("composition network must be a string");
out.network = role.network;
if (!isPlain(role.authority)) refuse("composition authority must be a JSON object");
keysWithin(role.authority, AUTHORITY_KEYS, "authority");
out.authority = {};
for (const key of AUTHORITY_KEYS) out.authority[key] = stringList(role.authority[key], `authority.${key}`);
out.authority.crossRole.forEach((action, i) => {
if (out.authority.withinRole.includes(action)) refuse(`composition authority.crossRole[${i}] is also in authority.withinRole`);
});
if (!Array.isArray(role.credentials)) refuse("composition credentials must be an array");
out.credentials = role.credentials.map((entry, i) => {
const where = `credentials[${i}]`;
if (!isPlain(entry)) refuse(`composition ${where} must be a JSON object`);
keysWithin(entry, CREDENTIAL_KEYS, where);
if (typeof entry.service !== "string") refuse(`composition ${where}.service must be a string`);
const copy = { service: entry.service };
if (entry.scopes !== undefined) {
if (Array.isArray(entry.scopes)) {
copy.scopes = stringList(entry.scopes, `${where}.scopes`);
} else if (isPlain(entry.scopes)) {
// No prototype, so a "__proto__" group stays a key and step 2 refuses it.
copy.scopes = Object.create(null);
Object.keys(entry.scopes).forEach((group, j) => {
if (group.includes("\0")) refuse(`composition ${where}.scopes key ${j + 1} must be a string`);
copy.scopes[group] = stringList(entry.scopes[group], `${where}.scopes (group ${j + 1})`);
});
} else {
refuse(`composition ${where}.scopes must be an array or a JSON object`);
}
}
return copy;
});
if (role.messaging === undefined) {
out.messaging = null;
} else {
if (!isPlain(role.messaging)) refuse("composition messaging must be a JSON object");
keysWithin(role.messaging, MESSAGING_KEYS, "messaging");
out.messaging = {};
for (const key of MESSAGING_KEYS) {
out.messaging[key] = stringList(role.messaging[key], `messaging.${key}`);
out.messaging[key].forEach((entry, i) => {
if (!ID_PATTERN.test(entry)) refuse(`composition messaging.${key}[${i}] must be a role name matching ${ID_PATTERN}`);
if (entry === "tester") refuse(`composition messaging.${key}[${i}] names the tester, which is never a role`);
});
}
}
out.skills = role.skills === undefined ? [] : stringList(role.skills, "skills");
out.skills.forEach((name, i) => {
if (!SKILL_NAME.test(name) || name === "." || name === "..") refuse(`composition skills[${i}] must be a skill directory name matching ${SKILL_NAME}`);
});
out.instructions = text(role.instructions, "instructions");
if (role.personality === undefined) {
out.personality = null;
} else {
if (!isPlain(role.personality)) refuse("composition personality must be a JSON object");
keysWithin(role.personality, PERSONALITY_KEYS, "personality");
out.personality = {};
if (role.personality.summary !== undefined) out.personality.summary = text(role.personality.summary, "personality.summary");
if (role.personality.traits !== undefined) out.personality.traits = stringList(role.personality.traits, "personality.traits");
if (role.personality.voice !== undefined) out.personality.voice = text(role.personality.voice, "personality.voice");
}
return out;
}
function checkGitea(scopes, where) {
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of read:<category> or write:<category>`);
const categories = new Set();
scopes.forEach((scope, i) => {
const m = GITEA_SCOPE.exec(scope);
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
refuse(`composition ${where}.scopes[${i}] is not a grantable gitea scope (categories: ${GITEA_SCOPE_CATEGORIES.join(", ")}; admin and all are never granted)`);
}
if (categories.has(m[2])) refuse(`composition ${where}.scopes[${i}] names the category ${m[2]} again; a token holds one level per category`);
categories.add(m[2]);
});
}
function checkVikunja(scopes, where) {
if (!isPlain(scopes) || Object.keys(scopes).length === 0) refuse(`composition ${where}.scopes must name at least one vikunja route group`);
Object.keys(scopes).forEach((group, j) => {
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group)) refuse(`composition ${where}.scopes group ${j + 1} is not a grantable vikunja route group`);
const verbs = scopes[group];
if (verbs.length === 0) refuse(`composition ${where}.scopes.${group} must not be empty`);
verbs.forEach((verb, i) => {
if (!VIKUNJA_GRANTABLE[group].includes(verb)) refuse(`composition ${where}.scopes.${group}[${i}] is not a grantable vikunja verb`);
});
});
}
function checkPluginScopes(scopes, declared, service, where) {
if (!Array.isArray(scopes) || scopes.length === 0) refuse(`composition ${where}.scopes must be a non-empty array of ${service} scopes`);
scopes.forEach((scope, i) => {
if (!declared.includes(scope)) refuse(`composition ${where}.scopes[${i}] is not a scope ${service} declares`);
});
}
// Step 2: every action, tool, network, service and scope must be in the registry.
function registered(c, registry) {
c.tools.forEach((tool, i) => {
if (!registry.tools.includes(tool)) refuse(`composition tools[${i}] names no registered tool`);
});
if (!registry.networks.includes(c.network)) refuse(`composition network must be one of: ${registry.networks.join(", ")}`);
for (const key of AUTHORITY_KEYS) {
c.authority[key].forEach((action, i) => {
if (!registry.actions.includes(action)) refuse(`composition authority.${key}[${i}] names no registered action`);
});
}
const seen = new Set();
c.credentials.forEach((entry, i) => {
const where = `credentials[${i}]`;
if (!Object.hasOwn(registry.services, entry.service)) refuse(`composition ${where}.service names no registered credential service`);
if (seen.has(entry.service)) refuse(`composition ${where} names ${entry.service} again`);
seen.add(entry.service);
const { kind, scopes } = registry.services[entry.service];
if (kind === HOST_SERVICE) {
if (entry.scopes !== undefined) refuse(`composition ${where} names ${HOST_SERVICE}, which takes no scopes`);
} else if (kind === "gitea") {
checkGitea(entry.scopes, where);
} else if (kind === "vikunja") {
checkVikunja(entry.scopes, where);
} else {
checkPluginScopes(entry.scopes, scopes, entry.service, where);
}
});
}
function eachAction(c, fn) {
for (const key of AUTHORITY_KEYS) c.authority[key].forEach((action, i) => fn(action, key, i));
}
// Every string in the composition with its path, for step 6.
function strings(c) {
const out = [];
c.tools.forEach((v, i) => out.push([`tools[${i}]`, v]));
out.push(["network", c.network]);
eachAction(c, (v, key, i) => out.push([`authority.${key}[${i}]`, v]));
c.credentials.forEach((entry, i) => {
out.push([`credentials[${i}].service`, entry.service]);
if (Array.isArray(entry.scopes)) entry.scopes.forEach((v, j) => out.push([`credentials[${i}].scopes[${j}]`, v]));
else if (entry.scopes) {
for (const [group, verbs] of Object.entries(entry.scopes)) {
out.push([`credentials[${i}].scopes (group key)`, group]);
verbs.forEach((v, j) => out.push([`credentials[${i}].scopes.${group}[${j}]`, v]));
}
}
});
if (c.messaging) for (const key of MESSAGING_KEYS) c.messaging[key].forEach((v, i) => out.push([`messaging.${key}[${i}]`, v]));
c.skills.forEach((v, i) => out.push([`skills[${i}]`, v]));
out.push(["instructions", c.instructions]);
if (c.personality) {
if (c.personality.summary !== undefined) out.push(["personality.summary", c.personality.summary]);
(c.personality.traits ?? []).forEach((v, i) => out.push([`personality.traits[${i}]`, v]));
if (c.personality.voice !== undefined) out.push(["personality.voice", c.personality.voice]);
}
return out;
}
function bounded(value, max, where, { empty = false } = {}) {
if ((!empty && value.trim().length === 0) || value.length > max) {
refuse(`composition ${where} must be ${empty ? "" : "non-empty and "}at most ${max} characters`);
}
}
// Step 6, the bounds half.
function checkBounds(c) {
const b = COMPOSITION_BOUNDS;
bounded(c.instructions, b.instructions, "instructions");
if (c.personality) {
if (c.personality.summary !== undefined) bounded(c.personality.summary, b.summary, "personality.summary");
if (c.personality.voice !== undefined) bounded(c.personality.voice, b.voice, "personality.voice");
const traits = c.personality.traits ?? [];
if (traits.length > b.traits) refuse(`composition personality.traits must hold at most ${b.traits} entries`);
traits.forEach((trait, i) => bounded(trait, b.trait, `personality.traits[${i}]`));
}
if (c.messaging) {
for (const key of MESSAGING_KEYS) {
if (c.messaging[key].length > b.messaging) refuse(`composition messaging.${key} must hold at most ${b.messaging} entries`);
}
}
if (c.skills.length > b.skills) refuse(`composition skills must hold at most ${b.skills} entries`);
}
// A plain JSON copy of a caller's object, read once. Getters, proxies and toJSON run here and only
// here, and whatever they throw becomes the fixed message: their error text could carry role text.
function snapshot(value, message) {
let copy;
try {
copy = isPlain(value) ? JSON.parse(JSON.stringify(value)) : undefined;
} catch {
copy = undefined;
}
if (!isPlain(copy)) refuse(message);
return copy;
}
// The chief's resolved role, as loadRole returns it. Read only for step 7; a malformed one refuses.
function checkChief(chief) {
const bad = () => refuse("checkComposition needs the chief's resolved role");
if (!isPlain(chief) || !Array.isArray(chief.tools) || !NETWORKS.includes(chief.network)) bad();
if (!isPlain(chief.authority) || !AUTHORITY_KEYS.every((k) => Array.isArray(chief.authority[k]))) bad();
if (!Array.isArray(chief.credentials) || !chief.credentials.every((c) => isPlain(c) && typeof c.service === "string")) bad();
if (chief.messaging !== null && chief.messaging !== undefined &&
!(isPlain(chief.messaging) && MESSAGING_KEYS.every((k) => Array.isArray(chief.messaging[k])))) bad();
return chief;
}
// One string per credential scope a role holds: service, then scope (a vikunja scope is
// group.verb; host has the one line "host").
function credentialLines(credentials) {
const out = [];
for (const { service, scopes } of credentials) {
if (scopes === undefined) out.push([service, service]);
else if (Array.isArray(scopes)) for (const scope of scopes) out.push([service, scope]);
else for (const [group, verbs] of Object.entries(scopes)) for (const verb of verbs) out.push([service, `${group}.${verb}`]);
}
return out;
}
// Validate a composition (section 4's seven steps, in order) against a registry from
// buildRegistry, with the chief's resolved role for step 7. options.assertClean is the credential
// store's check (credentials.assertClean, as the broker's secretCheck); it throws on a held
// secret. Returns a frozen { composition, flagged }, where each flagged line is
// { field, value, reasons } and reasons holds "resolve-right" (step 5), "above-chief" (step 7) or
// both. A composition that fails a step refuses with an OrganizationConfigError.
export function checkComposition(role, registry, chief, { assertClean } = {}) {
if (!BUILT.has(registry)) refuse("checkComposition needs a registry from buildRegistry");
if (typeof assertClean !== "function") refuse("checkComposition needs the credential store's assertClean");
const boss = checkChief(snapshot(chief, "checkComposition needs the chief's resolved role"));
// 1. Unknown keys, and the shape.
const c = shape(snapshot(role, "composition must be a JSON object"));
// 2. Every name in the registry.
registered(c, registry);
// 3. Hard waits stay with the human (R4). From here on an action is a registered name and may be
// quoted.
eachAction(c, (action, key, i) => {
if (!registry.routed.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}, a hard wait the human always decides`);
});
// 4. No hire chains (R9).
eachAction(c, (action, key, i) => {
if (NEVER_HIRED.includes(action)) refuse(`composition authority.${key}[${i}] is ${action}; a hired agent cannot hire or fire`);
});
// 5. Every resolve right is a flagged line.
const lines = [];
const flag = (field, value, reason) => {
const line = lines.find((l) => l.field === field && l.value === value);
if (line) {
if (!line.reasons.includes(reason)) line.reasons.push(reason);
} else {
lines.push({ field, value, reasons: [reason] });
}
};
eachAction(c, (action, key) => {
if (action.startsWith(RESOLVE_PREFIX)) flag(`authority.${key}`, action, FLAG_RESOLVE);
});
// 6. No secret in any string (R1), and every field within its bound.
for (const [where, value] of strings(c)) {
let clean = true;
try {
assertClean(value);
} catch {
clean = false;
}
if (!clean) refuse(`composition ${where} holds a credential`);
}
checkBounds(c);
// 7. Everything the chief's own role does not hold (R8). A within-role action is held only
// within-role; a cross-role one either way.
const chiefWithin = boss.authority.withinRole;
const chiefAny = [...chiefWithin, ...boss.authority.crossRole];
c.authority.withinRole.forEach((action) => {
if (!chiefWithin.includes(action)) flag("authority.withinRole", action, FLAG_ABOVE_CHIEF);
});
c.authority.crossRole.forEach((action) => {
if (!chiefAny.includes(action)) flag("authority.crossRole", action, FLAG_ABOVE_CHIEF);
});
c.tools.forEach((tool) => {
if (!boss.tools.includes(tool)) flag("tools", tool, FLAG_ABOVE_CHIEF);
});
if (NETWORKS.indexOf(c.network) > NETWORKS.indexOf(boss.network)) flag("network", c.network, FLAG_ABOVE_CHIEF);
const held = new Set(credentialLines(boss.credentials).map(([s, v]) => `${s}\n${v}`));
for (const [service, value] of credentialLines(c.credentials)) {
if (!held.has(`${service}\n${value}`)) flag(`credentials.${service}`, value, FLAG_ABOVE_CHIEF);
}
// Both directions: the broker checks the sender's sendTo and the recipient's receiveFrom. A role
// with no messaging key may message and hear anyone; "*" stands for that.
if (boss.messaging) {
for (const key of MESSAGING_KEYS) {
const chiefList = boss.messaging[key];
if (c.messaging === null) flag(`messaging.${key}`, "*", FLAG_ABOVE_CHIEF);
else c.messaging[key].forEach((target) => {
if (!chiefList.includes(target)) flag(`messaging.${key}`, target, FLAG_ABOVE_CHIEF);
});
}
}
for (const line of lines) line.reasons.sort();
return deepFreeze({ composition: c, flagged: lines });
}
+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 = () => {
+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,318 @@
// Roles round 2, slice P1 (#1873): the composition validator and its registry.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { loadRole, BusinessError } from "../src/index.mjs";
import { GATED_ONLY, HIRE_ALLOWED, unhireableAction, ROUTED } from "../src/vocabulary.mjs";
import {
buildRegistry, checkComposition, COMPOSITION_BOUNDS, COMPOSITION_KEYS, FLAG_RESOLVE, FLAG_ABOVE_CHIEF,
} from "../src/composition.mjs";
import { REPO_ROLES } from "./helpers.mjs";
const SECRET = "sk-test-NOT-A-SECRET-1234";
// Stands in for the bus credential store's assertClean. Its own error quotes the secret, so a test
// can tell if checkComposition ever passes that error on.
const assertClean = (value) => {
if (JSON.stringify(value).includes(SECRET)) throw new Error(`credential-leak: ${SECRET}`);
};
const OPTS = { assertClean };
const CHIEF = loadRole(REPO_ROLES, "chief-of-staff");
const CORE = buildRegistry();
function base(overrides = {}) {
return {
tools: ["read"],
network: "none",
authority: { withinRole: ["message.send"], crossRole: [] },
credentials: [{ service: "gitea", scopes: ["read:repository"] }],
instructions: "Do the work in the brief.",
...overrides,
};
}
const run = (role, registry = CORE, chief = CHIEF) => checkComposition(role, registry, chief, OPTS);
function refuses(fn, pattern, ...absent) {
assert.throws(fn, (error) => {
assert.ok(error instanceof BusinessError, `expected BusinessError, got ${error}`);
assert.equal(error.exitCode, 2);
assert.match(error.message, pattern);
for (const text of [SECRET, ...absent]) {
assert.ok(!error.message.includes(text), `the refusal quotes ${JSON.stringify(text)}: ${error.message}`);
}
return true;
});
}
test("a minimal composition passes with nothing flagged", () => {
const { composition, flagged } = run(base());
assert.deepEqual(flagged, []);
assert.equal(composition.instructions, "Do the work in the brief.");
assert.equal(composition.messaging, null);
assert.equal(composition.personality, null);
assert.ok(Object.isFrozen(composition) && Object.isFrozen(composition.authority.withinRole));
});
test("checkComposition needs a registry from buildRegistry and the store's assertClean", () => {
refuses(() => checkComposition(base(), { ...CORE }, CHIEF, OPTS), /registry from buildRegistry/);
refuses(() => checkComposition(base(), CORE, CHIEF), /assertClean/);
refuses(() => checkComposition(base(), CORE, CHIEF, { assertClean: true }), /assertClean/);
refuses(() => checkComposition(base(), CORE, { tools: [] }, OPTS), /chief's resolved role/);
});
// Step 1.
test("an unknown key refuses at every level, without quoting the key", () => {
const key = `notes ${SECRET}`;
refuses(() => run({ ...base(), [key]: 1 }), /role has an unknown key/, key);
for (const field of ["name", "title", "contract", "roleVersion", "hireable"]) {
refuses(() => run({ ...base(), [field]: "x" }), /role has an unknown key/);
}
refuses(() => run(base({ authority: { withinRole: [], crossRole: [], [key]: [] } })), /authority has an unknown key/, key);
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"], [key]: 1 }] })), /credentials\[0\] has an unknown key/, key);
refuses(() => run(base({ messaging: { sendTo: [], receiveFrom: [], [key]: [] } })), /messaging has an unknown key/, key);
refuses(() => run(base({ personality: { summary: "Calm.", [key]: "x" } })), /personality has an unknown key/, key);
refuses(() => run(JSON.parse('{"__proto__": {"tools": ["read"]}}')), /unknown key|requires/);
});
test("the required primitives and their shapes", () => {
for (const key of ["tools", "network", "authority", "credentials", "instructions"]) {
const role = base();
delete role[key];
refuses(() => run(role), new RegExp(`requires "${key}"`));
}
refuses(() => run(base({ tools: [] })), /tools must not be empty/);
refuses(() => run(base({ tools: ["read", "read"] })), /tools\[1\] repeats/);
refuses(() => run(base({ authority: { withinRole: ["message.send"] } })), /authority.crossRole must be an array/);
refuses(() => run(base({ authority: { withinRole: ["message.send"], crossRole: ["message.send"] } })), /crossRole\[0\] is also in authority.withinRole/);
refuses(() => run(base({ instructions: ["x"] })), /instructions must be a string/);
refuses(() => run(base({ instructions: "a\0b" })), /instructions must be a string/);
refuses(() => run(base({ messaging: { sendTo: ["tester"], receiveFrom: [] } })), /names the tester/);
refuses(() => run(base({ messaging: { sendTo: ["Bad Name"], receiveFrom: [] } })), /sendTo\[0\] must be a role name/, "Bad Name");
refuses(() => run(base({ skills: [".."] })), /skills\[0\] must be a skill directory name/);
assert.deepEqual(Object.keys(base()).every((k) => COMPOSITION_KEYS.includes(k)), true);
});
// Step 2.
test("an unknown action, tool, network, service or scope refuses without quoting it", () => {
const odd = `zz-${SECRET}`;
refuses(() => run(base({ authority: { withinRole: [odd], crossRole: [] } })), /withinRole\[0\] names no registered action/, odd);
refuses(() => run(base({ authority: { withinRole: [], crossRole: ["task.nonesuch"] } })), /crossRole\[0\] names no registered action/, "task.nonesuch");
refuses(() => run(base({ tools: ["read", "hammer"] })), /tools\[1\] names no registered tool/, "hammer");
refuses(() => run(base({ network: odd })), /network must be one of: none, api-only, open/, odd);
refuses(() => run(base({ credentials: [{ service: "jira", scopes: ["read"] }] })), /credentials\[0\].service names no registered/, "jira");
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["write:nonesuch"] }] })), /scopes\[0\] is not a grantable gitea scope/, "nonesuch");
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { nonesuch: ["read_one"] } }] })), /group 1 is not a grantable vikunja route group/, "nonesuch");
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { __proto__: null, ["__proto__"]: ["read_one"] } }] })), /not a grantable vikunja route group/);
refuses(() => run(base({ credentials: [{ service: "vikunja", scopes: { tasks: ["obliterate"] } }] })), /scopes.tasks\[0\] is not a grantable vikunja verb/, "obliterate");
refuses(() => run(base({ credentials: [{ service: "host", scopes: ["any"] }] })), /host, which takes no scopes/);
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue"] }, { service: "gitea", scopes: ["read:user"] }] })), /credentials\[1\] names gitea again/);
});
test("gitea admin and all refuse, at either level", () => {
for (const scope of ["admin", "all", "write:admin", "read:admin", "write:all", "read:all", "sudo"]) {
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [scope] }] })), /not a grantable gitea scope/);
}
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: ["read:issue", "write:issue"] }] })), /scopes\[1\] names the category issue again/);
refuses(() => run(base({ credentials: [{ service: "gitea", scopes: [] }] })), /non-empty array/);
});
// Step 3.
test("each hard wait refuses, in either authority list", () => {
assert.ok(GATED_ONLY.length > 0);
for (const action of GATED_ONLY) {
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /withinRole\[0\] is .* a hard wait/);
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /crossRole\[0\] is .* a hard wait/);
}
});
// Step 4.
test("role.hire and role.fire refuse", () => {
for (const action of ["role.hire", "role.fire"]) {
assert.ok(ROUTED.includes(action), `${action} is routed, so step 4 is what refuses it`);
refuses(() => run(base({ authority: { withinRole: [action], crossRole: [] } })), /cannot hire or fire/);
refuses(() => run(base({ authority: { withinRole: [], crossRole: [action] } })), /cannot hire or fire/);
}
});
// Step 5.
test("a decision.resolve action comes back as a flagged line", () => {
const { flagged } = run(base({ authority: { withinRole: ["message.send"], crossRole: ["decision.resolve.technical"] } }));
assert.deepEqual(flagged, [
{ field: "authority.crossRole", value: "decision.resolve.technical", reasons: [FLAG_ABOVE_CHIEF, FLAG_RESOLVE] },
]);
// Held by the chief: still flagged as a resolve right, and only that.
const chief = { ...CHIEF, authority: { withinRole: [...CHIEF.authority.withinRole, "decision.resolve.technical"], crossRole: [] } };
const held = run(base({ authority: { withinRole: ["decision.resolve.technical"], crossRole: [] } }), CORE, chief);
assert.deepEqual(held.flagged, [{ field: "authority.withinRole", value: "decision.resolve.technical", reasons: [FLAG_RESOLVE] }]);
});
// Step 6.
test("a secret in instructions or personality refuses, and the refusal does not contain it", () => {
const cases = [
[{ instructions: `Use ${SECRET} for the API.` }, /instructions holds a credential/],
[{ personality: { summary: `Knows ${SECRET}.` } }, /personality.summary holds a credential/],
[{ personality: { traits: ["calm", `key ${SECRET}`] } }, /personality.traits\[1\] holds a credential/],
[{ personality: { voice: `Says ${SECRET} often.` } }, /personality.voice holds a credential/],
[{ skills: [SECRET] }, /skills\[0\] holds a credential/],
];
for (const [overrides, pattern] of cases) refuses(() => run(base(overrides)), pattern, "Use ", "Knows", "Says");
});
test("the free text never reaches an error", () => {
const prose = "PRIVATE-INSTRUCTION-TEXT";
refuses(() => run(base({ instructions: prose.repeat(2000) })), /instructions must be non-empty and at most 16384 characters/, prose);
refuses(() => run(base({ instructions: prose, personality: { summary: prose.repeat(20) } })), /personality.summary must be/, prose);
refuses(() => run(base({ instructions: prose, authority: { withinRole: ["deploy"], crossRole: [] } })), /hard wait/, prose);
});
test("the length bounds hold, from one exported constant", () => {
assert.deepEqual({ ...COMPOSITION_BOUNDS }, { instructions: 16384, summary: 280, traits: 12, trait: 40, voice: 280, messaging: 64, skills: 64 });
assert.ok(Object.isFrozen(COMPOSITION_BOUNDS));
const b = COMPOSITION_BOUNDS;
run(base({ instructions: "x".repeat(b.instructions), personality: { summary: "s".repeat(b.summary), voice: "v".repeat(b.voice), traits: Array.from({ length: b.traits }, (_, i) => `${i}`.padEnd(b.trait, "t")) } }));
refuses(() => run(base({ instructions: "x".repeat(b.instructions + 1) })), /instructions must be/);
refuses(() => run(base({ instructions: " " })), /instructions must be non-empty/);
refuses(() => run(base({ personality: { summary: "s".repeat(b.summary + 1) } })), /personality.summary must be/);
refuses(() => run(base({ personality: { voice: "v".repeat(b.voice + 1) } })), /personality.voice must be/);
refuses(() => run(base({ personality: { traits: Array.from({ length: b.traits + 1 }, (_, i) => `t${i}`) } })), /at most 12 entries/);
refuses(() => run(base({ personality: { traits: ["t".repeat(b.trait + 1)] } })), /traits\[0\] must be/);
refuses(() => run(base({ messaging: { sendTo: Array.from({ length: b.messaging + 1 }, (_, i) => `r${i}`), receiveFrom: [] } })), /sendTo must hold at most 64/);
refuses(() => run(base({ skills: Array.from({ length: b.skills + 1 }, (_, i) => `s${i}`) })), /skills must hold at most 64/);
});
test("the steps run in order: a hard wait refuses before the secret check", () => {
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["spend"], crossRole: [] } })), /hard wait/);
refuses(() => run(base({ instructions: SECRET, authority: { withinRole: ["role.fire"], crossRole: [] } })), /cannot hire or fire/);
});
// Step 7.
test("the shipped coder against the shipped chief flags exactly the hand-built set", () => {
const coder = loadRole(REPO_ROLES, "coder");
const role = {
tools: coder.tools,
network: coder.network,
authority: coder.authority,
credentials: coder.credentials,
skills: coder.skills,
instructions: readFileSync(join(REPO_ROLES, "coder.md"), "utf8"),
};
const { flagged } = run(role);
const above = (field, value) => ({ field, value, reasons: [FLAG_ABOVE_CHIEF] });
const expected = [
above("authority.withinRole", "task.update.assigned"),
above("authority.withinRole", "git.push.working"),
above("authority.withinRole", "review.request"),
above("authority.crossRole", "task.scope.change"),
above("tools", "write"),
above("tools", "edit"),
above("tools", "bash"),
above("credentials.gitea", "write:issue"),
above("credentials.gitea", "write:repository"),
above("credentials.vikunja", "tasks.read_one"),
above("credentials.vikunja", "tasks.update"),
above("credentials.vikunja", "tasks_comments.create"),
above("credentials.vikunja", "projects.views_buckets_tasks"),
];
const key = (l) => `${l.field} ${l.value}`;
assert.deepEqual([...flagged].map((l) => ({ ...l, reasons: [...l.reasons] })).sort((a, b) => key(a).localeCompare(key(b))),
expected.sort((a, b) => key(a).localeCompare(key(b))));
});
test("network, cross-role and messaging above the chief are flagged", () => {
assert.deepEqual(run(base({ network: "open" })).flagged, [{ field: "network", value: "open", reasons: [FLAG_ABOVE_CHIEF] }]);
assert.deepEqual(run(base({ network: "api-only" })).flagged, []);
// A cross-role action the chief holds within its role is not above it.
assert.deepEqual(run(base({ authority: { withinRole: [], crossRole: ["message.send"] } })).flagged, []);
// A within-role action the chief holds only cross-role is.
const crossChief = { ...CHIEF, authority: { withinRole: ["message.send"], crossRole: ["record.write"] } };
assert.deepEqual(run(base({ authority: { withinRole: ["record.write"], crossRole: [] } }), CORE, crossChief).flagged,
[{ field: "authority.withinRole", value: "record.write", reasons: [FLAG_ABOVE_CHIEF] }]);
const host = run(base({ credentials: [{ service: "host" }] })).flagged;
assert.deepEqual(host, [{ field: "credentials.host", value: "host", reasons: [FLAG_ABOVE_CHIEF] }]);
const narrow = { ...CHIEF, messaging: { sendTo: ["pm"], receiveFrom: ["qa"] } };
assert.deepEqual(run(base(), CORE, narrow).flagged, [
{ field: "messaging.sendTo", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
{ field: "messaging.receiveFrom", value: "*", reasons: [FLAG_ABOVE_CHIEF] },
]);
assert.deepEqual(run(base({ messaging: { sendTo: ["pm", "cto"], receiveFrom: ["qa"] } }), CORE, narrow).flagged,
[{ field: "messaging.sendTo", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
// Review finding 1: a wider inbound list is above the chief too.
assert.deepEqual(run(base({ messaging: { sendTo: ["pm"], receiveFrom: ["qa", "cto"] } }), CORE, narrow).flagged,
[{ field: "messaging.receiveFrom", value: "cto", reasons: [FLAG_ABOVE_CHIEF] }]);
// A chief with no messaging key holds both directions to everyone.
assert.deepEqual(run(base({ messaging: { sendTo: ["cto"], receiveFrom: ["cto"] } })).flagged, []);
});
// Review finding 2: the caller's object is read once, as JSON data, and nothing it throws escapes.
test("a getter, proxy or toJSON that throws role text refuses with a fixed message", () => {
const throwing = base();
Object.defineProperty(throwing, "instructions", { enumerable: true, get() { throw new Error(SECRET); } });
refuses(() => run(throwing), /^composition must be a JSON object$/);
const proxy = new Proxy(base(), { ownKeys() { throw new Error(SECRET); } });
refuses(() => run(proxy), /^composition must be a JSON object$/);
const proto = new Proxy(base(), { getPrototypeOf() { throw new Error(SECRET); } });
refuses(() => run(proto), /^composition must be a JSON object$/);
refuses(() => run(base({ personality: { toJSON() { throw new Error(SECRET); } } })), /^composition must be a JSON object$/);
const cyclic = base();
cyclic.authority.self = cyclic;
refuses(() => run(cyclic), /^composition must be a JSON object$/);
refuses(() => run(base({ tools: ["read", 10n] })), /^composition must be a JSON object$/);
const chief = { ...CHIEF };
Object.defineProperty(chief, "tools", { enumerable: true, get() { throw new Error(SECRET); } });
refuses(() => run(base(), CORE, chief), /chief's resolved role/);
});
test("a getter that changes its answer is read once: what is checked is what comes back", () => {
let reads = 0;
const role = base();
Object.defineProperty(role, "network", { enumerable: true, get() { reads += 1; return reads === 1 ? "none" : "open"; } });
const { composition, flagged } = run(role);
assert.equal(reads, 1);
assert.equal(composition.network, "none");
assert.deepEqual(flagged, []);
});
// The plugin-declared set (section 7, registry shape only).
test("a plugin action is accepted only with its prefix", () => {
const registry = buildRegistry({ plugins: [{ name: "forge", tools: ["lathe"], actions: ["build.run"], services: [{ name: "store", scopes: ["read", "write"] }] }] });
const ok = run(base({
tools: ["read", "forge.lathe"],
authority: { withinRole: ["forge.build.run"], crossRole: [] },
credentials: [{ service: "forge.store", scopes: ["read"] }],
}), registry);
assert.deepEqual(ok.flagged.map((l) => `${l.field} ${l.value}`).sort(),
["authority.withinRole forge.build.run", "credentials.forge.store read", "tools forge.lathe"]);
refuses(() => run(base({ authority: { withinRole: ["build.run"], crossRole: [] } }), registry), /names no registered action/, "build.run");
refuses(() => run(base({ tools: ["lathe"] }), registry), /names no registered tool/, "lathe");
refuses(() => run(base({ credentials: [{ service: "store", scopes: ["read"] }] }), registry), /names no registered credential service/);
refuses(() => run(base({ credentials: [{ service: "forge.store", scopes: ["admin"] }] }), registry), /is not a scope forge.store declares/, "admin");
// Without the plugin in the registry, its names are unknown.
refuses(() => run(base({ authority: { withinRole: ["forge.build.run"], crossRole: [] } })), /names no registered action/);
});
test("a plugin refuses if it reuses a core name or declares a hard wait", () => {
const reg = (plugin) => () => buildRegistry({ plugins: [plugin] });
for (const action of GATED_ONLY) refuses(reg({ name: "forge", actions: [action] }), /a hard wait/);
refuses(reg({ name: "forge", actions: ["message.send"] }), /reuses the core name "message.send"/);
refuses(reg({ name: "forge", tools: ["read"] }), /reuses the core name "read"/);
refuses(reg({ name: "forge", services: [{ name: "gitea", scopes: ["x"] }] }), /reuses the core name "gitea"/);
for (const name of ["git", "role", "decision", "read", "gitea", "host", "open"]) {
refuses(reg({ name, actions: ["x"] }), /is a core name/);
}
refuses(reg({ name: "Forge" }), /name must match/);
refuses(reg({ name: "forge", extra: 1 }), /has a key outside/);
refuses(reg({ name: "forge", actions: ["a", "a"] }), /repeats an earlier entry/);
refuses(reg({ name: "forge", tools: ["a"], actions: ["a"] }), /declares "forge.a" twice/);
refuses(reg({ name: "forge", actions: [`x ${SECRET}`] }), /must be a name matching/);
refuses(reg({ name: "forge", services: [{ name: "store", scopes: [] }] }), /scopes must not be empty/);
refuses(() => buildRegistry({ plugins: [{ name: "forge" }, { name: "forge" }] }), /plugin "forge" twice/);
refuses(() => buildRegistry({ plugins: {} }), /plugins must be an array/);
// A plugin action is routed, so it never reads as a hard wait at step 3.
assert.ok(buildRegistry({ plugins: [{ name: "forge", actions: ["build.run"] }] }).routed.includes("forge.build.run"));
});
test("HIRE_ALLOWED and unhireableAction stay until A4", () => {
assert.ok(Array.isArray(HIRE_ALLOWED) && HIRE_ALLOWED.length > 0);
assert.equal(typeof unhireableAction, "function");
});
@@ -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();
}
});
+1 -1
View File
@@ -269,7 +269,7 @@
restore(p, body, kept);
}
const content = body.querySelector('[data-project-content]');
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org) });
if (content) await window.MosaicProjects.mountContent({ container: content, project, threads, section: what.section, canSend: false, org, pane: true, views: viewsOf(p, org), idPrefix: `pane-${p.key}-` });
return what.section === 'overview' ? project.name || project.id : `${project.name || project.id} · ${sectionName}`;
}
// A pane's request choice and PRD disclosure, per organization, kept on the pane so a repaint
+12 -8
View File
@@ -205,14 +205,15 @@
// state lives as long as the container.
const paneViews = new WeakMap();
const viewState=(container,pane,views)=>{if(!pane) return {selected:selectedRequests,expanded:expandedPrds}; if(views?.selected instanceof Map && views.expanded instanceof Set) return views; if(!paneViews.has(container)) paneViews.set(container,{selected:new Map(),expanded:new Set()}); return paneViews.get(container);};
function requirementView(data,project,selected,expandedSet=expandedPrds) {
function requirementView(data,project,selected,expandedSet=expandedPrds,idPrefix='') {
const pid=esc(idPrefix);
const options=data.requests.filter(r=>r.draft), r=options.find(x=>x.id===selected)||options[0], d=r?.draft;
const north=typeof data.north?.content==='string'?data.north.content:null;
const select=options.length>1?`<label>Request <select id="project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
const select=options.length>1?`<label>Request <select id="${pid}project-request-select">${options.map(x=>`<option value="${clean(x.id)}"${x===r?' selected':''}>${clean(x.id)} · ${clean(x.thread.name)}</option>`).join('')}</select></label>`:'';
const candidate=d?.candidate, expanded=r && expandedSet.has(project.id+':'+r.id);
const docPath=candidate?.prd_path, docLink=project.repo && typeof docPath==='string' && /^docs\/[A-Za-z0-9][A-Za-z0-9._-]{0,127}\.md$/.test(docPath) ? `#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent(docPath)}` : null;
const rows=candidate ? [['PRD version',candidate.prd_version],['Approved snapshot',d.snapshot.id || 'None'],['PRD file',docPath],['PRD blob',candidate.prd_blob],['Records commit',candidate.records_commit],['Confirmed intent commit',candidate.spec_commit],['Mode revision',candidate.mode_revision]].map(([label,value])=>`<div class="project-provenance-row"><span>${label}</span>${label==='PRD file' && docLink?`<a href="${docLink}"><code>${clean(value)}</code></a>`:`<code>${clean(value)}</code>`}</div>`).join('') : '<p>Unavailable until a PRD is read.</p>';
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
return `${r?`<div class="project-panel project-source">${select || `Request <code>${clean(r.id)}</code>`} · ${chip(r.state)} · from ${clean(r.thread.name)} <a href="${conversationLink(r.thread)}">Open conversation</a></div>`:select}<div class="project-columns"><section><h2>Project direction</h2><h3 id="${pid}project-north-star">North star</h3>${north!==null?`<div data-north-star></div><a class="project-text-link" href="#/docs/${encodeURIComponent(project.repo)}/${encodeURIComponent('spec/north-star.md')}">Open north star in Docs</a>`:data.northRecordsMissing?`<p>${window.MosaicRecords.markup(project.repo)}</p>`:data.northMissing?'<p>No north star yet.</p>':project.repo?'<p>North star unavailable. Docs could not read this project’s repository.</p>':`<p>This project has no linked repository. The north star and project documents live in a repository. Linking one to an existing project isn't available yet.</p>`}<h3 id="${pid}project-prd">${candidate?`PRD ${clean(candidate.prd_version)}`:'PRD'} ${candidate?`<span class="project-chip ${d.snapshot.id?'success':''}">${d.snapshot.id?'Approved':'Draft'}</span>`:''}</h3>${d?`<p>${d.snapshot.id?'Approved snapshot '+clean(d.snapshot.id):'This candidate has no active approved snapshot.'}</p><div class="project-panel project-prd-panel"><div class="project-prd-preview${expanded?' expanded':''}" data-prd></div><button type="button" class="btn project-prd-toggle" aria-expanded="${!!expanded}">${expanded?'Show less':'Show full PRD'}</button></div><h3>Requirements <span class="project-count">${d.requirements.length}</span></h3><div class="project-panel">${d.requirements.map((req,i)=>`<details data-restore-key="req:${clean(r.id)}:${clean(req.id)}"><summary>${clean(req.id)} · ${clean(req.title)}</summary><p>${clean(req.text)}</p><p>Requirement identity: <code>${clean(candidate.requirements[i]?.path)}</code> · <code>${clean(candidate.requirements[i]?.blob)}</code></p></details>`).join('')}</div>`:`<p>${(data.failed || data.requests.some(x=>x.materialFailed))?'Request or PRD unavailable. Refresh to try again.':'No current PRD candidate or approved snapshot. Waiting for a PRD submission.'}</p>`}${d && data.requests.some(x=>x.materialFailed)?'<p role="status">Some request PRDs could not be read. Refresh to try again.</p>':''}</section><aside class="project-panel project-provenance"><details data-restore-key="provenance"${matchMedia('(min-width:700px)').matches?' open':''}><summary>Version and provenance</summary>${rows}${candidate?`<p>Approval and revocation live in the conversation's Request details.</p><a href="${conversationLink(r.thread)}">Open conversation</a>`:''}</details></aside></div>`;
}
function overview(data,project,canSend,receipt='') {
const active=data.requests.filter(r=>r.state && !ended(r.state)), waiting=active.filter(r=>needs(r.state)), ordered=[...data.requests].sort(requestOrder), first=ordered[0], threads=[...data.threads];
@@ -255,7 +256,9 @@
const focus=f.id ? root.querySelector('#'+CSS.escape(f.id)) : f.details ? [...root.querySelectorAll('details[data-restore-key]')].find(el=>el.dataset.restoreKey===f.details)?.querySelector(':scope > summary') : root.querySelectorAll(FOCUSABLE)[f.index];
focus?.focus({preventScroll:true});
}
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null}) {
// A Side by side pane passes `idPrefix` (pane-a-, pane-b-), so the same project in both panes
// draws distinct ids; the full view passes none and keeps its ids.
async function mountContent({container,project,threads,section,canSend,restore,roomReceipt,org=null,pane=false,views:paneState=null,idPrefix=''}) {
if(!container || !project) return;
const generation=epoch, run=(contentRuns.get(container) || 0)+1;
contentRuns.set(container,run);
@@ -267,7 +270,7 @@
const selected=selectedRequests.get(project.id);
if(selected && !data.requests.some(r=>r.id===selected && r.draft)) selectedRequests.delete(project.id);
const selection=selectedRequests.get(project.id);
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const view = section==='requirements'?requirementView(data,project,selection,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
const selectedRequest=data.requests.find(r=>r.id===(selection||data.requests.find(x=>x.draft)?.id));
// The generated presentation and separately mounted Markdown determine visible content.
const signature=JSON.stringify([view, section==='requirements' ? selectedRequest?.draft?.prd : null, ['requirements','overview'].includes(section) ? data.north?.content : null, data.failed && ['files','tasks'].includes(section)]);
@@ -285,12 +288,13 @@
const liveRestore=contentPaints.has(container) && container.children.length ? captureView(container) : null;
const draw=selected=>{
const {data,project,section,canSend,roomReceipt}=contentModels.get(container);
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
container.innerHTML=section==='requirements'?requirementView(data,project,selected,expandedPrds,idPrefix):section==='files'?filesView(data,project):section==='tasks'?tasksView(data):overview(data,project,canSend,roomReceipt);
if(data.failed && ['files','tasks'].includes(section)) container.insertAdjacentHTML('afterbegin','<p role="status">Some request data could not be read; this list may be incomplete.</p>');
const selectedRequest=data.requests.find(r=>r.id===(selected||data.requests.find(x=>x.draft)?.id));
const md=container.querySelector('[data-prd]'); if(md && selectedRequest?.draft) { md.replaceChildren(window.MosaicMarkdown?.render(selectedRequest.draft.prd) || document.createTextNode(selectedRequest.draft.prd)); const toggle=container.querySelector('.project-prd-toggle'); toggle.hidden=!md.classList.contains('expanded') && md.scrollHeight<=md.clientHeight; toggle.addEventListener('click',()=>{const key=project.id+':'+selectedRequest.id; if(expandedPrds.has(key)) expandedPrds.delete(key); else expandedPrds.add(key); const open=expandedPrds.has(key); md.classList.toggle('expanded',open); toggle.setAttribute('aria-expanded',String(open));toggle.textContent=open?'Show less':'Show full PRD';}); }
const north=container.querySelector('[data-north-star]'); if(north && typeof data.north?.content==='string') { const text=section==='overview' && data.north.content.length>500 ? previewCut(data.north.content,500) : data.north.content; north.replaceChildren(window.MosaicMarkdown?.render(text) || document.createTextNode(text)); }
container.querySelector('#project-request-select')?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector('#project-request-select')?.focus({preventScroll:true});});
const requestSelect='#'+CSS.escape(idPrefix+'project-request-select');
container.querySelector(requestSelect)?.addEventListener('change',e=>{selectedRequests.set(project.id,e.target.value);draw(e.target.value);container.querySelector(requestSelect)?.focus({preventScroll:true});});
};
draw(selection);
restore=liveRestore || restore;
@@ -298,7 +302,7 @@
restoreView(container,restore);
}
const sectionTarget=pane ? null : new URLSearchParams(location.hash.split('?')[1] || '').get('section');
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#project-'+sectionTarget)?.scrollIntoView({block:'start'});
if(section==='requirements' && ['prd','north-star'].includes(sectionTarget)) container.querySelector('#'+CSS.escape(idPrefix+'project-'+sectionTarget))?.scrollIntoView({block:'start'});
}
// Cut at a word boundary, unless that would leave only a markdown marker (a long heading).
function previewCut(content,max) {
+25 -7
View File
@@ -373,6 +373,8 @@
}
async function submit(form) {
const kind = form.dataset.tbForm, btns = form.querySelectorAll('button');
// A form whose Save is disabled is sending, or waiting on the current task: it takes no second save.
if (form.querySelector('[data-tb-submit]')?.disabled) return;
if (!form.reportValidity()) return;
let body, t = null;
if (kind === 'create') {
@@ -388,16 +390,32 @@
}
btns.forEach(b => { b.disabled = true; }); formStatus(form, 'Sending…');
const r = await write(body);
// The task changed under the form: the form reopens on the current task and keeps the refusal.
// Until the current task is read the old form stays disabled and does not show the refusal, so
// the refusal never stands beside the old task and no save goes out against its digest.
if (!r.ok && r.error === 'task-conflict' && t) {
formStatus(form, 'Reading the current task…');
// A read a poll overtook leaves the board's old read in place, so only a read that landed counts.
const fresh = await reload().catch(() => false);
const now = (fresh && find(key(t))) || await current(t);
// Closed (Escape, Cancel) while the task was read: the dialog stays closed. close() clears `open`
// at once but queues the event that empties the dialog, so `open` is the check that holds.
const d = dialogEl();
if (!d.open || !d.contains(form)) { paint(); return; }
if (now) { openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, r.message); }
else {
// The current task could not be read: the form still holds the old one, so it takes no save.
opened = null;
formStatus(form, 'The task changed after the board read it. Nothing was written, and the current task could not be read; close this form and open the task again.');
form.querySelectorAll('[data-tb-cancel]').forEach(b => { b.disabled = false; });
}
paint();
return;
}
btns.forEach(b => { b.disabled = false; });
if (!r.ok) {
formStatus(form, r.message);
// The task changed under the form: the form shows the current task and keeps the refusal.
if (r.error === 'task-conflict' && t) {
try { await reload(); } catch {}
const now = find(key(t)) || await current(t);
if (now) { const msg = r.message; openDialog(editForm(now), dialogEl().dataset.return, now); const f2 = dialogEl().querySelector('form'); if (f2) formStatus(f2, msg); }
paint();
} else if (r.unknown) { try { await reload(); } catch {} paint(); }
if (r.unknown) { try { await reload(); } catch {} paint(); }
return;
}
const label = orgOf(body.org).label;
@@ -259,14 +259,16 @@ test('the same project in both panes keeps a request choice per pane', async t =
await b.evaluate('window.MosaicPanes.refresh()');
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=forge/projects/${P}/requirements'`);
const values = `[...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value)`;
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
const values = `[...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value)`;
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
assert.deepEqual(await b.evaluate(values), ['request-1', 'request-1']);
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`${values}[0] === 'request-2'`);
// The redrawn select gets focus back through the pane's own id.
assert.equal(await b.evaluate('document.activeElement.id'), 'pane-a-project-request-select');
await b.evaluate('window.MosaicPanes.refresh()');
await settled(b);
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2`);
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2`);
assert.deepEqual(await b.evaluate(values), ['request-2', 'request-1']);
});
@@ -279,10 +281,10 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
// The same organization and project in both panes.
await b.evaluate(`location.hash = '#/orgs/ddigital/side-by-side?a=ddigital/projects/${P}/requirements&b=ddigital/projects/${P}/requirements'`);
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] #project-request-select')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
await b.evaluate(`(() => { const e = document.querySelector('#pane-a #project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`document.querySelector('#pane-a #project-request-select').value === 'request-2'`);
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] .project-prd-toggle').length === 2`);
const state = `({ selected: [...document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]')].map(e => e.value), expanded: [...document.querySelectorAll('[data-pane-body] [data-prd]')].map(e => e.classList.contains('expanded')) })`;
await b.evaluate(`(() => { const e = document.querySelector('#pane-a-project-request-select'); e.value = 'request-2'; e.dispatchEvent(new Event('change', { bubbles: true })); })()`);
await wait(`document.querySelector('#pane-a-project-request-select').value === 'request-2'`);
await b.evaluate(`document.querySelector('#pane-a .project-prd-toggle').click()`);
await wait(`document.querySelector('#pane-a [data-prd]')?.classList.contains('expanded')`);
// A new conversation changes the header's count, so each pane replaces its project content.
@@ -290,7 +292,7 @@ test('a pane keeps its request choice and PRD disclosure when a new conversation
await b.evaluate(`window.fixtureThreads.push({ ...window.fixtureThreads[0], id: '20000000-0000-4000-8000-000000000004', name: 'A new conversation' }); window.MosaicPanes.refresh()`);
await wait(`(() => { const t = document.querySelector('#pane-a .project-header p')?.textContent || ''; return t !== ${JSON.stringify(before)} && /4 conversations/.test(t) && /4 conversations/.test(document.querySelector('#pane-b .project-header p')?.textContent || ''); })()`);
await settled(b);
await wait(`document.querySelectorAll('[data-pane-body] #project-request-select').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
await wait(`document.querySelectorAll('[data-pane-body] select[id$="-project-request-select"]').length === 2 && document.querySelectorAll('[data-pane-body] [data-prd]').length === 2`);
assert.deepEqual(await b.evaluate(state), { selected: ['request-2', 'request-1'], expanded: [true, false] });
});
@@ -338,3 +340,68 @@ test('each Agents pane keeps its own project choice for its organization', async
assert.deepEqual(await b.evaluate(`[${select('a')}.value, ${select('b')}.value]`), ['spring-launch', 'ops']);
assert.equal(await b.evaluate(`!${rows('b')}.includes('load-tester') && ${rows('a')}.includes('social-scheduler')`), true);
});
// Polish 1 (#1873): every id a pane draws is its own. With the same page in both panes no id
// appears twice, and every label, ARIA reference and in-page link resolves inside its own pane.
const idAudit = `(() => {
const count = new Map();
for (const el of document.querySelectorAll('[id]')) count.set(el.id, (count.get(el.id) || 0) + 1);
const duplicates = [...count].filter(([, n]) => n > 1).map(([id]) => id).sort();
const stray = [];
for (const pane of document.querySelectorAll('.panes > .pane:not([hidden])')) {
for (const attr of ['for', 'aria-labelledby', 'aria-describedby', 'aria-controls']) {
for (const el of pane.querySelectorAll('[' + attr + ']')) {
for (const id of el.getAttribute(attr).split(/\\s+/).filter(Boolean)) {
const target = document.getElementById(id);
if (!target || !pane.contains(target)) stray.push(pane.id + ' ' + attr + '=' + id);
}
}
}
for (const a of pane.querySelectorAll('a[href^="#"]:not([href^="#/"])')) {
const id = decodeURIComponent(a.getAttribute('href').slice(1));
const target = id && document.getElementById(id);
if (id && (!target || !pane.contains(target))) stray.push(pane.id + ' href=#' + id);
}
}
return { duplicates, stray: stray.sort() };
})()`;
test('the same project, dashboard and Agents page in both panes draw no duplicate ids, and every reference stays in its pane', async t => {
const { b, wait } = await open(t);
await wait(`${title('b')}.includes("Forge")`);
const { stub, P } = await import('./helpers/projects-review-fixture.mjs');
await b.evaluate(stub.replace('const path=String(url),method', `if(url==='/api/orgs'||url==='/api/themes')return real(url,init);const path=String(url).replace(/^\\/api\\/orgs\\/(?:ddigital|forge)\\//,'/api/'),method`));
const a = (name, level = 'org', definition = name) => ({ name, level, definition, model: null, scope: { kind: 'org' }, overrides: [], narrowed: [] });
const set = agents => ({ state: 'ok', agents, excluded: [] });
const levels = { root: { state: 'ok', assistant: null, agents: [] }, orgs: [
{ id: 'ddigital', state: 'ok', base: set([a('chief')]), defined: { [P]: [{ name: 'social-scheduler', form: 'define' }] },
projects: { [P]: set([a('chief'), a('social-scheduler', 'project', 'coder')]) } },
] };
await b.evaluate(`(() => { const real = window.fetch; window.fetch = (url, init) => String(url).endsWith('/api/agents/levels') ? Promise.resolve(new Response(${JSON.stringify(JSON.stringify(levels))}, { headers: { 'content-type': 'application/json' } })) : real(url, init); })()`);
await b.evaluate('window.MosaicPanes.refresh()');
await wait(`document.querySelector('#pane-a-pick option[value="ddigital/projects/${P}"]')`);
const both = route => `location.hash = '#/orgs/ddigital/side-by-side?a=ddigital${route}&b=ddigital${route}'`;
const pages = [
['/organization', `${title('a')}.startsWith('Dashboard') && ${title('b')}.startsWith('Dashboard') && document.querySelectorAll('[data-pane-body] [data-system-types-heading]').length === 2`],
['/agents', `${title('a')}.startsWith('Agents') && ${title('b')}.startsWith('Agents') && document.querySelectorAll('[data-pane-body] [data-agents-available] tr[data-agent]').length >= 2`],
[`/projects/${P}`, `${title('a')}.startsWith('Pomodoro Timer') && ${title('b')}.startsWith('Pomodoro Timer') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
[`/projects/${P}/conversations`, `${title('a')}.includes('Conversations') && ${title('b')}.includes('Conversations')`],
[`/projects/${P}/tasks`, `${title('a')}.includes('Tasks') && ${title('b')}.includes('Tasks') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
[`/projects/${P}/requirements`, `document.querySelectorAll('[data-pane-body] [data-prd]').length === 2 && document.querySelectorAll('[data-pane-body] select').length >= 2`],
[`/projects/${P}/files`, `${title('a')}.includes('Files') && ${title('b')}.includes('Files') && !${text('a')}.includes('Reading…') && !${text('b')}.includes('Reading…')`],
];
const found = {}, drawn = {};
for (const [route, ready] of pages) {
await b.evaluate(both(route));
await wait(ready);
await settled(b);
const page = route.replace(P, 'P');
found[page] = await b.evaluate(idAudit);
drawn[page] = await b.evaluate(`[...document.querySelectorAll('#pane-a [data-pane-body] [id]')].map(e => e.id)`);
}
assert.deepEqual(found, Object.fromEntries(pages.map(([route]) => [route.replace(P, 'P'), { duplicates: [], stray: [] }])));
// The audit is not vacuous: the dashboard draws its system types heading and the requirements page
// its request select, north star and PRD headings, each with the pane's prefix.
assert.deepEqual(drawn['/organization'], ['pane-a-ws-reload-why', 'pane-a-ws-types-h']);
assert.deepEqual(drawn['/projects/P/requirements'], ['pane-a-project-request-select', 'pane-a-project-north-star', 'pane-a-project-prd']);
});
@@ -128,11 +128,14 @@ test('org and project filters narrow the board', async t => {
});
test('the layout toggle is remembered across a reload', async t => {
const { b, wait } = await open(t);
const { b, wait, url } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
await wait('document.querySelector("[data-tasks-board]").dataset.layout === "list" && document.querySelector("[data-tb-list]")');
assert.equal(await b.evaluate('document.querySelector("[data-tb-layout=list]").getAttribute("aria-pressed")'), 'true');
await b.evaluate('location.reload()');
// Load the page again through navigate(), which waits for the load. A location.reload() from evaluate
// returns before the reload commits, so the next wait ran on the old page and the reload killed it
// (as instances-browser found). The same URL with only a hash would not load, so add a query.
await b.navigate(url.replace('/#', '/?reload=1#'));
await wait('document.querySelector("[data-tasks-board] [data-tb-tracker=home]")');
assert.equal(await b.evaluate('document.querySelector("[data-tasks-board]").dataset.layout'), 'list');
assert.equal(await b.evaluate('document.querySelectorAll("[data-tb-list] tr.tb-row").length'), 5);
@@ -221,6 +224,107 @@ test('an edit dialog left open while another writer changes the task saves again
assert.equal(await b.evaluate('document.querySelector("#tb-dialog").open'), false);
});
// Polish 1 (#1873): after a conflict the dialog reads the current task before it reopens. This holds
// that read: the first workspace read after the write's answer waits until window.__release().
const HOLD_CONFLICT_READ = `(() => {
const real = window.fetch;
window.__armed = false; window.__held = false;
window.fetch = (u, o) => {
const url = String(u);
if (url === '/api/workspace/tasks') return real(u, o).then(r => { window.__armed = true; return r; });
if (url.startsWith('/api/workspace?') && window.__armed && !window.__held) {
window.__held = true;
return new Promise(go => { window.__release = () => real(u, o).then(go); });
}
return real(u, o);
};
})()`;
const SAVE_AS = title => `(() => { const f = document.querySelector("#tb-dialog form"); f.title.value = ${JSON.stringify(title)}; f.requestSubmit(); })()`;
const STATUS = 'document.querySelector("#tb-dialog [data-tb-form-status]")?.textContent';
test('after a conflict the dialog shows the refusal only with the current task, and takes no save while it reads it', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
const t42 = orgs.store.find(s => s.n === 42);
Object.assign(t42, { title: 'Invoice export (renamed elsewhere)', assignees: ['coder'], rev: t42.rev + 1 });
await b.evaluate(HOLD_CONFLICT_READ);
await b.evaluate(SAVE_AS('Invoice export, CSV'));
await wait('window.__held');
// The write was refused and the current task is still being read: the form still holds the old task,
// so it does not show the refusal and its Save is off.
assert.equal(await b.evaluate(STATUS), 'Reading the current task…');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export, CSV');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), true);
// A save in this window, even one sent past the disabled button, goes nowhere.
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
await b.evaluate('new Promise(r => setTimeout(r, 200))');
assert.deepEqual(orgs.writes, [['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(2), title: 'Invoice export, CSV' }]]);
// Once the read answers, the refusal and the current task appear together.
await b.evaluate('window.__release()');
await wait('/changed after the board read it/.test(' + STATUS + ')');
assert.equal(await b.evaluate('document.querySelector("#tb-dialog form").title.value'), 'Invoice export (renamed elsewhere)');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog input[name=assignees]:checked")].map(i => i.value)'), ['coder']);
assert.equal(await b.evaluate('document.querySelector("#tb-dialog [data-tb-submit]").disabled'), false);
await b.evaluate(SAVE_AS('Invoice export, CSV'));
await wait('/#42 saved in Forge/.test(document.querySelector("[data-tb-notice]").textContent)');
assert.deepEqual(orgs.writes.at(-1), ['forge', 'tasks.edit', { task_ref: 'task:42', expect: D(3), title: 'Invoice export, CSV' }]);
assert.equal(orgs.writes.length, 2);
});
test('after a conflict a task that can no longer be read leaves the dialog unable to save', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
const t42 = orgs.store.find(s => s.n === 42);
t42.rev++;
await b.evaluate(HOLD_CONFLICT_READ);
await b.evaluate(SAVE_AS('Invoice export, CSV'));
await wait('window.__held');
// The task leaves the store before the dialog's read answers, so neither read finds it.
orgs.store.splice(orgs.store.indexOf(t42), 1);
await b.evaluate('window.__release()');
await wait('/could not be read/.test(' + STATUS + ')');
assert.match(await b.evaluate(STATUS), /^The task changed after the board read it\. Nothing was written, and the current task could not be read; close this form and open the task again\.$/);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#tb-dialog button")].map(x => [x.textContent, x.disabled])'), [['Archive', true], ['Cancel', false], ['Save', true]]);
await b.evaluate(SAVE_AS('Invoice export, CSV again'));
await b.evaluate('new Promise(r => setTimeout(r, 200))');
assert.equal(orgs.writes.length, 1);
await b.evaluate('document.querySelector("#tb-dialog [data-tb-cancel]").click()');
await wait('!document.querySelector("#tb-dialog").open');
});
test('a dialog closed just before the conflict read answers stays closed', async t => {
const { b, wait, orgs } = await open(t);
await b.evaluate('document.querySelector("[data-tb-open=\'forge|task:42\']").click()');
await wait('document.querySelector("#tb-dialog[open] form[data-tb-form=edit]")');
orgs.store.find(s => s.n === 42).rev++;
// Hold the read's body, not the request, so the close and the read's answer land in one task:
// close() clears `open` at once, but the event that empties the dialog runs after the read resumes.
await b.evaluate(`(() => {
const real = window.fetch;
let armed = false, held = false;
window.fetch = (u, o) => {
if (String(u) === '/api/workspace/tasks') return real(u, o).then(r => { armed = true; return r; });
if (String(u).startsWith('/api/workspace?') && armed && !held) {
held = true;
return real(u, o).then(async r => {
const body = await r.json();
r.json = () => new Promise(go => { window.__releaseJson = () => go(structuredClone(body)); });
return r;
});
}
return real(u, o);
};
})()`);
await b.evaluate(SAVE_AS('Invoice export, CSV'));
await wait('typeof window.__releaseJson === "function"');
await b.evaluate('document.querySelector("#tb-dialog").close(); window.__releaseJson()');
await b.evaluate('new Promise(r => setTimeout(r, 250))');
assert.deepEqual(await b.evaluate('[document.querySelector("#tb-dialog").open, document.querySelector("#tb-dialog").innerHTML]'), [false, '']);
assert.equal(orgs.writes.length, 1);
});
test('a poll that answers after focus has moved leaves focus where the person put it', async t => {
const { b, wait } = await open(t);
await b.evaluate('document.querySelector("[data-tb-layout=list]").click()');
+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);
}