@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.
@@ -17,8 +17,14 @@
17
17
  /**
18
18
  * gh-broker installer — one command for first install AND upgrade.
19
19
  *
20
- * sudo npx @cldmv/gh-broker [agent-user] [flags] (from npm)
21
- * sudo node deploy/install.mjs [agent-user] [flags] (from a checkout)
20
+ * sudo gh-broker install [agent-user] [flags] (global npm install)
21
+ * sudo npx @cldmv/gh-broker install [agent-user] [flags] (one-off, from npm)
22
+ * sudo node deploy/install.mjs [agent-user] [flags] (from a checkout)
23
+ *
24
+ * The documented path is a global install (`sudo npm install -g --prefix /usr/local
25
+ * --ignore-scripts @cldmv/gh-broker`, then `sudo gh-broker install`); the package bin is
26
+ * deploy/cli.mjs, which hands `install` to this file. Agents, OTP and the `gh-broker` admin
27
+ * commands are in deploy/cli.mjs and lib/.
22
28
  *
23
29
  * Prerequisite: node >= 20. Idempotent: re-run to upgrade (config regenerated by
24
30
  * layering, existing secrets kept). Interactive by default; pass flags plus
@@ -30,24 +36,26 @@ import { fileURLToPath } from "node:url";
30
36
  import { dirname, join } from "node:path";
31
37
  import {
32
38
  run, has, isRoot, userExists, requireGid,
33
- ensureServiceUser, createAgentUser, addUserToGroup,
39
+ ensureServiceUser, createAgentUser,
34
40
  pointNodeSymlinks, currentNodeBin, ensureDirs, installUnit,
35
- enableService, restartService, serviceActive, deployDist, writeConfigFile,
41
+ enableService, restartService, serviceActive, deployDist, writeConfigFile, ensureAgentGroup,
36
42
  trySystemdCredDecrypt, systemdCredsEncrypt, ensureCredsDir, writeCredsDropin, removeCredsDropin, ensureDropDir
37
43
  } from "./lib/system.mjs";
38
44
  import { promptLine, promptHidden, confirm, choose } from "./lib/prompt.mjs";
39
45
  import { buildConfig, readOperatorConfig, secretsBlock } from "./lib/config-merge.mjs";
40
46
  import { writeSecretsFile, readSecretsFile, removeSecretsFile, referencedCredNames, optionalCredNames, detectSecretsProvider } from "./lib/secret-store.mjs";
41
- import { parseArgs, NPM_SCOPE } from "./lib/install-args.mjs";
47
+ import { parseArgs, NPM_SCOPE, envSecretKey } from "./lib/install-args.mjs";
48
+ import { generateSeed, seedCredName, seedExists, showSeed, writeFileAtomic } from "./lib/otp.mjs";
42
49
  import { credentialPlan } from "./lib/credentials.mjs";
43
- import { smokeTest } from "./lib/smoke.mjs";
44
50
  import { createNpmToken } from "./lib/npm-setup.mjs";
45
51
  import { hostname } from "node:os";
52
+ import { assertAgentName, classifyInstall, migrationPlan, NPM_INSTALL_COMMAND, parseGroups, registeredAgents, LEGACY_CLIENTS_GROUP } from "./lib/agents.mjs";
53
+ import { configureMcp, smokeAgent } from "./lib/agent-cli.mjs";
54
+ import { realDeps } from "./real-deps.mjs";
46
55
 
47
56
  const REPO = dirname(dirname(fileURLToPath(import.meta.url)));
48
57
  const SVC_USER = "gh-broker";
49
58
  const SVC_GROUP = "gh-broker";
50
- const CLIENTS_GROUP = "gh-broker-clients";
51
59
  const APP_DIR = "/opt/gh-broker";
52
60
  const DIST = join(REPO, "dist");
53
61
  const DIST_ENTRY = join(DIST, "index.mjs");
@@ -69,12 +77,18 @@ function readJson(path) {
69
77
 
70
78
  const HELP = `gh-broker installer — install and upgrade in one command.
71
79
 
72
- Usage: sudo npx @cldmv/gh-broker [agent-user] [flags] (from npm)
73
- sudo node deploy/install.mjs [agent-user] [flags] (from a checkout)
80
+ Usage: sudo gh-broker install [agent-user] [flags] (global npm install)
81
+ sudo npx @cldmv/gh-broker install [agent-user] [flags] (one-off, from npm)
82
+ sudo node deploy/install.mjs [agent-user] [flags] (from a checkout)
83
+ Global install: ${NPM_INSTALL_COMMAND}
84
+ After install: sudo gh-broker agent add|remove|list|sync, sudo gh-broker otp status|unlock|rotate
74
85
 
75
86
  Flags:
76
- --agent-user <name> the unprivileged user the agent runs as
77
- (also positional; defaults to $SUDO_USER)
87
+ --agent-user <name> an unprivileged agent user to register (also positional).
88
+ Each agent gets its own socket and MCP config. Optional on an
89
+ upgrade (registered agents are kept); a first install with none
90
+ asks (never $SUDO_USER)
91
+ --no-mcp-config do not write any agent's ~/.claude.json; print the entry instead
78
92
  --secrets-provider <p> systemd-creds | file (skips the prompt)
79
93
  --secrets-file <path> JSON { "<cred-name>": "<token>" } to import tokens from
80
94
  --secret <name>=<tok> one token on the command line (repeatable). Visible in
@@ -101,7 +115,7 @@ these flags). An upgrade run with none of them keeps the deployed owners and ghc
101
115
  GitHub App installation id for an owner (repeatable)
102
116
  --default-owner <org> owner whose read token serves calls that name no owner
103
117
  (a first install with a single owner defaults to it)
104
- --listen <unix:path> socket the broker listens on
118
+ --listen <agents:dir> directory holding the per-agent sockets (default agents:/run/gh-broker/agents)
105
119
  --upstream <url> GitHub hosted MCP base URL
106
120
  --app-id <n> GitHub App id (App-token-only verbs)
107
121
  --app-private-key <cred-name>
@@ -170,22 +184,47 @@ async function main() {
170
184
  for (const cmd of ["systemctl", "useradd", "getent", "sudo"]) if (!has(cmd)) die(`missing required command: ${cmd}`);
171
185
  for (const cmd of ["socat", "rsync"]) if (!has(cmd)) log(` note: '${cmd}' not found — ${cmd === "socat" ? "the agent socket bridge needs it" : "will fall back to a plain copy"}`);
172
186
 
173
- // --- agent user ---
174
- step("agent user");
175
- let agentUser = opts.agentUser || opts.positional[0] || process.env.SUDO_USER || "";
176
- if (!agentUser) {
187
+ // --- agent users ---
188
+ // An agent is registered by the group gh-broker-agent-<user>. Upgrades keep the registered ones,
189
+ // migrate members of the old shared gh-broker-clients group, and add the one named on the
190
+ // command line. A first install with none asks. It never defaults to $SUDO_USER: that is the
191
+ // admin running sudo, and an agent must be a plain user without it.
192
+ step("agent users");
193
+ const groupFile = "/etc/group";
194
+ const groups = parseGroups(existsSync(groupFile) ? readFileSync(groupFile, "utf8") : "");
195
+ const registered = registeredAgents(groups);
196
+ const migration = migrationPlan(groups, registered);
197
+ for (const name of migration.skipped) log(` skipping ${LEGACY_CLIENTS_GROUP} member "${name}": not a valid agent user name`);
198
+ let requested = opts.agentUser || opts.positional[0] || "";
199
+ if (!requested && registered.length === 0 && migration.add.length === 0) {
177
200
  if (opts.nonInteractive) die("no agent user — pass --agent-user <name>");
178
- agentUser = await promptLine("Which unprivileged user does the agent run as? ");
201
+ requested = await promptLine("Which unprivileged user does the agent run as? ");
202
+ if (!requested) die("no agent user given");
179
203
  }
180
- if (!agentUser) die("no agent user given");
181
- if (!userExists(agentUser)) {
182
- const create = opts.createAgentUser || (!opts.nonInteractive && (await confirm(`User "${agentUser}" does not exist. Create it (plain, no sudo)?`)));
183
- if (!create) die(`agent user "${agentUser}" missing — create it first or pass --create-agent-user`);
184
- createAgentUser(agentUser);
185
- log(` created "${agentUser}"`);
186
- } else {
187
- log(` "${agentUser}" exists`);
204
+ if (requested) {
205
+ try {
206
+ assertAgentName(requested);
207
+ } catch (err) {
208
+ die(err.message);
209
+ }
210
+ if (!userExists(requested)) {
211
+ const create = opts.createAgentUser || (!opts.nonInteractive && (await confirm(`User "${requested}" does not exist. Create it (plain, no sudo)?`)));
212
+ if (!create) die(`agent user "${requested}" missing — create it first or pass --create-agent-user`);
213
+ createAgentUser(requested);
214
+ log(` created "${requested}"`);
215
+ }
188
216
  }
217
+ const agents = [...new Set([...registered.map((a) => a.user), ...migration.add.filter(userExists), ...(requested ? [requested] : [])])].sort();
218
+ for (const name of migration.add) log(userExists(name) ? ` migrating ${LEGACY_CLIENTS_GROUP} member "${name}" to a registered agent` : ` skipping ${LEGACY_CLIENTS_GROUP} member "${name}": no such user`);
219
+ log(` agents: ${agents.join(", ")}`);
220
+ // The GHCR drop dir belongs to one agent. A new one goes to the agent named now, else the
221
+ // first registered; an existing one keeps its owner (see ensureDropDir).
222
+ const dropOwner = requested || agents[0];
223
+
224
+ // --- where this copy of the package lives ---
225
+ const where = classifyInstall(REPO);
226
+ if (where === "nvm") log(` warning: this package is installed under an nvm Node version folder; that copy breaks when Node is upgraded. Reinstall it globally: ${NPM_INSTALL_COMMAND}`);
227
+ else if (where === "checkout" && !existsSync("/usr/local/bin/gh-broker")) log(` note: no global gh-broker command found. Get one with: ${NPM_INSTALL_COMMAND}`);
189
228
 
190
229
  // --- build if needed ---
191
230
  // This installer requires root, but dist/ and node_modules/ are generated INSIDE
@@ -226,8 +265,8 @@ async function main() {
226
265
 
227
266
  // --- users, groups, dirs, node symlink ---
228
267
  step("service user, groups, directories");
229
- ensureServiceUser(SVC_USER, SVC_GROUP, CLIENTS_GROUP);
230
- addUserToGroup(agentUser, CLIENTS_GROUP);
268
+ ensureServiceUser(SVC_USER, SVC_GROUP);
269
+ for (const name of agents) ensureAgentGroup(name, SVC_USER);
231
270
  ensureDirs(configDir, APP_DIR, SVC_USER, SVC_GROUP);
232
271
  const linked = pointNodeSymlinks(currentNodeBin());
233
272
  log(` node symlink -> ${currentNodeBin()} (${linked.join(", ")})`);
@@ -246,8 +285,8 @@ async function main() {
246
285
  die(err.message);
247
286
  }
248
287
  if (merged.ghcr?.dropDir) {
249
- ensureDropDir(merged.ghcr.dropDir, agentUser, SVC_GROUP);
250
- log(` ghcr drop dir: ${merged.ghcr.dropDir} (2750 ${agentUser}:${SVC_GROUP})`);
288
+ const { created } = ensureDropDir(merged.ghcr.dropDir, dropOwner, SVC_GROUP);
289
+ log(` ghcr drop dir: ${merged.ghcr.dropDir} (2750 ${created ? dropOwner : "existing owner"}:${SVC_GROUP})`);
251
290
  }
252
291
 
253
292
  // --- npm integration (npm_scaffold): opt-in ---
@@ -322,13 +361,32 @@ async function main() {
322
361
  // single-line prompt can't accept — it must be supplied out-of-band.
323
362
  const appKeyName = merged.app?.privateKey?.startsWith("cred:") ? merged.app.privateKey.slice("cred:".length) : undefined;
324
363
 
364
+ // OTP seed (#143): generated on a first install and whenever none is stored, never over an
365
+ // existing one (that is `gh-broker otp rotate`). An operator-supplied seed is used as is and
366
+ // not shown again. The generated one rides in as an explicit value so the normal storage path
367
+ // (either backend) writes it. It is shown before it is stored: if a later step fails after the
368
+ // store, the next run finds a seed and won't make (or show) a new one, so showing it last
369
+ // could leave a seed nobody has.
370
+ const seedName = seedCredName(merged);
371
+ let generatedSeed;
372
+ if (seedName) {
373
+ const supplied = opts.cliSecrets[seedName] || importTokens?.[seedName] || process.env[envSecretKey(seedName)];
374
+ const stored = seedExists({ name: seedName, existingFile, credFileExists: (n) => existsSync(join(credsDir, `${n}.cred`)) });
375
+ if (!supplied && !stored) generatedSeed = generateSeed();
376
+ }
377
+ if (generatedSeed) {
378
+ step("OTP seed");
379
+ await showSeed({ seed: generatedSeed, host: hostname(), log });
380
+ log(" If this install fails and the next run shows a different key, use that one instead.");
381
+ }
382
+
325
383
  let plan;
326
384
  try {
327
385
  plan = credentialPlan({
328
386
  needed,
329
387
  optional,
330
388
  cliSecrets: opts.cliSecrets,
331
- imported: importTokens ?? {},
389
+ imported: { ...(importTokens ?? {}), ...(generatedSeed ? { [seedName]: generatedSeed } : {}) },
332
390
  env: process.env,
333
391
  existingFile,
334
392
  decrypt: (name) => trySystemdCredDecrypt(name, join(credsDir, `${name}.cred`)),
@@ -390,22 +448,21 @@ async function main() {
390
448
  installUnit(join(REPO, "deploy", "gh-broker.service"));
391
449
  enableService();
392
450
  deployDist(DIST, APP_DIR, SVC_USER, SVC_GROUP);
451
+ // `gh-broker otp status` / `agent list` compare it with the package. /opt/gh-broker belongs to
452
+ // the service user, so root writes it without following anything planted there.
453
+ writeFileAtomic(join(APP_DIR, "version"), `${readJson(join(REPO, "package.json"))?.version ?? ""}\n`, { uid: 0, gid: 0, mode: 0o644 });
393
454
  restartService();
394
455
  log(` service: ${serviceActive()}`);
395
456
 
396
- // --- smoke test ---
397
- step("smoke test");
398
- const sock = merged.listen.replace(/^unix:/, "");
399
- try {
400
- const r = await smokeTest(sock);
401
- log(` ✅ broker answered — ${r.tools.length} tools advertised${r.roundTrip ? `; ${r.roundTrip}` : ""}`);
402
- } catch (e) {
403
- die(`smoke test failed: ${e.message}\n check: journalctl -u gh-broker -n 30 --no-pager`);
404
- }
457
+ // --- each agent's MCP config, then a smoke test over its own socket ---
458
+ step("agents");
459
+ const dep = realDeps();
460
+ for (const name of agents) configureMcp(name, merged, dep, { noMcp: opts.noMcpConfig });
461
+ let smokeFailed = false;
462
+ for (const name of agents) if (!(await smokeAgent(name, merged, dep))) smokeFailed = true;
463
+ if (smokeFailed) die("smoke test failed (see above)");
405
464
 
406
- log(`\n✅ gh-broker installed. Point the agent's MCP client at the socket:`);
407
- log(` "github": { "type": "stdio", "command": "socat", "args": ["STDIO", "UNIX-CONNECT:${sock}"] }`);
408
- log(` (re-login as "${agentUser}" so its ${CLIENTS_GROUP} membership is active)`);
465
+ log(`\n✅ gh-broker installed. Manage agents with: sudo gh-broker agent add|remove|list|sync, OTP with: sudo gh-broker otp status|unlock|rotate`);
409
466
  }
410
467
 
411
468
  main().catch((e) => die(e.message));
@@ -0,0 +1,166 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/agent-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 agent add | remove | list | sync` (#143, contract v2). An agent is a Linux group
18
+ * `gh-broker-agent-<u>` (the registry), one socket per agent in the daemon's agents directory,
19
+ * and an entry in the agent user's Claude Code MCP config. Every effect is injected.
20
+ */
21
+ import { agentGroup, agentsDir, assertAgentName, registeredAgents, socketPath, versionWarning } from "./agents.mjs";
22
+ import { applyMcpConfig, mcpEntry, mcpFileStatus, removeMcpConfig, MCP_BACKUP, MCP_FILE } from "./mcp-config.mjs";
23
+ import { statePathOf } from "./otp-cli.mjs";
24
+ import { spendRecentCodes, withoutUser } from "./otp.mjs";
25
+
26
+ /** Run one `agent` subcommand. Returns the exit code; throws on a failure the dispatcher reports. */
27
+ export async function agentCommand(command, rest, config, d) {
28
+ const flags = rest.filter((a) => a.startsWith("-"));
29
+ const names = rest.filter((a) => !a.startsWith("-"));
30
+ const noMcp = flags.includes("--no-mcp-config");
31
+ if (command === "list") return list(config, d);
32
+ if (command === "sync") return sync(config, names[0], noMcp, d);
33
+ if (names.length !== 1) throw new Error(`name the agent user: sudo gh-broker agent ${command} <user>`);
34
+ return command === "add" ? add(config, names[0], noMcp, d) : remove(config, names[0], d);
35
+ }
36
+
37
+ const registry = (d) => registeredAgents(d.groups());
38
+
39
+ /**
40
+ * Write (or, with `noMcp`, just print) one agent's MCP config and explain the restart. Shared with
41
+ * the installer. Returns the lines it logged through `d.log`.
42
+ */
43
+ export function configureMcp(user, config, d, { noMcp = false } = {}) {
44
+ const dir = agentsDir(config.listen);
45
+ const entry = mcpEntry(user, dir);
46
+ const info = d.userInfo(user);
47
+ if (noMcp || !info) {
48
+ d.log(`${user}: not writing the MCP config${info ? " (--no-mcp-config)" : " (no such user)"}. Add this under "mcpServers" in the user's ~/.claude.json:`);
49
+ d.log(JSON.stringify({ github: entry }, null, "\t"));
50
+ return "skipped";
51
+ }
52
+ let r;
53
+ try {
54
+ r = applyMcpConfig({ home: info.home, user, dir, owner: { uid: info.uid, gid: info.gid } }, d.mcpIO);
55
+ } catch (err) {
56
+ // The home directory is the agent's, so a write there can fail on anything it puts in the
57
+ // way (a directory where the backup goes, say). Report it; it must not stop the caller.
58
+ d.error(`${user}: could not write the MCP config: ${err.message}. Add this under "mcpServers" in the user's ~/.claude.json by hand:`);
59
+ d.log(JSON.stringify({ github: entry }, null, "\t"));
60
+ return "failed";
61
+ }
62
+ if (r.status === "unsafe") {
63
+ d.error(`${user}: ${r.reason}, so it was left alone (root won't read or replace it). Make it a plain file the user owns and run "sudo gh-broker agent sync ${user}", or add this under "mcpServers" by hand:`);
64
+ d.log(JSON.stringify({ github: entry }, null, "\t"));
65
+ return "unsafe";
66
+ }
67
+ if (r.status === "unreadable") {
68
+ d.log(`${user}: ${r.path} is not valid JSON, so it was left alone. Add this under "mcpServers" by hand:`);
69
+ d.log(JSON.stringify({ github: entry }, null, "\t"));
70
+ return "unreadable";
71
+ }
72
+ d.log(`${user}: MCP config ${r.status === "unchanged" ? "already up to date" : r.status} in ${r.path} (entry "${r.key}")${r.status === "written" ? `; previous file saved as ${MCP_BACKUP}` : ""}`);
73
+ d.log(`${user}: restart the user's running Claude sessions to pick it up. If a running session overwrites the file, "sudo gh-broker agent sync ${user}" puts it back.`);
74
+ return r.status;
75
+ }
76
+
77
+ /** Smoke-test one agent's socket; logs the result and returns whether it answered. */
78
+ export async function smokeAgent(user, config, d) {
79
+ const sock = socketPath(agentsDir(config.listen), user);
80
+ try {
81
+ const r = await d.smoke(sock);
82
+ d.log(`${user}: broker answered on ${sock} (${r.tools.length} tools)`);
83
+ return true;
84
+ } catch (err) {
85
+ d.error(`${user}: smoke test failed on ${sock}: ${err.message}\n check: journalctl -u gh-broker -n 30 --no-pager`);
86
+ return false;
87
+ }
88
+ }
89
+
90
+ async function add(config, user, noMcp, d) {
91
+ assertAgentName(user);
92
+ if (!d.userInfo(user)) throw new Error(`user "${user}" does not exist; create it first (the installer can: sudo gh-broker install --agent-user ${user} --create-agent-user)`);
93
+ d.ensureGroup(user);
94
+ d.log(`${user}: registered (group ${agentGroup(user)})`);
95
+ configureMcp(user, config, d, { noMcp });
96
+ d.restart();
97
+ return (await smokeAgent(user, config, d)) ? 0 : 1;
98
+ }
99
+
100
+ function remove(config, user, d) {
101
+ assertAgentName(user);
102
+ if (!registry(d).some((a) => a.user === user)) throw new Error(`"${user}" is not a registered agent`);
103
+ // The group and the restart go first: they are what close the agent's socket, so nothing the
104
+ // agent can do to its own home directory (which the MCP cleanup below edits) can stop that.
105
+ d.removeGroup(user);
106
+ d.log(`${user}: unregistered (group ${agentGroup(user)} emptied; it is kept so its id is never given to another agent)`);
107
+ d.restart();
108
+ d.log("service restarted");
109
+ const info = d.userInfo(user);
110
+ if (info) {
111
+ try {
112
+ const r = removeMcpConfig({ home: info.home, dir: agentsDir(config.listen), uid: info.uid }, d.mcpIO);
113
+ const what = { removed: `removed the broker entry from ${r.path} (backup: ${MCP_BACKUP})`, absent: `no broker entry in ${r.path}`, "no-file": `no ${MCP_FILE} to edit`, unreadable: `${r.path} is not valid JSON, so it was left alone`, unsafe: `${r.reason}, so it was left alone` };
114
+ d.log(`${user}: ${what[r.status]}`);
115
+ } catch (err) {
116
+ d.error(`${user}: could not remove the broker entry from ${info.home}/${MCP_FILE}: ${err.message}. The agent is unregistered anyway; remove the entry by hand.`);
117
+ }
118
+ }
119
+ try {
120
+ const statePath = statePathOf(config);
121
+ const read = d.readState(statePath);
122
+ if (read.status === "ok" && Object.hasOwn(read.state.users, user)) {
123
+ d.writeState(statePath, spendRecentCodes(withoutUser(read.state, user), d.now()));
124
+ d.log(`${user}: cleared from ${statePath}`);
125
+ } else if (read.status === "corrupt") {
126
+ d.error(`${statePath} is corrupt, so ${user}'s entry was not cleared; run "sudo gh-broker otp unlock --all"`);
127
+ }
128
+ } catch (err) {
129
+ // The agent is already unregistered and its socket closed; a leftover entry is harmless.
130
+ d.error(`${user}: lock state not cleared: ${err.message}. "sudo gh-broker otp unlock ${user}" clears it later.`);
131
+ }
132
+ return 0;
133
+ }
134
+
135
+ function list(config, d) {
136
+ const dir = agentsDir(config.listen);
137
+ const agents = registry(d);
138
+ if (agents.length === 0) d.log("no agents registered (add one with: sudo gh-broker agent add <user>)");
139
+ for (const a of agents) {
140
+ const sock = socketPath(dir, a.user);
141
+ const info = d.userInfo(a.user);
142
+ let mcp = "no such user";
143
+ if (info) {
144
+ try {
145
+ mcp = mcpFileStatus({ home: info.home, user: a.user, dir, uid: info.uid }, d.mcpIO);
146
+ } catch (err) {
147
+ mcp = `can't read (${err.message})`; // one agent's file must not hide the others
148
+ }
149
+ }
150
+ d.log(`${a.user} ${sock} socket: ${d.socketExists(sock) ? "yes" : "no"} mcp: ${mcp}${a.complete ? "" : " (group members incomplete; run agent add)"}`);
151
+ }
152
+ const warn = versionWarning(d.versions().pkg, d.versions().deployed);
153
+ if (warn) d.error(warn);
154
+ return 0;
155
+ }
156
+
157
+ function sync(config, only, noMcp, d) {
158
+ const agents = registry(d).map((a) => a.user);
159
+ if (only) {
160
+ assertAgentName(only);
161
+ if (!agents.includes(only)) throw new Error(`"${only}" is not a registered agent`);
162
+ }
163
+ let ok = true;
164
+ for (const user of only ? [only] : agents) if (["failed", "unsafe", "unreadable"].includes(configureMcp(user, config, d, { noMcp }))) ok = false;
165
+ return ok ? 0 : 1;
166
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/agents.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
+ * The agent registry and the small pure helpers around it (#143, contract v2). An agent user `<u>`
18
+ * is registered when the group `gh-broker-agent-<u>` exists with exactly `gh-broker` and `<u>` as
19
+ * members; the daemon listens on one socket per registered agent. Everything here works on text
20
+ * (the contents of /etc/group, a `getent passwd` line), so it is testable without a host.
21
+ */
22
+ import { join } from "node:path";
23
+
24
+ export const SVC_USER = "gh-broker";
25
+ export const AGENT_GROUP_PREFIX = "gh-broker-agent-";
26
+ export const LEGACY_CLIENTS_GROUP = "gh-broker-clients";
27
+ export const DEFAULT_AGENTS_DIR = "/run/gh-broker/agents";
28
+ export const DEFAULT_LISTEN = `agents:${DEFAULT_AGENTS_DIR}`;
29
+ /** Keeps `gh-broker-agent-<u>` within the 32-character group name limit. */
30
+ export const AGENT_NAME = /^[a-z_][a-z0-9_-]{0,15}$/;
31
+
32
+ /** Names that fit the pattern but are refused, as the daemon refuses them (src/agents.ts): a special JS key, root, and the service user. */
33
+ export const RESERVED_AGENT_NAMES = ["__proto__", "root", SVC_USER];
34
+
35
+ /** Whether `name` may be registered as an agent user. */
36
+ export function validAgentName(name) {
37
+ return typeof name === "string" && AGENT_NAME.test(name) && !RESERVED_AGENT_NAMES.includes(name);
38
+ }
39
+
40
+ /** Throws a clear error for an invalid agent user name. */
41
+ export function assertAgentName(name) {
42
+ if (!validAgentName(name)) throw new Error(`"${name}" is not a valid agent user name (lowercase letters, digits, "_" and "-", starting with a letter or "_", at most 16 characters)`);
43
+ return name;
44
+ }
45
+
46
+ export const agentGroup = (user) => `${AGENT_GROUP_PREFIX}${user}`;
47
+
48
+ /** The directory the agents' sockets live in, from `listen` ("agents:<abs dir>"). */
49
+ export function agentsDir(listen) {
50
+ return typeof listen === "string" && /^agents:\/.+/.test(listen) ? listen.slice("agents:".length) : DEFAULT_AGENTS_DIR;
51
+ }
52
+
53
+ export const socketPath = (dir, user) => join(dir, `${user}.sock`);
54
+
55
+ /** Parse the contents of /etc/group (or `getent group` output) into { name, gid, members }. */
56
+ export function parseGroups(text) {
57
+ const groups = [];
58
+ for (const line of text.split("\n")) {
59
+ const parts = line.split(":");
60
+ if (parts.length < 4 || !parts[0]) continue;
61
+ groups.push({ name: parts[0], gid: Number(parts[2]), members: parts[3] ? parts[3].split(",").filter(Boolean) : [] });
62
+ }
63
+ return groups;
64
+ }
65
+
66
+ /**
67
+ * Every `gh-broker-agent-<u>` group, with whether it is complete: its members are exactly the
68
+ * service user and `<u>`, the same test the daemon uses (it ignores a group that isn't). A group
69
+ * with no members is a removed agent (see removeAgentGroup) and isn't listed.
70
+ * @returns {Array<{ user: string, gid: number, complete: boolean }>} sorted by user
71
+ */
72
+ export function registeredAgents(groups) {
73
+ const out = [];
74
+ for (const g of groups) {
75
+ if (!g.name.startsWith(AGENT_GROUP_PREFIX)) continue;
76
+ const user = g.name.slice(AGENT_GROUP_PREFIX.length);
77
+ if (!validAgentName(user) || g.members.length === 0) continue;
78
+ const members = new Set(g.members);
79
+ out.push({ user, gid: g.gid, complete: members.size === 2 && members.has(SVC_USER) && members.has(user) });
80
+ }
81
+ return out.sort((a, b) => a.user.localeCompare(b.user));
82
+ }
83
+
84
+ /**
85
+ * The agents to register on upgrade: members of the old shared `gh-broker-clients` group (other
86
+ * than the service user) that are not registered yet. Members with an invalid name are returned
87
+ * separately, to be reported and skipped.
88
+ */
89
+ export function migrationPlan(groups, registered) {
90
+ const clients = groups.find((g) => g.name === LEGACY_CLIENTS_GROUP);
91
+ const have = new Set(registered.map((a) => a.user));
92
+ const add = [];
93
+ const skipped = [];
94
+ for (const m of clients?.members ?? []) {
95
+ if (m === SVC_USER || have.has(m)) continue;
96
+ (validAgentName(m) ? add : skipped).push(m);
97
+ }
98
+ return { add, skipped };
99
+ }
100
+
101
+ /** Parse one `getent passwd` line into { uid, gid, home }, or undefined. */
102
+ export function parsePasswd(line) {
103
+ const f = line.trim().split(":");
104
+ if (f.length < 6) return undefined;
105
+ const uid = Number(f[2]);
106
+ const gid = Number(f[3]);
107
+ if (!Number.isInteger(uid) || !Number.isInteger(gid) || !f[5]) return undefined;
108
+ return { uid, gid, home: f[5] };
109
+ }
110
+
111
+ /**
112
+ * Where the installed package stands: a global install under /usr/local survives a Node upgrade,
113
+ * one under an nvm version folder does not.
114
+ * @returns {"global" | "nvm" | "checkout"}
115
+ */
116
+ export function classifyInstall(packageDir) {
117
+ if (/nvm\/versions\//.test(packageDir)) return "nvm";
118
+ if (packageDir.startsWith("/usr/local/lib/node_modules/")) return "global";
119
+ return "checkout";
120
+ }
121
+
122
+ /** A warning when the npm package and the deployed /opt copy differ, else undefined. */
123
+ export function versionWarning(pkgVersion, deployedVersion) {
124
+ if (!deployedVersion || !pkgVersion || pkgVersion === deployedVersion) return undefined;
125
+ return `warning: the installed package is ${pkgVersion} but the deployed service is ${deployedVersion}; run "sudo gh-broker install"`;
126
+ }
127
+
128
+ export const NPM_INSTALL_COMMAND = "sudo npm install -g --prefix /usr/local --ignore-scripts @cldmv/gh-broker@latest";
@@ -0,0 +1,72 @@
1
+ /**
2
+ *
3
+ * @Project: @cldmv/gh-broker
4
+ * @Filename: /deploy/lib/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
+ * The `gh-broker` command's dispatcher: `otp ...` and `agent ...` (and `install`, which
18
+ * deploy/cli.mjs hands to the installer before it gets here). Root check, config read and error
19
+ * reporting live here; the commands are in otp-cli.mjs and agent-cli.mjs.
20
+ */
21
+ import { otpCommand } from "./otp-cli.mjs";
22
+ import { agentCommand } from "./agent-cli.mjs";
23
+
24
+ export const USAGE = `Usage: sudo gh-broker <command>
25
+
26
+ Commands:
27
+ install [flags] install or upgrade the broker (see: gh-broker install --help)
28
+ agent add <user> register an agent user: group, MCP config, restart, smoke test (--no-mcp-config to skip the config)
29
+ agent remove <user> unregister an agent: empty its group, restart, MCP entry, lock state
30
+ agent list each agent, its socket, and whether its MCP config points at it
31
+ agent sync [<user>] re-apply the MCP config for one agent or all
32
+ otp status [<user>] [--json]
33
+ lock state per agent
34
+ otp list [--json] who is locked out now, and the command that clears each
35
+ otp unlock <user>|--all clear an agent's lockout and hard lock (--all: everyone)
36
+ otp rotate make a new OTP seed, store it, show it once, restart the service`;
37
+
38
+ const COMMANDS = { otp: ["unlock", "status", "list", "rotate"], agent: ["add", "remove", "list", "sync"] };
39
+
40
+ /**
41
+ * @param {string[]} argv arguments after the program name, e.g. ["otp", "unlock", "repo"]
42
+ * @param {object} d injected effects (see deploy/cli.mjs realDeps)
43
+ * @returns {Promise<number>} the process exit code
44
+ */
45
+ export async function runCli(argv, d) {
46
+ const [group, command, ...rest] = argv;
47
+ if (group === undefined || group === "-h" || group === "--help" || group === "help") {
48
+ d.log(USAGE);
49
+ return 0;
50
+ }
51
+ if (!COMMANDS[group]?.includes(command)) {
52
+ d.error(`gh-broker: unknown command "${argv.join(" ")}"\n${USAGE}`);
53
+ return 2;
54
+ }
55
+ if (!d.isRoot()) {
56
+ d.error(`gh-broker ${group} ${command}: must run as root: sudo gh-broker ${group} ${command}`);
57
+ return 1;
58
+ }
59
+ let config;
60
+ try {
61
+ config = d.readConfig();
62
+ } catch (err) {
63
+ d.error(`gh-broker ${group} ${command}: cannot read the broker config: ${err.message}`);
64
+ return 1;
65
+ }
66
+ try {
67
+ return await (group === "otp" ? otpCommand : agentCommand)(command, rest, config, d);
68
+ } catch (err) {
69
+ d.error(`gh-broker ${group} ${command}: ${err.message}`);
70
+ return 1;
71
+ }
72
+ }