Compare commits
20
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5320e3d2c0 | ||
|
|
16cdf5cdc1 | ||
|
|
1a45a7274b | ||
|
|
17eecb2d65 | ||
|
|
8fec494694 | ||
|
|
4a122699a0 | ||
|
|
f382699d47 | ||
|
|
5e93356cfa | ||
|
|
89205a6163 | ||
|
|
6342940634 | ||
|
|
cfd7e612d3 | ||
|
|
afe131444c | ||
|
|
0b52249cc3 | ||
|
|
b7c9eee603 | ||
|
|
1d07df0d61 | ||
|
|
be698e602b | ||
|
|
d085b16136 | ||
|
|
4c8bf9ca4e | ||
|
|
e588fb32cb | ||
|
|
e3577e9d31 |
No files matched your search
@@ -42,9 +42,15 @@ green at every step. Not production software — a proven foundation.
|
||||
rewritten, only pruned via `prune` with a receipt.
|
||||
6. **Fail closed**: missing or invalid config/policy refuses the operation.
|
||||
Never improvise around a refusal; diagnose it.
|
||||
7. **Policy**: missions govern tasks (least-privilege intersection — a task
|
||||
narrows, never widens). Role authority is declared in `roles/` and changes
|
||||
only via reviewed commits.
|
||||
7. **Policy**: missions govern tasks; a task narrows, never widens. The
|
||||
rails, the core vocabulary of tools and actions, and the shipped role
|
||||
templates in `roles/` change only by reviewed commit. An organization's
|
||||
own roles are copies or compositions of known parts. One takes effect
|
||||
only through a decision, resolved where the organization's owner-set
|
||||
autonomy level sends it, by default a human with `org.hire`. The
|
||||
decision lists every grant above the chief's own as a flagged line,
|
||||
which only the owner approves. Only the chief raises hires, and a hired
|
||||
agent cannot hire.
|
||||
8. **Git**: commit only after applicable suites are green. Work on the
|
||||
owner-authorized `refactor` branch; never force-push. Push remains an explicit
|
||||
act. Do not merge into `next` or `main` without separate authorization.
|
||||
@@ -104,7 +110,8 @@ Finish all independent work first, then ask one focused question only when:
|
||||
|
||||
Repository gates still apply. In particular, a successful implementation or a
|
||||
broad request to “finish” does not by itself authorize push, merge, deployment,
|
||||
release, production changes, policy/role expansion, or access to secrets. Perform
|
||||
release, production changes, policy/role expansion, or access to secrets. An approved
|
||||
hire decision is that authority for the one agent it names. Perform
|
||||
such an action only when the established plan explicitly includes it. If blocked,
|
||||
report the exact boundary, what is complete, the recommended resolution, and the
|
||||
specific action that will resume; do not use “waiting for confirmation” as a
|
||||
@@ -206,7 +213,7 @@ Full reference — usage, fields, exit codes, safety notes:
|
||||
- `LAYERS.md` — implemented vs deferred layers
|
||||
- `docs/SESSIONS.md` — session registry
|
||||
- `adapters/README.md` — the harness adapter contract
|
||||
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
|
||||
- `roles/` — shipped role templates and the conductor policy
|
||||
|
||||
## Recovery rule
|
||||
|
||||
|
||||
+178
-86
@@ -9,7 +9,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
|
||||
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
|
||||
import { loadRequirements } from '../../tasks/src/requirements.mjs';
|
||||
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
|
||||
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
|
||||
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
|
||||
|
||||
// Kept closed while S1 lands; its vocabulary is the integration authority.
|
||||
export const ACTIONS = Object.freeze([
|
||||
@@ -43,6 +43,7 @@ export const ACTIONS = Object.freeze([
|
||||
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
'role.change',
|
||||
'credential.mint',
|
||||
'spend',
|
||||
'deploy',
|
||||
@@ -85,6 +86,7 @@ const ROUTED = new Set([
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
'role.change',
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write.
|
||||
'library.write',
|
||||
]);
|
||||
@@ -172,7 +174,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
|
||||
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
|
||||
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
|
||||
@@ -193,6 +195,15 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
|
||||
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
|
||||
// The person's task-store writes, distinct from the agent's singular task.* actions.
|
||||
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
|
||||
// #1873 T1: the human's definition writes, each with its [allowed, required] argument keys. Kept here,
|
||||
// not imported, as the sandbox client loads this file; runtime.mjs maps each verb to the verb module
|
||||
// (organization/src/definition-verbs.mjs names the same four, and a test holds them equal).
|
||||
export const DEFINITION_BUS_KEYS = Object.freeze({
|
||||
'definition.import': [['revision', 'template', 'as'], ['revision', 'template']],
|
||||
'definition.invent': [['revision', 'name', 'definition'], ['revision', 'name', 'definition']],
|
||||
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
|
||||
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
|
||||
});
|
||||
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
|
||||
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
|
||||
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
|
||||
@@ -217,14 +228,6 @@ const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
|
||||
// Row 194 S6 (D2.2): the stop outcomes a fire's lifecycle.done reports, each written as its own event.
|
||||
const FIRE_STOPS = ['killed', 'alive'];
|
||||
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
|
||||
// required. Each carries the reason the log records.
|
||||
const HIRE_FIRE_ARGS = Object.freeze({
|
||||
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
|
||||
'role.fire': [['name', 'reason'], ['name', 'reason']],
|
||||
});
|
||||
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
|
||||
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
|
||||
// What instance.request and the host ops answer about a request.
|
||||
const lifecycleView = (r) => ({
|
||||
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
|
||||
@@ -593,6 +596,7 @@ export class Broker {
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#hires;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
@@ -656,7 +660,7 @@ export class Broker {
|
||||
#processing;
|
||||
#taskTracker;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -679,6 +683,7 @@ export class Broker {
|
||||
this.#secretCheck = secretCheck;
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
this.#hires = hires;
|
||||
this.#pluginService = pluginService;
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
@@ -1552,6 +1557,11 @@ export class Broker {
|
||||
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
|
||||
#copyRefused(b, instance) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
|
||||
if(copy?.level==='root') {
|
||||
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
|
||||
if(refusal)fail(refusal);
|
||||
}
|
||||
if (!copy || copy.state === 'instance') return;
|
||||
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
|
||||
@@ -1839,6 +1849,7 @@ export class Broker {
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.human) fail('agent-required');
|
||||
this.#copyRefused(this.#business(s.business),s.role);
|
||||
const h = this.#own(s);
|
||||
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
|
||||
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
|
||||
@@ -2274,6 +2285,14 @@ export class Broker {
|
||||
run: null,
|
||||
});
|
||||
}
|
||||
// Trusted account transport supplies freshly resolved membership, never request arguments.
|
||||
// This capability reaches only the A4 verbs; it cannot pass existing owner Console guards.
|
||||
bindHireConsole({business, actor, subject, rights, owner = false, instanceOwner = false}) {
|
||||
this.#business(business);
|
||||
identifier(actor);
|
||||
if (!object(subject) || !['provider','iss','sub'].every(k=>typeof subject[k]==='string' && subject[k]) || !Array.isArray(rights)) fail('human-required');
|
||||
return this.#cap({business,human:actor,role:null,run:null,via:'console',console:Object.freeze({...subject}),hireMembership:Object.freeze({actor,rights:[...rights],agent:false,owner,instanceOwner,subject:{...subject}})});
|
||||
}
|
||||
bindReader({ business }) {
|
||||
this.#business(business);
|
||||
return this.#cap({ business, reader: true, role: null, run: null, human: false });
|
||||
@@ -2431,6 +2450,7 @@ export class Broker {
|
||||
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
|
||||
new Set(options.map(o => o.key)).size !== options.length) return null;
|
||||
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
|
||||
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...raise.hire} : null;
|
||||
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
|
||||
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
|
||||
const approve = options.find((o) => o.key === raise.approvalChoice);
|
||||
@@ -3346,7 +3366,8 @@ export class Broker {
|
||||
const result = await write('instance.fire');
|
||||
step = 'C';
|
||||
this.#store.transaction(() => {
|
||||
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||
if(sc.human)this.#event(sc,'action.allowed',{action:'role.fire',target:args.name,reason:args.reason,transaction:result.transaction??null},args.name);
|
||||
else this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||
this.#event(sc, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null, job, grants: out.grants, grantsLeft: out.grantsLeft }, name);
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -6075,14 +6096,25 @@ export class Broker {
|
||||
return this.#withLast(cap, s, request);
|
||||
}
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
|
||||
{
|
||||
const result = this.#chiefLifecycle(s, request.verb, structuredClone(args));
|
||||
return result?.then ? result.catch(error => { throw this.#refused(s, error, request); }) : result;
|
||||
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
|
||||
if(request.verb==='hire.templates.console') {
|
||||
keys(args,[]);const caller=this.#hireCaller(s);
|
||||
if(!caller.rights.includes('org.hire'))fail('right-required');
|
||||
if(!this.#hires?.templates)fail('not-configured');
|
||||
return this.#hires.templates();
|
||||
}
|
||||
if (request.verb === 'instance.hire' || request.verb === 'instance.fire') fail('decision-required');
|
||||
if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' ||
|
||||
(request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) {
|
||||
const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args;
|
||||
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
|
||||
}
|
||||
if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
|
||||
if (['hire.preview.console','hire.resolve.console'].includes(request.verb))
|
||||
return this.#hireConsole(s,request.verb,args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
|
||||
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
|
||||
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
|
||||
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
|
||||
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
|
||||
// file lists organization.change. It takes the same keys, runs the same write path and
|
||||
// logs the same receipt; only the actor differs: the role definition and instance.
|
||||
@@ -6137,6 +6169,13 @@ export class Broker {
|
||||
else if (request.verb === "library.limits.set") {
|
||||
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
|
||||
this.#human(s);
|
||||
}
|
||||
// #1873 T1: the definition writes need org.hire. Until row 193, as #mayHuman, the business's
|
||||
// one human (the owner) passes; the tester is refused above and an agent here.
|
||||
else if (Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
|
||||
keys(args, ...DEFINITION_BUS_KEYS[request.verb]);
|
||||
this.#human(s);
|
||||
if (s.human !== this.#business(s.business).human) fail("human-required");
|
||||
} else {
|
||||
keys(args, ORGANIZATION_KEYS, ["revision"]);
|
||||
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
|
||||
@@ -6253,6 +6292,8 @@ export class Broker {
|
||||
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
|
||||
} catch (e) {
|
||||
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
|
||||
// org-defscan (#1873): the issue's definition check; its reason names where, never the value.
|
||||
if (e instanceof BusError && ['definition-holds-credential', 'definition-scan-unreadable'].includes(e.code)) throw e;
|
||||
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
|
||||
if (e.name === 'BusinessError') fail('invalid-request');
|
||||
throw new BusError('outcome-unknown');
|
||||
@@ -6272,47 +6313,121 @@ export class Broker {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
|
||||
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
|
||||
// transaction, so the grant is checked before it and the use logged only after it, as
|
||||
// organization.set's grant is.
|
||||
#chiefLifecycle(s, verb, a) {
|
||||
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
|
||||
this.#agent(s);
|
||||
const [allowed, required] = HIRE_FIRE_ARGS[action];
|
||||
keys(a, allowed, required);
|
||||
this.#hireFireArgs(action, a);
|
||||
const args = a;
|
||||
const b = this.#business(s.business);
|
||||
if (b.launch?.by !== s.role) fail('role-not-chief');
|
||||
if (!this.#organization) fail('not-configured');
|
||||
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
|
||||
// (decision-required), and these verbs take none.
|
||||
const authority = { target: args.name };
|
||||
this.#checkAuthority(s, action, authority);
|
||||
// D2.4: a kept name is neither hired nor fired.
|
||||
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
|
||||
const actor = b.roles[s.role].definition + '.' + s.role;
|
||||
const context = { instance: s.role, run: s.run, reason: args.reason };
|
||||
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
|
||||
const { reason: _, ...written } = args;
|
||||
const write = (op) => {
|
||||
try {
|
||||
const result = this.#organization(s.business, op, written, actor, context);
|
||||
return result?.then ? result.catch(error => this.#organizationFail(error, verb)) : result;
|
||||
} catch (e) {
|
||||
this.#organizationFail(e, verb);
|
||||
}
|
||||
#hireCaller(s) {
|
||||
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
|
||||
if(s.hireMembership) return s.hireMembership;
|
||||
if(s.human!==this.#business(s.business).human)fail('human-required');
|
||||
return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console};
|
||||
}
|
||||
async #raiseHire(s, input, immediate) {
|
||||
const b=this.#business(s.business);
|
||||
// Change is a replacement composition and uses the existing role.hire grant.
|
||||
const chiefAllowed=()=>{
|
||||
this.#agent(s);
|
||||
if(s.role!==this.#business(s.business).launch?.by)fail('role-not-chief');
|
||||
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 (hire) {
|
||||
this.#hireable(s, args.definition);
|
||||
const result = write('instance.hire');
|
||||
const finish = result => {
|
||||
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
|
||||
return result;
|
||||
};
|
||||
return result?.then ? result.then(finish) : finish(result);
|
||||
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
|
||||
else chiefAllowed();
|
||||
if(!this.#hires)fail('not-configured');
|
||||
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
|
||||
const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt});
|
||||
// Recheck the chief after asynchronous preparation; a released run cannot raise.
|
||||
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');
|
||||
}
|
||||
const action=`role.${p.op}`;
|
||||
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,'human',`${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},did);
|
||||
});
|
||||
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
|
||||
return {...this.#decisionView(this.#decision(s,did)),hire:p};
|
||||
}
|
||||
async #hireConsole(s,verb,a) {
|
||||
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
|
||||
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
|
||||
identifier(a.id);
|
||||
const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id);
|
||||
let p=raise?.hire;
|
||||
if(!['role.hire','role.fire','role.change'].includes(d.action) || !p)fail('decision-not-eligible');
|
||||
const closed=this.#closed(d.id);
|
||||
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(a.adjust!==undefined) {
|
||||
if(!this.#hires?.preview)fail('not-configured');
|
||||
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
|
||||
}
|
||||
if((p.flagged.length || p.credentialLines.length) && !caller.owner || p.level==='root' && !caller.instanceOwner)fail('hire-owner-required');
|
||||
if(verb==='hire.preview.console')return {id:d.id,business:s.business,question:d.question,kind:'hire',options:JSON.parse(d.options),...p};
|
||||
if(a.digest!==p.digest)fail('decision-stale');
|
||||
if(a.choice==='yes') { if(!this.#hires)fail('not-configured'); await this.#hires.check(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}); }
|
||||
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
|
||||
// A durable close/consume precedes the external file effect. A crash never replays an approval.
|
||||
this.#store.transaction(()=>{
|
||||
if(this.#closed(d.id))fail('decision-closed');
|
||||
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',caller.actor,a.choice,'console');
|
||||
this.#event(s,'human.input',{kind:'answer',decision:d.id,op:'resolved',choice:a.choice,via:'console',subject:s.console},d.id);
|
||||
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
|
||||
});
|
||||
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
|
||||
if(!this.#hires)fail('not-configured');
|
||||
try {
|
||||
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
|
||||
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust});
|
||||
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
|
||||
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
|
||||
} catch(e) {
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:e.code??'outcome-unknown'},d.id));
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
#expireHire(s,d,p,insideTransaction=false) {
|
||||
if(!p || Date.parse(p.expiresAt)>Math.max(Date.now(),this.#clock) || !Number.isFinite(Date.parse(p.expiresAt)))return false;
|
||||
const close=()=>{
|
||||
if(this.#closed(d.id))return;
|
||||
this.#store.run('INSERT INTO decision_events(decision,at,op,by,via) VALUES(?,?,?,?,?)',d.id,this.#now(),'withdrawn','expiry','broker');
|
||||
this.#event({business:s.business,role:null,run:null},'action.allowed',{operation:'hire.expired',decision:d.id},d.id);
|
||||
};
|
||||
if(insideTransaction)close();else this.#store.transaction(close);
|
||||
return true;
|
||||
}
|
||||
#expireHires(s) {
|
||||
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
|
||||
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
|
||||
}
|
||||
async #stopCopyApproved(s,p) {
|
||||
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
|
||||
const b=this.#business(s.business);
|
||||
if(!b.roles[instance])return;
|
||||
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
|
||||
const set=this.#store.transaction(()=>{
|
||||
const held=this.#threaded(s.business,instance)?this.#threadHolders(s.business,instance):[this.#holder(s.business,instance)].filter(h=>h?.op==='claim');
|
||||
for(const h of held)this.#claimEnd(s,h,'revoke',p.id);
|
||||
const runs=[...this.#runs.values()].filter(r=>r.business===s.business&&r.role===instance).map(r=>r.run);
|
||||
const request=runs.length?randomUUID():null;
|
||||
if(request)this.#event(s,'action.allowed',{action:'lifecycle.end',request,via:'human',by:s.human,runs,reason:'Approved fire decision',kill:true},instance);
|
||||
this.#event(s,'action.allowed',{action:'fire.stopped',request,decision:p.id},instance);
|
||||
return this.#grantSet(s.business,instance);
|
||||
});
|
||||
if(set.length)await this.#stepG(s.business,instance,set,{job:p.id,reason:'Approved fire decision',by:s.human,flag:{fire:true}});
|
||||
}
|
||||
// The existing row-281 fire executor, now entered only after a consumed human decision.
|
||||
#fireApproved(s,p) {
|
||||
const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire';
|
||||
const b=this.#business(s.business),authority={target:p.name};
|
||||
const write=op=>{
|
||||
try {const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human,{decision:p.id,digest:p.digest});
|
||||
return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result;
|
||||
} catch(e) {this.#organizationFail(e,verb);}
|
||||
};
|
||||
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
|
||||
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
||||
if (b.launch?.by === args.name) fail('organization-instance-referenced');
|
||||
@@ -6354,7 +6469,7 @@ export class Broker {
|
||||
// A non-empty set: step G, B and C run as a job, and the verb answers now with no transaction.
|
||||
if (stopped.job) {
|
||||
const { job, set, ...rest } = stopped;
|
||||
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: null };
|
||||
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human };
|
||||
this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set));
|
||||
return { ...rest, job, revoking: set.map((g) => g.label), done: false };
|
||||
}
|
||||
@@ -6363,41 +6478,13 @@ export class Broker {
|
||||
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
|
||||
const finish = result => {
|
||||
this.#store.transaction(() => {
|
||||
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||
this.#event(s,'action.allowed',{action:'role.fire',operation:'hire.apply',decision:p.id,transaction:result.transaction??null},args.name);
|
||||
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
|
||||
});
|
||||
return { ...result, ...stopped };
|
||||
};
|
||||
return result?.then ? result.then(finish) : finish(result);
|
||||
}
|
||||
// The shape of hire and fire arguments.
|
||||
#hireFireArgs(action, args) {
|
||||
identifier(args.name);
|
||||
// FED R3 r4 (sage-2557): `user` is the granted user's principal, as checkBusiness refuses it.
|
||||
if (args.name === 'human' || args.name === 'tester' || args.name === 'user') fail('invalid-request');
|
||||
string(args.reason, 500);
|
||||
if (action === 'role.fire') return;
|
||||
identifier(args.definition);
|
||||
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
|
||||
}
|
||||
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
|
||||
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
|
||||
#hireable(s, definition) {
|
||||
if (!this.#roleFile) fail('not-configured');
|
||||
const b = this.#business(s.business);
|
||||
const load = (name) => {
|
||||
try {
|
||||
return this.#roleFile(name);
|
||||
} catch (e) {
|
||||
if (e.name === 'BusinessError') fail('definition-not-hireable');
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
const def = load(definition);
|
||||
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
|
||||
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
|
||||
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
|
||||
}
|
||||
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
|
||||
// outcome-unknown: the write may have committed.
|
||||
#organizationFail(e, verb) {
|
||||
@@ -6413,6 +6500,8 @@ export class Broker {
|
||||
if ((verb === "instance.hire" || verb === "instance.fire") && e.code === "definition-not-hireable") fail(e.code);
|
||||
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
|
||||
if (e.code === "instance-kept") fail(e.code);
|
||||
// #1873 T1: the definition writer's refusals pass by code; a composition refusal stays invalid-request.
|
||||
if (Object.hasOwn(DEFINITION_BUS_KEYS, verb) && /^definition-[a-z-]+$/.test(e.code ?? "")) fail(e.code);
|
||||
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
|
||||
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
|
||||
// Row 65: the route checks every write of an instance's route vars runs.
|
||||
@@ -7296,6 +7385,7 @@ export class Broker {
|
||||
identifier(a.id);
|
||||
if (a.note !== undefined) string(a.note);
|
||||
const d = this.#decision(s, a.id);
|
||||
if (['role.hire','role.fire','role.change'].includes(d.action) && ['decision.resolve','decision.resolve.console'].includes(verb)) fail('hire-resolution-required');
|
||||
if (this.#closed(d.id)) fail('decision-closed');
|
||||
// Recheck eligibility, context digest and membership in the current options in this
|
||||
// transaction, before any resolution, audit, notification or grant execution.
|
||||
@@ -7755,6 +7845,7 @@ export class Broker {
|
||||
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
|
||||
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
|
||||
if (!s.human && !s.reader) this.#agent(s);
|
||||
if (verb === 'inbox')this.#expireHires(s);
|
||||
if (verb === 'inbox')
|
||||
return this.#store
|
||||
.all(
|
||||
@@ -7800,6 +7891,7 @@ export class Broker {
|
||||
case 'decisions': {
|
||||
if (!s.human && !s.reader) fail('human-required');
|
||||
keys(a, ['list'], ['list']);
|
||||
this.#expireHires(s);
|
||||
if (a.list === 'roles')
|
||||
return this.#store
|
||||
.all(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BusError } from './broker.mjs';
|
||||
import { isHostKind } from '../../organization/src/vocabulary.mjs';
|
||||
import { scanOrganizations } from './definition-scan.mjs';
|
||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
|
||||
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
|
||||
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
|
||||
@@ -10,7 +11,10 @@ const dateKey = (service) => (isHostKind(service) ? 'rotateBy' : DATE_KEY[servic
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
export function credentialVerbs({ credentials, writer }) {
|
||||
// `organizations` lists every business this bus serves; issue checks each one's definitions. Without
|
||||
// it, only the issuing business's. `boot` is the bus's boot copy snapshot's definitions, { org,
|
||||
// definitions }, checked as well.
|
||||
export function credentialVerbs({ credentials, writer, organizations = null, boot = null }) {
|
||||
const state = (instance, service) =>
|
||||
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
|
||||
return (id, verb, args, actor) => {
|
||||
@@ -27,8 +31,13 @@ export function credentialVerbs({ credentials, writer }) {
|
||||
const next = structuredClone(document);
|
||||
next.roles[agent].credentials[service] = { store: true, [dateKey(service)]: date };
|
||||
const detail = { verb: kind, agent, kind: [service], date };
|
||||
const status = credentials.issue(instance, service, token, date, () =>
|
||||
writer.write(id, next, { revision, actor, detail }),
|
||||
// org-defscan (#1873): the value must not already sit in any organization's definitions. The
|
||||
// check runs inside issue, before the store, the file or memory changes, on this bus loop, so
|
||||
// no launch admission can run between the check and the value becoming held.
|
||||
const status = credentials.issue(
|
||||
instance, service, token, date,
|
||||
() => writer.write(id, next, { revision, actor, detail }),
|
||||
(clean) => scanOrganizations({ organizations: organizations?.() ?? [id], own: { id, document }, readRaw: (b) => writer.readRaw(b), boot }, clean),
|
||||
);
|
||||
// Row 210 (design D9): a host key's public line and fingerprint, for ops-01's authorized_keys.
|
||||
const host = status.fingerprint ? { publicKey: status.publicKey, fingerprint: status.fingerprint } : {};
|
||||
|
||||
@@ -87,6 +87,19 @@ export function parseHostKey(text) {
|
||||
}
|
||||
return { text: lines.join('\n') + '\n', publicLine, fingerprint, seed, needles };
|
||||
}
|
||||
// Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token
|
||||
// or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is
|
||||
// about to hold.
|
||||
function assertNotHeld(value, entries) {
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
for (const { token, model } of entries)
|
||||
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
|
||||
}
|
||||
// D8: refuses credential-leak when the value's JSON text holds any part of the key.
|
||||
export function assertCleanKey(value, key) {
|
||||
let encoded;
|
||||
@@ -458,10 +471,13 @@ export class Credentials {
|
||||
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
|
||||
// `commit` (the business file's reference becomes store), then memory. The token comes from the
|
||||
// caller's stdin and is never logged. Returns the entry's new status.
|
||||
issue(instance, service, token, date, commit = () => {}) {
|
||||
// org-defscan (#1873): `check` runs before anything changes. It gets an assertClean for this value
|
||||
// alone, the test assertClean makes once the value is held; a throw leaves the store as it was.
|
||||
issue(instance, service, token, date, commit = () => {}, check = () => {}) {
|
||||
this.#known(instance, service);
|
||||
if (!day(date)) deny('credential-date');
|
||||
const value = Credentials.#format(service, token);
|
||||
check((v) => assertNotHeld(v, [value]));
|
||||
this.#invalidateModelProof(instance, service);
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
@@ -600,14 +616,7 @@ export class Credentials {
|
||||
return this.use(session.business + '/' + session.role, service, operation, options);
|
||||
}
|
||||
assertClean(value) {
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
for (const { token, model } of this.#tokens.values())
|
||||
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
|
||||
assertNotHeld(value, this.#tokens.values());
|
||||
}
|
||||
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
|
||||
busy(instance, service) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// #1873 T1 follow-up (org-defscan): the live credential store's held-secret check on an org
|
||||
// definition's text. A definition is role text written into the organization file; launch hands its
|
||||
// instructions to an agent as the contract. A file edited by hand, or while the bus was down, never
|
||||
// went through the writer's check, so bus start and every launch run this one.
|
||||
//
|
||||
// The answer names where a held credential sits, never the text there. A key that itself holds a
|
||||
// credential is named by its parent ("personality key"). Not imported by broker.mjs (the sandbox
|
||||
// loads that file); runtime.mjs and credential-verbs.mjs are its callers.
|
||||
import { BusError } from './broker.mjs';
|
||||
|
||||
export const DEFINITION_HOLDS_CREDENTIAL = 'definition-holds-credential';
|
||||
// An organization file credentials.issue could not read to check: the issue refuses.
|
||||
export const DEFINITION_SCAN_UNREADABLE = 'definition-scan-unreadable';
|
||||
// A definition name the scan quotes: the organization loader's id pattern. Anything else is "*".
|
||||
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
// A path segment the scan quotes; anything else becomes "*" (the hit is still reported).
|
||||
const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MAX_DEPTH = 32;
|
||||
|
||||
const isClean = (assertClean, value) => {
|
||||
try {
|
||||
assertClean(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The first place in `entry` whose text the store holds, as a path ("instructions",
|
||||
// "personality.traits[1]", "role key"), or null when the entry is clean. A hit the walk can't place
|
||||
// (a value JSON.stringify sees and the walk doesn't) is "(entry)": the check fails closed.
|
||||
export function heldPath(entry, assertClean) {
|
||||
if (isClean(assertClean, entry)) return null;
|
||||
const join = (path, key) => {
|
||||
const segment = SEGMENT.test(key) && isClean(assertClean, key) ? key : '*';
|
||||
return path ? `${path}.${segment}` : segment;
|
||||
};
|
||||
const walk = (value, path, depth) => {
|
||||
if (typeof value === 'string') return isClean(assertClean, value) ? null : path || '(entry)';
|
||||
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return null;
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const hit = walk(value[i], `${path}[${i}]`, depth + 1);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!isClean(assertClean, key)) return path ? `${path} key` : 'key';
|
||||
const hit = walk(child, join(path, key), depth + 1);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(entry, '', 0) ?? '(entry)';
|
||||
}
|
||||
|
||||
// The refusal for one definition. `name` is the definition's name without "org:". At issue time
|
||||
// (`organization` given) it names the organization too, and the value is the one being issued.
|
||||
export function definitionRefusal(name, path, { organization, boot = false } = {}) {
|
||||
const quoted = typeof name === 'string' && NAME.test(name) ? name : '*';
|
||||
const where = `${boot ? 'boot copy ' : ''}${name === null ? 'definitions' : `definitions.${quoted} ${path}`}`;
|
||||
if (organization === undefined) return new BusError(DEFINITION_HOLDS_CREDENTIAL, `${where} holds a credential this bus holds`);
|
||||
const org = typeof organization === 'string' && NAME.test(organization) ? organization : '*';
|
||||
return new BusError(DEFINITION_HOLDS_CREDENTIAL, `organization ${org} ${where} holds the credential being issued`);
|
||||
}
|
||||
|
||||
// The first entry of a `definitions` map that holds a credential, as { name, path }, or null.
|
||||
// `prefix` is stripped from each name ("org:" for a business's resolved definitions). A map that is
|
||||
// not an object is { name: null } when its text holds one.
|
||||
export function heldDefinition(definitions, assertClean, { prefix = '' } = {}) {
|
||||
if (definitions === undefined) return null; // an organization with no definitions key
|
||||
if (definitions === null || typeof definitions !== 'object' || Array.isArray(definitions))
|
||||
return isClean(assertClean, definitions) ? null : { name: null, path: null };
|
||||
for (const [key, entry] of Object.entries(definitions)) {
|
||||
if (prefix && !key.startsWith(prefix)) continue;
|
||||
if (!isClean(assertClean, key)) return { name: '*', path: 'key' };
|
||||
const path = heldPath(entry, assertClean);
|
||||
if (path !== null) return { name: key.slice(prefix.length), path };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Every entry of a `definitions` map; throws the refusal for the first that holds a credential.
|
||||
export function scanDefinitions(definitions, assertClean, options = {}) {
|
||||
const hit = heldDefinition(definitions, assertClean, options);
|
||||
if (hit) throw definitionRefusal(hit.name, hit.path, options);
|
||||
}
|
||||
|
||||
// org-defscan (#1873, Lead ruling on the re-check P2): credentials.issue and rotate refuse a value
|
||||
// that already sits in any organization's definitions, so no definition text becomes a held value
|
||||
// after its launch checks passed. `organizations` is every business this bus serves; `own` is the
|
||||
// issuing one's document, already read. Any other file is read raw, as bus start reads it, so a
|
||||
// definition no instance names is covered; a file that can't be read refuses. `boot` is the bus's boot
|
||||
// copy snapshot ({ org, definitions }, #1873 A3/A4): the root agents file's definitions merged with the
|
||||
// organization's at boot. Every reload re-applies it, so its text is live for the bus's lifetime.
|
||||
export function scanOrganizations({ organizations, own, readRaw, boot = null }, assertClean) {
|
||||
for (const organization of organizations) {
|
||||
let definitions;
|
||||
if (organization === own.id) definitions = own.document.definitions;
|
||||
else {
|
||||
let raw;
|
||||
try {
|
||||
raw = readRaw(organization);
|
||||
} catch {
|
||||
raw = { readable: false };
|
||||
}
|
||||
if (raw?.readable !== true) throw new BusError(DEFINITION_SCAN_UNREADABLE);
|
||||
definitions = raw.document.definitions;
|
||||
}
|
||||
scanDefinitions(definitions, assertClean, { organization });
|
||||
}
|
||||
if (boot?.definitions !== undefined) scanDefinitions(boot.definitions, assertClean, { organization: boot.org, boot: true });
|
||||
}
|
||||
@@ -198,12 +198,12 @@ export class ModelKeys {
|
||||
// Host-only callbacks are the existing synchronous organization path.
|
||||
hire(instance, check, publish) {
|
||||
return this.#exclusive(async () => {
|
||||
check(); // Fresh validation and captured revision AFTER controller queue.
|
||||
await check(); // Fresh validation and captured revision AFTER controller queue.
|
||||
const existed = this.hasRecord(instance);
|
||||
try {
|
||||
await this.#issue(instance);
|
||||
check(); // Recheck captured revision AFTER HTTP, before publication.
|
||||
return publish();
|
||||
await check(); // Recheck captured revision AFTER HTTP, before publication.
|
||||
return await publish();
|
||||
} catch (error) {
|
||||
// A newly issued generation has not escaped to queued admission yet.
|
||||
if (!existed && this.hasRecord(instance)) {
|
||||
@@ -347,7 +347,7 @@ export function modelOrganization({ modelKeys, writer }) {
|
||||
};
|
||||
const wrap = invoke => (business, verb, args, actor, context) => {
|
||||
const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
|
||||
if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
|
||||
if (!isRevoke) return invoke(business, verb, args, actor, context);
|
||||
return (async () => {
|
||||
if (pending.has(business)) fail('organization-busy');
|
||||
const agent = args.name ?? args.instance, instance = business + '/' + agent;
|
||||
@@ -355,19 +355,11 @@ export function modelOrganization({ modelKeys, writer }) {
|
||||
if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
|
||||
const { revision } = writer.read(business);
|
||||
if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
|
||||
const checkHire = () => {
|
||||
if (writer.read(business).revision !== revision) fail('revision-conflict');
|
||||
const checked = invoke(business, 'instance.hireCheck', args, actor, context);
|
||||
if (checked.revision !== revision) fail('revision-conflict');
|
||||
return checked;
|
||||
};
|
||||
if (!isRevoke) checkHire(); // No registration or proxy I/O before shared checks.
|
||||
const prepare = { instance, revision, remoteRevoked: false };
|
||||
pending.set(business, prepare);
|
||||
const existed = modelKeys.hasRecord(instance);
|
||||
try {
|
||||
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
|
||||
else return await modelKeys.hire(instance, checkHire, () => invoke(business, verb, args, actor, context));
|
||||
return invoke(business, verb, args, actor, context);
|
||||
} catch (error) {
|
||||
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
|
||||
@@ -375,5 +367,12 @@ export function modelOrganization({ modelKeys, writer }) {
|
||||
} finally { pending.delete(business); }
|
||||
})();
|
||||
};
|
||||
return { writer: adapter, revoke, wrap };
|
||||
const hire = async (business, agent, check, publish) => {
|
||||
if (pending.has(business)) fail('organization-busy');
|
||||
const prepare = { instance: `${business}/${agent}`, revision: writer.read(business).revision, remoteRevoked: false };
|
||||
pending.set(business, prepare);
|
||||
try { return await modelKeys.hire(prepare.instance, check, publish); }
|
||||
finally { pending.delete(business); }
|
||||
};
|
||||
return { writer: adapter, revoke, wrap, hire };
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// never command arguments, stdout or service-token-bearing environment variables.
|
||||
import { startBroker } from './runtime.mjs';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { DEFINITION_HOLDS_CREDENTIAL } from './definition-scan.mjs';
|
||||
const LAUNCH_OPS = new Set([
|
||||
'identity',
|
||||
'authorizeLaunch',
|
||||
@@ -9,6 +10,7 @@ const LAUNCH_OPS = new Set([
|
||||
'endLaunch',
|
||||
'recoverLaunch',
|
||||
'credentialStatus',
|
||||
'definitionScan',
|
||||
'modelPreflight',
|
||||
'modelAdmission',
|
||||
'modelStall',
|
||||
@@ -90,6 +92,8 @@ function launchOp(m) {
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
// #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition.
|
||||
if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry });
|
||||
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
|
||||
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||||
if (m.op === 'modelStall') return runtime.modelStall(m);
|
||||
@@ -163,7 +167,9 @@ if (!process.send) {
|
||||
}
|
||||
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
|
||||
} catch (e) {
|
||||
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }), () =>
|
||||
// org-defscan: a held credential in a definition says which definition and key, never the text.
|
||||
const reason = e instanceof BusError && e.code === DEFINITION_HOLDS_CREDENTIAL && typeof e.reason === 'string' ? { reason: e.reason } : {};
|
||||
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused', ...reason }), () =>
|
||||
close(2),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Static refusal table: each listed code maps to one rail and next step. These strings
|
||||
// never interpolate caller arguments, credential references, policy values or file bytes.
|
||||
const entries = [];
|
||||
|
||||
const group = (codes, rail, nextStep) => {
|
||||
const guidance = Object.freeze({ rail, nextStep });
|
||||
for (const code of codes.split(' ')) {
|
||||
@@ -8,6 +9,10 @@ const group = (codes, rail, nextStep) => {
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
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.");
|
||||
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.");
|
||||
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
|
||||
group(
|
||||
@@ -85,6 +90,16 @@ group(
|
||||
"Role configuration and protected instance controls",
|
||||
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
|
||||
);
|
||||
group(
|
||||
"definition-holds-credential",
|
||||
"R1: no held credential in an organization definition's role text",
|
||||
"Ask the organization owner to remove the credential from the named definition and key, then restart the bus, relaunch the agent or issue the credential again. For a hire, deny it and have the chief raise it again without that text. Do not copy the text anywhere to inspect it."
|
||||
);
|
||||
group(
|
||||
"definition-scan-unreadable",
|
||||
"R1/R3: a credential is issued only after every organization's definitions are checked",
|
||||
"Ask the instance owner to repair the organization file the bus cannot read, then issue the credential again. Nothing was stored."
|
||||
);
|
||||
group(
|
||||
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
|
||||
"Role task ownership, allowed fields and current task state",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationPlugins } from '../../plugins/src/organization.mjs';
|
||||
import { pluginService } from '../../plugins/src/tools.mjs';
|
||||
import { readPluginLock } from '../../plugins/src/pinned.mjs';
|
||||
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
|
||||
@@ -35,6 +37,10 @@ import { createProxyLock } from './proxy-lock.mjs';
|
||||
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
|
||||
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
|
||||
import { ORG_PREFIX, orgDefinitionName } from '../../organization/src/definitions.mjs';
|
||||
import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs';
|
||||
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
@@ -59,7 +65,7 @@ import { discordProcessingHooks } from '../../discord/src/processing.mjs';
|
||||
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
|
||||
|
||||
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
|
||||
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
|
||||
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.fire']);
|
||||
// FED R4 (#1801, design 3.7): the revocation timer's period.
|
||||
export const ACCESS_SWEEP_MS = 30_000;
|
||||
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
|
||||
@@ -383,6 +389,18 @@ export async function startBroker({
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
|
||||
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
|
||||
// #1873 T1 follow-up (org-defscan): no organization definition's text holds a credential this store
|
||||
// holds. The boot copy's org: definitions first, then every entry of the file itself, named or not.
|
||||
// A hit refuses the start; the refusal names the definition and key, never the text.
|
||||
const clean = value => credentials.assertClean(value);
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
scanDefinitions(b.definitions, clean, { prefix: ORG_PREFIX });
|
||||
if (!organization) continue;
|
||||
const raw = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }).readRaw(id);
|
||||
if (raw.readable) scanDefinitions(raw.document.definitions, clean);
|
||||
}
|
||||
// The boot copy snapshot's definitions (the root agents file's, A4): every reload re-applies them.
|
||||
if (copies?.definitions !== undefined) scanDefinitions(copies.definitions, clean, { boot: true });
|
||||
if (proxy) {
|
||||
const inventory = modelRunInventory(dataRoot, modelRuns, credentials);
|
||||
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: org ? instanceConfigDir : organization.dir, org: org?.id ?? null, lock: proxyLock,
|
||||
@@ -651,9 +669,19 @@ export async function startBroker({
|
||||
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
|
||||
if (instance !== undefined) {
|
||||
let now;
|
||||
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
|
||||
if (now === undefined) return no('organization-unreadable');
|
||||
if (now !== launched) return no('organization-changed');
|
||||
try { now = writer?.read(id); } catch { return no('organization-unreadable'); }
|
||||
if (now?.revision === undefined) return no('organization-unreadable');
|
||||
if (now.revision !== launched) return no('organization-changed');
|
||||
// org-defscan (#1873): the launch boundary. A credential issued since the launcher's scan
|
||||
// runs on this same loop, so the org: definition the instance names is checked again here,
|
||||
// in the reply the launcher spawns after with no further wait.
|
||||
const name = orgDefinitionName(now.document.roles?.[instance]?.definition);
|
||||
if (name !== null) {
|
||||
const clean = value => credentials.assertClean(value);
|
||||
let held;
|
||||
try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; }
|
||||
if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL);
|
||||
}
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
@@ -816,8 +844,6 @@ export async function startBroker({
|
||||
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
|
||||
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
|
||||
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
|
||||
// Internal read-only path: no reload, journal, credential or publication.
|
||||
if(verb==='instance.hireCheck')return instances.checkHire(id,args);
|
||||
let result;
|
||||
if (['limits.set', 'instances.set'].includes(verb) && settings.current(id).plugins?.['model-provider']?.length) {
|
||||
return catalogueFor(id, { fresh: true }).then(catalogue => {
|
||||
@@ -849,9 +875,11 @@ export async function startBroker({
|
||||
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
|
||||
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
|
||||
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
|
||||
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
|
||||
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
|
||||
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
|
||||
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
|
||||
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
|
||||
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name,revision:args.revision},{actor,context});
|
||||
else throw new BusError('invalid-request');
|
||||
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
|
||||
const live=reload(id);
|
||||
@@ -862,6 +890,24 @@ export async function startBroker({
|
||||
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
|
||||
}):null,
|
||||
// Row 194 (D2.3): the role files as they stand, for the hire checks.
|
||||
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});
|
||||
return {...service,apply:async(id,decision,caller)=>{
|
||||
const proposal=service.read(decision);
|
||||
const publish=async()=>{
|
||||
let approved=caller;
|
||||
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
|
||||
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
|
||||
await modelKeys.prepareRevoke(instance);
|
||||
approved={...caller,beforeWrite:()=>modelKeys.completeRevoke(instance)};
|
||||
}
|
||||
const result=await service.apply(id,decision,approved);reload(id);return result;
|
||||
};
|
||||
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
|
||||
return publish();
|
||||
}};
|
||||
})() : null,
|
||||
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
credentialStatus: () => credentials.status(),
|
||||
@@ -878,7 +924,8 @@ export async function startBroker({
|
||||
return settings;
|
||||
} : null,
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }),
|
||||
organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null })
|
||||
: null,
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
// as it stands now; an unreadable file switches none on.
|
||||
@@ -1126,6 +1173,20 @@ export async function startBroker({
|
||||
.filter((c) => c.instance === business + '/' + role)
|
||||
.map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) }));
|
||||
}
|
||||
// #1873 T1 follow-up (org-defscan): the launcher's check of the org: definition it is about to
|
||||
// launch, as it read it from the organization file. Answers where a held credential sits, or null;
|
||||
// never the text. A malformed ask refuses, so the launch refuses.
|
||||
function definitionScan({ business, definition, entry } = {}) {
|
||||
if (typeof business !== 'string' || !Object.hasOwn(businesses, business) || typeof definition !== 'string'
|
||||
|| !definition.startsWith(ORG_PREFIX) || definition.length > 128 || entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
||||
throw new BusError('invalid-request');
|
||||
let size;
|
||||
try { size = JSON.stringify(entry).length; } catch { throw new BusError('invalid-request'); }
|
||||
if (size > 4 * 1024 * 1024) throw new BusError('invalid-request');
|
||||
const clean = value => credentials.assertClean(value);
|
||||
try { clean(definition); } catch { return { held: 'name' }; }
|
||||
return { held: heldPath(entry, clean) };
|
||||
}
|
||||
// C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass
|
||||
// still running skips the tick. Only codes are logged, never git's output.
|
||||
const timed = Object.values(businesses).flatMap(business => Object.entries(business.projects ?? {})
|
||||
@@ -1249,7 +1310,7 @@ export async function startBroker({
|
||||
return refused(['model-provider-unavailable','model-catalogue-unavailable','model-harness-incompatible'].includes(answer?.code) ? answer.code : 'model-provider-unavailable');
|
||||
},
|
||||
modelPreflight: async ({ business, role }) => {
|
||||
const current = settings ? settings.current(business) : businesses[business];
|
||||
const current = organization ? loadOrg(business) : businesses[business];
|
||||
if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
|
||||
return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
|
||||
},
|
||||
@@ -1299,6 +1360,7 @@ export async function startBroker({
|
||||
sessionClearStart: (r) => broker.sessionClearStart(r),
|
||||
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
|
||||
credentialStatus,
|
||||
definitionScan,
|
||||
launches: bound,
|
||||
readers: readCaps,
|
||||
async close() {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { DatabaseSync } from 'node:sqlite';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { Store, schemaDigest } from '../src/store.mjs';
|
||||
import { Broker, ACTIONS } from '../src/broker.mjs';
|
||||
import { HIRE_ALLOWED } from '../../organization/src/vocabulary.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { busOrganization } from '../src/organization-context.mjs';
|
||||
@@ -711,380 +710,17 @@ test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse
|
||||
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
|
||||
});
|
||||
|
||||
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
|
||||
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
|
||||
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
|
||||
// files are stubs, so a definition's authority is the test's to set.
|
||||
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
|
||||
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
|
||||
const ROLE_FILES = {
|
||||
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
|
||||
coder: roleDoc(['message.send', 'record.write']),
|
||||
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
|
||||
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
|
||||
wide: roleDoc(['message.send', 'task.create']),
|
||||
banned: roleDoc(['message.send']),
|
||||
pm: roleDoc(['message.send']),
|
||||
};
|
||||
const roleFile = (files) => (name) => {
|
||||
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
|
||||
return files[name];
|
||||
};
|
||||
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
|
||||
return setup(t, extra, { roleFile: roleFile(files) });
|
||||
}
|
||||
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
|
||||
const FIRE = { name: 'coder', reason: 'the work is done' };
|
||||
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
|
||||
const withoutReason = ({ reason, ...rest }) => rest;
|
||||
|
||||
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
|
||||
const { call, organization, allowed, store } = hireSetup(t);
|
||||
const result = call('chief', 'instance.hire', HIRE);
|
||||
assert.equal(result.transaction, 't-1');
|
||||
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
|
||||
const [use, ...rest] = allowed('role.hire');
|
||||
assert.equal(rest.length, 0);
|
||||
assert.equal(use.subject, 'helper');
|
||||
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
|
||||
// Within-role: no decision is raised or needed.
|
||||
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
|
||||
// vars reach the writer with the name and definition.
|
||||
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
|
||||
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
|
||||
});
|
||||
|
||||
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
|
||||
// business's writer, and no argument names another.
|
||||
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => {
|
||||
store.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const organization = [];
|
||||
const one = demo(LISTED).demo;
|
||||
const b = new Broker({
|
||||
store,
|
||||
businesses: { demo: one, other: { ...one, id: 'other' } },
|
||||
roleFile: roleFile(ROLE_FILES),
|
||||
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
|
||||
});
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
for (const verb of ['instance.hire', 'instance.fire'])
|
||||
for (const extra of [{ business: 'other' }, { target: 'other' }])
|
||||
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
|
||||
assert.equal(organization.length, 0);
|
||||
b.request(chief, { verb: 'instance.hire', args: HIRE });
|
||||
b.request(chief, { verb: 'instance.fire', args: FIRE });
|
||||
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
|
||||
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
|
||||
const { call, organization } = hireSetup(t);
|
||||
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
|
||||
const r = reason === undefined ? {} : { reason };
|
||||
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
|
||||
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
|
||||
}
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
|
||||
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
|
||||
assert.equal(organization.length, 0);
|
||||
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
|
||||
assert.equal(organization.length, 1);
|
||||
});
|
||||
|
||||
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
|
||||
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
|
||||
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
|
||||
for (const who of ['pm', 'coder', 'assistant']) {
|
||||
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
|
||||
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
|
||||
}
|
||||
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
|
||||
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
|
||||
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
|
||||
for (const missing of ['role.hire', 'role.fire']) {
|
||||
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
|
||||
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
|
||||
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
|
||||
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
|
||||
assert.equal(organization.length, 0, missing);
|
||||
assert.equal(lastClaim('coder').op, 'claim', missing);
|
||||
// The other action still goes through.
|
||||
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
|
||||
call('chief', other, otherArgs);
|
||||
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
|
||||
}
|
||||
});
|
||||
|
||||
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
|
||||
const { call, organization, writer } = hireSetup(t);
|
||||
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
|
||||
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
|
||||
writer.error = Object.assign(new Error(code), { code });
|
||||
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
|
||||
}
|
||||
writer.error = null;
|
||||
call('chief', 'instance.hire', again);
|
||||
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
|
||||
});
|
||||
|
||||
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
|
||||
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
|
||||
for (const action of ACTIONS) {
|
||||
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
|
||||
const { call, organization } = hireSetup(t, LISTED, files);
|
||||
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
|
||||
if (HIRE_ALLOWED.includes(action)) {
|
||||
run();
|
||||
assert.equal(organization.length, 1, action);
|
||||
} else {
|
||||
assert.throws(run, /definition-not-hireable/, action);
|
||||
assert.equal(organization.length, 0, action);
|
||||
}
|
||||
}
|
||||
const { call, organization } = hireSetup(t);
|
||||
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
|
||||
// Version 1 (M60), noHire, not on the chief's list, and no role file.
|
||||
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
|
||||
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
|
||||
assert.equal(organization.length, 0);
|
||||
hire('coder');
|
||||
assert.equal(organization.length, 1);
|
||||
});
|
||||
|
||||
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
|
||||
const { call, organization } = setup(t, LISTED);
|
||||
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
|
||||
});
|
||||
|
||||
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
|
||||
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
|
||||
// An open launch request of the coder, made by the human.
|
||||
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
let seen = null;
|
||||
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
|
||||
const result = call('chief', 'instance.fire', FIRE);
|
||||
// Step A ran before the writer; step C after it.
|
||||
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
|
||||
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
|
||||
assert.deepEqual(result.runs, ['coder-run']);
|
||||
assert.deepEqual(result.cancelled, [open]);
|
||||
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
|
||||
const [end] = allowed('lifecycle.end');
|
||||
assert.equal(end.subject, 'coder');
|
||||
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
|
||||
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
|
||||
const [use] = allowed('role.fire');
|
||||
assert.equal(use.subject, 'coder');
|
||||
assert.equal(use.body.reason, 'the work is done');
|
||||
assert.equal(use.body.transaction, result.transaction);
|
||||
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
|
||||
// The human's read of the request shows the kill.
|
||||
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
|
||||
});
|
||||
|
||||
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
|
||||
// each becomes its own event against the instance. Only a fire's end carries them.
|
||||
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
|
||||
const { b, call, allowed } = hireSetup(t);
|
||||
call('chief', 'instance.fire', FIRE);
|
||||
const [end] = allowed('lifecycle.end');
|
||||
const request = end.body.request;
|
||||
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
|
||||
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
|
||||
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
|
||||
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
|
||||
// sage-2289: only an alive stop carries a reason, and only unknown.
|
||||
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
|
||||
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
|
||||
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
|
||||
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
|
||||
assert.equal(closed.state, 'closed');
|
||||
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
|
||||
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
|
||||
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
|
||||
// A terminate's end sends SIGTERM only, so its done has no stops.
|
||||
const term = call('human', 'instance.end', { instance: 'pm' }).request;
|
||||
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
|
||||
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
|
||||
assert.equal(allowed('lifecycle.end.killed').length, 1);
|
||||
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
|
||||
});
|
||||
|
||||
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
|
||||
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
|
||||
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
|
||||
assert.throws(() => fire('pm'), /instance-kept/);
|
||||
assert.throws(() => fire('nobody'), /unknown-role/);
|
||||
assert.throws(() => fire('assistant'), /organization-definition-refused/);
|
||||
assert.throws(() => fire('chief'), /organization-instance-referenced/);
|
||||
assert.throws(() => fire('coder'), /invalid-request/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
|
||||
assert.equal(lastClaim('coder').op, 'claim');
|
||||
});
|
||||
|
||||
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
|
||||
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
|
||||
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
|
||||
writer.during = null;
|
||||
assert.equal(allowed('fire.stopped').length, 1);
|
||||
assert.equal(allowed('role.fire').length, 0);
|
||||
// W-N1: while it fires, no launch of the instance starts by any path.
|
||||
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
|
||||
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
|
||||
// ended, so the repeat asks for its kill again, then delists and logs.
|
||||
const result = call('chief', 'instance.fire', FIRE);
|
||||
assert.deepEqual(result.runs, ['coder-run']);
|
||||
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||
assert.equal(allowed('role.fire').length, 1);
|
||||
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
|
||||
// Once done, the instance launches again.
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
|
||||
const { call, organization, allowed, store, writer } = hireSetup(t);
|
||||
// The delist commits, then the step C transaction aborts.
|
||||
writer.during = ([, verb]) => verb === 'instance.fire' && store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE));
|
||||
writer.during = null;
|
||||
store.run('DROP TRIGGER fail_c');
|
||||
assert.equal(allowed('role.fire').length, 0);
|
||||
assert.equal(allowed('fire.done').length, 0);
|
||||
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||
call('chief', 'instance.fire', FIRE);
|
||||
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
|
||||
assert.equal(allowed('role.fire').length, 1);
|
||||
assert.equal(allowed('fire.done').length, 1);
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
|
||||
const { call, store } = hireSetup(t);
|
||||
const step = (instance, action) =>
|
||||
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
|
||||
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
|
||||
step('coder', 'fire.done');
|
||||
step('coder', 'fire.stopped');
|
||||
step('pm', 'fire.done');
|
||||
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
|
||||
step('coder', 'fire.done');
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
|
||||
const { call, organization, lastClaim, allowed } = hireSetup(t);
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(lastClaim('pm').op, 'claim');
|
||||
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
|
||||
});
|
||||
|
||||
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
|
||||
// step A revokes, ends or marks anything, so the instance isn't left firing.
|
||||
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
|
||||
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
|
||||
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
|
||||
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
|
||||
writer.during = null;
|
||||
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
|
||||
assert.equal(lastClaim('coder').op, 'claim');
|
||||
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
|
||||
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
|
||||
});
|
||||
|
||||
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
|
||||
const { call, organization, allowed } = hireSetup(t);
|
||||
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
|
||||
}
|
||||
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
|
||||
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
|
||||
assert.equal(organization.length, 0);
|
||||
assert.equal(allowed('fire.stopped').length, 0);
|
||||
});
|
||||
|
||||
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
|
||||
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
writeJson(join(rolesDir, 'aide.json'), {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
});
|
||||
const chiefFile = join(rolesDir, 'chief-of-staff.json');
|
||||
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
|
||||
const busForm = (dir, roles) => {
|
||||
const b = loadBusiness('demo', { dir, rolesDir: roles });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
|
||||
return busOrganization(b, resolved);
|
||||
};
|
||||
const bus = await startBroker({
|
||||
dataRoot: join(root, 'data'),
|
||||
businesses: { demo: busForm(root, rolesDir) },
|
||||
organization: { dir: root, rolesDir, system: systemFor(root) },
|
||||
});
|
||||
t.after(async () => {
|
||||
await bus.close();
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
const { broker } = bus;
|
||||
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
broker.request(chief, { verb: 'role.claim' });
|
||||
const call = (cap, verb, args) => broker.request(cap, { verb, args });
|
||||
const receipts = () =>
|
||||
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
|
||||
const document = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
|
||||
// The shipped chief file grants both actions, so the runtime takes the reason form.
|
||||
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
|
||||
const bytes = readFileSync(file, 'utf8');
|
||||
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
|
||||
assert.equal(readFileSync(file, 'utf8'), bytes);
|
||||
|
||||
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
|
||||
assert.deepEqual(document().roles.helper, { definition: 'aide' });
|
||||
assert.ok(document().launch.instances.includes('helper'));
|
||||
const [h] = receipts().slice(-1);
|
||||
assert.equal(h.actor, 'chief-of-staff.chief');
|
||||
assert.deepEqual(h.context, CONTEXT('more hands'));
|
||||
|
||||
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
|
||||
assert.equal(result.delisted, true);
|
||||
assert.ok(!document().launch.instances.includes('helper'));
|
||||
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
|
||||
// The reloaded business sees the delist: a refused call leaves the file alone.
|
||||
const after = readFileSync(file, 'utf8');
|
||||
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
|
||||
assert.equal(readFileSync(file, 'utf8'), after);
|
||||
test('A4 the retired lifecycle verbs always require a decision and never call the writer',t=>{
|
||||
const {call,organization}=setup(t,KEPT);
|
||||
for(const who of ['chief','coder','human'])for(const verb of ['instance.hire','instance.fire'])
|
||||
assert.throws(()=>call(who,verb,{name:'coder',definition:'coder',reason:'help'}),{code:'decision-required'});
|
||||
assert.equal(organization.length,0);
|
||||
});
|
||||
|
||||
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
|
||||
const { b, call, store } = hireSetup(t);
|
||||
const { b, call, store } = setup(t,LISTED);
|
||||
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
|
||||
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
|
||||
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
|
||||
|
||||
@@ -89,7 +89,7 @@ function setup(t) {
|
||||
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
|
||||
// no unsuffixed verb is ever a console verb.
|
||||
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// #1873 T1 follow-up (org-defscan): bus start runs the live credential store's held-secret check on
|
||||
// every organization definition, and the launch check asks the bus the same question. A hit names
|
||||
// the definition and the key, never the text there or the credential's name.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError } from '../src/broker.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { DEFINITION_HOLDS_CREDENTIAL, DEFINITION_SCAN_UNREADABLE, heldPath, scanDefinitions, scanOrganizations } from '../src/definition-scan.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
|
||||
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
||||
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
// The fixture bus holds SENTINEL as coder's gitea token.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const UNHELD = 'sk-test-UNHELD-LOOKS-LIKE-A-TOKEN-0000';
|
||||
|
||||
function files(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
|
||||
// An organization whose coder names org:builder, a copy of the coder template; `edit` changes the
|
||||
// builder entry before the file is written. `held` is the coder's gitea token at start.
|
||||
function org(t, edit = (entry) => entry, { held = SENTINEL } = {}) {
|
||||
const root = tmp(t, 'defscan-');
|
||||
const builder = edit(entryFromTemplate(readTemplate(REPO_ROLES, 'coder')));
|
||||
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, held);
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
const boot = () => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
||||
const start = (b = boot()) => startBroker({ dataRoot: f.dataRoot, businesses: b.businesses, organization: b.organization });
|
||||
return { root, f, file, boot, start };
|
||||
}
|
||||
|
||||
function refusedWithout(e, reason) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
|
||||
assert.equal(e.reason, reason);
|
||||
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
|
||||
assert.ok(!text.includes(SENTINEL), 'the refusal never carries the value');
|
||||
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The start's outcome: the refusal, or "started" after closing a bus that should not have started.
|
||||
const outcome = (start) => start.then(async (bus) => { await bus.close(); return 'started'; }, (e) => e);
|
||||
|
||||
function nothingWritten(root) {
|
||||
for (const path of files(root)) {
|
||||
if (path.endsWith('.token') || path.endsWith('acme.json')) continue;
|
||||
assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
}
|
||||
}
|
||||
|
||||
test('org-defscan: the refusal code is in the TI refusal table with a next step', () => {
|
||||
const entry = REFUSALS[DEFINITION_HOLDS_CREDENTIAL];
|
||||
assert.ok(entry, 'definition-holds-credential is a known refusal');
|
||||
assert.match(entry.rail, /^R\d+:/);
|
||||
assert.match(entry.nextStep, /definition/);
|
||||
assert.match(entry.nextStep, /issue the credential again/);
|
||||
assert.match(entry.nextStep, /For a hire, deny it/);
|
||||
// The issue-time check's fail-closed code for an organization file it cannot read.
|
||||
const unreadable = REFUSALS[DEFINITION_SCAN_UNREADABLE];
|
||||
assert.ok(unreadable, 'definition-scan-unreadable is a known refusal');
|
||||
assert.match(unreadable.rail, /^R\d+/);
|
||||
assert.match(unreadable.nextStep, /organization file/);
|
||||
assert.equal(new BusError(DEFINITION_SCAN_UNREADABLE).nextStep, unreadable.nextStep);
|
||||
});
|
||||
|
||||
test('org-defscan: a held credential in a definition\'s instructions refuses bus start, naming definition and key only', async (t) => {
|
||||
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nThe forge token is ${SENTINEL}.\n` }));
|
||||
refusedWithout(await outcome(o.start()), 'definitions.builder instructions holds a credential this bus holds');
|
||||
nothingWritten(o.root);
|
||||
});
|
||||
|
||||
test('org-defscan: a held credential in a personality field refuses bus start the same way', async (t) => {
|
||||
const o = org(t, (entry) => ({ ...entry, personality: { summary: `Careful. Key ${SENTINEL}.`, traits: ['plain'] } }));
|
||||
refusedWithout(await outcome(o.start()), 'definitions.builder personality.summary holds a credential this bus holds');
|
||||
const p = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `x${SENTINEL}`] } }));
|
||||
refusedWithout(await outcome(p.start()), 'definitions.builder personality.traits[1] holds a credential this bus holds');
|
||||
nothingWritten(o.root);
|
||||
nothingWritten(p.root);
|
||||
});
|
||||
|
||||
test('org-defscan: a definition in the file but not in the boot copy is checked too', async (t) => {
|
||||
const o = org(t);
|
||||
const boot = o.boot();
|
||||
// Written after the load and before the bus starts: no instance names it, so only the raw read sees it.
|
||||
const doc = JSON.parse(readFileSync(o.file, 'utf8'));
|
||||
doc.definitions.spare = { ...doc.definitions.builder, instructions: `Spare: ${SENTINEL}\n` };
|
||||
writeJson(o.file, doc);
|
||||
refusedWithout(await outcome(o.start(boot)), 'definitions.spare instructions holds a credential this bus holds');
|
||||
});
|
||||
|
||||
test('org-defscan: a clean file starts, and token-looking text the store does not hold starts', async (t) => {
|
||||
for (const edit of [(entry) => entry, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample: ${UNHELD}\n`, personality: { summary: `Says ${UNHELD}.` } })]) {
|
||||
const o = org(t, edit);
|
||||
const bus = await o.start();
|
||||
try {
|
||||
// The launch check's question: a clean entry answers null, a held one names the key only.
|
||||
const entry = o.boot().businesses.acme.definitions['org:builder'];
|
||||
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
|
||||
const hit = bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { ...entry, instructions: `x ${SENTINEL}` } });
|
||||
assert.deepEqual(hit, { held: 'instructions' });
|
||||
// A malformed question refuses rather than answering clean.
|
||||
for (const bad of [{}, { business: 'acme', definition: 'builder', entry }, { business: 'other', definition: 'org:builder', entry },
|
||||
{ business: 'acme', definition: 'org:builder', entry: null }, { business: 'acme', definition: 'org:builder', entry: [entry] }])
|
||||
assert.throws(() => bus.definitionScan(bad), (e) => e.code === 'invalid-request');
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The coder's gitea credential as the human verb issues or rotates it.
|
||||
function verbs(o, bus) {
|
||||
const human = bus.broker.bindHuman({ business: 'acme', human: o.f.doc.human, via: 'cli', outsideAgent: true });
|
||||
return {
|
||||
issue: (token) => bus.broker.request(human, { verb: 'credentials.issue', args: { agent: 'coder', service: 'gitea', token, date: '2099-01-01' } }),
|
||||
revoke: () => bus.broker.request(human, { verb: 'credentials.revoke', args: { agent: 'coder', service: 'gitea', reason: 'test' } }),
|
||||
};
|
||||
}
|
||||
// Whether the bus holds `value` now, asked the way the launch check asks.
|
||||
const holds = (bus, value) => bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { instructions: `x ${value}` } }).held !== null;
|
||||
const state = (bus) => bus.credentials.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea')?.state;
|
||||
|
||||
function issueRefused(e, value, reason) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
|
||||
assert.equal(e.reason, reason);
|
||||
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
|
||||
assert.ok(!text.includes(value), 'the refusal never carries the value');
|
||||
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// No file under the root holds `value` except the organization file it was written into and that
|
||||
// file's own history copies.
|
||||
function onlyInOrganization(root, value) {
|
||||
for (const path of files(root))
|
||||
if (!path.endsWith('acme.json') && !path.includes('/businesses/.history/acme/')) assert.ok(!readFileSync(path).includes(value), path);
|
||||
}
|
||||
|
||||
test('org-defscan: a credential issued between the final launch admission and spawn is refused at issue; the store stays as it was', async (t) => {
|
||||
// The definition carries text that is not held at start. After the launch's final admission answers
|
||||
// ready, the human rotates the coder's token to that text with the same date. On the bus before this
|
||||
// fix the rotate succeeded and the agent spawned with a held value in its contract.
|
||||
const FIRST = 'fake_first_token_0123456789';
|
||||
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${SENTINEL}\n` }), { held: 'fake_boot_token_5555555555' });
|
||||
const boot = o.boot();
|
||||
const bus = await o.start(boot);
|
||||
t.after(() => bus.close());
|
||||
const v = verbs(o, bus);
|
||||
// A clean issue still works.
|
||||
assert.equal(v.issue(FIRST).state, 'valid');
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const revision = writer.read('acme').revision;
|
||||
const changes = files(o.root).filter((p) => p.endsWith('changes.jsonl')).map((p) => [p, readFileSync(p, 'utf8')]);
|
||||
const entry = o.boot().businesses.acme.definitions['org:builder'];
|
||||
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
|
||||
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
|
||||
assert.equal(final().ready, true);
|
||||
// Between the admission's answer and the spawn: the rotate.
|
||||
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder instructions holds the credential being issued'));
|
||||
// Nothing changed: the old token is held, the new one is not, the file and its receipts are as they were.
|
||||
assert.ok(holds(bus, FIRST) && !holds(bus, SENTINEL));
|
||||
assert.equal(state(bus), 'valid');
|
||||
assert.equal(writer.read('acme').revision, revision);
|
||||
for (const [p, text] of changes) assert.equal(readFileSync(p, 'utf8'), text, p);
|
||||
onlyInOrganization(o.root, SENTINEL);
|
||||
assert.equal(final().ready, true, 'the contract the agent gets holds no held value');
|
||||
});
|
||||
|
||||
test('org-defscan: issue over a revoked entry refuses the same way for a personality field, and a clean issue then works', async (t) => {
|
||||
const o = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `uses ${SENTINEL}`] } }), { held: 'fake_boot_token_5555555555' });
|
||||
const bus = await o.start();
|
||||
t.after(() => bus.close());
|
||||
const v = verbs(o, bus);
|
||||
v.revoke();
|
||||
assert.equal(state(bus), 'revoked');
|
||||
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder personality.traits[1] holds the credential being issued'));
|
||||
assert.equal(state(bus), 'revoked');
|
||||
assert.ok(!holds(bus, SENTINEL));
|
||||
onlyInOrganization(o.root, SENTINEL);
|
||||
const issued = v.issue('fake_clean_token_4444444444');
|
||||
assert.deepEqual([issued.verb, issued.state], ['issue', 'valid']);
|
||||
});
|
||||
|
||||
test('org-defscan: a value held without the issue verb still refuses the launch at its final admission, even when the file revision is unchanged', async (t) => {
|
||||
// Defence behind the issue check: the store comes to hold text already in the definition by a path
|
||||
// other than the human verb, which leaves the organization file's bytes, so its revision, as they were.
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${ROTATED}\n` }));
|
||||
const boot = o.boot();
|
||||
const bus = await o.start(boot);
|
||||
t.after(() => bus.close());
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const revision = writer.read('acme').revision;
|
||||
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
|
||||
assert.equal(final().ready, true);
|
||||
bus.credentials.issue('acme/coder', 'gitea', ROTATED, '2099-01-01');
|
||||
assert.equal(writer.read('acme').revision, revision, 'the store change left the file as it was');
|
||||
assert.deepEqual(final(), { ready: false, revision: null, code: DEFINITION_HOLDS_CREDENTIAL });
|
||||
// An instance on a shipped template is not asked about a definition.
|
||||
assert.equal(bus.admission({ business: 'acme', projects: [], instance: 'reviewer', revision }).ready, true);
|
||||
});
|
||||
|
||||
test('org-defscan: issue checks the other organizations the bus serves, and refuses when one\'s file cannot be read', async (t) => {
|
||||
// The bus serves acme and beta. beta's file has a definition holding text acme's coder then tries to
|
||||
// be issued; later beta's file stops being JSON. Neither issue changes acme's store or file.
|
||||
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
|
||||
const boot = o.boot();
|
||||
const betaFile = join(o.root, 'config/businesses/beta.json');
|
||||
const acme = JSON.parse(readFileSync(o.file, 'utf8'));
|
||||
const spare = { ...acme.definitions.builder, personality: { summary: 'Spare.', voice: `quotes ${SENTINEL}` } };
|
||||
writeJson(betaFile, { ...acme, id: 'beta', definitions: { spare } });
|
||||
const bus = await o.start({ ...boot, businesses: { ...boot.businesses, beta: { ...boot.businesses.acme, id: 'beta' } } });
|
||||
t.after(() => bus.close());
|
||||
const v = verbs(o, bus);
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const revision = writer.read('acme').revision;
|
||||
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization beta definitions.spare personality.voice holds the credential being issued'));
|
||||
writeFileSync(betaFile, '{ not json');
|
||||
const CLEAN = 'fake_clean_token_4444444444';
|
||||
assert.throws(() => v.issue(CLEAN), (e) => e instanceof BusError && e.code === DEFINITION_SCAN_UNREADABLE && !JSON.stringify(e).includes(CLEAN) && !String(e.stack).includes(CLEAN));
|
||||
for (const value of [SENTINEL, CLEAN]) assert.ok(!holds(bus, value));
|
||||
assert.equal(state(bus), 'valid');
|
||||
assert.equal(writer.read('acme').revision, revision);
|
||||
onlyInOrganization(o.root, CLEAN);
|
||||
// With beta's file readable again, the clean issue goes through.
|
||||
writeJson(betaFile, { ...acme, id: 'beta', definitions: {} });
|
||||
assert.equal(v.issue(CLEAN).state, 'valid');
|
||||
assert.ok(holds(bus, CLEAN));
|
||||
});
|
||||
|
||||
test('org-defscan: the issue check covers every organization the bus serves, and an unreadable file refuses', () => {
|
||||
const held = 'HELD-VALUE-77';
|
||||
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
|
||||
const own = { id: 'acme', document: { definitions: { builder: { instructions: 'fine' } } } };
|
||||
const files = { beta: { readable: true, document: { definitions: { spare: { instructions: 'fine', personality: { voice: `say ${held}` } } } } },
|
||||
gamma: { readable: true, document: {} }, broken: { readable: false } };
|
||||
const reads = [];
|
||||
const readRaw = (id) => { reads.push(id); if (id === 'throws') throw new Error('EACCES'); return files[id]; };
|
||||
assert.doesNotThrow(() => scanOrganizations({ organizations: ['acme', 'gamma'], own, readRaw }, assertClean));
|
||||
assert.deepEqual(reads, ['gamma'], 'the issuing organization is the document already read');
|
||||
assert.throws(() => scanOrganizations({ organizations: ['acme', 'gamma', 'beta'], own, readRaw }, assertClean),
|
||||
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'organization beta definitions.spare personality.voice holds the credential being issued' && !JSON.stringify(e).includes(held));
|
||||
assert.throws(() => scanOrganizations({ organizations: ['acme', { ...own, id: 'x' }.id], own: { id: 'acme', document: { definitions: { [held]: {} } } }, readRaw: () => ({ readable: true, document: {} }) }, assertClean),
|
||||
(e) => e.reason === 'organization acme definitions.* key holds the credential being issued' && !e.reason.includes(held));
|
||||
for (const bad of ['broken', 'throws', 'missing'])
|
||||
assert.throws(() => scanOrganizations({ organizations: ['acme', bad], own, readRaw }, assertClean), (e) => e.code === DEFINITION_SCAN_UNREADABLE && e.reason === undefined);
|
||||
});
|
||||
|
||||
test('org-defscan: heldPath names the first place, a key by its parent, and never the text', () => {
|
||||
const held = 'HELD-VALUE-77';
|
||||
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
|
||||
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', 'b'] } }, assertClean), null);
|
||||
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', `b${held}`] } }, assertClean), 'personality.traits[1]');
|
||||
assert.equal(heldPath({ role: { tools: [held] } }, assertClean), 'role.tools[0]');
|
||||
assert.equal(heldPath({ personality: { [held]: 'x' } }, assertClean), 'personality key');
|
||||
assert.equal(heldPath({ [`${held}`]: 'x' }, assertClean), 'key');
|
||||
assert.throws(() => scanDefinitions({ 'org:a': { instructions: 'ok' }, 'org:b': { instructions: held } }, assertClean, { prefix: 'org:' }),
|
||||
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'definitions.b instructions holds a credential this bus holds');
|
||||
// A name outside the loader's pattern is not quoted.
|
||||
assert.throws(() => scanDefinitions({ 'Bad Name': { instructions: held } }, assertClean), (e) => e.reason === 'definitions.* instructions holds a credential this bus holds');
|
||||
assert.throws(() => scanDefinitions({ [held]: { instructions: 'ok' } }, assertClean), (e) => e.reason === 'definitions.* key holds a credential this bus holds' && !e.reason.includes(held));
|
||||
assert.doesNotThrow(() => scanDefinitions(undefined, assertClean));
|
||||
});
|
||||
|
||||
test('org-defscan: an approved hire whose definition holds a credential issued after the raise refuses before the decision is consumed', async (t) => {
|
||||
// The chief raises a hire whose instructions carry text the store does not hold yet. The proposal is
|
||||
// private, so the issue-time check cannot see it, and the store then comes to hold that text. The
|
||||
// approval runs the same definition check as start, launch and issue: the refusal names the code, the
|
||||
// decision stays open, and the organization file is as it was.
|
||||
const LATER = 'fake_later_token_0123456789';
|
||||
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
|
||||
const boot = o.boot();
|
||||
const bus = await o.start(boot);
|
||||
t.after(() => bus.close());
|
||||
const chief = bus.broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
bus.broker.request(chief, { verb: 'role.claim' });
|
||||
const inline = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: `Read and report.\nExample ${LATER}\n` };
|
||||
const raised = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'scout', role: { inline }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
|
||||
// Issued through the store, so the organization file and its revision stay as they were and the
|
||||
// approval reaches the definition check rather than the revision check.
|
||||
bus.credentials.issue('acme/coder', 'gitea', LATER, '2099-01-01');
|
||||
assert.ok(holds(bus, LATER));
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const before = readFileSync(o.file);
|
||||
const revision = writer.read('acme').revision;
|
||||
const owner = bus.broker.bindConsole({ business: 'acme', human: o.f.doc.human, subject: { provider: 'test', iss: 'https://issuer.test', sub: 'owner' } });
|
||||
const refused = (e) => {
|
||||
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
|
||||
for (const text of [e.message, String(e.reason), String(e.stack), JSON.stringify(e)]) assert.ok(!text.includes(LATER), 'the refusal never carries the value');
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
|
||||
// Nothing was written.
|
||||
assert.deepEqual(readFileSync(o.file), before);
|
||||
assert.equal(writer.read('acme').revision, revision);
|
||||
assert.ok(!Object.hasOwn(writer.read('acme').document.roles, 'scout'));
|
||||
// The decision was not consumed: a second approval meets the same check, not decision-closed.
|
||||
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
|
||||
assert.deepEqual(readFileSync(o.file), before);
|
||||
// A hire with clean text, raised and approved on the same bus, is written as before.
|
||||
const clean = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'helper', role: { inline: { ...inline, instructions: 'Read and report.\n' } }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
|
||||
assert.equal((await bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: clean.id, choice: 'yes', digest: clean.hire.digest } })).choice, 'yes');
|
||||
assert.ok(Object.hasOwn(writer.read('acme').document.roles, 'helper'));
|
||||
onlyInOrganization(join(o.root, 'config/businesses'), LATER);
|
||||
});
|
||||
|
||||
test('org-defscan: the boot copy snapshot\'s definitions (the root agents file\'s) are checked at start and at issue', async (t) => {
|
||||
// A4: a root definition reaches an organization through the boot copy snapshot, which every reload
|
||||
// re-applies. Its text is in no organization file, so the start and issue checks read the snapshot.
|
||||
const LATER = 'fake_later_token_0123456789';
|
||||
const snapshot = (instructions) => ({ org: 'acme', root: 'ok', rootAgents: {}, copies: {},
|
||||
definitions: { rooted: { ...entryFromTemplate(readTemplate(REPO_ROLES, 'coder')), instructions } } });
|
||||
const startWith = (o, copies, b = o.boot()) => startBroker({ dataRoot: o.f.dataRoot, businesses: b.businesses, organization: b.organization, copies });
|
||||
// Held at start: the start refuses, naming the snapshot's definition and key.
|
||||
const held = org(t);
|
||||
refusedWithout(await outcome(startWith(held, snapshot(`Root copy ${SENTINEL}\n`))), 'boot copy definitions.rooted instructions holds a credential this bus holds');
|
||||
// Not held at start: the bus starts, then issuing that text refuses and the store is as it was.
|
||||
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
|
||||
const bus = await startWith(o, snapshot(`Root copy ${LATER}\n`));
|
||||
t.after(() => bus.close());
|
||||
assert.throws(() => verbs(o, bus).issue(LATER), (e) => issueRefused(e, LATER, 'organization acme boot copy definitions.rooted instructions holds the credential being issued'));
|
||||
assert.ok(!holds(bus, LATER));
|
||||
assert.equal(state(bus), 'valid');
|
||||
assert.equal(verbs(o, bus).issue('fake_clean_token_4444444444').state, 'valid');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
|
||||
// and the writer runs the credential store's held-secret check on what it would write.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const TOKEN = 'fixture-held-token-t1-5c0e9a';
|
||||
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
|
||||
|
||||
function files(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
|
||||
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
|
||||
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
|
||||
});
|
||||
|
||||
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
|
||||
const data = join(root, 'data');
|
||||
mkdirSync(data, { mode: 0o700 });
|
||||
const token = join(root, 'held.token');
|
||||
writeFileSync(token, TOKEN, { mode: 0o600 });
|
||||
chmodSync(token, 0o600);
|
||||
const bus = await startBroker({ dataRoot: data, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {
|
||||
chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
|
||||
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
|
||||
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const b = bus.broker;
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const revision = () => b.request(human, { verb: 'limits' }).revision;
|
||||
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
|
||||
|
||||
// An agent, a reader and the tester never reach the writer.
|
||||
const before = readFileSync(file);
|
||||
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
|
||||
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
|
||||
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
|
||||
// Unknown and missing arguments refuse; an actor can't be forged through them.
|
||||
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
|
||||
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
|
||||
// The human's invent commits, as the human, with a receipt that carries no role text.
|
||||
const receipt = b.request(human, invent('scout'));
|
||||
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
|
||||
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
|
||||
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(last.actor, 'jason');
|
||||
assert.equal(last.state, 'committed');
|
||||
// A writer refusal passes by its code.
|
||||
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
|
||||
|
||||
// The bus refuses a request that carries the held token, and the writer refuses a template whose
|
||||
// contract carries it (the import request itself holds no token). Neither writes.
|
||||
const written = readFileSync(file);
|
||||
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
|
||||
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
|
||||
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
|
||||
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
|
||||
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
|
||||
assert.deepEqual(readFileSync(file), written);
|
||||
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
|
||||
// test needs outside the human's yes go in by SQL as fixtures.
|
||||
import test from 'node:test';
|
||||
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
@@ -156,7 +157,7 @@ async function setup(t) {
|
||||
return { revision: REV };
|
||||
};
|
||||
const make = () =>
|
||||
new Broker({
|
||||
new Broker({hires:hireServiceFixture,
|
||||
store,
|
||||
businesses: { demo: structuredClone(doc) },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
@@ -184,7 +185,7 @@ async function setup(t) {
|
||||
store
|
||||
.all("SELECT actor_role, subject, body FROM events WHERE business='demo' AND kind='action.allowed' ORDER BY seq")
|
||||
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
|
||||
.filter((e) => e.body.action === action);
|
||||
.filter((e) => e.body.action === action && e.body.operation !== 'decision.raise');
|
||||
// The bus's step G messages to the human: no decision, so no grant notice.
|
||||
const inbox = () =>
|
||||
store.all("SELECT body FROM messages WHERE business='demo' AND from_role='_bus' AND to_role='human' AND decision IS NULL ORDER BY rowid").map((r) => r.body);
|
||||
@@ -232,7 +233,8 @@ async function setup(t) {
|
||||
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
|
||||
},
|
||||
});
|
||||
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'the work is done' });
|
||||
const hireConsole=fixtureConsole(broker);
|
||||
const fire = name => approveFixtureFire(broker,hireConsole,name);
|
||||
// A delete as the launcher's pass runs it: the human's request, the take, then lifecycleDelete. A
|
||||
// refused delete closes the request with its code, as the launcher does.
|
||||
const remove = (instance) => {
|
||||
@@ -304,7 +306,7 @@ function allRevoked(f, hosts = ['web1', 'web2']) {
|
||||
test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers with a job, then revokes all three', async (t) => {
|
||||
const f = await setup(t);
|
||||
await threeGrants(f);
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.equal(out.done, false);
|
||||
assert.match(out.job, UUID);
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'host:web2', 'gitea']);
|
||||
@@ -313,7 +315,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
|
||||
assert.equal(more.length, 0);
|
||||
assert.equal(revoking.body.job, out.job);
|
||||
assert.deepEqual(revoking.body.grants, out.revoking);
|
||||
assert.equal(revoking.actor_role, 'chief');
|
||||
assert.equal(revoking.actor_role, null);
|
||||
assert.equal(revoking.subject, 'coder');
|
||||
await f.broker.grantsIdle();
|
||||
// Each host as hosts.revoke records it, with fire: true, line first so the close says removed.
|
||||
@@ -340,7 +342,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
|
||||
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
|
||||
assert.deepEqual(done.body.grantsLeft, []);
|
||||
assert.match(done.body.transaction, /^t-/);
|
||||
assert.equal(done.actor_role, 'chief');
|
||||
assert.equal(done.actor_role, null);
|
||||
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
|
||||
assert.ok(!f.doc.launch.instances.includes('coder'));
|
||||
// No step G message for a job that completed.
|
||||
@@ -480,7 +482,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
|
||||
const f = await setup(t);
|
||||
await threeGrants(f);
|
||||
f.failing.add('web2');
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
|
||||
assert.equal(more.length, 0);
|
||||
@@ -510,7 +512,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
|
||||
assert.equal(f.state('coder', 'gitea'), 'valid');
|
||||
// B back: the repeat revokes only B and Gitea.
|
||||
f.failing.delete('web2');
|
||||
const repeat = f.fire('coder');
|
||||
const repeat = await f.fire('coder');
|
||||
assert.deepEqual(repeat.revoking, ['host:web2', 'gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
const [done] = f.allowed('fire.done');
|
||||
@@ -529,7 +531,7 @@ test('row 281 T4b (Q1): a line a manual hosts.revoke left on C is removed by the
|
||||
const tag3 = f.tag('coder', 'web3');
|
||||
assert.equal(manual.hostLineLeft, tag3);
|
||||
f.failing.delete('web3');
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.lines.web3.has(tag3), false);
|
||||
@@ -552,7 +554,7 @@ test('row 281 T4b (Q1): with C unreachable, the fire completes and names the lin
|
||||
f.failing.add('web3');
|
||||
await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
|
||||
const tag3 = f.tag('coder', 'web3');
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
|
||||
@@ -568,7 +570,7 @@ test("row 281 T5: Gitea's admin token can't be checked: the job refuses with the
|
||||
const f = await setup(t);
|
||||
await f.approveGitea('coder');
|
||||
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/version', { status: 500, json: {} });
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
off();
|
||||
@@ -597,7 +599,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
|
||||
const helperDeletes = () => f.gitea.requests.filter(tokenDelete('demo-helper')).length;
|
||||
const before = helperDeletes();
|
||||
const offCoder = f.gitea.rule(tokenDelete('demo-coder'), { status: 500, json: {} });
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
|
||||
assert.equal(refused.grant, 'gitea');
|
||||
@@ -608,7 +610,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
|
||||
assert.equal(f.allowed('fire.done').length, 0);
|
||||
// Gitea back for coder only. The helper's DELETE still fails, so a business-wide sweep would show.
|
||||
offCoder();
|
||||
const repeat = f.fire('coder');
|
||||
const repeat = await f.fire('coder');
|
||||
assert.deepEqual(repeat.revoking, ['gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
const [done] = f.allowed('fire.done');
|
||||
@@ -627,7 +629,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
|
||||
test('row 281 T6 (sage-2574): a host decision approved before the fire and run after it ends revoked, with nothing applied', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = f.fixtureHost('coder', 'web1');
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1']);
|
||||
await f.broker.grantsIdle();
|
||||
const [r] = f.revoked('coder', (e) => e.host === 'web1');
|
||||
@@ -644,7 +646,7 @@ test('row 281 T6 (sage-2574): a host decision approved before the fire and run a
|
||||
test('row 281 T6b (sage-2574): a Gitea decision approved before the fire and run after it ends revoked before any Gitea write', async (t) => {
|
||||
const f = await setup(t);
|
||||
const id = f.fixtureGitea('coder');
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
const [r] = f.revoked('coder', (e) => e.method === 'gitea');
|
||||
@@ -689,7 +691,7 @@ test('row 281 T6c: a host executor in flight aborts and ends revoked; a Gitea ex
|
||||
const gd = await f.raiseGitea('coder');
|
||||
f.call('human', 'decision.resolve', { id: gd.id, choice: 'yes' });
|
||||
await until(() => reached);
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
|
||||
await until(() => f.ended(host.id));
|
||||
assert.equal(f.ended(host.id).code, 'revoked');
|
||||
@@ -714,7 +716,7 @@ test('row 281 T6d (B1): undecided host and Gitea decisions are in the set, and a
|
||||
const f = await setup(t);
|
||||
const host = await f.raiseHost('coder', 'web1');
|
||||
const gd = await f.raiseGitea('coder');
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
@@ -736,14 +738,14 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
|
||||
await f.approveHost('coder', 'web1');
|
||||
// Firing with no job: step B refused, so fire.stopped stands with no fire.done.
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
|
||||
f.fire('coder');
|
||||
await f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.allowed('fire.failed').length, 1);
|
||||
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
|
||||
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
|
||||
// After fire.done: delisted.
|
||||
f.org.fireError = null;
|
||||
assert.match(f.fire('coder').transaction, /^t-/);
|
||||
assert.match((await f.fire('coder')).transaction, /^t-/);
|
||||
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
|
||||
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
|
||||
// A rehire lists the name again.
|
||||
@@ -761,7 +763,7 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
|
||||
|
||||
test('row 281 T7: an instance with no grant fires as today, synchronously, with the same events', async (t) => {
|
||||
const f = await setup(t);
|
||||
const out = f.fire('helper');
|
||||
const out = await f.fire('helper');
|
||||
assert.deepEqual(Object.keys(out).sort(), ['cancelled', 'request', 'revision', 'runs', 'taken', 'transaction']);
|
||||
assert.match(out.transaction, /^t-/);
|
||||
// Recorded before the verb answered: no job.
|
||||
@@ -781,9 +783,9 @@ test('row 281 T9 (B3): one step G per agent; the manual verbs refuse while it ru
|
||||
await f.approveHost('coder', 'web1');
|
||||
await f.approveGitea('coder');
|
||||
f.held.add('web1:remove');
|
||||
f.fire('coder');
|
||||
await f.fire('coder');
|
||||
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
|
||||
assert.equal(code(() => f.fire('coder')), 'fire-in-progress');
|
||||
await assert.rejects(f.fire('coder'),{code:'fire-in-progress'});
|
||||
assert.equal(f.remove('coder').code, 'grants-revoking');
|
||||
assert.equal(f.allowed('grants.revoking').length, 0);
|
||||
assert.equal(f.allowed('fire.revoking').length, 1);
|
||||
@@ -820,7 +822,7 @@ test('row 281 T10 (B2): the fire and the delete answer while their jobs are held
|
||||
// The verbs answer before the event loop turns, so before any part of a job has run.
|
||||
let turned = false;
|
||||
setImmediate(() => (turned = true));
|
||||
const fired = f.fire('coder');
|
||||
const fired = await f.fire('coder');
|
||||
const deleted = f.remove('ops');
|
||||
assert.equal(turned, false);
|
||||
assert.equal(fired.done, false);
|
||||
@@ -844,7 +846,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
f.held.add('web1:remove');
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
|
||||
f.broker.endLaunch({ business: 'demo', run: 'chief-run', reason: 'test' });
|
||||
f.sw.state = { version: 1, halted: true, generation: GENERATION };
|
||||
@@ -855,7 +857,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
|
||||
assert.match(done.body.transaction, /^t-/);
|
||||
const [used, ...more] = f.allowed('role.fire');
|
||||
assert.equal(more.length, 0);
|
||||
assert.equal(used.actor_role, 'chief');
|
||||
assert.equal(used.actor_role, null);
|
||||
assert.equal(used.body.transaction, done.body.transaction);
|
||||
assert.ok(!f.doc.launch.instances.includes('coder'));
|
||||
assert.equal(f.allowed('fire.failed').length, 0);
|
||||
@@ -867,12 +869,12 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
|
||||
await f.approveHost('coder', 'web1');
|
||||
// N7: the writer's own credential-revoke-failed, which step G's key revokes can't clear.
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [failed, ...more] = f.allowed('fire.failed');
|
||||
assert.equal(more.length, 0);
|
||||
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'credential-revoke-failed']);
|
||||
assert.equal(failed.actor_role, 'chief');
|
||||
assert.equal(failed.actor_role, null);
|
||||
const [message] = f.inbox();
|
||||
for (const s of [out.job, 'host:web1', 'step B', 'credential-revoke-failed']) assert.ok(message.includes(s), s);
|
||||
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
|
||||
@@ -882,7 +884,7 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
|
||||
// The grant was revoked before step B.
|
||||
assert.equal(f.hostRow('coder', 'web1'), undefined);
|
||||
f.org.fireError = null;
|
||||
const repeat = f.fire('coder');
|
||||
const repeat = await f.fire('coder');
|
||||
assert.equal(repeat.job, undefined);
|
||||
assert.match(repeat.transaction, /^t-/);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
@@ -900,7 +902,7 @@ test('row 281 T13 (N4): a restart records each held job interrupted once, with o
|
||||
assert.equal(f.allowed('grants.revoked.done').length, 1);
|
||||
f.held.add('web1:remove');
|
||||
f.held.add('web2:remove');
|
||||
const fired = f.fire('coder');
|
||||
const fired = await f.fire('coder');
|
||||
assert.equal(f.remove('ops').code, 'grants-revoking');
|
||||
const deleteJob = f.allowed('grants.revoking').at(-1).body.job;
|
||||
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`) && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
|
||||
@@ -930,7 +932,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
|
||||
const d = await f.raiseHost('coder', 'web1');
|
||||
// N1: hosts.remove doesn't check open decisions, so the decision outlives its host.
|
||||
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
assert.match(out.job, UUID);
|
||||
assert.deepEqual(out.revoking, ['host:web1']);
|
||||
await f.broker.grantsIdle();
|
||||
@@ -946,7 +948,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
|
||||
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
|
||||
await f.broker.grantsIdle();
|
||||
assert.equal(f.ended(d.id), undefined);
|
||||
const repeat = f.fire('coder');
|
||||
const repeat = await f.fire('coder');
|
||||
assert.equal(repeat.job, undefined);
|
||||
assert.match(repeat.transaction, /^t-/);
|
||||
assert.equal(f.allowed('fire.done').length, 1);
|
||||
@@ -961,7 +963,7 @@ test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [failed] = f.allowed('fire.failed');
|
||||
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
|
||||
@@ -987,7 +989,7 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
|
||||
const f = await setup(t);
|
||||
await f.approveHost('coder', 'web1');
|
||||
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
|
||||
const out = f.fire('coder');
|
||||
const out = await f.fire('coder');
|
||||
await f.broker.grantsIdle();
|
||||
const [failed] = f.allowed('fire.failed');
|
||||
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, 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';
|
||||
|
||||
test('A4 direct instance.hire and instance.fire refuse and cannot write', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'a4-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
let writes = 0;
|
||||
const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['role.hire','role.fire'], crossRole: [] } } }, launch: { by: 'chief', instances: [] } } }, organization: () => { writes++; } });
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
for (const verb of ['instance.hire', 'instance.fire']) assert.throws(() => b.request(chief, { verb, args: { name: 'scout', definition: 'coder', reason: 'Need help' } }), { code: 'decision-required' });
|
||||
assert.equal(writes, 0);
|
||||
});
|
||||
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { readFileSync, mkdirSync } from 'node:fs';
|
||||
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report sk-test-NOT-A-SECRET-1234.' };
|
||||
const SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
|
||||
function fixture(t) {
|
||||
const root=mkdtempSync(join(tmpdir(),'a4-bus-')), rolesDir=rolesCopy(root);
|
||||
const file=writeJson(join(root,'businesses/demo.json'),{...businessDoc(root,'demo'),plugins:{harness:['pi']}});
|
||||
const storeRoot=join(root,'data');mkdirSync(storeRoot,{mode:0o700});
|
||||
const writer=organizationWriter({dir:root,rolesDir}),store=new Store(storeRoot);
|
||||
const service=hireDecisions({writer,rolesDir,dataRoot:storeRoot,configDir:root,assertClean:()=>{}});
|
||||
const loaded=()=>{const doc=loadBusiness('demo',{dir:root,rolesDir});return {...doc,roles:Object.fromEntries(Object.entries(doc.roles).map(([n,e])=>[n,{...doc.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=>b.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights});
|
||||
const caps={owner,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={})=>b.request(cap,{verb,args});
|
||||
const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra});
|
||||
const raise=extra=>call(caps.chief,'hire.raise',request(extra));
|
||||
t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
|
||||
const reloadChief=authority=>{const doc=loaded();doc.roles.chief.authority=authority;b.reloadBusiness('demo',doc);};
|
||||
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,reloadChief,writes:()=>writes};
|
||||
}
|
||||
test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{
|
||||
const {call,caps,request,store}=fixture(t);
|
||||
await assert.rejects(call(caps.coder,'hire.raise',request()),{code:'role-not-chief'});
|
||||
await assert.rejects(call(caps.chief,'hire.raise',request({flagged:[]})),{code:'invalid-request'});
|
||||
assert.equal(store.get('SELECT count(*) n FROM decisions').n,0);
|
||||
});
|
||||
test('A4 approve is digest bound and consumed once; generic decide never resolves hire, fire or change',async t=>{
|
||||
const {raise,call,owner,writes,store,file,b,caps}=fixture(t);
|
||||
const d=await raise();const p=await call(owner,'hire.preview.console',{id:d.id});
|
||||
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
|
||||
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:'0'.repeat(64)}),{code:'decision-stale'});
|
||||
for(const action of ['role.hire','role.fire','role.change']) {
|
||||
if(action!==d.action)store.run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',action,'2026-01-01T00:00:00.000Z','demo','chief','chief-run','gated',action,'human','Question?',JSON.stringify([{key:'yes',text:'Yes'},{key:'no',text:'No'}]),'no',0);
|
||||
assert.throws(()=>call(owner,'decision.resolve.console',{id:action===d.action?d.id:action,choice:'yes',digest:p.digest}),{code:'hire-resolution-required'});
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
assert.throws(()=>call(human,'decision.resolve',{id:action===d.action?d.id:action,choice:'yes'}),{code:'hire-resolution-required'});
|
||||
}
|
||||
const before=readFileSync(file);
|
||||
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest});
|
||||
assert.equal(writes(),1);assert.notDeepEqual(readFileSync(file),before);
|
||||
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-closed'});
|
||||
assert.equal(writes(),1);
|
||||
for(const row of [...store.all('SELECT * FROM decisions'),...store.all('SELECT * FROM events')])assert.ok(!JSON.stringify(row).includes('sk-test-NOT-A-SECRET-1234'));
|
||||
});
|
||||
test('A4 denied and expired decisions leave organization bytes untouched',async t=>{
|
||||
const {raise,call,owner,file,writes}=fixture(t);
|
||||
const before=readFileSync(file),d=await raise();
|
||||
await call(owner,'hire.resolve.console',{id:d.id,choice:'no',digest:d.hire.digest});
|
||||
const e=await raise(); const original=Date.now;
|
||||
try{Date.now=()=>original()+8*86400000;await assert.rejects(call(owner,'hire.resolve.console',{id:e.id,choice:'yes',digest:e.hire.digest}),{code:'decision-expired'});}finally{Date.now=original;}
|
||||
assert.deepEqual(readFileSync(file),before);assert.equal(writes(),0);
|
||||
});
|
||||
test('A4 members with org.hire refuse flagged or credential grants; decide alone refuses all hires',async t=>{
|
||||
const {raise,call,member}=fixture(t);
|
||||
const plain=await raise();
|
||||
await assert.rejects(call(member(['decide']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest}),{code:'right-required'});
|
||||
const flagged=await raise({role:{inline:{...INLINE,tools:['read','bash']}}});
|
||||
const credential=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
|
||||
for(const d of [flagged,credential])await assert.rejects(call(member(['org.hire']),'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'hire-owner-required'});
|
||||
await call(member(['org.hire']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest});
|
||||
});
|
||||
test('A4 human Console start raises and resolves one recorded decision with computed lines',async t=>{
|
||||
const {call,owner,request,store,writes}=fixture(t);
|
||||
const r=await call(owner,'hire.start.console',request({role:{inline:{...INLINE,tools:['read','bash']}}}));
|
||||
assert.equal(r.op,'resolved');assert.equal(writes(),1);
|
||||
const event=store.all("SELECT body FROM events WHERE json_extract(body,'$.operation')='decision.raise'").map(e=>JSON.parse(e.body))[0];
|
||||
assert.deepEqual(event.hire.flagged,[{field:'tools',value:'bash',reasons:['above-chief']}]);
|
||||
assert.equal(store.get('SELECT raised_by_role FROM decisions WHERE id=?',r.id).raised_by_role,'human');
|
||||
});
|
||||
test('A4 a stale organization digest never reaches the writer',async t=>{
|
||||
const {raise,call,owner,writer,file,writes}=fixture(t);
|
||||
const d=await raise(),old=writer.read('demo');
|
||||
writeJson(file,{...old.document,vars:{...old.document.vars,'tracker.pollSeconds':61}});
|
||||
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'decision-stale'});
|
||||
assert.equal(writes(),0);
|
||||
});
|
||||
|
||||
test('A4 approved credential role refuses launch until a binding is present',async t=>{
|
||||
const {raise,call,owner,b,caps,writer,file,root,rolesDir}=fixture(t);
|
||||
const d=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
|
||||
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest});
|
||||
assert.throws(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}),{code:'credential-unbound'});
|
||||
// Binding implementation is outside A4. The existing file shape supplies its metadata here.
|
||||
const doc=writer.read('demo').document;
|
||||
doc.roles.scout.credentials={gitea:{store:true,rotateBy:'2099-01-01'}};
|
||||
writeJson(file,doc);
|
||||
const loaded=loadBusiness('demo',{dir:root,rolesDir});
|
||||
b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
|
||||
assert.doesNotThrow(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}));
|
||||
});
|
||||
test('A4 refusals and records never carry invalid role text',async t=>{
|
||||
const {raise,store}=fixture(t);
|
||||
await assert.rejects(raise({role:{inline:{...INLINE,authority:{withinRole:['sk-test-NOT-A-SECRET-1234'],crossRole:[]}}}}),e=>{
|
||||
assert.ok(!JSON.stringify(e).includes('sk-test-NOT-A-SECRET-1234'));return true;
|
||||
});
|
||||
assert.ok(!JSON.stringify(store.all('SELECT * FROM events')).includes('sk-test-NOT-A-SECRET-1234'));
|
||||
});
|
||||
|
||||
test('A4 a human may mark a hire seen, and the chief may withdraw its own proposal',async t=>{
|
||||
const {raise,call,b,caps,writes}=fixture(t),d=await raise();
|
||||
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
assert.equal(call(human,'decision.seen',{id:d.id}).op,'seen');
|
||||
assert.equal(call(caps.chief,'decision.withdraw',{id:d.id}).op,'withdrawn');
|
||||
assert.equal(writes(),0);
|
||||
});
|
||||
|
||||
test('A4 Console previews and consumes only the exact narrowed grants and route',async t=>{
|
||||
const {raise,call,owner,writer,store}=fixture(t);
|
||||
const d=await raise({role:{inline:{...INLINE,tools:['read','bash'],credentials:[{service:'gitea',scopes:['read:repository']}]}}});
|
||||
const adjust={role:{tools:['read']},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
|
||||
const p=await call(owner,'hire.preview.console',{id:d.id,adjust});
|
||||
assert.notEqual(p.digest,d.hire.digest);assert.deepEqual(p.flagged,[]);assert.deepEqual(p.credentialLines,[]);
|
||||
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-stale'});
|
||||
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest,adjust});
|
||||
const doc=writer.read('demo').document,entry=doc.definitions[doc.roles.scout.definition.slice(4)];
|
||||
assert.deepEqual(entry.role.tools,['read']);assert.deepEqual(entry.role.credentials,[]);
|
||||
const consume=JSON.parse(store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='hire.consume'").body);
|
||||
assert.equal(consume.hire.digest,p.digest);assert.deepEqual(consume.hire.credentialLines,[]);
|
||||
});
|
||||
|
||||
test('A4 Console lists template hashes without role text, under org.hire',async t=>{
|
||||
const {call,owner,member}=fixture(t);
|
||||
const list=await call(owner,'hire.templates.console');
|
||||
assert.ok(list.templates.some(x=>x.name==='coder'&&/^[a-f0-9]{64}$/.test(x.sha256)));
|
||||
assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256'));
|
||||
assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'});
|
||||
});
|
||||
|
||||
test('A4 chief proposals require current role.hire for hire/change and role.fire for fire',async t=>{
|
||||
const f=fixture(t);
|
||||
f.reloadChief({withinRole:['message.send'],crossRole:[]});
|
||||
for(const op of ['hire','change','fire'])for(const verb of ['hire.raise','decision.raise']) {
|
||||
const hire=f.request({op});
|
||||
await assert.rejects(f.call(f.caps.chief,verb,verb==='decision.raise'?{action:`role.${op}`,hire}:hire),{code:'not-authorized'});
|
||||
}
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
|
||||
f.reloadChief({withinRole:['role.hire'],crossRole:[]});
|
||||
assert.ok((await f.raise()).id);
|
||||
assert.ok((await f.raise({op:'change',name:'coder'})).id);
|
||||
const fire={op:'fire',level:'org',name:'coder',reason:'Finished'};
|
||||
await assert.rejects(f.call(f.caps.chief,'hire.raise',fire),{code:'not-authorized'});
|
||||
f.reloadChief({withinRole:['role.fire'],crossRole:[]});
|
||||
assert.ok((await f.call(f.caps.chief,'hire.raise',fire)).id);
|
||||
await assert.rejects(f.raise(),{code:'not-authorized'});
|
||||
});
|
||||
|
||||
test('A4 chief authority is checked again after asynchronous proposal preparation',async t=>{
|
||||
const f=fixture(t),pending=f.raise();
|
||||
f.reloadChief({withinRole:['message.send'],crossRole:[]});
|
||||
await assert.rejects(pending,{code:'not-authorized'});
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
|
||||
});
|
||||
|
||||
test('A4 a member start needing owner approval refuses before saving a decision',async t=>{
|
||||
const f=fixture(t),cap=f.member(['org.hire']),before=readFileSync(f.file);
|
||||
for(const inline of [{...INLINE,tools:['read','bash']},{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}])
|
||||
await assert.rejects(f.call(cap,'hire.start.console',f.request({role:{inline}})),{code:'hire-owner-required'});
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
|
||||
assert.deepEqual(readFileSync(f.file),before);
|
||||
assert.equal((await f.call(cap,'hire.start.console',f.request())).op,'resolved');
|
||||
});
|
||||
|
||||
test('A4 inbox and decision reads expire unopened hires once without changing configuration',async t=>{
|
||||
const f=fixture(t),d=await f.raise({role:{inline:{...INLINE,tools:['read','bash']}}}),before=readFileSync(f.file);
|
||||
const human=f.b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
const original=Date.now;
|
||||
try {
|
||||
Date.now=()=>original()+8*86400000;
|
||||
await assert.rejects(f.call(f.member(['org.hire']),'hire.preview.console',{id:d.id}),{code:'decision-expired'});
|
||||
assert.deepEqual(f.call(human,'inbox'),[]);
|
||||
const recent=f.call(human,'decisions',{list:'recent'});
|
||||
assert.equal(recent[0].closed.by,'expiry');
|
||||
const another=await f.raise();
|
||||
Date.now=()=>original()+16*86400000;
|
||||
assert.deepEqual(f.call(human,'inbox'),[]);
|
||||
f.call(human,'decisions',{list:'recent'});
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND by='expiry'",another.id).n,1);
|
||||
} finally {Date.now=original;}
|
||||
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
// Trusted proposal service stand-in for executor-only tests. Authorization and real writers are
|
||||
// covered by hire-decisions.test.mjs; these tests isolate the existing grant/fire executors.
|
||||
export const hireServiceFixture = {
|
||||
prepare: (_business,args,{id,expiresAt}) => ({id,op:args.op,level:args.level,name:args.name,revision:'b'.repeat(64),flagged:[],credentialLines:[],digest:'a'.repeat(64),expiresAt}),
|
||||
check: () => {},
|
||||
};
|
||||
export const fixtureConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
|
||||
export const approveFixtureFire = async (broker,cap,name) => {const {id,op,choice,...result}=await broker.request(cap,{verb:'hire.start.console',args:{op:'fire',level:'org',name,reason:'The work is done'}});if(result.done===false)delete result.transaction;return result;};
|
||||
export const ownerConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
|
||||
export async function approveTemplateHire(broker,rolesDir,{business='demo',name,definition,reason='Fixture hire',model='claude-opus-5-5',...extra}) {
|
||||
const {readTemplate}=await import('../../organization/src/definitions.mjs');
|
||||
const source=readTemplate(rolesDir,definition);
|
||||
return broker.request(ownerConsole(broker,business),{verb:'hire.start.console',args:{op:'hire',level:'org',name,role:{template:definition,sha256:source.sha256},route:{harness:'pi',model},reason,...extra}});
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
|
||||
// decisions. Tokens are made up or the fake's, and only ever compared.
|
||||
import test from 'node:test';
|
||||
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
@@ -68,7 +69,7 @@ function world(t, options) {
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const business = makeBusiness({ ...options, root });
|
||||
const held = new Set();
|
||||
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
|
||||
const broker = new Broker({hires:hireServiceFixture, store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
|
||||
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
|
||||
}
|
||||
|
||||
@@ -138,7 +139,7 @@ async function verbWorld(t, opts = {}) {
|
||||
}
|
||||
return { revision: REV };
|
||||
};
|
||||
broker = new Broker({
|
||||
broker = new Broker({hires:hireServiceFixture,
|
||||
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
|
||||
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
|
||||
});
|
||||
@@ -174,7 +175,7 @@ async function verbWorld(t, opts = {}) {
|
||||
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
|
||||
};
|
||||
const fire = async () => {
|
||||
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
|
||||
await approveFixtureFire(broker,fixtureConsole(broker,'acme'),'chief');
|
||||
await until(() => !doc.launch.instances.includes('chief'));
|
||||
await broker.grantsIdle();
|
||||
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
|
||||
@@ -196,7 +197,7 @@ test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s li
|
||||
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
|
||||
grant(w.store, 'chief', { user: 'chief-bot' });
|
||||
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
|
||||
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
|
||||
assert.deepEqual(new Broker({hires:hireServiceFixture, store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
|
||||
'a bus with no grant store refuses rather than guess');
|
||||
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
|
||||
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
|
||||
// Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials
|
||||
// after the writer's checks and before the write. Fire revokes each service and keeps the store
|
||||
// directory. Delete calls remove (sage-1593). A failed revoke stops the verb with
|
||||
@@ -54,7 +55,8 @@ async function setup(t, { drop = [], seed = null } = {}) {
|
||||
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
bus.broker.request(chief, { verb: 'role.claim' });
|
||||
const ask = (verb, args) => bus.broker.request(human, { verb, args });
|
||||
const fire = (name) => bus.broker.request(chief, { verb: 'instance.fire', args: { name, reason: 'the work is done' } });
|
||||
const hireConsole=fixtureConsole(bus.broker);
|
||||
const fire = name => approveFixtureFire(bus.broker,hireConsole,name);
|
||||
const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent);
|
||||
const plant = (agent, service) => {
|
||||
mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 });
|
||||
@@ -85,12 +87,12 @@ test('T20 fire: a revoke that fails stops the fire with the file unchanged; one
|
||||
// The revoke can't unlink the token, so it fails before the write: the file still lists coder.
|
||||
const before = f.bytes();
|
||||
chmodSync(f.agentDir('coder'), 0o500);
|
||||
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
|
||||
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
|
||||
chmodSync(f.agentDir('coder'), 0o700);
|
||||
assert.equal(f.bytes(), before);
|
||||
assert.ok(JSON.parse(before).launch.instances.includes('coder'));
|
||||
|
||||
const fired = f.fire('coder');
|
||||
const fired = await f.fire('coder');
|
||||
assert.equal(fired.delisted, true);
|
||||
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] });
|
||||
assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder'));
|
||||
@@ -109,7 +111,7 @@ test('T20 fire: an instance already delisted still has its tokens revoked and th
|
||||
doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2');
|
||||
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
||||
const before = f.bytes();
|
||||
const fired = f.fire('rev-2');
|
||||
const fired = await f.fire('rev-2');
|
||||
assert.equal(fired.delisted, false);
|
||||
assert.equal(fired.transaction, null);
|
||||
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
|
||||
@@ -122,10 +124,10 @@ test('T20 fire: with no entry in the running bus, a token on disk refuses and an
|
||||
const f = await setup(t, { drop: ['rev-2'] });
|
||||
f.plant('rev-2', 'gitea');
|
||||
const before = f.bytes();
|
||||
assert.equal(f.code(() => f.fire('rev-2')), 'credential-revoke-failed');
|
||||
await assert.rejects(f.fire('rev-2'),{code:'credential-revoke-failed'});
|
||||
assert.equal(f.bytes(), before);
|
||||
rmSync(join(f.agentDir('rev-2'), 'gitea'));
|
||||
const fired = f.fire('rev-2');
|
||||
const fired = await f.fire('rev-2');
|
||||
assert.equal(fired.delisted, true);
|
||||
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
|
||||
});
|
||||
@@ -198,11 +200,11 @@ test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses st
|
||||
const f = await setup(t);
|
||||
f.plant('coder', 'host.web1');
|
||||
const before = f.bytes();
|
||||
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
|
||||
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
|
||||
assert.equal(f.bytes(), before);
|
||||
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
|
||||
rmSync(join(f.agentDir('coder'), 'host.web1'));
|
||||
assert.equal(f.fire('coder').delisted, true);
|
||||
assert.equal((await f.fire('coder')).delisted, true);
|
||||
});
|
||||
|
||||
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {readTemplate} from '../../organization/src/definitions.mjs';
|
||||
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
|
||||
// #655 isolated STATIC fixture: actual managed fromSession caller + runtime/writer/controller.
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
@@ -25,10 +27,10 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
|
||||
// Available before any acquired resource/setup can fail; no later f/launcher TDZ.
|
||||
const launchLog=()=>{const file=launchLogFile(join(root,'data'),'acme');return fs.existsSync(file)?fs.readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[];};
|
||||
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
|
||||
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
|
||||
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];if(!listed)delete doc.roles.aide;return doc;});
|
||||
// The established scratch aide pattern only; repository roles/policy stay untouched.
|
||||
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
|
||||
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
|
||||
const chiefFile=join(f.roles,'chief-of-staff.json');
|
||||
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
|
||||
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
|
||||
const state={keys:[sentinel],calls:[],refuseProbe,counts:{GET:0,PUT:0,PROBE:0},puts:[],failRemoval:false};
|
||||
@@ -110,7 +112,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
|
||||
const file=join(configDir,'businesses','acme.json');
|
||||
const hold=(method='GET',ordinal=state.counts[method]+1)=>{let release;const promise=new Promise(r=>{release=r;});const gate={method,ordinal,seen:false,used:false,cancelled:false,promise,release};gates.push(gate);return {ordinal,seen:()=>gate.seen,entered:()=>waitFor(()=>gate.seen,'owned HTTP gate not reached',5000),release,cancel:()=>{gate.cancelled=true;release();}};};
|
||||
const outcome=promise=>{const box={settled:false};box.promise=track(Promise.resolve(promise).then(value=>{box.settled=true;box.ok=true;box.value=value;return box;},error=>{box.settled=true;box.ok=false;box.code=error.code??'unexpected-error';return box;}));return box;};
|
||||
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=bus.broker.request(cap,{verb:'instance.hire',args:{name,definition:'aide',reason:'fixture ownership',...extra}});called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
|
||||
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=(async()=>{const definition=extra.definition??'aide';const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(f.roles,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture ownership',...(extra.vars?{vars:extra.vars}:{})}});return bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.resolve.console',args:{id:d.id,choice:'yes',digest:d.hire.digest}});})();called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
|
||||
const record=(name='aide')=>bus.credentials.modelRecord('acme/'+name);
|
||||
const snapshot=()=>{const journal=join(configDir,'businesses','.history','acme','changes.jsonl');return {membership:id(fs.readFileSync(file)),journal:fs.existsSync(journal)?id(fs.readFileSync(journal)):null,credentials:id(JSON.stringify(bus.credentials.status())),model:record()?id(JSON.stringify(record())):null,remote:id(JSON.stringify(state.keys)),calls:state.calls.length};};
|
||||
return {root,f,state,trace,proxyReceipts,logs,launcher,bus,chief,record,hold,outcome,hire,snapshot,file,launchLog,expectedKeyId:()=>expectedKeyId,valueFree,
|
||||
@@ -123,7 +125,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
|
||||
assertModelAbsent:name=>{assert.equal(bus.credentials.modelRecord('acme/'+name)===null,true);assert.equal(fs.existsSync(join(configDir,'secrets','agents','acme',name,'model')),false);},
|
||||
rotateBound:async()=>{const before=record();bus.credentials.writeModel('acme/aide',{...before,rotateBy:new Date(Date.now()-86400_000).toISOString().slice(0,10)});await waitFor(()=>record()?.retiring.some(r=>r.keyId===before.keyId),'original runtime sweep rotates bound generation');return before.keyId;},
|
||||
explicitEnd:()=>bus.broker.request(chief,{verb:'role.revoke',args:{role:'aide',reason:'fixture explicit end'}}),
|
||||
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture explicit fire'}}))),
|
||||
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture explicit fire'}}))),
|
||||
runDirectories:()=>fs.existsSync(runsDir(f.dataRoot,'acme'))?fs.readdirSync(runsDir(f.dataRoot,'acme')).map(name=>join(runsDir(f.dataRoot,'acme'),name)):[],
|
||||
sessions:()=>readSessions(f.dataRoot),send:body=>bus.broker.request(human,{verb:'message.send',args:{to:'aide',body,class:'REQUEST'}}),
|
||||
reply:sent=>waitFor(()=>bus.broker.request(human,{verb:'message.receive'}).find(m=>m.in_reply_to===sent.id),'real managed reply observed'),
|
||||
|
||||
@@ -38,9 +38,8 @@ test('655 actual launch own preflight generation and bound consumer survive conc
|
||||
|
||||
for(const [label,options,args,code] of [
|
||||
['retired name',{retired:true},{name:'alpha'},'organization-instance-retired'],
|
||||
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
|
||||
['fired mismatched legal definition',{listed:false},{definition:'helper'},'organization-instance-exists'],
|
||||
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
|
||||
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'invalid-request'],
|
||||
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'invalid-request'],
|
||||
])test('655 shared validation '+label+' has no credential effects',{timeout:30000},async t=>{
|
||||
const f=await ownershipFixture(t,options),before=f.snapshot(),{name='aide',...extra}=args;await refused(f.hire(name,extra),code);
|
||||
assert.deepEqual(f.snapshot(),before,'shared hire refusal precedes credential registration or proxy I/O');f.valueFree();
|
||||
@@ -49,7 +48,7 @@ for(const [label,options,args,code] of [
|
||||
for(const [label,options,extra,code] of [
|
||||
['kept target',{keep:true},{},'instance-kept'],
|
||||
['noHire definition',{noHire:true},{},'definition-not-hireable'],
|
||||
['ineligible definition',{}, {definition:'security-reviewer'},'definition-not-hireable'],
|
||||
['duplicate with another definition',{}, {definition:'security-reviewer'},'organization-instance-exists'],
|
||||
['invalid vars',{}, {vars:{harness:'pi'}},'invalid-request'],
|
||||
])test('655 existing Broker '+label+' refusal remains before model effects',{timeout:30000},async t=>{
|
||||
const f=await ownershipFixture(t,options),before=f.snapshot();await refused(f.hire('aide',extra),code);assert.deepEqual(f.snapshot(),before);f.valueFree();
|
||||
@@ -59,9 +58,9 @@ test('655 existing nonchief actor refusal remains before shared checks and model
|
||||
const f=await ownershipFixture(t),cap=f.wrongActor(),before=f.snapshot();await refused(f.hire('aide',{},cap),'role-not-chief');assert.deepEqual(f.snapshot(),before);f.valueFree();
|
||||
});
|
||||
|
||||
test('655 valid fired matching definition rehire publishes before queued acquisition',{timeout:30000},async t=>{
|
||||
test('655 valid new hire publishes before queued acquisition',{timeout:30000},async t=>{
|
||||
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();assert.equal(f.listed('aide'),false);
|
||||
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.ok,true);assert.equal(f.listed('aide'),true);assert.equal(p.value.key===f.record().key,true);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
|
||||
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.code,'unknown-role','no admission before membership publication');assert.equal(f.listed('aide'),true);const admitted=await done(f.preflight());assert.equal(admitted.ok,true);assert.equal(admitted.value.key===f.record().key,true);assert.equal(await f.healthy(admitted.value.key),200);f.valueFree();
|
||||
});
|
||||
|
||||
test('655 preexisting admitted generation and retiring metadata survive duplicate failure',{skip:unavailable,timeout:30000},async t=>{
|
||||
@@ -74,22 +73,22 @@ test('655 captured revision is checked after controller queue before generation
|
||||
const f=await ownershipFixture(t),gate=f.hold('GET'),p=f.preflight('chief');await gate.entered();const h=f.hire('alpha');
|
||||
// A following same-business operation proves H established its pending slot,
|
||||
// rather than an arbitrary sleep before the external owner revision change.
|
||||
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'revision-conflict');
|
||||
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'decision-stale');
|
||||
assert.equal(f.state.counts.PUT,1,'queued stale hire does not issue a generation');f.assertModelAbsent('alpha');assert.equal(f.listed('alpha'),false);f.valueFree();
|
||||
});
|
||||
|
||||
test('655 genuine new generation revision failure cleans before queued acquisition receives a key',{timeout:30000},async t=>{
|
||||
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();
|
||||
const failedGeneration=f.state.puts[0].find(k=>!k.includes('-sentinel-'));assert.ok(failedGeneration,'real first issuance payload observed');
|
||||
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'revision-conflict');await done(p);
|
||||
const valid=p.ok&&p.value.key!==failedGeneration&&f.record()?.key===p.value.key&&f.state.keys.includes(p.value.key)&&!f.state.keys.includes(failedGeneration);
|
||||
assert.equal(valid,true,'queued acquisition receives a valid post-cleanup generation');assert.equal(f.listed('aide'),false);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
|
||||
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'decision-stale');await done(p);
|
||||
assert.equal(p.code,'unknown-role','an unpublished agent cannot acquire a model key');
|
||||
assert.equal(f.state.keys.includes(failedGeneration),false,'failed generation was removed');f.assertModelAbsent('aide');assert.equal(f.listed('aide'),false);f.valueFree();
|
||||
});
|
||||
|
||||
test('655 failed cleanup retains revoking intent and refuses queued admission',{timeout:30000},async t=>{
|
||||
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();f.bumpRevision();f.state.failRemoval=true;put.release();
|
||||
await refused(h,'revision-conflict');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
|
||||
assert.equal(!p.ok&&p.code==='proxy-key-revoking',true,'queued admission refuses retained revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
|
||||
await refused(h,'decision-stale');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
|
||||
assert.equal(!p.ok&&p.code==='unknown-role',true,'unpublished membership refuses admission while model record retains revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
|
||||
});
|
||||
|
||||
test('655 no-launch remains before own preflight and grants no consumer or stop authority',{timeout:30000},async t=>{
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import {materializeCopies,withCopies} from '../../organization/src/agent-copies.mjs';
|
||||
import {loadRootAgents} from '../../organization/src/agent-levels.mjs';
|
||||
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
|
||||
// P2: real private Credentials, fake loopback management/model server. No live config.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
@@ -173,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
|
||||
stopped = true; await overdue.sweep(); assert.equal(f.state.keys.includes(first.key), false);
|
||||
});
|
||||
|
||||
async function bootBusiness(f, binding = {}) {
|
||||
async function bootBusiness(f, {copyLevel = null,...binding} = {}) {
|
||||
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
|
||||
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
|
||||
@@ -181,23 +184,26 @@ async function bootBusiness(f, binding = {}) {
|
||||
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
|
||||
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
|
||||
});
|
||||
const chiefFile = join(rolesDir, 'chief-of-staff.json');
|
||||
writeJson(chiefFile, { ...JSON.parse(fs.readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
|
||||
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi'] };
|
||||
doc.roles['sec-1'] = { definition: 'security-reviewer' }; doc.launch.instances.push('sec-1');
|
||||
const copyEntry={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
||||
if(copyLevel==='project')doc.projects.stack.agents={aide:copyEntry};
|
||||
if(copyLevel==='root')writeJson(join(f.configDir,'agents.json'),{agentsVersion:1,agents:{aide:copyEntry}});
|
||||
const copies=copyLevel?materializeCopies({orgId:'demo',doc,rolesDir,root:loadRootAgents(f.configDir,{rolesDir})}):null;
|
||||
const file = writeJson(join(f.configDir, 'businesses', 'demo.json'), doc);
|
||||
fs.writeFileSync(join(f.configDir, 'secrets', 'proxy-management.key'), 'fx331-manage', { mode: 0o600 });
|
||||
const business = loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
|
||||
const business = copies?withCopies(doc,file,{rolesDir,snapshot:copies}):loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
|
||||
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
|
||||
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
|
||||
// Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
|
||||
new Registry(join(f.root, 'data'));
|
||||
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busOrganization(business, resolved) },
|
||||
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
|
||||
copies, organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
|
||||
f.closers.push(() => bus.close());
|
||||
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
bus.broker.request(chief, { verb: 'role.claim' });
|
||||
const fire = () => bus.broker.request(chief, { verb: 'instance.fire', args: { name: 'sec-1', reason: 'fixture fire' } });
|
||||
const fire = () => bus.broker.request(ownerConsole(bus.broker), { verb: 'hire.start.console', args: { op:'fire',level:'org',name: 'sec-1', reason: 'fixture fire' } });
|
||||
const listed = () => JSON.parse(fs.readFileSync(file, 'utf8')).launch.instances.includes('sec-1');
|
||||
return { bus, chief, fire, listed, file, rolesDir, system };
|
||||
}
|
||||
@@ -233,7 +239,7 @@ test('proxy-revoke-revision-conflict: racing organization write keeps record, ne
|
||||
});
|
||||
test('bus hire issues and confirms implicit model key before publishing new membership', async t => {
|
||||
const f = await fixture(t); const b = await bootBusiness(f); const calls = f.state.calls.length;
|
||||
const result = await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
|
||||
const result = await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
|
||||
assert.equal(typeof result.transaction, 'string');
|
||||
assert.equal(JSON.parse(fs.readFileSync(b.file, 'utf8')).launch.instances.includes('sec-new'), true);
|
||||
assert.equal(b.bus.credentials.modelRecord('demo/sec-new')?.state, 'valid');
|
||||
@@ -242,7 +248,7 @@ test('bus hire issues and confirms implicit model key before publishing new memb
|
||||
test('proxy-key-issue-failed: bus hire failure publishes neither membership nor model file', async t => {
|
||||
const f = await fixture(t); const b = await bootBusiness(f); f.state.failPut = true;
|
||||
const before = fs.readFileSync(b.file, 'utf8');
|
||||
await assert.rejects(b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } }), failCode('proxy-key-issue-failed'));
|
||||
await assert.rejects(approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'}), failCode('proxy-key-issue-failed'));
|
||||
assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.bus.credentials.modelRecord('demo/sec-new') === null, true);
|
||||
});
|
||||
test('proxy-open: removing sentinel after boot refuses the next launch before models probe', async t => {
|
||||
@@ -252,7 +258,7 @@ test('proxy-open: removing sentinel after boot refuses the next launch before mo
|
||||
});
|
||||
test('newly hired role can use runtime preflight without a broker restart', async t => {
|
||||
const f = await fixture(t); const b = await bootBusiness(f);
|
||||
await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
|
||||
await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
|
||||
const proxy = await b.bus.modelPreflight({ business: 'demo', role: 'sec-new' });
|
||||
assert.equal(proxy.baseUrl, f.baseUrl); assert.equal(f.state.keys.includes(proxy.key), true);
|
||||
});
|
||||
@@ -328,3 +334,15 @@ test('beta bus registers while alpha bus runs and alpha key stays accepted', asy
|
||||
assert.ok(f.state.keys.some(value => value.startsWith(`mso-${hash(f.configDir)}-beta-sentinel-`)));
|
||||
assert.equal((await alpha.bus.modelPreflight({ business: 'demo', role: 'sec-1' })).key, key);
|
||||
});
|
||||
|
||||
for(const level of ['root','project'])test(`A4 ${level} fire removes its implicit model key before publication`,async t=>{
|
||||
const f=await fixture(t),b=await bootBusiness(f,{copyLevel:level});
|
||||
const instance=level==='root'?'aide':'aide.stack';
|
||||
const issued=await b.bus.modelPreflight({business:'demo',role:instance});
|
||||
assert.ok(issued.key);
|
||||
await b.bus.broker.request(ownerConsole(b.bus.broker),{verb:'hire.start.console',args:{op:'fire',level,...(level==='project'?{project:'stack'}:{}),name:'aide',reason:'Copy work finished'}});
|
||||
assert.equal(f.state.keys.includes(issued.key),false,'remote generation is revoked');
|
||||
assert.equal(b.bus.credentials.modelRecord('demo/'+instance),null,'private generation is removed');
|
||||
const human=b.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
assert.throws(()=>b.bus.broker.request(human,{verb:'instance.launch',args:{instance}}));
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { approveTemplateHire } from './hire-fixtures.mjs';
|
||||
// #651 actual runtime + organization.hire publication with orphan private records.
|
||||
// In-process trusted Broker, real SQLite/organization writer/HTTP; not process IPC.
|
||||
import test from 'node:test';
|
||||
@@ -16,7 +17,7 @@ async function hireFixture(t,{revoking=false}={}){
|
||||
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
|
||||
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
|
||||
const chiefFile=join(rolesDir,'chief-of-staff.json');doc.plugins={...doc.plugins,harness:['pi']};
|
||||
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
|
||||
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
|
||||
const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
|
||||
@@ -27,7 +28,7 @@ async function hireFixture(t,{revoking=false}={}){
|
||||
assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
|
||||
const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
|
||||
const listed=()=>JSON.parse(fs.readFileSync(file,'utf8')).launch.instances.includes('orphan'),observed=[];f.state.observePut=()=>observed.push(listed());f.state.calls=[];
|
||||
const hire=()=>bus.broker.request(cap,{verb:'instance.hire',args:{name:'orphan',definition:'aide',reason:'fixture orphan hire'}});
|
||||
const hire=()=>approveTemplateHire(bus.broker,rolesDir,{name:'orphan',definition:'aide'});
|
||||
return {...f,bus,issued,file,log,listed,observed,hire};
|
||||
}
|
||||
test('L3b runtime hire publishes orphan only after fresh remote confirmation',async t=>{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { ownerConsole } from './hire-fixtures.mjs';
|
||||
// #654 isolated real runtime/writer/controller with owned HTTP request gates.
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
@@ -22,7 +24,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
|
||||
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
|
||||
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
|
||||
// Existing row331 scratch aide pattern only; repository role authority unchanged.
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
|
||||
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
|
||||
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
|
||||
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
|
||||
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
|
||||
@@ -44,7 +46,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
|
||||
const holdNext=(method='GET')=>{assert.equal(state.nextGate,null,'only one owned HTTP hold armed');const g={method,entered:deferred(),release:deferred()};gates.push(g);state.nextGate=g;return {entered:()=>bounded(g.entered.promise,'owned lifecycle HTTP boundary not observed'),release:()=>g.release.resolve()};};
|
||||
const listed=(business,name)=>JSON.parse(fs.readFileSync(files[business],'utf8')).launch.instances.includes(name);
|
||||
const snapshot=business=>{const journal=join(configDir,'businesses','.history',business,'changes.jsonl');return {membership:hash(fs.readFileSync(files[business])),publicationJournal:fs.existsSync(journal)?hash(fs.readFileSync(journal)):null,modelStatus:hash(JSON.stringify(bus.credentials.status())),remote:hash(JSON.stringify(state.keys)),calls:state.calls.length};};
|
||||
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>bus.broker.request(cap,{verb:'instance.hire',args:{name,definition,reason:'fixture pending hire'}})));
|
||||
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>(async()=>{const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(rolesDir,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture pending hire'}});return bus.broker.request(ownerConsole(bus.broker,business),{verb:'hire.resolve.console',args:{id:d.id,digest:d.hire.digest,choice:'yes'}});})()));
|
||||
const busy=async promise=>{const result=await bounded(promise.then(()=>({code:'unexpected-success'}),e=>({code:e.code??'unexpected-error'})),'same-business refusal not observed');assert.equal(result.code,'organization-busy','second same-business lifecycle call refuses exact pending code');};
|
||||
return {root,configDir,dataRoot,rolesDir,bus,state,receipts,chief,holdNext,listed,snapshot,hire,busy,valueFree,
|
||||
healthy:async(business,name)=>{const value=await bus.modelPreflight({business,role:name});const record=bus.credentials.modelRecord(business+'/'+name);assert.equal(value.key===record.key,true);assert.equal(state.keys.includes(record.key),true,'published member retains confirmed model credential');},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {ownerConsole} from './hire-fixtures.mjs';
|
||||
// #656 STATIC isolated fixtures. No real config/key/proxy; no production seam.
|
||||
import fs from 'node:fs';
|
||||
import http from 'node:http';
|
||||
@@ -74,7 +75,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
|
||||
syncBuiltinESMExports();restoreFs=()=>{Object.assign(fs,original);syncBuiltinESMExports();};
|
||||
const record=()=>c.modelRecord(instance),exists=path=>{try{original.lstatSync(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}};
|
||||
const seed=({matchingTmp=true,retiring=0,valid=false}={})=>{const current=key(),old=Array.from({length:retiring},()=>({key:key(),issuedAt:'2026-10-23T00:00:00.000Z',retireAt:'2099-01-01T00:00:00.000Z'})).map(r=>({...r,keyId:id(r.key)}));const value={key:current,keyId:id(current),issuedAt:'2026-10-24T00:00:00.000Z',rotateBy:'2099-01-01',state:valid?'valid':'revoking',...(valid?{}:{revokingAt:'2026-10-25T00:00:00.000Z'}),retiring:old};state.keys.push(current,...old.map(r=>r.key));c.writeModel(instance,value);if(matchingTmp){fs.copyFileSync(file,temporary);fs.chmodSync(temporary,0o600);}removedTmp=false;return value;};
|
||||
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
|
||||
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(ownerConsole(bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
|
||||
const writeSessions=entries=>writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:entries});
|
||||
const ownRun=async({wrongTicks=false,wrongKey=false,publish=true}={})=>{
|
||||
const spawnOwn=async()=>{const handle=spawn('/usr/bin/sleep',['60'],{stdio:'ignore'}),closed=new Promise(resolve=>handle.once('close',resolve));const owned={handle,closed};children.push(owned);await once(handle,'spawn');const ticks=startTimeOf(handle.pid);assert.equal(typeof ticks,'string');return {...owned,ticks,exit:async()=>{if(handle.exitCode===null&&handle.signalCode===null)handle.kill('SIGTERM');await bounded(closed,'owned stand-in exit timeout');}};};
|
||||
|
||||
@@ -41,7 +41,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
|
||||
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
|
||||
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
|
||||
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
|
||||
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import {approveTemplateHire,ownerConsole} from './hire-fixtures.mjs';
|
||||
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
|
||||
// restart would load it, so a created instance binds with no restart. A definition change on a
|
||||
// running instance refuses, and a file whose credential references changed isn't reloaded.
|
||||
@@ -149,8 +150,6 @@ test('T19 a credentialed create answers restart true; issue works only after a r
|
||||
// A, from the file, so the kept instance keeps its claim and isn't left firing.
|
||||
test('T14 after a refused reload veto.set, instance.hire and instance.fire answer restart true, and a fire of a name kept since refuses before step A', async (t) => {
|
||||
const f = await setup(t);
|
||||
const roleFile = join(f.rolesDir, 'chief-of-staff.json');
|
||||
writeFileSync(roleFile, JSON.stringify({ ...JSON.parse(readFileSync(roleFile, 'utf8')), hireable: ['chief-information-security-officer'] }, null, 2));
|
||||
mkdirSync(join(f.root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
|
||||
const chief = bind(f.bus, 'chief');
|
||||
f.bus.broker.request(chief, { verb: 'role.claim' });
|
||||
@@ -162,21 +161,21 @@ test('T14 after a refused reload veto.set, instance.hire and instance.fire answe
|
||||
|
||||
const added = f.ask('instances.set', { revision: f.revision(), instance: 'rev-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-demo-rev-2', botId: 40 } });
|
||||
assert.equal(added.restart, true);
|
||||
const hired = asChief('instance.hire', { name: 'sec-2', definition: 'chief-information-security-officer', reason: 'a security lead' });
|
||||
assert.equal(hired.restart, true);
|
||||
const hired = await approveTemplateHire(f.bus.broker,f.rolesDir,{name:'sec-2',definition:'chief-information-security-officer'});
|
||||
assert.ok(hired.transaction);
|
||||
const kept = f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'reviewer' });
|
||||
assert.equal(kept.restart, true);
|
||||
assert.equal(f.reloads.length, 0, 'the running copy still has reviewer unkept');
|
||||
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
assert.equal(code(() => asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' })), 'instance-kept');
|
||||
await assert.rejects(f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}}),{code:'instance-kept'});
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.deepEqual(f.ask('instances').instances.reviewer.running, run, 'step A never ran: the claim is held');
|
||||
assert.notEqual(code(() => f.ask('instance.launch', { instance: 'reviewer' })), 'instance-firing');
|
||||
|
||||
const unkept = f.ask('veto.set', { revision: f.revision(), op: 'unkeep', name: 'reviewer' });
|
||||
assert.equal(unkept.restart, true);
|
||||
const fired = asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' });
|
||||
const fired = await f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}});
|
||||
assert.equal(fired.restart, true);
|
||||
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).launch.instances.includes('reviewer'), false);
|
||||
});
|
||||
|
||||
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
|
||||
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
|
||||
|
||||
test('the console verb list includes the artifact read and existing owner controls', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
});
|
||||
|
||||
|
||||
@@ -385,7 +385,7 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
|
||||
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
|
||||
b.request(caps.chief, { verb: 'role.claim' });
|
||||
const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' });
|
||||
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'invalid-request', name);
|
||||
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'decision-required', name);
|
||||
// A name that isn't reserved passes the name check and refuses later, for want of role files.
|
||||
assert.notEqual(hire('helper'), 'invalid-request');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/definition-verbs.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/definition-verbs.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/definitions.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/definitions.mjs";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "../../organization/src/hire-decisions.mjs";
|
||||
@@ -154,11 +154,13 @@ function pidAlive(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
// Names the lock by label: its path sits beside the access file in the config
|
||||
// directory, and a refusal never quotes a host path (PR #1916).
|
||||
function lockRefusal(lock) {
|
||||
const pid = lockPid(lock);
|
||||
if (pid !== null && pidAlive(pid)) return BUSY;
|
||||
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
||||
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
||||
if (pid === null) return `refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again`;
|
||||
return `refused: access file lock is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
|
||||
}
|
||||
|
||||
function takeLock(lock, fs) {
|
||||
|
||||
@@ -37,8 +37,10 @@ function parse(args) {
|
||||
function owns(user, owner, providers) {
|
||||
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
|
||||
}
|
||||
function readOwner(root) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
|
||||
// The registry checked `id` against the id pattern, so the label may name it; the
|
||||
// root is a host path and never reaches a refusal (PR #1916).
|
||||
function readOwner(root, id) {
|
||||
const owner = readJsonFile(join(root, 'owner.json'), `organization owner of ${id}`, st => {
|
||||
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
|
||||
});
|
||||
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
|
||||
@@ -92,7 +94,7 @@ export async function accountsCommand(args, io, deps) {
|
||||
// writing owner.json, so any missing owner must block removal too.
|
||||
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
|
||||
let owner;
|
||||
try { owner = readOwner(entry.root); }
|
||||
try { owner = readOwner(entry.root, entry.id); }
|
||||
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
|
||||
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
|
||||
}
|
||||
|
||||
@@ -37,9 +37,10 @@ function write(dataRoot, status, event) {
|
||||
appendFileSync(bootEventsFile(dataRoot), `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
// A start refused: keeps the time of the last good load from the status it replaces.
|
||||
export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString() }) {
|
||||
const prior = readBootStatus(dataRoot);
|
||||
// A start refused: keeps the time of the last good load from the status it replaces. `prior` is the
|
||||
// status from before this start, for a refusal that comes after the start recorded its load (org-defscan:
|
||||
// the broker's held-secret check on the definitions).
|
||||
export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString(), prior = readBootStatus(dataRoot) }) {
|
||||
const lastGood = prior?.ok ? prior.at : prior?.lastGood ?? null;
|
||||
const text = String(reason).slice(0, REASON_MAX);
|
||||
write(dataRoot, { ok: false, at, business, file, reason: text, lastGood }, { event: "bus.boot.refused", at, business, file, reason: text });
|
||||
|
||||
@@ -37,6 +37,7 @@ import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
|
||||
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
|
||||
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
|
||||
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
|
||||
import { definitionsCommand } from "./org-definitions.mjs";
|
||||
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
|
||||
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
|
||||
import { createLauncher } from "./launcher.mjs";
|
||||
@@ -650,9 +651,14 @@ async function bus(rest, io, deps) {
|
||||
throw e;
|
||||
}
|
||||
if (instance) boot.consoleOwners = [...(boot.consoleOwners ?? []), DEV_OWNER];
|
||||
const priorBoot = readBootStatus(org.root);
|
||||
try { recordBootOk(org.root, { business: businessId, file }); } catch { /* status is advisory */ }
|
||||
const binding = readNotifyConfig(org.root, businessId);
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } });
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } }).catch((e) => {
|
||||
// org-defscan: the broker refused a definition that holds a credential it holds; the load was not good after all.
|
||||
if (e.code === "definition-holds-credential") try { recordBootRefused(org.root, { business: businessId, file, reason: e.message, prior: priorBoot }); } catch { /* the refusal still throws */ }
|
||||
throw e;
|
||||
});
|
||||
try {
|
||||
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
|
||||
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
|
||||
@@ -737,6 +743,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
// builds; `org validate FILE` stays orgCommand's draft check.
|
||||
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
|
||||
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// #1873 T1: definition writes go through the organization's bus, as the human.
|
||||
if (verb === 'org' && rest[0] === 'definitions') return definitionsCommand(rest.slice(1), io, { ...deps, callFor: (p) => callFor(p, io, deps) });
|
||||
if (verb === 'org') return orgCommand(rest, io, deps);
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
|
||||
@@ -46,7 +46,7 @@ function noCredentials(env) {
|
||||
function ownedPrivate(path, directory) {
|
||||
const st = lstatSync(path);
|
||||
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile()) || st.uid !== process.getuid() || (st.mode & 0o077))
|
||||
throw refuse('fixture-state-unsafe', `${path} is not a private ${directory ? 'directory' : 'file'} of this user`);
|
||||
throw refuse('fixture-state-unsafe', `${directory ? 'dev-fixture directory' : 'fixture file'} is not a private ${directory ? 'directory' : 'file'} of this user`);
|
||||
}
|
||||
|
||||
// The recorded fixture, or null when there is none. Anything malformed refuses.
|
||||
@@ -56,19 +56,19 @@ export function readFixture(dataRoot) {
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
|
||||
let fd;
|
||||
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `${file} can't be opened (${e.code})`); }
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `fixture.json can't be opened (${e.code})`); }
|
||||
let doc;
|
||||
try {
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', `${file} is not a private file of this user`);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', 'fixture.json is not a private file of this user');
|
||||
doc = JSON.parse(readFileSync(fd, 'utf8'));
|
||||
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', `${file} is not JSON`); }
|
||||
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', 'fixture.json is not JSON'); }
|
||||
finally { closeSync(fd); }
|
||||
const m = LOOPBACK.exec(doc?.endpoint ?? '');
|
||||
if (!m) throw refuse('fixture-endpoint-not-loopback', `${file} names an endpoint that is not http://127.0.0.1:<port>`);
|
||||
if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:<port>');
|
||||
if (doc.fixtureVersion !== 1 || doc.model !== 'deterministic' || Number(m[1]) !== doc.port || !Number.isSafeInteger(doc.pid) || doc.pid < 2
|
||||
|| typeof doc.instance !== 'string' || JSON.stringify(doc.entry) !== JSON.stringify(FIXTURE_ENTRY))
|
||||
throw refuse('fixture-state-invalid', `${file} is not a fixture record`);
|
||||
throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record');
|
||||
return doc;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,12 +79,13 @@ import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { join } from "node:path";
|
||||
import { basename, join } from "node:path";
|
||||
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
|
||||
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
|
||||
import { CliError } from "./errors.mjs";
|
||||
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
|
||||
import { withProcessing } from '../../processing-control/src/effects.mjs';
|
||||
|
||||
const HEADER_MAX = 4096;
|
||||
@@ -385,7 +386,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
if (!p) throw new Refusal("unknown-project");
|
||||
mkdirSync(logs, { recursive: true, mode: 0o700 });
|
||||
prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) });
|
||||
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`);
|
||||
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `project ${facts.project} isn't a mosaic-stack checkout with node_modules`);
|
||||
symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules"));
|
||||
} catch (e) {
|
||||
if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
|
||||
@@ -398,7 +399,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
|
||||
scope.guard();
|
||||
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
|
||||
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
|
||||
// The detail reaches the role, so the log is named inside the bus host directory, never by
|
||||
// host path (PR #1916); its file name only when it passes the id pattern.
|
||||
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see land/${id}${ID_PATTERN.test(basename(r.log ?? "")) ? `/${basename(r.log)}` : ""} in the bus host directory`);
|
||||
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
|
||||
} finally {
|
||||
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
|
||||
@@ -503,7 +506,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
|
||||
|
||||
async function listen() {
|
||||
if (existsSync(path)) {
|
||||
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
|
||||
if (!lstatSync(path).isSocket()) throw new CliError("git socket path exists and isn't a socket", 3);
|
||||
rmSync(path);
|
||||
}
|
||||
server = createServer((socket) => {
|
||||
|
||||
@@ -54,10 +54,10 @@ function readState(file, found = () => {}) {
|
||||
state = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT") return null;
|
||||
throw new CliError(`bus host state is unreadable: ${file}`, 3);
|
||||
throw new CliError("bus host state is unreadable", 3);
|
||||
}
|
||||
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
|
||||
throw new CliError(`bus host state is malformed: ${file}`, 3);
|
||||
throw new CliError("bus host state is malformed", 3);
|
||||
}
|
||||
return { ...state, live: startTimeOf(state.pid) === state.startTime };
|
||||
}
|
||||
@@ -94,7 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
|
||||
const prior = readState(file, found);
|
||||
if (!prior) continue;
|
||||
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
|
||||
if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3);
|
||||
if (refuseStale) throw new CliError("stale bus host lock", 3);
|
||||
between();
|
||||
try {
|
||||
renameSync(file, aside);
|
||||
@@ -111,7 +111,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
|
||||
}
|
||||
rmSync(aside, { force: true });
|
||||
}
|
||||
throw new CliError(`${file} kept changing; no bus host started`, 3);
|
||||
throw new CliError("bus host lock kept changing; no bus host started", 3);
|
||||
} finally {
|
||||
rmSync(tmp, { force: true });
|
||||
}
|
||||
@@ -228,7 +228,10 @@ export async function startHost({ boot, business, notifier = null, options = nul
|
||||
if (ready?.ok !== true) {
|
||||
await ended(broker, 5000);
|
||||
release();
|
||||
throw new CliError(`broker refused to start: ${typeof ready?.error === "string" ? ready.error : "startup-refused"}`, 3);
|
||||
const code = typeof ready?.error === "string" ? ready.error : "startup-refused";
|
||||
// org-defscan: the broker's reason names the definition and key that hold a credential, never the text.
|
||||
const why = code === "definition-holds-credential" && typeof ready?.reason === "string" ? `: ${ready.reason.slice(0, 300)}` : "";
|
||||
throw Object.assign(new CliError(`broker refused to start: ${code}${why}`, 3), code === "definition-holds-credential" ? { code } : {});
|
||||
}
|
||||
|
||||
// Every step from here to host.json shares one failure path: it stops the
|
||||
|
||||
@@ -120,7 +120,9 @@ function setArgs(parsed) {
|
||||
try {
|
||||
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
|
||||
} catch (error) {
|
||||
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
|
||||
// Name the flag and the error kind, never the file's path or bytes (PR #1916).
|
||||
const code = typeof error?.code === "string" && /^E[A-Z0-9]+$/.test(error.code) ? error.code : "unreadable";
|
||||
throw new CliError(`--vars file: ${error instanceof SyntaxError ? "not valid JSON" : code}`, 4);
|
||||
}
|
||||
}
|
||||
if (launch !== undefined) args.launch = launch;
|
||||
|
||||
@@ -380,9 +380,11 @@ export function createLauncher({
|
||||
// workspace and the thread's store, so no session of the instance starts while one lives.
|
||||
if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session))
|
||||
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
|
||||
let resolved;
|
||||
let resolved, definitionEntry;
|
||||
try {
|
||||
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
|
||||
// org-defscan (#1873): the entry the contract came from, taken before any await lets another check replace b.
|
||||
definitionEntry = String(resolved.definition).startsWith("org:") ? b.definitions?.[resolved.definition] : undefined;
|
||||
} catch (e) {
|
||||
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
|
||||
}
|
||||
@@ -412,7 +414,13 @@ export function createLauncher({
|
||||
};
|
||||
const primary = routeOf("primary");
|
||||
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
|
||||
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
|
||||
if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
|
||||
// org-defscan (#1873): an org: definition, as this check just read it, passes the bus's held-secret check before a contract exists.
|
||||
if (String(resolved.definition).startsWith("org:")) {
|
||||
const { held } = (await host.op({ op: "definitionScan", business: b.id, definition: resolved.definition, entry: definitionEntry })) ?? {};
|
||||
if (typeof held === "string") throw new Refusal("definition-holds-credential", `${instance}'s definition ${resolved.definition} holds a credential in ${held}`);
|
||||
if (held !== null) throw new Refusal("organization-refused", `the bus did not check ${instance}'s definition ${resolved.definition}`);
|
||||
}
|
||||
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
|
||||
try {
|
||||
readSkills(resolved.skills, skillsDir);
|
||||
@@ -723,7 +731,7 @@ export function createLauncher({
|
||||
models: b.launch.models,
|
||||
proxy: sessionProxy,
|
||||
resolver,
|
||||
contract: readFileSync(resolved.contract, "utf8"),
|
||||
contract: resolved.contract ? readFileSync(resolved.contract, "utf8") : resolved.instructions,
|
||||
arbiter: Object.values(b.arbiters).includes(instance),
|
||||
tracker,
|
||||
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
|
||||
|
||||
@@ -162,28 +162,28 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
} catch (e) {
|
||||
// A dangling link in place of the directory fails mkdir with ENOENT.
|
||||
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
|
||||
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
|
||||
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
|
||||
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code})`, 3);
|
||||
if (e.code !== "EEXIST") throw e;
|
||||
}
|
||||
const ds = lstatSync(dir);
|
||||
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
|
||||
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
|
||||
if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) {
|
||||
throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3);
|
||||
throw new CliError(`notify journal directory must be mode 0700 and owned by this user`, 3);
|
||||
}
|
||||
try {
|
||||
accessSync(dir, constants.W_OK | constants.X_OK);
|
||||
} catch (e) {
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code}): ${dir}`, 3);
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code})`, 3);
|
||||
throw e;
|
||||
}
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600);
|
||||
} catch (e) {
|
||||
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3);
|
||||
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
|
||||
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink`, 3);
|
||||
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code})`, 3);
|
||||
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
|
||||
throw e;
|
||||
}
|
||||
const sent = new Set();
|
||||
@@ -204,7 +204,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
try {
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
|
||||
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
|
||||
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
|
||||
}
|
||||
const bytes = readFileSync(fd);
|
||||
const end = bytes.lastIndexOf(0x0a) + 1;
|
||||
@@ -218,7 +218,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
|
||||
r = null;
|
||||
}
|
||||
const bad = r === null ? "json" : badField(r);
|
||||
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad}): ${file}`, 3);
|
||||
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad})`, 3);
|
||||
count(r);
|
||||
});
|
||||
if (end < bytes.length) {
|
||||
|
||||
@@ -6,27 +6,28 @@ import { CliError } from "./errors.mjs";
|
||||
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
|
||||
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
|
||||
// Missing refuses, so a host never starts without someone deciding whether
|
||||
// it DMs; null runs the host without a notifier.
|
||||
// it DMs; null runs the host without a notifier. Refusals name the business
|
||||
// and the path under the data root, never the host path (PR #1916).
|
||||
export function readNotifyConfig(dataRoot, business) {
|
||||
const file = join(dataRoot, "notify", business, "notify.json");
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(file);
|
||||
} catch {
|
||||
throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": "<discord binding>"} there, mode 0600, or "binding": null to run without DMs`, 3);
|
||||
throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3);
|
||||
}
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
|
||||
throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3);
|
||||
throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3);
|
||||
}
|
||||
let doc;
|
||||
try {
|
||||
doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
throw new CliError(`notifier config is not JSON: ${file}`, 3);
|
||||
throw new CliError(`notifier config for ${business} is not JSON`, 3);
|
||||
}
|
||||
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
|
||||
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
|
||||
throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": <binding name> | null}: ${file}`, 3);
|
||||
throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": <binding name> | null}`, 3);
|
||||
}
|
||||
return doc.binding;
|
||||
}
|
||||
|
||||
@@ -138,7 +138,9 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
if (!exited) { child.kill('SIGTERM'); await done; }
|
||||
throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3);
|
||||
// Name the log by its place in the organization's runtime directory, never by host path (PR #1916);
|
||||
// resolveOrg admitted `id` only after the registry's id check.
|
||||
throw new CliError(`bus failed to start; see bus-host/host.log in the runtime directory of organization ${id}`, 3);
|
||||
}
|
||||
export async function busRestart(ctx, id) {
|
||||
const org = await target(ctx, id);
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { join } from 'node:path';
|
||||
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';
|
||||
@@ -95,7 +95,8 @@ async function launch(ctx, args, lan, detach) {
|
||||
}
|
||||
await sleep(50);
|
||||
}
|
||||
fail(`console failed to start; see ${log}`);
|
||||
// The log is named under the data root, never by host path (PR #1916).
|
||||
fail(`console failed to start; see console/${basename(log)} under the data root`);
|
||||
} catch (e) {
|
||||
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
|
||||
throw e;
|
||||
@@ -193,11 +194,8 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
|
||||
const argv = cmdlineOf(s.pid);
|
||||
if (!argv || !argv.length) fail('console process identity is uncertain');
|
||||
const i = argv.indexOf(WEBUI);
|
||||
if (i < 0 || argv[i + 1] !== 'serve') {
|
||||
const serve = argv.indexOf('serve');
|
||||
const entry = serve > 0 ? ` ${argv[serve - 1]}` : '';
|
||||
fail(`console was started from another path${entry}; stop it there`);
|
||||
}
|
||||
// The other entry script is a host path, so the refusal never names it (PR #1916).
|
||||
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
|
||||
process.kill(s.pid, 'SIGTERM');
|
||||
const deadline = Date.now() + 20000;
|
||||
while (Date.now() < deadline) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// #1873 T1: `mosaic org definitions`, the human verbs on an organization's own role definitions and
|
||||
// the template hash and diff reads. Reads run here, from the file. A write runs only from a human
|
||||
// shell (refuseInsideAgent) and goes through the organization's bus as its human: the bus holds the
|
||||
// credential store, whose held-secret check every definition write needs (R1), and no other process
|
||||
// holds the tokens it looks for. Each write prints its receipt.
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
|
||||
import { checkDefinition } from '../../organization/src/definitions.mjs';
|
||||
import { rolesDir } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { busExit, refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const DEFINITIONS_USAGE = 'mosaic org definitions templates | definitions ID list | definitions ID show NAME | definitions ID diff NAME | definitions ID import TEMPLATE [--as NAME] | definitions ID invent NAME --from FILE | definitions ID edit NAME --from FILE --if-rev N | definitions ID remove NAME --if-rev N';
|
||||
|
||||
const WRITES = ['import', 'invent', 'edit', 'remove'];
|
||||
const VALUES = ['--as', '--from', '--if-rev'];
|
||||
|
||||
function parse(args) {
|
||||
const pos = [];
|
||||
const values = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (VALUES.includes(a)) {
|
||||
if (values[a] !== undefined || !args[i + 1] || args[i + 1].startsWith('--')) throw new CliError(`${a} needs one value`, 4);
|
||||
values[a] = args[++i];
|
||||
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
|
||||
else pos.push(a);
|
||||
}
|
||||
return { pos, values };
|
||||
}
|
||||
|
||||
async function definitionFile(file) {
|
||||
try {
|
||||
return JSON.parse(await readFile(file, 'utf8'));
|
||||
} catch (e) {
|
||||
// Never echo the file's text: it may hold what the composition check refuses.
|
||||
throw new CliError(`cannot read definition ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.code ?? 'unreadable'}`, 2);
|
||||
}
|
||||
}
|
||||
|
||||
// The bus answers a refusal with its code only. These say what the code means for this request;
|
||||
// none quotes the definition's text.
|
||||
function refusal(code, verb, name, rev, read) {
|
||||
const why = {
|
||||
'definition-in-use': () => `definition ${name} is named by ${read().usedBy.join(', ')}; a change to a hired agent's role is a change decision`,
|
||||
'definition-rev-conflict': () => `definition ${name} is not at rev ${rev}`,
|
||||
'definition-exists': () => `definitions already declares ${name}`,
|
||||
'definition-missing': () => `definitions declares no ${name}`,
|
||||
'revision-conflict': () => 'the organization file changed while this ran; run it again',
|
||||
'credential-leak': () => `the ${verb} request holds a credential the organization's bus holds; nothing was written`,
|
||||
'invalid-request': () => `the bus refused the ${verb}: the definition fails the composition check or holds a held credential`,
|
||||
}[code];
|
||||
let detail = null;
|
||||
try { detail = why?.() ?? null; } catch { detail = null; }
|
||||
const error = new CliError(detail ? `${code}: ${detail}` : code, busExit(code));
|
||||
error.code = code;
|
||||
return error;
|
||||
}
|
||||
|
||||
// deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs
|
||||
// passes its callFor). Without one, a write refuses: it never runs without the bus's secret check.
|
||||
export async function definitionsCommand(args, io, deps = {}) {
|
||||
const { pos, values } = parse(args);
|
||||
const allowed = (...keys) => { for (const k of Object.keys(values)) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
|
||||
const need = (key) => { if (!values[key]) throw new CliError(`${key} is required`, 4); return values[key]; };
|
||||
const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); };
|
||||
const [id, verb, name] = pos;
|
||||
if (WRITES.includes(verb)) refuseInsideAgent(io.env);
|
||||
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
|
||||
const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) });
|
||||
let result;
|
||||
if (id === 'templates' && pos.length === 1) { allowed(); result = verbs.templates(); }
|
||||
else if (verb === 'list' && pos.length === 2) { allowed(); result = verbs.list(id); }
|
||||
else if (['show', 'diff'].includes(verb) && pos.length === 3) { allowed(); result = verbs[verb](id, name); }
|
||||
else if (WRITES.includes(verb) && pos.length === 3) {
|
||||
let request, rev;
|
||||
if (verb === 'import') { allowed('--as'); request = { template: name, as: values['--as'] ?? name }; }
|
||||
else if (verb === 'invent') { allowed('--from'); request = { name, definition: await definitionFile(need('--from')) }; }
|
||||
else if (verb === 'edit') { allowed('--from', '--if-rev'); rev = ifRev(); request = { rev, name, definition: await definitionFile(need('--from')) }; }
|
||||
else { allowed('--if-rev'); rev = ifRev(); request = { rev, name }; }
|
||||
// The file's own checks first, so a bad key is named here; the bus repeats them with its secret check.
|
||||
if (request.definition) checkDefinition(name, { ...request.definition, rev: rev ?? 1 });
|
||||
if (typeof deps.callFor !== 'function') throw new CliError('a definition write goes through the organization bus; run it as `mosaic org definitions`', 3);
|
||||
const call = deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
|
||||
const { revision } = writer.read(id);
|
||||
try {
|
||||
result = await call(`definition.${verb}`, { revision, ...request });
|
||||
} catch (e) {
|
||||
if (typeof e?.code !== 'string' || e.code === '') throw e;
|
||||
throw refusal(e.code, verb, request.as ?? name, rev, () => verbs.show(id, request.as ?? name));
|
||||
}
|
||||
} else throw new CliError(DEFINITIONS_USAGE, 4);
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
}
|
||||
@@ -9,11 +9,16 @@ import { CliError } from './errors.mjs';
|
||||
import { busStopForDelete } from './org-bus.mjs';
|
||||
import { consoleBusRestart } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
|
||||
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
|
||||
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
|
||||
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
try {
|
||||
// #1873 T1: the definitions verbs take their own options.
|
||||
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
|
||||
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
||||
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
|
||||
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
|
||||
@@ -41,5 +46,11 @@ export async function orgCommand(args, io, deps = {}) {
|
||||
else throw new CliError(ORG_USAGE, 4);
|
||||
} else throw new CliError(ORG_USAGE, 4);
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); }
|
||||
} catch (e) {
|
||||
if (e instanceof CliError) throw e;
|
||||
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
|
||||
// and code, never by the host path in its message (PR #1916).
|
||||
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
|
||||
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ const inside = (root, path) => { const r = relative(root, path); return r !== ''
|
||||
export function threadRecords(file, thread) {
|
||||
const log = fileEvidence(file);
|
||||
if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 };
|
||||
if (!log.ok) throw new CliError(`run-evidence-refused: launch log ${file}: ${log.problem}`, 3);
|
||||
if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3);
|
||||
const launches = [], ends = []; let malformed = 0;
|
||||
log.bytes.toString('utf8').split('\n').forEach((line, i) => {
|
||||
if (!line) return;
|
||||
@@ -186,7 +186,7 @@ export async function runCommand(rest, io, deps = {}) {
|
||||
lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)');
|
||||
io.stdout.write(`${lines.join('\n')}\n`);
|
||||
}
|
||||
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in ${log.file}`, 2);
|
||||
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2);
|
||||
if (verify && !verified) throw new CliError('run evidence failed verification', 1);
|
||||
return report;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", a
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
|
||||
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`));
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
|
||||
assert.equal(s.receipts(), "");
|
||||
@@ -165,13 +165,14 @@ test("a failed rename leaves the old bytes, removes the temp file, and writes no
|
||||
assert.equal(s.receipts(), "");
|
||||
});
|
||||
|
||||
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
|
||||
test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => {
|
||||
const s = setup(t);
|
||||
chmodSync(s.file, 0o664);
|
||||
const before = readFileSync(s.file, "utf8");
|
||||
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
|
||||
assert.equal(err.exitCode, 3);
|
||||
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)");
|
||||
assert.ok(!err.message.includes(s.file), err.message);
|
||||
assert.equal(readFileSync(s.file, "utf8"), before);
|
||||
assert.equal(s.receipts(), "");
|
||||
assert.equal(existsSync(`${s.file}.lock`), false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
||||
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { accountsCommand } from '../src/accounts.mjs';
|
||||
@@ -101,3 +101,16 @@ test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any l
|
||||
chmodSync(parent, 0o1777);
|
||||
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
|
||||
});
|
||||
// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked
|
||||
// id, never by the organization root's host path.
|
||||
test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => {
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
|
||||
const root = join(s.configDir, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, mode: 0o700 });
|
||||
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{not json', { mode: 0o600 }); });
|
||||
const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; };
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/));
|
||||
rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json'));
|
||||
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/));
|
||||
assert.equal(s.read().users.length, 2, 'nothing was removed');
|
||||
});
|
||||
@@ -72,7 +72,9 @@ test("an unknown key and a JSON syntax error each leave the bus down with the re
|
||||
assert.equal(s.business, "acme");
|
||||
assert.equal(s.file, file);
|
||||
assert.match(s.reason, reason);
|
||||
assert.ok(s.reason.includes(file) || /surprise/.test(s.reason));
|
||||
// The reason names the file by label, never by host path (PR #1916); the path stays in `file`.
|
||||
assert.ok(!s.reason.includes(file), s.reason);
|
||||
assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason);
|
||||
// `bus status` shows it, in text and in JSON.
|
||||
const out = run(f, "bus", "status", "acme", "--json");
|
||||
assert.equal(out.status, 0, out.stderr);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// Invoked inside a private PID namespace. Check residual processes BEFORE its
|
||||
// init exits: namespace teardown would otherwise mask a missed descendant kill.
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { instrumentCleanupTimer } from './case-inner-timing-fixture.mjs';
|
||||
import { blockingQuerySource, killRecordedPids, recordedPids, recordedLivePids } from './case-sudo-fixture.mjs';
|
||||
|
||||
const [script,dir]=process.argv.slice(2),clock=join(dir,"clock '$;literal"),records=join(dir,'pids');
|
||||
try {
|
||||
writeFileSync(clock,'',{mode:0o600});
|
||||
const worker=join(dir,'blocked.mjs');
|
||||
writeFileSync(worker,blockingQuerySource(records,'sleep')+'\nloadSystem();');
|
||||
const source=instrumentCleanupTimer(readFileSync(script,'utf8'),clock);
|
||||
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
|
||||
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep "$@"','--',process.execPath,worker],{
|
||||
encoding:'utf8',timeout:12000,killSignal:'SIGKILL',
|
||||
});
|
||||
// These PIDs belong to this namespace; never use them to signal host PIDs.
|
||||
process.stdout.write(JSON.stringify({
|
||||
status:result.status,signal:result.signal,error:result.error?.message,
|
||||
stdout:result.stdout,stderr:result.stderr,pairs:recordedPids(records),
|
||||
live:recordedLivePids(records),clock:readFileSync(clock,'utf8'),
|
||||
}));
|
||||
} finally {
|
||||
killRecordedPids(records);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// A delay in finish(), after timing2's end stamp, must fail independently of
|
||||
// the inner timer. One fixed process tree runs at a time, without host setup,
|
||||
// fake sudo, or a parent-side elapsed-time assertion.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { instrumentCleanupTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
|
||||
|
||||
const scripts=['case1-accept.sh','case2-unit.sh'];
|
||||
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
|
||||
// The namespace fixes /proc inventory size; it does not replace clean_session.
|
||||
// See org-cleanup-time-build.md for the standalone and loaded calibration. The
|
||||
// bound must reject 1s/2s delays and leave most of the 2s production headroom.
|
||||
// Constant delays below 0.5s can pass; this is not a bound on host-wide scan
|
||||
// cost. In particular, per-entry work needs a shipped scan-cost follow-up.
|
||||
const CLEANUP_LIMIT_SECONDS=0.5;
|
||||
const probe=fileURLToPath(new URL('./case-cleanup-time-fixture.mjs',import.meta.url));
|
||||
|
||||
for(const name of scripts)test(`cleanup timing ${name}: observers preserve bytes and reject exit-anchor drift`,()=>{
|
||||
const source=read(name),clock="/unused/clock '$;literal";
|
||||
const copy=instrumentCleanupTimer(source,clock);
|
||||
assert.equal(removeInnerObservers(copy,clock),source);
|
||||
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
|
||||
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
|
||||
const exit=' exit "$rc"\n';
|
||||
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,''),clock),/anchor/);
|
||||
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,exit+exit),clock),/anchor/);
|
||||
});
|
||||
|
||||
// Both shipped copies are executed: Case1 shares this supervisor across legacy
|
||||
// calls, queries and P10; Case2 uses its copy for config queries.
|
||||
for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills descendants within the cleanup budget`,t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'cl-'));
|
||||
t.after(()=>rmSync(dir,{recursive:true,force:true}));
|
||||
// No skip/fallback: this Linux test requires unprivileged user/PID/mount
|
||||
// namespaces. --kill-child also removes our namespace if the probe times out.
|
||||
const outer=spawnSync('/usr/bin/unshare',[
|
||||
'--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',
|
||||
process.execPath,probe,fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,
|
||||
],{
|
||||
encoding:'utf8',timeout:15000,killSignal:'SIGKILL',
|
||||
});
|
||||
const prerequisite='requires /usr/bin/unshare and unprivileged user/PID/mount namespaces (check kernel/AppArmor/container seccomp)';
|
||||
assert.equal(outer.error,undefined,`${prerequisite}: ${outer.error?.message}`);
|
||||
assert.equal(outer.status,0,`${prerequisite}; probe output:\n${outer.stdout}${outer.stderr}`);
|
||||
assert.equal(outer.stderr,'');
|
||||
const result=JSON.parse(outer.stdout);
|
||||
assert.equal(result.error,undefined,result.error);
|
||||
assert.equal(result.signal,null,result.stderr);
|
||||
assert.equal(result.status,81,result.stderr);
|
||||
assert.equal(result.stdout,'');
|
||||
assert.equal(result.stderr,'');
|
||||
const pairs=result.pairs;
|
||||
assert.equal(pairs.length,2,'the command and its live descendant both started');
|
||||
assert.notEqual(pairs[0][0],pairs[0][1],'the trusted parent leads the command session');
|
||||
assert.notEqual(pairs[1][0],pairs[0][0]);
|
||||
assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session');
|
||||
assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown');
|
||||
// A positive centisecond pair relies on clean_session's current mandatory
|
||||
// 0.01s retry sleep. Revisit this measurement if shipped cleanup removes it;
|
||||
// keep the shared inner-timer parser strict in the meantime.
|
||||
const seconds=readProbeSeconds(result.clock);
|
||||
t.diagnostic(`${name}: cleanup ${seconds}s; limit ${CLEANUP_LIMIT_SECONDS}s`);
|
||||
assert.ok(seconds<CLEANUP_LIMIT_SECONDS,`${name}: cleanup ${seconds}s must be < ${CLEANUP_LIMIT_SECONDS}s`);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
// Test-copy observations only: bracket the supervisor's inner timer before
|
||||
// clean_session scans the host's /proc. Keep all shipped control flow intact.
|
||||
import { readProbeSeconds } from './case-p10-timing-fixture.mjs';
|
||||
export { readProbeSeconds };
|
||||
const BEGIN = '# row253-supervisor-begin\n';
|
||||
const END = '# row253-supervisor-end\n';
|
||||
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
|
||||
const FINISH = 'finish() {\n local rc="$?"\n';
|
||||
function unique(source, anchor) {
|
||||
const at=source.indexOf(anchor);
|
||||
if(at<0 || source.indexOf(anchor,at+anchor.length)>=0) throw new Error('inner observer anchor must occur exactly once');
|
||||
return at;
|
||||
}
|
||||
function innerObservers(clock) {
|
||||
// The literal path survives env -i in the fake sudo. It is a private scratch
|
||||
// file, never a product input. Quote even paths containing shell metacharacters.
|
||||
const file="'"+clock.replaceAll("'", "'\\''")+"'";
|
||||
return {
|
||||
begin:`# inner-observer-begin\n{ read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'begin %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n# inner-observer-begin-end\n`,
|
||||
end:` # inner-observer-end\n { read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'end %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n # inner-observer-end-end\n`,
|
||||
};
|
||||
}
|
||||
export function instrumentInnerTimer(source,clock) {
|
||||
const from=unique(source,BEGIN),to=unique(source,END);
|
||||
if(to<=from)throw new Error('inner observer scope is reversed');
|
||||
let supervisor=source.slice(from,to);
|
||||
unique(supervisor,TIMER);unique(supervisor,FINISH);
|
||||
const blocks=innerObservers(clock);
|
||||
supervisor=supervisor.replace(TIMER,blocks.begin+TIMER).replace(FINISH,FINISH+blocks.end);
|
||||
return source.slice(0,from)+supervisor+source.slice(to);
|
||||
}
|
||||
export function removeInnerObservers(source,clock) {
|
||||
for(const block of Object.values(innerObservers(clock))){unique(source,block);source=source.replace(block,'');}
|
||||
return source;
|
||||
}
|
||||
|
||||
// Reuse the timing2 finish-entry stamp, moving only the other observation.
|
||||
// The end is in the supervisor, after clean_session and both waits, immediately
|
||||
// before exit. Parent scheduling and process start-up never enter this pair.
|
||||
export function instrumentCleanupTimer(source,clock) {
|
||||
const blocks=innerObservers(clock);
|
||||
let observed=instrumentInnerTimer(source,clock);
|
||||
const from=unique(observed,BEGIN),to=unique(observed,END);
|
||||
let supervisor=observed.slice(from,to);
|
||||
const exit=' exit "$rc"\n';
|
||||
unique(supervisor,exit);
|
||||
supervisor=supervisor.replace(blocks.begin,'').replace(blocks.end,blocks.begin);
|
||||
supervisor=supervisor.replace(exit,blocks.end+exit);
|
||||
return observed.slice(0,from)+supervisor+observed.slice(to);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { tmp } from './helpers.mjs';
|
||||
import { instrumentInnerTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
|
||||
const scripts=['case1-accept.sh','case2-unit.sh'];
|
||||
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
|
||||
for(const name of scripts)test(`inner timing ${name}: private copy preserves shipped bytes and rejects anchor drift`,t=>{
|
||||
const clock=join(tmp(t,'timer-'),'clock');
|
||||
const source=read(name),copy=instrumentInnerTimer(source,clock);
|
||||
assert.equal(removeInnerObservers(copy,clock),read(name));
|
||||
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
|
||||
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
|
||||
for(const anchor of ['# row253-supervisor-begin\n','# row253-supervisor-end\n','"$sleep_bin" "$seconds" & timer=$!\n','finish() {\n local rc="$?"\n']){
|
||||
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,''),clock),/anchor/);
|
||||
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,anchor+anchor),clock),/anchor/);
|
||||
}
|
||||
});
|
||||
for(const name of scripts)test(`inner timing ${name}: real supervisor retains streams status and EXIT cleanup`,t=>{
|
||||
const clock=join(tmp(t,'timer-'),"clock '$;literal");writeFileSync(clock,'');
|
||||
const source=instrumentInnerTimer(read(name),clock);
|
||||
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
|
||||
const result=spawnSync('/bin/bash',['-c',body+'\n/bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep /bin/bash -c \'printf exact-out; printf exact-err >&2; /usr/bin/sleep 0.1; exit 7\''],{encoding:'utf8'});
|
||||
assert.equal(result.status,87,result.stderr);
|
||||
assert.equal(result.stdout,'exact-out');assert.equal(result.stderr,'exact-err');
|
||||
assert.match(readFileSync(clock,'utf8'),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
|
||||
// The same strict parser used by the timing bands rejects a guard's partial,
|
||||
// duplicate or reversed pair, rather than substituting wall time.
|
||||
for(const invalid of ['begin 10.00\n','end 11.00\n','begin 10.00\nend 11.00\nbegin 12.00\nend 13.00\n','begin 11.00\nend 10.00\n'])
|
||||
assert.throws(()=>readProbeSeconds(invalid));
|
||||
});
|
||||
@@ -5,6 +5,13 @@ const LEGACY = '# row276-legacy-begin';
|
||||
// Stable even when a mutant removes the outer timeout from this command.
|
||||
const LAUNCH = ' LC_ALL=C ';
|
||||
const WAIT = ' wait "$child" 2>/dev/null || rc=$?\n';
|
||||
// org-base-reds: the root pair above also spans supervisor start-up and clean_session, a scan of
|
||||
// every host /proc entry whose cost grows with the host's process count. The timer pair brackets
|
||||
// only the inner timer: from just before the supervisor starts its sleep to the start of finish().
|
||||
const SUPERVISOR = '# row253-supervisor-begin\n';
|
||||
const SUPERVISOR_END = '# row253-supervisor-end\n';
|
||||
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
|
||||
const FINISH = 'finish() {\n local rc="$?"\n';
|
||||
|
||||
export const P10_OBSERVERS = Object.freeze({
|
||||
begin: ` # row299-observer-begin-start
|
||||
@@ -20,6 +27,26 @@ export const P10_OBSERVERS = Object.freeze({
|
||||
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMING_FILE"; } 2>/dev/null || :
|
||||
fi
|
||||
# row299-observer-end-end
|
||||
`,
|
||||
// Supervisor-side pair. Only the P10 peer's arguments carry the peer-credential option name.
|
||||
// The pattern is split so this block never adds that literal to the supervisor text: the fake
|
||||
// sudo stalls any command whose arguments contain it, and the supervisor text is an argument of
|
||||
// every capture. finish() has no arguments of its own, so its stamp follows exactly one begin
|
||||
// and is written at most once.
|
||||
timerBegin: `# row299-timer-begin-start
|
||||
if [ -n "\${ROW299_TIMER_FILE-}" ] && [[ "$*" == *SO_PEER*CRED* ]]; then
|
||||
{ read -r row299_up row299_spare < /proc/uptime &&
|
||||
printf 'begin %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
|
||||
fi
|
||||
# row299-timer-begin-end
|
||||
`,
|
||||
timerEnd: ` # row299-timer-end-start
|
||||
if [ -n "\${ROW299_TIMER_FILE-}" ]; then
|
||||
{ read -r row299_up row299_spare < /proc/uptime &&
|
||||
[[ "$(< "$ROW299_TIMER_FILE")" == begin* && "$(< "$ROW299_TIMER_FILE")" != *end* ]] &&
|
||||
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
|
||||
fi
|
||||
# row299-timer-end-end
|
||||
`,
|
||||
});
|
||||
|
||||
@@ -40,7 +67,15 @@ export function instrumentP10Probe(source) {
|
||||
uniqueIndex(capture, WAIT);
|
||||
capture = capture.replace(LAUNCH, P10_OBSERVERS.begin + LAUNCH);
|
||||
capture = capture.replace(WAIT, WAIT + P10_OBSERVERS.end);
|
||||
return source.slice(0, start) + capture + source.slice(end);
|
||||
source = source.slice(0, start) + capture + source.slice(end);
|
||||
const from = uniqueIndex(source, SUPERVISOR), to = uniqueIndex(source, SUPERVISOR_END);
|
||||
if (to <= from) throw new Error('P10 observer supervisor scope is reversed');
|
||||
let supervisor = source.slice(from, to);
|
||||
uniqueIndex(supervisor, TIMER);
|
||||
uniqueIndex(supervisor, FINISH);
|
||||
supervisor = supervisor.replace(TIMER, P10_OBSERVERS.timerBegin + TIMER);
|
||||
supervisor = supervisor.replace(FINISH, FINISH + P10_OBSERVERS.timerEnd);
|
||||
return source.slice(0, from) + supervisor + source.slice(to);
|
||||
}
|
||||
|
||||
export function removeP10Observers(source) {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// No real process is signalled. These hostile kernel-boundary controls expose
|
||||
// a new session reusing the old SID after the original leader disappears.
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
for(const name of ['case1-accept.sh','case2-unit.sh'])for(const mode of ['already-dead','dies-before-signal'])test(`${name}: ${mode} original session never signals a recycled SID`,()=>{
|
||||
const source=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
|
||||
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
|
||||
assert.ok(program,'the shipped cleanup program exists');
|
||||
const anchor='\ntry:\n';assert.equal(program.split(anchor).length,2);
|
||||
const control=`
|
||||
import atexit, json
|
||||
from types import SimpleNamespace
|
||||
attempts = []
|
||||
checks = 0
|
||||
atexit.register(lambda: print(json.dumps(attempts)))
|
||||
def observed(pid):
|
||||
global checks
|
||||
if pid == "self/fd/42":
|
||||
checks += 1
|
||||
return (b"T", session) if ${mode==='already-dead'?'False':'checks < 3'} else None
|
||||
return (b"R", session)
|
||||
def send(fd, sig):
|
||||
attempts.append([fd, sig])
|
||||
raise PermissionError("never signal a real task")
|
||||
fields = observed
|
||||
os = SimpleNamespace(listdir=lambda path: ["999"], pidfd_open=lambda pid: 17 if pid == session else 99, close=lambda fd: None)
|
||||
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
|
||||
`;
|
||||
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,'\n'+control+anchor),'424242','42'],{encoding:'utf8'});
|
||||
assert.equal(r.status,1,r.stderr);assert.equal(r.stderr,'');
|
||||
assert.deepEqual(JSON.parse(r.stdout),[],'a stale SID must never authorize a signal');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Regression controls run the real supervisor. Only the kernel observation is
|
||||
// fault-injected: a transient empty stat and a delayed first observation of an
|
||||
// already completed fast command. Neither may authorize an unverified session.
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const names=['case1-accept.sh','case2-unit.sh'];
|
||||
function body(name){const s=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');return s.slice(s.indexOf('# row253-supervisor-begin'),s.indexOf('# row253-supervisor-end'));}
|
||||
function replaceOnce(source,anchor,replacement){assert.equal(source.split(anchor).length,2,'fault anchor must be unique');return source.replace(anchor,replacement);}
|
||||
function run(source,setsid='/usr/bin/setsid',command=['/bin/true']){
|
||||
return spawnSync('/bin/bash',['-c',source+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 "$@"','--',setsid,'/usr/bin/sleep',...command],{encoding:'utf8',timeout:8000});
|
||||
}
|
||||
test('shipped supervisors remain byte-identical',()=>assert.equal(body(names[0]),body(names[1])));
|
||||
for(const name of names){
|
||||
test(`${name}: a transient empty stat read retries within the inner budget`,()=>{
|
||||
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${empty_once:-0}" = 0 ]; then empty_once=1; return 1; fi');
|
||||
const r=run(source);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'');
|
||||
});
|
||||
test(`${name}: fast success cannot disappear before session verification`,()=>{
|
||||
// Delay only the parent's first observation. BASE lets /bin/true exit and
|
||||
// loses its proc entry; a stopped trusted launcher remains verifiable.
|
||||
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${delayed_once:-0}" = 0 ]; then delayed_once=1; "$sleep_bin" 0.08; fi');
|
||||
const r=run(source);assert.equal(r.status,0,r.stderr);
|
||||
});
|
||||
test(`${name}: a launcher that never creates a session is refused`,()=>{
|
||||
const r=run(body(name),'/bin/false');assert.equal(r.status,85,r.stderr);
|
||||
});
|
||||
test(`${name}: a stopped launcher without a new session is never accepted`,()=>{
|
||||
const r=run(body(name),'/usr/bin/env');assert.equal(r.status,81,r.stderr);
|
||||
});
|
||||
test(`${name}: persistently unreadable initial stat retains session refusal 85`,()=>{
|
||||
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n return 1');
|
||||
const r=run(source);assert.equal(r.status,85,r.stderr);
|
||||
});
|
||||
test(`${name}: command status and streams survive the launcher`,()=>{
|
||||
const r=run(body(name),'/usr/bin/setsid',['/bin/bash','-c','printf out; printf err >&2; exit 7']);
|
||||
assert.equal(r.status,87,r.stderr);assert.equal(r.stdout,'out');assert.equal(r.stderr,'err');
|
||||
});
|
||||
}
|
||||
|
||||
// A snapshot of the parent tree would miss a child reparented to namespace init.
|
||||
// A new session is a separately documented escape, not a session member.
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable','anchor-death'])test(`${name}: namespace cleanup ${mode}`,t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
|
||||
const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,'');
|
||||
const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null);
|
||||
assert.equal(result.status,['unreadable','unkillable','anchor-death'].includes(mode)?86:81,JSON.stringify(result));
|
||||
assert.equal(result.stdout,'');
|
||||
if(mode==='anchor-death')assert.match(result.stderr,/Killed/);else assert.equal(result.stderr,'');
|
||||
if(!['unreadable','unkillable','anchor-death'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive');
|
||||
if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;}
|
||||
assert.equal(result.pairs.length,2);
|
||||
const [leader,descendant]=result.pairs;
|
||||
assert.notEqual(leader[0],leader[1]);
|
||||
assert.equal(result.anchor,leader[1],'the trusted command parent reserves the SID');
|
||||
if(mode==='escape'){
|
||||
assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]);
|
||||
assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it');
|
||||
}else{
|
||||
assert.equal(descendant[1],leader[1]);
|
||||
if(mode==='anchor-death'){assert.ok(result.live.includes(leader[0]),'lost anchor refuses before signalling a bare SID');}
|
||||
else if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');}
|
||||
else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup');
|
||||
}
|
||||
});
|
||||
|
||||
// Unlike the private-inventory timing test, this sees the real host /proc.
|
||||
// Restoring the old per-entry bash read/parse scan exceeds this bound on a
|
||||
// representative host. It uses cleanup-time's stamps, excluding the inner wait.
|
||||
import {writeFileSync} from 'node:fs';
|
||||
import {instrumentCleanupTimer,readProbeSeconds} from './case-inner-timing-fixture.mjs';
|
||||
for(const name of names)test(`${name}: host inventory cleanup stays within 0.5 seconds`,t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'hc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
|
||||
const clock=join(dir,'clock');writeFileSync(clock,'');
|
||||
const r=run(instrumentCleanupTimer(body(name)+'# row253-supervisor-end\n',clock),'/usr/bin/setsid',['/bin/bash','-c','/usr/bin/sleep 30 & wait']);
|
||||
assert.equal(r.status,81,r.stderr);assert.equal(r.stderr,'');assert.equal(r.stdout,'');
|
||||
const seconds=readProbeSeconds(readFileSync(clock,'utf8'));t.diagnostic(`${name}: host cleanup ${seconds}s`);
|
||||
assert.ok(seconds<0.5,`host cleanup ${seconds}s must be < 0.5s`);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Namespace-local evidence and cleanup. The JSON observation precedes teardown.
|
||||
import {readFileSync,writeFileSync,readdirSync,openSync,closeSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs';
|
||||
const source=readFileSync(script,'utf8');
|
||||
let body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
|
||||
// Adversarial kernel boundary controls keep the real cleanup loop and budget.
|
||||
if(mode==='unreadable')body=body.replace('def fields(pid):','def fields(pid):\n raise PermissionError("injected unreadable stat")');
|
||||
if(mode==='unkillable')body=body.replace('signal.pidfd_send_signal(fd, signal.SIGKILL)','None # injected successful kill with a surviving member');
|
||||
if(mode==='fork')writeFileSync(worker,`
|
||||
import {spawn} from 'node:child_process';
|
||||
import {appendFileSync,readFileSync} from 'node:fs';
|
||||
const record=${JSON.stringify(records)};
|
||||
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
|
||||
appendFileSync(record,JSON.stringify([process.pid,sid(process.pid)])+'\\n');
|
||||
setInterval(()=>{
|
||||
const child=spawn('/usr/bin/sleep',['30'],{stdio:'ignore'});
|
||||
appendFileSync(record,JSON.stringify([child.pid,sid(child.pid)])+'\\n');
|
||||
},4);
|
||||
`);
|
||||
else writeFileSync(worker,`
|
||||
import {spawn} from 'node:child_process';
|
||||
import {readFileSync,writeFileSync} from 'node:fs';
|
||||
const record=${JSON.stringify(records)};
|
||||
const child=spawn('/bin/bash',['-c',${JSON.stringify(mode==='escape'?'exec /usr/bin/setsid /usr/bin/sleep 30':'/usr/bin/sleep 30 & echo $! > "$1"')},'--',record+'.child'],{stdio:'ignore'});
|
||||
setTimeout(()=>{
|
||||
const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"};
|
||||
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
|
||||
writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]]));
|
||||
writeFileSync(record+'.anchor',String(process.ppid));
|
||||
writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]);
|
||||
if(${JSON.stringify(mode)} === "anchor-death")process.kill(process.ppid,"SIGKILL");
|
||||
},100);
|
||||
setInterval(()=>{},1000);
|
||||
`);
|
||||
const out=openSync(dir+'/out','w'),err=openSync(dir+'/err','w');
|
||||
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',stdio:['ignore',out,err],timeout:8000,killSignal:'SIGKILL'});
|
||||
closeSync(out);closeSync(err);
|
||||
result.stdout=readFileSync(dir+'/out','utf8');result.stderr=readFileSync(dir+'/err','utf8');
|
||||
let pairs=[];
|
||||
try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{}
|
||||
const live=[];
|
||||
for(const [pid] of pairs){try{const state=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[0];if(!['Z','X'].includes(state))live.push(pid);}catch{}}
|
||||
// Inventory the entire private namespace too: a fork between spawn and its
|
||||
// record write must not escape the assertion just because it was unrecorded.
|
||||
const sessionLive=[];
|
||||
for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){
|
||||
try{
|
||||
const words=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ');
|
||||
if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid));
|
||||
}catch{}
|
||||
}
|
||||
let parent,anchor;
|
||||
try{anchor=Number(readFileSync(records+".anchor","utf8"));}catch{}
|
||||
try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{}
|
||||
process.stdout.write(JSON.stringify({sessionLive,parent,anchor,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live}));
|
||||
// These PIDs exist only in the private namespace. Never signal them on the host.
|
||||
for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{}
|
||||
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
import { instrumentP10Probe, removeP10Observers, readProbeSeconds, P10_OBSERVERS } from "./case-p10-timing-fixture.mjs";
|
||||
import { instrumentInnerTimer } from "./case-inner-timing-fixture.mjs";
|
||||
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource, LEGACY_CASES } from "./case-sudo-fixture.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
@@ -378,6 +379,9 @@ test('row299 P10 timing copy preserves shipped bytes and isolates observer varia
|
||||
assert.deepEqual([...block.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)=/g)],[],'no other assignments in observer blocks');
|
||||
assert.doesNotMatch(block,/\$ROOT|\$\{ROOT/,'fixture root cannot supply the clock');
|
||||
}
|
||||
// The fake sudo keys its faults on these argument markers; observers must not add or remove any.
|
||||
for(const marker of['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
|
||||
assert.equal(copy.split(marker).length,source.split(marker).length,`observers keep the ${marker} count`);
|
||||
const changedTimer=source.replace('tester_capture 2 4 128','tester_capture 1 4 128');
|
||||
assert.equal(removeP10Observers(instrumentP10Probe(changedTimer)),changedTimer,'copy keeps current timer mutants');
|
||||
});
|
||||
@@ -402,6 +406,34 @@ capture`;
|
||||
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
|
||||
});
|
||||
|
||||
test('row299 P10 timer observers keep supervisor status and record one P10 pair',t=>{
|
||||
const dir=tmp(t,'p10-timer-'),clock=join(dir,'clock'),trapFile=join(dir,'trap');
|
||||
put(clock,'',0o600);
|
||||
// Same shape as TESTER_SUPERVISOR: positional arguments are the setsid command, finish() has none.
|
||||
const program=`set +m
|
||||
finish() {
|
||||
local rc="$?"
|
||||
${P10_OBSERVERS.timerEnd} printf trap > "$ROW299_TRAP_FILE"
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
${P10_OBSERVERS.timerBegin}printf stdout-without-newline
|
||||
printf stderr-without-newline >&2
|
||||
exit 81`;
|
||||
const supervise=(...args)=>spawnSync('/bin/bash',['-c',program,'--',...args],{encoding:'utf8',env:{...process.env,ROW299_TIMER_FILE:clock,ROW299_TRAP_FILE:trapFile}});
|
||||
let r=supervise('node','-e','query');
|
||||
assert.equal(r.status,81);assert.equal(read(clock),'','a supervisor without the P10 peer records nothing');
|
||||
r=supervise('python','-c','getsockopt(SO_PEERCRED)');
|
||||
assert.equal(r.status,81);
|
||||
assert.equal(r.stdout,'stdout-without-newline');
|
||||
assert.equal(r.stderr,'stderr-without-newline');
|
||||
assert.equal(read(trapFile),'trap');
|
||||
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
|
||||
const pair=read(clock);
|
||||
r=supervise('node','-e','later');
|
||||
assert.equal(r.status,81);assert.equal(read(clock),pair,'a later supervisor cannot add a second end');
|
||||
});
|
||||
|
||||
test('row299 P10 observer records are complete and ordered',()=>{
|
||||
assert.equal(readProbeSeconds('begin 100.01\nend 102.03\n'),2.02);
|
||||
const invalid={
|
||||
@@ -433,23 +465,33 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
|
||||
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
|
||||
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
|
||||
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
|
||||
let observedScript=SCRIPT,timingFile;
|
||||
let observedScript=SCRIPT,timingFile,timerFile;
|
||||
if(fault==='inner'||fault==='outer'){
|
||||
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
|
||||
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
|
||||
// The outer fault stalls before the supervisor starts, so only the inner fault has a timer pair.
|
||||
if(fault==='inner'){timerFile=join(h.hc,'timer-uptime');put(timerFile,'',0o600);}
|
||||
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
|
||||
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
|
||||
}
|
||||
const stop=fixtureGuard(t,files,7000,timingFile);
|
||||
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
const observe={...(timingFile?{ROW299_TIMING_FILE:timingFile}:{}),...(timerFile?{ROW299_TIMER_FILE:timerFile}:{})};
|
||||
const start=performance.now(),r=run(h,observe,undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
|
||||
assert.equal(r.status,0,r.out+r.err);
|
||||
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
|
||||
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
|
||||
assert.doesNotMatch(r.out,/boundary=observed/);
|
||||
let probeSeconds=seconds;
|
||||
let probeSeconds=seconds,timerSeconds;
|
||||
if(timingFile)assert.doesNotThrow(()=>{probeSeconds=readProbeSeconds(read(timingFile));},'P10 observer pair is required');
|
||||
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds}));
|
||||
if(fault==='inner')assert.ok(probeSeconds>=1.25&&probeSeconds<2.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
|
||||
if(timerFile)assert.doesNotThrow(()=>{timerSeconds=readProbeSeconds(read(timerFile));},'P10 timer observer pair is required');
|
||||
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds,timerSeconds}));
|
||||
// org-base-reds: the band applies to the inner timer itself. The root pair adds supervisor start-up and
|
||||
// clean_session's whole-host /proc scan, which grows with the host's process count, so it must contain
|
||||
// the timer pair but has no band of its own; probe-inner-timeout above already places it before the outer bound.
|
||||
if(fault==='inner'){
|
||||
assert.ok(timerSeconds>=1.25&&timerSeconds<2.75,`${mode}: inner timer ${timerSeconds}s (root probe ${probeSeconds}s); baseline ${baselineSeconds}s is diagnostic only`);
|
||||
assert.ok(probeSeconds>=timerSeconds,`${mode}: root probe ${probeSeconds}s must contain the inner timer ${timerSeconds}s`);
|
||||
}
|
||||
if(fault==='outer'){
|
||||
assert.ok(probeSeconds>=3.25&&probeSeconds<4.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
|
||||
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
|
||||
@@ -896,7 +938,7 @@ test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env
|
||||
// mos-1326: assert every argv element, not flattened/substr command detection.
|
||||
const source=read(SCRIPT);
|
||||
const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n';
|
||||
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'34063098d7edb9067c11456165dc7535bc0e779d053c43599884d6b614528639');
|
||||
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'52509773fa2bb0701402b8d2454816153e18323cfaa75cb72418b46c4dbb2d34');
|
||||
const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1];
|
||||
const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1];
|
||||
const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line));
|
||||
@@ -927,8 +969,8 @@ test("row 195 accept: the host check runs with --check and never --enforce", (t)
|
||||
|
||||
|
||||
// Row276 exercises the real legacy launcher with fixture-only commands, never sudo.
|
||||
function legacyBody() {
|
||||
const source=read(SCRIPT),start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
|
||||
function legacyBody(source=read(SCRIPT)) {
|
||||
const start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
|
||||
assert.ok(start>=0&&end>start,'legacy supervisor block exists');
|
||||
return source.slice(start,end);
|
||||
}
|
||||
@@ -950,11 +992,18 @@ for(const {tag} of LEGACY_CASES)test(`row276 ${tag} legacy inner timeout cleans
|
||||
const h=host(t),records=join(h.hc,'legacy-pids'),files=[join(h.hc,'session-pids'),records];
|
||||
put(join(h.hc,'session-mode'),mode);
|
||||
const file=join(h.dir,'blocked.mjs');put(file,blockingQuerySource(records,'signals')+'\nloadSystem();');
|
||||
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500);
|
||||
const start=performance.now(),r=legacyCall(h,tag,[NODE,file]);capture();
|
||||
const clock=join(h.dir,'inner-clock');put(clock,'',0o600);
|
||||
const body=legacyBody(instrumentInnerTimer(read(SCRIPT),clock));
|
||||
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500,clock);
|
||||
// The outer watchdog includes startup and cleanup; only the supervisor pair
|
||||
// measures the inner budget. Seven seconds also lets the 4.5s guard expose a missing timer.
|
||||
const start=performance.now(),r=legacyCall(h,tag,[NODE,file],{outer:7,body});capture();
|
||||
assert.equal(r.status,81,r.stdout+r.stderr);
|
||||
assert.equal(r.stderr,`case1-accept: ${tag} inner-timeout\n`);
|
||||
assert.ok((performance.now()-start)/1000<2,mode);
|
||||
const seconds=readProbeSeconds(read(clock)),wall=(performance.now()-start)/1000;
|
||||
assert.ok(seconds>=0.25&&seconds<1.25,`${tag}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
|
||||
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
|
||||
t.diagnostic(`${tag}/${mode}: inner ${seconds}s; wall ${wall}s`);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],mode);await stop();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
|
||||
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
|
||||
import { tmp } from "./helpers.mjs";
|
||||
import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs";
|
||||
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
|
||||
|
||||
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
@@ -107,13 +108,13 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
|
||||
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
|
||||
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
|
||||
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
|
||||
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) {
|
||||
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
|
||||
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
|
||||
const r = spawnSync(UNIT, args, {
|
||||
const r = spawnSync(unitScript, args, {
|
||||
encoding: "utf8",
|
||||
timeout: 30000,
|
||||
env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
|
||||
env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
|
||||
});
|
||||
return { code: r.status, out: r.stdout, err: r.stderr };
|
||||
}
|
||||
@@ -197,12 +198,27 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
|
||||
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
|
||||
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
|
||||
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
|
||||
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
|
||||
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
|
||||
const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh');
|
||||
mkdirSync(dirname(observed),{recursive:true});
|
||||
symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh'));
|
||||
writeFileSync(clock,'',{mode:0o600});
|
||||
// The 7s outer watchdog spans startup and cleanup too. Give the private copy
|
||||
// cleanup room while retaining a separate band on the unchanged 5s inner timer.
|
||||
const source=read(UNIT),call='query="$(tester_capture 5 7 4096';
|
||||
assert.equal(source.split(call).length,2,'tester query budget anchor is unique');
|
||||
script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock));
|
||||
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock);
|
||||
const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{
|
||||
CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE,
|
||||
CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'),
|
||||
}}),wall=(performance.now()-start)/1000;capture();
|
||||
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
|
||||
const seconds=readProbeSeconds(read(clock));
|
||||
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
|
||||
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
|
||||
assert.ok(seconds<7,`${kind}/${mode}: ${seconds}s exceeds outer bound`);
|
||||
assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
|
||||
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
|
||||
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
|
||||
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
|
||||
assert.deepEqual(await pollFixtureGone(files),[],mode);
|
||||
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
|
||||
assert.equal(existsSync(h.log),false,mode);await stopGuard();
|
||||
@@ -240,24 +256,39 @@ test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
|
||||
});
|
||||
|
||||
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
|
||||
// A deterministic clock models a slow inventory without host-load timing.
|
||||
// All process reads and signals are stand-ins; no real PID is signalled.
|
||||
// Run the shipped cleanup with a deterministic slow inventory/clock. Every
|
||||
// kernel operation is replaced before execution; no real PID is signalled.
|
||||
const source=read(UNIT);
|
||||
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
|
||||
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
|
||||
assert.ok(program,'fixed cleanup program exists');
|
||||
const anchor='\ntry:\n';
|
||||
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
|
||||
const standins=`
|
||||
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
|
||||
stat_fields() {
|
||||
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
|
||||
[ "$seen" != 1 ] || ST_SESSION="$child"
|
||||
return 0
|
||||
}
|
||||
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
|
||||
kill() { kills=$((kills+1)); return 0; }
|
||||
clean_session; rc=$?
|
||||
printf '%s' "$kills"
|
||||
exit "$rc"
|
||||
from types import SimpleNamespace
|
||||
alive = True
|
||||
kills = 0
|
||||
clock = time.monotonic()
|
||||
|
||||
def inventory(path):
|
||||
global clock
|
||||
clock += 1000
|
||||
return ["999"]
|
||||
|
||||
def fake_fields(pid):
|
||||
return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
|
||||
|
||||
def send(fd, sig):
|
||||
global alive, kills
|
||||
if sig == 9:
|
||||
alive = False
|
||||
kills += 1
|
||||
|
||||
fields = fake_fields
|
||||
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
|
||||
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
|
||||
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
|
||||
`;
|
||||
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
|
||||
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// #1873 T1 follow-up (org-defscan): a definition that holds a credential the bus holds refuses at
|
||||
// launch (the launch check asks the bus before a contract exists) and at `mosaic bus start`, and the
|
||||
// value reaches no refusal, record or log.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createLauncher } from '../src/launcher.mjs';
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
import { readBootStatus, bootEventsFile } from '../src/boot-status.mjs';
|
||||
import { hostFile } from '../src/host.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
import { launchLogFile } from '../../seat/src/session.mjs';
|
||||
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture, notifyConfig, tmp, withoutHostCredentials } from './helpers.mjs';
|
||||
|
||||
// The fixture bus holds SENTINEL as coder's gitea token.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
|
||||
function files(dir) {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
const withBuilder = (builder) => (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } });
|
||||
|
||||
test('org-defscan: a file edited after start to hold a held credential refuses the next launch of an instance naming it; a template launch still works', async (t) => {
|
||||
const root = tmp(t);
|
||||
const f = fixture(root, 'acme', withBuilder(entryFromTemplate(readTemplate(REPO_ROLES, 'coder'))));
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
initializeControl(f.dataRoot);
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
||||
const bus = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
||||
t.after(() => bus.close());
|
||||
// The real host's op path for the scan, over a structured clone as IPC carries it; the fixture
|
||||
// stops at authorization, so "reached authorizeLaunch" is "would have launched".
|
||||
const ops = [];
|
||||
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
|
||||
ops.push(x.op);
|
||||
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
||||
if (x.op === 'mayLaunch') return { allow: true };
|
||||
if (x.op === 'authorizeLaunch') throw Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
||||
if (x.op === 'definitionScan') return structuredClone(bus.definitionScan(structuredClone({ business: x.business, definition: x.definition, entry: x.entry })));
|
||||
return {};
|
||||
} };
|
||||
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
|
||||
t.after(() => launcher.close?.());
|
||||
|
||||
// Clean: the scan passes and the launch reaches authorization.
|
||||
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
|
||||
assert.ok(ops.includes('definitionScan'));
|
||||
|
||||
// Edited by hand while the bus runs.
|
||||
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
||||
doc.definitions.builder.instructions += `\nThe forge token is ${SENTINEL}.\n`;
|
||||
writeJson(file, doc);
|
||||
ops.length = 0;
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' });
|
||||
assert.ok(!ops.includes('authorizeLaunch'), 'nothing launches');
|
||||
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(event.code, 'definition-holds-credential');
|
||||
assert.equal(event.instance, 'coder');
|
||||
assert.match(event.why, /coder's definition org:builder holds a credential in instructions/);
|
||||
|
||||
// A personality field is checked the same way.
|
||||
doc.definitions.builder.instructions = doc.definitions.builder.instructions.replace(SENTINEL, 'REDACTED');
|
||||
doc.definitions.builder.personality = { summary: 'Plain.', voice: `Signs off with ${SENTINEL}` };
|
||||
writeJson(file, doc);
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' });
|
||||
assert.match(JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1)).why, /in personality\.voice$/);
|
||||
|
||||
// An instance on a shipped template launches as before, and asks no scan.
|
||||
ops.length = 0;
|
||||
assert.equal((await launcher.fromSession('fixture-cap', 'reviewer')).error, 'fixture-policy');
|
||||
assert.ok(!ops.includes('definitionScan'));
|
||||
|
||||
// The value reached no record the bus or the launcher wrote.
|
||||
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
});
|
||||
|
||||
// A bootstrapped instance with an active organization whose file names org:builder.
|
||||
async function orgFixture(t, builder) {
|
||||
const root = tmp(t, 'o-');
|
||||
const f = fixture(root, 'acme', withBuilder(builder));
|
||||
const configDir = join(root, '.config', 'mosaic-boot');
|
||||
const env = { ...f.env, HOME: root, MOSAIC_CONFIG: join(configDir, 'config.json') };
|
||||
const bootstrap = spawnSync(join(process.cwd(), 'scripts/bootstrap.sh'), ['--instance', 'boot'], { env, encoding: 'utf8' });
|
||||
assert.equal(bootstrap.status, 0, bootstrap.stderr);
|
||||
mkdirSync(join(configDir, 'businesses'), { recursive: true, mode: 0o700 });
|
||||
const file = join(configDir, 'businesses', 'acme.json');
|
||||
writeFileSync(file, readFileSync(join(root, 'config/businesses/acme.json')), { mode: 0o600 });
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
|
||||
const dataRoot = join(root, '.mosaic-boot');
|
||||
const ctx = { configDir, dataRoot, instance: 'boot' };
|
||||
const org = await reserveOrg(ctx, 'acme');
|
||||
await activateOrg(ctx, 'acme');
|
||||
// A notifier config, so a start that passes the load goes on to start the broker.
|
||||
notifyConfig(org.root, 'acme', null);
|
||||
return { env, file, dataRoot, orgRoot: org.root };
|
||||
}
|
||||
|
||||
test('org-defscan: mosaic bus start refuses a held credential in a definition, records why, and the value is nowhere', async (t) => {
|
||||
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
||||
const o = await orgFixture(t, { ...builder, instructions: `${builder.instructions}\nThe forge token is ${SENTINEL}.\n` });
|
||||
const run = (...args) => spawnSync(process.execPath, [CLI, ...args], { env: o.env, encoding: 'utf8', timeout: 60000 });
|
||||
const r = run('bus', 'start', 'acme', '--detach', '--no-launch');
|
||||
// Should a start get through (it must not), stop it before the fixture's directory goes.
|
||||
if (r.status === 0 || existsSync(hostFile(o.orgRoot))) run('bus', 'stop', 'acme');
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.ok(!existsSync(join(o.orgRoot, 'bus', 'broker.sock')), 'no socket');
|
||||
const s = readBootStatus(o.orgRoot);
|
||||
assert.equal(s.ok, false);
|
||||
assert.equal(s.business, 'acme');
|
||||
assert.match(s.reason, /definition-holds-credential: definitions\.builder instructions holds a credential this bus holds/);
|
||||
assert.equal(s.lastGood, null, 'the load that the broker then refused is not a good load');
|
||||
const events = readFileSync(bootEventsFile(o.orgRoot), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(events.at(-1).event, 'bus.boot.refused');
|
||||
const status = run('bus', 'status', 'acme', '--json');
|
||||
assert.equal(JSON.parse(status.stdout).boot.ok, false);
|
||||
for (const text of [r.stdout, r.stderr, status.stdout, status.stderr]) assert.ok(!text.includes(SENTINEL));
|
||||
for (const path of files(o.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
});
|
||||
@@ -804,13 +804,27 @@ test("land: a failed gate is refused gate-failed with its step, told to the role
|
||||
assert.equal(ticket, TICKET);
|
||||
assert.equal(a.code, "gate-failed");
|
||||
assert.equal(a.notify, true);
|
||||
assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/);
|
||||
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12}\/pkg-bus\.log in the bus host directory$/);
|
||||
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
|
||||
assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move");
|
||||
assert.deepEqual(w.of("recordLand"), []);
|
||||
assert.equal(w.trees(), 0);
|
||||
});
|
||||
|
||||
// PR #1916 (org-redact5): the detail reaches the role, so it never carries the log's host
|
||||
// path; a log file name outside the id pattern is dropped too.
|
||||
test("land: a failed gate's detail names the land directory, never the log's host path", async (t) => {
|
||||
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
|
||||
let log = null;
|
||||
const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: (log = join(logs, SENTINEL, `${SENTINEL}.log`)) }) } });
|
||||
await w.land();
|
||||
await w.ended();
|
||||
const [[, a]] = w.of("landRefused");
|
||||
assert.equal(a.code, "gate-failed");
|
||||
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12} in the bus host directory$/);
|
||||
assert.ok(!a.detail.includes(SENTINEL) && !a.detail.includes(log) && !a.detail.includes(w.f.dataRoot), a.detail);
|
||||
});
|
||||
|
||||
test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => {
|
||||
const w = await landSetup(t);
|
||||
w.state.recordFails = "decision-consumed";
|
||||
|
||||
@@ -315,7 +315,7 @@ test("a host.lock that goes between the check and the read is tried again; one t
|
||||
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
|
||||
|
||||
writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 });
|
||||
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`);
|
||||
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === "bus host state is unreadable");
|
||||
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json");
|
||||
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
|
||||
});
|
||||
@@ -749,8 +749,14 @@ test("a stale organization writer lock refuses start and status without removing
|
||||
});
|
||||
|
||||
test("organization bus start refuses without a notifier config", async t => {
|
||||
// PR #1916 (org-redact5): the refusal names the log inside the organization's
|
||||
// runtime directory, never by host path. (A sentinel directory in the root would
|
||||
// push the bus socket path past the socket length limit, so the check is on the
|
||||
// fixture's own roots.)
|
||||
const f = await orgFixture(t, { notify: false });
|
||||
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i);
|
||||
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), (e) => e.exitCode === 3
|
||||
&& e.message === "bus failed to start; see bus-host/host.log in the runtime directory of organization acme"
|
||||
&& !e.message.includes(f.dataRoot) && !e.message.includes(f.instanceRoot));
|
||||
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
|
||||
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
|
||||
});
|
||||
|
||||
@@ -21,10 +21,15 @@ function fixture(t) {
|
||||
};
|
||||
return { root, file, env, run, boot };
|
||||
}
|
||||
function redacted(result, file, status) {
|
||||
// label: the CLI refusal names the file by this label and never by host path (PR #1916).
|
||||
// Without it, the refusal comes from scripts/mosaic-config.mjs, which still names the file.
|
||||
function redacted(result, file, status, label) {
|
||||
assert.equal(result.status, status, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
assert.ok(result.stderr.includes(file), result.stderr);
|
||||
if (label) {
|
||||
assert.ok(!result.stderr.includes(file), result.stderr);
|
||||
assert.ok(result.stderr.includes(label), result.stderr);
|
||||
} else assert.ok(result.stderr.includes(file), result.stderr);
|
||||
assert.match(result.stderr, /not valid JSON/);
|
||||
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
|
||||
}
|
||||
@@ -40,7 +45,7 @@ test('system config parse refusal and CLI relay omit parser bytes', t => {
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
return true;
|
||||
});
|
||||
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
|
||||
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3, 'configuration is not valid JSON; expected readable JSON: the system config MOSAIC_CONFIG names');
|
||||
});
|
||||
|
||||
test('named instance marker parse refusal omits parser bytes', t => {
|
||||
@@ -55,5 +60,5 @@ for (const operation of ['validate', 'create', 'update']) test(`org ${operation}
|
||||
const file = join(f.root, 'alpha.json'); writeFileSync(file, secret);
|
||||
const args = operation === 'validate' ? ['validate', file]
|
||||
: [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])];
|
||||
redacted(f.run(cli, 'org', ...args), file, 2);
|
||||
redacted(f.run(cli, 'org', ...args), file, 2, 'mosaic: cannot read organization document: not valid JSON\n');
|
||||
});
|
||||
@@ -46,14 +46,14 @@ for (const [field, edit] of configCases) test(`config ${field} omits the configu
|
||||
test('config-command refuses system config without relaying stderr file values', t => {
|
||||
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
|
||||
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(!err.includes(f.config), err); assert.match(err, /the system config MOSAIC_CONFIG names/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
|
||||
});
|
||||
test('project id mismatch omits the file value in config-command', t => {
|
||||
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
|
||||
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
|
||||
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
|
||||
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
|
||||
assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
|
||||
assert.equal(status, 2); assert.ok(!err.includes(file), err); assert.match(err, /project file of project stack has id that does not match the declared project key/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
for (const [field, edit] of [
|
||||
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
|
||||
@@ -80,7 +80,7 @@ cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdou
|
||||
const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)});
|
||||
process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`;
|
||||
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
|
||||
assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
assert.equal(r.status, 3, r.stderr); assert.ok(!r.stderr.includes(file), r.stderr); assert.match(r.stderr, /: the system config MOSAIC_CONFIG names\n$/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import {ownerConsole} from '../../bus/tests/hire-fixtures.mjs';
|
||||
import {readTemplate} from '../../organization/src/definitions.mjs';
|
||||
import { requestHostOps } from './request-host-ops.mjs';
|
||||
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
|
||||
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
|
||||
@@ -89,6 +91,8 @@ async function start(t, { f, adapter }, options = {}) {
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
// org-defscan (#1873): an org: definition's launch asks the bus's held-secret check, as process.mjs answers it.
|
||||
definitionScan: (m) => runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry }),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
@@ -238,7 +242,7 @@ function bind(s, role, run) {
|
||||
s.runtime.broker.request(cap, { verb: "role.claim" });
|
||||
return cap;
|
||||
}
|
||||
const fire = (s, chief, name) => s.runtime.broker.request(chief, { verb: "instance.fire", args: { name, reason: "the review is done" } });
|
||||
const fire = (s, chief, name) => s.runtime.broker.request(chief, {verb:"hire.raise",args:{op:"fire",level:"org",name,reason:"the review is done"}}).then(d => s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:d.id,choice:"yes",digest:d.hire.digest}}));
|
||||
|
||||
const allowed = (p, action) => {
|
||||
const db = new DatabaseSync(join(p.f.dataRoot, "bus", "bus.sqlite"), { readOnly: true });
|
||||
@@ -601,7 +605,7 @@ test("row 194 T9a: fire signals its bound run and a spawned but unbound registry
|
||||
const spare = await standIn(t, p, s, { instance: "coder", run: "coder-spare" });
|
||||
const stuck = await standIn(t, p, s, { instance: "coder", run: "coder-stuck", ignoreTerm: true });
|
||||
bind(s, "coder", "coder-run");
|
||||
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
// The bus knows only the bound run. The pass takes the spawned ones from the registry.
|
||||
assert.deepEqual(fired.runs, ["coder-run"]);
|
||||
await s.launcher.tick();
|
||||
@@ -629,7 +633,7 @@ test("row 194 T9a: fire of a registry run with no runtime record reads lifecycle
|
||||
const bound = await standIn(t, p, s, { instance: "coder", run: "coder-run" });
|
||||
const ghost = await standIn(t, p, s, { instance: "coder", run: "coder-ghost", managed: false });
|
||||
bind(s, "coder", "coder-run");
|
||||
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
assert.deepEqual(fired.runs, ["coder-run"]);
|
||||
await s.launcher.tick();
|
||||
assert.equal(await bound.exited, 143);
|
||||
@@ -685,7 +689,7 @@ test("row 194 T9a: a runner whose identity changed before the SIGTERM gets no si
|
||||
const between = await standIn(t, p, s, { instance: "coder", run: "coder-between", ignoreTerm: true });
|
||||
Object.assign(pids, { before: before.pid, between: between.pid });
|
||||
bind(s, "coder", "coder-between");
|
||||
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
|
||||
await s.launcher.tick();
|
||||
assert.deepEqual(sent, [["coder-between", "SIGTERM"]]);
|
||||
assert.deepEqual([before.gone(), between.gone(), before.terms(), between.terms()], [false, false, 0, 1]);
|
||||
@@ -719,7 +723,7 @@ test("row 194 T9b: a halt during a fire's stop sends no second SIGTERM, keeps th
|
||||
const x = await standIn(t, p, s, { instance: "coder", run: "coder-run", ignoreTerm: true });
|
||||
const chief = bind(s, "chief", "chief-run");
|
||||
bind(s, "coder", "coder-run");
|
||||
const fired = fire(s, chief, "coder");
|
||||
const fired = await fire(s, chief, "coder");
|
||||
const began = Date.now();
|
||||
const pass = s.launcher.tick();
|
||||
await until(() => x.terms() === 1);
|
||||
@@ -848,12 +852,6 @@ test("row 194 T11: two stack roots with the same business id; a launch, a termin
|
||||
// listing gets through to the host, which checks the file.
|
||||
test("row 194 D3 item 5: the chief's session hires with its tool and launches its hire in the same session, then fires it", { skip, timeout: 90000 }, async (t) => {
|
||||
const p = prepare(t);
|
||||
// The shipped role file lists nothing hireable. The test's copy lists the CISO, which holds
|
||||
// only message.send and record.write and no credential.
|
||||
const roleFile = join(p.f.env.MOSAIC_ROLES_DIR, "chief-of-staff.json");
|
||||
const role = JSON.parse(readFileSync(roleFile, "utf8"));
|
||||
role.hireable = ["chief-information-security-officer"];
|
||||
writeFileSync(roleFile, JSON.stringify(role, null, 2));
|
||||
const s = await start(t, p);
|
||||
const chief = (await s.carry(s.call("instance.launch", { instance: "chief" }).request)).done.run;
|
||||
await s.claimed(chief);
|
||||
@@ -861,15 +859,12 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
const sent = s.call("message.send", { to: "chief", class: "REQUEST", body: `TOOL ${name} ${JSON.stringify(args)}` }).id;
|
||||
return (await until(() => s.call("replies", { id: sent })[0], 30000)).body;
|
||||
};
|
||||
const hired = JSON.parse(await ask("hire", { name: "sec-2", definition: "chief-information-security-officer", vars: { model: "claude-sonnet-5-5" }, reason: "a security lead for the review queue" }));
|
||||
assert.ok(hired.transaction, JSON.stringify(hired));
|
||||
const listed = s.call("instances").instances["sec-2"];
|
||||
assert.equal(listed.launched, true);
|
||||
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
|
||||
// human sets, so until then the launch refuses no-route.
|
||||
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
|
||||
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
|
||||
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
|
||||
const role = readTemplate(p.f.env.MOSAIC_ROLES_DIR,"chief-information-security-officer");
|
||||
const hired = JSON.parse(await ask("hire", {op:"hire",level:"org",name:"sec-2",role:{template:role.name,sha256:role.sha256},route:{harness:"pi",model:"claude-sonnet-5-5",thinking:"high"},reason:"a security lead for the review queue"}));
|
||||
assert.ok(hired.id);
|
||||
assert.equal(s.call("instances").instances["sec-2"],undefined,"raise writes nothing");
|
||||
await s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:hired.id,choice:"yes",digest:hired.hire.digest}});
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched,true);
|
||||
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
|
||||
assert.match(launched.run, /^r[0-9a-f]{12}$/);
|
||||
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
|
||||
@@ -878,8 +873,9 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
|
||||
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
|
||||
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
|
||||
await s.claimed(launched.run);
|
||||
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
|
||||
assert.ok(fired.transaction, JSON.stringify(fired));
|
||||
const fired = JSON.parse(await ask("fire", {op:"fire",level:"org",name:"sec-2",reason:"the review is done"}));
|
||||
assert.ok(fired.id);
|
||||
await s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:fired.id,choice:"yes",digest:fired.hire.digest}});
|
||||
await until(() => s.running("sec-2").length === 0, 30000);
|
||||
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
|
||||
assert.equal(s.call("instances").instances["sec-2"].launched, false);
|
||||
|
||||
@@ -358,7 +358,7 @@ test("the journal: a line with a wrong type refuses with exit 3 and names the fi
|
||||
for (const [field, r] of bad) {
|
||||
const text = `${JSON.stringify(dm)}\n${JSON.stringify(r)}\n`;
|
||||
writeFileSync(file, text);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field}): ${file}`, `${field}: ${JSON.stringify(r)}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field})` && !e.message.includes(file), `${field}: ${JSON.stringify(r)}`);
|
||||
assert.equal(readFileSync(file, "utf8"), text, "a refusal changes nothing");
|
||||
}
|
||||
const good = [dm, digest, { ...dm, outcome: "refused", messageId: null, status: 403 }, { ...dm, outcome: "gave-up", messageId: null }, { ...digest, outcome: "unknown", messageId: null }];
|
||||
@@ -377,7 +377,7 @@ test("the journal: a symlinked directory refuses and says it is a link", (t) =>
|
||||
const file = journalPath(root, "demo");
|
||||
mkdirSync(dirname(dirname(file)), { mode: 0o700 });
|
||||
symlinkSync(real, dirname(file));
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(file)}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(file)));
|
||||
assert.deepEqual(readdirSync(real), [], "nothing was created through the link");
|
||||
});
|
||||
|
||||
@@ -386,13 +386,13 @@ test("the journal: a dangling directory link, a parent that is a file and a jour
|
||||
const dangling = journalPath(root, "demo");
|
||||
mkdirSync(dirname(dirname(dangling)), { mode: 0o700 });
|
||||
symlinkSync(join(root, "missing"), dirname(dangling));
|
||||
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(dangling)}`);
|
||||
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(dangling)));
|
||||
const under = join(root, "plain", "notify", "demo", "sent.jsonl");
|
||||
writeFileSync(join(root, "plain"), "");
|
||||
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR): ${dirname(under)}`);
|
||||
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR)` && !e.message.includes(dirname(under)));
|
||||
const asDir = journalPath(root, "acme");
|
||||
mkdirSync(asDir, { recursive: true, mode: 0o700 });
|
||||
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user: ${asDir}`);
|
||||
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user` && !e.message.includes(asDir));
|
||||
});
|
||||
|
||||
test("the journal: an append after the file was swapped for a symlink refuses and writes nothing through it", (t) => {
|
||||
@@ -414,18 +414,18 @@ test("the journal: a directory it cannot write or create refuses with exit 3 and
|
||||
// Modes are restored in finally: tmp()'s cleanup runs first among the after hooks.
|
||||
chmodSync(dirname(file), 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES): ${dirname(file)}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES)` && !e.message.includes(dirname(file)));
|
||||
} finally {
|
||||
chmodSync(dirname(file), 0o700);
|
||||
}
|
||||
openJournal(file);
|
||||
chmodSync(file, 0o400);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES): ${file}`);
|
||||
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES)` && !e.message.includes(file));
|
||||
const parent = join(root, "notify");
|
||||
const other = journalPath(root, "acme");
|
||||
chmodSync(parent, 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES): ${dirname(other)}`);
|
||||
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES)` && !e.message.includes(dirname(other)));
|
||||
} finally {
|
||||
chmodSync(parent, 0o700);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
// #1873 T1: org:<name> definitions at launch, and `mosaic org definitions`.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createLauncher } from '../src/launcher.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { orgCommand } from '../src/org.mjs';
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
import { BusError } from '../../bus/src/broker.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
import { launchLogFile } from '../../seat/src/session.mjs';
|
||||
import { entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
|
||||
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
|
||||
|
||||
const SCOUT = { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
|
||||
const stop = () => Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
||||
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
|
||||
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
||||
if (x.op === 'mayLaunch') return { allow: true };
|
||||
if (x.op === 'authorizeLaunch') throw stop();
|
||||
if (x.op === 'definitionScan') return { held: null };
|
||||
return {};
|
||||
} };
|
||||
|
||||
test('T1 an org: instance passes every launch check, and a missing definition refuses naming instance and definition', async (t) => {
|
||||
const root = tmp(t);
|
||||
// The org's own copy of the coder template, so the instance's credentials and tracker still match.
|
||||
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
||||
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
initializeControl(f.dataRoot);
|
||||
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
|
||||
// Resolve, route, contract (the definition's instructions) and skills all pass; the fixture stops at authorization.
|
||||
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
|
||||
|
||||
// The chief names a definition the file doesn't declare: the whole file refuses, so a session's
|
||||
// launch and the chief's launch both refuse.
|
||||
writeJson(file, { ...f.doc, roles: { ...f.doc.roles, chief: { ...f.doc.roles.chief, definition: 'org:ghost' } } });
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'organization-refused' });
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.code === 'organization-refused' && /roles\.chief\.definition names org:ghost/.test(e.message));
|
||||
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(event.code, 'organization-refused');
|
||||
assert.equal(event.instance, 'chief');
|
||||
assert.match(event.why, /definition-missing: .*roles\.chief\.definition names org:ghost/);
|
||||
await launcher.close?.();
|
||||
|
||||
// A host does not start on a file that names a missing definition.
|
||||
assert.throws(() => createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }), /org:ghost/);
|
||||
});
|
||||
|
||||
// The fixture bus holds SENTINEL as coder's gitea token, so the bus's secret check refuses it.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
function files(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
|
||||
test('T1 mosaic org definitions: writes go through the bus and its secret check, each prints its receipt, and an agent shell refuses', async (t) => {
|
||||
const root = tmp(t);
|
||||
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
||||
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
const changes = join(root, 'config/businesses/.history/acme/changes.jsonl');
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
||||
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
||||
t.after(() => b.close());
|
||||
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
|
||||
const calls = [];
|
||||
// The human transport's shape: a bus refusal arrives as its code.
|
||||
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
|
||||
calls.push(verb);
|
||||
try { return structuredClone(b.broker.request(human, { verb, args })); }
|
||||
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
|
||||
} };
|
||||
const run = async (args, env = f.env) => { const x = io(env); await main(['org', 'definitions', ...args], x, deps); return JSON.parse(x.out.text); };
|
||||
const hash = templateHash(f.roles, 'coder');
|
||||
assert.ok((await run(['templates'])).some((h) => h.template === 'coder' && h.sha256 === hash));
|
||||
|
||||
const imported = await run(['acme', 'import', 'coder', '--as', 'copy']);
|
||||
assert.deepEqual({ ...imported, revision: 0, transaction: 0 }, { verb: 'import', organization: 'acme', definition: 'copy', rev: 1, revision: 0, transaction: 0, from: { template: 'coder', sha256: hash } });
|
||||
const spec = join(root, 'scout.json');
|
||||
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: SCOUT.instructions }));
|
||||
assert.equal((await run(['acme', 'invent', 'scout', '--from', spec])).rev, 1);
|
||||
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: 'Read, then report.\n' }));
|
||||
const edited = await run(['acme', 'edit', 'scout', '--from', spec, '--if-rev', '1']);
|
||||
assert.equal(edited.verb, 'edit');
|
||||
assert.equal(edited.rev, 2);
|
||||
assert.deepEqual((await run(['acme', 'list'])).definitions.map((d) => [d.name, d.rev]), [['builder', 1], ['copy', 1], ['scout', 2]]);
|
||||
assert.equal((await run(['acme', 'show', 'scout'])).instructions, 'Read, then report.\n');
|
||||
assert.equal((await run(['acme', 'diff', 'copy'])).changed, false);
|
||||
assert.equal((await run(['acme', 'remove', 'scout', '--if-rev', '2'])).removedRev, 2);
|
||||
assert.deepEqual(calls, ['definition.import', 'definition.invent', 'definition.edit', 'definition.remove']);
|
||||
const committed = readFileSync(changes, 'utf8').trim().split('\n').map((l) => JSON.parse(l)).filter((e) => e.state === 'committed').slice(-4);
|
||||
assert.deepEqual(committed.map((e) => e.actor), Array(4).fill(f.doc.human));
|
||||
|
||||
// A definition holding a token the bus holds refuses on invent and on edit, in valid JSON, and
|
||||
// the token reaches no message, no organization file and nothing the bus wrote.
|
||||
const before = readFileSync(file);
|
||||
const beforeChanges = readFileSync(changes);
|
||||
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: `Use ${SENTINEL} to report.\n` }));
|
||||
for (const args of [['acme', 'invent', 'leak', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1']]) {
|
||||
await assert.rejects(run(args), (e) => e.code === 'credential-leak' && e.exitCode === 2 && !e.message.includes(SENTINEL) && !String(e.stack).includes(SENTINEL));
|
||||
}
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
assert.deepEqual(readFileSync(changes), beforeChanges);
|
||||
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
|
||||
// An unparsable file never echoes; a key the file's checks refuse is named before the bus is
|
||||
// called; a stale --if-rev and an in-use definition refuse with what they mean.
|
||||
writeFileSync(spec, `{"role": ${SENTINEL}`);
|
||||
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), (e) => e.exitCode === 2 && !e.message.includes('sk-test'));
|
||||
writeFileSync(spec, JSON.stringify({ role: { ...SCOUT.role, network: 'everywhere' }, instructions: 'x\n' }));
|
||||
const asked = calls.length;
|
||||
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), /definitions\.other/);
|
||||
assert.equal(calls.length, asked);
|
||||
await assert.rejects(run(['acme', 'remove', 'copy', '--if-rev', '7']), (e) => /^definition-rev-conflict: definition copy is not at rev 7/.test(e.message));
|
||||
await assert.rejects(run(['acme', 'remove', 'builder', '--if-rev', '1']), (e) => /^definition-in-use: definition builder is named by coder/.test(e.message));
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
|
||||
// Inside an agent run every write refuses before it reads anything or calls the bus; reads still work.
|
||||
const agent = { ...f.env, MOSAIC_RUN_ID: 'run-1' };
|
||||
const sent = calls.length;
|
||||
for (const args of [['acme', 'import', 'reviewer'], ['acme', 'invent', 'x', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1'], ['acme', 'remove', 'copy', '--if-rev', '1']]) {
|
||||
await assert.rejects(run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message));
|
||||
}
|
||||
assert.equal(calls.length, sent);
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
assert.equal((await run(['acme', 'list'], agent)).definitions.length, 2);
|
||||
|
||||
// With no bus call to make, a write refuses rather than writing without the secret check.
|
||||
const x = io(f.env);
|
||||
await assert.rejects(orgCommand(['definitions', 'acme', 'remove', 'copy', '--if-rev', '1'], x), (e) => e.exitCode === 3 && /organization bus/.test(e.message));
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
});
|
||||
@@ -40,8 +40,10 @@ test('console stop quarantines dead and wrong-process states without signaling',
|
||||
});
|
||||
|
||||
test('live console from another entry path remains recorded and is not signalled', { timeout: 30000 }, async t => {
|
||||
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, 'console.mjs');
|
||||
mkdirSync(dir, { mode: 0o700 });
|
||||
// The other entry sits under the sentinel directory: the refusal must name neither (PR #1916).
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, SENTINEL, 'console.mjs');
|
||||
mkdirSync(dir, { mode: 0o700 }); mkdirSync(join(f.root, SENTINEL), { mode: 0o700 });
|
||||
symlinkSync(new URL('../../webui/src/cli.mjs', import.meta.url).pathname, alias);
|
||||
const child = spawn(process.execPath, [alias, 'serve', '--port', '0'], { env: humanEnv({ HOME: f.root, MOSAIC_CONFIG: join(f.ctx.configDir, 'config.json') }), stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const ended = once(child, 'exit'); child.kill('SIGTERM'); await ended; } });
|
||||
@@ -53,7 +55,7 @@ test('live console from another entry path remains recorded and is not signalled
|
||||
const record = { pid: child.pid, startTime: startTimeOf(child.pid), url };
|
||||
json(join(dir, 'console.json'), record);
|
||||
await assert.rejects(consoleStart(f.ctx), /console state exists/);
|
||||
await assert.rejects(consoleStop(f.ctx), e => /another path/.test(e.message) && e.message.includes(alias));
|
||||
await assert.rejects(consoleStop(f.ctx), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'console was started from another checkout; stop it there'); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(alias), e.message); return true; });
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(dir, 'console.json'), 'utf8')), record);
|
||||
assert.equal(startTimeOf(child.pid), record.startTime);
|
||||
assert.equal(readdirSync(dir).filter(x => x.startsWith('console.json.stale-')).length, 0);
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
// PR #1916 redaction (org-redact5): a CLI refusal names the file by its label,
|
||||
// never by host path. Every fixture here sits under a directory named for the
|
||||
// sentinel, so a refusal that quoted the path would carry it.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createServer } from "node:net";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { gitSocketPath } from "../../seat/src/session.mjs";
|
||||
import { withLock } from "../src/access.mjs";
|
||||
import { fixtureFile, readFixture } from "../src/dev-fixture.mjs";
|
||||
import { createGitSocket } from "../src/git-socket.mjs";
|
||||
import { consoleStart, consoleStop } from "../src/org-console.mjs";
|
||||
import { claimHostLock, hostLock } from "../src/host.mjs";
|
||||
import { journalPath, openJournal } from "../src/notifier.mjs";
|
||||
import { readNotifyConfig } from "../src/notify-config.mjs";
|
||||
import { orgCommand } from "../src/org.mjs";
|
||||
import { instancesCommand } from "../src/instances.mjs";
|
||||
import { FIXTURE_ENTRY } from "../../plugins/model-provider/fixture/index.mjs";
|
||||
|
||||
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
|
||||
const ROOT_SKIP = process.getuid() === 0 && "root ignores the modes";
|
||||
|
||||
function under(t) {
|
||||
const top = mkdtempSync(join(tmpdir(), "redact-"));
|
||||
t.after(() => rmSync(top, { recursive: true, force: true }));
|
||||
const root = join(top, SENTINEL);
|
||||
mkdirSync(root, { mode: 0o700 });
|
||||
return root;
|
||||
}
|
||||
|
||||
// The refusal is exactly `message` (a string) or matches it (a RegExp), with exit
|
||||
// code `exitCode`, and carries neither the sentinel nor any of `paths`.
|
||||
function refusal(message, { exitCode = 3, paths = [], code } = {}) {
|
||||
return (error) => {
|
||||
assert.equal(error.name, "CliError");
|
||||
assert.equal(error.exitCode, exitCode);
|
||||
if (code !== undefined) assert.equal(error.code, code);
|
||||
if (message instanceof RegExp) assert.match(error.message, message);
|
||||
else assert.equal(error.message, message);
|
||||
assert.ok(!error.message.includes(SENTINEL), error.message);
|
||||
for (const path of paths) assert.ok(!error.message.includes(path), error.message);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
// --- access.json lock ---
|
||||
|
||||
test("access lock: an unreadable lock is named by label, never by host path", (t) => {
|
||||
const file = join(under(t), "access.json");
|
||||
writeFileSync(`${file}.lock`, "not-a-pid\n", { mode: 0o600 });
|
||||
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
|
||||
refusal("refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again", { paths: [file] }));
|
||||
});
|
||||
|
||||
test("access lock: a lock left by a dead pid is named by label, never by host path", (t) => {
|
||||
const file = join(under(t), "access.json");
|
||||
const dead = spawnSync(process.execPath, ["-e", ""]);
|
||||
writeFileSync(`${file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
|
||||
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
|
||||
refusal(`refused: access file lock is left by pid ${dead.pid}, which is not running; if no write is running, remove it by hand and try again`, { paths: [file] }));
|
||||
});
|
||||
|
||||
// --- bus host lock ---
|
||||
|
||||
const MINE = { pid: process.pid, startTime: "1", business: "acme", startedAt: "2026-11-10T00:00:00.000Z" };
|
||||
const STALE = { pid: 999999999, startTime: "1", business: "acme", startedAt: "2026-11-09T00:00:00.000Z" };
|
||||
|
||||
function hostRoot(t, lock) {
|
||||
const dataRoot = join(under(t), "data");
|
||||
mkdirSync(join(dirname(hostLock(dataRoot))), { recursive: true, mode: 0o700 });
|
||||
if (lock !== undefined) writeFileSync(hostLock(dataRoot), lock, { mode: 0o600 });
|
||||
return dataRoot;
|
||||
}
|
||||
|
||||
test("host lock: an unreadable host.lock refuses by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, "{not json");
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is unreadable", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("host lock: a malformed host.lock refuses by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, JSON.stringify({ pid: "x" }));
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is malformed", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("host lock: a stale host.lock refused on request is named by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, JSON.stringify(STALE));
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE, { refuseStale: true }), refusal("stale bus host lock", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("host lock: a lock that keeps changing is named by label, never by host path", (t) => {
|
||||
const dataRoot = hostRoot(t, JSON.stringify(STALE));
|
||||
let n = 0;
|
||||
const race = () => writeFileSync(hostLock(dataRoot), JSON.stringify({ ...STALE, startedAt: `2026-11-09T00:00:${String(++n % 60).padStart(2, "0")}.000Z` }), { mode: 0o600 });
|
||||
assert.throws(() => claimHostLock(dataRoot, MINE, { between: race }), refusal("bus host lock kept changing; no bus host started", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
// --- notify journal ---
|
||||
|
||||
test("notify journal: a directory link, dangling or not, is refused by label", (t) => {
|
||||
const root = under(t);
|
||||
const dangling = journalPath(root, "demo");
|
||||
mkdirSync(dirname(dirname(dangling)), { recursive: true, mode: 0o700 });
|
||||
symlinkSync(join(root, "missing"), dirname(dangling));
|
||||
assert.throws(() => openJournal(dangling), refusal("notify journal directory must not be a symlink", { paths: [dangling] }));
|
||||
const real = join(root, "real");
|
||||
mkdirSync(real, { mode: 0o700 });
|
||||
const linked = journalPath(root, "acme");
|
||||
symlinkSync(real, dirname(linked));
|
||||
assert.throws(() => openJournal(linked), refusal("notify journal directory must not be a symlink", { paths: [linked] }));
|
||||
});
|
||||
|
||||
test("notify journal: a parent that is a file is refused by its error code", (t) => {
|
||||
const root = under(t);
|
||||
writeFileSync(join(root, "plain"), "");
|
||||
const file = join(root, "plain", "notify", "demo", "sent.jsonl");
|
||||
assert.throws(() => openJournal(file), refusal("notify journal directory cannot be created (ENOTDIR)", { paths: [file] }));
|
||||
});
|
||||
|
||||
test("notify journal: a directory of the wrong mode is refused by label", (t) => {
|
||||
const file = journalPath(under(t), "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
|
||||
chmodSync(dirname(file), 0o755);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal directory must be mode 0700 and owned by this user", { paths: [file] }));
|
||||
});
|
||||
|
||||
test("notify journal: a directory it cannot write or create is refused by its error code", { skip: ROOT_SKIP }, (t) => {
|
||||
const root = under(t);
|
||||
const file = journalPath(root, "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(file), 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(file), refusal("notify journal directory is not writable (EACCES)", { paths: [file] }));
|
||||
} finally {
|
||||
chmodSync(dirname(file), 0o700);
|
||||
}
|
||||
const parent = dirname(dirname(file));
|
||||
const other = journalPath(root, "acme");
|
||||
chmodSync(parent, 0o500);
|
||||
try {
|
||||
assert.throws(() => openJournal(other), refusal("notify journal directory cannot be created (EACCES)", { paths: [other] }));
|
||||
} finally {
|
||||
chmodSync(parent, 0o700);
|
||||
}
|
||||
});
|
||||
|
||||
test("notify journal: a journal link, an unwritable journal and a directory journal are refused by label", { skip: ROOT_SKIP }, (t) => {
|
||||
const root = under(t);
|
||||
const file = journalPath(root, "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(root, "elsewhere.jsonl"), "", { mode: 0o600 });
|
||||
symlinkSync(join(root, "elsewhere.jsonl"), file);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal must not be a symlink", { paths: [file] }));
|
||||
rmSync(file);
|
||||
writeFileSync(file, "", { mode: 0o400 });
|
||||
chmodSync(file, 0o400);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal is not writable (EACCES)", { paths: [file] }));
|
||||
const asDir = journalPath(root, "acme");
|
||||
mkdirSync(asDir, { recursive: true, mode: 0o700 });
|
||||
assert.throws(() => openJournal(asDir), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [asDir] }));
|
||||
});
|
||||
|
||||
test("notify journal: a journal of the wrong mode and a malformed line are refused by label", (t) => {
|
||||
const file = journalPath(under(t), "demo");
|
||||
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(file, "", { mode: 0o644 });
|
||||
chmodSync(file, 0o644);
|
||||
assert.throws(() => openJournal(file), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [file] }));
|
||||
chmodSync(file, 0o600);
|
||||
writeFileSync(file, "not json\n");
|
||||
assert.throws(() => openJournal(file), refusal("notify journal line 1 is malformed (json)", { paths: [file] }));
|
||||
});
|
||||
|
||||
// --- notifier config ---
|
||||
|
||||
function notifyRoot(t, text, mode = 0o600) {
|
||||
const dataRoot = join(under(t), "data");
|
||||
const dir = join(dataRoot, "notify", "acme");
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
if (text !== undefined) {
|
||||
writeFileSync(join(dir, "notify.json"), text, { mode });
|
||||
chmodSync(join(dir, "notify.json"), mode);
|
||||
}
|
||||
return dataRoot;
|
||||
}
|
||||
|
||||
test("notifier config: a missing file names the business and its place under the data root", (t) => {
|
||||
const dataRoot = notifyRoot(t);
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"),
|
||||
refusal('no notifier config for acme: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/acme/notify.json under the data root, mode 0600, or "binding": null to run without DMs', { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("notifier config: a file of the wrong mode names the business only", (t) => {
|
||||
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null }), 0o644);
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme must be a regular file, mode 0600, owned by this user", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("notifier config: a file that is not JSON names the business only", (t) => {
|
||||
const dataRoot = notifyRoot(t, "{not json");
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme is not JSON", { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("notifier config: a file of the wrong shape names the business only", (t) => {
|
||||
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null, extra: 1 }));
|
||||
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal('notifier config for acme must be exactly {"notifyVersion": 1, "binding": <binding name> | null}', { paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
// --- dev fixture ---
|
||||
|
||||
const RECORD = { fixtureVersion: 1, model: "deterministic", endpoint: "http://127.0.0.1:45678", port: 45678, pid: 999999, instance: "fxtest", entry: FIXTURE_ENTRY };
|
||||
|
||||
function fixtureRoot(t, { record = RECORD, text = JSON.stringify(record), dirMode = 0o700, fileMode = 0o600 } = {}) {
|
||||
const dataRoot = join(under(t), "data");
|
||||
const file = fixtureFile(dataRoot);
|
||||
mkdirSync(dirname(file), { recursive: true, mode: dirMode });
|
||||
chmodSync(dirname(file), dirMode);
|
||||
if (text !== null) {
|
||||
writeFileSync(file, text, { mode: fileMode });
|
||||
chmodSync(file, fileMode);
|
||||
}
|
||||
return dataRoot;
|
||||
}
|
||||
|
||||
test("dev fixture: a shared fixture directory is refused by label", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { dirMode: 0o755 });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: dev-fixture directory is not a private directory of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a linked fixture file is refused by label and error code", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { text: null });
|
||||
writeFileSync(join(dataRoot, "elsewhere.json"), JSON.stringify(RECORD), { mode: 0o600 });
|
||||
symlinkSync(join(dataRoot, "elsewhere.json"), fixtureFile(dataRoot));
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json can't be opened (ELOOP)", { code: "fixture-state-unsafe", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a shared fixture file is refused by label", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { fileMode: 0o644 });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json is not a private file of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a fixture file that is not JSON is refused by label", (t) => {
|
||||
const dataRoot = fixtureRoot(t, { text: "not json" });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not JSON", { code: "fixture-state-invalid", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
test("dev fixture: a non-loopback endpoint and a record of the wrong shape are refused by label", (t) => {
|
||||
let dataRoot = fixtureRoot(t, { record: { ...RECORD, endpoint: "http://0.0.0.0:45678" } });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-endpoint-not-loopback: fixture.json names an endpoint that is not http://127.0.0.1:<port>", { code: "fixture-endpoint-not-loopback", paths: [dataRoot] }));
|
||||
dataRoot = fixtureRoot(t, { record: { ...RECORD, port: 1 } });
|
||||
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not a fixture record", { code: "fixture-state-invalid", paths: [dataRoot] }));
|
||||
});
|
||||
|
||||
// --- git socket ---
|
||||
|
||||
test("git socket: a path that exists and is not a socket is refused by label", async (t) => {
|
||||
const dataRoot = join(under(t), "data");
|
||||
const path = gitSocketPath(dataRoot);
|
||||
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(path, "", { mode: 0o600 });
|
||||
const sock = createGitSocket({ host: {}, business: { id: "acme", projects: {} }, dataRoot, env: {}, log: () => {} });
|
||||
await assert.rejects(sock.listen(), refusal("git socket path exists and isn't a socket", { paths: [dataRoot, path] }));
|
||||
});
|
||||
|
||||
// --- console launch ---
|
||||
|
||||
// The Console is given a port this test holds, so its listen fails and it exits at
|
||||
// startup. Should it start anyway, it is stopped before the tree goes.
|
||||
test("console: a Console that exits at startup is named by its log under the data root, never by host path", { timeout: 60000 }, async (t) => {
|
||||
const root = under(t);
|
||||
const ctx = { configDir: join(root, "config"), dataRoot: join(root, "data"), instance: "redact", env: { PATH: process.env.PATH, HOME: root } };
|
||||
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
|
||||
const held = createServer();
|
||||
await new Promise((resolve, reject) => held.once("error", reject).listen(0, "127.0.0.1", resolve));
|
||||
try {
|
||||
await assert.rejects(consoleStart(ctx, { detach: true, port: String(held.address().port), warn: () => {} }),
|
||||
refusal(/^console failed to start; see console\/console-[0-9]+-[0-9]+\.log under the data root$/, { paths: [ctx.dataRoot] }));
|
||||
} finally {
|
||||
await consoleStop(ctx, { warn: () => {} }).catch(() => {});
|
||||
await new Promise((resolve) => held.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
// --- review round (org-redact5 Sol P2 #3): documents, --vars and raw fs errors ---
|
||||
|
||||
const quietIo = () => ({ stdout: { write: () => true }, stderr: { write: () => true } });
|
||||
|
||||
test("org validate: a missing or invalid document is refused by kind, never by host path or bytes", async (t) => {
|
||||
const root = under(t), missing = join(root, "missing.json"), bad = join(root, "bad.json");
|
||||
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
|
||||
await assert.rejects(orgCommand(["validate", missing], quietIo()), refusal("cannot read organization document: ENOENT", { exitCode: 2, paths: [missing, root] }));
|
||||
await assert.rejects(orgCommand(["validate", bad], quietIo()), refusal("cannot read organization document: not valid JSON", { exitCode: 2, paths: [bad, root] }));
|
||||
});
|
||||
|
||||
test("org template render: a raw filesystem error is named by operation and code, never by host path", async (t) => {
|
||||
const root = under(t), output = join(root, "alpha.json");
|
||||
writeFileSync(output, "{}\n", { mode: 0o600 });
|
||||
await assert.rejects(orgCommand(["template", "render", "alpha", "--output", output], quietIo()),
|
||||
refusal("EEXIST: cannot open (EEXIST)", { exitCode: 2, paths: [output, root] }));
|
||||
});
|
||||
|
||||
test("instance set: a missing or invalid --vars file is refused by flag and kind, never by host path or bytes", async (t) => {
|
||||
const root = under(t), missing = join(root, "vars.json"), bad = join(root, "bad.json");
|
||||
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
|
||||
const rev = "a".repeat(64);
|
||||
const callFor = () => assert.fail("must refuse before any call");
|
||||
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", missing, "--revision", rev], quietIo(), callFor),
|
||||
refusal("--vars file: ENOENT", { exitCode: 4, paths: [missing, root] }));
|
||||
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", bad, "--revision", rev], quietIo(), callFor),
|
||||
refusal("--vars file: not valid JSON", { exitCode: 4, paths: [bad, root] }));
|
||||
});
|
||||
@@ -169,7 +169,7 @@ test("rename r2 B2: a draft file named like an installed ID: org validate reads
|
||||
rmSync(join(cwd, "acme"));
|
||||
const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
|
||||
assert.equal(noDraft.status, 2);
|
||||
assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /);
|
||||
assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n");
|
||||
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
|
||||
rmSync(join(root, "config", "businesses", "acme.json"));
|
||||
const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
|
||||
|
||||
@@ -21,11 +21,11 @@ const OTHER = '11111111-2222-4333-8444-555555555555';
|
||||
let n = 0;
|
||||
|
||||
// An instance with org alpha and one finished run for THREAD.
|
||||
async function world({ run = 'r0123456789ab' } = {}) {
|
||||
async function world({ run = 'r0123456789ab', under = HOME } = {}) {
|
||||
const name = `ev${++n}`;
|
||||
const env = { PATH: process.env.PATH, HOME, MOSAIC_CONFIG: join(HOME, '.config', `mosaic-${name}`, 'config.json') };
|
||||
mkdirSync(dirname(env.MOSAIC_CONFIG), { recursive: true, mode: 0o700 });
|
||||
const dataRoot = join(HOME, `data-${name}`); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const dataRoot = join(under, `data-${name}`); mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||
const org = await reserveOrg({ configDir: dirname(env.MOSAIC_CONFIG), dataRoot, instance: name, principal: DEV_OWNER }, 'alpha');
|
||||
const runDir = join(runsDir(org.root, 'alpha'), run);
|
||||
mkdirSync(join(runDir, 'bundle'), { recursive: true, mode: 0o700 });
|
||||
@@ -132,6 +132,27 @@ test('no launch for the thread, an unknown org and a usage error', async () => {
|
||||
await assert.rejects(runCommand(['evidence', 'alpha', '--thread', THREAD], io({ PATH: process.env.PATH, HOME }), { system: { dataRoot: w.dataRoot } }), e => e.exitCode === 3 && e.code === 'run-evidence-instance-refused');
|
||||
});
|
||||
|
||||
// PR #1916 (org-redact5): a refusal names the launch log by label, never by host path.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const noPath = (e, w) => !e.message.includes(SENTINEL) && !e.message.includes(w.dataRoot) && !e.message.includes(w.log);
|
||||
|
||||
test('a launch log of the wrong mode refuses by label, never by host path', async () => {
|
||||
const w = await world({ under: join(HOME, SENTINEL) });
|
||||
chmodSync(w.log, 0o644);
|
||||
const r = await evidence(w, ['--thread', THREAD, '--json']);
|
||||
assert.equal(r.error?.exitCode, 3);
|
||||
assert.equal(r.error.message, 'run-evidence-refused: launch log: mode 644, expected 600');
|
||||
assert.ok(noPath(r.error, w), r.error.message);
|
||||
});
|
||||
|
||||
test('no launch for the thread names the launch log by label, never by host path', async () => {
|
||||
const w = await world({ under: join(HOME, SENTINEL) });
|
||||
const r = await evidence(w, ['--thread', '99999999-2222-4333-8444-555555555555', '--json']);
|
||||
assert.equal(r.error?.exitCode, 2);
|
||||
assert.equal(r.error.message, 'no launch for thread 99999999-2222-4333-8444-555555555555 in the launch log');
|
||||
assert.ok(noPath(r.error, w), r.error.message);
|
||||
});
|
||||
|
||||
test('a thread name resolves through the bus with the org as business', async () => {
|
||||
const w = await world();
|
||||
const seen = [];
|
||||
|
||||
@@ -130,9 +130,13 @@ export function writeTurn(dir, id, record) {
|
||||
return path;
|
||||
}
|
||||
|
||||
// A parse refusal names the turn by its id, never by host path (PR #1916); a
|
||||
// listed file's id is named only when it passes TURN_ID.
|
||||
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
|
||||
|
||||
export function readTurn(dir, id) {
|
||||
const path = turnPath(dir, id);
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
|
||||
}
|
||||
|
||||
export function listTurns(dir) {
|
||||
@@ -140,7 +144,7 @@ export function listTurns(dir) {
|
||||
if (!existsSync(turns)) return [];
|
||||
return readdirSync(turns)
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length))));
|
||||
}
|
||||
|
||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||
|
||||
@@ -28,9 +28,10 @@ for (const [field, edit] of [
|
||||
});
|
||||
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
|
||||
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
|
||||
const root = scratch(t); mkdirSync(join(root, 'turns'));
|
||||
const root = join(scratch(t), secret); mkdirSync(join(root, 'turns'), { recursive: true });
|
||||
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
|
||||
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
|
||||
// The refusal names the turn by its id, never by host path (PR #1916).
|
||||
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe('turn record turn-1 is not valid JSON', SyntaxError, {}, file));
|
||||
});
|
||||
|
||||
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
|
||||
|
||||
@@ -223,21 +223,22 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
|
||||
reason: str("Why the run ends, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["role", "reason"]));
|
||||
}
|
||||
// Row 194 (#1692, PRD 1.7 REQ-COS-3): the chief of staff's hire and fire, routed within-role. The
|
||||
// broker checks the definition against the role files and the veto on every call.
|
||||
// A4: these propose changes. Only a person with org.hire can approve them.
|
||||
if (held.has("role.hire")) {
|
||||
add("hire", "instance.hire", "role.hire", "Hire a new role instance: add it to the organization file under a new name, with a definition your role file lists as hireable. A reason is required, 1 to 500 characters, and goes into the log. vars may set the model key and limits.* keys only. The broker refuses a definition that isn't hireable with definition-not-hireable. Launch the instance afterwards with launch.", obj({
|
||||
name: str("The new instance's name"),
|
||||
definition: str("The role definition it runs"),
|
||||
vars: { type: "object", description: "The instance's vars: model and limits.* keys only" },
|
||||
reason: str("Why you hire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["name", "definition", "reason"]));
|
||||
add("hire", "hire.raise", "role.hire", "Raise a hire or role-change decision under role.hire for a person to approve. The broker computes flagged grants and credential lines. Nothing is written until approval against the digest. Only the chief raises these decisions.", obj({
|
||||
op: {type:"string",enum:["hire","change"]}, level:{type:"string",enum:["org","project","root"]},
|
||||
project:str("Project name for project level"), name:str("Instance name"),
|
||||
role:{type:"object",description:"Exactly {template, sha256}, {definition, rev}, or {inline: composition}"},
|
||||
credentials:{type:"array",items:{type:"object"},description:"Requested services and scopes; no credential values"},
|
||||
personality:{type:"object",description:"Draft personality and traits"},
|
||||
route:obj({harness:str("Harness"),model:str("Configured model key"),thinking:str("Thinking level")},["harness","model"]),
|
||||
reason:str("Reason, 1 to 500 characters",{minLength:1,maxLength:500})
|
||||
},["op","level","name","role","route","reason"]));
|
||||
}
|
||||
if (held.has("role.fire")) {
|
||||
add("fire", "instance.fire", "role.fire", "Fire a role instance: stop its run, then remove it from the organization file. A reason is required, 1 to 500 characters, and goes into the log. The broker refuses an instance the human has kept with instance-kept. It also refuses your own instance.", obj({
|
||||
name: str("Role instance to fire"),
|
||||
reason: str("Why you fire it, 1 to 500 characters", { minLength: 1, maxLength: 500 }),
|
||||
}, ["name", "reason"]));
|
||||
add("fire", "hire.raise", "role.fire", "Raise a fire decision for a person to approve. Approval stops new launches and revokes grants; raising writes no agent configuration.", obj({
|
||||
op:{type:"string",enum:["fire"]},level:{type:"string",enum:["org","project","root"]},project:str("Project name for project level"),name:str("Instance name"),reason:str("Reason, 1 to 500 characters",{minLength:1,maxLength:500})
|
||||
},["op","level","name","reason"]));
|
||||
}
|
||||
if (held.has("organization.change")) {
|
||||
// Row 134 (#1669): organization_revision is the read change_organization's revision comes from.
|
||||
|
||||
@@ -64,15 +64,15 @@ test("the chief of staff gets launch, send_message, end_run, hire, fire, change_
|
||||
|
||||
// Row 194 (#1692, PRD 1.7 REQ-COS-3): hire and fire send the broker's argument keys, reason required,
|
||||
// and only a holder of the grant gets them.
|
||||
test("hire and fire send instance.hire and instance.fire under role.hire and role.fire, only for a holder of the grant", (t) => {
|
||||
test("hire and fire raise decisions under role.hire and role.fire, only for a holder of the grant", (t) => {
|
||||
const tools = typedTools({ resolved: resolvedFor(t, "chief"), tracker: true });
|
||||
const hire = tools.find((x) => x.name === "hire");
|
||||
const fire = tools.find((x) => x.name === "fire");
|
||||
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["instance.hire", "role.hire", "instance.fire", "role.fire"]);
|
||||
assert.deepEqual(Object.keys(hire.parameters.properties), ["name", "definition", "vars", "reason"]);
|
||||
assert.deepEqual(hire.parameters.required, ["name", "definition", "reason"]);
|
||||
assert.deepEqual(Object.keys(fire.parameters.properties), ["name", "reason"]);
|
||||
assert.deepEqual(fire.parameters.required, ["name", "reason"]);
|
||||
assert.deepEqual([hire.verb, hire.action, fire.verb, fire.action], ["hire.raise", "role.hire", "hire.raise", "role.fire"]);
|
||||
assert.deepEqual(Object.keys(hire.parameters.properties), ["op", "level", "project", "name", "role", "credentials", "personality", "route", "reason"]);
|
||||
assert.deepEqual(hire.parameters.required, ["op", "level", "name", "role", "route", "reason"]);
|
||||
assert.deepEqual(Object.keys(fire.parameters.properties), ["op", "level", "project", "name", "reason"]);
|
||||
assert.deepEqual(fire.parameters.required, ["op", "level", "name", "reason"]);
|
||||
for (const tool of [hire, fire]) {
|
||||
assert.equal(tool.parameters.additionalProperties, false);
|
||||
assert.deepEqual([tool.parameters.properties.reason.minLength, tool.parameters.properties.reason.maxLength], [1, 500]);
|
||||
|
||||
@@ -167,8 +167,8 @@ export function checkAccess(document, context) {
|
||||
// other, as the business file is.
|
||||
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
|
||||
const document = readJsonFile(file, "access file", (stat) => {
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`);
|
||||
});
|
||||
return checkAccess(document, context);
|
||||
}
|
||||
@@ -18,6 +18,7 @@
|
||||
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { HOST_SERVICE } from "./vocabulary.mjs";
|
||||
import { checkDefinitions } from "./definitions.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { resolveAgentLevels } from "./agent-levels.mjs";
|
||||
import { mergeVars } from "./vars.mjs";
|
||||
@@ -44,9 +45,9 @@ export function copyInstanceName(name, project = null) {
|
||||
// The credential services a copy of `definition` would need bound in the organization, and whether
|
||||
// it needs a tracker bot. The host key comes from a grant record at bus start, never from the file,
|
||||
// so it is not one (organization-config.mjs, checkRoles).
|
||||
function needs(definition, rolesDir, cache) {
|
||||
function needs(definition, rolesDir, cache, definitions = {}) {
|
||||
if (!cache.has(definition)) {
|
||||
const role = loadRole(rolesDir, definition);
|
||||
const role = checkDefinitions(definitions,"copy definitions")[definition] ?? loadRole(rolesDir, definition);
|
||||
const services = role.credentials.map((c) => c.service).filter((s) => s !== HOST_SERVICE);
|
||||
cache.set(definition, [...new Set(services)].sort());
|
||||
}
|
||||
@@ -62,6 +63,8 @@ function needs(definition, rolesDir, cache) {
|
||||
// read of the organization file recomputes each project's union without reading the root file.
|
||||
export function materializeCopies({ orgId, doc, root, rolesDir }) {
|
||||
const rootState = root === null ? "refused" : root.state;
|
||||
const definitions={...(root?.definitions??{}),...(doc.definitions??{})};
|
||||
doc={...doc,definitions};
|
||||
const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir });
|
||||
const rootAgents = {};
|
||||
for (const [name, entry] of Object.entries(root?.agents ?? {})) {
|
||||
@@ -88,7 +91,7 @@ export function materializeCopies({ orgId, doc, root, rolesDir }) {
|
||||
if (instance !== null && Object.hasOwn(doc?.roles ?? {}, instance)) return;
|
||||
if (refusal) { copies[key] = { ...base, state: "refused", reason: refusal }; return; }
|
||||
if (instance === null) { copies[key] = { ...base, state: "refused", reason: `project ${project}: ${agent.name}.${project} is longer than an instance name may be` }; return; }
|
||||
const missing = needs(agent.definition, rolesDir, cache);
|
||||
const missing = needs(agent.definition, rolesDir, cache, definitions);
|
||||
copies[key] = missing.length ? { ...base, state: "unbound", missing } : { ...base, state: "instance" };
|
||||
};
|
||||
// Root copies: the base union's root-level agents. It already leaves out the root assistant (A6),
|
||||
@@ -104,7 +107,7 @@ export function materializeCopies({ orgId, doc, root, rolesDir }) {
|
||||
add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`);
|
||||
}
|
||||
}
|
||||
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies });
|
||||
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies, ...(Object.keys(definitions).length?{definitions}: {}) });
|
||||
}
|
||||
|
||||
// The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing
|
||||
@@ -189,6 +192,7 @@ function projectCopyRefusal(copy, document, levels) {
|
||||
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
|
||||
// no project agents is plain validateBusinessDocument.
|
||||
export function withCopies(document, file, { rolesDir, snapshot, dataRoot = null }) {
|
||||
if(snapshot?.definitions)document={...document,definitions:{...snapshot.definitions,...(document.definitions??{})}};
|
||||
const copies = snapshot?.copies ?? {};
|
||||
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
// The document's own refusals come first, whole.
|
||||
@@ -279,6 +283,7 @@ export function copyOf(business, instance) {
|
||||
// Why a copy can't launch, as { code, message }, or null when it can. The message names the agent,
|
||||
// its project and the missing services or the refusal, and never a value.
|
||||
export function copyRefusal(business, instance) {
|
||||
if(business.roles?.[instance]?.unbound?.length)return {code:"credential-unbound",message:`${instance} has no binding for ${business.roles[instance].unbound.join(", ")}`};
|
||||
const copy = copyOf(business, instance);
|
||||
if (!copy || copy.state === "instance") return null;
|
||||
if (copy.state === "unbound") {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { join } from 'node:path';
|
||||
import { OrgError } from './org-registry.mjs';
|
||||
import { assertTrustedConfigDir } from './config-dir-trust.mjs';
|
||||
import { ROOT_FILE, checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, rootAgentsPath } from './agent-levels.mjs';
|
||||
import { checkDefinition } from "./definitions.mjs";
|
||||
import { canonicalJson, isPlainObject } from './util.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { REGISTRY } from './vars.mjs';
|
||||
@@ -163,7 +164,7 @@ async function syncDirectory(dir) {
|
||||
// Change one root agent. `entry` is the new entry, null to remove it, or a function of the old
|
||||
// entry (undefined when there is none) returning either. Returns the receipt. An error after the
|
||||
// rename carries committed: true, so a caller never retries a write that landed.
|
||||
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory } = {}) {
|
||||
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory, approvedDefinition = null, decision = null, assertClean = null } = {}) {
|
||||
requireRootOwner(actor, owner);
|
||||
if (typeof name !== 'string' || !ID_PATTERN.test(name)) fail('agents-invalid', 'agent name must be an id: lowercase letters, digits, dot, dash or underscore, at most 64');
|
||||
if (typeof revision !== 'string' || !REVISION.test(revision)) fail('revision-conflict', 'a root agent change needs the revision it was read at');
|
||||
@@ -187,8 +188,15 @@ export async function changeRootAgent(configDir, { revision, name, entry }, { ac
|
||||
if (value === null && old === undefined) fail('agent-not-found', `root agents.${name} does not exist`);
|
||||
if (value !== null && !isPlainObject(value)) fail('agents-invalid', `root agents.${name} must be an object`);
|
||||
const next = structuredClone(before.document);
|
||||
if (value === null) delete next.agents[name]; else next.agents[name] = value;
|
||||
if (value === null) {delete next.agents[name];if(decision)next.retired=[...(next.retired??[]),name];} else next.agents[name] = value;
|
||||
if (approvedDefinition) {
|
||||
const {name:def,entry}=approvedDefinition;
|
||||
if(typeof assertClean!=="function")fail("definition-secret-check-required");
|
||||
checkDefinition(def,entry,{assertClean});
|
||||
next.definitions={...(next.definitions??{}),[def]:entry};
|
||||
}
|
||||
const changed = changedKeys(old, value ?? undefined);
|
||||
if(approvedDefinition && canonicalJson(before.document.definitions?.[approvedDefinition.name])!==canonicalJson(approvedDefinition.entry))changed.push("definition.rev");
|
||||
if (changed.length === 0) fail('no-change', `root agents.${name} is already that`);
|
||||
validate(next, rolesDir, value);
|
||||
const bytes = JSON.stringify(next, null, 2) + '\n';
|
||||
@@ -197,7 +205,7 @@ export async function changeRootAgent(configDir, { revision, name, entry }, { ac
|
||||
const after = sha256(bytes);
|
||||
const line = {
|
||||
at: new Date().toISOString(), uid: process.geteuid(), actor: 'owner',
|
||||
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name,
|
||||
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name, ...(decision ? {decision} : {}),
|
||||
...(value === null ? {} : { definition: value.definition }),
|
||||
revisionBefore: before.revision, revisionAfter: after, changed,
|
||||
};
|
||||
@@ -257,7 +265,7 @@ export function projectAgentSummary(agents) {
|
||||
// `next` may differ from `old` only in projects.<project>.agents.<name>, the project must exist and
|
||||
// not be a library, and the project's entries and its union must pass A1's checks. Returns
|
||||
// [code, message] or null. `root` is the root level as loadRootAgents reads it.
|
||||
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root }) {
|
||||
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root, definitions = old.definitions }) {
|
||||
const target = old?.projects?.[project];
|
||||
if (!isPlainObject(target)) return ['project-not-found', `projects.${project} does not exist`];
|
||||
if (target.kind === 'library') return ['project-agents-library', `projects.${project} is a library project; it takes no agents`];
|
||||
@@ -276,7 +284,7 @@ export function projectAgentProblem(old, next, { orgId, project, name, entry, ro
|
||||
if (canonicalJson(after ?? null) === canonicalJson(target.agents?.[name] ?? null)) return ['no-change', `projects.${project}.agents.${name} is already that`];
|
||||
const where = `projects.${project}.agents`;
|
||||
let entries;
|
||||
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where }); }
|
||||
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where, definitions }); }
|
||||
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
|
||||
const org = Object.fromEntries(Object.entries(old.roles ?? {}).map(([n, r]) => [n, { definition: r?.definition, vars: r?.vars ?? {} }]));
|
||||
try { projectUnion({ orgId, root, org, project: entries, projectId: project }); }
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { checkDefinitions } from "./definitions.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkVars, mergeVars, REGISTRY } from "./vars.mjs";
|
||||
import { deepFreeze, isPlainObject, readJsonFile, rejectUnknownKeys, requireDistinctList, requireId, requireObject } from "./util.mjs";
|
||||
@@ -23,7 +24,8 @@ export function rootAgentsPath(configDir) {
|
||||
return join(configDir, ROOT_FILE);
|
||||
}
|
||||
|
||||
function definitionOf(name, at, rolesDir, cache) {
|
||||
function definitionOf(name, at, rolesDir, cache, definitions = {}) {
|
||||
if (typeof name === "string" && name.startsWith("org:") && Object.hasOwn(definitions,name)) return definitions[name];
|
||||
requireId(name, `${at}.definition`);
|
||||
if (!cache.has(name)) {
|
||||
// A role refusal names host paths; this message travels to the Console, so it names none.
|
||||
@@ -44,10 +46,12 @@ function noBoundKeys(entry, at) {
|
||||
// The root file's document. `where` names it in messages, never with a host path.
|
||||
export function checkRootAgents(document, { rolesDir, where = "root agents" } = {}) {
|
||||
requireObject(document, where);
|
||||
rejectUnknownKeys(document, ["agentsVersion", "agents"], where);
|
||||
rejectUnknownKeys(document, ["agentsVersion", "agents", "definitions", "retired"], where);
|
||||
if (document.agentsVersion !== 1) refuse(`${where} requires "agentsVersion": 1`);
|
||||
requireObject(document.agents, `${where} agents`);
|
||||
if(document.retired!==undefined)requireDistinctList(document.retired,"root retired",n=>requireId(n,"retired agent"));
|
||||
const cache = new Map();
|
||||
const definitions = checkDefinitions(document.definitions, "root agents");
|
||||
const agents = {};
|
||||
let assistant = null;
|
||||
for (const [name, entry] of Object.entries(document.agents)) {
|
||||
@@ -55,8 +59,8 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
|
||||
requireId(name, `${where} agent name`);
|
||||
requireObject(entry, at);
|
||||
noBoundKeys(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "vars", "excludeOrgs"], at);
|
||||
const definition = definitionOf(entry.definition, at, rolesDir, cache);
|
||||
rejectUnknownKeys(entry, ["definition", "vars", "excludeOrgs", "hireDecision"], at);
|
||||
const definition = definitionOf(entry.definition, at, rolesDir, cache, definitions);
|
||||
// Each organization's chief is its own (REQ-COS-3): never a root agent.
|
||||
if (entry.definition === CHIEF) refuse(`${at}: ${CHIEF} can't be a root agent; each organization has its own chief`);
|
||||
if (entry.definition === ASSISTANT) {
|
||||
@@ -67,10 +71,11 @@ export function checkRootAgents(document, { rolesDir, where = "root agents" } =
|
||||
definition: entry.definition,
|
||||
title: definition.title,
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
...(entry.hireDecision ? {hireDecision:entry.hireDecision}: {}),
|
||||
excludeOrgs: entry.excludeOrgs === undefined ? [] : requireDistinctList(entry.excludeOrgs, `${at}.excludeOrgs`, (id) => requireId(id, `${at}.excludeOrgs entry`)),
|
||||
};
|
||||
}
|
||||
return deepFreeze({ agents, assistant });
|
||||
return deepFreeze({ agents, assistant, ...(document.definitions ? {definitions:document.definitions}: {}) });
|
||||
}
|
||||
|
||||
// The root file. Missing means no root agents; anything else wrong refuses. The same descriptor
|
||||
@@ -86,9 +91,9 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
|
||||
// The read's messages carry the host path; the Console shows this one, so it names the file only.
|
||||
// Keep parse refusals file-only here too, without the host path.
|
||||
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
|
||||
// The Console shows this one, so it names the file only. The shared reader no longer quotes the
|
||||
// host path; the replaceAll below stays as a second guard.
|
||||
if (/ is not valid JSON$/.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
|
||||
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
|
||||
}
|
||||
try { return deepFreeze({ state: "ok", ...checkRootAgents(document, { rolesDir }) }); }
|
||||
@@ -103,7 +108,8 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
|
||||
const INTERSECT = Object.keys(REGISTRY).filter((key) => REGISTRY[key].merge === "intersect");
|
||||
|
||||
// One project's `agents` map. Each entry is exactly one of define, narrow or exclude.
|
||||
export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
export function checkProjectAgents(agents, { rolesDir, where, definitions = {} }) {
|
||||
definitions = checkDefinitions(definitions,"project definitions");
|
||||
requireObject(agents, where);
|
||||
const cache = new Map();
|
||||
const out = {};
|
||||
@@ -112,16 +118,16 @@ export function checkProjectAgents(agents, { rolesDir, where }) {
|
||||
requireId(name, `${where} agent name`);
|
||||
requireObject(entry, at);
|
||||
noBoundKeys(entry, at.replace(/^.*?projects\./, "project "));
|
||||
rejectUnknownKeys(entry, ["definition", "vars", "exclude"], at);
|
||||
rejectUnknownKeys(entry, ["definition", "vars", "exclude", "hireDecision"], at);
|
||||
if (Object.hasOwn(entry, "exclude")) {
|
||||
if (entry.exclude !== true || Object.keys(entry).length !== 1) refuse(`${at}: an exclude entry is exactly {"exclude": true}`);
|
||||
out[name] = { form: "exclude" };
|
||||
continue;
|
||||
}
|
||||
if (Object.hasOwn(entry, "definition")) {
|
||||
const definition = definitionOf(entry.definition, at, rolesDir, cache);
|
||||
const definition = definitionOf(entry.definition, at, rolesDir, cache, definitions);
|
||||
if (entry.definition === CHIEF || entry.definition === ASSISTANT) refuse(`${at}: ${entry.definition} can't be a project agent`);
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`) };
|
||||
out[name] = { form: "define", definition: entry.definition, title: definition.title, vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`), ...(entry.hireDecision ? {hireDecision:entry.hireDecision}: {}) };
|
||||
continue;
|
||||
}
|
||||
if (!isPlainObject(entry.vars) || Object.keys(entry.vars).length === 0) refuse(`${at}: an entry defines (definition), narrows (vars) or excludes (exclude)`);
|
||||
@@ -144,7 +150,7 @@ function orgLevels(doc, rolesDir) {
|
||||
const projects = {};
|
||||
for (const [id, entry] of Object.entries(doc?.projects ?? {})) {
|
||||
if (entry?.kind === "library") continue;
|
||||
projects[id] = entry?.agents === undefined ? {} : checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
|
||||
projects[id] = entry?.agents === undefined ? {} : checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents`, definitions:doc.definitions });
|
||||
}
|
||||
return { org, projects };
|
||||
}
|
||||
|
||||
@@ -7,10 +7,7 @@
|
||||
// checks one against a registry (the core set from vocabulary.mjs plus a plugin-declared set) in
|
||||
// the seven steps section 4 lists, in order, and returns the lines a human must see flagged.
|
||||
//
|
||||
// Nothing calls checkComposition yet. HIRE_ALLOWED and unhireableAction (vocabulary.mjs) still
|
||||
// bound a hire, and role.mjs and the broker still use them. Slice A4 switches hire over to this
|
||||
// validator and removes them. In P1 the plugin set is whatever the caller passes, empty by
|
||||
// default; nothing here reads a plugin file (PL1 builds install and the manifest).
|
||||
// The broker and approved writer validate every hire with this registry.
|
||||
//
|
||||
// A refusal names the field and the position at fault. It quotes a value only when that value is
|
||||
// a registered name (a core or plugin action, tool, network, service or scope), so no role text,
|
||||
@@ -50,8 +47,8 @@ export const COMPOSITION_BOUNDS = Object.freeze({
|
||||
skills: 64,
|
||||
});
|
||||
|
||||
// Ruling 8 and R9: a hired agent can't hire or fire, whatever HIRE_ALLOWED says later.
|
||||
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire"]);
|
||||
// Ruling 8 and R9: a hired agent cannot hire, fire or change another role.
|
||||
export const NEVER_HIRED = Object.freeze(["role.hire", "role.fire", "role.change"]);
|
||||
// The role rails (section 3): an action that lets its holder decide for other agents.
|
||||
export const RESOLVE_PREFIX = "decision.resolve.";
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
? "configuration is not valid JSON; expected readable JSON: "
|
||||
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
|
||||
: "expected valid system configuration: ";
|
||||
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
|
||||
// Name the config by where it came from, never by host path (PR #1916).
|
||||
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config"}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
@@ -119,7 +120,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
continue;
|
||||
}
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`);
|
||||
if (project.id !== id) fail(2, `project file of project ${id} has id that does not match the declared project key`);
|
||||
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
|
||||
projects[id] = "valid";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// Roles round 2, slice T1 (#1873): the human verbs on an organization's definitions, and the reads.
|
||||
// Design: docs/plans/2026-11-10_roles-and-rails-design.md section 4.
|
||||
//
|
||||
// Each write needs the caller's org.hire right (VERB_RIGHTS) and refuses every agent caller, then
|
||||
// goes through the writer's setDefinition: only that definition changes, rev counts up, the entry
|
||||
// passes checkComposition, and a definition an instance names refuses definition-in-use.
|
||||
//
|
||||
// caller: { actor, rights, agent } — actor is the authenticated account the writer logs; rights is
|
||||
// the caller's resolved right list (an owner holds ROLE_RIGHTS.owner); agent is true for any agent
|
||||
// session, which never writes a definition. The receipt names the verb, organization, definition,
|
||||
// rev, revision and transaction, never role text.
|
||||
|
||||
import { VERB_RIGHTS } from "./org-rights.mjs";
|
||||
import { OrganizationError } from "./organization.mjs";
|
||||
import {
|
||||
diffDefinition, entryFromTemplate, instancesNaming, readTemplate, templateHashes, checkDefinitionName, ORG_PREFIX,
|
||||
} from "./definitions.mjs";
|
||||
|
||||
// The verb names the policy table maps, following R1's hire.resolve.console.
|
||||
export const DEFINITION_VERBS = Object.freeze({
|
||||
import: "definition.import.console",
|
||||
edit: "definition.edit.console",
|
||||
invent: "definition.invent.console",
|
||||
remove: "definition.remove.console",
|
||||
});
|
||||
|
||||
// The bus verbs for the writes (the broker's DEFINITION_BUS_KEYS checks their arguments). The bus
|
||||
// runs them for the business's human at the cli, with its credential store's secret check; the CLI's
|
||||
// writes go through them, as no other process holds the tokens that check looks for.
|
||||
export const DEFINITION_BUS_VERBS = Object.freeze({
|
||||
"definition.import": "import", "definition.invent": "invent", "definition.edit": "edit", "definition.remove": "remove",
|
||||
});
|
||||
|
||||
const fail = (code, message = code) => {
|
||||
throw new OrganizationError(code, message);
|
||||
};
|
||||
|
||||
function authorize(caller, verb) {
|
||||
if (caller === null || typeof caller !== "object") fail("caller-required");
|
||||
if (caller.agent !== false) fail("human-required", "an agent never writes a definition");
|
||||
const right = VERB_RIGHTS[DEFINITION_VERBS[verb]];
|
||||
if (typeof right !== "string") fail("verb-unmapped");
|
||||
if (!Array.isArray(caller.rights) || !caller.rights.includes(right)) fail("right-required", `${verb} needs ${right}`);
|
||||
}
|
||||
|
||||
// The parts of an entry a caller may supply for invent and edit: role, instructions, personality.
|
||||
const SUPPLIED = ["role", "instructions", "personality"];
|
||||
function supplied(input) {
|
||||
if (input === null || typeof input !== "object" || Array.isArray(input)) fail("definition-invalid", "the definition must be a JSON object");
|
||||
const bad = Object.keys(input).findIndex((key) => !SUPPLIED.includes(key));
|
||||
if (bad !== -1) fail("definition-invalid", `the definition has an unknown key (key ${bad + 1}; allowed: ${SUPPLIED.join(", ")})`);
|
||||
const out = { role: input.role, instructions: input.instructions };
|
||||
if (input.personality !== undefined) out.personality = input.personality;
|
||||
return out;
|
||||
}
|
||||
|
||||
function receipt(verb, id, name, result) {
|
||||
return { verb, organization: id, definition: name, rev: result.rev, revision: result.revision, transaction: result.transaction };
|
||||
}
|
||||
|
||||
export function definitionVerbs({ writer, rolesDir, assertClean }) {
|
||||
const options = (verb, caller, extra) => ({ ...extra, actor: caller.actor, assertClean, detail: { verb: DEFINITION_VERBS[verb] } });
|
||||
|
||||
// Copies a template into definitions with from set to its name and current hash.
|
||||
function importTemplate(id, { revision, template, as = template }, caller) {
|
||||
authorize(caller, "import");
|
||||
checkDefinitionName(as, "definition name");
|
||||
const source = readTemplate(rolesDir, template);
|
||||
const result = writer.setDefinition(id, as, (current) => {
|
||||
if (current) fail("definition-exists", `definitions already declares ${as}`);
|
||||
const { rev: _, ...entry } = entryFromTemplate(source);
|
||||
return entry;
|
||||
}, options("import", caller, { revision }));
|
||||
return { ...receipt("import", id, as, result), from: { template, sha256: source.sha256 } };
|
||||
}
|
||||
|
||||
// Changes an existing definition: the caller supplies the whole role, instructions and
|
||||
// personality; from stays as it was.
|
||||
function edit(id, { revision, rev, name, definition }, caller) {
|
||||
authorize(caller, "edit");
|
||||
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "edit needs the definition's current rev");
|
||||
const entry = supplied(definition);
|
||||
const result = writer.setDefinition(id, name, (current) => {
|
||||
if (!current) fail("definition-missing", `definitions declares no ${name}`);
|
||||
return current.from ? { from: current.from, ...entry } : entry;
|
||||
}, options("edit", caller, { revision, rev }));
|
||||
return receipt("edit", id, name, result);
|
||||
}
|
||||
|
||||
// Writes a new definition with no from.
|
||||
function invent(id, { revision, name, definition }, caller) {
|
||||
authorize(caller, "invent");
|
||||
const entry = supplied(definition);
|
||||
const result = writer.setDefinition(id, name, (current) => {
|
||||
if (current) fail("definition-exists", `definitions already declares ${name}`);
|
||||
return entry;
|
||||
}, options("invent", caller, { revision }));
|
||||
return receipt("invent", id, name, result);
|
||||
}
|
||||
|
||||
function remove(id, { revision, rev, name }, caller) {
|
||||
authorize(caller, "remove");
|
||||
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "remove needs the definition's current rev");
|
||||
const result = writer.setDefinition(id, name, () => null, options("remove", caller, { revision, rev }));
|
||||
return { ...receipt("remove", id, name, result), rev: null, removedRev: rev };
|
||||
}
|
||||
|
||||
// Reads. Each reads the validated document.
|
||||
function list(id) {
|
||||
const { document, revision } = writer.read(id);
|
||||
const definitions = Object.entries(document.definitions ?? {}).map(([name, entry]) => ({
|
||||
name, reference: ORG_PREFIX + name, rev: entry.rev, from: entry.from ?? null, usedBy: instancesNaming(document, name),
|
||||
}));
|
||||
return { organization: id, revision, definitions };
|
||||
}
|
||||
|
||||
function show(id, name) {
|
||||
const { document, revision } = writer.read(id);
|
||||
if (!Object.hasOwn(document.definitions ?? {}, name)) fail("definition-missing", `definitions declares no ${name}`);
|
||||
return { organization: id, revision, name, reference: ORG_PREFIX + name, usedBy: instancesNaming(document, name), ...structuredClone(document.definitions[name]) };
|
||||
}
|
||||
|
||||
function diff(id, name) {
|
||||
const { document, revision } = writer.read(id);
|
||||
return { organization: id, revision, ...diffDefinition(document, name, rolesDir) };
|
||||
}
|
||||
|
||||
return { import: importTemplate, edit, invent, remove, list, show, diff, templates: () => templateHashes(rolesDir) };
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
// Roles round 2, slice T1 (#1873): an organization's own role definitions and the templates they
|
||||
// copy. Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 4 and 5, and the T1
|
||||
// paragraph of section 13.
|
||||
//
|
||||
// The organization file's `definitions` key holds one entry per name:
|
||||
// { rev, from?: { template, sha256 }, role: { tools, network, authority, credentials,
|
||||
// messaging?, skills? }, instructions, personality? }
|
||||
// An instance names one as `definition: "org:<name>"`. Every entry passes checkComposition (P1)
|
||||
// whenever the file is validated, so no path writes or loads a definition that skips it. A refusal
|
||||
// names the definition and the key at fault, never a value: composition.mjs quotes only registered
|
||||
// names, and this module quotes only definition and template names, which match ID_PATTERN.
|
||||
//
|
||||
// A template is a shipped roles/<name>.json (role version 2) with its contract. Its identity is the
|
||||
// name plus templateHash: sha256 over the JSON bytes and the contract bytes together, each preceded
|
||||
// by its byte length and a newline, so no field goes into roles/.
|
||||
//
|
||||
// Pure reads only. The writer verbs live in definition-verbs.mjs, which organization.mjs's writer
|
||||
// backs; this module is imported by organization-config.mjs and must not import the writer.
|
||||
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { buildRegistry, checkComposition } from "./composition.mjs";
|
||||
import { validateRoleDocument } from "./role.mjs";
|
||||
import { ID_PATTERN } from "./vocabulary.mjs";
|
||||
import { canonicalJson, deepFreeze } from "./util.mjs";
|
||||
|
||||
export const ORG_PREFIX = "org:";
|
||||
export const ENTRY_KEYS = Object.freeze(["rev", "from", "role", "instructions", "personality"]);
|
||||
// The primitives an entry keeps under `role`; instructions and personality sit beside it.
|
||||
export const ROLE_KEYS = Object.freeze(["tools", "network", "authority", "credentials", "messaging", "skills"]);
|
||||
// What the diff read compares, in section 4's order.
|
||||
export const PRIMITIVES = Object.freeze([...ROLE_KEYS, "instructions", "personality"]);
|
||||
const FROM_KEYS = Object.freeze(["template", "sha256"]);
|
||||
const HEX64 = /^[0-9a-f]{64}$/;
|
||||
|
||||
// The core registry. Plugin-declared primitives arrive with PL1; until then an org definition uses
|
||||
// core names only.
|
||||
const CORE = buildRegistry();
|
||||
// Validation keeps no flagged lines: A4 computes them at the decision, against the chief's live
|
||||
// role. Step 7 only flags, so a chief holding nothing refuses nothing here.
|
||||
const NO_CHIEF = Object.freeze({
|
||||
tools: Object.freeze([]), network: "none",
|
||||
authority: Object.freeze({ withinRole: Object.freeze([]), crossRole: Object.freeze([]) }),
|
||||
credentials: Object.freeze([]), messaging: null,
|
||||
});
|
||||
// The file-load check has no credential store: a load runs before the bus builds one from the file.
|
||||
// The held-secret scan runs where definitions are written (setDefinition requires it, and the bus
|
||||
// passes credentials.assertClean). A load still runs every other composition check.
|
||||
const NO_STORE = () => {};
|
||||
|
||||
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
|
||||
|
||||
// The definition name an instance's `definition` value names, or null for a template name.
|
||||
export function orgDefinitionName(value) {
|
||||
return typeof value === "string" && value.startsWith(ORG_PREFIX) ? value.slice(ORG_PREFIX.length) : null;
|
||||
}
|
||||
|
||||
export function checkDefinitionName(name, where) {
|
||||
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`${where} must be a definition name matching ${ID_PATTERN}`);
|
||||
return name;
|
||||
}
|
||||
|
||||
function keysOnly(object, allowed, where) {
|
||||
Object.keys(object).forEach((key, i) => {
|
||||
if (!allowed.includes(key)) refuse(`${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
|
||||
});
|
||||
}
|
||||
|
||||
// The composition an entry describes, for checkComposition. Absent optional keys stay absent.
|
||||
export function compositionOf(entry) {
|
||||
const out = { ...entry.role, instructions: entry.instructions };
|
||||
if (entry.personality !== undefined) out.personality = entry.personality;
|
||||
return out;
|
||||
}
|
||||
|
||||
// One entry: its own keys, then checkComposition. Returns { composition, flagged }. options.assertClean
|
||||
// is the credential store's check; a write always passes it (setDefinition), a file load can't.
|
||||
export function checkDefinition(name, entry, { assertClean = NO_STORE, where = "definitions" } = {}) {
|
||||
const at = `${where}.${checkDefinitionName(name, `${where} key`)}`;
|
||||
if (!plain(entry)) refuse(`${at} must be a JSON object`);
|
||||
keysOnly(entry, ENTRY_KEYS, at);
|
||||
if (!Number.isSafeInteger(entry.rev) || entry.rev < 1) refuse(`${at}.rev must be a positive integer`);
|
||||
if (entry.from !== undefined) {
|
||||
if (!plain(entry.from)) refuse(`${at}.from must be a JSON object`);
|
||||
keysOnly(entry.from, FROM_KEYS, `${at}.from`);
|
||||
if (typeof entry.from.template !== "string" || !ID_PATTERN.test(entry.from.template)) refuse(`${at}.from.template must be a template name matching ${ID_PATTERN}`);
|
||||
if (typeof entry.from.sha256 !== "string" || !HEX64.test(entry.from.sha256)) refuse(`${at}.from.sha256 must be 64 lowercase hex characters`);
|
||||
}
|
||||
if (!plain(entry.role)) refuse(`${at}.role must be a JSON object`);
|
||||
keysOnly(entry.role, ROLE_KEYS, `${at}.role`);
|
||||
try {
|
||||
return checkComposition(compositionOf(entry), CORE, NO_CHIEF, { assertClean });
|
||||
} catch (error) {
|
||||
// composition.mjs's refusals quote registered names only; keep its text, add where.
|
||||
if (error?.name === "BusinessError") refuse(`${at}: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// The resolved role an `org:` instance launches with: loadRole's shape, so the business checks,
|
||||
// the resolver and the broker read it as they read a template. Instructions stand in for the
|
||||
// contract file (contractPath null). A definition grants no hiring authority.
|
||||
function resolved(name, entry, composition) {
|
||||
return {
|
||||
roleVersion: 2, name: ORG_PREFIX + name, title: name, contract: null, contractPath: null,
|
||||
tools: composition.tools, network: composition.network, authority: composition.authority,
|
||||
credentials: composition.credentials, messaging: composition.messaging, skills: composition.skills,
|
||||
instructions: composition.instructions, personality: composition.personality,
|
||||
rev: entry.rev, from: entry.from === undefined ? null : { ...entry.from },
|
||||
};
|
||||
}
|
||||
|
||||
// The whole `definitions` key. Returns a frozen map "org:<name>" -> resolved role. A bad entry
|
||||
// refuses the whole file.
|
||||
export function checkDefinitions(definitions, file, options = {}) {
|
||||
if (definitions === undefined) return deepFreeze({});
|
||||
const where = `${file} definitions`;
|
||||
if (!plain(definitions)) refuse(`${where} must be a JSON object`);
|
||||
const out = {};
|
||||
Object.keys(definitions).forEach((name, i) => {
|
||||
checkDefinitionName(name, `${where} key ${i + 1}`);
|
||||
const { composition } = checkDefinition(name, definitions[name], { ...options, where });
|
||||
out[ORG_PREFIX + name] = resolved(name, definitions[name], composition);
|
||||
});
|
||||
return deepFreeze(out);
|
||||
}
|
||||
|
||||
// The instances that name `org:<name>`, sorted.
|
||||
export function instancesNaming(document, name) {
|
||||
const ref=ORG_PREFIX+name;
|
||||
const users=Object.entries(document.roles??{}).filter(([,entry])=>entry.definition===ref).map(([instance])=>instance);
|
||||
for(const [project,p]of Object.entries(document.projects??{}))for(const [instance,entry]of Object.entries(p.agents??{}))if(entry.definition===ref)users.push(`${project}.${instance}`);
|
||||
return users.sort();
|
||||
}
|
||||
|
||||
// ---- Templates ----
|
||||
|
||||
function frame(...parts) {
|
||||
const hash = createHash("sha256");
|
||||
for (const part of parts) {
|
||||
hash.update(`${part.length}\n`);
|
||||
hash.update(part);
|
||||
}
|
||||
return hash.digest("hex");
|
||||
}
|
||||
|
||||
// One template, read once: its validated role, its contract text and its hash. Refuses a missing,
|
||||
// invalid or version 1 template.
|
||||
export function readTemplate(rolesDir, name) {
|
||||
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`template name must match ${ID_PATTERN}`);
|
||||
const file = join(rolesDir, `${name}.json`);
|
||||
let json;
|
||||
try {
|
||||
json = readFileSync(file);
|
||||
} catch {
|
||||
refuse(`template ${name} not found`);
|
||||
}
|
||||
let document;
|
||||
try {
|
||||
document = JSON.parse(json.toString("utf8"));
|
||||
} catch {
|
||||
refuse(`template ${name} is not valid JSON`);
|
||||
}
|
||||
const role = validateRoleDocument(document, file);
|
||||
if (role.roleVersion !== 2) refuse(`template ${name} is role version 1 and has no contract`);
|
||||
let contract;
|
||||
try { contract = readFileSync(role.contractPath); } catch { refuse(`template ${name} contract is unavailable`); }
|
||||
return { name, role, contract: contract.toString("utf8"), sha256: frame(json, contract) };
|
||||
}
|
||||
|
||||
export function templateHash(rolesDir, name) {
|
||||
return readTemplate(rolesDir, name).sha256;
|
||||
}
|
||||
|
||||
// The read: every version 2 template in rolesDir with its current hash, sorted by name. A file that
|
||||
// is not a usable version 2 role (conductor-policy.json, a version 1 role) is not a template.
|
||||
export function templateHashes(rolesDir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(rolesDir).filter((f) => f.endsWith(".json")).sort()) {
|
||||
const name = entry.slice(0, -".json".length);
|
||||
if (!ID_PATTERN.test(name)) continue;
|
||||
let template;
|
||||
try {
|
||||
template = readTemplate(rolesDir, name);
|
||||
} catch (error) {
|
||||
if (error?.name === "BusinessError") continue;
|
||||
throw error;
|
||||
}
|
||||
out.push({ template: name, sha256: template.sha256 });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The entry an import writes: the template's primitives and its contract as instructions.
|
||||
export function entryFromTemplate(template) {
|
||||
const { role } = template;
|
||||
const out = {
|
||||
tools: [...role.tools], network: role.network,
|
||||
authority: { withinRole: [...role.authority.withinRole], crossRole: [...role.authority.crossRole] },
|
||||
credentials: role.credentials.map((c) => (c.scopes === undefined ? { service: c.service } : { service: c.service, scopes: structuredClone(c.scopes) })),
|
||||
};
|
||||
if (role.messaging) out.messaging = { sendTo: [...role.messaging.sendTo], receiveFrom: [...role.messaging.receiveFrom] };
|
||||
if (role.skills.length) out.skills = [...role.skills];
|
||||
return { rev: 1, from: { template: template.name, sha256: template.sha256 }, role: out, instructions: template.contract };
|
||||
}
|
||||
|
||||
// ---- The diff read ----
|
||||
|
||||
// A line diff of two texts: [{ op: " " | "-" | "+", line }]. Longest common subsequence over lines;
|
||||
// past MAX_CELLS it reports the old text removed and the new one added, which is still exact.
|
||||
const MAX_CELLS = 4_000_000;
|
||||
export function lineDiff(before, after) {
|
||||
const a = before.split("\n");
|
||||
const b = after.split("\n");
|
||||
if (a.length * b.length > MAX_CELLS) {
|
||||
return [...a.map((line) => ({ op: "-", line })), ...b.map((line) => ({ op: "+", line }))];
|
||||
}
|
||||
const width = b.length + 1;
|
||||
const table = new Uint32Array((a.length + 1) * width);
|
||||
for (let i = a.length - 1; i >= 0; i--) {
|
||||
for (let j = b.length - 1; j >= 0; j--) {
|
||||
table[i * width + j] = a[i] === b[j] ? table[(i + 1) * width + j + 1] + 1
|
||||
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
|
||||
}
|
||||
}
|
||||
const out = [];
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
while (i < a.length && j < b.length) {
|
||||
if (a[i] === b[j]) {
|
||||
out.push({ op: " ", line: a[i] });
|
||||
i++;
|
||||
j++;
|
||||
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
|
||||
out.push({ op: "-", line: a[i++] });
|
||||
} else {
|
||||
out.push({ op: "+", line: b[j++] });
|
||||
}
|
||||
}
|
||||
while (i < a.length) out.push({ op: "-", line: a[i++] });
|
||||
while (j < b.length) out.push({ op: "+", line: b[j++] });
|
||||
return out;
|
||||
}
|
||||
|
||||
// For a copy whose from.sha256 differs from its template's current hash: which primitives differ
|
||||
// between the copy as it stands and the template as it stands, and the instructions' line diff.
|
||||
// The file keeps only the hash of what was imported, so a customized copy's own edits show here
|
||||
// too. Nothing is applied: apply or keep is a change decision (A4).
|
||||
export function diffDefinition(document, name, rolesDir) {
|
||||
const definitions = document?.definitions ?? {};
|
||||
if (!Object.hasOwn(definitions, name)) refuse(`definitions declares no ${name}`);
|
||||
const entry = definitions[name];
|
||||
const base = { definition: name, rev: entry.rev, from: entry.from ?? null };
|
||||
if (!entry.from) return { ...base, current: null, changed: false, primitives: [], instructions: [] };
|
||||
let template;
|
||||
try {
|
||||
template = readTemplate(rolesDir, entry.from.template);
|
||||
} catch (error) {
|
||||
if (error?.name !== "BusinessError") throw error;
|
||||
return { ...base, current: null, changed: true, templateMissing: true, primitives: [], instructions: [] };
|
||||
}
|
||||
if (template.sha256 === entry.from.sha256) return { ...base, current: template.sha256, changed: false, primitives: [], instructions: [] };
|
||||
const now = compositionOf(entryFromTemplate(template));
|
||||
const copy = compositionOf(entry);
|
||||
const primitives = PRIMITIVES.filter((key) => canonicalJson(copy[key] ?? null) !== canonicalJson(now[key] ?? null));
|
||||
return {
|
||||
...base, current: template.sha256, changed: true, primitives,
|
||||
instructions: primitives.includes("instructions") ? lineDiff(copy.instructions, now.instructions) : [],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// A4: private, pinned proposals; public decision records contain metadata and hashes only.
|
||||
// This is a trusted host service, never a worker import or a credential binding writer.
|
||||
import { mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, openSync, closeSync, fsyncSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { buildRegistry, checkComposition } from './composition.mjs';
|
||||
import { compositionOf, entryFromTemplate, readTemplate, templateHashes } from './definitions.mjs';
|
||||
import { readRootAgentsFile, changeRootAgent } from './agent-levels-write.mjs';
|
||||
import { validateBusinessDocument } from './organization-config.mjs';
|
||||
import { canonicalJson, isPlainObject, readJsonFile } from './util.mjs';
|
||||
import { ID_PATTERN, NETWORKS } from './vocabulary.mjs';
|
||||
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';
|
||||
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'];
|
||||
export function authorizeHire(proposal, caller) {
|
||||
if (!caller || caller.agent !== false) fail('human-required');
|
||||
if (!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console'])) fail('right-required');
|
||||
if ((proposal.flagged.length || proposal.credentialLines.length) && caller.owner !== true) fail('hire-owner-required');
|
||||
if (proposal.level === 'root' && caller.instanceOwner !== true) fail('hire-owner-required');
|
||||
}
|
||||
export function credentialLines(credentials) {
|
||||
return credentials.flatMap(({service,scopes}) => scopes === undefined ? [{service}] : Array.isArray(scopes)
|
||||
? scopes.map(scope => ({service,scope})) : Object.entries(scopes).flatMap(([group,verbs]) => verbs.map(verb => ({service,scope:`${group}.${verb}`}))));
|
||||
}
|
||||
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)} : {}),
|
||||
// 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() }) {
|
||||
const scanEntry = (name, entry) => {
|
||||
if (typeof scanDefinition === 'function') return scanDefinition(name, structuredClone(entry));
|
||||
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
|
||||
assertClean(entry);
|
||||
};
|
||||
const home = join(dataRoot,'authority-proposals');
|
||||
let receiptCache;
|
||||
const rootReceipts=()=>{
|
||||
try{
|
||||
const file=join(configDir,'agents-receipts.jsonl'),st=lstatSync(file,{bigint:true});
|
||||
if(!st.isFile() || st.uid!==BigInt(process.getuid()) || (st.mode&0o022n) || st.nlink!==1n)throw new Error('unsafe receipts');
|
||||
const stamp=[st.dev,st.ino,st.size,st.mtimeNs,st.ctimeNs].join(':');
|
||||
if(receiptCache?.stamp===stamp)return receiptCache.rows;
|
||||
const rows=readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(JSON.parse);
|
||||
receiptCache={stamp,rows};return rows;
|
||||
}
|
||||
catch(error){if(error.code==='ENOENT')return [];throw error;}
|
||||
};
|
||||
let bootReceipts;
|
||||
try{bootReceipts=rootReceipts();}catch{bootReceipts=null;}
|
||||
const rootRefusal=name=>{
|
||||
try {
|
||||
if(!bootReceipts)return 'root-policy-unreadable';
|
||||
const latest=rootReceipts().filter(r=>r.agent===name).at(-1),boot=bootReceipts.filter(r=>r.agent===name).at(-1);
|
||||
if(latest?.verb==='root.agent.remove')return 'instance-fired';
|
||||
return canonicalJson(latest??null)===canonicalJson(boot??null)?null:'copy-stale';
|
||||
} catch {return 'root-policy-unreadable';}
|
||||
};
|
||||
async function assertRootFireLocal(business,registry=undefined) {
|
||||
if(registry===undefined)registry=await readRegistry({configDir});
|
||||
// Another organization can retain a boot snapshot and live external grants even after
|
||||
// excludeOrgs changes. Without a cross-bus revoke protocol, refuse conservatively.
|
||||
const others=new Set(Object.keys(registry?.orgs??{}));
|
||||
for(const file of readdirSync(join(configDir,'businesses')))if(file.endsWith('.json') && ID_PATTERN.test(file.slice(0,-5)))others.add(file.slice(0,-5));
|
||||
others.delete(business);
|
||||
if(others.size)throw new OrganizationError('root-fire-shared',`root fire requires coordinated revocation in ${[...others].sort().join(', ')}`);
|
||||
}
|
||||
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'); } };
|
||||
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 (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']);
|
||||
for (const x of Object.values(input.route)) if (typeof x!=='string' || !x || x.length>200) fail('invalid-request');
|
||||
keys(input.role,['template','sha256','definition','rev','inline']);
|
||||
const count=['template','definition','inline'].filter(k=>k in input.role).length;
|
||||
if (count!==1) fail('invalid-request');
|
||||
if ('template' in input.role) { id(input.role.template); if (!/^[a-f0-9]{64}$/.test(input.role.sha256??'') || Object.keys(input.role).length!==2) fail('invalid-request'); }
|
||||
if ('definition' in input.role) { id(input.role.definition); if (!Number.isSafeInteger(input.role.rev) || Object.keys(input.role).length!==2) fail('invalid-request'); }
|
||||
if ('inline' in input.role && Object.keys(input.role).length!==1) fail('invalid-request');
|
||||
} else if (['role','route','credentials','personality'].some(k=>k in input)) fail('invalid-request');
|
||||
const {document:doc,revision} = writer.read(business);
|
||||
const loaded = validateBusinessDocument(doc,`${business}.json`,{rolesDir,dataRoot});
|
||||
let chief=loaded.definitions[doc.roles[doc.launch?.by]?.definition];
|
||||
if(chief) {const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});chief={...chief,...resolved.limits};}
|
||||
if (!chief) fail('no-chief-of-staff');
|
||||
const root = input.level==='root' ? await readRootAgentsFile(configDir) : null;
|
||||
if(input.level==='root' && input.op==='fire')await assertRootFireLocal(business);
|
||||
if(root?.document.retired?.includes(input.name))fail('organization-instance-retired');
|
||||
const entries = input.level==='root' ? root.document.agents : input.level==='org' ? doc.roles : doc.projects[input.project]?.agents ?? {};
|
||||
if (input.level==='project' && (!doc.projects[input.project] || doc.projects[input.project].kind==='library')) fail('project-agents-invalid');
|
||||
if ((doc.retired??[]).includes(input.name)) fail('organization-instance-retired');
|
||||
if ((doc.launch?.keep??[]).includes(input.name)) fail('instance-kept');
|
||||
if (input.op==='hire' && (doc.launch?.noHire??[]).includes(input.role?.template??input.role?.definition))fail('definition-not-hireable');
|
||||
if ((input.op==='hire') === Object.hasOwn(entries,input.name)) fail(input.op==='hire'?'organization-instance-exists':'organization-unknown-instance');
|
||||
if (input.name===doc.launch?.by || Object.values(doc.arbiters).includes(input.name)) fail('organization-instance-referenced');
|
||||
let entry=null, flagged=[], lines=[];
|
||||
if (input.op!=='fire') {
|
||||
const ref=input.role;
|
||||
if ('template' in ref) { const template=readTemplate(rolesDir,ref.template); if(template.sha256!==ref.sha256) fail('decision-stale'); entry=entryFromTemplate(template); }
|
||||
else if ('definition' in ref) { entry=doc.definitions?.[ref.definition]; if(!entry || entry.rev!==ref.rev) fail('definition-rev-conflict'); entry=structuredClone(entry); }
|
||||
else entry={rev:1,role:{},instructions:''};
|
||||
let c='inline' in ref ? structuredClone(ref.inline) : compositionOf(entry);
|
||||
if ('credentials' in input) c.credentials=structuredClone(input.credentials);
|
||||
if ('personality' in input) c.personality=structuredClone(input.personality);
|
||||
const checked=checkComposition(c,registryFor(doc),chief,{assertClean});
|
||||
const {instructions,personality,...role}=checked.composition;
|
||||
if(role.messaging===null)delete role.messaging;
|
||||
entry={...entry,role,instructions,...(personality===null?{}:{personality})};
|
||||
if(personality===null)delete entry.personality;
|
||||
flagged=checked.flagged; lines=credentialLines(checked.composition.credentials);
|
||||
// Validate the proposed route through the existing host route policy before a proposal
|
||||
// reaches approval. Use a temporary org instance for project/root route validation too.
|
||||
const routeDoc=structuredClone(doc), definition=`hire-${decision}`;
|
||||
routeDoc.definitions={...(routeDoc.definitions??{}),[definition]:entry};
|
||||
routeDoc.roles[input.name]={definition:`org:${definition}`,vars:input.route,hireDecision:decision};
|
||||
const proposed=validateBusinessDocument(routeDoc,`${business}.json`,{rolesDir,dataRoot});
|
||||
checkRoutes({proposed,old:loaded,levels,catalogue:await catalogueFor(business,doc)});
|
||||
}
|
||||
const current=entries[input.name];
|
||||
if(input.op==='fire') {
|
||||
const definition=current?.definition;
|
||||
const value=definition?.startsWith('org:') ? (input.level==='root'?root.document.definitions:doc.definitions)?.[definition.slice(4)] : entryFromTemplate(readTemplate(rolesDir,definition));
|
||||
if(!value)fail('definition-missing');
|
||||
const checked=checkComposition(compositionOf(value),registryFor(doc),chief,{assertClean});
|
||||
flagged=checked.flagged;lines=credentialLines(checked.composition.credentials);
|
||||
}
|
||||
if(input.op==='change' && current?.definition?.startsWith('org:') && input.level!=='root') {
|
||||
const users=Object.entries(doc.roles).filter(([,e])=>e.definition===current.definition).map(([n])=>`org:${n}`);
|
||||
for(const [project,value] of Object.entries(doc.projects))for(const [n,e]of Object.entries(value.agents??{}))if(e.definition===current.definition)users.push(`project:${project}:${n}`);
|
||||
const target=input.level==='org'?`org:${input.name}`:`project:${input.project}:${input.name}`;
|
||||
if(users.some(n=>n!==target))fail('definition-shared');
|
||||
}
|
||||
if(input.op==='change' && input.level==='root' && Object.entries(root.document.agents).some(([name,e])=>name!==input.name && e.definition===current.definition))fail('definition-shared');
|
||||
const targetDefinition=input.op==='change' && current?.definition?.startsWith('org:') ? current.definition.slice(4) : `hire-${decision}`;
|
||||
const p={id:decision,business,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,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);
|
||||
}
|
||||
async function adjusted(business, decision, adjust) {
|
||||
const p=read(decision);
|
||||
if(p.business!==business)fail('decision-stale');
|
||||
// org-defscan (#1873): the stored entry is the definition text an approval writes. A credential
|
||||
// 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;
|
||||
keys(adjust,['role','credentials','route']);
|
||||
if(!p.entry)fail('invalid-request');
|
||||
assertClean(adjust);
|
||||
const before=compositionOf(p.entry), after=structuredClone(before);
|
||||
if(adjust.role!==undefined) {
|
||||
keys(adjust.role,['tools','network','authority','messaging','skills']);
|
||||
Object.assign(after,structuredClone(adjust.role));
|
||||
}
|
||||
if(adjust.credentials!==undefined)after.credentials=structuredClone(adjust.credentials);
|
||||
const doc=writer.read(business).document;
|
||||
const loaded=validateBusinessDocument(doc,`${business}.json`,{rolesDir,dataRoot});
|
||||
const chief=loaded.definitions[doc.roles[doc.launch.by].definition];
|
||||
const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});
|
||||
const checked=checkComposition(after,registryFor(doc),{...chief,...resolved.limits},{assertClean});
|
||||
const c=checked.composition, subset=(a,b)=>a.every(x=>b.includes(x));
|
||||
if(!subset(c.tools,before.tools) || !subset(c.skills,before.skills??[]) || NETWORKS.indexOf(c.network)>NETWORKS.indexOf(before.network))fail('invalid-request');
|
||||
// Treat the two authority domains literally. Moving an action to the other domain widens it.
|
||||
for(const domain of ['withinRole','crossRole'])if(!subset(c.authority[domain],before.authority[domain]))fail('invalid-request');
|
||||
if(before.messaging)for(const direction of ['sendTo','receiveFrom'])if(!c.messaging || !subset(c.messaging[direction],before.messaging[direction]))fail('invalid-request');
|
||||
if(!subset(credentialLines(c.credentials).map(canonicalJson),credentialLines(before.credentials).map(canonicalJson)))fail('invalid-request');
|
||||
const {instructions,personality,...role}=c;
|
||||
if(role.messaging===null)delete role.messaging;
|
||||
p.entry={...p.entry,role,instructions,...(personality===null?{}:{personality})};
|
||||
p.flagged=checked.flagged;p.credentialLines=credentialLines(c.credentials);
|
||||
if(adjust.route!==undefined) {
|
||||
keys(adjust.route,['harness','model','thinking'],['harness','model']);
|
||||
if(Object.values(adjust.route).some(x=>typeof x!=='string'||!x||x.length>200))fail('invalid-request');
|
||||
p.request.route=structuredClone(adjust.route);
|
||||
}
|
||||
const routeDoc=structuredClone(doc), definition=`hire-${decision}`;
|
||||
routeDoc.definitions={...(routeDoc.definitions??{}),[definition]:p.entry};
|
||||
routeDoc.roles[p.request.name]={definition:`org:${definition}`,vars:p.request.route,hireDecision:decision};
|
||||
checkRoutes({proposed:validateBusinessDocument(routeDoc,`${business}.json`,{rolesDir,dataRoot}),old:loaded,levels,catalogue:await catalogueFor(business,doc)});
|
||||
// Bind the adjustment itself as well as its result. A confirmation cannot be reused for
|
||||
// another edit, even when two edits happen to result in the same primitive set.
|
||||
p.adjust=structuredClone(adjust);
|
||||
return p;
|
||||
}
|
||||
async function check(business, decision, caller) {
|
||||
const p=await adjusted(business,decision,caller.adjust), view=publicOf(p);
|
||||
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(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) {
|
||||
const doc=writer.read(business).document;
|
||||
const loaded=validateBusinessDocument(doc,`${business}.json`,{rolesDir,dataRoot});
|
||||
const chief=loaded.definitions[doc.roles[doc.launch.by].definition];
|
||||
const resolved=resolveInstance({system,business:loaded,instance:doc.launch.by});
|
||||
const checked=checkComposition(compositionOf(p.entry),registryFor(doc),{...chief,...resolved.limits},{assertClean});
|
||||
if(canonicalJson(checked.flagged)!==canonicalJson(p.flagged))fail('decision-stale');
|
||||
if(p.request.role.template && readTemplate(rolesDir,p.request.role.template).sha256!==p.request.role.sha256)fail('decision-stale');
|
||||
}
|
||||
if(p.rootRevision!==null && (await readRootAgentsFile(configDir)).revision!==p.rootRevision)fail('decision-stale');
|
||||
if(p.request.level==='root' && p.request.op==='fire')await assertRootFireLocal(business);
|
||||
return p;
|
||||
}
|
||||
async function apply(business, decision, caller) {
|
||||
const p=await check(business,decision,caller);
|
||||
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;
|
||||
const approvedEntry=p.entry?{...p.entry,rev:(currentDoc.definitions?.[p.definition]?.rev??0)+1}:null;
|
||||
// org-defscan (#1873): check() ran before the awaits above; the entry the writers get is checked again.
|
||||
if(approvedEntry)scanEntry(p.definition,approvedEntry);
|
||||
const excluded=r.level==='root' && r.op==='change' ? currentDoc.agents[r.name]?.excludeOrgs : undefined;
|
||||
const entry=r.op==='fire'?null:{definition:`org:${p.definition}`,vars,hireDecision:p.id,...(excluded===undefined?{}:{excludeOrgs:excluded})};
|
||||
if(r.level==='root') {
|
||||
const publish=()=>changeRootAgent(configDir,{revision:p.rootRevision,name:r.name,entry},
|
||||
{actor:caller.subject,owner:caller.subject,rolesDir,approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeReplace:caller.beforeWrite});
|
||||
if(r.op==='fire')return withRegistryWriter(configDir,async registry=>{await assertRootFireLocal(business,registry);return publish();});
|
||||
return publish();
|
||||
}
|
||||
if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor,
|
||||
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite});
|
||||
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean});
|
||||
}
|
||||
return {prepare,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
|
||||
}
|
||||
@@ -77,7 +77,9 @@ export function checkCommands(commands, where) {
|
||||
if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`);
|
||||
}
|
||||
if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`);
|
||||
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`);
|
||||
// Name the command by its basename, which refusedCommand matched against a closed list or
|
||||
// pattern, never by an absolute host path (PR #1916).
|
||||
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0].split("/").pop()}, a shell, runner or interpreter, which would grant any command`);
|
||||
const key = JSON.stringify(prefix);
|
||||
if (seen.has(key)) refuse(`${at} repeats a prefix`);
|
||||
seen.add(key);
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
|
||||
export { OrganizationConfigError, BusinessError } from "./errors.mjs";
|
||||
export {
|
||||
ACTIONS, GATED_ONLY, HIRE_ALLOWED, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
} from "./vocabulary.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole, unhireableAction } from "./role.mjs";
|
||||
export { validateRoleDocument, loadRoleFile, loadRole } from "./role.mjs";
|
||||
export { LAYERS, REGISTRY, THINKING, FALLBACK_VARS, checkVars, mergeVars } from "./vars.mjs";
|
||||
export { parseCredentialRef, checkCredentialRef } from "./credentials.mjs";
|
||||
export {
|
||||
|
||||
@@ -22,10 +22,6 @@ const VETO_OPS = ["keep", "unkeep", "nohire", "hire"];
|
||||
const SCOPE_KEYS = ["revision", "scope"];
|
||||
const id = value => typeof value === "string" && ID_PATTERN.test(value);
|
||||
const revisionOk = value => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
|
||||
// Row 194 (D2.2, N3): the vars a hire may set, the ones the chief can already set on an instance.
|
||||
// Every limits.* key merges by intersection, so it can only narrow.
|
||||
const HIRE_VAR = key => key === "model" || key.startsWith("limits.");
|
||||
|
||||
// Row 256 (#1761, REQ-COS-3): the END_PROTECTED part of a delete, for one actor. A creative
|
||||
// instance is the human's to delete, and any other actor is refused, as row 213 refuses it a
|
||||
// change. The assistant is refused for every actor, the human included. The listing's
|
||||
@@ -347,56 +343,13 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
return { ...commit(business, old, next, { revision: args.revision, actor }), scope, old: before };
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D2.2): the chief's hire, after the broker's identity, grant, allowlist and
|
||||
// hireable checks. args { name, definition, vars? }. A new name is added and listed. A fired name
|
||||
// (declared, not listed) is listed again only with its own definition and no vars. The writer
|
||||
// reads the file as it stands, so a veto or retire written since the broker's copy still counts.
|
||||
function prepareHire(business, args) {
|
||||
const { document: old, revision } = writer.read(business);
|
||||
if (!old.launch) throw new OrganizationError("organization-launch-not-configured");
|
||||
const { name, definition } = args;
|
||||
// FED R3 r4 (sage-2557): and user, the granted user's principal, which the broker's load check refuses.
|
||||
if (!id(name) || !id(definition) || name === "human" || name === "tester" || name === "user") throw new OrganizationError("invalid-request");
|
||||
if (Object.hasOwn(args, "vars") && (!isPlainObject(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) {
|
||||
throw new OrganizationError("invalid-request", "hire vars may set only model and limits keys");
|
||||
}
|
||||
if ((old.launch.noHire ?? []).includes(definition)) throw new OrganizationError("definition-not-hireable");
|
||||
// Row 213 (M10): definitionFor lets a creative definition through for the human's add and
|
||||
// change. A hire is the chief's, so it refuses one here.
|
||||
if (CREATIVE_DEFINITIONS.includes(definition)) {
|
||||
throw new OrganizationError("definition-not-hireable", `${definition} is a creative definition, which only the human adds`);
|
||||
}
|
||||
if ((old.retired ?? []).includes(name)) throw new OrganizationError("organization-instance-retired");
|
||||
if ((old.launch.keep ?? []).includes(name)) throw new OrganizationError("instance-kept");
|
||||
const next = structuredClone(old);
|
||||
if (Object.hasOwn(old.roles, name)) {
|
||||
const fired = !old.launch.instances.includes(name) && old.launch.by !== name;
|
||||
if (!fired || old.roles[name].definition !== definition || Object.hasOwn(args, "vars")) {
|
||||
throw new OrganizationError("organization-instance-exists");
|
||||
}
|
||||
} else {
|
||||
next.roles[name] = { definition, ...(Object.hasOwn(args, "vars") ? { vars: structuredClone(args.vars) } : {}) };
|
||||
}
|
||||
// mos-1585: duplicates refuse before definition eligibility, with no file or credential effect.
|
||||
definitionFor(rolesDir, definition);
|
||||
next.launch.instances.push(name);
|
||||
return { old, next, revision };
|
||||
}
|
||||
// Trusted read-only lifecycle preparation; the writer uses the same checks again.
|
||||
function checkHire(business, args) {
|
||||
return { revision: prepareHire(business, args).revision };
|
||||
}
|
||||
function hire(business, args, { actor, context }) {
|
||||
const { old, next, revision } = prepareHire(business, args);
|
||||
return commit(business, old, next, { revision, actor, context });
|
||||
}
|
||||
|
||||
// Row 194 (D2.2 step B): the fire's delist. An instance already delisted is done, so a repeated
|
||||
// fire after a failed step C goes on to consume (webby-0011 B2). The checks repeat against the file.
|
||||
function fire(business, args, { actor, context }) {
|
||||
const { document: old, revision } = writer.read(business);
|
||||
const { name } = args;
|
||||
fireChecks(old, name);
|
||||
if(args.revision!==undefined && args.revision!==revision)throw new OrganizationError("revision-conflict");
|
||||
// Item 8: fire revokes each service of the entry and keeps the store directory (sage-1593).
|
||||
const services = Object.keys(old.roles[name].credentials ?? {});
|
||||
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
|
||||
@@ -473,5 +426,5 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
||||
}
|
||||
|
||||
return { writer, read, set, checkDelete, checkFire, checkHire, veto, hire, fire, grantScope, grantScopeRead };
|
||||
return { writer, read, set, checkDelete, checkFire, veto, fire, grantScope, grantScopeRead };
|
||||
}
|
||||
@@ -24,18 +24,20 @@ export function defaultRolesDir(env = process.env) {
|
||||
export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) {
|
||||
// lstat first: only nothing at the path is an absent directory. Anything else
|
||||
// there (a file, a dangling symlink, a directory) must list, or it throws.
|
||||
// The refusal names the directory by label and the error by its code only:
|
||||
// err.message quotes the host path (PR #1916).
|
||||
const businesses = join(dir, "businesses");
|
||||
try {
|
||||
lstatSync(businesses);
|
||||
} catch (err) {
|
||||
if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() };
|
||||
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
|
||||
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
|
||||
}
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(businesses).filter((name) => name.endsWith(".json"));
|
||||
} catch (err) {
|
||||
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
|
||||
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
|
||||
}
|
||||
const librarySeats = new Set();
|
||||
const creativeSeats = new Set();
|
||||
|
||||
@@ -67,6 +67,14 @@ export const VERB_RIGHTS = record({
|
||||
'decision.preview.console': 'decide',
|
||||
'decision.resolve.console': 'decide',
|
||||
'hire.resolve.console': 'org.hire',
|
||||
'hire.preview.console': 'org.hire',
|
||||
'hire.start.console': 'org.hire',
|
||||
'hire.templates.console': 'org.hire',
|
||||
// #1873 T1: the human verbs on an organization's own definitions.
|
||||
'definition.import.console': 'org.hire',
|
||||
'definition.edit.console': 'org.hire',
|
||||
'definition.invent.console': 'org.hire',
|
||||
'definition.remove.console': 'org.hire',
|
||||
'request.answer.console': 'request.write',
|
||||
'request.attach.console': 'request.write',
|
||||
'request.cancel.console': 'request.write',
|
||||
|
||||
@@ -19,15 +19,22 @@ const encode = doc => JSON.stringify(doc, null, 2) + '\n';
|
||||
const projectPath = (root, project) => join(root, 'projects', project);
|
||||
function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-z][a-z0-9]{1,15}$/.test(ctx.instance) || ctx.instance === 'dev' || !same(ctx.principal, owner)) fail('org-forbidden', 'authenticated local development owner required'); }
|
||||
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
|
||||
async function privateRead(file) { const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } finally { await fd.close(); } }
|
||||
// A filesystem error names the operation, the file by label and the error code, never the
|
||||
// host path Node puts in its message (PR #1916). code, syscall and the other fields stay.
|
||||
const quieted = new WeakSet();
|
||||
function quiet(e, what) {
|
||||
if (e && typeof e === 'object' && typeof e.syscall === 'string' && typeof e.code === 'string' && !quieted.has(e)) { e.message = `cannot ${e.syscall} ${what} (${e.code})`; quieted.add(e); }
|
||||
return e;
|
||||
}
|
||||
async function privateRead(file, what = 'organization file') { let fd; try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (e) { throw quiet(e, what); } try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } catch (e) { throw quiet(e, what); } finally { await fd.close(); } }
|
||||
async function atomic(file, content, exclusive = false) {
|
||||
const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } finally { await fs.rm(tmp, { force: true }); }
|
||||
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } catch (e) { throw quiet(e, 'organization file'); } finally { await fs.rm(tmp, { force: true }); }
|
||||
}
|
||||
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; } const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
|
||||
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw quiet(e, 'organization directory'); } const st = await fs.lstat(path).catch(e => { throw quiet(e, 'organization directory'); }); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
|
||||
async function receipt(root, op, org, principal, fields = {}) {
|
||||
const result = { op, org, at: new Date().toISOString(), subject: { ...principal }, ...fields };
|
||||
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600).catch(e => { throw quiet(e, 'organization receipts'); });
|
||||
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe receipts', file); await fd.writeFile(JSON.stringify(result) + '\n'); } finally { await fd.close(); } return result;
|
||||
}
|
||||
async function receipts(root) {
|
||||
@@ -129,11 +136,11 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
|
||||
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
|
||||
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
|
||||
const ownerFile = join(found.root, 'owner.json');
|
||||
const principal = parseJsonFileText(await privateRead(ownerFile), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
const principal = parseJsonFileText(await privateRead(ownerFile, 'organization owner file'), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
|
||||
// From here the caller is the verified owner. A failure carries that proof so a list may
|
||||
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
|
||||
try {
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file);
|
||||
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file, 'organization file');
|
||||
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
|
||||
const doc = parseJsonFileText(raw, file, 'organization file'); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
|
||||
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
|
||||
@@ -171,13 +178,13 @@ export async function showOrg(ctx, id, opts = {}) { authorize(ctx); const { root
|
||||
export async function notificationShow(ctx, id) {
|
||||
authorize(ctx); const current = await readCurrent(ctx, id);
|
||||
for (const path of [join(current.root, 'notify'), join(current.root, 'notify', id)]) {
|
||||
const st = await fs.lstat(path);
|
||||
const st = await fs.lstat(path).catch(e => { throw quiet(e, 'notifier directory'); });
|
||||
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe notifier directory');
|
||||
}
|
||||
const file = join(current.root, 'notify', id, 'notify.json');
|
||||
const st = await fs.lstat(file);
|
||||
const st = await fs.lstat(file).catch(e => { throw quiet(e, 'notifier configuration file'); });
|
||||
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
|
||||
const binding = parseJsonFileText(await privateRead(file), file, 'notifier configuration file');
|
||||
const binding = parseJsonFileText(await privateRead(file, 'notifier configuration file'), file, 'notifier configuration file');
|
||||
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
|
||||
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
|
||||
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
|
||||
@@ -198,7 +205,7 @@ function options({ mode, remote, acceptLocalLossRisk }) {
|
||||
}
|
||||
async function git(root, args) {
|
||||
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
|
||||
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git failed: ${err.trim()}`))); });
|
||||
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git ${args[0]} failed (exit ${code})`))); });
|
||||
}
|
||||
async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); }
|
||||
async function setup(ctx, id, project, opts) {
|
||||
@@ -273,6 +280,8 @@ export async function restoreOrg(ctx, id, opts = {}) { return lifecycleChange(ct
|
||||
|
||||
// Deletion keeps a durable marker in the registry and a receipt beside the
|
||||
// moved data. It never opens the contents of organization secret trees.
|
||||
// The deletion folder deleteOrg generates: `<id>-<ISO time with : and . as ->-<8 hex>`.
|
||||
const deletionFolderPattern = (id) => new RegExp(`^${id.replace(/[.]/g, '\\.')}-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z-[0-9a-f]{8}$`);
|
||||
async function deleteInspect(path, { secret = false } = {}) {
|
||||
let count = 0;
|
||||
async function visit(p) {
|
||||
@@ -328,7 +337,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
try {
|
||||
await deleteSafeRoot(ctx.dataRoot);
|
||||
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile), receiptFile, 'deletion receipt file'); }
|
||||
try { result = parseJsonFileText(await privateRead(receiptFile, 'deletion receipt file'), receiptFile, 'deletion receipt file'); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
|
||||
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
|
||||
if (!result.bus) {
|
||||
@@ -380,9 +389,12 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
|
||||
});
|
||||
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
|
||||
} catch (e) {
|
||||
e.deleteStep = phase; if (result) e.deleteReceipt = result;
|
||||
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
|
||||
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
|
||||
e.deleteStep = phase; if (result) e.deleteReceipt = result; quiet(e, `a file in the deletion ${phase} step`);
|
||||
// Name the receipt under the data root, never by host path (PR #1916). The folder comes
|
||||
// from the registry, so it is named only when it has the shape this function generates.
|
||||
const at = deletionFolderPattern(id).test(deletion.folder) ? `deleted/${deletion.folder}/receipt.json under the data root` : 'the deletion receipt under the data root';
|
||||
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${at}`; throw e; }
|
||||
e.message += `; deletion remains in progress. Inspect ${at} and rerun mosaic org delete ${id} --confirm ${id}`;
|
||||
throw e;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
|
||||
import { checkVars } from "./vars.mjs";
|
||||
import { parseCredentialRef } from "./credentials.mjs";
|
||||
import { loadRole } from "./role.mjs";
|
||||
import { checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
|
||||
import { END_PROTECTED } from "./vocabulary.mjs";
|
||||
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
|
||||
import { hasModule } from "../../plugins/src/loader.mjs";
|
||||
@@ -26,7 +27,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"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -122,7 +123,7 @@ function checkPersonaVars(vars, definition, at) {
|
||||
// so the business file can't name them. The key stays known so the refusal says where they went.
|
||||
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
|
||||
|
||||
function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions) {
|
||||
const where = `${file} roles`;
|
||||
requireObject(roles, where);
|
||||
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
|
||||
@@ -134,12 +135,21 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
requireId(instance, `${where} instance name`);
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
|
||||
requireId(entry.definition, `${at}.definition`);
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts", "hireDecision"], at);
|
||||
// #1873 T1: "org:<name>" names one of this file's definitions; anything else is a template.
|
||||
const orgName = orgDefinitionName(entry.definition);
|
||||
if (orgName === null) requireId(entry.definition, `${at}.definition`);
|
||||
else if (!Object.hasOwn(orgDefinitions, ORG_PREFIX + checkDefinitionName(orgName, `${at}.definition after org:`))) {
|
||||
refuse(`definition-missing: ${at}.definition names ${ORG_PREFIX}${orgName}, which definitions doesn't declare`);
|
||||
}
|
||||
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition]
|
||||
: orgName === null ? loadRole(rolesDir, entry.definition) : orgDefinitions[entry.definition];
|
||||
definitions[entry.definition] = definition;
|
||||
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
|
||||
const services = definition.credentials.map((c) => c.service);
|
||||
const hired = typeof entry.hireDecision === 'string' && /^[a-f0-9-]{36}$/.test(entry.hireDecision) && orgName !== null;
|
||||
if (entry.hireDecision !== undefined && !hired) refuse(`${at}.hireDecision must name an approved composition decision`);
|
||||
const missing = hired ? services.filter(s => s !== HOST_SERVICE && !Object.hasOwn(entry.credentials ?? {},s)) : [];
|
||||
if (entry.hosts !== undefined) refuse(`${at}.hosts ${HOSTS_MOVED}`);
|
||||
// A host.<id> key reference comes from the agent's grant record at bus start (r6 X4), never from here.
|
||||
// Row 285 amendment 1 (mos-1580): the chief needs its read reference only for a library.
|
||||
@@ -150,12 +160,12 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
}
|
||||
let tracker = null;
|
||||
if (services.includes("vikunja")) {
|
||||
if (entry.tracker === undefined) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
|
||||
tracker = checkBot(entry.tracker, `${at}.tracker`);
|
||||
if (bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
|
||||
if (botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
|
||||
bots.add(tracker.bot);
|
||||
botIds.add(tracker.botId);
|
||||
if (entry.tracker === undefined && !missing.includes("vikunja")) refuse(`${at} needs "tracker" with its Vikunja bot, because ${entry.definition} uses vikunja`);
|
||||
tracker = missing.includes("vikunja") ? null : checkBot(entry.tracker, `${at}.tracker`);
|
||||
if (tracker && bots.has(tracker.bot)) refuse(`${at}.tracker.bot ${tracker.bot} is used by another instance`);
|
||||
if (tracker && botIds.has(tracker.botId)) refuse(`${at}.tracker.botId ${tracker.botId} is used by another instance`);
|
||||
if(tracker) bots.add(tracker.bot);
|
||||
if(tracker) botIds.add(tracker.botId);
|
||||
} else if (entry.tracker !== undefined) {
|
||||
refuse(`${at} has "tracker" but ${entry.definition} uses no vikunja credential`);
|
||||
}
|
||||
@@ -164,9 +174,10 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary) {
|
||||
holder: entry.holder === undefined ? null : requireId(entry.holder, `${at}.holder`),
|
||||
vars: checkVars(entry.vars ?? {}, "agent", `${at}.vars`),
|
||||
tracker,
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, kinds, `${at}.credentials`,
|
||||
credentials: checkCredentialMap(entry.credentials ?? {}, hired ? kinds.filter(s=>!missing.includes(s)) : kinds, `${at}.credentials`,
|
||||
{ configDir: dirname(dirname(file)), business, agent: instance }),
|
||||
};
|
||||
if(hired) { out[instance].hireDecision=entry.hireDecision; out[instance].unbound=missing; }
|
||||
// Row 199 (#1697, design D8, D9 step 2): identity and publish are optional, so today's files
|
||||
// load unchanged. publish needs both an identity and a gitea credential; the project ids it
|
||||
// names are checked against projects once those are read.
|
||||
@@ -239,7 +250,9 @@ function checkRetired(retired, roles, file) {
|
||||
|
||||
// Validate a parsed business document read from `file`. `rolesDir` is where
|
||||
// role definitions live. Returns a frozen business with its definitions.
|
||||
export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null }) {
|
||||
// options.assertClean is the credential store's secret check for the definitions' text, when the
|
||||
// caller holds one (#1873 T1); without it the check is the empty store's.
|
||||
export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null, assertClean } = {}) {
|
||||
requireObject(document, "business file");
|
||||
rejectUnknownKeys(document, TOP_KEYS, "business file");
|
||||
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
|
||||
@@ -253,7 +266,8 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
|
||||
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
|
||||
const hasLibrary = Object.values(document.projects ?? {}).some((project) => project?.kind === "library");
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary);
|
||||
const orgDefinitions = checkDefinitions(document.definitions, file, assertClean ? { assertClean } : {});
|
||||
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary, orgDefinitions);
|
||||
|
||||
requireObject(document.arbiters, `${file} arbiters`);
|
||||
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
|
||||
@@ -290,7 +304,7 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
rejectUnknownKeys(entry, ["root", "repo", "remote", "git", "agents"], `${file} projects.${id}`);
|
||||
// #1873 A1: the project's own agent entries. Checked so a bad entry refuses the whole file, but
|
||||
// not copied below: nothing a bus loads sees them; only the Console's agent-levels read does.
|
||||
if (Object.hasOwn(entry, "agents")) checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents` });
|
||||
if (Object.hasOwn(entry, "agents")) checkProjectAgents(entry.agents, { rolesDir, where: `projects.${id}.agents`, definitions:document.definitions });
|
||||
if (typeof entry.root !== "string" || !isAbsolute(entry.root) || normalize(entry.root) !== entry.root || entry.root.includes("\0")) {
|
||||
refuse(`${file} projects.${id}.root must be a normalized absolute path`);
|
||||
}
|
||||
@@ -358,7 +372,7 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
export function loadOrganization(id, { dir = configDir(), rolesDir, dataRoot = null }) {
|
||||
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
|
||||
const file = organizationFilePath(id, dir);
|
||||
const document = readJsonFile(file, "business file", (stat) => {
|
||||
const document = readJsonFile(file, `business file ${id}.json`, (stat) => {
|
||||
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
|
||||
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -352,8 +353,62 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
const projectsProtected = (old, document) => {
|
||||
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
|
||||
};
|
||||
// #1873 T1: definitions change only through setDefinition, which bumps rev and checks the entry.
|
||||
const definitionsProtected = (old, document) => {
|
||||
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
|
||||
};
|
||||
function write(id, document, options) {
|
||||
return commit(id, document, { ...options, guard: projectsProtected });
|
||||
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
||||
}
|
||||
|
||||
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
|
||||
// rev, or null to remove it; current is the entry or null. Nothing else in the document may change.
|
||||
// An agent or the tester never writes one. A write bumps rev (1 for a new name); `rev`, when given,
|
||||
// must be the current one (definition-rev-conflict). Editing or removing a definition an instance
|
||||
// names refuses definition-in-use: changing a hired agent's role is a change decision (A4).
|
||||
// assertClean is the credential store's held-secret check (the bus's credentials.assertClean). A
|
||||
// write that adds or changes an entry needs it: with no check to run, the write refuses (R1).
|
||||
function setDefinition(id, name, next, { revision, rev, actor, detail, assertClean }) {
|
||||
checkDefinitionName(name, "definition name");
|
||||
const logged = { ...detail, definition: name, rev: null };
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
if (actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor)) fail("human-required");
|
||||
const { definitions: a = {}, ...restOld } = old;
|
||||
const { definitions: b = {}, ...restNew } = document ?? {};
|
||||
if (canonicalJson(restNew) !== canonicalJson(restOld)) fail("organization-definitions-only");
|
||||
const others = (d) => canonicalJson(Object.fromEntries(Object.entries(d).filter(([k]) => k !== name)));
|
||||
if (others(a) !== others(b)) fail("organization-definitions-only");
|
||||
const current = Object.hasOwn(a, name) ? a[name] : null;
|
||||
const sansRev = (e) => canonicalJson({ ...e, rev: 0 });
|
||||
if (current && Object.hasOwn(b, name) && sansRev(current) === sansRev(b[name])) fail("definition-unchanged", `definition ${name} is unchanged`);
|
||||
if (current) {
|
||||
const users = instancesNaming(old, name);
|
||||
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
|
||||
}
|
||||
if (Object.hasOwn(b, name)) {
|
||||
const want = current ? current.rev + 1 : 1;
|
||||
if (b[name]?.rev !== want) fail("definition-rev-conflict", `definition ${name} must be written at rev ${want}`);
|
||||
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a definition write needs the credential store's secret check; write it through the bus");
|
||||
checkDefinition(name, b[name], { assertClean });
|
||||
}
|
||||
};
|
||||
const out = commit(id, (old) => {
|
||||
const definitions = { ...(old.definitions ?? {}) };
|
||||
const current = Object.hasOwn(definitions, name) ? definitions[name] : null;
|
||||
if (rev !== undefined && (current?.rev ?? null) !== rev) fail("definition-rev-conflict", `definition ${name} is not at rev ${rev}; read again`);
|
||||
const entry = next(current);
|
||||
if (entry === null) {
|
||||
if (!current) fail("definition-missing", `definitions declares no ${name}`);
|
||||
delete definitions[name];
|
||||
} else {
|
||||
definitions[name] = { ...entry, rev: current ? current.rev + 1 : 1 };
|
||||
logged.rev = definitions[name].rev;
|
||||
}
|
||||
const { definitions: _, ...rest } = old;
|
||||
return Object.keys(definitions).length ? { ...rest, definitions } : rest;
|
||||
}, { revision, actor, guard: (old, document) => { projectsProtected(old, document); guard(old, document); }, detail: logged });
|
||||
return { ...out, rev: logged.rev };
|
||||
}
|
||||
|
||||
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
|
||||
@@ -393,7 +448,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
|
||||
// a library, and the project's entries and union pass A1's checks against the current root level.
|
||||
// The change log names paths only; the detail names the project, the agent and the form.
|
||||
function setProjectAgent(id, { revision, project, name, entry }, actor) {
|
||||
function setProjectAgent(id, { revision, project, name, entry }, actor, approval = null) {
|
||||
requireId(id, "organization id");
|
||||
requireId(project, "project id");
|
||||
requireId(name, "agent name");
|
||||
@@ -401,9 +456,17 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
const root = rootForProjectCheck(dir, rolesDir);
|
||||
const guard = (old, document) => {
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
|
||||
if (actor !== old.human || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
if ((!approval && actor !== old.human) || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
|
||||
const problem = projectAgentProblem(old, document, { orgId: id, project, name, entry, rolesDir, root });
|
||||
const checked = structuredClone(document);
|
||||
if (approval?.approvedDefinition) {
|
||||
const { name: def, entry: value } = approval.approvedDefinition;
|
||||
checkDefinition(def, value, { assertClean: approval.assertClean });
|
||||
const expected = { ...(old.definitions ?? {}), [def]: value };
|
||||
if (canonicalJson(document.definitions) !== canonicalJson(expected)) fail("organization-definitions-only");
|
||||
if (old.definitions === undefined) delete checked.definitions; else checked.definitions = old.definitions;
|
||||
}
|
||||
const problem = projectAgentProblem(old, checked, { orgId: id, project, name, entry, rolesDir, root, definitions: document.definitions });
|
||||
if (problem) fail(...problem);
|
||||
};
|
||||
return commit(id, old => {
|
||||
@@ -412,8 +475,46 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
const agents = { ...(target.agents ?? {}) };
|
||||
if (entry === null) delete agents[name]; else agents[name] = entry;
|
||||
const { agents: _drop, ...rest } = target;
|
||||
return { ...old, projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
|
||||
}, { revision, actor, guard, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry) } });
|
||||
return { ...old, ...(approval?.approvedDefinition ? {definitions:{...(old.definitions??{}),[approval.approvedDefinition.name]:approval.approvedDefinition.entry}} : {}), projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
|
||||
}, { revision, actor, guard, beforeWrite:approval?.beforeWrite, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } });
|
||||
}
|
||||
|
||||
// A4 trusted decision executor: one locked publication of the role, route and definition.
|
||||
// The broker has consumed the matching digest before entering this host-only writer.
|
||||
function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null }) {
|
||||
const { request: r, definition, entry, revision } = proposal;
|
||||
if (!proposal.id || r.level !== 'org') fail('invalid-request');
|
||||
const build = old => {
|
||||
if (actor === 'tester' || Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`)) fail('human-required');
|
||||
if ((old.launch?.keep ?? []).includes(r.name)) fail('instance-kept');
|
||||
if ((old.retired ?? []).includes(r.name)) fail('organization-instance-retired');
|
||||
if (r.name === old.launch?.by || Object.values(old.arbiters).includes(r.name)) fail('organization-instance-referenced');
|
||||
const exists = Object.hasOwn(old.roles,r.name);
|
||||
if ((r.op === 'hire') === exists) fail(r.op === 'hire' ? 'organization-instance-exists' : 'organization-unknown-instance');
|
||||
const next = structuredClone(old);
|
||||
if (r.op === 'fire') {
|
||||
next.launch.instances = next.launch.instances.filter(n => n !== r.name);
|
||||
} else {
|
||||
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
|
||||
const current=old.definitions?.[definition];
|
||||
const value={...entry,rev:current?current.rev+1:1};
|
||||
checkDefinition(definition,value,{assertClean});
|
||||
next.definitions={...(old.definitions??{}),[definition]:value};
|
||||
const {harness:_h,model:_m,thinking:_t,...otherVars}=old.roles[r.name]?.vars??{};
|
||||
const vars={...otherVars,harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})};
|
||||
next.roles[r.name]={...(old.roles[r.name]??{}),definition:`org:${definition}`,vars,hireDecision:proposal.id};
|
||||
// Bindings never move between roles. A role change can retain only services it still declares.
|
||||
const services=entry.role.credentials.map(c=>c.service);
|
||||
if(next.roles[r.name].credentials)next.roles[r.name].credentials=Object.fromEntries(Object.entries(next.roles[r.name].credentials).filter(([s])=>services.includes(s)));
|
||||
if(!services.includes('vikunja'))delete next.roles[r.name].tracker;
|
||||
if(!next.launch.instances.includes(r.name))next.launch.instances.push(r.name);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
return commit(id, build, {revision,actor,beforeWrite,pathsOnly:true,guard:(old,next)=>{
|
||||
if(canonicalJson(next)!==canonicalJson(build(old)))fail('organization-definitions-only');
|
||||
projectsProtected(old,next);
|
||||
},context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}});
|
||||
}
|
||||
|
||||
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
|
||||
@@ -483,5 +584,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -10,20 +10,23 @@ export function projectFilePath(root) {
|
||||
return join(root, ".mosaic", "project.json");
|
||||
}
|
||||
|
||||
// Refusals name the project file by label: its path is the project root from
|
||||
// the business file, a config value, so no refusal quotes it (PR #1916). An
|
||||
// instance name is quoted only after requireId passed it.
|
||||
export function validateProjectDocument(document, file) {
|
||||
requireObject(document, "project file");
|
||||
rejectUnknownKeys(document, ["projectVersion", "id", "vars", "roles"], "project file");
|
||||
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1 (${file})`);
|
||||
if (document.projectVersion !== 1) refuse(`project file "projectVersion" must be 1`);
|
||||
requireId(document.id, "project id");
|
||||
const vars = checkVars(document.vars ?? {}, "project", `${file} vars`);
|
||||
const vars = checkVars(document.vars ?? {}, "project", "project file vars");
|
||||
const roles = {};
|
||||
if (document.roles !== undefined) {
|
||||
requireObject(document.roles, `${file} roles`);
|
||||
requireObject(document.roles, "project file roles");
|
||||
for (const [instance, entry] of Object.entries(document.roles)) {
|
||||
requireId(instance, `${file} role instance`);
|
||||
requireObject(entry, `${file} roles.${instance}`);
|
||||
rejectUnknownKeys(entry, ["vars"], `${file} roles.${instance}`);
|
||||
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `${file} roles.${instance}.vars`) };
|
||||
requireId(instance, "project file role instance");
|
||||
requireObject(entry, `project file roles.${instance}`);
|
||||
rejectUnknownKeys(entry, ["vars"], `project file roles.${instance}`);
|
||||
roles[instance] = { vars: checkVars(entry.vars ?? {}, "project", `project file roles.${instance}.vars`) };
|
||||
}
|
||||
}
|
||||
return deepFreeze({ projectVersion: 1, id: document.id, file, vars, roles });
|
||||
|
||||
@@ -116,6 +116,9 @@ export function resolveInstance({ system, business, project = null, instance, en
|
||||
webSource: business.plugins?.["search-source"] ?? null,
|
||||
// The role file's skill names. The bundle resolves each against <dataRoot>/skills-enabled.
|
||||
skills: [...definition.skills],
|
||||
// #1873 T1: an org definition has no contract file; its instructions are the contract. Added
|
||||
// only then, so a template instance resolves, and digests, exactly as before.
|
||||
...(definition.contractPath === null && typeof definition.instructions === "string" ? { instructions: definition.instructions } : {}),
|
||||
};
|
||||
return deepFreeze({ ...resolved, digest: sha256(canonicalJson(resolved)) });
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { basename, dirname, join } from "node:path";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import {
|
||||
ACTIONS, GATED_ONLY, TOOLS, NETWORKS, SERVICES, HOST_SERVICE, GITEA_SCOPE_CATEGORIES, VIKUNJA_GRANTABLE,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, CREATIVE_ACTIONS, CREATIVE_SERVICE, unhireableAction,
|
||||
CREATIVE_DEFINITIONS, CREATIVE_TOOLS, CREATIVE_ACTIONS, CREATIVE_SERVICE,
|
||||
} from "./vocabulary.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
@@ -20,7 +20,7 @@ import { pluginName } from "../../plugins/src/organization.mjs";
|
||||
const CONTRACT_NAME = /^[a-z0-9][a-z0-9._-]{0,60}\.md$/;
|
||||
const V1_KEYS = ["roleVersion", "name", "tools", "network"];
|
||||
const V2_REQUIRED = ["roleVersion", "name", "title", "contract", "tools", "network", "authority", "credentials"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills", "hireable"];
|
||||
const V2_KEYS = [...V2_REQUIRED, "messaging", "skills"];
|
||||
const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
function checkTools(tools) {
|
||||
@@ -78,26 +78,6 @@ function checkSkills(skills) {
|
||||
});
|
||||
}
|
||||
|
||||
// Row 194 (#1692, D2.3): unhireableAction is vocabulary.mjs's, exported here as before.
|
||||
export { unhireableAction };
|
||||
|
||||
// Row 194 (#1692, D2.3): optional. The role definitions this role may hire, by name. Each one must
|
||||
// be a version 2 file in the same directory whose every action is on HIRE_ALLOWED. The named file
|
||||
// is validated without its own "hireable" check, so a list can't recurse. A creative definition
|
||||
// (row 213, M10) is never hireable: only the human adds one.
|
||||
function checkHireable(hireable, file) {
|
||||
const dir = dirname(file);
|
||||
return requireDistinctList(hireable, "role hireable", (name) => {
|
||||
requireId(name, "role hireable entry");
|
||||
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
|
||||
const path = join(dir, `${name}.json`);
|
||||
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
|
||||
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
|
||||
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
|
||||
const outside = unhireableAction(named);
|
||||
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside} outside the hireable ceiling`);
|
||||
});
|
||||
}
|
||||
|
||||
function checkGiteaScopes(scopes) {
|
||||
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
|
||||
@@ -189,9 +169,8 @@ function checkCreative(role) {
|
||||
// Validate a parsed role document read from `file`. Returns a frozen role:
|
||||
// { roleVersion, name, title, contract, contractPath, tools, network,
|
||||
// authority: { withinRole, crossRole }, credentials: [{ service, scopes }],
|
||||
// messaging: { sendTo, receiveFrom } | null, skills: [name], hireable: [name] }.
|
||||
// options.hireable false skips the hireable list's checks of other files (D2.3) and keeps the list.
|
||||
export function validateRoleDocument(document, file, options = {}) {
|
||||
// messaging: { sendTo, receiveFrom } | null, skills: [name] }.
|
||||
export function validateRoleDocument(document, file) {
|
||||
requireObject(document, "role");
|
||||
if (document.roleVersion !== 1 && document.roleVersion !== 2) refuse('role "roleVersion" must be 1 or 2');
|
||||
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
|
||||
@@ -204,7 +183,7 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
const network = document.network === undefined ? "none" : checkNetwork(document.network);
|
||||
return deepFreeze({
|
||||
roleVersion: 1, name: document.name, title: null, contract: null, contractPath: null, tools, network,
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [], hireable: [],
|
||||
authority: { withinRole: [], crossRole: [] }, credentials: [], messaging: null, skills: [],
|
||||
});
|
||||
}
|
||||
for (const key of V2_REQUIRED) {
|
||||
@@ -222,15 +201,12 @@ export function validateRoleDocument(document, file, options = {}) {
|
||||
credentials: checkCredentials(document.credentials),
|
||||
messaging: document.messaging === undefined ? null : checkMessaging(document.messaging),
|
||||
skills: document.skills === undefined ? [] : checkSkills(document.skills),
|
||||
hireable: document.hireable === undefined ? []
|
||||
: options.hireable === false ? requireDistinctList(document.hireable, "role hireable", (name) => requireId(name, "role hireable entry"))
|
||||
: checkHireable(document.hireable, file),
|
||||
};
|
||||
return deepFreeze(CREATIVE_DEFINITIONS.includes(role.name) ? checkCreative(role) : role);
|
||||
}
|
||||
|
||||
export function loadRoleFile(file) {
|
||||
return validateRoleDocument(readJsonFile(file, "role file"), file);
|
||||
return validateRoleDocument(readJsonFile(file, /^[a-z0-9][a-z0-9._-]{0,63}\.json$/.test(basename(file)) ? `role file ${basename(file)}` : "role file"), file);
|
||||
}
|
||||
|
||||
export function loadRole(rolesDir, name) {
|
||||
|
||||
@@ -60,11 +60,14 @@ export function requireDate(value, where) {
|
||||
|
||||
// Parser diagnostics can quote file bytes. Keep the existing SyntaxError refusal,
|
||||
// but never attach the parser's message or cause to an error that callers can log.
|
||||
// The message names the file by its `what` label only: a refusal never quotes a
|
||||
// host path (PR #1916). `file` stays in the signature so callers don't change;
|
||||
// it is not used.
|
||||
export function parseJsonFileText(text, file, what = "file") {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
throw new SyntaxError(`${what} is not valid JSON (${file})`);
|
||||
throw new SyntaxError(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,19 +75,21 @@ export function parseJsonFileText(text, file, what = "file") {
|
||||
// Missing or not a regular file is 4; unparseable is 2. One descriptor,
|
||||
// opened without following a link, serves every check and the read.
|
||||
// `checkStat` sees that descriptor's stat before the read, so the file
|
||||
// can't be swapped between the check and the read.
|
||||
// can't be swapped between the check and the read. Every refusal names the
|
||||
// file by `what` and never by path; a caller whose label alone can't tell
|
||||
// two files apart adds a name that already passed a strict pattern.
|
||||
export function readJsonFile(file, what, checkStat) {
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") refuse(`${what} not found: ${file}`, 4);
|
||||
refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
if (error.code === "ENOENT") refuse(`${what} not found`, 4);
|
||||
refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
||||
}
|
||||
let text;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file: ${file}`, 4);
|
||||
if (!stat.isFile()) refuse(`${what} must be a regular, non-symbolic-link file`, 4);
|
||||
checkStat?.(stat);
|
||||
text = readFileSync(fd, "utf8");
|
||||
} finally {
|
||||
@@ -93,7 +98,7 @@ export function readJsonFile(file, what, checkStat) {
|
||||
try {
|
||||
return parseJsonFileText(text, file, what);
|
||||
} catch {
|
||||
refuse(`${what} is not valid JSON (${file})`);
|
||||
refuse(`${what} is not valid JSON`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ export const ACTIONS = Object.freeze([
|
||||
// runs and delists it). Hard waits under PRD 1.6 (mos-1017), routed under PRD 1.7 REQ-COS-3.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
"role.change",
|
||||
"credential.mint",
|
||||
"spend",
|
||||
"deploy",
|
||||
@@ -98,6 +99,7 @@ export const ROUTED = Object.freeze([
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
"role.hire",
|
||||
"role.fire",
|
||||
"role.change",
|
||||
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write, within the
|
||||
// bounds that paragraph names.
|
||||
"library.write",
|
||||
@@ -110,30 +112,6 @@ export const ROUTED = Object.freeze([
|
||||
// git.push.remote, host.sudo (row 210) and deploy; a test pins the list.
|
||||
export const GATED_ONLY = Object.freeze(ACTIONS.filter((action) => !ROUTED.includes(action)));
|
||||
|
||||
// Row 194 (#1692, D2.3): the chief of staff hires only a definition whose every withinRole and
|
||||
// crossRole action is on this list. An allowlist, so an action added to ACTIONS later is never
|
||||
// hireable until a reviewed commit adds it here. git.push.remote (row 199) is not on it.
|
||||
export const HIRE_ALLOWED = Object.freeze([
|
||||
"message.send",
|
||||
"record.write",
|
||||
"review.request",
|
||||
"task.update.assigned",
|
||||
"task.scope.change",
|
||||
"git.push.working",
|
||||
]);
|
||||
|
||||
// Row 194 (#1692, D2.3): the first action of a definition's authority that isn't on
|
||||
// HIRE_ALLOWED, or null when every one is. role.mjs's hireable check and the broker's hire check
|
||||
// use it. It lives here because the session sandbox mounts this file and not role.mjs
|
||||
// (seat/src/sandbox.mjs RUNNER_FILES), and the runner imports broker.mjs.
|
||||
export function unhireableAction(role) {
|
||||
for (const key of ["withinRole", "crossRole"]) {
|
||||
const outside = role.authority[key].find((action) => !HIRE_ALLOWED.includes(action));
|
||||
if (outside !== undefined) return outside;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// pi's documented built-in tools; the same list scripts/mosaic-task.mjs
|
||||
// accepts for tasks.
|
||||
export const BUILTIN_TOOLS = Object.freeze(["read", "write", "edit", "bash", "grep", "find", "ls"]);
|
||||
|
||||
@@ -59,7 +59,8 @@ test("row 325: the old mosaic-stack project key refuses against those same proje
|
||||
const r = x.validate();
|
||||
assert.equal(r.status, 2, r.stdout + r.stderr);
|
||||
assert.equal(r.stdout, "");
|
||||
assert.match(r.stderr, /has id that does not match the declared project key/);
|
||||
assert.ok(r.stderr.includes(x.projectFile));
|
||||
assert.match(r.stderr, /project file of project mosaic-stack has id that does not match the declared project key/);
|
||||
// The refusal names the declared key, never the project root (PR #1916).
|
||||
assert.ok(!r.stderr.includes(x.projectFile), r.stderr);
|
||||
assert.doesNotMatch(r.stderr, /no project file/);
|
||||
});
|
||||
@@ -22,8 +22,6 @@ const APPROVED = Object.freeze({
|
||||
authority: Object.freeze({ withinRole: Object.freeze(["message.send", "role.launch", "role.end", "organization.change", "record.write", "instance.name", "role.hire", "role.fire", "decision.resolve.technical", "decision.resolve.delivery"]), crossRole: Object.freeze([]) }),
|
||||
// Row 285 (#1790, mos-1309 item 5, sage-2915): gitea for the library read grant, read scopes only.
|
||||
credentials: Object.freeze([Object.freeze({ service: "gitea", scopes: Object.freeze(["read:repository", "read:user"]) })]),
|
||||
// Row 194 (#1692, Q4, sage-1374): empty until a policy commit after the PRD 1.7 resolve.
|
||||
hireable: Object.freeze([]),
|
||||
});
|
||||
|
||||
// The fixture business's chief-of-staff instance, which is also its launcher.
|
||||
@@ -144,7 +142,8 @@ test("the contract names no duty to talk to the human and the pm contract no lau
|
||||
assert.doesNotMatch(chief, /\bJason\b/);
|
||||
assert.match(chief, /never talk to the user/);
|
||||
// Row 194 (#1692, D3 item 5, N3): a hire launches with no restart once the human sets its route.
|
||||
assert.match(chief, /A hire is listed for `launch` at once, with no restart, but it\s+runs only after the human sets its route \(harness and thinking\)/);
|
||||
assert.match(chief, /Approval sets the route/);
|
||||
assert.match(chief, /credential-unbound/);
|
||||
const pm = readFileSync(join(REPO_ROLES, "pm.md"), "utf8");
|
||||
assert.doesNotMatch(pm, /^- Launch /m);
|
||||
assert.match(pm, /You don't launch sessions/);
|
||||
|
||||
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user