Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dbd7e72d3 | ||
|
|
d23673cd3e | ||
|
|
ebe2af86de | ||
|
|
1947a056fb | ||
|
|
7ffe932250 |
No files matched your search
+1
-1
@@ -333,7 +333,7 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
|
||||
|
||||
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
|
||||
|
||||
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start` (otherwise the Console answers `not-supervised`); it waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
|
||||
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start`, or by `mosaic console start --dev-local-owner --detach`, which leaves a detached supervisor (`packages/cli/src/console-supervisor.mjs`, its own session) running the Console as the foreground start does; its receipt adds the supervisor's `supervisor` pid, and its later warnings go to `console/supervisor-*.log` under the data root. `mosaic console stop` stops that Console, and its supervisor then exits. `mosaic console start --dev-local-owner` refuses inside an agent run (exit 3), with or without `--detach`, checking the starting process's own environment as well as the one given for the Console; a start without `--dev-local-owner` is unchanged. A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
|
||||
|
||||
The instance processing switch is separate from per-business launches off/on.
|
||||
`mosaic halt` durably stops managed processing across every business in the
|
||||
|
||||
@@ -6488,7 +6488,9 @@ export class Broker {
|
||||
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
|
||||
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
|
||||
};
|
||||
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
|
||||
// #1873 CB1: a copy credential binding is owner-only. A person raising one in one step must be
|
||||
// the organization's owner; a member with org.hire is not.
|
||||
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); if(input?.binding!==undefined && c.owner!==true)fail('human-required'); }
|
||||
else chiefAllowed();
|
||||
if(!this.#hires)fail('not-configured');
|
||||
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
|
||||
@@ -6497,12 +6499,14 @@ export class Broker {
|
||||
if(!immediate)chiefAllowed();
|
||||
else {
|
||||
const caller=this.#hireCaller(s);
|
||||
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
|
||||
if(p.binding && caller.owner!==true)fail('human-required');
|
||||
if(!p.binding && (((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner)))fail('hire-owner-required');
|
||||
}
|
||||
const action=`role.${p.op}`;
|
||||
const level=decisionAutonomy('authority',b.autonomy);
|
||||
let route='human';
|
||||
if(!immediate && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
|
||||
// A binding always goes to the owner; no autonomy level moves it (design section 4).
|
||||
if(!immediate && !p.binding && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
|
||||
if(level==='out of the loop')fail('authority-limits-unavailable');
|
||||
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
|
||||
route=b.autonomy?.authorityResolver;
|
||||
@@ -6512,7 +6516,7 @@ export class Broker {
|
||||
this.#store.transaction(()=>{
|
||||
const raiser=immediate?'human':s.role;
|
||||
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
|
||||
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,p.binding?`${p.binding.after===null?'unbind':'bind'} credentials for copy ${p.binding.instance}?`:`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
|
||||
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
|
||||
});
|
||||
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
|
||||
@@ -6531,6 +6535,7 @@ export class Broker {
|
||||
const validate=()=>{
|
||||
this.#agent(s);
|
||||
if(d.route_to!==s.role || d.raised_by_role===s.role || resolverSubject(this.#business(s.business),s.role,p?.name))fail('not-resolver');
|
||||
if(p?.binding)fail('human-required');
|
||||
if(!p || p.level==='root' || p.flagged.length || p.credentialLines.length)fail('hire-owner-required');
|
||||
if(this.#closed(d.id))fail('decision-closed');
|
||||
if(this.#expireHire(s,d,p))fail('decision-expired');
|
||||
@@ -6581,6 +6586,7 @@ export class Broker {
|
||||
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
|
||||
if(this.#expireHire(s,d,p))fail('decision-expired');
|
||||
if(!caller.rights.includes('org.hire'))fail('right-required');
|
||||
if(p.binding && caller.owner!==true)fail('human-required');
|
||||
if(a.adjust!==undefined) {
|
||||
if(!this.#hires?.preview)fail('not-configured');
|
||||
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
|
||||
|
||||
@@ -16,6 +16,13 @@ group("confirmation-required", "R5/R12: person confirms the arbiter resolution",
|
||||
group("decision-reversed reverse-window-closed", "R2/R12: one-use reverse within the owner's window", "Inspect the reversal trail. After the window, raise an ordinary new decision through the normal approval path; never replay the old approval.");
|
||||
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
|
||||
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
|
||||
// #1873 CB1: copy credential bindings (design sections 4 and 6). Approval is owner-only; nothing here
|
||||
// names a token, a slot path or a file path.
|
||||
group("organization-bindings-protected", "R1/R8: copy bindings change only through an owner-approved decision", "Ask the chief to raise a binding decision for the copy; only the organization owner approves it. No other write path changes copyBindings.");
|
||||
group("credential-binding-changed credential-role-changed credential-definition-unpinned", "R1/R8: binding approval pinned to the current copy, definition and generation", "Read the copy's current binding and definition, then ask the chief to raise a fresh binding decision for the owner; an old approval never carries over.");
|
||||
group("credential-scope-exceeded", "R1/R8: effective access is the role intersected with the approval", "Narrow the requested scopes and projects to what the copy's role and admitted projects allow, or change the role through its own decision first.");
|
||||
group("credential-unbound", "R1/R8: launch needs an approved, verified binding", "Ask the chief to raise a binding decision for the missing service; the owner approves it. A pending binding is not usable until verification exists.");
|
||||
group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision.");
|
||||
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
|
||||
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
|
||||
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
|
||||
|
||||
@@ -912,7 +912,9 @@ export async function startBroker({
|
||||
autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],countsAccount:levels.countsAccount,hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null,
|
||||
hires: organization ? (()=>{
|
||||
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
|
||||
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
|
||||
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry,
|
||||
// #1873 CB1: a copy binding names a copy this bus booted with; no snapshot, no binding.
|
||||
copies:id=>copies?.org===id?copies:null});
|
||||
return authorityHost(service,{modelKeys,reload,proxyOrganization});
|
||||
})() : null,
|
||||
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// #1873 CB1: a copy credential binding through the broker. Only the chief raises one; only the
|
||||
// organization owner resolves it; no autonomy level, member right, resolver agent, tester or reader
|
||||
// moves it. The written record stays pending, so nothing launches on it in CB1.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { materializeCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const GITEA = { gitea: { scopes: ['read:repository'], projects: ['stack'] } };
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
async function fixture(t, edit = d => d) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-bus-')), rolesDir = rolesCopy(root);
|
||||
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
const doc = edit(base);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const storeRoot = join(root, 'data'); mkdirSync(storeRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot: storeRoot, instance: 'test' };
|
||||
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(storeRoot);
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot: storeRoot, configDir: root, assertClean: v => { if (JSON.stringify(v).includes(SECRET)) throw Object.assign(new Error('x'), { code: 'definition-holds-credential' }); }, copies: id => snapshot.org === id ? snapshot : null });
|
||||
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])) }; };
|
||||
let writes = 0;
|
||||
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { writes++; const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } } });
|
||||
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const member = (rights, extra = {}) => b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights, ...extra });
|
||||
const caps = { chief: b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }), coder: b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }) };
|
||||
b.request(caps.chief, { verb: 'role.claim' }); b.request(caps.coder, { verb: 'role.claim' });
|
||||
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
const bind = (extra = {}) => ({ op: 'change', level: 'root', name: 'rev', reason: 'Bind the reviewer copy', binding: { services: GITEA }, ...extra });
|
||||
const raise = extra => call(caps.chief, 'hire.raise', bind(extra));
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const bindings = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings ?? {};
|
||||
return { root, rolesDir, file, b, store, writer, service, owner, member, caps, call, bind, raise, bindings, writes: () => writes, loaded };
|
||||
}
|
||||
|
||||
test('CB1 bus: only the chief raises a binding; the decision names the copy and holds no token or path', async t => {
|
||||
const f = await fixture(t);
|
||||
await assert.rejects(f.call(f.caps.coder, 'hire.raise', f.bind()), { code: 'role-not-chief' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
const d = await f.raise();
|
||||
assert.equal(d.route_to, 'human');
|
||||
assert.equal(d.question, 'bind credentials for copy rev?');
|
||||
assert.equal(d.hire.binding.instance, 'rev');
|
||||
const text = JSON.stringify(d);
|
||||
for (const bad of [SECRET, f.root, 'token']) assert.ok(!text.includes(bad), bad);
|
||||
assert.equal(f.writes(), 0);
|
||||
assert.deepEqual(f.bindings(), {});
|
||||
});
|
||||
|
||||
test('CB1 bus: no autonomy level routes a binding away from the owner', async t => {
|
||||
for (const authority of ['near the loop', 'out of the loop']) {
|
||||
const f = await fixture(t, doc => {
|
||||
doc.definitions = { resolver: { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.authority'], crossRole: [] }, credentials: [] }, instructions: 'Resolve independent decisions.' } };
|
||||
doc.roles.cto = { definition: 'org:resolver' };
|
||||
doc.plugins['limit-check'] = ['token-limit']; doc.launch.limits = { tokens: { providers: { zai: { limit: 100000, since: '2026-01-01T00:00:00.000Z' } } } };
|
||||
doc.autonomy = { authority, authorityResolver: 'cto', notices: true };
|
||||
return doc;
|
||||
});
|
||||
const live = f.loaded(); live.roles.cto.authority = { withinRole: ['decision.resolve.authority'], crossRole: [] }; f.b.reloadBusiness('demo', live);
|
||||
const resolver = f.b.bindLaunch({ business: 'demo', role: 'cto', run: 'cto-run', harness: 'pi' }); f.b.request(resolver, { verb: 'role.claim' });
|
||||
const d = await f.raise();
|
||||
assert.equal(d.route_to, 'human', authority);
|
||||
// The authority resolver cannot take it, nor can the chief through the agent verb.
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
|
||||
await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
|
||||
assert.equal(f.writes(), 0, authority);
|
||||
assert.deepEqual(f.bindings(), {}, authority);
|
||||
// A project-level unbind is not root; it lists the lines it removes and still goes to the owner.
|
||||
if (authority === 'near the loop') {
|
||||
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
|
||||
const u = await f.raise({ level: 'project', project: 'stack', name: 'checker', binding: { services: null } });
|
||||
assert.equal(u.hire.level, 'project');
|
||||
assert.equal(u.route_to, 'human');
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' }), { code: 'hire-resolution-required' });
|
||||
assert.equal(f.bindings()['checker.stack'].state, 'pending');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('CB1 bus: members with org.hire or decide, the tester and a reader cannot resolve; the owner can, once', async t => {
|
||||
const f = await fixture(t);
|
||||
const d = await f.raise();
|
||||
const digest = d.hire.digest;
|
||||
await assert.rejects(f.call(f.member(['org.hire']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.member(['org.hire', 'decide'], { instanceOwner: true }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.member(['decide']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'right-required' });
|
||||
await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'human-required' });
|
||||
assert.equal(f.writes(), 0);
|
||||
assert.deepEqual(f.bindings(), {});
|
||||
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: '0'.repeat(64) }), { code: 'decision-stale' });
|
||||
await f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest });
|
||||
assert.equal(f.writes(), 1);
|
||||
const rec = f.bindings().rev;
|
||||
assert.equal(rec.state, 'pending');
|
||||
assert.equal(rec.decision, d.id);
|
||||
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'decision-closed' });
|
||||
assert.equal(f.writes(), 1);
|
||||
});
|
||||
|
||||
test('CB1 bus: a one-step Console binding is owner-only; a member is refused before any decision is saved', async t => {
|
||||
const f = await fixture(t);
|
||||
await assert.rejects(f.call(f.member(['org.hire'], { instanceOwner: true }), 'hire.start.console', f.bind()), { code: 'human-required' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
await f.call(f.owner, 'hire.start.console', f.bind());
|
||||
assert.equal(f.bindings().rev.state, 'pending');
|
||||
// Unbind is owner-only too.
|
||||
await assert.rejects(f.call(f.member(['org.hire']), 'hire.start.console', f.bind({ binding: { services: null } })), { code: 'human-required' });
|
||||
await f.call(f.owner, 'hire.start.console', f.bind({ binding: { services: null } }));
|
||||
assert.equal(f.bindings().rev.state, 'unbound');
|
||||
});
|
||||
|
||||
test('CB1 bus: every new refusal has a table entry with a next step', () => {
|
||||
for (const code of ['organization-bindings-protected', 'credential-binding-changed', 'credential-role-changed', 'credential-definition-unpinned', 'credential-scope-exceeded', 'credential-unbound', 'copy-unavailable', 'copy-refused']) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, code), code);
|
||||
assert.ok(REFUSALS[code].nextStep?.length > 20, code);
|
||||
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/copy-bindings.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/copy-bindings.mjs";
|
||||
@@ -595,6 +595,8 @@ async function consoleCommand(rest, io, deps) {
|
||||
// org-lan-bind: refused here, before any config is read, unless both flags come with
|
||||
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
|
||||
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
|
||||
// D1: an agent run never starts a dev-local-owner Console (org-console.mjs consoleStart checks again).
|
||||
if (devOwner) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
|
||||
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
|
||||
let lan;
|
||||
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// D1 (#1873): the supervisor of a detached dev-local-owner Console. `mosaic console start
|
||||
// --dev-local-owner --detach` starts this process in its own session (org-console.mjs
|
||||
// detachedSupervisor). It starts the Console exactly as a foreground start does, with --supervised,
|
||||
// and relaunches it when the owner restarts it (F3). Its one stdout line is the start's reply:
|
||||
// { receipt, warnings } or { error, exitCode, warnings }. Later warnings go to stderr, the log.
|
||||
import { consoleStart } from './org-console.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
|
||||
const { configDir, dataRoot, instance, port, bind, allowHosts } = JSON.parse(process.argv[2]);
|
||||
const warnings = [];
|
||||
let started = false;
|
||||
const warn = s => { if (started) process.stderr.write(s); else warnings.push(s); };
|
||||
// The starting CLI can be gone before the reply (Ctrl-C, a wrapper's timeout). The Console is
|
||||
// started and its state names it, so a reply nobody reads is dropped and supervision goes on.
|
||||
process.stdout.on('error', () => {});
|
||||
const reply = value => new Promise(resolve => process.stdout.write(`${JSON.stringify(value)}\n`, () => resolve()));
|
||||
// A stop signal during the start (the CLI's start timeout signals this process group) is kept and
|
||||
// acted on once the start settles, so a failed start still releases the start lock.
|
||||
let stopAsked = false;
|
||||
const early = () => { stopAsked = true; };
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, early);
|
||||
let receipt;
|
||||
try {
|
||||
receipt = await consoleStart({ configDir, dataRoot, instance, env: process.env }, { devLocalOwner: true, port, bind, allowHosts, warn });
|
||||
} catch (e) {
|
||||
const exitCode = e instanceof CliError ? e.exitCode : 1;
|
||||
if (!(e instanceof CliError)) process.stderr.write(`${e?.stack ?? e}\n`);
|
||||
await reply({ ...(e instanceof CliError ? { error: e.message } : {}), exitCode, warnings });
|
||||
process.exit(exitCode);
|
||||
}
|
||||
started = true;
|
||||
// A stop signal ends supervision cleanly, as in the foreground (cli.mjs consoleCommand).
|
||||
const stop = () => receipt.stop();
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) { process.off(signal, early); process.on(signal, stop); }
|
||||
if (stopAsked) stop();
|
||||
await reply({ receipt, warnings });
|
||||
const code = await receipt.done;
|
||||
if (code !== 0) process.stderr.write(`mosaic: console stopped unexpectedly (${code})\n`);
|
||||
process.exit(code);
|
||||
@@ -3,13 +3,14 @@ import { randomBytes } from 'node:crypto';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { cmdlineOf, startTimeOf } from './host.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { devLanOptions } from './dev-local-owner.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
|
||||
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
|
||||
@@ -63,8 +64,25 @@ function takeLock(ctx) {
|
||||
checkDir(ctx);
|
||||
const path = join(directory(ctx), 'start.lock');
|
||||
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
|
||||
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
|
||||
// supervisor it killed (clearDeadLock) and no other.
|
||||
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
|
||||
return () => { closeSync(fd); rmSync(path, { force: true }); };
|
||||
}
|
||||
// Removes start.lock only when it names this exact process (pid and start time) and that process
|
||||
// is gone. A lock that is unreadable, unnamed or another holder's stays.
|
||||
function clearDeadLock(ctx, pid, startTime) {
|
||||
if (!startTime) return;
|
||||
const path = join(directory(ctx), 'start.lock');
|
||||
let owner;
|
||||
try {
|
||||
const st = lstatSync(path);
|
||||
if (!st.isFile() || st.uid !== process.getuid()) return;
|
||||
owner = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch { return; }
|
||||
if (owner?.pid !== pid || owner.startTime !== startTime || startTimeOf(pid) === startTime) return;
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
|
||||
// own restart route (POST /api/console/restart, a human action) exits with it.
|
||||
export const RESTART_EXIT = 75;
|
||||
@@ -153,15 +171,76 @@ function supervise(ctx, first, args, lan, warn) {
|
||||
})();
|
||||
return { done, stop };
|
||||
}
|
||||
// D1 (#1873): `--dev-local-owner --detach` runs the foreground supervision above in a process of
|
||||
// its own session (console-supervisor.mjs), so Restart Console works on a detached dev Console. The
|
||||
// supervisor takes the start lock and writes the console state as a foreground start does. This
|
||||
// process waits for the supervisor's one-line reply, passes its warnings on and leaves it running.
|
||||
const SUPERVISOR = fileURLToPath(new URL('./console-supervisor.mjs', import.meta.url));
|
||||
async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) {
|
||||
checkDir(ctx);
|
||||
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
||||
const name = `supervisor-${process.pid}-${lastStamp}.log`;
|
||||
const fd = openSync(join(directory(ctx), name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance, port, bind, allowHosts })],
|
||||
{ env: ctx.env ?? process.env, detached: true, stdio: ['ignore', 'pipe', fd] });
|
||||
} finally { closeSync(fd); }
|
||||
const supervisorStart = child.pid ? startTimeOf(child.pid) : null;
|
||||
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
|
||||
// The log is named under the data root, never by host path (PR #1916).
|
||||
const unclear = `console failed to start; see console/${name} under the data root`;
|
||||
let text = '', timer;
|
||||
child.stdout.setEncoding('utf8');
|
||||
const line = await Promise.race([
|
||||
new Promise(resolve => {
|
||||
child.stdout.on('data', c => { text += c; const n = text.indexOf('\n'); if (n >= 0) resolve(text.slice(0, n)); else if (text.length > 65536) resolve(null); });
|
||||
child.stdout.once('end', () => resolve(null));
|
||||
}),
|
||||
new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 60000); }),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
child.stdout.destroy();
|
||||
// The supervisor leads its own process group, with the Console it may have started: a timed-out
|
||||
// start signals that group, then kills it if it is still there after 10 seconds. A supervisor
|
||||
// killed while it held the start lock never released it, so the lock it named is cleared.
|
||||
if (line === undefined) {
|
||||
const group = signal => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
|
||||
group('SIGTERM');
|
||||
let grace; const exited = await Promise.race([done.then(() => true), new Promise(r => { grace = setTimeout(() => r(false), 10000); })]);
|
||||
clearTimeout(grace);
|
||||
if (!exited) { group('SIGKILL'); await done; }
|
||||
clearDeadLock(ctx, child.pid, supervisorStart);
|
||||
fail(unclear);
|
||||
}
|
||||
let reply = null;
|
||||
try { reply = JSON.parse(line); } catch { /* no reply */ }
|
||||
for (const w of Array.isArray(reply?.warnings) ? reply.warnings : []) if (typeof w === 'string') warn(w);
|
||||
child.unref();
|
||||
if (typeof reply?.receipt?.url !== 'string' || !Number.isSafeInteger(reply.receipt.pid)) {
|
||||
const code = [1, 2, 3, 4].includes(reply?.exitCode) ? reply.exitCode : 1;
|
||||
throw new CliError(typeof reply?.error === 'string' ? reply.error : unclear, code);
|
||||
}
|
||||
const result = { ...reply.receipt, supervisor: child.pid };
|
||||
Object.defineProperty(result, 'done', { value: done });
|
||||
Object.defineProperty(result, 'stop', { value: () => child.kill('SIGTERM') });
|
||||
return result;
|
||||
}
|
||||
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
|
||||
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
|
||||
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
|
||||
// this process relaunches it in place (F3). A detached Console has no supervisor.
|
||||
// this process relaunches it in place (F3). A detached dev-local-owner Console runs under a
|
||||
// detached supervisor (D1); any other detached Console has none.
|
||||
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
|
||||
const value = port === 'auto' ? 0 : Number(port);
|
||||
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
|
||||
let lan;
|
||||
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
|
||||
// D1 fix round (security review P1): a dev-local-owner Console acts as the owner with no sign-in,
|
||||
// so an agent run never starts one, detached or not. Other starts are unchanged. This process's own
|
||||
// environment is checked as well as the one given for the Console (security re-check P1).
|
||||
if (devLocalOwner) { refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env); }
|
||||
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
|
||||
const release = takeLock(ctx);
|
||||
let run, args;
|
||||
try {
|
||||
|
||||
@@ -115,21 +115,47 @@ test('mosaic console restart relaunches a supervised Console and the trail recor
|
||||
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
|
||||
});
|
||||
|
||||
test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => {
|
||||
// D1 (#1873): a detached dev-owner Console runs under a detached supervisor, so the terminal restart
|
||||
// works on it as on a foreground one. Before D1 this Console refused with not-supervised.
|
||||
test('a detached dev-owner Console restarts through mosaic console restart, and org reload of an unknown org is refused with no trail', { timeout: 120000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
|
||||
// The real organization service refuses an org that does not exist before any trail line.
|
||||
const unknown = await mosaic(env, ['org', 'reload', 'nosuch']);
|
||||
assert.equal(unknown.status, 3, unknown.stderr);
|
||||
assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
const before = (await get(port, '/api/config')).body;
|
||||
assert.equal(before.consoleRestart, true);
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const answer = JSON.parse(r.stdout);
|
||||
assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot);
|
||||
assert.equal((await get(port, '/api/config')).body.boot, answer.boot);
|
||||
assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
// The supervisor is unref'd, so a timer keeps this process awake while it exits.
|
||||
let code; result.done.then(c => { code = c; });
|
||||
for (let i = 0; i < 200 && code === undefined; i++) await new Promise(r => setTimeout(r, 50));
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
|
||||
// A dev-owner Console nothing supervises (here one in this process, started with no restart hook)
|
||||
// still refuses the terminal restart and stays up, with no trail line.
|
||||
test('an unsupervised Console refuses mosaic console restart with not-supervised and stays up', { timeout: 90000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
recordConsole(ctx, `http://127.0.0.1:${port}/`);
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
|
||||
assert.equal((await get(port, '/api/config')).status, 200);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
|
||||
// The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts.
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
|
||||
import { get, request } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
|
||||
import { humanEnv } from './helpers.mjs';
|
||||
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
|
||||
import { consoleStart, consoleStatus, consoleStop, consoleRestart } from '../src/org-console.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
// A detached supervisor's handle is unref'd, so its exit is awaited with a timer that keeps the loop alive.
|
||||
async function exitOf(done, ms = 20000) {
|
||||
let code; done.then(c => { code = c; });
|
||||
for (const deadline = Date.now() + ms; code === undefined; await new Promise(r => setTimeout(r, 100))) if (Date.now() > deadline) assert.fail('the supervisor did not exit');
|
||||
return code;
|
||||
}
|
||||
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 's1-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
|
||||
@@ -31,11 +38,13 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
|
||||
assert.ok(Number.isSafeInteger(result.pid));
|
||||
// A selector producer (rename r2): the Console child gets the active organization as --business.
|
||||
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
|
||||
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
|
||||
// D1 (#1873): a detached dev-owner Console runs under a detached supervisor, so it is --supervised.
|
||||
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
|
||||
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
|
||||
assert.equal(status, 200);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
});
|
||||
|
||||
test('mosaic console start and stop dispatch through the CLI', { timeout: 60000 }, async t => {
|
||||
@@ -198,15 +207,208 @@ test('a foreground console relaunches on the same address when the owner restart
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
});
|
||||
|
||||
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
|
||||
// D1 (#1873, readiness audit P1-1): Restart Console on a detached dev-owner Console. The start runs
|
||||
// the foreground supervision in a process of its own session; the Console is that process's child.
|
||||
const SUPERVISOR = join(REPO, 'packages/cli/src/console-supervisor.mjs');
|
||||
const statOf = pid => readFileSync(`/proc/${pid}/stat`, 'utf8').replace(/^.*\) /, '').split(' ');
|
||||
test('a detached dev-owner console runs under a detached supervisor, so the owner restarts it in place', { timeout: 120000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(Object.keys(result), ['url', 'pid', 'supervisor']);
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
|
||||
const sup = argvOf(result.supervisor);
|
||||
assert.deepEqual(sup.slice(0, 2), [process.execPath, SUPERVISOR]);
|
||||
assert.deepEqual(JSON.parse(sup[2]), { configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] });
|
||||
// The supervisor leads its own session; the Console is its child, started as in the foreground.
|
||||
assert.equal(Number(statOf(result.supervisor)[3]), result.supervisor);
|
||||
assert.equal(Number(statOf(result.pid)[1]), result.supervisor);
|
||||
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
const before = (await call(port, '/api/config')).body;
|
||||
assert.equal(before.consoleRestart, true);
|
||||
const stateFile = join(ctx.dataRoot, 'console/console.json'), trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
|
||||
// Another origin or site restarts nothing.
|
||||
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
|
||||
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
// The page's request, as `mosaic console restart` sends it: 202, then a new boot on the same address.
|
||||
const restarted = await consoleRestart(ctx);
|
||||
assert.equal(restarted.outcome, 'restarted'); assert.equal(restarted.from, before.boot); assert.notEqual(restarted.boot, before.boot);
|
||||
// The relaunched Console answers before the supervisor writes its state (relaunch), so wait for it.
|
||||
const saved = await relaunchedState(ctx, result.pid);
|
||||
assert.notEqual(saved.pid, result.pid);
|
||||
assert.equal(saved.url, result.url);
|
||||
assert.equal(Number(statOf(saved.pid)[1]), result.supervisor);
|
||||
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
const after = (await call(port, '/api/config')).body;
|
||||
assert.equal(after.boot, restarted.boot); assert.equal(after.consoleRestart, true);
|
||||
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
assert.deepEqual(more, []);
|
||||
assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
|
||||
// The supervisor's later warnings go to its log under the data root.
|
||||
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^supervisor-\d+-\d+\.log$/.test(x));
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(statSync(join(ctx.dataRoot, 'console', logs[0])).mode & 0o777, 0o600);
|
||||
assert.equal(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8'), 'mosaic: console asked for a restart; relaunching on the same arguments\n');
|
||||
// A second start is refused by the supervisor's start and says so here; nothing else starts.
|
||||
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), e => e.exitCode === 3 && /console state exists/.test(e.message));
|
||||
const cli = mosaic(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
|
||||
assert.equal(cli.status, 3); assert.match(cli.stderr, /console state exists/); assert.equal(cli.stdout, '');
|
||||
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
|
||||
// A stop ends the Console and then the supervisor, cleanly.
|
||||
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: saved.pid });
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
});
|
||||
|
||||
test('a supervisor stopped by a signal stops its detached dev-owner console', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
|
||||
result.stop();
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
});
|
||||
|
||||
// relaunch() writes console.json after the new Console answers; read it once it names a new pid.
|
||||
async function relaunchedState(ctx, oldPid) {
|
||||
const file = join(ctx.dataRoot, 'console/console.json');
|
||||
for (const deadline = Date.now() + 15000; ; await new Promise(r => setTimeout(r, 50))) {
|
||||
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (saved.pid !== oldPid || Date.now() > deadline) return saved;
|
||||
}
|
||||
}
|
||||
|
||||
// D1 fix round (build review P2): the starting CLI can die before it reads the supervisor's reply
|
||||
// (Ctrl-C, a wrapper's timeout). The reply is dropped, and the supervisor keeps supervising.
|
||||
test('a supervisor whose starting CLI is gone keeps supervising, so Restart Console still relaunches', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
|
||||
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(child.pid, 'SIGTERM'); } catch {} });
|
||||
const exit = new Promise(resolve => child.once('exit', code => resolve(code)));
|
||||
let stderr = ''; child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
child.stdout.destroy();
|
||||
let status = consoleStatus(ctx);
|
||||
for (const deadline = Date.now() + 30000; !status.running; status = consoleStatus(ctx)) {
|
||||
if (Date.now() > deadline) assert.fail(`the console did not start: ${stderr}`);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
// The reply follows the state write; give it time to meet the closed pipe.
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
assert.equal(child.exitCode, null, stderr);
|
||||
assert.equal(Number(statOf(status.pid)[1]), child.pid);
|
||||
const restarted = await consoleRestart(ctx);
|
||||
assert.equal(restarted.outcome, 'restarted');
|
||||
const after = await relaunchedState(ctx, status.pid);
|
||||
assert.notEqual(after.pid, status.pid); assert.equal(after.url, status.url);
|
||||
assert.equal(Number(statOf(after.pid)[1]), child.pid);
|
||||
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: after.pid });
|
||||
assert.equal(await exit, 0);
|
||||
assert.doesNotMatch(stderr, /EPIPE|Unhandled/);
|
||||
});
|
||||
|
||||
// D1 fix round (security review P2): a stop signal while the supervisor holds the start lock is kept
|
||||
// until the start settles; then the Console stops, the lock is released and the supervisor exits.
|
||||
test('a supervisor signalled during its start stops the console it started and releases the start lock', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
|
||||
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
|
||||
const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal })));
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
||||
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
const lock = join(ctx.dataRoot, 'console/start.lock');
|
||||
for (const deadline = Date.now() + 20000; !existsSync(lock); await new Promise(r => setTimeout(r, 2))) {
|
||||
if (Date.now() > deadline) assert.fail(`the supervisor never took the start lock: ${stderr}`);
|
||||
}
|
||||
process.kill(child.pid, 'SIGTERM');
|
||||
assert.deepEqual(await exit, { code: 0, signal: null }, stderr);
|
||||
assert.equal(existsSync(lock), false);
|
||||
// As after a foreground stop by signal, the state file stays and reads stale, not running.
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
// The started Console was this supervisor's child, and it is gone.
|
||||
const reply = JSON.parse(stdout.trim().split('\n')[0]);
|
||||
assert.equal(existsSync(`/proc/${reply.receipt.pid}`), false);
|
||||
});
|
||||
|
||||
// D1 fix round (security review P1): an agent run never starts a dev-local-owner Console.
|
||||
test('an agent run cannot start a dev-local-owner console; a start without it is unchanged', { timeout: 60000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const agent = { ...ctx, env: { ...ctx.env, MOSAIC_RUN_ID: 'run-1' } };
|
||||
for (const detach of [true, false]) {
|
||||
await assert.rejects(consoleStart(agent, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /only from a human shell/.test(e.message));
|
||||
}
|
||||
// A clean environment given for the Console does not hide this process's own marker (re-check P1).
|
||||
process.env.MOSAIC_RUN_ID = 'run-2';
|
||||
try {
|
||||
for (const detach of [true, false]) {
|
||||
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message));
|
||||
}
|
||||
} finally { delete process.env.MOSAIC_RUN_ID; }
|
||||
const agentCli = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env: { ...ctx.env, MOSAIC_AGENT_RUN: '1' }, encoding: 'utf8', timeout: 30000 });
|
||||
const refused = agentCli(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
|
||||
assert.equal(refused.status, 3); assert.equal(refused.stdout, ''); assert.match(refused.stderr, /only from a human shell/);
|
||||
const consoleDir = join(ctx.dataRoot, 'console');
|
||||
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
const plain = agentCli(['console', 'start', '--detach', '--port', 'auto']);
|
||||
assert.equal(plain.status, 0, plain.stderr);
|
||||
assert.deepEqual(Object.keys(JSON.parse(plain.stdout)), ['url', 'pid']);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
|
||||
// D1 security re-check P2: a supervisor that hangs holding the start lock is killed with its group
|
||||
// after the 60 s start timeout and 10 s grace. Its lock names it, so the start clears it and the
|
||||
// next start proceeds. The hook blocks the supervisor's event loop once it holds the lock (at its
|
||||
// console state read), so its stop handler never runs and only the group SIGKILL ends it.
|
||||
test('a detached start that times out clears the start lock of the supervisor it killed', { timeout: 150000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const hook = join(dirname(ctx.dataRoot), 'hang-after-lock.mjs');
|
||||
writeFileSync(hook, `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
||||
if (process.argv[1]?.endsWith('console-supervisor.mjs')) { const real = fs.openSync;
|
||||
fs.openSync = (...args) => { if (String(args[0]).endsWith('/console/console.json')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); return real(...args); };
|
||||
syncBuiltinESMExports(); }\n`);
|
||||
const hung = { ...ctx, env: { ...ctx.env, NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` } };
|
||||
const lock = join(ctx.dataRoot, 'console/start.lock');
|
||||
const began = Date.now();
|
||||
await assert.rejects(consoleStart(hung, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }),
|
||||
e => e.exitCode === 3 && /^console failed to start; see console\/supervisor-\d+-\d+\.log under the data root$/.test(e.message));
|
||||
assert.ok(Date.now() - began >= 69000, 'the start waited out its timeout and grace');
|
||||
assert.equal(existsSync(lock), false);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
// The next start is not blocked by the killed supervisor's lock.
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
});
|
||||
|
||||
// Without --dev-local-owner a detached start is what it was before D1: no supervisor, restart refused.
|
||||
test('a detached console without the dev owner has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { detach: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(Object.keys(result), ['url', 'pid']);
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!argvOf(result.pid).includes('--supervised'));
|
||||
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha']);
|
||||
assert.equal(readdirSync(join(ctx.dataRoot, 'console')).filter(x => x.startsWith('supervisor-')).length, 0);
|
||||
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
|
||||
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
|
||||
// Without a dev owner there is no owner session to restart as: refused before the supervisor check.
|
||||
assert.equal(r.status, 403, JSON.stringify(r.body)); assert.deepEqual(r.body, { error: 'owner-only' });
|
||||
assert.equal((await call(port, '/api/config')).status, 200);
|
||||
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
// The CLI's receipt keeps its two fields.
|
||||
const start = mosaic(['console', 'start', '--detach', '--port', 'auto']);
|
||||
assert.equal(start.status, 0, start.stderr);
|
||||
assert.deepEqual(Object.keys(JSON.parse(start.stdout)), ['url', 'pid']);
|
||||
assert.equal(start.stderr, '');
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copy credential bindings, slices CB0 and CB1 (#1873; design
|
||||
// docs/plans/2026-11-11_copy-credential-binding-design.md sections 3, 5 and 7).
|
||||
//
|
||||
// An organization binds a service to one concrete copy (a root agent's copy or a project agent's
|
||||
// copy) through a record under the organization file's top-level `copyBindings`, keyed by the
|
||||
// copy's instance name. A record holds identities, known scope names, project ids and integers:
|
||||
// never a token, a token fragment or digest, a path or an environment name.
|
||||
//
|
||||
// { copyId, orgIncarnation, source: { level, name, project }, definition: { ref, rev, sha256 },
|
||||
// generation, state, services: { gitea?: { slot, scopes, resources }, vikunja?: {...} },
|
||||
// decision, tracker? }
|
||||
//
|
||||
// copyId is 128 random bits minted on the first approval for that copy; it is kept, with the
|
||||
// generation counting up, through every later change and the unbound tombstone. orgIncarnation is
|
||||
// the registry's (org-registry.mjs): a record copied from another organization, or from a removed
|
||||
// organization with the same id, never matches. `source` is the copy's level, name and project, so
|
||||
// a dotted-name collision or a reused source name never inherits a record. `definition` pins the
|
||||
// copy's definition identity: an org definition by its rev and the hash of its whole entry
|
||||
// (composition, instructions and personality), a role template by the hash readTemplate takes over
|
||||
// its file and contract.
|
||||
//
|
||||
// States: `pending` is approved and not verified. Only a verifier (slice CB2, not built) may turn a
|
||||
// record `bound`, and only `bound` resolves. `unbound` is the tombstone an unbind leaves. Nothing
|
||||
// in CB1 writes `bound`, so no copy binding resolves and no copy launches with one.
|
||||
//
|
||||
// Everything here is pure: no file is read, no secret, no slot.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { GITEA_SCOPE_CATEGORIES, HOST_SERVICE, ID_PATTERN, SERVICES, VIKUNJA_GRANTABLE } from "./vocabulary.mjs";
|
||||
import { ORG_PREFIX } from "./definitions.mjs";
|
||||
import { canonicalJson, isPlainObject, sha256 } from "./util.mjs";
|
||||
|
||||
export const COPY_ID = /^[0-9a-f]{32}$/;
|
||||
export const INCARNATION = /^[0-9a-f]{32}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
||||
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker"];
|
||||
const SERVICE_KEYS = ["slot", "scopes", "resources"];
|
||||
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound"]);
|
||||
// CB1 binds the core services (vocabulary SERVICES) only. A plugin service has no verifier design yet.
|
||||
export const BINDABLE_SERVICES = SERVICES;
|
||||
export const MAX_BINDINGS = 256;
|
||||
|
||||
export const newCopyId = () => randomBytes(16).toString("hex");
|
||||
|
||||
// Fixed text for every binding refusal. `service` is a registered service label or null, never a
|
||||
// submitted value.
|
||||
const TEXT = {
|
||||
"human-required": () => "Only the organization owner can approve credential binding.",
|
||||
"credential-unbound": (s) => `credentials.${s} needs an owner-approved binding.`,
|
||||
"credential-scope-exceeded": (s) => `credentials.${s}.scopes exceeds the approved role.`,
|
||||
"credential-scope-unverified": (s) => `credentials.${s} permissions could not be verified.`,
|
||||
"credential-binding-changed": (s) => `credentials.${s} changed; read and approve again.`,
|
||||
"credential-role-changed": (s) => `credentials.${s} no longer matches the approved definition; the owner must review the binding.`,
|
||||
"copy-unavailable": () => "copy is no longer active in this organization.",
|
||||
"copy-refused": () => "copy identity is ambiguous; the owner must resolve the definition collision.",
|
||||
};
|
||||
const label = (service) => (BINDABLE_SERVICES.includes(service) ? service : "service");
|
||||
export function bindingText(code, service = null) {
|
||||
const text = TEXT[code];
|
||||
return text ? text(label(service)) : code;
|
||||
}
|
||||
|
||||
export class CopyBindingError extends Error {
|
||||
constructor(code, service = null) {
|
||||
super(bindingText(code, service));
|
||||
this.name = "CopyBindingError";
|
||||
this.code = code;
|
||||
if (service !== null) this.service = label(service);
|
||||
}
|
||||
}
|
||||
export const bindingFail = (code, service = null) => { throw new CopyBindingError(code, service); };
|
||||
|
||||
// ---- Definition pin ----
|
||||
|
||||
// The identity a binding pins for a copy's definition. `definitions` is the merged root and org
|
||||
// definition map as the boot snapshot holds it; `readTemplate` reads a role template (definitions.mjs).
|
||||
// An org definition: its rev and the hash of its canonical entry. A template: rev null and the
|
||||
// template hash. A version 1 template has no contract and cannot be pinned: null.
|
||||
export function definitionPin(ref, { definitions = {}, rolesDir, readTemplate }) {
|
||||
if (typeof ref !== "string") return null;
|
||||
if (ref.startsWith(ORG_PREFIX)) {
|
||||
const name = ref.slice(ORG_PREFIX.length);
|
||||
const entry = Object.hasOwn(definitions, name) ? definitions[name] : null;
|
||||
if (!isPlainObject(entry) || !Number.isSafeInteger(entry.rev) || entry.rev < 1) return null;
|
||||
return { ref, rev: entry.rev, sha256: sha256(canonicalJson(entry)) };
|
||||
}
|
||||
if (!ID_PATTERN.test(ref) || typeof readTemplate !== "function") return null;
|
||||
try {
|
||||
return { ref, rev: null, sha256: readTemplate(rolesDir, ref).sha256 };
|
||||
} catch (error) {
|
||||
if (error?.name === "BusinessError") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const samePin = (a, b) => isPlainObject(a) && isPlainObject(b) && a.ref === b.ref && a.rev === b.rev && a.sha256 === b.sha256;
|
||||
|
||||
// ---- Scope semantics ----
|
||||
|
||||
// Gitea: {category: "read"|"write"}, one level per category. admin and all are never granted.
|
||||
function giteaLevels(scopes) {
|
||||
if (!Array.isArray(scopes)) return null;
|
||||
const out = {};
|
||||
for (const scope of scopes) {
|
||||
const m = typeof scope === "string" ? GITEA_SCOPE.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2]) || Object.hasOwn(out, m[2])) return null;
|
||||
out[m[2]] = m[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const RANK = { read: 1, write: 2 };
|
||||
// Vikunja: the set of "group verb" pairs, each one VIKUNJA_GRANTABLE lists.
|
||||
function vikunjaPairs(scopes) {
|
||||
if (!isPlainObject(scopes)) return null;
|
||||
const out = new Set();
|
||||
for (const [group, verbs] of Object.entries(scopes)) {
|
||||
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group) || !Array.isArray(verbs) || verbs.length === 0) return null;
|
||||
for (const verb of verbs) {
|
||||
if (typeof verb !== "string" || !VIKUNJA_GRANTABLE[group].includes(verb) || out.has(`${group} ${verb}`)) return null;
|
||||
out.add(`${group} ${verb}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function validScopes(service, scopes) {
|
||||
if (service === "gitea") { const l = giteaLevels(scopes); return l !== null && Object.keys(l).length > 0; }
|
||||
if (service === "vikunja") { const p = vikunjaPairs(scopes); return p !== null && p.size > 0; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether `approved` is within `role` for the service, compared by the service's semantics: Gitea
|
||||
// read is narrower than write in one category and categories are independent; Vikunja compares
|
||||
// each group and verb pair. A missing category or group is no permission. Never a string compare.
|
||||
export function scopesWithin(service, approved, role) {
|
||||
if (service === "gitea") {
|
||||
const a = giteaLevels(approved), r = giteaLevels(role);
|
||||
if (!a || !r) return false;
|
||||
return Object.entries(a).every(([category, level]) => Object.hasOwn(r, category) && RANK[level] <= RANK[r[category]]);
|
||||
}
|
||||
if (service === "vikunja") {
|
||||
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
|
||||
if (!a || !r) return false;
|
||||
return [...a].every((pair) => r.has(pair));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// effective(V) = role-declared(V) intersect approved(V), as scopes of the service's own shape.
|
||||
// Null when the intersection is empty.
|
||||
export function intersectScopes(service, approved, role) {
|
||||
if (service === "gitea") {
|
||||
const a = giteaLevels(approved), r = giteaLevels(role);
|
||||
if (!a || !r) return null;
|
||||
const out = Object.keys(a).filter((c) => Object.hasOwn(r, c)).sort()
|
||||
.map((c) => `${RANK[a[c]] <= RANK[r[c]] ? a[c] : r[c]}:${c}`);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
if (service === "vikunja") {
|
||||
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
|
||||
if (!a || !r) return null;
|
||||
const out = {};
|
||||
for (const pair of [...a].filter((p) => r.has(p)).sort()) {
|
||||
const [group, verb] = pair.split(" ");
|
||||
(out[group] ??= []).push(verb);
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The role's declared scopes per bindable service. A host service is bound per host elsewhere and
|
||||
// is not a copy binding; a service outside the core pair cannot be bound in CB1.
|
||||
export function roleServices(role) {
|
||||
const out = {};
|
||||
for (const c of role?.credentials ?? []) {
|
||||
if (c.service === HOST_SERVICE) continue;
|
||||
out[c.service] = c.scopes;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Record schema ----
|
||||
|
||||
// The positional refusal for record `i` (1-based): it never names a submitted key or value.
|
||||
const at = (i, field) => `copyBindings entry ${i} ${field} field is invalid`;
|
||||
|
||||
// Checks the whole `copyBindings` value of an organization document. `trackers` is the list of
|
||||
// {bot, botId} the document's instances and its tracker sync already use; a copy's tracker bot is
|
||||
// unique against them and against other copies. Throws a BusinessError with a positional message.
|
||||
export function validateCopyBindings(value, { trackers = [] } = {}) {
|
||||
if (value === undefined) return;
|
||||
if (!isPlainObject(value)) refuse("copyBindings field is invalid");
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > MAX_BINDINGS) refuse("copyBindings field is invalid");
|
||||
const bots = new Set(trackers.map((t) => t.bot)), botIds = new Set(trackers.map((t) => t.botId)), copyIds = new Set();
|
||||
keys.forEach((instance, n) => {
|
||||
const i = n + 1, r = value[instance];
|
||||
if (!ID_PATTERN.test(instance)) refuse(`copyBindings entry ${i} name is invalid`);
|
||||
if (!isPlainObject(r)) refuse(`copyBindings entry ${i} is invalid`);
|
||||
const unknown = Object.keys(r).some((k) => !RECORD_KEYS.includes(k));
|
||||
if (unknown) refuse(`copyBindings entry ${i} has an unknown field`);
|
||||
if (typeof r.copyId !== "string" || !COPY_ID.test(r.copyId)) refuse(at(i, "copyId"));
|
||||
if (copyIds.has(r.copyId)) refuse(at(i, "copyId"));
|
||||
copyIds.add(r.copyId);
|
||||
if (typeof r.orgIncarnation !== "string" || !INCARNATION.test(r.orgIncarnation)) refuse(at(i, "orgIncarnation"));
|
||||
const s = r.source;
|
||||
if (!isPlainObject(s) || Object.keys(s).sort().join(",") !== "level,name,project"
|
||||
|| !["root", "project"].includes(s.level) || typeof s.name !== "string" || !ID_PATTERN.test(s.name)
|
||||
|| (s.level === "root" ? s.project !== null : typeof s.project !== "string" || !ID_PATTERN.test(s.project))
|
||||
|| (s.level === "root" ? s.name : `${s.name}.${s.project}`) !== instance) refuse(at(i, "source"));
|
||||
const d = r.definition;
|
||||
if (!isPlainObject(d) || Object.keys(d).sort().join(",") !== "ref,rev,sha256" || typeof d.ref !== "string"
|
||||
|| typeof d.sha256 !== "string" || !SHA256.test(d.sha256)
|
||||
|| (d.ref.startsWith(ORG_PREFIX)
|
||||
? !ID_PATTERN.test(d.ref.slice(ORG_PREFIX.length)) || !Number.isSafeInteger(d.rev) || d.rev < 1
|
||||
: !ID_PATTERN.test(d.ref) || d.rev !== null)) refuse(at(i, "definition"));
|
||||
if (!Number.isSafeInteger(r.generation) || r.generation < 1) refuse(at(i, "generation"));
|
||||
if (!BINDING_STATES.includes(r.state)) refuse(at(i, "state"));
|
||||
if (typeof r.decision !== "string" || !UUID.test(r.decision)) refuse(at(i, "decision"));
|
||||
if (!isPlainObject(r.services)) refuse(at(i, "services"));
|
||||
const services = Object.keys(r.services);
|
||||
if ((r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
|
||||
for (const service of services) {
|
||||
const v = r.services[service];
|
||||
if (!BINDABLE_SERVICES.includes(service) || !isPlainObject(v) || Object.keys(v).sort().join(",") !== SERVICE_KEYS.slice().sort().join(",")
|
||||
|| v.slot !== "self" || !validScopes(service, v.scopes)) refuse(at(i, "services"));
|
||||
const p = v.resources;
|
||||
if (!isPlainObject(p) || Object.keys(p).join(",") !== "projects" || !Array.isArray(p.projects) || p.projects.length === 0
|
||||
|| p.projects.some((x) => typeof x !== "string" || !ID_PATTERN.test(x)) || new Set(p.projects).size !== p.projects.length
|
||||
|| (s.level === "project" && (p.projects.length !== 1 || p.projects[0] !== s.project))) refuse(at(i, "services"));
|
||||
}
|
||||
if (r.tracker !== undefined) {
|
||||
const t = r.tracker;
|
||||
if (!services.includes("vikunja") || !isPlainObject(t) || Object.keys(t).sort().join(",") !== "bot,botId"
|
||||
|| typeof t.bot !== "string" || !BOT_NAME.test(t.bot) || !Number.isSafeInteger(t.botId) || t.botId < 1) refuse(at(i, "tracker"));
|
||||
if (bots.has(t.bot) || botIds.has(t.botId)) refuse(`copyBindings entry ${i} tracker field uses a bot another instance uses`);
|
||||
bots.add(t.bot);
|
||||
botIds.add(t.botId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Resolver (CB0) ----
|
||||
|
||||
// What a copy's binding grants in this organization, from plain inputs only:
|
||||
// orgId, incarnation the organization and its live registry incarnation (null: none)
|
||||
// snapshot the boot snapshot materializeCopies made for orgId
|
||||
// instance the copy's instance name
|
||||
// record copyBindings[instance] from the organization document, or undefined
|
||||
// pin definitionPin of the copy's current definition (null: cannot pin)
|
||||
// role the copy's current resolved role ({credentials: [...]})
|
||||
// projects the project ids that admit this copy now: for a root copy, the projects
|
||||
// whose union holds it (an excluded or refusing project is left out), not
|
||||
// the organization's whole project list
|
||||
// Returns { ok: true, copyId, generation, services: { V: { scopes, projects } } } when every
|
||||
// service the role needs is bound, or { ok: false, code, service } with the first refusal. Only a
|
||||
// `bound` record resolves; pending, partial, tombstoned, foreign and stale records never do.
|
||||
export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, record, pin, role, projects = [] }) {
|
||||
const no = (code, service = null) => ({ ok: false, code, service: service === null ? null : label(service) });
|
||||
if (!snapshot || snapshot.org !== orgId) return no("copy-unavailable");
|
||||
const copy = Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
// An org instance of the same name shadows the copy, and a removed or excluded source makes none.
|
||||
if (!copy) return no("copy-unavailable");
|
||||
if (copy.state === "refused") return no(copy.claims ? "copy-refused" : "copy-unavailable");
|
||||
const needed = roleServices(role);
|
||||
const first = Object.keys(needed).sort()[0] ?? null;
|
||||
if (record === undefined || record === null) return no("credential-unbound", first);
|
||||
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || record.orgIncarnation !== incarnation) return no("credential-unbound", first);
|
||||
const source = { level: copy.level, name: copy.name, project: copy.project };
|
||||
if (canonicalJson(record.source) !== canonicalJson(source)) return no("credential-unbound", first);
|
||||
if (!pin || !samePin(record.definition, pin) || record.definition.ref !== copy.definition) return no("credential-role-changed", first);
|
||||
if (record.state !== "bound") return no("credential-unbound", first);
|
||||
const approved = record.services ?? {};
|
||||
for (const service of Object.keys(approved).sort()) {
|
||||
if (!Object.hasOwn(needed, service)) return no("credential-scope-exceeded", service);
|
||||
if (!scopesWithin(service, approved[service].scopes, needed[service])) return no("credential-scope-exceeded", service);
|
||||
}
|
||||
const admitted = new Set(copy.level === "project" ? (projects.includes(copy.project) ? [copy.project] : []) : projects);
|
||||
const services = {};
|
||||
for (const service of Object.keys(needed).sort()) {
|
||||
if (!BINDABLE_SERVICES.includes(service) || !Object.hasOwn(approved, service)) return no("credential-unbound", service);
|
||||
const scopes = intersectScopes(service, approved[service].scopes, needed[service]);
|
||||
const reach = approved[service].resources.projects.filter((p) => admitted.has(p)).sort();
|
||||
if (!scopes || reach.length === 0) return no("credential-unbound", service);
|
||||
services[service] = { scopes, projects: reach };
|
||||
}
|
||||
if (!Object.keys(services).length) return no("credential-unbound", first);
|
||||
return { ok: true, copyId: record.copyId, generation: record.generation, services };
|
||||
}
|
||||
|
||||
// ---- Proposal checks (CB1) ----
|
||||
|
||||
// The binding payload a chief proposes: { service: { scopes, projects } } for a bind, or null for
|
||||
// an unbind. Each service must be one the copy's role declares, its scopes within the role and its
|
||||
// projects admitted for the copy (`projects`: for a root copy, the projects whose union holds it).
|
||||
// Never trims a broader request. Returns the normalized services.
|
||||
export function checkBindingRequest(services, { copy, role, projects }) {
|
||||
if (services === null) return null;
|
||||
if (!isPlainObject(services) || Object.keys(services).length === 0) refuse("binding services field is invalid");
|
||||
const needed = roleServices(role);
|
||||
const out = {};
|
||||
for (const service of Object.keys(services).sort()) {
|
||||
if (!BINDABLE_SERVICES.includes(service)) refuse("binding services field is invalid");
|
||||
const v = services[service];
|
||||
if (!isPlainObject(v) || Object.keys(v).sort().join(",") !== "projects,scopes") refuse("binding services field is invalid");
|
||||
if (!validScopes(service, v.scopes)) refuse("binding scopes field is invalid");
|
||||
if (!Object.hasOwn(needed, service) || !scopesWithin(service, v.scopes, needed[service])) bindingFail("credential-scope-exceeded", service);
|
||||
if (!Array.isArray(v.projects) || v.projects.length === 0 || v.projects.some((p) => typeof p !== "string" || !ID_PATTERN.test(p))
|
||||
|| new Set(v.projects).size !== v.projects.length) refuse("binding projects field is invalid");
|
||||
const allowed = copy.level === "project" ? [copy.project] : projects;
|
||||
if (v.projects.some((p) => !allowed.includes(p))) bindingFail("credential-scope-exceeded", service);
|
||||
out[service] = { slot: "self", scopes: structuredClone(v.scopes), resources: { projects: [...v.projects].sort() } };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -13,14 +13,29 @@ import { OrganizationError } from './organization.mjs';
|
||||
import { resolveInstance } from './resolve.mjs';
|
||||
import { checkRoutes } from './launch-settings.mjs';
|
||||
import { VERB_RIGHTS } from './org-rights.mjs';
|
||||
import { readRegistry, withRegistryWriter } from './org-registry.mjs';
|
||||
import { ensureOrgIncarnation, orgIncarnation, readRegistry, withRegistryWriter } from './org-registry.mjs';
|
||||
import { checkDefinitions } from './definitions.mjs';
|
||||
import { loadRole } from './role.mjs';
|
||||
import { copyInstanceName } from './agent-copies.mjs';
|
||||
import { resolveAgentLevels } from './agent-levels.mjs';
|
||||
import { bindingFail, checkBindingRequest, definitionPin, newCopyId, samePin } from './copy-bindings.mjs';
|
||||
const fail = code => { throw new OrganizationError(code); };
|
||||
const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex');
|
||||
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
||||
const keys = (x, allowed, required = []) => { if (!isPlainObject(x) || Object.keys(x).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(x,k))) fail('invalid-request'); };
|
||||
const id = x => { if (typeof x !== 'string' || !ID_PATTERN.test(x) || ['human','tester','user'].includes(x)) fail('invalid-request'); };
|
||||
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason'];
|
||||
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason','binding'];
|
||||
// #1873 CB1: a copy credential binding (bind or unbind) is owner-only. No agent resolves it, whatever
|
||||
// authority decision or resolver it carries; a person needs org.hire and must be the current owner
|
||||
// of the organization. An instance operator alone is not.
|
||||
const bindingOwnerOnly = () => fail('human-required');
|
||||
export function authorizeHire(proposal, caller) {
|
||||
if(proposal?.binding!==undefined) {
|
||||
if(!caller || caller.agent!==false)bindingOwnerOnly();
|
||||
if(!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console']))fail('right-required');
|
||||
if(caller.owner!==true)bindingOwnerOnly();
|
||||
return;
|
||||
}
|
||||
if(caller?.agent===true) {
|
||||
if(typeof caller.authorityDecision!=='string' || typeof caller.resolver!=='string')fail('human-required');
|
||||
if(caller.authorityDecision!==proposal.id || !caller.rights?.includes('decision.resolve.authority') || proposal.flagged.length || proposal.credentialLines.length || proposal.level==='root' || caller.resolver===proposal.name)fail('hire-owner-required');
|
||||
@@ -38,12 +53,16 @@ export function credentialLines(credentials) {
|
||||
const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p.request.level, name:p.request.name,
|
||||
...(p.request.project === undefined ? {} : {project:p.request.project}), route:p.request.route ?? null,
|
||||
...(p.entry ? {role:structuredClone(p.entry.role)} : {}),
|
||||
// #1873 CB1: ids, scope names, project ids and integers. Never a token, a slot path or a file path.
|
||||
...(p.kind === 'binding' ? {binding:structuredClone(p.binding)} : {}),
|
||||
// reason and all free text stay in the private proposal as well.
|
||||
flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) });
|
||||
// org-defscan (#1873): `scanDefinition(name, entry)` is the bus's definition check on the approved
|
||||
// entry; it throws (definition-holds-credential, naming the definition and key) when the live store
|
||||
// holds text in it. Without one, the entry still goes through assertClean whole.
|
||||
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
|
||||
// `copies(business)` (#1873 CB1) is the bus's boot snapshot of this organization's copies
|
||||
// (agent-copies.mjs materializeCopies). A bus without one refuses every binding: copy-unavailable.
|
||||
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry(), copies = () => null }) {
|
||||
const scanEntry = (name, entry) => {
|
||||
if (typeof scanDefinition === 'function') return scanDefinition(name, structuredClone(entry));
|
||||
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
|
||||
@@ -83,12 +102,97 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
}
|
||||
const location = value => { if (!UUID.test(value)) fail('invalid-request'); return join(home,`${value}.json`); };
|
||||
const read = value => { try { return readJsonFile(location(value),'private hire proposal',st=>{if(st.uid!==process.getuid() || st.mode&0o077 || st.nlink!==1)fail('decision-stale');}); } catch { fail('decision-stale'); } };
|
||||
// ---- #1873 CB1: copy credential binding subtype ----
|
||||
// The copy, its pinned definition and role, as this bus booted them and the file holds them now.
|
||||
// Every refusal is a fixed code; nothing names a token, a slot or a path.
|
||||
const bindingFacts = (business, request, doc) => {
|
||||
const snapshot = copies(business);
|
||||
const project = request.level === 'project' ? request.project : null;
|
||||
const instance = copyInstanceName(request.name, project);
|
||||
const copy = instance !== null && snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
if (!copy || copy.name !== request.name || copy.project !== project || copy.level !== request.level) fail('copy-unavailable');
|
||||
if (copy.state === 'refused') fail(copy.claims ? 'copy-refused' : 'copy-unavailable');
|
||||
// A root copy is only as fresh as its root receipts: removed, replaced or unreadable since boot refuses.
|
||||
if (copy.level === 'root') { const stale = rootRefusal(copy.name); if (stale) fail(stale); }
|
||||
// A named instance added since boot shadows the copy (design section 6).
|
||||
if (Object.hasOwn(doc.roles ?? {}, instance)) fail('copy-unavailable');
|
||||
const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) };
|
||||
const pin = definitionPin(copy.definition, { definitions, rolesDir, readTemplate });
|
||||
if (!pin) fail('credential-definition-unpinned');
|
||||
const role = copy.definition.startsWith('org:') ? checkDefinitions(definitions, 'copy definitions')[copy.definition] : loadRole(rolesDir, copy.definition);
|
||||
if (!role) fail('credential-definition-unpinned');
|
||||
// The projects that admit this copy now. A root copy reaches only the projects whose union,
|
||||
// recomputed from this document and the boot's root agents, still holds it: an exclude entry or a
|
||||
// refusing union bars it, and a request naming that project refuses (credential-scope-exceeded).
|
||||
const code = Object.keys(doc.projects ?? {}).filter(p => doc.projects[p]?.kind !== 'library');
|
||||
let projects = code;
|
||||
if (copy.level === 'root') {
|
||||
let levels;
|
||||
try { levels = resolveAgentLevels({ orgId: business, doc: { ...doc, definitions }, root: { agents: snapshot.rootAgents ?? {} }, rolesDir }); }
|
||||
catch (error) { if (error?.name === 'BusinessError') fail('copy-unavailable'); throw error; }
|
||||
projects = code.filter(p => levels.unions[p]?.ok && levels.unions[p].agents.some(a => a.level === 'root' && a.name === copy.name));
|
||||
}
|
||||
return { instance, copy, pin, role, definitions, projects, source: { level: copy.level, name: copy.name, project } };
|
||||
};
|
||||
const bindingLines = services => credentialLines(Object.entries(services ?? {}).map(([service, v]) => ({ service, scopes: v.scopes })));
|
||||
async function prepareBinding(business, input, { id: decision, expiresAt }) {
|
||||
if (input.op !== 'change' || !['root','project'].includes(input.level) || ['role','route','credentials','personality'].some(k => k in input)) fail('invalid-request');
|
||||
if (input.level === 'project') id(input.project); else if ('project' in input) fail('invalid-request');
|
||||
keys(input.binding, ['services'], ['services']);
|
||||
const { document: doc, revision } = writer.read(business);
|
||||
const facts = bindingFacts(business, input, doc);
|
||||
let after;
|
||||
try { after = checkBindingRequest(input.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
|
||||
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
|
||||
const current = doc.copyBindings?.[facts.instance] ?? null;
|
||||
if (current && canonicalJson(current.source) !== canonicalJson(facts.source)) fail('credential-binding-changed');
|
||||
if (after === null && (!current || current.state === 'unbound')) fail('credential-unbound');
|
||||
// An org reserved before CB0 gets its incarnation here, under the registry lock.
|
||||
const incarnation = ensureOrgIncarnation(configDir, business);
|
||||
if (incarnation === null) fail('copy-unavailable');
|
||||
if (current && current.orgIncarnation !== incarnation) fail('credential-binding-changed');
|
||||
const binding = {
|
||||
instance: facts.instance, source: facts.source, copyId: current?.copyId ?? newCopyId(), fresh: !current, orgIncarnation: incarnation,
|
||||
definition: facts.pin, before: current && current.state !== 'unbound' ? structuredClone(current.services) : null, after, generation: current?.generation ?? 0,
|
||||
};
|
||||
// Only the structured fields are kept. The free-text reason is never written to the proposal or
|
||||
// covered by its digest: a chief may paste a path or a token fragment there by mistake.
|
||||
const request = { op: input.op, level: input.level, name: input.name, ...(input.level === 'project' ? { project: input.project } : {}), binding: { services: structuredClone(input.binding.services) } };
|
||||
const p = { id: decision, business, kind: 'binding', prior: null, revision, rootRevision: null, request, entry: null, definition: null,
|
||||
binding, flagged: [], credentialLines: bindingLines(after ?? binding.before), expiresAt };
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
const st = lstatSync(home); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || st.mode & 0o077) fail('organization-refused');
|
||||
const fd = openSync(location(decision), 'wx', 0o600);
|
||||
try { writeFileSync(fd, JSON.stringify(p)); fsyncSync(fd); } finally { closeSync(fd); }
|
||||
return publicOf(p);
|
||||
}
|
||||
// Everything the approval pinned, read again before any effect: the file revision, the copy, its
|
||||
// definition, the registry incarnation, the generation and the requested scopes.
|
||||
async function checkBinding(business, p) {
|
||||
const { document: doc, revision } = writer.read(business);
|
||||
if (revision !== p.revision) fail('decision-stale');
|
||||
const facts = bindingFacts(business, p.request, doc);
|
||||
const b = p.binding;
|
||||
if (facts.instance !== b.instance || canonicalJson(facts.source) !== canonicalJson(b.source)) fail('credential-binding-changed');
|
||||
if (!samePin(facts.pin, b.definition)) fail('credential-role-changed');
|
||||
if (orgIncarnation(await readRegistry({ configDir }), business) !== b.orgIncarnation) fail('decision-stale');
|
||||
const current = doc.copyBindings?.[b.instance] ?? null;
|
||||
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : !b.fresh)) fail('credential-binding-changed');
|
||||
if (b.after !== null) {
|
||||
let after;
|
||||
try { after = checkBindingRequest(p.request.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
|
||||
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
|
||||
if (canonicalJson(after) !== canonicalJson(b.after)) fail('decision-stale');
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
async function prepare(business, input, { id: decision, expiresAt }) {
|
||||
keys(input,requestKeys,['op','level','name','reason']); id(input.name);
|
||||
if (!['hire','fire','change'].includes(input.op) || !['org','project','root'].includes(input.level)) fail('invalid-request');
|
||||
if (typeof input.reason!=='string' || input.reason.trim().length===0 || input.reason.length>500) fail('invalid-request');
|
||||
if (typeof assertClean!=='function') fail('definition-secret-check-required');
|
||||
assertClean(input);
|
||||
if ('binding' in input) return prepareBinding(business, input, { id: decision, expiresAt });
|
||||
if (input.level==='project') id(input.project); else if ('project' in input) fail('invalid-request');
|
||||
if (input.op!=='fire') {
|
||||
keys(input.route,['harness','model','thinking'],['harness','model']);
|
||||
@@ -169,6 +273,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
// issued after the proposal refuses it here, before preview, check or apply goes further.
|
||||
if(p.entry)scanEntry(p.definition,p.entry);
|
||||
if(adjust===undefined)return p;
|
||||
// A binding is never adjusted: narrowing or replacing it is a new decision (design section 4).
|
||||
if(p.kind==='binding')fail('invalid-request');
|
||||
keys(adjust,['role','credentials','route']);
|
||||
if(!p.entry)fail('invalid-request');
|
||||
assertClean(adjust);
|
||||
@@ -212,6 +318,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
if (p.business!==business || caller.digest!==view.digest)fail('decision-stale');
|
||||
if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');
|
||||
authorizeHire(view,caller);
|
||||
if(p.kind==='binding'){await checkBinding(business,p);return p;}
|
||||
if(writer.read(business).revision!==p.revision)fail('decision-stale');
|
||||
// Re-run composition against current policy and scan private text again before any effect.
|
||||
if(p.entry) {
|
||||
@@ -229,6 +336,14 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
}
|
||||
async function apply(business, decision, caller) {
|
||||
const p=await check(business,decision,caller);
|
||||
if(p.kind==='binding') {
|
||||
const facts=await checkBinding(business,p);
|
||||
// A root copy's receipts are read again under the organization lock, before the write and
|
||||
// before publication, so a removal or replacement after checkBinding still refuses.
|
||||
const fresh=()=>{if(facts.copy.level==='root'){const stale=rootRefusal(facts.copy.name);if(stale)fail(stale);}};
|
||||
return writer.applyCopyBindingDecision(business,{...p,digest:publicOf(p).digest},{actor:caller.actor,owner:caller.owner===true,definitions:facts.definitions,
|
||||
beforeWrite:()=>{fresh();caller.beforeWrite?.();},beforePublish:()=>{fresh();caller.beforePublish?.();}});
|
||||
}
|
||||
const r=p.request;
|
||||
const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {};
|
||||
const currentDoc=r.level==='root'?(await readRootAgentsFile(configDir)).document:writer.read(business).document;
|
||||
@@ -248,7 +363,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
|
||||
}
|
||||
async function reverse(business,decision,caller) {
|
||||
const p=read(decision);if(p.business!==business || !p.prior || p.request.level==='root')fail('decision-stale');
|
||||
const p=read(decision);if(p.business!==business || p.kind==='binding' || !p.prior || p.request.level==='root')fail('decision-stale');
|
||||
const r=p.request,old=writer.read(business),current=r.level==='org'?old.document.roles[r.name]:old.document.projects[r.project]?.agents?.[r.name];
|
||||
// A later approved change must not be overwritten by an earlier inverse.
|
||||
if(r.op==='fire') {
|
||||
|
||||
@@ -50,9 +50,19 @@ async function readPrivate(file) {
|
||||
async function privateDir(path, what = 'a directory') { const st = await fs.lstat(path).catch(rethrow(what)); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
|
||||
function validateRegistry(doc, file) {
|
||||
if (!doc || doc.version !== 1 || typeof doc.instance !== 'string' || !doc.orgs || typeof doc.orgs !== 'object' || Array.isArray(doc.orgs)) fail('org-mismatch', 'invalid registry', file);
|
||||
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string') fail('org-mismatch', 'invalid registry entry', file); }
|
||||
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string' || (entry.incarnation !== undefined && (typeof entry.incarnation !== 'string' || !INCARNATION.test(entry.incarnation)))) fail('org-mismatch', 'invalid registry entry', file); }
|
||||
return doc;
|
||||
}
|
||||
// Registry incarnation (CB0, #1873): 128 random bits minted when an id is
|
||||
// reserved. A removed and recreated organization with the same id gets a new
|
||||
// value, so metadata bound to the old one never matches. An entry reserved
|
||||
// before this field existed has none; nothing binds to it (orgIncarnation
|
||||
// returns null and every binding refuses).
|
||||
export const INCARNATION = /^[0-9a-f]{32}$/;
|
||||
export function orgIncarnation(registry, id) {
|
||||
const value = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id]?.incarnation : undefined;
|
||||
return typeof value === 'string' && INCARNATION.test(value) ? value : null;
|
||||
}
|
||||
export async function readRegistry({ configDir }) {
|
||||
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
|
||||
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
|
||||
@@ -126,6 +136,17 @@ export function withRegistryWriter(configDir, fn) {
|
||||
return result;
|
||||
} finally { if (!pending) release(); }
|
||||
}
|
||||
// An active entry reserved before CB0 has no incarnation. The first binding proposal for it mints
|
||||
// one under the registry lock; no binding can exist before that, so none is orphaned. A removed and
|
||||
// recreated id is reserved afresh and gets a new value. Returns null for any entry not active.
|
||||
export function ensureOrgIncarnation(configDir, id) {
|
||||
return withRegistryWriter(configDir, (registry, publish) => {
|
||||
const entry = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id] : null;
|
||||
if (!entry || entry.state !== 'active') return null;
|
||||
if (orgIncarnation(registry, id) === null) { entry.incarnation = randomBytes(16).toString('hex'); publish(); }
|
||||
return entry.incarnation;
|
||||
});
|
||||
}
|
||||
export async function reserveOrg(ctx, id) {
|
||||
const root = orgRoot(ctx.dataRoot, id);
|
||||
return withRegistry(ctx, async registry => {
|
||||
@@ -135,7 +156,7 @@ export async function reserveOrg(ctx, id) {
|
||||
await safeDir(dirname(root), true, 'org-runtime under the data root'); await privateDir(dirname(root), 'org-runtime under the data root');
|
||||
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw quiet(e, 'the organization root'); }
|
||||
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' }).catch(rethrow('the organization marker'));
|
||||
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1 }; registry.orgs[id] = entry; return { ...entry };
|
||||
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1, incarnation: randomBytes(16).toString('hex') }; registry.orgs[id] = entry; return { ...entry };
|
||||
});
|
||||
}
|
||||
export async function resolveOrg(ctx, id) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { OrgError, orgRoot, quiet, 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';
|
||||
import { canonicalJson, 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); };
|
||||
@@ -38,6 +38,9 @@ async function receipts(root) {
|
||||
}
|
||||
function normalizeDoc(ctx, id, input, existing) {
|
||||
if (!input || input.id !== id) fail('org-invalid', 'document id must match organization id', '$.id');
|
||||
// #1873 CB1: copy bindings change only through the owner-approved binding decision's writer.
|
||||
// A create or a whole-document update that adds, edits or drops one is refused.
|
||||
if (canonicalJson(input.copyBindings ?? null) !== canonicalJson(existing?.copyBindings ?? null)) fail('organization-bindings-protected', 'copyBindings change only through an owner-approved binding decision', '$.copyBindings');
|
||||
const doc = structuredClone(input); const root = orgRoot(ctx.dataRoot, id);
|
||||
for (const [project, entry] of Object.entries(doc.projects ?? {})) {
|
||||
checkId(project); if (!entry || entry.kind === 'library') fail('org-invalid', 'configured projects must be Git projects', `$.projects.${project}`);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
import { checkAppearance } from "./appearance.mjs";
|
||||
import { checkProjectAgents } from "./agent-levels.mjs";
|
||||
import { validateCopyBindings } from "./copy-bindings.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
@@ -28,7 +29,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy", "copyBindings"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -344,6 +345,10 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
|
||||
}
|
||||
}
|
||||
// #1873 CB1: copy credential bindings. Each copy's tracker bot is unique against every named
|
||||
// instance's bot, the tracker sync bot and the other copies.
|
||||
validateCopyBindings(document.copyBindings, { trackers: [...Object.values(roles).map((r) => r.tracker).filter(Boolean), ...(tracker ? [tracker.sync] : [])] });
|
||||
const copyBindings = document.copyBindings === undefined ? undefined : structuredClone(document.copyBindings);
|
||||
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
|
||||
// REQ-PLUG-1: the module names switched on per kind. The names are checked
|
||||
// here; whether each module loads is the loader's check, at load time.
|
||||
@@ -372,6 +377,7 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
|
||||
...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
|
||||
...(copyBindings === undefined ? {} : { copyBindings }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
|
||||
import { bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -27,6 +28,15 @@ const fail = (code, message) => {
|
||||
throw new OrganizationError(code, message);
|
||||
};
|
||||
|
||||
// #1873 CB1: the capability only applyCopyBindingDecision holds. Every other commit refuses a change
|
||||
// to `copyBindings`, whatever options its caller spreads in (write() passes its caller's options).
|
||||
const BINDING_WRITE = Symbol("copy-binding-write");
|
||||
const bindingsFence = (old, document, capability) => {
|
||||
if (capability !== BINDING_WRITE && canonicalJson(document?.copyBindings ?? null) !== canonicalJson(old.copyBindings ?? null)) {
|
||||
fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
}
|
||||
};
|
||||
|
||||
function regular(file) {
|
||||
let fd;
|
||||
try {
|
||||
@@ -282,7 +292,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// the old document, for a writer that builds what it publishes from what it read under the lock.
|
||||
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
|
||||
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false }) {
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false, capability = null, incarnation = undefined }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
let revoked = false;
|
||||
@@ -297,9 +307,13 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
fail('org-mismatch', 'organization document and registry disagree');
|
||||
}
|
||||
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
// #1873 CB1: a binding names the registry incarnation it was approved in. A removed and
|
||||
// recreated organization with the same id, or a legacy entry with none, never matches.
|
||||
if (incarnation !== undefined && (!entry || orgIncarnation(registry, id) === null || orgIncarnation(registry, id) !== incarnation)) fail("decision-stale", "organization incarnation changed; raise the decision again");
|
||||
const old = parseJsonFileText(oldBytes, file, "organization file");
|
||||
if (typeof document === "function") document = document(old);
|
||||
guard(old, document);
|
||||
bindingsFence(old, document, capability);
|
||||
replyLimitsFence(old, document, actor);
|
||||
modelQuotaFence(old, document, actor);
|
||||
usageFence(old, document, actor);
|
||||
@@ -311,6 +325,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
|
||||
const written = JSON.parse(bytes);
|
||||
guard(old, written);
|
||||
bindingsFence(old, written, capability);
|
||||
replyLimitsFence(old, written, actor);
|
||||
modelQuotaFence(old, written, actor);
|
||||
usageFence(old, written, actor);
|
||||
@@ -373,7 +388,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
|
||||
};
|
||||
function write(id, document, options) {
|
||||
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
||||
return commit(id, document, { ...options, capability: null, incarnation: undefined, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
||||
}
|
||||
|
||||
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
|
||||
@@ -532,6 +547,53 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
},context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}});
|
||||
}
|
||||
|
||||
// #1873 CB1 (design sections 3 and 4): the only writer of `copyBindings`, one copy per call. The
|
||||
// broker has consumed the owner's approval of this exact digest before it gets here; the writer
|
||||
// still refuses the tester, every agent identity and a caller that is not the owner, and rechecks
|
||||
// under the locks the registry incarnation, the document revision, the copy's binding generation
|
||||
// and copyId, and its definition pin. A bind writes `pending` (approved, not verified: nothing
|
||||
// resolves or launches it until a verifier exists); an unbind writes the `unbound` tombstone. The
|
||||
// record holds ids, scope names and integers only; the receipt holds paths only.
|
||||
// `definitions` is the boot snapshot's merged definition map, for a root copy whose definition
|
||||
// is not in this file.
|
||||
function applyCopyBindingDecision(id, proposal, { actor, owner = false, definitions = {}, beforeWrite = null, beforePublish = null }) {
|
||||
const { binding: b, revision } = proposal ?? {};
|
||||
if (typeof proposal?.id !== "string" || proposal.kind !== "binding" || !b || typeof b.instance !== "string") fail("invalid-request");
|
||||
const humanOnly = () => fail("human-required", bindingText("human-required"));
|
||||
// Before the lock: no owner, no write. The agent identities are checked again under it.
|
||||
if (owner !== true || actor === "tester") humanOnly();
|
||||
const build = old => {
|
||||
const agents = [
|
||||
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [n, `${e?.definition}.${n}`]),
|
||||
...Object.entries(old.projects ?? {}).flatMap(([p, v]) => Object.entries(v?.agents ?? {}).flatMap(([n, e]) => [n, `${n}.${p}`, `${e?.definition}.${n}.${p}`])),
|
||||
...Object.keys(old.copyBindings ?? {}), ...Object.keys(definitions ?? {}), b.instance, b.source?.name,
|
||||
];
|
||||
if (owner !== true || actor === "tester" || agents.includes(actor)) humanOnly();
|
||||
const current = old.copyBindings?.[b.instance];
|
||||
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : b.fresh !== true)
|
||||
|| (current && canonicalJson(current.source) !== canonicalJson(b.source))) fail("credential-binding-changed", bindingText("credential-binding-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
|
||||
if (b.after === null && (!current || current.state === "unbound")) fail("credential-binding-changed", bindingText("credential-binding-changed"));
|
||||
const pin = definitionPin(b.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
|
||||
if (!samePin(pin, b.definition)) fail("credential-role-changed", bindingText("credential-role-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
|
||||
const record = {
|
||||
copyId: b.copyId, orgIncarnation: b.orgIncarnation, source: structuredClone(b.source), definition: structuredClone(b.definition),
|
||||
generation: b.generation + 1, state: b.after === null ? "unbound" : "pending",
|
||||
services: b.after === null ? {} : structuredClone(b.after), decision: proposal.id,
|
||||
};
|
||||
return { ...structuredClone(old), copyBindings: { ...(old.copyBindings ?? {}), [b.instance]: record } };
|
||||
};
|
||||
return commit(id, build, {
|
||||
revision, actor, beforeWrite, beforePublish, pathsOnly: true, capability: BINDING_WRITE, incarnation: b.orgIncarnation,
|
||||
guard: (old, next) => {
|
||||
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
projectsProtected(old, next);
|
||||
definitionsProtected(old, next);
|
||||
},
|
||||
context: { decision: proposal.id, digest: proposal.digest ?? null },
|
||||
detail: { verb: b.after === null ? "binding.unbind" : "binding.apply", decision: proposal.id, agent: b.instance, generation: b.generation + 1 },
|
||||
});
|
||||
}
|
||||
|
||||
// AU inverse, from an immutable private proposal. It touches only the named instance and
|
||||
// definition; writer validation, fresh revision and owner fence still apply. Bindings never restore.
|
||||
function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) {
|
||||
@@ -637,5 +699,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// #1873 copy credential binding, slices CB0 and CB1 (design
|
||||
// docs/plans/2026-11-11_copy-credential-binding-design.md section 8, rows CB0 and CB1).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { hireDecisions, authorizeHire } from '../src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
import { checkRootAgents } from '../src/agent-levels.mjs';
|
||||
import { copyRefusal, loadWithCopies, materializeCopies } from '../src/agent-copies.mjs';
|
||||
import { readTemplate } from '../src/definitions.mjs';
|
||||
import { validateBusinessDocument } from '../src/organization-config.mjs';
|
||||
import { activateOrg, ensureOrgIncarnation, orgIncarnation, orgRoot, readRegistry, reserveOrg, withRegistryWriter } from '../src/org-registry.mjs';
|
||||
import { definitionPin, intersectScopes, resolveCopyBinding, validateCopyBindings } from '../src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from './helpers.mjs';
|
||||
import { createOrg, showOrg, templateRender, updateOrg } from '../src/org-service.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const OWNER = { actor: 'jason', agent: false, rights: ['org.hire'], owner: true, instanceOwner: true, subject: { provider: 'test', iss: 'https://test', sub: 'owner' } };
|
||||
const FUTURE = '2099-01-01T00:00:00.000Z';
|
||||
const ID1 = '11111111-1111-4111-8111-111111111111', ID2 = '22222222-2222-4222-8222-222222222222', ID3 = '33333333-3333-4333-8333-333333333333';
|
||||
const GITEA = { gitea: { scopes: ['read:repository'], projects: ['stack'] } };
|
||||
const sha = b => createHash('sha256').update(b).digest('hex');
|
||||
|
||||
// ---- CB0: pure resolver fixtures ----
|
||||
|
||||
const rolesDir = join(new URL('../../..', import.meta.url).pathname, 'roles');
|
||||
const rootOf = agents => ({ state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
|
||||
const reviewer = () => JSON.parse(readFileSync(join(rolesDir, 'reviewer.json'), 'utf8'));
|
||||
const pinOf = (ref, definitions = {}) => definitionPin(ref, { definitions, rolesDir, readTemplate });
|
||||
const INC_A = 'a'.repeat(32), INC_B = 'b'.repeat(32);
|
||||
const docFor = (id, edit = d => d) => edit({ id, roles: {}, projects: { stack: {}, web: {} } });
|
||||
const record = (over = {}) => ({
|
||||
copyId: 'c'.repeat(32), orgIncarnation: INC_A, source: { level: 'root', name: 'rev', project: null },
|
||||
definition: pinOf('reviewer'), generation: 2, state: 'bound',
|
||||
services: { gitea: { slot: 'self', scopes: ['read:repository', 'write:issue'], resources: { projects: ['stack'] } },
|
||||
vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } },
|
||||
decision: ID1, ...over,
|
||||
});
|
||||
const resolve = (over = {}) => {
|
||||
const snapshot = over.snapshot ?? materializeCopies({ orgId: 'acme', doc: docFor('acme'), root: rootOf({ rev: { definition: 'reviewer' } }), rolesDir });
|
||||
return resolveCopyBinding({ orgId: 'acme', incarnation: INC_A, snapshot, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack', 'web'], ...over });
|
||||
};
|
||||
|
||||
test('CB0 a bound record resolves to effective = role intersect approved, only on admitted projects', () => {
|
||||
const r = resolve();
|
||||
assert.equal(r.ok, true);
|
||||
// The role declares write:issue, write:repository and read:user; the owner approved less.
|
||||
assert.deepEqual(r.services.gitea, { scopes: ['write:issue', 'read:repository'], projects: ['stack'] });
|
||||
assert.deepEqual(r.services.vikunja, { scopes: { tasks: ['read_one'] }, projects: ['stack'] });
|
||||
assert.deepEqual(intersectScopes('gitea', ['write:repository', 'read:issue'], ['read:repository', 'write:issue']), ['read:issue', 'read:repository']);
|
||||
// A project no longer admitted drops out of the reach; none left, no binding.
|
||||
assert.equal(resolve({ projects: ['web'] }).code, 'credential-unbound');
|
||||
// Approved broader than the role (the role narrowed): never widened, refused.
|
||||
const wide = record(); wide.services.gitea.scopes = ['write:admin'];
|
||||
assert.equal(resolve({ record: wide }).ok, false);
|
||||
const extra = record(); extra.services.gitea.scopes = ['write:repository', 'write:organization'];
|
||||
assert.deepEqual([resolve({ record: extra }).code, resolve({ record: extra }).service], ['credential-scope-exceeded', 'gitea']);
|
||||
});
|
||||
|
||||
test('CB0 pending, partial and tombstoned records never resolve', () => {
|
||||
assert.equal(resolve({ record: record({ state: 'pending' }) }).code, 'credential-unbound');
|
||||
assert.equal(resolve({ record: record({ state: 'unbound', services: {} }) }).code, 'credential-unbound');
|
||||
const partial = record(); delete partial.services.vikunja;
|
||||
assert.deepEqual([resolve({ record: partial }).code, resolve({ record: partial }).service], ['credential-unbound', 'vikunja']);
|
||||
assert.equal(resolve({ record: undefined }).code, 'credential-unbound');
|
||||
});
|
||||
|
||||
test('CB0 identical root copies in two organizations never share a binding', () => {
|
||||
const root = rootOf({ rev: { definition: 'reviewer' } });
|
||||
const a = materializeCopies({ orgId: 'acme', doc: docFor('acme'), root, rolesDir });
|
||||
const b = materializeCopies({ orgId: 'beta', doc: docFor('beta'), root, rolesDir });
|
||||
assert.deepEqual({ ...a.copies.rev }, { ...b.copies.rev }, 'the same copy shape in both');
|
||||
assert.equal(resolve({ snapshot: a }).ok, true);
|
||||
// A's record copied into B carries A's incarnation.
|
||||
assert.equal(resolveCopyBinding({ orgId: 'beta', incarnation: INC_B, snapshot: b, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack'] }).code, 'credential-unbound');
|
||||
// A's snapshot never answers for B.
|
||||
assert.equal(resolveCopyBinding({ orgId: 'beta', incarnation: INC_A, snapshot: a, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack'] }).code, 'copy-unavailable');
|
||||
});
|
||||
|
||||
test('CB0 project dotted-name collisions never attach a record', () => {
|
||||
const root = rootOf({});
|
||||
// project "a.b" agent "c" and project "b" agent "c.a" both make instance c.a.b: both refuse.
|
||||
const both = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { 'a.b': { agents: { c: { definition: 'reviewer' } } }, b: { agents: { 'c.a': { definition: 'reviewer' } } } } })), root, rolesDir });
|
||||
assert.equal(both.copies['c.a.b'].state, 'refused');
|
||||
const old = record({ source: { level: 'project', name: 'c.a', project: 'b' }, services: { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['b'] } } } });
|
||||
const args = { orgId: 'acme', incarnation: INC_A, instance: 'c.a.b', record: old, pin: pinOf('reviewer'), role: reviewer(), projects: ['a.b', 'b'] };
|
||||
assert.equal(resolveCopyBinding({ ...args, snapshot: both }).code, 'copy-refused');
|
||||
// Project b drops its agent; project a.b's "c" now owns c.a.b alone. b's record never attaches.
|
||||
const one = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { 'a.b': { agents: { c: { definition: 'reviewer' } } }, b: {} } })), root, rolesDir });
|
||||
assert.equal(one.copies['c.a.b'].state, 'unbound');
|
||||
assert.equal(resolveCopyBinding({ ...args, snapshot: one, record: { ...old, state: 'bound' } }).code, 'credential-unbound');
|
||||
// A root agent named like a project copy: the root record never attaches to the project copy.
|
||||
const proj = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { stack: { agents: { checker: { definition: 'reviewer' } } } } })), root, rolesDir });
|
||||
const rootRecord = record({ source: { level: 'root', name: 'checker.stack', project: null } });
|
||||
assert.equal(resolveCopyBinding({ ...args, instance: 'checker.stack', snapshot: proj, record: rootRecord, projects: ['stack'] }).code, 'credential-unbound');
|
||||
});
|
||||
|
||||
test('CB0 an organization instance shadows the copy; its record never resolves', () => {
|
||||
const snapshot = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, roles: { rev: { definition: 'reviewer' } } })), root: rootOf({ rev: { definition: 'reviewer' } }), rolesDir });
|
||||
assert.equal(Object.hasOwn(snapshot.copies, 'rev'), false);
|
||||
assert.equal(resolve({ snapshot }).code, 'copy-unavailable');
|
||||
});
|
||||
|
||||
test('CB0 a removed and recreated organization has a new incarnation; the old record never resolves', async t => {
|
||||
const base = mkdtempSync(join(tmpdir(), 'cb0-')); t.after(() => rmSync(base, { recursive: true, force: true }));
|
||||
const c = { configDir: join(base, 'config'), dataRoot: join(base, 'data'), instance: 'test' };
|
||||
mkdirSync(c.configDir, { mode: 0o700 }); mkdirSync(c.dataRoot, { mode: 0o700 });
|
||||
const first = await reserveOrg(c, 'acme');
|
||||
assert.match(first.incarnation, /^[0-9a-f]{32}$/);
|
||||
assert.notEqual((await reserveOrg(c, 'beta')).incarnation, first.incarnation);
|
||||
withRegistryWriter(c.configDir, (registry, publish) => { delete registry.orgs.acme; publish(); });
|
||||
rmSync(orgRoot(c.dataRoot, 'acme'), { recursive: true });
|
||||
const again = await reserveOrg(c, 'acme');
|
||||
assert.notEqual(again.incarnation, first.incarnation);
|
||||
assert.equal(orgIncarnation(await readRegistry(c), 'acme'), again.incarnation);
|
||||
assert.equal(resolve({ incarnation: again.incarnation, record: record({ orgIncarnation: first.incarnation }) }).code, 'credential-unbound');
|
||||
assert.equal(resolve({ incarnation: null }).code, 'credential-unbound', 'no incarnation, no binding');
|
||||
// An invalid incarnation in the registry refuses the registry.
|
||||
withRegistryWriter(c.configDir, (registry, publish) => { registry.orgs.acme.incarnation = 'x'; publish(); });
|
||||
await assert.rejects(readRegistry(c), { code: 'org-mismatch' });
|
||||
});
|
||||
|
||||
test('CB0 a reused source name with another definition or level never inherits a record', () => {
|
||||
// Root "rev" was a reviewer; it is removed and a new root "rev" is a coder.
|
||||
const snapshot = materializeCopies({ orgId: 'acme', doc: docFor('acme'), root: rootOf({ rev: { definition: 'coder' } }), rolesDir });
|
||||
const coder = JSON.parse(readFileSync(join(rolesDir, 'coder.json'), 'utf8'));
|
||||
assert.equal(resolve({ snapshot, pin: pinOf('coder'), role: coder }).code, 'credential-role-changed');
|
||||
// An org definition changed in place (same name, new rev or text) changes the pin.
|
||||
const defs = { d: { rev: 1, role: { template: 'reviewer' } } };
|
||||
const before = pinOf('org:d', defs), after = pinOf('org:d', { d: { ...defs.d, instructions: 'other' } });
|
||||
assert.ok(before && after && before.sha256 !== after.sha256);
|
||||
// The copyId is kept only with its own record: a record for another copyId does not move.
|
||||
assert.equal(resolve({ record: record({ source: { level: 'project', name: 'rev', project: 'stack' } }) }).code, 'credential-unbound');
|
||||
});
|
||||
|
||||
test('CB0 copyBindings schema: ids and scope names only, every refusal positional', () => {
|
||||
const ok = { rev: record() };
|
||||
assert.doesNotThrow(() => validateCopyBindings(ok));
|
||||
const cases = [
|
||||
{ rev: { ...record(), token: SECRET } },
|
||||
{ rev: { ...record(), services: { gitea: { ...record().services.gitea, file: '/x/token' } } } },
|
||||
{ rev: { ...record(), services: { gitea: { ...record().services.gitea, slot: '/srv/token' } } } },
|
||||
{ rev: { ...record(), copyId: SECRET } },
|
||||
{ rev: { ...record(), orgIncarnation: 'z' } },
|
||||
{ rev: { ...record(), generation: 0 } },
|
||||
{ rev: { ...record(), state: 'active' } },
|
||||
{ rev: { ...record(), source: { level: 'root', name: 'other', project: null } } },
|
||||
{ rev: { ...record(), state: 'unbound' } },
|
||||
{ rev: record(), other: { ...record(), source: { level: 'root', name: 'other', project: null } } },
|
||||
];
|
||||
for (const value of cases) {
|
||||
assert.throws(() => validateCopyBindings(value), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes('/'));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- CB1: decision subtype, guarded writer ----
|
||||
|
||||
function setup(t, { incarnation = true, edit = d => d } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const roles = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
doc.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
edit(doc);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
const ready = (async () => {
|
||||
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
||||
if (!incarnation) withRegistryWriter(root, (registry, publish) => { delete registry.orgs.demo.incarnation; publish(); });
|
||||
})();
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir: roles }) }, rolesDir: roles });
|
||||
const writer = organizationWriter({ dir: root, rolesDir: roles });
|
||||
const service = hireDecisions({ writer, rolesDir: roles, configDir: root, dataRoot, assertClean: v => { if (JSON.stringify(v).includes(SECRET)) throw Object.assign(new Error('x'), { code: 'definition-holds-credential' }); }, copies: id => snapshot.org === id ? snapshot : null });
|
||||
const bind = (services = GITEA, extra = {}) => ({ op: 'change', level: 'root', name: 'rev', reason: 'Bind the reviewer copy', binding: { services }, ...extra });
|
||||
const prepare = async (input = bind(), id = ID1, expiresAt = FUTURE) => { await ready; return service.prepare('demo', input, { id, expiresAt }); };
|
||||
const bytes = () => readFileSync(file);
|
||||
return { root, roles, file, writer, service, snapshot, bind, prepare, bytes, ready, ctx, dataRoot };
|
||||
}
|
||||
|
||||
test('CB1 owner binds and unbinds; the record holds ids only and stays pending; receipts are paths only', async t => {
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
assert.equal(p.binding.instance, 'rev');
|
||||
assert.deepEqual(p.credentialLines, [{ service: 'gitea', scope: 'read:repository' }]);
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const rec = f.writer.read('demo').document.copyBindings.rev;
|
||||
assert.equal(rec.state, 'pending');
|
||||
assert.equal(rec.generation, 1);
|
||||
assert.deepEqual(rec.services, { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['stack'] } } });
|
||||
assert.deepEqual(rec.source, { level: 'root', name: 'rev', project: null });
|
||||
assert.equal(rec.orgIncarnation, orgIncarnation(await readRegistry(f.ctx), 'demo'));
|
||||
assert.deepEqual(rec.definition, definitionPin('reviewer', { rolesDir: f.roles, readTemplate }));
|
||||
assert.equal(rec.decision, p.id);
|
||||
const text = JSON.stringify(rec);
|
||||
for (const bad of [SECRET, f.root, '/', 'token']) assert.ok(!text.includes(bad), bad);
|
||||
const log = readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8');
|
||||
assert.ok(log.includes(p.id)); assert.ok(log.includes('binding.apply'));
|
||||
assert.ok(!log.includes('read:repository'), 'paths only: the receipt holds no scope value');
|
||||
// The owner unbinds through the same decision kind; a tombstone stays with the same copyId.
|
||||
const u = await f.prepare(f.bind(null), ID2);
|
||||
await f.service.apply('demo', u.id, { ...OWNER, digest: u.digest });
|
||||
const tomb = f.writer.read('demo').document.copyBindings.rev;
|
||||
assert.deepEqual({ state: tomb.state, services: tomb.services, generation: tomb.generation, copyId: tomb.copyId }, { state: 'unbound', services: {}, generation: 2, copyId: rec.copyId });
|
||||
assert.ok(readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('binding.unbind'));
|
||||
// Unbinding a tombstone refuses.
|
||||
await assert.rejects(f.prepare(f.bind(null), ID3), { code: 'credential-unbound' });
|
||||
});
|
||||
|
||||
test('CB1 a project copy binds only its own project; a role-exceeding request refuses, never trims', async t => {
|
||||
const f = setup(t);
|
||||
const project = (services, id) => f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services } }, id);
|
||||
const p = await project(GITEA, ID1);
|
||||
assert.equal(p.binding.instance, 'checker.stack');
|
||||
await assert.rejects(project({ gitea: { scopes: ['write:admin'], projects: ['stack'] } }, ID2), { code: 'invalid-request' });
|
||||
await assert.rejects(project({ gitea: { scopes: ['write:organization'], projects: ['stack'] } }, ID2), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(project({ gitea: { scopes: ['read:repository'], projects: ['other'] } }, ID2), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'], token: SECRET } }), ID2), e => !JSON.stringify(e).includes(SECRET) && !e.message.includes(SECRET));
|
||||
// A copy the bus did not boot, and an org instance, are not copies.
|
||||
await assert.rejects(f.prepare({ ...f.bind(), name: 'nobody' }, ID2), { code: 'copy-unavailable' });
|
||||
await assert.rejects(f.prepare({ ...f.bind(), name: 'coder' }, ID2), { code: 'copy-unavailable' });
|
||||
// A binding is only ever a change of a root or project copy, with no role, route or credentials.
|
||||
for (const extra of [{ op: 'hire' }, { level: 'org' }, { route: { harness: 'pi', model: 'm' } }, { credentials: {} }]) {
|
||||
await assert.rejects(f.prepare({ ...f.bind(), ...extra }, ID2), { code: 'invalid-request' });
|
||||
}
|
||||
});
|
||||
|
||||
test('CB1 only the owner resolves: member with org.hire or decide, chief, arbiter, subject, tester and reader all fail', async t => {
|
||||
const plain = { binding: {}, flagged: [], credentialLines: [], level: 'root', id: ID1, name: 'rev' };
|
||||
const member = { ...OWNER, owner: false, instanceOwner: false };
|
||||
assert.throws(() => authorizeHire(plain, member), { code: 'human-required' });
|
||||
assert.throws(() => authorizeHire(plain, { ...member, instanceOwner: true }), { code: 'human-required' }, 'an instance operator alone is not the owner');
|
||||
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: ['decide'] }), { code: 'right-required' });
|
||||
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: [] }), { code: 'right-required' }, 'reader');
|
||||
for (const agent of ['chief', 'cto', 'rev', 'tester']) {
|
||||
// An agent, even one carrying an authority decision and the authority right, never resolves.
|
||||
assert.throws(() => authorizeHire(plain, { actor: agent, agent: true, rights: ['org.hire', 'decision.resolve.authority'], owner: true, authorityDecision: ID1, resolver: agent }), { code: 'human-required' });
|
||||
}
|
||||
assert.doesNotThrow(() => authorizeHire(plain, OWNER));
|
||||
// Through the service: each refused caller leaves the file byte-identical.
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
const before = f.bytes();
|
||||
for (const caller of [member, { ...OWNER, rights: ['decide'] }, { ...OWNER, rights: [] },
|
||||
{ actor: 'chief', agent: true, rights: ['org.hire'], owner: true }, { actor: 'cto', agent: true, rights: ['decision.resolve.authority', 'org.hire'], authorityDecision: p.id, resolver: 'cto' },
|
||||
{ actor: 'rev', agent: true, rights: ['org.hire'] }, { actor: 'tester', agent: true, rights: ['org.hire'] }]) {
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...caller, digest: p.digest }));
|
||||
}
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
// The writer refuses the tester and every agent identity even when told owner:true.
|
||||
const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8')), digest: p.digest };
|
||||
for (const actor of ['tester', 'chief', 'rev', 'checker.stack', 'reviewer', 'pm']) {
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', full, { actor, owner: true }), { code: 'human-required' }, actor);
|
||||
}
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', full, { actor: 'jason', owner: false }), { code: 'human-required' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
});
|
||||
|
||||
test('CB1 generic writes never change copyBindings, whatever options they pass', async t => {
|
||||
const f = setup(t); await f.ready;
|
||||
const { document, revision } = f.writer.read('demo');
|
||||
const forged = { ...document, copyBindings: { rev: { ...record(), orgIncarnation: orgIncarnation(await readRegistry(f.ctx), 'demo'), state: 'bound' } } };
|
||||
assert.doesNotThrow(() => validateBusinessDocument(forged, f.file, { rolesDir: f.roles }), 'a valid document is still refused by the writer');
|
||||
const before = f.bytes();
|
||||
for (const options of [{}, { capability: Symbol('copy-binding-write') }, { capability: Symbol.for('copy-binding-write'), incarnation: forged.copyBindings.rev.orgIncarnation }, { guard: () => {} }]) {
|
||||
assert.throws(() => f.writer.write('demo', forged, { revision, actor: 'jason', ...options }), { code: 'organization-bindings-protected' });
|
||||
}
|
||||
assert.throws(() => f.writer.write('demo', old => ({ ...old, copyBindings: forged.copyBindings }), { revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
// Once a record exists, a generic write can neither remove nor edit it.
|
||||
const p = await f.prepare(); await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const now = f.writer.read('demo');
|
||||
const { copyBindings: _, ...stripped } = now.document;
|
||||
assert.throws(() => f.writer.write('demo', stripped, { revision: now.revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
|
||||
const promoted = structuredClone(now.document); promoted.copyBindings.rev.state = 'bound';
|
||||
assert.throws(() => f.writer.write('demo', promoted, { revision: now.revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
|
||||
// An unrelated write keeps the record.
|
||||
const edited = structuredClone(now.document); edited.vars['tracker.pollSeconds'] = 61;
|
||||
f.writer.write('demo', edited, { revision: now.revision, actor: 'jason' });
|
||||
assert.deepEqual(f.writer.read('demo').document.copyBindings, now.document.copyBindings);
|
||||
});
|
||||
|
||||
test('CB1 stale revision, digest or generation, expiry, replay and a wrong incarnation activate nothing', async t => {
|
||||
const f = setup(t);
|
||||
// Digest.
|
||||
const p = await f.prepare();
|
||||
const before = f.bytes();
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: '0'.repeat(64) }), { code: 'decision-stale' });
|
||||
// Expiry.
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, now: () => Date.parse('2099-06-01T00:00:00Z') }), { code: 'decision-expired' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
// Revision: any change to the file after the proposal.
|
||||
const q = await f.prepare(f.bind(), ID2);
|
||||
const r = f.writer.read('demo'), edited = structuredClone(r.document); edited.vars['tracker.pollSeconds'] = 61;
|
||||
f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' });
|
||||
await assert.rejects(f.service.apply('demo', q.id, { ...OWNER, digest: q.digest }), { code: 'decision-stale' });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings, undefined);
|
||||
// Replay: applying the same approval twice changes nothing the second time.
|
||||
const s = await f.prepare(f.bind(), ID3);
|
||||
await f.service.apply('demo', s.id, { ...OWNER, digest: s.digest });
|
||||
const once = f.bytes();
|
||||
await assert.rejects(f.service.apply('demo', s.id, { ...OWNER, digest: s.digest }), { code: 'decision-stale' });
|
||||
const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${s.id}.json`), 'utf8')), digest: s.digest };
|
||||
// Generation: the writer refuses a proposal at an old generation even with a fresh revision.
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...full, revision: f.writer.read('demo').revision }, { actor: 'jason', owner: true }), { code: 'credential-binding-changed' });
|
||||
assert.deepEqual(f.bytes(), once);
|
||||
// Wrong incarnation: the organization was recreated under the same id after approval.
|
||||
const u = await f.prepare(f.bind(null), '44444444-4444-4444-8444-444444444444');
|
||||
withRegistryWriter(f.root, (registry, publish) => { registry.orgs.demo.incarnation = 'f'.repeat(32); publish(); });
|
||||
await assert.rejects(f.service.apply('demo', u.id, { ...OWNER, digest: u.digest }), { code: 'decision-stale' });
|
||||
const uf = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${u.id}.json`), 'utf8')), digest: u.digest };
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', uf, { actor: 'jason', owner: true }), { code: 'decision-stale' });
|
||||
assert.deepEqual(f.bytes(), once);
|
||||
// A changed definition pin refuses too.
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...uf, binding: { ...uf.binding, orgIncarnation: 'f'.repeat(32), definition: { ...uf.binding.definition, sha256: '0'.repeat(64) } } }, { actor: 'jason', owner: true }), { code: 'credential-role-changed' });
|
||||
// A binding is never reversed through the AU inverse, and never adjusted.
|
||||
await assert.rejects(f.service.reverse('demo', s.id, OWNER), { code: 'decision-stale' });
|
||||
await assert.rejects(f.service.check('demo', u.id, { ...OWNER, digest: u.digest, adjust: { role: {} } }), { code: 'invalid-request' });
|
||||
});
|
||||
|
||||
test('CB1 a legacy organization gets its incarnation at the first proposal, under the registry lock', async t => {
|
||||
const f = setup(t, { incarnation: false });
|
||||
await f.ready;
|
||||
assert.equal(orgIncarnation(await readRegistry(f.ctx), 'demo'), null);
|
||||
const p = await f.prepare();
|
||||
const inc = orgIncarnation(await readRegistry(f.ctx), 'demo');
|
||||
assert.match(inc, /^[0-9a-f]{32}$/);
|
||||
assert.equal(p.binding.orgIncarnation, inc);
|
||||
assert.equal(ensureOrgIncarnation(f.root, 'demo'), inc, 'minted once');
|
||||
assert.equal(ensureOrgIncarnation(f.root, 'nobody'), null);
|
||||
});
|
||||
|
||||
test('CB1 a pending or partial binding never launches the copy', async t => {
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const business = loadWithCopies('demo', { dir: f.root, rolesDir: f.roles, snapshot: f.snapshot });
|
||||
assert.equal(copyRefusal(business, 'rev').code, 'credential-unbound');
|
||||
assert.equal(Object.hasOwn(business.roles, 'rev'), false);
|
||||
const rec = business.copyBindings.rev;
|
||||
assert.equal(rec.state, 'pending');
|
||||
const pin = definitionPin('reviewer', { rolesDir: f.roles, readTemplate });
|
||||
const role = JSON.parse(readFileSync(join(f.roles, 'reviewer.json'), 'utf8'));
|
||||
const args = { orgId: 'demo', incarnation: rec.orgIncarnation, snapshot: f.snapshot, instance: 'rev', pin, role, projects: ['stack'] };
|
||||
assert.equal(resolveCopyBinding({ ...args, record: rec }).code, 'credential-unbound');
|
||||
// Even forced to bound, gitea alone is partial for a role that also needs vikunja.
|
||||
assert.deepEqual(resolveCopyBinding({ ...args, record: { ...rec, state: 'bound' } }), { ok: false, code: 'credential-unbound', service: 'vikunja' });
|
||||
// A pending record that covers every service the role needs is still not a binding; bound, it is.
|
||||
const whole = { ...rec, services: { ...rec.services, vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } } };
|
||||
assert.equal(resolveCopyBinding({ ...args, record: { ...whole, state: 'bound' } }).ok, true);
|
||||
assert.deepEqual(resolveCopyBinding({ ...args, record: whole }), { ok: false, code: 'credential-unbound', service: 'gitea' });
|
||||
});
|
||||
|
||||
test('CB1 no proposal, record, receipt or refusal names a token or a path', async t => {
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const texts = [JSON.stringify(p), readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8'),
|
||||
...readdirSync(join(f.dataRoot, 'authority-proposals')).map(n => readFileSync(join(f.dataRoot, 'authority-proposals', n), 'utf8'))];
|
||||
for (const text of texts) {
|
||||
assert.ok(!text.includes(SECRET)); assert.ok(!text.includes(join(f.root, 'secrets')));
|
||||
}
|
||||
const refusals = [];
|
||||
for (const args of [[f.bind({ gitea: { scopes: ['write:organization'], projects: ['stack'] } }), ID2], [{ ...f.bind(), name: 'gone' }, ID3]]) {
|
||||
await f.prepare(...args).catch(e => refusals.push(e));
|
||||
}
|
||||
const t2 = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8')) };
|
||||
try { f.writer.applyCopyBindingDecision('demo', { ...t2, revision: f.writer.read('demo').revision }, { actor: 'chief', owner: true }); } catch (e) { refusals.push(e); }
|
||||
assert.equal(refusals.length, 3);
|
||||
for (const e of refusals) assert.ok(!e.message.includes('/') && !e.message.includes(SECRET), e.message);
|
||||
assert.equal(refusals[2].message, 'Only the organization owner can approve credential binding.');
|
||||
void sha;
|
||||
});
|
||||
|
||||
test('CB1 fix: the free-text reason is never written to a binding proposal', async t => {
|
||||
const f = setup(t);
|
||||
const leak = '/home/someone/.config/mosaic-dev/secrets/gitea tok-fragment-9f8e';
|
||||
const p = await f.prepare(f.bind(GITEA, { reason: `Bind; see ${leak}` }));
|
||||
const stored = readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8');
|
||||
assert.ok(!stored.includes(leak) && !stored.includes('tok-fragment') && !stored.includes('"reason"'), 'no reason text on disk');
|
||||
assert.deepEqual(Object.keys(JSON.parse(stored).request).sort(), ['binding', 'level', 'name', 'op']);
|
||||
assert.ok(!JSON.stringify(p).includes('tok-fragment'));
|
||||
// The proposal still applies on its digest.
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings.rev.state, 'pending');
|
||||
assert.ok(!readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('tok-fragment'));
|
||||
});
|
||||
|
||||
test('CB1 fix: a root copy removed or replaced since boot refuses at prepare, at apply and before publication', async t => {
|
||||
const receipts = root => join(root, 'agents-receipts.jsonl');
|
||||
const add = (root, row) => writeFileSync(receipts(root), JSON.stringify(row) + '\n', { flag: 'a', mode: 0o600 });
|
||||
// Removed since boot: prepare refuses instance-fired; replaced: copy-stale.
|
||||
for (const [verb, code] of [['root.agent.remove', 'instance-fired'], ['root.agent.set', 'copy-stale']]) {
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'rev', verb, at: FUTURE });
|
||||
await assert.rejects(f.prepare(), { code }, verb);
|
||||
let saved = []; try { saved = readdirSync(join(f.dataRoot, 'authority-proposals')); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
assert.deepEqual(saved, [], verb);
|
||||
}
|
||||
// Prepared while fresh, then the root copy is removed: apply refuses and writes nothing.
|
||||
{
|
||||
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
|
||||
add(f.root, { agent: 'rev', verb: 'root.agent.remove', at: FUTURE });
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'instance-fired' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
}
|
||||
// The change lands after checkBinding, under the lock: the publication fence still refuses.
|
||||
{
|
||||
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
|
||||
let wrote = false;
|
||||
const late = () => { if (!wrote) { wrote = true; add(f.root, { agent: 'rev', verb: 'root.agent.set', at: FUTURE }); } };
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, beforeWrite: late }), { code: 'copy-stale' });
|
||||
assert.equal(wrote, true);
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
}
|
||||
// A receipt for another root agent leaves this copy fresh; an unreadable receipts file refuses.
|
||||
{
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'other', verb: 'root.agent.remove', at: FUTURE });
|
||||
const p = await f.prepare();
|
||||
writeFileSync(receipts(f.root), 'not json\n', { mode: 0o600 });
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'root-policy-unreadable' });
|
||||
}
|
||||
// A project copy does not read root receipts.
|
||||
{
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'checker', verb: 'root.agent.remove', at: FUTURE });
|
||||
const p = await f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings['checker.stack'].state, 'pending');
|
||||
}
|
||||
});
|
||||
|
||||
test('CB1 the org service refuses copyBindings on create and on a whole-document update', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-svc-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const c = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
mkdirSync(c.configDir, { mode: 0o700 }); mkdirSync(c.dataRoot, { mode: 0o700 });
|
||||
const forged = { rev: { ...record(), state: 'bound' } };
|
||||
await assert.rejects(createOrg(c, 'alpha', { ...await templateRender('alpha'), copyBindings: forged }), { code: 'organization-bindings-protected' });
|
||||
await createOrg(c, 'alpha', await templateRender('alpha'));
|
||||
const current = await showOrg(c, 'alpha');
|
||||
await assert.rejects(updateOrg(c, 'alpha', { ...current.doc, copyBindings: forged }, { ifRevision: current.revision }), { code: 'organization-bindings-protected' });
|
||||
assert.equal((await showOrg(c, 'alpha')).doc.copyBindings, undefined);
|
||||
await updateOrg(c, 'alpha', current.doc, { ifRevision: current.revision });
|
||||
});
|
||||
|
||||
test('CB1 fix: a root copy binds only projects whose union still admits it', async t => {
|
||||
const f = setup(t, { edit: doc => { doc.projects.web = { root: doc.projects.stack.root }; doc.projects.stack.agents.rev = { exclude: true }; } });
|
||||
// stack excludes rev: a request naming it refuses whole, never trimmed to web.
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack', 'web'] } })), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'] } }, {}), ID2), { code: 'credential-scope-exceeded' });
|
||||
// web still admits it.
|
||||
const p = await f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['web'] } }), ID3);
|
||||
assert.deepEqual(p.binding.after.gitea.resources.projects, ['web']);
|
||||
});
|
||||
|
||||
test('CB1 fix: a named instance added after boot shadows the copy at proposal time', async t => {
|
||||
const f = setup(t); await f.ready;
|
||||
const r = f.writer.read('demo'), edited = structuredClone(r.document);
|
||||
const like = Object.values(edited.roles).find(e => e.definition === 'reviewer');
|
||||
edited.roles.rev = { ...structuredClone(like), tracker: { bot: 'bot-demo-rev', botId: 977 } };
|
||||
f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' });
|
||||
await assert.rejects(f.prepare(), { code: 'copy-unavailable' });
|
||||
});
|
||||
|
||||
test('CB1 fix: the loader refuses a copyBindings record holding a token, naming neither the value nor a path', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-load-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const roles = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), copyBindings: { rev: { ...record(), state: 'pending', token: SECRET } } };
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
assert.throws(() => validateBusinessDocument(doc, file, { rolesDir: roles }), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes(root));
|
||||
// The same document without the token loads.
|
||||
const { token: _, ...clean } = doc.copyBindings.rev;
|
||||
validateBusinessDocument({ ...doc, copyBindings: { rev: clean } }, file, { rolesDir: roles });
|
||||
});
|
||||
@@ -21,7 +21,8 @@ baseline CLI.
|
||||
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
|
||||
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
|
||||
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed |
|
||||
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground: Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
|
||||
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground, or with `--dev-local-owner --detach` under a detached supervisor (D1): Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
|
||||
| Save, review, confirm or reverse an organization's autonomy | Save only: edit the autonomy block and run `mosaic org update ORG --from FILE --if-revision N`; no autonomy verb yet (readiness P1-3) | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes only the four console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console` and `decision.reverse.console` |
|
||||
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
|
||||
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
|
||||
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// AU: owner Console transport only. Identity and organization never come from a JSON body.
|
||||
export const AUTONOMY_ROUTE=/^\/api\/autonomy(?:\/(confirm|reverse|review))?$/;
|
||||
// D1 (#1873): the owner writes this route makes, and a fence that passes only those to a console transport.
|
||||
export const AUTONOMY_WRITE_VERBS=Object.freeze(['autonomy.set.console','autonomy.review.console','decision.confirm.console','decision.reverse.console']);
|
||||
export const autonomyCaller=call=>async(verb,args,options)=>{if(!AUTONOMY_WRITE_VERBS.includes(verb))throw new Error(`the autonomy writer does not call ${verb}`);return call(verb,args,options);};
|
||||
const one=(req,k)=>req.headersDistinct[k]?.length===1?req.headersDistinct[k][0]:null;
|
||||
const json=(res,status,value)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));};
|
||||
export async function autonomyHTTP(req,res,{path,read,write,session,live,origin}) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
|
||||
import { organizationConsole } from './organizations.mjs';
|
||||
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
|
||||
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
|
||||
import { autonomyCaller } from './autonomy-http.mjs';
|
||||
import { consoleAccess } from './access.mjs';
|
||||
import { accessContextFor } from '../../cli/src/access.mjs';
|
||||
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
|
||||
@@ -183,6 +184,10 @@ try {
|
||||
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
|
||||
// with its revision) and a project's selection, over the owner's human transport under --send only.
|
||||
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
|
||||
// D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus.
|
||||
// These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes
|
||||
// those four verbs and nothing else the console transport carries.
|
||||
autonomyWrite: session.user === undefined && sendOn ? autonomyCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
|
||||
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -246,6 +246,10 @@ export function toCanonical(id, doc, stored = null) {
|
||||
};
|
||||
for (const [name, role] of Object.entries(stored?.roles ?? {})) if (out.roles?.[name] && role?.credentials) { out.roles[name].credentials ??= {}; restore(out.roles[name].credentials, role.credentials); }
|
||||
if (stored?.tracker?.sync?.credentials && out.tracker?.sync) { out.tracker.sync.credentials ??= {}; restore(out.tracker.sync.credentials, stored.tracker.sync.credentials); }
|
||||
// #1873 CB1: copy bindings are server-held on an update. Only an owner-approved binding decision
|
||||
// changes them, so the stored ones go back whatever the browser sent. A create passes the
|
||||
// browser's value through and the service refuses any binding there.
|
||||
if (stored) { if (Object.hasOwn(stored, 'copyBindings')) out.copyBindings = structuredClone(stored.copyBindings); else delete out.copyBindings; }
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@@ -859,7 +859,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
let boundAddress = null, boundPort = null;
|
||||
const server = createServer(async (req, res) => {
|
||||
// A granted user's request rebinds these below; every other request keeps the owner's.
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [];
|
||||
let { bus, reads, send, settingsWrite, projectFiles, projectFile, projectSave, libraryStatus, libraryRevert, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, systemTypesWrite, upstream, files } = owner, granted = false, chatGrants = [], autonomyWrite = null;
|
||||
res.setHeader('x-content-type-options', 'nosniff');
|
||||
res.setHeader('referrer-policy', 'no-referrer');
|
||||
// Under --sso the Sign out form's POST is redirected to the provider's end-session endpoint, and
|
||||
@@ -954,7 +954,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
const instanceAction = originalPath === '/api/console/restart';
|
||||
if (session && !session.rootOwner && session.user === undefined && orgId === null && !orgList && !hook?.publicRead && !instanceAction && path !== '/api/config' && path !== '/healthz' && !files.has(path)) return json(res, 404, { error: 'not-found' });
|
||||
if (session && !session.rootOwner && session.user === undefined) {
|
||||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = upstream = null;
|
||||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = upstream = null;
|
||||
}
|
||||
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
|
||||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
@@ -991,7 +991,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
({ bus = null, reads = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null,
|
||||
projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null,
|
||||
assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null,
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, upstream = null } = selected);
|
||||
processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, autonomyWrite = null, upstream = null } = selected);
|
||||
}
|
||||
// F3: the owner's settings writes reach the selected organization's bus too (limits, routing,
|
||||
// agents, assistant, instances, model proxy). The routes below keep their own checks.
|
||||
@@ -1052,7 +1052,9 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||||
if(autonomyPath) {
|
||||
if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'});
|
||||
return autonomyHTTP(req,res,{path,read:requestRead,write:send?requestWrite:null,session:ssoSession,
|
||||
// D1 (#1873): an organization's own autonomy writer (`autonomyWrite`, the four autonomy verbs only);
|
||||
// without one the route keeps the request writer, as before.
|
||||
return autonomyHTTP(req,res,{path,read:requestRead,write:send?(autonomyWrite??requestWrite):null,session:ssoSession,
|
||||
live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin});
|
||||
}
|
||||
if (systemTypesPath) {
|
||||
|
||||
@@ -371,6 +371,18 @@ test('toCanonical keeps library projects free of a root', () => {
|
||||
assert.deepEqual(out.projects, { lib: { kind: 'library' }, work: { root: '/__mosaic_org__/acme/projects/work' } });
|
||||
});
|
||||
|
||||
// #1873 CB1: copy bindings are server-held. An update keeps the stored ones whatever the browser
|
||||
// sent, and drops any the browser invented when none are stored.
|
||||
test('toCanonical keeps stored copyBindings and drops browser-sent ones on an update', () => {
|
||||
const stored = { id: 'acme', projects: { work: {} }, copyBindings: { rev: { state: 'pending', generation: 1 } } };
|
||||
const kept = toCanonical('acme', { projects: { work: {} }, copyBindings: { rev: { state: 'bound', generation: 9 } } }, stored);
|
||||
assert.deepEqual(kept.copyBindings, stored.copyBindings);
|
||||
const dropped = toCanonical('acme', { projects: { work: {} }, copyBindings: { rev: { state: 'bound' } } }, { id: 'acme', projects: { work: {} } });
|
||||
assert.equal(Object.hasOwn(dropped, 'copyBindings'), false);
|
||||
const removed = toCanonical('acme', { projects: { work: {} } }, stored);
|
||||
assert.deepEqual(removed.copyBindings, stored.copyBindings);
|
||||
});
|
||||
|
||||
test('the Settings page ships the organization assets', async t => {
|
||||
const c = await console_(t);
|
||||
const index = await c.call('GET', '/');
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// D1 (#1873, readiness audit P0-2): on a Console started with --dev-local-owner, an organization's
|
||||
// autonomy page read `writable:false` and refused every owner save. The organization options built in
|
||||
// cli.mjs carried no writer the autonomy route could use. This runs the real cli.mjs serve
|
||||
// --dev-local-owner against a real organization record; only the bus transports cli.mjs builds are
|
||||
// replaced (loader hook), so no bus or human proof is involved.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdirSync, readFileSync, renameSync, chmodSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { tmp, fixture } from '../../cli/tests/helpers.mjs';
|
||||
import { fakeOidc } from './helpers/fake-oidc.mjs';
|
||||
import { ownerAccounts } from './helpers/owner-accounts.mjs';
|
||||
import { SESSION_COOKIE } from '../src/sso.mjs';
|
||||
import { createOrg } from '../../organization/src/org-service.mjs';
|
||||
import { orgPaths } from '../../cli/src/config.mjs';
|
||||
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
|
||||
|
||||
const AUTONOMY = { revision: 3, autonomy: { default: 'near' } };
|
||||
const HOST = 'console.example.test', ORIGIN = `https://${HOST}`;
|
||||
|
||||
// `sso`: a deployed Console instead (--sso --send, a fake OP on loopback, the owner signed in).
|
||||
async function consoleFor(t, { sso = false } = {}) {
|
||||
const root = tmp(t, 'o-'), f = fixture(root), home = join(root, 'home'), configDir = join(home, '.config', 'mosaic-test');
|
||||
mkdirSync(join(home, '.config'), { recursive: true }); renameSync(join(root, 'config'), configDir);
|
||||
chmodSync(configDir, 0o700); chmodSync(join(configDir, 'businesses'), 0o700);
|
||||
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
|
||||
const doc = structuredClone(f.doc); doc.id = 'alpha'; doc.human = DEV_OWNER.sub; doc.projects.stack.root = '/__mosaic_org__/alpha/projects/stack';
|
||||
const org = await createOrg({ configDir, dataRoot: f.dataRoot, instance: 'test', principal: DEV_OWNER }, 'alpha', doc);
|
||||
const log = join(root, 'calls.jsonl'); writeFileSync(log, '');
|
||||
const busURL = new URL('../src/bus.mjs', import.meta.url).href, orgBusURL = new URL('../../cli/src/org-bus.mjs', import.meta.url).href;
|
||||
// The console transport answers the autonomy verbs from fixed rows and logs (kind, business, socket, verb, args, subject).
|
||||
const busShim = join(root, 'bus-shim.mjs'), orgBusShim = join(root, 'org-bus-shim.mjs'), hook = join(root, 'hook.mjs');
|
||||
writeFileSync(busShim, `import { appendFileSync } from 'node:fs';
|
||||
import { BusReadError } from ${JSON.stringify(busURL)};
|
||||
export * from ${JSON.stringify(busURL)};
|
||||
const record = (kind, o, verb, args, extra = {}) => appendFileSync(${JSON.stringify(log)}, JSON.stringify({ kind, business: o.business ?? null, socket: o.socket ?? null, verb, args, ...extra }) + '\\n');
|
||||
export const busCaller = o => async (verb, args) => { record('human', o, verb, args); return verb === 'plugins' ? { plugins: { channel: ['cli', 'webui'] } } : []; };
|
||||
export const consoleCall = o => async (verb, args, { subject }) => {
|
||||
record('console', o, verb, args, { subject });
|
||||
if (verb === 'autonomy.get.console') return { revision: 3, autonomy: { default: 'in' } };
|
||||
if (verb === 'autonomy.set.console') return { revision: 4, autonomy: args.autonomy, receipt: { action: 'autonomy.set', revision: 4 } };
|
||||
if (['autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console'].includes(verb)) return { answered: verb };
|
||||
throw new BusReadError('unavailable', 'fixture: ' + verb);
|
||||
};
|
||||
`);
|
||||
writeFileSync(orgBusShim, `export * from ${JSON.stringify(orgBusURL)};\nexport const busStatus = async () => ({ healthy: true });\n`);
|
||||
writeFileSync(hook, `import { registerHooks } from 'node:module';
|
||||
registerHooks({ resolve(specifier, context, next) {
|
||||
if (context.parentURL?.endsWith('/webui/src/cli.mjs')) {
|
||||
if (specifier === './bus.mjs') return { url: ${JSON.stringify(pathToFileURL(busShim).href)}, shortCircuit: true };
|
||||
if (specifier === '../../cli/src/org-bus.mjs') return { url: ${JSON.stringify(pathToFileURL(orgBusShim).href)}, shortCircuit: true };
|
||||
}
|
||||
return next(specifier, context);
|
||||
} });
|
||||
`);
|
||||
const env = { ...f.env, HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json'), NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` };
|
||||
let op = null, a = null;
|
||||
if (sso) {
|
||||
op = await fakeOidc(); t.after(() => op.close());
|
||||
a = op.issuer('a');
|
||||
const provider = { id: a.name, issuer: a.issuer, clientId: a.clientId, clientSecret: { slot: a.name }, algs: [...new Set(a.keys.map(k => k.alg))], subjects: [{ sub: `user-${a.name}`, name: `person-${a.name}` }] };
|
||||
const config = JSON.parse(readFileSync(env.MOSAIC_CONFIG, 'utf8'));
|
||||
writeFileSync(env.MOSAIC_CONFIG, JSON.stringify({ ...config, console: { sso: { publicOrigin: ORIGIN, providers: [provider] } } }));
|
||||
mkdirSync(join(configDir, 'secrets', 'mosaic-stack'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(configDir, 'secrets', 'mosaic-stack', a.name), `${a.clientSecret}\n`, { mode: 0o600 });
|
||||
writeFileSync(join(configDir, 'accounts.json'), JSON.stringify({ accountsVersion: 1, revision: 1, users: ownerAccounts({ providers: [provider] }).load().users.map(u => ({ ...u, created: '2026-11-10T00:00:00Z' })), memberships: [], invites: [] }), { mode: 0o600 });
|
||||
writeFileSync(join(root, 'ca.pem'), op.cert);
|
||||
env.NODE_EXTRA_CA_CERTS = join(root, 'ca.pem');
|
||||
}
|
||||
const mode = sso ? ['--sso', '--send'] : ['--dev-local-owner'];
|
||||
const child = spawn(process.execPath, [new URL('../src/cli.mjs', import.meta.url).pathname, 'serve', '--port', '0', '--board', 'http://127.0.0.1:9', ...mode], { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
let out = ''; child.stdout.on('data', c => out += c); child.stderr.on('data', c => out += c);
|
||||
t.after(() => { if (child.exitCode === null) child.kill('SIGTERM'); });
|
||||
for (let i = 0; i < 300 && !/Mosaic Console: (http:\/\/127\.0\.0\.1:\d+\/)/.test(out); i++) {
|
||||
if (child.exitCode !== null) break;
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
const base = /Mosaic Console: (http:\/\/127\.0\.0\.1:(\d+)\/)/.exec(out);
|
||||
assert.ok(base, `the Console did not start:\n${out}`);
|
||||
const calls = () => readFileSync(log, 'utf8').trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
|
||||
return { port: Number(base[2]), calls, org, op, a, rootSocket: join(f.dataRoot, 'bus', 'broker.sock') };
|
||||
}
|
||||
|
||||
function call(port, path, { method = 'GET', body = null, headers = {}, host = `127.0.0.1:${port}`, origin = `http://${host}` } = {}) {
|
||||
const h = { host, ...(method === 'GET' ? {} : { origin, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' }), ...headers };
|
||||
for (const k of Object.keys(h)) if (h[k] === null) delete h[k];
|
||||
return new Promise((resolve, reject) => {
|
||||
const r = request({ host: '127.0.0.1', port, path, method, headers: h }, res => {
|
||||
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
||||
res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch {} resolve({ status: res.statusCode, headers: res.headers, body: json }); });
|
||||
});
|
||||
r.on('error', reject); r.end(body ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('the dev-local owner reads an organization autonomy page as writable and a save reaches that organization bus', { timeout: 60000 }, async t => {
|
||||
const { port, calls, org } = await consoleFor(t);
|
||||
const subject = { provider: DEV_OWNER.provider, iss: DEV_OWNER.iss, sub: DEV_OWNER.sub };
|
||||
const read = await call(port, '/api/orgs/alpha/autonomy');
|
||||
assert.equal(read.status, 200, JSON.stringify(read.body));
|
||||
assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true });
|
||||
const saved = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY) });
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true });
|
||||
const socket = orgPaths(org.root).socket;
|
||||
assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket: s, verb, args, subject: who }) => ({ business, socket: s, verb, args, subject: who })), [
|
||||
{ business: 'alpha', socket, verb: 'autonomy.get.console', args: {}, subject },
|
||||
{ business: 'alpha', socket, verb: 'autonomy.set.console', args: AUTONOMY, subject },
|
||||
]);
|
||||
// Review, confirm and reverse go to the same organization bus as the owner (D1 fix round).
|
||||
for (const [step, body, verb] of [['review', { decisionClass: 'merge' }, 'autonomy.review.console'], ['confirm', { id: 'd-1', confirm: true, reason: 'checked' }, 'decision.confirm.console'], ['reverse', { id: 'd-1' }, 'decision.reverse.console']]) {
|
||||
const r = await call(port, `/api/orgs/alpha/autonomy/${step}`, { method: 'POST', body: JSON.stringify(body) });
|
||||
assert.equal(r.status, 200, `${step}: ${JSON.stringify(r.body)}`);
|
||||
assert.deepEqual(r.body, { answered: verb, writable: true });
|
||||
assert.deepEqual(calls().filter(c => c.kind === 'console').at(-1), { kind: 'console', business: 'alpha', socket, verb, args: body, subject });
|
||||
}
|
||||
// The write keeps the route's own checks: another site saves nothing.
|
||||
const before = calls().length;
|
||||
for (const headers of [{ origin: 'http://evil.example' }, { 'sec-fetch-site': 'cross-site' }]) {
|
||||
const r = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), headers });
|
||||
assert.equal(r.status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(calls().length, before);
|
||||
// Request intake and project remote writes stay unavailable for the organizations release (README inventory).
|
||||
const config = (await call(port, '/api/orgs/alpha/config')).body;
|
||||
assert.equal(config.requestWrites, false); assert.equal(config.projectRemoteWrites, false);
|
||||
});
|
||||
|
||||
test('the organization autonomy writer passes the four autonomy verbs and nothing else', async () => {
|
||||
// Imported here so the other tests in this file also run against BASE, which has no such export.
|
||||
const { AUTONOMY_WRITE_VERBS, autonomyCaller } = await import('../src/autonomy-http.mjs');
|
||||
const seen = [];
|
||||
const write = autonomyCaller(async (verb, args, options) => { seen.push(verb); return { verb, args, options }; });
|
||||
for (const verb of AUTONOMY_WRITE_VERBS) assert.equal((await write(verb, {}, { subject: {} })).verb, verb);
|
||||
assert.deepEqual(AUTONOMY_WRITE_VERBS, ['autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console']);
|
||||
for (const verb of ['autonomy.get.console', 'request.draft.console', 'project.remote.set.console', 'decision.resolve.console', 'hire.start.console', 'prd.revoke.console']) {
|
||||
await assert.rejects(write(verb, {}, { subject: {} }), /does not call/);
|
||||
}
|
||||
assert.deepEqual(seen, AUTONOMY_WRITE_VERBS);
|
||||
});
|
||||
|
||||
// Without --dev-local-owner nothing changed (D1 acceptance). A deployed Console (--sso --send) has no
|
||||
// organization routes, and its own autonomy route still reads and writes through the instance's request
|
||||
// transport (the root bus, no business), for its signed-in owner, exactly as before D1.
|
||||
test('a deployed --sso --send Console is unchanged: no organization autonomy route, and the root route keeps the request transport', { timeout: 60000 }, async t => {
|
||||
const { port, calls, op, a, rootSocket } = await consoleFor(t, { sso: true });
|
||||
const login = await call(port, '/auth/login', { host: HOST });
|
||||
assert.equal(login.status, 303);
|
||||
const loginCookie = [login.headers['set-cookie'] ?? []].flat()[0].split(';')[0];
|
||||
const granted = op.grant(a, login.headers.location);
|
||||
const state = new URL(login.headers.location).searchParams.get('state');
|
||||
const signed = await call(port, `/auth/callback?${new URLSearchParams({ code: granted.code, state })}`, { host: HOST, headers: { cookie: loginCookie } });
|
||||
const cookie = [signed.headers['set-cookie'] ?? []].flat().find(c => c.startsWith(`${SESSION_COOKIE}=`) && !c.includes('Max-Age=0'))?.split(';')[0];
|
||||
assert.ok(cookie, `no session: ${signed.status}`);
|
||||
const orgRead = await call(port, '/api/orgs/alpha/autonomy', { host: HOST, headers: { cookie } });
|
||||
assert.equal(orgRead.status, 404); assert.deepEqual(orgRead.body, { error: 'not found' });
|
||||
const orgSave = await call(port, '/api/orgs/alpha/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } });
|
||||
assert.equal(orgSave.status, 403); assert.deepEqual(orgSave.body, { error: 'read-only', message: 'the Console preview is read-only' });
|
||||
const read = await call(port, '/api/autonomy', { host: HOST, headers: { cookie } });
|
||||
assert.equal(read.status, 200, JSON.stringify(read.body));
|
||||
assert.deepEqual(read.body, { revision: 3, autonomy: { default: 'in' }, writable: true });
|
||||
const saved = await call(port, '/api/autonomy', { method: 'POST', body: JSON.stringify(AUTONOMY), host: HOST, origin: ORIGIN, headers: { cookie } });
|
||||
assert.equal(saved.status, 200, JSON.stringify(saved.body));
|
||||
assert.deepEqual(saved.body, { revision: 4, autonomy: { default: 'near' }, receipt: { action: 'autonomy.set', revision: 4 }, writable: true });
|
||||
const subject = { provider: a.name, iss: a.issuer, sub: `user-${a.name}` };
|
||||
assert.deepEqual(calls().filter(c => c.kind === 'console').map(({ business, socket, verb, subject: who }) => ({ business, socket, verb, subject: who })), [
|
||||
{ business: null, socket: rootSocket, verb: 'autonomy.get.console', subject },
|
||||
{ business: null, socket: rootSocket, verb: 'autonomy.set.console', subject },
|
||||
]);
|
||||
});
|
||||
Reference in new issue
Block a user