@cldmv/gh-broker 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,7 +7,7 @@
7
7
  * @Email: <Shinrai@users.noreply.github.com>
8
8
  * -----
9
9
  * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
- * @Last modified time: 2026-11-01T00:40:44-07:00 (1793518844)
10
+ * @Last modified time: 2026-11-02T21:30:45-08:00 (1793683845)
11
11
  * -----
12
12
  * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
13
  *
@@ -24,9 +24,11 @@
24
24
  * Field-level rules, because the layers have different owners:
25
25
  * - `tools` (the allowlist) is a SECURITY control and is always taken from the example, so a
26
26
  * removal or tightening in an update applies and neither the deployed copy, a --config
27
- * file nor a flag can widen it. A --config file that contains `tools` is refused.
27
+ * file nor a flag can widen it. A --config file that contains `tools` is refused. The one
28
+ * exception is each deployed tool's boolean `otp` and `enabled`, which carry onto the same
29
+ * tool in the new list (they never add a tool).
28
30
  * - Everything else is operator data and the deployed copy wins over the example: `listen`,
29
- * `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`. The example ships
31
+ * `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`, `otp`. The example ships
30
32
  * none of the owner-specific ones, so a fresh install has no owners until the operator
31
33
  * supplies one (--owner, --config, or the interactive prompt).
32
34
  * - Layers 3 and 4 are merged on top of what is deployed rather than replacing it: scalars are
@@ -37,9 +39,11 @@
37
39
  * The result is validated by the real loader (`--check`) before anything is deployed, and a
38
40
  * --config file may hold only `cred:` references, never token values.
39
41
  */
42
+ import { DEFAULT_LISTEN } from "./agents.mjs";
43
+
40
44
  export const DEFAULT_GHCR_DROP_DIR = "/srv/gh-broker/ghcr-drop";
41
45
 
42
- const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr"];
46
+ const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr", "otp"];
43
47
  const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
44
48
  /** Shapes of live GitHub / npm tokens (classic, fine-grained, app, npm granular). */
45
49
  const TOKEN_SHAPE = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|npm_[A-Za-z0-9]{30,})\b/;
@@ -51,11 +55,20 @@ export function mergeConfig(example, current) {
51
55
 
52
56
  const out = structuredClone(example);
53
57
 
54
- // tools: always authoritative from the example (already copied into out).
58
+ // tools: the allowlist is authoritative from the example (already copied into out). The per-tool
59
+ // settings the operator owns are `otp` (gate a tool or ungate it, #143) and `enabled` (turn a
60
+ // shipped tool on or off, #146), so a boolean one on a deployed tool is carried onto the same
61
+ // tool in the new list. It never adds a tool.
62
+ const deployed = (Array.isArray(current.tools) ? current.tools : []).filter(isObject);
63
+ for (const key of ["otp", "enabled"]) {
64
+ const kept = new Map(deployed.filter((t) => typeof t[key] === "boolean").map((t) => [t.name, t[key]]));
65
+ for (const t of out.tools ?? []) if (kept.has(t.name)) t[key] = kept.get(t.name);
66
+ }
55
67
 
56
- // scalars: current wins when defined.
68
+ // scalars: current wins when defined. An old `unix:` listen is the shared-socket form the
69
+ // per-agent sockets replaced, so it is not carried over (buildConfig sets the new default).
57
70
  for (const k of ["listen", "upstream", "defaultOwner"]) {
58
- if (current[k] !== undefined) out[k] = current[k];
71
+ if (current[k] !== undefined && !(k === "listen" && String(current[k]).startsWith("unix:"))) out[k] = current[k];
59
72
  }
60
73
 
61
74
  // owners: operator data. Current entries win per owner; any the example ships are kept.
@@ -66,6 +79,10 @@ export function mergeConfig(example, current) {
66
79
  if (current[k] !== undefined) out[k] = structuredClone(current[k]);
67
80
  }
68
81
 
82
+ // otp: the example ships the default seed ref; the deployed block wins per field, so a
83
+ // user's own limits or state path survive an upgrade and new default fields still arrive.
84
+ if (current.otp !== undefined) out.otp = { ...(example.otp ?? {}), ...structuredClone(current.otp) };
85
+
69
86
  return out;
70
87
  }
71
88
 
@@ -78,7 +95,7 @@ export function layerConfig(base, overlay) {
78
95
  for (const k of ["listen", "upstream", "defaultOwner"]) {
79
96
  if (overlay[k] !== undefined) out[k] = overlay[k];
80
97
  }
81
- for (const k of ["app", "secrets", "npm"]) {
98
+ for (const k of ["app", "secrets", "npm", "otp"]) {
82
99
  if (overlay[k] !== undefined) out[k] = { ...(out[k] ?? {}), ...structuredClone(overlay[k]) };
83
100
  }
84
101
  if (overlay.owners !== undefined) {
@@ -174,6 +191,10 @@ export async function buildConfig({ example, current, file, opts, nonInteractive
174
191
  if (file) config = layerConfig(config, file);
175
192
  config = layerConfig(config, overlayFromFlags(opts));
176
193
  config.owners ??= {};
194
+ if (typeof config.listen !== "string" || config.listen.startsWith("unix:")) {
195
+ config.listen = DEFAULT_LISTEN;
196
+ notes.push(`listen set to ${DEFAULT_LISTEN} (one socket per agent; the shared unix: socket is gone)`);
197
+ }
177
198
 
178
199
  if (Object.keys(config.owners).length === 0) {
179
200
  if (nonInteractive) {
@@ -32,7 +32,7 @@ const IMAGE_GLOB = /^[a-z0-9*](?:[a-z0-9._*-]*[a-z0-9*])?(?:\/[a-z0-9*](?:[a-z0-
32
32
  export const envSecretKey = (name) => "GH_BROKER_SECRET_" + name.toUpperCase().replace(/[^A-Z0-9]/g, "_");
33
33
 
34
34
  export function parseArgs(argv) {
35
- const o = { positional: [], nonInteractive: false, createAgentUser: false, npm: false, npmScopes: [], npmOrgs: [], cliSecrets: {} };
35
+ const o = { positional: [], nonInteractive: false, createAgentUser: false, noMcpConfig: false, npm: false, npmScopes: [], npmOrgs: [], cliSecrets: {} };
36
36
  // Consume the value after a value-taking flag, failing if it's missing or is
37
37
  // itself a flag — so `--agent-user --secrets-provider file` can't silently take
38
38
  // "--secrets-provider" as the username and proceed with an unintended user.
@@ -46,6 +46,7 @@ export function parseArgs(argv) {
46
46
  if (a === "-h" || a === "--help") o.help = true;
47
47
  else if (a === "-y" || a === "--non-interactive") o.nonInteractive = true;
48
48
  else if (a === "--create-agent-user") o.createAgentUser = true;
49
+ else if (a === "--no-mcp-config") o.noMcpConfig = true;
49
50
  else if (a === "--agent-user") o.agentUser = value(++i, a);
50
51
  else if (a === "--secrets-provider") o.provider = value(++i, a);
51
52
  else if (a === "--secrets-file") o.secretsFile = value(++i, a);
@@ -85,7 +86,7 @@ export function parseArgs(argv) {
85
86
  o.defaultOwner = org;
86
87
  } else if (a === "--listen") {
87
88
  const addr = value(++i, a);
88
- if (!/^unix:\/.+/.test(addr)) throw new Error(`--listen must be a "unix:<absolute path>" address, got "${addr}"`);
89
+ if (!/^agents:\/.+/.test(addr)) throw new Error(`--listen must be an "agents:<absolute directory>" address, got "${addr}"`);
89
90
  o.listen = addr;
90
91
  } else if (a === "--upstream") {
91
92
  const url = value(++i, a);
@@ -0,0 +1,211 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/mcp-config.mjs
5
+ * @Date: 2026-11-02T09:12:00-08:00 (1793639520)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-11-02T09:12:00-08:00 (1793639520)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * Writing an agent user's Claude Code MCP config (#143, contract v2). The broker's entry goes in
18
+ * the top-level `mcpServers` of `<home>/.claude.json`; nothing else in that file is touched. The
19
+ * pure functions build the entry, pick its key and edit the JSON text; `applyMcpConfig` and
20
+ * `removeMcpConfig` do the file work (backup, atomic write that keeps the owner and mode, and a
21
+ * read-back check) with every filesystem effect injected. Root does that work in a directory the
22
+ * agent user owns, so `readOwnedFile` refuses anything but the user's own plain file.
23
+ */
24
+ import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
25
+ import { agentGroup, socketPath } from "./agents.mjs";
26
+
27
+ export const MCP_FILE = ".claude.json";
28
+ export const MCP_BACKUP = ".claude.json.gh-broker.bak";
29
+ const BROKER_PATH = "/run/gh-broker/";
30
+
31
+ /** Largest ~/.claude.json root will read (Claude Code's own file is far smaller). */
32
+ export const MAX_CONFIG_BYTES = 16 * 1024 * 1024;
33
+
34
+ /** A file root must not read or replace: a symlink, not a regular file, not the user's, hard-linked, or oversized. */
35
+ export class UnsafeFile extends Error {}
36
+
37
+ /**
38
+ * Read `path` as root on behalf of the user `uid`, without following a symlink: the file must be
39
+ * a regular file owned by `uid` with a single link, and no larger than MAX_CONFIG_BYTES. Opened with O_NOFOLLOW (and O_NONBLOCK, so a
40
+ * FIFO can't hang the read), then checked and read through the same descriptor, so it can't be
41
+ * swapped between the check and the read.
42
+ * @returns {{ text: string, stat: { uid: number, gid: number, mode: number } } | undefined} undefined when there is no file
43
+ */
44
+ export function readOwnedFile(path, uid) {
45
+ let fd;
46
+ try {
47
+ fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
48
+ } catch (err) {
49
+ if (err.code === "ENOENT") return undefined;
50
+ if (err.code === "ELOOP") throw new UnsafeFile(`${path} is a symlink`);
51
+ throw err;
52
+ }
53
+ try {
54
+ const st = fstatSync(fd);
55
+ if (!st.isFile()) throw new UnsafeFile(`${path} is not a regular file`);
56
+ if (st.uid !== uid) throw new UnsafeFile(`${path} is owned by uid ${st.uid}, not the agent user (uid ${uid})`);
57
+ if (st.nlink !== 1) throw new UnsafeFile(`${path} has ${st.nlink} hard links`);
58
+ // Read at most one byte past the limit ourselves: the owner can grow the file after fstat.
59
+ const buf = Buffer.alloc(MAX_CONFIG_BYTES + 1);
60
+ let length = 0;
61
+ for (let n; length < buf.length && (n = readSync(fd, buf, length, buf.length - length, null)) > 0; ) length += n;
62
+ if (length > MAX_CONFIG_BYTES) throw new UnsafeFile(`${path} is over the ${MAX_CONFIG_BYTES}-byte limit`);
63
+ return { text: buf.toString("utf8", 0, length), stat: { uid: st.uid, gid: st.gid, mode: st.mode } };
64
+ } finally {
65
+ closeSync(fd);
66
+ }
67
+ }
68
+
69
+ /** `io.read`, with an unsafe file turned into `{ unsafe: reason }`. */
70
+ function load(io, path, uid) {
71
+ try {
72
+ return io.read(path, uid);
73
+ } catch (err) {
74
+ if (err instanceof UnsafeFile) return { unsafe: err.message };
75
+ throw err;
76
+ }
77
+ }
78
+
79
+ /** The entry for `user`. `sg` makes the new group effective without a re-login. */
80
+ export function mcpEntry(user, dir) {
81
+ return { type: "stdio", command: "sg", args: [agentGroup(user), "-c", `exec socat STDIO UNIX-CONNECT:${socketPath(dir, user)}`] };
82
+ }
83
+
84
+ /** Whether an entry points at a broker socket: the standard /run/gh-broker/ tree, or the configured agents directory. */
85
+ const mentionsBroker = (entry, dir) => {
86
+ const text = JSON.stringify(entry ?? null);
87
+ return text.includes(BROKER_PATH) || (dir !== undefined && text.includes(`${dir}/`));
88
+ };
89
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
90
+
91
+ /** Keys of the entries that point at the broker (any /run/gh-broker/ socket, old or new, or one in `dir`). */
92
+ export function brokerKeys(servers, dir) {
93
+ return Object.keys(servers).filter((k) => mentionsBroker(servers[k], dir));
94
+ }
95
+
96
+ /** The key to write: the existing broker entry's, else `github`, `gh-broker`, then `gh-broker-<user>` (first free). */
97
+ export function chooseKey(servers, user, dir) {
98
+ const existing = brokerKeys(servers, dir);
99
+ if (existing.length) return existing[0];
100
+ return ["github", "gh-broker", `gh-broker-${user}`].find((k) => !(k in servers)) ?? `gh-broker-${user}`;
101
+ }
102
+
103
+ const sameEntry = (a, b) => JSON.stringify(a) === JSON.stringify(b);
104
+
105
+ /** Indentation of the file as written (tab or n spaces), so the edit doesn't reformat it. */
106
+ function indentOf(text) {
107
+ const m = /^\{\r?\n([ \t]+)\S/.exec(text);
108
+ return m ? m[1] : " ";
109
+ }
110
+
111
+ /** Parse the file; `undefined` text (no file) is an empty config. Returns undefined when it isn't a JSON object. */
112
+ function parse(text) {
113
+ if (text === undefined) return {};
114
+ try {
115
+ const v = JSON.parse(text);
116
+ return isObject(v) ? v : undefined;
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ }
121
+
122
+ /** Whether the file's broker entry for `user` is "ok", "missing", "points elsewhere" or "unreadable". */
123
+ export function mcpStatus(text, user, dir) {
124
+ const cfg = parse(text);
125
+ if (cfg === undefined) return "unreadable";
126
+ const servers = isObject(cfg.mcpServers) ? cfg.mcpServers : {};
127
+ const keys = brokerKeys(servers, dir);
128
+ if (keys.length === 0) return "missing";
129
+ return keys.some((k) => sameEntry(servers[k], mcpEntry(user, dir))) ? "ok" : "points elsewhere";
130
+ }
131
+
132
+ /**
133
+ * The file text with `user`'s entry set. Only the chosen key changes.
134
+ * @returns {{ status: "unreadable" } | { status: "unchanged" | "changed", key: string, text: string }}
135
+ */
136
+ export function mergeEntry(text, user, dir) {
137
+ const cfg = parse(text);
138
+ if (cfg === undefined) return { status: "unreadable" };
139
+ const servers = isObject(cfg.mcpServers) ? cfg.mcpServers : {};
140
+ const key = chooseKey(servers, user, dir);
141
+ const entry = mcpEntry(user, dir);
142
+ if (text !== undefined && sameEntry(servers[key], entry)) return { status: "unchanged", key, text };
143
+ const next = { ...cfg, mcpServers: { ...servers, [key]: entry } };
144
+ return { status: "changed", key, text: JSON.stringify(next, null, text === undefined ? " " : indentOf(text)) + "\n" };
145
+ }
146
+
147
+ /**
148
+ * The file text with every broker entry removed (an empty `mcpServers` is left in place).
149
+ * @returns {{ status: "unreadable" | "absent" } | { status: "removed", keys: string[], text: string }}
150
+ */
151
+ export function dropEntries(text, dir) {
152
+ const cfg = parse(text);
153
+ if (cfg === undefined) return { status: "unreadable" };
154
+ const servers = isObject(cfg.mcpServers) ? cfg.mcpServers : {};
155
+ const keys = brokerKeys(servers, dir);
156
+ if (keys.length === 0) return { status: "absent" };
157
+ const rest = Object.fromEntries(Object.entries(servers).filter(([k]) => !keys.includes(k)));
158
+ return { status: "removed", keys, text: JSON.stringify({ ...cfg, mcpServers: rest }, null, indentOf(text)) + "\n" };
159
+ }
160
+
161
+ /**
162
+ * Set `user`'s entry in their `.claude.json`.
163
+ * @param {object} o
164
+ * @param {string} o.home the agent user's home directory
165
+ * @param {string} o.user
166
+ * @param {string} o.dir the agents' socket directory
167
+ * @param {{ uid: number, gid: number }} o.owner the agent user; owner for a file that has to be created
168
+ * @param {object} io { read(path, uid) -> { text, stat: { uid, gid, mode } } | undefined (see readOwnedFile), write(path, text, { uid, gid, mode }) }
169
+ * @returns {{ status: "created" | "written" | "unchanged" | "unreadable" | "unsafe", key?: string, reason?: string, entry: object, path: string }}
170
+ */
171
+ export function applyMcpConfig({ home, user, dir, owner }, io) {
172
+ const path = `${home}/${MCP_FILE}`;
173
+ const entry = mcpEntry(user, dir);
174
+ const before = load(io, path, owner.uid);
175
+ if (before?.unsafe) return { status: "unsafe", reason: before.unsafe, entry, path };
176
+ const merged = mergeEntry(before?.text, user, dir);
177
+ if (merged.status === "unreadable") return { status: "unreadable", entry, path };
178
+ if (merged.status === "unchanged") return { status: "unchanged", key: merged.key, entry, path };
179
+ let target = { ...owner, mode: 0o600 };
180
+ if (before !== undefined) {
181
+ target = { uid: before.stat.uid, gid: before.stat.gid, mode: before.stat.mode & 0o777 };
182
+ io.write(`${home}/${MCP_BACKUP}`, before.text, target);
183
+ }
184
+ io.write(path, merged.text, target);
185
+ const after = load(io, path, owner.uid);
186
+ if (after?.unsafe || mcpStatus(after?.text, user, dir) !== "ok") throw new Error(`${path}: the broker entry is not there after writing; restore ${MCP_BACKUP} if the file looks wrong`);
187
+ return { status: before === undefined ? "created" : "written", key: merged.key, entry, path };
188
+ }
189
+
190
+ /**
191
+ * Remove the broker entry from `user`'s `.claude.json`, backing the file up first.
192
+ * @returns {{ status: "removed" | "absent" | "no-file" | "unreadable" | "unsafe", keys?: string[], reason?: string, path: string }}
193
+ */
194
+ export function removeMcpConfig({ home, dir, uid }, io) {
195
+ const path = `${home}/${MCP_FILE}`;
196
+ const before = load(io, path, uid);
197
+ if (before === undefined) return { status: "no-file", path };
198
+ if (before.unsafe) return { status: "unsafe", reason: before.unsafe, path };
199
+ const dropped = dropEntries(before.text, dir);
200
+ if (dropped.status !== "removed") return { status: dropped.status, path };
201
+ const target = { uid: before.stat.uid, gid: before.stat.gid, mode: before.stat.mode & 0o777 };
202
+ io.write(`${home}/${MCP_BACKUP}`, before.text, target);
203
+ io.write(path, dropped.text, target);
204
+ return { status: "removed", keys: dropped.keys, path };
205
+ }
206
+
207
+ /** The MCP status of `user`'s file for `agent list`: mcpStatus, or "unsafe (<reason>)". */
208
+ export function mcpFileStatus({ home, user, dir, uid }, io) {
209
+ const got = load(io, `${home}/${MCP_FILE}`, uid);
210
+ return got?.unsafe ? `unsafe (${got.unsafe})` : mcpStatus(got?.text, user, dir);
211
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/otp-cli.mjs
5
+ * @Date: 2026-11-02T09:12:00-08:00 (1793639520)
6
+ * @Author: Nate Corcoran <CLDMV>
7
+ * @Email: <Shinrai@users.noreply.github.com>
8
+ * -----
9
+ * @Last modified by: Nate Corcoran <CLDMV> (Shinrai@users.noreply.github.com)
10
+ * @Last modified time: 2026-11-02T09:12:00-08:00 (1793639520)
11
+ * -----
12
+ * @Copyright: Copyright (c) 2013-2026 Catalyzed Motivation Inc. All rights reserved.
13
+ *
14
+ */
15
+
16
+ /**
17
+ * `gh-broker otp unlock | status | list | rotate` (#143, contract v2): the root-side half of the OTP gate.
18
+ * Lock state is per agent user, in the state file the daemon writes. Every effect is injected (see
19
+ * deploy/cli.mjs for the real wiring), so the commands are testable without root.
20
+ */
21
+ import { DEFAULT_STATE_PATH, DEFAULT_MAX_FAILURES, DEFAULT_MAX_LOCKOUTS, describeUsers, emptyState, formatStatus, generateSeed, secondsUntilFreshCode, seedCredName, showSeed, spendRecentCodes, userOf, withoutUser, withoutUsers } from "./otp.mjs";
22
+ import { assertAgentName, registeredAgents, versionWarning } from "./agents.mjs";
23
+
24
+ export const statePathOf = (config) => config.otp?.statePath ?? DEFAULT_STATE_PATH;
25
+ export const limitsOf = (config) => ({ maxFailures: config.otp?.maxFailures ?? DEFAULT_MAX_FAILURES, maxLockouts: config.otp?.maxLockouts ?? DEFAULT_MAX_LOCKOUTS });
26
+
27
+ /** Run one `otp` subcommand. Returns the exit code; throws on a failure the dispatcher reports. */
28
+ export async function otpCommand(command, rest, config, d) {
29
+ if (command === "status") return status(config, rest, d);
30
+ if (command === "unlock") return unlock(config, rest, d);
31
+ if (command === "list") return list(config, rest, d);
32
+ return rotate(config, d);
33
+ }
34
+
35
+ function status(config, rest, d) {
36
+ const only = rest.find((a) => !a.startsWith("-"));
37
+ const statePath = statePathOf(config);
38
+ const read = d.readState(statePath);
39
+ const known = registeredAgents(d.groups()).map((a) => a.user);
40
+ const users = describeUsers(read, only ? [only] : known, limitsOf(config), d.now()).filter((u) => !only || u.user === only);
41
+ const configured = seedCredName(config) !== undefined;
42
+ const warn = versionWarning(d.versions().pkg, d.versions().deployed);
43
+ if (rest.includes("--json")) {
44
+ d.log(JSON.stringify({ configured, statePath, fileStatus: read.status, users, unlockCommand: "sudo gh-broker otp unlock <user>" }, null, "\t"));
45
+ } else {
46
+ for (const line of formatStatus(users, { statePath, fileStatus: read.status, configured })) d.log(line);
47
+ }
48
+ if (warn) d.error(warn);
49
+ return 0;
50
+ }
51
+
52
+ /** Who is locked out right now (short lockout or hard lock), with the command that clears each. */
53
+ function list(config, rest, d) {
54
+ const statePath = statePathOf(config);
55
+ const read = d.readState(statePath);
56
+ const known = registeredAgents(d.groups()).map((a) => a.user);
57
+ const locked = describeUsers(read, known, limitsOf(config), d.now())
58
+ .filter((u) => u.locked)
59
+ .map((u) => ({ user: u.user, corrupt: Boolean(u.corrupt), hardLock: u.hardLock, lockedUntil: u.lockedUntil, unlockCommand: u.corrupt ? "sudo gh-broker otp unlock --all" : `sudo gh-broker otp unlock ${u.user}` }));
60
+ if (rest.includes("--json")) {
61
+ d.log(JSON.stringify({ statePath, fileStatus: read.status, locked }, null, "\t"));
62
+ return 0;
63
+ }
64
+ if (locked.length === 0) d.log("nobody is locked out");
65
+ for (const u of locked) {
66
+ const why = u.corrupt ? `locked: ${statePath} is corrupt` : u.hardLock ? `hard-locked since ${u.hardLock.since} after ${u.hardLock.lockouts} lockouts` : `locked until ${u.lockedUntil}`;
67
+ d.log(`${u.user} ${why} unlock: ${u.unlockCommand}`);
68
+ }
69
+ return 0;
70
+ }
71
+
72
+ /** Record an unlock in the journal next to the daemon's audit log (never fatal). */
73
+ function auditUnlock(d, user, detail) {
74
+ try {
75
+ d.audit({ ts: new Date(d.now()).toISOString(), decision: "otp-unlock", user, by: d.sudoUser() ?? "root", ...(detail ? { detail } : {}) });
76
+ } catch (err) {
77
+ d.error(`warning: the unlock could not be written to the journal: ${err.message}`);
78
+ }
79
+ }
80
+
81
+ /** What to tell the operator after a write that spent the recent codes (a code that is refused counts as a bad one). */
82
+ const afterUnlock = (now) => `The code the authenticator shows now won't work, nor the one after it: wait ${secondsUntilFreshCode(now)} s for a fresh code.`;
83
+
84
+ function unlock(config, rest, d) {
85
+ const all = rest.includes("--all");
86
+ const user = rest.find((a) => !a.startsWith("-"));
87
+ if (all === Boolean(user)) throw new Error('name the agent to unlock ("sudo gh-broker otp unlock <user>") or pass --all');
88
+ if (user) assertAgentName(user);
89
+ const statePath = statePathOf(config);
90
+ const read = d.readState(statePath);
91
+ if (read.status === "missing") {
92
+ d.log(`nothing to unlock: no state file at ${statePath}`);
93
+ return 0;
94
+ }
95
+ const now = d.now();
96
+ if (read.status === "corrupt") {
97
+ if (!all) throw new Error(`${statePath} is corrupt, so every user is locked; run "sudo gh-broker otp unlock --all" to rewrite it clean`);
98
+ // The old lastAcceptedStep can't be read back, so spend everything up to now instead.
99
+ d.writeState(statePath, spendRecentCodes(emptyState(), now));
100
+ auditUnlock(d, "*", "rewrote a corrupt state file");
101
+ d.log(`rewrote the corrupt state file at ${statePath}; everyone is unlocked. ${afterUnlock(now)}`);
102
+ return 0;
103
+ }
104
+ if (all) {
105
+ d.writeState(statePath, spendRecentCodes(withoutUsers(read.state), now));
106
+ auditUnlock(d, "*");
107
+ d.log(`cleared the lock state of every user in ${statePath}. ${afterUnlock(now)}`);
108
+ return 0;
109
+ }
110
+ if (!Object.hasOwn(read.state.users, user)) {
111
+ d.log(`${user} has no lock state; nothing to clear`);
112
+ return 0;
113
+ }
114
+ const wasLocked = Boolean(userOf(read.state, user).hardLock);
115
+ d.writeState(statePath, spendRecentCodes(withoutUser(read.state, user), now));
116
+ auditUnlock(d, user);
117
+ d.log(`${wasLocked ? `unlocked ${user}` : `cleared ${user}'s counters (not hard-locked)`}. ${afterUnlock(now)}`);
118
+ return 0;
119
+ }
120
+
121
+ async function rotate(config, d) {
122
+ const name = seedCredName(config);
123
+ if (!name) throw new Error("OTP is not configured (no otp.seed in the config); re-run the installer first");
124
+ const seed = d.newSeed ? d.newSeed() : generateSeed();
125
+ // Shown before it is stored: storing replaces the old seed, so a failure after the store
126
+ // would otherwise leave a seed nobody has.
127
+ await showSeed({ seed, host: d.host(), log: d.log, ...(d.qr ? { qr: d.qr } : {}) });
128
+ try {
129
+ d.storeSeed(config, name, seed);
130
+ } catch (err) {
131
+ throw new Error(`storing the new seed failed (${err.message}); the key above is not in use and the old seed is still the stored one. Nothing was restarted; run "sudo gh-broker otp rotate" again`);
132
+ }
133
+ d.restart();
134
+ d.log("service restarted. The old authenticator entry no longer works; remove it from your app.");
135
+ return 0;
136
+ }