@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
package/README.md
CHANGED
|
@@ -19,19 +19,18 @@ It runs as a dedicated `gh-broker` system user, holds a read-scoped and a write-
|
|
|
19
19
|
|
|
20
20
|
## ✨ What's New
|
|
21
21
|
|
|
22
|
-
### Latest: v0.10.
|
|
22
|
+
### Latest: v0.10.2 (November 2026)
|
|
23
23
|
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
-
|
|
27
|
-
- [View full v0.10.0 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.0.md)
|
|
24
|
+
- **The MCP entry reaches every Claude config folder an agent uses.** Sessions started with `CLAUDE_CONFIG_DIR` (like `~/.claude-secondary`) were left on the removed shared socket. `install` and `agent sync` now update every `~/.claude*` folder in the agent's home, the folder of each running session, and folders saved with `--claude-config`.
|
|
25
|
+
- **Smaller package.** The 1.4 MB source map no longer ships, and the bundle is built minified with tsup, taking the package from 3.2 MB to about 780 kB unpacked. The Node.js floor is back to 22.0.0.
|
|
26
|
+
- [View full v0.10.2 Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.2.md)
|
|
28
27
|
|
|
29
28
|
### Recent Releases
|
|
30
29
|
|
|
30
|
+
- **v0.10.1** (November 2026) — the OTP gate says why it refused a code, and `report_dependents` lists dev branches on large orgs without a 502 ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.1.md))
|
|
31
|
+
- **v0.10.0** (November 2026) — OTP gate for risky write tools, one socket per agent with the `gh-broker` command, eleven new write tools and a built-in deny list ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.10.0.md))
|
|
31
32
|
- **v0.9.0** (November 2026) — `report_alert_status`, `report_release_changes` and `report_dependents` for the release and fleet skills, `ciVerbosity` on `list_pull_requests`, and install from npm ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.9.0.md))
|
|
32
33
|
- **v0.8.0** (November 2026) — `npm_scaffold` creates an npm package and its trusted publisher, `ghcr_push` pushes images without an agent-held token, and optional credentials hide their tools ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.8.0.md))
|
|
33
|
-
- **v0.7.0** (October 2026) — `issue_labels` adds, removes or swaps single labels without replacing the set, and `issue_write` warns that its `labels` does ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.7.0.md))
|
|
34
|
-
- **v0.6.1** (October 2026) — MCP SDK 1.32.0 (same-origin redirects only) and a refreshed vitest toolchain; no broker source changes ([Changelog](https://github.com/CLDMV/gh-broker/blob/master/docs/changelog/v0/v0.6.1.md))
|
|
35
34
|
|
|
36
35
|
📚 For the complete version history, see the [docs/changelog/](https://github.com/CLDMV/gh-broker/tree/master/docs/changelog/) folder.
|
|
37
36
|
|
|
@@ -144,7 +143,7 @@ The broker presents its **own** MCP contract to the agent — a proxied tool's s
|
|
|
144
143
|
### Requirements
|
|
145
144
|
|
|
146
145
|
- **Linux with systemd.** The broker runs as a hardened systemd service and listens on a Unix socket.
|
|
147
|
-
- **Node.js 22.
|
|
146
|
+
- **Node.js 22.0.0 or later** (`engines.node` is `>=22.0.0`; development needs 22.22.0+). The installer points `/usr/local/bin/node` at the node it runs under.
|
|
148
147
|
- **The agent runs as its own Linux user**, without passwordless sudo, so it can't read the broker's secrets. The model behind that is in [docs/agent-user-model.md](docs/agent-user-model.md).
|
|
149
148
|
- **Two fine-grained PATs per org** (read and write). The scopes are listed in [deploy/README.md](deploy/README.md).
|
|
150
149
|
|
package/deploy/README.md
CHANGED
|
@@ -148,7 +148,17 @@ sudo gh-broker agent sync [<user>] # re-apply the MCP config for one agent or
|
|
|
148
148
|
}
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
If an existing entry already mentions `/run/gh-broker/` (the old shared socket or the new one) that entry is updated in place and keeps its name; otherwise the entry is added as `github`, or `gh-broker` when that name is taken. Nothing else in the file changes. The file is parsed, edited, written to a temp file in the same directory and renamed, keeping its owner and mode, and `~/.claude.json.gh-broker.bak` keeps the previous copy (overwritten each time). A missing file is created `0600`; a file that doesn't parse is left alone and the entry is printed to add by hand. The home directory belongs to the agent user and the command runs as root, so it reads the file without following a symlink and only touches a plain file the user owns with a single link; anything else (a symlink, a hard link, someone else's file) is left alone with a warning, and the temp file is created with a random name that can't be pre-planted. Running Claude sessions for that user must be restarted to pick the change up, and `agent sync <user>` puts the entry back if a running session overwrote the file.
|
|
151
|
+
If an existing entry already mentions `/run/gh-broker/` (the old shared socket or the new one) that entry is updated in place and keeps its name; otherwise the entry is added as `github`, or `gh-broker` when that name is taken. Nothing else in the file changes. The file is parsed, edited, written to a temp file in the same directory and renamed, keeping its owner and mode, and `~/.claude.json.gh-broker.bak` keeps the previous copy (overwritten each time). A missing file is created `0600`; a file that doesn't parse is left alone and the entry is printed to add by hand. The home directory belongs to the agent user and the command runs as root, so it reads the file without following a symlink and only touches a plain file the user owns with a single link; anything else (a symlink, a hard link, someone else's file) is left alone with a warning, and the temp file is created with a random name that can't be pre-planted. Running Claude sessions for that user must be restarted to pick the change up, and `agent sync <user>` puts the entry back if a running session overwrote the file.
|
|
152
|
+
|
|
153
|
+
**Other Claude config folders.** A Claude session started with `CLAUDE_CONFIG_DIR` reads `$CLAUDE_CONFIG_DIR/.claude.json` and ignores `~/.claude.json`, so the entry also goes into the `.claude.json` of the agent's other Claude config folders. Only registered agents' folders are looked at:
|
|
154
|
+
|
|
155
|
+
- each `~/.claude*` directory in the agent's home;
|
|
156
|
+
- the `CLAUDE_CONFIG_DIR` of each `claude` process the agent is running, as long as it resolves inside the agent's home;
|
|
157
|
+
- folders saved for the agent with `--claude-config` (`sudo gh-broker agent add|sync <user> --claude-config <dir>`, or `sudo gh-broker install --claude-config <user>:<dir>`). They're kept in `claudeConfigDirs` in the broker config, so every later sync updates them. This is how a folder outside the home directory is added.
|
|
158
|
+
|
|
159
|
+
In these folders the file is only updated when it already exists (Claude Code creates it on first run). The folder must be a real directory the agent owns, not a symlink; anything else is skipped with a warning and doesn't fail the run. The folder is opened once and the file is edited through that open folder, so it can't be swapped mid-way. A session folder outside the home directory is skipped, because owning a folder doesn't mean the agent can reach it; save it with `--claude-config` if it's meant to be used. `agent remove` cleans the entry out of every folder it finds, and forgets the saved ones. A session folder that isn't running at that moment and isn't saved keeps its entry, which then points at a closed socket.
|
|
160
|
+
|
|
161
|
+
`--no-mcp-config` (on `install`, `agent add` and `agent sync`) skips the write and prints the entry.
|
|
152
162
|
|
|
153
163
|
**Upgrading from the shared socket.** The first `gh-broker install` after the change registers every member of `gh-broker-clients` (other than `gh-broker`) as an agent, writes the new `listen` (`agents:/run/gh-broker/agents`), rewrites each agent's MCP config to its own socket and smoke-tests every socket. `gh-broker-clients` is left in place; nothing depends on it any more. Agents added later need a service restart, which `agent add` does.
|
|
154
164
|
|
package/deploy/install.mjs
CHANGED
|
@@ -88,7 +88,11 @@ Flags:
|
|
|
88
88
|
Each agent gets its own socket and MCP config. Optional on an
|
|
89
89
|
upgrade (registered agents are kept); a first install with none
|
|
90
90
|
asks (never $SUDO_USER)
|
|
91
|
-
--no-mcp-config do not write any agent's
|
|
91
|
+
--no-mcp-config do not write any agent's Claude MCP config; print the entry instead
|
|
92
|
+
--claude-config <user>:<dir>
|
|
93
|
+
another Claude config folder (CLAUDE_CONFIG_DIR) that agent uses,
|
|
94
|
+
kept in the config so every sync updates it (repeatable). Folders
|
|
95
|
+
in the agent's home are found without it
|
|
92
96
|
--secrets-provider <p> systemd-creds | file (skips the prompt)
|
|
93
97
|
--secrets-file <path> JSON { "<cred-name>": "<token>" } to import tokens from
|
|
94
98
|
--secret <name>=<tok> one token on the command line (repeatable). Visible in
|
package/deploy/lib/agent-cli.mjs
CHANGED
|
@@ -16,26 +16,108 @@
|
|
|
16
16
|
/**
|
|
17
17
|
* `gh-broker agent add | remove | list | sync` (#143, contract v2). An agent is a Linux group
|
|
18
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.
|
|
19
|
+
* and an entry in the agent user's Claude Code MCP config: `~/.claude.json`, plus the
|
|
20
|
+
* `.claude.json` in each other Claude config folder the user has (#163). Every effect is injected.
|
|
20
21
|
*/
|
|
21
22
|
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 { applyMcpConfig, mcpEntry, mcpFileStatus, removeMcpConfig, SkippedFolder, MCP_BACKUP, MCP_FILE } from "./mcp-config.mjs";
|
|
23
24
|
import { statePathOf } from "./otp-cli.mjs";
|
|
25
|
+
import { absoluteFolders, addClaudeConfigDirs } from "./config-merge.mjs";
|
|
24
26
|
import { spendRecentCodes, withoutUser } from "./otp.mjs";
|
|
25
27
|
|
|
26
28
|
/** Run one `agent` subcommand. Returns the exit code; throws on a failure the dispatcher reports. */
|
|
27
29
|
export async function agentCommand(command, rest, config, d) {
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
const noMcp = flags.includes("--no-mcp-config");
|
|
30
|
+
const { names, noMcp, claudeConfigs } = parseAgentArgs(rest);
|
|
31
|
+
if (claudeConfigs.length && !["add", "sync"].includes(command)) throw new Error("--claude-config only goes with agent add or agent sync");
|
|
31
32
|
if (command === "list") return list(config, d);
|
|
32
|
-
if (command === "sync")
|
|
33
|
+
if (command === "sync") {
|
|
34
|
+
if (claudeConfigs.length && names.length !== 1) throw new Error("--claude-config needs the agent named: sudo gh-broker agent sync <user> --claude-config <dir>");
|
|
35
|
+
if (claudeConfigs.length) {
|
|
36
|
+
assertAgentName(names[0]);
|
|
37
|
+
if (!registry(d).some((a) => a.user === names[0])) throw new Error(`"${names[0]}" is not a registered agent`);
|
|
38
|
+
config = rememberFolders(config, names[0], claudeConfigs, d);
|
|
39
|
+
}
|
|
40
|
+
return sync(config, names[0], noMcp, d);
|
|
41
|
+
}
|
|
33
42
|
if (names.length !== 1) throw new Error(`name the agent user: sudo gh-broker agent ${command} <user>`);
|
|
34
|
-
|
|
43
|
+
if (command === "add") {
|
|
44
|
+
assertAgentName(names[0]);
|
|
45
|
+
if (!d.userInfo(names[0])) throw new Error(`user "${names[0]}" does not exist; create it first (the installer can: sudo gh-broker install --agent-user ${names[0]} --create-agent-user)`);
|
|
46
|
+
if (claudeConfigs.length) config = rememberFolders(config, names[0], claudeConfigs, d);
|
|
47
|
+
return add(config, names[0], noMcp, d);
|
|
48
|
+
}
|
|
49
|
+
return remove(config, names[0], d);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Split `agent` arguments into names and flags; `--claude-config <dir>` (or `=<dir>`) is repeatable. */
|
|
53
|
+
function parseAgentArgs(rest) {
|
|
54
|
+
const names = [];
|
|
55
|
+
const claudeConfigs = [];
|
|
56
|
+
let noMcp = false;
|
|
57
|
+
for (let i = 0; i < rest.length; i++) {
|
|
58
|
+
const a = rest[i];
|
|
59
|
+
if (a === "--no-mcp-config") noMcp = true;
|
|
60
|
+
else if (a === "--claude-config" || a.startsWith("--claude-config=")) {
|
|
61
|
+
const dir = a === "--claude-config" ? rest[++i] : a.slice("--claude-config=".length);
|
|
62
|
+
if (!dir?.startsWith("/")) throw new Error("--claude-config takes an absolute folder: --claude-config /path/to/claude-config");
|
|
63
|
+
claudeConfigs.push(dir.replace(/(.)\/+$/, "$1"));
|
|
64
|
+
} else if (a.startsWith("-")) throw new Error(`unknown flag: ${a}`);
|
|
65
|
+
else names.push(a);
|
|
66
|
+
}
|
|
67
|
+
return { names, noMcp, claudeConfigs };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Add `dirs` to `user`'s saved Claude config folders and write the broker config. Returns the new config. */
|
|
71
|
+
function rememberFolders(config, user, dirs, d) {
|
|
72
|
+
const next = { ...config, claudeConfigDirs: addClaudeConfigDirs(config.claudeConfigDirs, { [user]: dirs }) };
|
|
73
|
+
d.writeConfig(next);
|
|
74
|
+
d.log(`${user}: Claude config folders kept in the broker config: ${next.claudeConfigDirs[user].join(", ")}`);
|
|
75
|
+
return next;
|
|
35
76
|
}
|
|
36
77
|
|
|
37
78
|
const registry = (d) => registeredAgents(d.groups());
|
|
38
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The agent's Claude config folders other than the home directory (#163): the ones saved for it
|
|
82
|
+
* with --claude-config, its `~/.claude*` folders, and the `CLAUDE_CONFIG_DIR` of its running
|
|
83
|
+
* `claude` sessions. Only these users' own folders are looked at, never anyone else's. Saved
|
|
84
|
+
* folders were named by root, so they may be outside the home directory; found ones may not.
|
|
85
|
+
* @returns {Array<{ folder: string, saved: boolean }>}
|
|
86
|
+
*/
|
|
87
|
+
function extraFolders(info, d, config, user) {
|
|
88
|
+
const home = info.home.replace(/\/+$/, "");
|
|
89
|
+
const saved = absoluteFolders(config.claudeConfigDirs?.[user]);
|
|
90
|
+
const found = [...(d.mcpIO.scan?.(info.home) ?? []), ...(d.mcpIO.sessions?.(info.uid) ?? [])];
|
|
91
|
+
const seen = new Set([home, info.home]);
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const [list, isSaved] of [[saved, true], [found, false]]) {
|
|
94
|
+
for (const folder of list) {
|
|
95
|
+
if (seen.has(folder)) continue;
|
|
96
|
+
seen.add(folder);
|
|
97
|
+
out.push({ folder, saved: isSaved });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Run `fn(io)` for the files in `folder`. The home directory uses the io as is; any other folder is
|
|
105
|
+
* pinned first (it must be a real directory the agent owns, and unless it was saved with
|
|
106
|
+
* --claude-config, inside the agent's home). Returns undefined when the folder is gone.
|
|
107
|
+
*/
|
|
108
|
+
function inFolder({ folder, saved }, info, d, fn) {
|
|
109
|
+
if (folder === info.home || !d.mcpIO.pin) return fn(d.mcpIO);
|
|
110
|
+
const pinned = d.mcpIO.pin(folder, info.uid, saved ? undefined : info.home);
|
|
111
|
+
if (!pinned) return undefined;
|
|
112
|
+
try {
|
|
113
|
+
return fn(pinned.io);
|
|
114
|
+
} finally {
|
|
115
|
+
pinned.close();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const BAD = ["failed", "unsafe", "unreadable"];
|
|
120
|
+
|
|
39
121
|
/**
|
|
40
122
|
* Write (or, with `noMcp`, just print) one agent's MCP config and explain the restart. Shared with
|
|
41
123
|
* the installer. Returns the lines it logged through `d.log`.
|
|
@@ -49,16 +131,41 @@ export function configureMcp(user, config, d, { noMcp = false } = {}) {
|
|
|
49
131
|
d.log(JSON.stringify({ github: entry }, null, "\t"));
|
|
50
132
|
return "skipped";
|
|
51
133
|
}
|
|
134
|
+
const home = configureFolder(user, { folder: info.home, saved: false }, info, dir, entry, d);
|
|
135
|
+
let worst = home;
|
|
136
|
+
for (const place of extraFolders(info, d, config, user)) {
|
|
137
|
+
const r = configureFolder(user, place, info, dir, entry, d);
|
|
138
|
+
if (BAD.includes(r) && !BAD.includes(worst)) worst = r;
|
|
139
|
+
}
|
|
140
|
+
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.`);
|
|
141
|
+
return worst;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Write the entry in one folder's `.claude.json` and log the outcome. The home directory's file is
|
|
146
|
+
* created when missing; another folder is only updated when it already has one, since Claude
|
|
147
|
+
* Code writes that file itself in any folder it uses.
|
|
148
|
+
*/
|
|
149
|
+
function configureFolder(user, place, info, dir, entry, d) {
|
|
150
|
+
const { folder } = place;
|
|
151
|
+
const isHome = folder === info.home;
|
|
152
|
+
const file = isHome ? "the user's ~/.claude.json" : `${folder}/${MCP_FILE}`;
|
|
153
|
+
const where = isHome ? "" : ` in ${folder}/${MCP_FILE}`;
|
|
52
154
|
let r;
|
|
53
155
|
try {
|
|
54
|
-
r = applyMcpConfig({
|
|
156
|
+
r = inFolder(place, info, d, (io) => applyMcpConfig({ folder, user, dir, owner: { uid: info.uid, gid: info.gid }, create: isHome }, io)) ?? { status: "no-file" };
|
|
55
157
|
} catch (err) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
158
|
+
if (err instanceof SkippedFolder) {
|
|
159
|
+
d.error(`${user}: skipped ${err.message}`);
|
|
160
|
+
return "skipped";
|
|
161
|
+
}
|
|
162
|
+
// Root is working in a folder the agent owns, so a write can fail on anything the agent puts
|
|
163
|
+
// in the way (a directory where the backup goes). Report it; it must not stop the caller.
|
|
164
|
+
d.error(`${user}: could not write the MCP config${where}: ${err.message}. Add this under "mcpServers" in ${file} by hand:`);
|
|
59
165
|
d.log(JSON.stringify({ github: entry }, null, "\t"));
|
|
60
166
|
return "failed";
|
|
61
167
|
}
|
|
168
|
+
if (r.status === "no-file") return "no-file";
|
|
62
169
|
if (r.status === "unsafe") {
|
|
63
170
|
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
171
|
d.log(JSON.stringify({ github: entry }, null, "\t"));
|
|
@@ -70,7 +177,6 @@ export function configureMcp(user, config, d, { noMcp = false } = {}) {
|
|
|
70
177
|
return "unreadable";
|
|
71
178
|
}
|
|
72
179
|
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
180
|
return r.status;
|
|
75
181
|
}
|
|
76
182
|
|
|
@@ -87,9 +193,8 @@ export async function smokeAgent(user, config, d) {
|
|
|
87
193
|
}
|
|
88
194
|
}
|
|
89
195
|
|
|
196
|
+
/** `user` is a valid name and an existing user (agentCommand checks both before it saves anything). */
|
|
90
197
|
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
198
|
d.ensureGroup(user);
|
|
94
199
|
d.log(`${user}: registered (group ${agentGroup(user)})`);
|
|
95
200
|
configureMcp(user, config, d, { noMcp });
|
|
@@ -108,12 +213,31 @@ function remove(config, user, d) {
|
|
|
108
213
|
d.log("service restarted");
|
|
109
214
|
const info = d.userInfo(user);
|
|
110
215
|
if (info) {
|
|
216
|
+
const dir = agentsDir(config.listen);
|
|
217
|
+
for (const place of [{ folder: info.home, saved: false }, ...extraFolders(info, d, config, user)]) {
|
|
218
|
+
const { folder } = place;
|
|
219
|
+
const isHome = folder === info.home;
|
|
220
|
+
try {
|
|
221
|
+
const r = inFolder(place, info, d, (io) => removeMcpConfig({ folder, dir, uid: info.uid }, io));
|
|
222
|
+
if (!r || (!isHome && r.status === "no-file")) continue;
|
|
223
|
+
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` };
|
|
224
|
+
d.log(`${user}: ${what[r.status]}`);
|
|
225
|
+
} catch (err) {
|
|
226
|
+
if (err instanceof SkippedFolder) {
|
|
227
|
+
d.error(`${user}: skipped ${err.message}`);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
d.error(`${user}: could not remove the broker entry from ${folder}/${MCP_FILE}: ${err.message}. The agent is unregistered anyway; remove the entry by hand.`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (config.claudeConfigDirs?.[user]) {
|
|
111
235
|
try {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
d.log(`${user}:
|
|
236
|
+
const { [user]: _gone, ...rest } = config.claudeConfigDirs;
|
|
237
|
+
d.writeConfig({ ...config, claudeConfigDirs: rest });
|
|
238
|
+
d.log(`${user}: saved Claude config folders removed from the broker config`);
|
|
115
239
|
} catch (err) {
|
|
116
|
-
d.error(`${user}: could not
|
|
240
|
+
d.error(`${user}: could not update the broker config: ${err.message}. Remove claudeConfigDirs.${user} from it by hand.`);
|
|
117
241
|
}
|
|
118
242
|
}
|
|
119
243
|
try {
|
|
@@ -142,10 +266,22 @@ function list(config, d) {
|
|
|
142
266
|
let mcp = "no such user";
|
|
143
267
|
if (info) {
|
|
144
268
|
try {
|
|
145
|
-
mcp = mcpFileStatus({
|
|
269
|
+
mcp = mcpFileStatus({ folder: info.home, user: a.user, dir, uid: info.uid }, d.mcpIO);
|
|
146
270
|
} catch (err) {
|
|
147
271
|
mcp = `can't read (${err.message})`; // one agent's file must not hide the others
|
|
148
272
|
}
|
|
273
|
+
const others = [];
|
|
274
|
+
for (const place of extraFolders(info, d, config, a.user)) {
|
|
275
|
+
const { folder } = place;
|
|
276
|
+
let status;
|
|
277
|
+
try {
|
|
278
|
+
status = inFolder(place, info, d, (io) => mcpFileStatus({ folder, user: a.user, dir, uid: info.uid, ifAbsent: null }, io));
|
|
279
|
+
} catch (err) {
|
|
280
|
+
status = err instanceof SkippedFolder ? `skipped (${err.message})` : `can't read (${err.message})`;
|
|
281
|
+
}
|
|
282
|
+
if (status) others.push(`${folder}: ${status}`);
|
|
283
|
+
}
|
|
284
|
+
if (others.length) mcp += ` (${others.join(", ")})`;
|
|
149
285
|
}
|
|
150
286
|
d.log(`${a.user} ${sock} socket: ${d.socketExists(sock) ? "yes" : "no"} mcp: ${mcp}${a.complete ? "" : " (group members incomplete; run agent add)"}`);
|
|
151
287
|
}
|
|
@@ -161,6 +297,6 @@ function sync(config, only, noMcp, d) {
|
|
|
161
297
|
if (!agents.includes(only)) throw new Error(`"${only}" is not a registered agent`);
|
|
162
298
|
}
|
|
163
299
|
let ok = true;
|
|
164
|
-
for (const user of only ? [only] : agents) if (
|
|
300
|
+
for (const user of only ? [only] : agents) if (BAD.includes(configureMcp(user, config, d, { noMcp }))) ok = false;
|
|
165
301
|
return ok ? 0 : 1;
|
|
166
302
|
}
|
package/deploy/lib/cli.mjs
CHANGED
|
@@ -26,6 +26,7 @@ export const USAGE = `Usage: sudo gh-broker <command>
|
|
|
26
26
|
Commands:
|
|
27
27
|
install [flags] install or upgrade the broker (see: gh-broker install --help)
|
|
28
28
|
agent add <user> register an agent user: group, MCP config, restart, smoke test (--no-mcp-config to skip the config)
|
|
29
|
+
--claude-config <dir>: another Claude config folder it uses outside its home (repeatable, also on sync)
|
|
29
30
|
agent remove <user> unregister an agent: empty its group, restart, MCP entry, lock state
|
|
30
31
|
agent list each agent, its socket, and whether its MCP config points at it
|
|
31
32
|
agent sync [<user>] re-apply the MCP config for one agent or all
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
* exception is each deployed tool's boolean `otp` and `enabled`, which carry onto the same
|
|
29
29
|
* tool in the new list (they never add a tool).
|
|
30
30
|
* - Everything else is operator data and the deployed copy wins over the example: `listen`,
|
|
31
|
-
* `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`, `otp
|
|
31
|
+
* `upstream`, `defaultOwner`, `owners`, `ghcr`, `app`, `secrets`, `npm`, `otp`,
|
|
32
|
+
* `claudeConfigDirs` (each agent's extra Claude config folders, #163; layers add to it). The example ships
|
|
32
33
|
* none of the owner-specific ones, so a fresh install has no owners until the operator
|
|
33
34
|
* supplies one (--owner, --config, or the interactive prompt).
|
|
34
35
|
* - Layers 3 and 4 are merged on top of what is deployed rather than replacing it: scalars are
|
|
@@ -39,11 +40,11 @@
|
|
|
39
40
|
* The result is validated by the real loader (`--check`) before anything is deployed, and a
|
|
40
41
|
* --config file may hold only `cred:` references, never token values.
|
|
41
42
|
*/
|
|
42
|
-
import { DEFAULT_LISTEN } from "./agents.mjs";
|
|
43
|
+
import { assertAgentName, DEFAULT_LISTEN } from "./agents.mjs";
|
|
43
44
|
|
|
44
45
|
export const DEFAULT_GHCR_DROP_DIR = "/srv/gh-broker/ghcr-drop";
|
|
45
46
|
|
|
46
|
-
const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr", "otp"];
|
|
47
|
+
const OPERATOR_KEYS = ["listen", "upstream", "defaultOwner", "owners", "app", "secrets", "npm", "ghcr", "otp", "claudeConfigDirs"];
|
|
47
48
|
const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
48
49
|
/** Shapes of live GitHub / npm tokens (classic, fine-grained, app, npm granular). */
|
|
49
50
|
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/;
|
|
@@ -75,7 +76,7 @@ export function mergeConfig(example, current) {
|
|
|
75
76
|
out.owners = { ...(example.owners ?? {}), ...(current.owners ?? {}) };
|
|
76
77
|
|
|
77
78
|
// optional blocks: current wins if present.
|
|
78
|
-
for (const k of ["app", "secrets", "npm", "ghcr"]) {
|
|
79
|
+
for (const k of ["app", "secrets", "npm", "ghcr", "claudeConfigDirs"]) {
|
|
79
80
|
if (current[k] !== undefined) out[k] = structuredClone(current[k]);
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -102,6 +103,7 @@ export function layerConfig(base, overlay) {
|
|
|
102
103
|
out.owners = { ...(out.owners ?? {}) };
|
|
103
104
|
for (const [name, creds] of Object.entries(overlay.owners)) out.owners[name] = { ...(out.owners[name] ?? {}), ...creds };
|
|
104
105
|
}
|
|
106
|
+
if (overlay.claudeConfigDirs !== undefined) out.claudeConfigDirs = addClaudeConfigDirs(out.claudeConfigDirs, overlay.claudeConfigDirs);
|
|
105
107
|
if (overlay.ghcr !== undefined) {
|
|
106
108
|
const { owners: ghcrOwners, ...scalars } = overlay.ghcr;
|
|
107
109
|
const merged = { ...(out.ghcr ?? {}), ...structuredClone(scalars) };
|
|
@@ -114,6 +116,34 @@ export function layerConfig(base, overlay) {
|
|
|
114
116
|
return out;
|
|
115
117
|
}
|
|
116
118
|
|
|
119
|
+
/**
|
|
120
|
+
* `claudeConfigDirs` with `more` added: per agent, the folders of both, each once. The result is
|
|
121
|
+
* a new object; neither argument is changed.
|
|
122
|
+
*/
|
|
123
|
+
export function addClaudeConfigDirs(base, more) {
|
|
124
|
+
const out = structuredClone(isObject(base) ? base : {});
|
|
125
|
+
for (const [user, dirs] of Object.entries(more)) out[user] = [...new Set([...absoluteFolders(out[user]), ...absoluteFolders(dirs)])];
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The absolute folder paths in one agent's saved list. The deployed config can be hand-edited, so
|
|
131
|
+
* a string instead of a list, or a relative entry, yields nothing rather than odd folders.
|
|
132
|
+
*/
|
|
133
|
+
export function absoluteFolders(dirs) {
|
|
134
|
+
return Array.isArray(dirs) ? dirs.filter((d) => typeof d === "string" && d.startsWith("/")) : [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Throw unless `v` is { "<agent user>": ["/absolute/dir", ...] }. `where` names it in the message. */
|
|
138
|
+
export function checkClaudeConfigDirs(v, where) {
|
|
139
|
+
if (!isObject(v)) throw new Error(`${where} must be an object of agent user -> list of folders`);
|
|
140
|
+
for (const [user, dirs] of Object.entries(v)) {
|
|
141
|
+
assertAgentName(user);
|
|
142
|
+
if (!Array.isArray(dirs) || !dirs.every((d) => typeof d === "string" && d.startsWith("/"))) throw new Error(`${where}.${user} must be a list of absolute paths`);
|
|
143
|
+
}
|
|
144
|
+
return v;
|
|
145
|
+
}
|
|
146
|
+
|
|
117
147
|
/** The config fragment the CLI flags describe (same shape as a config file), or {} when none were given. */
|
|
118
148
|
export function overlayFromFlags(opts) {
|
|
119
149
|
const o = {};
|
|
@@ -132,6 +162,7 @@ export function overlayFromFlags(opts) {
|
|
|
132
162
|
g.owners = { [opts.ghcrOwner]: { username: opts.ghcrUsername, credential: `cred:${opts.ghcrOwner}-packages`, images: [...opts.ghcrImages] } };
|
|
133
163
|
}
|
|
134
164
|
if (Object.keys(g).length) o.ghcr = g;
|
|
165
|
+
for (const { user, dir } of opts.claudeConfigs ?? []) ((o.claudeConfigDirs ??= {})[user] ??= []).push(dir);
|
|
135
166
|
return o;
|
|
136
167
|
}
|
|
137
168
|
|
|
@@ -151,6 +182,7 @@ export function readOperatorConfig(path, read) {
|
|
|
151
182
|
if (raw.tools !== undefined) throw new Error("--config: `tools` is the shipped allowlist and cannot be set from a config file");
|
|
152
183
|
const unknown = Object.keys(raw).filter((k) => !OPERATOR_KEYS.includes(k));
|
|
153
184
|
if (unknown.length) throw new Error(`--config: unknown key(s) ${unknown.join(", ")} (allowed: ${OPERATOR_KEYS.join(", ")})`);
|
|
185
|
+
if (raw.claudeConfigDirs !== undefined) checkClaudeConfigDirs(raw.claudeConfigDirs, "--config: claudeConfigDirs");
|
|
154
186
|
const hit = findTokenShape(raw, "");
|
|
155
187
|
if (hit) throw new Error(`--config: ${hit} looks like a token. The config holds only "cred:<name>" references; pass the token with --secret, --secrets-file or env`);
|
|
156
188
|
return raw;
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
22
|
import { assertSafeCredName } from "./secret-store.mjs";
|
|
23
|
+
import { assertAgentName } from "./agents.mjs";
|
|
23
24
|
|
|
24
25
|
export const NPM_SCOPE = /^@[a-z0-9][a-z0-9._~-]*$/;
|
|
25
26
|
export const NPM_ORG = /^[a-z0-9][a-z0-9._~-]*$/;
|
|
@@ -125,6 +126,12 @@ export function parseArgs(argv) {
|
|
|
125
126
|
const n = value(++i, a);
|
|
126
127
|
if (!/^[1-9][0-9]{0,15}$/.test(n) || !Number.isSafeInteger(Number(n))) throw new Error("--ghcr-max-archive-bytes must be a positive whole number of bytes");
|
|
127
128
|
o.ghcrMaxArchiveBytes = Number(n);
|
|
129
|
+
} else if (a === "--claude-config") {
|
|
130
|
+
const pair = value(++i, a);
|
|
131
|
+
const colon = pair.indexOf(":");
|
|
132
|
+
const dir = pair.slice(colon + 1);
|
|
133
|
+
if (colon <= 0 || !dir.startsWith("/")) throw new Error(`--claude-config takes <agent user>:<absolute folder>, got "${pair}"`);
|
|
134
|
+
(o.claudeConfigs ??= []).push({ user: assertAgentName(pair.slice(0, colon)), dir: dir.replace(/(.)\/+$/, "$1") });
|
|
128
135
|
} else if (a === "--protect-release-tags") o.protectReleaseTags = true;
|
|
129
136
|
else if (a === "--no-protect-release-tags") o.protectReleaseTags = false;
|
|
130
137
|
else if (a.startsWith("-")) throw new Error(`unknown flag: ${a} (see --help)`);
|