Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b8fe202bc | ||
|
|
5f8079a972 |
No files matched your search
@@ -643,6 +643,8 @@ export class Broker {
|
||||
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
|
||||
#giteaGrant;
|
||||
#copyGrant;
|
||||
// #1873 CB3: copy-launch.mjs's gate, or null. It answers for a copy the boot left needing a binding.
|
||||
#copyLaunch;
|
||||
#giteaKeys;
|
||||
#giteaUndo = [];
|
||||
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
|
||||
@@ -668,7 +670,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, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, copyGrant = 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, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, copyGrant = null, copyLaunch = 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');
|
||||
@@ -703,6 +705,7 @@ export class Broker {
|
||||
this.#hostKeys = hostKeys;
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#copyGrant = copyGrant;
|
||||
this.#copyLaunch = copyLaunch;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#access = access;
|
||||
this.#accessStamp = accessStamp;
|
||||
@@ -1566,15 +1569,32 @@ export class Broker {
|
||||
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
|
||||
// names the agent and the missing services or the reason, never a value (organization
|
||||
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
|
||||
#copyRefused(b, instance) {
|
||||
#copyRefused(b, instance, session = null) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
|
||||
if(copy?.level==='root') {
|
||||
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
|
||||
if(refusal)fail(refusal);
|
||||
}
|
||||
// #1873 CB3: a copy bound at boot holds only while the gate's launch authority does, and a run
|
||||
// only while it is the authority bindLaunch admitted for it. Without a gate it is unavailable.
|
||||
if (b.roles[instance]?.copyBound?.length) {
|
||||
if (!this.#copyLaunch) fail('credential-unavailable');
|
||||
const { authority } = this.#copyLaunch.authority(b.id, instance);
|
||||
if (session && session.copyAuthority !== authority) fail('copy-authority-changed');
|
||||
}
|
||||
if (!copy || copy.state === 'instance') return;
|
||||
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
if (copy.state === 'unbound') {
|
||||
// A binding approved after boot is restart-required, a failed boot check keeps its code.
|
||||
if (this.#copyLaunch) {
|
||||
try {
|
||||
this.#copyLaunch.authority(b.id, instance);
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError) || e.code !== 'credential-unbound') throw e;
|
||||
}
|
||||
}
|
||||
throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
}
|
||||
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
|
||||
}
|
||||
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
|
||||
@@ -1860,7 +1880,7 @@ export class Broker {
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.human) fail('agent-required');
|
||||
this.#copyRefused(this.#business(s.business),s.role);
|
||||
this.#copyRefused(this.#business(s.business),s.role,s);
|
||||
const h = this.#own(s);
|
||||
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
|
||||
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
|
||||
@@ -2121,7 +2141,7 @@ export class Broker {
|
||||
#launchProof(record) {
|
||||
keys(
|
||||
record,
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript', 'copyAuthority', 'revision'],
|
||||
['business', 'role', 'run', 'harness'],
|
||||
);
|
||||
const b = this.#business(identifier(record.business));
|
||||
@@ -2186,6 +2206,14 @@ export class Broker {
|
||||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
||||
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
|
||||
if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked');
|
||||
// #1873 CB3: a bound copy's run carries the launch authority its prepare and final admission
|
||||
// checked, and the organization file revision (A3) the launch resolved; both must still hold
|
||||
// now, and the session keeps the authority for every later use.
|
||||
if (launchRole.copyBound?.length) {
|
||||
if (!this.#copyLaunch) fail('credential-unavailable');
|
||||
if (typeof record.copyAuthority !== 'string') fail('copy-authority-changed');
|
||||
this.#copyLaunch.admit(record.business, record.role, record.copyAuthority, record.revision ?? null);
|
||||
} else if (record.copyAuthority !== undefined || record.revision !== undefined) fail('invalid-request');
|
||||
if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||||
this.#runs.set(key, session);
|
||||
return this.#cap(session);
|
||||
@@ -5487,6 +5515,20 @@ export class Broker {
|
||||
}
|
||||
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
|
||||
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
|
||||
// #1873 CB3: who a copy credential call is for, from the capability's own live run: business,
|
||||
// role, run and the launch authority bindLaunch admitted. Never a human, tester or reader, never an
|
||||
// ended run, and only while the copy's authority is still the one the run was admitted on.
|
||||
// Trusted host only; no socket verb returns it.
|
||||
copySession(cap) {
|
||||
const s = this.#session(cap);
|
||||
if (s.human || s.tester || s.reader) fail('copy-request-refused');
|
||||
const key = s.business + ':' + s.run;
|
||||
if (this.#runs.get(key) !== s || this.#ended.has(key)) fail('run-ended');
|
||||
const b = this.#business(s.business);
|
||||
if (!b.roles[s.role]?.copyBound?.length || typeof s.copyAuthority !== 'string') fail('copy-request-refused');
|
||||
this.#copyRefused(b, s.role, s);
|
||||
return Object.freeze({ business: s.business, role: s.role, run: s.run, copyAuthority: s.copyAuthority });
|
||||
}
|
||||
publishSession(ticket) {
|
||||
const { s } = this.#publish(ticket);
|
||||
return Object.freeze({ business: s.business, role: s.role });
|
||||
@@ -6412,11 +6454,16 @@ export class Broker {
|
||||
if (s.tester || s.reader || !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
|
||||
if (s.hireMembership ? s.hireMembership.owner !== true : s.human !== this.#business(s.business).human) fail('human-required');
|
||||
}
|
||||
// #1873 CB3: a write that changes a copy's binding or grant runs inside the gate's fence, so no
|
||||
// launch is admitted and no call goes out while it runs; an unknown outcome keeps the fence.
|
||||
#copyFence(business, instance, fn) {
|
||||
return this.#copyLaunch && typeof instance === 'string' ? this.#copyLaunch.fence(business, instance, fn) : Promise.resolve().then(fn);
|
||||
}
|
||||
#issueCopyGrant(s, a) {
|
||||
this.#copyOwner(s);
|
||||
const passed = (e) => e instanceof BusError && (e.code.startsWith('credential-') || ['copy-unavailable', 'human-required',
|
||||
'definition-holds-credential', 'definition-scan-unreadable'].includes(e.code));
|
||||
return this.#copyGrant(s.business, a, { actor: s.human, owner: true, recheck: () => this.#copyOwner(s) })
|
||||
return this.#copyFence(s.business, a.agent, () => this.#copyGrant(s.business, a, { actor: s.human, owner: true, recheck: () => this.#copyOwner(s) }))
|
||||
.then((result) => {
|
||||
this.#secretCheck(result);
|
||||
if (JSON.stringify(result).includes(a.token)) fail('credential-leak');
|
||||
@@ -6656,7 +6703,7 @@ export class Broker {
|
||||
if(!this.#hires)fail('not-configured');
|
||||
try {
|
||||
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
|
||||
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust});
|
||||
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#copyFence(s.business,p.binding?.instance,()=>this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}));
|
||||
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
|
||||
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
// #1873 CB3: launch admission for an agent copy's bound service, and the one checked call a copy
|
||||
// session makes with it (design docs/plans/2026-11-11_copy-credential-binding-design.md section 5
|
||||
// steps 7 and 8). Gitea is the only service with a verifier.
|
||||
//
|
||||
// Boot: each copy the boot left unbound whose record is bound with every service granted is
|
||||
// re-resolved (role, definition pin, admitted projects, incarnation) and its slot's token is proved
|
||||
// live at the service again. Only a copy that passes all of it is materialized (promoteCopies). A
|
||||
// binding approved after boot is never launched: it answers restart-required until the next boot.
|
||||
//
|
||||
// Every launch (prepare, final admission and bindLaunch) recomputes the launch authority: a digest
|
||||
// of the organization, copy, definition pin, copyId, incarnation, binding generation, each grant's
|
||||
// id and generation, the services and projects the copy reaches now, and their repositories at the
|
||||
// one origin the boot proved the token at. Every check reads the registry entry again: an
|
||||
// organization no longer active, or with another incarnation, admits and sends nothing. Prepare
|
||||
// proves the slot live again; the digest before and after must agree. The
|
||||
// session keeps the digest bindLaunch admitted, and each call recomputes it before and after the
|
||||
// request: a revoke, a re-issue, a role change or a project change in between denies the call.
|
||||
//
|
||||
// A revoke of a copy's binding runs inside fence(): while one is in flight the copy admits nothing
|
||||
// and a call refuses credential-revoking. A revoke whose outcome is unknown keeps the fence until
|
||||
// the next boot. The token is never held: each call reads the slot by the identity the boot proved
|
||||
// and checks it against a keyed MAC of the token the boot proved. Nothing here logs, and no result
|
||||
// or refusal holds the token, the slot's path or anything derived from the token.
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { assertNotHeld } from './credentials.mjs';
|
||||
import { send } from './gitea-grant.mjs';
|
||||
import { boundRepositories, tokenLevels, verifyGiteaToken } from './copy-grant.mjs';
|
||||
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
|
||||
import { allGranted, copyBotLogin, definitionPin, resolveCopyBinding } from '../../organization/src/copy-bindings.mjs';
|
||||
import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs';
|
||||
import { checkDefinitions, readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveAgentLevels } from '../../organization/src/agent-levels.mjs';
|
||||
import { canonicalJson, sha256 } from '../../organization/src/util.mjs';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// The repository API a copy session may call: /api/v1/repos/<owner>/<repo>[/<rest>], ASCII names,
|
||||
// no dot segment, no encoded separator, no query beyond plain pairs.
|
||||
const REPO_PATH = /^\/api\/v1\/repos\/([A-Za-z0-9_.-]{1,100})\/([A-Za-z0-9_.-]{1,100})((?:\/[A-Za-z0-9_.-]{1,200}){0,8})(\?[A-Za-z0-9_=&.-]{0,200})?$/;
|
||||
const READ = ['GET', 'HEAD'];
|
||||
const WRITE = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
// Codes a boot check keeps for the copy; anything else (a thrown bug, a transport surprise) is
|
||||
// credential-unavailable.
|
||||
const KEPT = /^(credential-|copy-)/;
|
||||
|
||||
// The launcher waits on its host channel for prepare, and the host stops on a reply later than its
|
||||
// request timeout (10 s); the boot runs inside the host's boot timeout (30 s). Each proof is held to
|
||||
// a budget well under those: a service slower than that answers no answer, so it is not proved.
|
||||
export const PREPARE_BUDGET_MS = 8000;
|
||||
export const BOOT_BUDGET_MS = 20000;
|
||||
|
||||
export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snapshot, transport = send, ca = undefined, timeoutMs = undefined, now = () => new Date(),
|
||||
prepareMs = PREPARE_BUDGET_MS, bootMs = BOOT_BUDGET_MS }) {
|
||||
const org = snapshot?.org ?? null;
|
||||
const key = randomBytes(32);
|
||||
const mac = (token) => createHmac('sha256', key).update(token).digest();
|
||||
const verified = new Map();
|
||||
const failed = new Map();
|
||||
const fences = new Map();
|
||||
const today = () => now().toISOString().slice(0, 10);
|
||||
// The organization's registry incarnation as the registry stands now, or null when the entry is
|
||||
// gone, carries none, or is not active (reserved, deleting, archived). Read on every check, never
|
||||
// cached: an organization archived while the bus runs admits and sends nothing. An unreadable or
|
||||
// unsafe registry refuses credential-unavailable.
|
||||
const liveIncarnation = () => {
|
||||
let entry;
|
||||
try {
|
||||
entry = readOrgEntrySync(configDir, org);
|
||||
} catch {
|
||||
return deny('credential-unavailable');
|
||||
}
|
||||
return entry?.state === 'active' ? entry.incarnation : null;
|
||||
};
|
||||
// The organization file is still the revision the launch resolved its bundle from (#1873 A3).
|
||||
const sameRevision = (business, revision) => {
|
||||
if (revision === undefined) return;
|
||||
let now;
|
||||
try {
|
||||
now = writer.read(business);
|
||||
} catch {
|
||||
deny('organization-unreadable');
|
||||
}
|
||||
if (typeof revision !== 'string' || now?.revision !== revision) deny('organization-changed');
|
||||
};
|
||||
const read = (business) => {
|
||||
try {
|
||||
return writer.read(business).document;
|
||||
} catch {
|
||||
return deny('credential-unavailable');
|
||||
}
|
||||
};
|
||||
// The copy, its pin, role and admitted projects as the file stands now (hire-decisions.mjs
|
||||
// bindingFacts, without the refusals that function makes for a proposal).
|
||||
const facts = (business, instance, doc) => {
|
||||
const copy = snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
|
||||
const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) };
|
||||
const pin = definitionPin(copy.definition, { definitions, rolesDir, readTemplate });
|
||||
let role = null;
|
||||
try {
|
||||
role = copy.definition.startsWith('org:') ? checkDefinitions(definitions, 'copy definitions')[copy.definition] : loadRole(rolesDir, copy.definition);
|
||||
} catch {
|
||||
role = null;
|
||||
}
|
||||
if (!pin || !role) deny('credential-role-changed');
|
||||
const code = Object.keys(doc.projects ?? {}).filter((p) => doc.projects[p]?.kind !== 'library');
|
||||
let projects = code;
|
||||
if (copy.level === 'root') {
|
||||
let levels;
|
||||
try {
|
||||
levels = resolveAgentLevels({ orgId: business, doc: { ...doc, definitions }, root: { agents: snapshot.rootAgents ?? {} }, rolesDir });
|
||||
} catch {
|
||||
deny('copy-unavailable');
|
||||
}
|
||||
projects = code.filter((p) => levels.unions[p]?.ok && levels.unions[p].agents.some((a) => a.level === 'root' && a.name === copy.name));
|
||||
}
|
||||
return { copy, pin, role, projects };
|
||||
};
|
||||
const resolve = (business, instance, doc, record, inc) => {
|
||||
const f = facts(business, instance, doc);
|
||||
const res = resolveCopyBinding({ orgId: business, incarnation: inc, snapshot, instance, record, pin: f.pin, role: f.role, projects: f.projects });
|
||||
if (!res.ok) deny(res.code);
|
||||
if (Object.keys(res.services).some((s) => !COPY_BINDABLE.includes(s))) deny('credential-scope-unverified');
|
||||
return { ...f, res };
|
||||
};
|
||||
const grantsOf = (record) => Object.fromEntries(Object.keys(record.services).sort()
|
||||
.map((s) => [s, { grantId: record.services[s].grantId, grantGeneration: record.services[s].grantGeneration }]));
|
||||
const slot = (instance) => org + '/' + instance;
|
||||
// Live proof of one slot token against the record: the service's own answer, the grant id the
|
||||
// verifier committed, and a rotation date not yet passed.
|
||||
// The transport held to a deadline: each call gets what is left, and none starts after it.
|
||||
const within = (deadline) => (req) => {
|
||||
const left = deadline - Date.now();
|
||||
if (left <= 0) return Promise.resolve({ status: 0, json: null, total: null });
|
||||
return transport({ ...req, timeoutMs: Math.min(req.timeoutMs ?? left, left) });
|
||||
};
|
||||
const prove = async (doc, record, token, inc, deadline) => {
|
||||
const approved = record.services.gitea;
|
||||
if (approved.rotateBy < today()) deny('credential-expired');
|
||||
const { origin, repos } = boundRepositories(doc, approved.resources.projects);
|
||||
const login = copyBotLogin(inc, record.copyId, 'gitea');
|
||||
if (!login) deny('credential-revoked');
|
||||
const grant = await verifyGiteaToken({ transport: within(deadline), url: origin, ca, timeoutMs, token, approved: approved.scopes, repos, login, rotateBy: approved.rotateBy });
|
||||
if (`gitea:${grant.userId}:${grant.tokenId}` !== approved.grantId) deny('credential-revoked');
|
||||
};
|
||||
|
||||
async function boot(business) {
|
||||
if (business !== org) return;
|
||||
const deadline = Date.now() + bootMs;
|
||||
// An inactive or unreadable registry entry leaves null: the resolver refuses every copy with its
|
||||
// own code.
|
||||
let incarnation = null;
|
||||
try {
|
||||
incarnation = liveIncarnation();
|
||||
} catch {
|
||||
incarnation = null;
|
||||
}
|
||||
let doc;
|
||||
try {
|
||||
doc = writer.read(business).document;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const [instance, copy] of Object.entries(snapshot.copies ?? {})) {
|
||||
if (copy.state !== 'unbound') continue;
|
||||
const record = doc.copyBindings?.[instance];
|
||||
if (!object(record) || record.state !== 'bound' || !allGranted(record.services)) continue;
|
||||
try {
|
||||
if ((copy.missing ?? []).some((s) => !COPY_BINDABLE.includes(s))) deny('credential-scope-unverified');
|
||||
const { pin, res } = resolve(business, instance, doc, record, incarnation);
|
||||
const identity = credentials.copyIdentity(slot(instance), 'gitea');
|
||||
if (!identity) deny('credential-unavailable');
|
||||
const { token } = credentials.readCopy(slot(instance), 'gitea', identity);
|
||||
await prove(doc, record, token, incarnation, deadline);
|
||||
// The origin the token was proved at is the only one a call goes to.
|
||||
const { origin } = boundRepositories(doc, res.services.gitea.projects);
|
||||
verified.set(instance, Object.freeze({
|
||||
copyId: record.copyId, incarnation, generation: record.generation, pin, grants: grantsOf(record),
|
||||
identity, mac: mac(token), services: Object.keys(res.services), origin,
|
||||
}));
|
||||
} catch (e) {
|
||||
failed.set(instance, e instanceof BusError && KEPT.test(e.code) ? e.code : 'credential-unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The current authority of a copy, or the refusal. `withToken` also returns the slot's token,
|
||||
// read now by the boot's identity and checked against the boot's MAC; only call() asks for it.
|
||||
function current(business, instance, { withToken = false } = {}) {
|
||||
if (business !== org || typeof instance !== 'string') deny('copy-unavailable');
|
||||
if (fences.get(instance)) deny('credential-revoking');
|
||||
const doc = read(business);
|
||||
const record = doc.copyBindings?.[instance];
|
||||
const v = verified.get(instance);
|
||||
if (!v) {
|
||||
if (object(record) && record.state === 'bound' && allGranted(record.services))
|
||||
deny(failed.get(instance) ?? 'restart-required');
|
||||
// Not bound: the resolver's own code (credential-unbound, a role change, a refused copy). This
|
||||
// branch always refuses and holds no credential, so an unreadable registry is no incarnation.
|
||||
let known = null;
|
||||
try { known = liveIncarnation(); } catch { known = null; }
|
||||
resolve(business, instance, doc, record, known);
|
||||
deny('restart-required');
|
||||
}
|
||||
// A verified copy holds only while its organization is active under the incarnation boot proved.
|
||||
const incarnation = liveIncarnation();
|
||||
if (!object(record) || record.state !== 'bound' || record.copyId !== v.copyId || record.orgIncarnation !== v.incarnation
|
||||
|| incarnation !== v.incarnation) deny('credential-revoked');
|
||||
if (record.generation !== v.generation || canonicalJson(grantsOf(record)) !== canonicalJson(v.grants)) deny('restart-required');
|
||||
const { res } = resolve(business, instance, doc, record, incarnation);
|
||||
if (canonicalJson(Object.keys(res.services)) !== canonicalJson(v.services)) deny('restart-required');
|
||||
// A project's remote moved to another server is a new binding: the token never goes there.
|
||||
const reach = boundRepositories(doc, res.services.gitea.projects);
|
||||
if (reach.origin !== v.origin) deny('restart-required');
|
||||
if (record.services.gitea.rotateBy < today()) deny('credential-expired');
|
||||
const identity = credentials.copyIdentity(slot(instance), 'gitea');
|
||||
if (!identity || identity.dev !== v.identity.dev || identity.ino !== v.identity.ino) deny('credential-unavailable');
|
||||
let token;
|
||||
try {
|
||||
token = credentials.readCopy(slot(instance), 'gitea', v.identity).token;
|
||||
} catch {
|
||||
deny('credential-unavailable');
|
||||
}
|
||||
if (!timingSafeEqual(mac(token), v.mac)) deny('credential-unavailable');
|
||||
const authority = sha256(canonicalJson({
|
||||
org, instance, pin: v.pin, copyId: v.copyId, incarnation: v.incarnation, generation: v.generation, grants: v.grants, services: res.services,
|
||||
origin: v.origin, repos: [...reach.repos].sort(),
|
||||
}));
|
||||
return { authority, services: v.services, record, res, reach, ...(withToken ? { doc, token } : {}) };
|
||||
}
|
||||
|
||||
const gate = {
|
||||
// { instance: [services] } of the copies the boot proved, for promoteCopies.
|
||||
bound: () => Object.fromEntries([...verified].map(([instance, v]) => [instance, [...v.services]])),
|
||||
verified: (business, instance) => business === org && verified.has(instance),
|
||||
boot,
|
||||
// Sync: the launch authority now, or the refusal.
|
||||
authority(business, instance) {
|
||||
const { authority, services } = current(business, instance);
|
||||
return { authority, services: [...services] };
|
||||
},
|
||||
// The launcher's prepare: the authority, a live proof of the slot at the service, a fresh
|
||||
// registry read, and the authority again; any change in between refuses.
|
||||
// With `revision` (the launcher's, from the bundle it resolved) the organization file must still
|
||||
// be that revision before and after the proof.
|
||||
async prepare(business, instance, revision = undefined) {
|
||||
sameRevision(business, revision);
|
||||
const before = current(business, instance, { withToken: true });
|
||||
await prove(before.doc, before.record, before.token, before.record.orgIncarnation, Date.now() + prepareMs);
|
||||
const after = current(business, instance);
|
||||
if (after.authority !== before.authority) deny('copy-authority-changed');
|
||||
sameRevision(business, revision);
|
||||
return { authority: after.authority, services: [...after.services] };
|
||||
},
|
||||
// Final admission and bindLaunch: the digest the launcher carries must still be the authority,
|
||||
// and with `revision` the organization file still the one the launch resolved.
|
||||
admit(business, instance, digest, revision = undefined) {
|
||||
if (typeof digest !== 'string') deny('copy-authority-changed');
|
||||
if (current(business, instance).authority !== digest) deny('copy-authority-changed');
|
||||
sameRevision(business, revision);
|
||||
},
|
||||
revoking: (business, instance) => business === org && Boolean(fences.get(instance)),
|
||||
// A write that can take a copy's grant away runs inside the fence. A definite answer (success,
|
||||
// or a refusal the writer gave before any change) lifts it; an unknown outcome keeps it.
|
||||
async fence(business, instance, fn) {
|
||||
if (business !== org || typeof instance !== 'string') return fn();
|
||||
fences.set(instance, (fences.get(instance) ?? 0) + 1);
|
||||
let release = true;
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// A refusal code (lowercase, the bus's and the organization writer's) is a definite answer
|
||||
// given before any change. outcome-unknown, a system error (ENOSPC, EIO) or an error with no
|
||||
// code may have changed the binding or the slot, so the fence stays.
|
||||
if (typeof e?.code !== 'string' || !/^[a-z][a-z0-9-]{1,80}$/.test(e.code) || e.code.startsWith('outcome-unknown')) release = false;
|
||||
throw e;
|
||||
} finally {
|
||||
if (release) {
|
||||
const n = fences.get(instance) - 1;
|
||||
if (n > 0) fences.set(instance, n);
|
||||
else fences.delete(instance);
|
||||
}
|
||||
}
|
||||
},
|
||||
// One checked Gitea request for a copy session: { business, role, copyAuthority } from the
|
||||
// session the bus bound, never from the caller's arguments. Returns { status, json }.
|
||||
async call(session, request) {
|
||||
if (!object(session) || !object(request)) deny('copy-request-refused');
|
||||
const { business, role: instance, copyAuthority } = session;
|
||||
if (typeof copyAuthority !== 'string') deny('copy-authority-changed');
|
||||
const { service = 'gitea', method, path, body } = request;
|
||||
if (service !== 'gitea' || typeof method !== 'string' || typeof path !== 'string' || path.length > 1024) deny('copy-request-refused');
|
||||
const m = REPO_PATH.exec(path);
|
||||
if (!m || [m[1], m[2], ...m[3].split('/').slice(1)].some((x) => x === '.' || x === '..')) deny('copy-request-refused');
|
||||
const before = current(business, instance, { withToken: true });
|
||||
if (before.authority !== copyAuthority) deny('copy-authority-changed');
|
||||
// Gitea's own semantics, enforced here: a read needs read:repository or write:repository, a
|
||||
// write needs write:repository. A binding with neither sends no repository request.
|
||||
const level = tokenLevels(before.res.services.gitea.scopes)?.repository;
|
||||
if (READ.includes(method) ? !['read', 'write'].includes(level) : !(WRITE.includes(method) && level === 'write')) deny('copy-request-refused');
|
||||
if (READ.includes(method) && body !== undefined) deny('copy-request-refused');
|
||||
const { origin, repos } = before.reach;
|
||||
if (!repos.has(`${m[1]}/${m[2]}`.toLowerCase())) deny('copy-request-refused');
|
||||
const r = await transport({ url: origin, method, path, auth: { token: before.token }, ...(body === undefined ? {} : { body }), ca, timeoutMs });
|
||||
// The answer is the session's only while the authority still holds.
|
||||
const after = current(business, instance);
|
||||
if (after.authority !== copyAuthority) deny('credential-revoked');
|
||||
if (!object(r) || !Number.isSafeInteger(r.status) || r.status === 0) deny('credential-unavailable');
|
||||
if (r.status >= 300 && r.status < 400) deny('copy-redirect-refused');
|
||||
const out = { status: r.status, json: r.json ?? null };
|
||||
assertNotHeld(out, [{ token: before.token }]);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
return Object.freeze(gate);
|
||||
}
|
||||
@@ -587,6 +587,23 @@ export class Credentials {
|
||||
}
|
||||
return Credentials.#format(service, text);
|
||||
}
|
||||
// #1873 CB3: the identity (dev, ino) of a copy's slot as it is now, for the launch gate's boot check,
|
||||
// or null when there is no single-link, self-owned 0600 regular file under private directories.
|
||||
// The gate then reads the token by this identity (readCopy) and proves it at the service.
|
||||
copyIdentity(instance, service) {
|
||||
if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) return null;
|
||||
try {
|
||||
const file = this.#storePath(instance, service);
|
||||
privateDir(this.#storeRoot);
|
||||
privateDir(dirname(dirname(file)));
|
||||
privateDir(dirname(file));
|
||||
const st = lstatSync(file);
|
||||
if (st.isSymbolicLink() || !st.isFile() || st.nlink !== 1 || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) return null;
|
||||
return { dev: st.dev, ino: st.ino };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Removes the slot only while it is still the file the stage wrote; another file is left alone.
|
||||
unlinkCopy(instance, service, identity) {
|
||||
const file = this.#storePath(instance, service);
|
||||
|
||||
@@ -40,6 +40,7 @@ const LAUNCH_OPS = new Set([
|
||||
'sessionClearUncertain',
|
||||
'sessionClearRetire',
|
||||
'sessionClearStart',
|
||||
'copyLaunch',
|
||||
'request.admission', 'request.launches', 'request.launchResult', 'request.release', 'request.chief', 'request.manifest',
|
||||
]);
|
||||
function launchOp(m) {
|
||||
@@ -90,7 +91,9 @@ function launchOp(m) {
|
||||
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
|
||||
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session, authority: m.authority });
|
||||
// #1873 CB3: a copy's launch prepare: the launch authority after a live proof at the service.
|
||||
if (m.op === 'copyLaunch') return runtime.copyLaunch({ business: m.business, instance: m.instance, revision: m.revision });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
// #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition.
|
||||
if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry });
|
||||
@@ -165,7 +168,7 @@ if (!process.send) {
|
||||
await runtime.close();
|
||||
return;
|
||||
}
|
||||
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
|
||||
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers, copyBound: runtime.copyBound });
|
||||
} catch (e) {
|
||||
// org-defscan: a held credential in a definition says which definition and key, never the text.
|
||||
const reason = e instanceof BusError && e.code === DEFINITION_HOLDS_CREDENTIAL && typeof e.reason === 'string' ? { reason: e.reason } : {};
|
||||
|
||||
@@ -26,6 +26,16 @@ group("credential-unbound", "R1/R8: launch needs an approved, verified binding",
|
||||
group("credential-scope-unverified", "R1/R8: a copy credential is accepted only when the service proves its whole grant", "Gitea: approve read:user in the copy's binding (its role must declare it) so the account's repositories can be listed, give every bound project a Gitea remote on one server, and retry when the server answers. Vikunja has no verifier yet; its copy bindings stay unusable.");
|
||||
group("credential-identity-refused", "R1/R8: one dedicated service account per organization, copy and service", "Create the account the refusal names on the copy's Gitea server, not an administrator, with access to the bound projects' repositories only, and issue a token from that account. Never reuse another copy's or agent's account or token.");
|
||||
group("credential-date credential-format", "R1/R8: well-formed credentials with a future rotation date", "Supply the token on standard input alone, and a rotation date after today in YYYY-MM-DD form, no more than a year ahead for a copy credential.");
|
||||
// #1873 CB3: a copy launches only on a binding this bus verified at boot, and each use rechecks it.
|
||||
group("restart-required", "R1/R3/R8: a copy's binding is materialized only at boot, after live verification", "Ask the instance owner to restart the organization bus so it verifies the copy's newly approved or re-issued binding, then launch the copy again.");
|
||||
group("credential-revoked", "R1/R8: a revoked or replaced binding grants nothing, with no cached token", "Read the copy's binding. Ask the chief to raise a fresh binding decision for the owner, have the credential issued again, then restart the organization bus.");
|
||||
group("credential-revoking", "R1/R8: a revoke in flight blocks every new launch and call", "Wait for the revoke to finish and read its receipt. If its outcome stays unknown, ask the instance owner to restart the organization bus before launching the copy again.");
|
||||
group("credential-unavailable", "R1/R8: a copy credential is used only while the service and the stored slot prove it", "Check that the copy's Gitea server answers, then ask the owner to issue the copy's credential again and restart the organization bus. A slot changed outside the bus is never trusted.");
|
||||
group("credential-expired", "R1/R8: a copy credential stops at its rotation date", "Ask the owner to issue a fresh credential for the copy with a new rotation date, then restart the organization bus.");
|
||||
group("copy-authority-changed", "R1/R3/R8: the launch authority admitted at prepare must hold through bind and every use", "The copy's role, projects or binding changed during the launch. Read the copy's current binding and launch it again; a running session from the old authority must be stopped and relaunched.");
|
||||
group("copy-request-refused", "R1/R8: a copy calls only its bound repositories with the methods its approved scopes allow", "Call a repository of a project the copy is bound to, with GET or HEAD when the binding approved read:repository or write:repository, or a write method only when it approved write:repository. Ask the chief for a binding decision to widen it.");
|
||||
group("organization-changed organization-unreadable", "R3/R8: a launch holds to the organization file revision it resolved", "The organization file changed, or could not be read, while the launch was in progress. Launch the agent again so it resolves the file as it stands now.");
|
||||
group("copy-redirect-refused", "R1/R8: a copy credential goes only to the approved Gitea origin", "The Gitea server answered with a redirect. Fix the bound project's remote to name the server's final address, then ask the owner to issue the credential again; redirects are never followed.");
|
||||
group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision.");
|
||||
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
|
||||
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
|
||||
|
||||
@@ -45,8 +45,9 @@ import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './defini
|
||||
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { loadWithCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
|
||||
import { copyLaunchGate } from './copy-launch.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -320,6 +321,9 @@ export async function startBroker({
|
||||
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
|
||||
// reload of the organization file below re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
// #1873 CB3: the tests' Gitea transport and CA for the copy launch gate and copyGrant, { send, ca }.
|
||||
// In-process only, like librarySync; production passes none.
|
||||
copyTransport = null,
|
||||
}) {
|
||||
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|
||||
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
|
||||
@@ -339,7 +343,7 @@ export async function startBroker({
|
||||
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null, proxyPort = null;
|
||||
let modelKeys = null, modelTimer = null, modelSweep = null, modelCatalogues = null;
|
||||
const modelRuns = new Map();
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null;
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null, copyLaunch = null;
|
||||
try {
|
||||
// Lifecycle Task 2 (#797): a product whose mapping is published but not yet activated is not part of
|
||||
// this process. A managed product is part of it only with both a committed activate receipt in the
|
||||
@@ -437,6 +441,25 @@ export async function startBroker({
|
||||
}
|
||||
// The boot copy snapshot's definitions (the root agents file's, A4): every reload re-applies them.
|
||||
if (copies?.definitions !== undefined) scanDefinitions(copies.definitions, clean, { boot: true });
|
||||
// #1873 CB3: each unbound copy whose binding is bound is re-resolved and its slot proved live at
|
||||
// the service; only those that pass join the organization, now, before anything takes a copy of
|
||||
// `businesses`. A binding approved later answers restart-required. copyGrant keeps the boot's own
|
||||
// snapshot (bootCopies), and so does the gate: what it promotes is decided at boot only.
|
||||
const bootCopies = copies;
|
||||
if (organization && storeRoot && copies) {
|
||||
copyLaunch = copyLaunchGate({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
|
||||
configDir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies, ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}) });
|
||||
await copyLaunch.boot(copies.org);
|
||||
const verified = copyLaunch.bound();
|
||||
if (Object.keys(verified).length) {
|
||||
copies = promoteCopies(copies, verified);
|
||||
const b = loadOrg(copies.org);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
businesses[copies.org] = withoutPending(copies.org, busOrganization(b, resolved));
|
||||
if (proxy) for (const instance of Object.keys(verified)) if (Object.hasOwn(businesses[copies.org].roles, instance)) credentials.registerModel(copies.org + '/' + instance);
|
||||
}
|
||||
}
|
||||
if (proxy) {
|
||||
const inventory = modelRunInventory(dataRoot, modelRuns, credentials);
|
||||
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: org ? instanceConfigDir : organization.dir, org: org?.id ?? null, lock: proxyLock,
|
||||
@@ -628,6 +651,9 @@ export async function startBroker({
|
||||
&& fixedAtStart(b).libraries === boot.libraries
|
||||
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
|
||||
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
|
||||
// #1873 CB3: a copy the boot snapshot had unbound (whether the gate promoted it or not).
|
||||
const copyNeedsAuthority = (id, instance) => copyLaunch !== null && bootCopies?.org === id
|
||||
&& Object.hasOwn(bootCopies.copies, instance) && bootCopies.copies[instance].state === 'unbound';
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
@@ -693,7 +719,10 @@ export async function startBroker({
|
||||
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
|
||||
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
|
||||
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
|
||||
// #1873 CB3: with `instance`, a copy the boot left needing a binding is admitted only on
|
||||
// `authority`, the launch authority digest its prepare returned, still current now. Checked
|
||||
// before the projects, so a launch with no project is held to it as well.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session, authority }) {
|
||||
if (projects !== undefined) {
|
||||
const no = code => ({ ready: false, revision: null, code });
|
||||
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
|
||||
@@ -718,6 +747,9 @@ export async function startBroker({
|
||||
try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; }
|
||||
if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL);
|
||||
}
|
||||
if (copyNeedsAuthority(id, instance)) {
|
||||
try { copyLaunch.admit(id, instance, authority); } catch (e) { return no(e?.code ?? 'copy-authority-changed'); }
|
||||
}
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
@@ -810,8 +842,9 @@ export async function startBroker({
|
||||
// #1873 CB2: credentials.issue for an agent copy's bound Gitea service, verified live at the server.
|
||||
copyGrant: organization && storeRoot
|
||||
? copyGrant({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
|
||||
copies, organizations: () => Object.keys(businesses) })
|
||||
copies: bootCopies, organizations: () => Object.keys(businesses), ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}) })
|
||||
: null,
|
||||
copyLaunch,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -1308,8 +1341,27 @@ export async function startBroker({
|
||||
credentials,
|
||||
// P2 consumes this inside the bus only. No process IPC/verb returns it.
|
||||
get modelProxy() { return modelProxyClient; },
|
||||
// #1873 CB3: the copies the boot proved live, { instance: [service] }; names only.
|
||||
copyBound: Object.freeze(copyLaunch ? copyLaunch.bound() : {}),
|
||||
path,
|
||||
bindLaunch,
|
||||
// #1873 CB3: the launcher's prepare for a copy the boot left needing a binding: the launch
|
||||
// authority { authority, services } after a live proof at the service, or the refusal. Any
|
||||
// other instance needs none and gets { authority: null }.
|
||||
// `revision` is the organization file revision the launch resolved its bundle from (A3); a
|
||||
// copy's prepare holds the file to it.
|
||||
copyLaunch: async ({ business, instance, revision }) => {
|
||||
if (typeof business !== 'string' || typeof instance !== 'string') throw new BusError('invalid-request');
|
||||
if (!copyNeedsAuthority(business, instance)) return { authority: null, services: [] };
|
||||
if (typeof revision !== 'string') throw new BusError('invalid-request');
|
||||
return copyLaunch.prepare(business, instance, revision);
|
||||
},
|
||||
// #1873 CB3: one checked Gitea request for a bound copy's run. Who it is for comes from the
|
||||
// capability's session, never from the request. Returns { status, json }; nothing holds the token.
|
||||
copyCall: async (cap, request) => {
|
||||
if (!copyLaunch) throw new BusError('credential-unavailable');
|
||||
return copyLaunch.call(broker.copySession(cap), request);
|
||||
},
|
||||
recoverLaunch: ({ modelKeyId, ...record }) => broker.recoverLaunch(record),
|
||||
endLaunch: (r) => { const out = broker.endLaunch(r); modelRuns.delete(r.business + ':' + r.run); return out; },
|
||||
modelAdmission: async ({ business, instance, modelKey, harness, revision, entry: expected, provider, model, account, route, transport }) => {
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
// #1873 CB3 live acceptance: a bound copy's launch gate against a scratch gitea/gitea:1.27.1, a
|
||||
// throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after. The boot proves the
|
||||
// slot live, a launch carries its authority to the bound session, one checked call reads the bound
|
||||
// repository, and a reissue denies the next call before any request. The test never pulls an image;
|
||||
// without Docker or the image it skips. Every token here is a scratch token this container made;
|
||||
// none is printed, and nothing here reaches git.mosaicstack.dev or any real service.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:https';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.mjs';
|
||||
import { copyLaunchGate } from '../src/copy-launch.mjs';
|
||||
import { send } from '../src/gitea-grant.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const IMAGE = 'gitea/gitea:1.27.1';
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
||||
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
||||
|
||||
const freePort = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
// One HTTPS call to the scratch container as its administrator, trusting only the scratch CA.
|
||||
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
||||
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
resolve({ status: res.statusCode, json });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0, json: null }));
|
||||
req.end(data ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('CB3-D: on scratch 1.27.1 a bound copy boots proved, launches with its authority, reads only its repository, refuses a redirect, and a reissue denies the next call unsent', { skip, timeout: 300000 }, async (t) => {
|
||||
// The child's complete TAP output is captured before any of it reaches this runner, so the parent
|
||||
// can search it for the minted values without putting one in an assertion.
|
||||
if (!process.env.CB3_COPY_CAPTURE) {
|
||||
const capture = mkdtempSync(join(tmpdir(), 'cb3cap-'));
|
||||
t.after(() => rmSync(capture, { recursive: true, force: true }));
|
||||
const file = join(capture, 'minted');
|
||||
const childEnv = { ...process.env, CB3_COPY_CAPTURE: file };
|
||||
delete childEnv.NODE_TEST_CONTEXT;
|
||||
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB3-D:', fileURLToPath(import.meta.url)], {
|
||||
encoding: 'utf8', timeout: 280000, env: childEnv,
|
||||
});
|
||||
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
||||
const output = (child.stdout ?? '') + (child.stderr ?? '');
|
||||
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
|
||||
t.diagnostic(output);
|
||||
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
|
||||
assert.equal(child.status, 0, 'Docker child passed');
|
||||
assert.equal(values.length, 3, 'all three scratch tokens were minted: the acceptance actually ran');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cb3d-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
|
||||
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
||||
chmodSync(tlsDir, 0o755);
|
||||
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
||||
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
|
||||
const url = `https://127.0.0.1:${port}`, name = 'cb3-' + randomBytes(6).toString('hex');
|
||||
t.after(() => docker('rm', '-f', name));
|
||||
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
||||
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
|
||||
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
|
||||
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
||||
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
|
||||
assert.equal(started.status, 0, 'scratch container starts');
|
||||
const end = Date.now() + 120000;
|
||||
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
||||
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
|
||||
await sleep(500);
|
||||
}
|
||||
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
||||
const minted = [];
|
||||
const createUser = (user, admin = false) => {
|
||||
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
|
||||
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
||||
assert.equal(r.status, 0, `create scratch ${user}`);
|
||||
};
|
||||
const mint = (user, scopes) => {
|
||||
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb3-' + randomBytes(4).toString('hex'),
|
||||
'--scopes', scopes.join(','), '--raw');
|
||||
assert.equal(r.status, 0, `mint scratch ${user}`);
|
||||
const value = r.stdout.trim();
|
||||
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
|
||||
minted.push(value);
|
||||
writeFileSync(process.env.CB3_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
|
||||
return value;
|
||||
};
|
||||
createUser('scratch-admin', true);
|
||||
createUser('owner');
|
||||
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
|
||||
for (const repo of ['stack', 'other']) {
|
||||
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
|
||||
assert.equal(made.status, 201, `create owner/${repo}`);
|
||||
}
|
||||
|
||||
// The organization: one root agent "rev" (research, Gitea alone) bound read-only to project stack.
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb3o-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${url}/owner/stack`;
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
await reserveOrg(ctx, 'demo');
|
||||
await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'research' } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
// Every request the gate sends, by op only.
|
||||
const sent = [];
|
||||
const transport = (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
|
||||
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 });
|
||||
let promoted = snapshot;
|
||||
const business = () => {
|
||||
const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted });
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
|
||||
};
|
||||
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
|
||||
const b = new Broker({
|
||||
store, businesses: { demo: business() }, copyLaunch: gate,
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
|
||||
});
|
||||
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services: READ } });
|
||||
const rec = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8')).copyBindings.rev;
|
||||
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
|
||||
createUser(login);
|
||||
const collaborate = (repo) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission: 'read' } });
|
||||
assert.equal((await collaborate('stack')).status, 204, 'bot is a read collaborator on owner/stack');
|
||||
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
|
||||
assert.equal((await issueToken(mint(login, ['read:repository', 'read:user']))).state, 'bound');
|
||||
|
||||
// Bound after this gate was made: restart-required until a boot proves it.
|
||||
await assert.rejects(gate.prepare('demo', 'rev'), { code: 'restart-required' });
|
||||
assert.deepEqual(sent, []);
|
||||
await gate.boot('demo');
|
||||
assert.deepEqual(gate.bound(), { rev: ['gitea'] });
|
||||
assert.ok(sent.includes('GET /api/v1/token'), 'the boot proved the slot live at the service');
|
||||
promoted = promoteCopies(snapshot, gate.bound());
|
||||
b.reloadBusiness('demo', business());
|
||||
|
||||
// A launch: prepare proves the slot again, bindLaunch carries the authority to the session.
|
||||
const project = (await request_(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
||||
const thread = (await request_(human, 'thread.create', { project, name: 'one', instance: 'rev' })).id;
|
||||
const proofs = sent.length;
|
||||
const revision = writer.read('demo').revision;
|
||||
const { authority } = await gate.prepare('demo', 'rev', revision);
|
||||
assert.ok(sent.slice(proofs).includes('GET /api/v1/token'));
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'rev', run: 'rev-run', harness: 'pi', thread, copyAuthority: authority, revision });
|
||||
const use = (req) => Promise.resolve().then(() => gate.call(b.copySession(cap), req));
|
||||
const own = await use({ method: 'GET', path: '/api/v1/repos/owner/stack' });
|
||||
assert.equal(own.status, 200);
|
||||
assert.equal(own.json.full_name, 'owner/stack');
|
||||
// A repository outside the binding, and a write on a read binding: refused before any request.
|
||||
const before = sent.length;
|
||||
await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/other' }), { code: 'copy-request-refused' });
|
||||
await assert.rejects(use({ method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } }), { code: 'copy-request-refused' });
|
||||
assert.equal(sent.length, before);
|
||||
|
||||
// A reissue (a new generation) while the session is live: the next call refuses, none is sent.
|
||||
await issueToken(mint(login, ['read:repository', 'read:user']));
|
||||
assert.equal(rec().services.gitea.grantGeneration, 2);
|
||||
const afterIssue = sent.length;
|
||||
await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'restart-required' });
|
||||
assert.equal(sent.length, afterIssue, 'no request after the reissue');
|
||||
|
||||
// Gitea answers a renamed repository's old path with a redirect. A fresh boot and launch on the
|
||||
// second token; the bound path now redirects, and the gate refuses it without following.
|
||||
const gate2 = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 });
|
||||
await gate2.boot('demo');
|
||||
assert.deepEqual(gate2.bound(), { rev: ['gitea'] });
|
||||
const { authority: authority2 } = await gate2.prepare('demo', 'rev');
|
||||
// The broker's session for the old launch is gone with its authority; this one is gate2's own.
|
||||
assert.throws(() => b.copySession(cap), { code: 'restart-required' });
|
||||
const session = { business: 'demo', role: 'rev', copyAuthority: authority2 };
|
||||
assert.equal((await gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' })).status, 200);
|
||||
assert.equal((await call(url, ca, 'PATCH', '/api/v1/repos/owner/stack', { auth: admin, body: { name: 'moved' } })).status, 200, 'rename owner/stack');
|
||||
const viaOld = await call(url, ca, 'GET', '/api/v1/repos/owner/stack', { auth: admin });
|
||||
assert.equal(viaOld.status, 301, 'Gitea 1.27.1 redirects a renamed repository\'s old path');
|
||||
const n = sent.length;
|
||||
await assert.rejects(gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'copy-redirect-refused' });
|
||||
assert.equal(sent.length, n + 1, 'one request, never followed');
|
||||
t.diagnostic(`minted ${minted.length}`);
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
// #1873 CB3 (design section 5 steps 7 and 8, section 8 row CB3): a bound agent copy launches only
|
||||
// when the bus's boot proved its binding live at the service, every launch carries a launch
|
||||
// authority the bus checks again at prepare, final admission and bindLaunch, and each credential
|
||||
// call is checked against the session's authority before and after the request. The fake Gitea is
|
||||
// fake-gitea-api.mjs on 127.0.0.1; the runtime and launcher halves are runtime tests below and
|
||||
// packages/cli/tests/agent-copies.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.mjs';
|
||||
import { copyLaunchGate } from '../src/copy-launch.mjs';
|
||||
import { send } from '../src/gitea-grant.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const WRITE = { gitea: { scopes: ['read:user', 'write:repository'], projects: ['stack'] } };
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const CODES = ['restart-required', 'credential-revoked', 'credential-revoking', 'credential-unavailable', 'credential-expired',
|
||||
'copy-authority-changed', 'copy-request-refused', 'copy-redirect-refused', 'organization-changed', 'organization-unreadable'];
|
||||
|
||||
// demo with the project stack on the fake Gitea and the root copy rev (research, which needs gitea
|
||||
// alone; a binding may be narrower than the role). The
|
||||
// owner binds rev with `services` and, unless `issue` is false, issues one live token for it (CB2).
|
||||
// `gate` is the CB3 gate on the boot snapshot; `boot()` runs its boot check and returns the broker's
|
||||
// business with the copies it proved promoted, as runtime.mjs does.
|
||||
async function fixture(t, { services = READ, perm = 'read', issue = true, definition = 'research', budgets = {} } = {}) {
|
||||
let clock = Date.now();
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb3-')), rolesDir = rolesCopy(root);
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const api = await fakeGiteaApi(t, root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${api.url}/owner/stack`;
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
// Every request the gate sends, with what the test lets through; `hold` parks one until released.
|
||||
const sent = [];
|
||||
const transport = async (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
|
||||
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(clock), ...budgets });
|
||||
const advance = (days) => { clock += days * 86400000; };
|
||||
let promoted = snapshot;
|
||||
const business = () => {
|
||||
const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted });
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
|
||||
};
|
||||
const issueGrant = (...a) => copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: 3000 })(...a);
|
||||
const b = new Broker({ store, businesses: { demo: business() }, copyLaunch: gate,
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issueGrant(...a) });
|
||||
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.rev;
|
||||
const repo = api.addRepo('owner', 'stack');
|
||||
api.addRepo('owner', 'other');
|
||||
await call(owner, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services } });
|
||||
const r = rec();
|
||||
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
|
||||
const bot = api.addUser(login).user;
|
||||
repo.collaborators.push(login); repo.permissions[login] = perm;
|
||||
const scopes = services.gitea.scopes;
|
||||
const reissue = (scopesNow = scopes) => call(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token: api.addToken(login, `t${api.issued.length}`, scopesNow).sha1 });
|
||||
if (issue) await reissue();
|
||||
const boot = async () => {
|
||||
await gate.boot('demo');
|
||||
promoted = promoteCopies(snapshot, gate.bound());
|
||||
b.reloadBusiness('demo', business());
|
||||
};
|
||||
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
|
||||
// A run of rev, bound with the authority the launcher's prepare returned.
|
||||
// rev is a thread instance, so each run takes a thread of its own in the stack project.
|
||||
let project = null;
|
||||
const threadFor = async (run) => {
|
||||
project ??= (await call(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
||||
return (await call(human, 'thread.create', { project, name: run, instance: 'rev' })).id;
|
||||
};
|
||||
const launch = async (run = 'rev-run') => {
|
||||
const thread = await threadFor(run);
|
||||
const revision = writer.read('demo').revision;
|
||||
const { authority } = await gate.prepare('demo', 'rev', revision);
|
||||
return { authority, thread, cap: b.bindLaunch({ business: 'demo', role: 'rev', run, harness: 'pi', thread, copyAuthority: authority, revision }) };
|
||||
};
|
||||
const use = (cap, request) => Promise.resolve().then(() => gate.call(b.copySession(cap), request));
|
||||
const GET = { method: 'GET', path: '/api/v1/repos/owner/stack' };
|
||||
// The organization file edited in place, as an owner's editor would.
|
||||
const edit = (fn) => {
|
||||
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||
fn(d);
|
||||
writeFileSync(file, JSON.stringify(d, null, 2) + '\n');
|
||||
};
|
||||
// The next request matching `match` waits at the fake server until release() lets it through.
|
||||
const hold = (match) => {
|
||||
let release, arrived;
|
||||
const go = new Promise((r) => { release = r; });
|
||||
const seen = new Promise((r) => { arrived = r; });
|
||||
const unrule = api.rule((e, body) => match(e, body), async () => { unrule(); arrived(); await go; return { pass: true }; });
|
||||
return { release: () => release(), arrived: seen };
|
||||
};
|
||||
// Gate requests to the copy's repositories (the proof's /version, /token and /user routes aside).
|
||||
const repoCalls = () => sent.filter((x) => x.includes('/api/v1/repos/')).length;
|
||||
return { root, rolesDir, edit, hold, advance, repoCalls, file, api, b, gate, threadFor, writer, credentials, owner, human, call, rec, bot, login, repo, reissue, boot, slot, launch, use, sent, GET, storeRoot, snapshot, business };
|
||||
}
|
||||
|
||||
const refuses = (code) => (e) => { assert.ok(e instanceof BusError, String(e?.stack ?? e)); assert.equal(e.code, code); return true; };
|
||||
|
||||
test('CB3 every new refusal code is in the refusal table with a rail and a next step naming no path', () => {
|
||||
for (const code of CODES) {
|
||||
const entry = REFUSALS[code];
|
||||
assert.ok(entry, code);
|
||||
assert.match(entry.rail, /^R\d+[/:]/, code);
|
||||
assert.ok(entry.nextStep.length > 20, code);
|
||||
assert.ok(!/(^|\s)(\/|~\/)\S/.test(entry.nextStep), `${code} names a path`);
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a copy bound before boot is proved live at boot and launches; prepare, admission and bindLaunch carry one authority', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), { rev: ['gitea'] });
|
||||
const role = f.b.taskRoles('demo').rev;
|
||||
assert.deepEqual([role.copyBound, role.credentials], [['gitea'], {}]);
|
||||
const { authority, cap } = await f.launch();
|
||||
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||
assert.equal(f.gate.authority('demo', 'rev').authority, authority);
|
||||
// The live proof at prepare went to the bound origin as the copy's account.
|
||||
assert.ok(f.sent.includes('GET /api/v1/token'));
|
||||
const r = await f.use(cap, f.GET);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.full_name, 'owner/stack');
|
||||
assert.ok(!JSON.stringify(r).includes(f.api.issued.at(-1)));
|
||||
// bindLaunch refuses a bound copy without the authority, or with another one.
|
||||
const r2 = await f.threadFor('r2'), r3 = await f.threadFor('r3');
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2 }), refuses('copy-authority-changed'));
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r3', harness: 'pi', thread: r3, copyAuthority: 'f'.repeat(64) }), refuses('copy-authority-changed'));
|
||||
// A non-copy never carries one.
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', copyAuthority: authority }), refuses('invalid-request'));
|
||||
});
|
||||
|
||||
test('CB3 a binding approved after boot answers restart-required at every launch step and sends nothing', async (t) => {
|
||||
const f = await fixture(t, { issue: false });
|
||||
assert.notEqual(f.rec().state, 'bound');
|
||||
await f.boot();
|
||||
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
|
||||
// The owner issues the grant now: the record is bound and granted, but the boot didn't prove it.
|
||||
await f.reissue();
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
assert.equal(f.b.taskRoles('demo').rev, undefined);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('restart-required'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', 'f'.repeat(64)), refuses('restart-required'));
|
||||
// The verb says so too, before its own unbound refusal.
|
||||
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('restart-required'));
|
||||
assert.deepEqual(f.sent, []);
|
||||
});
|
||||
|
||||
test('CB3 a partial binding (a role needing gitea and vikunja, bound for gitea alone) never launches and its boot sends nothing', async (t) => {
|
||||
const f = await fixture(t, { definition: 'reviewer' });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
await f.boot();
|
||||
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-scope-unverified'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-scope-unverified'));
|
||||
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('credential-scope-unverified'));
|
||||
assert.deepEqual(f.sent, []);
|
||||
});
|
||||
|
||||
test('CB3 a redirect from the bound origin is refused and never followed', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
f.api.rule((e) => e.path === '/api/v1/repos/owner/stack', { status: 302 });
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('copy-redirect-refused'));
|
||||
assert.equal(f.api.requests.filter((e) => e.path.startsWith('/api/v1/repos/')).length, 1);
|
||||
});
|
||||
|
||||
test('CB3 a call reaches only the admitted repositories, by the methods the binding allows, with no body on a read', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
for (const request of [
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/other' },
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/stack/../other' },
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/stack/%2e%2e/other' },
|
||||
{ method: 'GET', path: '/api/v1/user' },
|
||||
{ method: 'GET', path: 'https://evil.test/api/v1/repos/owner/stack' },
|
||||
{ method: 'GET', path: '//evil.test/api/v1/repos/owner/stack' },
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/stack', body: { x: 1 } },
|
||||
{ method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } },
|
||||
{ method: 'TRACE', path: '/api/v1/repos/owner/stack' },
|
||||
{ service: 'vikunja', method: 'GET', path: '/api/v1/repos/owner/stack' },
|
||||
]) await assert.rejects(f.use(cap, request), refuses('copy-request-refused'), JSON.stringify(request));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
// The session, never the arguments, names who the call is for.
|
||||
await assert.rejects(f.gate.call({ business: 'demo', role: 'rev', copyAuthority: 'f'.repeat(64) }, f.GET), refuses('copy-authority-changed'));
|
||||
assert.throws(() => f.b.copySession(f.human), refuses('copy-request-refused'));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
|
||||
test('CB3 a write binding reaches its repository by POST as the copy\'s account (a READ binding is refused above)', async (t) => {
|
||||
const f = await fixture(t, { services: WRITE, perm: 'write' });
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
f.api.rule((e) => e.method === 'POST' && e.path === '/api/v1/repos/owner/stack/issues', { status: 201, json: { number: 1 } });
|
||||
const r = await f.use(cap, { method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } });
|
||||
assert.deepEqual(r, { status: 201, json: { number: 1 } });
|
||||
const seen = f.api.requests.filter((e) => e.path === '/api/v1/repos/owner/stack/issues');
|
||||
assert.deepEqual(seen.map((e) => [e.method, e.as]), [['POST', f.login]]);
|
||||
});
|
||||
|
||||
test('CB3 a re-issue, a replaced slot or rewritten token bytes deny the next call with no request and no cached token', async (t) => {
|
||||
for (const [name, change, code] of [
|
||||
['re-issue', (f) => f.reissue(), 'restart-required'],
|
||||
['slot replaced', (f) => { writeFileSync(f.slot + '.n', 'x'.repeat(40), { mode: 0o600 }); renameSync(f.slot + '.n', f.slot); }, 'credential-unavailable'],
|
||||
['bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40))), 'credential-unavailable'],
|
||||
['slot removed', (f) => unlinkSync(f.slot), 'credential-unavailable'],
|
||||
['mode widened', (f) => chmodSync(f.slot, 0o644), 'credential-unavailable'],
|
||||
]) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
assert.equal((await f.use(cap, f.GET)).status, 200);
|
||||
const before = f.sent.length;
|
||||
await change(f);
|
||||
// The bus's session lookup refuses the run itself, before the gate's own check.
|
||||
assert.throws(() => f.b.copySession(cap), refuses(code));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses(code));
|
||||
// No new launch either.
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses(code));
|
||||
assert.deepEqual(f.sent.slice(before), []);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a revoke while a call is in flight denies that call\'s answer and the next call', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack');
|
||||
const pending = f.use(cap, f.GET);
|
||||
await h.arrived;
|
||||
await f.reissue();
|
||||
h.release();
|
||||
await assert.rejects(pending, refuses('restart-required'));
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('restart-required'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
|
||||
test('CB3 a grant change in flight fences the copy: nothing is admitted or sent until it ends, and an unknown outcome keeps the fence', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
const r2 = await f.threadFor('r2');
|
||||
const authority = f.gate.authority('demo', 'rev').authority;
|
||||
// The owner's re-issue holds at its live proof; the copy is fenced meanwhile.
|
||||
const h = f.hold((e) => e.path === '/api/v1/version');
|
||||
const issuing = f.reissue();
|
||||
await h.arrived;
|
||||
const before = f.sent.length;
|
||||
assert.equal(f.gate.revoking('demo', 'rev'), true);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoking'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoking'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoking'));
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2, copyAuthority: authority }), refuses('credential-revoking'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoking'));
|
||||
assert.equal(f.sent.length, before);
|
||||
h.release();
|
||||
await issuing;
|
||||
assert.equal(f.gate.revoking('demo', 'rev'), false);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required'));
|
||||
// A definite refusal lifts the fence; an unknown outcome, a system error or a codeless throw keeps it.
|
||||
await assert.rejects(f.gate.fence('demo', 'rev', () => { throw new BusError('credential-date'); }), refuses('credential-date'));
|
||||
assert.equal(f.gate.revoking('demo', 'rev'), false);
|
||||
for (const thrown of [new BusError('outcome-unknown'), Object.assign(new Error('disk'), { code: 'ENOSPC' }), new Error('bug')]) {
|
||||
const g = await fixture(t);
|
||||
await g.boot();
|
||||
await assert.rejects(g.gate.fence('demo', 'rev', () => { throw thrown; }));
|
||||
assert.equal(g.gate.revoking('demo', 'rev'), true, String(thrown.code));
|
||||
await assert.rejects(g.gate.prepare('demo', 'rev'), refuses('credential-revoking'));
|
||||
assert.deepEqual(g.sent.filter((x) => !x.startsWith('GET /api/v1/version') && !x.startsWith('GET /api/v1/token') && !x.startsWith('GET /api/v1/user')), []);
|
||||
}
|
||||
});
|
||||
|
||||
// Each change lands while prepare's live proof is at the server (between the authority before and
|
||||
// after), then again between prepare and bindLaunch, then between bindLaunch and the first use. None
|
||||
// may end in a request to the copy's repositories.
|
||||
const RACES = [
|
||||
['role definition changed', (f) => writeFileSync(join(f.rolesDir, 'research.md'), readFileSync(join(f.rolesDir, 'research.md'), 'utf8') + '\nChanged.\n')],
|
||||
['binding generation moved', (f) => f.edit((d) => { d.copyBindings.rev.generation += 1; })],
|
||||
['binding unbound', (f) => f.edit((d) => { d.copyBindings.rev.state = 'unbound'; })],
|
||||
['grant scopes narrowed', (f) => f.edit((d) => { d.copyBindings.rev.services.gitea.scopes = ['read:user']; })],
|
||||
['token bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40)))],
|
||||
['project remote moved', (f) => f.edit((d) => { d.projects.stack.remote = 'https://elsewhere.test/owner/stack'; })],
|
||||
['project removed', (f) => f.edit((d) => { delete d.projects.stack; })],
|
||||
['project excludes the copy', (f) => f.edit((d) => { d.projects.stack.agents = { ...d.projects.stack.agents, rev: { exclude: true } }; })],
|
||||
];
|
||||
|
||||
test('CB3 a change during prepare\'s live proof refuses the launch with no repository request', async (t) => {
|
||||
for (const [name, change] of RACES) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||
const preparing = f.gate.prepare('demo', 'rev');
|
||||
await h.arrived;
|
||||
await change(f);
|
||||
h.release();
|
||||
await assert.rejects(preparing, (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a change between prepare and bindLaunch, or between bindLaunch and use, refuses with no repository request', async (t) => {
|
||||
for (const [name, change] of RACES) {
|
||||
await t.test(`${name} before bind`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const thread = await f.threadFor('r1');
|
||||
const { authority } = await f.gate.prepare('demo', 'rev');
|
||||
await change(f);
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority }),
|
||||
(e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
await t.test(`${name} before use`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
await change(f);
|
||||
await assert.rejects(f.use(cap, f.GET), (e) => e instanceof BusError && /^(copy-|credential-|restart-|run-)/.test(e.code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The organization's registry entry rewritten in place (mode kept), as a lifecycle change would.
|
||||
const registryEdit = (f, fn) => {
|
||||
const file = join(f.root, 'org-runtime.json');
|
||||
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||
fn(d.orgs.demo, d);
|
||||
writeFileSync(file, JSON.stringify(d, null, 2) + '\n');
|
||||
};
|
||||
|
||||
test('CB3 an organization no longer active admits and sends nothing, with the binding and incarnation unchanged', async (t) => {
|
||||
for (const state of ['archived', 'deleting', 'reserved']) {
|
||||
await t.test(`${state} after launch`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap, authority } = await f.launch();
|
||||
registryEdit(f, (e) => { e.state = state; });
|
||||
const before = f.sent.length;
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoked'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
||||
assert.equal(f.sent.length, before);
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
await t.test('archived while prepare\'s proof is at the server', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||
const preparing = f.gate.prepare('demo', 'rev');
|
||||
await h.arrived;
|
||||
registryEdit(f, (e) => { e.state = 'archived'; });
|
||||
h.release();
|
||||
await assert.rejects(preparing, refuses('credential-revoked'));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
await t.test('archived before boot', async (t) => {
|
||||
const f = await fixture(t);
|
||||
registryEdit(f, (e) => { e.state = 'archived'; });
|
||||
await f.boot();
|
||||
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
|
||||
});
|
||||
await t.test('registry unsafe after launch', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
chmodSync(join(f.root, 'org-runtime.json'), 0o644);
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-unavailable'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
});
|
||||
|
||||
test('CB3 an organization recreated under the same id (a new incarnation) admits and sends nothing for the old copy', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap, authority } = await f.launch();
|
||||
registryEdit(f, (e) => { e.incarnation = 'f'.repeat(32); });
|
||||
const before = f.sent.length;
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
|
||||
// The project points at another repository on the same server: origin, binding and slot are as
|
||||
// boot proved them, only the repositories the launch authority covers move.
|
||||
const repointed = (f) => f.edit((d) => { d.projects.stack.remote = d.projects.stack.remote.replace('/owner/stack', '/owner/other'); });
|
||||
|
||||
test('CB3 a change while a call is in flight denies that call\'s answer, including one that moves only the launch authority', async (t) => {
|
||||
for (const [name, change, code] of [['repository repointed', repointed, 'credential-revoked'],
|
||||
...RACES.filter(([n]) => ['role definition changed', 'project excludes the copy'].includes(n)).map(([n, c]) => [n, c, null])]) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack');
|
||||
const pending = f.use(cap, f.GET);
|
||||
await h.arrived;
|
||||
await change(f);
|
||||
h.release();
|
||||
await assert.rejects(pending, code ? refuses(code) : (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a running copy session whose launch authority moved answers no agent verb', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
// Past the copy check to the holder check: the session holds no claim yet.
|
||||
await assert.rejects(f.call(cap, 'inbox'), refuses('not-holder'));
|
||||
repointed(f);
|
||||
assert.doesNotThrow(() => f.gate.authority('demo', 'rev'));
|
||||
await assert.rejects(f.call(cap, 'inbox'), refuses('copy-authority-changed'));
|
||||
});
|
||||
|
||||
test('CB3 a binding with no repository scope sends no repository request, read or write', async (t) => {
|
||||
const f = await fixture(t, { services: { gitea: { scopes: ['read:user'], projects: ['stack'] } } });
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), { rev: ['gitea'] });
|
||||
const { cap } = await f.launch();
|
||||
for (const method of ['GET', 'HEAD', 'POST'])
|
||||
await assert.rejects(f.use(cap, { method, path: '/api/v1/repos/owner/stack' }), refuses('copy-request-refused'), method);
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
|
||||
test('CB3 the organization file revision the launch resolved holds through prepare, final admission and bindLaunch', async (t) => {
|
||||
// Same document, other bytes: the launch authority is unchanged, only the revision moves.
|
||||
const rewrite = (f) => writeFileSync(f.file, JSON.stringify(JSON.parse(readFileSync(f.file, 'utf8'))) + '\n');
|
||||
await t.test('changed before prepare', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const revision = f.writer.read('demo').revision;
|
||||
rewrite(f);
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev', revision), refuses('organization-changed'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
await t.test('changed during prepare\'s proof', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const revision = f.writer.read('demo').revision;
|
||||
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||
const preparing = f.gate.prepare('demo', 'rev', revision);
|
||||
await h.arrived;
|
||||
rewrite(f);
|
||||
h.release();
|
||||
await assert.rejects(preparing, refuses('organization-changed'));
|
||||
});
|
||||
await t.test('changed after final admission, before bindLaunch', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const thread = await f.threadFor('r1');
|
||||
const revision = f.writer.read('demo').revision;
|
||||
const { authority } = await f.gate.prepare('demo', 'rev', revision);
|
||||
f.gate.admit('demo', 'rev', authority, revision);
|
||||
rewrite(f);
|
||||
assert.equal(f.gate.authority('demo', 'rev').authority, authority);
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority, revision), refuses('organization-changed'));
|
||||
const bind = (extra) => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority, ...extra });
|
||||
assert.throws(() => bind({ revision }), refuses('organization-changed'));
|
||||
assert.throws(() => bind({}), refuses('organization-changed'));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
// The same authority on the file as it stands now binds: the refusal above was the revision's.
|
||||
assert.ok(bind({ revision: f.writer.read('demo').revision }));
|
||||
});
|
||||
await t.test('a non-copy never carries a revision', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', revision: f.writer.read('demo').revision }), refuses('invalid-request'));
|
||||
});
|
||||
});
|
||||
|
||||
test('CB3 a copy past its rotation date refuses credential-expired at prepare and at the next call', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
f.advance(31);
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-expired'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-expired'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
|
||||
test('CB3 a boot that cannot verify proves nothing: the service unreachable, the token refused, the slot missing', async (t) => {
|
||||
for (const [name, setup, code] of [
|
||||
// The verifier answers an unreachable service as unverified (copy-grant.mjs verifyGiteaToken).
|
||||
['service down', (f) => f.api.rule(() => true, { drop: true }), 'credential-scope-unverified'],
|
||||
['token revoked at the service', (f) => f.api.rule((e) => e.path === '/api/v1/token', { status: 401, json: { message: 'no' } }), 'credential-scope-unverified'],
|
||||
['slot missing', (f) => unlinkSync(f.slot), 'credential-unavailable'],
|
||||
]) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
setup(f);
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses(code));
|
||||
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses(code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The launcher's host stops on a reply later than 10 s, so a slow service must not hold prepare past
|
||||
// its budget: the proof gets no answer, the launch is refused, and nothing more is sent.
|
||||
test('CB3: a service that does not answer within the budget fails prepare and boot closed, inside the budget', { timeout: 60000 }, async (t) => {
|
||||
await t.test('prepare', async (t) => {
|
||||
const f = await fixture(t, { budgets: { prepareMs: 400 } });
|
||||
await f.boot();
|
||||
const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true });
|
||||
t.after(unrule);
|
||||
const at = Date.now();
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified');
|
||||
assert.ok(Date.now() - at < 2500, `prepare took ${Date.now() - at} ms`);
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
await t.test('boot', async (t) => {
|
||||
const f = await fixture(t, { budgets: { bootMs: 400 } });
|
||||
const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true });
|
||||
t.after(unrule);
|
||||
const at = Date.now();
|
||||
await f.boot();
|
||||
assert.ok(Date.now() - at < 2500, `boot took ${Date.now() - at} ms`);
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified');
|
||||
});
|
||||
});
|
||||
@@ -367,7 +367,12 @@ export async function startHost({ boot, business, notifier = null, options = nul
|
||||
close(1);
|
||||
});
|
||||
|
||||
return Object.freeze({ path: ready.path, dataRoot, business, bindLaunch, op, beforeClose, close, done, pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
|
||||
// #1873 CB3: the copies the broker's boot proved live, names only; anything malformed counts as none.
|
||||
const copyBound = {};
|
||||
if (ready.copyBound && typeof ready.copyBound === "object" && !Array.isArray(ready.copyBound))
|
||||
for (const [instance, services] of Object.entries(ready.copyBound))
|
||||
if (Array.isArray(services) && services.every((s) => typeof s === "string")) copyBound[instance] = Object.freeze([...services]);
|
||||
return Object.freeze({ path: ready.path, dataRoot, business, bindLaunch, op, beforeClose, close, done, copyBound: Object.freeze(copyBound), pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
|
||||
}
|
||||
|
||||
// `mosaic bus stop`: SIGTERM to the recorded host, after checking that the
|
||||
|
||||
@@ -147,7 +147,7 @@ import { configDir, resolveInstance, systemVars } from "../../organization/src/i
|
||||
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 { copyOf, copyRefusal, loadWithCopies, projectRefusal, promoteCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
|
||||
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../organization/src/launch.mjs";
|
||||
@@ -264,6 +264,9 @@ export function createLauncher({
|
||||
if (testProxy !== null && system.proxy)
|
||||
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
|
||||
const dataRoot = system.dataRoot;
|
||||
// #1873 CB3: a copy the bus's boot proved live at its service launches as bound; the bus decides
|
||||
// which, and admits each launch again on its own launch authority. The rest stay as booted.
|
||||
if (copies && host.copyBound) copies = promoteCopies(copies, host.copyBound);
|
||||
function admission(generation = null) {
|
||||
try { return assertProcessing(dataRoot, generation); }
|
||||
catch (e) {
|
||||
@@ -827,7 +830,19 @@ export function createLauncher({
|
||||
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
|
||||
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
|
||||
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
|
||||
// #1873 CB3: a bound copy's launch authority, after the bus re-resolved its binding and proved the
|
||||
// slot live at the service. The final admission and bindLaunch each check it is still current.
|
||||
let copyAuthority = null;
|
||||
if (copyOf(b, instance)?.bound?.length) {
|
||||
// The bus's refusal is the launch's: its code goes to the log and the thread as given.
|
||||
let prepared;
|
||||
try { prepared = await host.op({ op: "copyLaunch", business: b.id, instance, revision }); }
|
||||
catch (e) { throw new Refusal(typeof e?.code === "string" && /^[a-z][a-z0-9-]*$/.test(e.code) ? e.code : "credential-unavailable", `${instance}'s bound services can't be proved live`); }
|
||||
if (typeof prepared?.authority !== "string") throw new Refusal("credential-unavailable", `${instance}'s bound services can't be proved live`);
|
||||
copyAuthority = prepared.authority;
|
||||
admission(admissionGeneration);
|
||||
}
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session, ...(copyAuthority ? { authority: copyAuthority } : {}) });
|
||||
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
@@ -856,7 +871,7 @@ export function createLauncher({
|
||||
admission(admissionGeneration);
|
||||
if (!outerStart) throw new Error("the session process exited at once");
|
||||
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(admittedProxy ? { modelKeyId } : {}) });
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(admittedProxy ? { modelKeyId } : {}), ...(copyAuthority ? { copyAuthority, revision } : {}) });
|
||||
bound = true;
|
||||
admission(admissionGeneration);
|
||||
entry = {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
|
||||
@@ -23,6 +23,11 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
import { fakeGiteaApi } from "../../bus/tests/fake-gitea-api.mjs";
|
||||
import { send as giteaSend } from "../../bus/src/gitea-grant.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { activateOrg, reserveOrg } from "../../organization/src/org-registry.mjs";
|
||||
import { copyBotLogin } from "../../organization/src/copy-bindings.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -81,10 +86,10 @@ function prepare(t, edit = (doc) => doc) {
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot, copyTransport = null, beforeBind = null } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
|
||||
const runtime = await startBroker(boot);
|
||||
const runtime = await startBroker({ ...boot, ...(copyTransport ? { copyTransport } : {}) });
|
||||
const dataRoot = boot.dataRoot;
|
||||
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
@@ -104,6 +109,7 @@ async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
copyLaunch: (m) => runtime.copyLaunch(m),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
@@ -123,8 +129,11 @@ async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
bindLaunch: beforeBind
|
||||
? async (r) => { await beforeBind(r); return refusing("launch bind", (x) => runtime.bindLaunch(x))(r); }
|
||||
: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
copyBound: runtime.copyBound,
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
@@ -593,3 +602,157 @@ test("A3: a second organization boots its own copies from the same root file and
|
||||
assert.equal(sessions(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(b.running("gate").length, 0);
|
||||
});
|
||||
|
||||
// #1873 CB3: the researcher copy (research, gitea alone) bound and granted on a fake Gitea, through
|
||||
// the real bus, its boot check and the launcher. `sent` is every request the bus sends to the service.
|
||||
async function boundCopy(t) {
|
||||
const api = await fakeGiteaApi(t, tmp(t, "cb3-api-"));
|
||||
const p = prepare(t, (doc) => {
|
||||
doc.projects.stack.remote = `${api.url}/owner/stack`;
|
||||
return doc;
|
||||
});
|
||||
const ctx = { configDir: join(p.root, "config"), dataRoot: p.f.dataRoot, instance: "test" };
|
||||
chmodSync(ctx.configDir, 0o700);
|
||||
await reserveOrg(ctx, "acme");
|
||||
await activateOrg(ctx, "acme");
|
||||
const sent = [];
|
||||
const copyTransport = { ca: api.ca, send: (req) => { sent.push(`${req.method} ${req.path.split("?")[0]}`); return giteaSend(req); } };
|
||||
const repo = api.addRepo("owner", "stack");
|
||||
const writer = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles });
|
||||
const rec = () => writer.read("acme").document.copyBindings?.researcher;
|
||||
const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
// Bind (the owner's console) and issue (the human), on a bus that is already up.
|
||||
const bind = async (s) => {
|
||||
const owner = s.runtime.broker.bindConsole({ business: "acme", human: p.f.doc.human, subject: { provider: "test", iss: "https://issuer.test", sub: "owner" } });
|
||||
await s.runtime.broker.request(owner, { verb: "hire.start.console", args: { op: "change", level: "root", name: "researcher", reason: "Bind the copy",
|
||||
binding: { services: { gitea: { scopes: ["read:repository", "read:user"], projects: ["stack"] } } } } });
|
||||
const r = rec();
|
||||
const login = copyBotLogin(r.orgIncarnation, r.copyId, "gitea");
|
||||
api.addUser(login);
|
||||
repo.collaborators.push(login); repo.permissions[login] = "read";
|
||||
await s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: api.addToken(login, "t1", ["read:repository", "read:user"]).sha1 });
|
||||
return login;
|
||||
};
|
||||
return { ...p, api, sent, copyTransport, rec, bind };
|
||||
}
|
||||
|
||||
const launchEvents = (s) => existsSync(launchLogFile(s.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)) : [];
|
||||
// One message to a new researcher thread, and the launcher's tick that takes it: the launch or the refusal.
|
||||
async function threadLaunch(s, name) {
|
||||
const project = s.call("project.list").find((x) => x.name === "stack work")?.id ?? s.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const thread = s.call("thread.create", { project, name, instance: "researcher" }).id;
|
||||
s.call("thread.send", { thread, body: "hello" });
|
||||
await s.launcher.tick();
|
||||
return until(() => launchEvents(s).find((e) => e.thread === thread && ["launch", "refused"].includes(e.event)));
|
||||
}
|
||||
|
||||
test("CB3: a binding approved after boot answers restart-required; after a restart the bus proves it live, and the launcher carries one launch authority to bind", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
const first = await start(t, p, { copyTransport: p.copyTransport });
|
||||
assert.deepEqual(first.runtime.copyBound, {});
|
||||
const login = await p.bind(first);
|
||||
assert.equal(p.rec().state, "bound");
|
||||
// The issue's own check (CB2) is the last request this bus sends for the copy.
|
||||
const issued = p.sent.length;
|
||||
assert.ok(issued > 0);
|
||||
// Bound and granted after this boot: restart-required at the verb, at the bus's prepare, and at its admission.
|
||||
assert.equal(refusedAt(first, "researcher").code, "restart-required");
|
||||
const revision = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||
// A copy launch carries the revision its bundle came from; without one it is not a request.
|
||||
await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher" }), (e) => e.code === "invalid-request");
|
||||
await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher", revision }), (e) => e.code === "restart-required");
|
||||
assert.equal(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision }).code, "copy-authority-changed");
|
||||
assert.deepEqual(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision, authority: "0".repeat(64) }), { ready: false, revision: null, code: "restart-required" });
|
||||
assert.equal(p.sent.length, issued);
|
||||
await first.close();
|
||||
|
||||
// The next boot proves the slot live as the copy's account, and the launcher sees it bound.
|
||||
const s = await start(t, p, { copyTransport: p.copyTransport });
|
||||
assert.deepEqual(s.runtime.copyBound, { researcher: ["gitea"] });
|
||||
assert.ok(p.sent.slice(issued).includes("GET /api/v1/token"));
|
||||
assert.ok(p.api.requests.filter((e) => e.path === "/api/v1/token").every((e) => e.as === login));
|
||||
// Final admission with an empty project list still needs the authority.
|
||||
const rev2 = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2 }).code, "copy-authority-changed");
|
||||
const { authority } = await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: rev2 });
|
||||
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2, authority }), { ready: true, revision: null, code: null });
|
||||
// Any other instance has no authority to carry.
|
||||
assert.deepEqual(await s.runtime.copyLaunch({ business: "acme", instance: "gate" }), { authority: null, services: [] });
|
||||
|
||||
const before = p.sent.length;
|
||||
const launched = await threadLaunch(s, "one");
|
||||
assert.equal(launched.event, "launch", JSON.stringify(launched));
|
||||
// The launcher's prepare proved the slot live again at launch.
|
||||
assert.ok(p.sent.slice(before).includes("GET /api/v1/token"));
|
||||
assert.equal(s.running("researcher").length, 1);
|
||||
assert.equal(p.sent.filter((x) => x.includes("/api/v1/repos/")).length, 0);
|
||||
});
|
||||
|
||||
// A reissue is a new generation: the slot the launch proved is not the slot it would use.
|
||||
const reissue = (p, s, login, name) => s.call("credentials.issue", { agent: "researcher", service: "gitea",
|
||||
date: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), token: p.api.addToken(login, name, ["read:repository", "read:user"]).sha1 });
|
||||
const restarted = async (t, p, opts = {}) => {
|
||||
const first = await start(t, p, { copyTransport: p.copyTransport });
|
||||
const login = await p.bind(first);
|
||||
await first.close();
|
||||
return { login, s: await start(t, p, { copyTransport: p.copyTransport, ...opts }) };
|
||||
};
|
||||
const repoCalls = (p) => p.sent.filter((x) => x.includes("/api/v1/repos/")).length;
|
||||
|
||||
test("CB3 race: a reissue while the launcher's live proof is in flight refuses the launch restart-required; nothing runs and no credential call is made", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
const { login, s } = await restarted(t, p);
|
||||
let go, arrived;
|
||||
const held = new Promise((r) => { arrived = r; });
|
||||
const unrule = p.api.rule((e) => e.path === "/api/v1/token", async () => { unrule(); arrived(); await new Promise((r) => { go = r; }); return { pass: true }; });
|
||||
const launching = threadLaunch(s, "raced");
|
||||
await held;
|
||||
await reissue(p, s, login, "t2");
|
||||
go();
|
||||
const event = await launching;
|
||||
assert.equal(event.event, "refused", JSON.stringify(event));
|
||||
assert.equal(event.code, "restart-required");
|
||||
assert.equal(s.running("researcher").length, 0);
|
||||
assert.equal(repoCalls(p), 0);
|
||||
});
|
||||
|
||||
test("CB3 race: a reissue between the launcher's admission and bind refuses at bind; the spawned run is stopped and makes no credential call", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
let s, login, raced = 0;
|
||||
({ login, s } = await restarted(t, p, { beforeBind: async (r) => { if (r.role === "researcher" && raced++ === 0) await reissue(p, s, login, "t3"); } }));
|
||||
const event = await threadLaunch(s, "bindrace");
|
||||
assert.equal(raced, 1);
|
||||
await until(() => s.running("researcher").length === 0);
|
||||
assert.equal(repoCalls(p), 0);
|
||||
const events = launchEvents(s).filter((e) => e.instance === "researcher");
|
||||
// The launcher reports any bind refusal as launch-failed, naming the bus's code.
|
||||
assert.equal(event.event, "refused", JSON.stringify(event));
|
||||
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: restart-required"), JSON.stringify(events));
|
||||
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
|
||||
});
|
||||
|
||||
test("CB3 race: an organization file change between the launcher's admission and bind refuses at bind, even with the copy authority unchanged; the spawned run is stopped and makes no credential call", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
let s, raced = 0, authority = null;
|
||||
// Same document, other bytes: the revision moves, the launch authority does not.
|
||||
({ s } = await restarted(t, p, { beforeBind: async (r) => {
|
||||
if (r.role !== "researcher" || raced++ !== 0) return;
|
||||
authority = r.copyAuthority;
|
||||
writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, "utf8"))) + "\n", { mode: 0o600 });
|
||||
} }));
|
||||
const event = await threadLaunch(s, "revrace");
|
||||
assert.equal(raced, 1);
|
||||
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||
await until(() => s.running("researcher").length === 0);
|
||||
assert.equal(repoCalls(p), 0);
|
||||
// The refusal was the revision's: on the file as it stands the same authority comes back.
|
||||
const fresh = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||
assert.equal((await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: fresh })).authority, authority);
|
||||
const events = launchEvents(s).filter((e) => e.instance === "researcher");
|
||||
assert.equal(event.event, "refused", JSON.stringify(event));
|
||||
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: organization-changed"), JSON.stringify(events));
|
||||
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
|
||||
});
|
||||
@@ -115,11 +115,41 @@ export function materializeCopies({ orgId, doc, root, rolesDir }) {
|
||||
export function copiesView(copies) {
|
||||
return Object.fromEntries(Object.entries(copies ?? {}).map(([instance, c]) => [instance, {
|
||||
name: c.name, level: c.level, project: c.project, definition: c.definition, state: c.state,
|
||||
...(c.missing ? { missing: [...c.missing] } : {}), ...(c.reason ? { reason: c.reason } : {}),
|
||||
...(c.missing ? { missing: [...c.missing] } : {}), ...(c.bound ? { bound: [...c.bound] } : {}), ...(c.reason ? { reason: c.reason } : {}),
|
||||
...(c.claims ? { claims: c.claims.map(({ name, project }) => ({ name, project })) } : {}),
|
||||
}]));
|
||||
}
|
||||
|
||||
// #1873 CB3: the services a copy may have bound for it, through the store the bus checks at boot
|
||||
// and on every launch. Nothing else is bound for a copy (a tracker bot isn't).
|
||||
export const COPY_BINDABLE = Object.freeze(["gitea"]);
|
||||
|
||||
// #1873 CB3: the snapshot with the copies the bus verified at boot made instances. `bound` is
|
||||
// { instance: [services] }, from copy-launch.mjs's boot check. A copy is promoted only when it is
|
||||
// unbound and its verified services are exactly the ones it misses, every one bindable: a partial
|
||||
// binding never joins, and an extra service is no promotion either. A promoted copy is
|
||||
// { ...copy, state: "instance", bound: [services] } with no `missing`. Anything else is unchanged.
|
||||
export function promoteCopies(snapshot, bound = {}) {
|
||||
if (!snapshot) return snapshot;
|
||||
const copies = { ...snapshot.copies };
|
||||
for (const [instance, services] of Object.entries(bound ?? {})) {
|
||||
const copy = Object.hasOwn(copies, instance) ? copies[instance] : null;
|
||||
if (copy?.state !== "unbound" || !Array.isArray(services)) continue;
|
||||
const want = [...copy.missing].sort();
|
||||
const got = [...new Set(services)].sort();
|
||||
if (want.length === 0 || want.join(",") !== got.join(",") || !want.every((s) => COPY_BINDABLE.includes(s))) continue;
|
||||
const { missing: _, ...rest } = copy;
|
||||
copies[instance] = { ...rest, state: "instance", bound: want };
|
||||
}
|
||||
return deepFreeze({ ...snapshot, copies });
|
||||
}
|
||||
|
||||
// A stand-in date for a promoted copy's credential reference. The reference exists only so the
|
||||
// organization's own checks see the services the role needs; withCopies takes it out again, so no
|
||||
// path, date or file of it reaches the business. The copy's real date is its binding's rotateBy,
|
||||
// which copy-launch.mjs checks.
|
||||
const BOUND_PLACEHOLDER = "2999-12-31";
|
||||
|
||||
// Every string the document or the copy holds as a var or a credential reference, longest first.
|
||||
function valuesOf(document, copy) {
|
||||
const out = new Set();
|
||||
@@ -236,7 +266,8 @@ export function withCopies(document, file, { rolesDir, snapshot, dataRoot = null
|
||||
if (copy.state !== "instance") continue;
|
||||
const refused = copy.project === null ? null : projectCopyRefusal(copy, document, levels);
|
||||
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
|
||||
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
|
||||
const base = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
|
||||
const entry = copy.bound?.length ? { ...base, credentials: Object.fromEntries(copy.bound.map((s) => [s, { store: true, rotateBy: BOUND_PLACEHOLDER }])) } : base;
|
||||
try {
|
||||
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir, dataRoot });
|
||||
join[instance] = entry;
|
||||
@@ -246,7 +277,15 @@ export function withCopies(document, file, { rolesDir, snapshot, dataRoot = null
|
||||
}
|
||||
}
|
||||
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir, dataRoot });
|
||||
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
|
||||
// #1873 CB3: a bound copy keeps no credential reference. Its token is read from the bus's copy
|
||||
// store only for one checked call (copy-launch.mjs), so no path, store file or token goes to a
|
||||
// launch, a sandbox or the bus's Credentials. `copyBound` names the services, never a value.
|
||||
const out = { ...business, roles: { ...business.roles } };
|
||||
for (const [instance, copy] of Object.entries(copies)) {
|
||||
if (!copy.bound?.length || !Object.hasOwn(join, instance) || !Object.hasOwn(out.roles, instance)) continue;
|
||||
out.roles[instance] = { ...out.roles[instance], credentials: {}, copyBound: [...copy.bound] };
|
||||
}
|
||||
return deepFreeze({ ...out, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
|
||||
}
|
||||
|
||||
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
|
||||
@@ -286,6 +325,9 @@ export function copyRefusal(business, instance) {
|
||||
if(business.roles?.[instance]?.unbound?.length)return {code:"credential-unbound",message:`${instance} has no binding for ${business.roles[instance].unbound.join(", ")}`};
|
||||
const copy = copyOf(business, instance);
|
||||
if (!copy || copy.state === "instance") return null;
|
||||
// #1873 CB3: a copy whose binding was approved after the bus started waits for a restart. The bus
|
||||
// answers restart-required for it at instance.launch and thread.create (copy-launch.mjs), before a
|
||||
// launch reaches the launcher, so this view's credential-unbound is only its last word.
|
||||
if (copy.state === "unbound") {
|
||||
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
|
||||
}
|
||||
|
||||
@@ -63,6 +63,23 @@ export function orgIncarnation(registry, id) {
|
||||
const value = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id]?.incarnation : undefined;
|
||||
return typeof value === 'string' && INCARNATION.test(value) ? value : null;
|
||||
}
|
||||
// #1873 CB3: one organization's registry entry read now, without the registry lock (the writer
|
||||
// publishes by rename, so a read sees one whole file). For the copy launch gate, which checks the
|
||||
// entry on every admission and use. null when no registry or no such entry; throws when unsafe.
|
||||
export function readOrgEntrySync(configDir, id) {
|
||||
const file = join(configDir, 'org-runtime.json');
|
||||
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);
|
||||
let fd;
|
||||
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
||||
catch (e) { if (e.code === 'ENOENT') return null; throw quiet(e, 'the registry file'); }
|
||||
try {
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
|
||||
const registry = validateRegistry(parseJsonFileText(readFileSync(fd, 'utf8'), file, 'registry file'), file);
|
||||
return Object.hasOwn(registry.orgs, id) ? { state: registry.orgs[id].state, incarnation: orgIncarnation(registry, id) } : null;
|
||||
} finally { closeSync(fd); }
|
||||
}
|
||||
export async function readRegistry({ configDir }) {
|
||||
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
|
||||
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
|
||||
|
||||
@@ -327,3 +327,34 @@ test("A3: a project narrowing a root copy narrows that copy; an organization wit
|
||||
assert.deepEqual(plain.roles.coder.vars["limits.tools"], ["read"]);
|
||||
assert.equal(Object.hasOwn(plain, "copies"), false);
|
||||
});
|
||||
|
||||
// #1873 CB3: a copy the bus verified at boot joins as an instance with no credential reference, only
|
||||
// the services it was bound for; a partial or extra binding never promotes.
|
||||
test("CB3: a verified copy joins with copyBound and no reference; a partial or extra binding stays unbound", async () => {
|
||||
const { promoteCopies } = await import("../src/agent-copies.mjs");
|
||||
const dir = tmp();
|
||||
const doc = org(dir, "acme");
|
||||
const r = root({ researcher: { definition: "research" }, reviewer2: { definition: "reviewer" } });
|
||||
const snapshot = materializeCopies({ orgId: "acme", doc, root: r, rolesDir });
|
||||
const promoted = promoteCopies(snapshot, { researcher: ["gitea"], reviewer2: ["gitea"], nobody: ["gitea"] });
|
||||
assert.equal(promoted.copies.researcher.state, "instance");
|
||||
assert.deepEqual(promoted.copies.researcher.bound, ["gitea"]);
|
||||
assert.equal(Object.hasOwn(promoted.copies.researcher, "missing"), false);
|
||||
// reviewer needs gitea and vikunja: gitea alone is a partial binding.
|
||||
assert.equal(promoted.copies.reviewer2.state, "unbound");
|
||||
assert.equal(promoteCopies(snapshot, { researcher: ["gitea", "vikunja"] }).copies.researcher.state, "unbound", "an extra service is no promotion");
|
||||
assert.equal(promoteCopies(snapshot, { reviewer2: ["gitea", "vikunja"] }).copies.reviewer2.state, "unbound", "vikunja has no copy verifier");
|
||||
assert.ok(Object.isFrozen(promoted.copies.researcher));
|
||||
assert.equal(snapshot.copies.researcher.state, "unbound", "the boot snapshot is unchanged");
|
||||
const business = withCopies(doc, businessFilePath("acme", dir), { rolesDir, snapshot: promoted });
|
||||
assert.deepEqual(business.roles.researcher.credentials, {});
|
||||
assert.deepEqual(business.roles.researcher.copyBound, ["gitea"]);
|
||||
assert.equal(business.copies.researcher.state, "instance");
|
||||
assert.deepEqual(business.copies.researcher.bound, ["gitea"]);
|
||||
assert.equal(copyRefusal(business, "researcher"), null);
|
||||
assert.equal(copyRefusal(business, "reviewer2").code, "credential-unbound");
|
||||
assert.equal(JSON.stringify(business).includes(join("secrets", "agents")), false, "no store path reaches the business");
|
||||
assert.equal(JSON.stringify(business.roles.researcher).includes(dir), false);
|
||||
const resolved = resolveInstance({ system: systemFor(dir), business, instance: "researcher" });
|
||||
assert.deepEqual(resolved.credentials, {});
|
||||
});
|
||||
Reference in new issue
Block a user