@cldmv/gh-broker 0.10.0 → 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.
- package/README.md +7 -8
- package/deploy/README.md +11 -1
- package/deploy/install.mjs +5 -1
- package/deploy/lib/agent-cli.mjs +156 -20
- package/deploy/lib/cli.mjs +1 -0
- package/deploy/lib/config-merge.mjs +36 -4
- package/deploy/lib/install-args.mjs +7 -0
- package/deploy/lib/mcp-config.mjs +142 -17
- package/deploy/real-deps.mjs +7 -2
- package/dist/index.mjs +120 -27825
- package/package.json +14 -5
- package/dist/index.mjs.map +0 -7
|
@@ -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
|
|
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
|
|
189
|
+
* Set `user`'s entry in the `.claude.json` in `folder`.
|
|
163
190
|
* @param {object} o
|
|
164
|
-
* @param {string} o.
|
|
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
|
|
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 {
|
|
169
|
-
* @
|
|
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({
|
|
172
|
-
const path = `${
|
|
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(`${
|
|
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
|
|
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({
|
|
195
|
-
const path = `${
|
|
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(`${
|
|
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 `
|
|
208
|
-
export function mcpFileStatus({
|
|
209
|
-
const got = load(io, `${
|
|
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) });
|
package/deploy/real-deps.mjs
CHANGED
|
@@ -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:
|
|
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() }),
|