Files
stack/packages/cli/src/credentials.mjs
T
jason.woltje f5753c0e8d host access: agent host sudo through the credential store, host.run over broker.sock (row 210, #1707)
Blobs of Gizmoduck's r5 0485384e9 on origin/gizmoduck/row210-r5 (base
ad95c888c), the 23 manifest paths, manifest 688d38b9. Duckworth approve
round 2 (rev 2592) and round 3 (comment 29851, rev 2614). Round 3 is a
rebase-only round over row 237 (d7d943199): broker.mjs is a clean 3-way
merge (610af0c4), the 210 hunks sit away from 237's insert, and range-diff
against r4 c43008749 is all equal.

Mutants 52 of 53 killed on the merged tree. M22b is equivalent (rev 2584);
its coverage moves to row 235. Preflight singles: seat sandbox 10/0, bus
project-runtime 3/0. Gate on ad95c888c plus these paths: 15 package suites
2796 pass 0 fail, 10 scripts/test-*.sh suites 429 pass 0 fail (plus 156 node
tests in the queue suite). Refactor moved to 9d165be8c since, two briefs
touching no manifest path. No live change: the stack runs from copy
4429a104f until the 10-24 re-pin window.
2026-10-20 21:47:11 -05:00

96 lines
5.3 KiB
JavaScript

// `mosaic credentials` (row 199, #1697, design section 6): an agent's per-agent credentials through
// the broker's credentials, credentials.issue, credentials.revoke and credentials.delete. Every verb
// is the human's; with --tester (Q173) only status and revoke. issue and rotate read the token on
// stdin, never argv, and nothing here prints it: the broker's answer carries the kind, date and state.
import { CliError } from "./errors.mjs";
export const CREDENTIALS_USAGE = "mosaic credentials [status [AGENT]] [--json] [--tester] | issue|rotate AGENT gitea --rotate-by DATE | " +
"issue|rotate AGENT vikunja --expires DATE | issue|rotate AGENT host.ID --rotate-by DATE | " +
"revoke AGENT [KIND] --reason TEXT [--tester] | delete AGENT --reason TEXT [--business ID]";
const DATE_FLAG = Object.freeze({ gitea: "--rotate-by", vikunja: "--expires" });
// Row 210 (#1707, design D1): host.<id> takes --rotate-by, as gitea does.
const HOST_KIND = /^host\.[a-z0-9][a-z0-9-]{0,31}$/;
const dateFlag = (kind) => (HOST_KIND.test(kind ?? "") ? "--rotate-by" : DATE_FLAG[kind]);
const MAX_TOKEN = 16384;
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--json" || arg === "--tester") out.flags.add(arg);
else if (["--business", "--rotate-by", "--expires", "--reason"].includes(arg)) {
if (!args[i + 1] || Object.hasOwn(out.values, arg)) throw new CliError(CREDENTIALS_USAGE, 4);
out.values[arg] = args[++i];
} else if (arg.startsWith("--")) throw new CliError(CREDENTIALS_USAGE, 4);
else out.positional.push(arg);
}
return out;
}
// The whole of stdin, one token; a terminal shows what is typed, so a pipe or a paste then EOF.
async function readToken(stdin) {
let text = "";
for await (const chunk of stdin) {
text += chunk;
if (text.length > MAX_TOKEN + 2) throw new CliError("refused: the token on stdin is too long", 2);
}
if (!text.trim()) throw new CliError("refused: no token on stdin; pipe or paste it, then end input", 4);
return text;
}
// The states the bus's status() gives a held token: gitea is valid or rotation-due, vikunja valid,
// expiring or expired. not-issued and revoked hold none.
const ISSUED = new Set(["valid", "rotation-due", "expiring", "expired"]);
export async function credentialsCommand(args, io, callFor) {
const parsed = parse(args);
const [verb = "status", agent, kind, ...extra] = parsed.positional;
const v = parsed.values;
const shape = {
status: () => kind === undefined && !v["--rotate-by"] && !v["--expires"] && !v["--reason"],
issue: () => agent && dateFlag(kind) && v[dateFlag(kind)] && !v[dateFlag(kind) === "--rotate-by" ? "--expires" : "--rotate-by"] && !v["--reason"],
revoke: () => agent && (kind === undefined || dateFlag(kind)) && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
delete: () => agent && kind === undefined && v["--reason"] && !v["--rotate-by"] && !v["--expires"],
};
shape.rotate = shape.issue;
if (extra.length || !shape[verb]?.()) throw new CliError(CREDENTIALS_USAGE, 4);
// Q173 (duckworth N1): a token the tester issued would pass through its session.
if (parsed.flags.has("--tester") && !["status", "revoke"].includes(verb)) {
throw new CliError(`--tester takes only status and revoke\n${CREDENTIALS_USAGE}`, 4);
}
try {
const call = callFor(parsed);
let result;
if (verb === "status") {
const rows = await call("credentials", {});
result = agent ? rows.filter((r) => r.role === agent) : rows;
if (!parsed.flags.has("--json")) {
io.stdout.write(result.map((r) => `${r.role}\t${r.service}\t${r.state}\t${r.dateKind} ${r.date}${r.fingerprint ? `\t${r.fingerprint}` : ""}`).join("\n") + (result.length ? "\n" : ""));
return;
}
} else if (verb === "issue" || verb === "rotate") {
// rotate names an entry the bus holds now; checked before stdin is read.
if (verb === "rotate") {
const held = (await call("credentials", {})).find((r) => r.role === agent && r.service === kind);
if (!ISSUED.has(held?.state)) throw new CliError(`refused: ${agent} has no issued ${kind} credential to rotate; use issue`, 2);
}
const token = await readToken(io.stdin);
result = await call("credentials.issue", { agent, service: kind, token, date: v[dateFlag(kind)] });
// Row 210 (design D9): public material only. The from= address is the stack host's, which
// ops-01 fills in when it installs the line.
if (result.publicKey && !parsed.flags.has("--json")) {
const business = v["--business"] ?? "BUSINESS";
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
io.stdout.write(`authorized_keys line for ops-01:\nfrom="<stack host address>",restrict ${result.publicKey} mosaic-${business}-${agent}-${kind.slice("host.".length)}\n`);
return;
}
} else if (verb === "revoke") {
result = await call("credentials.revoke", { agent, ...(kind ? { service: kind } : {}), reason: v["--reason"] });
} else result = await call("credentials.delete", { agent, reason: v["--reason"] });
io.stdout.write(JSON.stringify(result, null, 2) + "\n");
} catch (error) {
if (error instanceof CliError) throw error;
throw new CliError(error.message, 2);
}
}