@cldmv/gh-broker 0.10.1 → 0.10.2

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.
@@ -15,13 +15,16 @@
15
15
 
16
16
  /**
17
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
18
+ * the top-level `mcpServers` of `<home>/.claude.json`, and of the `.claude.json` in each other
19
+ * Claude config folder the user has (#163: a session started with `CLAUDE_CONFIG_DIR` reads only
20
+ * that folder's file); nothing else in those files is touched. The
19
21
  * pure functions build the entry, pick its key and edit the JSON text; `applyMcpConfig` and
20
22
  * `removeMcpConfig` do the file work (backup, atomic write that keeps the owner and mode, and a
21
23
  * read-back check) with every filesystem effect injected. Root does that work in a directory the
22
24
  * agent user owns, so `readOwnedFile` refuses anything but the user's own plain file.
23
25
  */
24
- import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
26
+ import { closeSync, constants, fstatSync, openSync, readdirSync, readlinkSync, readSync, realpathSync, statSync } from "node:fs";
27
+ import { basename } from "node:path";
25
28
  import { agentGroup, socketPath } from "./agents.mjs";
26
29
 
27
30
  export const MCP_FILE = ".claude.json";
@@ -34,6 +37,30 @@ export const MAX_CONFIG_BYTES = 16 * 1024 * 1024;
34
37
  /** A file root must not read or replace: a symlink, not a regular file, not the user's, hard-linked, or oversized. */
35
38
  export class UnsafeFile extends Error {}
36
39
 
40
+ /** A config folder root won't work in (a symlink, not the agent's, outside the agent's home). It is skipped with a warning, not an error. */
41
+ export class SkippedFolder extends Error {}
42
+
43
+ /** Most of `/proc/<pid>/cmdline` read (only argv[0] and argv[1] are looked at). */
44
+ export const MAX_CMDLINE_BYTES = 64 * 1024;
45
+ /** Most of `/proc/<pid>/environ` read: the agent sets its own environment's size, and root reads it. */
46
+ export const MAX_ENVIRON_BYTES = 1024 * 1024;
47
+
48
+ /** Up to `max` bytes of `path`. */
49
+ export function readHead(path, max) {
50
+ const fd = openSync(path, constants.O_RDONLY | constants.O_NONBLOCK);
51
+ try {
52
+ const buf = Buffer.alloc(max);
53
+ let length = 0;
54
+ for (let n; length < max && (n = readSync(fd, buf, length, max - length, null)) > 0; ) length += n;
55
+ return buf.toString("utf8", 0, length);
56
+ } finally {
57
+ closeSync(fd);
58
+ }
59
+ }
60
+
61
+ /** Whether a process's argv is a Claude Code session: the `claude` binary, or node running the claude-code package. */
62
+ const isClaude = (argv) => argv.slice(0, 2).some((a) => basename(a) === "claude" || a.includes("/@anthropic-ai/claude-code/"));
63
+
37
64
  /**
38
65
  * Read `path` as root on behalf of the user `uid`, without following a symlink: the file must be
39
66
  * 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
@@ -159,27 +186,29 @@ export function dropEntries(text, dir) {
159
186
  }
160
187
 
161
188
  /**
162
- * Set `user`'s entry in their `.claude.json`.
189
+ * Set `user`'s entry in the `.claude.json` in `folder`.
163
190
  * @param {object} o
164
- * @param {string} o.home the agent user's home directory
191
+ * @param {string} o.folder the folder holding the file: the agent user's home, or a Claude config folder
165
192
  * @param {string} o.user
166
- * @param {string} o.dir the agents' socket directory
193
+ * @param {string} o.dir the agents' socket directory
167
194
  * @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 }}
195
+ * @param {boolean} [o.create=true] create the file when there is none; when false that is "no-file"
196
+ * @param {object} io { read(path, uid) -> { text, stat: { uid, gid, mode } } | undefined (see readOwnedFile), write(path, text, { uid, gid, mode }) }
197
+ * @returns {{ status: "created" | "written" | "unchanged" | "unreadable" | "unsafe" | "no-file", key?: string, reason?: string, entry: object, path: string }}
170
198
  */
171
- export function applyMcpConfig({ home, user, dir, owner }, io) {
172
- const path = `${home}/${MCP_FILE}`;
199
+ export function applyMcpConfig({ folder, user, dir, owner, create = true }, io) {
200
+ const path = `${folder}/${MCP_FILE}`;
173
201
  const entry = mcpEntry(user, dir);
174
202
  const before = load(io, path, owner.uid);
175
203
  if (before?.unsafe) return { status: "unsafe", reason: before.unsafe, entry, path };
204
+ if (before === undefined && !create) return { status: "no-file", entry, path };
176
205
  const merged = mergeEntry(before?.text, user, dir);
177
206
  if (merged.status === "unreadable") return { status: "unreadable", entry, path };
178
207
  if (merged.status === "unchanged") return { status: "unchanged", key: merged.key, entry, path };
179
208
  let target = { ...owner, mode: 0o600 };
180
209
  if (before !== undefined) {
181
210
  target = { uid: before.stat.uid, gid: before.stat.gid, mode: before.stat.mode & 0o777 };
182
- io.write(`${home}/${MCP_BACKUP}`, before.text, target);
211
+ io.write(`${folder}/${MCP_BACKUP}`, before.text, target);
183
212
  }
184
213
  io.write(path, merged.text, target);
185
214
  const after = load(io, path, owner.uid);
@@ -188,24 +217,120 @@ export function applyMcpConfig({ home, user, dir, owner }, io) {
188
217
  }
189
218
 
190
219
  /**
191
- * Remove the broker entry from `user`'s `.claude.json`, backing the file up first.
220
+ * Remove the broker entry from the `.claude.json` in `folder`, backing the file up first.
192
221
  * @returns {{ status: "removed" | "absent" | "no-file" | "unreadable" | "unsafe", keys?: string[], reason?: string, path: string }}
193
222
  */
194
- export function removeMcpConfig({ home, dir, uid }, io) {
195
- const path = `${home}/${MCP_FILE}`;
223
+ export function removeMcpConfig({ folder, dir, uid }, io) {
224
+ const path = `${folder}/${MCP_FILE}`;
196
225
  const before = load(io, path, uid);
197
226
  if (before === undefined) return { status: "no-file", path };
198
227
  if (before.unsafe) return { status: "unsafe", reason: before.unsafe, path };
199
228
  const dropped = dropEntries(before.text, dir);
200
229
  if (dropped.status !== "removed") return { status: dropped.status, path };
201
230
  const target = { uid: before.stat.uid, gid: before.stat.gid, mode: before.stat.mode & 0o777 };
202
- io.write(`${home}/${MCP_BACKUP}`, before.text, target);
231
+ io.write(`${folder}/${MCP_BACKUP}`, before.text, target);
203
232
  io.write(path, dropped.text, target);
204
233
  return { status: "removed", keys: dropped.keys, path };
205
234
  }
206
235
 
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);
236
+ /** The MCP status of the file in `folder` for `agent list`: mcpStatus, "unsafe (<reason>)", or `ifAbsent` when there is no file and that is given. */
237
+ export function mcpFileStatus({ folder, user, dir, uid, ifAbsent }, io) {
238
+ const got = load(io, `${folder}/${MCP_FILE}`, uid);
239
+ if (got === undefined && ifAbsent !== undefined) return ifAbsent;
210
240
  return got?.unsafe ? `unsafe (${got.unsafe})` : mcpStatus(got?.text, user, dir);
211
241
  }
242
+
243
+ /**
244
+ * The folders in `home` that may be Claude config folders: `.claude*` entries that are real
245
+ * directories (a symlink is skipped; readdir's entry types don't follow it). Whether one holds a
246
+ * `.claude.json` is decided when it's opened, through pinFolder.
247
+ */
248
+ export function scanConfigFolders(home, { readdir = readdirSync } = {}) {
249
+ let entries;
250
+ try {
251
+ entries = readdir(home, { withFileTypes: true });
252
+ } catch {
253
+ return []; // no home directory, or root can't list it: only ~/.claude.json is tried
254
+ }
255
+ return entries.filter((e) => e.isDirectory() && e.name.startsWith(".claude")).map((e) => `${home}/${e.name}`).sort();
256
+ }
257
+
258
+ /**
259
+ * The absolute `CLAUDE_CONFIG_DIR` of every `claude` process `uid` is running, read from `proc`.
260
+ * Only root can read another user's environ, which is the case here. The values come from the
261
+ * agent, so pinFolder checks each one before anything is read there.
262
+ */
263
+ export function sessionConfigFolders(uid, { proc = "/proc", fs = { readdirSync, statSync, readHead } } = {}) {
264
+ const found = new Set();
265
+ let pids;
266
+ try {
267
+ pids = fs.readdirSync(proc).filter((n) => /^\d+$/.test(n));
268
+ } catch {
269
+ return [];
270
+ }
271
+ for (const pid of pids) {
272
+ try {
273
+ if (fs.statSync(`${proc}/${pid}`).uid !== uid) continue;
274
+ const argv = fs.readHead(`${proc}/${pid}/cmdline`, MAX_CMDLINE_BYTES).split("\0");
275
+ if (!isClaude(argv)) continue;
276
+ const env = fs.readHead(`${proc}/${pid}/environ`, MAX_ENVIRON_BYTES).split("\0");
277
+ const value = env.find((e) => e.startsWith("CLAUDE_CONFIG_DIR="))?.slice("CLAUDE_CONFIG_DIR=".length).replace(/\/+$/, "");
278
+ if (value?.startsWith("/")) found.add(value);
279
+ } catch {
280
+ // the process exited while being read
281
+ }
282
+ }
283
+ return [...found].sort();
284
+ }
285
+
286
+ /**
287
+ * Open `folder` as a directory owned by `uid`, without following a symlink, and return an io that
288
+ * reaches the files in it through the open descriptor (`/proc/self/fd/<n>/...`), so the agent
289
+ * can't swap the folder for a symlink between the check and the write. With `within` (the real
290
+ * path of the agent's home), the opened folder must also resolve inside it: owning a folder
291
+ * doesn't mean the agent can reach it (one inside another user's private directory, named through
292
+ * `..` or a symlinked parent), and root shouldn't write where the agent couldn't. Error messages
293
+ * keep the folder's own path. Throws SkippedFolder for a folder root won't use; returns undefined
294
+ * when there is no such folder.
295
+ * @returns {{ io: object, close: () => void } | undefined}
296
+ */
297
+ export function pinFolder(folder, uid, io, { within, open = openSync, fstat = fstatSync, close = closeSync, readlink = readlinkSync } = {}) {
298
+ let fd;
299
+ try {
300
+ fd = open(folder, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
301
+ } catch (err) {
302
+ if (err.code === "ENOENT") return undefined;
303
+ if (err.code === "ELOOP" || err.code === "ENOTDIR") throw new SkippedFolder(`${folder} is a symlink or not a directory`);
304
+ throw err;
305
+ }
306
+ const base = `/proc/self/fd/${fd}`;
307
+ const refuse = (why) => {
308
+ close(fd);
309
+ throw new SkippedFolder(why);
310
+ };
311
+ const uidOf = fstat(fd).uid;
312
+ if (uidOf !== uid) refuse(`${folder} is owned by uid ${uidOf}, not the agent user (uid ${uid})`);
313
+ if (within !== undefined) {
314
+ const real = readlink(base);
315
+ if (real !== within && !real.startsWith(`${within}/`)) refuse(`${folder} is outside the agent's home directory`);
316
+ }
317
+ const at = (p) => base + p.slice(folder.length);
318
+ const named = (fn) => {
319
+ try {
320
+ return fn();
321
+ } catch (err) {
322
+ if (err instanceof Error) err.message = err.message.split(base).join(folder);
323
+ throw err;
324
+ }
325
+ };
326
+ return {
327
+ io: { read: (p, u) => named(() => io.read(at(p), u)), write: (p, text, owner) => named(() => io.write(at(p), text, owner)) },
328
+ close: () => close(fd)
329
+ };
330
+ }
331
+
332
+ /**
333
+ * The `pin` effect for an io: pinFolder, held inside the real path of `home` when one is given
334
+ * (a found folder) and not when it isn't (a folder root saved with --claude-config).
335
+ */
336
+ export const pinner = (io, { realpath = realpathSync } = {}) => (folder, uid, home) => pinFolder(folder, uid, io, { within: home === undefined ? undefined : realpath(home) });
@@ -24,7 +24,7 @@ import { join, dirname } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { parseGroups, parsePasswd } from "./lib/agents.mjs";
26
26
  import { stateStore, writeFileAtomic, storeSeed } from "./lib/otp.mjs";
27
- import { readOwnedFile } from "./lib/mcp-config.mjs";
27
+ import { pinner, readOwnedFile, scanConfigFolders, sessionConfigFolders } from "./lib/mcp-config.mjs";
28
28
  import { readSecretsFile, writeSecretsFile } from "./lib/secret-store.mjs";
29
29
  import { smokeTest } from "./lib/smoke.mjs";
30
30
  import { run, isRoot, requireUid, requireGid, systemdCredsEncrypt, writeCredsDropin, restartService, ensureAgentGroup, removeAgentGroup } from "./lib/system.mjs";
@@ -62,6 +62,8 @@ export function realDeps() {
62
62
  isRoot,
63
63
  host: () => hostname(),
64
64
  readConfig: () => JSON.parse(readFileSync(configPath(), "utf8")),
65
+ // Atomic: agent commands now write the live config, and a torn file would stop the daemon.
66
+ writeConfig: (cfg) => writeFileAtomic(configPath(), JSON.stringify(cfg, null, "\t") + "\n", { uid: 0, gid: requireGid(SVC), mode: 0o640 }),
65
67
  groups: () => parseGroups(readText("/etc/group") ?? ""),
66
68
  userInfo: (user) => {
67
69
  const r = run("getent", ["passwd", user], { allowFail: true });
@@ -69,7 +71,10 @@ export function realDeps() {
69
71
  },
70
72
  ensureGroup: (user) => ensureAgentGroup(user, SVC),
71
73
  removeGroup: removeAgentGroup,
72
- mcpIO: { read: readOwnedFile, write: (p, text, owner) => writeFileAtomic(p, text, owner) },
74
+ mcpIO: (() => {
75
+ const io = { read: readOwnedFile, write: (p, text, owner) => writeFileAtomic(p, text, owner) };
76
+ return { ...io, scan: (home) => scanConfigFolders(home), sessions: (uid) => sessionConfigFolders(uid), pin: pinner(io) };
77
+ })(),
73
78
  socketExists: existsSync,
74
79
  smoke: smokeTest,
75
80
  versions: () => ({ pkg: JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version, deployed: deployedVersion() }),