Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce4fe048bb | ||
|
|
63e2a574af | ||
|
|
d9023e28db | ||
|
|
c3fe6b77ad | ||
|
|
809b1b0100 | ||
|
|
4a9d9d4b89 | ||
|
|
87db40e0cd | ||
|
|
f3f33a6171 | ||
|
|
5320e3d2c0 | ||
|
|
16cdf5cdc1 | ||
|
|
1a45a7274b | ||
|
|
17eecb2d65 | ||
|
|
8fec494694 | ||
|
|
4a122699a0 | ||
|
|
f382699d47 | ||
|
|
5e93356cfa | ||
|
|
89205a6163 |
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
|
||||
|
||||
|
||||
+159
-85
@@ -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.
|
||||
@@ -226,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,
|
||||
@@ -602,6 +596,7 @@ export class Broker {
|
||||
#builtin;
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#hires;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
@@ -665,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');
|
||||
@@ -688,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;
|
||||
@@ -1561,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}`);
|
||||
@@ -1848,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'
|
||||
@@ -2283,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 });
|
||||
@@ -2440,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);
|
||||
@@ -3355,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) {
|
||||
@@ -6084,11 +6096,22 @@ 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) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
|
||||
@@ -6269,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');
|
||||
@@ -6288,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');
|
||||
@@ -6370,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 };
|
||||
}
|
||||
@@ -6379,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) {
|
||||
@@ -7314,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.
|
||||
@@ -7773,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(
|
||||
@@ -7818,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';
|
||||
@@ -36,6 +38,8 @@ import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } fr
|
||||
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';
|
||||
@@ -61,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.
|
||||
@@ -385,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,
|
||||
@@ -653,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 });
|
||||
@@ -818,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 => {
|
||||
@@ -855,8 +879,7 @@ export async function startBroker({
|
||||
// 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);
|
||||
@@ -867,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(),
|
||||
@@ -883,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.
|
||||
@@ -1131,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 ?? {})
|
||||
@@ -1254,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;
|
||||
},
|
||||
@@ -1304,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');
|
||||
});
|
||||
@@ -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 @@
|
||||
export * from "../../organization/src/hire-decisions.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/system-config-problem.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/system-config-problem.mjs";
|
||||
@@ -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 });
|
||||
|
||||
@@ -519,7 +519,8 @@ async function processingCommand(verb, rest, io, deps) {
|
||||
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
|
||||
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
||||
if (verb === 'halt' && result.quiescent !== true)
|
||||
throw new CliError(`halt gate is on for instance ${system.dataRoot}; ${result.remaining ?? 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
|
||||
// #1873: the refusal names the instance, never its data root; a count that is not a count is unknown.
|
||||
throw new CliError(`halt gate is on for this instance; ${Number.isSafeInteger(result.remaining) && result.remaining >= 0 ? result.remaining : 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
|
||||
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
|
||||
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
|
||||
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
|
||||
@@ -651,9 +652,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;
|
||||
|
||||
@@ -13,6 +13,7 @@ import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loa
|
||||
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
|
||||
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { systemConfigProblem } from "../../organization/src/system-config-problem.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { busOrganization } from "../../bus/src/organization-context.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
@@ -41,13 +42,40 @@ export function orgPaths(root) {
|
||||
threads: join(root, "threads"), hostDir };
|
||||
}
|
||||
|
||||
// #1873: mosaic-config's stderr can quote the config's path, the data root or a config value, so
|
||||
// it is never relayed. An instance refusal keeps its code and the instance names, which passed
|
||||
// the instance pattern; a missing config keeps its bootstrap hint; anything else reads as
|
||||
// `mosaic org config` reads it: a known field and its constraint, the config named by label.
|
||||
const INSTANCE = "[a-z][a-z0-9]{1,15}";
|
||||
function instanceProblem(problem, label) {
|
||||
const body = problem.replace(/^mosaic-config: /, "");
|
||||
let m = new RegExp(`^instance-root-mismatch: .+ is claimed by instance (${INSTANCE})(?:, not (${INSTANCE})|; select it with --instance \\1)$`).exec(body);
|
||||
if (m) return `instance-root-mismatch: the data root is claimed by instance ${m[1]}${m[2] ? `, not ${m[2]}` : `; select it with --instance ${m[1]}`}`;
|
||||
m = new RegExp(`^instance-root-unclaimed: .+ holds no instance\\.json naming (${INSTANCE}) \\(scripts/bootstrap\\.sh --instance \\1 claims a new root\\)$`).exec(body);
|
||||
if (m) return `instance-root-unclaimed: the data root holds no instance.json naming ${m[1]} (scripts/bootstrap.sh --instance ${m[1]} claims a new root)`;
|
||||
if (body.startsWith("instance-root-unclaimed: ")) return "instance-root-unclaimed: the data root's instance.json is not a readable, valid marker";
|
||||
m = new RegExp(`^instance-config-mismatch: --instance (${INSTANCE}) names `).exec(body);
|
||||
if (m) return `instance-config-mismatch: --instance ${m[1]} names its own config, but MOSAIC_CONFIG names another`;
|
||||
if (body.startsWith("instance-config-mismatch: ")) return "instance-config-mismatch: --instance names its own config, but MOSAIC_CONFIG names another";
|
||||
if (body.startsWith("instance-name-invalid: ")) return `instance-name-invalid: an instance name must match /^${INSTANCE}$/`;
|
||||
if (body.startsWith("configuration not found: ")) {
|
||||
m = new RegExp(`\\(run (scripts/bootstrap\\.sh(?: --instance ${INSTANCE})?) to create it\\)$`).exec(body);
|
||||
return `configuration not found: ${label}${m ? ` (run ${m[1]} to create it)` : ""}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadSystem({ env = process.env, instance = null } = {}) {
|
||||
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate", ...(instance === null ? [] : ["--instance", instance])], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
env,
|
||||
});
|
||||
if (proc.status !== 0) throw new CliError(`system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`, 3);
|
||||
if (proc.status !== 0) {
|
||||
const label = instance !== null && instance !== "dev" ? `the config of instance ${instance}` : env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config";
|
||||
const known = instanceProblem(String(proc.stderr ?? "").trim(), label);
|
||||
throw new CliError(known ? `system config problem (mosaic-config exit ${proc.status}): ${known}` : systemConfigProblem(proc, env, label), 3);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -90,7 +118,7 @@ function trackerFor(system, b, warn, dataRoot) {
|
||||
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
|
||||
if (project.id !== id) throw new CliError(`project file of project ${id} has id that does not match the declared project key`, 3);
|
||||
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
|
||||
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
|
||||
}
|
||||
@@ -127,17 +155,20 @@ function accessFor(system, env, warn) {
|
||||
try {
|
||||
context = accessContextFor(system, env);
|
||||
} catch (e) {
|
||||
warn(`access.json is present but its context can't be read, so no granted user binds: ${e.message}`);
|
||||
// #1873: the error's message can quote a path; only a plain code is relayed.
|
||||
const code = typeof e?.code === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e.code) ? ` (${e.code})` : "";
|
||||
warn(`access.json is present but its context can't be read, so no granted user binds${code}`);
|
||||
return null;
|
||||
}
|
||||
// sage-3153: one line at boot when the loader refuses the file, so the owner sees why no user
|
||||
// binds. The loader's messages can quote the file, so the line names only the path and a code
|
||||
// from its exit code: 4 is a file it could not open as a regular file, anything else invalid.
|
||||
// binds. The loader's messages can quote the file, so the line names only the file, by label, and
|
||||
// a code from its exit code: 4 is a file it could not open as a regular file, anything else
|
||||
// invalid. #1873: no host path.
|
||||
try {
|
||||
loadAccess(file, context);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusinessError && e.exitCode !== 4 ? "access-invalid" : "access-unreadable";
|
||||
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: ${file}`);
|
||||
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`);
|
||||
}
|
||||
return { file, context };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -145,6 +145,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync,
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { quietMessage } from "../../organization/src/org-registry.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
|
||||
@@ -201,13 +202,24 @@ class Refusal extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// #1873: a refusal's reason never quotes a host path. A Node system error's message names one,
|
||||
// so it reads as the call and its code (org-registry quietMessage). Any other message is the
|
||||
// host's own text or text relayed from a module (a preflight, the loader, a bundle or checkout
|
||||
// error); relay() replaces every absolute or home path and file: URL in it with "a host path".
|
||||
const HOST_PATH = /(^|[\s'"(=:,[<])(?:~?\/(?!\/)|file:)[^\s'"),\]>;]*/gi;
|
||||
export const relay = (text) => String(text).replace(HOST_PATH, (_, before) => `${before}a host path`);
|
||||
const reason = (e) => quietMessage(e, "a host file") ?? relay(e?.message ?? e);
|
||||
const relayed = (failed) => failed && (typeof failed.message === "string" ? { ...failed, message: relay(failed.message) } : failed);
|
||||
|
||||
// A harness module runs with the host's authority, and anything it throws refuses the launch
|
||||
// and names the module rather than escaping the host.
|
||||
// and names the module rather than escaping the host. What it threw is named by its system code
|
||||
// or its class, never its message, which the host didn't write (#1873).
|
||||
function call(name, what, fn) {
|
||||
try {
|
||||
return fn();
|
||||
} catch (e) {
|
||||
throw new Refusal("harness-failed", `harness ${name} ${what}: ${e.message}`);
|
||||
const thrown = quietMessage(e, "a host file") ?? `threw ${/^[A-Za-z][A-Za-z0-9]{0,39}$/.test(String(e?.name)) ? e.name : "a value"} without a system code`;
|
||||
throw new Refusal("harness-failed", `harness ${name} ${what}: ${thrown}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +284,8 @@ export function createLauncher({
|
||||
try {
|
||||
leftover = readSessions(dataRoot).filter((s) => s.business === b.id);
|
||||
} catch (e) {
|
||||
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
|
||||
// readSessions names the file by its place under the data root (#1873); anything else is unreadable.
|
||||
throw new CliError(`${e?.name === "SeatError" ? e.message : "sessions file is unreadable"}; check which sessions still run, then move the file aside`, 3);
|
||||
}
|
||||
const registry = new Registry(dataRoot, { readOnly: !launches });
|
||||
// Row 269: earlier-host runs still live after recover(). check() sees each while it lives.
|
||||
@@ -380,11 +393,13 @@ 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);
|
||||
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : relay(e.message));
|
||||
}
|
||||
// Resolve the business's current provider modules with a fresh loader; a refused
|
||||
// load never inherits a previous business's authority.
|
||||
@@ -407,17 +422,23 @@ export function createLauncher({
|
||||
return sessionModel(resolved, b.launch.models, which, { proxy: Boolean(system.proxy || proxyContext), resolver });
|
||||
} catch (e) {
|
||||
const code = e instanceof BundleError && (e.code === "no-route" || e.code.startsWith("proxy-") || e.code.startsWith("model-")) ? e.code : "unsupported-harness";
|
||||
throw new Refusal(code, e.message);
|
||||
throw new Refusal(code, relay(e.message));
|
||||
}
|
||||
};
|
||||
const primary = routeOf("primary");
|
||||
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
|
||||
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);
|
||||
} catch (e) {
|
||||
if (e instanceof BundleError) throw new Refusal(e.code === "skill-not-enabled" ? "skill-not-enabled" : "skill-refused", e.message);
|
||||
if (e instanceof BundleError) throw new Refusal(e.code === "skill-not-enabled" ? "skill-not-enabled" : "skill-refused", relay(e.message));
|
||||
throw e;
|
||||
}
|
||||
// Both of a launch's harnesses must be switched on, whichever it takes.
|
||||
@@ -429,7 +450,9 @@ export function createLauncher({
|
||||
const harnessOf = async (m) => {
|
||||
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) });
|
||||
const { refused } = await loader.load({ harness: [m.harness] });
|
||||
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
|
||||
// #1873: a loader message can quote the module's path or its load error; only a
|
||||
// missing-export one, which names the export, is relayed. Any other refusal names its code.
|
||||
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => r.code === "missing-export" ? `${r.code}: ${relay(r.message)}` : String(r.code)).join("; ")}`);
|
||||
const module = loader.modules("harness")[0];
|
||||
const levels = call(m.harness, "levels", () => module.levels());
|
||||
if (!levels.includes(m.thinking)) throw new Refusal("thinking-unsupported", `harness ${m.harness} takes thinking ${levels.join(", ")}, not ${m.thinking}`);
|
||||
@@ -476,7 +499,7 @@ export function createLauncher({
|
||||
}
|
||||
}
|
||||
const unavailable = sandboxProblem({ bwrap });
|
||||
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
|
||||
if (unavailable) throw new Refusal("sandbox-unavailable", relay(unavailable));
|
||||
const hit=limitHit(registry.sessions,b.id,b.launch.limits,model);
|
||||
if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;}
|
||||
// Row 125 (#1619, REQ-LIMIT-4): the limit checks. An answer that isn't an allow refuses,
|
||||
@@ -524,7 +547,8 @@ export function createLauncher({
|
||||
// and the helper returns the last one they saw.
|
||||
const pickRoute = async (proxyOf) => {
|
||||
let sessionProxy = await proxyOf(primary);
|
||||
const failed = call(primary.harness, "preflight", () => harness.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
|
||||
// #1873: a preflight's message is the module's own text, relayed with host paths replaced.
|
||||
const failed = relayed(call(primary.harness, "preflight", () => harness.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy })));
|
||||
if (failed) {
|
||||
if (!fallback) throw new Refusal(failed.code, failed.message);
|
||||
// The fallback only on a failed primary preflight, and only at launch (row 65). Any refusal of
|
||||
@@ -534,7 +558,7 @@ export function createLauncher({
|
||||
throw e;
|
||||
});
|
||||
sessionProxy = await proxyOf(fallback);
|
||||
const also = call(fallback.harness, "preflight", () => second.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
|
||||
const also = relayed(call(fallback.harness, "preflight", () => second.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy })));
|
||||
if (also) throw new Refusal(also.code, `fallback ${fallback.harness}: ${also.message} (primary ${primary.harness}: ${failed.message})`);
|
||||
[model, harness, route, routeReason] = [fallback, second, "fallback", failed.code];
|
||||
}
|
||||
@@ -656,7 +680,7 @@ export function createLauncher({
|
||||
return ensureCheckouts({ workspace, projects: codeProjects, env });
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
|
||||
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${relay(e.message)}`);
|
||||
throw e;
|
||||
}
|
||||
for (const c of checkouts) {
|
||||
@@ -853,7 +877,7 @@ export function createLauncher({
|
||||
unregisterRuntime(dataRoot, managed.id);
|
||||
if (e instanceof Refusal) throw e;
|
||||
if (e.code?.startsWith('proxy-')) throw new Refusal(e.code, e.code);
|
||||
throw new Refusal("launch-failed", e.message);
|
||||
throw new Refusal("launch-failed", reason(e));
|
||||
}
|
||||
children.set(run, exited);
|
||||
const outputWatch = admittedProxy ? watchModelOutput({ runDir, armAtFirstTurn: true, keyId: modelKeyId, seconds: system.proxy.firstOutputSeconds ?? 120,
|
||||
@@ -1033,7 +1057,7 @@ export function createLauncher({
|
||||
if (refusedThreads.get(key) === code) return;
|
||||
refusedThreads.set(key, code);
|
||||
log(`thread ${t.thread} session ${t.session} (${t.instance}): start refused: ${code}`);
|
||||
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1135,7 +1159,7 @@ export function createLauncher({
|
||||
if (clearLogged.get(c.op) === code) return;
|
||||
clearLogged.set(c.op, code);
|
||||
log(`clear ${c.op}: successor ${c.successor} (${c.instance}): start ${CLEAR_HELD.has(code) ? "held" : "refused"}: ${code}`);
|
||||
record({ event: "refused", by: b.human, instance: c.instance, thread: c.thread, session: c.successor, code, why: e.message, ...(e.limit ? { limit: e.limit } : {}) });
|
||||
record({ event: "refused", by: b.human, instance: c.instance, thread: c.thread, session: c.successor, code, why: reason(e), ...(e.limit ? { limit: e.limit } : {}) });
|
||||
await host.op({ op: "sessionClearStart", record: { ...where, start: CLEAR_HELD.has(code) ? "held" : "refused", refusal: code } });
|
||||
return;
|
||||
}
|
||||
@@ -1195,7 +1219,7 @@ export function createLauncher({
|
||||
if (refusedLand.get(h.decision) === code) return;
|
||||
refusedLand.set(h.decision, code);
|
||||
log(`decision ${h.decision} (${h.instance}): landing start refused: ${code}`);
|
||||
record({ event: "refused", by: b.human, instance: h.instance, decision: h.decision, code, why: e.message, ...(e.limit ? { limit: e.limit } : {}) });
|
||||
record({ event: "refused", by: b.human, instance: h.instance, decision: h.decision, code, why: reason(e), ...(e.limit ? { limit: e.limit } : {}) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1240,7 +1264,7 @@ export function createLauncher({
|
||||
try {
|
||||
done = await act(take);
|
||||
} catch (e) {
|
||||
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: e.message, limit: e.limit };
|
||||
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: reason(e), limit: e.limit };
|
||||
}
|
||||
const { why, limit, ...closing } = done;
|
||||
if (closing.refusal) record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: closing.refusal, ...(why ? { why } : {}), ...(limit ? { limit } : {}) });
|
||||
@@ -1348,18 +1372,19 @@ export function createLauncher({
|
||||
const bot = out.bot ? { bot: out.bot } : {};
|
||||
// The old instance workspace, then (row 269) the instance's session workspaces, each moved
|
||||
// whole. A retry moves whichever is still in place.
|
||||
for (const from of [workspaceDir(dataRoot, b.id, r.instance), join(dataRoot, "session-workspaces", b.id, r.instance)]) {
|
||||
for (const [from, label] of [[workspaceDir(dataRoot, b.id, r.instance), "the instance workspace"], [join(dataRoot, "session-workspaces", b.id, r.instance), "the session workspaces"]]) {
|
||||
if (!existsSync(from)) continue;
|
||||
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
|
||||
try {
|
||||
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(to), 0o700);
|
||||
if (existsSync(to)) throw new Error(`${to} exists`);
|
||||
if (existsSync(to)) throw new Error("its .retired name already exists");
|
||||
renameSync(from, to);
|
||||
chmodSync(to, 0o700);
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
|
||||
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
|
||||
// #1873: the reason names the workspace by label and the error by code, never a path.
|
||||
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${quietMessage(e, label) ?? `${label}: ${relay(e.message)}`}; left in place` };
|
||||
}
|
||||
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
|
||||
}
|
||||
@@ -1430,7 +1455,7 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
if (!(e instanceof Refusal)) throw e;
|
||||
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
record({ event: "refused", by: who.role, instance, code: e.code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
return reply({ ok: false, error: e.code });
|
||||
}
|
||||
try {
|
||||
@@ -1445,7 +1470,7 @@ export function createLauncher({
|
||||
const code = e instanceof Refusal ? e.code : "launch-failed";
|
||||
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
|
||||
await host.op({ op: "refuse", cap, code }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
record({ event: "refused", by: who.role, instance, code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
return reply({ ok: false, error: code });
|
||||
}
|
||||
});
|
||||
@@ -1459,8 +1484,8 @@ export function createLauncher({
|
||||
return await start({ instance: b.launch.by, launchedBy: b.human, ...(await check(b.launch.by, { pm: true })) });
|
||||
} catch (e) {
|
||||
if (!(e instanceof Refusal)) throw e;
|
||||
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
const error = new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
|
||||
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
const error = new CliError(`PM launch refused: ${e.code}${reason(e) !== e.code ? ` (${reason(e)})` : ""}`, 3);
|
||||
error.code = e.code;
|
||||
throw error;
|
||||
}
|
||||
@@ -1511,7 +1536,7 @@ export function createLauncher({
|
||||
await recover();
|
||||
const path = launchSocketPath(dataRoot);
|
||||
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("launch socket path exists and isn't a socket", 3);
|
||||
rmSync(path);
|
||||
}
|
||||
server = createServer((socket) => {
|
||||
|
||||
@@ -91,7 +91,9 @@ test("R6 B3 a boot with a file R1's loader refuses still boots, binds no user an
|
||||
const s = await boot(t, file);
|
||||
assert.equal(s.config.access.file, s.path, why);
|
||||
assert.equal(s.bind("alice"), "access-revoked", why);
|
||||
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: ${s.path}`], why);
|
||||
// #1873: the warning names access.json by its place, never by host path.
|
||||
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`], why);
|
||||
assert.ok(!s.warnings.join("\n").includes(s.path), why);
|
||||
assert.doesNotMatch(s.warnings.join("\n"), /secret-marker/, why);
|
||||
}
|
||||
});
|
||||
@@ -55,8 +55,8 @@ for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills desc
|
||||
assert.equal(result.stdout,'');
|
||||
assert.equal(result.stderr,'');
|
||||
const pairs=result.pairs;
|
||||
assert.equal(pairs.length,2,'the session leader and its live descendant both started');
|
||||
assert.equal(pairs[0][0],pairs[0][1],'the command leads its kernel session');
|
||||
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');
|
||||
|
||||
@@ -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{}
|
||||
@@ -938,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));
|
||||
|
||||
@@ -215,7 +215,7 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
|
||||
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.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`);
|
||||
@@ -256,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);
|
||||
});
|
||||
@@ -22,7 +22,7 @@ function fixture(t) {
|
||||
return { root, file, env, run, boot };
|
||||
}
|
||||
// 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.
|
||||
// scripts/mosaic-config.mjs names it by label too (#1873 redact6).
|
||||
function redacted(result, file, status, label) {
|
||||
assert.equal(result.status, status, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
@@ -37,10 +37,12 @@ function redacted(result, file, status, label) {
|
||||
test('system config parse refusal and CLI relay omit parser bytes', t => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2, 'configuration is not valid JSON (the config of instance redacttest)');
|
||||
assert.throws(() => loadSystem({ env: f.env, instance: 'redacttest' }), error => {
|
||||
assert.equal(error.exitCode, 3);
|
||||
assert.ok(error.message.includes(f.file));
|
||||
// #1873: the cli relay names the instance's config by label, never its path.
|
||||
assert.ok(!error.message.includes(f.file), error.message);
|
||||
assert.ok(error.message.includes('configuration is not valid JSON; expected readable JSON: the config of instance redacttest'), error.message);
|
||||
assert.match(error.message, /not valid JSON/);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
return true;
|
||||
@@ -52,7 +54,7 @@ test('named instance marker parse refusal omits parser bytes', t => {
|
||||
const f = fixture(t); f.boot();
|
||||
const { dataRoot } = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const marker = join(dataRoot, 'instance.json'); writeFileSync(marker, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3, "instance-root-unclaimed: the data root's instance marker is not a valid marker (not valid JSON)");
|
||||
});
|
||||
|
||||
for (const operation of ['validate', 'create', 'update']) test(`org ${operation} document refusal omits parser bytes`, t => {
|
||||
|
||||
@@ -89,7 +89,12 @@ test('CLI boot project id mismatch omits the configured value and keeps exit 3',
|
||||
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() }));
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), safe(file, CliError, { exitCode: 3 }, secret.toLowerCase()));
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), error => {
|
||||
// #1873: the refusal names the project by its key, never the project file's path.
|
||||
safe('project file of project stack has id that does not match the declared project key', CliError, { exitCode: 3 }, secret.toLowerCase())(error);
|
||||
assert.ok(!error.message.includes(file) && !error.message.includes(f.doc.projects.stack.root), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool rejects an unknown value before naming it`, t => {
|
||||
|
||||
@@ -120,7 +120,7 @@ test('row 65 the loader refusing a module refuses harness-refused and names it;
|
||||
}
|
||||
const s=await setup(t,route('throws','claude-sonnet-5-5','high'));
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'harness-failed'});
|
||||
assert.equal(s.last().why,'harness throws preflight: fixture preflight threw');
|
||||
assert.equal(s.last().why,'harness throws preflight: threw Error without a system code');
|
||||
});
|
||||
|
||||
test('row 65 a thinking level the route harness does not apply refuses thinking-unsupported, on the primary or a fallback taken',async t=>{
|
||||
|
||||
@@ -768,9 +768,10 @@ test("a missing or failing bwrap refuses sandbox-unavailable before anything sta
|
||||
const bad = join(given.root, "bwrap");
|
||||
writeFileSync(bad, "#!/bin/sh\necho 'bwrap: setting up uid map: Permission denied' >&2\nexit 1\n", { mode: 0o755 });
|
||||
const { f, launcher, cto } = await setup(t, undefined, given, { bwrap: bad });
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} failed: bwrap: setting up uid map: Permission denied)`);
|
||||
// #1873: the sandbox by name, never the binary's host path.
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === "PM launch refused: sandbox-unavailable (the sandbox (bwrap) failed: bwrap: setting up uid map: Permission denied)");
|
||||
rmSync(bad);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} is missing)`);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === "PM launch refused: sandbox-unavailable (the sandbox (bwrap) is missing)");
|
||||
// A session's launch request gets the same code, through refuse().
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "sandbox-unavailable" });
|
||||
assert.deepEqual(log(f).map((e) => e.code), ["sandbox-unavailable", "sandbox-unavailable", "sandbox-unavailable"]);
|
||||
@@ -791,7 +792,7 @@ test("row 64 a role skill that isn't enabled refuses skill-not-enabled, and one
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-not-enabled" });
|
||||
// A symlink in skills-enabled is refused, not followed.
|
||||
symlinkSync(join(REPO, "skills", "ms-unslop"), join(enabled, "ms-unslop"));
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === `PM launch refused: skill-refused (skill ms-unslop isn't a plain directory under ${enabled})`);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === "PM launch refused: skill-refused (skill ms-unslop isn't a plain directory under the enabled skills directory)");
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-refused" });
|
||||
assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "skill-not-enabled"], ["refused", "skill-not-enabled"], ["refused", "skill-refused"], ["refused", "skill-refused"]]);
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
@@ -832,7 +833,9 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const e = entries[1];
|
||||
assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
|
||||
assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
// #1873: the file by its place under the persona directory, never its host path.
|
||||
assert.equal(e.why, "instance bean: persona file acme/bean.md in the persona directory isn't the file persona.sha256 names");
|
||||
assert.ok(!e.why.includes(dir));
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
|
||||
@@ -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 });
|
||||
@@ -400,6 +404,10 @@ test("row 194 V5: delete retires the name, renames the workspace to .retired/I-<
|
||||
const del = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
|
||||
const failed = await s.carry(del);
|
||||
assert.equal(failed.done.refusal, "workspace-rename-failed");
|
||||
// #1873: the refused event's reason names the workspace by label and the error by code, never a path.
|
||||
const refused = launchLog(p.f).filter((e) => e.event === "refused" && e.code === "workspace-rename-failed");
|
||||
assert.deepEqual(refused.map((e) => e.why), ["cannot mkdir the instance workspace (EEXIST); left in place"]);
|
||||
assert.ok(!JSON.stringify(refused).includes(p.f.dataRoot));
|
||||
const transaction = failed.done.transaction;
|
||||
assert.ok(transaction);
|
||||
const file = JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8"));
|
||||
@@ -601,7 +609,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 +637,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 +693,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 +727,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 +856,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 +863,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 +877,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);
|
||||
|
||||
@@ -21,6 +21,7 @@ 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 {};
|
||||
} };
|
||||
|
||||
|
||||
@@ -84,7 +84,8 @@ test('lock recovery reports fences and recovers a dead clearer while refusing li
|
||||
assert.equal(shown.fence.pid, process.pid);
|
||||
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), new RegExp(`recovery is already in progress.*${process.pid}`));
|
||||
writeFileSync(fence, '');
|
||||
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), e => e.message.includes(fence) && /remove.*hand/i.test(e.message));
|
||||
// #1873: the fence is named by label in the config directory, never by its path.
|
||||
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), e => !e.message.includes(f.ctx.configDir) && e.message.includes('recovery fence org-lifecycle-alpha.lock.clear in the config directory') && /remove.*hand/i.test(e.message));
|
||||
unlinkSync(fence);
|
||||
const registry = new URL('../../organization/src/org-registry.mjs', import.meta.url).href;
|
||||
const code = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// #1873 (redact6): a cli refusal names the file, the instance or the field, never a host path,
|
||||
// and never relays mosaic-config's stderr or a Node system error's message. The sentinel is a
|
||||
// component of every scratch path, so a refusal that quotes a path quotes the sentinel too; each
|
||||
// test also checks the scratch root and the label. The launcher cases need no bus: a bus socket
|
||||
// under a sentinel path would pass the Unix socket length limit, so they use a stub host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { createLauncher, relay } from '../src/launcher.mjs';
|
||||
import { CliError } from '../src/errors.mjs';
|
||||
import { loadSystem } from '../src/config.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
import { launchLogFile } from '../../seat/src/session.mjs';
|
||||
import { fakeHarnesses, fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) {
|
||||
const root = tmp(t, 'r6-');
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir, { mode: 0o700 });
|
||||
return { root, dir };
|
||||
}
|
||||
function clean(text, root) {
|
||||
assert.ok(!text.includes(root), text);
|
||||
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||
}
|
||||
const PI = JSON.stringify(new URL('../../plugins/harness/pi/index.mjs', import.meta.url).href);
|
||||
|
||||
function stubLauncher(t, { op = async () => ({}) } = {}) {
|
||||
const { root, dir } = scratch(t);
|
||||
const f = fixture(dir, 'acme');
|
||||
initializeControl(f.dataRoot);
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const system = JSON.parse(readFileSync(f.config, 'utf8'));
|
||||
const host = { business: 'acme', beforeClose: () => {}, op };
|
||||
return { root, f, make: () => createLauncher({ host, system, env: f.env, bwrap: '/usr/bin/true', log: () => {} }) };
|
||||
}
|
||||
|
||||
test('an unreadable sessions file refuses exit 3 naming bus-host/sessions.json under the data root, never its path', t => {
|
||||
const s = stubLauncher(t);
|
||||
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), '{not json');
|
||||
assert.throws(() => s.make(), e => {
|
||||
assert.ok(e instanceof CliError, e.stack);
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, 'sessions file is unreadable: bus-host/sessions.json under the data root; check which sessions still run, then move the file aside');
|
||||
clean(e.message, s.root);
|
||||
return true;
|
||||
});
|
||||
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), JSON.stringify({ sessionsVersion: 999, sessions: [] }));
|
||||
assert.throws(() => s.make(), e => e.exitCode === 3 && e.message.startsWith('sessions file is malformed: bus-host/sessions.json under the data root;') && !e.message.includes(s.root));
|
||||
});
|
||||
|
||||
test('a launch socket path that is not a socket refuses exit 3 by label, never its path', async t => {
|
||||
const s = stubLauncher(t);
|
||||
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(s.f.dataRoot, 'bus-host', 'launch.sock'), 'in the way\n');
|
||||
const launcher = s.make();
|
||||
t.after(() => launcher.close?.());
|
||||
await assert.rejects(launcher.listen(), e => {
|
||||
assert.ok(e instanceof CliError, e.stack);
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, "launch socket path exists and isn't a socket");
|
||||
clean(e.message, s.root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// The launch-routes pattern: a stub host stops each launch at authorizeLaunch, and the launch
|
||||
// log's refused event carries the reason.
|
||||
async function routed(t, harness) {
|
||||
const { root, dir } = scratch(t);
|
||||
const f = fixture(dir, 'acme', doc => {
|
||||
doc.roles.coder.vars = { harness, model: 'claude-sonnet-5-5', thinking: 'high' };
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi', 'claude-code', harness] };
|
||||
return doc;
|
||||
});
|
||||
initializeControl(f.dataRoot);
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
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 Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
||||
return {};
|
||||
} };
|
||||
const absent = join(dir, 'absent.json');
|
||||
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null }, write => {
|
||||
// fsthrows: its preflight reads a missing file, so the error's message quotes the path.
|
||||
write('harness', 'fsthrows', `export * from ${PI};\nimport { readFileSync } from "node:fs";\nexport function preflight() { readFileSync(${JSON.stringify(absent)}); }\n`);
|
||||
// plainthrows: a plain Error whose message quotes a host path, as a module's own text.
|
||||
write('harness', 'plainthrows', `export * from ${PI};\nexport function preflight() { throw new Error(${JSON.stringify(`cannot read ${absent}`)}); }\n`);
|
||||
// pathfails: a preflight that returns a failure whose message quotes a host path.
|
||||
write('harness', 'pathfails', `export * from ${PI};\nexport function preflight() { return { code: "harness-binary-missing", message: ${JSON.stringify(`no binary at ${absent}; see file://${absent}`)} }; }\n`);
|
||||
});
|
||||
const system = JSON.parse(readFileSync(f.config, 'utf8'));
|
||||
const launcher = createLauncher({ host, system, env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
|
||||
const last = () => readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').map(l => JSON.parse(l)).at(-1);
|
||||
return { root, f, launch: () => launcher.fromSession('fixture-cap', 'coder'), last };
|
||||
}
|
||||
|
||||
test('a harness whose preflight throws a plain Error naming a path refuses harness-failed by its class, never its message', async t => {
|
||||
const s = await routed(t, 'plainthrows');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
|
||||
assert.equal(s.last().why, 'harness plainthrows preflight: threw Error without a system code');
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('a preflight failure whose message quotes a path keeps its code and relays the text with the path replaced', async t => {
|
||||
const s = await routed(t, 'pathfails');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-binary-missing' });
|
||||
assert.equal(s.last().why, 'no binary at a host path; see a host path');
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('the claude-code preflight names MOSAIC_CLAUDE_BIN, never its value', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const f = fixture(dir, 'acme', doc => {
|
||||
doc.roles.coder.vars = { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'high' };
|
||||
return doc;
|
||||
});
|
||||
initializeControl(f.dataRoot);
|
||||
withoutHostCredentials(f.env);
|
||||
f.env.CLAUDE_CODE_OAUTH_TOKEN = 'fixture-only';
|
||||
f.env.MOSAIC_CLAUDE_BIN = join(dir, 'absent-claude');
|
||||
const host = { business: 'acme', beforeClose: () => {}, op: async x => x.op === 'identity' ? { business: 'acme', role: 'pm' } : x.op === 'mayLaunch' ? { allow: true } : {} };
|
||||
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null });
|
||||
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'harness-binary-missing' });
|
||||
const last = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(last.why, "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path");
|
||||
clean(JSON.stringify(last), root);
|
||||
});
|
||||
|
||||
test('relay replaces absolute, home and file: paths and keeps the rest of the text', () => {
|
||||
assert.equal(relay(`bad ${'/srv/x/' + secret} and ~/y, (file:///z) here`), 'bad a host path and a host path, (a host path) here');
|
||||
assert.equal(relay('fatal: not a git repository: .git; see https://example.test/a'), 'fatal: not a git repository: .git; see https://example.test/a');
|
||||
});
|
||||
|
||||
test('a harness whose preflight throws a system error refuses harness-failed naming the call and code, never the path', async t => {
|
||||
const s = await routed(t, 'fsthrows');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
|
||||
assert.equal(s.last().why, 'harness fsthrows preflight: cannot open a host file (ENOENT)');
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('a harness the loader refuses names its code only, never the loader message or a path', async t => {
|
||||
const s = await routed(t, 'absent');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-refused' });
|
||||
assert.match(s.last().why, /^harness absent: [a-z][a-z0-9-]*$/);
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('the halt gate refusal names the instance, never its data root, and keeps exit 1 and the count', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const result = { version: 1, generation: '11111111-1111-4111-8111-111111111111', operation: '11111111-1111-4111-8111-111111111111', business: 'demo', human: 'jason', at: '2026-10-20T00:00:00.000Z', recovered: false, halted: true, quiescent: false, remaining: 2, stopped: 0 };
|
||||
const x = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], x, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => result }), e => {
|
||||
assert.equal(e.exitCode, 1);
|
||||
assert.equal(e.message, 'halt gate is on for this instance; 2 stops unconfirmed. Use mosaic processing status');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
const odd = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], odd, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => ({ ...result, remaining: join(dir, 'x') }) }), e => {
|
||||
assert.equal(e.message, 'halt gate is on for this instance; unknown stops unconfirmed. Use mosaic processing status');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('a system config problem names the config by label and never relays mosaic-config stderr', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
// Missing: the label for where it came from, with the bootstrap hint.
|
||||
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir, MOSAIC_CONFIG: join(dir, 'absent.json') } }), e => {
|
||||
assert.ok(e instanceof CliError, e.stack);
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, 'system config problem (mosaic-config exit 3): configuration not found: the system config MOSAIC_CONFIG names (run scripts/bootstrap.sh to create it)');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir } }), e => {
|
||||
assert.match(e.message, /^system config problem \(mosaic-config exit 3\): configuration not found: the default system config \(run scripts\/bootstrap\.sh to create it\)$/);
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
// A field whose value quotes the sentinel: the field and its constraint, never the value.
|
||||
const f = fixture(dir, 'acme');
|
||||
const doc = JSON.parse(readFileSync(f.config, 'utf8'));
|
||||
writeFileSync(f.config, JSON.stringify({ ...doc, dataRoot: `relative/${secret}` }));
|
||||
assert.throws(() => loadSystem({ env: f.env }), e => {
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.match(e.message, /^system config problem \(mosaic-config exit \d+\): dataRoot: expected a canonical absolute instance data path: the system config MOSAIC_CONFIG names$/);
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// scripts/mosaic-config.mjs run standalone: every refusal names the config by where it came from,
|
||||
// the data root and its marker by their roles, and a key only when it is a plain name. Exit codes
|
||||
// are unchanged: 2 invalid, 3 missing or unclaimed, 4 usage.
|
||||
const CONFIG_SCRIPT = new URL('../../../scripts/mosaic-config.mjs', import.meta.url).pathname;
|
||||
function mosaicConfig(home, env, ...args) {
|
||||
return spawnSync(process.execPath, [CONFIG_SCRIPT, ...args], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: home, ...env } });
|
||||
}
|
||||
function refusedBy(r, status, text, root) {
|
||||
assert.equal(r.status, status, r.stderr);
|
||||
assert.equal(r.stdout, '');
|
||||
assert.ok(r.stderr.includes(text), r.stderr);
|
||||
clean(r.stderr, root);
|
||||
}
|
||||
|
||||
test('mosaic-config refusals name the config, data root and keys by label, never a host path or value', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const file = join(dir, 'config.json');
|
||||
const env = { MOSAIC_CONFIG: file };
|
||||
writeFileSync(file, 'not json');
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'mosaic-config: configuration is not valid JSON (the system config MOSAIC_CONFIG names)', root);
|
||||
const good = { configVersion: 1, environment: 'development', dataRoot: join(dir, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash' } };
|
||||
writeFileSync(file, JSON.stringify({ ...good, [join(dir, 'k')]: 1 }));
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'unsupported configuration key: that is not a plain name', root);
|
||||
writeFileSync(file, JSON.stringify({ ...good, console: { [join(dir, 'k')]: 1 } }));
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'console has a key that is not a plain name', root);
|
||||
writeFileSync(file, JSON.stringify({ ...good, dataRoot: dir }));
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'execution "dataRoot" must not be or contain the home directory', root);
|
||||
writeFileSync(join(dir, 'real.json'), JSON.stringify(good));
|
||||
const link = join(dir, 'link.json');
|
||||
symlinkSync(join(dir, 'real.json'), link);
|
||||
refusedBy(mosaicConfig(dir, { MOSAIC_CONFIG: link }, 'validate'), 2, 'configuration path must be a regular, non-symbolic-link file: the system config MOSAIC_CONFIG names', root);
|
||||
// A named instance: MOSAIC_CONFIG naming another file is usage (4); an unmarked root is 3.
|
||||
refusedBy(mosaicConfig(dir, env, 'validate', '--instance', 'alpha'), 4, 'instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file', root);
|
||||
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', join(dir, 'x')), 4, 'instance-name-invalid: that name must match', root);
|
||||
const named = join(dir, '.config', 'mosaic-alpha');
|
||||
mkdirSync(named, { recursive: true, mode: 0o700 });
|
||||
mkdirSync(join(dir, '.mosaic-alpha'), { mode: 0o700 });
|
||||
writeFileSync(join(named, 'config.json'), JSON.stringify({ ...good, dataRoot: join(dir, '.mosaic-alpha') }));
|
||||
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, 'instance-root-unclaimed: the data root holds no instance.json naming alpha', root);
|
||||
mkdirSync(join(dir, '.mosaic-alpha', 'instance.json'));
|
||||
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, "instance-root-unclaimed: the data root's instance marker must be a regular, non-symbolic-link file", root);
|
||||
});
|
||||
@@ -130,7 +130,8 @@ test('#774 F1 proxy launch of an agent with no key yet passes the first check un
|
||||
// claude-code module on claude-opus-5-5 (provider anthropic); MOSAIC_CLAUDE_BIN decides whether its
|
||||
// preflight passes. The stated fallback is pi on glm-5.3-flash (provider zai).
|
||||
const NO_CLAUDE = '/nonexistent-774/claude';
|
||||
const NO_CLAUDE_WHY = `MOSAIC_CLAUDE_BIN ${NO_CLAUDE} isn't an executable file at an absolute path`;
|
||||
// #1873: names the variable, never its value (was `MOSAIC_CLAUDE_BIN ${NO_CLAUDE} isn't ...`).
|
||||
const NO_CLAUDE_WHY = "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path";
|
||||
const routed = (t, bin) => managedLaunchFixture(t, { harness: 'claude-code', extra: ['pi'], real: ['claude-code'], hostEnv: { MOSAIC_CLAUDE_BIN: bin } });
|
||||
const withFallback = (doc, harness = 'pi', model = 'glm-5.3-flash') => {
|
||||
claudeCoder(doc);
|
||||
|
||||
@@ -112,7 +112,7 @@ test("rename r2 B2: a credential problem, an expiry warning and a system config
|
||||
for (const args of [["validate", "acme"], ["resolve", "acme", "coder"]]) {
|
||||
const { old, now } = await sameAcrossSpellings(args, bad.env, bad.root, 2);
|
||||
assert.equal(now.stderr, old.stderr, "a refusal is byte for byte the same, unnormalized");
|
||||
assert.match(old.stderr, /^mosaic business: .*coder-gitea\.token.*mode 644/m);
|
||||
assert.match(old.stderr, /^mosaic business: (?:roles\.coder\.credentials\.gitea|coder gitea) file: mode 644/m);
|
||||
assert.match(old.stderr, /credential reference problem\(s\)\n$/);
|
||||
}
|
||||
const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
@@ -133,7 +133,9 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
|
||||
for (const name of ["gamma", "worktrees", "delta"]) {
|
||||
const r = bootstrap(home, "--instance", name);
|
||||
assert.equal(r.code, 3, r.err);
|
||||
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
|
||||
// #1873: the data root by its role, never its path.
|
||||
assert.match(r.err, new RegExp(`instance-root-unclaimed: the instance's data root exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
|
||||
}
|
||||
assert.deepEqual(snapshot(home), before);
|
||||
@@ -154,7 +156,8 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
|
||||
const before = snapshot(home);
|
||||
const r = bootstrap(home, "--instance", "alpha");
|
||||
assert.equal(r.code, 3);
|
||||
assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/);
|
||||
assert.match(r.err, /instance-root-unclaimed: the data root holds no instance\.json naming alpha/);
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
assert.deepEqual(snapshot(home), before);
|
||||
});
|
||||
|
||||
@@ -262,7 +265,8 @@ test("T-I2 selection: the four rows of D1 through the instance op and through an
|
||||
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
|
||||
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
|
||||
assert.equal(r.out, "");
|
||||
assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
|
||||
assert.match(r.err, /instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file/);
|
||||
assert.ok(!r.err.includes(home) && !r.err.includes(given), r.err);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -270,7 +274,9 @@ test("T-I2 selection: the four rows of D1 through the instance op and through an
|
||||
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
|
||||
const r = config(home, {}, "instance", "--instance", "gamma");
|
||||
assert.equal(r.code, 3);
|
||||
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
|
||||
// #1873: the refusal names the config by its instance, never by host path.
|
||||
assert.match(r.err, /configuration not found: the config of instance gamma \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
|
||||
assert.ok(!r.err.includes(cfgOf(home, "gamma")) && !r.err.includes(home), r.err);
|
||||
});
|
||||
|
||||
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
|
||||
@@ -326,7 +332,8 @@ test("T-I7 claims: the instance op refuses a root another instance claims, and a
|
||||
const r = config(home, {}, op, "--instance", "alpha");
|
||||
assert.equal(r.code, 3, `${op}: ${r.err}`);
|
||||
assert.equal(r.out, "");
|
||||
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
|
||||
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance beta, not alpha/);
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
}
|
||||
edit("alpha", join(home, ".mosaic-alpha"));
|
||||
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
|
||||
@@ -336,7 +343,8 @@ test("T-I7 claims: the instance op refuses a root another instance claims, and a
|
||||
for (const op of ["instance"]) {
|
||||
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
|
||||
assert.equal(r.code, 3, r.err);
|
||||
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
|
||||
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance alpha; select it with --instance alpha/);
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS, plainKey } from "../../seat/src/seat.mjs";
|
||||
|
||||
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
|
||||
|
||||
@@ -32,17 +32,18 @@ export function defaultConfigPath() {
|
||||
}
|
||||
|
||||
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
|
||||
// Refusals name the config, never its path (#1873).
|
||||
export function loadConfig(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
|
||||
if (!existsSync(path)) throw new ConfigError("config not found");
|
||||
let raw;
|
||||
try {
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new ConfigError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError("config is not an object");
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
|
||||
throw new ConfigError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
throw new ConfigError("config.dataRoot must be an absolute path");
|
||||
}
|
||||
return { dataRoot: raw.dataRoot };
|
||||
}
|
||||
@@ -230,11 +231,11 @@ export function loadSeen(boardDir) {
|
||||
try {
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new ConfigError(`seen marks file seen.json is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError("seen marks file seen.json must be a JSON object");
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v !== "string") throw new ConfigError(`seen marks file has a non-string value for ${JSON.stringify(k)}: ${path}`);
|
||||
if (typeof v !== "string") throw new ConfigError(`seen marks file seen.json has a non-string value for ${plainKey(k)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// #1873 (redact6): a control-board refusal names the config, the seen marks file or the seat
|
||||
// by label, never a host path. The sentinel is a component of every scratch path, so a refusal
|
||||
// that quotes a path quotes the sentinel too; each test also checks the scratch root and the label.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { ConfigError, loadConfig, loadRegistrations, loadSeen } from '../src/scan.mjs';
|
||||
import { readRegistration, registrationPath, SeatError } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r6-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return { root, dir };
|
||||
}
|
||||
function clean(text, root) {
|
||||
assert.ok(!text.includes(root), text);
|
||||
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||
}
|
||||
function labelled(label, root) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ConfigError, error.stack);
|
||||
assert.equal(error.message, label);
|
||||
clean(error.message, root);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
function write(file, text) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
return file;
|
||||
}
|
||||
|
||||
test('config refusals name the config, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => loadConfig(join(dir, 'config.json')), labelled('config not found', root));
|
||||
assert.throws(() => loadConfig(write(join(dir, 'array.json'), '[]')), labelled('config is not an object', root));
|
||||
assert.throws(() => loadConfig(write(join(dir, 'relative.json'), '{"dataRoot":"data"}')), labelled('config.dataRoot must be an absolute path', root));
|
||||
});
|
||||
|
||||
test('seen marks refusals name seen.json, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(join(dir, 'seen.json'), '[]');
|
||||
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json must be a JSON object', root));
|
||||
write(join(dir, 'seen.json'), '{"a":1}');
|
||||
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for "a"', root));
|
||||
});
|
||||
|
||||
test('a bad registration is reported by layout and seat, never by its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(registrationPath(dir, 'builder', 'repo'), '{}');
|
||||
const { registrations, errors } = loadRegistrations(dir);
|
||||
assert.deepEqual(registrations, []);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /^seat repo\/builder: .*: registration repo\/builder$/);
|
||||
clean(errors[0], root);
|
||||
});
|
||||
|
||||
// Review round (#1873): a key or name that could be a host path is described, never echoed.
|
||||
test('a seen marks key or a registration field that is not a plain name is described, never echoed', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(join(dir, 'seen.json'), JSON.stringify({ [join(dir, 'host')]: 1 }));
|
||||
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for that is not a plain name', root));
|
||||
const f = registrationPath(dir, 'builder', 'repo');
|
||||
mkdirSync(dirname(f), { recursive: true });
|
||||
writeFileSync(f, JSON.stringify({ [join(dir, 'host')]: 1 }));
|
||||
assert.throws(() => readRegistration(dir, 'builder', 'repo'), e => {
|
||||
assert.ok(e instanceof SeatError, e.stack);
|
||||
assert.equal(e.exitCode, 2);
|
||||
assert.equal(e.message, 'registration has an unknown field: that is not a plain name: registration repo/builder');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('an invalid seat name or layout states the constraint, never the value, and keeps exit 4', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => registrationPath(dir, join(dir, 'x'), 'repo'), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid seat name (lowercase letters, digits, ., _ or -, at most 64)' && (clean(e.message, root), true));
|
||||
assert.throws(() => registrationPath(dir, 'builder', join(dir, 'x')), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid layout (one of repo, fleet, unknown)' && (clean(e.message, root), true));
|
||||
});
|
||||
|
||||
// The board API returns a scan refusal in its JSON body, so the HTTP body carries the label too.
|
||||
test('a seen marks refusal reaches the board API body by label, never by path', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(join(dir, 'seen.json'), '[]');
|
||||
const server = await startServer({ host: '127.0.0.1', port: 0, specs: [], boardDir: dir, isAlive: () => true, page: '<html></html>' });
|
||||
t.after(() => new Promise(done => server.close(done)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/board`);
|
||||
const body = await res.text();
|
||||
assert.equal(res.status, 500);
|
||||
assert.deepEqual(JSON.parse(body), { error: 'seen marks file seen.json must be a JSON object' });
|
||||
clean(body, root);
|
||||
});
|
||||
@@ -6,10 +6,13 @@ import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
// #1873: the sentinel is a path component, so a refusal that quotes a path quotes it too.
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return dir;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
@@ -21,6 +24,7 @@ function safe(label, ErrorType, properties = {}) {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.ok(!error.message.includes(tmpdir()), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
@@ -30,13 +34,13 @@ function safe(label, ErrorType, properties = {}) {
|
||||
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
|
||||
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(kind === 'config' ? 'config is not valid JSON' : 'seen marks file seen.json is not valid JSON', ConfigError));
|
||||
});
|
||||
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
|
||||
safe(file, ConfigError)(error);
|
||||
safe(kind === 'config' ? 'config is not valid JSON' : 'seen marks file seen.json is not valid JSON', ConfigError)(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
assert.doesNotMatch(error.message, /illegal operation/);
|
||||
return true;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { DiscordError, quietFs, quietRead } from "./errors.mjs";
|
||||
|
||||
export const APPROVE_WORD = "approve";
|
||||
export const CUSTOM_ID_PREFIX = "approve:";
|
||||
@@ -95,19 +95,29 @@ export function appendApproval(dir, entry) {
|
||||
if (!isObject(entry) || typeof entry.kind !== "string") throw new DiscordError("approvals: entry needs a kind", 1);
|
||||
const line = JSON.stringify(entry);
|
||||
if (line.includes("\n")) throw new DiscordError("approvals: line must not contain a newline", 1);
|
||||
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
|
||||
try {
|
||||
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
|
||||
} catch (e) {
|
||||
throw quietFs(e, "approvals.jsonl", "append to");
|
||||
}
|
||||
}
|
||||
|
||||
export function readApprovals(dir) {
|
||||
const path = approvalsPath(dir);
|
||||
if (!existsSync(path)) return [];
|
||||
const out = [];
|
||||
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, "approvals.jsonl");
|
||||
}
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
throw new DiscordError(`approvals.jsonl:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -22,14 +22,14 @@ import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { loadWebConfig } from "./web.mjs";
|
||||
import { loadSetsparkConfig } from "./setspark.mjs";
|
||||
import { isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { BINDING_NAME, DiscordError, plainKey, quietRead } from "./errors.mjs";
|
||||
import { TOOL_DEFAULTS } from "./tools.mjs";
|
||||
import { loadGitConfig } from "./git.mjs";
|
||||
|
||||
export const BINDING_VERSION = 1;
|
||||
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
export { BINDING_NAME };
|
||||
export const SNOWFLAKE = /^[0-9]{17,20}$/;
|
||||
export const CHANNEL_MODES = Object.freeze(["open", "mention"]);
|
||||
export const THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
@@ -56,16 +56,17 @@ export function defaultConfigPath(env = process.env) {
|
||||
}
|
||||
|
||||
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
|
||||
// Refusals name the config, never its path (#1873).
|
||||
export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
|
||||
if (!existsSync(path)) throw new DiscordError("config not found");
|
||||
let raw;
|
||||
try {
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new DiscordError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError("config is not an object");
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError("config.dataRoot must be an absolute path");
|
||||
return raw.dataRoot;
|
||||
}
|
||||
|
||||
@@ -73,13 +74,16 @@ export function discordDir(dataRoot) {
|
||||
return join(dataRoot, "discord");
|
||||
}
|
||||
|
||||
// The name is checked, never echoed: a bad one could be a host path (#1873).
|
||||
const INVALID_BINDING_NAME = "invalid binding name (lowercase letters, digits, ., _ or -, at most 64)";
|
||||
|
||||
export function bindingPath(dataRoot, name) {
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(INVALID_BINDING_NAME, 4);
|
||||
return join(discordDir(dataRoot), `${name}.json`);
|
||||
}
|
||||
|
||||
export function bindingDataDir(dataRoot, name) {
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(INVALID_BINDING_NAME, 4);
|
||||
return join(discordDir(dataRoot), name);
|
||||
}
|
||||
|
||||
@@ -89,7 +93,7 @@ function isObject(v) {
|
||||
|
||||
function onlyKeys(obj, allowed, where) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${JSON.stringify(key)}`);
|
||||
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${plainKey(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,38 +278,49 @@ export function reloadDiff(current, next) {
|
||||
}
|
||||
|
||||
// A private file: regular, not a symlink, owner-only (0600), non-empty.
|
||||
// `what` is the label a refusal names; the path never appears (#1873).
|
||||
export function checkPrivateFile(path, what) {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new DiscordError(`${what} not found: ${path}`);
|
||||
throw new DiscordError(`${what} not found`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink: ${path}`);
|
||||
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file: ${path}`);
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink`);
|
||||
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file`);
|
||||
const mode = st.mode & 0o777;
|
||||
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}: ${path}`);
|
||||
if (st.size === 0) throw new DiscordError(`${what} is empty: ${path}`);
|
||||
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}`);
|
||||
if (st.size === 0) throw new DiscordError(`${what} is empty`);
|
||||
return st;
|
||||
}
|
||||
|
||||
// The binding is named by its file's name when that is a valid binding name
|
||||
// (bindingPath() makes it so), never by its path.
|
||||
export function loadBinding(path) {
|
||||
checkPrivateFile(path, "binding");
|
||||
const name = basename(String(path), ".json");
|
||||
const label = BINDING_NAME.test(name) ? `binding ${name}` : "binding";
|
||||
checkPrivateFile(path, label);
|
||||
let raw;
|
||||
try {
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new DiscordError(`${label} is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
return validateBinding(raw, `binding ${path}`);
|
||||
return validateBinding(raw, label);
|
||||
}
|
||||
|
||||
// The token is read once into memory and handed to the REST and gateway
|
||||
// clients. It is never printed, journaled, or put on a command line.
|
||||
export function readToken(binding) {
|
||||
checkPrivateFile(binding.tokenFile, "token file");
|
||||
const token = readFileSync(binding.tokenFile, "utf8").trim();
|
||||
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError(`token file does not hold a bot token: ${binding.tokenFile}`);
|
||||
// A file that changes after its check fails with a native error that carries the path (#1873).
|
||||
let token;
|
||||
try {
|
||||
token = readFileSync(binding.tokenFile, "utf8").trim();
|
||||
} catch (e) {
|
||||
throw quietRead(e, "token file");
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError("token file does not hold a bot token");
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -315,22 +330,34 @@ export function readToken(binding) {
|
||||
// the model's prompt, so this is the boundary that keeps host files out of
|
||||
// Discord Sage (Q14, Q16). Every file must be a regular non-empty file.
|
||||
export function resolveContextFiles(binding, repo) {
|
||||
const root = realpathSync(repo);
|
||||
return binding.context.files.map((f) => {
|
||||
let root;
|
||||
try {
|
||||
root = realpathSync(repo);
|
||||
} catch (e) {
|
||||
throw quietRead(e, "the repository");
|
||||
}
|
||||
// Refusals name the entry by its index in context.files, never its path (#1873).
|
||||
return binding.context.files.map((f, i) => {
|
||||
const at = `context.files[${i}]`;
|
||||
if (typeof f !== "string" || f.length === 0) throw new DiscordError("context file must be a non-empty string");
|
||||
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${f}`);
|
||||
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${f}`);
|
||||
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${at}`);
|
||||
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${at}`);
|
||||
const path = resolve(root, f);
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new DiscordError(`missing context file: ${path}`);
|
||||
throw new DiscordError(`missing context file: ${at}`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${path}`);
|
||||
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${path}`);
|
||||
const real = realpathSync(path);
|
||||
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${f}`);
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${at}`);
|
||||
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${at}`);
|
||||
let real;
|
||||
try {
|
||||
real = realpathSync(path);
|
||||
} catch (e) {
|
||||
throw quietRead(e, `context file ${at}`);
|
||||
}
|
||||
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${at}`);
|
||||
return path;
|
||||
});
|
||||
}
|
||||
@@ -363,18 +390,28 @@ function webConfig(raw, where) {
|
||||
|
||||
export function resolveToolRoots(binding, { dataRoot }) {
|
||||
if (!binding.tools) return null;
|
||||
const data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
|
||||
let data;
|
||||
try {
|
||||
data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
|
||||
} catch (e) {
|
||||
throw quietRead(e, "the data root");
|
||||
}
|
||||
const roots = binding.tools.roots.map((r) => {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(r.path);
|
||||
} catch {
|
||||
throw new DiscordError(`tool root ${r.name} does not exist: ${r.path}`);
|
||||
throw new DiscordError(`tool root ${r.name} does not exist`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink: ${r.path}`);
|
||||
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory: ${r.path}`);
|
||||
const real = realpathSync(r.path);
|
||||
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root: ${r.path}`);
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink`);
|
||||
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory`);
|
||||
let real;
|
||||
try {
|
||||
real = realpathSync(r.path);
|
||||
} catch (e) {
|
||||
throw quietRead(e, `tool root ${r.name}`);
|
||||
}
|
||||
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root`);
|
||||
return { name: r.name, path: real, write: r.write, ...(r.git ? { git: { branch: r.git.branch, identity: r.git.identity, tokenFile: r.git.tokenFile, author: `${r.git.author.name} <${r.git.author.email}>`, ...(r.git.protocol ? { protocol: r.git.protocol } : {}) } } : {}) };
|
||||
});
|
||||
// setspark carries only the keys loadSetsparkConfig accepts; the extension
|
||||
|
||||
@@ -46,22 +46,22 @@
|
||||
// 3 refused by a brake (STOP present or the binding held; a supervisor must
|
||||
// not retry); 4 usage.
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { DiscordError, printable } from "./errors.mjs";
|
||||
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
||||
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
|
||||
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
|
||||
import { createRest } from "./rest.mjs";
|
||||
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
||||
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
|
||||
import { createEngine, buildPiArgs, sessionContinues } from "./engine-pi.mjs";
|
||||
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
|
||||
import { createSetsparkApi } from "./setspark.mjs";
|
||||
import { assembleContext } from "./context.mjs";
|
||||
import { createConnector } from "./connector.mjs";
|
||||
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
||||
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, journalLabel, BRAKE_EXIT } from "./journal.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
|
||||
@@ -114,7 +114,7 @@ function checkLibraryEngine(configDirectory, binding) {
|
||||
try {
|
||||
sets = loadSeatSets({ dir: configDirectory });
|
||||
} catch (err) {
|
||||
throw new DiscordError(`discord-business-unreadable: ${err.message}`, 2);
|
||||
throw new DiscordError(`discord-business-unreadable: ${printable(err, "a business file")}`, 2);
|
||||
}
|
||||
if (!sets.librarySeats.has(binding.seat)) return;
|
||||
if (binding.engine.provider !== "mosaic-bus") {
|
||||
@@ -135,7 +135,7 @@ function prepare(opts) {
|
||||
const contextFiles = resolveContextFiles(binding, opts.repo);
|
||||
const toolRoots = resolveToolRoots(binding, { dataRoot });
|
||||
const pi = join(opts.repo, "node_modules", ".bin", "pi");
|
||||
if (!existsSync(pi)) throw new DiscordError(`pi not found at ${pi}; run npm ci in the repository`);
|
||||
if (!existsSync(pi)) throw new DiscordError("pi not found at node_modules/.bin/pi in the repository; run npm ci in the repository");
|
||||
const journalDir = bindingDataDir(dataRoot, binding.name);
|
||||
const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`);
|
||||
return { dataRoot, bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir };
|
||||
@@ -205,7 +205,7 @@ async function run(opts) {
|
||||
const outcome = recover(journalDir);
|
||||
if (outcome === "cleared") warn("supervised start: run.lock left by a process that is gone was removed");
|
||||
}
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, BRAKE_EXIT);
|
||||
writePid(journalDir, process.pid);
|
||||
let managed = null;
|
||||
const cleanupPid = () => {
|
||||
@@ -234,7 +234,7 @@ async function run(opts) {
|
||||
const promptFile = join(launch, "context.md");
|
||||
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
|
||||
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
|
||||
const continueSession = existsSync(sessionDir) && statSync(sessionDir).isDirectory() && hasJsonl(sessionDir);
|
||||
const continueSession = sessionContinues(sessionDir);
|
||||
|
||||
const rest = createRest({ token, log: warn });
|
||||
const gw = await rest.getGatewayBot();
|
||||
@@ -272,7 +272,7 @@ async function run(opts) {
|
||||
clearInterval(processingWatch);
|
||||
exitCode = code;
|
||||
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
|
||||
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
|
||||
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${printable(err)}`)).finally(() => {
|
||||
cleanupPid();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
@@ -317,8 +317,8 @@ async function run(opts) {
|
||||
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
|
||||
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
|
||||
} catch (err) {
|
||||
appendReload(journalDir, { at, outcome: "refused", error: err.message });
|
||||
warn(`reload refused, binding unchanged: ${err.message}`);
|
||||
appendReload(journalDir, { at, outcome: "refused", error: printable(err) });
|
||||
warn(`reload refused, binding unchanged: ${printable(err)}`);
|
||||
}
|
||||
};
|
||||
let reloadChain = Promise.resolve();
|
||||
@@ -332,13 +332,6 @@ async function run(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasJsonl(dir) {
|
||||
try {
|
||||
return readdirSync(dir).some((f) => f.endsWith(".jsonl"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stop(opts) {
|
||||
const dataRoot = loadDataRoot(opts.config);
|
||||
@@ -413,7 +406,8 @@ main().then((code) => {
|
||||
if (code !== null) process.exit(code);
|
||||
}).catch((err) => {
|
||||
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
|
||||
warn(err.message);
|
||||
// Never a native error's message: it carries the path (#1873).
|
||||
warn(printable(err));
|
||||
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
|
||||
process.exit(code);
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import { reloadDiff } from "./binding.mjs";
|
||||
import { envelope, splitReply } from "./context.mjs";
|
||||
import {
|
||||
ensureJournal, appendInbox, readInboxIds, appendOutbox, unresolvedOutbox, appendDrop,
|
||||
writeTurn, appendAdmission, countAdmissionsOn, appendNotice, noticeOn, utcDate, stopRequested,
|
||||
writeTurn, appendAdmission, countAdmissionsOn, appendNotice, noticeOn, utcDate, stopRequested, journalLabel,
|
||||
} from "./journal.mjs";
|
||||
import { RestOutcome } from "./rest.mjs";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
@@ -596,7 +596,7 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
async start() {
|
||||
processing();
|
||||
if (state.started) throw new DiscordError("connector already started", 1);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, 1);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, 1);
|
||||
const reconciled = await reconcile();
|
||||
const approvals = await reconcileApprovals();
|
||||
processing();
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
// without the connector noticing: the contract is start(), prompt(), stop().
|
||||
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { DiscordError, printable, quietFs } from "./errors.mjs";
|
||||
import { enabledToolNames } from "./tools.mjs";
|
||||
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
|
||||
@@ -51,6 +52,25 @@ export const TOOLS_EXTENSION = fileURLToPath(new URL("../extension/tools.mjs", i
|
||||
// Older name, kept for the tests and docs that still use it.
|
||||
export const READONLY_TOOLS_EXTENSION = TOOLS_EXTENSION;
|
||||
|
||||
// Whether pi continues the session in sessionDir: it is a directory holding a
|
||||
// .jsonl session file. A stat that fails names the binding's session directory,
|
||||
// never its path (#1873); a listing that fails means a new session, as before.
|
||||
export function sessionContinues(sessionDir) {
|
||||
if (!existsSync(sessionDir)) return false;
|
||||
let st;
|
||||
try {
|
||||
st = statSync(sessionDir);
|
||||
} catch (e) {
|
||||
throw quietFs(e, "the binding's session directory");
|
||||
}
|
||||
if (!st.isDirectory()) return false;
|
||||
try {
|
||||
return readdirSync(sessionDir).some((f) => f.endsWith(".jsonl"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPiArgs({ provider, model, thinking, sessionDir, appendSystemPromptFile, continueSession, tools = null }) {
|
||||
const args = [...PI_FIXED_ARGS];
|
||||
if (tools) args.push("--no-builtin-tools", "--extension", TOOLS_EXTENSION, "--tools", enabledToolNames(tools).join(","));
|
||||
@@ -393,9 +413,11 @@ export function createEngine({
|
||||
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
|
||||
child.on("error", (err) => {
|
||||
releaseRuntime();
|
||||
log(`engine spawn error: ${err.message}`);
|
||||
state.exited = { code: null, signal: null, error: err.message };
|
||||
failAll("engine-down", `engine failed: ${err.message}`);
|
||||
// A spawn error's message carries the command's path (#1873).
|
||||
const why = printable(err, "pi");
|
||||
log(`engine spawn error: ${why}`);
|
||||
state.exited = { code: null, signal: null, error: why };
|
||||
failAll("engine-down", `engine failed: ${why}`);
|
||||
onExit(state.exited);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
// One error class for the package. exitCode follows docs/TOOLS.md: 1 operation
|
||||
// failed, 2 invalid data or configuration, 3 refused by a brake (STOP or a
|
||||
// held binding; a supervisor must not retry), 4 usage.
|
||||
// A refusal that names an unknown key quotes it only when it is a plain name;
|
||||
// a key that could carry a host path or a secret is described, never echoed (#1873).
|
||||
export const plainKey = (k) => (/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(String(k)) ? JSON.stringify(k) : "that is not a plain name");
|
||||
// A binding (and seat) name. Defined here, not in binding.mjs, so journal.mjs can label a
|
||||
// journal directory without importing binding.mjs and its config readers; copies of
|
||||
// journal.mjs + errors.mjs (the queue test repo) stay self-contained. binding.mjs re-exports it.
|
||||
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
// A filesystem call that fails names the file by label, the call and the
|
||||
// error code, never the path Node puts in the message. The same error object
|
||||
// is rethrown, so its type, code and the exit it maps to are unchanged, and
|
||||
// it is marked so the CLI's top-level handler prints it as it is (#1873).
|
||||
const QUIET = Symbol("discord.quiet");
|
||||
const SYSCALL = /^[a-z][a-z0-9_]{0,31}$/;
|
||||
const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const callOf = (e) => {
|
||||
const word = typeof e.syscall === "string" ? e.syscall.split(" ")[0] : "";
|
||||
return SYSCALL.test(word) ? word : "access";
|
||||
};
|
||||
const codeOf = (e) => (typeof e.code === "string" && CODE.test(e.code) ? e.code : "UNKNOWN");
|
||||
export function quietFs(e, label, verb = null) {
|
||||
if (e && typeof e === "object" && typeof e.code === "string") {
|
||||
e.message = `cannot ${verb ?? callOf(e)} ${label} (${codeOf(e)})`;
|
||||
e[QUIET] = true;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
export const quietRead = (e, label) => quietFs(e, label, "read");
|
||||
|
||||
// What a top-level handler prints for an error (#1873). Our refusals and an
|
||||
// error already labelled at its source print their own message. A native
|
||||
// Node error (it carries a syscall, or a code and a path) that slipped past
|
||||
// every source label prints only its call and code, never its message,
|
||||
// which carries the path; `label` names the file when the error has one.
|
||||
export function printable(err, label = "a discord file") {
|
||||
if (err instanceof DiscordError || (err && err[QUIET] === true)) return String(err.message);
|
||||
if (err && typeof err === "object" && (typeof err.syscall === "string" || (typeof err.code === "string" && typeof err.path === "string"))) {
|
||||
return typeof err.path === "string" ? `cannot ${callOf(err)} ${label} (${codeOf(err)})` : `cannot ${callOf(err)} (${codeOf(err)})`;
|
||||
}
|
||||
return err && typeof err.message === "string" ? err.message : String(err);
|
||||
}
|
||||
|
||||
export class DiscordError extends Error {
|
||||
constructor(message, exitCode = 2, details = undefined) {
|
||||
super(message);
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { plainKey } from "./errors.mjs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const GIT_TOOL_NAMES = Object.freeze(["git_status", "git_commit", "git_pull", "git_push"]);
|
||||
@@ -109,7 +110,7 @@ const AUTHOR = /^([^<>\r\n]{1,64}?) <([^<>\s@]+@[^<>\s@]+)>$/;
|
||||
export function loadGitConfig(raw, where, real) {
|
||||
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${plainKey(k)}`);
|
||||
}
|
||||
if (typeof raw.branch !== "string" || !BRANCH_NAME.test(raw.branch) || raw.branch.includes("..")) throw new Error(`${where}.branch: must be a branch name`);
|
||||
if (typeof raw.identity !== "string" || !IDENTITY.test(raw.identity)) throw new Error(`${where}.identity: must match ${IDENTITY}`);
|
||||
@@ -125,7 +126,7 @@ export function loadGitConfig(raw, where, real) {
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: ${real} is not a git work tree`);
|
||||
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: the root is not a git work tree`);
|
||||
}
|
||||
if (process.execPath.includes("'") || CREDENTIAL_HELPER.includes("'")) throw new Error(`${where}: the node or helper path holds a quote and cannot be a credential helper`);
|
||||
return Object.freeze({
|
||||
@@ -139,12 +140,12 @@ function checkPrivateFile(path, what) {
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new Error(`${what}: not found: ${path}`);
|
||||
throw new Error(`${what}: not found`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty`);
|
||||
}
|
||||
|
||||
// The environment every child sees. No global or system git configuration,
|
||||
@@ -201,7 +202,7 @@ function cap(text) {
|
||||
function run(cmd, args, cwd, env, spawn) {
|
||||
const r = spawn(cmd, args, { cwd, env, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
||||
if (r.error && r.error.code === "ETIMEDOUT") throw new GitRefusal(GIT_REFUSAL.TIMEOUT, `${cmd} ${args[0] || ""}`);
|
||||
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || r.error.message})`);
|
||||
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || "error"})`);
|
||||
return { status: r.status, stdout: cap(r.stdout || ""), stderr: cap(r.stderr || "") };
|
||||
}
|
||||
|
||||
@@ -209,8 +210,12 @@ function git(root, gitCfg, args, { remote = false, spawn = spawnSync } = {}) {
|
||||
return run("git", args, root.real, gitEnv(gitCfg, { remote }), spawn);
|
||||
}
|
||||
|
||||
// git output reaches a refusal detail, the journal and Discord. A line that
|
||||
// carries a host path (an absolute or home path, or a file: URL) is dropped,
|
||||
// never relayed; repo-relative lines such as a lock owner or a conflict stay (#1873).
|
||||
const HOST_PATH = /(^|[\s'"(=:,[<])~?\/(?!\/)|file:/i;
|
||||
function firstLines(text, n = 5) {
|
||||
return text.split("\n").filter((l) => l.trim().length > 0).slice(0, n).join("\n");
|
||||
return text.split("\n").filter((l) => l.trim().length > 0 && !HOST_PATH.test(l)).slice(0, n).join("\n");
|
||||
}
|
||||
|
||||
// The guard every verb runs first: on the named branch, nothing in
|
||||
|
||||
@@ -25,8 +25,16 @@ import {
|
||||
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
|
||||
unlinkSync, writeFileSync, writeSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { basename, join } from "node:path";
|
||||
import { BINDING_NAME, DiscordError, quietFs, quietRead } from "./errors.mjs";
|
||||
|
||||
// Where a refusal says a binding's journal is: its place under the data root
|
||||
// when the directory carries a valid binding name (bindingDataDir() makes it
|
||||
// so), never the host path (#1873).
|
||||
export function journalLabel(dir) {
|
||||
const name = basename(String(dir));
|
||||
return BINDING_NAME.test(name) ? `discord/${name} under the data root` : "the binding's journal directory";
|
||||
}
|
||||
|
||||
export const OUTBOX_STATUS = Object.freeze(["intent", "confirmed", "refused", "unknown"]);
|
||||
|
||||
@@ -38,19 +46,28 @@ export function ensureJournal(dir) {
|
||||
function appendLine(path, record) {
|
||||
const line = JSON.stringify(record);
|
||||
if (line.includes("\n")) throw new DiscordError("journal line must not contain a newline", 1);
|
||||
appendFileSync(path, line + "\n", { mode: 0o600 });
|
||||
try {
|
||||
appendFileSync(path, line + "\n", { mode: 0o600 });
|
||||
} catch (e) {
|
||||
throw quietFs(e, basename(path), "append to");
|
||||
}
|
||||
}
|
||||
|
||||
function readLines(path) {
|
||||
if (!existsSync(path)) return [];
|
||||
const out = [];
|
||||
const text = readFileSync(path, "utf8");
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, basename(path));
|
||||
}
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
throw new DiscordError(`${basename(path)}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -109,42 +126,61 @@ export function readDrops(dir) {
|
||||
const TURN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
export function turnPath(dir, id) {
|
||||
if (!TURN_ID.test(String(id))) throw new DiscordError(`invalid turn id: ${JSON.stringify(id)}`, 1);
|
||||
if (!TURN_ID.test(String(id))) throw new DiscordError("invalid turn id (letters, digits, ., _ or -, at most 128)", 1);
|
||||
return join(dir, "turns", `${id}.json`);
|
||||
}
|
||||
|
||||
// A parse refusal or a failed read or write names the turn by its id, never by
|
||||
// host path (PR #1916, #1873); 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 writeTurn(dir, id, record) {
|
||||
const path = turnPath(dir, id);
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, "wx", 0o600);
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${path}`, 1);
|
||||
throw err;
|
||||
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${id}`, 1);
|
||||
throw quietFs(err, turnLabel(String(id)), "write");
|
||||
}
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch (err) {
|
||||
throw quietFs(err, turnLabel(String(id)), "write");
|
||||
}
|
||||
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");
|
||||
function readTurnFile(path, id) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, turnLabel(id));
|
||||
}
|
||||
return parseJsonFileText(text, path, turnLabel(id));
|
||||
}
|
||||
|
||||
export function readTurn(dir, id) {
|
||||
const path = turnPath(dir, id);
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
|
||||
return readTurnFile(turnPath(dir, id), String(id));
|
||||
}
|
||||
|
||||
export function listTurns(dir) {
|
||||
const turns = join(dir, "turns");
|
||||
if (!existsSync(turns)) return [];
|
||||
return readdirSync(turns)
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(turns);
|
||||
} catch (e) {
|
||||
throw quietRead(e, `the turns directory in ${journalLabel(dir)}`);
|
||||
}
|
||||
return names
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length))));
|
||||
.map((f) => readTurnFile(join(turns, f), f.slice(0, -".json".length)));
|
||||
}
|
||||
|
||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||
@@ -189,21 +225,38 @@ function stopLock(dir, action) {
|
||||
const lock = join(dir, '.stop.lock');
|
||||
try { mkdirSync(lock, { mode: 0o700 }); }
|
||||
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
|
||||
try { return action(); } finally { rmdirSync(lock); }
|
||||
try { return action(); } finally {
|
||||
try { rmdirSync(lock); } catch (e) { throw quietFs(e, `.stop.lock in ${journalLabel(dir)}`, "remove"); }
|
||||
}
|
||||
}
|
||||
|
||||
// A STOP write or removal that fails names STOP in the journal, never its path (#1873).
|
||||
const stopLabel = (dir) => `STOP in ${journalLabel(dir)}`;
|
||||
|
||||
function appendStop(dir, reason) {
|
||||
const path = stopPath(dir);
|
||||
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch (e) {
|
||||
throw quietFs(e, stopLabel(dir), "write");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function removeStop(dir) {
|
||||
try {
|
||||
unlinkSync(stopPath(dir));
|
||||
} catch (e) {
|
||||
throw quietFs(e, stopLabel(dir), "remove");
|
||||
}
|
||||
}
|
||||
|
||||
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
function processingStops(dir) {
|
||||
let fd;
|
||||
@@ -235,14 +288,13 @@ export function clearProcessingStop(dir, generation) {
|
||||
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
|
||||
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
|
||||
new Date(r.at).toISOString() !== r.at)) return false;
|
||||
unlinkSync(stopPath(dir));
|
||||
removeStop(dir);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearStop(dir) {
|
||||
const path = stopPath(dir);
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
if (existsSync(stopPath(dir))) removeStop(dir);
|
||||
}
|
||||
|
||||
// The STOP lines as written, oldest first. A line that does not parse is
|
||||
@@ -250,7 +302,13 @@ export function clearStop(dir) {
|
||||
// treated as ours.
|
||||
export function readStop(dir) {
|
||||
if (!stopRequested(dir)) return [];
|
||||
return readFileSync(stopPath(dir), "utf8").split("\n").filter((l) => l.trim() !== "").map((l) => {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(stopPath(dir), "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, stopLabel(dir));
|
||||
}
|
||||
return text.split("\n").filter((l) => l.trim() !== "").map((l) => {
|
||||
try {
|
||||
const v = JSON.parse(l);
|
||||
return v && typeof v === "object" && typeof v.reason === "string" ? v : { reason: null };
|
||||
@@ -396,7 +454,7 @@ function lockRefusal(dir, opts) {
|
||||
const existing = readPid(dir);
|
||||
const state = ownerState(existing, opts);
|
||||
if (state === "absent") return new DiscordError(`run.lock exists without an owner record: a start is in progress or was interrupted; ${UNLOCK_HINT}`, BRAKE_EXIT);
|
||||
if (state === "invalid") return new DiscordError(`run.lock has an owner record that cannot be read; refusing. Inspect ${ownerPath(dir)} by hand`, BRAKE_EXIT);
|
||||
if (state === "invalid") return new DiscordError(`run.lock has an owner record that cannot be read; refusing. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, BRAKE_EXIT);
|
||||
if (state === "live") return new DiscordError(`another connector is running for this binding (pid ${existing.pid})`, BRAKE_EXIT);
|
||||
if (state === "unknown") return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is alive but whose identity cannot be verified; refusing`, BRAKE_EXIT);
|
||||
return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is gone or is a different process now; ${UNLOCK_HINT}`, BRAKE_EXIT);
|
||||
@@ -409,17 +467,21 @@ export function writePid(dir, pid, { now = Date.now(), identity = identityOf } =
|
||||
try {
|
||||
mkdirSync(lock, { mode: 0o700 });
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") throw err;
|
||||
if (err.code !== "EEXIST") throw quietFs(err, `run.lock in ${journalLabel(dir)}`, "create");
|
||||
throw lockRefusal(dir, { identity });
|
||||
}
|
||||
const tmp = join(lock, "owner.json.tmp");
|
||||
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, ownerPath(dir));
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, ownerPath(dir));
|
||||
} catch (e) {
|
||||
throw quietFs(e, `run.lock/owner.json in ${journalLabel(dir)}`, "write");
|
||||
}
|
||||
// The gate: STOP written before this point (by `stop` or `unlock`) means
|
||||
// this claim must not stand, however it interleaved with an unlock.
|
||||
if (stopRequested(dir)) {
|
||||
clearPid(dir, pid);
|
||||
throw new DiscordError(`STOP is present in ${dir}; remove it to run`, BRAKE_EXIT);
|
||||
throw new DiscordError(`STOP is present in ${journalLabel(dir)}; remove it to run`, BRAKE_EXIT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,9 +501,9 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
|
||||
const state = ownerState(rec, { identity });
|
||||
if (state === "live") throw new DiscordError(`refusing to unlock: the connector is running (pid ${rec.pid}); use stop, and unlock only a lock whose owner is gone`, 1);
|
||||
if (state === "unknown") throw new DiscordError(`refusing to unlock: pid ${rec.pid} is alive and its identity cannot be verified; nothing removed. Stop that process first`, 1);
|
||||
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect ${ownerPath(dir)} by hand`, 1);
|
||||
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, 1);
|
||||
if (beforeRemove) beforeRemove();
|
||||
rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
||||
removeLock(dir, { maxRetries: 10, retryDelay: 20 });
|
||||
return rec;
|
||||
}
|
||||
|
||||
@@ -468,14 +530,14 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
|
||||
export function recover(dir, { identity = identityOf, beforeRemove = null } = {}) {
|
||||
const ours = (lines) => lines.length === 1 && lines[0].reason === RECOVER_REASON;
|
||||
if (stopRequested(dir)) {
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP is present in ${dir}; the brake is on. Remove it to run`, BRAKE_EXIT);
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP is present in ${journalLabel(dir)}; the brake is on. Remove it to run`, BRAKE_EXIT);
|
||||
clearStop(dir);
|
||||
}
|
||||
if (!existsSync(lockPath(dir))) return "clean";
|
||||
const state = ownerState(readPid(dir), { identity });
|
||||
if (state === "live" || state === "unknown" || state === "invalid") throw lockRefusal(dir, { identity });
|
||||
unlock(dir, { identity, reason: RECOVER_REASON, beforeRemove });
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP was written by an operator while recovering ${dir}; the brake stays. Remove it to run`, BRAKE_EXIT);
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP was written by an operator while recovering ${journalLabel(dir)}; the brake stays. Remove it to run`, BRAKE_EXIT);
|
||||
clearStop(dir);
|
||||
return "cleared";
|
||||
}
|
||||
@@ -489,7 +551,15 @@ export function stopTarget(dir, opts) {
|
||||
|
||||
export function clearPid(dir, pid) {
|
||||
const rec = readPid(dir);
|
||||
if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true });
|
||||
if (rec !== null && !rec.invalid && rec.pid === pid) removeLock(dir);
|
||||
}
|
||||
|
||||
function removeLock(dir, retry = {}) {
|
||||
try {
|
||||
rmSync(lockPath(dir), { recursive: true, force: true, ...retry });
|
||||
} catch (e) {
|
||||
throw quietFs(e, `run.lock in ${journalLabel(dir)}`, "remove");
|
||||
}
|
||||
}
|
||||
|
||||
// --- reloads ---
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { plainKey, quietRead } from "./errors.mjs";
|
||||
|
||||
export const SETSPARK_DEFAULTS = Object.freeze({ timeoutMs: 15000, maxResponseBytes: 262144 });
|
||||
export const USER_AGENT = "mosaic-discord-sage/1 (Mosaic Stack Discord connector; setspark client)";
|
||||
@@ -69,7 +70,7 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
||||
export function loadSetsparkConfig(raw, where = "setspark") {
|
||||
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${plainKey(k)}`);
|
||||
}
|
||||
if (typeof raw.baseUrl !== "string") throw new Error(`${where}.baseUrl: must be a url string`);
|
||||
let u;
|
||||
@@ -108,12 +109,12 @@ function checkPrivateFile(path, what) {
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new Error(`${what}: not found: ${path}`);
|
||||
throw new Error(`${what}: not found`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty`);
|
||||
}
|
||||
|
||||
// Read the key for one call. The file is re-checked every time, so a key
|
||||
@@ -132,7 +133,14 @@ export function readKey(config) {
|
||||
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE);
|
||||
}
|
||||
if (st.size > KEY_MAX_BYTES) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
|
||||
const text = readFileSync(config.keyFile, "utf8");
|
||||
// A file that changes after its checks fails with a native error that carries the path: it
|
||||
// keeps its type and code and names the field (#1873).
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(config.keyFile, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, "keyFile");
|
||||
}
|
||||
// Either one bare key line, or the mint's own JSON output
|
||||
// ({"key_id", "key", "note"}) stored as is; only "key" is used.
|
||||
let key = text;
|
||||
|
||||
@@ -177,7 +177,8 @@ test("cli: check refuses a missing context file and a missing binding with exit
|
||||
assert.match(r.stderr, /missing context file/);
|
||||
r = runCli(["check", "other-seat", "--config", dep.config, "--repo", repo]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /binding not found/);
|
||||
assert.match(r.stderr, /binding other-seat not found/);
|
||||
assert.ok(!r.stderr.includes(root), r.stderr);
|
||||
r = runCli(["check"]);
|
||||
assert.equal(r.status, 4);
|
||||
r = runCli(["frobnicate", "x"]);
|
||||
|
||||
@@ -230,7 +230,7 @@ test("restart: an inbox with three ids and a replay of the same three produces z
|
||||
test("stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes", async () => {
|
||||
const { journalDir, rest, engine, connector } = setup({ replies: [{ text: "done", delayMs: 30 }] });
|
||||
requestStop(journalDir, "test");
|
||||
await assert.rejects(connector.start(), /STOP is present/);
|
||||
await assert.rejects(connector.start(), (err) => /STOP is present/.test(err.message) && !err.message.includes(journalDir));
|
||||
assert.equal(engine.started, false);
|
||||
writeFileSync(join(journalDir, "STOP"), ""); // still present; clear it
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// #1873 (redact6, re-check 2): a native fs error never prints its path. The journal and
|
||||
// approvals readers and writers name the file by label at the source, and the CLI's
|
||||
// top-level handler prints any native error that slipped through as its call and code
|
||||
// only. The sentinel is a component of every scratch path, so an error message that
|
||||
// quotes a path quotes the sentinel too.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs, { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { appendApproval } from '../src/approvals.mjs';
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import {
|
||||
appendInbox, clearStop, ensureJournal, listTurns, lockPath, readTurn, recover, requestStop, stopPath, turnPath, writePid, writeTurn,
|
||||
} from '../src/journal.mjs';
|
||||
import { makeDeployment, makeRepo } from './helpers.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
|
||||
// A journal directory named like a binding, under a sentinel path component, so its
|
||||
// label is "discord/seat under the data root".
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r6n-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, secret, 'seat');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return { root, dir };
|
||||
}
|
||||
const where = 'discord/seat under the data root';
|
||||
function quiet(text, root) {
|
||||
assert.ok(text.includes(root) === false, text);
|
||||
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||
}
|
||||
function labelled(label, root) {
|
||||
return error => {
|
||||
assert.ok(error instanceof Error, String(error));
|
||||
assert.equal(error instanceof DiscordError, false, 'a native fs error keeps its type');
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
quiet(error.message, root);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
// Swap one node:fs function so the target goes away right before the real call, which
|
||||
// then fails with a genuine error that names the path. One swap per test.
|
||||
function race(t, fn, target, remove) {
|
||||
const original = fs[fn];
|
||||
const swapped = function (p, ...rest) {
|
||||
if (String(p) === target) remove();
|
||||
return original.call(this, p, ...rest);
|
||||
};
|
||||
if (original.native) swapped.native = original.native;
|
||||
fs[fn] = swapped;
|
||||
syncBuiltinESMExports();
|
||||
t.after(() => {
|
||||
fs[fn] = original;
|
||||
syncBuiltinESMExports();
|
||||
});
|
||||
}
|
||||
|
||||
// --- the four named sites ---
|
||||
|
||||
test('recover: a STOP removed between its check and its read names STOP in the journal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
writeFileSync(stopPath(dir), JSON.stringify({ at: new Date().toISOString(), reason: 'cli stop' }) + '\n', { mode: 0o600 });
|
||||
race(t, 'readFileSync', stopPath(dir), () => rmSync(stopPath(dir)));
|
||||
assert.throws(() => recover(dir), labelled(`cannot read STOP in ${where} (ENOENT)`, root));
|
||||
});
|
||||
|
||||
test('readTurn: a turn record removed before its read names the turn by id', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
|
||||
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
|
||||
assert.throws(() => readTurn(dir, 't1'), labelled('cannot read turn record t1 (ENOENT)', root));
|
||||
});
|
||||
|
||||
test('listTurns: a turns directory removed between its check and its listing names the turns directory', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
const turns = join(dir, 'turns');
|
||||
race(t, 'readdirSync', turns, () => rmSync(turns, { recursive: true }));
|
||||
assert.throws(() => listTurns(dir), labelled(`cannot read the turns directory in ${where} (ENOENT)`, root));
|
||||
});
|
||||
|
||||
test('listTurns: a turn record removed between the listing and its read names the turn by id', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
|
||||
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
|
||||
assert.throws(() => listTurns(dir), labelled('cannot read turn record t1 (ENOENT)', root));
|
||||
});
|
||||
|
||||
test("run: a session directory removed between its check and its stat names the binding's session directory", async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const { sessionContinues } = await import('../src/engine-pi.mjs');
|
||||
assert.equal(typeof sessionContinues, 'function', 'engine-pi.mjs exports sessionContinues');
|
||||
const session = join(dir, 'session');
|
||||
assert.equal(sessionContinues(session), false, 'no directory: a new session');
|
||||
mkdirSync(session);
|
||||
assert.equal(sessionContinues(session), false, 'an empty directory: a new session');
|
||||
writeFileSync(join(session, 'a.jsonl'), '');
|
||||
assert.equal(sessionContinues(session), true, 'a .jsonl file: the session continues');
|
||||
race(t, 'statSync', session, () => rmSync(session, { recursive: true }));
|
||||
assert.throws(() => sessionContinues(session), labelled("cannot stat the binding's session directory (ENOENT)", root));
|
||||
});
|
||||
|
||||
// --- journal and approvals writers (the connector logs err.message at runtime) ---
|
||||
|
||||
test('a journal line that cannot be appended names the journal file, never its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
mkdirSync(join(dir, 'inbox.jsonl'));
|
||||
assert.throws(() => appendInbox(dir, { id: '1' }), labelled('cannot append to inbox.jsonl (EISDIR)', root));
|
||||
});
|
||||
|
||||
test('an approvals line that cannot be appended names approvals.jsonl, never its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
mkdirSync(join(dir, 'approvals.jsonl'));
|
||||
assert.throws(() => appendApproval(dir, { kind: 'opened' }), labelled('cannot append to approvals.jsonl (EISDIR)', root));
|
||||
});
|
||||
|
||||
test('a turn record that cannot be created names the turn by id; an existing one keeps its refusal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => writeTurn(dir, 't1', {}), labelled('cannot write turn record t1 (ENOENT)', root));
|
||||
ensureJournal(dir);
|
||||
writeTurn(dir, 't1', {});
|
||||
assert.throws(() => writeTurn(dir, 't1', {}), e => e instanceof DiscordError && e.exitCode === 1 && /turn record already exists: t1/.test(e.message));
|
||||
});
|
||||
|
||||
test('a STOP that cannot be written names STOP in the journal; the lock is released', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
mkdirSync(stopPath(dir));
|
||||
assert.throws(() => requestStop(dir, 'cli stop'), labelled(`cannot write STOP in ${where} (EISDIR)`, root));
|
||||
assert.equal(existsSync(join(dir, '.stop.lock')), false);
|
||||
});
|
||||
|
||||
test('a STOP removed between its check and its removal names STOP in the journal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(stopPath(dir), '{}\n', { mode: 0o600 });
|
||||
race(t, 'unlinkSync', stopPath(dir), () => rmSync(stopPath(dir)));
|
||||
assert.throws(() => clearStop(dir), labelled(`cannot remove STOP in ${where} (ENOENT)`, root));
|
||||
});
|
||||
|
||||
test('a run.lock that cannot be created names run.lock in the journal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const gone = join(dir, 'gone');
|
||||
assert.throws(() => writePid(gone, process.pid), e => {
|
||||
assert.ok(e.message.includes('cannot create run.lock in discord/gone under the data root (ENOENT)'), e.message);
|
||||
quiet(e.message, root);
|
||||
return true;
|
||||
});
|
||||
assert.equal(existsSync(lockPath(gone)), false);
|
||||
});
|
||||
|
||||
test('engine: a pi that cannot be spawned is logged and recorded as "cannot spawn pi", never its path', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const { createEngine } = await import('../src/engine-pi.mjs');
|
||||
const logs = [];
|
||||
let exited = null;
|
||||
const engine = createEngine({ command: join(dir, 'pi'), args: [], cwd: dir, env: {}, log: (m) => logs.push(m), onExit: (e) => (exited = e) });
|
||||
engine.start();
|
||||
for (let i = 0; i < 200 && exited === null; i += 1) await new Promise((res) => setTimeout(res, 10));
|
||||
assert.ok(exited, 'the spawn error reached onExit');
|
||||
assert.equal(exited.error, 'cannot spawn pi (ENOENT)');
|
||||
assert.ok(logs.includes('engine spawn error: cannot spawn pi (ENOENT)'), logs.join('\n'));
|
||||
quiet(JSON.stringify({ logs, exited }), root);
|
||||
});
|
||||
|
||||
// --- the choke point ---
|
||||
|
||||
test('printable: a native error prints its call and code, a refusal or a labelled error its own message', async () => {
|
||||
const { printable, quietRead } = await import('../src/errors.mjs');
|
||||
assert.equal(typeof printable, 'function', 'errors.mjs exports printable');
|
||||
const path = `/x/${secret}/discord/seat/STOP`;
|
||||
const native = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { code: 'ENOENT', syscall: 'open', path, errno: -2 });
|
||||
assert.equal(printable(native), 'cannot open a discord file (ENOENT)');
|
||||
const pathOnly = Object.assign(new Error(`EACCES ${path}`), { code: 'EACCES', path });
|
||||
assert.equal(printable(pathOnly), 'cannot access a discord file (EACCES)');
|
||||
const spawn = Object.assign(new Error(`spawn ${path} ENOENT`), { code: 'ENOENT', syscall: `spawn ${path}`, path });
|
||||
assert.equal(printable(spawn), 'cannot spawn a discord file (ENOENT)');
|
||||
const net = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:1'), { code: 'ECONNREFUSED', syscall: 'connect' });
|
||||
assert.equal(printable(net), 'cannot connect (ECONNREFUSED)');
|
||||
const odd = Object.assign(new Error(path), { code: `X ${path}`, syscall: path, path });
|
||||
assert.equal(printable(odd), 'cannot access a discord file (UNKNOWN)');
|
||||
const labelledRead = quietRead(Object.assign(new Error(path), { code: 'ENOENT', syscall: 'open', path }), 'token file');
|
||||
assert.equal(printable(labelledRead), 'cannot read token file (ENOENT)');
|
||||
assert.equal(printable(new DiscordError('STOP is present in discord/seat under the data root; remove it to run', 3)), 'STOP is present in discord/seat under the data root; remove it to run');
|
||||
assert.equal(printable(new Error('plain refusal')), 'plain refusal');
|
||||
for (const v of [native, pathOnly, spawn, odd]) assert.doesNotMatch(printable(v), /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
|
||||
test('cli: a native fs error reaching the top-level handler prints its call and code, never the path, and exits 1', t => {
|
||||
const { root } = scratch(t);
|
||||
const base = join(root, secret, 'dep');
|
||||
mkdirSync(base, { recursive: true });
|
||||
const repo = makeRepo(base);
|
||||
const dep = makeDeployment(base);
|
||||
initializeControl(dep.dataRoot);
|
||||
const run = (args) => spawnSync(process.execPath, [cli, ...args, '--config', dep.config, '--repo', repo], { encoding: 'utf8' });
|
||||
|
||||
// stop: the journal directory is a regular file, so ensureJournal's mkdir fails.
|
||||
writeFileSync(dep.journalDir, 'not a directory');
|
||||
let r = run(['stop', 'test-seat']);
|
||||
assert.equal(r.status, 1, r.stderr);
|
||||
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
|
||||
quiet(r.stderr + r.stdout, root);
|
||||
rmSync(dep.journalDir);
|
||||
|
||||
// run: the sessions directory is a regular file, so the claimed run's mkdir fails
|
||||
// after the claim; the claim is released and the exit stays 1.
|
||||
writeFileSync(join(dep.dataRoot, 'sessions'), 'not a directory');
|
||||
r = run(['run', 'test-seat']);
|
||||
assert.equal(r.status, 1, r.stderr);
|
||||
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
|
||||
quiet(r.stderr + r.stdout, root);
|
||||
assert.equal(existsSync(lockPath(dep.journalDir)), false, 'the claim is released');
|
||||
});
|
||||
@@ -75,6 +75,6 @@ test("notify: no dmRecipient, a non-0600 binding or token, or a missing binding
|
||||
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && e.exitCode === 2 && /no dmRecipient/.test(e.message));
|
||||
const loose = makeDeployment(makeRoot(), { dmRecipient: IDS.owner }, { tokenMode: 0o644 });
|
||||
assert.throws(() => openDirect({ dataRoot: loose.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && /mode 0600/.test(e.message) && !e.message.includes(TOKEN));
|
||||
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding not found/.test(e.message));
|
||||
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding absent not found/.test(e.message) && !e.message.includes(none.dataRoot));
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// #1873 (redact6): a discord refusal names the file, field or binding, never a host path.
|
||||
// The sentinel is a component of every scratch path, so a refusal that quotes a path
|
||||
// quotes the sentinel too; each test also checks the scratch root and the label.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs, { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import { readApprovals } from '../src/approvals.mjs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { bindingDataDir, bindingPath, loadDataRoot, readToken, resolveContextFiles, resolveToolRoots, validateBinding } from '../src/binding.mjs';
|
||||
import { ensureJournal, lockPath, ownerPath, readInboxIds, recover, requestStop, turnPath, unlock, writeTurn } from '../src/journal.mjs';
|
||||
import { gitPull, loadGitConfig } from '../src/git.mjs';
|
||||
import { loadSetsparkConfig, readKey } from '../src/setspark.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r6-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return { root, dir };
|
||||
}
|
||||
function labelled(label, root, Type = DiscordError) {
|
||||
return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.ok(!error.message.includes(root), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
test('config not found names the config, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => loadDataRoot(join(dir, 'config.json')), labelled('config not found', root));
|
||||
});
|
||||
|
||||
test('token file refusals name the token file, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => readToken({ tokenFile: join(dir, 'token') }), labelled('token file not found', root));
|
||||
const short = join(dir, 'short');
|
||||
writeFileSync(short, 'x', { mode: 0o600 });
|
||||
assert.throws(() => readToken({ tokenFile: short }), labelled('token file does not hold a bot token', root));
|
||||
const open = join(dir, 'open');
|
||||
writeFileSync(open, 'x'.repeat(30), { mode: 0o644 });
|
||||
assert.throws(() => readToken({ tokenFile: open }), labelled('token file must be mode 0600, is 0644', root));
|
||||
});
|
||||
|
||||
test('a context file refusal names its index in context.files', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const repo = join(dir, 'repo');
|
||||
mkdirSync(repo);
|
||||
assert.throws(() => resolveContextFiles({ context: { files: [join(dir, 'abs.md')] } }, repo), labelled('context file must be repository-relative: context.files[0]', root));
|
||||
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('missing context file: context.files[0]', root));
|
||||
});
|
||||
|
||||
test('a tool root refusal names the root, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const binding = { tools: { roots: [{ name: 'docs', path: join(dir, 'docs'), write: false }] } };
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs does not exist', root));
|
||||
mkdirSync(join(dir, 'data', 'docs'), { recursive: true });
|
||||
binding.tools.roots[0].path = join(dir, 'data', 'docs');
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs overlaps the data root', root));
|
||||
});
|
||||
|
||||
test('git and setspark key file refusals name the field', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const git = { branch: 'main', identity: 'sage', tokenFile: join(dir, 'gitea'), author: 'Sage <[email protected]>' };
|
||||
assert.throws(() => loadGitConfig(git, 'tools.roots[0].git'), labelled('tools.roots[0].git.tokenFile: not found', root, Error));
|
||||
const setspark = { baseUrl: 'https://setspark.example', keyFile: join(dir, 'key'), principal: 'sage' };
|
||||
assert.throws(() => loadSetsparkConfig(setspark), labelled('setspark.keyFile: not found', root, Error));
|
||||
});
|
||||
|
||||
for (const [name, label] of [['bot', 'discord/bot under the data root'], [secret, "the binding's journal directory"]]) {
|
||||
test(`journal refusals name ${label}`, t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const journal = join(dir, name);
|
||||
ensureJournal(journal);
|
||||
requestStop(journal, 'operator');
|
||||
assert.throws(() => recover(journal), labelled(`STOP is present in ${label}`, root));
|
||||
rmSync(join(journal, 'STOP'));
|
||||
mkdirSync(lockPath(journal));
|
||||
writeFileSync(ownerPath(journal), secret, { mode: 0o600 });
|
||||
assert.throws(() => unlock(journal), labelled(`Inspect run.lock/owner.json in ${label} by hand`, root));
|
||||
writeTurn(journal, 't1', {});
|
||||
assert.throws(() => writeTurn(journal, 't1', {}), labelled('turn record already exists: t1', root));
|
||||
});
|
||||
}
|
||||
|
||||
// Review round (#1873): keys, names, read errors and git output that could carry a host path.
|
||||
test('an unknown key that is not a plain name is described, never echoed; a plain one is still quoted', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const doc = { [join(dir, 'host')]: 1 };
|
||||
assert.throws(() => validateBinding(doc), labelled('unknown key that is not a plain name', root));
|
||||
assert.throws(() => loadGitConfig(doc, 'tools.roots[0].git'), labelled('tools.roots[0].git: unknown key that is not a plain name', root, Error));
|
||||
assert.throws(() => loadSetsparkConfig(doc, 'setspark'), labelled('setspark: unknown key that is not a plain name', root, Error));
|
||||
assert.throws(() => validateBinding({ colour: 1 }), labelled('unknown key "colour"', root));
|
||||
});
|
||||
|
||||
test('an invalid binding name or turn id states the constraint, never the value, and keeps its exit', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
for (const f of [bindingPath, bindingDataDir]) {
|
||||
assert.throws(() => f(dir, join(dir, 'bad')), e => labelled('invalid binding name (lowercase letters, digits, ., _ or -, at most 64)', root)(e) && e.exitCode === 4);
|
||||
}
|
||||
assert.throws(() => turnPath(dir, join(dir, 'bad')), e => labelled('invalid turn id (letters, digits, ., _ or -, at most 128)', root)(e) && e.exitCode === 1);
|
||||
});
|
||||
|
||||
test('an unreadable journal or approvals file, or a missing repository, names the file and code, never the path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(join(dir, 'approvals.jsonl'), '{}\n', { mode: 0o000 });
|
||||
writeFileSync(join(dir, 'inbox.jsonl'), '{}\n', { mode: 0o000 });
|
||||
assert.throws(() => readApprovals(dir), e => labelled('cannot read approvals.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES' && !(e instanceof DiscordError));
|
||||
assert.throws(() => readInboxIds(dir), e => labelled('cannot read inbox.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES');
|
||||
assert.throws(() => resolveContextFiles({ context: { files: ['x'] } }, join(dir, 'missing')), e => labelled('cannot read the repository (ENOENT)', root, Error)(e) && e.code === 'ENOENT');
|
||||
});
|
||||
|
||||
test('a git refusal drops stderr lines that carry a host path and a spawn failure names only its code', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const rootCfg = { real: dir, name: 'docs', git: { branch: 'main', identity: 'sage', tokenFile: join(dir, 'never-read'), author: { name: 'Sage', email: '[email protected]' } } };
|
||||
let calls = 0;
|
||||
const spawn = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: cannot access ${join(dir, 'repo')}\n` };
|
||||
assert.throws(() => gitPull(rootCfg, { spawn }), e => {
|
||||
assert.equal(e.reason, 'that root has no git');
|
||||
assert.equal(e.message, e.reason);
|
||||
assert.ok(!e.detail, String(e.detail));
|
||||
return labelled(e.reason, root, Error)(e);
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
// A repo-relative line survives: the detail still says what git said.
|
||||
calls = 0;
|
||||
const kept = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: not a git repository: .git\n` };
|
||||
assert.throws(() => gitPull(rootCfg, { spawn: kept }), e => e.detail === 'fatal: not a git repository: .git');
|
||||
const failed = () => ({ error: Object.assign(new Error(`spawn ${join(dir, 'git')} ENOENT`), { code: 'ENOENT' }) });
|
||||
assert.throws(() => gitPull(rootCfg, { spawn: failed }), e => labelled('git could not start (ENOENT)', root, Error)(e));
|
||||
});
|
||||
|
||||
// Re-check (#1873): a file removed between its check and its read surfaces a native
|
||||
// error. Each site names the file by label and code; the error's path never reaches the
|
||||
// message. `race` swaps one node:fs function so the target goes away right before the
|
||||
// real call, which then fails with a genuine ENOENT that names the path.
|
||||
function race(t, fn, target, remove) {
|
||||
const original = fs[fn];
|
||||
const swapped = function (p, ...rest) {
|
||||
if (String(p) === target) remove();
|
||||
return original.call(this, p, ...rest);
|
||||
};
|
||||
if (original.native) swapped.native = original.native;
|
||||
fs[fn] = swapped;
|
||||
syncBuiltinESMExports();
|
||||
t.after(() => {
|
||||
fs[fn] = original;
|
||||
syncBuiltinESMExports();
|
||||
});
|
||||
}
|
||||
|
||||
test('a token file removed between its check and its read names the token file', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const tokenFile = join(dir, 'token');
|
||||
writeFileSync(tokenFile, 'x'.repeat(30), { mode: 0o600 });
|
||||
race(t, 'readFileSync', tokenFile, () => rmSync(tokenFile));
|
||||
assert.throws(() => readToken({ tokenFile }), labelled('cannot read token file (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a context file removed between its check and its realpath names its index', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const repo = join(dir, 'repo');
|
||||
mkdirSync(repo);
|
||||
writeFileSync(join(repo, 'ok.md'), 'ok');
|
||||
writeFileSync(join(repo, `${secret}.md`), 'x');
|
||||
const target = join(repo, `${secret}.md`);
|
||||
race(t, 'realpathSync', target, () => rmSync(target));
|
||||
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('cannot read context file context.files[1] (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a tool root removed between its check and its realpath names the root', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const docs = join(dir, 'docs');
|
||||
mkdirSync(docs);
|
||||
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
|
||||
race(t, 'realpathSync', docs, () => rmSync(docs, { recursive: true }));
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('cannot read tool root docs (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a data root removed between its check and its realpath names the data root', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const docs = join(dir, 'docs');
|
||||
mkdirSync(docs);
|
||||
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
|
||||
const data = join(dir, 'data');
|
||||
mkdirSync(data);
|
||||
race(t, 'realpathSync', data, () => rmSync(data, { recursive: true }));
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: data }), labelled('cannot read the data root (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a setspark key file removed between its check and its read names keyFile', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const keyFile = join(dir, 'key');
|
||||
writeFileSync(keyFile, 'ssk_' + 'a'.repeat(40), { mode: 0o600 });
|
||||
const config = loadSetsparkConfig({ baseUrl: 'https://setspark.example', keyFile, principal: 'sage' });
|
||||
race(t, 'readFileSync', keyFile, () => rmSync(keyFile));
|
||||
assert.throws(() => readKey(config), labelled('cannot read keyFile (ENOENT)', root, Error));
|
||||
});
|
||||
@@ -6,10 +6,13 @@ import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
// #1873: the sentinel is a path component, so a refusal that quotes a path quotes it too.
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return dir;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
@@ -21,6 +24,7 @@ function safe(label, ErrorType, properties = {}) {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.ok(!error.message.includes(tmpdir()), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
@@ -33,15 +37,15 @@ import { readApprovals } from '../src/approvals.mjs';
|
||||
import { readInboxIds } from '../src/journal.mjs';
|
||||
import extension from '../extension/tools.mjs';
|
||||
import { TOOLS_ENV } from '../src/tools.mjs';
|
||||
for (const [name, read, relative] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
|
||||
for (const [name, read, relative, label] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json', 'config is not valid JSON'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json', 'binding binding is not valid JSON'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl', 'approvals.jsonl:2: not valid JSON'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl', 'inbox.jsonl:2: not valid JSON'],
|
||||
]) test(`${name} JSON refusal omits parser bytes`, t => {
|
||||
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
|
||||
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
|
||||
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
|
||||
assert.throws(() => read(file, root), safe(label, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('tools environment JSON refusal names the config key without its bytes', () => {
|
||||
const previous = process.env[TOOLS_ENV];
|
||||
@@ -52,7 +56,7 @@ test('tools environment JSON refusal names the config key without its bytes', ()
|
||||
test('config read refusal retains the filesystem code', t => {
|
||||
const file = scratch(t);
|
||||
assert.throws(() => loadDataRoot(file), error => {
|
||||
safe(file, DiscordError, { exitCode: 2 })(error);
|
||||
safe('config is not valid JSON', DiscordError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ function readSkill(name, root, size) {
|
||||
try { return lstatSync(path); } catch (e) { if (e.code === "ENOENT") throw skillNotEnabled(name); throw e; }
|
||||
};
|
||||
// scripts/skill.sh activate moves a real directory in, so a symlink here is refused, not followed.
|
||||
if (!found(join(root, name)).isDirectory()) throw new BundleError(`skill ${name} isn't a plain directory under ${root}`);
|
||||
if (!found(join(root, name)).isDirectory()) throw new BundleError(`skill ${name} isn't a plain directory under the enabled skills directory`);
|
||||
if (!found(join(root, name, "SKILL.md")).isFile()) throw new BundleError(`skill ${name}: SKILL.md isn't a plain file`);
|
||||
walk(join(root, name), "");
|
||||
return files;
|
||||
@@ -222,34 +222,36 @@ function readPersona(resolved, personaDir) {
|
||||
throw new BundleError(`instance ${resolved.instance} has a persona, but the bundle has no persona directory`, "persona-invalid");
|
||||
}
|
||||
const path = join(personaDir, resolved.business, `${resolved.instance}.md`);
|
||||
// #1873: a refusal names the file under the persona directory, never its host path.
|
||||
const shown = `${resolved.business}/${resolved.instance}.md in the persona directory`;
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT" && digest === undefined) return null;
|
||||
if (e.code === "ENOENT") throw new BundleError(`instance ${resolved.instance}: persona file ${path} doesn't exist`, "persona-invalid");
|
||||
if (e.code === "ELOOP") throw new BundleError(`instance ${resolved.instance}: persona file ${path} is a symlink`, "persona-invalid");
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${path} can't be opened (${e.code})`, "persona-invalid");
|
||||
if (e.code === "ENOENT") throw new BundleError(`instance ${resolved.instance}: persona file ${shown} doesn't exist`, "persona-invalid");
|
||||
if (e.code === "ELOOP") throw new BundleError(`instance ${resolved.instance}: persona file ${shown} is a symlink`, "persona-invalid");
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${shown} can't be opened (${e.code})`, "persona-invalid");
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't a regular file`, "persona-invalid");
|
||||
if (stat.uid !== process.getuid()) throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't owned by uid ${process.getuid()}`, "persona-invalid");
|
||||
if (stat.mode & 0o077) throw new BundleError(`instance ${resolved.instance}: persona file ${path} has group or other mode bits`, "persona-invalid");
|
||||
if (!stat.isFile()) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't a regular file`, "persona-invalid");
|
||||
if (stat.uid !== process.getuid()) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't owned by uid ${process.getuid()}`, "persona-invalid");
|
||||
if (stat.mode & 0o077) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} has group or other mode bits`, "persona-invalid");
|
||||
// Four bytes is the most one character takes in UTF-8.
|
||||
if (stat.size > PERSONA_CHARS * 4) throw new BundleError(`instance ${resolved.instance}: persona file ${path} is over ${PERSONA_CHARS} characters`, "persona-invalid");
|
||||
if (stat.size > PERSONA_CHARS * 4) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} is over ${PERSONA_CHARS} characters`, "persona-invalid");
|
||||
data = readFileSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
if (sha256(data) !== digest) {
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't the file persona.sha256 names`, "persona-mismatch");
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't the file persona.sha256 names`, "persona-mismatch");
|
||||
}
|
||||
let text;
|
||||
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't UTF-8 text`, "persona-invalid"); }
|
||||
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't UTF-8 text`, "persona-invalid"); }
|
||||
const fault = personaTextFault(text);
|
||||
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${path} ${fault}`, "persona-invalid");
|
||||
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} ${fault}`, "persona-invalid");
|
||||
return { name: resolved.vars["persona.name"], sha256: digest, text };
|
||||
}
|
||||
// Between the contract and "## This session". The contract wins on a conflict, and the broker's
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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
|
||||
@@ -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.";
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { configDir, loadOrganization } from "./organization-config.mjs";
|
||||
import { loadProject, projectFilePath } from "./project.mjs";
|
||||
import { checkCredentialRef } from "./credentials.mjs";
|
||||
import { systemVars, resolveInstance } from "./resolve.mjs";
|
||||
import { systemConfigProblem } from "./system-config-problem.mjs";
|
||||
|
||||
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
|
||||
@@ -67,21 +68,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
maxBuffer: 1024 * 1024,
|
||||
env,
|
||||
});
|
||||
// Render known key paths with fixed constraints; never relay child stderr.
|
||||
if (proc.status !== 0) {
|
||||
const problem = proc.stderr.trim();
|
||||
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
|
||||
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
|
||||
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
|
||||
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
|
||||
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
|
||||
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
|
||||
? "configuration is not valid JSON; expected readable JSON: "
|
||||
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
|
||||
: "expected valid system configuration: ";
|
||||
// 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})` : ""}`);
|
||||
}
|
||||
if (proc.status !== 0) fail(3, systemConfigProblem(proc, env));
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -90,19 +77,23 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
};
|
||||
const load = (id) => loadOrganization(id, { dir: configDir(env), rolesDir: resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles")) });
|
||||
|
||||
// Check credential references; return the problems and warnings.
|
||||
// Check credential references, each a [field, ref] pair; return the problems
|
||||
// and warnings. They name the field and the roots by label, never a path (#1873).
|
||||
const checkRefs = (refs, forbiddenRoots) => {
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
for (const ref of refs) {
|
||||
const result = checkCredentialRef(ref, { forbiddenRoots, env });
|
||||
for (const [where, ref] of refs) {
|
||||
const result = checkCredentialRef(ref, { forbiddenRoots, env, where });
|
||||
problems.push(...result.problems);
|
||||
warnings.push(...result.warnings);
|
||||
}
|
||||
return { problems, warnings };
|
||||
};
|
||||
const forbiddenRoots = (config, organization) =>
|
||||
[REPO, config.dataRoot, ...Object.values(organization.projects).filter((p) => p.kind !== "library").map((p) => p.root)];
|
||||
const forbiddenRoots = (config, organization) => [
|
||||
{ path: REPO, label: "the repository" },
|
||||
{ path: config.dataRoot, label: "the data root" },
|
||||
...Object.entries(organization.projects).filter(([, p]) => p.kind !== "library").map(([id, p]) => ({ path: p.root, label: `the root of project ${id}` })),
|
||||
];
|
||||
|
||||
const validate = (args) => {
|
||||
if (args.length !== 1) usage();
|
||||
@@ -125,8 +116,8 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
projects[id] = "valid";
|
||||
}
|
||||
const refs = [
|
||||
...Object.values(organization.tracker?.sync?.credentials ?? {}),
|
||||
...Object.values(organization.roles).flatMap((r) => Object.values(r.credentials)),
|
||||
...Object.entries(organization.tracker?.sync?.credentials ?? {}).map(([service, ref]) => [`tracker.sync.credentials.${service}`, ref]),
|
||||
...Object.entries(organization.roles).flatMap(([instance, r]) => Object.entries(r.credentials).map(([service, ref]) => [`roles.${instance}.credentials.${service}`, ref])),
|
||||
];
|
||||
const checked = checkRefs(refs, forbiddenRoots(config, organization));
|
||||
warnings.push(...checked.warnings);
|
||||
@@ -159,11 +150,12 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
|
||||
let loaded = null;
|
||||
if (project !== null) {
|
||||
const entry = Object.hasOwn(organization.projects, project) ? organization.projects[project] : null;
|
||||
if (!entry) fail(2, `business ${organization.id} declares no project ${JSON.stringify(project)}`);
|
||||
// The flag is quoted only when it is a plain name, never a path (#1873).
|
||||
if (!entry) fail(2, `business ${organization.id} declares no project ${/^[a-z0-9][a-z0-9._-]{0,63}$/.test(project) ? JSON.stringify(project) : "by that name"}`);
|
||||
if (entry.kind !== "library") loaded = loadProject(entry.root);
|
||||
}
|
||||
const resolved = resolveInstance({ system: systemVars(config), business: organization, project: loaded, instance });
|
||||
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, organization));
|
||||
const checked = checkRefs(Object.entries(resolved.credentials).map(([service, ref]) => [`${instance} ${service}`, ref]), forbiddenRoots(config, organization));
|
||||
for (const w of checked.warnings) say(`warning: ${w}`);
|
||||
if (checked.problems.length > 0) {
|
||||
for (const p of checked.problems) say(p);
|
||||
|
||||
@@ -44,7 +44,7 @@ export function parseCredentialRef(ref, service, where, { store = null } = {}) {
|
||||
}
|
||||
out.file = ref.file;
|
||||
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
|
||||
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
|
||||
refuse(`${where}.file credential-not-yours: the file is in the agents' store but not this agent's path`);
|
||||
}
|
||||
} else {
|
||||
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
|
||||
@@ -68,11 +68,16 @@ function inside(root, path) {
|
||||
// Check a parsed reference against the filesystem and the calendar.
|
||||
// Returns { problems: [...], warnings: [...] }; a caller that finds any
|
||||
// problem refuses. `forbiddenRoots` are directories a token file must not
|
||||
// sit in (the repository, dataRoot). `now` is a Date.
|
||||
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) {
|
||||
// sit in (the repository, dataRoot): each a path, or { path, label } so a
|
||||
// problem can name it. `where` is the reference's field (for example
|
||||
// roles.coder.credentials.gitea). `now` is a Date.
|
||||
// A problem or warning names the reference by its field, its service and its
|
||||
// kind, and an env reference by its checked variable name. It never quotes the
|
||||
// token file's path or a root's path (#1873).
|
||||
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid(), where = null } = {}) {
|
||||
const problems = [];
|
||||
const warnings = [];
|
||||
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
|
||||
const label = `${where ?? ref.service} ${ref.store ? "store file" : ref.file ? "file" : `env ${ref.env}`}`;
|
||||
let notIssued = false;
|
||||
if (ref.file) {
|
||||
let stat = null;
|
||||
@@ -96,14 +101,16 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
|
||||
} catch {
|
||||
problems.push(`${label}: path can't be resolved`);
|
||||
}
|
||||
for (const root of forbiddenRoots) {
|
||||
for (const entry of forbiddenRoots) {
|
||||
const root = typeof entry === "string" ? entry : entry.path;
|
||||
const name = typeof entry === "string" ? "a forbidden root" : entry.label;
|
||||
let realRoot = root;
|
||||
try {
|
||||
realRoot = realpathSync(root);
|
||||
} catch {
|
||||
// A root that doesn't exist yet can't contain the file.
|
||||
}
|
||||
if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`);
|
||||
if (inside(realRoot, real)) problems.push(`${label}: inside ${name}; token files live outside the repository and dataRoot`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,13 +102,13 @@ export function checkDefinition(name, entry, { assertClean = NO_STORE, where = "
|
||||
|
||||
// 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 hires nobody: hireable stays empty.
|
||||
// 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,
|
||||
hireable: [], instructions: composition.instructions, personality: composition.personality,
|
||||
instructions: composition.instructions, personality: composition.personality,
|
||||
rev: entry.rev, from: entry.from === undefined ? null : { ...entry.from },
|
||||
};
|
||||
}
|
||||
@@ -130,8 +130,10 @@ export function checkDefinitions(definitions, file, options = {}) {
|
||||
|
||||
// The instances that name `org:<name>`, sorted.
|
||||
export function instancesNaming(document, name) {
|
||||
return Object.entries(document?.roles ?? {})
|
||||
.filter(([, entry]) => entry?.definition === ORG_PREFIX + name).map(([instance]) => instance).sort();
|
||||
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 ----
|
||||
@@ -162,9 +164,10 @@ export function readTemplate(rolesDir, name) {
|
||||
} catch {
|
||||
refuse(`template ${name} is not valid JSON`);
|
||||
}
|
||||
const role = validateRoleDocument(document, file, { hireable: false });
|
||||
const role = validateRoleDocument(document, file);
|
||||
if (role.roleVersion !== 2) refuse(`template ${name} is role version 1 and has no contract`);
|
||||
const contract = readFileSync(role.contractPath);
|
||||
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) };
|
||||
}
|
||||
|
||||
|
||||
@@ -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))};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -6,34 +6,56 @@ import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { parseJsonFileText } from './util.mjs';
|
||||
|
||||
// `path` is kept only when it is a document field ($.projects.alpha.root): a
|
||||
// host path never rides on the error, where a caller could print or serialise
|
||||
// it (#1873).
|
||||
export class OrgError extends Error {
|
||||
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (path) this.path = path; }
|
||||
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (typeof path === 'string' && path.startsWith('$')) this.path = path; }
|
||||
}
|
||||
// A Node system error names the host path in its message, and a spawn's syscall names the
|
||||
// program. quietMessage is the call's first word, the file's label and the error code, or null
|
||||
// for an error that is not a system error; quiet puts it in the message. The code and syscall
|
||||
// fields stay (#1873, from PR #1916's org-service quiet).
|
||||
const quieted = new WeakSet();
|
||||
export function quietMessage(e, what) {
|
||||
if (!e || typeof e !== 'object') return null;
|
||||
if (quieted.has(e)) return e.message;
|
||||
return typeof e.syscall === 'string' && typeof e.code === 'string' ? `cannot ${e.syscall.split(' ')[0]} ${what} (${e.code})` : null;
|
||||
}
|
||||
export function quiet(e, what) {
|
||||
const message = quietMessage(e, what);
|
||||
if (message !== null && !quieted.has(e)) { e.message = message; quieted.add(e); }
|
||||
return e;
|
||||
}
|
||||
// A filesystem call's error keeps its code and loses its path: rethrow(what) for a promise,
|
||||
// quiet(e, what) in a catch.
|
||||
const rethrow = what => e => { throw quiet(e, what); };
|
||||
const CONFIG = 'the config directory';
|
||||
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
|
||||
function idCheck(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid organization id'); }
|
||||
export function orgRoot(dataRoot, id) { idCheck(id); if (!isAbsolute(dataRoot) || normalize(dataRoot) !== dataRoot) fail('org-invalid', 'data root must be a normalized absolute path'); return join(dataRoot, 'org-runtime', id); }
|
||||
async function safeDir(path, create = false) {
|
||||
async function safeDir(path, create = false, what = 'a directory') {
|
||||
if (!isAbsolute(path) || resolve(path) !== path) fail('org-mismatch', 'directory must be a normalized absolute path', path);
|
||||
let current = parse(path).root;
|
||||
for (const part of path.slice(current.length).split('/').filter(Boolean)) {
|
||||
current = join(current, part);
|
||||
try { const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'directory path contains a symlink or non-directory', current); }
|
||||
catch (e) { if (e.code !== 'ENOENT' || !create) throw e; await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw err; }); const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe directory', current); }
|
||||
catch (e) { if (e.code !== 'ENOENT' || !create) throw quiet(e, what); await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw quiet(err, what); }); const st = await fs.lstat(current).catch(rethrow(what)); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe directory', current); }
|
||||
}
|
||||
}
|
||||
async function readPrivate(file) {
|
||||
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return parseJsonFileText(await fd.readFile('utf8'), file, 'metadata file'); } finally { await fd.close(); }
|
||||
}
|
||||
async function privateDir(path) { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
|
||||
async function privateDir(path, what = 'a directory') { const st = await fs.lstat(path).catch(rethrow(what)); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
|
||||
function validateRegistry(doc, file) {
|
||||
if (!doc || doc.version !== 1 || typeof doc.instance !== 'string' || !doc.orgs || typeof doc.orgs !== 'object' || Array.isArray(doc.orgs)) fail('org-mismatch', 'invalid registry', file);
|
||||
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string') fail('org-mismatch', 'invalid registry entry', file); }
|
||||
return doc;
|
||||
}
|
||||
export async function readRegistry({ configDir }) {
|
||||
await safeDir(configDir); await privateDir(configDir); const file = join(configDir, 'org-runtime.json'); let doc;
|
||||
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e.message}`, file); }
|
||||
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
|
||||
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
|
||||
return validateRegistry(doc, file);
|
||||
}
|
||||
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
|
||||
@@ -49,8 +71,8 @@ export async function readRegistryLocked(registry, ctx) {
|
||||
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
|
||||
}
|
||||
export async function withRegistry(ctx, fn, { write = true } = {}) {
|
||||
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
|
||||
await safeDir(ctx.configDir, false, CONFIG); await privateDir(ctx.configDir, CONFIG); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
|
||||
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw quiet(e, 'the registry lock'); await new Promise(r => setTimeout(r, 10)); } }
|
||||
if (!fd) fail('org-locked', 'registry lock is held', lock);
|
||||
try {
|
||||
const persisted = await readRegistry(ctx);
|
||||
@@ -62,10 +84,10 @@ export async function withRegistry(ctx, fn, { write = true } = {}) {
|
||||
finally { heldRegistries.delete(registry); }
|
||||
if (write) {
|
||||
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
|
||||
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
|
||||
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } catch (e) { throw quiet(e, 'the registry file'); } finally { await fs.rm(temp, { force: true }).catch(rethrow('the registry file')); }
|
||||
}
|
||||
return result;
|
||||
} finally { await fd.close(); await fs.unlink(lock); }
|
||||
} finally { await fd.close(); await fs.unlink(lock).catch(rethrow('the registry lock')); }
|
||||
}
|
||||
// The broker's existing organization writer is synchronous. It shares the
|
||||
// registry lock with the async service and refuses contention rather than
|
||||
@@ -74,16 +96,16 @@ export function withRegistryWriter(configDir, fn) {
|
||||
const file = join(configDir, 'org-runtime.json');
|
||||
const lock = join(configDir, 'org-runtime.lock'); let lockFd;
|
||||
try { lockFd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
||||
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'registry lock is held', lock); throw e; }
|
||||
const release = () => { closeSync(lockFd); unlinkSync(lock); };
|
||||
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'registry lock is held', lock); throw quiet(e, 'the registry lock'); }
|
||||
const release = () => { closeSync(lockFd); try { unlinkSync(lock); } catch (e) { throw quiet(e, 'the registry lock'); } };
|
||||
let pending = false;
|
||||
try {
|
||||
let fd, registry = null;
|
||||
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
catch (e) { if (e.code !== 'ENOENT') throw quiet(e, 'the registry file'); }
|
||||
if (fd !== undefined) {
|
||||
try {
|
||||
const dir = lstatSync(configDir);
|
||||
let dir; try { dir = lstatSync(configDir); } catch (e) { throw quiet(e, CONFIG); }
|
||||
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
|
||||
@@ -93,7 +115,8 @@ export function withRegistryWriter(configDir, fn) {
|
||||
const publish = () => {
|
||||
const temp = join(configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
|
||||
try { writeFileSync(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); renameSync(temp, file); }
|
||||
finally { try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
|
||||
catch (e) { throw quiet(e, 'the registry file'); }
|
||||
finally { try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw quiet(e, 'the registry file'); } }
|
||||
};
|
||||
const result = fn(registry, publish);
|
||||
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
|
||||
@@ -108,10 +131,10 @@ export async function reserveOrg(ctx, id) {
|
||||
return withRegistry(ctx, async registry => {
|
||||
if (Object.hasOwn(registry.orgs, id)) fail('org-exists', 'organization already reserved', root);
|
||||
for (const entry of Object.values(registry.orgs)) if (entry.root === root || root.startsWith(entry.root + '/') || entry.root.startsWith(root + '/')) fail('org-overlap', 'organization roots overlap', root);
|
||||
await safeDir(ctx.dataRoot); await privateDir(ctx.dataRoot);
|
||||
await safeDir(dirname(root), true); await privateDir(dirname(root));
|
||||
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw e; }
|
||||
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
await safeDir(ctx.dataRoot, false, 'the data root'); await privateDir(ctx.dataRoot, 'the data root');
|
||||
await safeDir(dirname(root), true, 'org-runtime under the data root'); await privateDir(dirname(root), 'org-runtime under the data root');
|
||||
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw quiet(e, 'the organization root'); }
|
||||
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' }).catch(rethrow('the organization marker'));
|
||||
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1 }; registry.orgs[id] = entry; return { ...entry };
|
||||
});
|
||||
}
|
||||
@@ -132,14 +155,14 @@ export async function activateOrg(ctx, id) {
|
||||
// no document yet; preserve the registry's existing activation contract.
|
||||
const dir = join(ctx.configDir, 'businesses'), file = join(dir, `${id}.json`);
|
||||
try {
|
||||
await safeDir(dir);
|
||||
await safeDir(dir, false, 'businesses in the config directory');
|
||||
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 & 0o022)) fail('org-mismatch', 'unsafe organization document', file);
|
||||
registry.orgs[id].digest = createHash('sha256').update(await fd.readFile()).digest('hex');
|
||||
} finally { await fd.close(); }
|
||||
} catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
} catch (e) { if (e.code !== 'ENOENT') throw quiet(e, 'the organization document'); }
|
||||
registry.orgs[id].state = 'active';
|
||||
return { ...registry.orgs[id] };
|
||||
});
|
||||
@@ -148,12 +171,12 @@ export async function activateOrg(ctx, id) {
|
||||
// Serialize bus launch with organization lifecycle operations.
|
||||
// A leftover lock is never guessed stale; the operator must inspect it.
|
||||
export async function withOrgLifecycle(ctx, id, fn) {
|
||||
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
|
||||
idCheck(id); await safeDir(ctx.configDir, false, CONFIG); await privateDir(ctx.configDir, CONFIG);
|
||||
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`); let fd;
|
||||
try { fd = await fs.open(path, 'wx', 0o600); }
|
||||
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'organization lifecycle lock is held; inspect the lock and resume'); throw e; }
|
||||
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'organization lifecycle lock is held; inspect the lock and resume'); throw quiet(e, 'the lifecycle lock'); }
|
||||
try { await fd.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n'); return await fn(); }
|
||||
finally { await fd.close(); await fs.unlink(path); }
|
||||
finally { await fd.close(); await fs.unlink(path).catch(rethrow('the lifecycle lock')); }
|
||||
}
|
||||
|
||||
// Lifecycle locks carry {pid, at}; registry locks are empty and cannot be
|
||||
@@ -173,7 +196,7 @@ function inspectRecoveryFence(path) {
|
||||
} finally { closeSync(fd); }
|
||||
}
|
||||
export async function orgLock(ctx, id, { clear = false } = {}) {
|
||||
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
|
||||
idCheck(id); await safeDir(ctx.configDir, false, CONFIG); await privateDir(ctx.configDir, CONFIG);
|
||||
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`);
|
||||
const guard = `${path}.clear`; let guardFd, self;
|
||||
if (clear) {
|
||||
@@ -182,16 +205,16 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
|
||||
for (;;) {
|
||||
try { guardFd = openSync(guard, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); break; }
|
||||
catch (e) {
|
||||
if (e.code !== 'EEXIST') throw e;
|
||||
if (e.code !== 'EEXIST') throw quiet(e, 'the recovery fence');
|
||||
const holder = inspectRecoveryFence(guard);
|
||||
if (!holder) continue;
|
||||
if (holder.unreadable) fail('org-locked', `unreadable recovery fence ${guard}; remove it by hand`, guard);
|
||||
if (holder.unreadable) fail('org-locked', `unreadable recovery fence org-lifecycle-${id}.lock.clear in the config directory; remove it by hand`);
|
||||
if (holder.alive !== false) fail('org-locked', `lock recovery is already in progress by pid ${holder.pid}`, guard);
|
||||
let current; try { current = lstatSync(guard); } catch (err) { if (err.code === 'ENOENT') continue; throw err; }
|
||||
let current; try { current = lstatSync(guard); } catch (err) { if (err.code === 'ENOENT') continue; throw quiet(err, 'the recovery fence'); }
|
||||
if (current.dev !== holder.dev || current.ino !== holder.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
try { renameSync(guard, `${guard}.stale-${stamp}-${randomBytes(4).toString('hex')}`); }
|
||||
catch (err) { if (err.code === 'ENOENT') continue; throw err; }
|
||||
catch (err) { if (err.code === 'ENOENT') continue; throw quiet(err, 'the recovery fence'); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,7 +224,7 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
|
||||
const reportFence = result => { if (fence) { const { dev, ino, ...visible } = fence; result.fence = visible; } return result; };
|
||||
let fd;
|
||||
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||||
catch (e) { if (e.code === 'ENOENT') return reportFence(clear ? { held: false, cleared: false } : { held: false }); throw e; }
|
||||
catch (e) { if (e.code === 'ENOENT') return reportFence(clear ? { held: false, cleared: false } : { held: false }); throw quiet(e, 'the lifecycle lock'); }
|
||||
try {
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('org-mismatch', 'unsafe lifecycle lock', path);
|
||||
@@ -210,10 +233,11 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
|
||||
let status; try { status = processState(holder.pid); } catch { fail('org-mismatch', 'lifecycle lock holder is uncertain', path); }
|
||||
const result = { held: true, pid: holder.pid, start: holder.at, alive: status.alive };
|
||||
if (clear) {
|
||||
if (status.alive) fail('org-locked', `lifecycle lock held by pid ${holder.pid} since ${holder.at} (alive)`, path);
|
||||
const current = lstatSync(path);
|
||||
if (status.alive) fail('org-locked', `lifecycle lock held by pid ${holder.pid} since ${new Date(holder.at).toISOString()} (alive)`, path);
|
||||
let current; try { current = lstatSync(path); } catch (e) { throw quiet(e, 'the lifecycle lock'); }
|
||||
if (current.dev !== st.dev || current.ino !== st.ino) fail('org-locked', 'lifecycle lock changed during recovery', path);
|
||||
unlinkSync(path); result.cleared = true;
|
||||
try { unlinkSync(path); } catch (e) { throw quiet(e, 'the lifecycle lock'); }
|
||||
result.cleared = true;
|
||||
}
|
||||
return reportFence(result);
|
||||
} finally { closeSync(fd); }
|
||||
@@ -221,9 +245,9 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
|
||||
if (guardFd !== undefined) {
|
||||
const owned = fstatSync(guardFd);
|
||||
try {
|
||||
const current = lstatSync(guard);
|
||||
let current; try { current = lstatSync(guard); } catch (e) { throw quiet(e, 'the recovery fence'); }
|
||||
if (owned.dev !== current.dev || owned.ino !== current.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
|
||||
unlinkSync(guard);
|
||||
try { unlinkSync(guard); } catch (e) { throw quiet(e, 'the recovery fence'); }
|
||||
} finally { closeSync(guardFd); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,9 @@ 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',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { validateBusinessDocument, httpsUrl } from './organization-config.mjs';
|
||||
import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
|
||||
import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
|
||||
import { loadInstalled } from '../../plugins/src/loader.mjs';
|
||||
import { BINDING_NAME } from '../../discord/src/binding.mjs';
|
||||
import { parseJsonFileText } from './util.mjs';
|
||||
@@ -21,11 +21,6 @@ function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-
|
||||
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
|
||||
// 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')}`);
|
||||
|
||||
@@ -135,7 +135,7 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions)
|
||||
requireId(instance, `${where} instance name`);
|
||||
const at = `${where}.${instance}`;
|
||||
requireObject(entry, at);
|
||||
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
|
||||
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`);
|
||||
@@ -147,6 +147,9 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions)
|
||||
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.
|
||||
@@ -157,12 +160,12 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions)
|
||||
}
|
||||
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`);
|
||||
}
|
||||
@@ -171,9 +174,10 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions)
|
||||
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.
|
||||
@@ -300,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`);
|
||||
}
|
||||
|
||||
@@ -448,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");
|
||||
@@ -456,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 => {
|
||||
@@ -467,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
|
||||
@@ -538,5 +584,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, 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)) };
|
||||
}
|
||||
@@ -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 ${name}.json`), 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,9 +201,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// One refusal line for a failed `mosaic-config.mjs validate`, shared by `mosaic business` /
|
||||
// `mosaic org config` and the cli's system config load (#1873). Its own module, so the
|
||||
// config-command module's export list stays what importers pin.
|
||||
|
||||
// A failed `mosaic-config.mjs validate` as one refusal line. Render known key paths with fixed
|
||||
// constraints; never relay the child's stderr, which can quote a host path or a config value.
|
||||
// The config is named by where it came from, never by host path (PR #1916): `label` when the
|
||||
// caller knows it (the cli's --instance load, #1873), else from MOSAIC_CONFIG.
|
||||
export function systemConfigProblem(proc, env, label = null) {
|
||||
const problem = String(proc.stderr ?? "").trim();
|
||||
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
|
||||
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
|
||||
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
|
||||
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
|
||||
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
|
||||
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
|
||||
? "configuration is not valid JSON; expected readable JSON: "
|
||||
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
|
||||
: "expected valid system configuration: ";
|
||||
return `system config problem (mosaic-config exit ${proc.status}): ${detail}${label ?? (env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config")}${proc.error?.code ? ` (${proc.error.code})` : ""}`;
|
||||
}
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
@@ -124,8 +124,8 @@ test("validate: credential reference problems exit 2 and name each one", () => {
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 2);
|
||||
assert.equal(r.out, "");
|
||||
assert.match(r.err, /cto-gitea\.token: mode 644/);
|
||||
assert.match(r.err, /coder\.token: inside .*data/);
|
||||
assert.match(r.err, /roles\.cto\.credentials\.gitea file: mode 644/);
|
||||
assert.match(r.err, /roles\.coder\.credentials\.vikunja file: inside the data root/);
|
||||
assert.match(r.err, /expired on 2026-01-01/);
|
||||
assert.match(r.err, /3 credential reference problem/);
|
||||
});
|
||||
@@ -140,8 +140,8 @@ test("validate: a not-issued store agent dated today passes, and an issued one w
|
||||
});
|
||||
const r = run(s, "validate", "acme");
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.match(r.err, /gitea file .*secrets\/agents\/acme\/coder\/gitea: not issued/);
|
||||
assert.match(r.err, /vikunja file .*secrets\/agents\/acme\/coder\/vikunja: not issued/);
|
||||
assert.match(r.err, /roles\.coder\.credentials\.gitea store file: not issued/);
|
||||
assert.match(r.err, /roles\.coder\.credentials\.vikunja store file: not issued/);
|
||||
assert.doesNotMatch(r.err, /expire|rotation/);
|
||||
|
||||
const issued = setup((doc) => {
|
||||
@@ -150,7 +150,7 @@ test("validate: a not-issued store agent dated today passes, and an issued one w
|
||||
tokenFile(join(issued.root, "config", "secrets", "agents", "acme", "coder"), "vikunja");
|
||||
const e = run(issued, "validate", "acme");
|
||||
assert.equal(e.code, 2);
|
||||
assert.match(e.err, /vikunja file .*coder\/vikunja: expired on 2026-01-01/);
|
||||
assert.match(e.err, /roles\.coder\.credentials\.vikunja store file: expired on 2026-01-01/);
|
||||
});
|
||||
|
||||
test("validate: a token file inside the repository is refused", () => {
|
||||
@@ -211,5 +211,10 @@ test("resolve: refusals", () => {
|
||||
assert.equal(run(s, "resolve", "acme", "ghost").code, 2);
|
||||
assert.equal(run(s, "resolve", "acme", "constructor").code, 2);
|
||||
assert.match(run(s, "resolve", "acme", "pm", "--project", "ghost").err, /declares no project "ghost"/);
|
||||
// #1873: a --project value that is not a plain name is never echoed.
|
||||
const odd = run(s, "resolve", "acme", "pm", "--project", "/srv/sk-test-NOT-A-SECRET-1234/x");
|
||||
assert.equal(odd.code, 2);
|
||||
assert.match(odd.err, /declares no project by that name/);
|
||||
assert.doesNotMatch(odd.err, /sk-test|NOT-A-SECRET|\/srv\//);
|
||||
assert.equal(run(s, "resolve", "acme", "pm", "--project", "stack").code, 4);
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { loadRole, BusinessError } from "../src/index.mjs";
|
||||
import { GATED_ONLY, HIRE_ALLOWED, unhireableAction, ROUTED } from "../src/vocabulary.mjs";
|
||||
import { GATED_ONLY, ROUTED } from "../src/vocabulary.mjs";
|
||||
import {
|
||||
buildRegistry, checkComposition, COMPOSITION_BOUNDS, COMPOSITION_KEYS, FLAG_RESOLVE, FLAG_ABOVE_CHIEF,
|
||||
} from "../src/composition.mjs";
|
||||
@@ -319,7 +319,8 @@ test("a plugin refuses if it reuses a core name or declares a hard wait", () =>
|
||||
assert.ok(buildRegistry({ plugins: [{ name: "forge", actions: ["build.run"] }] }).routed.includes("forge.build.run"));
|
||||
});
|
||||
|
||||
test("HIRE_ALLOWED and unhireableAction stay until A4", () => {
|
||||
assert.ok(Array.isArray(HIRE_ALLOWED) && HIRE_ALLOWED.length > 0);
|
||||
assert.equal(typeof unhireableAction, "function");
|
||||
test("A4 removes the legacy hire ceiling", async () => {
|
||||
const vocabulary=await import("../src/vocabulary.mjs");
|
||||
assert.equal(Object.hasOwn(vocabulary,"HIRE_ALLOWED"),false);
|
||||
assert.equal(Object.hasOwn(vocabulary,"unhireableAction"),false);
|
||||
});
|
||||
@@ -1,12 +1,14 @@
|
||||
// #1873 T1: organization definitions and templates (roles-and-rails design sections 4, 5 and 13).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import { syncBuiltinESMExports } from "node:module";
|
||||
import { appendFileSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { organizationWriter } from "../src/organization.mjs";
|
||||
import { validateBusinessDocument } from "../src/organization-config.mjs";
|
||||
import { definitionVerbs, DEFINITION_VERBS } from "../src/definition-verbs.mjs";
|
||||
import { templateHash, templateHashes, diffDefinition, lineDiff } from "../src/definitions.mjs";
|
||||
import { readTemplate, templateHash, templateHashes, diffDefinition, lineDiff } from "../src/definitions.mjs";
|
||||
import { resolveInstance } from "../src/resolve.mjs";
|
||||
import { ROLE_RIGHTS, VERB_RIGHTS, normalizeRights } from "../src/org-rights.mjs";
|
||||
import { businessDoc, rolesCopy, systemFor, tmp, writeJson } from "./helpers.mjs";
|
||||
@@ -349,3 +351,15 @@ test("T1 a write that adds or changes a definition refuses without the credentia
|
||||
// A removal writes no role text, so it needs no secret check.
|
||||
assert.equal(unchecked.remove("acme", { revision: rev(f), rev: 1, name: "scout" }, OWNER).removedRev, 1);
|
||||
});
|
||||
|
||||
test('A4 T1 hand-off: a failed second contract read names the template, never the path', t => {
|
||||
const root=tmp(),rolesDir=rolesCopy(root),read=fs.default.readFileSync;
|
||||
let contracts=0;
|
||||
fs.default.readFileSync=function(file,...args){
|
||||
if(file===join(rolesDir,'reviewer.md') && ++contracts===2)throw new Error(`cannot read ${root}/sk-test-NOT-A-SECRET-1234`);
|
||||
return read(file,...args);
|
||||
};
|
||||
syncBuiltinESMExports();
|
||||
try { assert.throws(()=>readTemplate(rolesDir,'reviewer'),{message:'template reviewer contract is unavailable'}); }
|
||||
finally { fs.default.readFileSync=read;syncBuiltinESMExports();rmSync(root,{recursive:true,force:true}); }
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { hireDecisions, authorizeHire } from '../src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
import { readTemplate } from '../src/definitions.mjs';
|
||||
import { loadBusiness } from '../src/organization-config.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from './helpers.mjs';
|
||||
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read carefully. sk-test-NOT-A-SECRET-1234' };
|
||||
const OWNER = { actor: 'jason', agent: false, rights: ['org.hire'], owner: true, instanceOwner: true, subject: { provider: 'test', iss: 'https://test', sub: 'owner' } };
|
||||
function setup(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'a4-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root), file = writeJson(join(root,'businesses/demo.json'), {...businessDoc(root,'demo'),plugins:{harness:['pi']}});
|
||||
const writer = organizationWriter({ dir: root, rolesDir });
|
||||
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const service = hireDecisions({ writer, rolesDir, configDir: root, dataRoot, assertClean: () => {} });
|
||||
const request = (extra = {}) => ({ op: 'hire', level: 'org', name: 'scout', role: { inline: INLINE }, route: { harness: 'pi', model: 'claude-opus-5-5' }, reason: 'Need a reader', ...extra });
|
||||
const prepare = (extra, id = '11111111-1111-4111-8111-111111111111') => service.prepare('demo', request(extra), { id, expiresAt: '2099-01-01T00:00:00.000Z' });
|
||||
return { root, rolesDir, file, writer, service, prepare, request };
|
||||
}
|
||||
test('A4 composition flags and credential lines are computed, never supplied; public records omit instructions', async t => {
|
||||
const { prepare } = setup(t);
|
||||
for (const field of ['flagged','credentialLines','digest','expiresAt']) await assert.rejects(prepare({ [field]: [] }), { code: 'invalid-request' });
|
||||
const p = await prepare();
|
||||
assert.equal(p.flagged.length, 0);
|
||||
assert.ok(!JSON.stringify(p).includes(INLINE.instructions));
|
||||
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
|
||||
const c = await prepare({ role: { inline: { ...INLINE, credentials: [{ service: 'gitea', scopes: ['read:repository'] }] } } }, '22222222-2222-4222-8222-222222222222');
|
||||
assert.deepEqual(c.credentialLines, [{ service: 'gitea', scope: 'read:repository' }]);
|
||||
});
|
||||
test('A4 only org.hire people resolve; flagged, credential and root decisions need their owner', () => {
|
||||
const member = { ...OWNER, owner: false, instanceOwner: false };
|
||||
const plain = { level: 'org', flagged: [], credentialLines: [] };
|
||||
assert.doesNotThrow(() => authorizeHire(plain, member));
|
||||
for (const p of [{ ...plain, flagged: [{ field: 'tools' }] }, { ...plain, credentialLines: [{ service: 'gitea' }] }, { ...plain, level: 'root' }]) assert.throws(() => authorizeHire(p, member), { code: 'hire-owner-required' });
|
||||
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: ['decide'] }), { code: 'right-required' });
|
||||
assert.throws(() => authorizeHire(plain, { ...OWNER, agent: true }), { code: 'human-required' });
|
||||
});
|
||||
test('A4 org approval writes the pinned definition, route and a values-free writer receipt', async t => {
|
||||
const { prepare, service, writer, root, file } = setup(t);
|
||||
const p = await prepare();
|
||||
const before = readFileSync(file);
|
||||
await assert.rejects(service.apply('demo', p.id, { ...OWNER, digest: '0'.repeat(64) }), { code: 'decision-stale' });
|
||||
assert.deepEqual(readFileSync(file), before);
|
||||
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const doc = writer.read('demo').document;
|
||||
assert.equal(doc.roles.scout.definition, 'org:hire-11111111-1111-4111-8111-111111111111');
|
||||
assert.equal(doc.roles.scout.vars.model, 'claude-opus-5-5');
|
||||
assert.ok(doc.launch.instances.includes('scout'));
|
||||
const log = readFileSync(join(root,'businesses/.history/demo/changes.jsonl'),'utf8');
|
||||
assert.ok(log.includes(p.id)); assert.ok(!log.includes(INLINE.instructions)); assert.ok(!log.includes('sk-test-NOT-A-SECRET-1234'));
|
||||
});
|
||||
test('A4 approved credential hires stay unbound until all requested bindings exist', async t => {
|
||||
const { prepare, service, root, rolesDir } = setup(t);
|
||||
const p = await prepare({ role: { inline: { ...INLINE, credentials: [{ service: 'gitea', scopes: ['read:repository'] }] } } });
|
||||
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const loaded = loadBusiness('demo',{ dir: root, rolesDir });
|
||||
assert.deepEqual(loaded.roles.scout.unbound, ['gitea']);
|
||||
});
|
||||
test('A4 project approval uses the project writer and changes only its agent plus the pinned definition', async t => {
|
||||
const { prepare, service, writer } = setup(t);
|
||||
const before = writer.read('demo').document;
|
||||
const p = await prepare({ level: 'project', project: 'stack' });
|
||||
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const after = writer.read('demo').document;
|
||||
assert.deepEqual(after.roles, before.roles);
|
||||
assert.deepEqual(after.launch, before.launch);
|
||||
assert.equal(after.projects.stack.agents.scout.vars.harness, 'pi');
|
||||
});
|
||||
test('A4 root approval uses A2 root writer, records decision id and leaves the organization unchanged', async t => {
|
||||
const { prepare, service, writer, root } = setup(t);
|
||||
const before = writer.read('demo').revision;
|
||||
const p = await prepare({ level: 'root' });
|
||||
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
assert.equal(writer.read('demo').revision, before);
|
||||
const doc = JSON.parse(readFileSync(join(root,'agents.json')));
|
||||
assert.equal(doc.agents.scout.vars.model, 'claude-opus-5-5');
|
||||
assert.ok(readFileSync(join(root,'agents-receipts.jsonl'),'utf8').includes(p.id));
|
||||
});
|
||||
test('A4 change applies a T1 template update to an in-use definition; direct edits still refuse', async t => {
|
||||
const { root, rolesDir, file, writer, service, prepare } = setup(t);
|
||||
const p = await prepare(); await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const name = writer.read('demo').document.roles.scout.definition.slice(4);
|
||||
const old = writer.read('demo');
|
||||
assert.throws(() => writer.setDefinition('demo', name, e => ({ ...e, instructions: 'changed' }), { actor: 'jason', revision: old.revision, rev: 1, assertClean: () => {} }), { code: 'definition-in-use' });
|
||||
const template = readTemplate(rolesDir,'reviewer');
|
||||
const change = await service.prepare('demo',{ op: 'change', level: 'org', name: 'scout', role: { template: 'reviewer', sha256: template.sha256 }, route: { harness: 'pi', model: 'claude-opus-5-5' }, reason: 'Apply current template' }, { id: '22222222-2222-4222-8222-222222222222', expiresAt: '2099-01-01T00:00:00.000Z' });
|
||||
await service.apply('demo',change.id,{ ...OWNER,digest:change.digest });
|
||||
assert.equal(writer.read('demo').document.definitions[name].rev,2);
|
||||
assert.equal(writer.read('demo').document.definitions[name].from.sha256,template.sha256);
|
||||
});
|
||||
|
||||
test('A4 reserved names and invalid routes refuse before publication', async t => {
|
||||
const {prepare,writer}=setup(t),before=writer.read('demo').revision;
|
||||
for(const name of ['human','tester','user'])await assert.rejects(prepare({name}),{code:'invalid-request'});
|
||||
await assert.rejects(prepare({route:{harness:'pi',model:'unconfigured'}}),/selects an unknown model/);
|
||||
assert.equal(writer.read('demo').revision,before);
|
||||
});
|
||||
|
||||
test('A4 human adjustments are digest bound, remove grants and credentials, and never widen', async t => {
|
||||
const {service,prepare,writer}=setup(t);
|
||||
const original={...INLINE,tools:['read','bash'],network:'open',authority:{withinRole:['git.merge.protected'],crossRole:[]},credentials:[{service:'gitea',scopes:['read:repository']}]};
|
||||
const p=await prepare({role:{inline:original}});
|
||||
const adjust={role:{tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]}},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
|
||||
const view=await service.preview('demo',p.id,{...OWNER,adjust});
|
||||
assert.notEqual(view.digest,p.digest);assert.deepEqual(view.flagged,[]);assert.deepEqual(view.credentialLines,[]);
|
||||
await assert.rejects(service.apply('demo',p.id,{...OWNER,adjust,digest:p.digest}),{code:'decision-stale'});
|
||||
for(const bad of [{role:{tools:['read','write']}},{role:{authority:{withinRole:[],crossRole:['git.merge.protected']}}},{credentials:[{service:'gitea',scopes:['write:repository']}]},{role:{instructions:'different'}}])
|
||||
await assert.rejects(service.preview('demo',p.id,{...OWNER,adjust:bad}),{code:'invalid-request'});
|
||||
await service.apply('demo',p.id,{...OWNER,adjust,digest:view.digest});
|
||||
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,[]);assert.equal(entry.role.network,'none');
|
||||
assert.equal(entry.instructions,INLINE.instructions);assert.equal(doc.roles.scout.vars.model,'claude-sonnet-5-5');
|
||||
});
|
||||
|
||||
test('A4 changed root copies lose broker admission until a fresh boot snapshot',async t=>{
|
||||
const {service,prepare}=setup(t);
|
||||
assert.equal(service.rootActive('scout'),true);
|
||||
const p=await prepare({level:'root'});await service.apply('demo',p.id,{...OWNER,digest:p.digest});
|
||||
assert.equal(service.rootActive('scout'),false,'an old boot cannot keep an earlier root policy after a decision write');
|
||||
});
|
||||
|
||||
test('A4 a changed route clears the previous thinking override when approval omits it',async t=>{
|
||||
const {file,writer,prepare,service}=setup(t);
|
||||
const doc=writer.read('demo').document;doc.roles.scout={definition:'security-reviewer',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};doc.launch.instances.push('scout');writeJson(file,doc);
|
||||
const p=await prepare({op:'change'});await service.apply('demo',p.id,{...OWNER,digest:p.digest});
|
||||
assert.equal(Object.hasOwn(writer.read('demo').document.roles.scout.vars,'thinking'),false);
|
||||
});
|
||||
|
||||
test('A4 a root role change preserves the owner-set organization exclusions',async t=>{
|
||||
const {root,prepare,service}=setup(t);
|
||||
writeJson(join(root,'agents.json'),{agentsVersion:1,agents:{scout:{definition:'security-reviewer',vars:{harness:'pi',model:'claude-opus-5-5'},excludeOrgs:['elsewhere']}}});
|
||||
const p=await prepare({op:'change',level:'root'});await service.apply('demo',p.id,{...OWNER,digest:p.digest});
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(root,'agents.json'))).agents.scout.excludeOrgs,['elsewhere']);
|
||||
});
|
||||
|
||||
test('A4 root fire refuses other organizations before proposal and again before publication',async t=>{
|
||||
const {root,prepare,service,writer}=setup(t);
|
||||
writeJson(join(root,'agents.json'),{agentsVersion:1,agents:{scout:{definition:'security-reviewer'}}});
|
||||
const fire=id=>service.prepare('demo',{op:'fire',level:'root',name:'scout',reason:'Finished'}, {id,expiresAt:'2099-01-01T00:00:00.000Z'});
|
||||
const pending=await fire('11111111-1111-4111-8111-111111111111');
|
||||
const before=readFileSync(join(root,'agents.json'));
|
||||
writeJson(join(root,'businesses/other.json'),businessDoc(root,'other'));
|
||||
await assert.rejects(service.apply('demo',pending.id,{...OWNER,digest:pending.digest}),{code:'root-fire-shared'});
|
||||
assert.deepEqual(readFileSync(join(root,'agents.json')),before);
|
||||
await assert.rejects(fire('22222222-2222-4222-8222-222222222222'),{code:'root-fire-shared'});
|
||||
assert.ok(writer.read('demo').revision);
|
||||
});
|
||||
|
||||
test('A4 root snapshot refusals distinguish changed, fired and unreadable policy',async t=>{
|
||||
const {root,service}=setup(t),file=join(root,'agents-receipts.jsonl');
|
||||
assert.equal(service.rootRefusal('scout'),null);
|
||||
writeFileSync(file,JSON.stringify({agent:'scout',verb:'root.agent.set'})+'\n',{mode:0o600});
|
||||
assert.equal(service.rootRefusal('scout'),'copy-stale');
|
||||
writeFileSync(file,JSON.stringify({agent:'scout',verb:'root.agent.remove'})+'\n');
|
||||
assert.equal(service.rootRefusal('scout'),'instance-fired');
|
||||
writeFileSync(file,'invalid json\n');
|
||||
assert.equal(service.rootRefusal('scout'),'root-policy-unreadable');
|
||||
const broken=hireDecisions({writer:{},configDir:root,dataRoot:join(root,'data')});
|
||||
assert.equal(broken.rootRefusal('scout'),'root-policy-unreadable');
|
||||
});
|
||||
@@ -142,13 +142,7 @@ test("row 165 refuses each validation failure and leaves the file's bytes alone"
|
||||
refuses(f, { instance: "tester", op: "add", definition: "merge-gate" }, "invalid-request");
|
||||
// FED R3 (dewey-0777): and user, the granted user's principal.
|
||||
refuses(f, { instance: "user", op: "add", definition: "merge-gate" }, "invalid-request");
|
||||
// FED R3 r4 (sage-2557): row 194's hire refuses the same three names, and leaves the file alone.
|
||||
for (const name of ["human", "tester", "user"]) {
|
||||
const bytes = readFileSync(f.file, "utf8");
|
||||
assert.throws(() => f.settings.hire("acme", { name, definition: "security-reviewer" }, { actor: "chief", context: {} }),
|
||||
e => e.code === "invalid-request", name);
|
||||
assert.equal(readFileSync(f.file, "utf8"), bytes, name);
|
||||
}
|
||||
// A4 decision-path name refusals are covered by hire-decisions.test.mjs.
|
||||
const one = fixture(doc => { doc.launch.instances = ["coder"]; });
|
||||
refuses(one, { instance: "coder", op: "change", launch: false }, "BusinessError");
|
||||
const none = fixture(doc => { delete doc.launch; });
|
||||
@@ -171,8 +165,7 @@ test("T19 add of a credentialed definition writes store references dated today,
|
||||
refuses(f, add("reviewer-2", "reviewer", { tracker: { bot: "bot-acme-sync", botId: 40 } }), "organization-tracker-bot-taken");
|
||||
refuses(f, add("sec-1", "security-reviewer", { tracker: { bot: "bot-acme-sec-1", botId: 40 } }), "invalid-request");
|
||||
refuses(f, { instance: "coder", op: "change", vars: {}, tracker: { bot: "bot-acme-coder-2", botId: 40 } }, "invalid-request");
|
||||
assert.throws(() => f.settings.hire("acme", { name: "reviewer-3", definition: "reviewer" }, { actor: "chief", context: {} }),
|
||||
e => e.code === "organization-credentials-out-of-scope");
|
||||
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const done = f.settings.set("acme", { revision: f.settings.read("acme").revision, ...add("reviewer-2", "reviewer", { tracker: { bot: "bot-acme-reviewer-2", botId: 40 } }) }, "jason");
|
||||
@@ -184,7 +177,7 @@ test("T19 add of a credentialed definition writes store references dated today,
|
||||
});
|
||||
const validated = f.validate();
|
||||
assert.equal(validated.status, 0, validated.stderr);
|
||||
assert.match(validated.stderr, /reviewer-2\/vikunja: not issued/);
|
||||
assert.match(validated.stderr, /roles\.reviewer-2\.credentials\.vikunja store file: not issued/);
|
||||
// The Console's create form keeps listing it as not accepted.
|
||||
assert.equal(f.settings.read("acme").definitions.reviewer.accepted, false);
|
||||
});
|
||||
|
||||
@@ -48,7 +48,6 @@ function refuses(f, act, code) {
|
||||
}
|
||||
const del = (f, instance, extra = {}) => () => f.settings.set("acme", { revision: f.revision(), instance, op: "delete", ...extra }, "jason");
|
||||
const veto = (f, op, name, actor = "human") => f.settings.veto("acme", { revision: f.revision(), op, name }, actor);
|
||||
const hire = (f, args) => f.settings.hire("acme", args, { actor: "chief", context: CONTEXT });
|
||||
const fire = (f, name) => f.settings.fire("acme", { name }, { actor: "chief", context: CONTEXT });
|
||||
|
||||
test("T6 delete refuses in the design's order and leaves the file's bytes alone", () => {
|
||||
@@ -101,7 +100,7 @@ test("T6 a clean delete removes the entry and its own references, adds retired,
|
||||
assert.ok(entry.changes.some(c => c.path === "retired" && c.oldPresent === false));
|
||||
refuses(f, () => f.settings.set("acme", { revision: f.revision(), instance: "sec-1", op: "add", definition: "security-reviewer" }, "jason"),
|
||||
"organization-instance-retired");
|
||||
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-retired");
|
||||
assert.equal(f.settings.hire, undefined, "direct hire writer removed; decisions are covered in hire-decisions.test.mjs");
|
||||
refuses(f, del(f, "sec-1"), "organization-unknown-instance");
|
||||
});
|
||||
|
||||
@@ -150,57 +149,6 @@ test("T7 veto refuses an unknown or END_PROTECTED keep, a bad request, a stale r
|
||||
refuses(none, () => veto(none, "nohire", "coder"), "organization-launch-not-configured");
|
||||
});
|
||||
|
||||
test("D2.2 hire adds and lists a new instance, logs the chief's context, and sets only model and limits", () => {
|
||||
const f = fixture();
|
||||
const done = hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
|
||||
assert.deepEqual(done.instances["sec-2"], {
|
||||
definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" }, launched: true, launcher: false, protected: false, kept: false, endProtected: false,
|
||||
});
|
||||
assert.equal(done.restart, false);
|
||||
assert.deepEqual(f.onDisk().launch.instances, ["coder", "reviewer", "sec-1", "sec-2"]);
|
||||
const entry = f.log().at(-1);
|
||||
assert.equal(entry.actor, "chief");
|
||||
assert.deepEqual(entry.context, CONTEXT);
|
||||
assert.equal(entry.transaction, done.transaction);
|
||||
assert.equal(f.validate().status, 0);
|
||||
for (const vars of [{ harness: "pi" }, { model: "claude-sonnet-5-5", thinking: "high" }, []]) {
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars }), "invalid-request");
|
||||
}
|
||||
for (const args of [{ name: "Bad Name", definition: "security-reviewer" }, { name: "human", definition: "security-reviewer" },
|
||||
{ name: "tester", definition: "security-reviewer" }, { name: "sec-3", definition: "Bad Name" }]) {
|
||||
refuses(f, () => hire(f, args), "invalid-request");
|
||||
}
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer", vars: { model: "not-in-the-table" } }), "BusinessError");
|
||||
refuses(f, () => hire(f, { name: "coder-2", definition: "coder" }), "organization-credentials-out-of-scope");
|
||||
refuses(f, () => hire(f, { name: "asst-2", definition: "assistant" }), "organization-definition-refused");
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "no-such-role" }), "BusinessError");
|
||||
});
|
||||
|
||||
test("D2.2 hire refuses noHire, a kept name and a listed name, and re-lists a fired one only as it was", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.launch.noHire = ["merge-gate"];
|
||||
doc.roles["sec-2"] = { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } };
|
||||
doc.roles["sec-3"] = { definition: "security-reviewer" };
|
||||
doc.launch.keep = ["sec-3"];
|
||||
});
|
||||
refuses(f, () => hire(f, { name: "gate-1", definition: "merge-gate" }), "definition-not-hireable");
|
||||
// Row 213 (M10): a creative definition is the human's to add, never the chief's to hire.
|
||||
for (const definition of CREATIVE_DEFINITIONS) refuses(f, () => hire(f, { name: "muse-1", definition }), "definition-not-hireable");
|
||||
refuses(f, () => hire(f, { name: "sec-3", definition: "security-reviewer" }), "instance-kept");
|
||||
refuses(f, () => hire(f, { name: "sec-1", definition: "security-reviewer" }), "organization-instance-exists");
|
||||
refuses(f, () => hire(f, { name: "chief", definition: "chief-of-staff" }), "organization-instance-exists");
|
||||
// sec-2 is declared and not listed: fired. Only its own definition with no vars lists it again.
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "definition-not-hireable");
|
||||
veto(f, "hire", "merge-gate");
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "merge-gate" }), "organization-instance-exists");
|
||||
refuses(f, () => hire(f, { name: "sec-2", definition: "security-reviewer", vars: { model: "claude-opus-5-5" } }), "organization-instance-exists");
|
||||
const done = hire(f, { name: "sec-2", definition: "security-reviewer" });
|
||||
assert.equal(done.instances["sec-2"].launched, true);
|
||||
assert.deepEqual(f.onDisk().roles["sec-2"], { definition: "security-reviewer", vars: { model: "claude-sonnet-5-5" } });
|
||||
const none = fixture(doc => { delete doc.launch; });
|
||||
refuses(none, () => hire(none, { name: "sec-9", definition: "security-reviewer" }), "organization-launch-not-configured");
|
||||
});
|
||||
|
||||
test("D2.2 fire delists and keeps the entry, a repeat is done with no write, and each refusal holds", () => {
|
||||
const f = fixture(doc => {
|
||||
doc.roles.assistant = { definition: "assistant", holder: "mos" };
|
||||
|
||||
@@ -60,6 +60,9 @@ const VERBS = {
|
||||
'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',
|
||||
'definition.import.console': 'org.hire',
|
||||
'definition.edit.console': 'org.hire',
|
||||
'definition.invent.console': 'org.hire',
|
||||
|
||||
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user