Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5320e3d2c0 | ||
|
|
16cdf5cdc1 | ||
|
|
1a45a7274b | ||
|
|
17eecb2d65 | ||
|
|
8fec494694 | ||
|
|
4a122699a0 | ||
|
|
f382699d47 |
No files matched your search
@@ -6292,6 +6292,8 @@ export class Broker {
|
||||
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
|
||||
} catch (e) {
|
||||
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
|
||||
// org-defscan (#1873): the issue's definition check; its reason names where, never the value.
|
||||
if (e instanceof BusError && ['definition-holds-credential', 'definition-scan-unreadable'].includes(e.code)) throw e;
|
||||
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
|
||||
if (e.name === 'BusinessError') fail('invalid-request');
|
||||
throw new BusError('outcome-unknown');
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BusError } from './broker.mjs';
|
||||
import { isHostKind } from '../../organization/src/vocabulary.mjs';
|
||||
import { scanOrganizations } from './definition-scan.mjs';
|
||||
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
|
||||
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
|
||||
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
|
||||
@@ -10,7 +11,10 @@ const dateKey = (service) => (isHostKind(service) ? 'rotateBy' : DATE_KEY[servic
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
export function credentialVerbs({ credentials, writer }) {
|
||||
// `organizations` lists every business this bus serves; issue checks each one's definitions. Without
|
||||
// it, only the issuing business's. `boot` is the bus's boot copy snapshot's definitions, { org,
|
||||
// definitions }, checked as well.
|
||||
export function credentialVerbs({ credentials, writer, organizations = null, boot = null }) {
|
||||
const state = (instance, service) =>
|
||||
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
|
||||
return (id, verb, args, actor) => {
|
||||
@@ -27,8 +31,13 @@ export function credentialVerbs({ credentials, writer }) {
|
||||
const next = structuredClone(document);
|
||||
next.roles[agent].credentials[service] = { store: true, [dateKey(service)]: date };
|
||||
const detail = { verb: kind, agent, kind: [service], date };
|
||||
const status = credentials.issue(instance, service, token, date, () =>
|
||||
writer.write(id, next, { revision, actor, detail }),
|
||||
// org-defscan (#1873): the value must not already sit in any organization's definitions. The
|
||||
// check runs inside issue, before the store, the file or memory changes, on this bus loop, so
|
||||
// no launch admission can run between the check and the value becoming held.
|
||||
const status = credentials.issue(
|
||||
instance, service, token, date,
|
||||
() => writer.write(id, next, { revision, actor, detail }),
|
||||
(clean) => scanOrganizations({ organizations: organizations?.() ?? [id], own: { id, document }, readRaw: (b) => writer.readRaw(b), boot }, clean),
|
||||
);
|
||||
// Row 210 (design D9): a host key's public line and fingerprint, for ops-01's authorized_keys.
|
||||
const host = status.fingerprint ? { publicKey: status.publicKey, fingerprint: status.fingerprint } : {};
|
||||
|
||||
@@ -87,6 +87,19 @@ export function parseHostKey(text) {
|
||||
}
|
||||
return { text: lines.join('\n') + '\n', publicLine, fingerprint, seed, needles };
|
||||
}
|
||||
// Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token
|
||||
// or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is
|
||||
// about to hold.
|
||||
function assertNotHeld(value, entries) {
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
for (const { token, model } of entries)
|
||||
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
|
||||
}
|
||||
// D8: refuses credential-leak when the value's JSON text holds any part of the key.
|
||||
export function assertCleanKey(value, key) {
|
||||
let encoded;
|
||||
@@ -458,10 +471,13 @@ export class Credentials {
|
||||
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
|
||||
// `commit` (the business file's reference becomes store), then memory. The token comes from the
|
||||
// caller's stdin and is never logged. Returns the entry's new status.
|
||||
issue(instance, service, token, date, commit = () => {}) {
|
||||
// org-defscan (#1873): `check` runs before anything changes. It gets an assertClean for this value
|
||||
// alone, the test assertClean makes once the value is held; a throw leaves the store as it was.
|
||||
issue(instance, service, token, date, commit = () => {}, check = () => {}) {
|
||||
this.#known(instance, service);
|
||||
if (!day(date)) deny('credential-date');
|
||||
const value = Credentials.#format(service, token);
|
||||
check((v) => assertNotHeld(v, [value]));
|
||||
this.#invalidateModelProof(instance, service);
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
@@ -600,14 +616,7 @@ export class Credentials {
|
||||
return this.use(session.business + '/' + session.role, service, operation, options);
|
||||
}
|
||||
assertClean(value) {
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
} catch {
|
||||
deny('invalid-request');
|
||||
}
|
||||
for (const { token, model } of this.#tokens.values())
|
||||
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
|
||||
assertNotHeld(value, this.#tokens.values());
|
||||
}
|
||||
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
|
||||
busy(instance, service) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// #1873 T1 follow-up (org-defscan): the live credential store's held-secret check on an org
|
||||
// definition's text. A definition is role text written into the organization file; launch hands its
|
||||
// instructions to an agent as the contract. A file edited by hand, or while the bus was down, never
|
||||
// went through the writer's check, so bus start and every launch run this one.
|
||||
//
|
||||
// The answer names where a held credential sits, never the text there. A key that itself holds a
|
||||
// credential is named by its parent ("personality key"). Not imported by broker.mjs (the sandbox
|
||||
// loads that file); runtime.mjs and credential-verbs.mjs are its callers.
|
||||
import { BusError } from './broker.mjs';
|
||||
|
||||
export const DEFINITION_HOLDS_CREDENTIAL = 'definition-holds-credential';
|
||||
// An organization file credentials.issue could not read to check: the issue refuses.
|
||||
export const DEFINITION_SCAN_UNREADABLE = 'definition-scan-unreadable';
|
||||
// A definition name the scan quotes: the organization loader's id pattern. Anything else is "*".
|
||||
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
||||
// A path segment the scan quotes; anything else becomes "*" (the hit is still reported).
|
||||
const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
|
||||
const MAX_DEPTH = 32;
|
||||
|
||||
const isClean = (assertClean, value) => {
|
||||
try {
|
||||
assertClean(value);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// The first place in `entry` whose text the store holds, as a path ("instructions",
|
||||
// "personality.traits[1]", "role key"), or null when the entry is clean. A hit the walk can't place
|
||||
// (a value JSON.stringify sees and the walk doesn't) is "(entry)": the check fails closed.
|
||||
export function heldPath(entry, assertClean) {
|
||||
if (isClean(assertClean, entry)) return null;
|
||||
const join = (path, key) => {
|
||||
const segment = SEGMENT.test(key) && isClean(assertClean, key) ? key : '*';
|
||||
return path ? `${path}.${segment}` : segment;
|
||||
};
|
||||
const walk = (value, path, depth) => {
|
||||
if (typeof value === 'string') return isClean(assertClean, value) ? null : path || '(entry)';
|
||||
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return null;
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const hit = walk(value[i], `${path}[${i}]`, depth + 1);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (!isClean(assertClean, key)) return path ? `${path} key` : 'key';
|
||||
const hit = walk(child, join(path, key), depth + 1);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
return walk(entry, '', 0) ?? '(entry)';
|
||||
}
|
||||
|
||||
// The refusal for one definition. `name` is the definition's name without "org:". At issue time
|
||||
// (`organization` given) it names the organization too, and the value is the one being issued.
|
||||
export function definitionRefusal(name, path, { organization, boot = false } = {}) {
|
||||
const quoted = typeof name === 'string' && NAME.test(name) ? name : '*';
|
||||
const where = `${boot ? 'boot copy ' : ''}${name === null ? 'definitions' : `definitions.${quoted} ${path}`}`;
|
||||
if (organization === undefined) return new BusError(DEFINITION_HOLDS_CREDENTIAL, `${where} holds a credential this bus holds`);
|
||||
const org = typeof organization === 'string' && NAME.test(organization) ? organization : '*';
|
||||
return new BusError(DEFINITION_HOLDS_CREDENTIAL, `organization ${org} ${where} holds the credential being issued`);
|
||||
}
|
||||
|
||||
// The first entry of a `definitions` map that holds a credential, as { name, path }, or null.
|
||||
// `prefix` is stripped from each name ("org:" for a business's resolved definitions). A map that is
|
||||
// not an object is { name: null } when its text holds one.
|
||||
export function heldDefinition(definitions, assertClean, { prefix = '' } = {}) {
|
||||
if (definitions === undefined) return null; // an organization with no definitions key
|
||||
if (definitions === null || typeof definitions !== 'object' || Array.isArray(definitions))
|
||||
return isClean(assertClean, definitions) ? null : { name: null, path: null };
|
||||
for (const [key, entry] of Object.entries(definitions)) {
|
||||
if (prefix && !key.startsWith(prefix)) continue;
|
||||
if (!isClean(assertClean, key)) return { name: '*', path: 'key' };
|
||||
const path = heldPath(entry, assertClean);
|
||||
if (path !== null) return { name: key.slice(prefix.length), path };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Every entry of a `definitions` map; throws the refusal for the first that holds a credential.
|
||||
export function scanDefinitions(definitions, assertClean, options = {}) {
|
||||
const hit = heldDefinition(definitions, assertClean, options);
|
||||
if (hit) throw definitionRefusal(hit.name, hit.path, options);
|
||||
}
|
||||
|
||||
// org-defscan (#1873, Lead ruling on the re-check P2): credentials.issue and rotate refuse a value
|
||||
// that already sits in any organization's definitions, so no definition text becomes a held value
|
||||
// after its launch checks passed. `organizations` is every business this bus serves; `own` is the
|
||||
// issuing one's document, already read. Any other file is read raw, as bus start reads it, so a
|
||||
// definition no instance names is covered; a file that can't be read refuses. `boot` is the bus's boot
|
||||
// copy snapshot ({ org, definitions }, #1873 A3/A4): the root agents file's definitions merged with the
|
||||
// organization's at boot. Every reload re-applies it, so its text is live for the bus's lifetime.
|
||||
export function scanOrganizations({ organizations, own, readRaw, boot = null }, assertClean) {
|
||||
for (const organization of organizations) {
|
||||
let definitions;
|
||||
if (organization === own.id) definitions = own.document.definitions;
|
||||
else {
|
||||
let raw;
|
||||
try {
|
||||
raw = readRaw(organization);
|
||||
} catch {
|
||||
raw = { readable: false };
|
||||
}
|
||||
if (raw?.readable !== true) throw new BusError(DEFINITION_SCAN_UNREADABLE);
|
||||
definitions = raw.document.definitions;
|
||||
}
|
||||
scanDefinitions(definitions, assertClean, { organization });
|
||||
}
|
||||
if (boot?.definitions !== undefined) scanDefinitions(boot.definitions, assertClean, { organization: boot.org, boot: true });
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// never command arguments, stdout or service-token-bearing environment variables.
|
||||
import { startBroker } from './runtime.mjs';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { DEFINITION_HOLDS_CREDENTIAL } from './definition-scan.mjs';
|
||||
const LAUNCH_OPS = new Set([
|
||||
'identity',
|
||||
'authorizeLaunch',
|
||||
@@ -9,6 +10,7 @@ const LAUNCH_OPS = new Set([
|
||||
'endLaunch',
|
||||
'recoverLaunch',
|
||||
'credentialStatus',
|
||||
'definitionScan',
|
||||
'modelPreflight',
|
||||
'modelAdmission',
|
||||
'modelStall',
|
||||
@@ -90,6 +92,8 @@ function launchOp(m) {
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
// #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition.
|
||||
if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry });
|
||||
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
|
||||
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
|
||||
if (m.op === 'modelStall') return runtime.modelStall(m);
|
||||
@@ -163,7 +167,9 @@ if (!process.send) {
|
||||
}
|
||||
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
|
||||
} catch (e) {
|
||||
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }), () =>
|
||||
// org-defscan: a held credential in a definition says which definition and key, never the text.
|
||||
const reason = e instanceof BusError && e.code === DEFINITION_HOLDS_CREDENTIAL && typeof e.reason === 'string' ? { reason: e.reason } : {};
|
||||
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused', ...reason }), () =>
|
||||
close(2),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,16 @@ group(
|
||||
"Role configuration and protected instance controls",
|
||||
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
|
||||
);
|
||||
group(
|
||||
"definition-holds-credential",
|
||||
"R1: no held credential in an organization definition's role text",
|
||||
"Ask the organization owner to remove the credential from the named definition and key, then restart the bus, relaunch the agent or issue the credential again. For a hire, deny it and have the chief raise it again without that text. Do not copy the text anywhere to inspect it."
|
||||
);
|
||||
group(
|
||||
"definition-scan-unreadable",
|
||||
"R1/R3: a credential is issued only after every organization's definitions are checked",
|
||||
"Ask the instance owner to repair the organization file the bus cannot read, then issue the credential again. Nothing was stored."
|
||||
);
|
||||
group(
|
||||
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
|
||||
"Role task ownership, allowed fields and current task state",
|
||||
|
||||
@@ -38,6 +38,8 @@ import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } fr
|
||||
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
|
||||
import { ORG_PREFIX, orgDefinitionName } from '../../organization/src/definitions.mjs';
|
||||
import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs';
|
||||
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
@@ -387,6 +389,18 @@ export async function startBroker({
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
|
||||
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
|
||||
// #1873 T1 follow-up (org-defscan): no organization definition's text holds a credential this store
|
||||
// holds. The boot copy's org: definitions first, then every entry of the file itself, named or not.
|
||||
// A hit refuses the start; the refusal names the definition and key, never the text.
|
||||
const clean = value => credentials.assertClean(value);
|
||||
for (const [id, b] of Object.entries(businesses)) {
|
||||
scanDefinitions(b.definitions, clean, { prefix: ORG_PREFIX });
|
||||
if (!organization) continue;
|
||||
const raw = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }).readRaw(id);
|
||||
if (raw.readable) scanDefinitions(raw.document.definitions, clean);
|
||||
}
|
||||
// The boot copy snapshot's definitions (the root agents file's, A4): every reload re-applies them.
|
||||
if (copies?.definitions !== undefined) scanDefinitions(copies.definitions, clean, { boot: true });
|
||||
if (proxy) {
|
||||
const inventory = modelRunInventory(dataRoot, modelRuns, credentials);
|
||||
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: org ? instanceConfigDir : organization.dir, org: org?.id ?? null, lock: proxyLock,
|
||||
@@ -655,9 +669,19 @@ export async function startBroker({
|
||||
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
|
||||
if (instance !== undefined) {
|
||||
let now;
|
||||
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
|
||||
if (now === undefined) return no('organization-unreadable');
|
||||
if (now !== launched) return no('organization-changed');
|
||||
try { now = writer?.read(id); } catch { return no('organization-unreadable'); }
|
||||
if (now?.revision === undefined) return no('organization-unreadable');
|
||||
if (now.revision !== launched) return no('organization-changed');
|
||||
// org-defscan (#1873): the launch boundary. A credential issued since the launcher's scan
|
||||
// runs on this same loop, so the org: definition the instance names is checked again here,
|
||||
// in the reply the launcher spawns after with no further wait.
|
||||
const name = orgDefinitionName(now.document.roles?.[instance]?.definition);
|
||||
if (name !== null) {
|
||||
const clean = value => credentials.assertClean(value);
|
||||
let held;
|
||||
try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; }
|
||||
if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL);
|
||||
}
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
@@ -868,7 +892,7 @@ export async function startBroker({
|
||||
// Row 194 (D2.3): the role files as they stand, for the hire checks.
|
||||
hires: organization ? (()=>{
|
||||
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
|
||||
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
|
||||
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
|
||||
return {...service,apply:async(id,decision,caller)=>{
|
||||
const proposal=service.read(decision);
|
||||
const publish=async()=>{
|
||||
@@ -900,7 +924,8 @@ export async function startBroker({
|
||||
return settings;
|
||||
} : null,
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }),
|
||||
organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null })
|
||||
: null,
|
||||
// Row 65: a new launch binds only on a harness its business switches on, read from the file
|
||||
// as it stands now; an unreadable file switches none on.
|
||||
@@ -1148,6 +1173,20 @@ export async function startBroker({
|
||||
.filter((c) => c.instance === business + '/' + role)
|
||||
.map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) }));
|
||||
}
|
||||
// #1873 T1 follow-up (org-defscan): the launcher's check of the org: definition it is about to
|
||||
// launch, as it read it from the organization file. Answers where a held credential sits, or null;
|
||||
// never the text. A malformed ask refuses, so the launch refuses.
|
||||
function definitionScan({ business, definition, entry } = {}) {
|
||||
if (typeof business !== 'string' || !Object.hasOwn(businesses, business) || typeof definition !== 'string'
|
||||
|| !definition.startsWith(ORG_PREFIX) || definition.length > 128 || entry === null || typeof entry !== 'object' || Array.isArray(entry))
|
||||
throw new BusError('invalid-request');
|
||||
let size;
|
||||
try { size = JSON.stringify(entry).length; } catch { throw new BusError('invalid-request'); }
|
||||
if (size > 4 * 1024 * 1024) throw new BusError('invalid-request');
|
||||
const clean = value => credentials.assertClean(value);
|
||||
try { clean(definition); } catch { return { held: 'name' }; }
|
||||
return { held: heldPath(entry, clean) };
|
||||
}
|
||||
// C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass
|
||||
// still running skips the tick. Only codes are logged, never git's output.
|
||||
const timed = Object.values(businesses).flatMap(business => Object.entries(business.projects ?? {})
|
||||
@@ -1321,6 +1360,7 @@ export async function startBroker({
|
||||
sessionClearStart: (r) => broker.sessionClearStart(r),
|
||||
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
|
||||
credentialStatus,
|
||||
definitionScan,
|
||||
launches: bound,
|
||||
readers: readCaps,
|
||||
async close() {
|
||||
|
||||
@@ -0,0 +1,336 @@
|
||||
// #1873 T1 follow-up (org-defscan): bus start runs the live credential store's held-secret check on
|
||||
// every organization definition, and the launch check asks the bus the same question. A hit names
|
||||
// the definition and the key, never the text there or the credential's name.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError } from '../src/broker.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { DEFINITION_HOLDS_CREDENTIAL, DEFINITION_SCAN_UNREADABLE, heldPath, scanDefinitions, scanOrganizations } from '../src/definition-scan.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
|
||||
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
|
||||
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
// The fixture bus holds SENTINEL as coder's gitea token.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const UNHELD = 'sk-test-UNHELD-LOOKS-LIKE-A-TOKEN-0000';
|
||||
|
||||
function files(dir) {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
|
||||
// An organization whose coder names org:builder, a copy of the coder template; `edit` changes the
|
||||
// builder entry before the file is written. `held` is the coder's gitea token at start.
|
||||
function org(t, edit = (entry) => entry, { held = SENTINEL } = {}) {
|
||||
const root = tmp(t, 'defscan-');
|
||||
const builder = edit(entryFromTemplate(readTemplate(REPO_ROLES, 'coder')));
|
||||
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, held);
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
const boot = () => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
||||
const start = (b = boot()) => startBroker({ dataRoot: f.dataRoot, businesses: b.businesses, organization: b.organization });
|
||||
return { root, f, file, boot, start };
|
||||
}
|
||||
|
||||
function refusedWithout(e, reason) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
|
||||
assert.equal(e.reason, reason);
|
||||
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
|
||||
assert.ok(!text.includes(SENTINEL), 'the refusal never carries the value');
|
||||
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The start's outcome: the refusal, or "started" after closing a bus that should not have started.
|
||||
const outcome = (start) => start.then(async (bus) => { await bus.close(); return 'started'; }, (e) => e);
|
||||
|
||||
function nothingWritten(root) {
|
||||
for (const path of files(root)) {
|
||||
if (path.endsWith('.token') || path.endsWith('acme.json')) continue;
|
||||
assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
}
|
||||
}
|
||||
|
||||
test('org-defscan: the refusal code is in the TI refusal table with a next step', () => {
|
||||
const entry = REFUSALS[DEFINITION_HOLDS_CREDENTIAL];
|
||||
assert.ok(entry, 'definition-holds-credential is a known refusal');
|
||||
assert.match(entry.rail, /^R\d+:/);
|
||||
assert.match(entry.nextStep, /definition/);
|
||||
assert.match(entry.nextStep, /issue the credential again/);
|
||||
assert.match(entry.nextStep, /For a hire, deny it/);
|
||||
// The issue-time check's fail-closed code for an organization file it cannot read.
|
||||
const unreadable = REFUSALS[DEFINITION_SCAN_UNREADABLE];
|
||||
assert.ok(unreadable, 'definition-scan-unreadable is a known refusal');
|
||||
assert.match(unreadable.rail, /^R\d+/);
|
||||
assert.match(unreadable.nextStep, /organization file/);
|
||||
assert.equal(new BusError(DEFINITION_SCAN_UNREADABLE).nextStep, unreadable.nextStep);
|
||||
});
|
||||
|
||||
test('org-defscan: a held credential in a definition\'s instructions refuses bus start, naming definition and key only', async (t) => {
|
||||
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nThe forge token is ${SENTINEL}.\n` }));
|
||||
refusedWithout(await outcome(o.start()), 'definitions.builder instructions holds a credential this bus holds');
|
||||
nothingWritten(o.root);
|
||||
});
|
||||
|
||||
test('org-defscan: a held credential in a personality field refuses bus start the same way', async (t) => {
|
||||
const o = org(t, (entry) => ({ ...entry, personality: { summary: `Careful. Key ${SENTINEL}.`, traits: ['plain'] } }));
|
||||
refusedWithout(await outcome(o.start()), 'definitions.builder personality.summary holds a credential this bus holds');
|
||||
const p = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `x${SENTINEL}`] } }));
|
||||
refusedWithout(await outcome(p.start()), 'definitions.builder personality.traits[1] holds a credential this bus holds');
|
||||
nothingWritten(o.root);
|
||||
nothingWritten(p.root);
|
||||
});
|
||||
|
||||
test('org-defscan: a definition in the file but not in the boot copy is checked too', async (t) => {
|
||||
const o = org(t);
|
||||
const boot = o.boot();
|
||||
// Written after the load and before the bus starts: no instance names it, so only the raw read sees it.
|
||||
const doc = JSON.parse(readFileSync(o.file, 'utf8'));
|
||||
doc.definitions.spare = { ...doc.definitions.builder, instructions: `Spare: ${SENTINEL}\n` };
|
||||
writeJson(o.file, doc);
|
||||
refusedWithout(await outcome(o.start(boot)), 'definitions.spare instructions holds a credential this bus holds');
|
||||
});
|
||||
|
||||
test('org-defscan: a clean file starts, and token-looking text the store does not hold starts', async (t) => {
|
||||
for (const edit of [(entry) => entry, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample: ${UNHELD}\n`, personality: { summary: `Says ${UNHELD}.` } })]) {
|
||||
const o = org(t, edit);
|
||||
const bus = await o.start();
|
||||
try {
|
||||
// The launch check's question: a clean entry answers null, a held one names the key only.
|
||||
const entry = o.boot().businesses.acme.definitions['org:builder'];
|
||||
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
|
||||
const hit = bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { ...entry, instructions: `x ${SENTINEL}` } });
|
||||
assert.deepEqual(hit, { held: 'instructions' });
|
||||
// A malformed question refuses rather than answering clean.
|
||||
for (const bad of [{}, { business: 'acme', definition: 'builder', entry }, { business: 'other', definition: 'org:builder', entry },
|
||||
{ business: 'acme', definition: 'org:builder', entry: null }, { business: 'acme', definition: 'org:builder', entry: [entry] }])
|
||||
assert.throws(() => bus.definitionScan(bad), (e) => e.code === 'invalid-request');
|
||||
} finally {
|
||||
await bus.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// The coder's gitea credential as the human verb issues or rotates it.
|
||||
function verbs(o, bus) {
|
||||
const human = bus.broker.bindHuman({ business: 'acme', human: o.f.doc.human, via: 'cli', outsideAgent: true });
|
||||
return {
|
||||
issue: (token) => bus.broker.request(human, { verb: 'credentials.issue', args: { agent: 'coder', service: 'gitea', token, date: '2099-01-01' } }),
|
||||
revoke: () => bus.broker.request(human, { verb: 'credentials.revoke', args: { agent: 'coder', service: 'gitea', reason: 'test' } }),
|
||||
};
|
||||
}
|
||||
// Whether the bus holds `value` now, asked the way the launch check asks.
|
||||
const holds = (bus, value) => bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { instructions: `x ${value}` } }).held !== null;
|
||||
const state = (bus) => bus.credentials.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea')?.state;
|
||||
|
||||
function issueRefused(e, value, reason) {
|
||||
assert.ok(e instanceof BusError, String(e));
|
||||
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
|
||||
assert.equal(e.reason, reason);
|
||||
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
|
||||
assert.ok(!text.includes(value), 'the refusal never carries the value');
|
||||
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// No file under the root holds `value` except the organization file it was written into and that
|
||||
// file's own history copies.
|
||||
function onlyInOrganization(root, value) {
|
||||
for (const path of files(root))
|
||||
if (!path.endsWith('acme.json') && !path.includes('/businesses/.history/acme/')) assert.ok(!readFileSync(path).includes(value), path);
|
||||
}
|
||||
|
||||
test('org-defscan: a credential issued between the final launch admission and spawn is refused at issue; the store stays as it was', async (t) => {
|
||||
// The definition carries text that is not held at start. After the launch's final admission answers
|
||||
// ready, the human rotates the coder's token to that text with the same date. On the bus before this
|
||||
// fix the rotate succeeded and the agent spawned with a held value in its contract.
|
||||
const FIRST = 'fake_first_token_0123456789';
|
||||
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${SENTINEL}\n` }), { held: 'fake_boot_token_5555555555' });
|
||||
const boot = o.boot();
|
||||
const bus = await o.start(boot);
|
||||
t.after(() => bus.close());
|
||||
const v = verbs(o, bus);
|
||||
// A clean issue still works.
|
||||
assert.equal(v.issue(FIRST).state, 'valid');
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const revision = writer.read('acme').revision;
|
||||
const changes = files(o.root).filter((p) => p.endsWith('changes.jsonl')).map((p) => [p, readFileSync(p, 'utf8')]);
|
||||
const entry = o.boot().businesses.acme.definitions['org:builder'];
|
||||
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
|
||||
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
|
||||
assert.equal(final().ready, true);
|
||||
// Between the admission's answer and the spawn: the rotate.
|
||||
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder instructions holds the credential being issued'));
|
||||
// Nothing changed: the old token is held, the new one is not, the file and its receipts are as they were.
|
||||
assert.ok(holds(bus, FIRST) && !holds(bus, SENTINEL));
|
||||
assert.equal(state(bus), 'valid');
|
||||
assert.equal(writer.read('acme').revision, revision);
|
||||
for (const [p, text] of changes) assert.equal(readFileSync(p, 'utf8'), text, p);
|
||||
onlyInOrganization(o.root, SENTINEL);
|
||||
assert.equal(final().ready, true, 'the contract the agent gets holds no held value');
|
||||
});
|
||||
|
||||
test('org-defscan: issue over a revoked entry refuses the same way for a personality field, and a clean issue then works', async (t) => {
|
||||
const o = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `uses ${SENTINEL}`] } }), { held: 'fake_boot_token_5555555555' });
|
||||
const bus = await o.start();
|
||||
t.after(() => bus.close());
|
||||
const v = verbs(o, bus);
|
||||
v.revoke();
|
||||
assert.equal(state(bus), 'revoked');
|
||||
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder personality.traits[1] holds the credential being issued'));
|
||||
assert.equal(state(bus), 'revoked');
|
||||
assert.ok(!holds(bus, SENTINEL));
|
||||
onlyInOrganization(o.root, SENTINEL);
|
||||
const issued = v.issue('fake_clean_token_4444444444');
|
||||
assert.deepEqual([issued.verb, issued.state], ['issue', 'valid']);
|
||||
});
|
||||
|
||||
test('org-defscan: a value held without the issue verb still refuses the launch at its final admission, even when the file revision is unchanged', async (t) => {
|
||||
// Defence behind the issue check: the store comes to hold text already in the definition by a path
|
||||
// other than the human verb, which leaves the organization file's bytes, so its revision, as they were.
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${ROTATED}\n` }));
|
||||
const boot = o.boot();
|
||||
const bus = await o.start(boot);
|
||||
t.after(() => bus.close());
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const revision = writer.read('acme').revision;
|
||||
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
|
||||
assert.equal(final().ready, true);
|
||||
bus.credentials.issue('acme/coder', 'gitea', ROTATED, '2099-01-01');
|
||||
assert.equal(writer.read('acme').revision, revision, 'the store change left the file as it was');
|
||||
assert.deepEqual(final(), { ready: false, revision: null, code: DEFINITION_HOLDS_CREDENTIAL });
|
||||
// An instance on a shipped template is not asked about a definition.
|
||||
assert.equal(bus.admission({ business: 'acme', projects: [], instance: 'reviewer', revision }).ready, true);
|
||||
});
|
||||
|
||||
test('org-defscan: issue checks the other organizations the bus serves, and refuses when one\'s file cannot be read', async (t) => {
|
||||
// The bus serves acme and beta. beta's file has a definition holding text acme's coder then tries to
|
||||
// be issued; later beta's file stops being JSON. Neither issue changes acme's store or file.
|
||||
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
|
||||
const boot = o.boot();
|
||||
const betaFile = join(o.root, 'config/businesses/beta.json');
|
||||
const acme = JSON.parse(readFileSync(o.file, 'utf8'));
|
||||
const spare = { ...acme.definitions.builder, personality: { summary: 'Spare.', voice: `quotes ${SENTINEL}` } };
|
||||
writeJson(betaFile, { ...acme, id: 'beta', definitions: { spare } });
|
||||
const bus = await o.start({ ...boot, businesses: { ...boot.businesses, beta: { ...boot.businesses.acme, id: 'beta' } } });
|
||||
t.after(() => bus.close());
|
||||
const v = verbs(o, bus);
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const revision = writer.read('acme').revision;
|
||||
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization beta definitions.spare personality.voice holds the credential being issued'));
|
||||
writeFileSync(betaFile, '{ not json');
|
||||
const CLEAN = 'fake_clean_token_4444444444';
|
||||
assert.throws(() => v.issue(CLEAN), (e) => e instanceof BusError && e.code === DEFINITION_SCAN_UNREADABLE && !JSON.stringify(e).includes(CLEAN) && !String(e.stack).includes(CLEAN));
|
||||
for (const value of [SENTINEL, CLEAN]) assert.ok(!holds(bus, value));
|
||||
assert.equal(state(bus), 'valid');
|
||||
assert.equal(writer.read('acme').revision, revision);
|
||||
onlyInOrganization(o.root, CLEAN);
|
||||
// With beta's file readable again, the clean issue goes through.
|
||||
writeJson(betaFile, { ...acme, id: 'beta', definitions: {} });
|
||||
assert.equal(v.issue(CLEAN).state, 'valid');
|
||||
assert.ok(holds(bus, CLEAN));
|
||||
});
|
||||
|
||||
test('org-defscan: the issue check covers every organization the bus serves, and an unreadable file refuses', () => {
|
||||
const held = 'HELD-VALUE-77';
|
||||
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
|
||||
const own = { id: 'acme', document: { definitions: { builder: { instructions: 'fine' } } } };
|
||||
const files = { beta: { readable: true, document: { definitions: { spare: { instructions: 'fine', personality: { voice: `say ${held}` } } } } },
|
||||
gamma: { readable: true, document: {} }, broken: { readable: false } };
|
||||
const reads = [];
|
||||
const readRaw = (id) => { reads.push(id); if (id === 'throws') throw new Error('EACCES'); return files[id]; };
|
||||
assert.doesNotThrow(() => scanOrganizations({ organizations: ['acme', 'gamma'], own, readRaw }, assertClean));
|
||||
assert.deepEqual(reads, ['gamma'], 'the issuing organization is the document already read');
|
||||
assert.throws(() => scanOrganizations({ organizations: ['acme', 'gamma', 'beta'], own, readRaw }, assertClean),
|
||||
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'organization beta definitions.spare personality.voice holds the credential being issued' && !JSON.stringify(e).includes(held));
|
||||
assert.throws(() => scanOrganizations({ organizations: ['acme', { ...own, id: 'x' }.id], own: { id: 'acme', document: { definitions: { [held]: {} } } }, readRaw: () => ({ readable: true, document: {} }) }, assertClean),
|
||||
(e) => e.reason === 'organization acme definitions.* key holds the credential being issued' && !e.reason.includes(held));
|
||||
for (const bad of ['broken', 'throws', 'missing'])
|
||||
assert.throws(() => scanOrganizations({ organizations: ['acme', bad], own, readRaw }, assertClean), (e) => e.code === DEFINITION_SCAN_UNREADABLE && e.reason === undefined);
|
||||
});
|
||||
|
||||
test('org-defscan: heldPath names the first place, a key by its parent, and never the text', () => {
|
||||
const held = 'HELD-VALUE-77';
|
||||
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
|
||||
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', 'b'] } }, assertClean), null);
|
||||
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', `b${held}`] } }, assertClean), 'personality.traits[1]');
|
||||
assert.equal(heldPath({ role: { tools: [held] } }, assertClean), 'role.tools[0]');
|
||||
assert.equal(heldPath({ personality: { [held]: 'x' } }, assertClean), 'personality key');
|
||||
assert.equal(heldPath({ [`${held}`]: 'x' }, assertClean), 'key');
|
||||
assert.throws(() => scanDefinitions({ 'org:a': { instructions: 'ok' }, 'org:b': { instructions: held } }, assertClean, { prefix: 'org:' }),
|
||||
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'definitions.b instructions holds a credential this bus holds');
|
||||
// A name outside the loader's pattern is not quoted.
|
||||
assert.throws(() => scanDefinitions({ 'Bad Name': { instructions: held } }, assertClean), (e) => e.reason === 'definitions.* instructions holds a credential this bus holds');
|
||||
assert.throws(() => scanDefinitions({ [held]: { instructions: 'ok' } }, assertClean), (e) => e.reason === 'definitions.* key holds a credential this bus holds' && !e.reason.includes(held));
|
||||
assert.doesNotThrow(() => scanDefinitions(undefined, assertClean));
|
||||
});
|
||||
|
||||
test('org-defscan: an approved hire whose definition holds a credential issued after the raise refuses before the decision is consumed', async (t) => {
|
||||
// The chief raises a hire whose instructions carry text the store does not hold yet. The proposal is
|
||||
// private, so the issue-time check cannot see it, and the store then comes to hold that text. The
|
||||
// approval runs the same definition check as start, launch and issue: the refusal names the code, the
|
||||
// decision stays open, and the organization file is as it was.
|
||||
const LATER = 'fake_later_token_0123456789';
|
||||
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
|
||||
const boot = o.boot();
|
||||
const bus = await o.start(boot);
|
||||
t.after(() => bus.close());
|
||||
const chief = bus.broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
bus.broker.request(chief, { verb: 'role.claim' });
|
||||
const inline = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: `Read and report.\nExample ${LATER}\n` };
|
||||
const raised = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'scout', role: { inline }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
|
||||
// Issued through the store, so the organization file and its revision stay as they were and the
|
||||
// approval reaches the definition check rather than the revision check.
|
||||
bus.credentials.issue('acme/coder', 'gitea', LATER, '2099-01-01');
|
||||
assert.ok(holds(bus, LATER));
|
||||
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
|
||||
const before = readFileSync(o.file);
|
||||
const revision = writer.read('acme').revision;
|
||||
const owner = bus.broker.bindConsole({ business: 'acme', human: o.f.doc.human, subject: { provider: 'test', iss: 'https://issuer.test', sub: 'owner' } });
|
||||
const refused = (e) => {
|
||||
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
|
||||
for (const text of [e.message, String(e.reason), String(e.stack), JSON.stringify(e)]) assert.ok(!text.includes(LATER), 'the refusal never carries the value');
|
||||
return true;
|
||||
};
|
||||
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
|
||||
// Nothing was written.
|
||||
assert.deepEqual(readFileSync(o.file), before);
|
||||
assert.equal(writer.read('acme').revision, revision);
|
||||
assert.ok(!Object.hasOwn(writer.read('acme').document.roles, 'scout'));
|
||||
// The decision was not consumed: a second approval meets the same check, not decision-closed.
|
||||
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
|
||||
assert.deepEqual(readFileSync(o.file), before);
|
||||
// A hire with clean text, raised and approved on the same bus, is written as before.
|
||||
const clean = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'helper', role: { inline: { ...inline, instructions: 'Read and report.\n' } }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
|
||||
assert.equal((await bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: clean.id, choice: 'yes', digest: clean.hire.digest } })).choice, 'yes');
|
||||
assert.ok(Object.hasOwn(writer.read('acme').document.roles, 'helper'));
|
||||
onlyInOrganization(join(o.root, 'config/businesses'), LATER);
|
||||
});
|
||||
|
||||
test('org-defscan: the boot copy snapshot\'s definitions (the root agents file\'s) are checked at start and at issue', async (t) => {
|
||||
// A4: a root definition reaches an organization through the boot copy snapshot, which every reload
|
||||
// re-applies. Its text is in no organization file, so the start and issue checks read the snapshot.
|
||||
const LATER = 'fake_later_token_0123456789';
|
||||
const snapshot = (instructions) => ({ org: 'acme', root: 'ok', rootAgents: {}, copies: {},
|
||||
definitions: { rooted: { ...entryFromTemplate(readTemplate(REPO_ROLES, 'coder')), instructions } } });
|
||||
const startWith = (o, copies, b = o.boot()) => startBroker({ dataRoot: o.f.dataRoot, businesses: b.businesses, organization: b.organization, copies });
|
||||
// Held at start: the start refuses, naming the snapshot's definition and key.
|
||||
const held = org(t);
|
||||
refusedWithout(await outcome(startWith(held, snapshot(`Root copy ${SENTINEL}\n`))), 'boot copy definitions.rooted instructions holds a credential this bus holds');
|
||||
// Not held at start: the bus starts, then issuing that text refuses and the store is as it was.
|
||||
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
|
||||
const bus = await startWith(o, snapshot(`Root copy ${LATER}\n`));
|
||||
t.after(() => bus.close());
|
||||
assert.throws(() => verbs(o, bus).issue(LATER), (e) => issueRefused(e, LATER, 'organization acme boot copy definitions.rooted instructions holds the credential being issued'));
|
||||
assert.ok(!holds(bus, LATER));
|
||||
assert.equal(state(bus), 'valid');
|
||||
assert.equal(verbs(o, bus).issue('fake_clean_token_4444444444').state, 'valid');
|
||||
});
|
||||
@@ -37,9 +37,10 @@ function write(dataRoot, status, event) {
|
||||
appendFileSync(bootEventsFile(dataRoot), `${JSON.stringify(event)}\n`, { mode: 0o600 });
|
||||
}
|
||||
|
||||
// A start refused: keeps the time of the last good load from the status it replaces.
|
||||
export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString() }) {
|
||||
const prior = readBootStatus(dataRoot);
|
||||
// A start refused: keeps the time of the last good load from the status it replaces. `prior` is the
|
||||
// status from before this start, for a refusal that comes after the start recorded its load (org-defscan:
|
||||
// the broker's held-secret check on the definitions).
|
||||
export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString(), prior = readBootStatus(dataRoot) }) {
|
||||
const lastGood = prior?.ok ? prior.at : prior?.lastGood ?? null;
|
||||
const text = String(reason).slice(0, REASON_MAX);
|
||||
write(dataRoot, { ok: false, at, business, file, reason: text, lastGood }, { event: "bus.boot.refused", at, business, file, reason: text });
|
||||
|
||||
@@ -651,9 +651,14 @@ async function bus(rest, io, deps) {
|
||||
throw e;
|
||||
}
|
||||
if (instance) boot.consoleOwners = [...(boot.consoleOwners ?? []), DEV_OWNER];
|
||||
const priorBoot = readBootStatus(org.root);
|
||||
try { recordBootOk(org.root, { business: businessId, file }); } catch { /* status is advisory */ }
|
||||
const binding = readNotifyConfig(org.root, businessId);
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } });
|
||||
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } }).catch((e) => {
|
||||
// org-defscan: the broker refused a definition that holds a credential it holds; the load was not good after all.
|
||||
if (e.code === "definition-holds-credential") try { recordBootRefused(org.root, { business: businessId, file, reason: e.message, prior: priorBoot }); } catch { /* the refusal still throws */ }
|
||||
throw e;
|
||||
});
|
||||
try {
|
||||
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
|
||||
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
|
||||
|
||||
@@ -228,7 +228,10 @@ export async function startHost({ boot, business, notifier = null, options = nul
|
||||
if (ready?.ok !== true) {
|
||||
await ended(broker, 5000);
|
||||
release();
|
||||
throw new CliError(`broker refused to start: ${typeof ready?.error === "string" ? ready.error : "startup-refused"}`, 3);
|
||||
const code = typeof ready?.error === "string" ? ready.error : "startup-refused";
|
||||
// org-defscan: the broker's reason names the definition and key that hold a credential, never the text.
|
||||
const why = code === "definition-holds-credential" && typeof ready?.reason === "string" ? `: ${ready.reason.slice(0, 300)}` : "";
|
||||
throw Object.assign(new CliError(`broker refused to start: ${code}${why}`, 3), code === "definition-holds-credential" ? { code } : {});
|
||||
}
|
||||
|
||||
// Every step from here to host.json shares one failure path: it stops the
|
||||
|
||||
@@ -380,9 +380,11 @@ export function createLauncher({
|
||||
// workspace and the thread's store, so no session of the instance starts while one lives.
|
||||
if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session))
|
||||
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
|
||||
let resolved;
|
||||
let resolved, definitionEntry;
|
||||
try {
|
||||
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
|
||||
// org-defscan (#1873): the entry the contract came from, taken before any await lets another check replace b.
|
||||
definitionEntry = String(resolved.definition).startsWith("org:") ? b.definitions?.[resolved.definition] : undefined;
|
||||
} catch (e) {
|
||||
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
|
||||
}
|
||||
@@ -413,6 +415,12 @@ export function createLauncher({
|
||||
const primary = routeOf("primary");
|
||||
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
|
||||
if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
|
||||
// org-defscan (#1873): an org: definition, as this check just read it, passes the bus's held-secret check before a contract exists.
|
||||
if (String(resolved.definition).startsWith("org:")) {
|
||||
const { held } = (await host.op({ op: "definitionScan", business: b.id, definition: resolved.definition, entry: definitionEntry })) ?? {};
|
||||
if (typeof held === "string") throw new Refusal("definition-holds-credential", `${instance}'s definition ${resolved.definition} holds a credential in ${held}`);
|
||||
if (held !== null) throw new Refusal("organization-refused", `the bus did not check ${instance}'s definition ${resolved.definition}`);
|
||||
}
|
||||
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
|
||||
try {
|
||||
readSkills(resolved.skills, skillsDir);
|
||||
|
||||
@@ -55,8 +55,8 @@ for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills desc
|
||||
assert.equal(result.stdout,'');
|
||||
assert.equal(result.stderr,'');
|
||||
const pairs=result.pairs;
|
||||
assert.equal(pairs.length,2,'the session leader and its live descendant both started');
|
||||
assert.equal(pairs[0][0],pairs[0][1],'the command leads its kernel session');
|
||||
assert.equal(pairs.length,2,'the command and its live descendant both started');
|
||||
assert.notEqual(pairs[0][0],pairs[0][1],'the trusted parent leads the command session');
|
||||
assert.notEqual(pairs[1][0],pairs[0][0]);
|
||||
assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session');
|
||||
assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown');
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// No real process is signalled. These hostile kernel-boundary controls expose
|
||||
// a new session reusing the old SID after the original leader disappears.
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
for(const name of ['case1-accept.sh','case2-unit.sh'])for(const mode of ['already-dead','dies-before-signal'])test(`${name}: ${mode} original session never signals a recycled SID`,()=>{
|
||||
const source=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
|
||||
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
|
||||
assert.ok(program,'the shipped cleanup program exists');
|
||||
const anchor='\ntry:\n';assert.equal(program.split(anchor).length,2);
|
||||
const control=`
|
||||
import atexit, json
|
||||
from types import SimpleNamespace
|
||||
attempts = []
|
||||
checks = 0
|
||||
atexit.register(lambda: print(json.dumps(attempts)))
|
||||
def observed(pid):
|
||||
global checks
|
||||
if pid == "self/fd/42":
|
||||
checks += 1
|
||||
return (b"T", session) if ${mode==='already-dead'?'False':'checks < 3'} else None
|
||||
return (b"R", session)
|
||||
def send(fd, sig):
|
||||
attempts.append([fd, sig])
|
||||
raise PermissionError("never signal a real task")
|
||||
fields = observed
|
||||
os = SimpleNamespace(listdir=lambda path: ["999"], pidfd_open=lambda pid: 17 if pid == session else 99, close=lambda fd: None)
|
||||
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
|
||||
`;
|
||||
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,'\n'+control+anchor),'424242','42'],{encoding:'utf8'});
|
||||
assert.equal(r.status,1,r.stderr);assert.equal(r.stderr,'');
|
||||
assert.deepEqual(JSON.parse(r.stdout),[],'a stale SID must never authorize a signal');
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
// Regression controls run the real supervisor. Only the kernel observation is
|
||||
// fault-injected: a transient empty stat and a delayed first observation of an
|
||||
// already completed fast command. Neither may authorize an unverified session.
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const names=['case1-accept.sh','case2-unit.sh'];
|
||||
function body(name){const s=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');return s.slice(s.indexOf('# row253-supervisor-begin'),s.indexOf('# row253-supervisor-end'));}
|
||||
function replaceOnce(source,anchor,replacement){assert.equal(source.split(anchor).length,2,'fault anchor must be unique');return source.replace(anchor,replacement);}
|
||||
function run(source,setsid='/usr/bin/setsid',command=['/bin/true']){
|
||||
return spawnSync('/bin/bash',['-c',source+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 "$@"','--',setsid,'/usr/bin/sleep',...command],{encoding:'utf8',timeout:8000});
|
||||
}
|
||||
test('shipped supervisors remain byte-identical',()=>assert.equal(body(names[0]),body(names[1])));
|
||||
for(const name of names){
|
||||
test(`${name}: a transient empty stat read retries within the inner budget`,()=>{
|
||||
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${empty_once:-0}" = 0 ]; then empty_once=1; return 1; fi');
|
||||
const r=run(source);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'');
|
||||
});
|
||||
test(`${name}: fast success cannot disappear before session verification`,()=>{
|
||||
// Delay only the parent's first observation. BASE lets /bin/true exit and
|
||||
// loses its proc entry; a stopped trusted launcher remains verifiable.
|
||||
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${delayed_once:-0}" = 0 ]; then delayed_once=1; "$sleep_bin" 0.08; fi');
|
||||
const r=run(source);assert.equal(r.status,0,r.stderr);
|
||||
});
|
||||
test(`${name}: a launcher that never creates a session is refused`,()=>{
|
||||
const r=run(body(name),'/bin/false');assert.equal(r.status,85,r.stderr);
|
||||
});
|
||||
test(`${name}: a stopped launcher without a new session is never accepted`,()=>{
|
||||
const r=run(body(name),'/usr/bin/env');assert.equal(r.status,81,r.stderr);
|
||||
});
|
||||
test(`${name}: persistently unreadable initial stat retains session refusal 85`,()=>{
|
||||
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n return 1');
|
||||
const r=run(source);assert.equal(r.status,85,r.stderr);
|
||||
});
|
||||
test(`${name}: command status and streams survive the launcher`,()=>{
|
||||
const r=run(body(name),'/usr/bin/setsid',['/bin/bash','-c','printf out; printf err >&2; exit 7']);
|
||||
assert.equal(r.status,87,r.stderr);assert.equal(r.stdout,'out');assert.equal(r.stderr,'err');
|
||||
});
|
||||
}
|
||||
|
||||
// A snapshot of the parent tree would miss a child reparented to namespace init.
|
||||
// A new session is a separately documented escape, not a session member.
|
||||
import {mkdtempSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {fileURLToPath} from 'node:url';
|
||||
for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable','anchor-death'])test(`${name}: namespace cleanup ${mode}`,t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
|
||||
const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,'');
|
||||
const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null);
|
||||
assert.equal(result.status,['unreadable','unkillable','anchor-death'].includes(mode)?86:81,JSON.stringify(result));
|
||||
assert.equal(result.stdout,'');
|
||||
if(mode==='anchor-death')assert.match(result.stderr,/Killed/);else assert.equal(result.stderr,'');
|
||||
if(!['unreadable','unkillable','anchor-death'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive');
|
||||
if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;}
|
||||
assert.equal(result.pairs.length,2);
|
||||
const [leader,descendant]=result.pairs;
|
||||
assert.notEqual(leader[0],leader[1]);
|
||||
assert.equal(result.anchor,leader[1],'the trusted command parent reserves the SID');
|
||||
if(mode==='escape'){
|
||||
assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]);
|
||||
assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it');
|
||||
}else{
|
||||
assert.equal(descendant[1],leader[1]);
|
||||
if(mode==='anchor-death'){assert.ok(result.live.includes(leader[0]),'lost anchor refuses before signalling a bare SID');}
|
||||
else if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');}
|
||||
else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup');
|
||||
}
|
||||
});
|
||||
|
||||
// Unlike the private-inventory timing test, this sees the real host /proc.
|
||||
// Restoring the old per-entry bash read/parse scan exceeds this bound on a
|
||||
// representative host. It uses cleanup-time's stamps, excluding the inner wait.
|
||||
import {writeFileSync} from 'node:fs';
|
||||
import {instrumentCleanupTimer,readProbeSeconds} from './case-inner-timing-fixture.mjs';
|
||||
for(const name of names)test(`${name}: host inventory cleanup stays within 0.5 seconds`,t=>{
|
||||
const dir=mkdtempSync(join(tmpdir(),'hc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
|
||||
const clock=join(dir,'clock');writeFileSync(clock,'');
|
||||
const r=run(instrumentCleanupTimer(body(name)+'# row253-supervisor-end\n',clock),'/usr/bin/setsid',['/bin/bash','-c','/usr/bin/sleep 30 & wait']);
|
||||
assert.equal(r.status,81,r.stderr);assert.equal(r.stderr,'');assert.equal(r.stdout,'');
|
||||
const seconds=readProbeSeconds(readFileSync(clock,'utf8'));t.diagnostic(`${name}: host cleanup ${seconds}s`);
|
||||
assert.ok(seconds<0.5,`host cleanup ${seconds}s must be < 0.5s`);
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
// Namespace-local evidence and cleanup. The JSON observation precedes teardown.
|
||||
import {readFileSync,writeFileSync,readdirSync,openSync,closeSync} from 'node:fs';
|
||||
import {spawnSync} from 'node:child_process';
|
||||
const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs';
|
||||
const source=readFileSync(script,'utf8');
|
||||
let body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
|
||||
// Adversarial kernel boundary controls keep the real cleanup loop and budget.
|
||||
if(mode==='unreadable')body=body.replace('def fields(pid):','def fields(pid):\n raise PermissionError("injected unreadable stat")');
|
||||
if(mode==='unkillable')body=body.replace('signal.pidfd_send_signal(fd, signal.SIGKILL)','None # injected successful kill with a surviving member');
|
||||
if(mode==='fork')writeFileSync(worker,`
|
||||
import {spawn} from 'node:child_process';
|
||||
import {appendFileSync,readFileSync} from 'node:fs';
|
||||
const record=${JSON.stringify(records)};
|
||||
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
|
||||
appendFileSync(record,JSON.stringify([process.pid,sid(process.pid)])+'\\n');
|
||||
setInterval(()=>{
|
||||
const child=spawn('/usr/bin/sleep',['30'],{stdio:'ignore'});
|
||||
appendFileSync(record,JSON.stringify([child.pid,sid(child.pid)])+'\\n');
|
||||
},4);
|
||||
`);
|
||||
else writeFileSync(worker,`
|
||||
import {spawn} from 'node:child_process';
|
||||
import {readFileSync,writeFileSync} from 'node:fs';
|
||||
const record=${JSON.stringify(records)};
|
||||
const child=spawn('/bin/bash',['-c',${JSON.stringify(mode==='escape'?'exec /usr/bin/setsid /usr/bin/sleep 30':'/usr/bin/sleep 30 & echo $! > "$1"')},'--',record+'.child'],{stdio:'ignore'});
|
||||
setTimeout(()=>{
|
||||
const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"};
|
||||
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
|
||||
writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]]));
|
||||
writeFileSync(record+'.anchor',String(process.ppid));
|
||||
writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]);
|
||||
if(${JSON.stringify(mode)} === "anchor-death")process.kill(process.ppid,"SIGKILL");
|
||||
},100);
|
||||
setInterval(()=>{},1000);
|
||||
`);
|
||||
const out=openSync(dir+'/out','w'),err=openSync(dir+'/err','w');
|
||||
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',stdio:['ignore',out,err],timeout:8000,killSignal:'SIGKILL'});
|
||||
closeSync(out);closeSync(err);
|
||||
result.stdout=readFileSync(dir+'/out','utf8');result.stderr=readFileSync(dir+'/err','utf8');
|
||||
let pairs=[];
|
||||
try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{}
|
||||
const live=[];
|
||||
for(const [pid] of pairs){try{const state=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[0];if(!['Z','X'].includes(state))live.push(pid);}catch{}}
|
||||
// Inventory the entire private namespace too: a fork between spawn and its
|
||||
// record write must not escape the assertion just because it was unrecorded.
|
||||
const sessionLive=[];
|
||||
for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){
|
||||
try{
|
||||
const words=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ');
|
||||
if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid));
|
||||
}catch{}
|
||||
}
|
||||
let parent,anchor;
|
||||
try{anchor=Number(readFileSync(records+".anchor","utf8"));}catch{}
|
||||
try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{}
|
||||
process.stdout.write(JSON.stringify({sessionLive,parent,anchor,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live}));
|
||||
// These PIDs exist only in the private namespace. Never signal them on the host.
|
||||
for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{}
|
||||
@@ -938,7 +938,7 @@ test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env
|
||||
// mos-1326: assert every argv element, not flattened/substr command detection.
|
||||
const source=read(SCRIPT);
|
||||
const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n';
|
||||
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'34063098d7edb9067c11456165dc7535bc0e779d053c43599884d6b614528639');
|
||||
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'52509773fa2bb0701402b8d2454816153e18323cfaa75cb72418b46c4dbb2d34');
|
||||
const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1];
|
||||
const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1];
|
||||
const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line));
|
||||
|
||||
@@ -215,7 +215,7 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
|
||||
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
|
||||
const seconds=readProbeSeconds(read(clock));
|
||||
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
|
||||
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
|
||||
assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
|
||||
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
|
||||
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
|
||||
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
|
||||
@@ -256,24 +256,39 @@ test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
|
||||
});
|
||||
|
||||
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
|
||||
// A deterministic clock models a slow inventory without host-load timing.
|
||||
// All process reads and signals are stand-ins; no real PID is signalled.
|
||||
// Run the shipped cleanup with a deterministic slow inventory/clock. Every
|
||||
// kernel operation is replaced before execution; no real PID is signalled.
|
||||
const source=read(UNIT);
|
||||
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
|
||||
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
|
||||
assert.ok(program,'fixed cleanup program exists');
|
||||
const anchor='\ntry:\n';
|
||||
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
|
||||
const standins=`
|
||||
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
|
||||
stat_fields() {
|
||||
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
|
||||
[ "$seen" != 1 ] || ST_SESSION="$child"
|
||||
return 0
|
||||
}
|
||||
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
|
||||
kill() { kills=$((kills+1)); return 0; }
|
||||
clean_session; rc=$?
|
||||
printf '%s' "$kills"
|
||||
exit "$rc"
|
||||
from types import SimpleNamespace
|
||||
alive = True
|
||||
kills = 0
|
||||
clock = time.monotonic()
|
||||
|
||||
def inventory(path):
|
||||
global clock
|
||||
clock += 1000
|
||||
return ["999"]
|
||||
|
||||
def fake_fields(pid):
|
||||
return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
|
||||
|
||||
def send(fd, sig):
|
||||
global alive, kills
|
||||
if sig == 9:
|
||||
alive = False
|
||||
kills += 1
|
||||
|
||||
fields = fake_fields
|
||||
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
|
||||
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
|
||||
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
|
||||
`;
|
||||
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
|
||||
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
|
||||
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// #1873 T1 follow-up (org-defscan): a definition that holds a credential the bus holds refuses at
|
||||
// launch (the launch check asks the bus before a contract exists) and at `mosaic bus start`, and the
|
||||
// value reaches no refusal, record or log.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createLauncher } from '../src/launcher.mjs';
|
||||
import { bootConfig, loadSystem } from '../src/config.mjs';
|
||||
import { readBootStatus, bootEventsFile } from '../src/boot-status.mjs';
|
||||
import { hostFile } from '../src/host.mjs';
|
||||
import { startBroker } from '../../bus/src/runtime.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
import { launchLogFile } from '../../seat/src/session.mjs';
|
||||
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { fixture, notifyConfig, tmp, withoutHostCredentials } from './helpers.mjs';
|
||||
|
||||
// The fixture bus holds SENTINEL as coder's gitea token.
|
||||
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
|
||||
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
|
||||
function files(dir) {
|
||||
if (!existsSync(dir)) return [];
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
|
||||
}
|
||||
const withBuilder = (builder) => (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } });
|
||||
|
||||
test('org-defscan: a file edited after start to hold a held credential refuses the next launch of an instance naming it; a template launch still works', async (t) => {
|
||||
const root = tmp(t);
|
||||
const f = fixture(root, 'acme', withBuilder(entryFromTemplate(readTemplate(REPO_ROLES, 'coder'))));
|
||||
const file = join(root, 'config/businesses/acme.json');
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
initializeControl(f.dataRoot);
|
||||
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
|
||||
const bus = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
|
||||
t.after(() => bus.close());
|
||||
// The real host's op path for the scan, over a structured clone as IPC carries it; the fixture
|
||||
// stops at authorization, so "reached authorizeLaunch" is "would have launched".
|
||||
const ops = [];
|
||||
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
|
||||
ops.push(x.op);
|
||||
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
||||
if (x.op === 'mayLaunch') return { allow: true };
|
||||
if (x.op === 'authorizeLaunch') throw Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
||||
if (x.op === 'definitionScan') return structuredClone(bus.definitionScan(structuredClone({ business: x.business, definition: x.definition, entry: x.entry })));
|
||||
return {};
|
||||
} };
|
||||
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
|
||||
t.after(() => launcher.close?.());
|
||||
|
||||
// Clean: the scan passes and the launch reaches authorization.
|
||||
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
|
||||
assert.ok(ops.includes('definitionScan'));
|
||||
|
||||
// Edited by hand while the bus runs.
|
||||
const doc = JSON.parse(readFileSync(file, 'utf8'));
|
||||
doc.definitions.builder.instructions += `\nThe forge token is ${SENTINEL}.\n`;
|
||||
writeJson(file, doc);
|
||||
ops.length = 0;
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' });
|
||||
assert.ok(!ops.includes('authorizeLaunch'), 'nothing launches');
|
||||
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(event.code, 'definition-holds-credential');
|
||||
assert.equal(event.instance, 'coder');
|
||||
assert.match(event.why, /coder's definition org:builder holds a credential in instructions/);
|
||||
|
||||
// A personality field is checked the same way.
|
||||
doc.definitions.builder.instructions = doc.definitions.builder.instructions.replace(SENTINEL, 'REDACTED');
|
||||
doc.definitions.builder.personality = { summary: 'Plain.', voice: `Signs off with ${SENTINEL}` };
|
||||
writeJson(file, doc);
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' });
|
||||
assert.match(JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1)).why, /in personality\.voice$/);
|
||||
|
||||
// An instance on a shipped template launches as before, and asks no scan.
|
||||
ops.length = 0;
|
||||
assert.equal((await launcher.fromSession('fixture-cap', 'reviewer')).error, 'fixture-policy');
|
||||
assert.ok(!ops.includes('definitionScan'));
|
||||
|
||||
// The value reached no record the bus or the launcher wrote.
|
||||
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
});
|
||||
|
||||
// A bootstrapped instance with an active organization whose file names org:builder.
|
||||
async function orgFixture(t, builder) {
|
||||
const root = tmp(t, 'o-');
|
||||
const f = fixture(root, 'acme', withBuilder(builder));
|
||||
const configDir = join(root, '.config', 'mosaic-boot');
|
||||
const env = { ...f.env, HOME: root, MOSAIC_CONFIG: join(configDir, 'config.json') };
|
||||
const bootstrap = spawnSync(join(process.cwd(), 'scripts/bootstrap.sh'), ['--instance', 'boot'], { env, encoding: 'utf8' });
|
||||
assert.equal(bootstrap.status, 0, bootstrap.stderr);
|
||||
mkdirSync(join(configDir, 'businesses'), { recursive: true, mode: 0o700 });
|
||||
const file = join(configDir, 'businesses', 'acme.json');
|
||||
writeFileSync(file, readFileSync(join(root, 'config/businesses/acme.json')), { mode: 0o600 });
|
||||
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
|
||||
const dataRoot = join(root, '.mosaic-boot');
|
||||
const ctx = { configDir, dataRoot, instance: 'boot' };
|
||||
const org = await reserveOrg(ctx, 'acme');
|
||||
await activateOrg(ctx, 'acme');
|
||||
// A notifier config, so a start that passes the load goes on to start the broker.
|
||||
notifyConfig(org.root, 'acme', null);
|
||||
return { env, file, dataRoot, orgRoot: org.root };
|
||||
}
|
||||
|
||||
test('org-defscan: mosaic bus start refuses a held credential in a definition, records why, and the value is nowhere', async (t) => {
|
||||
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
|
||||
const o = await orgFixture(t, { ...builder, instructions: `${builder.instructions}\nThe forge token is ${SENTINEL}.\n` });
|
||||
const run = (...args) => spawnSync(process.execPath, [CLI, ...args], { env: o.env, encoding: 'utf8', timeout: 60000 });
|
||||
const r = run('bus', 'start', 'acme', '--detach', '--no-launch');
|
||||
// Should a start get through (it must not), stop it before the fixture's directory goes.
|
||||
if (r.status === 0 || existsSync(hostFile(o.orgRoot))) run('bus', 'stop', 'acme');
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.ok(!existsSync(join(o.orgRoot, 'bus', 'broker.sock')), 'no socket');
|
||||
const s = readBootStatus(o.orgRoot);
|
||||
assert.equal(s.ok, false);
|
||||
assert.equal(s.business, 'acme');
|
||||
assert.match(s.reason, /definition-holds-credential: definitions\.builder instructions holds a credential this bus holds/);
|
||||
assert.equal(s.lastGood, null, 'the load that the broker then refused is not a good load');
|
||||
const events = readFileSync(bootEventsFile(o.orgRoot), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.equal(events.at(-1).event, 'bus.boot.refused');
|
||||
const status = run('bus', 'status', 'acme', '--json');
|
||||
assert.equal(JSON.parse(status.stdout).boot.ok, false);
|
||||
for (const text of [r.stdout, r.stderr, status.stdout, status.stderr]) assert.ok(!text.includes(SENTINEL));
|
||||
for (const path of files(o.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
|
||||
});
|
||||
@@ -91,6 +91,8 @@ async function start(t, { f, adapter }, options = {}) {
|
||||
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
|
||||
endLaunch: (m) => runtime.endLaunch(m.record),
|
||||
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
|
||||
// org-defscan (#1873): an org: definition's launch asks the bus's held-secret check, as process.mjs answers it.
|
||||
definitionScan: (m) => runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry }),
|
||||
threadsState: (m) => runtime.threadsState(m.business),
|
||||
threadIdle: (m) => runtime.threadIdle(m.record),
|
||||
mayLaunch: (m) => runtime.mayLaunch(m),
|
||||
|
||||
@@ -21,6 +21,7 @@ const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
|
||||
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
||||
if (x.op === 'mayLaunch') return { allow: true };
|
||||
if (x.op === 'authorizeLaunch') throw stop();
|
||||
if (x.op === 'definitionScan') return { held: null };
|
||||
return {};
|
||||
} };
|
||||
|
||||
|
||||
@@ -35,7 +35,15 @@ const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p.
|
||||
...(p.entry ? {role:structuredClone(p.entry.role)} : {}),
|
||||
// reason and all free text stay in the private proposal as well.
|
||||
flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) });
|
||||
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
|
||||
// org-defscan (#1873): `scanDefinition(name, entry)` is the bus's definition check on the approved
|
||||
// entry; it throws (definition-holds-credential, naming the definition and key) when the live store
|
||||
// holds text in it. Without one, the entry still goes through assertClean whole.
|
||||
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
|
||||
const scanEntry = (name, entry) => {
|
||||
if (typeof scanDefinition === 'function') return scanDefinition(name, structuredClone(entry));
|
||||
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
|
||||
assertClean(entry);
|
||||
};
|
||||
const home = join(dataRoot,'authority-proposals');
|
||||
let receiptCache;
|
||||
const rootReceipts=()=>{
|
||||
@@ -151,6 +159,9 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
async function adjusted(business, decision, adjust) {
|
||||
const p=read(decision);
|
||||
if(p.business!==business)fail('decision-stale');
|
||||
// org-defscan (#1873): the stored entry is the definition text an approval writes. A credential
|
||||
// issued after the proposal refuses it here, before preview, check or apply goes further.
|
||||
if(p.entry)scanEntry(p.definition,p.entry);
|
||||
if(adjust===undefined)return p;
|
||||
keys(adjust,['role','credentials','route']);
|
||||
if(!p.entry)fail('invalid-request');
|
||||
@@ -216,6 +227,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {};
|
||||
const currentDoc=r.level==='root'?(await readRootAgentsFile(configDir)).document:writer.read(business).document;
|
||||
const approvedEntry=p.entry?{...p.entry,rev:(currentDoc.definitions?.[p.definition]?.rev??0)+1}:null;
|
||||
// org-defscan (#1873): check() ran before the awaits above; the entry the writers get is checked again.
|
||||
if(approvedEntry)scanEntry(p.definition,approvedEntry);
|
||||
const excluded=r.level==='root' && r.op==='change' ? currentDoc.agents[r.name]?.excludeOrgs : undefined;
|
||||
const entry=r.op==='fire'?null:{definition:`org:${p.definition}`,vars,hireDecision:p.id,...(excluded===undefined?{}:{excludeOrgs:excluded})};
|
||||
if(r.level==='root') {
|
||||
|
||||
+134
-42
@@ -136,47 +136,110 @@ trap 'rm -rf -- "$WORK"' EXIT
|
||||
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
|
||||
set +m
|
||||
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
|
||||
child="" timer="" verified=0
|
||||
child="" timer="" session_ref="" verified=0
|
||||
stat_fields() {
|
||||
local pid="$1" raw="" rest
|
||||
local pid="$1" raw="" rest file="/proc/$1/stat"
|
||||
if [ "$pid" = "${child:-}" ] && [ -n "${session_ref:-}" ]; then
|
||||
file="/proc/self/fd/$session_ref/stat"
|
||||
fi
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw 2>/dev/null < "/proc/$pid/stat" || :
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
IFS= read -r -d '' raw 2>/dev/null < "$file" || :
|
||||
[ -n "$raw" ] || { [ -e "$file" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
|
||||
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
|
||||
}
|
||||
clock_cs() {
|
||||
local boot spare
|
||||
read -r boot spare < /proc/uptime || return 1
|
||||
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
|
||||
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
|
||||
}
|
||||
clean_session() {
|
||||
local entry pid rc live until=""
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
pid="${entry%/stat}"; pid="${pid##*/}"
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" != 3 ] || continue
|
||||
[ "$rc" = 0 ] || return 1
|
||||
[ "$ST_SESSION" = "$child" ] || continue
|
||||
case "$ST_STATE" in Z|X) continue ;; esac
|
||||
live=1
|
||||
if ! kill -KILL "$pid" 2>/dev/null; then
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
|
||||
fi
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
# Fixed, isolated stdlib code, never imported from the copy or its cwd.
|
||||
# Buffered reads avoid bash read -d's one-byte reads over the host inventory.
|
||||
/usr/bin/python3 -I -S -c '
|
||||
import os, signal, sys, time
|
||||
session = int(sys.argv[1])
|
||||
until = None
|
||||
anchor_fd = None
|
||||
|
||||
def fields(pid):
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
# A disappearing task can return EOF while its proc entry is still visible.
|
||||
# Retry once; a persistent unreadable/malformed entry refuses cleanup.
|
||||
if not raw:
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
rest = raw.rsplit(b") ", 1)
|
||||
if len(rest) != 2:
|
||||
raise ValueError("stat")
|
||||
words = rest[1].split()
|
||||
if len(words) < 4 or len(words[0]) != 1 or not words[0].isalpha():
|
||||
raise ValueError("stat")
|
||||
return words[0], int(words[3])
|
||||
|
||||
def original_leader():
|
||||
# The inherited proc directory refers to the original task, even if its
|
||||
# numeric PID is reused. A live trusted leader keeps this SID allocated.
|
||||
info = fields("self/fd/" + sys.argv[2])
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
raise ValueError("lost session anchor")
|
||||
return info
|
||||
|
||||
try:
|
||||
anchor_fd = os.pidfd_open(session)
|
||||
original_leader()
|
||||
while True:
|
||||
original_leader()
|
||||
live = False
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdecimal() or int(pid) == session:
|
||||
continue
|
||||
info = fields(pid)
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
continue
|
||||
live = True
|
||||
try:
|
||||
# Pin the task before rechecking membership: a recycled numeric
|
||||
# pid must never redirect our signal to an unrelated process.
|
||||
fd = os.pidfd_open(int(pid))
|
||||
try:
|
||||
current = fields(pid)
|
||||
if current is not None and current[1] == session and current[0] not in (b"Z", b"X"):
|
||||
# Check AFTER membership: while the original anchor is
|
||||
# alive, this SID cannot name a later session. The pidfd
|
||||
# then prevents task reuse between this check and kill.
|
||||
original_leader()
|
||||
signal.pidfd_send_signal(fd, signal.SIGKILL)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError:
|
||||
info = fields(pid)
|
||||
if info is not None and info[0] not in (b"Z", b"X"):
|
||||
raise
|
||||
if not live and original_leader()[0] == b"T":
|
||||
# The trusted leader is stopped after waiting for the command.
|
||||
# With all other members dead it may return the saved command code.
|
||||
signal.pidfd_send_signal(anchor_fd, signal.SIGCONT)
|
||||
break
|
||||
now = time.monotonic()
|
||||
# Inventory pass 1 remains outside the 250 ms confirmation budget.
|
||||
if until is None:
|
||||
until = now + 0.25
|
||||
if now >= until:
|
||||
raise TimeoutError("cleanup")
|
||||
time.sleep(0.01)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if anchor_fd is not None:
|
||||
os.close(anchor_fd)
|
||||
' "$child" "$session_ref"
|
||||
}
|
||||
finish() {
|
||||
local rc="$?"
|
||||
@@ -186,30 +249,59 @@ finish() {
|
||||
elif [ -n "$child" ]; then
|
||||
kill -KILL "$child" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$child" ] || wait "$child" 2>/dev/null || :
|
||||
# A refused cleanup may leave an unkillable member. Do not hide rc 86
|
||||
# behind an unbounded wait; the caller retains its existing outer guard.
|
||||
if [ "$rc" != 86 ] && [ -n "$child" ]; then
|
||||
local command_rc=0
|
||||
wait "$child" 2>/dev/null || command_rc=$?
|
||||
[ "$rc" != 0 ] || [ "$command_rc" = 0 ] || rc=87
|
||||
fi
|
||||
[ -z "$session_ref" ] || exec {session_ref}<&-
|
||||
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
# The trusted session leader stays alive through cleanup. Copy code runs as
|
||||
# its child; only after cleanup continues the stopped leader does it return
|
||||
# the saved command status. An open proc directory pins its task identity.
|
||||
"$setsid_bin" /bin/bash -c '
|
||||
set +m
|
||||
kill -STOP "$BASHPID"
|
||||
"$@" & command=$!
|
||||
rc=0; wait "$command" 2>/dev/null || rc=$?
|
||||
kill -STOP "$BASHPID"
|
||||
exit "${rc}"
|
||||
' -- "$@" & child=$!
|
||||
{ exec {session_ref}<"/proc/$child"; } 2>/dev/null || exit 85
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
[ "$rc" != 3 ] || exit 85
|
||||
if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then
|
||||
verified=1
|
||||
kill -CONT "$child" 2>/dev/null || exit 85
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$timer" 2>/dev/null; then
|
||||
# Persistent unreadable metadata remains a session refusal, not a
|
||||
# command timeout. A transient read gets only this same inner budget.
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
exit 81
|
||||
fi
|
||||
"$sleep_bin" 0.002
|
||||
done
|
||||
[ "$verified" = 1 ] || exit 85
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 86
|
||||
case "$ST_STATE" in Z|X) exit 86 ;; esac
|
||||
[ "$ST_STATE" != T ] || break
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
rc=0; wait "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 87
|
||||
exit 0
|
||||
MOSAIC_TESTER_SUPERVISOR
|
||||
# row253-supervisor-end
|
||||
@@ -217,7 +309,7 @@ MOSAIC_TESTER_SUPERVISOR
|
||||
tester_tools() {
|
||||
local input target p st magic
|
||||
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD="" TS_TAIL=""
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head "$@"; do
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head /usr/bin/python3 "$@"; do
|
||||
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
|
||||
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
|
||||
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
|
||||
|
||||
+134
-42
@@ -228,47 +228,110 @@ fi
|
||||
IFS= read -r -d '' TESTER_SUPERVISOR <<'MOSAIC_TESTER_SUPERVISOR' || :
|
||||
set +m
|
||||
seconds="$1" setsid_bin="$2" sleep_bin="$3"; shift 3
|
||||
child="" timer="" verified=0
|
||||
child="" timer="" session_ref="" verified=0
|
||||
stat_fields() {
|
||||
local pid="$1" raw="" rest
|
||||
local pid="$1" raw="" rest file="/proc/$1/stat"
|
||||
if [ "$pid" = "${child:-}" ] && [ -n "${session_ref:-}" ]; then
|
||||
file="/proc/self/fd/$session_ref/stat"
|
||||
fi
|
||||
# EOF is expected: stat has no NUL. Decide on content, not read's status.
|
||||
IFS= read -r -d '' raw 2>/dev/null < "/proc/$pid/stat" || :
|
||||
[ -n "$raw" ] || { [ -e "/proc/$pid/stat" ] && return 1; return 3; }
|
||||
IFS= read -r -d '' raw 2>/dev/null < "$file" || :
|
||||
[ -n "$raw" ] || { [ -e "$file" ] && return 1; return 3; }
|
||||
rest="${raw##*) }"
|
||||
[ "$rest" != "$raw" ] || return 1
|
||||
read -r ST_STATE ST_PARENT ST_GROUP ST_SESSION _ <<< "$rest"
|
||||
[[ "$ST_STATE" =~ ^[A-Za-z]$ && "$ST_SESSION" =~ ^[0-9]+$ && "$ST_GROUP" =~ ^[0-9]+$ ]]
|
||||
}
|
||||
clock_cs() {
|
||||
local boot spare
|
||||
read -r boot spare < /proc/uptime || return 1
|
||||
[[ "$boot" =~ ^([0-9]+)\.([0-9]{2})$ ]] || return 1
|
||||
CLOCK_CS=$((10#${BASH_REMATCH[1]} * 100 + 10#${BASH_REMATCH[2]}))
|
||||
}
|
||||
clean_session() {
|
||||
local entry pid rc live until=""
|
||||
while :; do
|
||||
live=0
|
||||
for entry in /proc/[0-9]*/stat; do
|
||||
pid="${entry%/stat}"; pid="${pid##*/}"
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" != 3 ] || continue
|
||||
[ "$rc" = 0 ] || return 1
|
||||
[ "$ST_SESSION" = "$child" ] || continue
|
||||
case "$ST_STATE" in Z|X) continue ;; esac
|
||||
live=1
|
||||
if ! kill -KILL "$pid" 2>/dev/null; then
|
||||
rc=0; stat_fields "$pid" || rc=$?
|
||||
[ "$rc" = 3 ] || { [ "$rc" = 0 ] && { [ "$ST_STATE" = Z ] || [ "$ST_STATE" = X ]; }; } || return 1
|
||||
fi
|
||||
done
|
||||
[ "$live" != 0 ] || return 0
|
||||
clock_cs || return 1
|
||||
# Inventory pass 1 is outside the bounded confirmation/retry budget.
|
||||
[ -n "$until" ] || until=$((CLOCK_CS + 25))
|
||||
[ "$CLOCK_CS" -lt "$until" ] || return 1
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
# Fixed, isolated stdlib code, never imported from the copy or its cwd.
|
||||
# Buffered reads avoid bash read -d's one-byte reads over the host inventory.
|
||||
/usr/bin/python3 -I -S -c '
|
||||
import os, signal, sys, time
|
||||
session = int(sys.argv[1])
|
||||
until = None
|
||||
anchor_fd = None
|
||||
|
||||
def fields(pid):
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
# A disappearing task can return EOF while its proc entry is still visible.
|
||||
# Retry once; a persistent unreadable/malformed entry refuses cleanup.
|
||||
if not raw:
|
||||
try:
|
||||
with open("/proc/" + pid + "/stat", "rb") as f:
|
||||
raw = f.read()
|
||||
except (FileNotFoundError, ProcessLookupError):
|
||||
return None
|
||||
rest = raw.rsplit(b") ", 1)
|
||||
if len(rest) != 2:
|
||||
raise ValueError("stat")
|
||||
words = rest[1].split()
|
||||
if len(words) < 4 or len(words[0]) != 1 or not words[0].isalpha():
|
||||
raise ValueError("stat")
|
||||
return words[0], int(words[3])
|
||||
|
||||
def original_leader():
|
||||
# The inherited proc directory refers to the original task, even if its
|
||||
# numeric PID is reused. A live trusted leader keeps this SID allocated.
|
||||
info = fields("self/fd/" + sys.argv[2])
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
raise ValueError("lost session anchor")
|
||||
return info
|
||||
|
||||
try:
|
||||
anchor_fd = os.pidfd_open(session)
|
||||
original_leader()
|
||||
while True:
|
||||
original_leader()
|
||||
live = False
|
||||
for pid in os.listdir("/proc"):
|
||||
if not pid.isdecimal() or int(pid) == session:
|
||||
continue
|
||||
info = fields(pid)
|
||||
if info is None or info[1] != session or info[0] in (b"Z", b"X"):
|
||||
continue
|
||||
live = True
|
||||
try:
|
||||
# Pin the task before rechecking membership: a recycled numeric
|
||||
# pid must never redirect our signal to an unrelated process.
|
||||
fd = os.pidfd_open(int(pid))
|
||||
try:
|
||||
current = fields(pid)
|
||||
if current is not None and current[1] == session and current[0] not in (b"Z", b"X"):
|
||||
# Check AFTER membership: while the original anchor is
|
||||
# alive, this SID cannot name a later session. The pidfd
|
||||
# then prevents task reuse between this check and kill.
|
||||
original_leader()
|
||||
signal.pidfd_send_signal(fd, signal.SIGKILL)
|
||||
finally:
|
||||
os.close(fd)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except OSError:
|
||||
info = fields(pid)
|
||||
if info is not None and info[0] not in (b"Z", b"X"):
|
||||
raise
|
||||
if not live and original_leader()[0] == b"T":
|
||||
# The trusted leader is stopped after waiting for the command.
|
||||
# With all other members dead it may return the saved command code.
|
||||
signal.pidfd_send_signal(anchor_fd, signal.SIGCONT)
|
||||
break
|
||||
now = time.monotonic()
|
||||
# Inventory pass 1 remains outside the 250 ms confirmation budget.
|
||||
if until is None:
|
||||
until = now + 0.25
|
||||
if now >= until:
|
||||
raise TimeoutError("cleanup")
|
||||
time.sleep(0.01)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if anchor_fd is not None:
|
||||
os.close(anchor_fd)
|
||||
' "$child" "$session_ref"
|
||||
}
|
||||
finish() {
|
||||
local rc="$?"
|
||||
@@ -278,30 +341,59 @@ finish() {
|
||||
elif [ -n "$child" ]; then
|
||||
kill -KILL "$child" 2>/dev/null || :
|
||||
fi
|
||||
[ -z "$child" ] || wait "$child" 2>/dev/null || :
|
||||
# A refused cleanup may leave an unkillable member. Do not hide rc 86
|
||||
# behind an unbounded wait; the caller retains its existing outer guard.
|
||||
if [ "$rc" != 86 ] && [ -n "$child" ]; then
|
||||
local command_rc=0
|
||||
wait "$child" 2>/dev/null || command_rc=$?
|
||||
[ "$rc" != 0 ] || [ "$command_rc" = 0 ] || rc=87
|
||||
fi
|
||||
[ -z "$session_ref" ] || exec {session_ref}<&-
|
||||
if [ -n "$timer" ]; then kill -KILL "$timer" 2>/dev/null || :; wait "$timer" 2>/dev/null || :; fi
|
||||
exit "$rc"
|
||||
}
|
||||
trap finish EXIT
|
||||
trap 'exit 88' HUP INT TERM
|
||||
"$sleep_bin" "$seconds" & timer=$!
|
||||
"$setsid_bin" "$@" & child=$!
|
||||
# The trusted session leader stays alive through cleanup. Copy code runs as
|
||||
# its child; only after cleanup continues the stopped leader does it return
|
||||
# the saved command status. An open proc directory pins its task identity.
|
||||
"$setsid_bin" /bin/bash -c '
|
||||
set +m
|
||||
kill -STOP "$BASHPID"
|
||||
"$@" & command=$!
|
||||
rc=0; wait "$command" 2>/dev/null || rc=$?
|
||||
kill -STOP "$BASHPID"
|
||||
exit "${rc}"
|
||||
' -- "$@" & child=$!
|
||||
{ exec {session_ref}<"/proc/$child"; } 2>/dev/null || exit 85
|
||||
# Allow session creation the whole inner budget, rather than a 200 ms margin.
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
if [ "$ST_SESSION" = "$child" ]; then verified=1; break; fi
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
[ "$rc" != 3 ] || exit 85
|
||||
if [ "$rc" = 0 ] && [ "$ST_SESSION" = "$child" ] && [ "$ST_STATE" = T ] && [ "$ST_PARENT" = "$$" ]; then
|
||||
verified=1
|
||||
kill -CONT "$child" 2>/dev/null || exit 85
|
||||
break
|
||||
fi
|
||||
if ! kill -0 "$timer" 2>/dev/null; then
|
||||
# Persistent unreadable metadata remains a session refusal, not a
|
||||
# command timeout. A transient read gets only this same inner budget.
|
||||
[ "$rc" = 0 ] || exit 85
|
||||
exit 81
|
||||
fi
|
||||
"$sleep_bin" 0.002
|
||||
done
|
||||
[ "$verified" = 1 ] || exit 85
|
||||
while kill -0 "$child" 2>/dev/null; do
|
||||
while :; do
|
||||
rc=0; stat_fields "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 86
|
||||
case "$ST_STATE" in Z|X) exit 86 ;; esac
|
||||
[ "$ST_STATE" != T ] || break
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
"$sleep_bin" 0.01
|
||||
done
|
||||
kill -0 "$timer" 2>/dev/null || exit 81
|
||||
rc=0; wait "$child" || rc=$?
|
||||
[ "$rc" = 0 ] || exit 87
|
||||
exit 0
|
||||
MOSAIC_TESTER_SUPERVISOR
|
||||
# row253-supervisor-end
|
||||
@@ -309,7 +401,7 @@ MOSAIC_TESTER_SUPERVISOR
|
||||
tester_tools() {
|
||||
local input target p st magic
|
||||
TS_BASH="" TS_SETSID="" TS_SLEEP="" TS_TIMEOUT="" TS_HEAD=""
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head; do
|
||||
for input in /bin/bash /usr/bin/setsid /usr/bin/sleep /usr/bin/timeout /usr/bin/head /usr/bin/python3; do
|
||||
target="$(realpath -e -- "$input" 2>/dev/null)" || return 1
|
||||
case "$target" in /home/*|/root/*|/run/user/*) return 1 ;; esac
|
||||
[ -f "$target" ] && [ -x "$target" ] && [ ! -L "$target" ] || return 1
|
||||
|
||||
Reference in new issue
Block a user