@consiliency/agent-board-cli 1.24.0 → 1.25.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.
@@ -0,0 +1,653 @@
1
+ // SETUP SL-2 — the `harness` step: register the board with one harness, in one scope, at the
2
+ // versions this cli build baked (IF-0-SETUP-1, IF-0-SETUP-4).
3
+ //
4
+ // harness how setup's own exact check
5
+ // claude `claude plugin marketplace add …@<releaseTag>` our plugin row's version = the
6
+ // + `install` (or `update` over an older one) baked mcp version and its ref = the
7
+ // baked release tag
8
+ // pi `pi install [-l] npm:@consiliency/agent-board-pi@<v>` exactly that spec, once
9
+ // opencode `opencode plugin @consiliency/agent-board-opencode@<v> [-g]` exactly that spec, once
10
+ // codex `[mcp_servers.agent-board]` in config.toml (writer) the stanza deep-equals ours
11
+ // grok `[mcp_servers.agent-board]` in config.toml (writer) the stanza deep-equals ours
12
+ // agy `mcpServers.agent-board` in mcp_config.json (writer) the stanza deep-equals ours
13
+ //
14
+ // Doctor's registration reader is version-blind (it asks "is there a board server"), so it is
15
+ // never the exact check; it is the reader every written file must still satisfy, and the files
16
+ // are located and read through doctor's own scope table (`HARNESS_CONFIG_SCOPES`) so the two
17
+ // cannot disagree about where a harness looks. Auto-update is never enabled by anything here.
18
+ //
19
+ // Every `reason` is an authored code. File content (a server name, a path) only reaches the
20
+ // human `fix`, and a server name only when it is a plain token.
21
+ import { spawn } from "node:child_process";
22
+ import { constants as FS, accessSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
23
+ import * as fsp from "node:fs/promises";
24
+ import { homedir } from "node:os";
25
+ import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
26
+ import { normalizeKeyReference } from "@consiliency/agent-board-client";
27
+ import { ConfigEditRefused, upsertJsonMember, upsertTomlTable } from "./config_edit.js";
28
+ import { REPORTABLE_TOKEN } from "./doctor.js";
29
+ import { CLAUDE_PLUGIN_ID, CLAUDE_PLUGIN_MARKETPLACE, CLAUDE_PLUGIN_MCP_PACKAGE, CLAUDE_PLUGIN_SERVER, HARNESS_CONFIG_SCOPES, OPENCODE_PLUGIN_PACKAGE, PI_PACKAGE, claudeProjectKey, judgeMcpEntry, nodeRegistrationFs, own, packageIdentity, parseRestrictedToml, readClaudePluginScopes, readScopes, } from "./doctor_scopes.js";
30
+ export const SETUP_SCOPES = Object.freeze(["user", "local"]);
31
+ /** The server name setup writes; the same name the claude plugin registers. */
32
+ export const BOARD_SERVER_NAME = CLAUDE_PLUGIN_SERVER;
33
+ /** `Consiliency/message-board`: the marketplace source, pinned to the baked release tag. */
34
+ export const CLAUDE_MARKETPLACE_REPO = "Consiliency/message-board";
35
+ /**
36
+ * What codex hands an MCP child and nothing else (`process_ancestry.ts`, measured 2026-09-18 on
37
+ * codex 0.155.0, across three configurations including `shell_environment_policy.inherit=all`).
38
+ * The writer test pins this list against that measurement's own text.
39
+ */
40
+ export const CODEX_CHILD_ENV = Object.freeze(["HOME", "LANG", "LOGNAME", "PATH", "SHELL", "TERM", "USER"]);
41
+ /**
42
+ * Every non-secret input that decides where the identity file and the runtime records resolve
43
+ * (`identityConfigRoot`, `identityStateRoot`, `resolveHostSegment`). Written into the codex
44
+ * stanza's `env` when setup's own value is not the default, so the child resolves what setup
45
+ * wrote. Never a credential: the identity itself comes from the file.
46
+ */
47
+ export const CODEX_PATH_INPUTS = Object.freeze(["XDG_CONFIG_HOME", "XDG_STATE_HOME", "MESSAGE_BOARD_WAKE_MACHINE_ID", "MESSAGE_BOARD_WAKE_HOSTNAME"]);
48
+ /** `secret-tool` inherits the child env (`keystore_os_keychain.ts`): an os_keychain key needs the session bus. */
49
+ export const KEYCHAIN_CHILD_INPUTS = Object.freeze(["DBUS_SESSION_BUS_ADDRESS", "XDG_RUNTIME_DIR"]);
50
+ const nonBlank = (value) => (value !== undefined && value.trim() !== "" ? value : undefined);
51
+ const homeOf = (env) => nonBlank(env.HOME) ?? homedir();
52
+ /** The `agent_socket:` socket spellings that mean `$SSH_AUTH_SOCK` (the client's `resolveSocketPath`). */
53
+ const SOCKET_SENTINELS = new Set(["", "default", "$ssh_auth_sock", "ssh_auth_sock"]);
54
+ /**
55
+ * The env an `agent_socket:` reference's socket resolves through, which codex does not hand its
56
+ * child: `MESSAGE_BOARD_AGENT_SOCKET_PATH` when setup's env sets it (it overrides the handle), else
57
+ * `SSH_AUTH_SOCK` when the handle names the socket only by that sentinel (an external agent). An
58
+ * absolute socket in the handle (a keyslot) needs nothing. null: the socket resolves to nothing
59
+ * here, so no child could reach it.
60
+ */
61
+ export function agentSocketInputs(keyReference, env) {
62
+ if (keyReference === null)
63
+ return {};
64
+ let handle;
65
+ try {
66
+ const reference = normalizeKeyReference(keyReference);
67
+ if (reference.resolver !== "agent_socket")
68
+ return {};
69
+ handle = String(reference.handle);
70
+ }
71
+ catch {
72
+ return {};
73
+ }
74
+ const override = nonBlank(env.MESSAGE_BOARD_AGENT_SOCKET_PATH);
75
+ if (override !== undefined)
76
+ return { MESSAGE_BOARD_AGENT_SOCKET_PATH: override };
77
+ const hash = handle.indexOf("#");
78
+ const spec = (hash >= 0 ? handle.slice(0, hash) : handle).trim();
79
+ if (!SOCKET_SENTINELS.has(spec.toLowerCase()))
80
+ return {};
81
+ const sock = nonBlank(env.SSH_AUTH_SOCK);
82
+ return sock === undefined ? null : { SSH_AUTH_SOCK: sock };
83
+ }
84
+ /**
85
+ * The codex stanza's `env`: each frozen path input setup's env sets to a non-default value, the
86
+ * session bus for an os_keychain key, and the socket inputs of an agent_socket one. null when the
87
+ * key's socket cannot be resolved (see `agentSocketInputs`).
88
+ */
89
+ export function codexStanzaEnv(env, keyReference) {
90
+ const home = homeOf(env);
91
+ const defaults = {
92
+ XDG_CONFIG_HOME: join(home, ".config"),
93
+ XDG_STATE_HOME: join(home, ".local", "state"),
94
+ MESSAGE_BOARD_WAKE_MACHINE_ID: undefined,
95
+ MESSAGE_BOARD_WAKE_HOSTNAME: undefined,
96
+ };
97
+ const out = {};
98
+ for (const name of CODEX_PATH_INPUTS) {
99
+ const value = nonBlank(env[name]);
100
+ if (value !== undefined && value !== defaults[name])
101
+ out[name] = value;
102
+ }
103
+ let resolver = null;
104
+ try {
105
+ resolver = keyReference === null ? null : normalizeKeyReference(keyReference).resolver;
106
+ }
107
+ catch {
108
+ resolver = null;
109
+ }
110
+ if (resolver === "os_keychain") {
111
+ for (const name of KEYCHAIN_CHILD_INPUTS) {
112
+ const value = nonBlank(env[name]);
113
+ if (value !== undefined)
114
+ out[name] = value;
115
+ }
116
+ }
117
+ const socket = agentSocketInputs(keyReference, env);
118
+ if (socket === null)
119
+ return null;
120
+ return { ...out, ...socket };
121
+ }
122
+ /** The environment codex actually gives the MCP child: the frozen seven from `env`, then the stanza's. */
123
+ export function codexChildEnv(env, stanza) {
124
+ const out = {};
125
+ for (const name of CODEX_CHILD_ENV)
126
+ if (env[name] !== undefined)
127
+ out[name] = env[name];
128
+ const extra = stanza.env;
129
+ if (extra !== undefined && typeof extra === "object" && !Array.isArray(extra)) {
130
+ for (const [name, value] of Object.entries(extra))
131
+ if (typeof value === "string")
132
+ out[name] = value;
133
+ }
134
+ return out;
135
+ }
136
+ /**
137
+ * The stanza setup writes for an MCP-configured harness: `npx -y <mcp>@<pin> --harness <h>` (+ the
138
+ * codex env). null only for codex, when its key's agent socket resolves to nothing here.
139
+ */
140
+ export function mcpStanza(harness, assets, env, keyReference) {
141
+ const stanza = { command: "npx", args: ["-y", `${CLAUDE_PLUGIN_MCP_PACKAGE}@${assets.mcpVersion}`, "--harness", harness] };
142
+ if (harness === "codex") {
143
+ const childEnv = codexStanzaEnv(env, keyReference);
144
+ if (childEnv === null)
145
+ return null;
146
+ if (Object.keys(childEnv).length > 0)
147
+ return { ...stanza, env: childEnv };
148
+ }
149
+ return stanza;
150
+ }
151
+ /**
152
+ * Whether an entry under our name runs OUR server: an argv element whose package identity is
153
+ * exactly `@consiliency/agent-board-mcp` (any version, as `npx -y <pkg>@<v>` passes it), or the
154
+ * package's bin `agent-board-mcp` as its own element. A substring is never enough: a lookalike
155
+ * package (`@example/agent-board-mcp-tools`, `@consiliency/agent-board-mcp-extra`) or a wrapper
156
+ * script whose path merely contains the name is someone else's, and is never overwritten.
157
+ */
158
+ export function isOurServer(words) {
159
+ return words.some((word) => word === "agent-board-mcp" || packageIdentity(word) === CLAUDE_PLUGIN_MCP_PACKAGE);
160
+ }
161
+ /** The entry keys setup owns; any other key a user keeps in the entry survives an upgrade. */
162
+ const OWNED_ENTRY_KEYS = new Set(["command", "args", "env"]);
163
+ /**
164
+ * The `env` names setup owns: exactly the ones it may write. Any other name a user keeps in the
165
+ * entry's `env` survives an upgrade; an owned one setup no longer writes is dropped.
166
+ */
167
+ export const OWNED_ENV_NAMES = new Set([
168
+ ...CODEX_PATH_INPUTS, ...KEYCHAIN_CHILD_INPUTS, "SSH_AUTH_SOCK", "MESSAGE_BOARD_AGENT_SOCKET_PATH",
169
+ ]);
170
+ function scopeContext(env, cwd, fs, claudeManagedDir) {
171
+ return { cwd, home: homeOf(env), env, fs, claudeManagedDir };
172
+ }
173
+ const row = (harness, name) => {
174
+ const found = HARNESS_CONFIG_SCOPES[harness].find((candidate) => candidate.name === name);
175
+ if (!found)
176
+ throw new Error(`setup: doctor's scope table has no ${name} row`);
177
+ return found;
178
+ };
179
+ /**
180
+ * Whether `setup --harness <h> --scope <s>` is supported here. agy has no project scope; a codex
181
+ * project layer is loaded only under a trusted `[projects]` entry, judged by doctor's own walk
182
+ * (the `codex_project` row's `loaded`), so setup and doctor cannot disagree. Setup never writes trust.
183
+ */
184
+ export function scopeRefusal(harness, scope, ctx) {
185
+ if (scope === "user")
186
+ return null;
187
+ if (harness === "agy") {
188
+ return { reason: "scope_unsupported", fix: "agy has no project scope (only ~/.gemini/config/mcp_config.json): re-run with --scope user" };
189
+ }
190
+ if (harness === "codex") {
191
+ const scopeCtx = scopeContext(ctx.env, ctx.cwd, ctx.fs ?? nodeRegistrationFs, ctx.claudeManagedDir ?? "/etc/claude-code");
192
+ const project = row("codex", "codex_project");
193
+ let trusted = false;
194
+ try {
195
+ trusted = project.loaded?.(join(ctx.cwd, ".codex", "config.toml"), scopeCtx) ?? true;
196
+ }
197
+ catch {
198
+ trusted = false;
199
+ }
200
+ if (!trusted) {
201
+ return {
202
+ reason: "codex_project_untrusted",
203
+ fix: "codex loads .codex/config.toml only in a trusted project: open codex in this directory and accept its trust prompt (it records trust_level = \"trusted\" under [projects] in ~/.codex/config.toml), then re-run setup. Setup never writes trust.",
204
+ };
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+ export const defaultChildRunner = (file, args, options) => new Promise((resolve) => {
210
+ let stdout = "";
211
+ let stderr = "";
212
+ let child;
213
+ try {
214
+ child = spawn(file, [...args], { env: options.env, cwd: options.cwd, stdio: ["ignore", "pipe", "pipe"], timeout: options.timeoutMs ?? 600_000 });
215
+ }
216
+ catch {
217
+ resolve({ code: null, stdout, stderr });
218
+ return;
219
+ }
220
+ child.stdout.on("data", (chunk) => { stdout += String(chunk); });
221
+ child.stderr.on("data", (chunk) => { stderr += String(chunk); });
222
+ child.on("error", () => resolve({ code: null, stdout, stderr }));
223
+ child.on("close", (code, signal) => resolve({ code: signal ? null : code, stdout, stderr }));
224
+ });
225
+ /** An executable named `name` on `env`'s PATH (never `process.env`'s), or null. */
226
+ export function which(name, env) {
227
+ for (const dir of (env.PATH ?? "").split(delimiter)) {
228
+ if (!dir || !isAbsolute(dir))
229
+ continue;
230
+ const candidate = join(dir, name);
231
+ try {
232
+ if (!statSync(candidate).isFile())
233
+ continue;
234
+ accessSync(candidate, FS.X_OK);
235
+ return candidate;
236
+ }
237
+ catch {
238
+ continue;
239
+ }
240
+ }
241
+ return null;
242
+ }
243
+ function isSymlink(path) {
244
+ try {
245
+ return lstatSync(path).isSymbolicLink();
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ /**
252
+ * Replace `path`'s content so a reader sees the old file or the new one. A symlink is written
253
+ * THROUGH (its target is replaced, the link kept) unless `followSymlinks` is false, which refuses.
254
+ * An existing file keeps its mode; a new one is 0600 in 0700 directories.
255
+ */
256
+ async function writeConfigFile(path, text, followSymlinks) {
257
+ let target = path;
258
+ let mode = 0o600;
259
+ let symlink = false;
260
+ let stats = null;
261
+ try {
262
+ stats = lstatSync(path);
263
+ }
264
+ catch {
265
+ stats = null;
266
+ }
267
+ if (stats?.isSymbolicLink()) {
268
+ if (!followSymlinks)
269
+ return { reason: "config_symlink_refused", fix: `${path} is a symlink and --no-follow-symlinks was given: write the board entry yourself, or re-run without --no-follow-symlinks to write through it` };
270
+ try {
271
+ target = realpathSync(path);
272
+ }
273
+ catch {
274
+ return { reason: "config_symlink_dangling", fix: `${path} is a symlink to nothing: fix or remove the link, then re-run setup` };
275
+ }
276
+ symlink = true;
277
+ stats = lstatSync(target);
278
+ }
279
+ if (stats && !stats.isFile())
280
+ return { reason: "config_not_a_file", fix: `${target} is not a regular file` };
281
+ if (stats)
282
+ mode = stats.mode & 0o7777;
283
+ else
284
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
285
+ const temp = join(dirname(target), `.${basename(target)}.setup-${process.pid}-${Date.now()}`);
286
+ const handle = await fsp.open(temp, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
287
+ try {
288
+ await handle.writeFile(text, "utf8");
289
+ await handle.chmod(mode);
290
+ await handle.sync();
291
+ }
292
+ catch (error) {
293
+ await handle.close();
294
+ await fsp.unlink(temp).catch(() => { });
295
+ throw error;
296
+ }
297
+ await handle.close();
298
+ try {
299
+ await fsp.rename(temp, target);
300
+ }
301
+ catch (error) {
302
+ await fsp.unlink(temp).catch(() => { });
303
+ throw error;
304
+ }
305
+ const dir = await fsp.open(dirname(target), "r");
306
+ try {
307
+ await dir.sync();
308
+ }
309
+ finally {
310
+ await dir.close();
311
+ }
312
+ return { symlink };
313
+ }
314
+ const readText = (path) => {
315
+ try {
316
+ return readFileSync(path, "utf8");
317
+ }
318
+ catch {
319
+ return null;
320
+ }
321
+ };
322
+ const failed = (reason, fix) => ({ status: "failed", reason, fix });
323
+ const token = (value) => (REPORTABLE_TOKEN.test(value) ? value : "(unprintable name)");
324
+ /** The MCP-writer target file for a harness and scope, as doctor's scope rows locate it. */
325
+ export function mcpTarget(harness, scope, ctx) {
326
+ const name = harness === "codex" ? (scope === "user" ? "codex_user" : "codex_project")
327
+ : harness === "grok" ? (scope === "user" ? "grok_user" : "grok_project")
328
+ : "agy_user";
329
+ const paths = row(harness, name).paths(ctx);
330
+ // A walked project row lists cwd first.
331
+ return scope === "user" ? paths[0] : join(ctx.cwd, harness === "codex" ? ".codex" : ".grok", "config.toml");
332
+ }
333
+ /** Board MCP servers registered anywhere in the harness's scopes (as doctor reads them), except `exempt`. */
334
+ function boardEntries(harness, ctx, exempt) {
335
+ const scopes = readScopes(harness, ctx);
336
+ let disabled = new Set();
337
+ if (harness === "claude") {
338
+ try {
339
+ const doc = JSON.parse(readText(join(nonBlank(ctx.env.CLAUDE_CONFIG_DIR) ?? ctx.home, ".claude.json")) ?? "{}");
340
+ const list = own(own(own(doc, "projects"), claudeProjectKey(ctx)), "disabledMcpServers");
341
+ disabled = new Set(Array.isArray(list) ? list.filter((item) => typeof item === "string") : []);
342
+ }
343
+ catch {
344
+ disabled = new Set();
345
+ }
346
+ }
347
+ const entries = [];
348
+ for (const file of scopes.files) {
349
+ for (const entry of file.contribution.mcp) {
350
+ if (entry.disabled)
351
+ continue;
352
+ if (harness === "claude" && disabled.has(entry.name) && file.row !== "claude_managed_mcp" && file.row !== "claude_managed_settings")
353
+ continue;
354
+ if (!judgeMcpEntry(entry, ctx.fs).board)
355
+ continue;
356
+ if (exempt && file.path === exempt.path && entry.name === exempt.name)
357
+ continue;
358
+ entries.push({ row: file.row, path: file.path, name: entry.name });
359
+ }
360
+ }
361
+ return { entries, unparseable: scopes.unparseable };
362
+ }
363
+ function existingBoardServer(harness, seen) {
364
+ const where = `${seen.row} (${seen.path}), server ${token(seen.name)}`;
365
+ const fix = harness === "claude"
366
+ ? `a board MCP server is already registered: ${where}. Remove it, or turn it off for this directory with projects[<this project>].disabledMcpServers in ~/.claude.json, then re-run setup; setup never overwrites it`
367
+ : `a board MCP server is already registered: ${where}. Remove it (or re-run setup with the --scope that holds it), then re-run setup; setup never overwrites it`;
368
+ return failed("existing_board_server", fix);
369
+ }
370
+ async function installMcp(ctx, scopeCtx) {
371
+ const target = mcpTarget(ctx.harness, ctx.scope, scopeCtx);
372
+ const seen = boardEntries(ctx.harness, scopeCtx, { path: target, name: BOARD_SERVER_NAME });
373
+ if (seen.unparseable.length > 0)
374
+ return failed("config_unparseable", `these ${ctx.harness} config files do not parse, so setup cannot tell what they register: ${[...new Set(seen.unparseable)].join(", ")}`);
375
+ if (seen.entries.length > 0)
376
+ return existingBoardServer(ctx.harness, seen.entries[0]);
377
+ const stanza = mcpStanza(ctx.harness, ctx.assets, ctx.env, ctx.keyReference);
378
+ if (stanza === null) {
379
+ return failed("agent_socket_unresolved", "the key's agent socket resolves through SSH_AUTH_SOCK (or MESSAGE_BOARD_AGENT_SOCKET_PATH), which is unset here, and codex hands its MCP child neither: run setup from the shell whose agent holds the key");
380
+ }
381
+ const text = readText(target) ?? "";
382
+ const path = ctx.harness === "agy" ? ["mcpServers", BOARD_SERVER_NAME] : ["mcp_servers", BOARD_SERVER_NAME];
383
+ // An entry under our name that is not a board server is someone else's: never overwritten. Ours
384
+ // keeps every key the user added (only command, args and env are setup's), and a server the user
385
+ // turned off stays off.
386
+ let desired = stanza;
387
+ let disabledByUser = false;
388
+ try {
389
+ const doc = ctx.harness === "agy" ? (text.trim() === "" ? {} : JSON.parse(text)) : parseRestrictedToml(text);
390
+ const current = own(own(doc, path[0]), path[1]);
391
+ if (current !== undefined && current !== null && typeof current === "object") {
392
+ const command = own(current, "command");
393
+ const args = own(current, "args");
394
+ const words = [typeof command === "string" ? command : "", ...(Array.isArray(args) ? args.filter((arg) => typeof arg === "string") : [])];
395
+ if (!isOurServer(words)) {
396
+ return failed("existing_board_server", `${target} already has a server named ${BOARD_SERVER_NAME} that is not ${CLAUDE_PLUGIN_MCP_PACKAGE} (another package, or a wrapper): setup never overwrites it; rename or remove it, then re-run setup`);
397
+ }
398
+ const kept = {};
399
+ for (const key of Object.keys(current))
400
+ if (!OWNED_ENTRY_KEYS.has(key))
401
+ kept[key] = own(current, key);
402
+ // `env` merges name by name: the user's names stay, setup's own come from the stanza.
403
+ const currentEnv = own(current, "env");
404
+ const env = {};
405
+ if (currentEnv !== null && typeof currentEnv === "object" && !Array.isArray(currentEnv)) {
406
+ for (const name of Object.keys(currentEnv))
407
+ if (!OWNED_ENV_NAMES.has(name))
408
+ env[name] = own(currentEnv, name);
409
+ }
410
+ for (const [name, value] of Object.entries(stanza.env ?? {}))
411
+ env[name] = value;
412
+ const { env: _stanzaEnv, ...rest } = stanza;
413
+ void _stanzaEnv;
414
+ desired = { ...rest, ...(Object.keys(env).length > 0 ? { env } : {}), ...kept };
415
+ disabledByUser = own(current, "enabled") === false || own(current, "disabled") === true;
416
+ }
417
+ }
418
+ catch {
419
+ return failed("config_unparseable", `${target} does not parse`);
420
+ }
421
+ const off = disabledByUser
422
+ ? { suffix: ",disabled_by_user", fix: `the board server in ${target} is turned off (enabled = false / disabled: true), and setup keeps your choice: remove that key to turn it on; until then doctor reports no registration` }
423
+ : { suffix: "", fix: undefined };
424
+ let edit;
425
+ try {
426
+ edit = ctx.harness === "agy"
427
+ ? upsertJsonMember(text, path, desired, { strict: true })
428
+ : upsertTomlTable(text, path, desired);
429
+ }
430
+ catch (error) {
431
+ if (error instanceof ConfigEditRefused) {
432
+ if (error.reason === "config_inline_table" || error.reason === "config_array_table") {
433
+ return failed(error.reason, `${target} defines mcp_servers (or the board entry) as an inline table, a dotted key or an array of tables, which TOML does not let a [mcp_servers.${BOARD_SERVER_NAME}] table extend: rewrite each server as its own [mcp_servers.<name>] table, then re-run setup`);
434
+ }
435
+ return failed(error.reason, `setup could not place the board entry in ${target} without disturbing it: add this stanza yourself: ${JSON.stringify(stanza)}`);
436
+ }
437
+ throw error;
438
+ }
439
+ if (edit.prior === "exact")
440
+ return { status: "already", reason: `exact${off.suffix}`, ...(off.fix ? { fix: off.fix } : {}) };
441
+ const written = await writeConfigFile(target, edit.text, ctx.followSymlinks);
442
+ if ("reason" in written)
443
+ return failed(written.reason, written.fix);
444
+ const reason = `${edit.prior === "differs" ? "upgraded" : "installed"}${written.symlink ? ",symlink_followed" : ""}${off.suffix}`;
445
+ return { status: "done", reason, ...(off.fix ? { fix: off.fix } : {}) };
446
+ }
447
+ /** The settings files a CLI install writes for this scope, as doctor's rows name them. */
448
+ function cliWriteTargets(harness, scope, ctx) {
449
+ if (harness === "claude")
450
+ return row("claude", scope === "user" ? "claude_user_settings" : "claude_project_local_settings").paths(ctx);
451
+ if (harness === "pi")
452
+ return row("pi", scope === "user" ? "pi_user" : "pi_project").paths(ctx);
453
+ if (scope === "user")
454
+ return row("opencode", "opencode_user").paths(ctx);
455
+ return row("opencode", "opencode_project").paths(ctx).filter((path) => dirname(path) === ctx.cwd);
456
+ }
457
+ /** `readlink` on each target BEFORE the harness CLI writes it: a symlink is reported, or refused. */
458
+ function symlinkCheck(ctx, targets) {
459
+ const links = targets.filter(isSymlink);
460
+ if (links.length === 0)
461
+ return { refusal: null, followed: false };
462
+ if (!ctx.followSymlinks) {
463
+ return { refusal: failed("config_symlink_refused", `${links.join(", ")} is a symlink and --no-follow-symlinks was given: register the board yourself, or re-run without --no-follow-symlinks`), followed: false };
464
+ }
465
+ return { refusal: null, followed: true };
466
+ }
467
+ async function runCli(ctx, cli, args) {
468
+ const outcome = await (ctx.run ?? defaultChildRunner)(cli, args, { env: ctx.env, cwd: ctx.cwd });
469
+ if (outcome.code === 0)
470
+ return null;
471
+ return failed("harness_cli_failed", `\`${basename(cli)} ${args.join(" ")}\` exited ${outcome.code ?? "abnormally"}: run it yourself and read what it reports, then re-run setup`);
472
+ }
473
+ const PACKAGE_ROWS = {
474
+ pi: { user: ["pi_user"], local: ["pi_project"] },
475
+ opencode: { user: ["opencode_user"], local: ["opencode_project", "opencode_project_dotdir"] },
476
+ };
477
+ function packageSpecsSeen(harness, ctx) {
478
+ const wanted = harness === "pi" ? PI_PACKAGE : OPENCODE_PLUGIN_PACKAGE;
479
+ const scopes = readScopes(harness, ctx);
480
+ const specs = scopes.files.flatMap((file) => file.contribution.packages
481
+ .filter((spec) => packageIdentity(spec) === wanted)
482
+ .map((spec) => ({ row: file.row, spec })));
483
+ return { specs, unparseable: scopes.unparseable };
484
+ }
485
+ async function installPackage(ctx, scopeCtx) {
486
+ const cli = which(ctx.harness, ctx.env);
487
+ if (cli === null)
488
+ return failed("harness_cli_missing", `the ${ctx.harness} CLI is not on PATH: install ${ctx.harness}, then re-run setup`);
489
+ const version = ctx.harness === "pi" ? ctx.assets.piVersion : ctx.assets.opencodeVersion;
490
+ const spec = ctx.harness === "pi" ? `${PI_PACKAGE}@${version}` : `${OPENCODE_PLUGIN_PACKAGE}@${version}`;
491
+ const ours = PACKAGE_ROWS[ctx.harness][ctx.scope];
492
+ const seenBoard = boardEntries(ctx.harness, scopeCtx);
493
+ if (seenBoard.entries.length > 0)
494
+ return existingBoardServer(ctx.harness, seenBoard.entries[0]);
495
+ const before = packageSpecsSeen(ctx.harness, scopeCtx);
496
+ if (before.unparseable.length > 0 || seenBoard.unparseable.length > 0) {
497
+ return failed("config_unparseable", `these ${ctx.harness} config files do not parse: ${[...new Set([...before.unparseable, ...seenBoard.unparseable])].join(", ")}`);
498
+ }
499
+ const elsewhere = before.specs.find((seen) => !ours.includes(seen.row));
500
+ if (elsewhere)
501
+ return failed("existing_board_package", `the board package is already registered in ${elsewhere.row}: remove it there (or re-run setup with that --scope), then re-run setup`);
502
+ const mine = before.specs.filter((seen) => ours.includes(seen.row));
503
+ if (mine.length > 1)
504
+ return failed("duplicate_board_package", `the board package is listed ${mine.length} times in this scope: keep one, then re-run setup`);
505
+ if (mine.length === 1 && mine[0].spec === spec)
506
+ return { status: "already", reason: "exact" };
507
+ const links = symlinkCheck(ctx, cliWriteTargets(ctx.harness, ctx.scope, scopeCtx));
508
+ if (links.refusal)
509
+ return links.refusal;
510
+ const upgrade = mine.length === 1;
511
+ const args = ctx.harness === "pi"
512
+ ? ["install", ...(ctx.scope === "local" ? ["-l"] : []), spec]
513
+ : ["plugin", spec, ...(ctx.scope === "user" ? ["-g"] : []), ...(upgrade ? ["-f"] : [])];
514
+ const cliFailure = await runCli(ctx, cli, args);
515
+ if (cliFailure)
516
+ return cliFailure;
517
+ // The count, not presence: an install that appended beside the old entry is not an upgrade.
518
+ const after = packageSpecsSeen(ctx.harness, scopeCtx);
519
+ if (after.specs.length !== 1 || after.specs[0].spec !== spec || !ours.includes(after.specs[0].row)) {
520
+ return failed("harness_install_unverified", `after \`${ctx.harness} ${args.join(" ")}\` the scope does not hold exactly one ${spec}: fix the entry by hand, then re-run setup`);
521
+ }
522
+ const reason = upgrade ? "upgraded" : "installed";
523
+ return { status: "done", reason: links.followed ? `${reason},symlink_followed` : reason };
524
+ }
525
+ /**
526
+ * Our plugin as installed AT one scope: that scope's `installed_plugins.json` row (for local, the row
527
+ * whose project is this directory), the enable in that scope's settings file, and the marketplace
528
+ * ref that file declares. The cwd-effective summary alone cannot say this: a local install makes
529
+ * the plugin effective here while a `--scope user` request is still unmet everywhere else.
530
+ */
531
+ function claudeScopeInstall(ctx, scope) {
532
+ const configured = nonBlank(ctx.env.CLAUDE_CONFIG_DIR);
533
+ const configDir = configured !== undefined && isAbsolute(configured) ? configured : join(ctx.home, ".claude");
534
+ const json = (path) => {
535
+ try {
536
+ return JSON.parse(readFileSync(path, "utf8"));
537
+ }
538
+ catch {
539
+ return null;
540
+ }
541
+ };
542
+ const real = (path) => {
543
+ try {
544
+ return realpathSync(path);
545
+ }
546
+ catch {
547
+ return path;
548
+ }
549
+ };
550
+ const rows = own(own(json(join(configDir, "plugins", "installed_plugins.json")), "plugins"), CLAUDE_PLUGIN_ID);
551
+ const row = Array.isArray(rows)
552
+ ? rows.find((candidate) => own(candidate, "scope") === scope
553
+ && (scope === "user" || (typeof own(candidate, "projectPath") === "string" && real(own(candidate, "projectPath")) === real(ctx.cwd))))
554
+ : undefined;
555
+ const settings = json(cliWriteTargets("claude", scope, ctx)[0]);
556
+ const ref = own(own(own(own(settings, "extraKnownMarketplaces"), CLAUDE_PLUGIN_MARKETPLACE), "source"), "ref");
557
+ const version = own(row, "version");
558
+ return {
559
+ row: row !== undefined,
560
+ version: typeof version === "string" ? version : null,
561
+ enabled: own(own(settings, "enabledPlugins"), CLAUDE_PLUGIN_ID) === true,
562
+ // The user turned it off at this scope (`claude plugin disable`): setup never turns it back on.
563
+ disabled: own(own(settings, "enabledPlugins"), CLAUDE_PLUGIN_ID) === false,
564
+ ref: typeof ref === "string" ? ref : null,
565
+ };
566
+ }
567
+ /**
568
+ * Setup's exact check for claude: our plugin, at the baked version, from the baked release tag,
569
+ * effective here AND installed and enabled at the REQUESTED scope from that tag.
570
+ */
571
+ export function claudePluginExact(ctx, assets, scope) {
572
+ const read = readClaudePluginScopes(ctx);
573
+ const summary = read.summary;
574
+ const atScope = claudeScopeInstall(ctx, scope);
575
+ const effective = summary.installed && summary.version === assets.mcpVersion && summary.sourceRef === assets.releaseTag && !read.failures.includes("plugin_stanza_mismatch");
576
+ return {
577
+ exact: effective && atScope.row && atScope.version === assets.mcpVersion && atScope.enabled && atScope.ref === assets.releaseTag,
578
+ installedAtScope: atScope.row,
579
+ enabledAtScope: atScope.row && atScope.enabled,
580
+ disabledAtScope: atScope.row && atScope.disabled,
581
+ pinnedAtScope: atScope.row && atScope.version === assets.mcpVersion && atScope.ref === assets.releaseTag,
582
+ foreign: read.failures.includes("foreign_board_plugin"),
583
+ unparseable: read.unparseable.length > 0 || read.untrusted,
584
+ };
585
+ }
586
+ async function installClaude(ctx, scopeCtx) {
587
+ const cli = which("claude", ctx.env);
588
+ if (cli === null)
589
+ return failed("harness_cli_missing", "the claude CLI is not on PATH: install Claude Code, then re-run setup");
590
+ const seen = boardEntries("claude", scopeCtx);
591
+ if (seen.unparseable.length > 0)
592
+ return failed("config_unparseable", `these claude config files do not parse: ${[...new Set(seen.unparseable)].join(", ")}`);
593
+ if (seen.entries.length > 0)
594
+ return existingBoardServer("claude", seen.entries[0]);
595
+ const before = claudePluginExact(scopeCtx, ctx.assets, ctx.scope);
596
+ if (before.foreign)
597
+ return failed("existing_board_plugin", "another Claude Code plugin already registers a board server or wake hook: disable it (claude plugin disable <id>), then re-run setup");
598
+ if (before.unparseable)
599
+ return failed("config_unparseable", "Claude Code's plugin records do not parse or name a path outside its cache: run `claude plugin list` and repair them, then re-run setup");
600
+ if (before.exact)
601
+ return { status: "already", reason: "exact" };
602
+ // A plugin the user disabled at this scope stays disabled, as an MCP harness's off switch does:
603
+ // pinned already → `already`; otherwise upgraded in place (`update` keeps the enable state).
604
+ const offFix = "the board plugin is disabled at this scope (claude plugin disable), and setup keeps your choice: `claude plugin enable agent-board@message-board` to turn it on; until then doctor reports no registration";
605
+ if (before.disabledAtScope && before.pinnedAtScope)
606
+ return { status: "already", reason: "exact,disabled_by_user", fix: offFix };
607
+ // readlink BEFORE `claude plugin … --scope` writes the settings file.
608
+ const links = symlinkCheck(ctx, cliWriteTargets("claude", ctx.scope, scopeCtx));
609
+ if (links.refusal)
610
+ return links.refusal;
611
+ const scopeArgs = ["--scope", ctx.scope];
612
+ const steps = [["plugin", "marketplace", "add", `${CLAUDE_MARKETPLACE_REPO}@${ctx.assets.releaseTag}`, ...scopeArgs]];
613
+ // An update only over OUR row at this scope; a plugin effective here from another scope is not it.
614
+ if (before.installedAtScope) {
615
+ steps.push(["plugin", "marketplace", "update", CLAUDE_PLUGIN_MARKETPLACE]);
616
+ steps.push(["plugin", "update", CLAUDE_PLUGIN_ID, ...scopeArgs]);
617
+ // Our row with NO enable at this scope (not a disable: that is the user's choice, kept above).
618
+ if (!before.enabledAtScope && !before.disabledAtScope)
619
+ steps.push(["plugin", "enable", CLAUDE_PLUGIN_ID, ...scopeArgs]);
620
+ }
621
+ else {
622
+ steps.push(["plugin", "install", CLAUDE_PLUGIN_ID, ...scopeArgs]);
623
+ }
624
+ for (const args of steps) {
625
+ const cliFailure = await runCli(ctx, cli, args);
626
+ if (cliFailure)
627
+ return cliFailure;
628
+ }
629
+ const after = claudePluginExact(scopeCtx, ctx.assets, ctx.scope);
630
+ if (before.disabledAtScope) {
631
+ if (!after.pinnedAtScope || !after.disabledAtScope) {
632
+ return failed("harness_install_unverified", `after the claude plugin update, the disabled plugin at this scope is not ${ctx.assets.mcpVersion} from ${ctx.assets.releaseTag} (or was re-enabled): run \`claude plugin list\``);
633
+ }
634
+ return { status: "done", reason: `upgraded${links.followed ? ",symlink_followed" : ""},disabled_by_user`, fix: offFix };
635
+ }
636
+ if (!after.exact) {
637
+ return failed("harness_install_unverified", `after the claude plugin install, the installed plugin is not ${ctx.assets.mcpVersion} from ${ctx.assets.releaseTag}: run \`claude plugin list\` and doctor --harness claude`);
638
+ }
639
+ const reason = before.installedAtScope ? "upgraded" : "installed";
640
+ return { status: "done", reason: links.followed ? `${reason},symlink_followed` : reason };
641
+ }
642
+ /** The `harness` step for one harness and scope. Never throws for a host condition. */
643
+ export async function installHarness(ctx) {
644
+ const scopeCtx = scopeContext(ctx.env, ctx.cwd, ctx.fs ?? nodeRegistrationFs, ctx.claudeManagedDir ?? "/etc/claude-code");
645
+ const refusal = scopeRefusal(ctx.harness, ctx.scope, ctx);
646
+ if (refusal)
647
+ return failed(refusal.reason, refusal.fix);
648
+ if (ctx.harness === "claude")
649
+ return installClaude(ctx, scopeCtx);
650
+ if (ctx.harness === "pi" || ctx.harness === "opencode")
651
+ return installPackage(ctx, scopeCtx);
652
+ return installMcp(ctx, scopeCtx);
653
+ }
@@ -0,0 +1,20 @@
1
+ # Installed to ~/.config/systemd/user/ by scripts/keyslot/provision.sh (IF-0-KEYSEAL-2).
2
+ # Loads the slot key into agent-board-keyslot@%i whenever that agent starts or restarts: a
3
+ # sealed key by decrypting it into a pipe, or an opted-in unsealed key from its 0600 file (A1).
4
+ # slot-%i is a per-slot symlink to the immutable loader version that slot runs (A1).
5
+ [Unit]
6
+ Description=Message Board key slot loader for %i (sealed key via a pipe, or opted-in unsealed key)
7
+ Documentation=https://github.com/Consiliency/message-board
8
+ BindsTo=agent-board-keyslot@%i.service
9
+ After=agent-board-keyslot@%i.service
10
+
11
+ [Service]
12
+ Type=oneshot
13
+ RemainAfterExit=yes
14
+ # Informational only: keyslot-load.sh sets its own socket from XDG_RUNTIME_DIR and %i,
15
+ # because the manager exports an ambient SSH_AUTH_SOCK (M7).
16
+ Environment=SSH_AUTH_SOCK=%t/agent-board/%i.sock
17
+ ExecStart=%h/.local/libexec/agent-board/slot-%i/keyslot-load.sh %i
18
+
19
+ [Install]
20
+ WantedBy=agent-board-keyslot@%i.service