Compare commits

...
28 Commits
Author SHA1 Message Date
jason.woltje ce4fe048bb fix(discord): the connector's STOP refusal names the journal by label (#1873)
Re-check 3 found connector.start() still quoted the journal directory in
its STOP refusal, and the CLI passes a DiscordError through unchanged.
It now uses journalLabel like the CLI and journal refusals. The connector
test asserts the path is absent: 22/1 on the old source, 23/0 after.
2026-11-11 10:22:14 -06:00
jason.woltje 63e2a574af fix(discord): a native fs error never prints its path: CLI choke point and journal labels (#1873)
The discord CLI's top-level handler prints a native Node error that is not
already a labelled refusal as 'cannot <syscall> <label> (<CODE>)'. The journal
and approvals readers and writers name the file by label at the source, so
conversation, control-board and webui callers get the label too. The session
directory check moves to engine-pi.mjs as sessionContinues(). Exit codes and
refusal codes are unchanged.
2026-11-11 10:11:11 -06:00
jason.woltje d9023e28db fix(discord): check-then-read gaps name the file by label, never the path (#1873)
A token file, context file, tool root, data root or setspark key file
that changes between its check and its read failed with a native error
whose message carried the host path. Each read now names the file by
label and code (quietRead): same error object, type and code, so exit
codes, refusal codes and check order are unchanged.
2026-11-11 09:50:14 -06:00
jason.woltje c3fe6b77ad fix(discord): journal.mjs takes BINDING_NAME from errors.mjs, not binding.mjs (#1873)
The journal label import pulled binding.mjs and its config readers into
every journal.mjs importer. The queue test repo copies only journal.mjs
and errors.mjs, so its genesis failed with ERR_MODULE_NOT_FOUND (webui
reads.test.mjs red, 4 tests). BINDING_NAME now lives in errors.mjs and
binding.mjs re-exports it, so its export is unchanged.
2026-11-11 08:53:31 -06:00
jason.woltje 809b1b0100 test(control-board): board API body carries the seen marks label, not the path (#1873)
The board API returns a scan refusal in its JSON error body. The test
starts a board on port 0 over a seen.json that is not an object and
checks the body names seen.json by label, with no sentinel or scratch
path. It fails on BASE, where the body quotes the host path.
2026-11-11 08:53:31 -06:00
jason.woltje 4a9d9d4b89 fix(business): old-path re-export for system-config-problem.mjs (#1873)
rename-b-identity pins one packages/business/src module per moved
organization module; the review round's new module needs its shim.
2026-11-11 08:53:31 -06:00
jason.woltje 87db40e0cd fix(cli,seat,discord,organization,control-board,harness,plugins,webui): review round: launcher reasons, session, discord reads, mosaic-config and keys by label (#1873)
Review "changes 8" fix round for org-redact6.

- launcher: a harness module's throw names its system code or class,
  never its message; preflight, loader, bundle, checkout, sandbox and
  route text is relayed with host paths replaced (relay()).
- claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the
  repository's node_modules, never a path; persona and skill bundle
  errors and sandboxProblem name their file or the sandbox by label.
- seat session: thread store, legacy store, overlap, busy lock and
  newRun refusals name the place under the data root.
- discord: unknown keys, binding names and turn ids are described when
  not plain; approvals, journal and repository read errors name the file
  and code; git drops stderr lines that carry a host path and a spawn
  failure names its code only.
- seat, control-board: registration fields and seen keys are described
  when not plain; invalid seat name and layout state the constraint.
- organization: the registry names the config directory and registry
  file by label; --project is quoted only when it is a plain name;
  systemConfigProblem moves to its own module.
- scripts/mosaic-config.mjs: the config by where it came from, the data
  root and its marker by role, keys and arguments only when plain, and
  error codes in place of raw messages. Exit codes unchanged.
- webui sso-config: an unknown console key is named only when plain.

Sentinel tests in each package's path-redaction-labels file fail on
BASE; pins that quoted a path now assert the label and its absence.
2026-11-11 08:53:31 -06:00
jason.woltje f3f33a6171 fix(organization,cli,discord,seat,control-board): refusals name the field or file by label, never a host path (#1873)
redact6. A refusal names the field, the file or the instance. It never quotes a
config value, a host path, or a raw e.message or stderr that carries one.

- organization: checkCredentialRef names the field and each forbidden root by
  label; OrgError keeps .path only for a document field ($...); the registry,
  fence and lock refusals use the quiet helpers (call and code).
- cli: loadSystem never relays mosaic-config stderr: instance refusals keep
  their code and names, everything else reads as a field and its constraint
  against a labelled config. Project-key, access warning, halt gate, sessions
  file, launch socket, harness relays and the workspace rename name labels.
  Launch-log reasons read a Node system error as its call and code.
- scripts/mosaic-config: configuration not found names where the config came from.
- discord, seat, control-board: config, token, context, tool-root, journal,
  binding, registration and seen-marks refusals name labels.

Exit codes, refusal codes and check order are unchanged. Each new or changed
test fails on BASE be698e602.
2026-11-11 08:53:31 -06:00
jason.woltje 5320e3d2c0 test(cli): the lifecycle fixture host answers definitionScan (#1873)
A4's hire writes an org:hire-<uuid> definition, so the chief's in-session
launch of its hire now reaches the launcher's held-secret check. The fixture's
op table lacked definitionScan and refused invalid-host-request; process.mjs
answers it.
2026-11-11 08:49:15 -06:00
jason.woltje 16cdf5cdc1 fix(organization): an approved hire and the boot copy definitions run the definition check (#1873)
The hire service takes the bus's definition check as scanDefinition and runs
it on the stored proposal entry before preview, check or apply goes further,
and on the approved entry again just before the writers. An approval whose
definition holds a credential the store came to hold after the raise refuses
definition-holds-credential before the decision is consumed; nothing is
written. Without the hook the entry still goes through assertClean whole.

Bus start and credentials.issue also check the boot copy snapshot's
definitions (A4 root agents file definitions, re-applied on every reload),
which no organization file holds.
2026-11-11 08:49:15 -06:00
jason.woltje 1a45a7274b fix(organization): credentials issue and rotate refuse a value already in an organization definition (#1873)
credentials.issue runs a check after it normalizes the value and before the store, the
organization file or memory changes: the value must not appear in the definitions of any
organization the bus serves (instructions, personality and every other field or key). A hit
refuses definition-holds-credential naming the organization, definition and key, never the
value or the credential. An organization file the check cannot read refuses
definition-scan-unreadable (new TI entry). The check runs on the bus loop inside issue, so a
launch admission can no longer pass and then see its definition text become held before spawn.
The final admission re-scan stays as the backstop for values held outside the verb.
2026-11-11 08:49:15 -06:00
jason.woltje 17eecb2d65 fix(organization): final launch admission re-checks the org definition against held credentials (#1873)
Review round (P2): a credential issued between the launcher's definition scan and
spawn could make text already in the definition a held value; a same-date rotate
leaves the organization file's revision unchanged, so the revision fence missed it.
The bus's final request.admission for an instance, the last bus op before spawn,
now runs the held-secret check on the org: definition the instance names, on the
bus loop that issue runs on, and refuses definition-holds-credential.

Self-found: check() takes the scanned entry from the same read as resolveInstance,
before any await can let another check replace the shared organization.
2026-11-11 08:49:15 -06:00
jason.woltje 8fec494694 fix(organization): bus start and launch check org definitions against the held credentials (#1873)
T1 follow-up. A definition's text becomes an agent's contract, but a load ran the
held-secret check with an empty store. Bus start now checks every definitions entry
(the boot copy's org: entries and the file's own) with the live store, and the
launch check asks the bus the same question for the org: definition it just read,
before a contract exists. A hit refuses as definition-holds-credential, a new code
in the refusal table; the refusal names the definition and key, never the text or
the credential. bus start records the refusal in the boot status.
2026-11-11 08:49:15 -06:00
jason.woltje 4a122699a0 fix: preserve tester session identity through cleanup (#1873) 2026-11-11 08:22:31 -06:00
jason.woltje f382699d47 fix: bound tester supervisor cleanup and verify fast sessions (#1873) 2026-11-11 05:13:47 -06:00
jason.woltje 5e93356cfa fix(authority): close chief grant and root fire review gaps (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 89205a6163 feat(authority): resolve hire, fire and role changes through pinned human decisions (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 6342940634 test(cli): clarify cleanup timing limits and namespace failures (#1873) 2026-11-11 03:49:22 -06:00
jason.woltje cfd7e612d3 test(cli): bound supervisor cleanup with a fixed process inventory (#1873) 2026-11-11 03:20:27 -06:00
jason.woltje afe131444c test(cli): retain default service template coverage (#1873) 2026-11-11 02:34:50 -06:00
jason.woltje 0b52249cc3 test(cli): measure legacy and query inner timers at the supervisor (#1873) 2026-11-11 02:11:48 -06:00
jason.woltje b7c9eee603 fix(organization): definition writes go through the bus with the held-secret check (#1873)
Review P1: the CLI wrote definitions with a no-op secret check. setDefinition
now refuses definition-secret-check-required when a write that adds or changes
an entry has no assertClean. New bus verbs definition.import|invent|edit|remove
run the writes for the business's human at the cli with the credential
store's assertClean; the tester, agents and readers refuse. The CLI's
definition writes call the bus and map its codes to messages that never
quote the definition.
2026-11-11 01:15:12 -06:00
jason.woltje 1d07df0d61 feat(organization): org definitions, template hash and diff reads, definition verbs (#1873)
Roles round 2, slice T1 (roles-and-rails design sections 4, 5 and 13).

- The org file's definitions key: each entry {rev, from?, role, instructions,
  personality?} passes checkComposition; a bad entry refuses the whole file,
  naming the definition and key, never a value.
- org:<name> instance references resolve to the file's definition; a missing
  one refuses definition-missing, naming the instance. Template names are
  unchanged.
- Template identity is the name plus a sha256 over the template JSON and its
  contract; templateHashes reads the current hash for each template.
- The writer's setDefinition is the only path that changes definitions: it
  checks the composition, bumps rev, refuses agent actors, and refuses
  definition-in-use for a definition an instance names (A4 adds the change
  decision). write() refuses organization-definitions-protected.
- Definition verbs import, edit, invent and remove need org.hire
  (definition.*.console in VERB_RIGHTS) and refuse every agent caller.
- The diff read lists the primitives that changed and a line diff of the
  instructions. It applies nothing.
- CLI: mosaic org definitions templates|list|show|diff|import|invent|edit|remove.
2026-11-11 01:15:12 -06:00
jason.woltje be698e602b test(cli): json-redaction pins follow the label refusals
The review round changed the config-command relay and the org document
refusal to name the file by label. cli/tests/json-redaction.test.mjs still
required the host path in those two CLI refusals. They now require the
label and the path's absence. The direct scripts/mosaic-config.mjs checks
and the loadSystem check (PL1, deferred) still require the path.
2026-11-11 01:04:15 -06:00
jason.woltje d085b16136 fix(organization,cli): review round: raw fs and git errors, console entry, config, document and --vars refusals name labels
Sol review (org-redact5, changes 6):
- org-service: Node filesystem errors become "cannot <syscall> <label> (<CODE>)";
  code, syscall, deleteStep and deleteReceipt stay. git failures name the verb
  and exit code, never git's stderr.
- org-console: consoleStop names "another checkout", not the entry path. Exit 3.
- config-command: the system config is named by where it came from. Exit 3.
- cli org: documents and raw fs errors are named by kind and code. Exit 2.
- instances: --vars file named by flag and kind. Exit 4.
- hosts: a refused command is named by the basename refusedCommand matched.
- role.mjs (T1): the change is back to the readJsonFile label only.
Sentinel tests for each; pins in last-values, rename-b-commands, org-recovery.
2026-11-11 01:04:15 -06:00
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00
jason.woltje e588fb32cb test(cli): band the P10 inner timer at the supervisor, not around cleanup (#1873)
The row299 root-side pair (mos-1409) spans supervisor start-up and
clean_session, which scans every /proc entry on the host. With about
1,850 processes that adds 0.6 to 1.6 s, so the unchanged inner probe read
2.5 to 3.9 s and failed the [1.25, 2.75) band, while the 1 s inner-timer
mutant read 2.06 to 2.38 s and passed. Widening the bound alone would
have kept that mutant alive.

A second observer pair, also confined to the private test copy, brackets
only the inner timer: begin just before the supervisor starts its sleep,
end at the start of finish(). It writes only for the SO_PEERCRED peer and
at most one pair. The unchanged band now applies to that interval
(2.01 s on base, 1.00 s and 3.00 s for the 1 s and 3 s mutants, so both
fail). The root pair remains required and must contain the timer pair;
probe-inner-timeout already places it before the outer bound.
2026-11-11 00:30:10 -06:00
jason.woltje e3577e9d31 fix(case2): give the case2 fixture host the request-host ops (#1873)
test-case2.sh failed 8 of 21 steps on base from S3b on. Bisect names
f23a998ee (#797, "close project archive admission races") as the first
bad commit: it made every launch call request.admission and updated each
in-process launcher fixture except scripts/case2/launch.mjs. The fixture
host refused the op with invalid-host-request, so no launch started; the
S6/S7 "unauthenticated" and S8/S10 reds followed from the missing launch
capability, not from a separate session change.

The fixture now spreads requestHostOps from packages/cli/tests, the shared
map every other fixture uses, so it tracks the production protocol.
2026-11-11 00:30:10 -06:00
166 changed files with 6525 additions and 1567 deletions

No files matched your search

+12 -5
View File
@@ -42,9 +42,15 @@ green at every step. Not production software — a proven foundation.
rewritten, only pruned via `prune` with a receipt.
6. **Fail closed**: missing or invalid config/policy refuses the operation.
Never improvise around a refusal; diagnose it.
7. **Policy**: missions govern tasks (least-privilege intersection — a task
narrows, never widens). Role authority is declared in `roles/` and changes
only via reviewed commits.
7. **Policy**: missions govern tasks; a task narrows, never widens. The
rails, the core vocabulary of tools and actions, and the shipped role
templates in `roles/` change only by reviewed commit. An organization's
own roles are copies or compositions of known parts. One takes effect
only through a decision, resolved where the organization's owner-set
autonomy level sends it, by default a human with `org.hire`. The
decision lists every grant above the chief's own as a flagged line,
which only the owner approves. Only the chief raises hires, and a hired
agent cannot hire.
8. **Git**: commit only after applicable suites are green. Work on the
owner-authorized `refactor` branch; never force-push. Push remains an explicit
act. Do not merge into `next` or `main` without separate authorization.
@@ -104,7 +110,8 @@ Finish all independent work first, then ask one focused question only when:
Repository gates still apply. In particular, a successful implementation or a
broad request to “finish” does not by itself authorize push, merge, deployment,
release, production changes, policy/role expansion, or access to secrets. Perform
release, production changes, policy/role expansion, or access to secrets. An approved
hire decision is that authority for the one agent it names. Perform
such an action only when the established plan explicitly includes it. If blocked,
report the exact boundary, what is complete, the recommended resolution, and the
specific action that will resume; do not use “waiting for confirmation” as a
@@ -206,7 +213,7 @@ Full reference — usage, fields, exit codes, safety notes:
- `LAYERS.md` — implemented vs deferred layers
- `docs/SESSIONS.md` — session registry
- `adapters/README.md` — the harness adapter contract
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
- `roles/` — shipped role templates and the conductor policy
## Recovery rule
+178 -86
View File
@@ -9,7 +9,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -43,6 +43,7 @@ export const ACTIONS = Object.freeze([
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
'role.hire',
'role.fire',
'role.change',
'credential.mint',
'spend',
'deploy',
@@ -85,6 +86,7 @@ const ROUTED = new Set([
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
'role.hire',
'role.fire',
'role.change',
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write.
'library.write',
]);
@@ -172,7 +174,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -193,6 +195,15 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// The person's task-store writes, distinct from the agent's singular task.* actions.
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
// #1873 T1: the human's definition writes, each with its [allowed, required] argument keys. Kept here,
// not imported, as the sandbox client loads this file; runtime.mjs maps each verb to the verb module
// (organization/src/definition-verbs.mjs names the same four, and a test holds them equal).
export const DEFINITION_BUS_KEYS = Object.freeze({
'definition.import': [['revision', 'template', 'as'], ['revision', 'template']],
'definition.invent': [['revision', 'name', 'definition'], ['revision', 'name', 'definition']],
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
});
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -217,14 +228,6 @@ const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
// Row 194 S6 (D2.2): the stop outcomes a fire's lifecycle.done reports, each written as its own event.
const FIRE_STOPS = ['killed', 'alive'];
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
// required. Each carries the reason the log records.
const HIRE_FIRE_ARGS = Object.freeze({
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
'role.fire': [['name', 'reason'], ['name', 'reason']],
});
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
// What instance.request and the host ops answer about a request.
const lifecycleView = (r) => ({
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
@@ -593,6 +596,7 @@ export class Broker {
#builtin;
#builtinBinding;
#organization;
#hires;
#pluginService;
#credentialVerbs;
#proxyInit;
@@ -656,7 +660,7 @@ export class Broker {
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -679,6 +683,7 @@ export class Broker {
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#hires = hires;
this.#pluginService = pluginService;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
@@ -1552,6 +1557,11 @@ export class Broker {
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
#copyRefused(b, instance) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
if(copy?.level==='root') {
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
if(refusal)fail(refusal);
}
if (!copy || copy.state === 'instance') return;
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
@@ -1839,6 +1849,7 @@ export class Broker {
if (s.tester) fail('tester-refused');
if (s.reader) fail('read-only');
if (s.human) fail('agent-required');
this.#copyRefused(this.#business(s.business),s.role);
const h = this.#own(s);
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
@@ -2274,6 +2285,14 @@ export class Broker {
run: null,
});
}
// Trusted account transport supplies freshly resolved membership, never request arguments.
// This capability reaches only the A4 verbs; it cannot pass existing owner Console guards.
bindHireConsole({business, actor, subject, rights, owner = false, instanceOwner = false}) {
this.#business(business);
identifier(actor);
if (!object(subject) || !['provider','iss','sub'].every(k=>typeof subject[k]==='string' && subject[k]) || !Array.isArray(rights)) fail('human-required');
return this.#cap({business,human:actor,role:null,run:null,via:'console',console:Object.freeze({...subject}),hireMembership:Object.freeze({actor,rights:[...rights],agent:false,owner,instanceOwner,subject:{...subject}})});
}
bindReader({ business }) {
this.#business(business);
return this.#cap({ business, reader: true, role: null, run: null, human: false });
@@ -2431,6 +2450,7 @@ export class Broker {
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
new Set(options.map(o => o.key)).size !== options.length) return null;
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...raise.hire} : null;
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
@@ -3346,7 +3366,8 @@ export class Broker {
const result = await write('instance.fire');
step = 'C';
this.#store.transaction(() => {
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
if(sc.human)this.#event(sc,'action.allowed',{action:'role.fire',target:args.name,reason:args.reason,transaction:result.transaction??null},args.name);
else this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(sc, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null, job, grants: out.grants, grantsLeft: out.grantsLeft }, name);
});
} catch (e) {
@@ -6075,14 +6096,25 @@ export class Broker {
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
{
const result = this.#chiefLifecycle(s, request.verb, structuredClone(args));
return result?.then ? result.catch(error => { throw this.#refused(s, error, request); }) : result;
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
if(request.verb==='hire.templates.console') {
keys(args,[]);const caller=this.#hireCaller(s);
if(!caller.rights.includes('org.hire'))fail('right-required');
if(!this.#hires?.templates)fail('not-configured');
return this.#hires.templates();
}
if (request.verb === 'instance.hire' || request.verb === 'instance.fire') fail('decision-required');
if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' ||
(request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) {
const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args;
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
}
if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (['hire.preview.console','hire.resolve.console'].includes(request.verb))
return this.#hireConsole(s,request.verb,args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -6137,6 +6169,13 @@ export class Broker {
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
}
// #1873 T1: the definition writes need org.hire. Until row 193, as #mayHuman, the business's
// one human (the owner) passes; the tester is refused above and an agent here.
else if (Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
keys(args, ...DEFINITION_BUS_KEYS[request.verb]);
this.#human(s);
if (s.human !== this.#business(s.business).human) fail("human-required");
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -6253,6 +6292,8 @@ export class Broker {
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
} catch (e) {
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
// org-defscan (#1873): the issue's definition check; its reason names where, never the value.
if (e instanceof BusError && ['definition-holds-credential', 'definition-scan-unreadable'].includes(e.code)) throw e;
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
if (e.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
@@ -6272,47 +6313,121 @@ export class Broker {
}
return result;
}
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
// transaction, so the grant is checked before it and the use logged only after it, as
// organization.set's grant is.
#chiefLifecycle(s, verb, a) {
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
this.#agent(s);
const [allowed, required] = HIRE_FIRE_ARGS[action];
keys(a, allowed, required);
this.#hireFireArgs(action, a);
const args = a;
const b = this.#business(s.business);
if (b.launch?.by !== s.role) fail('role-not-chief');
if (!this.#organization) fail('not-configured');
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
// (decision-required), and these verbs take none.
const authority = { target: args.name };
this.#checkAuthority(s, action, authority);
// D2.4: a kept name is neither hired nor fired.
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
const actor = b.roles[s.role].definition + '.' + s.role;
const context = { instance: s.role, run: s.run, reason: args.reason };
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
const { reason: _, ...written } = args;
const write = (op) => {
try {
const result = this.#organization(s.business, op, written, actor, context);
return result?.then ? result.catch(error => this.#organizationFail(error, verb)) : result;
} catch (e) {
this.#organizationFail(e, verb);
}
#hireCaller(s) {
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
if(s.hireMembership) return s.hireMembership;
if(s.human!==this.#business(s.business).human)fail('human-required');
return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console};
}
async #raiseHire(s, input, immediate) {
const b=this.#business(s.business);
// Change is a replacement composition and uses the existing role.hire grant.
const chiefAllowed=()=>{
this.#agent(s);
if(s.role!==this.#business(s.business).launch?.by)fail('role-not-chief');
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
};
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
const finish = result => {
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
return result;
};
return result?.then ? result.then(finish) : finish(result);
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
else chiefAllowed();
if(!this.#hires)fail('not-configured');
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt});
// Recheck the chief after asynchronous preparation; a released run cannot raise.
if(!immediate)chiefAllowed();
else {
const caller=this.#hireCaller(s);
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
}
const action=`role.${p.op}`;
this.#store.transaction(()=>{
const raiser=immediate?'human':s.role;
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,'human',`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p},did);
});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
return {...this.#decisionView(this.#decision(s,did)),hire:p};
}
async #hireConsole(s,verb,a) {
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
identifier(a.id);
const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id);
let p=raise?.hire;
if(!['role.hire','role.fire','role.change'].includes(d.action) || !p)fail('decision-not-eligible');
const closed=this.#closed(d.id);
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(!caller.rights.includes('org.hire'))fail('right-required');
if(a.adjust!==undefined) {
if(!this.#hires?.preview)fail('not-configured');
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
}
if((p.flagged.length || p.credentialLines.length) && !caller.owner || p.level==='root' && !caller.instanceOwner)fail('hire-owner-required');
if(verb==='hire.preview.console')return {id:d.id,business:s.business,question:d.question,kind:'hire',options:JSON.parse(d.options),...p};
if(a.digest!==p.digest)fail('decision-stale');
if(a.choice==='yes') { if(!this.#hires)fail('not-configured'); await this.#hires.check(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}); }
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
// A durable close/consume precedes the external file effect. A crash never replays an approval.
this.#store.transaction(()=>{
if(this.#closed(d.id))fail('decision-closed');
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',caller.actor,a.choice,'console');
this.#event(s,'human.input',{kind:'answer',decision:d.id,op:'resolved',choice:a.choice,via:'console',subject:s.console},d.id);
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
});
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
if(!this.#hires)fail('not-configured');
try {
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust});
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
} catch(e) {
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:e.code??'outcome-unknown'},d.id));
throw e;
}
}
#expireHire(s,d,p,insideTransaction=false) {
if(!p || Date.parse(p.expiresAt)>Math.max(Date.now(),this.#clock) || !Number.isFinite(Date.parse(p.expiresAt)))return false;
const close=()=>{
if(this.#closed(d.id))return;
this.#store.run('INSERT INTO decision_events(decision,at,op,by,via) VALUES(?,?,?,?,?)',d.id,this.#now(),'withdrawn','expiry','broker');
this.#event({business:s.business,role:null,run:null},'action.allowed',{operation:'hire.expired',decision:d.id},d.id);
};
if(insideTransaction)close();else this.#store.transaction(close);
return true;
}
#expireHires(s) {
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
}
async #stopCopyApproved(s,p) {
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
const b=this.#business(s.business);
if(!b.roles[instance])return;
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
const set=this.#store.transaction(()=>{
const held=this.#threaded(s.business,instance)?this.#threadHolders(s.business,instance):[this.#holder(s.business,instance)].filter(h=>h?.op==='claim');
for(const h of held)this.#claimEnd(s,h,'revoke',p.id);
const runs=[...this.#runs.values()].filter(r=>r.business===s.business&&r.role===instance).map(r=>r.run);
const request=runs.length?randomUUID():null;
if(request)this.#event(s,'action.allowed',{action:'lifecycle.end',request,via:'human',by:s.human,runs,reason:'Approved fire decision',kill:true},instance);
this.#event(s,'action.allowed',{action:'fire.stopped',request,decision:p.id},instance);
return this.#grantSet(s.business,instance);
});
if(set.length)await this.#stepG(s.business,instance,set,{job:p.id,reason:'Approved fire decision',by:s.human,flag:{fire:true}});
}
// The existing row-281 fire executor, now entered only after a consumed human decision.
#fireApproved(s,p) {
const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire';
const b=this.#business(s.business),authority={target:p.name};
const write=op=>{
try {const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human,{decision:p.id,digest:p.digest});
return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result;
} catch(e) {this.#organizationFail(e,verb);}
};
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
if (b.launch?.by === args.name) fail('organization-instance-referenced');
@@ -6354,7 +6469,7 @@ export class Broker {
// A non-empty set: step G, B and C run as a job, and the verb answers now with no transaction.
if (stopped.job) {
const { job, set, ...rest } = stopped;
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: null };
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human };
this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set));
return { ...rest, job, revoking: set.map((g) => g.label), done: false };
}
@@ -6363,41 +6478,13 @@ export class Broker {
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
const finish = result => {
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s,'action.allowed',{action:'role.fire',operation:'hire.apply',decision:p.id,transaction:result.transaction??null},args.name);
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
};
return result?.then ? result.then(finish) : finish(result);
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
identifier(args.name);
// FED R3 r4 (sage-2557): `user` is the granted user's principal, as checkBusiness refuses it.
if (args.name === 'human' || args.name === 'tester' || args.name === 'user') fail('invalid-request');
string(args.reason, 500);
if (action === 'role.fire') return;
identifier(args.definition);
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
}
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
#hireable(s, definition) {
if (!this.#roleFile) fail('not-configured');
const b = this.#business(s.business);
const load = (name) => {
try {
return this.#roleFile(name);
} catch (e) {
if (e.name === 'BusinessError') fail('definition-not-hireable');
throw e;
}
};
const def = load(definition);
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
@@ -6413,6 +6500,8 @@ export class Broker {
if ((verb === "instance.hire" || verb === "instance.fire") && e.code === "definition-not-hireable") fail(e.code);
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
if (e.code === "instance-kept") fail(e.code);
// #1873 T1: the definition writer's refusals pass by code; a composition refusal stays invalid-request.
if (Object.hasOwn(DEFINITION_BUS_KEYS, verb) && /^definition-[a-z-]+$/.test(e.code ?? "")) fail(e.code);
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
@@ -7296,6 +7385,7 @@ export class Broker {
identifier(a.id);
if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id);
if (['role.hire','role.fire','role.change'].includes(d.action) && ['decision.resolve','decision.resolve.console'].includes(verb)) fail('hire-resolution-required');
if (this.#closed(d.id)) fail('decision-closed');
// Recheck eligibility, context digest and membership in the current options in this
// transaction, before any resolution, audit, notification or grant execution.
@@ -7755,6 +7845,7 @@ export class Broker {
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(s);
if (verb === 'inbox')this.#expireHires(s);
if (verb === 'inbox')
return this.#store
.all(
@@ -7800,6 +7891,7 @@ export class Broker {
case 'decisions': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['list'], ['list']);
this.#expireHires(s);
if (a.list === 'roles')
return this.#store
.all(
+12 -3
View File
@@ -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 } : {};
+18 -9
View File
@@ -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) {
+114
View File
@@ -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 });
}
+12 -13
View File
@@ -198,12 +198,12 @@ export class ModelKeys {
// Host-only callbacks are the existing synchronous organization path.
hire(instance, check, publish) {
return this.#exclusive(async () => {
check(); // Fresh validation and captured revision AFTER controller queue.
await check(); // Fresh validation and captured revision AFTER controller queue.
const existed = this.hasRecord(instance);
try {
await this.#issue(instance);
check(); // Recheck captured revision AFTER HTTP, before publication.
return publish();
await check(); // Recheck captured revision AFTER HTTP, before publication.
return await publish();
} catch (error) {
// A newly issued generation has not escaped to queued admission yet.
if (!existed && this.hasRecord(instance)) {
@@ -347,7 +347,7 @@ export function modelOrganization({ modelKeys, writer }) {
};
const wrap = invoke => (business, verb, args, actor, context) => {
const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
if (!isRevoke) return invoke(business, verb, args, actor, context);
return (async () => {
if (pending.has(business)) fail('organization-busy');
const agent = args.name ?? args.instance, instance = business + '/' + agent;
@@ -355,19 +355,11 @@ export function modelOrganization({ modelKeys, writer }) {
if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
const { revision } = writer.read(business);
if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
const checkHire = () => {
if (writer.read(business).revision !== revision) fail('revision-conflict');
const checked = invoke(business, 'instance.hireCheck', args, actor, context);
if (checked.revision !== revision) fail('revision-conflict');
return checked;
};
if (!isRevoke) checkHire(); // No registration or proxy I/O before shared checks.
const prepare = { instance, revision, remoteRevoked: false };
pending.set(business, prepare);
const existed = modelKeys.hasRecord(instance);
try {
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
else return await modelKeys.hire(instance, checkHire, () => invoke(business, verb, args, actor, context));
return invoke(business, verb, args, actor, context);
} catch (error) {
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
@@ -375,5 +367,12 @@ export function modelOrganization({ modelKeys, writer }) {
} finally { pending.delete(business); }
})();
};
return { writer: adapter, revoke, wrap };
const hire = async (business, agent, check, publish) => {
if (pending.has(business)) fail('organization-busy');
const prepare = { instance: `${business}/${agent}`, revision: writer.read(business).revision, remoteRevoked: false };
pending.set(business, prepare);
try { return await modelKeys.hire(prepare.instance, check, publish); }
finally { pending.delete(business); }
};
return { writer: adapter, revoke, wrap, hire };
}
+7 -1
View File
@@ -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),
);
}
+15
View File
@@ -1,6 +1,7 @@
// Static refusal table: each listed code maps to one rail and next step. These strings
// never interpolate caller arguments, credential references, policy values or file bytes.
const entries = [];
const group = (codes, rail, nextStep) => {
const guidance = Object.freeze({ rail, nextStep });
for (const code of codes.split(' ')) {
@@ -8,6 +9,10 @@ const group = (codes, rail, nextStep) => {
entries.push([code, guidance]);
}
};
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
group(
@@ -85,6 +90,16 @@ group(
"Role configuration and protected instance controls",
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
);
group(
"definition-holds-credential",
"R1: no held credential in an organization definition's role text",
"Ask the organization owner to remove the credential from the named definition and key, then restart the bus, relaunch the agent or issue the credential again. For a hire, deny it and have the chief raise it again without that text. Do not copy the text anywhere to inspect it."
);
group(
"definition-scan-unreadable",
"R1/R3: a credential is issued only after every organization's definitions are checked",
"Ask the instance owner to repair the organization file the bus cannot read, then issue the credential again. Nothing was stored."
);
group(
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
"Role task ownership, allowed fields and current task state",
+72 -10
View File
@@ -1,3 +1,5 @@
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationPlugins } from '../../plugins/src/organization.mjs';
import { pluginService } from '../../plugins/src/tools.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
@@ -35,6 +37,10 @@ import { createProxyLock } from './proxy-lock.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ORG_PREFIX, orgDefinitionName } from '../../organization/src/definitions.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs';
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
@@ -59,7 +65,7 @@ import { discordProcessingHooks } from '../../discord/src/processing.mjs';
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.fire']);
// FED R4 (#1801, design 3.7): the revocation timer's period.
export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
@@ -383,6 +389,18 @@ export async function startBroker({
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
// #1873 T1 follow-up (org-defscan): no organization definition's text holds a credential this store
// holds. The boot copy's org: definitions first, then every entry of the file itself, named or not.
// A hit refuses the start; the refusal names the definition and key, never the text.
const clean = value => credentials.assertClean(value);
for (const [id, b] of Object.entries(businesses)) {
scanDefinitions(b.definitions, clean, { prefix: ORG_PREFIX });
if (!organization) continue;
const raw = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }).readRaw(id);
if (raw.readable) scanDefinitions(raw.document.definitions, clean);
}
// The boot copy snapshot's definitions (the root agents file's, A4): every reload re-applies them.
if (copies?.definitions !== undefined) scanDefinitions(copies.definitions, clean, { boot: true });
if (proxy) {
const inventory = modelRunInventory(dataRoot, modelRuns, credentials);
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: org ? instanceConfigDir : organization.dir, org: org?.id ?? null, lock: proxyLock,
@@ -651,9 +669,19 @@ export async function startBroker({
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
if (instance !== undefined) {
let now;
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
if (now === undefined) return no('organization-unreadable');
if (now !== launched) return no('organization-changed');
try { now = writer?.read(id); } catch { return no('organization-unreadable'); }
if (now?.revision === undefined) return no('organization-unreadable');
if (now.revision !== launched) return no('organization-changed');
// org-defscan (#1873): the launch boundary. A credential issued since the launcher's scan
// runs on this same loop, so the org: definition the instance names is checked again here,
// in the reply the launcher spawns after with no further wait.
const name = orgDefinitionName(now.document.roles?.[instance]?.definition);
if (name !== null) {
const clean = value => credentials.assertClean(value);
let held;
try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; }
if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL);
}
}
for (const p of projects) {
const result = admission({ business: id, project: p });
@@ -816,8 +844,6 @@ export async function startBroker({
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
// Internal read-only path: no reload, journal, credential or publication.
if(verb==='instance.hireCheck')return instances.checkHire(id,args);
let result;
if (['limits.set', 'instances.set'].includes(verb) && settings.current(id).plugins?.['model-provider']?.length) {
return catalogueFor(id, { fresh: true }).then(catalogue => {
@@ -849,9 +875,11 @@ export async function startBroker({
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name,revision:args.revision},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
@@ -862,6 +890,24 @@ export async function startBroker({
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}):null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
hires: organization ? (()=>{
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
return {...service,apply:async(id,decision,caller)=>{
const proposal=service.read(decision);
const publish=async()=>{
let approved=caller;
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
await modelKeys.prepareRevoke(instance);
approved={...caller,beforeWrite:()=>modelKeys.completeRevoke(instance)};
}
const result=await service.apply(id,decision,approved);reload(id);return result;
};
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
return publish();
}};
})() : null,
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
@@ -878,7 +924,8 @@ export async function startBroker({
return settings;
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }),
organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
@@ -1126,6 +1173,20 @@ export async function startBroker({
.filter((c) => c.instance === business + '/' + role)
.map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) }));
}
// #1873 T1 follow-up (org-defscan): the launcher's check of the org: definition it is about to
// launch, as it read it from the organization file. Answers where a held credential sits, or null;
// never the text. A malformed ask refuses, so the launch refuses.
function definitionScan({ business, definition, entry } = {}) {
if (typeof business !== 'string' || !Object.hasOwn(businesses, business) || typeof definition !== 'string'
|| !definition.startsWith(ORG_PREFIX) || definition.length > 128 || entry === null || typeof entry !== 'object' || Array.isArray(entry))
throw new BusError('invalid-request');
let size;
try { size = JSON.stringify(entry).length; } catch { throw new BusError('invalid-request'); }
if (size > 4 * 1024 * 1024) throw new BusError('invalid-request');
const clean = value => credentials.assertClean(value);
try { clean(definition); } catch { return { held: 'name' }; }
return { held: heldPath(entry, clean) };
}
// C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass
// still running skips the tick. Only codes are logged, never git's output.
const timed = Object.values(businesses).flatMap(business => Object.entries(business.projects ?? {})
@@ -1249,7 +1310,7 @@ export async function startBroker({
return refused(['model-provider-unavailable','model-catalogue-unavailable','model-harness-incompatible'].includes(answer?.code) ? answer.code : 'model-provider-unavailable');
},
modelPreflight: async ({ business, role }) => {
const current = settings ? settings.current(business) : businesses[business];
const current = organization ? loadOrg(business) : businesses[business];
if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
},
@@ -1299,6 +1360,7 @@ export async function startBroker({
sessionClearStart: (r) => broker.sessionClearStart(r),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
definitionScan,
launches: bound,
readers: readCaps,
async close() {
+6 -370
View File
@@ -8,7 +8,6 @@ import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../organization/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
@@ -711,380 +710,17 @@ test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
// each becomes its own event against the instance. Only a fire's end carries them.
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
const { b, call, allowed } = hireSetup(t);
call('chief', 'instance.fire', FIRE);
const [end] = allowed('lifecycle.end');
const request = end.body.request;
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
// sage-2289: only an alive stop carries a reason, and only unknown.
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
assert.equal(closed.state, 'closed');
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
// A terminate's end sends SIGTERM only, so its done has no stops.
const term = call('human', 'instance.end', { instance: 'pm' }).request;
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
assert.equal(allowed('lifecycle.end.killed').length, 1);
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.during = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = ([, verb]) => verb === 'instance.fire' && store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
// step A revokes, ends or marks anything, so the instance isn't left firing.
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
writer.during = null;
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
assert.equal(lastClaim('coder').op, 'claim');
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busOrganization(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
test('A4 the retired lifecycle verbs always require a decision and never call the writer',t=>{
const {call,organization}=setup(t,KEPT);
for(const who of ['chief','coder','human'])for(const verb of ['instance.hire','instance.fire'])
assert.throws(()=>call(who,verb,{name:'coder',definition:'coder',reason:'help'}),{code:'decision-required'});
assert.equal(organization.length,0);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = hireSetup(t);
const { b, call, store } = setup(t,LISTED);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
+1 -1
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
+336
View File
@@ -0,0 +1,336 @@
// #1873 T1 follow-up (org-defscan): bus start runs the live credential store's held-secret check on
// every organization definition, and the launch check asks the bus the same question. A hit names
// the definition and the key, never the text there or the credential's name.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { BusError } from '../src/broker.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, DEFINITION_SCAN_UNREADABLE, heldPath, scanDefinitions, scanOrganizations } from '../src/definition-scan.mjs';
import { startBroker } from '../src/runtime.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
// The fixture bus holds SENTINEL as coder's gitea token.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const UNHELD = 'sk-test-UNHELD-LOOKS-LIKE-A-TOKEN-0000';
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
// An organization whose coder names org:builder, a copy of the coder template; `edit` changes the
// builder entry before the file is written. `held` is the coder's gitea token at start.
function org(t, edit = (entry) => entry, { held = SENTINEL } = {}) {
const root = tmp(t, 'defscan-');
const builder = edit(entryFromTemplate(readTemplate(REPO_ROLES, 'coder')));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
writeFileSync(f.doc.roles.coder.credentials.gitea.file, held);
const file = join(root, 'config/businesses/acme.json');
const boot = () => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const start = (b = boot()) => startBroker({ dataRoot: f.dataRoot, businesses: b.businesses, organization: b.organization });
return { root, f, file, boot, start };
}
function refusedWithout(e, reason) {
assert.ok(e instanceof BusError, String(e));
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
assert.equal(e.reason, reason);
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
assert.ok(!text.includes(SENTINEL), 'the refusal never carries the value');
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
}
return true;
}
// The start's outcome: the refusal, or "started" after closing a bus that should not have started.
const outcome = (start) => start.then(async (bus) => { await bus.close(); return 'started'; }, (e) => e);
function nothingWritten(root) {
for (const path of files(root)) {
if (path.endsWith('.token') || path.endsWith('acme.json')) continue;
assert.ok(!readFileSync(path).includes(SENTINEL), path);
}
}
test('org-defscan: the refusal code is in the TI refusal table with a next step', () => {
const entry = REFUSALS[DEFINITION_HOLDS_CREDENTIAL];
assert.ok(entry, 'definition-holds-credential is a known refusal');
assert.match(entry.rail, /^R\d+:/);
assert.match(entry.nextStep, /definition/);
assert.match(entry.nextStep, /issue the credential again/);
assert.match(entry.nextStep, /For a hire, deny it/);
// The issue-time check's fail-closed code for an organization file it cannot read.
const unreadable = REFUSALS[DEFINITION_SCAN_UNREADABLE];
assert.ok(unreadable, 'definition-scan-unreadable is a known refusal');
assert.match(unreadable.rail, /^R\d+/);
assert.match(unreadable.nextStep, /organization file/);
assert.equal(new BusError(DEFINITION_SCAN_UNREADABLE).nextStep, unreadable.nextStep);
});
test('org-defscan: a held credential in a definition\'s instructions refuses bus start, naming definition and key only', async (t) => {
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nThe forge token is ${SENTINEL}.\n` }));
refusedWithout(await outcome(o.start()), 'definitions.builder instructions holds a credential this bus holds');
nothingWritten(o.root);
});
test('org-defscan: a held credential in a personality field refuses bus start the same way', async (t) => {
const o = org(t, (entry) => ({ ...entry, personality: { summary: `Careful. Key ${SENTINEL}.`, traits: ['plain'] } }));
refusedWithout(await outcome(o.start()), 'definitions.builder personality.summary holds a credential this bus holds');
const p = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `x${SENTINEL}`] } }));
refusedWithout(await outcome(p.start()), 'definitions.builder personality.traits[1] holds a credential this bus holds');
nothingWritten(o.root);
nothingWritten(p.root);
});
test('org-defscan: a definition in the file but not in the boot copy is checked too', async (t) => {
const o = org(t);
const boot = o.boot();
// Written after the load and before the bus starts: no instance names it, so only the raw read sees it.
const doc = JSON.parse(readFileSync(o.file, 'utf8'));
doc.definitions.spare = { ...doc.definitions.builder, instructions: `Spare: ${SENTINEL}\n` };
writeJson(o.file, doc);
refusedWithout(await outcome(o.start(boot)), 'definitions.spare instructions holds a credential this bus holds');
});
test('org-defscan: a clean file starts, and token-looking text the store does not hold starts', async (t) => {
for (const edit of [(entry) => entry, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample: ${UNHELD}\n`, personality: { summary: `Says ${UNHELD}.` } })]) {
const o = org(t, edit);
const bus = await o.start();
try {
// The launch check's question: a clean entry answers null, a held one names the key only.
const entry = o.boot().businesses.acme.definitions['org:builder'];
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
const hit = bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { ...entry, instructions: `x ${SENTINEL}` } });
assert.deepEqual(hit, { held: 'instructions' });
// A malformed question refuses rather than answering clean.
for (const bad of [{}, { business: 'acme', definition: 'builder', entry }, { business: 'other', definition: 'org:builder', entry },
{ business: 'acme', definition: 'org:builder', entry: null }, { business: 'acme', definition: 'org:builder', entry: [entry] }])
assert.throws(() => bus.definitionScan(bad), (e) => e.code === 'invalid-request');
} finally {
await bus.close();
}
}
});
// The coder's gitea credential as the human verb issues or rotates it.
function verbs(o, bus) {
const human = bus.broker.bindHuman({ business: 'acme', human: o.f.doc.human, via: 'cli', outsideAgent: true });
return {
issue: (token) => bus.broker.request(human, { verb: 'credentials.issue', args: { agent: 'coder', service: 'gitea', token, date: '2099-01-01' } }),
revoke: () => bus.broker.request(human, { verb: 'credentials.revoke', args: { agent: 'coder', service: 'gitea', reason: 'test' } }),
};
}
// Whether the bus holds `value` now, asked the way the launch check asks.
const holds = (bus, value) => bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { instructions: `x ${value}` } }).held !== null;
const state = (bus) => bus.credentials.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea')?.state;
function issueRefused(e, value, reason) {
assert.ok(e instanceof BusError, String(e));
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
assert.equal(e.reason, reason);
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
assert.ok(!text.includes(value), 'the refusal never carries the value');
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
}
return true;
}
// No file under the root holds `value` except the organization file it was written into and that
// file's own history copies.
function onlyInOrganization(root, value) {
for (const path of files(root))
if (!path.endsWith('acme.json') && !path.includes('/businesses/.history/acme/')) assert.ok(!readFileSync(path).includes(value), path);
}
test('org-defscan: a credential issued between the final launch admission and spawn is refused at issue; the store stays as it was', async (t) => {
// The definition carries text that is not held at start. After the launch's final admission answers
// ready, the human rotates the coder's token to that text with the same date. On the bus before this
// fix the rotate succeeded and the agent spawned with a held value in its contract.
const FIRST = 'fake_first_token_0123456789';
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${SENTINEL}\n` }), { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const v = verbs(o, bus);
// A clean issue still works.
assert.equal(v.issue(FIRST).state, 'valid');
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
const changes = files(o.root).filter((p) => p.endsWith('changes.jsonl')).map((p) => [p, readFileSync(p, 'utf8')]);
const entry = o.boot().businesses.acme.definitions['org:builder'];
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
assert.equal(final().ready, true);
// Between the admission's answer and the spawn: the rotate.
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder instructions holds the credential being issued'));
// Nothing changed: the old token is held, the new one is not, the file and its receipts are as they were.
assert.ok(holds(bus, FIRST) && !holds(bus, SENTINEL));
assert.equal(state(bus), 'valid');
assert.equal(writer.read('acme').revision, revision);
for (const [p, text] of changes) assert.equal(readFileSync(p, 'utf8'), text, p);
onlyInOrganization(o.root, SENTINEL);
assert.equal(final().ready, true, 'the contract the agent gets holds no held value');
});
test('org-defscan: issue over a revoked entry refuses the same way for a personality field, and a clean issue then works', async (t) => {
const o = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `uses ${SENTINEL}`] } }), { held: 'fake_boot_token_5555555555' });
const bus = await o.start();
t.after(() => bus.close());
const v = verbs(o, bus);
v.revoke();
assert.equal(state(bus), 'revoked');
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder personality.traits[1] holds the credential being issued'));
assert.equal(state(bus), 'revoked');
assert.ok(!holds(bus, SENTINEL));
onlyInOrganization(o.root, SENTINEL);
const issued = v.issue('fake_clean_token_4444444444');
assert.deepEqual([issued.verb, issued.state], ['issue', 'valid']);
});
test('org-defscan: a value held without the issue verb still refuses the launch at its final admission, even when the file revision is unchanged', async (t) => {
// Defence behind the issue check: the store comes to hold text already in the definition by a path
// other than the human verb, which leaves the organization file's bytes, so its revision, as they were.
const ROTATED = 'fake_rotated_token_9876543210';
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${ROTATED}\n` }));
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
assert.equal(final().ready, true);
bus.credentials.issue('acme/coder', 'gitea', ROTATED, '2099-01-01');
assert.equal(writer.read('acme').revision, revision, 'the store change left the file as it was');
assert.deepEqual(final(), { ready: false, revision: null, code: DEFINITION_HOLDS_CREDENTIAL });
// An instance on a shipped template is not asked about a definition.
assert.equal(bus.admission({ business: 'acme', projects: [], instance: 'reviewer', revision }).ready, true);
});
test('org-defscan: issue checks the other organizations the bus serves, and refuses when one\'s file cannot be read', async (t) => {
// The bus serves acme and beta. beta's file has a definition holding text acme's coder then tries to
// be issued; later beta's file stops being JSON. Neither issue changes acme's store or file.
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const betaFile = join(o.root, 'config/businesses/beta.json');
const acme = JSON.parse(readFileSync(o.file, 'utf8'));
const spare = { ...acme.definitions.builder, personality: { summary: 'Spare.', voice: `quotes ${SENTINEL}` } };
writeJson(betaFile, { ...acme, id: 'beta', definitions: { spare } });
const bus = await o.start({ ...boot, businesses: { ...boot.businesses, beta: { ...boot.businesses.acme, id: 'beta' } } });
t.after(() => bus.close());
const v = verbs(o, bus);
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization beta definitions.spare personality.voice holds the credential being issued'));
writeFileSync(betaFile, '{ not json');
const CLEAN = 'fake_clean_token_4444444444';
assert.throws(() => v.issue(CLEAN), (e) => e instanceof BusError && e.code === DEFINITION_SCAN_UNREADABLE && !JSON.stringify(e).includes(CLEAN) && !String(e.stack).includes(CLEAN));
for (const value of [SENTINEL, CLEAN]) assert.ok(!holds(bus, value));
assert.equal(state(bus), 'valid');
assert.equal(writer.read('acme').revision, revision);
onlyInOrganization(o.root, CLEAN);
// With beta's file readable again, the clean issue goes through.
writeJson(betaFile, { ...acme, id: 'beta', definitions: {} });
assert.equal(v.issue(CLEAN).state, 'valid');
assert.ok(holds(bus, CLEAN));
});
test('org-defscan: the issue check covers every organization the bus serves, and an unreadable file refuses', () => {
const held = 'HELD-VALUE-77';
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
const own = { id: 'acme', document: { definitions: { builder: { instructions: 'fine' } } } };
const files = { beta: { readable: true, document: { definitions: { spare: { instructions: 'fine', personality: { voice: `say ${held}` } } } } },
gamma: { readable: true, document: {} }, broken: { readable: false } };
const reads = [];
const readRaw = (id) => { reads.push(id); if (id === 'throws') throw new Error('EACCES'); return files[id]; };
assert.doesNotThrow(() => scanOrganizations({ organizations: ['acme', 'gamma'], own, readRaw }, assertClean));
assert.deepEqual(reads, ['gamma'], 'the issuing organization is the document already read');
assert.throws(() => scanOrganizations({ organizations: ['acme', 'gamma', 'beta'], own, readRaw }, assertClean),
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'organization beta definitions.spare personality.voice holds the credential being issued' && !JSON.stringify(e).includes(held));
assert.throws(() => scanOrganizations({ organizations: ['acme', { ...own, id: 'x' }.id], own: { id: 'acme', document: { definitions: { [held]: {} } } }, readRaw: () => ({ readable: true, document: {} }) }, assertClean),
(e) => e.reason === 'organization acme definitions.* key holds the credential being issued' && !e.reason.includes(held));
for (const bad of ['broken', 'throws', 'missing'])
assert.throws(() => scanOrganizations({ organizations: ['acme', bad], own, readRaw }, assertClean), (e) => e.code === DEFINITION_SCAN_UNREADABLE && e.reason === undefined);
});
test('org-defscan: heldPath names the first place, a key by its parent, and never the text', () => {
const held = 'HELD-VALUE-77';
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', 'b'] } }, assertClean), null);
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', `b${held}`] } }, assertClean), 'personality.traits[1]');
assert.equal(heldPath({ role: { tools: [held] } }, assertClean), 'role.tools[0]');
assert.equal(heldPath({ personality: { [held]: 'x' } }, assertClean), 'personality key');
assert.equal(heldPath({ [`${held}`]: 'x' }, assertClean), 'key');
assert.throws(() => scanDefinitions({ 'org:a': { instructions: 'ok' }, 'org:b': { instructions: held } }, assertClean, { prefix: 'org:' }),
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'definitions.b instructions holds a credential this bus holds');
// A name outside the loader's pattern is not quoted.
assert.throws(() => scanDefinitions({ 'Bad Name': { instructions: held } }, assertClean), (e) => e.reason === 'definitions.* instructions holds a credential this bus holds');
assert.throws(() => scanDefinitions({ [held]: { instructions: 'ok' } }, assertClean), (e) => e.reason === 'definitions.* key holds a credential this bus holds' && !e.reason.includes(held));
assert.doesNotThrow(() => scanDefinitions(undefined, assertClean));
});
test('org-defscan: an approved hire whose definition holds a credential issued after the raise refuses before the decision is consumed', async (t) => {
// The chief raises a hire whose instructions carry text the store does not hold yet. The proposal is
// private, so the issue-time check cannot see it, and the store then comes to hold that text. The
// approval runs the same definition check as start, launch and issue: the refusal names the code, the
// decision stays open, and the organization file is as it was.
const LATER = 'fake_later_token_0123456789';
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const inline = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: `Read and report.\nExample ${LATER}\n` };
const raised = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'scout', role: { inline }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
// Issued through the store, so the organization file and its revision stay as they were and the
// approval reaches the definition check rather than the revision check.
bus.credentials.issue('acme/coder', 'gitea', LATER, '2099-01-01');
assert.ok(holds(bus, LATER));
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const before = readFileSync(o.file);
const revision = writer.read('acme').revision;
const owner = bus.broker.bindConsole({ business: 'acme', human: o.f.doc.human, subject: { provider: 'test', iss: 'https://issuer.test', sub: 'owner' } });
const refused = (e) => {
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
for (const text of [e.message, String(e.reason), String(e.stack), JSON.stringify(e)]) assert.ok(!text.includes(LATER), 'the refusal never carries the value');
return true;
};
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
// Nothing was written.
assert.deepEqual(readFileSync(o.file), before);
assert.equal(writer.read('acme').revision, revision);
assert.ok(!Object.hasOwn(writer.read('acme').document.roles, 'scout'));
// The decision was not consumed: a second approval meets the same check, not decision-closed.
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
assert.deepEqual(readFileSync(o.file), before);
// A hire with clean text, raised and approved on the same bus, is written as before.
const clean = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'helper', role: { inline: { ...inline, instructions: 'Read and report.\n' } }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
assert.equal((await bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: clean.id, choice: 'yes', digest: clean.hire.digest } })).choice, 'yes');
assert.ok(Object.hasOwn(writer.read('acme').document.roles, 'helper'));
onlyInOrganization(join(o.root, 'config/businesses'), LATER);
});
test('org-defscan: the boot copy snapshot\'s definitions (the root agents file\'s) are checked at start and at issue', async (t) => {
// A4: a root definition reaches an organization through the boot copy snapshot, which every reload
// re-applies. Its text is in no organization file, so the start and issue checks read the snapshot.
const LATER = 'fake_later_token_0123456789';
const snapshot = (instructions) => ({ org: 'acme', root: 'ok', rootAgents: {}, copies: {},
definitions: { rooted: { ...entryFromTemplate(readTemplate(REPO_ROLES, 'coder')), instructions } } });
const startWith = (o, copies, b = o.boot()) => startBroker({ dataRoot: o.f.dataRoot, businesses: b.businesses, organization: b.organization, copies });
// Held at start: the start refuses, naming the snapshot's definition and key.
const held = org(t);
refusedWithout(await outcome(startWith(held, snapshot(`Root copy ${SENTINEL}\n`))), 'boot copy definitions.rooted instructions holds a credential this bus holds');
// Not held at start: the bus starts, then issuing that text refuses and the store is as it was.
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const bus = await startWith(o, snapshot(`Root copy ${LATER}\n`));
t.after(() => bus.close());
assert.throws(() => verbs(o, bus).issue(LATER), (e) => issueRefused(e, LATER, 'organization acme boot copy definitions.rooted instructions holds the credential being issued'));
assert.ok(!holds(bus, LATER));
assert.equal(state(bus), 'valid');
assert.equal(verbs(o, bus).issue('fake_clean_token_4444444444').state, 'valid');
});
@@ -0,0 +1,74 @@
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
// and the writer runs the credential store's held-secret check on what it would write.
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fixture-held-token-t1-5c0e9a';
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
});
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const bus = await startBroker({ dataRoot: data, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const revision = () => b.request(human, { verb: 'limits' }).revision;
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
// An agent, a reader and the tester never reach the writer.
const before = readFileSync(file);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
// Unknown and missing arguments refuse; an actor can't be forged through them.
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
assert.deepEqual(readFileSync(file), before);
// The human's invent commits, as the human, with a receipt that carries no role text.
const receipt = b.request(human, invent('scout'));
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
assert.equal(last.actor, 'jason');
assert.equal(last.state, 'committed');
// A writer refusal passes by its code.
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
// The bus refuses a request that carries the held token, and the writer refuses a template whose
// contract carries it (the import request itself holds no token). Neither writes.
const written = readFileSync(file);
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
assert.deepEqual(readFileSync(file), written);
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
});
+35 -33
View File
@@ -6,6 +6,7 @@
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
// test needs outside the human's yes go in by SQL as fixtures.
import test from 'node:test';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
@@ -156,7 +157,7 @@ async function setup(t) {
return { revision: REV };
};
const make = () =>
new Broker({
new Broker({hires:hireServiceFixture,
store,
businesses: { demo: structuredClone(doc) },
secretCheck: (value) => credentials.assertClean(value),
@@ -184,7 +185,7 @@ async function setup(t) {
store
.all("SELECT actor_role, subject, body FROM events WHERE business='demo' AND kind='action.allowed' ORDER BY seq")
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
.filter((e) => e.body.action === action);
.filter((e) => e.body.action === action && e.body.operation !== 'decision.raise');
// The bus's step G messages to the human: no decision, so no grant notice.
const inbox = () =>
store.all("SELECT body FROM messages WHERE business='demo' AND from_role='_bus' AND to_role='human' AND decision IS NULL ORDER BY rowid").map((r) => r.body);
@@ -232,7 +233,8 @@ async function setup(t) {
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
},
});
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'the work is done' });
const hireConsole=fixtureConsole(broker);
const fire = name => approveFixtureFire(broker,hireConsole,name);
// A delete as the launcher's pass runs it: the human's request, the take, then lifecycleDelete. A
// refused delete closes the request with its code, as the launcher does.
const remove = (instance) => {
@@ -304,7 +306,7 @@ function allRevoked(f, hosts = ['web1', 'web2']) {
test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers with a job, then revokes all three', async (t) => {
const f = await setup(t);
await threeGrants(f);
const out = f.fire('coder');
const out = await f.fire('coder');
assert.equal(out.done, false);
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1', 'host:web2', 'gitea']);
@@ -313,7 +315,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.equal(more.length, 0);
assert.equal(revoking.body.job, out.job);
assert.deepEqual(revoking.body.grants, out.revoking);
assert.equal(revoking.actor_role, 'chief');
assert.equal(revoking.actor_role, null);
assert.equal(revoking.subject, 'coder');
await f.broker.grantsIdle();
// Each host as hosts.revoke records it, with fire: true, line first so the close says removed.
@@ -340,7 +342,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
assert.deepEqual(done.body.grantsLeft, []);
assert.match(done.body.transaction, /^t-/);
assert.equal(done.actor_role, 'chief');
assert.equal(done.actor_role, null);
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
assert.ok(!f.doc.launch.instances.includes('coder'));
// No step G message for a job that completed.
@@ -480,7 +482,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
const f = await setup(t);
await threeGrants(f);
f.failing.add('web2');
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(more.length, 0);
@@ -510,7 +512,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
assert.equal(f.state('coder', 'gitea'), 'valid');
// B back: the repeat revokes only B and Gitea.
f.failing.delete('web2');
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.deepEqual(repeat.revoking, ['host:web2', 'gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -529,7 +531,7 @@ test('row 281 T4b (Q1): a line a manual hosts.revoke left on C is removed by the
const tag3 = f.tag('coder', 'web3');
assert.equal(manual.hostLineLeft, tag3);
f.failing.delete('web3');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.lines.web3.has(tag3), false);
@@ -552,7 +554,7 @@ test('row 281 T4b (Q1): with C unreachable, the fire completes and names the lin
f.failing.add('web3');
await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
const tag3 = f.tag('coder', 'web3');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -568,7 +570,7 @@ test("row 281 T5: Gitea's admin token can't be checked: the job refuses with the
const f = await setup(t);
await f.approveGitea('coder');
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/version', { status: 500, json: {} });
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
off();
@@ -597,7 +599,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
const helperDeletes = () => f.gitea.requests.filter(tokenDelete('demo-helper')).length;
const before = helperDeletes();
const offCoder = f.gitea.rule(tokenDelete('demo-coder'), { status: 500, json: {} });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(refused.grant, 'gitea');
@@ -608,7 +610,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
assert.equal(f.allowed('fire.done').length, 0);
// Gitea back for coder only. The helper's DELETE still fails, so a business-wide sweep would show.
offCoder();
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.deepEqual(repeat.revoking, ['gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -627,7 +629,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
test('row 281 T6 (sage-2574): a host decision approved before the fire and run after it ends revoked, with nothing applied', async (t) => {
const f = await setup(t);
const id = f.fixtureHost('coder', 'web1');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.host === 'web1');
@@ -644,7 +646,7 @@ test('row 281 T6 (sage-2574): a host decision approved before the fire and run a
test('row 281 T6b (sage-2574): a Gitea decision approved before the fire and run after it ends revoked before any Gitea write', async (t) => {
const f = await setup(t);
const id = f.fixtureGitea('coder');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.method === 'gitea');
@@ -689,7 +691,7 @@ test('row 281 T6c: a host executor in flight aborts and ends revoked; a Gitea ex
const gd = await f.raiseGitea('coder');
f.call('human', 'decision.resolve', { id: gd.id, choice: 'yes' });
await until(() => reached);
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await until(() => f.ended(host.id));
assert.equal(f.ended(host.id).code, 'revoked');
@@ -714,7 +716,7 @@ test('row 281 T6d (B1): undecided host and Gitea decisions are in the set, and a
const f = await setup(t);
const host = await f.raiseHost('coder', 'web1');
const gd = await f.raiseGitea('coder');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.done').length, 1);
@@ -736,14 +738,14 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
await f.approveHost('coder', 'web1');
// Firing with no job: step B refused, so fire.stopped stands with no fire.done.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
f.fire('coder');
await f.fire('coder');
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.failed').length, 1);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// After fire.done: delisted.
f.org.fireError = null;
assert.match(f.fire('coder').transaction, /^t-/);
assert.match((await f.fire('coder')).transaction, /^t-/);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// A rehire lists the name again.
@@ -761,7 +763,7 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
test('row 281 T7: an instance with no grant fires as today, synchronously, with the same events', async (t) => {
const f = await setup(t);
const out = f.fire('helper');
const out = await f.fire('helper');
assert.deepEqual(Object.keys(out).sort(), ['cancelled', 'request', 'revision', 'runs', 'taken', 'transaction']);
assert.match(out.transaction, /^t-/);
// Recorded before the verb answered: no job.
@@ -781,9 +783,9 @@ test('row 281 T9 (B3): one step G per agent; the manual verbs refuse while it ru
await f.approveHost('coder', 'web1');
await f.approveGitea('coder');
f.held.add('web1:remove');
f.fire('coder');
await f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
assert.equal(code(() => f.fire('coder')), 'fire-in-progress');
await assert.rejects(f.fire('coder'),{code:'fire-in-progress'});
assert.equal(f.remove('coder').code, 'grants-revoking');
assert.equal(f.allowed('grants.revoking').length, 0);
assert.equal(f.allowed('fire.revoking').length, 1);
@@ -820,7 +822,7 @@ test('row 281 T10 (B2): the fire and the delete answer while their jobs are held
// The verbs answer before the event loop turns, so before any part of a job has run.
let turned = false;
setImmediate(() => (turned = true));
const fired = f.fire('coder');
const fired = await f.fire('coder');
const deleted = f.remove('ops');
assert.equal(turned, false);
assert.equal(fired.done, false);
@@ -844,7 +846,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.held.add('web1:remove');
const out = f.fire('coder');
const out = await f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
f.broker.endLaunch({ business: 'demo', run: 'chief-run', reason: 'test' });
f.sw.state = { version: 1, halted: true, generation: GENERATION };
@@ -855,7 +857,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
assert.match(done.body.transaction, /^t-/);
const [used, ...more] = f.allowed('role.fire');
assert.equal(more.length, 0);
assert.equal(used.actor_role, 'chief');
assert.equal(used.actor_role, null);
assert.equal(used.body.transaction, done.body.transaction);
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.allowed('fire.failed').length, 0);
@@ -867,12 +869,12 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
await f.approveHost('coder', 'web1');
// N7: the writer's own credential-revoke-failed, which step G's key revokes can't clear.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed, ...more] = f.allowed('fire.failed');
assert.equal(more.length, 0);
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'credential-revoke-failed']);
assert.equal(failed.actor_role, 'chief');
assert.equal(failed.actor_role, null);
const [message] = f.inbox();
for (const s of [out.job, 'host:web1', 'step B', 'credential-revoke-failed']) assert.ok(message.includes(s), s);
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -882,7 +884,7 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
// The grant was revoked before step B.
assert.equal(f.hostRow('coder', 'web1'), undefined);
f.org.fireError = null;
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -900,7 +902,7 @@ test('row 281 T13 (N4): a restart records each held job interrupted once, with o
assert.equal(f.allowed('grants.revoked.done').length, 1);
f.held.add('web1:remove');
f.held.add('web2:remove');
const fired = f.fire('coder');
const fired = await f.fire('coder');
assert.equal(f.remove('ops').code, 'grants-revoking');
const deleteJob = f.allowed('grants.revoking').at(-1).body.job;
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`) && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
@@ -930,7 +932,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
const d = await f.raiseHost('coder', 'web1');
// N1: hosts.remove doesn't check open decisions, so the decision outlives its host.
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
const out = f.fire('coder');
const out = await f.fire('coder');
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
@@ -946,7 +948,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
await f.broker.grantsIdle();
assert.equal(f.ended(d.id), undefined);
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -961,7 +963,7 @@ test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
@@ -987,7 +989,7 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
+207
View File
@@ -0,0 +1,207 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
test('A4 direct instance.hire and instance.fire refuse and cannot write', t => {
const root = mkdtempSync(join(tmpdir(), 'a4-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
let writes = 0;
const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['role.hire','role.fire'], crossRole: [] } } }, launch: { by: 'chief', instances: [] } } }, organization: () => { writes++; } });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire']) assert.throws(() => b.request(chief, { verb, args: { name: 'scout', definition: 'coder', reason: 'Need help' } }), { code: 'decision-required' });
assert.equal(writes, 0);
});
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { readFileSync, mkdirSync } from 'node:fs';
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report sk-test-NOT-A-SECRET-1234.' };
const SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
function fixture(t) {
const root=mkdtempSync(join(tmpdir(),'a4-bus-')), rolesDir=rolesCopy(root);
const file=writeJson(join(root,'businesses/demo.json'),{...businessDoc(root,'demo'),plugins:{harness:['pi']}});
const storeRoot=join(root,'data');mkdirSync(storeRoot,{mode:0o700});
const writer=organizationWriter({dir:root,rolesDir}),store=new Store(storeRoot);
const service=hireDecisions({writer,rolesDir,dataRoot:storeRoot,configDir:root,assertClean:()=>{}});
const loaded=()=>{const doc=loadBusiness('demo',{dir:root,rolesDir});return {...doc,roles:Object.fromEntries(Object.entries(doc.roles).map(([n,e])=>[n,{...doc.definitions[e.definition],...e}]))};};
let writes=0;
const b=new Broker({store,businesses:{demo:loaded()},hires:{...service,apply:async(...args)=>{writes++;const r=await service.apply(...args);b.reloadBusiness('demo',loaded());return r;}}});
const owner=b.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
const member=rights=>b.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights});
const caps={owner,chief:b.bindLaunch({business:'demo',role:'chief',run:'chief-run',harness:'pi'}),coder:b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi'})};
b.request(caps.chief,{verb:'role.claim'});b.request(caps.coder,{verb:'role.claim'});
const call=(cap,verb,args={})=>b.request(cap,{verb,args});
const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra});
const raise=extra=>call(caps.chief,'hire.raise',request(extra));
t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
const reloadChief=authority=>{const doc=loaded();doc.roles.chief.authority=authority;b.reloadBusiness('demo',doc);};
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,reloadChief,writes:()=>writes};
}
test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{
const {call,caps,request,store}=fixture(t);
await assert.rejects(call(caps.coder,'hire.raise',request()),{code:'role-not-chief'});
await assert.rejects(call(caps.chief,'hire.raise',request({flagged:[]})),{code:'invalid-request'});
assert.equal(store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 approve is digest bound and consumed once; generic decide never resolves hire, fire or change',async t=>{
const {raise,call,owner,writes,store,file,b,caps}=fixture(t);
const d=await raise();const p=await call(owner,'hire.preview.console',{id:d.id});
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:'0'.repeat(64)}),{code:'decision-stale'});
for(const action of ['role.hire','role.fire','role.change']) {
if(action!==d.action)store.run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',action,'2026-01-01T00:00:00.000Z','demo','chief','chief-run','gated',action,'human','Question?',JSON.stringify([{key:'yes',text:'Yes'},{key:'no',text:'No'}]),'no',0);
assert.throws(()=>call(owner,'decision.resolve.console',{id:action===d.action?d.id:action,choice:'yes',digest:p.digest}),{code:'hire-resolution-required'});
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>call(human,'decision.resolve',{id:action===d.action?d.id:action,choice:'yes'}),{code:'hire-resolution-required'});
}
const before=readFileSync(file);
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest});
assert.equal(writes(),1);assert.notDeepEqual(readFileSync(file),before);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-closed'});
assert.equal(writes(),1);
for(const row of [...store.all('SELECT * FROM decisions'),...store.all('SELECT * FROM events')])assert.ok(!JSON.stringify(row).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 denied and expired decisions leave organization bytes untouched',async t=>{
const {raise,call,owner,file,writes}=fixture(t);
const before=readFileSync(file),d=await raise();
await call(owner,'hire.resolve.console',{id:d.id,choice:'no',digest:d.hire.digest});
const e=await raise(); const original=Date.now;
try{Date.now=()=>original()+8*86400000;await assert.rejects(call(owner,'hire.resolve.console',{id:e.id,choice:'yes',digest:e.hire.digest}),{code:'decision-expired'});}finally{Date.now=original;}
assert.deepEqual(readFileSync(file),before);assert.equal(writes(),0);
});
test('A4 members with org.hire refuse flagged or credential grants; decide alone refuses all hires',async t=>{
const {raise,call,member}=fixture(t);
const plain=await raise();
await assert.rejects(call(member(['decide']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest}),{code:'right-required'});
const flagged=await raise({role:{inline:{...INLINE,tools:['read','bash']}}});
const credential=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
for(const d of [flagged,credential])await assert.rejects(call(member(['org.hire']),'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'hire-owner-required'});
await call(member(['org.hire']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest});
});
test('A4 human Console start raises and resolves one recorded decision with computed lines',async t=>{
const {call,owner,request,store,writes}=fixture(t);
const r=await call(owner,'hire.start.console',request({role:{inline:{...INLINE,tools:['read','bash']}}}));
assert.equal(r.op,'resolved');assert.equal(writes(),1);
const event=store.all("SELECT body FROM events WHERE json_extract(body,'$.operation')='decision.raise'").map(e=>JSON.parse(e.body))[0];
assert.deepEqual(event.hire.flagged,[{field:'tools',value:'bash',reasons:['above-chief']}]);
assert.equal(store.get('SELECT raised_by_role FROM decisions WHERE id=?',r.id).raised_by_role,'human');
});
test('A4 a stale organization digest never reaches the writer',async t=>{
const {raise,call,owner,writer,file,writes}=fixture(t);
const d=await raise(),old=writer.read('demo');
writeJson(file,{...old.document,vars:{...old.document.vars,'tracker.pollSeconds':61}});
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'decision-stale'});
assert.equal(writes(),0);
});
test('A4 approved credential role refuses launch until a binding is present',async t=>{
const {raise,call,owner,b,caps,writer,file,root,rolesDir}=fixture(t);
const d=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest});
assert.throws(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}),{code:'credential-unbound'});
// Binding implementation is outside A4. The existing file shape supplies its metadata here.
const doc=writer.read('demo').document;
doc.roles.scout.credentials={gitea:{store:true,rotateBy:'2099-01-01'}};
writeJson(file,doc);
const loaded=loadBusiness('demo',{dir:root,rolesDir});
b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
assert.doesNotThrow(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}));
});
test('A4 refusals and records never carry invalid role text',async t=>{
const {raise,store}=fixture(t);
await assert.rejects(raise({role:{inline:{...INLINE,authority:{withinRole:['sk-test-NOT-A-SECRET-1234'],crossRole:[]}}}}),e=>{
assert.ok(!JSON.stringify(e).includes('sk-test-NOT-A-SECRET-1234'));return true;
});
assert.ok(!JSON.stringify(store.all('SELECT * FROM events')).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 a human may mark a hire seen, and the chief may withdraw its own proposal',async t=>{
const {raise,call,b,caps,writes}=fixture(t),d=await raise();
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.equal(call(human,'decision.seen',{id:d.id}).op,'seen');
assert.equal(call(caps.chief,'decision.withdraw',{id:d.id}).op,'withdrawn');
assert.equal(writes(),0);
});
test('A4 Console previews and consumes only the exact narrowed grants and route',async t=>{
const {raise,call,owner,writer,store}=fixture(t);
const d=await raise({role:{inline:{...INLINE,tools:['read','bash'],credentials:[{service:'gitea',scopes:['read:repository']}]}}});
const adjust={role:{tools:['read']},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
const p=await call(owner,'hire.preview.console',{id:d.id,adjust});
assert.notEqual(p.digest,d.hire.digest);assert.deepEqual(p.flagged,[]);assert.deepEqual(p.credentialLines,[]);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-stale'});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest,adjust});
const doc=writer.read('demo').document,entry=doc.definitions[doc.roles.scout.definition.slice(4)];
assert.deepEqual(entry.role.tools,['read']);assert.deepEqual(entry.role.credentials,[]);
const consume=JSON.parse(store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='hire.consume'").body);
assert.equal(consume.hire.digest,p.digest);assert.deepEqual(consume.hire.credentialLines,[]);
});
test('A4 Console lists template hashes without role text, under org.hire',async t=>{
const {call,owner,member}=fixture(t);
const list=await call(owner,'hire.templates.console');
assert.ok(list.templates.some(x=>x.name==='coder'&&/^[a-f0-9]{64}$/.test(x.sha256)));
assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256'));
assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'});
});
test('A4 chief proposals require current role.hire for hire/change and role.fire for fire',async t=>{
const f=fixture(t);
f.reloadChief({withinRole:['message.send'],crossRole:[]});
for(const op of ['hire','change','fire'])for(const verb of ['hire.raise','decision.raise']) {
const hire=f.request({op});
await assert.rejects(f.call(f.caps.chief,verb,verb==='decision.raise'?{action:`role.${op}`,hire}:hire),{code:'not-authorized'});
}
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
f.reloadChief({withinRole:['role.hire'],crossRole:[]});
assert.ok((await f.raise()).id);
assert.ok((await f.raise({op:'change',name:'coder'})).id);
const fire={op:'fire',level:'org',name:'coder',reason:'Finished'};
await assert.rejects(f.call(f.caps.chief,'hire.raise',fire),{code:'not-authorized'});
f.reloadChief({withinRole:['role.fire'],crossRole:[]});
assert.ok((await f.call(f.caps.chief,'hire.raise',fire)).id);
await assert.rejects(f.raise(),{code:'not-authorized'});
});
test('A4 chief authority is checked again after asynchronous proposal preparation',async t=>{
const f=fixture(t),pending=f.raise();
f.reloadChief({withinRole:['message.send'],crossRole:[]});
await assert.rejects(pending,{code:'not-authorized'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 a member start needing owner approval refuses before saving a decision',async t=>{
const f=fixture(t),cap=f.member(['org.hire']),before=readFileSync(f.file);
for(const inline of [{...INLINE,tools:['read','bash']},{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}])
await assert.rejects(f.call(cap,'hire.start.console',f.request({role:{inline}})),{code:'hire-owner-required'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
assert.deepEqual(readFileSync(f.file),before);
assert.equal((await f.call(cap,'hire.start.console',f.request())).op,'resolved');
});
test('A4 inbox and decision reads expire unopened hires once without changing configuration',async t=>{
const f=fixture(t),d=await f.raise({role:{inline:{...INLINE,tools:['read','bash']}}}),before=readFileSync(f.file);
const human=f.b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const original=Date.now;
try {
Date.now=()=>original()+8*86400000;
await assert.rejects(f.call(f.member(['org.hire']),'hire.preview.console',{id:d.id}),{code:'decision-expired'});
assert.deepEqual(f.call(human,'inbox'),[]);
const recent=f.call(human,'decisions',{list:'recent'});
assert.equal(recent[0].closed.by,'expiry');
const another=await f.raise();
Date.now=()=>original()+16*86400000;
assert.deepEqual(f.call(human,'inbox'),[]);
f.call(human,'decisions',{list:'recent'});
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND by='expiry'",another.id).n,1);
} finally {Date.now=original;}
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
});
+14
View File
@@ -0,0 +1,14 @@
// Trusted proposal service stand-in for executor-only tests. Authorization and real writers are
// covered by hire-decisions.test.mjs; these tests isolate the existing grant/fire executors.
export const hireServiceFixture = {
prepare: (_business,args,{id,expiresAt}) => ({id,op:args.op,level:args.level,name:args.name,revision:'b'.repeat(64),flagged:[],credentialLines:[],digest:'a'.repeat(64),expiresAt}),
check: () => {},
};
export const fixtureConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export const approveFixtureFire = async (broker,cap,name) => {const {id,op,choice,...result}=await broker.request(cap,{verb:'hire.start.console',args:{op:'fire',level:'org',name,reason:'The work is done'}});if(result.done===false)delete result.transaction;return result;};
export const ownerConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export async function approveTemplateHire(broker,rolesDir,{business='demo',name,definition,reason='Fixture hire',model='claude-opus-5-5',...extra}) {
const {readTemplate}=await import('../../organization/src/definitions.mjs');
const source=readTemplate(rolesDir,definition);
return broker.request(ownerConsole(broker,business),{verb:'hire.start.console',args:{op:'hire',level:'org',name,role:{template:definition,sha256:source.sha256},route:{harness:'pi',model},reason,...extra}});
}
@@ -6,6 +6,7 @@
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
// decisions. Tokens are made up or the fake's, and only ever compared.
import test from 'node:test';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -68,7 +69,7 @@ function world(t, options) {
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = makeBusiness({ ...options, root });
const held = new Set();
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
const broker = new Broker({hires:hireServiceFixture, store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
}
@@ -138,7 +139,7 @@ async function verbWorld(t, opts = {}) {
}
return { revision: REV };
};
broker = new Broker({
broker = new Broker({hires:hireServiceFixture,
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
});
@@ -174,7 +175,7 @@ async function verbWorld(t, opts = {}) {
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
};
const fire = async () => {
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
await approveFixtureFire(broker,fixtureConsole(broker,'acme'),'chief');
await until(() => !doc.launch.instances.includes('chief'));
await broker.grantsIdle();
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
@@ -196,7 +197,7 @@ test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s li
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
grant(w.store, 'chief', { user: 'chief-bot' });
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
assert.deepEqual(new Broker({hires:hireServiceFixture, store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
'a bus with no grant store refuses rather than guess');
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
+10 -8
View File
@@ -1,3 +1,4 @@
import { fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
// Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials
// after the writer's checks and before the write. Fire revokes each service and keeps the store
// directory. Delete calls remove (sage-1593). A failed revoke stops the verb with
@@ -54,7 +55,8 @@ async function setup(t, { drop = [], seed = null } = {}) {
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const fire = (name) => bus.broker.request(chief, { verb: 'instance.fire', args: { name, reason: 'the work is done' } });
const hireConsole=fixtureConsole(bus.broker);
const fire = name => approveFixtureFire(bus.broker,hireConsole,name);
const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent);
const plant = (agent, service) => {
mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 });
@@ -85,12 +87,12 @@ test('T20 fire: a revoke that fails stops the fire with the file unchanged; one
// The revoke can't unlink the token, so it fails before the write: the file still lists coder.
const before = f.bytes();
chmodSync(f.agentDir('coder'), 0o500);
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
chmodSync(f.agentDir('coder'), 0o700);
assert.equal(f.bytes(), before);
assert.ok(JSON.parse(before).launch.instances.includes('coder'));
const fired = f.fire('coder');
const fired = await f.fire('coder');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] });
assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder'));
@@ -109,7 +111,7 @@ test('T20 fire: an instance already delisted still has its tokens revoked and th
doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2');
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const before = f.bytes();
const fired = f.fire('rev-2');
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, false);
assert.equal(fired.transaction, null);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
@@ -122,10 +124,10 @@ test('T20 fire: with no entry in the running bus, a token on disk refuses and an
const f = await setup(t, { drop: ['rev-2'] });
f.plant('rev-2', 'gitea');
const before = f.bytes();
assert.equal(f.code(() => f.fire('rev-2')), 'credential-revoke-failed');
await assert.rejects(f.fire('rev-2'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
rmSync(join(f.agentDir('rev-2'), 'gitea'));
const fired = f.fire('rev-2');
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
});
@@ -198,11 +200,11 @@ test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses st
const f = await setup(t);
f.plant('coder', 'host.web1');
const before = f.bytes();
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
rmSync(join(f.agentDir('coder'), 'host.web1'));
assert.equal(f.fire('coder').delisted, true);
assert.equal((await f.fire('coder')).delisted, true);
});
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
@@ -1,3 +1,5 @@
import {readTemplate} from '../../organization/src/definitions.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// #655 isolated STATIC fixture: actual managed fromSession caller + runtime/writer/controller.
import fs from 'node:fs';
import http from 'node:http';
@@ -25,10 +27,10 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
// Available before any acquired resource/setup can fail; no later f/launcher TDZ.
const launchLog=()=>{const file=launchLogFile(join(root,'data'),'acme');return fs.existsSync(file)?fs.readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[];};
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];if(!listed)delete doc.roles.aide;return doc;});
// The established scratch aide pattern only; repository roles/policy stay untouched.
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
const chiefFile=join(f.roles,'chief-of-staff.json');
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
const state={keys:[sentinel],calls:[],refuseProbe,counts:{GET:0,PUT:0,PROBE:0},puts:[],failRemoval:false};
@@ -110,7 +112,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
const file=join(configDir,'businesses','acme.json');
const hold=(method='GET',ordinal=state.counts[method]+1)=>{let release;const promise=new Promise(r=>{release=r;});const gate={method,ordinal,seen:false,used:false,cancelled:false,promise,release};gates.push(gate);return {ordinal,seen:()=>gate.seen,entered:()=>waitFor(()=>gate.seen,'owned HTTP gate not reached',5000),release,cancel:()=>{gate.cancelled=true;release();}};};
const outcome=promise=>{const box={settled:false};box.promise=track(Promise.resolve(promise).then(value=>{box.settled=true;box.ok=true;box.value=value;return box;},error=>{box.settled=true;box.ok=false;box.code=error.code??'unexpected-error';return box;}));return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=bus.broker.request(cap,{verb:'instance.hire',args:{name,definition:'aide',reason:'fixture ownership',...extra}});called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=(async()=>{const definition=extra.definition??'aide';const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(f.roles,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture ownership',...(extra.vars?{vars:extra.vars}:{})}});return bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.resolve.console',args:{id:d.id,choice:'yes',digest:d.hire.digest}});})();called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const record=(name='aide')=>bus.credentials.modelRecord('acme/'+name);
const snapshot=()=>{const journal=join(configDir,'businesses','.history','acme','changes.jsonl');return {membership:id(fs.readFileSync(file)),journal:fs.existsSync(journal)?id(fs.readFileSync(journal)):null,credentials:id(JSON.stringify(bus.credentials.status())),model:record()?id(JSON.stringify(record())):null,remote:id(JSON.stringify(state.keys)),calls:state.calls.length};};
return {root,f,state,trace,proxyReceipts,logs,launcher,bus,chief,record,hold,outcome,hire,snapshot,file,launchLog,expectedKeyId:()=>expectedKeyId,valueFree,
@@ -123,7 +125,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
assertModelAbsent:name=>{assert.equal(bus.credentials.modelRecord('acme/'+name)===null,true);assert.equal(fs.existsSync(join(configDir,'secrets','agents','acme',name,'model')),false);},
rotateBound:async()=>{const before=record();bus.credentials.writeModel('acme/aide',{...before,rotateBy:new Date(Date.now()-86400_000).toISOString().slice(0,10)});await waitFor(()=>record()?.retiring.some(r=>r.keyId===before.keyId),'original runtime sweep rotates bound generation');return before.keyId;},
explicitEnd:()=>bus.broker.request(chief,{verb:'role.revoke',args:{role:'aide',reason:'fixture explicit end'}}),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture explicit fire'}}))),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture explicit fire'}}))),
runDirectories:()=>fs.existsSync(runsDir(f.dataRoot,'acme'))?fs.readdirSync(runsDir(f.dataRoot,'acme')).map(name=>join(runsDir(f.dataRoot,'acme'),name)):[],
sessions:()=>readSessions(f.dataRoot),send:body=>bus.broker.request(human,{verb:'message.send',args:{to:'aide',body,class:'REQUEST'}}),
reply:sent=>waitFor(()=>bus.broker.request(human,{verb:'message.receive'}).find(m=>m.in_reply_to===sent.id),'real managed reply observed'),
@@ -38,9 +38,8 @@ test('655 actual launch own preflight generation and bound consumer survive conc
for(const [label,options,args,code] of [
['retired name',{retired:true},{name:'alpha'},'organization-instance-retired'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
['fired mismatched legal definition',{listed:false},{definition:'helper'},'organization-instance-exists'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'invalid-request'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'invalid-request'],
])test('655 shared validation '+label+' has no credential effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot(),{name='aide',...extra}=args;await refused(f.hire(name,extra),code);
assert.deepEqual(f.snapshot(),before,'shared hire refusal precedes credential registration or proxy I/O');f.valueFree();
@@ -49,7 +48,7 @@ for(const [label,options,args,code] of [
for(const [label,options,extra,code] of [
['kept target',{keep:true},{},'instance-kept'],
['noHire definition',{noHire:true},{},'definition-not-hireable'],
['ineligible definition',{}, {definition:'security-reviewer'},'definition-not-hireable'],
['duplicate with another definition',{}, {definition:'security-reviewer'},'organization-instance-exists'],
['invalid vars',{}, {vars:{harness:'pi'}},'invalid-request'],
])test('655 existing Broker '+label+' refusal remains before model effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot();await refused(f.hire('aide',extra),code);assert.deepEqual(f.snapshot(),before);f.valueFree();
@@ -59,9 +58,9 @@ test('655 existing nonchief actor refusal remains before shared checks and model
const f=await ownershipFixture(t),cap=f.wrongActor(),before=f.snapshot();await refused(f.hire('aide',{},cap),'role-not-chief');assert.deepEqual(f.snapshot(),before);f.valueFree();
});
test('655 valid fired matching definition rehire publishes before queued acquisition',{timeout:30000},async t=>{
test('655 valid new hire publishes before queued acquisition',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();assert.equal(f.listed('aide'),false);
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.ok,true);assert.equal(f.listed('aide'),true);assert.equal(p.value.key===f.record().key,true);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.code,'unknown-role','no admission before membership publication');assert.equal(f.listed('aide'),true);const admitted=await done(f.preflight());assert.equal(admitted.ok,true);assert.equal(admitted.value.key===f.record().key,true);assert.equal(await f.healthy(admitted.value.key),200);f.valueFree();
});
test('655 preexisting admitted generation and retiring metadata survive duplicate failure',{skip:unavailable,timeout:30000},async t=>{
@@ -74,22 +73,22 @@ test('655 captured revision is checked after controller queue before generation
const f=await ownershipFixture(t),gate=f.hold('GET'),p=f.preflight('chief');await gate.entered();const h=f.hire('alpha');
// A following same-business operation proves H established its pending slot,
// rather than an arbitrary sleep before the external owner revision change.
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'revision-conflict');
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'decision-stale');
assert.equal(f.state.counts.PUT,1,'queued stale hire does not issue a generation');f.assertModelAbsent('alpha');assert.equal(f.listed('alpha'),false);f.valueFree();
});
test('655 genuine new generation revision failure cleans before queued acquisition receives a key',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();
const failedGeneration=f.state.puts[0].find(k=>!k.includes('-sentinel-'));assert.ok(failedGeneration,'real first issuance payload observed');
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'revision-conflict');await done(p);
const valid=p.ok&&p.value.key!==failedGeneration&&f.record()?.key===p.value.key&&f.state.keys.includes(p.value.key)&&!f.state.keys.includes(failedGeneration);
assert.equal(valid,true,'queued acquisition receives a valid post-cleanup generation');assert.equal(f.listed('aide'),false);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'decision-stale');await done(p);
assert.equal(p.code,'unknown-role','an unpublished agent cannot acquire a model key');
assert.equal(f.state.keys.includes(failedGeneration),false,'failed generation was removed');f.assertModelAbsent('aide');assert.equal(f.listed('aide'),false);f.valueFree();
});
test('655 failed cleanup retains revoking intent and refuses queued admission',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();f.bumpRevision();f.state.failRemoval=true;put.release();
await refused(h,'revision-conflict');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='proxy-key-revoking',true,'queued admission refuses retained revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
await refused(h,'decision-stale');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='unknown-role',true,'unpublished membership refuses admission while model record retains revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
});
test('655 no-launch remains before own preflight and grants no consumer or stop authority',{timeout:30000},async t=>{
+27 -9
View File
@@ -1,3 +1,6 @@
import {materializeCopies,withCopies} from '../../organization/src/agent-copies.mjs';
import {loadRootAgents} from '../../organization/src/agent-levels.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// P2: real private Credentials, fake loopback management/model server. No live config.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -173,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
stopped = true; await overdue.sweep(); assert.equal(f.state.keys.includes(first.key), false);
});
async function bootBusiness(f, binding = {}) {
async function bootBusiness(f, {copyLevel = null,...binding} = {}) {
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
@@ -181,23 +184,26 @@ async function bootBusiness(f, binding = {}) {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(fs.readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
doc.plugins = { ...doc.plugins, harness: ['pi'] };
doc.roles['sec-1'] = { definition: 'security-reviewer' }; doc.launch.instances.push('sec-1');
const copyEntry={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5'}};
if(copyLevel==='project')doc.projects.stack.agents={aide:copyEntry};
if(copyLevel==='root')writeJson(join(f.configDir,'agents.json'),{agentsVersion:1,agents:{aide:copyEntry}});
const copies=copyLevel?materializeCopies({orgId:'demo',doc,rolesDir,root:loadRootAgents(f.configDir,{rolesDir})}):null;
const file = writeJson(join(f.configDir, 'businesses', 'demo.json'), doc);
fs.writeFileSync(join(f.configDir, 'secrets', 'proxy-management.key'), 'fx331-manage', { mode: 0o600 });
const business = loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
const business = copies?withCopies(doc,file,{rolesDir,snapshot:copies}):loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
// Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
new Registry(join(f.root, 'data'));
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
copies, organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
f.closers.push(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const fire = () => bus.broker.request(chief, { verb: 'instance.fire', args: { name: 'sec-1', reason: 'fixture fire' } });
const fire = () => bus.broker.request(ownerConsole(bus.broker), { verb: 'hire.start.console', args: { op:'fire',level:'org',name: 'sec-1', reason: 'fixture fire' } });
const listed = () => JSON.parse(fs.readFileSync(file, 'utf8')).launch.instances.includes('sec-1');
return { bus, chief, fire, listed, file, rolesDir, system };
}
@@ -233,7 +239,7 @@ test('proxy-revoke-revision-conflict: racing organization write keeps record, ne
});
test('bus hire issues and confirms implicit model key before publishing new membership', async t => {
const f = await fixture(t); const b = await bootBusiness(f); const calls = f.state.calls.length;
const result = await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
const result = await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
assert.equal(typeof result.transaction, 'string');
assert.equal(JSON.parse(fs.readFileSync(b.file, 'utf8')).launch.instances.includes('sec-new'), true);
assert.equal(b.bus.credentials.modelRecord('demo/sec-new')?.state, 'valid');
@@ -242,7 +248,7 @@ test('bus hire issues and confirms implicit model key before publishing new memb
test('proxy-key-issue-failed: bus hire failure publishes neither membership nor model file', async t => {
const f = await fixture(t); const b = await bootBusiness(f); f.state.failPut = true;
const before = fs.readFileSync(b.file, 'utf8');
await assert.rejects(b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } }), failCode('proxy-key-issue-failed'));
await assert.rejects(approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'}), failCode('proxy-key-issue-failed'));
assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.bus.credentials.modelRecord('demo/sec-new') === null, true);
});
test('proxy-open: removing sentinel after boot refuses the next launch before models probe', async t => {
@@ -252,7 +258,7 @@ test('proxy-open: removing sentinel after boot refuses the next launch before mo
});
test('newly hired role can use runtime preflight without a broker restart', async t => {
const f = await fixture(t); const b = await bootBusiness(f);
await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
const proxy = await b.bus.modelPreflight({ business: 'demo', role: 'sec-new' });
assert.equal(proxy.baseUrl, f.baseUrl); assert.equal(f.state.keys.includes(proxy.key), true);
});
@@ -328,3 +334,15 @@ test('beta bus registers while alpha bus runs and alpha key stays accepted', asy
assert.ok(f.state.keys.some(value => value.startsWith(`mso-${hash(f.configDir)}-beta-sentinel-`)));
assert.equal((await alpha.bus.modelPreflight({ business: 'demo', role: 'sec-1' })).key, key);
});
for(const level of ['root','project'])test(`A4 ${level} fire removes its implicit model key before publication`,async t=>{
const f=await fixture(t),b=await bootBusiness(f,{copyLevel:level});
const instance=level==='root'?'aide':'aide.stack';
const issued=await b.bus.modelPreflight({business:'demo',role:instance});
assert.ok(issued.key);
await b.bus.broker.request(ownerConsole(b.bus.broker),{verb:'hire.start.console',args:{op:'fire',level,...(level==='project'?{project:'stack'}:{}),name:'aide',reason:'Copy work finished'}});
assert.equal(f.state.keys.includes(issued.key),false,'remote generation is revoked');
assert.equal(b.bus.credentials.modelRecord('demo/'+instance),null,'private generation is removed');
const human=b.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>b.bus.broker.request(human,{verb:'instance.launch',args:{instance}}));
});
@@ -1,3 +1,4 @@
import { approveTemplateHire } from './hire-fixtures.mjs';
// #651 actual runtime + organization.hire publication with orphan private records.
// In-process trusted Broker, real SQLite/organization writer/HTTP; not process IPC.
import test from 'node:test';
@@ -16,7 +17,7 @@ async function hireFixture(t,{revoking=false}={}){
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
const chiefFile=join(rolesDir,'chief-of-staff.json');doc.plugins={...doc.plugins,harness:['pi']};
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
@@ -27,7 +28,7 @@ async function hireFixture(t,{revoking=false}={}){
assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
const listed=()=>JSON.parse(fs.readFileSync(file,'utf8')).launch.instances.includes('orphan'),observed=[];f.state.observePut=()=>observed.push(listed());f.state.calls=[];
const hire=()=>bus.broker.request(cap,{verb:'instance.hire',args:{name:'orphan',definition:'aide',reason:'fixture orphan hire'}});
const hire=()=>approveTemplateHire(bus.broker,rolesDir,{name:'orphan',definition:'aide'});
return {...f,bus,issued,file,log,listed,observed,hire};
}
test('L3b runtime hire publishes orphan only after fresh remote confirmation',async t=>{
+4 -2
View File
@@ -1,3 +1,5 @@
import { readTemplate } from '../../organization/src/definitions.mjs';
import { ownerConsole } from './hire-fixtures.mjs';
// #654 isolated real runtime/writer/controller with owned HTTP request gates.
import fs from 'node:fs';
import http from 'node:http';
@@ -22,7 +24,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
// Existing row331 scratch aide pattern only; repository role authority unchanged.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
@@ -44,7 +46,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const holdNext=(method='GET')=>{assert.equal(state.nextGate,null,'only one owned HTTP hold armed');const g={method,entered:deferred(),release:deferred()};gates.push(g);state.nextGate=g;return {entered:()=>bounded(g.entered.promise,'owned lifecycle HTTP boundary not observed'),release:()=>g.release.resolve()};};
const listed=(business,name)=>JSON.parse(fs.readFileSync(files[business],'utf8')).launch.instances.includes(name);
const snapshot=business=>{const journal=join(configDir,'businesses','.history',business,'changes.jsonl');return {membership:hash(fs.readFileSync(files[business])),publicationJournal:fs.existsSync(journal)?hash(fs.readFileSync(journal)):null,modelStatus:hash(JSON.stringify(bus.credentials.status())),remote:hash(JSON.stringify(state.keys)),calls:state.calls.length};};
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>bus.broker.request(cap,{verb:'instance.hire',args:{name,definition,reason:'fixture pending hire'}})));
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>(async()=>{const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(rolesDir,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture pending hire'}});return bus.broker.request(ownerConsole(bus.broker,business),{verb:'hire.resolve.console',args:{id:d.id,digest:d.hire.digest,choice:'yes'}});})()));
const busy=async promise=>{const result=await bounded(promise.then(()=>({code:'unexpected-success'}),e=>({code:e.code??'unexpected-error'})),'same-business refusal not observed');assert.equal(result.code,'organization-busy','second same-business lifecycle call refuses exact pending code');};
return {root,configDir,dataRoot,rolesDir,bus,state,receipts,chief,holdNext,listed,snapshot,hire,busy,valueFree,
healthy:async(business,name)=>{const value=await bus.modelPreflight({business,role:name});const record=bus.credentials.modelRecord(business+'/'+name);assert.equal(value.key===record.key,true);assert.equal(state.keys.includes(record.key),true,'published member retains confirmed model credential');},
@@ -1,3 +1,4 @@
import {ownerConsole} from './hire-fixtures.mjs';
// #656 STATIC isolated fixtures. No real config/key/proxy; no production seam.
import fs from 'node:fs';
import http from 'node:http';
@@ -74,7 +75,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
syncBuiltinESMExports();restoreFs=()=>{Object.assign(fs,original);syncBuiltinESMExports();};
const record=()=>c.modelRecord(instance),exists=path=>{try{original.lstatSync(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}};
const seed=({matchingTmp=true,retiring=0,valid=false}={})=>{const current=key(),old=Array.from({length:retiring},()=>({key:key(),issuedAt:'2026-10-23T00:00:00.000Z',retireAt:'2099-01-01T00:00:00.000Z'})).map(r=>({...r,keyId:id(r.key)}));const value={key:current,keyId:id(current),issuedAt:'2026-10-24T00:00:00.000Z',rotateBy:'2099-01-01',state:valid?'valid':'revoking',...(valid?{}:{revokingAt:'2026-10-25T00:00:00.000Z'}),retiring:old};state.keys.push(current,...old.map(r=>r.key));c.writeModel(instance,value);if(matchingTmp){fs.copyFileSync(file,temporary);fs.chmodSync(temporary,0o600);}removedTmp=false;return value;};
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(ownerConsole(bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const writeSessions=entries=>writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:entries});
const ownRun=async({wrongTicks=false,wrongKey=false,publish=true}={})=>{
const spawnOwn=async()=>{const handle=spawn('/usr/bin/sleep',['60'],{stdio:'ignore'}),closed=new Promise(resolve=>handle.once('close',resolve));const owned={handle,closed};children.push(owned);await once(handle,'spawn');const ticks=startTimeOf(handle.pid);assert.equal(typeof ticks,'string');return {...owned,ticks,exit:async()=>{if(handle.exitCode===null&&handle.signalCode===null)handle.kill('SIGTERM');await bounded(closed,'owned stand-in exit timeout');}};};
+1 -1
View File
@@ -41,7 +41,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+5 -6
View File
@@ -1,3 +1,4 @@
import {approveTemplateHire,ownerConsole} from './hire-fixtures.mjs';
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
// restart would load it, so a created instance binds with no restart. A definition change on a
// running instance refuses, and a file whose credential references changed isn't reloaded.
@@ -149,8 +150,6 @@ test('T19 a credentialed create answers restart true; issue works only after a r
// A, from the file, so the kept instance keeps its claim and isn't left firing.
test('T14 after a refused reload veto.set, instance.hire and instance.fire answer restart true, and a fire of a name kept since refuses before step A', async (t) => {
const f = await setup(t);
const roleFile = join(f.rolesDir, 'chief-of-staff.json');
writeFileSync(roleFile, JSON.stringify({ ...JSON.parse(readFileSync(roleFile, 'utf8')), hireable: ['chief-information-security-officer'] }, null, 2));
mkdirSync(join(f.root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
const chief = bind(f.bus, 'chief');
f.bus.broker.request(chief, { verb: 'role.claim' });
@@ -162,21 +161,21 @@ test('T14 after a refused reload veto.set, instance.hire and instance.fire answe
const added = f.ask('instances.set', { revision: f.revision(), instance: 'rev-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-demo-rev-2', botId: 40 } });
assert.equal(added.restart, true);
const hired = asChief('instance.hire', { name: 'sec-2', definition: 'chief-information-security-officer', reason: 'a security lead' });
assert.equal(hired.restart, true);
const hired = await approveTemplateHire(f.bus.broker,f.rolesDir,{name:'sec-2',definition:'chief-information-security-officer'});
assert.ok(hired.transaction);
const kept = f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'reviewer' });
assert.equal(kept.restart, true);
assert.equal(f.reloads.length, 0, 'the running copy still has reviewer unkept');
const before = readFileSync(f.file, 'utf8');
assert.equal(code(() => asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' })), 'instance-kept');
await assert.rejects(f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}}),{code:'instance-kept'});
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.deepEqual(f.ask('instances').instances.reviewer.running, run, 'step A never ran: the claim is held');
assert.notEqual(code(() => f.ask('instance.launch', { instance: 'reviewer' })), 'instance-firing');
const unkept = f.ask('veto.set', { revision: f.revision(), op: 'unkeep', name: 'reviewer' });
assert.equal(unkept.restart, true);
const fired = asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' });
const fired = await f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}});
assert.equal(fired.restart, true);
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).launch.instances.includes('reviewer'), false);
});
+1 -1
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
+1 -1
View File
@@ -385,7 +385,7 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
b.request(caps.chief, { verb: 'role.claim' });
const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' });
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'invalid-request', name);
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'decision-required', name);
// A name that isn't reserved passes the name check and refuses later, for want of role files.
assert.notEqual(hire('helper'), 'invalid-request');
});
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definition-verbs.mjs (rename design r2, stage B).
export * from "../../organization/src/definition-verbs.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definitions.mjs (rename design r2, stage B).
export * from "../../organization/src/definitions.mjs";
+1
View File
@@ -0,0 +1 @@
export * from "../../organization/src/hire-decisions.mjs";
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/system-config-problem.mjs (rename design r2, stage B).
export * from "../../organization/src/system-config-problem.mjs";
+4 -2
View File
@@ -154,11 +154,13 @@ function pidAlive(pid) {
}
}
// Names the lock by label: its path sits beside the access file in the config
// directory, and a refusal never quotes a host path (PR #1916).
function lockRefusal(lock) {
const pid = lockPid(lock);
if (pid !== null && pidAlive(pid)) return BUSY;
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
if (pid === null) return `refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: access file lock is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
}
function takeLock(lock, fs) {
+5 -3
View File
@@ -37,8 +37,10 @@ function parse(args) {
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
// The registry checked `id` against the id pattern, so the label may name it; the
// root is a host path and never reaches a refusal (PR #1916).
function readOwner(root, id) {
const owner = readJsonFile(join(root, 'owner.json'), `organization owner of ${id}`, st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
@@ -92,7 +94,7 @@ export async function accountsCommand(args, io, deps) {
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
try { owner = readOwner(entry.root, entry.id); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
+4 -3
View File
@@ -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 });
+11 -2
View File
@@ -37,6 +37,7 @@ import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { definitionsCommand } from "./org-definitions.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
@@ -518,7 +519,8 @@ async function processingCommand(verb, rest, io, deps) {
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
if (verb === 'halt' && result.quiescent !== true)
throw new CliError(`halt gate is on for instance ${system.dataRoot}; ${result.remaining ?? 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
// #1873: the refusal names the instance, never its data root; a count that is not a count is unknown.
throw new CliError(`halt gate is on for this instance; ${Number.isSafeInteger(result.remaining) && result.remaining >= 0 ? result.remaining : 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
@@ -650,9 +652,14 @@ async function bus(rest, io, deps) {
throw e;
}
if (instance) boot.consoleOwners = [...(boot.consoleOwners ?? []), DEV_OWNER];
const priorBoot = readBootStatus(org.root);
try { recordBootOk(org.root, { business: businessId, file }); } catch { /* status is advisory */ }
const binding = readNotifyConfig(org.root, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } });
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } }).catch((e) => {
// org-defscan: the broker refused a definition that holds a credential it holds; the load was not good after all.
if (e.code === "definition-holds-credential") try { recordBootRefused(org.root, { business: businessId, file, reason: e.message, prior: priorBoot }); } catch { /* the refusal still throws */ }
throw e;
});
try {
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
@@ -737,6 +744,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
// builds; `org validate FILE` stays orgCommand's draft check.
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
// #1873 T1: definition writes go through the organization's bus, as the human.
if (verb === 'org' && rest[0] === 'definitions') return definitionsCommand(rest.slice(1), io, { ...deps, callFor: (p) => callFor(p, io, deps) });
if (verb === 'org') return orgCommand(rest, io, deps);
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
+37 -6
View File
@@ -13,6 +13,7 @@ import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loa
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { systemConfigProblem } from "../../organization/src/system-config-problem.mjs";
import { businessFilePath } from "../../organization/src/organization-config.mjs";
import { busOrganization } from "../../bus/src/organization-context.mjs";
import { BusError } from "../../bus/src/broker.mjs";
@@ -41,13 +42,40 @@ export function orgPaths(root) {
threads: join(root, "threads"), hostDir };
}
// #1873: mosaic-config's stderr can quote the config's path, the data root or a config value, so
// it is never relayed. An instance refusal keeps its code and the instance names, which passed
// the instance pattern; a missing config keeps its bootstrap hint; anything else reads as
// `mosaic org config` reads it: a known field and its constraint, the config named by label.
const INSTANCE = "[a-z][a-z0-9]{1,15}";
function instanceProblem(problem, label) {
const body = problem.replace(/^mosaic-config: /, "");
let m = new RegExp(`^instance-root-mismatch: .+ is claimed by instance (${INSTANCE})(?:, not (${INSTANCE})|; select it with --instance \\1)$`).exec(body);
if (m) return `instance-root-mismatch: the data root is claimed by instance ${m[1]}${m[2] ? `, not ${m[2]}` : `; select it with --instance ${m[1]}`}`;
m = new RegExp(`^instance-root-unclaimed: .+ holds no instance\\.json naming (${INSTANCE}) \\(scripts/bootstrap\\.sh --instance \\1 claims a new root\\)$`).exec(body);
if (m) return `instance-root-unclaimed: the data root holds no instance.json naming ${m[1]} (scripts/bootstrap.sh --instance ${m[1]} claims a new root)`;
if (body.startsWith("instance-root-unclaimed: ")) return "instance-root-unclaimed: the data root's instance.json is not a readable, valid marker";
m = new RegExp(`^instance-config-mismatch: --instance (${INSTANCE}) names `).exec(body);
if (m) return `instance-config-mismatch: --instance ${m[1]} names its own config, but MOSAIC_CONFIG names another`;
if (body.startsWith("instance-config-mismatch: ")) return "instance-config-mismatch: --instance names its own config, but MOSAIC_CONFIG names another";
if (body.startsWith("instance-name-invalid: ")) return `instance-name-invalid: an instance name must match /^${INSTANCE}$/`;
if (body.startsWith("configuration not found: ")) {
m = new RegExp(`\\(run (scripts/bootstrap\\.sh(?: --instance ${INSTANCE})?) to create it\\)$`).exec(body);
return `configuration not found: ${label}${m ? ` (run ${m[1]} to create it)` : ""}`;
}
return null;
}
export function loadSystem({ env = process.env, instance = null } = {}) {
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate", ...(instance === null ? [] : ["--instance", instance])], {
encoding: "utf8",
maxBuffer: 1024 * 1024,
env,
});
if (proc.status !== 0) throw new CliError(`system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`, 3);
if (proc.status !== 0) {
const label = instance !== null && instance !== "dev" ? `the config of instance ${instance}` : env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config";
const known = instanceProblem(String(proc.stderr ?? "").trim(), label);
throw new CliError(known ? `system config problem (mosaic-config exit ${proc.status}): ${known}` : systemConfigProblem(proc, env, label), 3);
}
try {
return JSON.parse(proc.stdout);
} catch {
@@ -90,7 +118,7 @@ function trackerFor(system, b, warn, dataRoot) {
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const project = loadProject(entry.root);
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
if (project.id !== id) throw new CliError(`project file of project ${id} has id that does not match the declared project key`, 3);
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
}
@@ -127,17 +155,20 @@ function accessFor(system, env, warn) {
try {
context = accessContextFor(system, env);
} catch (e) {
warn(`access.json is present but its context can't be read, so no granted user binds: ${e.message}`);
// #1873: the error's message can quote a path; only a plain code is relayed.
const code = typeof e?.code === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e.code) ? ` (${e.code})` : "";
warn(`access.json is present but its context can't be read, so no granted user binds${code}`);
return null;
}
// sage-3153: one line at boot when the loader refuses the file, so the owner sees why no user
// binds. The loader's messages can quote the file, so the line names only the path and a code
// from its exit code: 4 is a file it could not open as a regular file, anything else invalid.
// binds. The loader's messages can quote the file, so the line names only the file, by label, and
// a code from its exit code: 4 is a file it could not open as a regular file, anything else
// invalid. #1873: no host path.
try {
loadAccess(file, context);
} catch (e) {
const code = e instanceof BusinessError && e.exitCode !== 4 ? "access-invalid" : "access-unreadable";
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: ${file}`);
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`);
}
return { file, context };
}
+6 -6
View File
@@ -46,7 +46,7 @@ function noCredentials(env) {
function ownedPrivate(path, directory) {
const st = lstatSync(path);
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile()) || st.uid !== process.getuid() || (st.mode & 0o077))
throw refuse('fixture-state-unsafe', `${path} is not a private ${directory ? 'directory' : 'file'} of this user`);
throw refuse('fixture-state-unsafe', `${directory ? 'dev-fixture directory' : 'fixture file'} is not a private ${directory ? 'directory' : 'file'} of this user`);
}
// The recorded fixture, or null when there is none. Anything malformed refuses.
@@ -56,19 +56,19 @@ export function readFixture(dataRoot) {
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
let fd;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `${file} can't be opened (${e.code})`); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `fixture.json can't be opened (${e.code})`); }
let doc;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', `${file} is not a private file of this user`);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', 'fixture.json is not a private file of this user');
doc = JSON.parse(readFileSync(fd, 'utf8'));
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', `${file} is not JSON`); }
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', 'fixture.json is not JSON'); }
finally { closeSync(fd); }
const m = LOOPBACK.exec(doc?.endpoint ?? '');
if (!m) throw refuse('fixture-endpoint-not-loopback', `${file} names an endpoint that is not http://127.0.0.1:<port>`);
if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:<port>');
if (doc.fixtureVersion !== 1 || doc.model !== 'deterministic' || Number(m[1]) !== doc.port || !Number.isSafeInteger(doc.pid) || doc.pid < 2
|| typeof doc.instance !== 'string' || JSON.stringify(doc.entry) !== JSON.stringify(FIXTURE_ENTRY))
throw refuse('fixture-state-invalid', `${file} is not a fixture record`);
throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record');
return doc;
}
+7 -4
View File
@@ -79,12 +79,13 @@ import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { basename, join } from "node:path";
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
import { withProcessing } from '../../processing-control/src/effects.mjs';
const HEADER_MAX = 4096;
@@ -385,7 +386,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (!p) throw new Refusal("unknown-project");
mkdirSync(logs, { recursive: true, mode: 0o700 });
prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) });
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`);
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `project ${facts.project} isn't a mosaic-stack checkout with node_modules`);
symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules"));
} catch (e) {
if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -398,7 +399,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
scope.guard();
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
// The detail reaches the role, so the log is named inside the bus host directory, never by
// host path (PR #1916); its file name only when it passes the id pattern.
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see land/${id}${ID_PATTERN.test(basename(r.log ?? "")) ? `/${basename(r.log)}` : ""} in the bus host directory`);
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
} finally {
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -503,7 +506,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
async function listen() {
if (existsSync(path)) {
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
if (!lstatSync(path).isSocket()) throw new CliError("git socket path exists and isn't a socket", 3);
rmSync(path);
}
server = createServer((socket) => {
+8 -5
View File
@@ -54,10 +54,10 @@ function readState(file, found = () => {}) {
state = JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
if (e.code === "ENOENT") return null;
throw new CliError(`bus host state is unreadable: ${file}`, 3);
throw new CliError("bus host state is unreadable", 3);
}
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
throw new CliError(`bus host state is malformed: ${file}`, 3);
throw new CliError("bus host state is malformed", 3);
}
return { ...state, live: startTimeOf(state.pid) === state.startTime };
}
@@ -94,7 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
const prior = readState(file, found);
if (!prior) continue;
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3);
if (refuseStale) throw new CliError("stale bus host lock", 3);
between();
try {
renameSync(file, aside);
@@ -111,7 +111,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
}
rmSync(aside, { force: true });
}
throw new CliError(`${file} kept changing; no bus host started`, 3);
throw new CliError("bus host lock kept changing; no bus host started", 3);
} finally {
rmSync(tmp, { force: true });
}
@@ -228,7 +228,10 @@ export async function startHost({ boot, business, notifier = null, options = nul
if (ready?.ok !== true) {
await ended(broker, 5000);
release();
throw new CliError(`broker refused to start: ${typeof ready?.error === "string" ? ready.error : "startup-refused"}`, 3);
const code = typeof ready?.error === "string" ? ready.error : "startup-refused";
// org-defscan: the broker's reason names the definition and key that hold a credential, never the text.
const why = code === "definition-holds-credential" && typeof ready?.reason === "string" ? `: ${ready.reason.slice(0, 300)}` : "";
throw Object.assign(new CliError(`broker refused to start: ${code}${why}`, 3), code === "definition-holds-credential" ? { code } : {});
}
// Every step from here to host.json shares one failure path: it stops the
+3 -1
View File
@@ -120,7 +120,9 @@ function setArgs(parsed) {
try {
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
} catch (error) {
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
// Name the flag and the error kind, never the file's path or bytes (PR #1916).
const code = typeof error?.code === "string" && /^E[A-Z0-9]+$/.test(error.code) ? error.code : "unreadable";
throw new CliError(`--vars file: ${error instanceof SyntaxError ? "not valid JSON" : code}`, 4);
}
}
if (launch !== undefined) args.launch = launch;
+52 -27
View File
@@ -145,6 +145,7 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync,
import { dirname, join, relative } from "node:path";
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { quietMessage } from "../../organization/src/org-registry.mjs";
import { businessFilePath } from "../../organization/src/organization-config.mjs";
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
@@ -201,13 +202,24 @@ class Refusal extends Error {
}
}
// #1873: a refusal's reason never quotes a host path. A Node system error's message names one,
// so it reads as the call and its code (org-registry quietMessage). Any other message is the
// host's own text or text relayed from a module (a preflight, the loader, a bundle or checkout
// error); relay() replaces every absolute or home path and file: URL in it with "a host path".
const HOST_PATH = /(^|[\s'"(=:,[<])(?:~?\/(?!\/)|file:)[^\s'"),\]>;]*/gi;
export const relay = (text) => String(text).replace(HOST_PATH, (_, before) => `${before}a host path`);
const reason = (e) => quietMessage(e, "a host file") ?? relay(e?.message ?? e);
const relayed = (failed) => failed && (typeof failed.message === "string" ? { ...failed, message: relay(failed.message) } : failed);
// A harness module runs with the host's authority, and anything it throws refuses the launch
// and names the module rather than escaping the host.
// and names the module rather than escaping the host. What it threw is named by its system code
// or its class, never its message, which the host didn't write (#1873).
function call(name, what, fn) {
try {
return fn();
} catch (e) {
throw new Refusal("harness-failed", `harness ${name} ${what}: ${e.message}`);
const thrown = quietMessage(e, "a host file") ?? `threw ${/^[A-Za-z][A-Za-z0-9]{0,39}$/.test(String(e?.name)) ? e.name : "a value"} without a system code`;
throw new Refusal("harness-failed", `harness ${name} ${what}: ${thrown}`);
}
}
@@ -272,7 +284,8 @@ export function createLauncher({
try {
leftover = readSessions(dataRoot).filter((s) => s.business === b.id);
} catch (e) {
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
// readSessions names the file by its place under the data root (#1873); anything else is unreadable.
throw new CliError(`${e?.name === "SeatError" ? e.message : "sessions file is unreadable"}; check which sessions still run, then move the file aside`, 3);
}
const registry = new Registry(dataRoot, { readOnly: !launches });
// Row 269: earlier-host runs still live after recover(). check() sees each while it lives.
@@ -380,11 +393,13 @@ export function createLauncher({
// workspace and the thread's store, so no session of the instance starts while one lives.
if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session))
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
let resolved, definitionEntry;
try {
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
// org-defscan (#1873): the entry the contract came from, taken before any await lets another check replace b.
definitionEntry = String(resolved.definition).startsWith("org:") ? b.definitions?.[resolved.definition] : undefined;
} catch (e) {
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : relay(e.message));
}
// Resolve the business's current provider modules with a fresh loader; a refused
// load never inherits a previous business's authority.
@@ -407,17 +422,23 @@ export function createLauncher({
return sessionModel(resolved, b.launch.models, which, { proxy: Boolean(system.proxy || proxyContext), resolver });
} catch (e) {
const code = e instanceof BundleError && (e.code === "no-route" || e.code.startsWith("proxy-") || e.code.startsWith("model-")) ? e.code : "unsupported-harness";
throw new Refusal(code, e.message);
throw new Refusal(code, relay(e.message));
}
};
const primary = routeOf("primary");
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
// org-defscan (#1873): an org: definition, as this check just read it, passes the bus's held-secret check before a contract exists.
if (String(resolved.definition).startsWith("org:")) {
const { held } = (await host.op({ op: "definitionScan", business: b.id, definition: resolved.definition, entry: definitionEntry })) ?? {};
if (typeof held === "string") throw new Refusal("definition-holds-credential", `${instance}'s definition ${resolved.definition} holds a credential in ${held}`);
if (held !== null) throw new Refusal("organization-refused", `the bus did not check ${instance}'s definition ${resolved.definition}`);
}
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
try {
readSkills(resolved.skills, skillsDir);
} catch (e) {
if (e instanceof BundleError) throw new Refusal(e.code === "skill-not-enabled" ? "skill-not-enabled" : "skill-refused", e.message);
if (e instanceof BundleError) throw new Refusal(e.code === "skill-not-enabled" ? "skill-not-enabled" : "skill-refused", relay(e.message));
throw e;
}
// Both of a launch's harnesses must be switched on, whichever it takes.
@@ -429,7 +450,9 @@ export function createLauncher({
const harnessOf = async (m) => {
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) });
const { refused } = await loader.load({ harness: [m.harness] });
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
// #1873: a loader message can quote the module's path or its load error; only a
// missing-export one, which names the export, is relayed. Any other refusal names its code.
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => r.code === "missing-export" ? `${r.code}: ${relay(r.message)}` : String(r.code)).join("; ")}`);
const module = loader.modules("harness")[0];
const levels = call(m.harness, "levels", () => module.levels());
if (!levels.includes(m.thinking)) throw new Refusal("thinking-unsupported", `harness ${m.harness} takes thinking ${levels.join(", ")}, not ${m.thinking}`);
@@ -476,7 +499,7 @@ export function createLauncher({
}
}
const unavailable = sandboxProblem({ bwrap });
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
if (unavailable) throw new Refusal("sandbox-unavailable", relay(unavailable));
const hit=limitHit(registry.sessions,b.id,b.launch.limits,model);
if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;}
// Row 125 (#1619, REQ-LIMIT-4): the limit checks. An answer that isn't an allow refuses,
@@ -524,7 +547,8 @@ export function createLauncher({
// and the helper returns the last one they saw.
const pickRoute = async (proxyOf) => {
let sessionProxy = await proxyOf(primary);
const failed = call(primary.harness, "preflight", () => harness.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
// #1873: a preflight's message is the module's own text, relayed with host paths replaced.
const failed = relayed(call(primary.harness, "preflight", () => harness.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy })));
if (failed) {
if (!fallback) throw new Refusal(failed.code, failed.message);
// The fallback only on a failed primary preflight, and only at launch (row 65). Any refusal of
@@ -534,7 +558,7 @@ export function createLauncher({
throw e;
});
sessionProxy = await proxyOf(fallback);
const also = call(fallback.harness, "preflight", () => second.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
const also = relayed(call(fallback.harness, "preflight", () => second.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy })));
if (also) throw new Refusal(also.code, `fallback ${fallback.harness}: ${also.message} (primary ${primary.harness}: ${failed.message})`);
[model, harness, route, routeReason] = [fallback, second, "fallback", failed.code];
}
@@ -656,7 +680,7 @@ export function createLauncher({
return ensureCheckouts({ workspace, projects: codeProjects, env });
});
} catch (e) {
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${relay(e.message)}`);
throw e;
}
for (const c of checkouts) {
@@ -723,7 +747,7 @@ export function createLauncher({
models: b.launch.models,
proxy: sessionProxy,
resolver,
contract: readFileSync(resolved.contract, "utf8"),
contract: resolved.contract ? readFileSync(resolved.contract, "utf8") : resolved.instructions,
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
@@ -853,7 +877,7 @@ export function createLauncher({
unregisterRuntime(dataRoot, managed.id);
if (e instanceof Refusal) throw e;
if (e.code?.startsWith('proxy-')) throw new Refusal(e.code, e.code);
throw new Refusal("launch-failed", e.message);
throw new Refusal("launch-failed", reason(e));
}
children.set(run, exited);
const outputWatch = admittedProxy ? watchModelOutput({ runDir, armAtFirstTurn: true, keyId: modelKeyId, seconds: system.proxy.firstOutputSeconds ?? 120,
@@ -1033,7 +1057,7 @@ export function createLauncher({
if (refusedThreads.get(key) === code) return;
refusedThreads.set(key, code);
log(`thread ${t.thread} session ${t.session} (${t.instance}): start refused: ${code}`);
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
}
});
}
@@ -1135,7 +1159,7 @@ export function createLauncher({
if (clearLogged.get(c.op) === code) return;
clearLogged.set(c.op, code);
log(`clear ${c.op}: successor ${c.successor} (${c.instance}): start ${CLEAR_HELD.has(code) ? "held" : "refused"}: ${code}`);
record({ event: "refused", by: b.human, instance: c.instance, thread: c.thread, session: c.successor, code, why: e.message, ...(e.limit ? { limit: e.limit } : {}) });
record({ event: "refused", by: b.human, instance: c.instance, thread: c.thread, session: c.successor, code, why: reason(e), ...(e.limit ? { limit: e.limit } : {}) });
await host.op({ op: "sessionClearStart", record: { ...where, start: CLEAR_HELD.has(code) ? "held" : "refused", refusal: code } });
return;
}
@@ -1195,7 +1219,7 @@ export function createLauncher({
if (refusedLand.get(h.decision) === code) return;
refusedLand.set(h.decision, code);
log(`decision ${h.decision} (${h.instance}): landing start refused: ${code}`);
record({ event: "refused", by: b.human, instance: h.instance, decision: h.decision, code, why: e.message, ...(e.limit ? { limit: e.limit } : {}) });
record({ event: "refused", by: b.human, instance: h.instance, decision: h.decision, code, why: reason(e), ...(e.limit ? { limit: e.limit } : {}) });
}
});
}
@@ -1240,7 +1264,7 @@ export function createLauncher({
try {
done = await act(take);
} catch (e) {
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: e.message, limit: e.limit };
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: reason(e), limit: e.limit };
}
const { why, limit, ...closing } = done;
if (closing.refusal) record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: closing.refusal, ...(why ? { why } : {}), ...(limit ? { limit } : {}) });
@@ -1348,18 +1372,19 @@ export function createLauncher({
const bot = out.bot ? { bot: out.bot } : {};
// The old instance workspace, then (row 269) the instance's session workspaces, each moved
// whole. A retry moves whichever is still in place.
for (const from of [workspaceDir(dataRoot, b.id, r.instance), join(dataRoot, "session-workspaces", b.id, r.instance)]) {
for (const [from, label] of [[workspaceDir(dataRoot, b.id, r.instance), "the instance workspace"], [join(dataRoot, "session-workspaces", b.id, r.instance), "the session workspaces"]]) {
if (!existsSync(from)) continue;
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
try {
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
chmodSync(dirname(to), 0o700);
if (existsSync(to)) throw new Error(`${to} exists`);
if (existsSync(to)) throw new Error("its .retired name already exists");
renameSync(from, to);
chmodSync(to, 0o700);
} catch (e) {
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
// #1873: the reason names the workspace by label and the error by code, never a path.
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${quietMessage(e, label) ?? `${label}: ${relay(e.message)}`}; left in place` };
}
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
}
@@ -1430,7 +1455,7 @@ export function createLauncher({
} catch (e) {
if (!(e instanceof Refusal)) throw e;
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
record({ event: "refused", by: who.role, instance, code: e.code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
return reply({ ok: false, error: e.code });
}
try {
@@ -1445,7 +1470,7 @@ export function createLauncher({
const code = e instanceof Refusal ? e.code : "launch-failed";
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
await host.op({ op: "refuse", cap, code }).catch(() => {});
record({ event: "refused", by: who.role, instance, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
record({ event: "refused", by: who.role, instance, code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
return reply({ ok: false, error: code });
}
});
@@ -1459,8 +1484,8 @@ export function createLauncher({
return await start({ instance: b.launch.by, launchedBy: b.human, ...(await check(b.launch.by, { pm: true })) });
} catch (e) {
if (!(e instanceof Refusal)) throw e;
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
const error = new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
const error = new CliError(`PM launch refused: ${e.code}${reason(e) !== e.code ? ` (${reason(e)})` : ""}`, 3);
error.code = e.code;
throw error;
}
@@ -1511,7 +1536,7 @@ export function createLauncher({
await recover();
const path = launchSocketPath(dataRoot);
if (existsSync(path)) {
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
if (!lstatSync(path).isSocket()) throw new CliError("launch socket path exists and isn't a socket", 3);
rmSync(path);
}
server = createServer((socket) => {
+10 -10
View File
@@ -162,28 +162,28 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
mkdirSync(dir, { recursive: true, mode: 0o700 });
} catch (e) {
// A dangling link in place of the directory fails mkdir with ENOENT.
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code})`, 3);
if (e.code !== "EEXIST") throw e;
}
const ds = lstatSync(dir);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) {
throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3);
throw new CliError(`notify journal directory must be mode 0700 and owned by this user`, 3);
}
try {
accessSync(dir, constants.W_OK | constants.X_OK);
} catch (e) {
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code}): ${dir}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code})`, 3);
throw e;
}
let fd;
try {
fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600);
} catch (e) {
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code})`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
throw e;
}
const sent = new Set();
@@ -204,7 +204,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
}
const bytes = readFileSync(fd);
const end = bytes.lastIndexOf(0x0a) + 1;
@@ -218,7 +218,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
r = null;
}
const bad = r === null ? "json" : badField(r);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad}): ${file}`, 3);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad})`, 3);
count(r);
});
if (end < bytes.length) {
+6 -5
View File
@@ -6,27 +6,28 @@ import { CliError } from "./errors.mjs";
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
// Missing refuses, so a host never starts without someone deciding whether
// it DMs; null runs the host without a notifier.
// it DMs; null runs the host without a notifier. Refusals name the business
// and the path under the data root, never the host path (PR #1916).
export function readNotifyConfig(dataRoot, business) {
const file = join(dataRoot, "notify", business, "notify.json");
let st;
try {
st = lstatSync(file);
} catch {
throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": "<discord binding>"} there, mode 0600, or "binding": null to run without DMs`, 3);
throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3);
}
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3);
}
let doc;
try {
doc = JSON.parse(readFileSync(file, "utf8"));
} catch {
throw new CliError(`notifier config is not JSON: ${file}`, 3);
throw new CliError(`notifier config for ${business} is not JSON`, 3);
}
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": <binding name> | null}: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": <binding name> | null}`, 3);
}
return doc.binding;
}
+3 -1
View File
@@ -138,7 +138,9 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
await new Promise(r => setTimeout(r, 50));
}
if (!exited) { child.kill('SIGTERM'); await done; }
throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3);
// Name the log by its place in the organization's runtime directory, never by host path (PR #1916);
// resolveOrg admitted `id` only after the registry's id check.
throw new CliError(`bus failed to start; see bus-host/host.log in the runtime directory of organization ${id}`, 3);
}
export async function busRestart(ctx, id) {
const org = await target(ctx, id);
+5 -7
View File
@@ -4,7 +4,7 @@ import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
@@ -95,7 +95,8 @@ async function launch(ctx, args, lan, detach) {
}
await sleep(50);
}
fail(`console failed to start; see ${log}`);
// The log is named under the data root, never by host path (PR #1916).
fail(`console failed to start; see console/${basename(log)} under the data root`);
} catch (e) {
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
@@ -193,11 +194,8 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
const argv = cmdlineOf(s.pid);
if (!argv || !argv.length) fail('console process identity is uncertain');
const i = argv.indexOf(WEBUI);
if (i < 0 || argv[i + 1] !== 'serve') {
const serve = argv.indexOf('serve');
const entry = serve > 0 ? ` ${argv[serve - 1]}` : '';
fail(`console was started from another path${entry}; stop it there`);
}
// The other entry script is a host path, so the refusal never names it (PR #1916).
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
process.kill(s.pid, 'SIGTERM');
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
+96
View File
@@ -0,0 +1,96 @@
// #1873 T1: `mosaic org definitions`, the human verbs on an organization's own role definitions and
// the template hash and diff reads. Reads run here, from the file. A write runs only from a human
// shell (refuseInsideAgent) and goes through the organization's bus as its human: the bus holds the
// credential store, whose held-secret check every definition write needs (R1), and no other process
// holds the tokens it looks for. Each write prints its receipt.
import { readFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
import { checkDefinition } from '../../organization/src/definitions.mjs';
import { rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { busExit, refuseInsideAgent } from './transport.mjs';
export const DEFINITIONS_USAGE = 'mosaic org definitions templates | definitions ID list | definitions ID show NAME | definitions ID diff NAME | definitions ID import TEMPLATE [--as NAME] | definitions ID invent NAME --from FILE | definitions ID edit NAME --from FILE --if-rev N | definitions ID remove NAME --if-rev N';
const WRITES = ['import', 'invent', 'edit', 'remove'];
const VALUES = ['--as', '--from', '--if-rev'];
function parse(args) {
const pos = [];
const values = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (VALUES.includes(a)) {
if (values[a] !== undefined || !args[i + 1] || args[i + 1].startsWith('--')) throw new CliError(`${a} needs one value`, 4);
values[a] = args[++i];
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
else pos.push(a);
}
return { pos, values };
}
async function definitionFile(file) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (e) {
// Never echo the file's text: it may hold what the composition check refuses.
throw new CliError(`cannot read definition ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.code ?? 'unreadable'}`, 2);
}
}
// The bus answers a refusal with its code only. These say what the code means for this request;
// none quotes the definition's text.
function refusal(code, verb, name, rev, read) {
const why = {
'definition-in-use': () => `definition ${name} is named by ${read().usedBy.join(', ')}; a change to a hired agent's role is a change decision`,
'definition-rev-conflict': () => `definition ${name} is not at rev ${rev}`,
'definition-exists': () => `definitions already declares ${name}`,
'definition-missing': () => `definitions declares no ${name}`,
'revision-conflict': () => 'the organization file changed while this ran; run it again',
'credential-leak': () => `the ${verb} request holds a credential the organization's bus holds; nothing was written`,
'invalid-request': () => `the bus refused the ${verb}: the definition fails the composition check or holds a held credential`,
}[code];
let detail = null;
try { detail = why?.() ?? null; } catch { detail = null; }
const error = new CliError(detail ? `${code}: ${detail}` : code, busExit(code));
error.code = code;
return error;
}
// deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs
// passes its callFor). Without one, a write refuses: it never runs without the bus's secret check.
export async function definitionsCommand(args, io, deps = {}) {
const { pos, values } = parse(args);
const allowed = (...keys) => { for (const k of Object.keys(values)) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
const need = (key) => { if (!values[key]) throw new CliError(`${key} is required`, 4); return values[key]; };
const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); };
const [id, verb, name] = pos;
if (WRITES.includes(verb)) refuseInsideAgent(io.env);
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) });
let result;
if (id === 'templates' && pos.length === 1) { allowed(); result = verbs.templates(); }
else if (verb === 'list' && pos.length === 2) { allowed(); result = verbs.list(id); }
else if (['show', 'diff'].includes(verb) && pos.length === 3) { allowed(); result = verbs[verb](id, name); }
else if (WRITES.includes(verb) && pos.length === 3) {
let request, rev;
if (verb === 'import') { allowed('--as'); request = { template: name, as: values['--as'] ?? name }; }
else if (verb === 'invent') { allowed('--from'); request = { name, definition: await definitionFile(need('--from')) }; }
else if (verb === 'edit') { allowed('--from', '--if-rev'); rev = ifRev(); request = { rev, name, definition: await definitionFile(need('--from')) }; }
else { allowed('--if-rev'); rev = ifRev(); request = { rev, name }; }
// The file's own checks first, so a bad key is named here; the bus repeats them with its secret check.
if (request.definition) checkDefinition(name, { ...request.definition, rev: rev ?? 1 });
if (typeof deps.callFor !== 'function') throw new CliError('a definition write goes through the organization bus; run it as `mosaic org definitions`', 3);
const call = deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
const { revision } = writer.read(id);
try {
result = await call(`definition.${verb}`, { revision, ...request });
} catch (e) {
if (typeof e?.code !== 'string' || e.code === '') throw e;
throw refusal(e.code, verb, request.as ?? name, rev, () => verbs.show(id, request.as ?? name));
}
} else throw new CliError(DEFINITIONS_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
+14 -3
View File
@@ -9,11 +9,16 @@ import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
try {
// #1873 T1: the definitions verbs take their own options.
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
@@ -41,5 +46,11 @@ export async function orgCommand(args, io, deps = {}) {
else throw new CliError(ORG_USAGE, 4);
} else throw new CliError(ORG_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); }
} catch (e) {
if (e instanceof CliError) throw e;
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
// and code, never by the host path in its message (PR #1916).
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
}
}
+2 -2
View File
@@ -63,7 +63,7 @@ const inside = (root, path) => { const r = relative(root, path); return r !== ''
export function threadRecords(file, thread) {
const log = fileEvidence(file);
if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 };
if (!log.ok) throw new CliError(`run-evidence-refused: launch log ${file}: ${log.problem}`, 3);
if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3);
const launches = [], ends = []; let malformed = 0;
log.bytes.toString('utf8').split('\n').forEach((line, i) => {
if (!line) return;
@@ -186,7 +186,7 @@ export async function runCommand(rest, io, deps = {}) {
lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)');
io.stdout.write(`${lines.join('\n')}\n`);
}
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in ${log.file}`, 2);
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2);
if (verify && !verified) throw new CliError('run evidence failed verification', 1);
return report;
}
+3 -1
View File
@@ -91,7 +91,9 @@ test("R6 B3 a boot with a file R1's loader refuses still boots, binds no user an
const s = await boot(t, file);
assert.equal(s.config.access.file, s.path, why);
assert.equal(s.bind("alice"), "access-revoked", why);
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: ${s.path}`], why);
// #1873: the warning names access.json by its place, never by host path.
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`], why);
assert.ok(!s.warnings.join("\n").includes(s.path), why);
assert.doesNotMatch(s.warnings.join("\n"), /secret-marker/, why);
}
});
+4 -3
View File
@@ -148,7 +148,7 @@ test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", a
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
@@ -165,13 +165,14 @@ test("a failed rename leaves the old bytes, removes the temp file, and writes no
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
assert.equal(err.exitCode, 3);
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)");
assert.ok(!err.message.includes(s.file), err.message);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
+14 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
@@ -101,3 +101,16 @@ test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any l
chmodSync(parent, 0o1777);
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
});
// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked
// id, never by the organization root's host path.
test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => {
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const root = join(s.configDir, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, mode: 0o700 });
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{not json', { mode: 0o600 }); });
const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; };
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/));
rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json'));
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/));
assert.equal(s.read().users.length, 2, 'nothing was removed');
});
+3 -1
View File
@@ -72,7 +72,9 @@ test("an unknown key and a JSON syntax error each leave the bus down with the re
assert.equal(s.business, "acme");
assert.equal(s.file, file);
assert.match(s.reason, reason);
assert.ok(s.reason.includes(file) || /surprise/.test(s.reason));
// The reason names the file by label, never by host path (PR #1916); the path stays in `file`.
assert.ok(!s.reason.includes(file), s.reason);
assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason);
// `bus status` shows it, in text and in JSON.
const out = run(f, "bus", "status", "acme", "--json");
assert.equal(out.status, 0, out.stderr);
@@ -0,0 +1,27 @@
// Invoked inside a private PID namespace. Check residual processes BEFORE its
// init exits: namespace teardown would otherwise mask a missed descendant kill.
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { instrumentCleanupTimer } from './case-inner-timing-fixture.mjs';
import { blockingQuerySource, killRecordedPids, recordedPids, recordedLivePids } from './case-sudo-fixture.mjs';
const [script,dir]=process.argv.slice(2),clock=join(dir,"clock '$;literal"),records=join(dir,'pids');
try {
writeFileSync(clock,'',{mode:0o600});
const worker=join(dir,'blocked.mjs');
writeFileSync(worker,blockingQuerySource(records,'sleep')+'\nloadSystem();');
const source=instrumentCleanupTimer(readFileSync(script,'utf8'),clock);
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep "$@"','--',process.execPath,worker],{
encoding:'utf8',timeout:12000,killSignal:'SIGKILL',
});
// These PIDs belong to this namespace; never use them to signal host PIDs.
process.stdout.write(JSON.stringify({
status:result.status,signal:result.signal,error:result.error?.message,
stdout:result.stdout,stderr:result.stderr,pairs:recordedPids(records),
live:recordedLivePids(records),clock:readFileSync(clock,'utf8'),
}));
} finally {
killRecordedPids(records);
}
@@ -0,0 +1,69 @@
// A delay in finish(), after timing2's end stamp, must fail independently of
// the inner timer. One fixed process tree runs at a time, without host setup,
// fake sudo, or a parent-side elapsed-time assertion.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { instrumentCleanupTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
const scripts=['case1-accept.sh','case2-unit.sh'];
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
// The namespace fixes /proc inventory size; it does not replace clean_session.
// See org-cleanup-time-build.md for the standalone and loaded calibration. The
// bound must reject 1s/2s delays and leave most of the 2s production headroom.
// Constant delays below 0.5s can pass; this is not a bound on host-wide scan
// cost. In particular, per-entry work needs a shipped scan-cost follow-up.
const CLEANUP_LIMIT_SECONDS=0.5;
const probe=fileURLToPath(new URL('./case-cleanup-time-fixture.mjs',import.meta.url));
for(const name of scripts)test(`cleanup timing ${name}: observers preserve bytes and reject exit-anchor drift`,()=>{
const source=read(name),clock="/unused/clock '$;literal";
const copy=instrumentCleanupTimer(source,clock);
assert.equal(removeInnerObservers(copy,clock),source);
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
const exit=' exit "$rc"\n';
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,''),clock),/anchor/);
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,exit+exit),clock),/anchor/);
});
// Both shipped copies are executed: Case1 shares this supervisor across legacy
// calls, queries and P10; Case2 uses its copy for config queries.
for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills descendants within the cleanup budget`,t=>{
const dir=mkdtempSync(join(tmpdir(),'cl-'));
t.after(()=>rmSync(dir,{recursive:true,force:true}));
// No skip/fallback: this Linux test requires unprivileged user/PID/mount
// namespaces. --kill-child also removes our namespace if the probe times out.
const outer=spawnSync('/usr/bin/unshare',[
'--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',
process.execPath,probe,fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,
],{
encoding:'utf8',timeout:15000,killSignal:'SIGKILL',
});
const prerequisite='requires /usr/bin/unshare and unprivileged user/PID/mount namespaces (check kernel/AppArmor/container seccomp)';
assert.equal(outer.error,undefined,`${prerequisite}: ${outer.error?.message}`);
assert.equal(outer.status,0,`${prerequisite}; probe output:\n${outer.stdout}${outer.stderr}`);
assert.equal(outer.stderr,'');
const result=JSON.parse(outer.stdout);
assert.equal(result.error,undefined,result.error);
assert.equal(result.signal,null,result.stderr);
assert.equal(result.status,81,result.stderr);
assert.equal(result.stdout,'');
assert.equal(result.stderr,'');
const pairs=result.pairs;
assert.equal(pairs.length,2,'the command and its live descendant both started');
assert.notEqual(pairs[0][0],pairs[0][1],'the trusted parent leads the command session');
assert.notEqual(pairs[1][0],pairs[0][0]);
assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session');
assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown');
// A positive centisecond pair relies on clean_session's current mandatory
// 0.01s retry sleep. Revisit this measurement if shipped cleanup removes it;
// keep the shared inner-timer parser strict in the meantime.
const seconds=readProbeSeconds(result.clock);
t.diagnostic(`${name}: cleanup ${seconds}s; limit ${CLEANUP_LIMIT_SECONDS}s`);
assert.ok(seconds<CLEANUP_LIMIT_SECONDS,`${name}: cleanup ${seconds}s must be < ${CLEANUP_LIMIT_SECONDS}s`);
});
@@ -0,0 +1,50 @@
// Test-copy observations only: bracket the supervisor's inner timer before
// clean_session scans the host's /proc. Keep all shipped control flow intact.
import { readProbeSeconds } from './case-p10-timing-fixture.mjs';
export { readProbeSeconds };
const BEGIN = '# row253-supervisor-begin\n';
const END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
function unique(source, anchor) {
const at=source.indexOf(anchor);
if(at<0 || source.indexOf(anchor,at+anchor.length)>=0) throw new Error('inner observer anchor must occur exactly once');
return at;
}
function innerObservers(clock) {
// The literal path survives env -i in the fake sudo. It is a private scratch
// file, never a product input. Quote even paths containing shell metacharacters.
const file="'"+clock.replaceAll("'", "'\\''")+"'";
return {
begin:`# inner-observer-begin\n{ read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'begin %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n# inner-observer-begin-end\n`,
end:` # inner-observer-end\n { read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'end %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n # inner-observer-end-end\n`,
};
}
export function instrumentInnerTimer(source,clock) {
const from=unique(source,BEGIN),to=unique(source,END);
if(to<=from)throw new Error('inner observer scope is reversed');
let supervisor=source.slice(from,to);
unique(supervisor,TIMER);unique(supervisor,FINISH);
const blocks=innerObservers(clock);
supervisor=supervisor.replace(TIMER,blocks.begin+TIMER).replace(FINISH,FINISH+blocks.end);
return source.slice(0,from)+supervisor+source.slice(to);
}
export function removeInnerObservers(source,clock) {
for(const block of Object.values(innerObservers(clock))){unique(source,block);source=source.replace(block,'');}
return source;
}
// Reuse the timing2 finish-entry stamp, moving only the other observation.
// The end is in the supervisor, after clean_session and both waits, immediately
// before exit. Parent scheduling and process start-up never enter this pair.
export function instrumentCleanupTimer(source,clock) {
const blocks=innerObservers(clock);
let observed=instrumentInnerTimer(source,clock);
const from=unique(observed,BEGIN),to=unique(observed,END);
let supervisor=observed.slice(from,to);
const exit=' exit "$rc"\n';
unique(supervisor,exit);
supervisor=supervisor.replace(blocks.begin,'').replace(blocks.end,blocks.begin);
supervisor=supervisor.replace(exit,blocks.end+exit);
return observed.slice(0,from)+supervisor+observed.slice(to);
}
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { tmp } from './helpers.mjs';
import { instrumentInnerTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
const scripts=['case1-accept.sh','case2-unit.sh'];
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
for(const name of scripts)test(`inner timing ${name}: private copy preserves shipped bytes and rejects anchor drift`,t=>{
const clock=join(tmp(t,'timer-'),'clock');
const source=read(name),copy=instrumentInnerTimer(source,clock);
assert.equal(removeInnerObservers(copy,clock),read(name));
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
for(const anchor of ['# row253-supervisor-begin\n','# row253-supervisor-end\n','"$sleep_bin" "$seconds" & timer=$!\n','finish() {\n local rc="$?"\n']){
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,''),clock),/anchor/);
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,anchor+anchor),clock),/anchor/);
}
});
for(const name of scripts)test(`inner timing ${name}: real supervisor retains streams status and EXIT cleanup`,t=>{
const clock=join(tmp(t,'timer-'),"clock '$;literal");writeFileSync(clock,'');
const source=instrumentInnerTimer(read(name),clock);
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
const result=spawnSync('/bin/bash',['-c',body+'\n/bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep /bin/bash -c \'printf exact-out; printf exact-err >&2; /usr/bin/sleep 0.1; exit 7\''],{encoding:'utf8'});
assert.equal(result.status,87,result.stderr);
assert.equal(result.stdout,'exact-out');assert.equal(result.stderr,'exact-err');
assert.match(readFileSync(clock,'utf8'),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
// The same strict parser used by the timing bands rejects a guard's partial,
// duplicate or reversed pair, rather than substituting wall time.
for(const invalid of ['begin 10.00\n','end 11.00\n','begin 10.00\nend 11.00\nbegin 12.00\nend 13.00\n','begin 11.00\nend 10.00\n'])
assert.throws(()=>readProbeSeconds(invalid));
});
+36 -1
View File
@@ -5,6 +5,13 @@ const LEGACY = '# row276-legacy-begin';
// Stable even when a mutant removes the outer timeout from this command.
const LAUNCH = ' LC_ALL=C ';
const WAIT = ' wait "$child" 2>/dev/null || rc=$?\n';
// org-base-reds: the root pair above also spans supervisor start-up and clean_session, a scan of
// every host /proc entry whose cost grows with the host's process count. The timer pair brackets
// only the inner timer: from just before the supervisor starts its sleep to the start of finish().
const SUPERVISOR = '# row253-supervisor-begin\n';
const SUPERVISOR_END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
export const P10_OBSERVERS = Object.freeze({
begin: ` # row299-observer-begin-start
@@ -20,6 +27,26 @@ export const P10_OBSERVERS = Object.freeze({
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMING_FILE"; } 2>/dev/null || :
fi
# row299-observer-end-end
`,
// Supervisor-side pair. Only the P10 peer's arguments carry the peer-credential option name.
// The pattern is split so this block never adds that literal to the supervisor text: the fake
// sudo stalls any command whose arguments contain it, and the supervisor text is an argument of
// every capture. finish() has no arguments of its own, so its stamp follows exactly one begin
// and is written at most once.
timerBegin: `# row299-timer-begin-start
if [ -n "\${ROW299_TIMER_FILE-}" ] && [[ "$*" == *SO_PEER*CRED* ]]; then
{ read -r row299_up row299_spare < /proc/uptime &&
printf 'begin %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-begin-end
`,
timerEnd: ` # row299-timer-end-start
if [ -n "\${ROW299_TIMER_FILE-}" ]; then
{ read -r row299_up row299_spare < /proc/uptime &&
[[ "$(< "$ROW299_TIMER_FILE")" == begin* && "$(< "$ROW299_TIMER_FILE")" != *end* ]] &&
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-end-end
`,
});
@@ -40,7 +67,15 @@ export function instrumentP10Probe(source) {
uniqueIndex(capture, WAIT);
capture = capture.replace(LAUNCH, P10_OBSERVERS.begin + LAUNCH);
capture = capture.replace(WAIT, WAIT + P10_OBSERVERS.end);
return source.slice(0, start) + capture + source.slice(end);
source = source.slice(0, start) + capture + source.slice(end);
const from = uniqueIndex(source, SUPERVISOR), to = uniqueIndex(source, SUPERVISOR_END);
if (to <= from) throw new Error('P10 observer supervisor scope is reversed');
let supervisor = source.slice(from, to);
uniqueIndex(supervisor, TIMER);
uniqueIndex(supervisor, FINISH);
supervisor = supervisor.replace(TIMER, P10_OBSERVERS.timerBegin + TIMER);
supervisor = supervisor.replace(FINISH, FINISH + P10_OBSERVERS.timerEnd);
return source.slice(0, from) + supervisor + source.slice(to);
}
export function removeP10Observers(source) {
@@ -0,0 +1,34 @@
// No real process is signalled. These hostile kernel-boundary controls expose
// a new session reusing the old SID after the original leader disappears.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
for(const name of ['case1-accept.sh','case2-unit.sh'])for(const mode of ['already-dead','dies-before-signal'])test(`${name}: ${mode} original session never signals a recycled SID`,()=>{
const source=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'the shipped cleanup program exists');
const anchor='\ntry:\n';assert.equal(program.split(anchor).length,2);
const control=`
import atexit, json
from types import SimpleNamespace
attempts = []
checks = 0
atexit.register(lambda: print(json.dumps(attempts)))
def observed(pid):
global checks
if pid == "self/fd/42":
checks += 1
return (b"T", session) if ${mode==='already-dead'?'False':'checks < 3'} else None
return (b"R", session)
def send(fd, sig):
attempts.append([fd, sig])
raise PermissionError("never signal a real task")
fields = observed
os = SimpleNamespace(listdir=lambda path: ["999"], pidfd_open=lambda pid: 17 if pid == session else 99, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
`;
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,'\n'+control+anchor),'424242','42'],{encoding:'utf8'});
assert.equal(r.status,1,r.stderr);assert.equal(r.stderr,'');
assert.deepEqual(JSON.parse(r.stdout),[],'a stale SID must never authorize a signal');
});
@@ -0,0 +1,85 @@
// Regression controls run the real supervisor. Only the kernel observation is
// fault-injected: a transient empty stat and a delayed first observation of an
// already completed fast command. Neither may authorize an unverified session.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
const names=['case1-accept.sh','case2-unit.sh'];
function body(name){const s=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');return s.slice(s.indexOf('# row253-supervisor-begin'),s.indexOf('# row253-supervisor-end'));}
function replaceOnce(source,anchor,replacement){assert.equal(source.split(anchor).length,2,'fault anchor must be unique');return source.replace(anchor,replacement);}
function run(source,setsid='/usr/bin/setsid',command=['/bin/true']){
return spawnSync('/bin/bash',['-c',source+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 "$@"','--',setsid,'/usr/bin/sleep',...command],{encoding:'utf8',timeout:8000});
}
test('shipped supervisors remain byte-identical',()=>assert.equal(body(names[0]),body(names[1])));
for(const name of names){
test(`${name}: a transient empty stat read retries within the inner budget`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${empty_once:-0}" = 0 ]; then empty_once=1; return 1; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'');
});
test(`${name}: fast success cannot disappear before session verification`,()=>{
// Delay only the parent's first observation. BASE lets /bin/true exit and
// loses its proc entry; a stopped trusted launcher remains verifiable.
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${delayed_once:-0}" = 0 ]; then delayed_once=1; "$sleep_bin" 0.08; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);
});
test(`${name}: a launcher that never creates a session is refused`,()=>{
const r=run(body(name),'/bin/false');assert.equal(r.status,85,r.stderr);
});
test(`${name}: a stopped launcher without a new session is never accepted`,()=>{
const r=run(body(name),'/usr/bin/env');assert.equal(r.status,81,r.stderr);
});
test(`${name}: persistently unreadable initial stat retains session refusal 85`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n return 1');
const r=run(source);assert.equal(r.status,85,r.stderr);
});
test(`${name}: command status and streams survive the launcher`,()=>{
const r=run(body(name),'/usr/bin/setsid',['/bin/bash','-c','printf out; printf err >&2; exit 7']);
assert.equal(r.status,87,r.stderr);assert.equal(r.stdout,'out');assert.equal(r.stderr,'err');
});
}
// A snapshot of the parent tree would miss a child reparented to namespace init.
// A new session is a separately documented escape, not a session member.
import {mkdtempSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable','anchor-death'])test(`${name}: namespace cleanup ${mode}`,t=>{
const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,'');
const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null);
assert.equal(result.status,['unreadable','unkillable','anchor-death'].includes(mode)?86:81,JSON.stringify(result));
assert.equal(result.stdout,'');
if(mode==='anchor-death')assert.match(result.stderr,/Killed/);else assert.equal(result.stderr,'');
if(!['unreadable','unkillable','anchor-death'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive');
if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;}
assert.equal(result.pairs.length,2);
const [leader,descendant]=result.pairs;
assert.notEqual(leader[0],leader[1]);
assert.equal(result.anchor,leader[1],'the trusted command parent reserves the SID');
if(mode==='escape'){
assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]);
assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it');
}else{
assert.equal(descendant[1],leader[1]);
if(mode==='anchor-death'){assert.ok(result.live.includes(leader[0]),'lost anchor refuses before signalling a bare SID');}
else if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');}
else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup');
}
});
// Unlike the private-inventory timing test, this sees the real host /proc.
// Restoring the old per-entry bash read/parse scan exceeds this bound on a
// representative host. It uses cleanup-time's stamps, excluding the inner wait.
import {writeFileSync} from 'node:fs';
import {instrumentCleanupTimer,readProbeSeconds} from './case-inner-timing-fixture.mjs';
for(const name of names)test(`${name}: host inventory cleanup stays within 0.5 seconds`,t=>{
const dir=mkdtempSync(join(tmpdir(),'hc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const clock=join(dir,'clock');writeFileSync(clock,'');
const r=run(instrumentCleanupTimer(body(name)+'# row253-supervisor-end\n',clock),'/usr/bin/setsid',['/bin/bash','-c','/usr/bin/sleep 30 & wait']);
assert.equal(r.status,81,r.stderr);assert.equal(r.stderr,'');assert.equal(r.stdout,'');
const seconds=readProbeSeconds(readFileSync(clock,'utf8'));t.diagnostic(`${name}: host cleanup ${seconds}s`);
assert.ok(seconds<0.5,`host cleanup ${seconds}s must be < 0.5s`);
});
@@ -0,0 +1,58 @@
// Namespace-local evidence and cleanup. The JSON observation precedes teardown.
import {readFileSync,writeFileSync,readdirSync,openSync,closeSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs';
const source=readFileSync(script,'utf8');
let body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
// Adversarial kernel boundary controls keep the real cleanup loop and budget.
if(mode==='unreadable')body=body.replace('def fields(pid):','def fields(pid):\n raise PermissionError("injected unreadable stat")');
if(mode==='unkillable')body=body.replace('signal.pidfd_send_signal(fd, signal.SIGKILL)','None # injected successful kill with a surviving member');
if(mode==='fork')writeFileSync(worker,`
import {spawn} from 'node:child_process';
import {appendFileSync,readFileSync} from 'node:fs';
const record=${JSON.stringify(records)};
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
appendFileSync(record,JSON.stringify([process.pid,sid(process.pid)])+'\\n');
setInterval(()=>{
const child=spawn('/usr/bin/sleep',['30'],{stdio:'ignore'});
appendFileSync(record,JSON.stringify([child.pid,sid(child.pid)])+'\\n');
},4);
`);
else writeFileSync(worker,`
import {spawn} from 'node:child_process';
import {readFileSync,writeFileSync} from 'node:fs';
const record=${JSON.stringify(records)};
const child=spawn('/bin/bash',['-c',${JSON.stringify(mode==='escape'?'exec /usr/bin/setsid /usr/bin/sleep 30':'/usr/bin/sleep 30 & echo $! > "$1"')},'--',record+'.child'],{stdio:'ignore'});
setTimeout(()=>{
const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"};
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]]));
writeFileSync(record+'.anchor',String(process.ppid));
writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]);
if(${JSON.stringify(mode)} === "anchor-death")process.kill(process.ppid,"SIGKILL");
},100);
setInterval(()=>{},1000);
`);
const out=openSync(dir+'/out','w'),err=openSync(dir+'/err','w');
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',stdio:['ignore',out,err],timeout:8000,killSignal:'SIGKILL'});
closeSync(out);closeSync(err);
result.stdout=readFileSync(dir+'/out','utf8');result.stderr=readFileSync(dir+'/err','utf8');
let pairs=[];
try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{}
const live=[];
for(const [pid] of pairs){try{const state=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[0];if(!['Z','X'].includes(state))live.push(pid);}catch{}}
// Inventory the entire private namespace too: a fork between spawn and its
// record write must not escape the assertion just because it was unrecorded.
const sessionLive=[];
for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){
try{
const words=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ');
if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid));
}catch{}
}
let parent,anchor;
try{anchor=Number(readFileSync(records+".anchor","utf8"));}catch{}
try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{}
process.stdout.write(JSON.stringify({sessionLive,parent,anchor,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live}));
// These PIDs exist only in the private namespace. Never signal them on the host.
for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{}
+60 -11
View File
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
import { instrumentP10Probe, removeP10Observers, readProbeSeconds, P10_OBSERVERS } from "./case-p10-timing-fixture.mjs";
import { instrumentInnerTimer } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource, LEGACY_CASES } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -378,6 +379,9 @@ test('row299 P10 timing copy preserves shipped bytes and isolates observer varia
assert.deepEqual([...block.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)=/g)],[],'no other assignments in observer blocks');
assert.doesNotMatch(block,/\$ROOT|\$\{ROOT/,'fixture root cannot supply the clock');
}
// The fake sudo keys its faults on these argument markers; observers must not add or remove any.
for(const marker of['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,`observers keep the ${marker} count`);
const changedTimer=source.replace('tester_capture 2 4 128','tester_capture 1 4 128');
assert.equal(removeP10Observers(instrumentP10Probe(changedTimer)),changedTimer,'copy keeps current timer mutants');
});
@@ -402,6 +406,34 @@ capture`;
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
});
test('row299 P10 timer observers keep supervisor status and record one P10 pair',t=>{
const dir=tmp(t,'p10-timer-'),clock=join(dir,'clock'),trapFile=join(dir,'trap');
put(clock,'',0o600);
// Same shape as TESTER_SUPERVISOR: positional arguments are the setsid command, finish() has none.
const program=`set +m
finish() {
local rc="$?"
${P10_OBSERVERS.timerEnd} printf trap > "$ROW299_TRAP_FILE"
exit "$rc"
}
trap finish EXIT
${P10_OBSERVERS.timerBegin}printf stdout-without-newline
printf stderr-without-newline >&2
exit 81`;
const supervise=(...args)=>spawnSync('/bin/bash',['-c',program,'--',...args],{encoding:'utf8',env:{...process.env,ROW299_TIMER_FILE:clock,ROW299_TRAP_FILE:trapFile}});
let r=supervise('node','-e','query');
assert.equal(r.status,81);assert.equal(read(clock),'','a supervisor without the P10 peer records nothing');
r=supervise('python','-c','getsockopt(SO_PEERCRED)');
assert.equal(r.status,81);
assert.equal(r.stdout,'stdout-without-newline');
assert.equal(r.stderr,'stderr-without-newline');
assert.equal(read(trapFile),'trap');
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
const pair=read(clock);
r=supervise('node','-e','later');
assert.equal(r.status,81);assert.equal(read(clock),pair,'a later supervisor cannot add a second end');
});
test('row299 P10 observer records are complete and ordered',()=>{
assert.equal(readProbeSeconds('begin 100.01\nend 102.03\n'),2.02);
const invalid={
@@ -433,23 +465,33 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
let observedScript=SCRIPT,timingFile;
let observedScript=SCRIPT,timingFile,timerFile;
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
// The outer fault stalls before the supervisor starts, so only the inner fault has a timer pair.
if(fault==='inner'){timerFile=join(h.hc,'timer-uptime');put(timerFile,'',0o600);}
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const stop=fixtureGuard(t,files,7000,timingFile);
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
const observe={...(timingFile?{ROW299_TIMING_FILE:timingFile}:{}),...(timerFile?{ROW299_TIMER_FILE:timerFile}:{})};
const start=performance.now(),r=run(h,observe,undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
assert.equal(r.status,0,r.out+r.err);
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
assert.doesNotMatch(r.out,/boundary=observed/);
let probeSeconds=seconds;
let probeSeconds=seconds,timerSeconds;
if(timingFile)assert.doesNotThrow(()=>{probeSeconds=readProbeSeconds(read(timingFile));},'P10 observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds}));
if(fault==='inner')assert.ok(probeSeconds>=1.25&&probeSeconds<2.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(timerFile)assert.doesNotThrow(()=>{timerSeconds=readProbeSeconds(read(timerFile));},'P10 timer observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds,timerSeconds}));
// org-base-reds: the band applies to the inner timer itself. The root pair adds supervisor start-up and
// clean_session's whole-host /proc scan, which grows with the host's process count, so it must contain
// the timer pair but has no band of its own; probe-inner-timeout above already places it before the outer bound.
if(fault==='inner'){
assert.ok(timerSeconds>=1.25&&timerSeconds<2.75,`${mode}: inner timer ${timerSeconds}s (root probe ${probeSeconds}s); baseline ${baselineSeconds}s is diagnostic only`);
assert.ok(probeSeconds>=timerSeconds,`${mode}: root probe ${probeSeconds}s must contain the inner timer ${timerSeconds}s`);
}
if(fault==='outer'){
assert.ok(probeSeconds>=3.25&&probeSeconds<4.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
@@ -896,7 +938,7 @@ test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env
// mos-1326: assert every argv element, not flattened/substr command detection.
const source=read(SCRIPT);
const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n';
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'34063098d7edb9067c11456165dc7535bc0e779d053c43599884d6b614528639');
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'52509773fa2bb0701402b8d2454816153e18323cfaa75cb72418b46c4dbb2d34');
const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1];
const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1];
const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line));
@@ -927,8 +969,8 @@ test("row 195 accept: the host check runs with --check and never --enforce", (t)
// Row276 exercises the real legacy launcher with fixture-only commands, never sudo.
function legacyBody() {
const source=read(SCRIPT),start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
function legacyBody(source=read(SCRIPT)) {
const start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
assert.ok(start>=0&&end>start,'legacy supervisor block exists');
return source.slice(start,end);
}
@@ -950,11 +992,18 @@ for(const {tag} of LEGACY_CASES)test(`row276 ${tag} legacy inner timeout cleans
const h=host(t),records=join(h.hc,'legacy-pids'),files=[join(h.hc,'session-pids'),records];
put(join(h.hc,'session-mode'),mode);
const file=join(h.dir,'blocked.mjs');put(file,blockingQuerySource(records,'signals')+'\nloadSystem();');
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500);
const start=performance.now(),r=legacyCall(h,tag,[NODE,file]);capture();
const clock=join(h.dir,'inner-clock');put(clock,'',0o600);
const body=legacyBody(instrumentInnerTimer(read(SCRIPT),clock));
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500,clock);
// The outer watchdog includes startup and cleanup; only the supervisor pair
// measures the inner budget. Seven seconds also lets the 4.5s guard expose a missing timer.
const start=performance.now(),r=legacyCall(h,tag,[NODE,file],{outer:7,body});capture();
assert.equal(r.status,81,r.stdout+r.stderr);
assert.equal(r.stderr,`case1-accept: ${tag} inner-timeout\n`);
assert.ok((performance.now()-start)/1000<2,mode);
const seconds=readProbeSeconds(read(clock)),wall=(performance.now()-start)/1000;
assert.ok(seconds>=0.25&&seconds<1.25,`${tag}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${tag}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);await stop();
}
});
+53 -22
View File
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
import { tmp } from "./helpers.mjs";
import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -107,13 +108,13 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) {
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const r = spawnSync(UNIT, args, {
const r = spawnSync(unitScript, args, {
encoding: "utf8",
timeout: 30000,
env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
});
return { code: r.status, out: r.stdout, err: r.stderr };
}
@@ -197,12 +198,27 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh');
mkdirSync(dirname(observed),{recursive:true});
symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh'));
writeFileSync(clock,'',{mode:0o600});
// The 7s outer watchdog spans startup and cleanup too. Give the private copy
// cleanup room while retaining a separate band on the unchanged 5s inner timer.
const source=read(UNIT),call='query="$(tester_capture 5 7 4096';
assert.equal(source.split(call).length,2,'tester query budget anchor is unique');
script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock);
const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{
CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE,
CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'),
}}),wall=(performance.now()-start)/1000;capture();
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
const seconds=readProbeSeconds(read(clock));
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
assert.ok(seconds<7,`${kind}/${mode}: ${seconds}s exceeds outer bound`);
assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
assert.equal(existsSync(h.log),false,mode);await stopGuard();
@@ -240,24 +256,39 @@ test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
});
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
// A deterministic clock models a slow inventory without host-load timing.
// All process reads and signals are stand-ins; no real PID is signalled.
// Run the shipped cleanup with a deterministic slow inventory/clock. Every
// kernel operation is replaced before execution; no real PID is signalled.
const source=read(UNIT);
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'fixed cleanup program exists');
const anchor='\ntry:\n';
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
const standins=`
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
stat_fields() {
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
[ "$seen" != 1 ] || ST_SESSION="$child"
return 0
}
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
kill() { kills=$((kills+1)); return 0; }
clean_session; rc=$?
printf '%s' "$kills"
exit "$rc"
from types import SimpleNamespace
alive = True
kills = 0
clock = time.monotonic()
def inventory(path):
global clock
clock += 1000
return ["999"]
def fake_fields(pid):
return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
def send(fd, sig):
global alive, kills
if sig == 9:
alive = False
kills += 1
fields = fake_fields
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
`;
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
});
+129
View File
@@ -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);
});
+15 -1
View File
@@ -804,13 +804,27 @@ test("land: a failed gate is refused gate-failed with its step, told to the role
assert.equal(ticket, TICKET);
assert.equal(a.code, "gate-failed");
assert.equal(a.notify, true);
assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/);
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12}\/pkg-bus\.log in the bus host directory$/);
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move");
assert.deepEqual(w.of("recordLand"), []);
assert.equal(w.trees(), 0);
});
// PR #1916 (org-redact5): the detail reaches the role, so it never carries the log's host
// path; a log file name outside the id pattern is dropped too.
test("land: a failed gate's detail names the land directory, never the log's host path", async (t) => {
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
let log = null;
const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: (log = join(logs, SENTINEL, `${SENTINEL}.log`)) }) } });
await w.land();
await w.ended();
const [[, a]] = w.of("landRefused");
assert.equal(a.code, "gate-failed");
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12} in the bus host directory$/);
assert.ok(!a.detail.includes(SENTINEL) && !a.detail.includes(log) && !a.detail.includes(w.f.dataRoot), a.detail);
});
test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => {
const w = await landSetup(t);
w.state.recordFails = "decision-consumed";
+8 -2
View File
@@ -315,7 +315,7 @@ test("a host.lock that goes between the check and the read is tried again; one t
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`);
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === "bus host state is unreadable");
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json");
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
});
@@ -749,8 +749,14 @@ test("a stale organization writer lock refuses start and status without removing
});
test("organization bus start refuses without a notifier config", async t => {
// PR #1916 (org-redact5): the refusal names the log inside the organization's
// runtime directory, never by host path. (A sentinel directory in the root would
// push the bus socket path past the socket length limit, so the check is on the
// fixture's own roots.)
const f = await orgFixture(t, { notify: false });
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i);
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), (e) => e.exitCode === 3
&& e.message === "bus failed to start; see bus-host/host.log in the runtime directory of organization acme"
&& !e.message.includes(f.dataRoot) && !e.message.includes(f.instanceRoot));
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
+14 -7
View File
@@ -21,10 +21,15 @@ function fixture(t) {
};
return { root, file, env, run, boot };
}
function redacted(result, file, status) {
// label: the CLI refusal names the file by this label and never by host path (PR #1916).
// scripts/mosaic-config.mjs names it by label too (#1873 redact6).
function redacted(result, file, status, label) {
assert.equal(result.status, status, result.stderr);
assert.equal(result.stdout, '');
assert.ok(result.stderr.includes(file), result.stderr);
if (label) {
assert.ok(!result.stderr.includes(file), result.stderr);
assert.ok(result.stderr.includes(label), result.stderr);
} else assert.ok(result.stderr.includes(file), result.stderr);
assert.match(result.stderr, /not valid JSON/);
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
}
@@ -32,22 +37,24 @@ function redacted(result, file, status) {
test('system config parse refusal and CLI relay omit parser bytes', t => {
const f = fixture(t);
mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, secret);
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2);
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2, 'configuration is not valid JSON (the config of instance redacttest)');
assert.throws(() => loadSystem({ env: f.env, instance: 'redacttest' }), error => {
assert.equal(error.exitCode, 3);
assert.ok(error.message.includes(f.file));
// #1873: the cli relay names the instance's config by label, never its path.
assert.ok(!error.message.includes(f.file), error.message);
assert.ok(error.message.includes('configuration is not valid JSON; expected readable JSON: the config of instance redacttest'), error.message);
assert.match(error.message, /not valid JSON/);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
return true;
});
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3, 'configuration is not valid JSON; expected readable JSON: the system config MOSAIC_CONFIG names');
});
test('named instance marker parse refusal omits parser bytes', t => {
const f = fixture(t); f.boot();
const { dataRoot } = JSON.parse(readFileSync(f.file, 'utf8'));
const marker = join(dataRoot, 'instance.json'); writeFileSync(marker, secret);
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3);
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3, "instance-root-unclaimed: the data root's instance marker is not a valid marker (not valid JSON)");
});
for (const operation of ['validate', 'create', 'update']) test(`org ${operation} document refusal omits parser bytes`, t => {
@@ -55,5 +62,5 @@ for (const operation of ['validate', 'create', 'update']) test(`org ${operation}
const file = join(f.root, 'alpha.json'); writeFileSync(file, secret);
const args = operation === 'validate' ? ['validate', file]
: [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])];
redacted(f.run(cli, 'org', ...args), file, 2);
redacted(f.run(cli, 'org', ...args), file, 2, 'mosaic: cannot read organization document: not valid JSON\n');
});
+9 -4
View File
@@ -46,14 +46,14 @@ for (const [field, edit] of configCases) test(`config ${field} omits the configu
test('config-command refuses system config without relaying stderr file values', t => {
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d));
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(!err.includes(f.config), err); assert.match(err, /the system config MOSAIC_CONFIG names/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
});
test('project id mismatch omits the file value in config-command', t => {
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
assert.equal(status, 2); assert.ok(!err.includes(file), err); assert.match(err, /project file of project stack has id that does not match the declared project key/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
});
for (const [field, edit] of [
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
@@ -80,7 +80,7 @@ cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdou
const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)});
process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`;
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
assert.equal(r.status, 3, r.stderr); assert.ok(!r.stderr.includes(file), r.stderr); assert.match(r.stderr, /: the system config MOSAIC_CONFIG names\n$/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
});
import { bootConfig, loadSystem } from '../src/config.mjs';
@@ -89,7 +89,12 @@ test('CLI boot project id mismatch omits the configured value and keeps exit 3',
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), safe(file, CliError, { exitCode: 3 }, secret.toLowerCase()));
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), error => {
// #1873: the refusal names the project by its key, never the project file's path.
safe('project file of project stack has id that does not match the declared project key', CliError, { exitCode: 3 }, secret.toLowerCase())(error);
assert.ok(!error.message.includes(file) && !error.message.includes(f.doc.projects.stack.root), error.message);
return true;
});
});
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool rejects an unknown value before naming it`, t => {
+1 -1
View File
@@ -120,7 +120,7 @@ test('row 65 the loader refusing a module refuses harness-refused and names it;
}
const s=await setup(t,route('throws','claude-sonnet-5-5','high'));
assert.deepEqual(await s.launch(),{ok:false,error:'harness-failed'});
assert.equal(s.last().why,'harness throws preflight: fixture preflight threw');
assert.equal(s.last().why,'harness throws preflight: threw Error without a system code');
});
test('row 65 a thinking level the route harness does not apply refuses thinking-unsupported, on the primary or a fallback taken',async t=>{
+7 -4
View File
@@ -768,9 +768,10 @@ test("a missing or failing bwrap refuses sandbox-unavailable before anything sta
const bad = join(given.root, "bwrap");
writeFileSync(bad, "#!/bin/sh\necho 'bwrap: setting up uid map: Permission denied' >&2\nexit 1\n", { mode: 0o755 });
const { f, launcher, cto } = await setup(t, undefined, given, { bwrap: bad });
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} failed: bwrap: setting up uid map: Permission denied)`);
// #1873: the sandbox by name, never the binary's host path.
await assert.rejects(launcher.launchPm(), (e) => e.message === "PM launch refused: sandbox-unavailable (the sandbox (bwrap) failed: bwrap: setting up uid map: Permission denied)");
rmSync(bad);
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} is missing)`);
await assert.rejects(launcher.launchPm(), (e) => e.message === "PM launch refused: sandbox-unavailable (the sandbox (bwrap) is missing)");
// A session's launch request gets the same code, through refuse().
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "sandbox-unavailable" });
assert.deepEqual(log(f).map((e) => e.code), ["sandbox-unavailable", "sandbox-unavailable", "sandbox-unavailable"]);
@@ -791,7 +792,7 @@ test("row 64 a role skill that isn't enabled refuses skill-not-enabled, and one
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-not-enabled" });
// A symlink in skills-enabled is refused, not followed.
symlinkSync(join(REPO, "skills", "ms-unslop"), join(enabled, "ms-unslop"));
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === `PM launch refused: skill-refused (skill ms-unslop isn't a plain directory under ${enabled})`);
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === "PM launch refused: skill-refused (skill ms-unslop isn't a plain directory under the enabled skills directory)");
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-refused" });
assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "skill-not-enabled"], ["refused", "skill-not-enabled"], ["refused", "skill-refused"], ["refused", "skill-refused"]]);
assert.deepEqual(readSessions(f.dataRoot), []);
@@ -832,7 +833,9 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
const e = entries[1];
assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
assert.equal(e.thread, thread);
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
// #1873: the file by its place under the persona directory, never its host path.
assert.equal(e.why, "instance bean: persona file acme/bean.md in the persona directory isn't the file persona.sha256 names");
assert.ok(!e.why.includes(dir));
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
assert.deepEqual(readSessions(f.dataRoot), []);
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
+22 -22
View File
@@ -1,3 +1,5 @@
import {ownerConsole} from '../../bus/tests/hire-fixtures.mjs';
import {readTemplate} from '../../organization/src/definitions.mjs';
import { requestHostOps } from './request-host-ops.mjs';
// Row 194 (#1692, D1.1, T2-T5, V5): the launcher half of the lifecycle verbs. The broker records
// the human's launch, end, regenerate and delete as requests (bus lifecycle-verbs.test.mjs), and
@@ -89,6 +91,8 @@ async function start(t, { f, adapter }, options = {}) {
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
// org-defscan (#1873): an org: definition's launch asks the bus's held-secret check, as process.mjs answers it.
definitionScan: (m) => runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry }),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
@@ -238,7 +242,7 @@ function bind(s, role, run) {
s.runtime.broker.request(cap, { verb: "role.claim" });
return cap;
}
const fire = (s, chief, name) => s.runtime.broker.request(chief, { verb: "instance.fire", args: { name, reason: "the review is done" } });
const fire = (s, chief, name) => s.runtime.broker.request(chief, {verb:"hire.raise",args:{op:"fire",level:"org",name,reason:"the review is done"}}).then(d => s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:d.id,choice:"yes",digest:d.hire.digest}}));
const allowed = (p, action) => {
const db = new DatabaseSync(join(p.f.dataRoot, "bus", "bus.sqlite"), { readOnly: true });
@@ -400,6 +404,10 @@ test("row 194 V5: delete retires the name, renames the workspace to .retired/I-<
const del = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
const failed = await s.carry(del);
assert.equal(failed.done.refusal, "workspace-rename-failed");
// #1873: the refused event's reason names the workspace by label and the error by code, never a path.
const refused = launchLog(p.f).filter((e) => e.event === "refused" && e.code === "workspace-rename-failed");
assert.deepEqual(refused.map((e) => e.why), ["cannot mkdir the instance workspace (EEXIST); left in place"]);
assert.ok(!JSON.stringify(refused).includes(p.f.dataRoot));
const transaction = failed.done.transaction;
assert.ok(transaction);
const file = JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8"));
@@ -601,7 +609,7 @@ test("row 194 T9a: fire signals its bound run and a spawned but unbound registry
const spare = await standIn(t, p, s, { instance: "coder", run: "coder-spare" });
const stuck = await standIn(t, p, s, { instance: "coder", run: "coder-stuck", ignoreTerm: true });
bind(s, "coder", "coder-run");
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
// The bus knows only the bound run. The pass takes the spawned ones from the registry.
assert.deepEqual(fired.runs, ["coder-run"]);
await s.launcher.tick();
@@ -629,7 +637,7 @@ test("row 194 T9a: fire of a registry run with no runtime record reads lifecycle
const bound = await standIn(t, p, s, { instance: "coder", run: "coder-run" });
const ghost = await standIn(t, p, s, { instance: "coder", run: "coder-ghost", managed: false });
bind(s, "coder", "coder-run");
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
assert.deepEqual(fired.runs, ["coder-run"]);
await s.launcher.tick();
assert.equal(await bound.exited, 143);
@@ -685,7 +693,7 @@ test("row 194 T9a: a runner whose identity changed before the SIGTERM gets no si
const between = await standIn(t, p, s, { instance: "coder", run: "coder-between", ignoreTerm: true });
Object.assign(pids, { before: before.pid, between: between.pid });
bind(s, "coder", "coder-between");
const fired = fire(s, bind(s, "chief", "chief-run"), "coder");
const fired = await fire(s, bind(s, "chief", "chief-run"), "coder");
await s.launcher.tick();
assert.deepEqual(sent, [["coder-between", "SIGTERM"]]);
assert.deepEqual([before.gone(), between.gone(), before.terms(), between.terms()], [false, false, 0, 1]);
@@ -719,7 +727,7 @@ test("row 194 T9b: a halt during a fire's stop sends no second SIGTERM, keeps th
const x = await standIn(t, p, s, { instance: "coder", run: "coder-run", ignoreTerm: true });
const chief = bind(s, "chief", "chief-run");
bind(s, "coder", "coder-run");
const fired = fire(s, chief, "coder");
const fired = await fire(s, chief, "coder");
const began = Date.now();
const pass = s.launcher.tick();
await until(() => x.terms() === 1);
@@ -848,12 +856,6 @@ test("row 194 T11: two stack roots with the same business id; a launch, a termin
// listing gets through to the host, which checks the file.
test("row 194 D3 item 5: the chief's session hires with its tool and launches its hire in the same session, then fires it", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
// The shipped role file lists nothing hireable. The test's copy lists the CISO, which holds
// only message.send and record.write and no credential.
const roleFile = join(p.f.env.MOSAIC_ROLES_DIR, "chief-of-staff.json");
const role = JSON.parse(readFileSync(roleFile, "utf8"));
role.hireable = ["chief-information-security-officer"];
writeFileSync(roleFile, JSON.stringify(role, null, 2));
const s = await start(t, p);
const chief = (await s.carry(s.call("instance.launch", { instance: "chief" }).request)).done.run;
await s.claimed(chief);
@@ -861,15 +863,12 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
const sent = s.call("message.send", { to: "chief", class: "REQUEST", body: `TOOL ${name} ${JSON.stringify(args)}` }).id;
return (await until(() => s.call("replies", { id: sent })[0], 30000)).body;
};
const hired = JSON.parse(await ask("hire", { name: "sec-2", definition: "chief-information-security-officer", vars: { model: "claude-sonnet-5-5" }, reason: "a security lead for the review queue" }));
assert.ok(hired.transaction, JSON.stringify(hired));
const listed = s.call("instances").instances["sec-2"];
assert.equal(listed.launched, true);
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
// human sets, so until then the launch refuses no-route.
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
const role = readTemplate(p.f.env.MOSAIC_ROLES_DIR,"chief-information-security-officer");
const hired = JSON.parse(await ask("hire", {op:"hire",level:"org",name:"sec-2",role:{template:role.name,sha256:role.sha256},route:{harness:"pi",model:"claude-sonnet-5-5",thinking:"high"},reason:"a security lead for the review queue"}));
assert.ok(hired.id);
assert.equal(s.call("instances").instances["sec-2"],undefined,"raise writes nothing");
await s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:hired.id,choice:"yes",digest:hired.hire.digest}});
assert.equal(s.call("instances").instances["sec-2"].launched,true);
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
assert.match(launched.run, /^r[0-9a-f]{12}$/);
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
@@ -878,8 +877,9 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
await s.claimed(launched.run);
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
assert.ok(fired.transaction, JSON.stringify(fired));
const fired = JSON.parse(await ask("fire", {op:"fire",level:"org",name:"sec-2",reason:"the review is done"}));
assert.ok(fired.id);
await s.runtime.broker.request(ownerConsole(s.runtime.broker,"acme"),{verb:"hire.resolve.console",args:{id:fired.id,choice:"yes",digest:fired.hire.digest}});
await until(() => s.running("sec-2").length === 0, 30000);
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
assert.equal(s.call("instances").instances["sec-2"].launched, false);
+8 -8
View File
@@ -358,7 +358,7 @@ test("the journal: a line with a wrong type refuses with exit 3 and names the fi
for (const [field, r] of bad) {
const text = `${JSON.stringify(dm)}\n${JSON.stringify(r)}\n`;
writeFileSync(file, text);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field}): ${file}`, `${field}: ${JSON.stringify(r)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field})` && !e.message.includes(file), `${field}: ${JSON.stringify(r)}`);
assert.equal(readFileSync(file, "utf8"), text, "a refusal changes nothing");
}
const good = [dm, digest, { ...dm, outcome: "refused", messageId: null, status: 403 }, { ...dm, outcome: "gave-up", messageId: null }, { ...digest, outcome: "unknown", messageId: null }];
@@ -377,7 +377,7 @@ test("the journal: a symlinked directory refuses and says it is a link", (t) =>
const file = journalPath(root, "demo");
mkdirSync(dirname(dirname(file)), { mode: 0o700 });
symlinkSync(real, dirname(file));
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(file)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(file)));
assert.deepEqual(readdirSync(real), [], "nothing was created through the link");
});
@@ -386,13 +386,13 @@ test("the journal: a dangling directory link, a parent that is a file and a jour
const dangling = journalPath(root, "demo");
mkdirSync(dirname(dirname(dangling)), { mode: 0o700 });
symlinkSync(join(root, "missing"), dirname(dangling));
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(dangling)}`);
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(dangling)));
const under = join(root, "plain", "notify", "demo", "sent.jsonl");
writeFileSync(join(root, "plain"), "");
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR): ${dirname(under)}`);
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR)` && !e.message.includes(dirname(under)));
const asDir = journalPath(root, "acme");
mkdirSync(asDir, { recursive: true, mode: 0o700 });
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user: ${asDir}`);
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user` && !e.message.includes(asDir));
});
test("the journal: an append after the file was swapped for a symlink refuses and writes nothing through it", (t) => {
@@ -414,18 +414,18 @@ test("the journal: a directory it cannot write or create refuses with exit 3 and
// Modes are restored in finally: tmp()'s cleanup runs first among the after hooks.
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES): ${dirname(file)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES)` && !e.message.includes(dirname(file)));
} finally {
chmodSync(dirname(file), 0o700);
}
openJournal(file);
chmodSync(file, 0o400);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES): ${file}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES)` && !e.message.includes(file));
const parent = join(root, "notify");
const other = journalPath(root, "acme");
chmodSync(parent, 0o500);
try {
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES): ${dirname(other)}`);
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES)` && !e.message.includes(dirname(other)));
} finally {
chmodSync(parent, 0o700);
}
+138
View File
@@ -0,0 +1,138 @@
// #1873 T1: org:<name> definitions at launch, and `mosaic org definitions`.
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createLauncher } from '../src/launcher.mjs';
import { main } from '../src/cli.mjs';
import { orgCommand } from '../src/org.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { initializeControl } from '../../processing-control/src/state.mjs';
import { launchLogFile } from '../../seat/src/session.mjs';
import { entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
import { fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
const SCOUT = { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
const stop = () => Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
if (x.op === 'mayLaunch') return { allow: true };
if (x.op === 'authorizeLaunch') throw stop();
if (x.op === 'definitionScan') return { held: null };
return {};
} };
test('T1 an org: instance passes every launch check, and a missing definition refuses naming instance and definition', async (t) => {
const root = tmp(t);
// The org's own copy of the coder template, so the instance's credentials and tracker still match.
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
const file = join(root, 'config/businesses/acme.json');
withoutHostCredentials(f.env, { piKey: true });
initializeControl(f.dataRoot);
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
// Resolve, route, contract (the definition's instructions) and skills all pass; the fixture stops at authorization.
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
// The chief names a definition the file doesn't declare: the whole file refuses, so a session's
// launch and the chief's launch both refuse.
writeJson(file, { ...f.doc, roles: { ...f.doc.roles, chief: { ...f.doc.roles.chief, definition: 'org:ghost' } } });
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'organization-refused' });
await assert.rejects(launcher.launchPm(), (e) => e.code === 'organization-refused' && /roles\.chief\.definition names org:ghost/.test(e.message));
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
assert.equal(event.code, 'organization-refused');
assert.equal(event.instance, 'chief');
assert.match(event.why, /definition-missing: .*roles\.chief\.definition names org:ghost/);
await launcher.close?.();
// A host does not start on a file that names a missing definition.
assert.throws(() => createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }), /org:ghost/);
});
// The fixture bus holds SENTINEL as coder's gitea token, so the bus's secret check refuses it.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 mosaic org definitions: writes go through the bus and its secret check, each prints its receipt, and an agent shell refuses', async (t) => {
const root = tmp(t);
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
const file = join(root, 'config/businesses/acme.json');
const changes = join(root, 'config/businesses/.history/acme/changes.jsonl');
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
const calls = [];
// The human transport's shape: a bus refusal arrives as its code.
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
calls.push(verb);
try { return structuredClone(b.broker.request(human, { verb, args })); }
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
} };
const run = async (args, env = f.env) => { const x = io(env); await main(['org', 'definitions', ...args], x, deps); return JSON.parse(x.out.text); };
const hash = templateHash(f.roles, 'coder');
assert.ok((await run(['templates'])).some((h) => h.template === 'coder' && h.sha256 === hash));
const imported = await run(['acme', 'import', 'coder', '--as', 'copy']);
assert.deepEqual({ ...imported, revision: 0, transaction: 0 }, { verb: 'import', organization: 'acme', definition: 'copy', rev: 1, revision: 0, transaction: 0, from: { template: 'coder', sha256: hash } });
const spec = join(root, 'scout.json');
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: SCOUT.instructions }));
assert.equal((await run(['acme', 'invent', 'scout', '--from', spec])).rev, 1);
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: 'Read, then report.\n' }));
const edited = await run(['acme', 'edit', 'scout', '--from', spec, '--if-rev', '1']);
assert.equal(edited.verb, 'edit');
assert.equal(edited.rev, 2);
assert.deepEqual((await run(['acme', 'list'])).definitions.map((d) => [d.name, d.rev]), [['builder', 1], ['copy', 1], ['scout', 2]]);
assert.equal((await run(['acme', 'show', 'scout'])).instructions, 'Read, then report.\n');
assert.equal((await run(['acme', 'diff', 'copy'])).changed, false);
assert.equal((await run(['acme', 'remove', 'scout', '--if-rev', '2'])).removedRev, 2);
assert.deepEqual(calls, ['definition.import', 'definition.invent', 'definition.edit', 'definition.remove']);
const committed = readFileSync(changes, 'utf8').trim().split('\n').map((l) => JSON.parse(l)).filter((e) => e.state === 'committed').slice(-4);
assert.deepEqual(committed.map((e) => e.actor), Array(4).fill(f.doc.human));
// A definition holding a token the bus holds refuses on invent and on edit, in valid JSON, and
// the token reaches no message, no organization file and nothing the bus wrote.
const before = readFileSync(file);
const beforeChanges = readFileSync(changes);
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: `Use ${SENTINEL} to report.\n` }));
for (const args of [['acme', 'invent', 'leak', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1']]) {
await assert.rejects(run(args), (e) => e.code === 'credential-leak' && e.exitCode === 2 && !e.message.includes(SENTINEL) && !String(e.stack).includes(SENTINEL));
}
assert.deepEqual(readFileSync(file), before);
assert.deepEqual(readFileSync(changes), beforeChanges);
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
// An unparsable file never echoes; a key the file's checks refuse is named before the bus is
// called; a stale --if-rev and an in-use definition refuse with what they mean.
writeFileSync(spec, `{"role": ${SENTINEL}`);
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), (e) => e.exitCode === 2 && !e.message.includes('sk-test'));
writeFileSync(spec, JSON.stringify({ role: { ...SCOUT.role, network: 'everywhere' }, instructions: 'x\n' }));
const asked = calls.length;
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), /definitions\.other/);
assert.equal(calls.length, asked);
await assert.rejects(run(['acme', 'remove', 'copy', '--if-rev', '7']), (e) => /^definition-rev-conflict: definition copy is not at rev 7/.test(e.message));
await assert.rejects(run(['acme', 'remove', 'builder', '--if-rev', '1']), (e) => /^definition-in-use: definition builder is named by coder/.test(e.message));
assert.deepEqual(readFileSync(file), before);
// Inside an agent run every write refuses before it reads anything or calls the bus; reads still work.
const agent = { ...f.env, MOSAIC_RUN_ID: 'run-1' };
const sent = calls.length;
for (const args of [['acme', 'import', 'reviewer'], ['acme', 'invent', 'x', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1'], ['acme', 'remove', 'copy', '--if-rev', '1']]) {
await assert.rejects(run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message));
}
assert.equal(calls.length, sent);
assert.deepEqual(readFileSync(file), before);
assert.equal((await run(['acme', 'list'], agent)).definitions.length, 2);
// With no bus call to make, a write refuses rather than writing without the secret check.
const x = io(f.env);
await assert.rejects(orgCommand(['definitions', 'acme', 'remove', 'copy', '--if-rev', '1'], x), (e) => e.exitCode === 3 && /organization bus/.test(e.message));
assert.deepEqual(readFileSync(file), before);
});
+7 -4
View File
@@ -40,8 +40,10 @@ test('console stop quarantines dead and wrong-process states without signaling',
});
test('live console from another entry path remains recorded and is not signalled', { timeout: 30000 }, async t => {
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, 'console.mjs');
mkdirSync(dir, { mode: 0o700 });
// The other entry sits under the sentinel directory: the refusal must name neither (PR #1916).
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, SENTINEL, 'console.mjs');
mkdirSync(dir, { mode: 0o700 }); mkdirSync(join(f.root, SENTINEL), { mode: 0o700 });
symlinkSync(new URL('../../webui/src/cli.mjs', import.meta.url).pathname, alias);
const child = spawn(process.execPath, [alias, 'serve', '--port', '0'], { env: humanEnv({ HOME: f.root, MOSAIC_CONFIG: join(f.ctx.configDir, 'config.json') }), stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const ended = once(child, 'exit'); child.kill('SIGTERM'); await ended; } });
@@ -53,7 +55,7 @@ test('live console from another entry path remains recorded and is not signalled
const record = { pid: child.pid, startTime: startTimeOf(child.pid), url };
json(join(dir, 'console.json'), record);
await assert.rejects(consoleStart(f.ctx), /console state exists/);
await assert.rejects(consoleStop(f.ctx), e => /another path/.test(e.message) && e.message.includes(alias));
await assert.rejects(consoleStop(f.ctx), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'console was started from another checkout; stop it there'); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(alias), e.message); return true; });
assert.deepEqual(JSON.parse(readFileSync(join(dir, 'console.json'), 'utf8')), record);
assert.equal(startTimeOf(child.pid), record.startTime);
assert.equal(readdirSync(dir).filter(x => x.startsWith('console.json.stale-')).length, 0);
@@ -82,7 +84,8 @@ test('lock recovery reports fences and recovers a dead clearer while refusing li
assert.equal(shown.fence.pid, process.pid);
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), new RegExp(`recovery is already in progress.*${process.pid}`));
writeFileSync(fence, '');
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), e => e.message.includes(fence) && /remove.*hand/i.test(e.message));
// #1873: the fence is named by label in the config directory, never by its path.
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), e => !e.message.includes(f.ctx.configDir) && e.message.includes('recovery fence org-lifecycle-alpha.lock.clear in the config directory') && /remove.*hand/i.test(e.message));
unlinkSync(fence);
const registry = new URL('../../organization/src/org-registry.mjs', import.meta.url).href;
const code = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
@@ -0,0 +1,242 @@
// #1873 (redact6): a cli refusal names the file, the instance or the field, never a host path,
// and never relays mosaic-config's stderr or a Node system error's message. The sentinel is a
// component of every scratch path, so a refusal that quotes a path quotes the sentinel too; each
// test also checks the scratch root and the label. The launcher cases need no bus: a bus socket
// under a sentinel path would pass the Unix socket length limit, so they use a stub host.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { createLauncher, relay } from '../src/launcher.mjs';
import { CliError } from '../src/errors.mjs';
import { loadSystem } from '../src/config.mjs';
import { main } from '../src/cli.mjs';
import { initializeControl } from '../../processing-control/src/state.mjs';
import { launchLogFile } from '../../seat/src/session.mjs';
import { fakeHarnesses, fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) {
const root = tmp(t, 'r6-');
const dir = join(root, secret);
mkdirSync(dir, { mode: 0o700 });
return { root, dir };
}
function clean(text, root) {
assert.ok(!text.includes(root), text);
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
}
const PI = JSON.stringify(new URL('../../plugins/harness/pi/index.mjs', import.meta.url).href);
function stubLauncher(t, { op = async () => ({}) } = {}) {
const { root, dir } = scratch(t);
const f = fixture(dir, 'acme');
initializeControl(f.dataRoot);
withoutHostCredentials(f.env, { piKey: true });
const system = JSON.parse(readFileSync(f.config, 'utf8'));
const host = { business: 'acme', beforeClose: () => {}, op };
return { root, f, make: () => createLauncher({ host, system, env: f.env, bwrap: '/usr/bin/true', log: () => {} }) };
}
test('an unreadable sessions file refuses exit 3 naming bus-host/sessions.json under the data root, never its path', t => {
const s = stubLauncher(t);
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), '{not json');
assert.throws(() => s.make(), e => {
assert.ok(e instanceof CliError, e.stack);
assert.equal(e.exitCode, 3);
assert.equal(e.message, 'sessions file is unreadable: bus-host/sessions.json under the data root; check which sessions still run, then move the file aside');
clean(e.message, s.root);
return true;
});
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), JSON.stringify({ sessionsVersion: 999, sessions: [] }));
assert.throws(() => s.make(), e => e.exitCode === 3 && e.message.startsWith('sessions file is malformed: bus-host/sessions.json under the data root;') && !e.message.includes(s.root));
});
test('a launch socket path that is not a socket refuses exit 3 by label, never its path', async t => {
const s = stubLauncher(t);
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
writeFileSync(join(s.f.dataRoot, 'bus-host', 'launch.sock'), 'in the way\n');
const launcher = s.make();
t.after(() => launcher.close?.());
await assert.rejects(launcher.listen(), e => {
assert.ok(e instanceof CliError, e.stack);
assert.equal(e.exitCode, 3);
assert.equal(e.message, "launch socket path exists and isn't a socket");
clean(e.message, s.root);
return true;
});
});
// The launch-routes pattern: a stub host stops each launch at authorizeLaunch, and the launch
// log's refused event carries the reason.
async function routed(t, harness) {
const { root, dir } = scratch(t);
const f = fixture(dir, 'acme', doc => {
doc.roles.coder.vars = { harness, model: 'claude-sonnet-5-5', thinking: 'high' };
doc.plugins = { ...doc.plugins, harness: ['pi', 'claude-code', harness] };
return doc;
});
initializeControl(f.dataRoot);
withoutHostCredentials(f.env, { piKey: true });
const host = { business: 'acme', beforeClose: () => {}, op: async x => {
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
if (x.op === 'mayLaunch') return { allow: true };
if (x.op === 'authorizeLaunch') throw Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
return {};
} };
const absent = join(dir, 'absent.json');
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null }, write => {
// fsthrows: its preflight reads a missing file, so the error's message quotes the path.
write('harness', 'fsthrows', `export * from ${PI};\nimport { readFileSync } from "node:fs";\nexport function preflight() { readFileSync(${JSON.stringify(absent)}); }\n`);
// plainthrows: a plain Error whose message quotes a host path, as a module's own text.
write('harness', 'plainthrows', `export * from ${PI};\nexport function preflight() { throw new Error(${JSON.stringify(`cannot read ${absent}`)}); }\n`);
// pathfails: a preflight that returns a failure whose message quotes a host path.
write('harness', 'pathfails', `export * from ${PI};\nexport function preflight() { return { code: "harness-binary-missing", message: ${JSON.stringify(`no binary at ${absent}; see file://${absent}`)} }; }\n`);
});
const system = JSON.parse(readFileSync(f.config, 'utf8'));
const launcher = createLauncher({ host, system, env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
const last = () => readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').map(l => JSON.parse(l)).at(-1);
return { root, f, launch: () => launcher.fromSession('fixture-cap', 'coder'), last };
}
test('a harness whose preflight throws a plain Error naming a path refuses harness-failed by its class, never its message', async t => {
const s = await routed(t, 'plainthrows');
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
assert.equal(s.last().why, 'harness plainthrows preflight: threw Error without a system code');
clean(JSON.stringify(s.last()), s.root);
});
test('a preflight failure whose message quotes a path keeps its code and relays the text with the path replaced', async t => {
const s = await routed(t, 'pathfails');
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-binary-missing' });
assert.equal(s.last().why, 'no binary at a host path; see a host path');
clean(JSON.stringify(s.last()), s.root);
});
test('the claude-code preflight names MOSAIC_CLAUDE_BIN, never its value', async t => {
const { root, dir } = scratch(t);
const f = fixture(dir, 'acme', doc => {
doc.roles.coder.vars = { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'high' };
return doc;
});
initializeControl(f.dataRoot);
withoutHostCredentials(f.env);
f.env.CLAUDE_CODE_OAUTH_TOKEN = 'fixture-only';
f.env.MOSAIC_CLAUDE_BIN = join(dir, 'absent-claude');
const host = { business: 'acme', beforeClose: () => {}, op: async x => x.op === 'identity' ? { business: 'acme', role: 'pm' } : x.op === 'mayLaunch' ? { allow: true } : {} };
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null });
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'harness-binary-missing' });
const last = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
assert.equal(last.why, "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path");
clean(JSON.stringify(last), root);
});
test('relay replaces absolute, home and file: paths and keeps the rest of the text', () => {
assert.equal(relay(`bad ${'/srv/x/' + secret} and ~/y, (file:///z) here`), 'bad a host path and a host path, (a host path) here');
assert.equal(relay('fatal: not a git repository: .git; see https://example.test/a'), 'fatal: not a git repository: .git; see https://example.test/a');
});
test('a harness whose preflight throws a system error refuses harness-failed naming the call and code, never the path', async t => {
const s = await routed(t, 'fsthrows');
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
assert.equal(s.last().why, 'harness fsthrows preflight: cannot open a host file (ENOENT)');
clean(JSON.stringify(s.last()), s.root);
});
test('a harness the loader refuses names its code only, never the loader message or a path', async t => {
const s = await routed(t, 'absent');
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-refused' });
assert.match(s.last().why, /^harness absent: [a-z][a-z0-9-]*$/);
clean(JSON.stringify(s.last()), s.root);
});
test('the halt gate refusal names the instance, never its data root, and keeps exit 1 and the count', async t => {
const { root, dir } = scratch(t);
const result = { version: 1, generation: '11111111-1111-4111-8111-111111111111', operation: '11111111-1111-4111-8111-111111111111', business: 'demo', human: 'jason', at: '2026-10-20T00:00:00.000Z', recovered: false, halted: true, quiescent: false, remaining: 2, stopped: 0 };
const x = io();
await assert.rejects(main(['halt', '--business', 'demo'], x, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => result }), e => {
assert.equal(e.exitCode, 1);
assert.equal(e.message, 'halt gate is on for this instance; 2 stops unconfirmed. Use mosaic processing status');
clean(e.message, root);
return true;
});
const odd = io();
await assert.rejects(main(['halt', '--business', 'demo'], odd, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => ({ ...result, remaining: join(dir, 'x') }) }), e => {
assert.equal(e.message, 'halt gate is on for this instance; unknown stops unconfirmed. Use mosaic processing status');
clean(e.message, root);
return true;
});
});
test('a system config problem names the config by label and never relays mosaic-config stderr', t => {
const { root, dir } = scratch(t);
// Missing: the label for where it came from, with the bootstrap hint.
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir, MOSAIC_CONFIG: join(dir, 'absent.json') } }), e => {
assert.ok(e instanceof CliError, e.stack);
assert.equal(e.exitCode, 3);
assert.equal(e.message, 'system config problem (mosaic-config exit 3): configuration not found: the system config MOSAIC_CONFIG names (run scripts/bootstrap.sh to create it)');
clean(e.message, root);
return true;
});
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir } }), e => {
assert.match(e.message, /^system config problem \(mosaic-config exit 3\): configuration not found: the default system config \(run scripts\/bootstrap\.sh to create it\)$/);
clean(e.message, root);
return true;
});
// A field whose value quotes the sentinel: the field and its constraint, never the value.
const f = fixture(dir, 'acme');
const doc = JSON.parse(readFileSync(f.config, 'utf8'));
writeFileSync(f.config, JSON.stringify({ ...doc, dataRoot: `relative/${secret}` }));
assert.throws(() => loadSystem({ env: f.env }), e => {
assert.equal(e.exitCode, 3);
assert.match(e.message, /^system config problem \(mosaic-config exit \d+\): dataRoot: expected a canonical absolute instance data path: the system config MOSAIC_CONFIG names$/);
clean(e.message, root);
return true;
});
});
// scripts/mosaic-config.mjs run standalone: every refusal names the config by where it came from,
// the data root and its marker by their roles, and a key only when it is a plain name. Exit codes
// are unchanged: 2 invalid, 3 missing or unclaimed, 4 usage.
const CONFIG_SCRIPT = new URL('../../../scripts/mosaic-config.mjs', import.meta.url).pathname;
function mosaicConfig(home, env, ...args) {
return spawnSync(process.execPath, [CONFIG_SCRIPT, ...args], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: home, ...env } });
}
function refusedBy(r, status, text, root) {
assert.equal(r.status, status, r.stderr);
assert.equal(r.stdout, '');
assert.ok(r.stderr.includes(text), r.stderr);
clean(r.stderr, root);
}
test('mosaic-config refusals name the config, data root and keys by label, never a host path or value', t => {
const { root, dir } = scratch(t);
const file = join(dir, 'config.json');
const env = { MOSAIC_CONFIG: file };
writeFileSync(file, 'not json');
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'mosaic-config: configuration is not valid JSON (the system config MOSAIC_CONFIG names)', root);
const good = { configVersion: 1, environment: 'development', dataRoot: join(dir, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash' } };
writeFileSync(file, JSON.stringify({ ...good, [join(dir, 'k')]: 1 }));
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'unsupported configuration key: that is not a plain name', root);
writeFileSync(file, JSON.stringify({ ...good, console: { [join(dir, 'k')]: 1 } }));
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'console has a key that is not a plain name', root);
writeFileSync(file, JSON.stringify({ ...good, dataRoot: dir }));
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'execution "dataRoot" must not be or contain the home directory', root);
writeFileSync(join(dir, 'real.json'), JSON.stringify(good));
const link = join(dir, 'link.json');
symlinkSync(join(dir, 'real.json'), link);
refusedBy(mosaicConfig(dir, { MOSAIC_CONFIG: link }, 'validate'), 2, 'configuration path must be a regular, non-symbolic-link file: the system config MOSAIC_CONFIG names', root);
// A named instance: MOSAIC_CONFIG naming another file is usage (4); an unmarked root is 3.
refusedBy(mosaicConfig(dir, env, 'validate', '--instance', 'alpha'), 4, 'instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file', root);
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', join(dir, 'x')), 4, 'instance-name-invalid: that name must match', root);
const named = join(dir, '.config', 'mosaic-alpha');
mkdirSync(named, { recursive: true, mode: 0o700 });
mkdirSync(join(dir, '.mosaic-alpha'), { mode: 0o700 });
writeFileSync(join(named, 'config.json'), JSON.stringify({ ...good, dataRoot: join(dir, '.mosaic-alpha') }));
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, 'instance-root-unclaimed: the data root holds no instance.json naming alpha', root);
mkdirSync(join(dir, '.mosaic-alpha', 'instance.json'));
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, "instance-root-unclaimed: the data root's instance marker must be a regular, non-symbolic-link file", root);
});
+312
View File
@@ -0,0 +1,312 @@
// PR #1916 redaction (org-redact5): a CLI refusal names the file by its label,
// never by host path. Every fixture here sits under a directory named for the
// sentinel, so a refusal that quoted the path would carry it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { withLock } from "../src/access.mjs";
import { fixtureFile, readFixture } from "../src/dev-fixture.mjs";
import { createGitSocket } from "../src/git-socket.mjs";
import { consoleStart, consoleStop } from "../src/org-console.mjs";
import { claimHostLock, hostLock } from "../src/host.mjs";
import { journalPath, openJournal } from "../src/notifier.mjs";
import { readNotifyConfig } from "../src/notify-config.mjs";
import { orgCommand } from "../src/org.mjs";
import { instancesCommand } from "../src/instances.mjs";
import { FIXTURE_ENTRY } from "../../plugins/model-provider/fixture/index.mjs";
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
const ROOT_SKIP = process.getuid() === 0 && "root ignores the modes";
function under(t) {
const top = mkdtempSync(join(tmpdir(), "redact-"));
t.after(() => rmSync(top, { recursive: true, force: true }));
const root = join(top, SENTINEL);
mkdirSync(root, { mode: 0o700 });
return root;
}
// The refusal is exactly `message` (a string) or matches it (a RegExp), with exit
// code `exitCode`, and carries neither the sentinel nor any of `paths`.
function refusal(message, { exitCode = 3, paths = [], code } = {}) {
return (error) => {
assert.equal(error.name, "CliError");
assert.equal(error.exitCode, exitCode);
if (code !== undefined) assert.equal(error.code, code);
if (message instanceof RegExp) assert.match(error.message, message);
else assert.equal(error.message, message);
assert.ok(!error.message.includes(SENTINEL), error.message);
for (const path of paths) assert.ok(!error.message.includes(path), error.message);
return true;
};
}
// --- access.json lock ---
test("access lock: an unreadable lock is named by label, never by host path", (t) => {
const file = join(under(t), "access.json");
writeFileSync(`${file}.lock`, "not-a-pid\n", { mode: 0o600 });
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
refusal("refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again", { paths: [file] }));
});
test("access lock: a lock left by a dead pid is named by label, never by host path", (t) => {
const file = join(under(t), "access.json");
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
refusal(`refused: access file lock is left by pid ${dead.pid}, which is not running; if no write is running, remove it by hand and try again`, { paths: [file] }));
});
// --- bus host lock ---
const MINE = { pid: process.pid, startTime: "1", business: "acme", startedAt: "2026-11-10T00:00:00.000Z" };
const STALE = { pid: 999999999, startTime: "1", business: "acme", startedAt: "2026-11-09T00:00:00.000Z" };
function hostRoot(t, lock) {
const dataRoot = join(under(t), "data");
mkdirSync(join(dirname(hostLock(dataRoot))), { recursive: true, mode: 0o700 });
if (lock !== undefined) writeFileSync(hostLock(dataRoot), lock, { mode: 0o600 });
return dataRoot;
}
test("host lock: an unreadable host.lock refuses by label, never by host path", (t) => {
const dataRoot = hostRoot(t, "{not json");
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is unreadable", { paths: [dataRoot] }));
});
test("host lock: a malformed host.lock refuses by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify({ pid: "x" }));
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is malformed", { paths: [dataRoot] }));
});
test("host lock: a stale host.lock refused on request is named by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify(STALE));
assert.throws(() => claimHostLock(dataRoot, MINE, { refuseStale: true }), refusal("stale bus host lock", { paths: [dataRoot] }));
});
test("host lock: a lock that keeps changing is named by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify(STALE));
let n = 0;
const race = () => writeFileSync(hostLock(dataRoot), JSON.stringify({ ...STALE, startedAt: `2026-11-09T00:00:${String(++n % 60).padStart(2, "0")}.000Z` }), { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, MINE, { between: race }), refusal("bus host lock kept changing; no bus host started", { paths: [dataRoot] }));
});
// --- notify journal ---
test("notify journal: a directory link, dangling or not, is refused by label", (t) => {
const root = under(t);
const dangling = journalPath(root, "demo");
mkdirSync(dirname(dirname(dangling)), { recursive: true, mode: 0o700 });
symlinkSync(join(root, "missing"), dirname(dangling));
assert.throws(() => openJournal(dangling), refusal("notify journal directory must not be a symlink", { paths: [dangling] }));
const real = join(root, "real");
mkdirSync(real, { mode: 0o700 });
const linked = journalPath(root, "acme");
symlinkSync(real, dirname(linked));
assert.throws(() => openJournal(linked), refusal("notify journal directory must not be a symlink", { paths: [linked] }));
});
test("notify journal: a parent that is a file is refused by its error code", (t) => {
const root = under(t);
writeFileSync(join(root, "plain"), "");
const file = join(root, "plain", "notify", "demo", "sent.jsonl");
assert.throws(() => openJournal(file), refusal("notify journal directory cannot be created (ENOTDIR)", { paths: [file] }));
});
test("notify journal: a directory of the wrong mode is refused by label", (t) => {
const file = journalPath(under(t), "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
chmodSync(dirname(file), 0o755);
assert.throws(() => openJournal(file), refusal("notify journal directory must be mode 0700 and owned by this user", { paths: [file] }));
});
test("notify journal: a directory it cannot write or create is refused by its error code", { skip: ROOT_SKIP }, (t) => {
const root = under(t);
const file = journalPath(root, "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => openJournal(file), refusal("notify journal directory is not writable (EACCES)", { paths: [file] }));
} finally {
chmodSync(dirname(file), 0o700);
}
const parent = dirname(dirname(file));
const other = journalPath(root, "acme");
chmodSync(parent, 0o500);
try {
assert.throws(() => openJournal(other), refusal("notify journal directory cannot be created (EACCES)", { paths: [other] }));
} finally {
chmodSync(parent, 0o700);
}
});
test("notify journal: a journal link, an unwritable journal and a directory journal are refused by label", { skip: ROOT_SKIP }, (t) => {
const root = under(t);
const file = journalPath(root, "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(join(root, "elsewhere.jsonl"), "", { mode: 0o600 });
symlinkSync(join(root, "elsewhere.jsonl"), file);
assert.throws(() => openJournal(file), refusal("notify journal must not be a symlink", { paths: [file] }));
rmSync(file);
writeFileSync(file, "", { mode: 0o400 });
chmodSync(file, 0o400);
assert.throws(() => openJournal(file), refusal("notify journal is not writable (EACCES)", { paths: [file] }));
const asDir = journalPath(root, "acme");
mkdirSync(asDir, { recursive: true, mode: 0o700 });
assert.throws(() => openJournal(asDir), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [asDir] }));
});
test("notify journal: a journal of the wrong mode and a malformed line are refused by label", (t) => {
const file = journalPath(under(t), "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(file, "", { mode: 0o644 });
chmodSync(file, 0o644);
assert.throws(() => openJournal(file), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [file] }));
chmodSync(file, 0o600);
writeFileSync(file, "not json\n");
assert.throws(() => openJournal(file), refusal("notify journal line 1 is malformed (json)", { paths: [file] }));
});
// --- notifier config ---
function notifyRoot(t, text, mode = 0o600) {
const dataRoot = join(under(t), "data");
const dir = join(dataRoot, "notify", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
if (text !== undefined) {
writeFileSync(join(dir, "notify.json"), text, { mode });
chmodSync(join(dir, "notify.json"), mode);
}
return dataRoot;
}
test("notifier config: a missing file names the business and its place under the data root", (t) => {
const dataRoot = notifyRoot(t);
assert.throws(() => readNotifyConfig(dataRoot, "acme"),
refusal('no notifier config for acme: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/acme/notify.json under the data root, mode 0600, or "binding": null to run without DMs', { paths: [dataRoot] }));
});
test("notifier config: a file of the wrong mode names the business only", (t) => {
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null }), 0o644);
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme must be a regular file, mode 0600, owned by this user", { paths: [dataRoot] }));
});
test("notifier config: a file that is not JSON names the business only", (t) => {
const dataRoot = notifyRoot(t, "{not json");
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme is not JSON", { paths: [dataRoot] }));
});
test("notifier config: a file of the wrong shape names the business only", (t) => {
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null, extra: 1 }));
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal('notifier config for acme must be exactly {"notifyVersion": 1, "binding": <binding name> | null}', { paths: [dataRoot] }));
});
// --- dev fixture ---
const RECORD = { fixtureVersion: 1, model: "deterministic", endpoint: "http://127.0.0.1:45678", port: 45678, pid: 999999, instance: "fxtest", entry: FIXTURE_ENTRY };
function fixtureRoot(t, { record = RECORD, text = JSON.stringify(record), dirMode = 0o700, fileMode = 0o600 } = {}) {
const dataRoot = join(under(t), "data");
const file = fixtureFile(dataRoot);
mkdirSync(dirname(file), { recursive: true, mode: dirMode });
chmodSync(dirname(file), dirMode);
if (text !== null) {
writeFileSync(file, text, { mode: fileMode });
chmodSync(file, fileMode);
}
return dataRoot;
}
test("dev fixture: a shared fixture directory is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { dirMode: 0o755 });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: dev-fixture directory is not a private directory of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a linked fixture file is refused by label and error code", (t) => {
const dataRoot = fixtureRoot(t, { text: null });
writeFileSync(join(dataRoot, "elsewhere.json"), JSON.stringify(RECORD), { mode: 0o600 });
symlinkSync(join(dataRoot, "elsewhere.json"), fixtureFile(dataRoot));
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json can't be opened (ELOOP)", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a shared fixture file is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { fileMode: 0o644 });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json is not a private file of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a fixture file that is not JSON is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { text: "not json" });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not JSON", { code: "fixture-state-invalid", paths: [dataRoot] }));
});
test("dev fixture: a non-loopback endpoint and a record of the wrong shape are refused by label", (t) => {
let dataRoot = fixtureRoot(t, { record: { ...RECORD, endpoint: "http://0.0.0.0:45678" } });
assert.throws(() => readFixture(dataRoot), refusal("fixture-endpoint-not-loopback: fixture.json names an endpoint that is not http://127.0.0.1:<port>", { code: "fixture-endpoint-not-loopback", paths: [dataRoot] }));
dataRoot = fixtureRoot(t, { record: { ...RECORD, port: 1 } });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not a fixture record", { code: "fixture-state-invalid", paths: [dataRoot] }));
});
// --- git socket ---
test("git socket: a path that exists and is not a socket is refused by label", async (t) => {
const dataRoot = join(under(t), "data");
const path = gitSocketPath(dataRoot);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, "", { mode: 0o600 });
const sock = createGitSocket({ host: {}, business: { id: "acme", projects: {} }, dataRoot, env: {}, log: () => {} });
await assert.rejects(sock.listen(), refusal("git socket path exists and isn't a socket", { paths: [dataRoot, path] }));
});
// --- console launch ---
// The Console is given a port this test holds, so its listen fails and it exits at
// startup. Should it start anyway, it is stopped before the tree goes.
test("console: a Console that exits at startup is named by its log under the data root, never by host path", { timeout: 60000 }, async (t) => {
const root = under(t);
const ctx = { configDir: join(root, "config"), dataRoot: join(root, "data"), instance: "redact", env: { PATH: process.env.PATH, HOME: root } };
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
const held = createServer();
await new Promise((resolve, reject) => held.once("error", reject).listen(0, "127.0.0.1", resolve));
try {
await assert.rejects(consoleStart(ctx, { detach: true, port: String(held.address().port), warn: () => {} }),
refusal(/^console failed to start; see console\/console-[0-9]+-[0-9]+\.log under the data root$/, { paths: [ctx.dataRoot] }));
} finally {
await consoleStop(ctx, { warn: () => {} }).catch(() => {});
await new Promise((resolve) => held.close(resolve));
}
});
// --- review round (org-redact5 Sol P2 #3): documents, --vars and raw fs errors ---
const quietIo = () => ({ stdout: { write: () => true }, stderr: { write: () => true } });
test("org validate: a missing or invalid document is refused by kind, never by host path or bytes", async (t) => {
const root = under(t), missing = join(root, "missing.json"), bad = join(root, "bad.json");
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
await assert.rejects(orgCommand(["validate", missing], quietIo()), refusal("cannot read organization document: ENOENT", { exitCode: 2, paths: [missing, root] }));
await assert.rejects(orgCommand(["validate", bad], quietIo()), refusal("cannot read organization document: not valid JSON", { exitCode: 2, paths: [bad, root] }));
});
test("org template render: a raw filesystem error is named by operation and code, never by host path", async (t) => {
const root = under(t), output = join(root, "alpha.json");
writeFileSync(output, "{}\n", { mode: 0o600 });
await assert.rejects(orgCommand(["template", "render", "alpha", "--output", output], quietIo()),
refusal("EEXIST: cannot open (EEXIST)", { exitCode: 2, paths: [output, root] }));
});
test("instance set: a missing or invalid --vars file is refused by flag and kind, never by host path or bytes", async (t) => {
const root = under(t), missing = join(root, "vars.json"), bad = join(root, "bad.json");
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
const rev = "a".repeat(64);
const callFor = () => assert.fail("must refuse before any call");
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", missing, "--revision", rev], quietIo(), callFor),
refusal("--vars file: ENOENT", { exitCode: 4, paths: [missing, root] }));
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", bad, "--revision", rev], quietIo(), callFor),
refusal("--vars file: not valid JSON", { exitCode: 4, paths: [bad, root] }));
});
@@ -130,7 +130,8 @@ test('#774 F1 proxy launch of an agent with no key yet passes the first check un
// claude-code module on claude-opus-5-5 (provider anthropic); MOSAIC_CLAUDE_BIN decides whether its
// preflight passes. The stated fallback is pi on glm-5.3-flash (provider zai).
const NO_CLAUDE = '/nonexistent-774/claude';
const NO_CLAUDE_WHY = `MOSAIC_CLAUDE_BIN ${NO_CLAUDE} isn't an executable file at an absolute path`;
// #1873: names the variable, never its value (was `MOSAIC_CLAUDE_BIN ${NO_CLAUDE} isn't ...`).
const NO_CLAUDE_WHY = "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path";
const routed = (t, bin) => managedLaunchFixture(t, { harness: 'claude-code', extra: ['pi'], real: ['claude-code'], hostEnv: { MOSAIC_CLAUDE_BIN: bin } });
const withFallback = (doc, harness = 'pi', model = 'glm-5.3-flash') => {
claudeCoder(doc);
@@ -112,7 +112,7 @@ test("rename r2 B2: a credential problem, an expiry warning and a system config
for (const args of [["validate", "acme"], ["resolve", "acme", "coder"]]) {
const { old, now } = await sameAcrossSpellings(args, bad.env, bad.root, 2);
assert.equal(now.stderr, old.stderr, "a refusal is byte for byte the same, unnormalized");
assert.match(old.stderr, /^mosaic business: .*coder-gitea\.token.*mode 644/m);
assert.match(old.stderr, /^mosaic business: (?:roles\.coder\.credentials\.gitea|coder gitea) file: mode 644/m);
assert.match(old.stderr, /credential reference problem\(s\)\n$/);
}
const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
@@ -169,7 +169,7 @@ test("rename r2 B2: a draft file named like an installed ID: org validate reads
rmSync(join(cwd, "acme"));
const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
assert.equal(noDraft.status, 2);
assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /);
assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n");
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
rmSync(join(root, "config", "businesses", "acme.json"));
const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
+23 -2
View File
@@ -21,11 +21,11 @@ const OTHER = '11111111-2222-4333-8444-555555555555';
let n = 0;
// An instance with org alpha and one finished run for THREAD.
async function world({ run = 'r0123456789ab' } = {}) {
async function world({ run = 'r0123456789ab', under = HOME } = {}) {
const name = `ev${++n}`;
const env = { PATH: process.env.PATH, HOME, MOSAIC_CONFIG: join(HOME, '.config', `mosaic-${name}`, 'config.json') };
mkdirSync(dirname(env.MOSAIC_CONFIG), { recursive: true, mode: 0o700 });
const dataRoot = join(HOME, `data-${name}`); mkdirSync(dataRoot, { mode: 0o700 });
const dataRoot = join(under, `data-${name}`); mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
const org = await reserveOrg({ configDir: dirname(env.MOSAIC_CONFIG), dataRoot, instance: name, principal: DEV_OWNER }, 'alpha');
const runDir = join(runsDir(org.root, 'alpha'), run);
mkdirSync(join(runDir, 'bundle'), { recursive: true, mode: 0o700 });
@@ -132,6 +132,27 @@ test('no launch for the thread, an unknown org and a usage error', async () => {
await assert.rejects(runCommand(['evidence', 'alpha', '--thread', THREAD], io({ PATH: process.env.PATH, HOME }), { system: { dataRoot: w.dataRoot } }), e => e.exitCode === 3 && e.code === 'run-evidence-instance-refused');
});
// PR #1916 (org-redact5): a refusal names the launch log by label, never by host path.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const noPath = (e, w) => !e.message.includes(SENTINEL) && !e.message.includes(w.dataRoot) && !e.message.includes(w.log);
test('a launch log of the wrong mode refuses by label, never by host path', async () => {
const w = await world({ under: join(HOME, SENTINEL) });
chmodSync(w.log, 0o644);
const r = await evidence(w, ['--thread', THREAD, '--json']);
assert.equal(r.error?.exitCode, 3);
assert.equal(r.error.message, 'run-evidence-refused: launch log: mode 644, expected 600');
assert.ok(noPath(r.error, w), r.error.message);
});
test('no launch for the thread names the launch log by label, never by host path', async () => {
const w = await world({ under: join(HOME, SENTINEL) });
const r = await evidence(w, ['--thread', '99999999-2222-4333-8444-555555555555', '--json']);
assert.equal(r.error?.exitCode, 2);
assert.equal(r.error.message, 'no launch for thread 99999999-2222-4333-8444-555555555555 in the launch log');
assert.ok(noPath(r.error, w), r.error.message);
});
test('a thread name resolves through the bus with the org as business', async () => {
const w = await world();
const seen = [];
+14 -6
View File
@@ -133,7 +133,9 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
for (const name of ["gamma", "worktrees", "delta"]) {
const r = bootstrap(home, "--instance", name);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
// #1873: the data root by its role, never its path.
assert.match(r.err, new RegExp(`instance-root-unclaimed: the instance's data root exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
assert.ok(!r.err.includes(home), r.err);
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
}
assert.deepEqual(snapshot(home), before);
@@ -154,7 +156,8 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
const before = snapshot(home);
const r = bootstrap(home, "--instance", "alpha");
assert.equal(r.code, 3);
assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/);
assert.match(r.err, /instance-root-unclaimed: the data root holds no instance\.json naming alpha/);
assert.ok(!r.err.includes(home), r.err);
assert.deepEqual(snapshot(home), before);
});
@@ -262,7 +265,8 @@ test("T-I2 selection: the four rows of D1 through the instance op and through an
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
assert.match(r.err, /instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file/);
assert.ok(!r.err.includes(home) && !r.err.includes(given), r.err);
}
}
});
@@ -270,7 +274,9 @@ test("T-I2 selection: the four rows of D1 through the instance op and through an
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
const r = config(home, {}, "instance", "--instance", "gamma");
assert.equal(r.code, 3);
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
// #1873: the refusal names the config by its instance, never by host path.
assert.match(r.err, /configuration not found: the config of instance gamma \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
assert.ok(!r.err.includes(cfgOf(home, "gamma")) && !r.err.includes(home), r.err);
});
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
@@ -326,7 +332,8 @@ test("T-I7 claims: the instance op refuses a root another instance claims, and a
const r = config(home, {}, op, "--instance", "alpha");
assert.equal(r.code, 3, `${op}: ${r.err}`);
assert.equal(r.out, "");
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance beta, not alpha/);
assert.ok(!r.err.includes(home), r.err);
}
edit("alpha", join(home, ".mosaic-alpha"));
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
@@ -336,7 +343,8 @@ test("T-I7 claims: the instance op refuses a root another instance claims, and a
for (const op of ["instance"]) {
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
assert.equal(r.code, 3, r.err);
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance alpha; select it with --instance alpha/);
assert.ok(!r.err.includes(home), r.err);
}
});
+9 -8
View File
@@ -18,7 +18,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
import { join, basename, isAbsolute, resolve } from "node:path";
import { homedir } from "node:os";
import { spawnSync } from "node:child_process";
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
import { readRegistration, samePath, SeatError, LAYOUTS, plainKey } from "../../seat/src/seat.mjs";
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
@@ -32,17 +32,18 @@ export function defaultConfigPath() {
}
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
// Refusals name the config, never its path (#1873).
export function loadConfig(path = defaultConfigPath()) {
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
if (!existsSync(path)) throw new ConfigError("config not found");
let raw;
try {
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
throw new ConfigError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError("config is not an object");
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
throw new ConfigError(`config.dataRoot must be an absolute path: ${path}`);
throw new ConfigError("config.dataRoot must be an absolute path");
}
return { dataRoot: raw.dataRoot };
}
@@ -230,11 +231,11 @@ export function loadSeen(boardDir) {
try {
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
throw new ConfigError(`seen marks file seen.json is not valid JSON${error.code ? ` (${error.code})` : ""}`);
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError("seen marks file seen.json must be a JSON object");
for (const [k, v] of Object.entries(parsed)) {
if (typeof v !== "string") throw new ConfigError(`seen marks file has a non-string value for ${JSON.stringify(k)}: ${path}`);
if (typeof v !== "string") throw new ConfigError(`seen marks file seen.json has a non-string value for ${plainKey(k)}`);
}
return parsed;
}
@@ -0,0 +1,98 @@
// #1873 (redact6): a control-board refusal names the config, the seen marks file or the seat
// by label, never a host path. The sentinel is a component of every scratch path, so a refusal
// that quotes a path quotes the sentinel too; each test also checks the scratch root and the label.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { ConfigError, loadConfig, loadRegistrations, loadSeen } from '../src/scan.mjs';
import { readRegistration, registrationPath, SeatError } from '../../seat/src/seat.mjs';
import { startServer } from '../src/serve.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r6-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, secret);
mkdirSync(dir);
return { root, dir };
}
function clean(text, root) {
assert.ok(!text.includes(root), text);
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
}
function labelled(label, root) {
return error => {
assert.ok(error instanceof ConfigError, error.stack);
assert.equal(error.message, label);
clean(error.message, root);
return true;
};
}
function write(file, text) {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, text);
return file;
}
test('config refusals name the config, not its path', t => {
const { root, dir } = scratch(t);
assert.throws(() => loadConfig(join(dir, 'config.json')), labelled('config not found', root));
assert.throws(() => loadConfig(write(join(dir, 'array.json'), '[]')), labelled('config is not an object', root));
assert.throws(() => loadConfig(write(join(dir, 'relative.json'), '{"dataRoot":"data"}')), labelled('config.dataRoot must be an absolute path', root));
});
test('seen marks refusals name seen.json, not its path', t => {
const { root, dir } = scratch(t);
write(join(dir, 'seen.json'), '[]');
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json must be a JSON object', root));
write(join(dir, 'seen.json'), '{"a":1}');
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for "a"', root));
});
test('a bad registration is reported by layout and seat, never by its path', t => {
const { root, dir } = scratch(t);
write(registrationPath(dir, 'builder', 'repo'), '{}');
const { registrations, errors } = loadRegistrations(dir);
assert.deepEqual(registrations, []);
assert.equal(errors.length, 1);
assert.match(errors[0], /^seat repo\/builder: .*: registration repo\/builder$/);
clean(errors[0], root);
});
// Review round (#1873): a key or name that could be a host path is described, never echoed.
test('a seen marks key or a registration field that is not a plain name is described, never echoed', t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'seen.json'), JSON.stringify({ [join(dir, 'host')]: 1 }));
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for that is not a plain name', root));
const f = registrationPath(dir, 'builder', 'repo');
mkdirSync(dirname(f), { recursive: true });
writeFileSync(f, JSON.stringify({ [join(dir, 'host')]: 1 }));
assert.throws(() => readRegistration(dir, 'builder', 'repo'), e => {
assert.ok(e instanceof SeatError, e.stack);
assert.equal(e.exitCode, 2);
assert.equal(e.message, 'registration has an unknown field: that is not a plain name: registration repo/builder');
clean(e.message, root);
return true;
});
});
test('an invalid seat name or layout states the constraint, never the value, and keeps exit 4', t => {
const { root, dir } = scratch(t);
assert.throws(() => registrationPath(dir, join(dir, 'x'), 'repo'), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid seat name (lowercase letters, digits, ., _ or -, at most 64)' && (clean(e.message, root), true));
assert.throws(() => registrationPath(dir, 'builder', join(dir, 'x')), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid layout (one of repo, fleet, unknown)' && (clean(e.message, root), true));
});
// The board API returns a scan refusal in its JSON body, so the HTTP body carries the label too.
test('a seen marks refusal reaches the board API body by label, never by path', async t => {
const { root, dir } = scratch(t);
write(join(dir, 'seen.json'), '[]');
const server = await startServer({ host: '127.0.0.1', port: 0, specs: [], boardDir: dir, isAlive: () => true, page: '<html></html>' });
t.after(() => new Promise(done => server.close(done)));
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/board`);
const body = await res.text();
assert.equal(res.status, 500);
assert.deepEqual(JSON.parse(body), { error: 'seen marks file seen.json must be a JSON object' });
clean(body, root);
});
@@ -6,10 +6,13 @@ import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
// #1873: the sentinel is a path component, so a refusal that quotes a path quotes it too.
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
const dir = join(root, secret);
mkdirSync(dir);
return dir;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
@@ -21,6 +24,7 @@ function safe(label, ErrorType, properties = {}) {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.ok(!error.message.includes(tmpdir()), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
@@ -30,13 +34,13 @@ function safe(label, ErrorType, properties = {}) {
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(kind === 'config' ? 'config is not valid JSON' : 'seen marks file seen.json is not valid JSON', ConfigError));
});
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
mkdirSync(file);
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
safe(file, ConfigError)(error);
safe(kind === 'config' ? 'config is not valid JSON' : 'seen marks file seen.json is not valid JSON', ConfigError)(error);
assert.match(error.message, /\(EISDIR\)$/);
assert.doesNotMatch(error.message, /illegal operation/);
return true;
+14 -4
View File
@@ -22,7 +22,7 @@
import { parseJsonFileText } from "../../organization/src/util.mjs";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { DiscordError } from "./errors.mjs";
import { DiscordError, quietFs, quietRead } from "./errors.mjs";
export const APPROVE_WORD = "approve";
export const CUSTOM_ID_PREFIX = "approve:";
@@ -95,19 +95,29 @@ export function appendApproval(dir, entry) {
if (!isObject(entry) || typeof entry.kind !== "string") throw new DiscordError("approvals: entry needs a kind", 1);
const line = JSON.stringify(entry);
if (line.includes("\n")) throw new DiscordError("approvals: line must not contain a newline", 1);
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
try {
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
} catch (e) {
throw quietFs(e, "approvals.jsonl", "append to");
}
}
export function readApprovals(dir) {
const path = approvalsPath(dir);
if (!existsSync(path)) return [];
const out = [];
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
let text;
try {
text = readFileSync(path, "utf8");
} catch (e) {
throw quietRead(e, "approvals.jsonl");
}
for (const [i, line] of text.split("\n").entries()) {
if (line.length === 0) continue;
try {
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
} catch {
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
throw new DiscordError(`approvals.jsonl:${i + 1}: not valid JSON`);
}
}
return out;
+72 -35
View File
@@ -22,14 +22,14 @@ import { parseJsonFileText } from "../../organization/src/util.mjs";
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
import { loadWebConfig } from "./web.mjs";
import { loadSetsparkConfig } from "./setspark.mjs";
import { isAbsolute, join, resolve, sep } from "node:path";
import { basename, isAbsolute, join, resolve, sep } from "node:path";
import { homedir } from "node:os";
import { DiscordError } from "./errors.mjs";
import { BINDING_NAME, DiscordError, plainKey, quietRead } from "./errors.mjs";
import { TOOL_DEFAULTS } from "./tools.mjs";
import { loadGitConfig } from "./git.mjs";
export const BINDING_VERSION = 1;
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
export { BINDING_NAME };
export const SNOWFLAKE = /^[0-9]{17,20}$/;
export const CHANNEL_MODES = Object.freeze(["open", "mention"]);
export const THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
@@ -56,16 +56,17 @@ export function defaultConfigPath(env = process.env) {
}
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
// Refusals name the config, never its path (#1873).
export function loadDataRoot(path = defaultConfigPath()) {
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
if (!existsSync(path)) throw new DiscordError("config not found");
let raw;
try {
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
throw new DiscordError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
}
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError("config is not an object");
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError("config.dataRoot must be an absolute path");
return raw.dataRoot;
}
@@ -73,13 +74,16 @@ export function discordDir(dataRoot) {
return join(dataRoot, "discord");
}
// The name is checked, never echoed: a bad one could be a host path (#1873).
const INVALID_BINDING_NAME = "invalid binding name (lowercase letters, digits, ., _ or -, at most 64)";
export function bindingPath(dataRoot, name) {
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
if (!BINDING_NAME.test(String(name))) throw new DiscordError(INVALID_BINDING_NAME, 4);
return join(discordDir(dataRoot), `${name}.json`);
}
export function bindingDataDir(dataRoot, name) {
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
if (!BINDING_NAME.test(String(name))) throw new DiscordError(INVALID_BINDING_NAME, 4);
return join(discordDir(dataRoot), name);
}
@@ -89,7 +93,7 @@ function isObject(v) {
function onlyKeys(obj, allowed, where) {
for (const key of Object.keys(obj)) {
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${JSON.stringify(key)}`);
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${plainKey(key)}`);
}
}
@@ -274,38 +278,49 @@ export function reloadDiff(current, next) {
}
// A private file: regular, not a symlink, owner-only (0600), non-empty.
// `what` is the label a refusal names; the path never appears (#1873).
export function checkPrivateFile(path, what) {
let st;
try {
st = lstatSync(path);
} catch {
throw new DiscordError(`${what} not found: ${path}`);
throw new DiscordError(`${what} not found`);
}
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink: ${path}`);
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file: ${path}`);
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink`);
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file`);
const mode = st.mode & 0o777;
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}: ${path}`);
if (st.size === 0) throw new DiscordError(`${what} is empty: ${path}`);
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}`);
if (st.size === 0) throw new DiscordError(`${what} is empty`);
return st;
}
// The binding is named by its file's name when that is a valid binding name
// (bindingPath() makes it so), never by its path.
export function loadBinding(path) {
checkPrivateFile(path, "binding");
const name = basename(String(path), ".json");
const label = BINDING_NAME.test(name) ? `binding ${name}` : "binding";
checkPrivateFile(path, label);
let raw;
try {
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
} catch (error) {
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
throw new DiscordError(`${label} is not valid JSON${error.code ? ` (${error.code})` : ""}`);
}
return validateBinding(raw, `binding ${path}`);
return validateBinding(raw, label);
}
// The token is read once into memory and handed to the REST and gateway
// clients. It is never printed, journaled, or put on a command line.
export function readToken(binding) {
checkPrivateFile(binding.tokenFile, "token file");
const token = readFileSync(binding.tokenFile, "utf8").trim();
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError(`token file does not hold a bot token: ${binding.tokenFile}`);
// A file that changes after its check fails with a native error that carries the path (#1873).
let token;
try {
token = readFileSync(binding.tokenFile, "utf8").trim();
} catch (e) {
throw quietRead(e, "token file");
}
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError("token file does not hold a bot token");
return token;
}
@@ -315,22 +330,34 @@ export function readToken(binding) {
// the model's prompt, so this is the boundary that keeps host files out of
// Discord Sage (Q14, Q16). Every file must be a regular non-empty file.
export function resolveContextFiles(binding, repo) {
const root = realpathSync(repo);
return binding.context.files.map((f) => {
let root;
try {
root = realpathSync(repo);
} catch (e) {
throw quietRead(e, "the repository");
}
// Refusals name the entry by its index in context.files, never its path (#1873).
return binding.context.files.map((f, i) => {
const at = `context.files[${i}]`;
if (typeof f !== "string" || f.length === 0) throw new DiscordError("context file must be a non-empty string");
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${f}`);
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${f}`);
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${at}`);
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${at}`);
const path = resolve(root, f);
let st;
try {
st = lstatSync(path);
} catch {
throw new DiscordError(`missing context file: ${path}`);
throw new DiscordError(`missing context file: ${at}`);
}
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${path}`);
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${path}`);
const real = realpathSync(path);
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${f}`);
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${at}`);
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${at}`);
let real;
try {
real = realpathSync(path);
} catch (e) {
throw quietRead(e, `context file ${at}`);
}
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${at}`);
return path;
});
}
@@ -363,18 +390,28 @@ function webConfig(raw, where) {
export function resolveToolRoots(binding, { dataRoot }) {
if (!binding.tools) return null;
const data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
let data;
try {
data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
} catch (e) {
throw quietRead(e, "the data root");
}
const roots = binding.tools.roots.map((r) => {
let st;
try {
st = lstatSync(r.path);
} catch {
throw new DiscordError(`tool root ${r.name} does not exist: ${r.path}`);
throw new DiscordError(`tool root ${r.name} does not exist`);
}
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink: ${r.path}`);
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory: ${r.path}`);
const real = realpathSync(r.path);
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root: ${r.path}`);
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink`);
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory`);
let real;
try {
real = realpathSync(r.path);
} catch (e) {
throw quietRead(e, `tool root ${r.name}`);
}
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root`);
return { name: r.name, path: real, write: r.write, ...(r.git ? { git: { branch: r.git.branch, identity: r.git.identity, tokenFile: r.git.tokenFile, author: `${r.git.author.name} <${r.git.author.email}>`, ...(r.git.protocol ? { protocol: r.git.protocol } : {}) } } : {}) };
});
// setspark carries only the keys loadSetsparkConfig accepts; the extension
+13 -19
View File
@@ -46,22 +46,22 @@
// 3 refused by a brake (STOP present or the binding held; a supervisor must
// not retry); 4 usage.
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs";
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { createHash } from "node:crypto";
import { DiscordError } from "./errors.mjs";
import { DiscordError, printable } from "./errors.mjs";
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
import { createRest } from "./rest.mjs";
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
import { createEngine, buildPiArgs, sessionContinues } from "./engine-pi.mjs";
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
import { createSetsparkApi } from "./setspark.mjs";
import { assembleContext } from "./context.mjs";
import { createConnector } from "./connector.mjs";
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, journalLabel, BRAKE_EXIT } from "./journal.mjs";
const USAGE = [
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
@@ -114,7 +114,7 @@ function checkLibraryEngine(configDirectory, binding) {
try {
sets = loadSeatSets({ dir: configDirectory });
} catch (err) {
throw new DiscordError(`discord-business-unreadable: ${err.message}`, 2);
throw new DiscordError(`discord-business-unreadable: ${printable(err, "a business file")}`, 2);
}
if (!sets.librarySeats.has(binding.seat)) return;
if (binding.engine.provider !== "mosaic-bus") {
@@ -135,7 +135,7 @@ function prepare(opts) {
const contextFiles = resolveContextFiles(binding, opts.repo);
const toolRoots = resolveToolRoots(binding, { dataRoot });
const pi = join(opts.repo, "node_modules", ".bin", "pi");
if (!existsSync(pi)) throw new DiscordError(`pi not found at ${pi}; run npm ci in the repository`);
if (!existsSync(pi)) throw new DiscordError("pi not found at node_modules/.bin/pi in the repository; run npm ci in the repository");
const journalDir = bindingDataDir(dataRoot, binding.name);
const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`);
return { dataRoot, bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir };
@@ -205,7 +205,7 @@ async function run(opts) {
const outcome = recover(journalDir);
if (outcome === "cleared") warn("supervised start: run.lock left by a process that is gone was removed");
}
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, BRAKE_EXIT);
writePid(journalDir, process.pid);
let managed = null;
const cleanupPid = () => {
@@ -234,7 +234,7 @@ async function run(opts) {
const promptFile = join(launch, "context.md");
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
const continueSession = existsSync(sessionDir) && statSync(sessionDir).isDirectory() && hasJsonl(sessionDir);
const continueSession = sessionContinues(sessionDir);
const rest = createRest({ token, log: warn });
const gw = await rest.getGatewayBot();
@@ -272,7 +272,7 @@ async function run(opts) {
clearInterval(processingWatch);
exitCode = code;
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${printable(err)}`)).finally(() => {
cleanupPid();
process.exit(exitCode);
});
@@ -317,8 +317,8 @@ async function run(opts) {
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
} catch (err) {
appendReload(journalDir, { at, outcome: "refused", error: err.message });
warn(`reload refused, binding unchanged: ${err.message}`);
appendReload(journalDir, { at, outcome: "refused", error: printable(err) });
warn(`reload refused, binding unchanged: ${printable(err)}`);
}
};
let reloadChain = Promise.resolve();
@@ -332,13 +332,6 @@ async function run(opts) {
}
}
function hasJsonl(dir) {
try {
return readdirSync(dir).some((f) => f.endsWith(".jsonl"));
} catch {
return false;
}
}
function stop(opts) {
const dataRoot = loadDataRoot(opts.config);
@@ -413,7 +406,8 @@ main().then((code) => {
if (code !== null) process.exit(code);
}).catch((err) => {
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
warn(err.message);
// Never a native error's message: it carries the path (#1873).
warn(printable(err));
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
process.exit(code);
});
+2 -2
View File
@@ -35,7 +35,7 @@ import { reloadDiff } from "./binding.mjs";
import { envelope, splitReply } from "./context.mjs";
import {
ensureJournal, appendInbox, readInboxIds, appendOutbox, unresolvedOutbox, appendDrop,
writeTurn, appendAdmission, countAdmissionsOn, appendNotice, noticeOn, utcDate, stopRequested,
writeTurn, appendAdmission, countAdmissionsOn, appendNotice, noticeOn, utcDate, stopRequested, journalLabel,
} from "./journal.mjs";
import { RestOutcome } from "./rest.mjs";
import { DiscordError } from "./errors.mjs";
@@ -596,7 +596,7 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
async start() {
processing();
if (state.started) throw new DiscordError("connector already started", 1);
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, 1);
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, 1);
const reconciled = await reconcile();
const approvals = await reconcileApprovals();
processing();
+26 -4
View File
@@ -32,8 +32,9 @@
// without the connector noticing: the contract is start(), prompt(), stop().
import { spawn as nodeSpawn } from "node:child_process";
import { existsSync, readdirSync, statSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { DiscordError } from "./errors.mjs";
import { DiscordError, printable, quietFs } from "./errors.mjs";
import { enabledToolNames } from "./tools.mjs";
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
@@ -51,6 +52,25 @@ export const TOOLS_EXTENSION = fileURLToPath(new URL("../extension/tools.mjs", i
// Older name, kept for the tests and docs that still use it.
export const READONLY_TOOLS_EXTENSION = TOOLS_EXTENSION;
// Whether pi continues the session in sessionDir: it is a directory holding a
// .jsonl session file. A stat that fails names the binding's session directory,
// never its path (#1873); a listing that fails means a new session, as before.
export function sessionContinues(sessionDir) {
if (!existsSync(sessionDir)) return false;
let st;
try {
st = statSync(sessionDir);
} catch (e) {
throw quietFs(e, "the binding's session directory");
}
if (!st.isDirectory()) return false;
try {
return readdirSync(sessionDir).some((f) => f.endsWith(".jsonl"));
} catch {
return false;
}
}
export function buildPiArgs({ provider, model, thinking, sessionDir, appendSystemPromptFile, continueSession, tools = null }) {
const args = [...PI_FIXED_ARGS];
if (tools) args.push("--no-builtin-tools", "--extension", TOOLS_EXTENSION, "--tools", enabledToolNames(tools).join(","));
@@ -393,9 +413,11 @@ export function createEngine({
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
child.on("error", (err) => {
releaseRuntime();
log(`engine spawn error: ${err.message}`);
state.exited = { code: null, signal: null, error: err.message };
failAll("engine-down", `engine failed: ${err.message}`);
// A spawn error's message carries the command's path (#1873).
const why = printable(err, "pi");
log(`engine spawn error: ${why}`);
state.exited = { code: null, signal: null, error: why };
failAll("engine-down", `engine failed: ${why}`);
onExit(state.exited);
});
child.on("exit", (code, signal) => {
+42
View File
@@ -1,6 +1,48 @@
// One error class for the package. exitCode follows docs/TOOLS.md: 1 operation
// failed, 2 invalid data or configuration, 3 refused by a brake (STOP or a
// held binding; a supervisor must not retry), 4 usage.
// A refusal that names an unknown key quotes it only when it is a plain name;
// a key that could carry a host path or a secret is described, never echoed (#1873).
export const plainKey = (k) => (/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(String(k)) ? JSON.stringify(k) : "that is not a plain name");
// A binding (and seat) name. Defined here, not in binding.mjs, so journal.mjs can label a
// journal directory without importing binding.mjs and its config readers; copies of
// journal.mjs + errors.mjs (the queue test repo) stay self-contained. binding.mjs re-exports it.
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// A filesystem call that fails names the file by label, the call and the
// error code, never the path Node puts in the message. The same error object
// is rethrown, so its type, code and the exit it maps to are unchanged, and
// it is marked so the CLI's top-level handler prints it as it is (#1873).
const QUIET = Symbol("discord.quiet");
const SYSCALL = /^[a-z][a-z0-9_]{0,31}$/;
const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
const callOf = (e) => {
const word = typeof e.syscall === "string" ? e.syscall.split(" ")[0] : "";
return SYSCALL.test(word) ? word : "access";
};
const codeOf = (e) => (typeof e.code === "string" && CODE.test(e.code) ? e.code : "UNKNOWN");
export function quietFs(e, label, verb = null) {
if (e && typeof e === "object" && typeof e.code === "string") {
e.message = `cannot ${verb ?? callOf(e)} ${label} (${codeOf(e)})`;
e[QUIET] = true;
}
return e;
}
export const quietRead = (e, label) => quietFs(e, label, "read");
// What a top-level handler prints for an error (#1873). Our refusals and an
// error already labelled at its source print their own message. A native
// Node error (it carries a syscall, or a code and a path) that slipped past
// every source label prints only its call and code, never its message,
// which carries the path; `label` names the file when the error has one.
export function printable(err, label = "a discord file") {
if (err instanceof DiscordError || (err && err[QUIET] === true)) return String(err.message);
if (err && typeof err === "object" && (typeof err.syscall === "string" || (typeof err.code === "string" && typeof err.path === "string"))) {
return typeof err.path === "string" ? `cannot ${callOf(err)} ${label} (${codeOf(err)})` : `cannot ${callOf(err)} (${codeOf(err)})`;
}
return err && typeof err.message === "string" ? err.message : String(err);
}
export class DiscordError extends Error {
constructor(message, exitCode = 2, details = undefined) {
super(message);
+14 -9
View File
@@ -46,6 +46,7 @@
import { spawnSync } from "node:child_process";
import { existsSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { plainKey } from "./errors.mjs";
import { fileURLToPath } from "node:url";
export const GIT_TOOL_NAMES = Object.freeze(["git_status", "git_commit", "git_pull", "git_push"]);
@@ -109,7 +110,7 @@ const AUTHOR = /^([^<>\r\n]{1,64}?) <([^<>\s@]+@[^<>\s@]+)>$/;
export function loadGitConfig(raw, where, real) {
if (!isObject(raw)) throw new Error(`${where}: not an object`);
for (const k of Object.keys(raw)) {
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${plainKey(k)}`);
}
if (typeof raw.branch !== "string" || !BRANCH_NAME.test(raw.branch) || raw.branch.includes("..")) throw new Error(`${where}.branch: must be a branch name`);
if (typeof raw.identity !== "string" || !IDENTITY.test(raw.identity)) throw new Error(`${where}.identity: must match ${IDENTITY}`);
@@ -125,7 +126,7 @@ export function loadGitConfig(raw, where, real) {
} catch {
// handled below
}
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: ${real} is not a git work tree`);
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: the root is not a git work tree`);
}
if (process.execPath.includes("'") || CREDENTIAL_HELPER.includes("'")) throw new Error(`${where}: the node or helper path holds a quote and cannot be a credential helper`);
return Object.freeze({
@@ -139,12 +140,12 @@ function checkPrivateFile(path, what) {
try {
st = lstatSync(path);
} catch {
throw new Error(`${what}: not found: ${path}`);
throw new Error(`${what}: not found`);
}
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink`);
if (!st.isFile()) throw new Error(`${what}: not a regular file`);
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600`);
if (st.size === 0) throw new Error(`${what}: is empty`);
}
// The environment every child sees. No global or system git configuration,
@@ -201,7 +202,7 @@ function cap(text) {
function run(cmd, args, cwd, env, spawn) {
const r = spawn(cmd, args, { cwd, env, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
if (r.error && r.error.code === "ETIMEDOUT") throw new GitRefusal(GIT_REFUSAL.TIMEOUT, `${cmd} ${args[0] || ""}`);
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || r.error.message})`);
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || "error"})`);
return { status: r.status, stdout: cap(r.stdout || ""), stderr: cap(r.stderr || "") };
}
@@ -209,8 +210,12 @@ function git(root, gitCfg, args, { remote = false, spawn = spawnSync } = {}) {
return run("git", args, root.real, gitEnv(gitCfg, { remote }), spawn);
}
// git output reaches a refusal detail, the journal and Discord. A line that
// carries a host path (an absolute or home path, or a file: URL) is dropped,
// never relayed; repo-relative lines such as a lock owner or a conflict stay (#1873).
const HOST_PATH = /(^|[\s'"(=:,[<])~?\/(?!\/)|file:/i;
function firstLines(text, n = 5) {
return text.split("\n").filter((l) => l.trim().length > 0).slice(0, n).join("\n");
return text.split("\n").filter((l) => l.trim().length > 0 && !HOST_PATH.test(l)).slice(0, n).join("\n");
}
// The guard every verb runs first: on the named branch, nothing in
+109 -35
View File
@@ -25,8 +25,16 @@ import {
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
unlinkSync, writeFileSync, writeSync,
} from "node:fs";
import { join } from "node:path";
import { DiscordError } from "./errors.mjs";
import { basename, join } from "node:path";
import { BINDING_NAME, DiscordError, quietFs, quietRead } from "./errors.mjs";
// Where a refusal says a binding's journal is: its place under the data root
// when the directory carries a valid binding name (bindingDataDir() makes it
// so), never the host path (#1873).
export function journalLabel(dir) {
const name = basename(String(dir));
return BINDING_NAME.test(name) ? `discord/${name} under the data root` : "the binding's journal directory";
}
export const OUTBOX_STATUS = Object.freeze(["intent", "confirmed", "refused", "unknown"]);
@@ -38,19 +46,28 @@ export function ensureJournal(dir) {
function appendLine(path, record) {
const line = JSON.stringify(record);
if (line.includes("\n")) throw new DiscordError("journal line must not contain a newline", 1);
appendFileSync(path, line + "\n", { mode: 0o600 });
try {
appendFileSync(path, line + "\n", { mode: 0o600 });
} catch (e) {
throw quietFs(e, basename(path), "append to");
}
}
function readLines(path) {
if (!existsSync(path)) return [];
const out = [];
const text = readFileSync(path, "utf8");
let text;
try {
text = readFileSync(path, "utf8");
} catch (e) {
throw quietRead(e, basename(path));
}
for (const [i, line] of text.split("\n").entries()) {
if (line.length === 0) continue;
try {
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
} catch {
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
throw new DiscordError(`${basename(path)}:${i + 1}: not valid JSON`);
}
}
return out;
@@ -109,38 +126,61 @@ export function readDrops(dir) {
const TURN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
export function turnPath(dir, id) {
if (!TURN_ID.test(String(id))) throw new DiscordError(`invalid turn id: ${JSON.stringify(id)}`, 1);
if (!TURN_ID.test(String(id))) throw new DiscordError("invalid turn id (letters, digits, ., _ or -, at most 128)", 1);
return join(dir, "turns", `${id}.json`);
}
// A parse refusal or a failed read or write names the turn by its id, never by
// host path (PR #1916, #1873); a listed file's id is named only when it passes TURN_ID.
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
export function writeTurn(dir, id, record) {
const path = turnPath(dir, id);
let fd;
try {
fd = openSync(path, "wx", 0o600);
} catch (err) {
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${path}`, 1);
throw err;
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${id}`, 1);
throw quietFs(err, turnLabel(String(id)), "write");
}
try {
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
} finally {
closeSync(fd);
try {
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
} finally {
closeSync(fd);
}
} catch (err) {
throw quietFs(err, turnLabel(String(id)), "write");
}
return path;
}
function readTurnFile(path, id) {
let text;
try {
text = readFileSync(path, "utf8");
} catch (e) {
throw quietRead(e, turnLabel(id));
}
return parseJsonFileText(text, path, turnLabel(id));
}
export function readTurn(dir, id) {
const path = turnPath(dir, id);
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
return readTurnFile(turnPath(dir, id), String(id));
}
export function listTurns(dir) {
const turns = join(dir, "turns");
if (!existsSync(turns)) return [];
return readdirSync(turns)
let names;
try {
names = readdirSync(turns);
} catch (e) {
throw quietRead(e, `the turns directory in ${journalLabel(dir)}`);
}
return names
.filter((f) => f.endsWith(".json"))
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
.map((f) => readTurnFile(join(turns, f), f.slice(0, -".json".length)));
}
// The daily ceiling counts admissions on the current UTC date. An admission
@@ -185,21 +225,38 @@ function stopLock(dir, action) {
const lock = join(dir, '.stop.lock');
try { mkdirSync(lock, { mode: 0o700 }); }
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
try { return action(); } finally { rmdirSync(lock); }
try { return action(); } finally {
try { rmdirSync(lock); } catch (e) { throw quietFs(e, `.stop.lock in ${journalLabel(dir)}`, "remove"); }
}
}
// A STOP write or removal that fails names STOP in the journal, never its path (#1873).
const stopLabel = (dir) => `STOP in ${journalLabel(dir)}`;
function appendStop(dir, reason) {
const path = stopPath(dir);
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
fsyncSync(fd);
} finally {
closeSync(fd);
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
try {
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
fsyncSync(fd);
} finally {
closeSync(fd);
}
} catch (e) {
throw quietFs(e, stopLabel(dir), "write");
}
return path;
}
function removeStop(dir) {
try {
unlinkSync(stopPath(dir));
} catch (e) {
throw quietFs(e, stopLabel(dir), "remove");
}
}
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
function processingStops(dir) {
let fd;
@@ -231,14 +288,13 @@ export function clearProcessingStop(dir, generation) {
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
new Date(r.at).toISOString() !== r.at)) return false;
unlinkSync(stopPath(dir));
removeStop(dir);
return true;
});
}
export function clearStop(dir) {
const path = stopPath(dir);
if (existsSync(path)) unlinkSync(path);
if (existsSync(stopPath(dir))) removeStop(dir);
}
// The STOP lines as written, oldest first. A line that does not parse is
@@ -246,7 +302,13 @@ export function clearStop(dir) {
// treated as ours.
export function readStop(dir) {
if (!stopRequested(dir)) return [];
return readFileSync(stopPath(dir), "utf8").split("\n").filter((l) => l.trim() !== "").map((l) => {
let text;
try {
text = readFileSync(stopPath(dir), "utf8");
} catch (e) {
throw quietRead(e, stopLabel(dir));
}
return text.split("\n").filter((l) => l.trim() !== "").map((l) => {
try {
const v = JSON.parse(l);
return v && typeof v === "object" && typeof v.reason === "string" ? v : { reason: null };
@@ -392,7 +454,7 @@ function lockRefusal(dir, opts) {
const existing = readPid(dir);
const state = ownerState(existing, opts);
if (state === "absent") return new DiscordError(`run.lock exists without an owner record: a start is in progress or was interrupted; ${UNLOCK_HINT}`, BRAKE_EXIT);
if (state === "invalid") return new DiscordError(`run.lock has an owner record that cannot be read; refusing. Inspect ${ownerPath(dir)} by hand`, BRAKE_EXIT);
if (state === "invalid") return new DiscordError(`run.lock has an owner record that cannot be read; refusing. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, BRAKE_EXIT);
if (state === "live") return new DiscordError(`another connector is running for this binding (pid ${existing.pid})`, BRAKE_EXIT);
if (state === "unknown") return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is alive but whose identity cannot be verified; refusing`, BRAKE_EXIT);
return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is gone or is a different process now; ${UNLOCK_HINT}`, BRAKE_EXIT);
@@ -405,17 +467,21 @@ export function writePid(dir, pid, { now = Date.now(), identity = identityOf } =
try {
mkdirSync(lock, { mode: 0o700 });
} catch (err) {
if (err.code !== "EEXIST") throw err;
if (err.code !== "EEXIST") throw quietFs(err, `run.lock in ${journalLabel(dir)}`, "create");
throw lockRefusal(dir, { identity });
}
const tmp = join(lock, "owner.json.tmp");
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
renameSync(tmp, ownerPath(dir));
try {
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
renameSync(tmp, ownerPath(dir));
} catch (e) {
throw quietFs(e, `run.lock/owner.json in ${journalLabel(dir)}`, "write");
}
// The gate: STOP written before this point (by `stop` or `unlock`) means
// this claim must not stand, however it interleaved with an unlock.
if (stopRequested(dir)) {
clearPid(dir, pid);
throw new DiscordError(`STOP is present in ${dir}; remove it to run`, BRAKE_EXIT);
throw new DiscordError(`STOP is present in ${journalLabel(dir)}; remove it to run`, BRAKE_EXIT);
}
}
@@ -435,9 +501,9 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
const state = ownerState(rec, { identity });
if (state === "live") throw new DiscordError(`refusing to unlock: the connector is running (pid ${rec.pid}); use stop, and unlock only a lock whose owner is gone`, 1);
if (state === "unknown") throw new DiscordError(`refusing to unlock: pid ${rec.pid} is alive and its identity cannot be verified; nothing removed. Stop that process first`, 1);
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect ${ownerPath(dir)} by hand`, 1);
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, 1);
if (beforeRemove) beforeRemove();
rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
removeLock(dir, { maxRetries: 10, retryDelay: 20 });
return rec;
}
@@ -464,14 +530,14 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
export function recover(dir, { identity = identityOf, beforeRemove = null } = {}) {
const ours = (lines) => lines.length === 1 && lines[0].reason === RECOVER_REASON;
if (stopRequested(dir)) {
if (!ours(readStop(dir))) throw new DiscordError(`STOP is present in ${dir}; the brake is on. Remove it to run`, BRAKE_EXIT);
if (!ours(readStop(dir))) throw new DiscordError(`STOP is present in ${journalLabel(dir)}; the brake is on. Remove it to run`, BRAKE_EXIT);
clearStop(dir);
}
if (!existsSync(lockPath(dir))) return "clean";
const state = ownerState(readPid(dir), { identity });
if (state === "live" || state === "unknown" || state === "invalid") throw lockRefusal(dir, { identity });
unlock(dir, { identity, reason: RECOVER_REASON, beforeRemove });
if (!ours(readStop(dir))) throw new DiscordError(`STOP was written by an operator while recovering ${dir}; the brake stays. Remove it to run`, BRAKE_EXIT);
if (!ours(readStop(dir))) throw new DiscordError(`STOP was written by an operator while recovering ${journalLabel(dir)}; the brake stays. Remove it to run`, BRAKE_EXIT);
clearStop(dir);
return "cleared";
}
@@ -485,7 +551,15 @@ export function stopTarget(dir, opts) {
export function clearPid(dir, pid) {
const rec = readPid(dir);
if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true });
if (rec !== null && !rec.invalid && rec.pid === pid) removeLock(dir);
}
function removeLock(dir, retry = {}) {
try {
rmSync(lockPath(dir), { recursive: true, force: true, ...retry });
} catch (e) {
throw quietFs(e, `run.lock in ${journalLabel(dir)}`, "remove");
}
}
// --- reloads ---
+15 -7
View File
@@ -28,6 +28,7 @@
import { lstatSync, readFileSync } from "node:fs";
import { request as httpsRequest } from "node:https";
import { request as httpRequest } from "node:http";
import { plainKey, quietRead } from "./errors.mjs";
export const SETSPARK_DEFAULTS = Object.freeze({ timeoutMs: 15000, maxResponseBytes: 262144 });
export const USER_AGENT = "mosaic-discord-sage/1 (Mosaic Stack Discord connector; setspark client)";
@@ -69,7 +70,7 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
export function loadSetsparkConfig(raw, where = "setspark") {
if (!isObject(raw)) throw new Error(`${where}: not an object`);
for (const k of Object.keys(raw)) {
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${plainKey(k)}`);
}
if (typeof raw.baseUrl !== "string") throw new Error(`${where}.baseUrl: must be a url string`);
let u;
@@ -108,12 +109,12 @@ function checkPrivateFile(path, what) {
try {
st = lstatSync(path);
} catch {
throw new Error(`${what}: not found: ${path}`);
throw new Error(`${what}: not found`);
}
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink`);
if (!st.isFile()) throw new Error(`${what}: not a regular file`);
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600`);
if (st.size === 0) throw new Error(`${what}: is empty`);
}
// Read the key for one call. The file is re-checked every time, so a key
@@ -132,7 +133,14 @@ export function readKey(config) {
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE);
}
if (st.size > KEY_MAX_BYTES) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
const text = readFileSync(config.keyFile, "utf8");
// A file that changes after its checks fails with a native error that carries the path: it
// keeps its type and code and names the field (#1873).
let text;
try {
text = readFileSync(config.keyFile, "utf8");
} catch (e) {
throw quietRead(e, "keyFile");
}
// Either one bare key line, or the mint's own JSON output
// ({"key_id", "key", "note"}) stored as is; only "key" is used.
let key = text;
+2 -1
View File
@@ -177,7 +177,8 @@ test("cli: check refuses a missing context file and a missing binding with exit
assert.match(r.stderr, /missing context file/);
r = runCli(["check", "other-seat", "--config", dep.config, "--repo", repo]);
assert.equal(r.status, 2);
assert.match(r.stderr, /binding not found/);
assert.match(r.stderr, /binding other-seat not found/);
assert.ok(!r.stderr.includes(root), r.stderr);
r = runCli(["check"]);
assert.equal(r.status, 4);
r = runCli(["frobnicate", "x"]);
+1 -1
View File
@@ -230,7 +230,7 @@ test("restart: an inbox with three ids and a replay of the same three produces z
test("stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes", async () => {
const { journalDir, rest, engine, connector } = setup({ replies: [{ text: "done", delayMs: 30 }] });
requestStop(journalDir, "test");
await assert.rejects(connector.start(), /STOP is present/);
await assert.rejects(connector.start(), (err) => /STOP is present/.test(err.message) && !err.message.includes(journalDir));
assert.equal(engine.started, false);
writeFileSync(join(journalDir, "STOP"), ""); // still present; clear it
const { unlinkSync } = await import("node:fs");
+3 -2
View File
@@ -28,9 +28,10 @@ for (const [field, edit] of [
});
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
const root = scratch(t); mkdirSync(join(root, 'turns'));
const root = join(scratch(t), secret); mkdirSync(join(root, 'turns'), { recursive: true });
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
// The refusal names the turn by its id, never by host path (PR #1916).
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe('turn record turn-1 is not valid JSON', SyntaxError, {}, file));
});
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
@@ -0,0 +1,225 @@
// #1873 (redact6, re-check 2): a native fs error never prints its path. The journal and
// approvals readers and writers name the file by label at the source, and the CLI's
// top-level handler prints any native error that slipped through as its call and code
// only. The sentinel is a component of every scratch path, so an error message that
// quotes a path quotes the sentinel too.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs, { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { appendApproval } from '../src/approvals.mjs';
import { DiscordError } from '../src/errors.mjs';
import {
appendInbox, clearStop, ensureJournal, listTurns, lockPath, readTurn, recover, requestStop, stopPath, turnPath, writePid, writeTurn,
} from '../src/journal.mjs';
import { makeDeployment, makeRepo } from './helpers.mjs';
import { initializeControl } from '../../processing-control/src/state.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
// A journal directory named like a binding, under a sentinel path component, so its
// label is "discord/seat under the data root".
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r6n-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, secret, 'seat');
mkdirSync(dir, { recursive: true });
return { root, dir };
}
const where = 'discord/seat under the data root';
function quiet(text, root) {
assert.ok(text.includes(root) === false, text);
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
}
function labelled(label, root) {
return error => {
assert.ok(error instanceof Error, String(error));
assert.equal(error instanceof DiscordError, false, 'a native fs error keeps its type');
assert.ok(error.message.includes(label), error.message);
quiet(error.message, root);
return true;
};
}
// Swap one node:fs function so the target goes away right before the real call, which
// then fails with a genuine error that names the path. One swap per test.
function race(t, fn, target, remove) {
const original = fs[fn];
const swapped = function (p, ...rest) {
if (String(p) === target) remove();
return original.call(this, p, ...rest);
};
if (original.native) swapped.native = original.native;
fs[fn] = swapped;
syncBuiltinESMExports();
t.after(() => {
fs[fn] = original;
syncBuiltinESMExports();
});
}
// --- the four named sites ---
test('recover: a STOP removed between its check and its read names STOP in the journal', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
writeFileSync(stopPath(dir), JSON.stringify({ at: new Date().toISOString(), reason: 'cli stop' }) + '\n', { mode: 0o600 });
race(t, 'readFileSync', stopPath(dir), () => rmSync(stopPath(dir)));
assert.throws(() => recover(dir), labelled(`cannot read STOP in ${where} (ENOENT)`, root));
});
test('readTurn: a turn record removed before its read names the turn by id', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
assert.throws(() => readTurn(dir, 't1'), labelled('cannot read turn record t1 (ENOENT)', root));
});
test('listTurns: a turns directory removed between its check and its listing names the turns directory', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
const turns = join(dir, 'turns');
race(t, 'readdirSync', turns, () => rmSync(turns, { recursive: true }));
assert.throws(() => listTurns(dir), labelled(`cannot read the turns directory in ${where} (ENOENT)`, root));
});
test('listTurns: a turn record removed between the listing and its read names the turn by id', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
assert.throws(() => listTurns(dir), labelled('cannot read turn record t1 (ENOENT)', root));
});
test("run: a session directory removed between its check and its stat names the binding's session directory", async t => {
const { root, dir } = scratch(t);
const { sessionContinues } = await import('../src/engine-pi.mjs');
assert.equal(typeof sessionContinues, 'function', 'engine-pi.mjs exports sessionContinues');
const session = join(dir, 'session');
assert.equal(sessionContinues(session), false, 'no directory: a new session');
mkdirSync(session);
assert.equal(sessionContinues(session), false, 'an empty directory: a new session');
writeFileSync(join(session, 'a.jsonl'), '');
assert.equal(sessionContinues(session), true, 'a .jsonl file: the session continues');
race(t, 'statSync', session, () => rmSync(session, { recursive: true }));
assert.throws(() => sessionContinues(session), labelled("cannot stat the binding's session directory (ENOENT)", root));
});
// --- journal and approvals writers (the connector logs err.message at runtime) ---
test('a journal line that cannot be appended names the journal file, never its path', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
mkdirSync(join(dir, 'inbox.jsonl'));
assert.throws(() => appendInbox(dir, { id: '1' }), labelled('cannot append to inbox.jsonl (EISDIR)', root));
});
test('an approvals line that cannot be appended names approvals.jsonl, never its path', t => {
const { root, dir } = scratch(t);
ensureJournal(dir);
mkdirSync(join(dir, 'approvals.jsonl'));
assert.throws(() => appendApproval(dir, { kind: 'opened' }), labelled('cannot append to approvals.jsonl (EISDIR)', root));
});
test('a turn record that cannot be created names the turn by id; an existing one keeps its refusal', t => {
const { root, dir } = scratch(t);
assert.throws(() => writeTurn(dir, 't1', {}), labelled('cannot write turn record t1 (ENOENT)', root));
ensureJournal(dir);
writeTurn(dir, 't1', {});
assert.throws(() => writeTurn(dir, 't1', {}), e => e instanceof DiscordError && e.exitCode === 1 && /turn record already exists: t1/.test(e.message));
});
test('a STOP that cannot be written names STOP in the journal; the lock is released', t => {
const { root, dir } = scratch(t);
mkdirSync(stopPath(dir));
assert.throws(() => requestStop(dir, 'cli stop'), labelled(`cannot write STOP in ${where} (EISDIR)`, root));
assert.equal(existsSync(join(dir, '.stop.lock')), false);
});
test('a STOP removed between its check and its removal names STOP in the journal', t => {
const { root, dir } = scratch(t);
writeFileSync(stopPath(dir), '{}\n', { mode: 0o600 });
race(t, 'unlinkSync', stopPath(dir), () => rmSync(stopPath(dir)));
assert.throws(() => clearStop(dir), labelled(`cannot remove STOP in ${where} (ENOENT)`, root));
});
test('a run.lock that cannot be created names run.lock in the journal', t => {
const { root, dir } = scratch(t);
const gone = join(dir, 'gone');
assert.throws(() => writePid(gone, process.pid), e => {
assert.ok(e.message.includes('cannot create run.lock in discord/gone under the data root (ENOENT)'), e.message);
quiet(e.message, root);
return true;
});
assert.equal(existsSync(lockPath(gone)), false);
});
test('engine: a pi that cannot be spawned is logged and recorded as "cannot spawn pi", never its path', async t => {
const { root, dir } = scratch(t);
const { createEngine } = await import('../src/engine-pi.mjs');
const logs = [];
let exited = null;
const engine = createEngine({ command: join(dir, 'pi'), args: [], cwd: dir, env: {}, log: (m) => logs.push(m), onExit: (e) => (exited = e) });
engine.start();
for (let i = 0; i < 200 && exited === null; i += 1) await new Promise((res) => setTimeout(res, 10));
assert.ok(exited, 'the spawn error reached onExit');
assert.equal(exited.error, 'cannot spawn pi (ENOENT)');
assert.ok(logs.includes('engine spawn error: cannot spawn pi (ENOENT)'), logs.join('\n'));
quiet(JSON.stringify({ logs, exited }), root);
});
// --- the choke point ---
test('printable: a native error prints its call and code, a refusal or a labelled error its own message', async () => {
const { printable, quietRead } = await import('../src/errors.mjs');
assert.equal(typeof printable, 'function', 'errors.mjs exports printable');
const path = `/x/${secret}/discord/seat/STOP`;
const native = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { code: 'ENOENT', syscall: 'open', path, errno: -2 });
assert.equal(printable(native), 'cannot open a discord file (ENOENT)');
const pathOnly = Object.assign(new Error(`EACCES ${path}`), { code: 'EACCES', path });
assert.equal(printable(pathOnly), 'cannot access a discord file (EACCES)');
const spawn = Object.assign(new Error(`spawn ${path} ENOENT`), { code: 'ENOENT', syscall: `spawn ${path}`, path });
assert.equal(printable(spawn), 'cannot spawn a discord file (ENOENT)');
const net = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:1'), { code: 'ECONNREFUSED', syscall: 'connect' });
assert.equal(printable(net), 'cannot connect (ECONNREFUSED)');
const odd = Object.assign(new Error(path), { code: `X ${path}`, syscall: path, path });
assert.equal(printable(odd), 'cannot access a discord file (UNKNOWN)');
const labelledRead = quietRead(Object.assign(new Error(path), { code: 'ENOENT', syscall: 'open', path }), 'token file');
assert.equal(printable(labelledRead), 'cannot read token file (ENOENT)');
assert.equal(printable(new DiscordError('STOP is present in discord/seat under the data root; remove it to run', 3)), 'STOP is present in discord/seat under the data root; remove it to run');
assert.equal(printable(new Error('plain refusal')), 'plain refusal');
for (const v of [native, pathOnly, spawn, odd]) assert.doesNotMatch(printable(v), /sk-test|NOT-A-SECRET/);
});
test('cli: a native fs error reaching the top-level handler prints its call and code, never the path, and exits 1', t => {
const { root } = scratch(t);
const base = join(root, secret, 'dep');
mkdirSync(base, { recursive: true });
const repo = makeRepo(base);
const dep = makeDeployment(base);
initializeControl(dep.dataRoot);
const run = (args) => spawnSync(process.execPath, [cli, ...args, '--config', dep.config, '--repo', repo], { encoding: 'utf8' });
// stop: the journal directory is a regular file, so ensureJournal's mkdir fails.
writeFileSync(dep.journalDir, 'not a directory');
let r = run(['stop', 'test-seat']);
assert.equal(r.status, 1, r.stderr);
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
quiet(r.stderr + r.stdout, root);
rmSync(dep.journalDir);
// run: the sessions directory is a regular file, so the claimed run's mkdir fails
// after the claim; the claim is released and the exit stays 1.
writeFileSync(join(dep.dataRoot, 'sessions'), 'not a directory');
r = run(['run', 'test-seat']);
assert.equal(r.status, 1, r.stderr);
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
quiet(r.stderr + r.stdout, root);
assert.equal(existsSync(lockPath(dep.journalDir)), false, 'the claim is released');
});
+1 -1
View File
@@ -75,6 +75,6 @@ test("notify: no dmRecipient, a non-0600 binding or token, or a missing binding
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && e.exitCode === 2 && /no dmRecipient/.test(e.message));
const loose = makeDeployment(makeRoot(), { dmRecipient: IDS.owner }, { tokenMode: 0o644 });
assert.throws(() => openDirect({ dataRoot: loose.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && /mode 0600/.test(e.message) && !e.message.includes(TOKEN));
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding not found/.test(e.message));
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding absent not found/.test(e.message) && !e.message.includes(none.dataRoot));
assert.equal(calls, 0);
});
@@ -0,0 +1,204 @@
// #1873 (redact6): a discord refusal names the file, field or binding, never a host path.
// The sentinel is a component of every scratch path, so a refusal that quotes a path
// quotes the sentinel too; each test also checks the scratch root and the label.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import fs, { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { syncBuiltinESMExports } from 'node:module';
import { readApprovals } from '../src/approvals.mjs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DiscordError } from '../src/errors.mjs';
import { bindingDataDir, bindingPath, loadDataRoot, readToken, resolveContextFiles, resolveToolRoots, validateBinding } from '../src/binding.mjs';
import { ensureJournal, lockPath, ownerPath, readInboxIds, recover, requestStop, turnPath, unlock, writeTurn } from '../src/journal.mjs';
import { gitPull, loadGitConfig } from '../src/git.mjs';
import { loadSetsparkConfig, readKey } from '../src/setspark.mjs';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r6-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const dir = join(root, secret);
mkdirSync(dir);
return { root, dir };
}
function labelled(label, root, Type = DiscordError) {
return error => {
assert.ok(error instanceof Type, error.stack);
assert.ok(error.message.includes(label), error.message);
assert.ok(!error.message.includes(root), error.message);
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
return true;
};
}
test('config not found names the config, not its path', t => {
const { root, dir } = scratch(t);
assert.throws(() => loadDataRoot(join(dir, 'config.json')), labelled('config not found', root));
});
test('token file refusals name the token file, not its path', t => {
const { root, dir } = scratch(t);
assert.throws(() => readToken({ tokenFile: join(dir, 'token') }), labelled('token file not found', root));
const short = join(dir, 'short');
writeFileSync(short, 'x', { mode: 0o600 });
assert.throws(() => readToken({ tokenFile: short }), labelled('token file does not hold a bot token', root));
const open = join(dir, 'open');
writeFileSync(open, 'x'.repeat(30), { mode: 0o644 });
assert.throws(() => readToken({ tokenFile: open }), labelled('token file must be mode 0600, is 0644', root));
});
test('a context file refusal names its index in context.files', t => {
const { root, dir } = scratch(t);
const repo = join(dir, 'repo');
mkdirSync(repo);
assert.throws(() => resolveContextFiles({ context: { files: [join(dir, 'abs.md')] } }, repo), labelled('context file must be repository-relative: context.files[0]', root));
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('missing context file: context.files[0]', root));
});
test('a tool root refusal names the root, not its path', t => {
const { root, dir } = scratch(t);
const binding = { tools: { roots: [{ name: 'docs', path: join(dir, 'docs'), write: false }] } };
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs does not exist', root));
mkdirSync(join(dir, 'data', 'docs'), { recursive: true });
binding.tools.roots[0].path = join(dir, 'data', 'docs');
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs overlaps the data root', root));
});
test('git and setspark key file refusals name the field', t => {
const { root, dir } = scratch(t);
const git = { branch: 'main', identity: 'sage', tokenFile: join(dir, 'gitea'), author: 'Sage <[email protected]>' };
assert.throws(() => loadGitConfig(git, 'tools.roots[0].git'), labelled('tools.roots[0].git.tokenFile: not found', root, Error));
const setspark = { baseUrl: 'https://setspark.example', keyFile: join(dir, 'key'), principal: 'sage' };
assert.throws(() => loadSetsparkConfig(setspark), labelled('setspark.keyFile: not found', root, Error));
});
for (const [name, label] of [['bot', 'discord/bot under the data root'], [secret, "the binding's journal directory"]]) {
test(`journal refusals name ${label}`, t => {
const { root, dir } = scratch(t);
const journal = join(dir, name);
ensureJournal(journal);
requestStop(journal, 'operator');
assert.throws(() => recover(journal), labelled(`STOP is present in ${label}`, root));
rmSync(join(journal, 'STOP'));
mkdirSync(lockPath(journal));
writeFileSync(ownerPath(journal), secret, { mode: 0o600 });
assert.throws(() => unlock(journal), labelled(`Inspect run.lock/owner.json in ${label} by hand`, root));
writeTurn(journal, 't1', {});
assert.throws(() => writeTurn(journal, 't1', {}), labelled('turn record already exists: t1', root));
});
}
// Review round (#1873): keys, names, read errors and git output that could carry a host path.
test('an unknown key that is not a plain name is described, never echoed; a plain one is still quoted', t => {
const { root, dir } = scratch(t);
const doc = { [join(dir, 'host')]: 1 };
assert.throws(() => validateBinding(doc), labelled('unknown key that is not a plain name', root));
assert.throws(() => loadGitConfig(doc, 'tools.roots[0].git'), labelled('tools.roots[0].git: unknown key that is not a plain name', root, Error));
assert.throws(() => loadSetsparkConfig(doc, 'setspark'), labelled('setspark: unknown key that is not a plain name', root, Error));
assert.throws(() => validateBinding({ colour: 1 }), labelled('unknown key "colour"', root));
});
test('an invalid binding name or turn id states the constraint, never the value, and keeps its exit', t => {
const { root, dir } = scratch(t);
for (const f of [bindingPath, bindingDataDir]) {
assert.throws(() => f(dir, join(dir, 'bad')), e => labelled('invalid binding name (lowercase letters, digits, ., _ or -, at most 64)', root)(e) && e.exitCode === 4);
}
assert.throws(() => turnPath(dir, join(dir, 'bad')), e => labelled('invalid turn id (letters, digits, ., _ or -, at most 128)', root)(e) && e.exitCode === 1);
});
test('an unreadable journal or approvals file, or a missing repository, names the file and code, never the path', t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'approvals.jsonl'), '{}\n', { mode: 0o000 });
writeFileSync(join(dir, 'inbox.jsonl'), '{}\n', { mode: 0o000 });
assert.throws(() => readApprovals(dir), e => labelled('cannot read approvals.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES' && !(e instanceof DiscordError));
assert.throws(() => readInboxIds(dir), e => labelled('cannot read inbox.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES');
assert.throws(() => resolveContextFiles({ context: { files: ['x'] } }, join(dir, 'missing')), e => labelled('cannot read the repository (ENOENT)', root, Error)(e) && e.code === 'ENOENT');
});
test('a git refusal drops stderr lines that carry a host path and a spawn failure names only its code', t => {
const { root, dir } = scratch(t);
const rootCfg = { real: dir, name: 'docs', git: { branch: 'main', identity: 'sage', tokenFile: join(dir, 'never-read'), author: { name: 'Sage', email: '[email protected]' } } };
let calls = 0;
const spawn = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: cannot access ${join(dir, 'repo')}\n` };
assert.throws(() => gitPull(rootCfg, { spawn }), e => {
assert.equal(e.reason, 'that root has no git');
assert.equal(e.message, e.reason);
assert.ok(!e.detail, String(e.detail));
return labelled(e.reason, root, Error)(e);
});
assert.equal(calls, 2);
// A repo-relative line survives: the detail still says what git said.
calls = 0;
const kept = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: not a git repository: .git\n` };
assert.throws(() => gitPull(rootCfg, { spawn: kept }), e => e.detail === 'fatal: not a git repository: .git');
const failed = () => ({ error: Object.assign(new Error(`spawn ${join(dir, 'git')} ENOENT`), { code: 'ENOENT' }) });
assert.throws(() => gitPull(rootCfg, { spawn: failed }), e => labelled('git could not start (ENOENT)', root, Error)(e));
});
// Re-check (#1873): a file removed between its check and its read surfaces a native
// error. Each site names the file by label and code; the error's path never reaches the
// message. `race` swaps one node:fs function so the target goes away right before the
// real call, which then fails with a genuine ENOENT that names the path.
function race(t, fn, target, remove) {
const original = fs[fn];
const swapped = function (p, ...rest) {
if (String(p) === target) remove();
return original.call(this, p, ...rest);
};
if (original.native) swapped.native = original.native;
fs[fn] = swapped;
syncBuiltinESMExports();
t.after(() => {
fs[fn] = original;
syncBuiltinESMExports();
});
}
test('a token file removed between its check and its read names the token file', t => {
const { root, dir } = scratch(t);
const tokenFile = join(dir, 'token');
writeFileSync(tokenFile, 'x'.repeat(30), { mode: 0o600 });
race(t, 'readFileSync', tokenFile, () => rmSync(tokenFile));
assert.throws(() => readToken({ tokenFile }), labelled('cannot read token file (ENOENT)', root, Error));
});
test('a context file removed between its check and its realpath names its index', t => {
const { root, dir } = scratch(t);
const repo = join(dir, 'repo');
mkdirSync(repo);
writeFileSync(join(repo, 'ok.md'), 'ok');
writeFileSync(join(repo, `${secret}.md`), 'x');
const target = join(repo, `${secret}.md`);
race(t, 'realpathSync', target, () => rmSync(target));
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('cannot read context file context.files[1] (ENOENT)', root, Error));
});
test('a tool root removed between its check and its realpath names the root', t => {
const { root, dir } = scratch(t);
const docs = join(dir, 'docs');
mkdirSync(docs);
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
race(t, 'realpathSync', docs, () => rmSync(docs, { recursive: true }));
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('cannot read tool root docs (ENOENT)', root, Error));
});
test('a data root removed between its check and its realpath names the data root', t => {
const { root, dir } = scratch(t);
const docs = join(dir, 'docs');
mkdirSync(docs);
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
const data = join(dir, 'data');
mkdirSync(data);
race(t, 'realpathSync', data, () => rmSync(data, { recursive: true }));
assert.throws(() => resolveToolRoots(binding, { dataRoot: data }), labelled('cannot read the data root (ENOENT)', root, Error));
});
test('a setspark key file removed between its check and its read names keyFile', t => {
const { root, dir } = scratch(t);
const keyFile = join(dir, 'key');
writeFileSync(keyFile, 'ssk_' + 'a'.repeat(40), { mode: 0o600 });
const config = loadSetsparkConfig({ baseUrl: 'https://setspark.example', keyFile, principal: 'sage' });
race(t, 'readFileSync', keyFile, () => rmSync(keyFile));
assert.throws(() => readKey(config), labelled('cannot read keyFile (ENOENT)', root, Error));
});
@@ -6,10 +6,13 @@ import { dirname, join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
const malformed = secret;
// #1873: the sentinel is a path component, so a refusal that quotes a path quotes it too.
function scratch(t) {
const root = mkdtempSync(join(tmpdir(), 'r3-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
return root;
const dir = join(root, secret);
mkdirSync(dir);
return dir;
}
function write(file, text = malformed) {
mkdirSync(dirname(file), { recursive: true });
@@ -21,6 +24,7 @@ function safe(label, ErrorType, properties = {}) {
assert.ok(error instanceof ErrorType);
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.ok(!error.message.includes(tmpdir()), error.message);
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
assert.doesNotMatch(error.stack, /sk-test/);
assert.equal(error.cause, undefined);
@@ -33,15 +37,15 @@ import { readApprovals } from '../src/approvals.mjs';
import { readInboxIds } from '../src/journal.mjs';
import extension from '../extension/tools.mjs';
import { TOOLS_ENV } from '../src/tools.mjs';
for (const [name, read, relative] of [
['config', (file) => loadDataRoot(file), 'config.json'],
['binding', (file) => loadBinding(file), 'binding.json'],
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
for (const [name, read, relative, label] of [
['config', (file) => loadDataRoot(file), 'config.json', 'config is not valid JSON'],
['binding', (file) => loadBinding(file), 'binding.json', 'binding binding is not valid JSON'],
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl', 'approvals.jsonl:2: not valid JSON'],
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl', 'inbox.jsonl:2: not valid JSON'],
]) test(`${name} JSON refusal omits parser bytes`, t => {
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
assert.throws(() => read(file, root), safe(label, DiscordError, { exitCode: 2 }));
});
test('tools environment JSON refusal names the config key without its bytes', () => {
const previous = process.env[TOOLS_ENV];
@@ -52,7 +56,7 @@ test('tools environment JSON refusal names the config key without its bytes', ()
test('config read refusal retains the filesystem code', t => {
const file = scratch(t);
assert.throws(() => loadDataRoot(file), error => {
safe(file, DiscordError, { exitCode: 2 })(error);
safe('config is not valid JSON', DiscordError, { exitCode: 2 })(error);
assert.match(error.message, /\(EISDIR\)$/);
return true;
});
+13 -11
View File
@@ -159,7 +159,7 @@ function readSkill(name, root, size) {
try { return lstatSync(path); } catch (e) { if (e.code === "ENOENT") throw skillNotEnabled(name); throw e; }
};
// scripts/skill.sh activate moves a real directory in, so a symlink here is refused, not followed.
if (!found(join(root, name)).isDirectory()) throw new BundleError(`skill ${name} isn't a plain directory under ${root}`);
if (!found(join(root, name)).isDirectory()) throw new BundleError(`skill ${name} isn't a plain directory under the enabled skills directory`);
if (!found(join(root, name, "SKILL.md")).isFile()) throw new BundleError(`skill ${name}: SKILL.md isn't a plain file`);
walk(join(root, name), "");
return files;
@@ -222,34 +222,36 @@ function readPersona(resolved, personaDir) {
throw new BundleError(`instance ${resolved.instance} has a persona, but the bundle has no persona directory`, "persona-invalid");
}
const path = join(personaDir, resolved.business, `${resolved.instance}.md`);
// #1873: a refusal names the file under the persona directory, never its host path.
const shown = `${resolved.business}/${resolved.instance}.md in the persona directory`;
let fd;
try {
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
} catch (e) {
if (e.code === "ENOENT" && digest === undefined) return null;
if (e.code === "ENOENT") throw new BundleError(`instance ${resolved.instance}: persona file ${path} doesn't exist`, "persona-invalid");
if (e.code === "ELOOP") throw new BundleError(`instance ${resolved.instance}: persona file ${path} is a symlink`, "persona-invalid");
throw new BundleError(`instance ${resolved.instance}: persona file ${path} can't be opened (${e.code})`, "persona-invalid");
if (e.code === "ENOENT") throw new BundleError(`instance ${resolved.instance}: persona file ${shown} doesn't exist`, "persona-invalid");
if (e.code === "ELOOP") throw new BundleError(`instance ${resolved.instance}: persona file ${shown} is a symlink`, "persona-invalid");
throw new BundleError(`instance ${resolved.instance}: persona file ${shown} can't be opened (${e.code})`, "persona-invalid");
}
let data;
try {
const stat = fstatSync(fd);
if (!stat.isFile()) throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't a regular file`, "persona-invalid");
if (stat.uid !== process.getuid()) throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't owned by uid ${process.getuid()}`, "persona-invalid");
if (stat.mode & 0o077) throw new BundleError(`instance ${resolved.instance}: persona file ${path} has group or other mode bits`, "persona-invalid");
if (!stat.isFile()) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't a regular file`, "persona-invalid");
if (stat.uid !== process.getuid()) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't owned by uid ${process.getuid()}`, "persona-invalid");
if (stat.mode & 0o077) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} has group or other mode bits`, "persona-invalid");
// Four bytes is the most one character takes in UTF-8.
if (stat.size > PERSONA_CHARS * 4) throw new BundleError(`instance ${resolved.instance}: persona file ${path} is over ${PERSONA_CHARS} characters`, "persona-invalid");
if (stat.size > PERSONA_CHARS * 4) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} is over ${PERSONA_CHARS} characters`, "persona-invalid");
data = readFileSync(fd);
} finally {
closeSync(fd);
}
if (sha256(data) !== digest) {
throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't the file persona.sha256 names`, "persona-mismatch");
throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't the file persona.sha256 names`, "persona-mismatch");
}
let text;
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't UTF-8 text`, "persona-invalid"); }
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't UTF-8 text`, "persona-invalid"); }
const fault = personaTextFault(text);
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${path} ${fault}`, "persona-invalid");
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} ${fault}`, "persona-invalid");
return { name: resolved.vars["persona.name"], sha256: digest, text };
}
// Between the contract and "## This session". The contract wins on a conflict, and the broker's
Loaded 100 of 166 files, more files were not shown because too many files have changed in this diff. Show more