@consiliency/agent-board-cli 1.25.0 → 1.27.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.
@@ -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,
@@ -110,6 +110,18 @@ export declare function repairKeyDir(env: NodeJS.ProcessEnv, uid: number): {
110
110
  problems: string[];
111
111
  root: string | null;
112
112
  };
113
+ /**
114
+ * Why the macOS login keychain did not round-trip, from the probe's classification (#554). Only a
115
+ * LOCKED keychain is told to unlock: before #554 every cause got that fix, including the
116
+ * write-permission failure an SSH session produced by writing to System.keychain, where unlocking
117
+ * the login keychain changed nothing.
118
+ */
119
+ /** A path as one POSIX shell word: bare when it is plainly safe, else single-quoted ('\'' for a quote). */
120
+ export declare function shellWord(value: string): string;
121
+ export declare function macosKeychainRefusal(probe: OsKeychainProbeResult | null): {
122
+ reason: string;
123
+ fix: string;
124
+ };
113
125
  /**
114
126
  * IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
115
127
  * condition; a refusal carries the fix.
@@ -23,7 +23,7 @@ import { execFile } from "node:child_process";
23
23
  import { createHash } from "node:crypto";
24
24
  import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
25
25
  import { dirname, isAbsolute, join } from "node:path";
26
- import { probeOsKeychainRoundTrip } from "@consiliency/agent-board-client";
26
+ import { OS_KEYCHAIN_SERVICE, probeOsKeychainRoundTrip } from "@consiliency/agent-board-client";
27
27
  import { resolveKeyslotDir } from "./assets.js";
28
28
  export const SLOT_RE = /^[a-z][a-z0-9-]{0,31}$/;
29
29
  const REFERENCE_LINE = /^agent_socket:\/\S+#[A-Za-z0-9_-]{43}$/;
@@ -347,6 +347,48 @@ function externalAgent(agentSocket, env, uid, machineIdFile, repairs) {
347
347
  return { storage: "external", reason: "external_declared", keyReference: reference, repairs };
348
348
  return refused(problems.includes("missing") ? "external_undeclared" : "external_declaration_mismatch", EXTERNAL_FIX(declaration), repairs);
349
349
  }
350
+ const OR_EXTERNAL = "or pass --agent-socket <ref> with an external-agent declaration";
351
+ /**
352
+ * Why the macOS login keychain did not round-trip, from the probe's classification (#554). Only a
353
+ * LOCKED keychain is told to unlock: before #554 every cause got that fix, including the
354
+ * write-permission failure an SSH session produced by writing to System.keychain, where unlocking
355
+ * the login keychain changed nothing.
356
+ */
357
+ /** A path as one POSIX shell word: bare when it is plainly safe, else single-quoted ('\'' for a quote). */
358
+ export function shellWord(value) {
359
+ return /^[A-Za-z0-9_\/.,:@%+=-]+$/.test(value) ? value : `'${value.replace(/'/g, "'\\''")}'`;
360
+ }
361
+ export function macosKeychainRefusal(probe) {
362
+ // Printed for pasting into a shell: a real path is quoted; the fallback keeps `~` unquoted so it expands.
363
+ const kc = probe?.keychain !== undefined ? shellWord(probe.keychain) : "~/Library/Keychains/login.keychain-db";
364
+ switch (probe?.failure) {
365
+ case "keychain_locked":
366
+ return { reason: "macos_keychain_locked", fix: `the login keychain is locked, and this session cannot show an unlock prompt: run \`security unlock-keychain ${kc}\` and re-run setup` };
367
+ case "write_denied":
368
+ return { reason: "macos_keychain_write_denied", fix: `the keychain refused the write (errSecWrPerm) to ${kc}: check that it is your login keychain and that you own it (ls -l ${kc}), ${OR_EXTERNAL}` };
369
+ case "login_keychain_absent":
370
+ return { reason: "macos_login_keychain_absent", fix: `there is no login keychain in ~/Library/Keychains (login.keychain-db or login.keychain): log in once at this Mac's console, which creates it, ${OR_EXTERNAL}` };
371
+ case "item_outside_login_keychain": {
372
+ // Setup only ever sees its PROBE: the item found outside the login keychain is the probe's own
373
+ // throwaway, not this harness's key (a harness key held elsewhere surfaces at enrollment, and in
374
+ // doctor as key_outside_login_keychain). So say that, and offer only the throwaway's cleanup.
375
+ const holder = probe?.heldBy !== undefined ? shellWord(probe.heldBy) : null;
376
+ const cleanup = holder !== null && probe?.handle !== undefined
377
+ ? `; remove the test item with \`security delete-generic-password -s ${OS_KEYCHAIN_SERVICE} -a ${probe.handle} ${holder}\``
378
+ : "";
379
+ return { reason: "macos_keychain_item_elsewhere", fix: `setup's throwaway test item was found outside the login keychain, in ${holder ?? "another keychain on your search list"}, after it was deleted from the login keychain, so this account's keychain search list or default keychain is unusual: check \`security list-keychains -d user\` and \`security default-keychain -d user\`${cleanup}, then re-run setup, ${OR_EXTERNAL}` };
380
+ }
381
+ case "item_search_undetermined":
382
+ return { reason: "macos_keychain_search_undetermined", fix: "a keychain on your search list could not be searched (it may be locked): unlock it or remove it from the search list (security list-keychains -d user), then re-run setup" };
383
+ case "login_keychain_untrusted":
384
+ case "login_keychain_path_unsafe":
385
+ return { reason: "macos_login_keychain_unusable", fix: `${kc} is not usable: it must be a regular file you own (not a symlink) at a path with no whitespace, quotes or backslashes; fix it, ${OR_EXTERNAL}` };
386
+ default: {
387
+ const detail = probe ? `${probe.outcome}${probe.failure ? `, ${probe.failure}` : ""}` : "the probe did not answer";
388
+ return { reason: "macos_keychain_unavailable", fix: `the login keychain did not complete a round trip (${detail}): run setup from a logged-in session, ${OR_EXTERNAL}` };
389
+ }
390
+ }
391
+ }
350
392
  /**
351
393
  * IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
352
394
  * condition; a refusal carries the fix.
@@ -378,7 +420,7 @@ export async function selectKeyStorage(ctx) {
378
420
  let gate = null;
379
421
  if (platform !== "linux") {
380
422
  gate = platform === "darwin"
381
- ? { reason: "macos_keychain_unavailable", fix: "the login keychain did not complete a round trip: unlock it (security unlock-keychain) in a logged-in session and re-run setup" }
423
+ ? macosKeychainRefusal(keychain)
382
424
  : { reason: "platform_unsupported", fix: "setup's key storage supports Linux and macOS; on Windows run setup inside WSL, or pass --agent-socket <ref> with an external-agent declaration" };
383
425
  }
384
426
  else {
@@ -1,13 +1,11 @@
1
1
  import { type IdentityHarness } from "@consiliency/agent-board-client";
2
- import type { BakedAssets } from "./assets.js";
2
+ import { type BakedAssets } from "./assets.js";
3
3
  import { type ConfigEntry } from "./config_edit.js";
4
4
  import { type RegistrationFs, type ScopeContext } from "./doctor_scopes.js";
5
5
  export type SetupScope = "user" | "local";
6
6
  export declare const SETUP_SCOPES: readonly SetupScope[];
7
7
  /** The server name setup writes; the same name the claude plugin registers. */
8
8
  export declare const BOARD_SERVER_NAME = "agent-board";
9
- /** `Consiliency/message-board`: the marketplace source, pinned to the baked release tag. */
10
- export declare const CLAUDE_MARKETPLACE_REPO = "Consiliency/message-board";
11
9
  /**
12
10
  * What codex hands an MCP child and nothing else (`process_ancestry.ts`, measured 2026-09-18 on
13
11
  * codex 0.155.0, across three configurations including `shell_environment_policy.inherit=all`).
@@ -103,6 +101,20 @@ export interface WriterContext {
103
101
  run?: ChildRunner;
104
102
  fs?: RegistrationFs;
105
103
  claudeManagedDir?: string;
104
+ /** The packaged Claude Code marketplace to materialise; default the cli package's own copy. */
105
+ marketplaceSource?: string;
106
+ /** Test injection for the shared trust chain (`/etc/group`, gid, user name, ownership). */
107
+ marketplaceTrust?: ScopeContext["claudeMarketplaceTrust"];
108
+ /**
109
+ * A TEST SEAM, never set by setup: called at each step of the directory swap with the paths in
110
+ * play, so a test can change the filesystem between setup's check and its use (#549 B3/S1).
111
+ */
112
+ marketplaceStep?: (step: "prepared" | "moved_old" | "swapped", paths: {
113
+ parent: string;
114
+ dir: string;
115
+ temp: string;
116
+ old: string;
117
+ }) => void;
106
118
  }
107
119
  export interface WriterResult {
108
120
  status: "done" | "already" | "failed";
@@ -112,10 +124,14 @@ export interface WriterResult {
112
124
  /** The MCP-writer target file for a harness and scope, as doctor's scope rows locate it. */
113
125
  export declare function mcpTarget(harness: IdentityHarness, scope: SetupScope, ctx: ScopeContext): string;
114
126
  /**
115
- * Setup's exact check for claude: our plugin, at the baked version, from the baked release tag,
116
- * effective here AND installed and enabled at the REQUESTED scope from that tag.
127
+ * Setup's exact check for claude: our plugin, at the baked version, from our per-user directory,
128
+ * effective here AND installed and enabled at the REQUESTED scope from that directory, with no layer
129
+ * setup may write (user, local) still declaring the marketplace another way, and our directory
130
+ * judged sound by doctor's own reader. `rewrote`: setup has just rewritten the directory because its
131
+ * content differed from the packaged bytes, so what Claude Code installed from it is not what this
132
+ * build ships — never exact.
117
133
  */
118
- export declare function claudePluginExact(ctx: ScopeContext, assets: BakedAssets, scope: SetupScope): {
134
+ export declare function claudePluginExact(ctx: ScopeContext, assets: BakedAssets, scope: SetupScope, rewrote?: boolean): {
119
135
  exact: boolean;
120
136
  installedAtScope: boolean;
121
137
  enabledAtScope: boolean;
@@ -123,6 +139,9 @@ export declare function claudePluginExact(ctx: ScopeContext, assets: BakedAssets
123
139
  pinnedAtScope: boolean;
124
140
  foreign: boolean;
125
141
  unparseable: boolean;
142
+ staleLayers: SetupScope[];
143
+ rowLayers: SetupScope[];
144
+ staleRows: SetupScope[];
126
145
  };
127
146
  /** The `harness` step for one harness and scope. Never throws for a host condition. */
128
147
  export declare function installHarness(ctx: WriterContext): Promise<WriterResult>;