@consiliency/agent-board-cli 1.24.0 → 1.26.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.
Files changed (37) hide show
  1. package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
  2. package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
  3. package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
  4. package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
  5. package/dist/assets.d.ts +26 -0
  6. package/dist/assets.js +70 -0
  7. package/dist/assets.json +6 -0
  8. package/dist/claude_marketplace.d.ts +117 -0
  9. package/dist/claude_marketplace.js +306 -0
  10. package/dist/cli.d.ts +37 -3
  11. package/dist/cli.js +68 -26
  12. package/dist/config_edit.d.ts +37 -0
  13. package/dist/config_edit.js +549 -0
  14. package/dist/doctor.d.ts +24 -2
  15. package/dist/doctor.js +164 -4
  16. package/dist/doctor_scopes.d.ts +34 -2
  17. package/dist/doctor_scopes.js +128 -20
  18. package/dist/identity_writer.d.ts +9 -7
  19. package/dist/identity_writer.js +18 -30
  20. package/dist/setup.d.ts +116 -0
  21. package/dist/setup.js +603 -0
  22. package/dist/setup_storage.d.ts +117 -0
  23. package/dist/setup_storage.js +447 -0
  24. package/dist/setup_writers.d.ts +147 -0
  25. package/dist/setup_writers.js +916 -0
  26. package/keyslot/agent-board-keyslot-load@.service +20 -0
  27. package/keyslot/agent-board-keyslot@.service +20 -0
  28. package/keyslot/constants.sh +73 -0
  29. package/keyslot/keygen.mjs +67 -0
  30. package/keyslot/keyslot-load.sh +78 -0
  31. package/keyslot/lib.sh +599 -0
  32. package/keyslot/optin.sh +185 -0
  33. package/keyslot/probe.sh +135 -0
  34. package/keyslot/provision.sh +530 -0
  35. package/keyslot/seam.sh +48 -0
  36. package/keyslot/status.sh +254 -0
  37. package/package.json +9 -4
package/dist/doctor.js CHANGED
@@ -9,15 +9,26 @@
9
9
  // only with a live `wake_producer` at its expected tier, so 0 is never returned without runtime
10
10
  // evidence. Output is names and codes only: no identity value, key or path content is printed,
11
11
  // and an error's `code` is printed only when this repository authored it (`authoredCode`).
12
+ import { execFile } from "node:child_process";
12
13
  import { randomBytes } from "node:crypto";
14
+ import { existsSync, readdirSync } from "node:fs";
13
15
  import { homedir, userInfo } from "node:os";
14
- import { resolve } from "node:path";
16
+ import { join, resolve } from "node:path";
15
17
  import { AUTHORED_CAUSE_CODES, AgentSocketSelectionError, GENERIC_SUPABASE_FIELDS, IDENTITY_FILE_KEYS, IDENTITY_TRIGGER_FIELDS, KeystoreUnavailableError, assertIdentityHarness, codeProvenance, createAgentBoardClient, createOsKeychainBackend, expectedWakeTier, normalizeKeyReference, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
16
18
  import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
19
+ import { resolveKeyslotDir } from "./assets.js";
20
+ import { SLOT_RE, externalDeclarationProblems, fileIdentity, keyslotSocketDir, machineIdOf, resolveAgentSocketPath, unsealedOptinProblems, } from "./setup_storage.js";
17
21
  export const DOCTOR_CHECKS = Object.freeze([
18
- "identity_file", "keystore_key", "whoami", "board_membership", "registration", "runtime",
22
+ "identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime",
19
23
  ]);
20
24
  export const REPORTABLE_TOKEN = /^[A-Za-z0-9._/-]{1,64}$/;
25
+ /**
26
+ * A path prints only when absolute and made of the token alphabet (no spaces, quotes or control
27
+ * characters), up to 1024 characters; else `unrecognised`. It is only ever doctor's own derived
28
+ * per-user directory (#549), never a string read from a file, but `$HOME` is still input.
29
+ */
30
+ export const REPORTABLE_PATH = /^\/[A-Za-z0-9._/-]{0,1023}$/;
31
+ const reportablePath = (value) => value === null ? null : REPORTABLE_PATH.test(value) ? value : "unrecognised";
21
32
  const reportable = (value) => value === null ? null : REPORTABLE_TOKEN.test(value) ? value : "unrecognised";
22
33
  /**
23
34
  * The pin, over the live wake_producers whose cwd (realpath) is doctor's cwd, and only while our
@@ -32,6 +43,12 @@ function pinCheck(verdict, entries, cwd, fs) {
32
43
  const pin = verdict.plugin?.installed && verdict.plugin.serverActive ? verdict.plugin.version : null;
33
44
  if (pin === null)
34
45
  return verdict;
46
+ // Round 4: a directory-sourced plugin runs from its directory. When that directory was not read as
47
+ // the effective, sound source (newer, stale, untrusted, unverifiable), `pin` is cache metadata,
48
+ // which says nothing about what runs: no mismatch is declared from it. The directory's own reason
49
+ // already stands (fail or undetermined).
50
+ if (verdict.plugin?.sourceKind === "directory" && verdict.plugin.versionFrom !== "source")
51
+ return verdict;
35
52
  // Real paths on both sides (panel S2): a server records its physical cwd; doctor may be in a symlink.
36
53
  const real = (path) => fs.realpath(path) ?? resolve(path);
37
54
  const here = entries.flatMap((entry) => entry.status === "live" && entry.record.role === "wake_producer"
@@ -228,6 +245,142 @@ async function keystoreCheck(values, env, probe) {
228
245
  ? check("pass", "fingerprint_matches")
229
246
  : check("fail", "fingerprint_mismatch");
230
247
  }
248
+ /**
249
+ * IF-0-SETUP-3 / IF-0-KEYSEAL-6 — which storage holds the identity's key, MEASURED: from the file's
250
+ * key reference plus the packaged `status.sh --json`, never from anything setup recorded.
251
+ *
252
+ * - an `os_keychain` reference → pass `os_keychain` (keystore_key proves the key is there);
253
+ * - an `agent_socket` reference: the socket keystore_key signs through (same resolution, including
254
+ * `MESSAGE_BOARD_AGENT_SOCKET_PATH`) is matched by DEVICE AND INODE, never spelling, against
255
+ * each `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`;
256
+ * - a keyslot → `status.sh --harness <h> --slot <slot> --json`. Its measurement fields
257
+ * (machine_id, uid, slot, seam, mode) are compared with doctor's own values FIRST: a report
258
+ * that is not about this host, user and slot says nothing, so a mismatch or a missing field is
259
+ * 2 even beside a status exit 1 (both are non-pass). Then exit 1 → 1, exit 2 → 2, and exit 0
260
+ * passes as `sealed`, or as `unsealed` only when doctor's own field-by-field read of the opt-in
261
+ * is valid (an invalid opt-in is 1, as A1-S2 requires of status.sh);
262
+ * - any other socket → `external`, which passes only with a valid declaration
263
+ * (`<configRoot>/agent-board/external-agent`); undeclared or mismatched → 2.
264
+ * - status.sh absent, unrunnable, timed out or unparseable → 2.
265
+ */
266
+ const STATUS_MODES = new Set(["sealed", "unsealed", "contradiction", "undetermined"]);
267
+ const STATUS_EXIT2_REASON = /^(nothing_committed|undeterminable|linger_off|upgrade_available|loader_outdated|loader_unrecorded|legacy_loader_present|capability_(error|incapable|incapable-for-keyslot))$/;
268
+ const STATUS_TIMEOUT_MS = 180_000;
269
+ function runStatus(file, args, env) {
270
+ return new Promise((done) => {
271
+ execFile(file, args, { env, timeout: STATUS_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout) => {
272
+ const failure = error;
273
+ const code = !failure ? 0 : typeof failure.code === "number" && !failure.killed ? failure.code : null;
274
+ done({ code, stdout: String(stdout ?? "") });
275
+ });
276
+ });
277
+ }
278
+ /** The keyslot whose socket is the same file as `socket`, or null. */
279
+ function matchKeyslot(socket, env) {
280
+ const dir = keyslotSocketDir(env);
281
+ const target = fileIdentity(socket);
282
+ if (!dir || !target)
283
+ return null;
284
+ let names;
285
+ try {
286
+ names = readdirSync(dir);
287
+ }
288
+ catch {
289
+ return null;
290
+ }
291
+ for (const name of names.sort()) {
292
+ if (!name.endsWith(".sock"))
293
+ continue;
294
+ const slot = name.slice(0, -".sock".length);
295
+ if (!SLOT_RE.test(slot))
296
+ continue;
297
+ const candidate = fileIdentity(join(dir, name));
298
+ if (candidate && candidate.dev === target.dev && candidate.ino === target.ino)
299
+ return slot;
300
+ }
301
+ return null;
302
+ }
303
+ async function keyStorageCheck(harness, values, fileEnv, env, options) {
304
+ let reference;
305
+ try {
306
+ reference = normalizeKeyReference(values[KEY_REFERENCE_KEY]);
307
+ }
308
+ catch {
309
+ return check("undetermined", "key_reference_invalid");
310
+ }
311
+ if (reference.resolver === "os_keychain")
312
+ return check("pass", "os_keychain");
313
+ if (reference.resolver !== "agent_socket")
314
+ return check("undetermined", "resolver_unsupported");
315
+ const socket = resolveAgentSocketPath(String(reference.handle), fileEnv);
316
+ if (socket === null)
317
+ return check("undetermined", "agent_socket_unresolved");
318
+ if (fileIdentity(socket) === null)
319
+ return check("undetermined", "agent_socket_absent");
320
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1);
321
+ const machineId = machineIdOf(options.machineIdFile);
322
+ const slot = matchKeyslot(socket, env);
323
+ if (slot === null) {
324
+ const problems = externalDeclarationProblems(env, socket, uid, machineId);
325
+ if (problems.length === 0)
326
+ return check("pass", "external");
327
+ return check("undetermined", problems.includes("missing") ? "external_undeclared" : "external_declaration_mismatch");
328
+ }
329
+ let dir;
330
+ try {
331
+ dir = options.keyslotDir ?? resolveKeyslotDir();
332
+ }
333
+ catch {
334
+ return check("undetermined", "status_absent");
335
+ }
336
+ if (!existsSync(join(dir, "status.sh")))
337
+ return check("undetermined", "status_absent");
338
+ const run = await runStatus(join(dir, "status.sh"), ["--harness", harness, "--slot", slot, "--json"], env);
339
+ if (run.code === null)
340
+ return check("undetermined", "status_did_not_complete");
341
+ let report;
342
+ try {
343
+ const parsed = JSON.parse(run.stdout);
344
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
345
+ throw new Error("not an object");
346
+ report = parsed;
347
+ }
348
+ catch {
349
+ return check("undetermined", "status_unreadable");
350
+ }
351
+ const seam = typeof env.KEYSEAL_TEST_ROOT === "string" && env.KEYSEAL_TEST_ROOT !== "";
352
+ const own = {
353
+ machine_id: (value) => machineId !== null && value === machineId,
354
+ uid: (value) => value === uid,
355
+ slot: (value) => value === slot,
356
+ seam: (value) => value === seam,
357
+ mode: (value) => typeof value === "string" && STATUS_MODES.has(value),
358
+ };
359
+ for (const [field, matches] of Object.entries(own)) {
360
+ if (!(field in report) || report[field] === null)
361
+ return check("undetermined", `status_field_missing:${field}`);
362
+ if (!matches(report[field]))
363
+ return check("undetermined", `status_field_mismatch:${field}`);
364
+ }
365
+ if (run.code === 1)
366
+ return check("fail", "keyslot_status_failed");
367
+ if (run.code === 2) {
368
+ const reasons = Array.isArray(report.exit2_reasons) ? report.exit2_reasons : [];
369
+ const named = reasons.length > 0 && reasons.every((reason) => typeof reason === "string" && STATUS_EXIT2_REASON.test(reason));
370
+ return check("undetermined", named ? `keyslot_status_undetermined:${reasons.join(",")}` : "keyslot_status_undetermined");
371
+ }
372
+ if (run.code !== 0)
373
+ return check("undetermined", "status_unexpected_exit");
374
+ if (report.mode === "sealed")
375
+ return check("pass", "sealed");
376
+ if (report.mode === "unsealed") {
377
+ const optin = report.optin;
378
+ if (optin?.ok !== true)
379
+ return check("fail", "unsealed_optin_invalid");
380
+ return unsealedOptinProblems(env, slot, uid, machineId).length === 0 ? check("pass", "unsealed") : check("fail", "unsealed_optin_invalid");
381
+ }
382
+ return check("undetermined", "status_mode_unexpected");
383
+ }
231
384
  async function whoamiCheck(client, expectedHandle) {
232
385
  let rows;
233
386
  try {
@@ -342,12 +495,14 @@ export async function runDoctor(options) {
342
495
  }
343
496
  if (values === null) {
344
497
  checks.keystore_key = check("undetermined", "no_identity_file");
498
+ checks.key_storage = check("undetermined", "no_identity_file");
345
499
  checks.whoami = check("undetermined", "no_identity_file");
346
500
  checks.board_membership = check("undetermined", "no_identity_file");
347
501
  }
348
502
  else {
349
503
  const fileEnv = fileIdentityEnv(env, values);
350
504
  checks.keystore_key = await keystoreCheck(values, fileEnv, options.probeKeystore ?? defaultKeystoreProbe);
505
+ checks.key_storage = await keyStorageCheck(harness, values, fileEnv, env, options);
351
506
  let client = null;
352
507
  try {
353
508
  client = (options.createClient ?? defaultClient)(fileEnv);
@@ -391,7 +546,8 @@ export async function runDoctor(options) {
391
546
  const verdicts = cwds.map((dir) => {
392
547
  try {
393
548
  return judgeRegistration(harness, { cwd: dir, home, env, fs, claudeManagedDir: options.claudeManagedDir ?? defaultClaudeManagedDir(platform),
394
- platform, userName, regQuery: options.regQuery });
549
+ platform, userName, regQuery: options.regQuery, claudeMarketplacePackaged: options.claudeMarketplacePackaged,
550
+ claudeMarketplaceTrust: options.claudeMarketplaceTrust });
395
551
  }
396
552
  catch (error) {
397
553
  return check("undetermined", codeOf(error, "registration_unreadable"));
@@ -413,6 +569,9 @@ export async function runDoctor(options) {
413
569
  installed_version: reportable(plugin?.version ?? null),
414
570
  pinned_mcp_version: reportable(plugin?.pinnedMcpVersion ?? null),
415
571
  source_ref: reportable(plugin?.sourceRef ?? null),
572
+ source_kind: plugin?.sourceKind ?? null,
573
+ source_path: reportablePath(plugin?.sourcePath ?? null),
574
+ version_from: plugin?.versionFrom ?? null,
416
575
  auto_update: plugin ? { effective: plugin.autoUpdate.effective, source: plugin.autoUpdate.source } : { effective: false, source: "undetermined" },
417
576
  };
418
577
  if (here.hookCounts)
@@ -434,9 +593,10 @@ export function formatDoctorReport(report) {
434
593
  const counts = report.registration?.hook_counts;
435
594
  lines.push([
436
595
  `${"plugin".padEnd(12)} installed=${plugin.installed ? "yes" : "no"}`,
437
- `version=${plugin.installed_version ?? "none"}`,
596
+ `version=${plugin.installed_version ?? "none"}${plugin.version_from ? `(${plugin.version_from})` : ""}`,
438
597
  `pinned_mcp=${plugin.pinned_mcp_version ?? "none"}`,
439
598
  `ref=${plugin.source_ref ?? "none"}`,
599
+ `source=${plugin.source_kind ?? "none"}${plugin.source_path ? `:${plugin.source_path}` : ""}`,
440
600
  `auto_update=${plugin.auto_update.effective ? "on" : "off"}(${plugin.auto_update.source})`,
441
601
  counts ? `hooks SessionStart=${counts.SessionStart} FileChanged=${counts.FileChanged}` : "hooks undetermined",
442
602
  ].join(" "));
@@ -1,4 +1,5 @@
1
1
  import type { IdentityHarness } from "@consiliency/agent-board-client";
2
+ import { type TrustOptions } from "./claude_marketplace.js";
2
3
  export declare const BOARD_MCP_MARKER = "agent-board-mcp";
3
4
  export declare const METADATA_ONLY_FLAG = "--metadata-only";
4
5
  export declare const OPENCODE_PLUGIN_PACKAGE = "@consiliency/agent-board-opencode";
@@ -12,6 +13,8 @@ export declare const CLAUDE_PLUGIN_MARKETPLACE = "message-board";
12
13
  /** Compared lower-cased, as Claude Code compares repos. */
13
14
  export declare const CLAUDE_PLUGIN_REPO = "consiliency/message-board";
14
15
  export declare const CLAUDE_PLUGIN_SERVER = "agent-board";
16
+ /** message-board#549: THE per-user marketplace directory. Defined once, in `claude_marketplace.ts`. */
17
+ export { claudeMarketplaceDir } from "./claude_marketplace.js";
15
18
  export declare const CLAUDE_PLUGIN_MCP_PACKAGE = "@consiliency/agent-board-mcp";
16
19
  /** The SL-0 outcome: `B` = the FileChanged hook carries `asyncRewake: true`. */
17
20
  export declare const CLAUDE_WAKE_SHAPE: "A" | "B";
@@ -40,6 +43,13 @@ export interface ScopeContext {
40
43
  userName?: string;
41
44
  /** Registry probe for the Windows policy keys; default `nodeRegQuery`. */
42
45
  regQuery?: RegQuery;
46
+ /**
47
+ * The cli's packaged marketplace (#549), which our directory source must equal byte for byte.
48
+ * Default: this cli package's own `claude-marketplace/`.
49
+ */
50
+ claudeMarketplacePackaged?: string;
51
+ /** Test injection for the shared trust chain (`/etc/group`, gid, user name, ownership). */
52
+ claudeMarketplaceTrust?: Omit<TrustOptions, "uid" | "platform">;
43
53
  }
44
54
  /** The filesystem reads registration makes; everything is read-only. */
45
55
  export interface RegistrationFs {
@@ -163,6 +173,12 @@ export interface ClaudePluginSummary {
163
173
  version: string | null;
164
174
  pinnedMcpVersion: string | null;
165
175
  sourceRef: string | null;
176
+ /** The winning declaration's kind, when the marketplace is ours: the github repo, or our directory. */
177
+ sourceKind: "github" | "directory" | null;
178
+ /** A directory source's path (always `claudeMarketplaceDir` for this env when set); else null. */
179
+ sourcePath: string | null;
180
+ /** Where `version` and the pin were read: our validated source directory, or the cache row (#549 round 3). */
181
+ versionFrom: "source" | "cache" | null;
166
182
  autoUpdate: {
167
183
  effective: boolean;
168
184
  source: AutoUpdateSource;
@@ -174,6 +190,11 @@ export interface ClaudePluginRead {
174
190
  unparseable: string[];
175
191
  /** An installPath (or a file under it) resolved outside the cache: nothing beyond it was read. */
176
192
  untrusted: boolean;
193
+ /**
194
+ * Undetermined reasons about our directory source: `plugin_source_unverifiable` (no packaged copy
195
+ * to compare with) or `plugin_source_newer` (it holds a NEWER plugin than this cli ships).
196
+ */
197
+ sourceUndetermined: string[];
177
198
  failures: string[];
178
199
  summary: ClaudePluginSummary;
179
200
  hookPolicy: ClaudeHookPolicy;
@@ -252,9 +273,21 @@ export declare const managedFileTierReplaced: (probe: ManagedPolicyProbe) => boo
252
273
  * IF-0-CCPLUGIN-3: every plugin enabled for `ctx.cwd`, followed from `enabledPlugins` to its
253
274
  * installed row and read from the cache. Ours is the plugin id `agent-board@message-board` whose
254
275
  * marketplace is declared, EVERYWHERE it is declared, as the github repository
255
- * Consiliency/message-board; anything else contributing a board server or a wake hook is foreign.
276
+ * Consiliency/message-board or as the per-user directory `claudeMarketplaceDir` (#549); anything
277
+ * else contributing a board server or a wake hook is foreign.
256
278
  */
257
279
  export declare function readClaudePluginScopes(ctx: ScopeContext): ClaudePluginRead;
280
+ export type ClaudeSettingsLayer = "user" | "project" | "local" | "managed";
281
+ /**
282
+ * Every settings layer that declares OUR marketplace name, in ascending precedence (the last one wins),
283
+ * with whether its source is exactly our per-user directory. Setup uses it to migrate each layer it
284
+ * may write and to name the one it may not.
285
+ */
286
+ export declare function claudeMarketplaceDeclarations(ctx: ScopeContext): Array<{
287
+ layer: ClaudeSettingsLayer;
288
+ path: string;
289
+ directory: boolean;
290
+ }>;
258
291
  /** A package spec's identity without its version: `@scope/name@1.2.3` → `@scope/name`. */
259
292
  export declare function packageIdentity(spec: string): string;
260
293
  interface BoardEntryVerdict {
@@ -285,4 +318,3 @@ export declare function readScopes(harness: IdentityHarness, ctx: ScopeContext):
285
318
  export declare function claudeProjectKey(ctx: ScopeContext): string;
286
319
  /** IF-0-CREDPATH-4 registration, for one working directory. */
287
320
  export declare function judgeRegistration(harness: IdentityHarness, ctx: ScopeContext): RegistrationVerdict;
288
- export {};
@@ -84,9 +84,14 @@
84
84
  // tree a github-sourced plugin runs from its row's `installPath` in the cache (measured:
85
85
  // editing the cached `plugin.json` changed what ran, the marketplace clone's did
86
86
  // not). A DIRECTORY-sourced plugin runs from the source directory, not the cache
87
- // (editing the source without reinstalling changed what ran). Ours never has a
88
- // directory source; for a FOREIGN directory plugin this is a floor: doctor reads
89
- // the cache copy, which may not be what runs.
87
+ // (editing the source without reinstalling changed what ran). For a FOREIGN
88
+ // directory plugin this is a floor: doctor reads the cache copy, which may not be
89
+ // what runs. Ours has a directory source since message-board#549 — the per-user
90
+ // `claudeMarketplaceDir` that `setup` materialises from the cli package, byte-equal
91
+ // to what it installed — so the floor is the same, and a MISSING directory is
92
+ // `plugin_source_missing` (measured: `plugin list` then shows `failed to load`).
93
+ // A directory source is `{source:"directory", path}` in both files (measured,
94
+ // claude 2.1.283, 2026-09-27); its installed row has no `gitCommitSha`.
90
95
  // auto-update off globally when `DISABLE_UPDATES` or `DISABLE_AUTOUPDATER` is truthy
91
96
  // (1/true/yes/on), `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` is non-empty, or
92
97
  // `.claude.json` has `autoUpdates: false` (unless `installMethod` is native and
@@ -228,6 +233,8 @@
228
233
  import { spawnSync } from "node:child_process";
229
234
  import * as nodeFs from "node:fs";
230
235
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
236
+ import { resolveMarketplaceDir } from "./assets.js";
237
+ import { MARKETPLACE_MANIFEST_KEY, checkTrustChain, claudeMarketplaceDir, compareVersions, isOurMarketplacePath, pluginVersionIn, readTree, sameTree, } from "./claude_marketplace.js";
231
238
  export const BOARD_MCP_MARKER = "agent-board-mcp";
232
239
  export const METADATA_ONLY_FLAG = "--metadata-only";
233
240
  export const OPENCODE_PLUGIN_PACKAGE = "@consiliency/agent-board-opencode";
@@ -244,6 +251,8 @@ export const CLAUDE_PLUGIN_MARKETPLACE = "message-board";
244
251
  /** Compared lower-cased, as Claude Code compares repos. */
245
252
  export const CLAUDE_PLUGIN_REPO = "consiliency/message-board";
246
253
  export const CLAUDE_PLUGIN_SERVER = "agent-board";
254
+ /** message-board#549: THE per-user marketplace directory. Defined once, in `claude_marketplace.ts`. */
255
+ export { claudeMarketplaceDir } from "./claude_marketplace.js";
247
256
  export const CLAUDE_PLUGIN_MCP_PACKAGE = "@consiliency/agent-board-mcp";
248
257
  /** The SL-0 outcome: `B` = the FileChanged hook carries `asyncRewake: true`. */
249
258
  export const CLAUDE_WAKE_SHAPE = "B";
@@ -1068,11 +1077,24 @@ function readJsonFile(fs, path) {
1068
1077
  return text === null ? null : parseJson(text);
1069
1078
  }
1070
1079
  const truthyEnv = (value) => value !== undefined && ["1", "true", "yes", "on"].includes(value.toLowerCase().trim());
1071
- /** `source` names a github marketplace of this repository, with no alternate manifest path. */
1072
- function isOurMarketplaceSource(source) {
1073
- const repo = own(source, "repo");
1074
- return isObject(source) && own(source, "source") === "github" && typeof repo === "string"
1075
- && repo.toLowerCase() === CLAUDE_PLUGIN_REPO && own(source, "path") === undefined;
1080
+ /**
1081
+ * `source` is ours: a github marketplace of this repository with no alternate manifest path (fleet
1082
+ * hosts installed from a release tag), OR a directory marketplace whose path resolves to THE
1083
+ * per-user directory setup materialises (`claudeMarketplaceDir`, message-board#549). Any other
1084
+ * directory is not ours, so a plugin from it is judged as foreign.
1085
+ */
1086
+ function marketplaceSourceKind(source, ctx) {
1087
+ if (!isObject(source))
1088
+ return null;
1089
+ const kind = own(source, "source");
1090
+ if (kind === "github") {
1091
+ const repo = own(source, "repo");
1092
+ return typeof repo === "string" && repo.toLowerCase() === CLAUDE_PLUGIN_REPO && own(source, "path") === undefined ? "github" : null;
1093
+ }
1094
+ if (kind === "directory") {
1095
+ return isOurMarketplacePath(own(source, "path"), ctx.env, ctx.home) ? "directory" : null;
1096
+ }
1097
+ return null;
1076
1098
  }
1077
1099
  /**
1078
1100
  * Reads one plugin tree, confined to the cache. `installPath` must resolve (realpath) strictly
@@ -1080,10 +1102,11 @@ function isOurMarketplaceSource(source) {
1080
1102
  * `UntrustedPluginPathError` and nothing beyond it is read: the only call made on an untrusted
1081
1103
  * path is the `realpath` that judged it.
1082
1104
  */
1083
- function readPluginTree(ctx, installPath, serverPrefix) {
1105
+ function readPluginTree(ctx, installPath, serverPrefix, confineTo) {
1084
1106
  if (!isAbsolute(installPath))
1085
1107
  throw new UntrustedPluginPathError();
1086
- const cache = ctx.fs.realpath(join(claudePluginsDir(ctx), "cache"));
1108
+ // Confined to the cache, or (round 3, C2) to our validated source directory.
1109
+ const cache = ctx.fs.realpath(confineTo ?? join(claudePluginsDir(ctx), "cache"));
1087
1110
  const root = cache === null ? null : ctx.fs.realpath(installPath);
1088
1111
  if (cache === null || root === null || !within(root, cache))
1089
1112
  throw new UntrustedPluginPathError();
@@ -1207,15 +1230,18 @@ function claudeAutoUpdate(ctx, declarations, known) {
1207
1230
  * IF-0-CCPLUGIN-3: every plugin enabled for `ctx.cwd`, followed from `enabledPlugins` to its
1208
1231
  * installed row and read from the cache. Ours is the plugin id `agent-board@message-board` whose
1209
1232
  * marketplace is declared, EVERYWHERE it is declared, as the github repository
1210
- * Consiliency/message-board; anything else contributing a board server or a wake hook is foreign.
1233
+ * Consiliency/message-board or as the per-user directory `claudeMarketplaceDir` (#549); anything
1234
+ * else contributing a board server or a wake hook is foreign.
1211
1235
  */
1212
1236
  export function readClaudePluginScopes(ctx) {
1213
1237
  const unparseable = new Set();
1214
1238
  const failures = new Set();
1215
1239
  const files = [];
1216
1240
  let untrusted = false;
1241
+ const sourceUndetermined = [];
1217
1242
  const summary = {
1218
- installed: false, serverActive: false, version: null, pinnedMcpVersion: null, sourceRef: null, autoUpdate: { effective: false, source: "default" },
1243
+ installed: false, serverActive: false, version: null, pinnedMcpVersion: null, sourceRef: null, sourceKind: null, sourcePath: null, versionFrom: null,
1244
+ autoUpdate: { effective: false, source: "default" },
1219
1245
  };
1220
1246
  const tolerant = (read, fallback) => {
1221
1247
  try {
@@ -1271,12 +1297,17 @@ export function readClaudePluginScopes(ctx) {
1271
1297
  const ourDeclarations = declarationsOf(CLAUDE_PLUGIN_MARKETPLACE);
1272
1298
  const ourKnown = own(known, CLAUDE_PLUGIN_MARKETPLACE);
1273
1299
  const everyDeclaration = [...ourDeclarations, ...(ourKnown === undefined ? [] : [ourKnown])];
1300
+ // Every declaration must be ours, but not all of one kind: a host migrated from the github ref to
1301
+ // the directory can still carry the github form in a project's settings file.
1274
1302
  const marketplaceIsOurs = everyDeclaration.length > 0
1275
- && everyDeclaration.every((declaration) => isOurMarketplaceSource(own(declaration, "source")));
1303
+ && everyDeclaration.every((declaration) => marketplaceSourceKind(own(declaration, "source"), ctx) !== null);
1276
1304
  if (marketplaceIsOurs) {
1277
- // The winning declaration's ref: the highest settings scope, else known_marketplaces.json.
1278
- const ref = own(own(everyDeclaration[ourDeclarations.length > 0 ? ourDeclarations.length - 1 : 0], "source"), "ref");
1279
- summary.sourceRef = typeof ref === "string" ? ref : null;
1305
+ // The winning declaration: the highest settings scope, else known_marketplaces.json.
1306
+ const winning = own(everyDeclaration[ourDeclarations.length > 0 ? ourDeclarations.length - 1 : 0], "source");
1307
+ summary.sourceKind = marketplaceSourceKind(winning, ctx);
1308
+ const ref = own(winning, "ref");
1309
+ summary.sourceRef = summary.sourceKind === "github" && typeof ref === "string" ? ref : null;
1310
+ summary.sourcePath = summary.sourceKind === "directory" ? claudeMarketplaceDir(ctx.env, ctx.home) : null;
1280
1311
  }
1281
1312
  summary.autoUpdate = tolerant(() => claudeAutoUpdate(ctx, ourDeclarations, known), { effective: false, source: "undetermined" });
1282
1313
  for (const [id, on] of enabled) {
@@ -1303,9 +1334,23 @@ export function readClaudePluginScopes(ctx) {
1303
1334
  continue;
1304
1335
  }
1305
1336
  const ours = id === CLAUDE_PLUGIN_ID && marketplaceIsOurs;
1337
+ // Round 3 (C2): a directory-sourced plugin RUNS from its source directory, so once that directory
1338
+ // is judged sound (trusted, byte-equal to this cli's packaged copy), the version, pin, stanza and
1339
+ // hooks are read from IT, not from the cache row (which a migration can leave at an old version).
1340
+ // A github source, or a source that is not sound, is read from the cache as before.
1341
+ let sourceVerdict;
1342
+ if (ours && summary.sourceKind === "directory" && summary.sourcePath !== null) {
1343
+ sourceVerdict = judgeOurSourceDirectory(ctx, summary.sourcePath);
1344
+ if (sourceVerdict === "plugin_source_unverifiable" || sourceVerdict === "plugin_source_newer")
1345
+ sourceUndetermined.push(sourceVerdict);
1346
+ else if (sourceVerdict !== null)
1347
+ failures.add(sourceVerdict);
1348
+ }
1349
+ const fromSource = ours && sourceVerdict === null && summary.sourcePath !== null;
1350
+ const treePath = fromSource ? join(summary.sourcePath, "plugins", "agent-board") : own(row, "installPath");
1306
1351
  let tree;
1307
1352
  try {
1308
- tree = readPluginTree(ctx, own(row, "installPath"), `plugin:${name}`);
1353
+ tree = readPluginTree(ctx, treePath, `plugin:${name}`, fromSource ? summary.sourcePath : undefined);
1309
1354
  }
1310
1355
  catch (error) {
1311
1356
  if (error instanceof UntrustedPluginPathError) {
@@ -1318,10 +1363,11 @@ export function readClaudePluginScopes(ctx) {
1318
1363
  continue;
1319
1364
  }
1320
1365
  const { contribution, manifest } = tree;
1321
- files.push({ row: ours ? CLAUDE_PLUGIN_ROW : CLAUDE_FOREIGN_PLUGIN_ROW, path: own(row, "installPath"), contribution, pluginId: id });
1366
+ files.push({ row: ours ? CLAUDE_PLUGIN_ROW : CLAUDE_FOREIGN_PLUGIN_ROW, path: treePath, contribution, pluginId: id });
1322
1367
  if (ours) {
1323
1368
  const version = own(manifest, "version");
1324
1369
  summary.installed = true;
1370
+ summary.versionFrom = fromSource ? "source" : "cache";
1325
1371
  summary.version = typeof version === "string" ? version : null;
1326
1372
  const pinArg = contribution.mcp[0]?.args.find((arg) => arg.startsWith(`${CLAUDE_PLUGIN_MCP_PACKAGE}@`));
1327
1373
  summary.pinnedMcpVersion = pinArg === undefined ? null : pinArg.slice(CLAUDE_PLUGIN_MCP_PACKAGE.length + 1);
@@ -1335,7 +1381,68 @@ export function readClaudePluginScopes(ctx) {
1335
1381
  failures.add("foreign_board_plugin");
1336
1382
  }
1337
1383
  }
1338
- return { files, unparseable: [...unparseable], untrusted, failures: [...failures], summary, hookPolicy };
1384
+ return { files, unparseable: [...unparseable], untrusted, sourceUndetermined, failures: [...failures], summary, hookPolicy };
1385
+ }
1386
+ /**
1387
+ * Our directory source, judged as what RUNS: a directory-sourced plugin runs from its source
1388
+ * directory, not the cache copy doctor reads above (CCPLUGIN recon; the header's `tree`). So the
1389
+ * directory itself must be trusted (`checkTrustChain`, shared with setup) and byte-equal to this cli's
1390
+ * packaged marketplace (`readTree`/`sameTree`, shared with setup). Gone → `plugin_source_missing`
1391
+ * (Claude Code then fails to load it, measured). Doctor never tightens a mode: that is setup's.
1392
+ */
1393
+ function judgeOurSourceDirectory(ctx, dir) {
1394
+ if (!ctx.fs.exists(dir))
1395
+ return "plugin_source_missing";
1396
+ if (checkTrustChain(dir, { ...ctx.claudeMarketplaceTrust, uid: ctx.fs.getuid(), platform: ctx.platform }) !== null)
1397
+ return "plugin_source_untrusted";
1398
+ let packagedDir;
1399
+ try {
1400
+ packagedDir = ctx.claudeMarketplacePackaged ?? resolveMarketplaceDir();
1401
+ }
1402
+ catch {
1403
+ return "plugin_source_unverifiable";
1404
+ }
1405
+ const packaged = readTree(packagedDir);
1406
+ if (packaged.kind !== "ok" || !packaged.files.has(MARKETPLACE_MANIFEST_KEY))
1407
+ return "plugin_source_unverifiable";
1408
+ // Round 5: every entry inside the directory is judged too (owner, no group/world write): it is
1409
+ // what Claude Code executes. The offending path is not echoed: reasons are authored codes.
1410
+ const current = readTree(dir, { uid: ctx.fs.getuid(), platform: ctx.platform, lstat: ctx.claudeMarketplaceTrust?.lstat });
1411
+ if (current.kind === "missing")
1412
+ return "plugin_source_missing";
1413
+ if (current.kind === "untrusted")
1414
+ return "plugin_source_untrusted";
1415
+ if (current.kind === "ok" && sameTree(current.files, packaged.files))
1416
+ return null;
1417
+ // Round 2, version skew: a directory another cli version wrote. OLDER (or the same version with
1418
+ // other bytes, or unreadable) is stale: re-run setup with this cli. NEWER means THIS doctor is
1419
+ // the old one; it cannot judge a plugin it does not ship, so that is undetermined, not a failure.
1420
+ const skew = current.kind === "ok" ? compareVersions(pluginVersionIn(current.files), pluginVersionIn(packaged.files)) : null;
1421
+ return skew !== null && skew > 0 ? "plugin_source_newer" : "plugin_source_stale";
1422
+ }
1423
+ /**
1424
+ * Every settings layer that declares OUR marketplace name, in ascending precedence (the last one wins),
1425
+ * with whether its source is exactly our per-user directory. Setup uses it to migrate each layer it
1426
+ * may write and to name the one it may not.
1427
+ */
1428
+ export function claudeMarketplaceDeclarations(ctx) {
1429
+ const managed = new Set(managedSettingsPaths(ctx));
1430
+ const layerOf = (path, index) => managed.has(path) ? "managed" : index === 0 ? "user" : index === 1 ? "project" : "local";
1431
+ const out = [];
1432
+ claudeSettingsLayers(ctx).forEach((path, index) => {
1433
+ let document;
1434
+ try {
1435
+ document = readJsonFile(ctx.fs, path);
1436
+ }
1437
+ catch {
1438
+ return;
1439
+ }
1440
+ const declaration = own(own(document, "extraKnownMarketplaces"), CLAUDE_PLUGIN_MARKETPLACE);
1441
+ if (declaration === undefined)
1442
+ return;
1443
+ out.push({ layer: layerOf(path, index), path, directory: marketplaceSourceKind(own(declaration, "source"), ctx) === "directory" });
1444
+ });
1445
+ return out;
1339
1446
  }
1340
1447
  // ---------------------------------------------------------------------------------------------
1341
1448
  // Judgement
@@ -1516,7 +1623,7 @@ export function judgeRegistration(harness, ctx) {
1516
1623
  // An absence is only evidence when every file was read: the missing entry may be in the file
1517
1624
  // that did not parse, under the plugin path doctor refused to follow, or a handler doctor
1518
1625
  // cannot judge. A duplicate or the flag, seen in files that DID parse, stands.
1519
- const complete = unparseable.length === 0 && !plugins?.untrusted && !unjudgeable && !policyUnjudgeable && policyCheckFailed.length === 0;
1626
+ const complete = unparseable.length === 0 && !plugins?.untrusted && (plugins?.sourceUndetermined.length ?? 0) === 0 && !unjudgeable && !policyUnjudgeable && policyCheckFailed.length === 0;
1520
1627
  const standing = complete
1521
1628
  ? failures
1522
1629
  : failures.filter((failure) => failure !== "no_registration" && !failure.startsWith("missing_hook:"));
@@ -1525,6 +1632,7 @@ export function judgeRegistration(harness, ctx) {
1525
1632
  const unknowns = [
1526
1633
  ...(unparseable.length > 0 ? [`unparseable:${[...new Set(unparseable)].join("+")}`] : []),
1527
1634
  ...(plugins?.untrusted ? ["plugin_path_untrusted"] : []),
1635
+ ...(plugins?.sourceUndetermined ?? []),
1528
1636
  ...(unjudgeable ? ["unjudgeable_hook"] : []),
1529
1637
  ...(policyUnjudgeable ? ["hook_policy_unjudgeable"] : []),
1530
1638
  ...policyCheckFailed,
@@ -16,16 +16,18 @@ export interface IdentityDeployment {
16
16
  publishableKey: string;
17
17
  }
18
18
  /**
19
- * Where each deployment value is read, in precedence order: the enrollment's own names first
20
- * (so the file records the deployment the enrollment used — pinned against the client's
21
- * enrollment resolvers in the test), then the identity names. The generic Supabase names the
22
- * enrollment also accepts for the publishable key are NOT read: a generic key never fills an
23
- * identity field, here or in the loader.
19
+ * Where each deployment value is read, in precedence order: the client's one alias table
20
+ * (`DEPLOYMENT_ENV_ALIASES`), which puts the enrollment's own names first (so the file records
21
+ * the deployment the enrollment used) and then the identity names. The enrollment resolvers read
22
+ * the same table, so the two cannot disagree. The generic Supabase names are in no list: a
23
+ * generic key never fills an identity field, here or in the loader.
24
24
  */
25
25
  export declare const DEPLOYMENT_ENV_NAMES: Readonly<Record<keyof IdentityDeployment, readonly string[]>>;
26
26
  /**
27
- * The deployment half of an identity, read and shape-checked before enrollment commits.
28
- * Absent → `identity_incomplete`; not the non-secret shape its key requires →
27
+ * The deployment half of an identity, read and shape-checked before enrollment commits. Each
28
+ * value is the shipped default, or env under the board group's all-or-nothing rule (the
29
+ * client's `resolveDeploymentField`). Absent from an env-set group → `identity_incomplete`,
30
+ * naming the variable that switched the group off; not the non-secret shape its key requires →
29
31
  * `identity_secret_value`. Both name the variable, never the value.
30
32
  */
31
33
  export declare function resolveIdentityDeployment(env: NodeJS.ProcessEnv): IdentityDeployment;