row 202: FED R5, mosaic access list, grant and revoke (#1700)

Candidate quackerjack/fed-r5-r5 a74cd9f87 on c3d88840c, manifest of 6
paths (CANDIDATE.sha256). Duckworth APPROVE round 5, rev 3026 (#1700
c30184). C1 gate on Sage's slot by Quackerjack on the merge of a74cd9f87
onto a849d89b7 (3b86180b9), finished 01:56:24Z: cli suite 771/771, skip 0
(run 2; run 1 red on a too-long gate TMPDIR, harness only), every
scripts/test-*.sh rc 0 with Docker up, mutants M1-M13 killed by their
named tests. Landed onto 9c9732983 with the candidate's blobs and modes
for the 6 paths; c3d88840c..9c9732983 touches none of them.
This commit is contained in:
jason.woltje committed 2026-10-21 21:00:28 -05:00
1 parent 9c97329833
commit ddfb29af7c
6 files changed
+769 -1

No files matched your search

+127
View File
@@ -0,0 +1,127 @@
# Row 202 BUILD: FED R5, `mosaic access list|grant|revoke`
Quackerjack, 2026-10-20. Plan: `PLAN.md` (commit 2d0aa2937). Manifest: `MANIFEST.md` in this directory.
Brief: `docs/plans/2026-10-20_federation-r1-r2-r5.md`, "Brief: FED R5". Reviewer Duckworth. Gate owner Sage.
Review record: round 2 approved as duckworth-0041 (commit 2a1d17da7) on 48a3fb96e; viewer carry c204bef73 after it.
## What was built
- `packages/cli/src/access.mjs`: the three verbs, the human proof, the context (`accessContextFor`), the
atomic write, the lock and the receipt.
- `packages/cli/src/cli.mjs`: routes `access` to `accessCommand`.
- `scripts/mosaic`: sends `access` to the CLI.
- `packages/cli/README.md`: the verbs, the human proof, the lock, the `instances` limit, the removal order,
and the limits on peers and subjects.
- `packages/cli/tests/access.test.mjs`: 21 tests (round 2; 18 in round 1). Single file, green on this branch.
R1 (`packages/business/src/access.mjs`) is merged in unchanged at 80fe31071, the Duckworth-approved
candidate. 44e36fc4c, the next commit on that branch, only removes a stray pyc file.
## The lock file (not in PLAN.md; added at Sage's request)
- Path: `<configDir>/access.json.lock`, the access file's path plus `.lock`. Mode 0600.
- Created with `O_EXCL` (`wx`). The holder writes its pid and a newline into it, then holds it for the
read, the check, the temp write and the rename. It is removed in a `finally`.
- A second writer that finds the lock reads the pid. If the pid is alive (or the pid is reused by a live
process), it refuses with `refused: ... being written by another command; try again` (exit 3). Nothing
is written.
- A lock whose pid is gone (`kill(pid, 0)` returns ESRCH) also refuses, and nothing reclaims it. The
refusal names the path and the pid. A human checks that no write is running and removes the file. This
is the crash case: the holder died mid-write, and a human clears it.
- An empty lock, or one that is not a pid, refuses. Its holder may still be between create and write. A
holder that died in that gap leaves an empty lock; a human removes it.
Reclaim was removed at Sage's ruling (sage-1429, 10-20). It was the one design with a race, so the
race below is gone, not accepted.
Former limit, now removed: reclaim race. The old design let a writer take a stale lock by unlinking it
and retaking it. The case that lost: writers A and B both read the same dead pid N. A unlinks the lock
and takes it. B then unlinks A's live lock and takes its own. Both read the same revision, both write
it, and the second rename silently replaces the first. The revision check did not catch it, because both
had read the old revision before either wrote.
Known limits, stated plainly:
1. A dead holder's lock blocks every write until a human removes it. That is the fail-closed price of
no reclaim. The refusal says which pid and which path.
2. A reused pid reads as alive, so a stale lock refuses. The fix is to remove the file by hand.
3. The lock is per access file. It does not cover the bus's own reads.
4. Crash-safety of the write path: the temp file is removed on any failure before or at the rename. The
directory is not fsynced after the rename, so a power loss right after a rename can lose the rename.
The bytes are never mixed.
Tests for the lock (all in `access.test.mjs`):
- "a live holder's lock refuses and leaves the file and the lock alone": pid = this process.
- "an empty or unreadable lock refuses": empty file, and `not-a-pid`.
- "a lock left by a dead pid refuses, names the pid, and is not reclaimed": a child that has exited. The
file, the lock and the receipt are unchanged.
- "the lock is mode 0600 while held and removed after a refused write".
Tests for the write path and the refusals (added in this round):
- "a failed rename leaves the old bytes, removes the temp file, and writes no receipt": the fs seam
(`deps.fs`) makes `renameSync` throw. Checks the old bytes, no `.access-*.tmp`, no lock, no receipt.
- "a group-writable access file refuses with exit 3, names the file, and changes nothing": a 0664 file.
Exit 3, the file path in the message, nothing written.
- "an agent or tester caller is refused before the context or the access file is read": `contextFor`
throws if called, and the file is 0664. The refusal must come first.
## The human proof, and the bus behavior behind test 10
The proof is one `project.list` call through `humanTransport`. The bus gates it with `#human`
(`packages/bus/src/broker.mjs`, the `project.list` case at :2319, and `#human` at :377). The call is in
one function, `proveHuman` in `access.mjs`, for R4 to swap.
Test 10 runs the real `human-cli.mjs` on a scratch bus from agent ancestry (`CLAUDECODE=1` in the child's
env). The bus refuses. The client reports `outcome-unknown`, not `human-required`, because the bus closes
the socket on a failed proof and `packages/bus/src/client.mjs:32-33` maps a closed socket to
`outcome-unknown`. Exit 1 (`busExit`). The test pins that code and asserts the access file and the
receipt are unchanged.
This is a bus behavior outside R5. It is not changed here. Gizmoduck and Gander own the verifier (row 196).
Until it changes, a human who hits the agent refusal sees `outcome-unknown`.
## Context
`accessContextFor(system, env)`:
- `providers`: `system.console.sso.providers[].id`. Row 192's shape (packages/webui/src/sso-config.mjs on
row 192's candidate).
- `ssoSubjects`: each provider's `subjects[].sub`, paired with the provider id. Gizmoduck's design says
`console.sso.subjects`; Sage ruled that is shorthand for this (erratum).
- `peers`: `[]` until row 203 adds the federation key. A grant that names a peer home refuses.
- `businesses`: `loadBusiness` for each `<configDir>/businesses/*.json`; `human` and the role names as
`instances`. A business file that fails to load fails the command.
No `console` key gives empty providers and subjects, so every grant naming a subject refuses. The
`checkConsole` check from row 192 is not on this branch yet. R5 rebases onto row 192's landing, and then
reads `console` through `checkConsole`.
## Mutant list (one per refusal; run on Sage's go, before review)
| # | Mutant | Expected kill |
|---|---|---|
| M1 | remove the `refuseInsideAgent` call | "an agent caller is refused before the bus is asked" |
| M2 | remove the `--tester` check | "--tester is refused on every verb" |
| M3 | the proof's error is swallowed | "a bus that refuses the human proof blocks the write" |
| M4 | the revision compare is removed | "a stale revision refuses with revision-conflict" |
| M5 | `takeLock` unlinks a dead-pid lock and retakes it (the reclaim this round removed) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M6 | `pidAlive` always returns true (a dead pid reads as live) | "a lock left by a dead pid refuses, names the pid, and is not reclaimed" |
| M11 | `writeAtomic` writes straight to access.json, no temp file or rename | "a failed rename leaves the old bytes, removes the temp file, and writes no receipt" |
| M12 | the BusinessError catch in `accessCommand` is removed | "a group-writable access file refuses with exit 3, names the file, and changes nothing" |
| M13 | `contextFor()` is called before the agent and tester refusals | "an agent or tester caller is refused before the context or the access file is read" |
| M7 | `checkAccess` is skipped before the write | "a grant naming an unknown instance is refused before any write" |
| M8 | the receipt append is removed | "grant writes ... a receipt line" |
| M9 | a project name is written as given (no lookup) | "a project name resolves to its id" |
| M10 | the lock is not taken | "a live holder's lock refuses" |
Round 2 closed the direct-write gap: `writeAtomic` takes the fs calls through `deps.fs` (default: the real
`node:fs` functions), and a rename that throws is tested (M11 is the direct-write mutant). Not yet run:
the mutant batch, the cli suite and `scripts/test-*.sh`. All wait on Sage's slot.
Round 2 also changed the file's refusal path: a `BusinessError` from `loadAccess` or `loadBusiness` now
exits 3 with the file path and key path on stderr, no stack trace (Duckworth B1, Sage sage-1437).
## Not done in this row
- `--peers` and the federation key: row 203.
- `access.reload`: row R4.
- Gizmoduck's `console.sso.subjects` name: erratum, the flattened shape is used.
- The suite (`packages/cli` and every `scripts/test-*.sh`) and the mutant batch: on Sage's gate.
+29
View File
@@ -1061,6 +1061,35 @@ A status line shows `revoking:` with the tags of a revoke still in progress.
Enrollment itself is a `host-enroll` grant the chief raises and the human approves, not a CLI
command. A usage error exits 4 and a bus refusal 2.
## Access (FED R5, #414)
```
mosaic access list --business ID [--json]
mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N
mosaic access revoke --business ID --user ID [--grant GID] --revision N
```
These edit `<configDir>/access.json` (design section 3.5). The human proof is one `project.list`
call through the human transport: the bus gates it with `#human` (`packages/bus/src/broker.mjs`,
the `project.list` case and `#human`). An agent, or `--tester`, is refused before any write. Each
write runs the file's checks on the new document first, bumps the revision by one, and renames
atomically. A `--revision` that is not the file's current revision refuses with `revision-conflict`.
A writer that finds the lock refuses at once. A lock whose pid is gone, or that is empty or
unreadable, also refuses, and nothing reclaims it: check that no write is running, then remove
`<configDir>/access.json.lock` by hand. Each write appends one receipt line to
`<dataRoot>/state/access-log.jsonl`.
The command runs as the instance's uid. `access.json` must belong to that uid and must not be
writable by group or other. If it is not, or a business file fails to load, the command refuses
with exit 3, names the file and the key path, and writes nothing.
A grant's `instances` is the real limit. The granted instance runs with the owner's role, so a
collaborator's message drives it with that authority. To cut a user off, revoke first: it acts at
once. Removing a provider from `console.sso` takes effect only after a Console restart.
Peer homes are not read yet, so a grant that names a peer home refuses until row 203 adds the
federation key. A grant that names a sign-in subject needs `console.sso` in the system config.
## Token usage
`mosaic usage [--business ID] [--json] [--tester]` reads the bus's accounting, without
+288
View File
@@ -0,0 +1,288 @@
// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414).
// The human edits <configDir>/access.json here. Each write is checked against the
// instance's context before it lands, bumps the revision by one, and appends one
// receipt line to <dataRoot>/state/access-log.jsonl. The human proof is one call
// through humanTransport: a bus that does not see a human shell refuses it, so
// no agent and no tester can write. The context (providers, peers, businesses,
// ssoSubjects) comes in as a function; the caller decides where it comes from.
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, openSync, writeSync, fsyncSync, closeSync, existsSync, unlinkSync } from "node:fs";
import { randomBytes } from "node:crypto";
import { join, dirname } from "node:path";
import { BusinessError, accessFilePath, checkAccess, configDir, loadAccess, loadBusiness, LEVELS } from "../../business/src/index.mjs";
import { rolesDir } from "./config.mjs";
import { CliError } from "./errors.mjs";
import { refuseInsideAgent } from "./transport.mjs";
export const ACCESS_USAGE = [
"mosaic access list --business ID [--json]",
"mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator|viewer [--expires ISO] --revision N",
"mosaic access revoke --business ID --user ID [--grant GID] --revision N",
].join("\n");
// The help text. A grant's `instances` is the real limit: the granted instance
// runs with the owner's role, so a collaborator drives it with that authority.
// Removing a user acts at once. Removing a provider from console.sso waits for a
// Console restart, so take the users out of access.json first. Peer homes are
// empty until the federation key lands (row 203), so a grant naming a peer
// home refuses until then. A grant naming a subject needs console.sso.
export const ACCESS_HELP = `${ACCESS_USAGE}
A grant gives a user exactly what the granted instances can do. The instances
list is the real limit: a collaborator's message drives a granted instance with
the owner's role.
To cut a user off, revoke them here first. It acts at once. Removing a provider
from console.sso takes effect only after a Console restart, so do that second.
Peer homes are not read yet (row 203 adds the federation key), so a grant that
names a peer home refuses. A grant that names a sign-in subject needs console.sso
in the system config; without it every such grant refuses.
The command runs as the instance's uid: access.json must belong to it and must
not be writable by group or other, or the command refuses (exit 3) and writes nothing.`;
// The instance's context for checkAccess, read from the system config and the business
// files. providers and ssoSubjects come from console.sso (row 192's shape: each provider
// has an id and a subjects list). peers is empty until row 203. A business file that
// fails to load fails the whole command.
export function accessContextFor(system, env) {
const providers = system.console?.sso?.providers ?? [];
const ssoSubjects = providers.flatMap((p) => p.subjects.map((s) => ({ provider: p.id, sub: s.sub })));
const dir = join(configDir(env), "businesses");
const businesses = {};
if (existsSync(dir)) {
for (const file of readdirSync(dir).filter((f) => f.endsWith(".json")).sort()) {
const id = file.slice(0, -".json".length);
const doc = loadBusiness(id, { dir: configDir(env), rolesDir: rolesDir(env) });
businesses[id] = { human: doc.human, instances: Object.keys(doc.roles) };
}
}
return { providers: providers.map((p) => p.id), peers: [], businesses, ssoSubjects };
}
// Every verb needs --business: the human proof runs through that business's bus.
// --tester is refused outright on every verb, before any bus call.
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === "--json" || a === "--tester") out.flags.add(a);
else if (a.startsWith("--")) {
if (i + 1 >= args.length || args[i + 1].startsWith("--")) throw new CliError(`${a} needs one value\n${ACCESS_USAGE}`, 4);
out.values[a] = args[++i];
} else out.positional.push(a);
}
return out;
}
const list = (value) => (value === "*" ? ["*"] : value.split(",").filter(Boolean));
function readCurrent(file, context) {
if (!existsSync(file)) return { document: { accessVersion: 1, revision: null, users: [], grants: [] }, revision: 0 };
const document = loadAccess(file, context);
return { document, revision: document.revision };
}
// The file calls the write path makes. A test passes a stand-in through deps.fs.
const realFs = { openSync, writeSync, fsyncSync, closeSync, renameSync, unlinkSync };
// Atomic write: a temp file in the same directory, 0600, fsynced, then renamed over
// the old file. A reader sees the old bytes or the new bytes, never a mix. A failure
// before or at the rename removes the temp file and leaves the old bytes in place.
function writeAtomic(file, text, fs = realFs) {
const tmp = join(dirname(file), `.access-${randomBytes(8).toString("hex")}.tmp`);
try {
const fd = fs.openSync(tmp, "wx", 0o600);
try {
fs.writeSync(fd, text);
fs.fsyncSync(fd);
} finally {
fs.closeSync(fd);
}
fs.renameSync(tmp, file);
} catch (error) {
try {
fs.unlinkSync(tmp);
} catch {
// Already gone: the temp file was never created.
}
throw error;
}
}
function receipt(dataRoot, line) {
const dir = join(dataRoot, "state");
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, "access-log.jsonl"), `${JSON.stringify(line)}\n`, { mode: 0o600 });
}
// Two writers never interleave: the read, the check and the rename happen under an
// exclusive lock file, `<access.json>.lock`, mode 0600. The file holds the holder's
// pid and a newline. A lock whose holder is alive refuses. A lock that is empty,
// unreadable, or whose pid is gone also refuses, and nothing reclaims it: a human
// removes it once no write is running. Fail closed, with no reclaim race.
const BUSY = "refused: the access file is being written by another command; try again";
function lockPid(lock) {
let text;
try {
text = readFileSync(lock, "utf8");
} catch {
return null;
}
const pid = Number(text.trim());
return Number.isSafeInteger(pid) && pid >= 1 ? pid : null;
}
function pidAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error.code !== "ESRCH";
}
}
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`;
}
function takeLock(lock, fs) {
try {
return fs.openSync(lock, "wx", 0o600);
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
throw new CliError(lockRefusal(lock), 3);
}
export function withLock(file, fn, fs = realFs) {
const lock = `${file}.lock`;
const fd = takeLock(lock, fs);
try {
fs.writeSync(fd, `${process.pid}\n`);
} finally {
fs.closeSync(fd);
}
try {
return fn();
} finally {
fs.unlinkSync(lock);
}
}
// The one human-proof call: project.list, which the bus gates with #human
// (broker.mjs:2319, and #human at :377). It also returns the rows that resolve
// project names. Row R4 swaps this call for access.reload.
async function proveHuman(transport, business) {
return transport(business)("project.list", {});
}
// Resolve a --projects item: "*" and UUIDs pass through, anything else is a project
// name looked up in project.list at grant time (the id is what gets written).
function resolveProjects(items, rows) {
return items.map((item) => {
if (item === "*" || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(item)) return item;
const hits = rows.filter((r) => r.name === item);
if (hits.length !== 1) throw new CliError(`refused: project-not-found: no project named ${item} in this business (or the name is ambiguous)`, 2);
return hits[0].id;
});
}
// deps: { system: { dataRoot }, transport(business) -> call(verb, args), contextFor() -> context, configDir?, now?, fs? }
export async function accessCommand(args, io, deps) {
refuseInsideAgent(io.env);
try {
return await accessVerb(args, io, deps);
} catch (error) {
// A file or business doc the checks refuse to load (group-writable, a wrong uid, a
// hand edit) is a refusal with exit 3 and the file and key path, not a crash.
if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 3);
throw error;
}
}
async function accessVerb(args, io, deps) {
const parsed = parse(args);
const sub = parsed.positional[0];
if (!["list", "grant", "revoke"].includes(sub) || parsed.positional.length !== 1) throw new CliError(ACCESS_HELP, 4);
if (parsed.flags.has("--tester")) throw new CliError("refused: access writes take the human's shell, not --tester", 3);
const business = parsed.values["--business"];
if (!business) throw new CliError(`--business is required\n${ACCESS_USAGE}`, 4);
const rows = await proveHuman(deps.transport, business);
const file = deps.file ?? accessFilePath(deps.configDir ?? configDir(io.env));
// Built after the human proof, so a refused caller never reads the business files.
const context = deps.contextFor();
if (sub === "list") {
const { document } = readCurrent(file, context);
io.stdout.write(parsed.flags.has("--json") ? `${JSON.stringify(document, null, 2)}\n` : formatList(document));
return;
}
const expected = parsed.values["--revision"];
if (!/^\d+$/.test(expected ?? "")) throw new CliError(`--revision takes the revision you read\n${ACCESS_USAGE}`, 4);
const user = parsed.values["--user"];
if (!user) throw new CliError(`${sub} needs --user\n${ACCESS_USAGE}`, 4);
const result = withLock(file, () => {
const { document, revision } = readCurrent(file, context);
if (Number(expected) !== revision) {
throw new CliError(`refused: revision-conflict: the access file is at revision ${revision}, not ${expected}; run list again`, 3);
}
let next;
let touched;
if (sub === "grant") {
const level = parsed.values["--level"];
if (!parsed.values["--projects"] || !parsed.values["--instances"] || level === undefined) {
throw new CliError(`grant needs --projects, --instances and --level\n${ACCESS_USAGE}`, 4);
}
if (!LEVELS.includes(level)) throw new CliError(`--level takes ${LEVELS.join(", ")}`, 2);
const id = `g-${randomBytes(4).toString("hex")}`;
const grant = {
id,
user,
business,
projects: resolveProjects(list(parsed.values["--projects"]), rows),
instances: list(parsed.values["--instances"]),
level,
};
if (parsed.values["--expires"] !== undefined) grant.expires = parsed.values["--expires"];
touched = [id];
next = { ...document, revision: revision + 1, grants: [...document.grants, grant] };
} else {
const only = parsed.values["--grant"];
const drop = (g) => g.user === user && g.business === business && (only === undefined || g.id === only);
touched = document.grants.filter(drop).map((g) => g.id);
if (touched.length === 0) throw new CliError(`refused: no grant for ${user} at ${business} to revoke`, 2);
next = { ...document, revision: revision + 1, grants: document.grants.filter((g) => !drop(g)) };
}
// Every check runs on the new document before a byte is written.
const checked = checkAccess(next, context);
writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`, deps.fs);
return { checked, revision, touched };
}, deps.fs);
receipt(deps.system.dataRoot, {
at: (deps.now ?? (() => new Date()))().toISOString(),
verb: sub,
business,
user,
actor: "human",
revisionBefore: result.revision,
revisionAfter: result.checked.revision,
grants: result.touched,
});
io.stdout.write(`access ${sub}: ${result.touched.join(", ")} at ${business}, revision ${result.checked.revision}\n`);
}
function formatList(doc) {
const lines = [`access file revision ${doc.revision ?? "none"}`];
for (const u of doc.users) lines.push(`user ${u.id} home ${u.home}`);
for (const g of doc.grants) {
lines.push(`grant ${g.id} user ${g.user} business ${g.business} projects ${g.projects.join(",")} instances ${g.instances.join(",")} level ${g.level}${g.expires ? ` expires ${g.expires}` : ""}`);
}
return `${lines.join("\n")}\n`;
}
+10
View File
@@ -20,6 +20,7 @@ import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
import { hostsCommand, HOSTS_USAGE } from "./hosts.mjs";
import { instancesCommand, INSTANCES_USAGE } from "./instances.mjs";
import { accessCommand, accessContextFor } from "./access.mjs";
import { createChannelCaller } from "../../plugins/src/channel.mjs";
import { PluginError } from "../../plugins/src/kinds.mjs";
import { CliError } from "./errors.mjs";
@@ -577,6 +578,15 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === "project") return ["create", "list"].includes(rest[0]) ? projectBusCommand(rest, io, (p) => channelCallFor(p, io, deps)) : projectCommand(rest, io, deps, (p) => callFor(p, io, deps));
if (verb === "threads") return threadsCommand(rest, io, (p) => channelCallFor(p, io, deps));
if (verb === "thread") return threadCommand(rest, io, (p) => channelCallFor(p, io, deps), formatMessage);
if (verb === "access") {
const system = deps.system ?? loadSystem({ env: io.env });
return accessCommand(rest, io, {
system,
transport: (b) => humanTransport({ socket: socketPath(system.dataRoot), business: b, env: io.env }),
contextFor: () => deps.accessContext ?? accessContextFor(system, io.env),
now: deps.now,
});
}
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
+314
View File
@@ -0,0 +1,314 @@
// `mosaic access` (FED R5, #414). Every write goes to a scratch config dir and a
// scratch data root. The success paths run on a stand-in human CLI (fakeCli).
// The one test that runs the real human-cli.mjs does it on a scratch bus from
// agent ancestry, so the bus must refuse, and nothing is written.
import test from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import * as nodeFs from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { startBroker } from "../../bus/src/runtime.mjs";
import { accessCommand, accessContextFor, withLock } from "../src/access.mjs";
import { CliError } from "../src/errors.mjs";
import { socketPath } from "../src/config.mjs";
import { HUMAN_CLI, humanTransport } from "../src/transport.mjs";
import { fakeCli, humanEnv, io, tmp, fixture } from "./helpers.mjs";
import { systemFor, writeJson } from "../../business/tests/helpers.mjs";
const PROJECT = "3f1c2a64-0d8e-4b7a-9c55-1a2b3c4d5e6f";
const ROWS = [{ id: PROJECT, name: "hub", repo: null }];
const CONTEXT = {
providers: ["authentik"],
peers: [],
businesses: { acme: { human: "jason", instances: ["coder"] } },
ssoSubjects: [{ provider: "authentik", sub: "jason-sub" }],
};
const DOC = {
accessVersion: 1,
revision: 1,
users: [{ id: "alice", home: "local", subjects: [{ provider: "authentik", sub: "alice-sub" }] }],
grants: [],
};
// The stand-in human CLI. It answers project.list with ROWS, or refuses like a bus
// that sees no human shell when FAKE_MODE is "refuse".
const BODY = `
import { readFileSync, writeFileSync } from "node:fs";
const input = JSON.parse(readFileSync(0, "utf8"));
writeFileSync(process.env.FAKE_LOG, JSON.stringify(input));
if (process.env.FAKE_MODE === "refuse") { process.stderr.write("human-required\\n"); process.exit(3); }
process.stdout.write(JSON.stringify(${JSON.stringify(ROWS)}));
`;
function setup(t, { mode = "ok", doc = DOC } = {}) {
const root = tmp(t, "mosaic-access-");
const config = join(root, "config");
mkdirSync(config, { recursive: true });
const dataRoot = join(root, "data");
const log = join(root, "fake.log");
const cli = fakeCli(root, BODY);
const env = humanEnv({ FAKE_LOG: log, FAKE_MODE: mode });
const file = join(config, "access.json");
if (doc) writeFileSync(file, JSON.stringify(doc, null, 2), { mode: 0o600 });
const deps = {
system: { dataRoot },
configDir: config,
contextFor: () => CONTEXT,
now: () => new Date("2026-10-20T20:00:00Z"),
transport: (business) => humanTransport({ socket: join(root, "bus.sock"), business, env, cli, timeoutMs: 5000 }),
};
const receipts = () => (existsSync(join(dataRoot, "state", "access-log.jsonl")) ? readFileSync(join(dataRoot, "state", "access-log.jsonl"), "utf8") : "");
return { root, config, file, deps, env, log, receipts, read: () => JSON.parse(readFileSync(file, "utf8")) };
}
const GRANT_BASE = ["grant", "--business", "acme", "--user", "alice"];
const GRANT = [...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "collaborator"];
async function run(args, deps, env = humanEnv()) {
const i = io(env);
await accessCommand(args, i, deps);
return i.out.text;
}
async function refusal(promise, pattern) {
await assert.rejects(promise, (e) => e instanceof CliError && pattern.test(e.message));
return promise.catch((e) => e);
}
test("list prints the revision and the users", async (t) => {
const s = setup(t);
const out = await run(["list", "--business", "acme"], s.deps);
assert.match(out, /access file revision 1/);
assert.match(out, /user alice home local/);
});
test("grant writes one revision up, a receipt line, and the id in the file", async (t) => {
const s = setup(t);
const out = await run([...GRANT, "--revision", "1"], s.deps);
assert.match(out, /access grant: g-[0-9a-f]{8} at acme, revision 2/);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.equal(doc.grants.length, 1);
assert.deepEqual(doc.grants[0].projects, [PROJECT]);
const [line] = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.equal(line.verb, "grant");
assert.equal(line.revisionBefore, 1);
assert.equal(line.revisionAfter, 2);
assert.equal(line.actor, "human");
});
test("a viewer grant writes the viewer level, one revision up", async (t) => {
const s = setup(t);
await run([...GRANT_BASE, "--projects", PROJECT, "--instances", "coder", "--level", "viewer", "--revision", "1"], s.deps);
const doc = s.read();
assert.equal(doc.revision, 2);
assert.equal(doc.grants[0].level, "viewer");
});
test("a second write appends a second receipt and leaves the first intact", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const lines = s.receipts().trim().split("\n").map((l) => JSON.parse(l));
assert.deepEqual(lines.map((l) => l.verb), ["grant", "revoke"]);
assert.equal(lines[0].revisionAfter, 2);
});
test("a stale revision refuses with revision-conflict and writes nothing", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /revision-conflict/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts().trim().split("\n").length, 1);
});
test("a live holder's lock refuses and leaves the file and the lock alone", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, `${process.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${process.pid}\n`);
assert.equal(readdirSync(s.config).filter((f) => f.startsWith(".access-")).length, 0);
});
test("an empty or unreadable lock refuses, since its holder may still be writing", async (t) => {
const s = setup(t);
writeFileSync(`${s.file}.lock`, "");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
writeFileSync(`${s.file}.lock`, "not-a-pid\n");
await refusal(run([...GRANT, "--revision", "1"], s.deps), /is unreadable or not a pid/);
assert.equal(existsSync(`${s.file}.lock`), true);
});
test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", async (t) => {
const s = setup(t);
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`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
});
test("a failed rename leaves the old bytes, removes the temp file, and writes no receipt", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const fs = { ...nodeFs, renameSync: () => { throw Object.assign(new Error("injected rename failure"), { code: "EXDEV" }); } };
await assert.rejects(run([...GRANT, "--revision", "1"], { ...s.deps, fs }), /injected rename failure/);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.deepEqual(readdirSync(s.config).filter((f) => f.startsWith(".access-")), []);
assert.equal(existsSync(`${s.file}.lock`), false);
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file, 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(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("an agent or tester caller is refused before the context or the access file is read", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const deps = { ...s.deps, contextFor: () => { throw new Error("context was read"); } };
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
await refusal(run([...GRANT, "--revision", "1"], deps, env), /human shell/);
await refusal(run(["list", "--business", "acme", "--tester"], deps), /not --tester/);
});
test("the lock is mode 0600 while held and removed after a refused write", async (t) => {
const s = setup(t);
let mode;
withLock(s.file, () => {
mode = statSync(`${s.file}.lock`).mode & 0o777;
});
assert.equal(mode, 0o600);
assert.equal(existsSync(`${s.file}.lock`), false);
await refusal(run([...GRANT, "--revision", "9"], s.deps), /revision-conflict/);
assert.equal(existsSync(`${s.file}.lock`), false);
});
test("a grant naming an unknown instance is refused before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const bad = [...GRANT_BASE, "--projects", PROJECT, "--instances", "nobody", "--level", "collaborator", "--revision", "1"];
await refusal(run(bad, s.deps), /refused: /);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("a project name resolves to its id at grant time", async (t) => {
const s = setup(t);
const args = [...GRANT_BASE, "--projects", "hub", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await run(args, s.deps);
assert.deepEqual(s.read().grants[0].projects, [PROJECT]);
});
test("an unknown project name refuses before any write", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const args = [...GRANT_BASE, "--projects", "nope", "--instances", "coder", "--level", "collaborator", "--revision", "1"];
await refusal(run(args, s.deps), /project-not-found/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("revoke removes the user's grants and leaves a revision behind", async (t) => {
const s = setup(t);
await run([...GRANT, "--revision", "1"], s.deps);
await run(["revoke", "--business", "acme", "--user", "alice", "--revision", "2"], s.deps);
const doc = s.read();
assert.equal(doc.revision, 3);
assert.deepEqual(doc.grants, []);
});
test("revoke of a user with no grant refuses and writes nothing", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
await refusal(run(["revoke", "--business", "acme", "--user", "alice", "--revision", "1"], s.deps), /no grant/);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("an agent caller is refused before the bus is asked", async (t) => {
const s = setup(t);
const before = readFileSync(s.file, "utf8");
const env = { ...humanEnv(), MOSAIC_RUN_ID: "run-1" };
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps, env), /human shell/);
assert.equal(err.exitCode, 3);
assert.equal(existsSync(s.log), false);
assert.equal(readFileSync(s.file, "utf8"), before);
});
test("--tester is refused on every verb before the bus is asked", async (t) => {
const s = setup(t);
for (const verb of [["list"], [...GRANT, "--revision", "1"], ["revoke", "--user", "alice", "--revision", "1"]]) {
const args = [...verb, "--business", "acme", "--tester"];
const err = await refusal(run(args, s.deps), /not --tester/);
assert.equal(err.exitCode, 3);
}
assert.equal(existsSync(s.log), false);
});
test("a bus that refuses the human proof blocks the write", async (t) => {
const s = setup(t, { mode: "refuse" });
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /human-required/);
assert.equal(err.exitCode, 3);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
});
test("the context reads console.sso subjects and the business files, and peers stay empty", async (t) => {
const root = tmp(t, "mosaic-access-ctx-");
const f = fixture(root, "acme");
const system = { console: { sso: { providers: [{ id: "authentik", subjects: [{ sub: "jason-sub" }] }] } } };
const ctx = accessContextFor(system, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(ctx.providers, ["authentik"]);
assert.deepEqual(ctx.ssoSubjects, [{ provider: "authentik", sub: "jason-sub" }]);
assert.deepEqual(ctx.peers, []);
assert.equal(ctx.businesses.acme.human, "jason");
assert.ok(Array.isArray(ctx.businesses.acme.instances) && ctx.businesses.acme.instances.length > 0);
const bare = accessContextFor({}, { MOSAIC_CONFIG: join(root, "config", "config.json") });
assert.deepEqual(bare.ssoSubjects, []);
assert.deepEqual(bare.providers, []);
});
// Real human-cli.mjs on a scratch bus, run from agent ancestry. The proof must fail.
test("the real human-cli, run from agent ancestry, refuses and writes nothing", async (t) => {
const root = tmp(t, "mosaic-access-real-");
const f = fixture(root, "acme");
const config = join(root, "config");
const bus = await startBroker({
dataRoot: f.dataRoot,
businesses: { acme: { id: "acme", human: "jason", arbiters: { technical: "coder", delivery: "coder" }, roles: { coder: { authority: { withinRole: [], crossRole: [] } } } } },
organization: { dir: config, rolesDir: f.roles, system: systemFor(root) },
});
t.after(() => bus.close());
writeJson(join(config, "access.json"), DOC);
const file = join(config, "access.json");
const before = readFileSync(file, "utf8");
const agentEnv = { ...humanEnv(), CLAUDECODE: "1" };
const deps = {
system: { dataRoot: f.dataRoot },
configDir: config,
contextFor: () => CONTEXT,
transport: (business) => humanTransport({ socket: socketPath(f.dataRoot), business, env: agentEnv, cli: HUMAN_CLI, timeoutMs: 10000 }),
};
// The bus closes the socket on a failed human proof, so the client reports
// outcome-unknown (client.mjs:32-33), not human-required. That is a bus behavior
// outside R5 (BUILD.md). The test pins it, and nothing is written.
const err = await refusal(run([...GRANT, "--revision", "1"], deps), /^outcome-unknown/);
assert.equal(err.code, "outcome-unknown");
assert.equal(err.exitCode, 1);
assert.equal(readFileSync(file, "utf8"), before);
assert.equal(existsSync(join(f.dataRoot, "state", "access-log.jsonl")), false);
});
+1 -1
View File
@@ -41,6 +41,6 @@ if [ "${1:-}" = library ] && [ "${2:-}" = survey ]; then
exec node "$REPO/packages/library/src/survey.mjs" "$@"
fi
case "${1:-}" in
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|name|names|follow|threads|thread) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|credentials|hosts|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;;
esac
exec node "$REPO/packages/seat/src/cli.mjs" "$@"