Compare commits
12
Commits
org-defscan
...
org-au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88a94ddd34 | ||
|
|
2d4b07e516 | ||
|
|
e3232b814f | ||
|
|
0ef6326084 | ||
|
|
ce4fe048bb | ||
|
|
63e2a574af | ||
|
|
d9023e28db | ||
|
|
c3fe6b77ad | ||
|
|
809b1b0100 | ||
|
|
4a9d9d4b89 | ||
|
|
87db40e0cd | ||
|
|
f3f33a6171 |
No files matched your search
@@ -0,0 +1,88 @@
|
||||
// AU trusted host seam. No worker can choose a path, snapshot or inverse operation.
|
||||
import { mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { prepareRevert, finishLand, dropLand } from '../../seat/src/working.mjs';
|
||||
import { isMosaicStack, runGate, gateEnv } from '../../seat/src/landing.mjs';
|
||||
const refuse=code=>{throw Object.assign(new Error(code),{code});};
|
||||
export function autonomyHost({writer,dataRoot,business,hires,reload,processing,env=process.env,gate={classify:isMosaicStack,run:runGate}}) {
|
||||
return {
|
||||
get:id=>{const r=writer.read(id);return {revision:r.revision,autonomy:r.document.autonomy??{}};},
|
||||
set:(id,args,actor)=>{
|
||||
const old=writer.read(id);if(old.revision!==args.revision)refuse('revision-conflict');
|
||||
const result=writer.write(id,{...old.document,autonomy:args.autonomy},{revision:args.revision,actor,detail:{verb:'autonomy.set'}});
|
||||
const live=reload(id);return {...result,restart:live===false};
|
||||
},
|
||||
reversible:cls=>['cross-role','merge'].includes(cls) || cls==='authority' && typeof hires?.reverse==='function',
|
||||
async reverse({business:id,decision,decisionClass,raised,effects,actor,beforeWrite,beforePublish,fire,stop,complete,relaunch,record}) {
|
||||
if(decisionClass==='authority') {
|
||||
if(!hires?.reverse)refuse('reversal-unavailable');
|
||||
let stopped;
|
||||
const result=await hires.reverse(id,decision,{actor,beforeWrite,beforePublish,fire,stop:async p=>{const r=await stop(p);stopped=p;return r;},relaunch,record});
|
||||
// The writer admitted publication within the window. Reload and relaunch
|
||||
// complete that committed inverse; crossing the deadline here cannot undo it.
|
||||
const receipt={committed:true,transaction:result.transaction??null,completion:stopped?'recorded':'not-needed',relaunch:result.relaunch?'pending':'not-needed'};
|
||||
try {if(stopped)complete?.(stopped,result);} catch {receipt.completion='failed';receipt.restart=true;}
|
||||
try {
|
||||
if(reload(id)===false)receipt.restart=true;
|
||||
if(result.relaunch && !receipt.restart){await relaunch(result.relaunch);receipt.relaunch='requested';}
|
||||
} catch {receipt.restart=true;if(result.relaunch)receipt.relaunch='failed';}
|
||||
return receipt;
|
||||
}
|
||||
const used=effects.filter(e=>e.operation!=='decision.raise' && !String(e.operation??'').startsWith('autonomy.'));
|
||||
// An unused approval has changed no task or policy. Reversing it revokes that permission.
|
||||
if(!used.length) {beforeWrite();return {permissionRevoked:true};}
|
||||
if(decisionClass!=='merge')refuse('reversal-unavailable');
|
||||
const landing=used.find(e=>e.action==='git.merge.protected' && typeof e.commit==='string');
|
||||
if(!landing)refuse('reversal-unavailable');
|
||||
const b=business(id),p=b.projects[landing.project];if(!p)refuse('project-not-found');
|
||||
const name=`reverse-${randomBytes(6).toString('hex')}`,dir=join(dataRoot,'autonomy-reversals',decision,name);
|
||||
mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const where={dataRoot,business:id,project:landing.project,env};
|
||||
let prepared;
|
||||
return processing.work(async scope=>{
|
||||
try {
|
||||
if(!await gate.classify(p.root,env))refuse('gate-missing');
|
||||
prepared=await prepareRevert({...where,commit:landing.commit,dir,name,decision});
|
||||
symlinkSync(join(p.root,'node_modules'),join(prepared.worktree,'node_modules'));
|
||||
const result=await gate.run({worktree:prepared.worktree,logs:dir,env:gateEnv(env),signal:scope.signal});
|
||||
scope.guard();if(!result.ok)refuse('gate-failed');beforeWrite();
|
||||
const receipt=await finishLand({...where,land:prepared,record:receipt=>{scope.guard();beforeWrite();record({...receipt,reverted:landing.commit});}});
|
||||
return {...receipt,reverted:landing.commit};
|
||||
} finally {if(prepared)await dropLand({...where,worktree:prepared.worktree});}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The runtime adapter is shared with integration tests, including key revocation order.
|
||||
export function authorityHost(service,{modelKeys,reload,proxyOrganization}) {
|
||||
return {
|
||||
...service,
|
||||
reverse:async(id,decision,caller)=>{
|
||||
const proposal=service.read(decision);
|
||||
if(!modelKeys || (proposal.level==='org' && proposal.op==='hire'))return service.reverse(id,decision,caller);
|
||||
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
|
||||
let prepared=false;
|
||||
return service.reverse(id,decision,{
|
||||
...caller,
|
||||
beforeWrite:()=>{caller.beforeWrite();if(prepared){modelKeys.completeRevoke(instance);prepared=false;}caller.beforeWrite();},
|
||||
stop:async(p,guard)=>{await modelKeys.prepareRevoke(instance,guard);prepared=true;return caller.stop(p);},
|
||||
});
|
||||
},
|
||||
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,()=>caller.beforeWrite?.());
|
||||
approved={...caller,beforeWrite:()=>{caller.beforeWrite?.();modelKeys.completeRevoke(instance);caller.beforeWrite?.();}};
|
||||
}
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// AU reads only immutable evidence. These operations are reserved broker writes.
|
||||
export function autonomyEvent(q, business, decision, operations) {
|
||||
const placeholders=operations.map(()=>'?').join(',');
|
||||
const row=q.get(`SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IN (${placeholders}) ORDER BY seq DESC LIMIT 1`,business,decision,...operations);
|
||||
return row?{...row,body:JSON.parse(row.body)}:null;
|
||||
}
|
||||
export function decisionResolution(q, business, decision) {
|
||||
const terminal=q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision);
|
||||
if(terminal)return terminal;
|
||||
const overlay=autonomyEvent(q,business,decision,['autonomy.resolved','autonomy.declined']);
|
||||
if(overlay)return overlay.body.operation==='autonomy.declined'?null:{...overlay.body.resolution,seq:overlay.seq};
|
||||
return q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision);
|
||||
}
|
||||
+298
-36
@@ -1,3 +1,5 @@
|
||||
import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';
|
||||
import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';
|
||||
import { ownRails } from '../../organization/src/rails.mjs';
|
||||
import { refusalGuidance } from './refusals.mjs';
|
||||
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||
@@ -54,6 +56,7 @@ export const ACTIONS = Object.freeze([
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
'decision.resolve.authority',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
@@ -83,6 +86,7 @@ const ROUTED = new Set([
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
'decision.resolve.authority',
|
||||
// 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',
|
||||
@@ -174,7 +178,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', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.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', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.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.
|
||||
@@ -525,6 +529,7 @@ const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
|
||||
|
||||
// The checks a business document passes at boot and at every reload.
|
||||
function checkBusiness(name, b) {
|
||||
checkAutonomy(b.autonomy);
|
||||
// Row 177: a human named `tester` would share the tester's threads.
|
||||
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
@@ -597,6 +602,7 @@ export class Broker {
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#hires;
|
||||
#autonomyHost;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
@@ -660,7 +666,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, 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 }) {
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = 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');
|
||||
@@ -684,6 +690,7 @@ export class Broker {
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
this.#hires = hires;
|
||||
this.#autonomyHost = autonomy;
|
||||
this.#pluginService = pluginService;
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
@@ -3341,24 +3348,26 @@ export class Broker {
|
||||
const job = (async () => {
|
||||
try {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await run();
|
||||
return await run();
|
||||
} finally {
|
||||
this.#revoking.delete(key);
|
||||
}
|
||||
})();
|
||||
this.#grantJobs.add(job);
|
||||
job.catch(() => {}).finally(() => this.#grantJobs.delete(job));
|
||||
return job;
|
||||
}
|
||||
// Section 2, the fire's job: step G, then step B and step C. A step G refusal is section 4's record. A
|
||||
// step B or C failure is fire.failed (d6, C1). Step C records the authority the verb checked, with no
|
||||
// holder check, since the chief's run may have ended since.
|
||||
async #fireJob(sc, args, authority, write, job, set) {
|
||||
async #fireJob(sc, args, authority, write, job, set,propagate=false) {
|
||||
const name = args.name;
|
||||
let out;
|
||||
try {
|
||||
out = await this.#stepG(sc.business, name, set, { job, reason: args.reason, by: sc.role, flag: { fire: true } });
|
||||
} catch (e) {
|
||||
this.#store.transaction(() => this.#grantRefused(sc.business, name, 'instance.fire', job, e.grantRefusal, sc.role));
|
||||
if(propagate)throw e;
|
||||
return;
|
||||
}
|
||||
let step = 'B';
|
||||
@@ -3370,6 +3379,7 @@ export class Broker {
|
||||
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);
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : 'outcome-unknown';
|
||||
const list = out.grants.map((g) => g.grant).join(', ') || 'none';
|
||||
@@ -3382,6 +3392,7 @@ export class Broker {
|
||||
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`,
|
||||
);
|
||||
});
|
||||
if(propagate)throw e;
|
||||
}
|
||||
}
|
||||
// Section 2, the delete's job: step G, then grants.revoked.done and the end message.
|
||||
@@ -4799,10 +4810,32 @@ export class Broker {
|
||||
};
|
||||
}
|
||||
#closed(id) {
|
||||
return this.#store.get(
|
||||
`SELECT * FROM decision_events WHERE decision=? AND op IN ${CLOSED} ORDER BY seq DESC LIMIT 1`,
|
||||
id,
|
||||
);
|
||||
const d=this.#store.get('SELECT business FROM decisions WHERE id=?',id);
|
||||
return d ? decisionResolution(this.#store,d.business,id) : null;
|
||||
}
|
||||
#autonomyLevel(d) {
|
||||
if(d.action==='prd.approve' || d.route_to==='human' || d.class==='within-role')return 'out of the loop';
|
||||
const current=decisionAutonomy(autonomyClass(d.action),this.#business(d.business).autonomy);
|
||||
const raised=this.#raised(d.business,d.id)?.autonomyLevel??'out of the loop';
|
||||
const levels=['in the loop','near the loop','out of the loop'];
|
||||
return levels[Math.min(levels.indexOf(current),levels.indexOf(raised))];
|
||||
}
|
||||
#autonomyOwner(s) {
|
||||
if(!s.console || !s.human || s.tester || s.reader || s.hireMembership || s.human!==this.#business(s.business).human)fail('human-required');
|
||||
}
|
||||
#resolutionEvent(s,d,choice) {
|
||||
const resolution={decision:d.id,op:'resolved',by:s.run,choice,via:'broker',at:this.#now()};
|
||||
if(this.#autonomyLevel(d)==='in the loop') {
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.pending',decision:d.id,resolution},d.id);
|
||||
return {id:d.id,op:'pending',choice};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
#autonomyNotice(s,d,resolution) {
|
||||
if(this.#autonomyLevel(d)!=='near the loop' || resolution.choice!==this.#raised(d.business,d.id)?.approvalChoice)return;
|
||||
const hours=this.#business(d.business).autonomy?.reverseWindowHours??24;
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.notice',decision:d.id,decisionClass:autonomyClass(d.action),resolution,
|
||||
reverseUntil:new Date(Date.parse(resolution.at)+hours*3600000).toISOString()},d.id);
|
||||
}
|
||||
// Row 58 (#1547): the 'tasks' rows, each with the task's priority and requirement. Priority
|
||||
// is the current row's; a tombstone has none. The requirement is the first task.created for
|
||||
@@ -5023,6 +5056,8 @@ export class Broker {
|
||||
const json = JSON.stringify(fields);
|
||||
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
|
||||
this.#secretCheck(fields);
|
||||
const near=kind==='action.allowed' && body?.decision && this.#autonomyLevel(this.#decision(s,body.decision))==='near the loop';
|
||||
if(near && current)this.#event(s,'action.allowed',{operation:'autonomy.before',decision:body.decision,target:ref,fields:current.fields},ref);
|
||||
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
|
||||
this.#store.run(
|
||||
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -5038,7 +5073,9 @@ export class Broker {
|
||||
event,
|
||||
json,
|
||||
);
|
||||
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
|
||||
const result=op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
|
||||
if(near && current)this.#event(s,'action.allowed',{operation:'autonomy.after',decision:body.decision,target:ref,digest:result.digest},ref);
|
||||
return result;
|
||||
}
|
||||
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
|
||||
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
|
||||
@@ -5122,7 +5159,9 @@ export class Broker {
|
||||
context.target !== target
|
||||
)
|
||||
fail('decision-mismatch');
|
||||
if(!r && autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed'])?.body.operation==='autonomy.pending')fail('confirmation-required');
|
||||
if (r?.op !== 'resolved' || r.choice !== context.approvalChoice) fail('decision-not-approved');
|
||||
this.#effectiveDecision(d, r);
|
||||
if (action === 'role.revoke') this.#protectedEnd(s, target, r);
|
||||
// A thread instance has no role claim, so both sides are null for it (row 155, #1649): the raise
|
||||
// records `?? null` and the current holder reads undefined.
|
||||
@@ -5147,14 +5186,25 @@ export class Broker {
|
||||
);
|
||||
return evidence ? JSON.parse(evidence.body) : null;
|
||||
}
|
||||
#effectiveDecision(d, r) {
|
||||
if (d.route_to === 'human' || d.class==='within-role' || r.via !== 'broker') return;
|
||||
const b = this.#business(d.business);
|
||||
if (d.raised_by_role === d.route_to || resolverSubject(b, d.route_to, this.#raised(d.business,d.id)?.target)) fail('not-resolver');
|
||||
this.#checkResolver({business:d.business,role:d.route_to},d);
|
||||
if(autonomyEvent(this.#store,d.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed');
|
||||
if(this.#autonomyLevel(d)==='in the loop') {
|
||||
const confirmed=autonomyEvent(this.#store,d.business,d.id,['autonomy.confirmed']);
|
||||
if(!confirmed || confirmed.body.resolution.at!==r.at || confirmed.body.resolution.by!==r.by)fail('confirmation-required');
|
||||
}
|
||||
}
|
||||
#checkResolver(s, d) {
|
||||
const b = this.#business(s.business), role = b.roles[s.role];
|
||||
const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
const domain = ['role.hire','role.fire','role.change'].includes(d.action) ? 'authority' : d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
// Pre-RR evidence has no domain. Assignments may have changed since raise,
|
||||
// so require both possible rights rather than infer one from today's file.
|
||||
const domains = domain === undefined ? ['technical', 'delivery'] : [domain];
|
||||
for (const kind of domains) {
|
||||
if (!['technical', 'delivery'].includes(kind)) fail('not-resolver');
|
||||
if (!['technical', 'delivery','authority'].includes(kind)) fail('not-resolver');
|
||||
const right = `decision.resolve.${kind}`;
|
||||
if (!role?.authority?.withinRole.includes(right)) {
|
||||
throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`);
|
||||
@@ -5165,7 +5215,7 @@ export class Broker {
|
||||
// consumed by an executor.
|
||||
#spent(business, decision) {
|
||||
return !!this.#store.get(
|
||||
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1",
|
||||
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' AND COALESCE(json_extract(body,'$.operation'),'') NOT LIKE 'autonomy.%' LIMIT 1",
|
||||
business,
|
||||
decision,
|
||||
);
|
||||
@@ -5199,6 +5249,9 @@ export class Broker {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#consumeAuthority(s, action, context);
|
||||
// External adapters have no host-owned prior task/policy snapshot. Near-loop work
|
||||
// must use the store mutation path, which commits its inverse evidence atomically.
|
||||
if(result.decision && this.#autonomyLevel(this.#decision(s,result.decision))==='near the loop' && ['cross-role','policy'].includes(autonomyClass(action)))fail('reversal-unavailable');
|
||||
if (action === 'task.close') this.#closeVerdict(s, context.target, context.verdict);
|
||||
return result;
|
||||
});
|
||||
@@ -5711,6 +5764,7 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && ['git.push.working', MERGE, 'processing.halt', 'processing.resume', 'processing.stop'].includes(body?.action)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && (String(body?.operation??'').startsWith('autonomy.') || body?.operation==='decision.raise' || String(body?.operation??'').startsWith('hire.'))) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && grantName(body)) fail('reserved-event');
|
||||
return this.#event(s, kind, body, subject);
|
||||
});
|
||||
@@ -6096,7 +6150,19 @@ export class Broker {
|
||||
return this.#withLast(cap, s, request);
|
||||
}
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if(['autonomy.get.console','autonomy.set.console','autonomy.review.console','decision.reverse.console'].includes(request.verb)) {
|
||||
this.#autonomyOwner(s);
|
||||
return this.#autonomyConsole(s,request.verb,structuredClone(args)).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);});
|
||||
}
|
||||
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
|
||||
if(request.verb==='decision.resolve' && !s.human) {
|
||||
if(s.tester)fail('tester-refused');
|
||||
keys(args,['id','choice','note'],['id','choice']);identifier(args.id);
|
||||
}
|
||||
if(request.verb==='decision.resolve' && !s.human && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action)) {
|
||||
const d=this.#decision(s,args.id);
|
||||
if(d.route_to!=='human')return this.#resolveAuthority(s,args).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);});
|
||||
}
|
||||
if(request.verb==='hire.templates.console') {
|
||||
keys(args,[]);const caller=this.#hireCaller(s);
|
||||
if(!caller.rights.includes('org.hire'))fail('right-required');
|
||||
@@ -6313,6 +6379,100 @@ export class Broker {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
async #autonomyConsole(s,verb,a) {
|
||||
if(verb==='autonomy.get.console') {
|
||||
keys(a,[]);
|
||||
if(!this.#autonomyHost?.get)fail('not-configured');
|
||||
const current=await this.#autonomyHost.get(s.business);
|
||||
return {...current,levels:Object.fromEntries(['cross-role','merge','policy','authority'].map(k=>[k,decisionAutonomy(k,current.autonomy)]))};
|
||||
}
|
||||
if(verb==='autonomy.review.console') {
|
||||
keys(a,['decisionClass'],['decisionClass']);decisionAutonomy(a.decisionClass);
|
||||
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.review',decisionClass:a.decisionClass,settingSeq:latest?.seq??null,subject:s.console},null));
|
||||
return {reviewed:true};
|
||||
}
|
||||
if(verb==='autonomy.set.console') {
|
||||
keys(a,['revision','autonomy'],['revision','autonomy']);
|
||||
const AUTONOMY_CLASSES=['cross-role','merge','policy','authority'];
|
||||
checkAutonomy(a.autonomy);
|
||||
if(!this.#autonomyHost?.get || !this.#autonomyHost?.set)fail('not-configured');
|
||||
const old=await this.#autonomyHost.get(s.business),b=this.#business(s.business);
|
||||
if(a.revision!==old.revision)fail('revision-conflict');
|
||||
if(AUTONOMY_CLASSES.some(cls=>decisionAutonomy(cls,a.autonomy)==='near the loop') && a.autonomy.notices!==true)fail('autonomy-precondition');
|
||||
for(const cls of AUTONOMY_CLASSES) {
|
||||
const before=decisionAutonomy(cls,old.autonomy),after=decisionAutonomy(cls,a.autonomy);
|
||||
// Pulling back is unconditional. Only a move away from the loop has prerequisites.
|
||||
// Lead ruling: policy has no effect executor. Its existing out and in levels
|
||||
// remain usable; near refuses in checkAutonomy until an inverse exists.
|
||||
if(cls==='policy')continue;
|
||||
if(cls==='authority' && after==='out of the loop' && before!==after)fail('authority-limits-unavailable');
|
||||
if(['in the loop','near the loop','out of the loop'].indexOf(after)<=['in the loop','near the loop','out of the loop'].indexOf(before))continue;
|
||||
if(a.autonomy.notices!==true || !this.#autonomyHost.reversible?.(cls,b))fail('autonomy-precondition');
|
||||
const resolvers=cls==='authority'?[a.autonomy.authorityResolver]:cls==='merge'?[b.arbiters.technical]:cls==='policy'?[b.arbiters.delivery]:Object.values(b.arbiters);
|
||||
for(const name of resolvers) {
|
||||
const role=b.roles[name],right=cls==='authority'?'decision.resolve.authority':`decision.resolve.${cls==='merge'?'technical':cls==='policy'?'delivery':name===b.arbiters.technical?'technical':'delivery'}`;
|
||||
if(!role?.authority?.withinRole.includes(right) || (cls==='authority' && name===b.launch?.by))fail('autonomy-precondition');
|
||||
}
|
||||
if(['merge','policy'].includes(cls) && !Object.values(b.roles).some(r=>r.authority?.withinRole.includes('review.verdict')))fail('autonomy-precondition');
|
||||
if(cls==='authority' && !authorityBudgetCovered(b))fail('autonomy-precondition');
|
||||
if(after==='out of the loop') {
|
||||
if(before!=='near the loop')fail('autonomy-precondition');
|
||||
const review=this.#store.get("SELECT seq,body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.review' AND json_extract(body,'$.decisionClass')=? ORDER BY seq DESC LIMIT 1",s.business,cls);
|
||||
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
|
||||
if(!review || JSON.parse(review.body).settingSeq!==latest?.seq)fail('autonomy-precondition');
|
||||
const notice=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.notice' AND json_extract(body,'$.decisionClass')=? AND seq>? ORDER BY seq DESC LIMIT 1",s.business,cls,latest?.seq??0);
|
||||
const reversal=this.#store.get("SELECT max(e.seq) seq FROM events e WHERE e.business=? AND e.kind='action.allowed' AND json_extract(e.body,'$.operation') LIKE 'autonomy.reverse%' AND EXISTS(SELECT 1 FROM events n WHERE n.business=e.business AND n.kind='action.allowed' AND json_extract(n.body,'$.operation')='autonomy.notice' AND json_extract(n.body,'$.decisionClass')=? AND json_extract(n.body,'$.decision')=json_extract(e.body,'$.decision'))",s.business,cls);
|
||||
if(!notice || review.seq<=Math.max(notice.seq,reversal?.seq??0))fail('autonomy-precondition');
|
||||
}
|
||||
}
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set.requested',autonomy:a.autonomy,subject:s.console},null));
|
||||
const result=await this.#autonomyHost.set(s.business,a,s.human);
|
||||
this.#autonomyOwner(s);
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set',autonomy:a.autonomy,subject:s.console,transaction:result.transaction},null));
|
||||
return result;
|
||||
}
|
||||
keys(a,['id'],['id']);identifier(a.id);
|
||||
const d=this.#decision(s,a.id),notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']);
|
||||
const windowOpen=()=>{
|
||||
if(!notice || Date.parse(notice.body.reverseUntil)<=Math.max(Date.now(),this.#clock))throw new BusError('reverse-window-closed','The reverse window has closed; raise an ordinary new decision for this change.');
|
||||
};
|
||||
windowOpen();
|
||||
if(autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed');
|
||||
const before=autonomyEvent(this.#store,s.business,d.id,['autonomy.before']),after=autonomyEvent(this.#store,s.business,d.id,['autonomy.after']);
|
||||
if(!before && !this.#autonomyHost?.reverse)fail('reversal-unavailable');
|
||||
this.#store.transaction(()=>{
|
||||
windowOpen();
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.reverse.consume',decision:d.id,subject:s.console},d.id);
|
||||
});
|
||||
let committedResult=null;
|
||||
try {
|
||||
if(before && after) return this.#store.transaction(()=>{
|
||||
windowOpen();
|
||||
const current=this.#storeCurrent(s.business,before.body.target);
|
||||
if(current.digest!==after.body.digest)fail('decision-stale');
|
||||
const result=this.#storeWrite(s,{current,op:'change',verb:'tasks.edit',fields:before.body.fields,by:{role:null,run:null,human:s.human},kind:'human.input',body:{kind:'autonomy.reverse',decision:d.id}});
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,revision:result.revision},d.id);
|
||||
return {id:d.id,reversed:true,task_ref:result.task_ref};
|
||||
});
|
||||
const effects=this.#store.all("SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? ORDER BY seq",s.business,d.id).map(e=>JSON.parse(e.body));
|
||||
let recorded=false;
|
||||
const result=await this.#autonomyHost.reverse({business:s.business,decision:d.id,decisionClass:autonomyClass(d.action),action:d.action,raised:this.#raised(s.business,d.id),effects,actor:s.human,
|
||||
beforeWrite:()=>{this.#autonomyOwner(s);windowOpen();},beforePublish:()=>{this.#autonomyOwner(s);windowOpen();},fire:proposal=>this.#fireApproved(s,proposal,()=>{this.#autonomyOwner(s);windowOpen();},true),stop:proposal=>this.#stopCopyApproved(s,proposal),complete:(proposal,result)=>this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id,transaction:result.transaction??null},proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name)),relaunch:proposal=>{
|
||||
this.#autonomyOwner(s);
|
||||
const instance=proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name;
|
||||
this.#mayHuman(s,'instance.launch',instance);this.#launchable(s,instance,'not-thread-launch');
|
||||
return this.#store.transaction(()=>({request:this.#lifecycleRecord(s,'lifecycle.launch',instance,{decision:d.id})}));
|
||||
},record:result=>this.#store.transaction(()=>{if(!(autonomyClass(d.action)==='authority' && result.committed===true))windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})});
|
||||
if(autonomyClass(d.action)==='authority' && result.committed===true)committedResult=result;
|
||||
if(!recorded)this.#store.transaction(()=>{if(!committedResult)windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);});
|
||||
return {id:d.id,reversed:true,...result};
|
||||
} catch(error) {
|
||||
if(committedResult)return {id:d.id,reversed:true,...committedResult,trailPending:true};
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.reverse.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
#hireCaller(s) {
|
||||
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
|
||||
if(s.hireMembership) return s.hireMembership;
|
||||
@@ -6340,15 +6500,74 @@ export class Broker {
|
||||
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
|
||||
}
|
||||
const action=`role.${p.op}`;
|
||||
const level=decisionAutonomy('authority',b.autonomy);
|
||||
let route='human';
|
||||
if(!immediate && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
|
||||
if(level==='out of the loop')fail('authority-limits-unavailable');
|
||||
if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition');
|
||||
route=b.autonomy?.authorityResolver;
|
||||
if(!b.roles[route]?.authority?.withinRole.includes('decision.resolve.authority'))fail('autonomy-precondition');
|
||||
if(route===s.role || resolverSubject(b,route,p.name))route='human';
|
||||
}
|
||||
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);
|
||||
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
|
||||
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
|
||||
});
|
||||
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
|
||||
return {...this.#decisionView(this.#decision(s,did)),hire:p};
|
||||
}
|
||||
// Trusted runtime inverse only; never a socket verb.
|
||||
hireReverse(...args) {
|
||||
if(!this.#hires?.reverse)fail('reversal-unavailable');
|
||||
return this.#hires.reverse(...args);
|
||||
}
|
||||
async #resolveAuthority(s,a) {
|
||||
keys(a,['id','choice'],['id','choice']);identifier(a.id);
|
||||
const d=this.#decision(s,a.id),p=this.#raised(s.business,d.id)?.hire;
|
||||
const validate=()=>{
|
||||
this.#agent(s);
|
||||
if(d.route_to!==s.role || d.raised_by_role===s.role || resolverSubject(this.#business(s.business),s.role,p?.name))fail('not-resolver');
|
||||
if(!p || p.level==='root' || p.flagged.length || p.credentialLines.length)fail('hire-owner-required');
|
||||
if(this.#closed(d.id))fail('decision-closed');
|
||||
if(this.#expireHire(s,d,p))fail('decision-expired');
|
||||
if(this.#autonomyLevel(d)==='in the loop')fail('hire-resolution-required');
|
||||
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
|
||||
if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition');
|
||||
if(!this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver');
|
||||
};
|
||||
validate();
|
||||
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
|
||||
if(!this.#hires)fail('not-configured');
|
||||
const caller={agent:true,authorityDecision:d.id,resolver:s.role,actor:s.role,rights:['decision.resolve.authority'],digest:p.digest,now:()=>Math.max(Date.now(),this.#clock),beforeWrite:validate};
|
||||
if(a.choice==='yes')await this.#hires.check(s.business,d.id,caller);
|
||||
validate();
|
||||
this.#store.transaction(()=>{
|
||||
validate();
|
||||
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',s.run,a.choice,'broker');
|
||||
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
|
||||
this.#autonomyNotice(s,d,this.#closed(d.id));
|
||||
});
|
||||
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
|
||||
// No caller-provided human identity: the trusted writer receives the resolving instance.
|
||||
const recheck=()=>{
|
||||
this.#agent(s);
|
||||
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
|
||||
if(!authorityBudgetCovered(this.#business(s.business)))fail('autonomy-precondition');
|
||||
if(this.#autonomyLevel(d)==='in the loop' || !this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority') || resolverSubject(this.#business(s.business),s.role,p.name))fail('not-resolver');
|
||||
};
|
||||
recheck();
|
||||
try {
|
||||
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
|
||||
recheck();
|
||||
const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck,beforePublish:recheck});
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.apply',decision:d.id,transaction:result.transaction??null,revision:result.revision??null},d.id));
|
||||
return {...result,id:d.id,op:'resolved',choice:a.choice};
|
||||
} catch(error) {
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));throw error;
|
||||
}
|
||||
}
|
||||
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']);
|
||||
@@ -6408,25 +6627,29 @@ export class Broker {
|
||||
const b=this.#business(s.business);
|
||||
if(!b.roles[instance])return;
|
||||
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
|
||||
let running=false;
|
||||
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);
|
||||
running=runs.length>0;
|
||||
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}});
|
||||
return {running};
|
||||
}
|
||||
// The existing row-281 fire executor, now entered only after a consumed human decision.
|
||||
#fireApproved(s,p) {
|
||||
#fireApproved(s,p,beforeWrite=()=>{},waitForCommit=false) {
|
||||
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 refused=e=>{if(['reverse-window-closed','autonomy-precondition','not-resolver'].includes(e.code))throw e;return this.#organizationFail(e,verb);};
|
||||
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);}
|
||||
try {beforeWrite();const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human??s.role,{decision:p.id,digest:p.digest,beforeWrite,beforePublish:beforeWrite});
|
||||
return result?.then?result.catch(refused):result;
|
||||
} catch(e) {refused(e);}
|
||||
};
|
||||
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
|
||||
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
||||
@@ -6470,7 +6693,8 @@ export class Broker {
|
||||
if (stopped.job) {
|
||||
const { job, set, ...rest } = stopped;
|
||||
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));
|
||||
const pending=this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set,waitForCommit));
|
||||
if(waitForCommit)return pending;
|
||||
return { ...rest, job, revoking: set.map((g) => g.label), done: false };
|
||||
}
|
||||
// Step B: the delist. An instance already delisted counts as done.
|
||||
@@ -7103,12 +7327,39 @@ export class Broker {
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'decision.confirm.console': {
|
||||
this.#autonomyOwner(s);
|
||||
keys(a,['id','confirm','reason'],['id','confirm']);identifier(a.id);
|
||||
if(typeof a.confirm!=='boolean' || (!a.confirm && !text(a.reason,500)))fail('invalid-request');
|
||||
const d=this.#decision(s,a.id);
|
||||
if(d.route_to==='human' || d.action==='prd.approve' || d.class==='within-role')fail('decision-not-eligible');
|
||||
const pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']);
|
||||
const closed=this.#closed(d.id);
|
||||
if(closed && ['withdrawn','expired'].includes(closed.op))fail('decision-closed');
|
||||
if(pending?.body.operation!=='autonomy.pending')fail('decision-not-eligible');
|
||||
const resolution=pending.body.resolution;
|
||||
if(!resolution || resolution.op!=='resolved' || this.#autonomyLevel(d)!=='in the loop')fail('decision-not-eligible');
|
||||
if(this.#spent(s.business,d.id))fail('decision-consumed');
|
||||
this.#checkResolver({business:s.business,role:d.route_to},d);
|
||||
if(d.raised_by_role===d.route_to || resolverSubject(b,d.route_to,this.#raised(s.business,d.id)?.target))fail('not-resolver');
|
||||
this.#event(s,'human.input',{kind:'autonomy.confirmation',decision:d.id,confirm:a.confirm,reason:a.reason??null,subject:s.console},d.id);
|
||||
if(!a.confirm) {
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.declined',decision:d.id,reason:a.reason},d.id);
|
||||
return {id:d.id,op:'declined'};
|
||||
}
|
||||
if(!this.#store.get("SELECT 1 FROM decision_events WHERE decision=? AND op='resolved'",d.id)) {
|
||||
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,resolution.at,'resolved',resolution.by,resolution.choice,'broker');
|
||||
}
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.resolved',decision:d.id,resolution},d.id);
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.confirmed',decision:d.id,resolution},d.id);
|
||||
return {id:d.id,op:'confirmed'};
|
||||
}
|
||||
case 'my_rails': {
|
||||
keys(a, []);
|
||||
this.#agent(s);
|
||||
const entry = b.roles[s.role], definition = b.definitions?.[entry.definition];
|
||||
return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network,
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by });
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by, autonomy: b.autonomy });
|
||||
}
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
@@ -7255,14 +7506,15 @@ export class Broker {
|
||||
const approvalChoice = a.approvalChoice ?? 'yes';
|
||||
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
|
||||
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
|
||||
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
if (a.action === 'prd.approve' && a.domain !== undefined && a.domain !== 'technical') throw new BusError('invalid-domain', 'prd.approve requires domain technical');
|
||||
const domain = a.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
// PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit
|
||||
// goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any
|
||||
// hard wait go to the human. readSpec already refused a stale or unconfirmed citation.
|
||||
const arbitrated =
|
||||
cls === 'cross-role' || (cls === 'gated' && spec !== null && !this.#hardWait(s, a.action, a.target));
|
||||
const proposedRoute = arbitrated ? b.arbiters[domain] : cls === 'gated' ? 'human' : s.role;
|
||||
const route = arbitrated && proposedRoute === s.role ? 'human' : proposedRoute;
|
||||
const route = arbitrated && (proposedRoute === s.role || resolverSubject(b, proposedRoute, a.target)) ? 'human' : proposedRoute;
|
||||
// Row 177 r2 (#1675, F1): nor does it put a decision in the human's inbox. A raise routed to an
|
||||
// arbiter stays allowed; the chief's reach to the human is no new reach (README, N1).
|
||||
if (route === 'human' && this.#testerThread(s)) fail('tester-thread-human');
|
||||
@@ -7338,6 +7590,7 @@ export class Broker {
|
||||
action: a.action,
|
||||
operation: 'decision.raise',
|
||||
domain,
|
||||
...(arbitrated && a.action!=='prd.approve' && route!=='human' ? {autonomyLevel:decisionAutonomy(autonomyClass(a.action),b.autonomy)} : {}),
|
||||
task_ref: a.task_ref ?? null,
|
||||
target: a.target ?? null,
|
||||
approvalChoice,
|
||||
@@ -7403,13 +7656,21 @@ export class Broker {
|
||||
}
|
||||
} else if (d.route_to === 'human') this.#human(s);
|
||||
else {
|
||||
if(s.human && this.#autonomyLevel(d)==='in the loop')fail('not-resolver');
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
if (verb === 'decision.resolve') this.#checkResolver(s, d);
|
||||
if (verb === 'decision.resolve') {
|
||||
if(d.class!=='within-role' && (d.raised_by_role===s.role || resolverSubject(b,s.role,this.#raised(s.business,d.id)?.target)))fail('not-resolver');
|
||||
this.#checkResolver(s, d);
|
||||
}
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
fail('invalid-choice');
|
||||
if(op==='resolved' && !s.human && d.route_to!=='human' && this.#autonomyLevel(d)==='in the loop') {
|
||||
const pending=this.#resolutionEvent(s,d,a.choice);
|
||||
if(pending)return pending;
|
||||
}
|
||||
const seq = Number(
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
@@ -7422,6 +7683,7 @@ export class Broker {
|
||||
s.console ? 'console' : s.human ? 'cli' : 'broker',
|
||||
).lastInsertRowid,
|
||||
);
|
||||
if(op==='resolved' && !s.human)this.#autonomyNotice(s,d,this.#closed(d.id));
|
||||
// Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's
|
||||
// record to start the executor. Its body names the owner subject and the close's seq.
|
||||
if (s.human)
|
||||
@@ -7846,19 +8108,19 @@ export class Broker {
|
||||
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(
|
||||
`SELECT d.* FROM decisions d WHERE business=? AND route_to=? AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY CASE class WHEN 'gated' THEN 0 ELSE 1 END,seq`,
|
||||
s.business,
|
||||
s.human || s.reader ? 'human' : s.role,
|
||||
)
|
||||
.map((d) => {
|
||||
// Row 252: a grant request the Console can answer carries its two answers. The Console
|
||||
// shows buttons from this, and the answer path rechecks it at the bus.
|
||||
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
|
||||
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
|
||||
});
|
||||
if (verb === 'inbox') {
|
||||
const owner=!!(s.human || s.reader);
|
||||
return this.#store.all('SELECT * FROM decisions WHERE business=? ORDER BY seq',s.business).flatMap(d=>{
|
||||
const closed=this.#closed(d.id), pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']);
|
||||
const waiting=pending?.body.operation==='autonomy.pending' && !closed;
|
||||
const notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']);
|
||||
const reversed=autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']);
|
||||
if(owner && (waiting || (notice && !reversed)))return [{...this.#decisionView(d),autonomy:{pending:waiting,notice:!!notice,reverseUntil:notice?.body.reverseUntil??null}}];
|
||||
if(closed || waiting || d.route_to!==(owner?'human':s.role))return [];
|
||||
const c=owner?this.#consoleResolution(d):null;
|
||||
return [c?{...this.#decisionView(d),console:c}:this.#decisionView(d)];
|
||||
}).sort((a,b)=>(a.class==='gated'?0:1)-(b.class==='gated'?0:1)||a.seq-b.seq);
|
||||
}
|
||||
if (verb === 'agents') {
|
||||
const claims = this.#store.all(
|
||||
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';
|
||||
import { autonomyEvent } from './autonomy-state.mjs';
|
||||
import { decisionResolution } from './autonomy-state.mjs';
|
||||
// The checks a landing makes on its merge decision and the task's review
|
||||
// (row 137, #1631). Two callers: scripts/land-work/read.mjs, which lands on
|
||||
// canonical refactor from a read-only copy of the bus, and the broker's
|
||||
@@ -96,7 +99,8 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
|
||||
const raise = json(raises[0].body) ?? {};
|
||||
if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
|
||||
|
||||
const closes = queries.closes(q, id);
|
||||
const resolved=decisionResolution(q,b,id);
|
||||
const closes=resolved?[resolved]:[];
|
||||
if (closes.length !== 1 || closes[0].op !== 'resolved')
|
||||
refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
|
||||
const res = closes[0];
|
||||
@@ -112,6 +116,13 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
|
||||
// A cross-role decision routed to an agent closes only by decision.resolve, which takes it from
|
||||
// the routed role alone (s.role === route_to), so res.by here is the arbiter's run.
|
||||
const arbitrated = arbiter && d.class === 'cross-role' && d.route_to !== 'human' && res.via === 'broker' && !answers.length;
|
||||
if(arbitrated && !business.roles?.[d.route_to]?.authority?.withinRole?.includes('decision.resolve.technical'))refuse('not-resolver','The technical resolver lost decision.resolve.technical; resolve afresh under current policy.');
|
||||
if(arbitrated) {
|
||||
if(d.route_to===d.raised_by_role || resolverSubject(business,d.route_to,raise.target))refuse('not-resolver','A person decides a resolver subject.');
|
||||
const confirmed=autonomyEvent(q,b,id,['autonomy.confirmed']);
|
||||
if((decisionAutonomy('merge',business.autonomy)==='in the loop' || raise.autonomyLevel==='in the loop') && (!confirmed || confirmed.body.resolution.at!==res.at || confirmed.body.resolution.by!==res.by))refuse('confirmation-required','A person must confirm the arbiter resolution.');
|
||||
if(autonomyEvent(q,b,id,['autonomy.reverse.consume']))refuse('decision-reversed','This decision was reversed.');
|
||||
}
|
||||
if (!human && !arbitrated)
|
||||
refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
|
||||
const options = json(d.options) ?? [];
|
||||
|
||||
@@ -244,7 +244,7 @@ export class ModelKeys {
|
||||
this.#receipt({ action: 'proxy.key.revoked', keyIds: [record.keyId, ...record.retiring.map(r => r.keyId)], remoteState: 'revoked', localState: 'kept' });
|
||||
} catch (error) { if (error.code === 'proxy-uncertain' || (this.#lock && this.#uncertain())) fail('proxy-uncertain'); fail('credential-revoke-failed'); }
|
||||
}
|
||||
prepareRevoke(instance) { return this.#exclusive(() => this.#prepareRevoke(instance)); }
|
||||
prepareRevoke(instance, beforeRevoke = () => {}) { return this.#exclusive(() => { beforeRevoke(); return this.#prepareRevoke(instance); }); }
|
||||
completeRevoke(instance) {
|
||||
try { this.#lock?.assertCertain(); } catch { fail('proxy-uncertain'); }
|
||||
try {
|
||||
@@ -359,7 +359,7 @@ export function modelOrganization({ modelKeys, writer }) {
|
||||
pending.set(business, prepare);
|
||||
const existed = modelKeys.hasRecord(instance);
|
||||
try {
|
||||
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
|
||||
if (isRevoke) { await modelKeys.prepareRevoke(instance, () => context?.beforeWrite?.()); prepare.remoteRevoked = existed; }
|
||||
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);
|
||||
|
||||
@@ -9,6 +9,11 @@ const group = (codes, rail, nextStep) => {
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
group("authority-limits-unavailable", "R3/R12: future authority spend coverage", "Use authority near the loop with an enforced organization budget. Out of the loop waits for enforceable per-agent limits in every approvable definition; current-instance limits do not cover future hires.");
|
||||
group("policy-reverse-unavailable", "R3/R12: policy inverse unavailable", "Choose policy in the loop or out of the loop. Near the loop waits for a policy effect executor and a tested reverse.");
|
||||
group("invalid-autonomy autonomy-precondition reversal-unavailable", "R3/R12: validated owner autonomy and reversible effects", "Use only the four decision classes and three levels. Keep work in the loop until the class has a distinct resolver, tested inverse, notices, review and spend prerequisites; an unsupported effect needs an ordinary new decision. Authority near needs enabled limit checks and aggregate coverage of new agents. Moving out needs a near-loop resolution followed by the owner’s trail review. A failed inverse may have stopped an agent; inspect its writer receipt and use the normal launch or decision path.");
|
||||
group("confirmation-required", "R5/R12: person confirms the arbiter resolution", "The arbiter remains the resolver. Confirm its pending resolution in the owner Console, or return it with a reason for a fresh resolution.");
|
||||
group("decision-reversed reverse-window-closed", "R2/R12: one-use reverse within the owner's window", "Inspect the reversal trail. After the window, raise an ordinary new decision through the normal approval path; never replay the old approval.");
|
||||
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
|
||||
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
|
||||
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.");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { autonomyHost, authorityHost } from './autonomy-host.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationPlugins } from '../../plugins/src/organization.mjs';
|
||||
import { pluginService } from '../../plugins/src/tools.mjs';
|
||||
@@ -890,23 +891,11 @@ 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.
|
||||
autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null,
|
||||
hires: organization ? (()=>{
|
||||
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
|
||||
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
|
||||
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();
|
||||
}};
|
||||
return authorityHost(service,{modelKeys,reload,proxyOrganization});
|
||||
})() : null,
|
||||
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,mkdirSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {Broker} from '../src/broker.mjs';
|
||||
import {Store} from '../src/store.mjs';
|
||||
import {autonomyHost,authorityHost} from '../src/autonomy-host.mjs';
|
||||
import {organizationWriter} from '../../organization/src/organization.mjs';
|
||||
import {instanceSettings} from '../../organization/src/instance-settings.mjs';
|
||||
import {hireDecisions} from '../../organization/src/hire-decisions.mjs';
|
||||
import {loadBusiness} from '../../organization/src/organization-config.mjs';
|
||||
import {businessDoc,rolesCopy,writeJson} from '../../organization/tests/helpers.mjs';
|
||||
const inline={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Changed role'};
|
||||
const cap={limit:100000,since:'2026-01-01T00:00:00.000Z'};
|
||||
function world(t,options={}) {
|
||||
const root=mkdtempSync(join(tmpdir(),'au-authority-')),rolesDir=rolesCopy(root),dataRoot=join(root,'data');mkdirSync(dataRoot,{mode:0o700});
|
||||
const doc=businessDoc(root,'demo');doc.plugins={harness:['pi'],'limit-check':['token-limit']};
|
||||
doc.launch.limits.tokens={providers:{zai:cap}};
|
||||
doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'},scout:{rev:3,role:structuredClone(inline),instructions:'Prior role'}};
|
||||
// Definition instructions live beside the role composition.
|
||||
for(const d of Object.values(doc.definitions))delete d.role.instructions;
|
||||
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
||||
doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
||||
doc.launch.instances.push('scout');doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true,reverseWindowHours:1};
|
||||
writeJson(join(root,'businesses/demo.json'),doc);
|
||||
let reversing=false,broker,now=Date.now;
|
||||
const writer=organizationWriter({dir:root,rolesDir,barrier:point=>{if(reversing && point==='prepared' && options.expirePrepared)Date.now=()=>now()+3600001;if(!reversing && point==='prepared' && options.removeResolverPrepared){const b=structuredClone(loaded());b.roles.judge.authority.withinRole=[];broker.reloadBusiness('demo',b);}}});
|
||||
const store=new Store(dataRoot),order=[];
|
||||
const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};};
|
||||
const reload=id=>{if(reversing && options.crossAfterCommit)Date.now=()=>now()+3600001;if(reversing && options.reloadRefused)return false;broker.reloadBusiness(id,loaded());return true;};
|
||||
const service=hireDecisions({writer,rolesDir,configDir:root,dataRoot,assertClean:()=>{}});
|
||||
const hires=authorityHost(service,{reload,modelKeys:{prepareRevoke:async(instance,guard)=>{guard();assert.equal(instance,'demo/scout');order.push('prepare');},completeRevoke:instance=>{assert.equal(instance,'demo/scout');assert.ok(store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='fire.stopped'"));assert.equal(writer.read('demo').document.definitions.scout.instructions,'Changed role');order.push('complete');}}});
|
||||
const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload});
|
||||
const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{order.push('fire-revoke');}});
|
||||
broker=new Broker({store,businesses:{demo:loaded()},hires,autonomy:host,organization:(id,verb,args,actor,context)=>{if(verb==='instance.fireCheck'){instances.checkFire(id,args.name);return {};}if(verb==='instance.fire')return instances.fire(id,args,{actor,context});throw Error('unexpected organization verb');}});
|
||||
const caps={};const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
|
||||
for(const role of ['chief','judge','scout']){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
|
||||
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}});
|
||||
t.after(()=>{Date.now=now;store.close();rmSync(root,{recursive:true,force:true});});
|
||||
const raise=(extra={})=>call('chief','hire.raise',{op:'change',level:'org',name:'scout',role:{inline},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Change the task',...extra});
|
||||
const reverse=id=>{reversing=true;return call('owner','decision.reverse.console',{id});};
|
||||
return {writer,store,order,broker,call,raise,reverse,loaded};
|
||||
}
|
||||
for(const crossAfterCommit of [false,true])test(`AU authority reverse uses runtime key wrapper and relaunches after commit (deadline crossed: ${crossAfterCommit})`,async t=>{
|
||||
const f=world(t,{crossAfterCommit}),d=await f.raise();assert.deepEqual(d.hire.flagged,[]);assert.equal(d.route_to,'judge');
|
||||
await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});assert.equal(f.writer.read('demo').document.definitions.scout.rev,4);
|
||||
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.relaunch,'requested');assert.deepEqual(f.order,['prepare','complete']);
|
||||
const restored=f.writer.read('demo').document.definitions.scout;assert.equal(restored.rev,5);assert.equal(restored.instructions,'Prior role');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
assert.ok(f.store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='lifecycle.launch' AND subject='scout'"));
|
||||
});
|
||||
test('AU a committed authority reverse reports pending recovery when reload refuses',async t=>{
|
||||
const f=world(t,{reloadRefused:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
});
|
||||
test('AU authority reverse crossing deadline in prepared IO never publishes',async t=>{
|
||||
const f=world(t,{expirePrepared:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});const prior=f.writer.read('demo').revision;
|
||||
await assert.rejects(f.reverse(d.id),{code:'reverse-window-closed'});assert.equal(f.writer.read('demo').revision,prior);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,prior);
|
||||
});
|
||||
test('AU authority resolver rechecks budget removal after a proposal was raised',async t=>{
|
||||
const f=world(t),d=await f.raise(),b=structuredClone(f.loaded());b.plugins['limit-check']=[];f.broker.reloadBusiness('demo',b);
|
||||
const before=f.writer.read('demo').revision;await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'autonomy-precondition'});
|
||||
assert.equal(f.writer.read('demo').revision,before);assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?',d.id).n,0);
|
||||
});
|
||||
|
||||
test('AU authority apply rechecks a removed resolve right at publication',async t=>{
|
||||
const f=world(t,{removeResolverPrepared:true}),d=await f.raise(),before=f.writer.read('demo').revision;
|
||||
await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
|
||||
assert.equal(f.writer.read('demo').revision,before);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,before);
|
||||
});
|
||||
test('AU authority hire reverse runs normal fire/revoke and records committed delisting',async t=>{
|
||||
const f=world(t),d=await f.raise({op:'hire',name:'helper'});await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.ok(f.writer.read('demo').document.launch.instances.includes('helper'));
|
||||
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.ok(!f.writer.read('demo').document.launch.instances.includes('helper'));assert.deepEqual(f.order,['fire-revoke']);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
});
|
||||
|
||||
test('AU completion-event failure after durable authority inverse records committed recovery, never reverse.failed',async t=>{
|
||||
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const run=f.store.run.bind(f.store);let injected=false;
|
||||
f.store.run=(sql,...args)=>{
|
||||
if(!injected && sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).action==='fire.done')){
|
||||
injected=true;throw new Error('injected completion event failure');
|
||||
}
|
||||
return run(sql,...args);
|
||||
};
|
||||
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.completion,'failed');assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');
|
||||
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
|
||||
assert.equal(f.writer.read('demo').document.definitions.scout.rev,5);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
});
|
||||
|
||||
test('AU inverse receipt-write failure returns committed with trailPending and never writes reverse.failed',async t=>{
|
||||
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const run=f.store.run.bind(f.store);let injected=false;
|
||||
f.store.run=(sql,...args)=>{
|
||||
if(sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).operation==='autonomy.reverse')){
|
||||
injected=true;throw new Error('injected receipt write failure');
|
||||
}
|
||||
return run(sql,...args);
|
||||
};
|
||||
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.trailPending,true);assert.equal(r.relaunch,'requested');
|
||||
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {Store} from '../src/store.mjs';
|
||||
import {Broker} from '../src/broker.mjs';
|
||||
import {autonomyHost} from '../src/autonomy-host.mjs';
|
||||
import {initializeWorking,workingRepo,push,prepareLand,finishLand,dropLand} from '../../seat/src/working.mjs';
|
||||
import {gitEnv} from '../../seat/src/checkout.mjs';
|
||||
const TASK='vikunja:1/2',SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
|
||||
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=Test','-c','[email protected]',...args],{cwd,encoding:'utf8',env:gitEnv()}).trim();
|
||||
async function world(t,level='near the loop') {
|
||||
const dir=mkdtempSync(join(tmpdir(),'au-')),root=join(dir,'root'),dataRoot=join(dir,'data');mkdirSync(root);mkdirSync(dataRoot,{mode:0o700});
|
||||
git(root,'init','-q','-b','main');writeFileSync(join(root,'file'),'before\n');git(root,'add','file');git(root,'commit','-qm','Initial');
|
||||
const before=git(root,'rev-parse','HEAD');
|
||||
const clone=join(dir,'clone');git(dir,'clone','-q',root,clone);git(clone,'checkout','-qb','feat');writeFileSync(join(clone,'file'),'after\n');git(clone,'commit','-qam','Change');const reviewed=git(clone,'rev-parse','HEAD');
|
||||
const bundle=join(dir,'bundle');git(clone,'bundle','create',bundle,'refs/heads/feat','--not','--remotes=origin');
|
||||
await initializeWorking({dataRoot,business:'demo',project:'stack',root});
|
||||
await push({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,branch:'feat',bundle});
|
||||
const authority=(withinRole=[],crossRole=[])=>({withinRole,crossRole});
|
||||
const business={id:'demo',human:'jason',projects:{stack:{root}},autonomy:{merge:level,notices:true,reverseWindowHours:1},arbiters:{technical:'cto',delivery:'cto'},roles:{cto:{definition:'cto',authority:authority(['decision.resolve.technical'])},coder:{definition:'coder',authority:authority(['git.push.working','review.request','message.send'])},reviewer:{definition:'reviewer',authority:authority(['review.verdict','message.send'])},gate:{definition:'merge-gate',authority:authority([],['git.merge.protected'])}}};
|
||||
let gates=0;
|
||||
const host=autonomyHost({dataRoot,business:()=>business,processing:{work:fn=>fn({guard(){},signal:undefined})},gate:{classify:async()=>true,run:async()=>{gates++;return {ok:true};}}});
|
||||
const store=new Store(dataRoot),broker=new Broker({store,businesses:{demo:business},autonomy:host}),caps={};
|
||||
t.after(()=>{store.close();rmSync(dir,{recursive:true,force:true});});
|
||||
const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
|
||||
for(const role of Object.keys(business.roles)){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
|
||||
caps.human=broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
|
||||
broker.checkPush(caps.coder,TASK);broker.recordPush(caps.coder,{task_ref:TASK,project:'stack',branch:'feat',ref:'work/vikunja-1-2',commit:reviewed,previous:null});
|
||||
const request=call('coder','review.request',{task_ref:TASK,commit:reviewed,to:'reviewer'});
|
||||
call('reviewer','review.verdict',{task_ref:TASK,request:request.request,verdict:'approve',body:'Reviewed'});
|
||||
const d=call('gate','decision.raise',{action:'git.merge.protected',project:'stack',target:TASK,question:'Land work/vikunja-1-2?',options:[{key:'yes',text:'Land change'},{key:'no',text:'Hold'}],recommendation:'yes',blocking:false});
|
||||
call('cto','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const land=async()=>{
|
||||
const facts=broker.checkLand(caps.gate,{task_ref:TASK,decision:d.id});
|
||||
const logs=join(dir,'land');mkdirSync(logs,{recursive:true});
|
||||
const prepared=await prepareLand({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,commit:reviewed,dir:logs,name:'original',rootHits:()=>[],message:()=> 'Change'});
|
||||
try {await finishLand({dataRoot,business:'demo',project:'stack',land:prepared,record:r=>broker.recordLand(facts.ticket,{commit:r.commit,previous:r.previous,reviewed,base:prepared.base,patch_id:prepared.patch_id})});}finally{await dropLand({dataRoot,business:'demo',project:'stack',worktree:prepared.worktree});}
|
||||
return prepared.commit;
|
||||
};
|
||||
return {broker,business,store,caps,call,d,before,land,gates:()=>gates,show:(...args)=>git(dir,`--git-dir=${workingRepo(dataRoot,'demo','stack')}`,...args)};
|
||||
}
|
||||
test('AU near merge lands immediately, owner notice reverses through gate and records a real child revert',async t=>{
|
||||
const f=await world(t),landed=await f.land();
|
||||
assert.equal(f.show('show','main:file'),'after');assert.equal(f.call('human','inbox').find(x=>x.id===f.d.id).autonomy.notice,true);
|
||||
const reverse=await f.call('owner','decision.reverse.console',{id:f.d.id});
|
||||
assert.equal(f.gates(),1);assert.equal(f.show('show','main:file'),'before');assert.equal(f.show('rev-parse','main^'),landed);
|
||||
assert.match(f.show('log','-1','--format=%B','main'),new RegExp(f.d.id));
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
assert.equal(reverse.reverted,landed);
|
||||
});
|
||||
test('AU in-loop landing refuses until confirmation; decline requires a new arbiter resolution',async t=>{
|
||||
const f=await world(t,'in the loop');
|
||||
await assert.rejects(f.land(),e=>['not-resolved','confirmation-required'].includes(e.code));
|
||||
f.call('owner','decision.confirm.console',{id:f.d.id,confirm:false,reason:'Review again'});
|
||||
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
|
||||
f.call('cto','decision.resolve',{id:f.d.id,choice:'yes'});f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true});
|
||||
await f.land();assert.equal(f.show('show','main:file'),'after');
|
||||
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
|
||||
});
|
||||
test('AU landing rechecks the resolver right both before land and after the gate',async t=>{
|
||||
const f=await world(t);const facts=f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id});
|
||||
f.business.roles.cto.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
|
||||
assert.throws(()=>f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id}),{code:'not-resolver'});
|
||||
assert.throws(()=>f.broker.recordLand(facts.ticket,{commit:'a'.repeat(40),previous:f.before,reviewed:'b'.repeat(40),base:f.before,patch_id:'c'.repeat(40)}),{code:'not-resolver'});
|
||||
assert.equal(f.show('rev-parse','main'),f.before);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
const SUBJECT = {provider:'test',iss:'https://issuer.test',sub:'owner'};
|
||||
async function setup(t, autonomy, autonomyHost=null) {
|
||||
const f=await fixture(t,{autonomyHost,roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:['git.merge.protected','task.scope.change','policy.change','prd.approve']}}}});
|
||||
if(autonomy) {f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);}
|
||||
f.owner=f.broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
|
||||
return f;
|
||||
}
|
||||
test('AU no setting keeps merge with technical arbiter',async t=>{
|
||||
const f=await setup(t);const d=f.raise({action:'git.merge.protected'});assert.equal(d.route_to,'chief');
|
||||
});
|
||||
test('AU in-loop merge keeps the arbiter resolver, waits for person, decline returns to arbiter',async t=>{
|
||||
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected',target:'vikunja:1/2'});
|
||||
assert.equal(d.route_to,'chief');
|
||||
assert.throws(()=>f.call(f.human,'decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}),{code:'confirmation-required'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Please revise'});
|
||||
assert.equal(f.call(f.chief,'inbox').find(x=>x.id===d.id)?.id,d.id);
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
|
||||
assert.equal(f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}).decision,d.id);
|
||||
});
|
||||
test('AU R5 subject resolver or resolver definition routes to person',async t=>{
|
||||
const f=await setup(t);
|
||||
for(const target of ['chief','chief-of-staff'])assert.equal(f.raise({action:'policy.change',target}).route_to,'human');
|
||||
});
|
||||
test('AU mismatched PRD domain refuses at raise with expected domain',async t=>{
|
||||
const f=await setup(t);
|
||||
assert.throws(()=>f.raise({action:'prd.approve',domain:'delivery'}),e=>e.code==='invalid-domain' && /technical/.test(e.reason));
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM decisions WHERE action='prd.approve'").n,0);
|
||||
});
|
||||
test('AU revoked resolver right refuses when an approved decision is consumed',async t=>{
|
||||
const f=await setup(t),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
f.business.roles.chief.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'not-resolver'});
|
||||
});
|
||||
test('AU member, agent and dev tester cannot change settings',async t=>{
|
||||
const revision='a'.repeat(64),f=await setup(t,null,{get:()=>({revision,autonomy:{}})});
|
||||
const member=f.broker.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights:['org.hire']});
|
||||
for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
|
||||
assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
|
||||
});
|
||||
test('AU near-loop merge sends owner a bounded notice; expired reverse names ordinary path',async t=>{
|
||||
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1}),d=f.raise({action:'git.merge.protected'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
const n=f.call(f.human,'inbox').find(x=>x.id===d.id);
|
||||
assert.equal(n.autonomy.notice,true);assert.ok(Date.parse(n.autonomy.reverseUntil)>Date.now());
|
||||
const now=Date.now;
|
||||
try{Date.now=()=>now()+3600001;await assert.rejects(Promise.resolve().then(()=>f.call(f.owner,'decision.reverse.console',{id:d.id})),e=>e.code==='reverse-window-closed' && /ordinary.*decision/.test(e.reason));}finally{Date.now=now;}
|
||||
});
|
||||
test('AU no setting can move each hard wait from a person',async t=>{
|
||||
const f=await setup(t,{'cross-role':'out of the loop',merge:'near the loop',authority:'out of the loop'});
|
||||
for(const action of ['git.push.protected','host.sudo','credential.mint','spend','deploy','data.delete']){
|
||||
if(action==='host.sudo')continue; // Its host grant precondition is covered in hard-waits.test.mjs.
|
||||
assert.equal(f.raise({action}).route_to,'human');
|
||||
}
|
||||
});
|
||||
test('AU owner setting changes have immutable actor trail; bad classes/levels refuse; tester refused',async t=>{
|
||||
let f,autonomy={},revision='a'.repeat(64);
|
||||
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'transaction-1'};},reversible:()=>true};
|
||||
f=await setup(t,null,host);
|
||||
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{merge:'in the loop'}});
|
||||
assert.equal(f.store.all("SELECT actor_role,actor_run,body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'").length,1);
|
||||
const row=f.store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'");assert.deepEqual(JSON.parse(row.body).subject,SUBJECT);
|
||||
for(const value of [{unknown:'near the loop'},{merge:'fast'}])await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),{code:'invalid-autonomy'});
|
||||
f.business.vars={'bus.tester':true};f.broker.reloadBusiness('demo',f.business);const tester=f.broker.bindTester({business:'demo'});
|
||||
assert.throws(()=>f.call(tester,'autonomy.set.console',{revision,autonomy:{}}),{code:'human-required'});
|
||||
assert.throws(()=>f.broker.recordEvent(f.coder,{kind:'action.allowed',body:{operation:'autonomy.confirmed',decision:'forged'}}),{code:'reserved-event'});
|
||||
});
|
||||
test('AU reverse consumes once and rechecks the window at the effect boundary',async t=>{
|
||||
let effects=0;
|
||||
const host={reverse:async({beforeWrite,record})=>{beforeWrite();effects++;return {commit:'b'.repeat(40)};}};
|
||||
const f=await setup(t,{merge:'near the loop',notices:true},host),d=f.raise({action:'git.merge.protected'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
const r=await f.call(f.owner,'decision.reverse.console',{id:d.id});assert.equal(r.reversed,true);assert.equal(effects,1);
|
||||
await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'decision-reversed'});
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
});
|
||||
test('AU a near-loop reverse that crosses the deadline cannot write',async t=>{
|
||||
let effects=0;const now=Date.now;
|
||||
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1},{reverse:async({beforeWrite})=>{Date.now=()=>now()+3600001;beforeWrite();effects++;return {};}});
|
||||
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
try{await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'reverse-window-closed'});}finally{Date.now=now;}
|
||||
assert.equal(effects,0);
|
||||
});
|
||||
|
||||
test('AU policy near refuses with a named next step; in and the existing out level remain usable',async t=>{
|
||||
let f,autonomy={},revision='a'.repeat(64);
|
||||
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'policy-setting'};},reversible:()=>false};
|
||||
f=await setup(t,null,host);
|
||||
await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'near the loop'}}),e=>e.code==='policy-reverse-unavailable' && /in the loop or out of the loop/.test(e.nextStep));
|
||||
assert.deepEqual(autonomy,{});
|
||||
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'in the loop'}});
|
||||
const d=f.raise({action:'policy.change',target:'policy-1'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}),{code:'confirmation-required'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
|
||||
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}).decision,d.id);
|
||||
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'out of the loop'}});
|
||||
const out=f.raise({action:'policy.change',target:'policy-2'});f.call(f.chief,'decision.resolve',{id:out.id,choice:'yes'});
|
||||
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:out.id,target:'policy-2'}).decision,out.id);
|
||||
});
|
||||
test('AU near cross-role authorization without a trusted inverse fails closed before consumption',async t=>{
|
||||
const f=await setup(t,{'cross-role':'near the loop',notices:true}),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'reversal-unavailable'});
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.action')='task.scope.change' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",d.id).n,0);
|
||||
});
|
||||
|
||||
|
||||
test('AU a withdrawal after decline is terminal for resolver, owner and confirmation',async t=>{
|
||||
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Revise'});
|
||||
f.call(f.coder,'decision.withdraw',{id:d.id});
|
||||
assert.throws(()=>f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}),{code:'decision-closed'});
|
||||
for(const cap of [f.chief,f.human])assert.ok(!f.call(cap,'inbox').some(x=>x.id===d.id));
|
||||
assert.throws(()=>f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}),e=>['decision-closed','decision-not-eligible'].includes(e.code));
|
||||
});
|
||||
async function settingsFixture(t) {
|
||||
let f,autonomy={},revision='a'.repeat(64),writes=0;
|
||||
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{writes++;autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'setting-test'};},reversible:()=>true};
|
||||
f=await setup(t,null,host);
|
||||
f.business.roles.judge={definition:'judge',authority:{withinRole:['decision.resolve.authority'],crossRole:[]}};
|
||||
f.business.roles.reviewer.authority.withinRole.push('review.verdict');
|
||||
f.business.launch={by:'chief',models:{model:{provider:'anthropic',model:'test-model'}},limits:{tokens:{}}};
|
||||
f.business.plugins={'limit-check':['token-limit']};
|
||||
f.broker.reloadBusiness('demo',f.business);
|
||||
return {...f,set:value=>f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),writes:()=>writes};
|
||||
}
|
||||
const authorityNear={authority:'near the loop',authorityResolver:'judge',notices:true};
|
||||
const tokenCap={limit:1000,since:'2026-01-01T00:00:00.000Z'};
|
||||
test('AU authority near requires an enforced organization budget covering every allowed model, including zero caps',async t=>{
|
||||
const f=await settingsFixture(t);
|
||||
for(const tokens of [{},{instances:{coder:tokenCap}},{providers:{unrelated:tokenCap}}]){
|
||||
f.business.launch.limits.tokens=tokens;f.broker.reloadBusiness('demo',f.business);
|
||||
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
|
||||
}
|
||||
f.business.launch.limits.tokens={providers:{anthropic:{...tokenCap,limit:0}}};
|
||||
f.business.plugins={'limit-check':[]};f.broker.reloadBusiness('demo',f.business);
|
||||
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
|
||||
f.business.plugins={'limit-check':['token-limit']};f.broker.reloadBusiness('demo',f.business);
|
||||
await f.set(authorityNear);assert.equal(f.writes(),1);
|
||||
});
|
||||
test('AU authority out refuses until per-definition agent limits exist, even with current agents capped',async t=>{
|
||||
const f=await settingsFixture(t);
|
||||
f.business.launch.limits.tokens={providers:{anthropic:tokenCap},instances:Object.fromEntries(Object.keys(f.business.roles).map(n=>[n,tokenCap]))};
|
||||
f.broker.reloadBusiness('demo',f.business);await f.set(authorityNear);
|
||||
f.call(f.owner,'autonomy.review.console',{decisionClass:'authority'});
|
||||
await assert.rejects(f.set({...authorityNear,authority:'out of the loop'}),e=>e.code==='authority-limits-unavailable' && /near the loop/.test(e.nextStep));
|
||||
assert.equal(f.writes(),1);
|
||||
});
|
||||
test('AU moving out requires an actual near resolution and a later owner review',async t=>{
|
||||
const f=await settingsFixture(t);await f.set({merge:'in the loop'});await f.set({merge:'near the loop',notices:true});
|
||||
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
|
||||
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
|
||||
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
|
||||
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
|
||||
await f.set({merge:'out of the loop',notices:true});
|
||||
});
|
||||
@@ -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', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.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', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.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);
|
||||
@@ -227,7 +227,8 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
|
||||
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
|
||||
const f = setup(t);
|
||||
for (const route of ['pm', 'chief']) {
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, raiser:route==='chief'?'pm':'chief', close: null });
|
||||
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
f.call(route, 'decision.resolve', { id, choice: 'yes' });
|
||||
|
||||
@@ -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 {autonomyHost} from '../src/autonomy-host.mjs';
|
||||
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -75,7 +76,7 @@ function makeBusiness(url) {
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t) {
|
||||
async function setup(t,autonomy=null) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'g281-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
||||
@@ -157,7 +158,7 @@ async function setup(t) {
|
||||
return { revision: REV };
|
||||
};
|
||||
const make = () =>
|
||||
new Broker({hires:hireServiceFixture,
|
||||
new Broker({hires:hireServiceFixture,autonomy,
|
||||
store,
|
||||
businesses: { demo: structuredClone(doc) },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
@@ -997,3 +998,24 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
|
||||
assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message);
|
||||
assert.ok(!message.includes('may already be revoked'), message);
|
||||
});
|
||||
|
||||
for(const expired of [false,true])test(`AU hire inverse waits for grant removal and refuses late publication (expired: ${expired})`,async t=>{
|
||||
const host=autonomyHost({hires:{reverse:async(id,decision,caller)=>caller.fire({id:decision,level:'org',name:'coder',revision:REV,digest:'a'.repeat(64)})},reload:()=>true});
|
||||
const f=await setup(t,host);await f.approveHost('coder','web1');
|
||||
const decision=grantDecision(f.store,{action:'role.hire',route:'pm',close:{op:'resolved',by:'pm-run',choice:'yes',via:'broker'}});
|
||||
const at=new Date().toISOString(),deadline=new Date(Date.now()+3600000).toISOString();
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,?,'action.allowed',?,?)",decision,at,'demo',decision,JSON.stringify({operation:'autonomy.notice',decision,decisionClass:'authority',reverseUntil:deadline}));
|
||||
f.held.add('web1:remove');
|
||||
const owner=fixtureConsole(f.broker),pending=f.broker.request(owner,{verb:'decision.reverse.console',args:{id:decision}});
|
||||
// Attach the rejection handler before letting the asynchronous revoke complete.
|
||||
const outcome=pending.then(value=>({value}),error=>({error}));
|
||||
await until(()=>f.sent.some(s=>s.startsWith('remove web1')));
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,0);assert.ok(f.doc.launch.instances.includes('coder'));
|
||||
const clock=Date.now;
|
||||
try {
|
||||
if(expired)Date.now=()=>Date.parse(deadline)+1;
|
||||
f.held.delete('web1:remove');const result=await outcome;await f.broker.grantsIdle();
|
||||
if(expired){assert.equal(result.error?.code,'reverse-window-closed');assert.ok(f.doc.launch.instances.includes('coder'));}
|
||||
else{assert.equal(result.error,undefined);assert.equal(result.value.committed,true);assert.match(result.value.transaction,/^t-/);assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);assert.ok(!f.doc.launch.instances.includes('coder'));}
|
||||
}finally{Date.now=clock;f.held.clear();}
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for i
|
||||
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
|
||||
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: ['git.merge.protected','prd.approve'].includes(action) ? 'cto' : 'chief' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -205,3 +205,36 @@ test('A4 inbox and decision reads expire unopened hires once without changing co
|
||||
} finally {Date.now=original;}
|
||||
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
|
||||
});
|
||||
test('AU a distinct authority resolver applies a plain near-loop hire, while flagged and credential hires stay human',async t=>{
|
||||
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
|
||||
doc.definitions={resolver:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.technical','decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent decisions.'}};
|
||||
doc.roles.cto={definition:'org:resolver'};
|
||||
doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}};
|
||||
doc.autonomy={authority:'near the loop',authorityResolver:'cto',notices:true};writeJson(f.file,doc);
|
||||
const live=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
|
||||
const business={...live,roles:Object.fromEntries(Object.entries(live.roles).map(([n,e])=>[n,{...live.definitions[e.definition],...e}]))};
|
||||
business.roles.cto.authority={withinRole:['decision.resolve.authority'],crossRole:[]};f.b.reloadBusiness('demo',business);
|
||||
const resolver=f.b.bindLaunch({business:'demo',role:'cto',run:'cto-run',harness:'pi'});f.call(resolver,'role.claim');
|
||||
const d=await f.raise();assert.equal(d.route_to,'cto');
|
||||
await assert.rejects(Promise.resolve().then(()=>f.call(f.caps.chief,'decision.resolve',{id:d.id,choice:'yes'})),{code:'not-resolver'});
|
||||
await f.call(resolver,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.equal(f.writes(),1);assert.equal(JSON.parse(readFileSync(f.file)).roles.scout.hireDecision,d.id);
|
||||
for(const change of [{name:'flagged',role:{inline:{...INLINE,tools:['bash']}}},{name:'credential',role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}}]) {
|
||||
const decision=await f.raise(change);assert.equal(decision.route_to,'human');
|
||||
}
|
||||
});
|
||||
|
||||
test('AU R5 a change naming the authority resolver stays with a person; no agent resolves itself',async t=>{
|
||||
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
|
||||
doc.definitions={judge:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent authority decisions.'}};
|
||||
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
||||
doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}};
|
||||
doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true};writeJson(f.file,doc);
|
||||
const loaded=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
|
||||
f.b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
|
||||
const judge=f.b.bindLaunch({business:'demo',role:'judge',run:'judge-run',harness:'pi'});f.call(judge,'role.claim');
|
||||
const d=await f.raise({op:'change',name:'judge',role:{inline:{...INLINE,authority:{withinRole:['decision.resolve.authority'],crossRole:[]},instructions:'Narrow this resolver'}}});
|
||||
assert.ok(d.hire.flagged.some(x=>x.value==='decision.resolve.authority'));assert.equal(d.route_to,'human');
|
||||
await assert.rejects(Promise.resolve().then(()=>f.call(judge,'decision.resolve',{id:d.id,choice:'yes'})));
|
||||
assert.equal(f.writes(),0);
|
||||
});
|
||||
@@ -12,15 +12,15 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
export function grantDecision(store, { business = 'demo', raiser = 'chief', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, AT, business, 'chief', 'chief-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
|
||||
id, AT, business, raiser, raiser+'-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
|
||||
);
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
randomUUID(), AT, business, raiser, raiser+'-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
|
||||
@@ -346,3 +346,10 @@ for(const level of ['root','project'])test(`A4 ${level} fire removes its implici
|
||||
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}}));
|
||||
});
|
||||
|
||||
test('AU resolver guard runs inside the model revocation queue before any remote or local effect',async t=>{
|
||||
const f=await fixture(t);await f.manager.reconcile();await f.manager.ensure('demo/coder');
|
||||
const before=f.credentials.modelRecord('demo/coder'),calls=f.state.calls.length;
|
||||
await assert.rejects(f.manager.prepareRevoke('demo/coder',()=>{throw Object.assign(new Error('not-resolver'),{code:'not-resolver'});}),{code:'not-resolver'});
|
||||
assert.deepEqual(f.credentials.modelRecord('demo/coder'),before);assert.equal(f.state.calls.length,calls);
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { fixture } from './spec-fixtures.mjs';
|
||||
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
"import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';",
|
||||
"import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';",
|
||||
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
|
||||
"import { ownRails } from '../../organization/src/rails.mjs';",
|
||||
"import { refusalGuidance } from './refusals.mjs';",
|
||||
@@ -45,7 +47,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/autonomy-state.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../organization/src/autonomy.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../organization/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
});
|
||||
|
||||
@@ -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', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.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', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.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']);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null, autonomyHost = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -20,7 +20,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
...roles,
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
const broker = new Broker({ store, businesses: { demo: business }, access, project: adapter ? {
|
||||
const broker = new Broker({ store, businesses: { demo: business }, access, autonomy: autonomyHost, project: adapter ? {
|
||||
readSpec: context => readSpec({ ...context, dataRoot: root }),
|
||||
writeRecord: context => writeRecord({ ...context, dataRoot: root }),
|
||||
listFiles: context => listProjectFiles({ ...context, dataRoot: root }),
|
||||
|
||||
@@ -546,3 +546,18 @@ test('review: saturated and adjacent positions refuse without a write', async (t
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
|
||||
});
|
||||
|
||||
test('AU near cross-role task scope restores prior fields and refuses overwriting a later edit',async t=>{
|
||||
const f=await org(t);f.broker.reloadBusiness('acme',business('acme',{autonomy:{'cross-role':'near the loop',notices:true}}));
|
||||
const owner=f.broker.bindConsole({business:'acme',human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
|
||||
const made=f.person('tasks.create',{title:'Before',requirement:'REQ-TASK-1'});
|
||||
const d=f.approve('coder','task.scope.change',made.task_ref);
|
||||
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'After',decision:d});
|
||||
const reversed=await f.broker.request(owner,{verb:'decision.reverse.console',args:{id:d}});
|
||||
assert.equal(reversed.reversed,true);assert.equal(f.read()[0].fields.title,'Before');
|
||||
const later=f.approve('coder','task.scope.change',made.task_ref);
|
||||
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'Second',decision:later});
|
||||
f.person('tasks.edit',{task_ref:made.task_ref,title:'Owner edit'});
|
||||
await assert.rejects(f.broker.request(owner,{verb:'decision.reverse.console',args:{id:later}}),{code:'decision-stale'});
|
||||
assert.equal(f.read()[0].fields.title,'Owner edit');
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Compatibility path for organization autonomy policy.
|
||||
export * from "../../organization/src/autonomy.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";
|
||||
@@ -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`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -386,7 +399,7 @@ export function createLauncher({
|
||||
// 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.
|
||||
@@ -409,7 +422,7 @@ 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");
|
||||
@@ -425,7 +438,7 @@ export function createLauncher({
|
||||
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.
|
||||
@@ -437,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}`);
|
||||
@@ -484,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,
|
||||
@@ -532,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
|
||||
@@ -542,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];
|
||||
}
|
||||
@@ -664,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) {
|
||||
@@ -861,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,
|
||||
@@ -1041,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}:{}) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1143,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;
|
||||
}
|
||||
@@ -1203,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 } : {}) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1248,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 } : {}) });
|
||||
@@ -1356,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 });
|
||||
}
|
||||
@@ -1438,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 {
|
||||
@@ -1453,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 });
|
||||
}
|
||||
});
|
||||
@@ -1467,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;
|
||||
}
|
||||
@@ -1519,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);
|
||||
}
|
||||
});
|
||||
@@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) {
|
||||
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
|
||||
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
|
||||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/organization/src/autonomy.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
|
||||
done
|
||||
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -404,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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -625,3 +625,32 @@ other two-file journals, a crash or IO failure between replacement and receipt
|
||||
append can leave committed state without its receipt; an observed append failure
|
||||
has `committed: true` and must be inspected before retrying. It never rolls back
|
||||
committed authority silently or fabricates a success receipt.
|
||||
|
||||
### Organization autonomy (AU)
|
||||
|
||||
The optional `autonomy` object selects `in the loop`, `near the loop` or
|
||||
`out of the loop` for `cross-role`, `merge`, `policy` and `authority`.
|
||||
Without it, cross-role, merge and policy retain their arbiter routing and
|
||||
plain hire/fire/change retain person approval. Only the owner Console writes
|
||||
this setting; every change records the authenticated owner and writer receipt.
|
||||
|
||||
In the loop, the arbiter resolves and a person confirms or returns the decision
|
||||
with a reason. Near the loop, the agent's resolution takes effect and the owner
|
||||
receives a Console notice. `reverseWindowHours` is an integer from 1 to 168
|
||||
(default 24). `notices: true` is required near the loop. `authorityResolver`
|
||||
names a distinct organization instance holding `decision.resolve.authority`
|
||||
withinRole; flagged grants, credential lines and root agents always retain
|
||||
owner approval. No setting changes hard waits or the PRD request-mode switch.
|
||||
|
||||
Moving toward the loop needs no prerequisites. Moving away requires the class's
|
||||
resolver, inverse, notices and review/limit prerequisites. Moving from near to
|
||||
out also requires an actual near-loop resolution and a later owner trail review. Authority near requires an enabled limit check and an aggregate quota covering every selectable model (or the shared account when every enabled harness uses it). Empty budgets and existing-instance caps do not cover future hires. Authority out refuses `authority-limits-unavailable` until per-definition limits exist. A task inverse restores its prior
|
||||
stored fields if no later edit intervened; a merge inverse lands a gated revert
|
||||
commit. Hire/fire/change inverses reuse the existing lifecycle and writer paths,
|
||||
never restore credential bindings, and refuse stale targets. Authority inverses check the window immediately before file publication. After a durable commit, completion-event, reload or relaunch failures are reported as recovery metadata. The inverse is recorded as committed; if that trail write itself fails, the response says `trailPending` rather than recording a false reversal failure. A refusal after stop/revoke leaves the agent stopped; inspect its trail before relaunching. External task
|
||||
adapters without host-owned inverse evidence refuse near-loop consumption.
|
||||
After the pinned window, use an ordinary new decision.
|
||||
|
||||
Policy has no effect executor. Per the Lead ruling, its in and existing out
|
||||
levels work, while near refuses `policy-reverse-unavailable` and directs the
|
||||
owner to choose in or out. Policy near and its reverse wait for that executor.
|
||||
@@ -0,0 +1,44 @@
|
||||
// AU: policy data only. Runtime prerequisites and caller identity belong to the host.
|
||||
export const AUTONOMY_CLASSES = Object.freeze(['cross-role','merge','policy','authority']);
|
||||
export const AUTONOMY_LEVELS = Object.freeze(['in the loop','near the loop','out of the loop']);
|
||||
export const AUTONOMY_DEFAULTS = Object.freeze({'cross-role':'out of the loop',merge:'out of the loop',policy:'out of the loop',authority:'in the loop'});
|
||||
const refuse = () => { const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e; };
|
||||
export function checkAutonomy(value) {
|
||||
if(value===undefined)return undefined;
|
||||
if(!value || typeof value!=='object' || Array.isArray(value) || ![Object.prototype,null].includes(Object.getPrototypeOf(value)))refuse();
|
||||
for(const [key,v]of Object.entries(value)) {
|
||||
if(AUTONOMY_CLASSES.includes(key)) {if(!AUTONOMY_LEVELS.includes(v))refuse();if(key==='policy' && v==='near the loop')throw Object.assign(new Error('policy-reverse-unavailable'),{code:'policy-reverse-unavailable'});}
|
||||
else if(key==='reverseWindowHours') {if(!Number.isSafeInteger(v)||v<1||v>168)refuse();}
|
||||
else if(key==='notices') {if(typeof v!=='boolean')refuse();}
|
||||
else if(key==='authorityResolver') {if(typeof v!=='string'||!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(v))refuse();}
|
||||
else refuse();
|
||||
}
|
||||
return structuredClone(value);
|
||||
}
|
||||
export function decisionAutonomy(name, setting) {
|
||||
if(!AUTONOMY_CLASSES.includes(name))refuse();
|
||||
checkAutonomy(setting);
|
||||
return setting?.[name]??AUTONOMY_DEFAULTS[name];
|
||||
}
|
||||
export function autonomyClass(action) {
|
||||
return action==='git.merge.protected'?'merge':action==='policy.change'?'policy':['role.hire','role.fire','role.change'].includes(action)?'authority':'cross-role';
|
||||
}
|
||||
export function resolverSubject(business, resolver, target) {
|
||||
if(target==null)return false;
|
||||
const definition=business.roles?.[resolver]?.definition;
|
||||
return target===resolver || target===definition || target===definition?.replace(/^org:/,'') || (definition!==undefined && business.roles?.[target]?.definition===definition);
|
||||
}
|
||||
|
||||
// Aggregate provider/model limits cover names that do not exist yet. An account
|
||||
// quota covers every future launch only when every enabled harness uses that account.
|
||||
export function authorityBudgetCovered(business) {
|
||||
const launch=business.launch, tokens=launch?.limits?.tokens, checks=business.plugins?.['limit-check']??[];
|
||||
const quota=x=>Number.isSafeInteger(x?.limit) && x.limit>=0 && typeof x.since==='string' && !Number.isNaN(Date.parse(x.since));
|
||||
const account=launch?.account?.name, harnesses=business.plugins?.harness??[];
|
||||
if(account && harnesses.length && harnesses.every(h=>h==='claude-code') &&
|
||||
(checks.includes('daily-quota') && quota(tokens?.daily?.[account]) ||
|
||||
checks.includes('token-limit') && quota(tokens?.keys?.[account]) && tokens.keys[account].reset===undefined))return true;
|
||||
const models=Object.values(launch?.models??{});
|
||||
return checks.includes('token-limit') && models.length>0 && models.every(({provider,model})=>
|
||||
quota(tokens?.providers?.[provider]) || quota(tokens?.models?.[provider]?.[model]));
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,11 @@ const keys = (x, allowed, required = []) => { if (!isPlainObject(x) || Object.ke
|
||||
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?.agent===true) {
|
||||
if(typeof caller.authorityDecision!=='string' || typeof caller.resolver!=='string')fail('human-required');
|
||||
if(caller.authorityDecision!==proposal.id || !caller.rights?.includes('decision.resolve.authority') || proposal.flagged.length || proposal.credentialLines.length || proposal.level==='root' || caller.resolver===proposal.name)fail('hire-owner-required');
|
||||
return;
|
||||
}
|
||||
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');
|
||||
@@ -149,7 +154,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
}
|
||||
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};
|
||||
const prior={instance:current??null,definition:current?.definition?.startsWith('org:')?(input.level==='root'?root.document.definitions:doc.definitions)?.[current.definition.slice(4)]??null:null,launched:doc.launch?.instances?.includes(input.name)??false};
|
||||
const p={id:decision,business,prior,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);
|
||||
@@ -238,8 +244,32 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
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});
|
||||
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
|
||||
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
|
||||
}
|
||||
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))};
|
||||
async function reverse(business,decision,caller) {
|
||||
const p=read(decision);if(p.business!==business || !p.prior || p.request.level==='root')fail('decision-stale');
|
||||
const r=p.request,old=writer.read(business),current=r.level==='org'?old.document.roles[r.name]:old.document.projects[r.project]?.agents?.[r.name];
|
||||
// A later approved change must not be overwritten by an earlier inverse.
|
||||
if(r.op==='fire') {
|
||||
if(r.level==='project'?current!==undefined:old.document.launch.instances.includes(r.name))fail('decision-stale');
|
||||
if(r.level==='org') {
|
||||
const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;};
|
||||
if(canonicalJson(strip(current))!==canonicalJson(strip(p.prior.instance)))fail('decision-stale');
|
||||
}
|
||||
if(p.prior.definition && canonicalJson(old.document.definitions?.[p.prior.instance.definition.slice(4)])!==canonicalJson(p.prior.definition))fail('decision-stale');
|
||||
} else if(current?.hireDecision!==decision)fail('decision-stale');
|
||||
const publicationGuard=()=>{(caller.beforePublish??caller.beforeWrite)();if(p.prior.definition)scanEntry(p.prior.instance.definition.slice(4),p.prior.definition);if(writer.read(business).revision!==old.revision)fail('decision-stale');};
|
||||
const guard=()=>{caller.beforeWrite();publicationGuard();};
|
||||
guard();
|
||||
if(r.op==='hire') {
|
||||
if(r.level==='org')return caller.fire({...publicOf(p),revision:old.revision});
|
||||
await caller.stop(publicOf(p),guard);guard();
|
||||
return writer.setProjectAgent(business,{revision:old.revision,project:r.project,name:r.name,entry:null},caller.actor,{beforeWrite:guard,beforePublish:publicationGuard});
|
||||
}
|
||||
const stopped=await caller.stop(publicOf(p),guard);guard();
|
||||
const result=writer.reverseHireDecision(business,p,{...caller,beforeWrite:guard,beforePublish:publicationGuard});
|
||||
return {...result,...(r.op==='change' && stopped?.running?{relaunch:publicOf(p)}:{})};
|
||||
}
|
||||
return {prepare,reverse,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))};
|
||||
}
|
||||
@@ -355,13 +355,14 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
|
||||
// its tokens revoked, with no write. A revoke is safe to repeat.
|
||||
if (!old.launch.instances.includes(name)) {
|
||||
context?.beforeWrite?.();
|
||||
const revoked = revoke ? revoke(business, name, "fire", services) : null;
|
||||
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
|
||||
}
|
||||
const next = structuredClone(old);
|
||||
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
|
||||
let revoked = null;
|
||||
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
|
||||
const beforeWrite = () => { context?.beforeWrite?.(); if(revoke)revoked = revoke(business, name, "fire", services); context?.beforeWrite?.(); };
|
||||
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
|
||||
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
|
||||
}
|
||||
@@ -422,7 +423,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
|
||||
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
|
||||
// a stale fire or delete from revoking.
|
||||
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
|
||||
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
|
||||
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite, beforePublish:context?.beforePublish });
|
||||
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
|
||||
}
|
||||
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,11 @@ export const ROLE_RIGHTS = record({
|
||||
// M5 must separately exclude credential.mint from decide and assignees from
|
||||
// member task writes; these names alone cannot express action/field restrictions.
|
||||
export const VERB_RIGHTS = record({
|
||||
'autonomy.get.console': null,
|
||||
'autonomy.set.console': null,
|
||||
'autonomy.review.console': null,
|
||||
'decision.confirm.console': null,
|
||||
'decision.reverse.console': null,
|
||||
'project.list': 'org.view',
|
||||
names: 'org.view',
|
||||
'thread.list': 'thread.read',
|
||||
|
||||
@@ -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')}`);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { checkAutonomy } from './autonomy.mjs';
|
||||
// The organization configuration file, still the v1 business file at
|
||||
// <configDir>/businesses/<id>.json (REQ-ROLE-3, note section 2, addendum A
|
||||
// sections 7 and 8; rename design r2 stage B: names only, same bytes). Human edits go through the trusted
|
||||
@@ -27,7 +28,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -357,12 +358,20 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
|
||||
}
|
||||
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
|
||||
const autonomy = checkAutonomy(document.autonomy);
|
||||
if(autonomy?.authorityResolver) {
|
||||
const entry=roles[autonomy.authorityResolver],right='decision.resolve.authority';
|
||||
if(!entry || !definitions[entry.definition].authority.withinRole.includes(right) ||
|
||||
[vars,entry.vars].some(layer=>layer?.['limits.authority']!==undefined && !layer['limits.authority'].includes(right))) {
|
||||
const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e;
|
||||
}
|
||||
}
|
||||
const appearance = checkAppearance(document.appearance, `${file} appearance`);
|
||||
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
|
||||
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
|
||||
...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
|
||||
...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,12 @@ function usageFence(old, document, actor) {
|
||||
fail("organization-usage-human-only");
|
||||
}
|
||||
|
||||
function autonomyFence(old, document, actor) {
|
||||
if(canonicalJson(old.autonomy)===canonicalJson(document.autonomy))return;
|
||||
const agents=Object.entries(old.roles??{}).flatMap(([n,e])=>[n,`${e.definition}.${n}`]);
|
||||
if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required');
|
||||
}
|
||||
|
||||
function systemTypesFence(old, document, actor) {
|
||||
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
|
||||
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
|
||||
@@ -276,7 +282,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// the old document, for a writer that builds what it publishes from what it read under the lock.
|
||||
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
|
||||
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
let revoked = false;
|
||||
@@ -298,6 +304,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
modelQuotaFence(old, document, actor);
|
||||
usageFence(old, document, actor);
|
||||
systemTypesFence(old, document, actor);
|
||||
autonomyFence(old, document, actor);
|
||||
validateBusinessDocument(document, file, { rolesDir, dataRoot });
|
||||
const bytes = JSON.stringify(document, null, 2) + "\n";
|
||||
// Check and log exactly what lands on disk, for every setter: a value JSON
|
||||
@@ -308,6 +315,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
modelQuotaFence(old, written, actor);
|
||||
usageFence(old, written, actor);
|
||||
systemTypesFence(old, written, actor);
|
||||
autonomyFence(old, written, actor);
|
||||
validateBusinessDocument(written, file, { rolesDir, dataRoot });
|
||||
if (beforeWrite) {
|
||||
beforeWrite();
|
||||
@@ -335,6 +343,13 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
rmSync(candidate);
|
||||
fail("revision-conflict", "organization revision changed before publication");
|
||||
}
|
||||
// Pure admission check after candidate IO, immediately before publication.
|
||||
// A refused prepared candidate is never rolled forward by recovery.
|
||||
try { beforePublish?.(); } catch (error) {
|
||||
log(id, { ...event, state: 'aborted' });
|
||||
rmSync(candidate);
|
||||
throw error;
|
||||
}
|
||||
renameSync(candidate, file);
|
||||
syncDir(parent);
|
||||
if (entry) { entry.digest = after; entry.revision++; publishRegistry(); }
|
||||
@@ -456,7 +471,7 @@ 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 ((!approval && actor !== old.human) || actor === "tester" || agents.includes(actor)) fail("human-required");
|
||||
if ((!approval && actor !== old.human) || actor === "tester" || (approval?.authorityDecision!==approval?.decision && 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 checked = structuredClone(document);
|
||||
if (approval?.approvedDefinition) {
|
||||
@@ -476,16 +491,16 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
if (entry === null) delete agents[name]; else agents[name] = entry;
|
||||
const { agents: _drop, ...rest } = target;
|
||||
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}: {}) } });
|
||||
}, { revision, actor, guard, beforeWrite:approval?.beforeWrite, beforePublish:approval?.beforePublish, 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 }) {
|
||||
function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null, beforePublish = null, authorityDecision = 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 (actor === 'tester' || (authorityDecision!==proposal.id && 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');
|
||||
@@ -511,12 +526,50 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
}
|
||||
return next;
|
||||
};
|
||||
return commit(id, build, {revision,actor,beforeWrite,pathsOnly:true,guard:(old,next)=>{
|
||||
return commit(id, build, {revision,actor,beforeWrite,beforePublish,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}});
|
||||
}
|
||||
|
||||
// AU inverse, from an immutable private proposal. It touches only the named instance and
|
||||
// definition; writer validation, fresh revision and owner fence still apply. Bindings never restore.
|
||||
function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) {
|
||||
const {document:old,revision}=read(id),r=p.request,prior=p.prior;
|
||||
if(actor!==old.human || !prior || r.level==='root')fail('human-required');
|
||||
if(r.op==='hire')fail('invalid-request'); // Hire reverses through the stop/revoke/fire executor.
|
||||
const build=current=>{
|
||||
const live=r.level==='org'?current.roles[r.name]:current.projects[r.project]?.agents?.[r.name];
|
||||
if(r.op==='fire') {
|
||||
if(r.level==='project'?live!==undefined:current.launch.instances.includes(r.name))fail('decision-stale');
|
||||
if(r.level==='org') {
|
||||
const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;};
|
||||
if(canonicalJson(strip(live))!==canonicalJson(strip(prior.instance)))fail('decision-stale');
|
||||
}
|
||||
if(prior.definition && canonicalJson(current.definitions?.[prior.instance.definition.slice(4)])!==canonicalJson(prior.definition))fail('decision-stale');
|
||||
} else if(live?.hireDecision!==p.id)fail('decision-stale');
|
||||
const next=structuredClone(current);
|
||||
if(r.level==='project') {
|
||||
const entries=next.projects[r.project].agents??{};
|
||||
if(prior.instance)entries[r.name]=structuredClone(prior.instance);else delete entries[r.name];
|
||||
delete entries[r.name]?.credentials;delete entries[r.name]?.tracker;
|
||||
next.projects[r.project].agents=entries;
|
||||
} else {
|
||||
if(!prior.instance)fail('decision-stale');
|
||||
next.roles[r.name]=structuredClone(prior.instance);
|
||||
delete next.roles[r.name].credentials;delete next.roles[r.name].tracker;
|
||||
next.launch.instances=next.launch.instances.filter(n=>n!==r.name);
|
||||
if(prior.launched)next.launch.instances.push(r.name);
|
||||
}
|
||||
if(prior.definition) {
|
||||
const name=prior.instance.definition.slice(4);
|
||||
next.definitions={...(next.definitions??{}),[name]:{...prior.definition,rev:(current.definitions?.[name]?.rev??0)+1}};
|
||||
}
|
||||
return next;
|
||||
};
|
||||
return commit(id,build,{revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(current,next)=>{if(actor!==current.human || canonicalJson(build(current))!==canonicalJson(next))fail('human-required');},detail:{verb:'hire.reverse',decision:p.id,agent:r.name}});
|
||||
}
|
||||
|
||||
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
|
||||
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
|
||||
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
|
||||
@@ -584,5 +637,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -1,15 +1,12 @@
|
||||
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
|
||||
// never credential references, vars, call arguments or file contents.
|
||||
import { decisionAutonomy } from './autonomy.mjs';
|
||||
export { decisionAutonomy } from './autonomy.mjs';
|
||||
import { GATED_ONLY } from './vocabulary.mjs';
|
||||
|
||||
const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
|
||||
|
||||
// TI extension point for AU: today's fixed levels, without changing any routing.
|
||||
export function decisionAutonomy(decisionClass) {
|
||||
return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop';
|
||||
}
|
||||
|
||||
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) {
|
||||
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null, autonomy }) {
|
||||
const actions = [];
|
||||
for (const [kind, held] of Object.entries(authority ?? {})) {
|
||||
for (const action of held) {
|
||||
@@ -24,8 +21,8 @@ export function ownRails({ instance, authority, network, credentials = [], messa
|
||||
const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])]
|
||||
.filter(action => action.startsWith('decision.resolve.')).map(right => {
|
||||
const domain = right.slice('decision.resolve.'.length);
|
||||
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : [];
|
||||
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) };
|
||||
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : domain === 'authority' ? ['authority'] : [];
|
||||
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name, autonomy) })) };
|
||||
});
|
||||
return {
|
||||
actions, resolve, network: network ?? 'unknown',
|
||||
|
||||
@@ -101,7 +101,7 @@ export function resolveInstance({ system, business, project = null, instance, en
|
||||
limits: { tools, network, authority: { withinRole, crossRole } },
|
||||
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
|
||||
// so it stays out of the session's credentials and the harness's needs.
|
||||
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
|
||||
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by, autonomy: business.autonomy }),
|
||||
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
|
||||
tracker: entry.tracker,
|
||||
launch,
|
||||
|
||||
@@ -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})` : ""}`;
|
||||
}
|
||||
@@ -60,6 +60,7 @@ export const ACTIONS = Object.freeze([
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
"decision.resolve.delivery",
|
||||
"decision.resolve.authority",
|
||||
// Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library.
|
||||
// Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role
|
||||
// to the creative roles only.
|
||||
@@ -96,6 +97,7 @@ export const ROUTED = Object.freeze([
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
"decision.resolve.delivery",
|
||||
"decision.resolve.authority",
|
||||
// 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",
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {randomUUID} from 'node:crypto';
|
||||
import {rmSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {tmp,rolesCopy,businessDoc,writeJson} from './helpers.mjs';
|
||||
import {organizationWriter} from '../src/organization.mjs';
|
||||
import {hireDecisions} from '../src/hire-decisions.mjs';
|
||||
import {instanceSettings} from '../src/instance-settings.mjs';
|
||||
const role={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Prior role'};
|
||||
function setup(t, scanDefinition, barrier) {
|
||||
const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));const rolesDir=rolesCopy(root),doc=businessDoc(root);
|
||||
doc.plugins={harness:['pi']};
|
||||
doc.definitions={scout:{rev:3,role:{tools:role.tools,network:role.network,authority:role.authority,credentials:[]},instructions:role.instructions}};
|
||||
doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};doc.launch.instances.push('scout');
|
||||
writeJson(join(root,'businesses/acme.json'),doc);
|
||||
const writer=organizationWriter({dir:root,rolesDir,barrier}),service=hireDecisions({writer,rolesDir,configDir:root,dataRoot:join(root,'data'),assertClean:()=>{},scanDefinition});
|
||||
let stopped=0,revoked=0;const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{revoked++;}});
|
||||
const prepare=async request=>{const p=await service.prepare('acme',Object.fromEntries(Object.entries({op:'change',level:'org',name:'scout',role:{inline:{...role,instructions:'Changed role'}},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Test',...request}).filter(([,v])=>v!==undefined)),{id:randomUUID(),expiresAt:'2099-01-01T00:00:00Z'});return p;};
|
||||
const caller=p=>({agent:false,rights:['org.hire'],owner:true,instanceOwner:true,actor:'jason',digest:p.digest,subject:{provider:'test',iss:'test',sub:'owner'}});
|
||||
const reverse=p=>service.reverse('acme',p.id,{actor:'jason',beforeWrite(){},stop:async()=>{stopped++;return {running:true};},fire:proposal=>instances.fire('acme',{name:proposal.name,revision:proposal.revision},{actor:'jason',context:{decision:proposal.id}})});
|
||||
return {writer,service,prepare,caller,reverse,stopped:()=>stopped,revoked:()=>revoked};
|
||||
}
|
||||
test('AU role-change inverse restores prior definition contents and requests relaunch of a running agent',async t=>{
|
||||
const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
|
||||
const r=await f.reverse(p),doc=f.writer.read('acme').document;
|
||||
assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.definitions.scout.rev,5);assert.equal(r.relaunch.name,'scout');assert.equal(f.stopped(),1);
|
||||
});
|
||||
test('AU fire inverse rehires prior definition without restoring revoked bindings',async t=>{
|
||||
const f=setup(t),p=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',p.id,f.caller(p));
|
||||
assert.ok(!f.writer.read('acme').document.launch.instances.includes('scout'));
|
||||
await f.reverse(p);const doc=f.writer.read('acme').document;
|
||||
assert.ok(doc.launch.instances.includes('scout'));assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.roles.scout.credentials,undefined);
|
||||
});
|
||||
test('AU hire inverse uses the normal fire/revoke executor',async t=>{
|
||||
const f=setup(t),p=await f.prepare({op:'hire',name:'new-scout'});await f.service.apply('acme',p.id,f.caller(p));
|
||||
await f.reverse(p);assert.ok(!f.writer.read('acme').document.launch.instances.includes('new-scout'));assert.equal(f.revoked(),1);
|
||||
});
|
||||
test('AU inverse never overwrites a subsequent role change or relaunched fired agent',async t=>{
|
||||
const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
|
||||
const later=await f.prepare({role:{inline:{...role,instructions:'Later'}}});await f.service.apply('acme',later.id,f.caller(later));
|
||||
await assert.rejects(f.reverse(p),{code:'decision-stale'});assert.equal(f.stopped(),0);
|
||||
const fire=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',fire.id,f.caller(fire));
|
||||
const old=f.writer.read('acme');f.writer.write('acme',{...old.document,launch:{...old.document.launch,instances:[...old.document.launch.instances,'scout']}},{revision:old.revision,actor:'jason'});
|
||||
await assert.rejects(f.reverse(fire),{code:'decision-stale'});
|
||||
});
|
||||
|
||||
test('AU inverse scans prior definition against current held credentials before stopping or writing',async t=>{
|
||||
let refuse=false;
|
||||
const f=setup(t,()=>{if(refuse)throw Object.assign(new Error('definition-holds-credential'),{code:'definition-holds-credential'});});
|
||||
const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
|
||||
const before=f.writer.read('acme').revision;refuse=true;
|
||||
await assert.rejects(f.reverse(p),{code:'definition-holds-credential'});
|
||||
assert.equal(f.stopped(),0);assert.equal(f.writer.read('acme').revision,before);
|
||||
});
|
||||
|
||||
test('AU inverse crossing its window during candidate IO never publishes or recovers forward',async t=>{
|
||||
let reversing=false,expired=false;
|
||||
const f=setup(t,undefined,point=>{if(reversing && point==='prepared')expired=true;});
|
||||
const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
|
||||
const prior=f.writer.read('acme');reversing=true;
|
||||
await assert.rejects(f.service.reverse('acme',p.id,{actor:'jason',beforeWrite(){if(expired)throw Object.assign(new Error('reverse-window-closed'),{code:'reverse-window-closed'});},stop:async()=>({running:true})}),{code:'reverse-window-closed'});
|
||||
assert.equal(f.writer.read('acme').revision,prior.revision);
|
||||
f.writer.recover('acme');assert.equal(f.writer.read('acme').revision,prior.revision);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { decisionAutonomy } from '../src/rails.mjs';
|
||||
import { VERB_RIGHTS } from '../src/org-rights.mjs';
|
||||
|
||||
test('AU absent key preserves fixed levels; present per-class levels are read', () => {
|
||||
for (const name of ['merge', 'policy', 'cross-role']) assert.equal(decisionAutonomy(name), 'out of the loop');
|
||||
assert.equal(decisionAutonomy('authority'), 'in the loop');
|
||||
assert.equal(decisionAutonomy('merge', { merge: 'near the loop' }), 'near the loop');
|
||||
});
|
||||
test('AU unknown class or level refuses instead of inventing a default', () => {
|
||||
assert.throws(() => decisionAutonomy('unknown'), { code: 'invalid-autonomy' });
|
||||
assert.throws(() => decisionAutonomy('merge', { merge: 'automatic' }), { code: 'invalid-autonomy' });
|
||||
});
|
||||
test('AU owner-only Console verbs have explicit null rights', () => {
|
||||
for (const verb of ['autonomy.get.console','autonomy.set.console','decision.confirm.console','decision.reverse.console']) assert.equal(VERB_RIGHTS[verb], null, verb);
|
||||
});
|
||||
import { tmp, rolesCopy, businessDoc, writeJson } from './helpers.mjs';
|
||||
import { rmSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { validateBusinessDocument } from '../src/organization-config.mjs';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
test('AU setting validates in the organization file; every writer refuses non-owner edits',t=>{
|
||||
const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const rolesDir=rolesCopy(root),doc=businessDoc(root);doc.autonomy={merge:'in the loop'};
|
||||
assert.equal(validateBusinessDocument(doc,join(root,'acme.json'),{rolesDir}).autonomy.merge,'in the loop');
|
||||
for(const autonomy of [{unknown:'in the loop'},{merge:'fast'},{deploy:'out of the loop'}])assert.throws(()=>validateBusinessDocument({...doc,autonomy},join(root,'acme.json'),{rolesDir}),{code:'invalid-autonomy'});
|
||||
writeJson(join(root,'businesses/acme.json'),doc);
|
||||
const writer=organizationWriter({dir:root,rolesDir}),old=writer.read('acme');
|
||||
for(const actor of ['coder','tester','member'])assert.throws(()=>writer.write('acme',{...doc,autonomy:{merge:'out of the loop'}},{revision:old.revision,actor}),{code:'human-required'});
|
||||
});
|
||||
|
||||
import {authorityBudgetCovered} from '../src/autonomy.mjs';
|
||||
test('AU authority budgets cover all selectable models and only enforced account harnesses',()=>{
|
||||
const cap={limit:0,since:'2026-01-01T00:00:00.000Z'};
|
||||
const b={plugins:{harness:['pi'],'limit-check':['token-limit']},launch:{models:{one:{provider:'one',model:'m'},two:{provider:'two',model:'n'}},limits:{tokens:{providers:{one:cap}}}}};
|
||||
assert.equal(authorityBudgetCovered(b),false);
|
||||
b.launch.limits.tokens.models={two:{n:cap}};assert.equal(authorityBudgetCovered(b),true);
|
||||
b.plugins['limit-check']=[];assert.equal(authorityBudgetCovered(b),false);
|
||||
b.launch.account={name:'shared'};b.launch.limits.tokens={keys:{shared:cap}};b.plugins['limit-check']=['token-limit'];
|
||||
assert.equal(authorityBudgetCovered(b),false);
|
||||
b.plugins.harness=['claude-code'];assert.equal(authorityBudgetCovered(b),true);
|
||||
b.plugins.harness.push('pi');assert.equal(authorityBudgetCovered(b),false);
|
||||
b.plugins.harness=['claude-code'];b.plugins['limit-check']=['daily-quota'];b.launch.limits.tokens={daily:{shared:{...cap,reset:'00:00',timeZone:'UTC'}}};assert.equal(authorityBudgetCovered(b),true);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -177,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);
|
||||
});
|
||||
|
||||
@@ -25,6 +25,11 @@ const BUNDLES = {
|
||||
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
|
||||
};
|
||||
const VERBS = {
|
||||
'autonomy.get.console': null,
|
||||
'autonomy.set.console': null,
|
||||
'autonomy.review.console': null,
|
||||
'decision.confirm.console': null,
|
||||
'decision.reverse.console': null,
|
||||
'project.list': 'org.view',
|
||||
names: 'org.view',
|
||||
'thread.list': 'thread.read',
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
// #1873 (redact6): an organization refusal names the field, the file or the root 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 { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { checkCredentialRef, parseCredentialRef, storePath } from '../src/credentials.mjs';
|
||||
import { OrgError, orgLock, quietMessage, readRegistry, withRegistryWriter } from '../src/org-registry.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, { mode: 0o700 });
|
||||
return { root, dir };
|
||||
}
|
||||
function clean(text, root) {
|
||||
assert.ok(!text.includes(root), text);
|
||||
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||
}
|
||||
|
||||
test('a credential problem names the field and the root by label, never the token file or the root', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const file = join(dir, 'gitea');
|
||||
writeFileSync(file, 'token', { mode: 0o644 });
|
||||
const ref = { service: 'gitea', file, rotateBy: '2999-01-01' };
|
||||
const { problems } = checkCredentialRef(ref, { forbiddenRoots: [{ path: dir, label: 'the data root' }, root], where: 'roles.coder.credentials.gitea' });
|
||||
assert.deepEqual(problems, [
|
||||
'roles.coder.credentials.gitea file: mode 644 gives group or other access; use 600',
|
||||
'roles.coder.credentials.gitea file: inside the data root; token files live outside the repository and dataRoot',
|
||||
'roles.coder.credentials.gitea file: inside a forbidden root; token files live outside the repository and dataRoot',
|
||||
]);
|
||||
for (const p of problems) clean(p, root);
|
||||
const missing = checkCredentialRef({ service: 'gitea', file: join(dir, 'absent'), rotateBy: '2999-01-01' });
|
||||
assert.deepEqual(missing.problems, ['gitea file: not found']);
|
||||
const store = checkCredentialRef({ service: 'gitea', store: true, file: join(dir, 'absent'), rotateBy: '2999-01-01' }, { where: 'roles.coder.credentials.gitea' });
|
||||
assert.deepEqual(store.warnings, ['roles.coder.credentials.gitea store file: not issued']);
|
||||
});
|
||||
|
||||
test('credential-not-yours names the field, not the file', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const store = { configDir: dir, business: 'acme', agent: 'coder' };
|
||||
const other = storePath({ ...store, agent: 'pm' }, 'gitea');
|
||||
assert.throws(() => parseCredentialRef({ file: other, rotateBy: '2999-01-01' }, 'gitea', 'roles.coder.credentials.gitea', { store }), error => {
|
||||
assert.match(error.message, /^roles\.coder\.credentials\.gitea\.file credential-not-yours: the file is in the agents' store/);
|
||||
clean(error.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('an OrgError keeps a document field but drops a host path', t => {
|
||||
const { dir } = scratch(t);
|
||||
assert.equal(new OrgError('org-mismatch', 'unsafe file', join(dir, 'org.json')).path, undefined);
|
||||
assert.equal(new OrgError('org-invalid', 'bad root', '$.projects.alpha.root').path, '$.projects.alpha.root');
|
||||
const error = Object.assign(new Error(`ENOENT: no such file or directory, open '${join(dir, 'x')}'`), { code: 'ENOENT', syscall: 'open' });
|
||||
assert.equal(quietMessage(error, 'the registry file'), 'cannot open the registry file (ENOENT)');
|
||||
assert.equal(quietMessage(Object.assign(new Error('x'), { code: 'ENOENT', syscall: `spawn ${join(dir, 'git')}` }), 'git'), 'cannot spawn git (ENOENT)');
|
||||
});
|
||||
|
||||
test('an unreadable registry names the registry file and the error code, never its path', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(join(dir, 'real.json'), '{}', { mode: 0o600 });
|
||||
symlinkSync(join(dir, 'real.json'), join(dir, 'org-runtime.json'));
|
||||
await assert.rejects(readRegistry({ configDir: dir }), error => {
|
||||
assert.ok(error instanceof OrgError, error.stack);
|
||||
assert.equal(error.code, 'org-mismatch');
|
||||
assert.equal(error.message, 'invalid registry: cannot open registry file (ELOOP)');
|
||||
assert.equal(error.path, undefined);
|
||||
clean(error.message, root);
|
||||
clean(JSON.stringify(error), root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('an unreadable recovery fence names the fence in the config directory, never its path', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(join(dir, 'org-lifecycle-acme.lock.clear'), '{}', { mode: 0o600 });
|
||||
chmodSync(join(dir, 'org-lifecycle-acme.lock.clear'), 0o644);
|
||||
await assert.rejects(orgLock({ configDir: dir }, 'acme', { clear: true }), error => {
|
||||
assert.ok(error instanceof OrgError, error.stack);
|
||||
assert.equal(error.code, 'org-locked');
|
||||
assert.equal(error.message, 'unreadable recovery fence org-lifecycle-acme.lock.clear in the config directory; remove it by hand');
|
||||
assert.equal(error.path, undefined);
|
||||
clean(error.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// Review round (#1873): the registry's own filesystem calls name the directory or file by label.
|
||||
test('a missing config directory names it by label and code, never its path, and keeps the system code', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
await assert.rejects(readRegistry({ configDir: join(dir, 'absent') }), error => {
|
||||
assert.equal(error.code, 'ENOENT');
|
||||
assert.equal(error.message, 'cannot lstat the config directory (ENOENT)');
|
||||
clean(error.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('the registry writer names the registry file by label when it cannot open it, never its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(join(dir, 'real'), '{}', { mode: 0o600 });
|
||||
symlinkSync(join(dir, 'real'), join(dir, 'org-runtime.json'));
|
||||
assert.throws(() => withRegistryWriter(dir, () => {}), error => {
|
||||
assert.equal(error.code, 'ELOOP');
|
||||
assert.equal(error.message, 'cannot open the registry file (ELOOP)');
|
||||
clean(error.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -63,7 +63,8 @@ export function preflight({ env, proxy = null }) {
|
||||
if (!binary(env)) {
|
||||
return {
|
||||
code: "harness-binary-missing",
|
||||
message: env.MOSAIC_CLAUDE_BIN ? `MOSAIC_CLAUDE_BIN ${env.MOSAIC_CLAUDE_BIN} isn't an executable file at an absolute path` : "no claude on the bus host's PATH",
|
||||
// Names the variable, never its value, which is a host path (#1873).
|
||||
message: env.MOSAIC_CLAUDE_BIN ? "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path" : "no claude on the bus host's PATH",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -37,7 +37,7 @@ export function preflight({ env = {}, proxy = null } = {}) {
|
||||
&& !["ANTHROPIC_API_KEY", "OPENAI_API_KEY", "OPENROUTER_API_KEY", "ZAI_API_KEY"].some(name => Boolean(env[name]))) {
|
||||
return { code: "no-session-credential", message: "pi needs its own provider key or a session proxy key" };
|
||||
}
|
||||
if (!existsSync(BIN)) return { code: "harness-binary-missing", message: `pi isn't installed at ${BIN}; run npm ci in the repository` };
|
||||
if (!existsSync(BIN)) return { code: "harness-binary-missing", message: "pi isn't installed in the repository's node_modules; run npm ci in the repository" };
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ export const RUNNER_FILES = Object.freeze([
|
||||
"packages/bus/src/broker.mjs",
|
||||
"packages/bus/src/refusals.mjs",
|
||||
"packages/organization/src/rails.mjs",
|
||||
"packages/organization/src/autonomy.mjs",
|
||||
"packages/bus/src/autonomy-state.mjs",
|
||||
"packages/bus/src/proxy-page-vocabulary.mjs",
|
||||
// Row 331 r5: parsing only; common.mjs and PIN remain outside the sandbox.
|
||||
"packages/model-proxy/src/validation.mjs",
|
||||
@@ -194,9 +196,10 @@ export function sandboxArgv(list, runDir, command, { business, role, bwrap = BWR
|
||||
// Whether bwrap runs here at all: the namespaces and the system binds, then
|
||||
// setpriv and `true`. Returns null, or why not.
|
||||
export function sandboxProblem({ bwrap = BWRAP } = {}) {
|
||||
if (!existsSync(bwrap)) return `${bwrap} is missing`;
|
||||
// #1873: names the sandbox, never the binary's host path; a launch refusal carries this text.
|
||||
if (!existsSync(bwrap)) return "the sandbox (bwrap) is missing";
|
||||
const r = spawnSync(bwrap, [...NAMESPACES, ...argsOf(systemBinds()), "--remount-ro", "/", "--", ...KILL_WITH_PARENT, "/usr/bin/true"], { encoding: "utf8", timeout: 10000 });
|
||||
if (r.status === 0) return null;
|
||||
const why = (r.stderr ?? "").trim().split("\n")[0] || (r.error ? r.error.code : `exit ${r.status ?? r.signal}`);
|
||||
return `${bwrap} failed: ${why}`;
|
||||
return `the sandbox (bwrap) failed: ${why}`;
|
||||
}
|
||||
+18
-12
@@ -50,16 +50,17 @@ export function defaultConfigPath(env = process.env) {
|
||||
|
||||
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
|
||||
// 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 SeatError(`config not found: ${path}`);
|
||||
if (!existsSync(path)) throw new SeatError("config not found");
|
||||
let raw;
|
||||
try {
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new SeatError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new SeatError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new SeatError("config is not an object");
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new SeatError("config.dataRoot must be an absolute path");
|
||||
return raw.dataRoot;
|
||||
}
|
||||
|
||||
@@ -69,11 +70,15 @@ export function seatsDir(dataRoot) {
|
||||
|
||||
export const LAYOUTS = Object.freeze(["repo", "fleet", "unknown"]);
|
||||
|
||||
// A refusal quotes a key or name only when it is a plain name; anything 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");
|
||||
|
||||
// Keyed by layout and seat, because a seat name is not unique across
|
||||
// layouts (this repository and the fleet both have a "darkwing").
|
||||
export function registrationPath(seats, seat, layout) {
|
||||
if (!SEAT_NAME.test(String(seat))) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4);
|
||||
if (!LAYOUTS.includes(layout)) throw new SeatError(`invalid layout: ${JSON.stringify(layout)}`, 4);
|
||||
if (!SEAT_NAME.test(String(seat))) throw new SeatError("invalid seat name (lowercase letters, digits, ., _ or -, at most 64)", 4);
|
||||
if (!LAYOUTS.includes(layout)) throw new SeatError(`invalid layout (one of ${LAYOUTS.join(", ")})`, 4);
|
||||
return join(seats, layout, seat, "registration.json");
|
||||
}
|
||||
|
||||
@@ -87,10 +92,11 @@ export function resolveSeat(arg, { repo = process.cwd() } = {}) {
|
||||
else throw new SeatError(`invalid seat name: ${JSON.stringify(arg)}`, 4);
|
||||
const seat = basename(seatDir);
|
||||
if (!SEAT_NAME.test(seat)) throw new SeatError(`invalid seat name: ${JSON.stringify(seat)}`, 4);
|
||||
if (!existsSync(seatDir) || !statSync(seatDir).isDirectory()) throw new SeatError(`no such seat directory: ${seatDir}`);
|
||||
// Named by the checked seat name, never the directory's path (#1873).
|
||||
if (!existsSync(seatDir) || !statSync(seatDir).isDirectory()) throw new SeatError(`no such seat directory: seat ${seat}`);
|
||||
const launchScript = join(seatDir, "launch.sh");
|
||||
if (!existsSync(launchScript) || !statSync(launchScript).isFile()) throw new SeatError(`seat has no launch.sh: ${seatDir}`);
|
||||
if ((statSync(launchScript).mode & 0o111) === 0) throw new SeatError(`launch script is not executable: ${launchScript}`);
|
||||
if (!existsSync(launchScript) || !statSync(launchScript).isFile()) throw new SeatError(`seat has no launch.sh: seat ${seat}`);
|
||||
if ((statSync(launchScript).mode & 0o111) === 0) throw new SeatError(`launch script is not executable: launch.sh of seat ${seat}`);
|
||||
|
||||
const parent = dirname(seatDir);
|
||||
const root = basename(parent) === "agents" ? dirname(parent) : null;
|
||||
@@ -141,7 +147,7 @@ const isTimestamp = (v) => typeof v === "string" && Number.isFinite(Date.parse(v
|
||||
// SeatError with the failing field; never echoes the offending value.
|
||||
export function validateRegistration(record) {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) throw new SeatError("registration is not an object");
|
||||
for (const key of Object.keys(record)) if (!FIELDS.includes(key)) throw new SeatError(`registration has an unknown field: ${key}`);
|
||||
for (const key of Object.keys(record)) if (!FIELDS.includes(key)) throw new SeatError(`registration has an unknown field: ${plainKey(key)}`);
|
||||
if (record.version !== REGISTRATION_VERSION) throw new SeatError("registration has an unsupported version");
|
||||
if (typeof record.seat !== "string" || !SEAT_NAME.test(record.seat)) throw new SeatError("registration.seat is invalid");
|
||||
if (typeof record.task !== "string" || record.task.length > TASK_LIMIT) throw new SeatError("registration.task must be a string");
|
||||
@@ -205,14 +211,14 @@ export function readRegistration(seats, seat, layout) {
|
||||
try {
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new SeatError(`registration is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new SeatError(`registration ${layout}/${seat} is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
try {
|
||||
const record = validateRegistration(parsed);
|
||||
if (record.seat !== seat || record.layout !== layout) throw new SeatError("registration does not match its path");
|
||||
return record;
|
||||
} catch (err) {
|
||||
throw new SeatError(`${err.message}: ${path}`);
|
||||
throw new SeatError(`${err.message}: registration ${layout}/${seat}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import { spawn } from "node:child_process";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { appendFileSync, closeSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { constants } from "node:os";
|
||||
import { dirname, join, sep } from "node:path";
|
||||
import { basename, dirname, join, relative, sep } from "node:path";
|
||||
import { childrenOf, cmdlineOf, startTimeOf } from "./proc.mjs";
|
||||
import { binds, prepareRun, REPO, sandboxArgv } from "./sandbox.mjs";
|
||||
import { SeatError } from "./seat.mjs";
|
||||
@@ -71,23 +71,33 @@ function segments(what, pairs, code) {
|
||||
if (!segment(x)) throw seatError(`${what}: ${name} ${JSON.stringify(x)} isn't an id`, code);
|
||||
}
|
||||
}
|
||||
// A directory under the data root is named by its path relative to the data root, never
|
||||
// its host path (#1873). Every segment below the root is a checked id or a fixed name.
|
||||
const under = (dataRoot, p) => `under the data root, ${relative(dataRoot, p) || "the root itself"}`;
|
||||
// Makes each directory in order, then checks it is ours, a real directory and private.
|
||||
function privateDirs(what, dirs, code) {
|
||||
function privateDirs(what, dataRoot, dirs, code) {
|
||||
for (const d of dirs) {
|
||||
// A system error keeps its code and type; its message names the directory by label.
|
||||
const quiet = (e) => Object.assign(e, { message: `${what}: cannot ${e.syscall ?? "use"} ${under(dataRoot, d)} (${e.code})` });
|
||||
try {
|
||||
mkdirSync(d, { mode: 0o700 });
|
||||
} catch (e) {
|
||||
if (e.code !== "EEXIST") throw e;
|
||||
if (e.code !== "EEXIST") throw quiet(e);
|
||||
}
|
||||
const st = lstatSync(d);
|
||||
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw seatError(`${what}: ${d} is unsafe`, code);
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(d);
|
||||
} catch (e) {
|
||||
throw quiet(e);
|
||||
}
|
||||
if (st.isSymbolicLink() || !st.isDirectory() || st.uid !== process.getuid() || st.mode & 0o077) throw seatError(`${what}: ${under(dataRoot, d)} is unsafe`, code);
|
||||
}
|
||||
}
|
||||
export function threadStore(dataRoot, business, thread) {
|
||||
segments("thread store", [["business", business], ["thread", thread]], "thread-store-refused");
|
||||
const dir = join(dataRoot, "threads", business, thread);
|
||||
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
|
||||
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects], "thread-store-refused");
|
||||
privateDirs("thread store", dataRoot, [join(dataRoot, "threads"), join(dataRoot, "threads", business), dir, out.session, out.projects], "thread-store-refused");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -110,11 +120,11 @@ export function sessionStore(dataRoot, business, thread, session, { legacy = fal
|
||||
if (e.code !== "ENOENT") throw e;
|
||||
old = false;
|
||||
}
|
||||
if (old) throw seatError(`thread store: ${join(threadDir, "session")} holds history no legacy session maps`, "legacy-store-unmapped");
|
||||
if (old) throw seatError(`thread store: ${under(dataRoot, join(threadDir, "session"))} holds history no legacy session maps`, "legacy-store-unmapped");
|
||||
}
|
||||
const dir = join(threadDir, "sessions", session);
|
||||
const out = { dir, session: join(dir, "session"), projects: join(dir, "claude-projects") };
|
||||
privateDirs("thread store", [join(dataRoot, "threads"), join(dataRoot, "threads", business), threadDir, join(threadDir, "sessions"), dir, out.session, out.projects], "thread-store-refused");
|
||||
privateDirs("thread store", dataRoot, [join(dataRoot, "threads"), join(dataRoot, "threads", business), threadDir, join(threadDir, "sessions"), dir, out.session, out.projects], "thread-store-refused");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -138,7 +148,7 @@ export function sessionWorkspace(dataRoot, business, instance, session, { live =
|
||||
segments("session workspace", [["business", business], ["instance", instance], ["session", session]], "session-workspace-unsafe");
|
||||
const root = join(dataRoot, "session-workspaces");
|
||||
const path = join(root, business, instance, session);
|
||||
privateDirs("session workspace", [root, join(root, business), join(root, business, instance), path], "session-workspace-unsafe");
|
||||
privateDirs("session workspace", dataRoot, [root, join(root, business), join(root, business, instance), path], "session-workspace-unsafe");
|
||||
const real = realpathSync(path);
|
||||
const legacy = join(dataRoot, "workspaces");
|
||||
const named = [legacy, join(legacy, business), join(legacy, business, instance)];
|
||||
@@ -152,11 +162,11 @@ export function sessionWorkspace(dataRoot, business, instance, session, { live =
|
||||
};
|
||||
for (const p of [...named, ...entries(legacy), ...entries(join(legacy, business))]) {
|
||||
const other = realOrNull(p);
|
||||
if (other && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the legacy workspace ${p}`, "session-workspace-overlap");
|
||||
if (other && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${under(dataRoot, path)} overlaps the legacy workspace ${relative(dataRoot, p)}`, "session-workspace-overlap");
|
||||
}
|
||||
for (const p of live) {
|
||||
const other = realOrNull(p);
|
||||
if (other && other !== real && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${path} overlaps the live workspace ${p}`, "session-workspace-overlap");
|
||||
if (other && other !== real && (inside(real, other) || inside(other, real))) throw seatError(`session workspace: ${under(dataRoot, path)} overlaps a live workspace`, "session-workspace-overlap");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
@@ -171,7 +181,8 @@ export function lockSessionWorkspace(workspace, owner) {
|
||||
const file = `${workspace}.lock`;
|
||||
let held = { ...owner };
|
||||
const write = (flag) => writeFileSync(file, `${JSON.stringify(held)}\n`, { mode: 0o600, flag });
|
||||
const busy = (why) => seatError(`session workspace ${workspace} is busy: ${why}`, "session-workspace-busy");
|
||||
// Named by its session id (the workspace's last segment, a checked id), never its path (#1873).
|
||||
const busy = (why) => seatError(`session workspace ${basename(workspace)} is busy: ${why}`, "session-workspace-busy");
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
write("wx");
|
||||
@@ -209,7 +220,7 @@ export function lockSessionWorkspace(workspace, owner) {
|
||||
other = JSON.parse(raw);
|
||||
} catch {}
|
||||
// An unreadable lock may be one being written, so it counts as held.
|
||||
if (!other || typeof other !== "object") throw busy(`${file} is unreadable`);
|
||||
if (!other || typeof other !== "object") throw busy("its lock is unreadable");
|
||||
if (lockAlive(other.hostPid, other.hostStartTime)) throw busy(`host pid ${other.hostPid} holds it`);
|
||||
if (lockAlive(other.runPid, other.runStartTime)) throw busy(`run pid ${other.runPid} holds it`);
|
||||
// Stale. Move it aside; if what moved isn't what was judged stale, another
|
||||
@@ -226,11 +237,11 @@ export function lockSessionWorkspace(workspace, owner) {
|
||||
linkSync(aside, file);
|
||||
} catch {}
|
||||
unlinkSync(aside);
|
||||
throw busy(`another host took ${file} over`);
|
||||
throw busy("another host took its lock over");
|
||||
}
|
||||
unlinkSync(aside);
|
||||
}
|
||||
throw busy(`${file} could not be taken`);
|
||||
throw busy("its lock could not be taken");
|
||||
}
|
||||
|
||||
// The session's environment is an allowlist: nothing else from the host's
|
||||
@@ -279,7 +290,7 @@ export function newRun(dataRoot, business) {
|
||||
if (e.code === "EEXIST") continue;
|
||||
throw e;
|
||||
}
|
||||
if (Buffer.byteLength(join(runDir, "tools.sock")) > 107) throw new SeatError(`data root path too long for a socket: ${runDir}`, 2);
|
||||
if (Buffer.byteLength(join(runDir, "tools.sock")) > 107) throw new SeatError(`data root path too long for a socket: run ${run}`, 2);
|
||||
return { run, runDir };
|
||||
}
|
||||
throw new SeatError("could not pick a free run id", 1);
|
||||
@@ -399,9 +410,10 @@ export function readSessions(dataRoot) {
|
||||
try {
|
||||
doc = JSON.parse(readFileSync(file, "utf8"));
|
||||
} catch {
|
||||
throw new SeatError(`sessions file is unreadable: ${file}`, 2);
|
||||
throw new SeatError("sessions file is unreadable: bus-host/sessions.json under the data root", 2);
|
||||
}
|
||||
if (!SESSIONS_READ.includes(doc?.sessionsVersion) || !Array.isArray(doc.sessions)) throw new SeatError(`sessions file is malformed: ${file}`, 2);
|
||||
// #1873: named by its fixed place under the data root, never by host path.
|
||||
if (!SESSIONS_READ.includes(doc?.sessionsVersion) || !Array.isArray(doc.sessions)) throw new SeatError("sessions file is malformed: bus-host/sessions.json under the data root", 2);
|
||||
return doc.sessions.map((s) => ({ ...s, live: startTimeOf(s.runnerPid) === s.runnerStartTime }));
|
||||
}
|
||||
|
||||
|
||||
@@ -583,3 +583,27 @@ export async function publishRemote({ repo, fork, user, source, dst, token, tls
|
||||
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
|
||||
return { ref: dst, commit: source, previous };
|
||||
}
|
||||
|
||||
// AU: a reverse is a new child of main, prepared under its ordinary repository lock.
|
||||
// The caller runs the normal land gate, then finishLand's compare-and-swap and receipt.
|
||||
export function prepareRevert({dataRoot,business,project,commit,dir,name,decision,env=process.env,limits=GIT_LIMITS}) {
|
||||
if(!PROJECT.test(project??'') || !OID.test(commit??'') || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(name??'') || !/^[a-zA-Z0-9-]{1,100}$/.test(decision??''))refuse('invalid-request');
|
||||
const repo=workingRepo(dataRoot,business,project),g=bound(env,limits,dataRoot,repo),worktree=join(dir,name);
|
||||
return locked(repo,async()=>{
|
||||
const at=[`--git-dir=${repo}`],old=await tip(repo,'refs/heads/main',g);
|
||||
if(!old || !(await g([...at,'merge-base','--is-ancestor',commit,old])).ok)refuse('ref-moved');
|
||||
const opened=await g([...at,'worktree','add','-q','--detach',worktree,old]);
|
||||
if(!opened.ok)refuse('git-failed');
|
||||
try {
|
||||
const wt=['-C',worktree];
|
||||
if(!(await g([...wt,'revert','--no-commit',commit],{pre:LAND_IDENTITY})).ok)refuse('base-conflict');
|
||||
const tree=await g([...wt,'write-tree']);if(!tree.ok)refuse('git-failed');
|
||||
const file=join(dir,'reverse-message');writeFileSync(file,`Revert ${commit}\n\nAutonomy-Reverse: ${decision}\n`,{mode:0o600});
|
||||
const made=await g([...wt,'commit-tree',tree.out.trim(),'-p',old,'-F',file],{pre:LAND_IDENTITY});
|
||||
if(!made.ok || !OID.test(made.out.trim()))refuse('git-failed');
|
||||
const next=made.out.trim();
|
||||
if(!(await g([...wt,'reset','-q','--soft',next])).ok)refuse('git-failed');
|
||||
return {worktree,old,commit:next,reverted:commit};
|
||||
} catch(error) {await withoutProcessing(()=>removeWorktree(repo,worktree,g));throw error;}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
// #1873 (redact6): a seat refusal names the config, the seat or the registration 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 { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { SeatError, loadDataRoot, readRegistration, registrationPath, resolveSeat } from '../src/seat.mjs';
|
||||
import { lockSessionWorkspace, newRun, readSessions, sessionStore, sessionWorkspace, sessionsFile, threadStore } from '../src/session.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) {
|
||||
return error => {
|
||||
assert.ok(error instanceof SeatError, error.stack);
|
||||
assert.equal(error.message, label);
|
||||
assert.ok(!error.message.includes(root), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
|
||||
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(() => loadDataRoot(join(dir, 'config.json')), labelled('config not found', root));
|
||||
assert.throws(() => loadDataRoot(write(join(dir, 'array.json'), '[]')), labelled('config is not an object', root));
|
||||
assert.throws(() => loadDataRoot(write(join(dir, 'relative.json'), '{"dataRoot":"data"}')), labelled('config.dataRoot must be an absolute path', root));
|
||||
});
|
||||
|
||||
test('seat directory refusals name the seat, not its directory', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => resolveSeat(join(dir, 'agents', 'builder')), labelled('no such seat directory: seat builder', root));
|
||||
mkdirSync(join(dir, 'agents', 'builder'), { recursive: true });
|
||||
assert.throws(() => resolveSeat('builder', { repo: dir }), labelled('seat has no launch.sh: seat builder', root));
|
||||
write(join(dir, 'agents', 'builder', 'launch.sh'), '#!/bin/sh\n');
|
||||
chmodSync(join(dir, 'agents', 'builder', 'launch.sh'), 0o644);
|
||||
assert.throws(() => resolveSeat('builder', { repo: dir }), labelled('launch script is not executable: launch.sh of seat builder', root));
|
||||
});
|
||||
|
||||
test('a registration that does not validate is named by its layout and seat', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(registrationPath(dir, 'builder', 'repo'), '{}');
|
||||
assert.throws(() => readRegistration(dir, 'builder', 'repo'), error => {
|
||||
assert.ok(error instanceof SeatError, error.stack);
|
||||
assert.match(error.message, /: registration repo\/builder$/);
|
||||
assert.ok(!error.message.includes(root), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('an unreadable or malformed sessions file is named by its place under the data root', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(sessionsFile(dir), 'not json');
|
||||
assert.throws(() => readSessions(dir), labelled('sessions file is unreadable: bus-host/sessions.json under the data root', root));
|
||||
write(sessionsFile(dir), '{"sessionsVersion":0}');
|
||||
assert.throws(() => readSessions(dir), labelled('sessions file is malformed: bus-host/sessions.json under the data root', root));
|
||||
});
|
||||
|
||||
// Review round (#1873): session stores, workspaces, locks and run directories are named under
|
||||
// the data root by their checked ids, never by host path; codes and exits are unchanged.
|
||||
function coded(label, root, code) {
|
||||
return error => labelled(label, root)(error) && error.code === code && error.exitCode === 2;
|
||||
}
|
||||
|
||||
test('an unsafe or unmakeable thread store directory is named under the data root, never by path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
mkdirSync(join(dir, 'threads'), { mode: 0o755 });
|
||||
chmodSync(join(dir, 'threads'), 0o755);
|
||||
assert.throws(() => threadStore(dir, 'acme', 't1'), coded('thread store: under the data root, threads is unsafe', root, 'thread-store-refused'));
|
||||
// A system error keeps its code; its message names the directory by label.
|
||||
assert.throws(() => threadStore(join(dir, 'absent'), 'acme', 't1'), error => {
|
||||
assert.equal(error.code, 'ENOENT');
|
||||
assert.equal(error.message, 'thread store: cannot mkdir under the data root, threads (ENOENT)');
|
||||
assert.ok(!error.message.includes(root), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('a legacy session store left behind is named under the data root, never by path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
mkdirSync(join(dir, 'threads', 'acme', 't1', 'session'), { recursive: true, mode: 0o700 });
|
||||
assert.throws(() => sessionStore(dir, 'acme', 't1', 's1'), coded('thread store: under the data root, threads/acme/t1/session holds history no legacy session maps', root, 'legacy-store-unmapped'));
|
||||
});
|
||||
|
||||
test('a session workspace that overlaps a live one is named under the data root, never by path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
sessionWorkspace(dir, 'acme', 'i1', 's1');
|
||||
assert.throws(() => sessionWorkspace(dir, 'acme', 'i1', 's2', { live: [join(dir, 'session-workspaces', 'acme')] }), coded('session workspace: under the data root, session-workspaces/acme/i1/s2 overlaps a live workspace', root, 'session-workspace-overlap'));
|
||||
});
|
||||
|
||||
test('a busy session workspace lock names the session, never the lock path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const ws = join(dir, 's1');
|
||||
writeFileSync(`${ws}.lock`, 'not json');
|
||||
assert.throws(() => lockSessionWorkspace(ws, {}), coded('session workspace s1 is busy: its lock is unreadable', root, 'session-workspace-busy'));
|
||||
});
|
||||
|
||||
test('a data root too long for a socket names the run, never its directory', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => newRun(join(dir, 'a'.repeat(70)), 'acme'), error => {
|
||||
assert.ok(error instanceof SeatError, error.stack);
|
||||
assert.equal(error.exitCode, 2);
|
||||
assert.match(error.message, /^data root path too long for a socket: run r[0-9a-f]{12}$/);
|
||||
assert.ok(!error.message.includes(root), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
@@ -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 { SeatError, loadDataRoot, readRegistration, registrationPath } from '../src/seat.mjs';
|
||||
for (const kind of ['config', 'registration']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo'));
|
||||
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), safe(file, SeatError, { exitCode: 2 }));
|
||||
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), safe(kind === 'config' ? 'config is not valid JSON' : 'registration repo/builder is not valid JSON', SeatError, { exitCode: 2 }));
|
||||
});
|
||||
for (const kind of ['config', 'registration']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = kind === 'config' ? join(root, 'config.json') : registrationPath(root, 'builder', 'repo');
|
||||
mkdirSync(file, { recursive: true });
|
||||
assert.throws(() => kind === 'config' ? loadDataRoot(file) : readRegistration(root, 'builder', 'repo'), error => {
|
||||
safe(file, SeatError, { exitCode: 2 })(error);
|
||||
safe(kind === 'config' ? 'config is not valid JSON' : 'registration repo/builder is not valid JSON', SeatError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -66,11 +66,12 @@ test("row 331 mounts only the parsing validator and fixed marker code, never pro
|
||||
|
||||
test("sandboxProblem names a missing bwrap and a bwrap that fails", (t) => {
|
||||
const root = scratch(t);
|
||||
assert.equal(sandboxProblem({ bwrap: join(root, "nope") }), `${join(root, "nope")} is missing`);
|
||||
// #1873: the sandbox by name, never the binary's host path.
|
||||
assert.equal(sandboxProblem({ bwrap: join(root, "nope") }), "the sandbox (bwrap) is missing");
|
||||
const bad = join(root, "bwrap");
|
||||
writeFileSync(bad, "#!/bin/sh\necho 'bwrap: No permissions to create new namespace' >&2\nexit 1\n");
|
||||
chmodSync(bad, 0o755);
|
||||
assert.equal(sandboxProblem({ bwrap: bad }), `${bad} failed: bwrap: No permissions to create new namespace`);
|
||||
assert.equal(sandboxProblem({ bwrap: bad }), "the sandbox (bwrap) failed: bwrap: No permissions to create new namespace");
|
||||
if (!skip) assert.equal(sandboxProblem(), null);
|
||||
});
|
||||
|
||||
|
||||
@@ -654,3 +654,19 @@ test('halted push receipt rolls back only its unrecorded ref and cleans temporar
|
||||
} })), /not recorded after halt/);
|
||||
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}`);
|
||||
});
|
||||
test('AU prepareRevert makes a new commit, finishLand preserves history and rechecks the reverse window',async t=>{
|
||||
const w=world(t);await initializeWorking({dataRoot:w.dataRoot,business:'acme',project:'stack',root:w.root});
|
||||
git(w.clone,'checkout','-q','-b','feat');const changed=commit(w.clone,'two');await w.go(w.bundle());
|
||||
const dir=join(w.dir,'land');mkdirSync(dir);
|
||||
const landed=await prepareLand({dataRoot:w.dataRoot,business:'acme',project:'stack',root:w.root,taskRef:TASK,commit:changed,dir,name:'original',rootHits:()=>[],message:()=> 'Change'});
|
||||
await finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:landed});await dropLand({dataRoot:w.dataRoot,business:'acme',project:'stack',worktree:landed.worktree});
|
||||
const {prepareRevert}=await import('../src/working.mjs');
|
||||
const reverse=await prepareRevert({dataRoot:w.dataRoot,business:'acme',project:'stack',commit:landed.commit,dir,name:'reverse',decision:'decision-1'});
|
||||
assert.equal(git(w.dir,`--git-dir=${w.repo}`,'show',`${reverse.commit}:README`),'one');
|
||||
assert.equal(git(w.dir,`--git-dir=${w.repo}`,'rev-parse',`${reverse.commit}^`),landed.commit);
|
||||
await assert.rejects(finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:reverse,record:()=>{throw Object.assign(new Error('closed'),{code:'reverse-window-closed'});}}),{code:'reverse-window-closed'});
|
||||
assert.equal(git(w.dir,`--git-dir=${w.repo}`,'rev-parse','main'),landed.commit);
|
||||
await finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:reverse});
|
||||
assert.match(git(w.dir,`--git-dir=${w.repo}`,'log','-1','--format=%B','main'),/decision-1/);
|
||||
await dropLand({dataRoot:w.dataRoot,business:'acme',project:'stack',worktree:reverse.worktree});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
// AU: owner Console transport only. Identity and organization never come from a JSON body.
|
||||
export const AUTONOMY_ROUTE=/^\/api\/autonomy(?:\/(confirm|reverse|review))?$/;
|
||||
const one=(req,k)=>req.headersDistinct[k]?.length===1?req.headersDistinct[k][0]:null;
|
||||
const json=(res,status,value)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));};
|
||||
export async function autonomyHTTP(req,res,{path,read,write,session,live,origin}) {
|
||||
if(!session)return json(res,401,{error:'sign-in-required'});
|
||||
if(!read)return json(res,503,{error:'not-configured'});
|
||||
if(!['GET','POST'].includes(req.method))return json(res,405,{error:'method-not-allowed'});
|
||||
const step=AUTONOMY_ROUTE.exec(path)?.[1]??null;
|
||||
if(step && req.method!=='POST')return json(res,405,{error:'method-not-allowed'});
|
||||
const post=req.method==='POST';
|
||||
if(post && !write)return json(res,403,{error:'read-only'});
|
||||
if(post && (one(req,'origin')!==origin || one(req,'sec-fetch-site')!=='same-origin'))return json(res,403,{error:'cross-origin'});
|
||||
let args={};
|
||||
if(post) {
|
||||
if(one(req,'content-type')?.split(';')[0].trim()!=='application/json')return json(res,400,{error:'invalid-request'});
|
||||
const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>4096)return json(res,413,{error:'invalid-request'});chunks.push(chunk);}
|
||||
try{args=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{return json(res,400,{error:'invalid-request'});}
|
||||
if(!args || typeof args!=='object' || Array.isArray(args))return json(res,400,{error:'invalid-request'});
|
||||
const keys=step==='confirm'?['id','confirm','reason']:step==='reverse'?['id']:step==='review'?['decisionClass']:['revision','autonomy'];
|
||||
if(Object.keys(args).some(k=>!keys.includes(k)))return json(res,400,{error:'invalid-request'});
|
||||
}
|
||||
if(live()!==session)return json(res,401,{error:'sign-in-required'});
|
||||
const subject={provider:session.provider,iss:session.iss,sub:session.sub};
|
||||
const verb=step==='confirm'?'decision.confirm.console':step==='reverse'?'decision.reverse.console':step==='review'?'autonomy.review.console':post?'autonomy.set.console':'autonomy.get.console';
|
||||
try{return json(res,200,{...await(post?write:read)(verb,args,{subject}),writable:!!write});}
|
||||
catch(e){const allowed=['authority-limits-unavailable','decision-closed','confirmation-required','not-found','storage-refused','human-required','invalid-request','invalid-autonomy','policy-reverse-unavailable','autonomy-precondition','revision-conflict','decision-not-eligible','decision-stale','decision-reversed','reverse-window-closed','reversal-unavailable','not-resolver','gate-missing','gate-failed','base-conflict','not-configured'];const code=allowed.includes(e?.code)?e.code:'outcome-unknown';return json(res,code==='human-required'?403:code==='invalid-request'||code==='invalid-autonomy'?400:409,{error:code});}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[data-autonomy], [data-autonomy-decision]{margin-top:16px}
|
||||
.autonomy-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:16px 0}
|
||||
.autonomy-fields label,[data-autonomy-decision] label{display:flex;flex-direction:column;align-items:stretch;gap:6px;min-width:0}
|
||||
[data-autonomy] :is(select,input:not([type=checkbox])),[data-autonomy-decision] input{width:100%;box-sizing:border-box;min-width:0;min-height:44px;padding:8px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--canvas);color:var(--text);font:inherit}
|
||||
[data-autonomy] .autonomy-checkbox{display:flex;align-items:center;gap:10px;min-height:44px;margin:12px 0}
|
||||
.autonomy-checkbox input{width:18px;height:18px;margin:0;flex:0 0 auto;accent-color:var(--action)}
|
||||
[data-autonomy] :is(button,select,input,summary):focus-visible,[data-autonomy-decision] :is(button,input):focus-visible{outline:2px solid var(--focus);outline-offset:2px}
|
||||
[data-autonomy] [role=status],[data-autonomy-decision] [role=status]{overflow-wrap:anywhere}
|
||||
[data-autonomy] details button{margin:8px 8px 0 0}
|
||||
@media(max-width:759px){.autonomy-fields{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Owner settings and decision notices. Server and broker establish identity and eligibility.
|
||||
(() => {
|
||||
'use strict';
|
||||
const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const classes=['cross-role','merge','policy','authority'],levels=['in the loop','near the loop','out of the loop'];
|
||||
const labels={'cross-role':'Cross-role work',merge:'Merge',policy:'Policy',authority:'Plain hire, fire and role change'};
|
||||
const states=new Map(),path=(org,step='')=>`${org?`/api/orgs/${encodeURIComponent(org)}`:'/api'}/autonomy${step?`/${step}`:''}`;
|
||||
const guidance={
|
||||
'policy-reverse-unavailable':'Policy has no effect executor or reverse yet. Choose in the loop or out of the loop.',
|
||||
'autonomy-precondition':'This move needs a separate resolver, a tested reverse, owner notices and an enforced budget covering new agents for authority. Out of the loop needs an actual near-loop decision followed by your review of its trail. If a reverse stopped an agent, inspect the trail before relaunching it.',
|
||||
'authority-limits-unavailable':'Choose near the loop with a budget covering new agents. Authority out of the loop waits for per-definition agent limits.',
|
||||
'decision-closed':'This decision is closed. Refresh the inbox; raise a new decision if needed.',
|
||||
'confirmation-required':'Confirm the arbiter resolution in the Console before using the decision.',
|
||||
'not-found':'This decision could not be found. Refresh the inbox.',
|
||||
'storage-refused':'The trail write was refused. Inspect the decision trail before retrying.',
|
||||
'reverse-window-closed':'The reverse window has closed. Raise an ordinary new decision.',
|
||||
'revision-conflict':'The organization changed. Reload before saving.',
|
||||
'not-resolver':'The resolver no longer holds the required right. Withdraw and raise a fresh decision under current policy.',
|
||||
'reversal-unavailable':'This effect cannot be reversed through this path. Use an ordinary new decision.',
|
||||
};
|
||||
async function call(org,step,body) {
|
||||
const r=await fetch(path(org,step),body===undefined?{cache:'no-store'}:{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
|
||||
const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value;
|
||||
}
|
||||
async function load(org) {try{states.set(org,await call(org,''));}catch(error){states.set(org,{error:error.message});}}
|
||||
function section(org) {
|
||||
const s=states.get(org);
|
||||
if(!s || s.error)return `<section class="panel" data-autonomy><h3>Autonomy</h3><p>${esc(s?.error??'Autonomy could not be read.')}</p></section>`;
|
||||
const v=s.autonomy??{};
|
||||
return `<section class="panel" data-autonomy data-autonomy-org="${esc(org)}"><h3>Autonomy</h3><p>Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.</p><form data-autonomy-form>
|
||||
<div class="autonomy-fields">${classes.map(k=>`<label>${labels[k]}<select name="${k}"${s.writable?'':' disabled'}>${levels.map(l=>`<option${s.levels[k]===l?' selected':''}>${l}</option>`).join('')}</select></label>`).join('')}</div>
|
||||
<div class="autonomy-fields"><label>Reverse window (hours)<input type="number" name="reverseWindowHours" min="1" max="168" value="${v.reverseWindowHours??24}"${s.writable?'':' disabled'}></label><label>Authority resolver<input name="authorityResolver" maxlength="64" value="${esc(v.authorityResolver??'')}" autocomplete="off"${s.writable?'':' disabled'}></label></div>
|
||||
<label class="autonomy-checkbox"><input name="notices" type="checkbox"${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label>
|
||||
<p>In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.</p>
|
||||
${s.writable?'<button class="btn" type="submit">Save autonomy</button>':'<p>Only the owner can change autonomy.</p>'}
|
||||
<p data-autonomy-status role="status"></p></form>
|
||||
${s.writable?`<details><summary>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}">Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`;
|
||||
}
|
||||
function notice(d) {
|
||||
if(!d?.autonomy)return '';
|
||||
const a=d.autonomy,org=d.business??window.MosaicOrg?.id;
|
||||
return `<section class="panel" data-autonomy-decision="${esc(d.id)}" data-autonomy-org="${esc(org??'')}"><h2>${a.pending?'Confirm the arbiter resolution':'Decision near the loop'}</h2>
|
||||
${a.pending?'<p>The arbiter resolved this decision. It has no effect until you confirm.</p><label>Reason for returning to the arbiter<input data-autonomy-reason maxlength="500"></label><p><button class="btn" data-autonomy-confirm="yes">Confirm resolution</button> <button class="btn" data-autonomy-confirm="no">Return to arbiter</button></p>':`<p>The agent resolved this decision. Reverse is available until ${esc(a.reverseUntil)}. A reverse cannot undo messages, work already done or code already shipped.</p><button class="btn" data-autonomy-reverse>Reverse decision</button>`}
|
||||
<p role="status" data-autonomy-status></p></section>`;
|
||||
}
|
||||
document.addEventListener('submit',async e=>{
|
||||
if(!e.target.matches('[data-autonomy-form]'))return;e.preventDefault();
|
||||
const form=e.target,section=form.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,s=states.get(org),fields=new FormData(form);
|
||||
const autonomy=Object.fromEntries(classes.map(k=>[k,fields.get(k)]));autonomy.notices=fields.has('notices');autonomy.reverseWindowHours=Number(fields.get('reverseWindowHours'));
|
||||
if(fields.get('authorityResolver'))autonomy.authorityResolver=fields.get('authorityResolver');
|
||||
const button=form.querySelector('button[type="submit"]'),status=form.querySelector('[data-autonomy-status]');button.disabled=true;
|
||||
try{const r=await call(org,'',{revision:s.revision,autonomy});s.revision=r.revision;s.autonomy=autonomy;s.levels=Object.fromEntries(classes.map(k=>[k,autonomy[k]]));status.textContent=`Saved. Receipt ${r.transaction}.`;}
|
||||
catch(error){status.textContent=error.message;}finally{button.disabled=false;}
|
||||
});
|
||||
document.addEventListener('click',async e=>{
|
||||
const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return;
|
||||
const section=button.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,status=section.querySelector('[data-autonomy-status]');
|
||||
const review=button.dataset.autonomyReview,reverse=button.hasAttribute('data-autonomy-reverse');
|
||||
const body=review?{decisionClass:review}:reverse?{id:section.dataset.autonomyDecision}:{id:section.dataset.autonomyDecision,confirm:button.dataset.autonomyConfirm==='yes',...(button.dataset.autonomyConfirm==='no'?{reason:section.querySelector('[data-autonomy-reason]').value}:{})};
|
||||
button.disabled=true;
|
||||
try{const r=await call(org,review?'review':reverse?'reverse':'confirm',body);status.textContent=review?'Trail review recorded.':reverse?(r.restart||r.trailPending?'Reversal committed. Completion needs recovery: inspect the decision trail and the stopped agent before requesting relaunch.':'Reversal recorded.'):(body.confirm?'Confirmation recorded. Refresh the inbox for current state.':'Returned to arbiter. Refresh the inbox for current state.');}
|
||||
catch(error){status.textContent=error.message;}finally{button.disabled=false;}
|
||||
});
|
||||
window.MosaicAutonomy={load,section,notice};
|
||||
})();
|
||||
@@ -112,7 +112,7 @@
|
||||
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
|
||||
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
|
||||
return {
|
||||
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
|
||||
id, business:d.business, autonomy:row?.autonomy??null, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
|
||||
options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
|
||||
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
|
||||
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
|
||||
@@ -302,7 +302,7 @@
|
||||
// Inbox
|
||||
function decisionCard(d) {
|
||||
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(['decision','hire'].includes(d.console.kind) ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
|
||||
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${d.autonomy?badge(d.autonomy.pending?'Awaiting your confirmation':'Near the loop','attn'):''}${canDecide && d.console ? ` ${badge(['decision','hire'].includes(d.console.kind) ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
|
||||
<p class="small s1-auth">${approves(d.authorization)}</p>
|
||||
${window.MosaicInbox?.cardMeta?.(d) ?? `<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}</p>`}<p class="small muted">${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
|
||||
}
|
||||
@@ -615,6 +615,7 @@
|
||||
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>${window.MosaicInbox?.detailMeta?.(d) ?? ''}
|
||||
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
|
||||
${answer?.id === d.id ? decideHtml() : ''}
|
||||
${canDecide?window.MosaicAutonomy?.notice(d)??'':''}
|
||||
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
|
||||
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}</section>
|
||||
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<link rel="stylesheet" href="/processing.css"><script src="/processing.js" defer></script>
|
||||
<link rel="stylesheet" href="/org-create.css"><script src="/org-create.js" defer></script>
|
||||
<link rel="stylesheet" href="/onboarding.css"><script src="/onboarding.js" defer></script><link rel="stylesheet" href="/panes.css">
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<script src="/workspace-data.js" defer></script><script src="/workspace.js" defer></script><script src="/tasks-board.js" defer></script><script src="/console-restart.js" defer></script><script src="/display.js" defer></script><script src="/inbox.js" defer></script><script src="/files.js" defer></script><script src="/project-remote.js" defer></script><link rel="stylesheet" href="/system-types.css"><script src="/system-types.js" defer></script><link rel="stylesheet" href="/autonomy.css"><script src="/autonomy.js" defer></script><script src="/federation.js" defer></script><script src="/brand.js" defer></script><script src="/app.js" defer></script><script src="/markdown-preview.js" defer></script><script src="/projects.js" defer></script><script src="/chat.js" defer></script><script src="/project-archive.js" defer></script><script src="/agents.js" defer></script><script src="/panes.js" defer></script><script src="/bus.js" defer></script><script src="/requests.js" defer></script></head>
|
||||
<body>
|
||||
<div hidden aria-hidden="true"><select id="palette"></select><select id="mode"><option value="system">system</option><option value="light">light</option><option value="dim">dim</option><option value="dark">dark</option></select></div>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
|
||||
@@ -207,7 +207,7 @@
|
||||
const org = list?.find(o => o.id === id && !o.invalid);
|
||||
if (!org) return `<article class="ws ws-dash" data-org-dashboard data-org-unavailable><div class="ws-vh"><h1 tabindex="-1">Organization unavailable</h1><p class="sub">${list ? 'This organization is not on your list.' : 'The organization list could not be read.'}</p></div></article>`;
|
||||
const types = window.MosaicSystemTypes || null;
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null]);
|
||||
const reads = await Promise.allSettled([data.business(id), data.settings(id), data.instances(id), data.threads(id), data.projects(id), data.queue(id), data.agents(id), data.claims(id), types ? types.load(id) : null, window.MosaicAutonomy?.load(id)]);
|
||||
const value = i => (reads[i].status === 'fulfilled' ? reads[i].value : null);
|
||||
const business = value(0), settings = value(1), instances = instancesOf(value(2));
|
||||
const threads = rowsOf(value(3)), projectRows = rowsOf(value(4)), queue = rowsOf(value(5));
|
||||
@@ -267,6 +267,7 @@
|
||||
${secH('Settings', 'every setting, with where its value comes from')}
|
||||
${settingsList(org, { business, settings, instances, orgCount: list ? list.length : null, types })}
|
||||
${types ? types.section(id, { pane, idPrefix: opts.idPrefix || '' }) : ''}
|
||||
${window.MosaicAutonomy?.section(id)??''}
|
||||
</section>
|
||||
</div>
|
||||
</article>`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AUTONOMY_ROUTE, autonomyHTTP } from './autonomy-http.mjs';
|
||||
import { proxyHTTP } from './proxy-http.mjs';
|
||||
import { createServer } from 'node:http';
|
||||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||||
@@ -78,6 +79,8 @@ const files = new Map([
|
||||
['/icons.svg', ['icons.svg', 'image/svg+xml']],
|
||||
['/project-remote.css', ['project-remote.css', 'text/css; charset=utf-8']],
|
||||
// Phase P2 (#1873): the owner's System types control (dashboard section and project selection).
|
||||
['/autonomy.js', ['autonomy.js', 'text/javascript; charset=utf-8']],
|
||||
['/autonomy.css', ['autonomy.css', 'text/css; charset=utf-8']],
|
||||
['/system-types.js', ['system-types.js', 'text/javascript; charset=utf-8']],
|
||||
['/system-types.css', ['system-types.css', 'text/css; charset=utf-8']],
|
||||
...[400, 500, 600, 700].map(w => [`/assets/fonts/manrope-${w}.woff2`, [`assets/fonts/manrope-${w}.woff2`, 'font/woff2']]),
|
||||
@@ -516,7 +519,7 @@ const AGENT_ROUTES = {
|
||||
done: r => /^[a-f0-9]{64}$/.test(r?.revision ?? '') && typeof r.restart === 'boolean' && r.instances && typeof r.instances === 'object' && !Array.isArray(r.instances) },
|
||||
};
|
||||
// F3: the writes the owner makes on a selected organization's bus from Settings (dev org Console).
|
||||
const ORG_OWNER_WRITE = path => path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|
||||
const ORG_OWNER_WRITE = path => AUTONOMY_ROUTE.test(path) || path === '/api/settings' || path === '/api/instances' || path === '/api/routes'
|
||||
|| Object.hasOwn(SEAT_ROUTES, path) || Object.hasOwn(AGENT_ROUTES, path) || path.startsWith('/api/proxy/')
|
||||
// Phase P2 (#1873): the System types enable switch and a project's selection; the route keeps its own checks.
|
||||
|| SYSTEM_TYPES_ROUTE.test(path);
|
||||
@@ -919,7 +922,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// alone stays open on a loopback Host for the status and release gates (Q-C).
|
||||
if (sso !== null && !named && !(path === '/healthz' && req.method === 'GET')) return json(res, 403, { error: 'public Host required' });
|
||||
// No CORS. JSON content type and same-origin checks keep browser forms out.
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path);
|
||||
const decisionPath = DECISION_ROUTE.test(path), requestPath = REQUEST_ROUTE.test(path), remotePath = PROJECT_REMOTE_ROUTE.test(path), systemTypesPath = SYSTEM_TYPES_ROUTE.test(path), autonomyPath = AUTONOMY_ROUTE.test(path);
|
||||
if (req.headers.origin && req.headers.origin !== (named ? (sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`) : `http://${req.headers.host}`))
|
||||
return decisionPath ? decisionRefuse(res, 'cross-origin') : requestPath ? requestRefuse(res, 'cross-origin') : json(res, 403, { error: 'cross-origin request refused' });
|
||||
let signer = preview, ssoSession = null;
|
||||
@@ -931,7 +934,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
if (!session && path !== '/healthz') {
|
||||
if (decisionPath) return decisionRefuse(res, 'sign-in-required');
|
||||
if (requestPath) return requestRefuse(res, 'sign-in-required');
|
||||
if (remotePath || systemTypesPath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (remotePath || systemTypesPath || autonomyPath) return json(res, 401, { error: 'sign-in-required' });
|
||||
if (path.startsWith('/api/') || req.method !== 'GET') return json(res, 401, { error: 'sign-in required' });
|
||||
res.writeHead(303, { location: `/auth/login?return=${encodeURIComponent(path + search)}`, 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
@@ -1040,6 +1043,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
session: ssoSession, live: () => { const live = sso.session(req); return live?.rootOwner === true ? live : null; }, origin: localOrigin ?? sso.publicOrigin });
|
||||
}
|
||||
// Phase P2 (#1873): the owner's System types read and writes on the selected organization.
|
||||
if(autonomyPath) {
|
||||
if(tester || granted || !named || sso===null)return json(res,403,{error:'owner-only'});
|
||||
return autonomyHTTP(req,res,{path,read:requestRead,write:send?requestWrite:null,session:ssoSession,
|
||||
live:()=>{const live=sso.session(req);return live?.rootOwner===true?live:null;},origin:localOrigin??sso.publicOrigin});
|
||||
}
|
||||
if (systemTypesPath) {
|
||||
if (tester || granted || !named || sso === null) return json(res, 403, { error: 'owner-only' });
|
||||
return systemTypesHTTP(req, res, { path, read: requestRead, write: send ? systemTypesWrite : null,
|
||||
|
||||
@@ -22,7 +22,8 @@ function refuse(where, why) {
|
||||
}
|
||||
function keys(value, where, allowed, required = allowed) {
|
||||
if (!object(value)) refuse(where, 'must be a JSON object');
|
||||
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(`${where}.${key}`, 'is not a supported key');
|
||||
// #1873: an unknown key is named only when it is a plain name; a key can be anything, a host path too.
|
||||
for (const key of Object.keys(value)) if (!allowed.includes(key)) refuse(/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(key) ? `${where}.${key}` : where, /^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(key) ? 'is not a supported key' : 'has a key that is not a plain name');
|
||||
for (const key of required) if (!Object.hasOwn(value, key)) refuse(`${where}.${key}`, 'is required');
|
||||
}
|
||||
function httpsURL(value, where) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdirSync} from 'node:fs';
|
||||
import {join} from 'node:path';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {startServer} from '../src/serve.mjs';
|
||||
import {createDevOwner} from '../src/dev-owner.mjs';
|
||||
import {browser} from './browser.mjs';
|
||||
import {close} from './fixture.mjs';
|
||||
const shots=join(tmpdir(),'autonomy-shots');
|
||||
const waitFor=(b,expression)=>b.evaluate(`(async()=>{for(let i=0;i<300;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30))}throw Error('autonomy view unavailable '+document.body.innerText)})()`);
|
||||
for(const [width,height]of [[1440,900],[390,844]])for(const theme of ['light','dark'])test(`AU owner settings ${width}x${height} ${theme}`,{timeout:60000},async t=>{
|
||||
let writes=0,confirms=0,declines=0,reverses=0,setting={merge:'out of the loop'},revision='a'.repeat(64);
|
||||
const call=async(verb,args)=>{
|
||||
if(verb==='autonomy.get.console')return {revision,autonomy:setting,levels:{'cross-role':'out of the loop',merge:setting.merge,policy:'out of the loop',authority:'in the loop'}};
|
||||
if(verb==='autonomy.set.console'){assert.equal(args.revision,revision);writes++;setting=args.autonomy;revision='b'.repeat(64);return {revision,transaction:'10000000-0000-4000-8000-000000000001'};}
|
||||
if(verb==='decision.confirm.console'){if(args.confirm)confirms++;else{declines++;assert.equal(args.reason,'Revise the plan');}return {op:args.confirm?'confirmed':'declined'};}
|
||||
if(verb==='decision.reverse.console'){reverses++;return {reversed:true};}
|
||||
if(verb==='system-types.console')return {installed:[],enabled:[],project:null};
|
||||
throw Object.assign(new Error('not-configured'),{code:'not-configured'});
|
||||
};
|
||||
const ids=['10000000-0000-4000-8000-000000000001','10000000-0000-4000-8000-000000000002','10000000-0000-4000-8000-000000000003'];
|
||||
const rows=ids.map((id,i)=>({id,business:'acme',at:'2026-11-11T12:00:00.000Z',class:'gated',action:'git.merge.protected',route_to:'cto',raised_by_role:'coder',raised_by_run:'coder-run',question:'Land the reviewed change?',options:[{key:'yes',text:'Land'},{key:'no',text:'Revise'}],recommendation:'yes',blocking:false,autonomy:i===2?{notice:true,reverseUntil:'2099-01-01T00:00:00Z'}:{pending:true}}));
|
||||
const trail=async id=>[{...rows.find(r=>r.id===id),table:'decisions'},{table:'events',kind:'action.allowed',body:{operation:'decision.raise',decision:id,approvalChoice:'yes',target:'vikunja:1/2'}}];
|
||||
const server=await startServer({port:0,sso:createDevOwner(),send:async()=>({}),organizations:{list:async()=>[{id:'acme',label:'Acme',busHealthy:true}],resolve:async()=>({busHealthy:true,decisionWrite:async()=>({}),requestRead:call,requestWrite:call,send:async()=>({}),reads:{business:async()=>({business:{id:'acme',projects:[],instances:[]}}),settings:async()=>({}),queue:async()=>({rows:[]})},bus:{instances:async()=>({instances:{}}),agents:async()=>[],'thread.claims':async()=>[],tasks:async()=>[],inbox:async()=>rows,trail,messages:async()=>({messages:[]})},chat:{projects:async()=>[],threads:async()=>[]}})},log:()=>{}});
|
||||
t.after(()=>close(server));const b=await browser({machine:false});t.after(()=>b.close());await b.viewport(width,height);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/organization`);
|
||||
await waitFor(b,`document.querySelector('[data-autonomy-form]')`);
|
||||
await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(theme)};document.querySelector('[data-autonomy-form]').scrollIntoView();true`);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value`),'out of the loop');
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true);
|
||||
await b.evaluate(`document.querySelector('[data-autonomy-form] select[name=merge]').value='in the loop';document.querySelector('[data-autonomy-form]').requestSubmit();true`);
|
||||
await waitFor(b,`/Saved/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(writes,1);assert.equal(setting.merge,'in the loop');
|
||||
mkdirSync(shots,{recursive:true});await b.evaluate(`document.querySelector('[data-autonomy]').scrollIntoView();true`);await b.screenshot(join(shots,`autonomy-${width}-${theme}.png`));
|
||||
const detail=async(id,selector)=>{
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/acme/inbox/${id}`);
|
||||
await waitFor(b,`document.querySelector(${JSON.stringify(selector)})`);
|
||||
await b.evaluate(`document.documentElement.dataset.mode=${JSON.stringify(theme)};document.querySelector('[data-autonomy-decision]').scrollIntoView();true`);
|
||||
assert.equal(await b.evaluate(`document.documentElement.scrollWidth<=innerWidth`),true);
|
||||
assert.equal(await b.evaluate(`document.querySelector('[data-autonomy-decision]').dataset.autonomyDecision`),id);
|
||||
};
|
||||
await detail(ids[0],'[data-autonomy-confirm=yes]');
|
||||
await b.evaluate(`document.querySelector('[data-autonomy-confirm=yes]').click();true`);
|
||||
await waitFor(b,`/Confirmation recorded/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(confirms,1);
|
||||
await b.screenshot(join(shots,`confirm-${width}-${theme}.png`));
|
||||
await detail(ids[1],'[data-autonomy-confirm=no]');
|
||||
await b.evaluate(`document.querySelector('[data-autonomy-reason]').value='Revise the plan';document.querySelector('[data-autonomy-confirm=no]').click();true`);
|
||||
await waitFor(b,`/Returned to arbiter/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(declines,1);
|
||||
await b.screenshot(join(shots,`decline-${width}-${theme}.png`));
|
||||
await detail(ids[2],'[data-autonomy-reverse]');
|
||||
await b.evaluate(`document.querySelector('[data-autonomy-reverse]').click();true`);
|
||||
await waitFor(b,`/Reversal recorded/.test(document.querySelector('[data-autonomy-status]').textContent)`);assert.equal(reverses,1);
|
||||
await b.screenshot(join(shots,`reverse-${width}-${theme}.png`));
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {Readable} from 'node:stream';
|
||||
import {autonomyHTTP} from '../src/autonomy-http.mjs';
|
||||
const owner={provider:'test',iss:'https://issuer.test',sub:'owner'};
|
||||
async function request({method='POST',path='/api/autonomy',body={revision:'a'.repeat(64),autonomy:{merge:'in the loop'}},headers={},session=owner,writable=true,live=()=>session,error=null}={}) {
|
||||
const req=Readable.from([Buffer.from(JSON.stringify(body))]);req.method=method;req.headersDistinct={origin:['http://console.test'],'sec-fetch-site':['same-origin'],'content-type':['application/json'],...headers};
|
||||
let status,result,calls=[];const res={writeHead:s=>status=s,end:text=>result=JSON.parse(text)};
|
||||
const invoke=async(...args)=>{calls.push(args);if(error)throw Object.assign(new Error('unsafe detail'),{code:error});return {revision:'b'.repeat(64)};};
|
||||
await autonomyHTTP(req,res,{path,session,live,read:invoke,write:writable?invoke:null,origin:'http://console.test'});
|
||||
return {status,result,calls};
|
||||
}
|
||||
test('AU HTTP uses the authenticated subject and owner bus verbs',async()=>{
|
||||
for(const [path,body,verb] of [['/api/autonomy',{revision:'a'.repeat(64),autonomy:{}},'autonomy.set.console'],['/api/autonomy/confirm',{id:'decision-1',confirm:true},'decision.confirm.console'],['/api/autonomy/reverse',{id:'decision-1'},'decision.reverse.console'],['/api/autonomy/review',{decisionClass:'merge'},'autonomy.review.console']]) {
|
||||
const r=await request({path,body});assert.equal(r.status,200);assert.deepEqual(r.calls,[[verb,body,{subject:owner}]]);
|
||||
}
|
||||
});
|
||||
test('AU HTTP member read-only session refuses every write before bus',async()=>{
|
||||
const r=await request({writable:false});assert.equal(r.status,403);assert.deepEqual(r.calls,[]);
|
||||
});
|
||||
test('AU HTTP sign-in, cross-origin and stale session refuse without bus effects',async()=>{
|
||||
for(const options of [{session:null},{headers:{origin:['https://attacker.test']}},{headers:{origin:['http://console.test','http://console.test']}},{headers:{'sec-fetch-site':['cross-site']}},{live:()=>null}]) {
|
||||
const r=await request(options);assert.ok([401,403].includes(r.status));assert.deepEqual(r.calls,[]);
|
||||
}
|
||||
});
|
||||
test('AU HTTP refuses caller-selected identity or organization',async()=>{
|
||||
for(const extra of [{actor:'owner'},{business:'other'},{subject:owner}]){const r=await request({body:{revision:'a'.repeat(64),autonomy:{},...extra}});assert.equal(r.status,400);assert.deepEqual(r.calls,[]);}
|
||||
});
|
||||
|
||||
test('AU HTTP preserves named state and authority refusals without raw details',async()=>{
|
||||
for(const code of ['authority-limits-unavailable','decision-closed','confirmation-required','not-found','storage-refused']){
|
||||
const r=await request({error:code});assert.equal(r.status,409);assert.deepEqual(r.result,{error:code});
|
||||
}
|
||||
});
|
||||
@@ -19,7 +19,7 @@ test('served Console browser: real board fixtures, keyboard, drafts, receipts, t
|
||||
await b.evaluate('document.querySelector("#hide-offline").click()');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("table.sessions tbody tr").length'), 4);
|
||||
// Rows 129, 154, 191, 194 and 294: chat.js, editor.js, processing.js, agents.js and federation.js are the scripts added, and #1839 adds requests.js (the intake panel under an owner's chat message), all same origin; the list is the pin, so another script is a visible change. #797 adds project-archive.js immediately after chat.js. F2 adds onboarding.js after org-create.js, and F3 adds console-restart.js after it. U2 (#1873) adds workspace-data.js and workspace.js before console-restart.js, and U4b adds tasks-board.js after workspace.js. U5 (#1873) adds panes.js (Side by side) just before bus.js, which mounts it. P2 (#1873) adds system-types.js after project-remote.js.
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("script")].map(s => s.getAttribute("src"))'), ['/editor.js', '/processing.js', '/org-create.js', '/onboarding.js', '/workspace-data.js', '/workspace.js', '/tasks-board.js', '/console-restart.js', '/display.js', '/inbox.js', '/files.js', '/project-remote.js', '/system-types.js', '/autonomy.js', '/federation.js', '/brand.js', '/app.js', '/markdown-preview.js', '/projects.js', '/chat.js', '/project-archive.js', '/agents.js', '/panes.js', '/bus.js', '/requests.js']);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/processing.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate(`document.querySelectorAll('script[src="/chat.js"]').length`), 1);
|
||||
assert.equal(await b.evaluate('!!window.injected'), false);
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"prd.approve",
|
||||
"decision.resolve.technical",
|
||||
"decision.resolve.delivery",
|
||||
"decision.resolve.authority",
|
||||
"library.write"
|
||||
],
|
||||
"vars": {
|
||||
@@ -100,6 +101,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"decision.resolve.authority": "gated",
|
||||
"decision.resolve.delivery": "within",
|
||||
"library.write": "gated"
|
||||
},
|
||||
@@ -149,6 +151,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "within",
|
||||
"decision.resolve.authority": "gated",
|
||||
"decision.resolve.delivery": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
@@ -199,6 +202,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"decision.resolve.authority": "gated",
|
||||
"decision.resolve.delivery": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
@@ -246,6 +250,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "gated",
|
||||
"decision.resolve.authority": "gated",
|
||||
"decision.resolve.delivery": "gated",
|
||||
"library.write": "gated"
|
||||
},
|
||||
@@ -293,6 +298,7 @@
|
||||
"policy.change": "gated",
|
||||
"prd.approve": "gated",
|
||||
"decision.resolve.technical": "within",
|
||||
"decision.resolve.authority": "gated",
|
||||
"decision.resolve.delivery": "within",
|
||||
"library.write": "gated"
|
||||
},
|
||||
|
||||
+38
-26
@@ -67,6 +67,16 @@ function fail(exitCode, message) {
|
||||
throw new Failure(exitCode, message);
|
||||
}
|
||||
|
||||
// #1873: a refusal names the config by where it came from, the data root and its marker by
|
||||
// their roles, and a key or argument only when it is a plain name; never a host path or a raw
|
||||
// error message. CONFIG is set when the selection is made.
|
||||
let CONFIG = "the default system config";
|
||||
function configLabel(instance) {
|
||||
return isNamed(instance) ? `the config of instance ${instance}` : process.env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config";
|
||||
}
|
||||
const plain = (value, otherwise) => /^[A-Za-z_][A-Za-z0-9_.-]{0,63}$/.test(String(value)) ? JSON.stringify(String(value)) : otherwise;
|
||||
const MARKER_LABEL = "the data root's instance marker";
|
||||
|
||||
function instancePath(name) {
|
||||
return path.join(os.homedir(), ".config", `mosaic-${name}`, "config.json");
|
||||
}
|
||||
@@ -82,6 +92,7 @@ function realOrNull(file) {
|
||||
// The config file for a selection. Without --instance, as before. With it,
|
||||
// NAME's file; a $MOSAIC_CONFIG beside it must be the same file.
|
||||
function configPath(instance) {
|
||||
CONFIG = configLabel(instance);
|
||||
if (instance === null) {
|
||||
return process.env.MOSAIC_CONFIG
|
||||
? path.resolve(process.env.MOSAIC_CONFIG)
|
||||
@@ -92,7 +103,7 @@ function configPath(instance) {
|
||||
const given = path.resolve(process.env.MOSAIC_CONFIG);
|
||||
const same = given === named || (realOrNull(given) !== null && realOrNull(given) === realOrNull(named));
|
||||
if (!same) {
|
||||
fail(4, `instance-config-mismatch: --instance ${instance} names ${named}, but MOSAIC_CONFIG names ${given}`);
|
||||
fail(4, `instance-config-mismatch: --instance ${instance} names its own config, but MOSAIC_CONFIG names another file`);
|
||||
}
|
||||
}
|
||||
return named;
|
||||
@@ -106,7 +117,7 @@ function parseArgs(args) {
|
||||
}
|
||||
const name = args[1];
|
||||
if (!INSTANCE_PATTERN.test(name)) {
|
||||
fail(4, `instance-name-invalid: ${JSON.stringify(name)} must match ${INSTANCE_PATTERN}`);
|
||||
fail(4, `instance-name-invalid: ${plain(name, "that name")} must match ${INSTANCE_PATTERN}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
@@ -124,20 +135,20 @@ function readMarker(dataRoot) {
|
||||
stat = fs.lstatSync(file);
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return null;
|
||||
fail(3, `instance-root-unclaimed: cannot read ${file} (${error.code})`);
|
||||
fail(3, `instance-root-unclaimed: cannot read ${MARKER_LABEL} (${error.code})`);
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
fail(3, `instance-root-unclaimed: ${file} must be a regular, non-symbolic-link file`);
|
||||
fail(3, `instance-root-unclaimed: ${MARKER_LABEL} must be a regular, non-symbolic-link file`);
|
||||
}
|
||||
let marker;
|
||||
try {
|
||||
marker = JSON.parse(fs.readFileSync(file, "utf8"));
|
||||
} catch (error) {
|
||||
fail(3, `instance-root-unclaimed: ${file} is not a valid marker (${error instanceof SyntaxError ? 'not valid JSON' : error.message})`);
|
||||
fail(3, `instance-root-unclaimed: ${MARKER_LABEL} is not a valid marker (${error instanceof SyntaxError ? 'not valid JSON' : `cannot read it, ${error.code ?? "error"}`})`);
|
||||
}
|
||||
if (!isPlainObject(marker) || Object.keys(marker).sort().join(",") !== "instanceVersion,name"
|
||||
|| marker.instanceVersion !== 1 || typeof marker.name !== "string" || !INSTANCE_PATTERN.test(marker.name)) {
|
||||
fail(3, `instance-root-unclaimed: ${file} is not a valid marker ({"instanceVersion":1,"name":NAME})`);
|
||||
fail(3, `instance-root-unclaimed: ${MARKER_LABEL} is not a valid marker ({"instanceVersion":1,"name":NAME})`);
|
||||
}
|
||||
return marker.name;
|
||||
}
|
||||
@@ -148,13 +159,13 @@ function checkClaim(dataRoot, instance) {
|
||||
const owner = readMarker(dataRoot);
|
||||
if (isNamed(instance)) {
|
||||
if (owner === null) {
|
||||
fail(3, `instance-root-unclaimed: ${dataRoot} holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
|
||||
fail(3, `instance-root-unclaimed: the data root holds no ${MARKER} naming ${instance} (scripts/bootstrap.sh --instance ${instance} claims a new root)`);
|
||||
}
|
||||
if (owner !== instance) {
|
||||
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}, not ${instance}`);
|
||||
fail(3, `instance-root-mismatch: the data root is claimed by instance ${owner}, not ${instance}`);
|
||||
}
|
||||
} else if (owner !== null && owner !== DEFAULT_INSTANCE) {
|
||||
fail(3, `instance-root-mismatch: ${dataRoot} is claimed by instance ${owner}; select it with --instance ${owner}`);
|
||||
fail(3, `instance-root-mismatch: the data root is claimed by instance ${owner}; select it with --instance ${owner}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,19 +177,19 @@ function readRaw(file) {
|
||||
return null; // missing
|
||||
}
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
fail(2, `configuration path must be a regular, non-symbolic-link file: ${file}`);
|
||||
fail(2, `configuration path must be a regular, non-symbolic-link file: ${CONFIG}`);
|
||||
}
|
||||
try {
|
||||
return fs.readFileSync(file, "utf8");
|
||||
} catch {
|
||||
fail(2, `configuration file is not readable: ${file}`);
|
||||
fail(2, `configuration file is not readable: ${CONFIG}`);
|
||||
}
|
||||
}
|
||||
|
||||
function rejectUnknownKeys(object, allowed, where) {
|
||||
for (const key of Object.keys(object)) {
|
||||
if (!allowed.includes(key)) {
|
||||
fail(2, `unsupported ${where} key: "${key}"`);
|
||||
fail(2, `unsupported ${where} key: ${plain(key, "that is not a plain name")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -206,11 +217,11 @@ function validateDataRoot(value, file) {
|
||||
}
|
||||
const home = path.resolve(os.homedir());
|
||||
if (resolved === home || isAncestorOf(resolved, home)) {
|
||||
fail(2, `execution "dataRoot" must not be or contain the home directory (${home})`);
|
||||
fail(2, 'execution "dataRoot" must not be or contain the home directory');
|
||||
}
|
||||
const configDir = path.dirname(path.resolve(file));
|
||||
if (resolved === configDir || isAncestorOf(resolved, configDir)) {
|
||||
fail(2, `execution "dataRoot" must not be or contain the configuration directory (${configDir})`);
|
||||
fail(2, 'execution "dataRoot" must not be or contain the configuration directory');
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
@@ -377,13 +388,14 @@ async function load(file, instance = null) {
|
||||
const raw = readRaw(file);
|
||||
if (raw === null) {
|
||||
const bootstrap = isNamed(instance) ? `scripts/bootstrap.sh --instance ${instance}` : "scripts/bootstrap.sh";
|
||||
fail(3, `configuration not found: ${file} (run ${bootstrap} to create it)`);
|
||||
// #1873: named by where it came from, never by host path.
|
||||
fail(3, `configuration not found: ${configLabel(instance)} (run ${bootstrap} to create it)`);
|
||||
}
|
||||
let document;
|
||||
try {
|
||||
document = JSON.parse(raw);
|
||||
} catch {
|
||||
fail(2, `configuration is not valid JSON (${file})`);
|
||||
fail(2, `configuration is not valid JSON (${CONFIG})`);
|
||||
}
|
||||
return await validate(document, file);
|
||||
}
|
||||
@@ -409,7 +421,7 @@ const DEFAULT_CONFIG = defaultConfig(path.join(os.homedir(), ".mosaic-dev"));
|
||||
|
||||
// 'wx': creation is exclusive; an existing file is never overwritten, and a
|
||||
// symbolic link at the path is never followed.
|
||||
function createExclusive(file, text, label = file) {
|
||||
function createExclusive(file, text, label) {
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(file, "wx", 0o644);
|
||||
@@ -417,7 +429,7 @@ function createExclusive(file, text, label = file) {
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error.code === "EEXIST") return false;
|
||||
fail(1, `unable to create ${label}: ${error.message}`);
|
||||
fail(1, `unable to create ${label} (${error.code ?? "error"})`);
|
||||
} finally {
|
||||
if (fd !== undefined) fs.closeSync(fd);
|
||||
}
|
||||
@@ -452,23 +464,23 @@ async function bootstrapNamed(file, instance) {
|
||||
let entries = null;
|
||||
try {
|
||||
const stat = fs.lstatSync(dataRoot);
|
||||
if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: ${dataRoot} exists and is not a directory; nothing was written`);
|
||||
if (!stat.isDirectory()) fail(3, `instance-root-unclaimed: the instance's data root exists and is not a directory; nothing was written`);
|
||||
entries = fs.readdirSync(dataRoot);
|
||||
} catch (error) {
|
||||
if (error instanceof Failure) throw error;
|
||||
if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read ${dataRoot} (${error.code}); nothing was written`);
|
||||
if (error.code !== "ENOENT") fail(3, `instance-root-unclaimed: cannot read the instance's data root (${error.code}); nothing was written`);
|
||||
}
|
||||
if (entries !== null && entries.length > 0 && readMarker(dataRoot) !== instance) {
|
||||
fail(3, `instance-root-unclaimed: ${dataRoot} exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`);
|
||||
fail(3, `instance-root-unclaimed: the instance's data root exists, is not empty and holds no ${MARKER} naming ${instance}; nothing was written`);
|
||||
}
|
||||
const configDir = path.dirname(file);
|
||||
fs.mkdirSync(path.dirname(configDir), { recursive: true });
|
||||
try {
|
||||
fs.mkdirSync(configDir, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") fail(1, `unable to create ${configDir}: ${error.message}`);
|
||||
if (error.code !== "EEXIST") fail(1, `unable to create the config directory of instance ${instance} (${error.code ?? "error"})`);
|
||||
}
|
||||
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`)) {
|
||||
if (!createExclusive(file, `${JSON.stringify(defaultConfig(dataRoot), null, 2)}\n`, CONFIG)) {
|
||||
const resolved = await load(file, instance);
|
||||
checkClaim(resolved.dataRoot, instance);
|
||||
return;
|
||||
@@ -477,9 +489,9 @@ async function bootstrapNamed(file, instance) {
|
||||
try {
|
||||
fs.mkdirSync(resolved.dataRoot, { mode: 0o700 });
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") fail(1, `unable to create ${resolved.dataRoot}: ${error.message}`);
|
||||
if (error.code !== "EEXIST") fail(1, `unable to create the data root (${error.code ?? "error"})`);
|
||||
}
|
||||
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`);
|
||||
createExclusive(path.join(resolved.dataRoot, MARKER), `${JSON.stringify({ instanceVersion: 1, name: instance })}\n`, MARKER_LABEL);
|
||||
checkClaim(resolved.dataRoot, instance);
|
||||
process.stderr.write(`mosaic-config: created configuration for instance ${instance}: ${file}, data root ${resolved.dataRoot}\n`);
|
||||
}
|
||||
@@ -531,7 +543,7 @@ const operation = process.argv[2];
|
||||
|
||||
try {
|
||||
if (!["bootstrap", "validate", "env", "instance"].includes(operation)) {
|
||||
fail(1, `unknown operation: ${JSON.stringify(operation ?? "")} (expected bootstrap | validate | env | instance)`);
|
||||
fail(1, `unknown operation: ${plain(operation ?? "", "that is not one")} (expected bootstrap | validate | env | instance)`);
|
||||
}
|
||||
const instance = parseArgs(process.argv.slice(3));
|
||||
switch (operation) {
|
||||
|
||||
Reference in new issue
Block a user