From 030809e15a6c4e99623148564d8f9c60206c54ef Mon Sep 17 00:00:00 2001 From: Jason Woltje Date: Tue, 20 Oct 2026 15:10:09 -0500 Subject: [PATCH] row 202 (#1700): mosaic access list, grant and revoke (FED R5), human proof, atomic write, lock, receipt; 18 tests --- agents/quackerjack/work/queue-202/BUILD.md | 100 ++++++++ packages/cli/README.md | 23 ++ packages/cli/src/access.mjs | 266 ++++++++++++++++++++ packages/cli/src/cli.mjs | 10 + packages/cli/tests/access.test.mjs | 271 +++++++++++++++++++++ scripts/mosaic | 2 +- 6 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 agents/quackerjack/work/queue-202/BUILD.md create mode 100644 packages/cli/src/access.mjs create mode 100644 packages/cli/tests/access.test.mjs diff --git a/agents/quackerjack/work/queue-202/BUILD.md b/agents/quackerjack/work/queue-202/BUILD.md new file mode 100644 index 000000000..f494d5e8a --- /dev/null +++ b/agents/quackerjack/work/queue-202/BUILD.md @@ -0,0 +1,100 @@ +# 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. + +## 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`: 18 tests. 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: `/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) is stale. The next writer removes it and takes + the lock. This is the crash case: the holder died mid-write. +- 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; remove it by hand. + +Known limits, stated plainly: +1. Reclaim is not atomic. Two writers that both find the same stale lock can both reclaim it, and then one + can remove the other's new lock. The window opens only after a crash. The revision check still refuses + a second write that read the old revision, but two writers that both read the new revision can race. + Fixing it needs a lock that the kernel releases with the holder (`flock`). That is a decision for Sage. +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. + +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 stale lock from a dead pid is reclaimed and the write goes through": a child that has exited. +- "the lock is mode 0600 while held and removed after a refused write". + +## 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 `/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 | `holderAlive` always returns false (always reclaim) | "a live holder's lock refuses" | +| M6 | `holderAlive` always returns true (never reclaim) | "a stale lock from a dead pid is reclaimed" | +| 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" | + +Known gap, not killed by any test: a direct `writeFileSync` in place of the temp file and rename. No test +makes the rename fail. Adding one needs a seam in `writeAtomic`, which is not exported. Sage to decide +whether that goes in this row or a follow-up. + +## 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. diff --git a/packages/cli/README.md b/packages/cli/README.md index 2ddff0187..029ed632d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1009,6 +1009,29 @@ names no one; the restart itself needs Mos's go or Jason's hand. A vars or launc prints `no restart`, because the launcher reads it at its next launch check. The Console Settings page has the same read and write (`packages/webui`). +## Access (FED R5, #414) + +``` +mosaic access list --business ID [--json] +mosaic access grant --business ID --user ID --projects UUID|NAME,...|* --instances NAME,...|* --level collaborator [--expires ISO] --revision N +mosaic access revoke --business ID --user ID [--grant GID] --revision N +``` + +These edit `/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 second writer that finds the lock refuses at once. Each write appends one receipt line to +`/state/access-log.jsonl`. + +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 diff --git a/packages/cli/src/access.mjs b/packages/cli/src/access.mjs new file mode 100644 index 000000000..d9f29caed --- /dev/null +++ b/packages/cli/src/access.mjs @@ -0,0 +1,266 @@ +// `mosaic access list|grant|revoke` (federation design section 3.5, FED R5, #414). +// The human edits /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 /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 parameter; 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 [--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 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 }; +} + +// 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. +function writeAtomic(file, text) { + const dir = dirname(file); + const tmp = join(dir, `.access-${randomBytes(8).toString("hex")}.tmp`); + const fd = openSync(tmp, "wx", 0o600); + try { + writeSync(fd, text); + fsyncSync(fd); + } finally { + closeSync(fd); + } + renameSync(tmp, file); +} + +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, `.lock`, mode 0600. The file holds the holder's +// pid and a newline. A lock whose pid is no longer alive (ESRCH) is stale: the +// holder died mid-write, so the next writer removes it and takes the lock. A lock +// whose pid is alive, or that is empty or unreadable, refuses (fail closed). An +// empty lock is a holder that died between create and write; remove it by hand. +// A reused pid reads as alive, so that also refuses; remove the file by hand then. +const BUSY = "refused: the access file is being written by another command; try again"; + +function holderAlive(lock) { + let pid; + try { + pid = Number(readFileSync(lock, "utf8").trim()); + } catch { + return true; + } + if (!Number.isSafeInteger(pid) || pid < 1) return true; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return error.code !== "ESRCH"; + } +} + +function takeLock(lock) { + try { + return openSync(lock, "wx", 0o600); + } catch (error) { + if (error.code !== "EEXIST") throw error; + } + if (holderAlive(lock)) throw new CliError(BUSY, 3); + unlinkSync(lock); + try { + return openSync(lock, "wx", 0o600); + } catch (error) { + if (error.code === "EEXIST") throw new CliError(BUSY, 3); + throw error; + } +} + +export function withLock(file, fn) { + const lock = `${file}.lock`; + const fd = takeLock(lock); + try { + writeSync(fd, `${process.pid}\n`); + } finally { + closeSync(fd); + } + try { + return fn(); + } finally { + 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), context, configDir?, now? } +export async function accessCommand(args, io, deps) { + refuseInsideAgent(io.env); + 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)); + const context = deps.context; + + 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. + let checked; + try { + checked = checkAccess(next, context); + } catch (error) { + if (error instanceof BusinessError) throw new CliError(`refused: ${error.message}`, 2); + throw error; + } + writeAtomic(file, `${JSON.stringify(checked, null, 2)}\n`); + return { checked, revision, touched }; + }); + + 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`; +} diff --git a/packages/cli/src/cli.mjs b/packages/cli/src/cli.mjs index 10ba53e95..ad5af34ac 100644 --- a/packages/cli/src/cli.mjs +++ b/packages/cli/src/cli.mjs @@ -18,6 +18,7 @@ import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TAL import { limitsCommand, LIMITS_USAGE } from "./limits.mjs"; import { pluginsCommand, PLUGINS_USAGE } from "./plugins.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"; @@ -573,6 +574,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 }), + context: 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)); diff --git a/packages/cli/tests/access.test.mjs b/packages/cli/tests/access.test.mjs new file mode 100644 index 000000000..fc99b0ccd --- /dev/null +++ b/packages/cli/tests/access.test.mjs @@ -0,0 +1,271 @@ +// `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 { 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, + context: 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 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), /being written by another command/); + writeFileSync(`${s.file}.lock`, "not-a-pid\n"); + await refusal(run([...GRANT, "--revision", "1"], s.deps), /being written by another command/); + assert.equal(existsSync(`${s.file}.lock`), true); +}); + +test("a stale lock from a dead pid is reclaimed and the write goes through", async (t) => { + const s = setup(t); + const dead = spawnSync(process.execPath, ["-e", ""]); + writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 }); + await run([...GRANT, "--revision", "1"], s.deps); + assert.equal(s.read().revision, 2); + assert.equal(existsSync(`${s.file}.lock`), false); +}); + +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, + context: 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); +}); diff --git a/scripts/mosaic b/scripts/mosaic index 34944880c..e05ad3439 100755 --- a/scripts/mosaic +++ b/scripts/mosaic @@ -35,6 +35,6 @@ if [ "${1:-}" = business ]; then exec node "$REPO/packages/business/src/cli.mjs" "$@" fi case "${1:-}" in - usage|project|limits|inbox|decide|tasks|agents|trail|talk|stop|launches|bus|plugins|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|name|names|follow|threads|thread|access) exec node "$REPO/packages/cli/src/cli.mjs" "$@" ;; esac exec node "$REPO/packages/seat/src/cli.mjs" "$@"