@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.
@@ -0,0 +1,306 @@
1
+ // message-board#549 — the per-user Claude Code marketplace directory, shared by setup (which writes
2
+ // it) and doctor (which judges it). ONE implementation of each rule, imported by both, so the two
3
+ // cannot disagree about where the directory is, what "the same content" means, or which chain of
4
+ // directories is trusted.
5
+ //
6
+ // claudeMarketplaceDir `${XDG_DATA_HOME}` if absolute, else `$HOME/.local/share`, then
7
+ // `/agent-board/claude-marketplace`. Claude Code 2.1.283 stores a directory
8
+ // marketplace path AS GIVEN (a trailing slash stripped, symlinks NOT
9
+ // resolved: measured), so setup and doctor compare paths lexically after
10
+ // `resolve()` under one env.
11
+ // readTree / sameTree every regular file, relative path (always `/`-separated, on every OS) →
12
+ // bytes and exec bit; a symlink, device, socket or FIFO anywhere inside makes
13
+ // the tree unusable. Equal = same file set, same bytes, same exec bit.
14
+ // resolveTrustChain (round 4: replaces the lexical + real-path chains) walks the path as the
15
+ // kernel does, from `/`, one component at a time. Every directory ENTERED:
16
+ // * the marketplace directory and its `agent-board` parent: a real
17
+ // directory (never a symlink), owned by this uid, not group/world-
18
+ // writable;
19
+ // * every other: owned by this uid or root, and not group/world-writable,
20
+ // except root-owned with the sticky bit (`/tmp`), or owned by this uid and
21
+ // group-writable (never world) by the user's PRIVATE group, proven from
22
+ // `/etc/group` AND `/etc/passwd` (stock Ubuntu/Fedora `~/.local` is 0775
23
+ // `<user>:<user>`).
24
+ // A symlink component: its own owner must be this uid or root, and its target
25
+ // is spliced into the remaining components, so every hop's containing
26
+ // directory is entered and judged. More than 40 links: a loop, refused.
27
+ // A same-uid attacker is out of scope (SETUP ruling). On win32 there is no
28
+ // uid (getuid() is -1) and no POSIX mode, so only the symlink rule applies.
29
+ // plugin versions `pluginVersionIn` / `compareVersions`, shared by setup (never downgrade a
30
+ // TRUSTED newer directory; an untrusted one is rebuilt) and doctor (report version skew).
31
+ import { lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
32
+ import { userInfo } from "node:os";
33
+ import { dirname, isAbsolute, join, resolve } from "node:path";
34
+ /** The manifest's key in a tree read by `readTree`: always `/`-separated. */
35
+ export const MARKETPLACE_MANIFEST_KEY = ".claude-plugin/marketplace.json";
36
+ /** THE per-user directory setup materialises the packaged marketplace into. User and local scope share it. */
37
+ export function claudeMarketplaceDir(env, home) {
38
+ const data = env.XDG_DATA_HOME;
39
+ const root = data !== undefined && data.trim() !== "" && isAbsolute(data) ? data : join(home, ".local", "share");
40
+ return join(root, "agent-board", "claude-marketplace");
41
+ }
42
+ /** A declared directory path is ours when it resolves to `claudeMarketplaceDir` for this env. */
43
+ export function isOurMarketplacePath(path, env, home) {
44
+ return typeof path === "string" && isAbsolute(path) && resolve(path) === resolve(claudeMarketplaceDir(env, home));
45
+ }
46
+ /**
47
+ * Every regular file under `dir`. `missing` when `dir` does not exist; `unusable` when it is not a
48
+ * plain tree. With `trust` (the materialised directory, never the packaged copy, which a root-owned
49
+ * global install legitimately owns): every directory and file under `dir` must be owned by this uid
50
+ * and not group/world-writable, else `untrusted` naming the first offender — a hook another uid can
51
+ * rewrite is a hook another uid runs as you. Skipped on win32, as elsewhere.
52
+ */
53
+ export function readTree(dir, trust) {
54
+ let top;
55
+ try {
56
+ top = lstatSync(dir);
57
+ }
58
+ catch (error) {
59
+ if (error.code === "ENOENT")
60
+ return { kind: "missing" };
61
+ return { kind: "unusable" };
62
+ }
63
+ if (!top.isDirectory())
64
+ return { kind: "unusable" };
65
+ const files = new Map();
66
+ const judged = trust !== undefined && (trust.platform ?? process.platform) !== "win32";
67
+ let offender = null;
68
+ const untrusted = (path, stats) => {
69
+ if (!judged)
70
+ return false;
71
+ const owned = trust.lstat ? trust.lstat(path) : stats;
72
+ if (owned.uid === trust.uid && (owned.mode & 0o022) === 0)
73
+ return false;
74
+ offender = path;
75
+ return true;
76
+ };
77
+ const walk = (relative) => {
78
+ for (const name of readdirSync(relative === "" ? dir : join(dir, ...relative.split("/"))).sort()) {
79
+ const child = relative === "" ? name : `${relative}/${name}`;
80
+ const absolute = join(dir, ...child.split("/"));
81
+ const stats = lstatSync(absolute);
82
+ if ((stats.isDirectory() || stats.isFile()) && untrusted(absolute, stats))
83
+ return false;
84
+ if (stats.isDirectory()) {
85
+ if (!walk(child))
86
+ return false;
87
+ }
88
+ else if (stats.isFile()) {
89
+ files.set(child, { bytes: readFileSync(absolute), executable: (stats.mode & 0o111) !== 0 });
90
+ }
91
+ else {
92
+ return false;
93
+ }
94
+ }
95
+ return true;
96
+ };
97
+ try {
98
+ if (walk(""))
99
+ return { kind: "ok", files };
100
+ return offender !== null ? { kind: "untrusted", path: offender } : { kind: "unusable" };
101
+ }
102
+ catch {
103
+ return { kind: "unusable" };
104
+ }
105
+ }
106
+ /** Same file set, same bytes, same exec bit. */
107
+ export function sameTree(a, b) {
108
+ if (a.size !== b.size)
109
+ return false;
110
+ for (const [path, file] of a) {
111
+ const other = b.get(path);
112
+ if (!other || other.executable !== file.executable || !other.bytes.equals(file.bytes))
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ /** Symlinks followed before a path is refused as a loop (Linux's MAXSYMLINKS). */
118
+ export const MAX_SYMLINK_HOPS = 40;
119
+ const readOr = (path) => {
120
+ try {
121
+ return readFileSync(path, "utf8");
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ };
127
+ /**
128
+ * The user's PRIVATE group (USERGROUPS_ENAB: Ubuntu, Debian, Fedora, RHEL make `~/.local` 0775 owned
129
+ * `<user>:<user>`). Proven only from the local files, and only when BOTH say no one else is in it:
130
+ * * `/etc/group` has exactly one entry for the gid, named as the user, whose members are a subset
131
+ * of {the user} (some tools list the user in their own group);
132
+ * * `/etc/passwd` has exactly one account whose primary gid is that gid, and it is this user (name
133
+ * AND uid) — two accounts sharing a primary gid share its write bit (round 4).
134
+ * The gid must also be this process's primary gid. An account or group only in NSS/LDAP, a shared
135
+ * group (SUSE `users`) or an unreadable file proves nothing: refused.
136
+ */
137
+ export function isPrivateGroup(gid, options) {
138
+ const primary = options.gid ?? process.getgid?.();
139
+ let name = options.userName;
140
+ if (name === undefined) {
141
+ try {
142
+ name = userInfo().username;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ const groupText = options.groupFile === undefined ? readOr("/etc/group") : options.groupFile;
149
+ const passwdText = options.passwdFile === undefined ? readOr("/etc/passwd") : options.passwdFile;
150
+ if (primary === undefined || gid !== primary || groupText === null || passwdText === null)
151
+ return false;
152
+ const rows = (text) => text.split("\n").map((line) => line.split(":"));
153
+ const groups = rows(groupText).filter((fields) => fields.length >= 4 && fields[2] !== "" && Number(fields[2]) === gid);
154
+ if (groups.length !== 1 || groups[0][0] !== name)
155
+ return false;
156
+ const members = groups[0][3].split(",").map((member) => member.trim()).filter(Boolean);
157
+ if (!members.every((member) => member === name))
158
+ return false;
159
+ const accounts = rows(passwdText).filter((fields) => fields.length >= 4 && fields[3] !== "" && Number(fields[3]) === gid);
160
+ return accounts.length === 1 && accounts[0][0] === name && Number(accounts[0][2]) === options.uid;
161
+ }
162
+ /**
163
+ * Round 4: ONE resolver, walking `dir` as the kernel does — from `/`, one component at a time. Every
164
+ * directory ENTERED is judged (the marketplace directory and its `agent-board` parent strictly; every
165
+ * other by the ancestor rules). A symlink component is judged by its own owner (its containing
166
+ * directory was judged when entered), and its `readlink` is spliced into the remaining components
167
+ * (absolute → from `/`, relative → from the current directory), so EVERY hop's containing
168
+ * directory is judged, however many hops there are. More than `MAX_SYMLINK_HOPS` links is a loop:
169
+ * refused. The walk stops at the first missing component: what setup will create. The marketplace
170
+ * directory and its parent are the last two components however the path is spliced, and neither may
171
+ * be a symlink. On win32 (no uid, no POSIX mode) only that symlink rule applies.
172
+ */
173
+ export function resolveTrustChain(dir, options) {
174
+ const windows = (options.platform ?? process.platform) === "win32";
175
+ const lstat = options.lstat ?? ((path) => lstatSync(path));
176
+ const readlink = options.readlink ?? ((path) => readlinkSync(path));
177
+ const target = resolve(dir);
178
+ const visited = [];
179
+ const done = (problem) => ({ problem, visited });
180
+ if (windows) {
181
+ for (const path of [dirname(target), target]) {
182
+ try {
183
+ if (lstat(path).isSymbolicLink())
184
+ return done({ path, cause: "symlink", tightenable: false });
185
+ }
186
+ catch {
187
+ break;
188
+ }
189
+ }
190
+ return done(null);
191
+ }
192
+ const ownedOrRoot = (stats) => stats.uid === options.uid || stats.uid === 0;
193
+ const judge = (path, stats, own) => {
194
+ const root = stats.uid === 0;
195
+ if (own ? stats.uid !== options.uid : !ownedOrRoot(stats))
196
+ return { path, cause: "foreign_owner", tightenable: false };
197
+ const worldWritable = (stats.mode & 0o002) !== 0;
198
+ const groupWritable = (stats.mode & 0o020) !== 0;
199
+ if (!worldWritable && !groupWritable)
200
+ return null;
201
+ if (!own && root && (stats.mode & 0o1000) !== 0)
202
+ return null; // /tmp
203
+ if (!own && !worldWritable && stats.uid === options.uid && isPrivateGroup(stats.gid, options))
204
+ return null;
205
+ return { path, cause: "writable", tightenable: own, command: `chmod ${worldWritable && groupWritable ? "go-w" : worldWritable ? "o-w" : "g-w"} ${path}` };
206
+ };
207
+ const enter = (path, role) => {
208
+ const stats = lstat(path);
209
+ if (!stats.isDirectory())
210
+ return { path, cause: "not_directory", tightenable: false };
211
+ const problem = judge(path, stats, role !== "ancestor");
212
+ if (problem)
213
+ return problem;
214
+ visited.push({ path, dev: stats.dev ?? -1, ino: stats.ino ?? -1, role });
215
+ return null;
216
+ };
217
+ const rootProblem = enter("/", "ancestor");
218
+ if (rootProblem)
219
+ return done(rootProblem);
220
+ let current = "/";
221
+ const queue = target.split("/").filter(Boolean);
222
+ let hops = 0;
223
+ while (queue.length > 0) {
224
+ const name = queue.shift();
225
+ // The tail is never spliced (a symlink there is refused), so the last two are always ours.
226
+ const role = queue.length === 0 ? "dir" : queue.length === 1 ? "parent" : "ancestor";
227
+ if (name === ".")
228
+ continue;
229
+ if (name === "..") {
230
+ current = dirname(current);
231
+ continue;
232
+ }
233
+ const path = current === "/" ? `/${name}` : `${current}/${name}`;
234
+ let link;
235
+ try {
236
+ link = lstat(path);
237
+ }
238
+ catch {
239
+ break; // missing from here down: what setup creates
240
+ }
241
+ if (link.isSymbolicLink()) {
242
+ if (role !== "ancestor")
243
+ return done({ path, cause: "symlink", tightenable: false });
244
+ if (!ownedOrRoot(link))
245
+ return done({ path, cause: "foreign_owner", tightenable: false });
246
+ if (++hops > MAX_SYMLINK_HOPS)
247
+ return done({ path, cause: "symlink_loop", tightenable: false });
248
+ let to;
249
+ try {
250
+ to = readlink(path);
251
+ }
252
+ catch {
253
+ return done({ path, cause: "not_directory", tightenable: false });
254
+ }
255
+ if (to.startsWith("/"))
256
+ current = "/";
257
+ queue.unshift(...to.split("/").filter(Boolean));
258
+ continue;
259
+ }
260
+ const problem = enter(path, role);
261
+ if (problem)
262
+ return done(problem);
263
+ current = path;
264
+ }
265
+ return done(null);
266
+ }
267
+ /** The first untrusted path, or null (see `resolveTrustChain`). */
268
+ export function checkTrustChain(dir, options) {
269
+ return resolveTrustChain(dir, options).problem;
270
+ }
271
+ /** The human words for a trust problem, for a `fix`. */
272
+ export function describeTrustProblem(problem) {
273
+ const words = {
274
+ symlink: "is a symlink",
275
+ not_directory: "is not a directory",
276
+ foreign_owner: "is owned by another user",
277
+ writable: "is writable by other users",
278
+ symlink_loop: `resolves through more than ${MAX_SYMLINK_HOPS} symlinks (a loop)`,
279
+ };
280
+ return `${problem.path} ${words[problem.cause]}${problem.command ? ` (run \`${problem.command}\`)` : ""}`;
281
+ }
282
+ /** The version in a marketplace tree's `plugins/agent-board/.claude-plugin/plugin.json`, or null. */
283
+ export function pluginVersionIn(files) {
284
+ const file = files.get("plugins/agent-board/.claude-plugin/plugin.json");
285
+ if (!file)
286
+ return null;
287
+ try {
288
+ const version = JSON.parse(file.bytes.toString("utf8"))?.version;
289
+ return typeof version === "string" ? version : null;
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ /** `x.y.z` numeric comparison (sign), or null when either side is not a plain release version. */
296
+ export function compareVersions(a, b) {
297
+ const parse = (value) => (value !== null && /^\d+\.\d+\.\d+$/.test(value) ? value.split(".").map(Number) : null);
298
+ const x = parse(a);
299
+ const y = parse(b);
300
+ if (x === null || y === null)
301
+ return null;
302
+ for (let i = 0; i < 3; i++)
303
+ if (x[i] !== y[i])
304
+ return Math.sign(x[i] - y[i]);
305
+ return 0;
306
+ }
package/dist/doctor.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { type IdentityHarness, type OperationSuccess, type OsKeychainExec, type SessionRecordEntry, type TrustOptions } from "@consiliency/agent-board-client";
2
- import { type ClaudePluginSummary, type RegistrationFs, type RegQuery, type RegistrationVerdict } from "./doctor_scopes.js";
1
+ import { type IdentityHarness, type OperationSuccess, type OsKeychainBackendOptions, type OsKeychainExec, type SessionRecordEntry, type TrustOptions } from "@consiliency/agent-board-client";
2
+ import { type ClaudePluginSummary, type RegistrationFs, type RegQuery, type RegistrationVerdict, type ScopeContext } from "./doctor_scopes.js";
3
3
  export type DoctorStatus = "pass" | "fail" | "undetermined";
4
4
  export declare const DOCTOR_CHECKS: readonly ["identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime"];
5
5
  export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number];
@@ -17,6 +17,12 @@ export interface PluginReport {
17
17
  installed_version: string | null;
18
18
  pinned_mcp_version: string | null;
19
19
  source_ref: string | null;
20
+ /** `github` (the repo at a ref: fleet hosts) or `directory` (the per-user copy setup made, #549). */
21
+ source_kind: "github" | "directory" | null;
22
+ /** A directory source's path; only ever our derived per-user directory, printed if path-shaped. */
23
+ source_path: string | null;
24
+ /** Where `installed_version` and `pinned_mcp_version` were read: `source` (our validated directory) or `cache`. */
25
+ version_from: "source" | "cache" | null;
20
26
  auto_update: ClaudePluginSummary["autoUpdate"];
21
27
  }
22
28
  export interface DoctorReport {
@@ -41,6 +47,12 @@ export interface DoctorReport {
41
47
  };
42
48
  }
43
49
  export declare const REPORTABLE_TOKEN: RegExp;
50
+ /**
51
+ * A path prints only when absolute and made of the token alphabet (no spaces, quotes or control
52
+ * characters), up to 1024 characters; else `unrecognised`. It is only ever doctor's own derived
53
+ * per-user directory (#549), never a string read from a file, but `$HOME` is still input.
54
+ */
55
+ export declare const REPORTABLE_PATH: RegExp;
44
56
  /**
45
57
  * The two reads doctor makes, on a client whose credentials come from the identity file. Typed
46
58
  * as the client's own `OperationSuccess` envelope, so `defaultClient` below stops compiling if
@@ -85,6 +97,10 @@ export interface DoctorOptions {
85
97
  claudeManagedDir?: string;
86
98
  /** The OS whose managed (MDM) sources are probed. Default `process.platform`. */
87
99
  platform?: NodeJS.Platform;
100
+ /** The packaged marketplace our directory source must equal (#549). Default: this cli's own copy. */
101
+ claudeMarketplacePackaged?: string;
102
+ /** Test injection for the marketplace trust chain (`/etc/group`, gid, user name, ownership). */
103
+ claudeMarketplaceTrust?: ScopeContext["claudeMarketplaceTrust"];
88
104
  /** The login name for the macOS per-user managed plist. Default: `lookupUserName()`. */
89
105
  userName?: string;
90
106
  /** Where the login name comes from; may throw. Default `os.userInfo().username`. */
@@ -131,7 +147,7 @@ export declare function fileIdentityEnv(ambient: NodeJS.ProcessEnv, values: Reco
131
147
  * for the keychain CLI. Nothing here reads its output; doctor.test.ts K-0 pins that no
132
148
  * key-decoding API is reachable.
133
149
  */
134
- export declare function osKeychainProbe(spawn?: OsKeychainExec): KeystoreProbe;
150
+ export declare function osKeychainProbe(spawn?: OsKeychainExec, where?: Pick<OsKeychainBackendOptions, "platform" | "homeDir">): KeystoreProbe;
135
151
  /** The default agent_socket probe: the loaded key's fingerprint, derived from the agent's identity list. */
136
152
  export declare const agentSocketProbe: KeystoreProbe;
137
153
  export declare const OS_KEYCHAIN_PRESENT = "present; key_id proven server-side by whoami; file fingerprint unverified for os_keychain";
package/dist/doctor.js CHANGED
@@ -14,7 +14,7 @@ import { randomBytes } from "node:crypto";
14
14
  import { existsSync, readdirSync } from "node:fs";
15
15
  import { homedir, userInfo } from "node:os";
16
16
  import { join, resolve } from "node:path";
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";
17
+ import { AUTHORED_CAUSE_CODES, AgentSocketSelectionError, GENERIC_SUPABASE_FIELDS, IDENTITY_FILE_KEYS, IDENTITY_TRIGGER_FIELDS, KeystoreUnavailableError, assertIdentityHarness, codeProvenance, createAgentBoardClient, createOsKeychainBackend, expectedWakeTier, normalizeKeyReference, osKeychainFailureOf, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
18
18
  import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
19
19
  import { resolveKeyslotDir } from "./assets.js";
20
20
  import { SLOT_RE, externalDeclarationProblems, fileIdentity, keyslotSocketDir, machineIdOf, resolveAgentSocketPath, unsealedOptinProblems, } from "./setup_storage.js";
@@ -22,6 +22,13 @@ export const DOCTOR_CHECKS = Object.freeze([
22
22
  "identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime",
23
23
  ]);
24
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";
25
32
  const reportable = (value) => value === null ? null : REPORTABLE_TOKEN.test(value) ? value : "unrecognised";
26
33
  /**
27
34
  * The pin, over the live wake_producers whose cwd (realpath) is doctor's cwd, and only while our
@@ -36,6 +43,12 @@ function pinCheck(verdict, entries, cwd, fs) {
36
43
  const pin = verdict.plugin?.installed && verdict.plugin.serverActive ? verdict.plugin.version : null;
37
44
  if (pin === null)
38
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;
39
52
  // Real paths on both sides (panel S2): a server records its physical cwd; doctor may be in a symlink.
40
53
  const real = (path) => fs.realpath(path) ?? resolve(path);
41
54
  const here = entries.flatMap((entry) => entry.status === "live" && entry.record.role === "wake_producer"
@@ -163,11 +176,11 @@ function defaultClient(fileEnv) {
163
176
  * for the keychain CLI. Nothing here reads its output; doctor.test.ts K-0 pins that no
164
177
  * key-decoding API is reachable.
165
178
  */
166
- export function osKeychainProbe(spawn) {
179
+ export function osKeychainProbe(spawn, where = {}) {
167
180
  return async (keyReference) => {
168
181
  const reference = normalizeKeyReference(keyReference);
169
182
  try {
170
- const capability = await createOsKeychainBackend(spawn ? { exec: spawn } : {})(reference);
183
+ const capability = await createOsKeychainBackend({ ...where, ...(spawn ? { exec: spawn } : {}) })(reference);
171
184
  await capability.sign(randomBytes(32));
172
185
  return { kind: "present_unverified" };
173
186
  }
@@ -177,6 +190,15 @@ export function osKeychainProbe(spawn) {
177
190
  if (error instanceof KeystoreUnavailableError && /no key found/.test(error.message)) {
178
191
  return { kind: "absent", reason: "key_absent" };
179
192
  }
193
+ // #554: the client classifies a platform keychain failure. A key held only OUTSIDE the macOS
194
+ // login keychain is not usable (the adapter reads the login keychain) and is a fail; any other
195
+ // class is carried in the reason, so a locked keychain reads `keystore_unavailable:keychain_locked`
196
+ // (fix: `security unlock-keychain <login keychain>`) instead of the bare code.
197
+ const classified = osKeychainFailureOf(error);
198
+ if (classified?.failure === "item_outside_login_keychain")
199
+ return { kind: "absent", reason: "key_outside_login_keychain" };
200
+ if (classified)
201
+ return { kind: "unreachable", reason: `keystore_unavailable:${classified.failure}` };
180
202
  return { kind: "unreachable", reason: codeOf(error, "keystore_unavailable") };
181
203
  }
182
204
  };
@@ -533,7 +555,8 @@ export async function runDoctor(options) {
533
555
  const verdicts = cwds.map((dir) => {
534
556
  try {
535
557
  return judgeRegistration(harness, { cwd: dir, home, env, fs, claudeManagedDir: options.claudeManagedDir ?? defaultClaudeManagedDir(platform),
536
- platform, userName, regQuery: options.regQuery });
558
+ platform, userName, regQuery: options.regQuery, claudeMarketplacePackaged: options.claudeMarketplacePackaged,
559
+ claudeMarketplaceTrust: options.claudeMarketplaceTrust });
537
560
  }
538
561
  catch (error) {
539
562
  return check("undetermined", codeOf(error, "registration_unreadable"));
@@ -555,6 +578,9 @@ export async function runDoctor(options) {
555
578
  installed_version: reportable(plugin?.version ?? null),
556
579
  pinned_mcp_version: reportable(plugin?.pinnedMcpVersion ?? null),
557
580
  source_ref: reportable(plugin?.sourceRef ?? null),
581
+ source_kind: plugin?.sourceKind ?? null,
582
+ source_path: reportablePath(plugin?.sourcePath ?? null),
583
+ version_from: plugin?.versionFrom ?? null,
558
584
  auto_update: plugin ? { effective: plugin.autoUpdate.effective, source: plugin.autoUpdate.source } : { effective: false, source: "undetermined" },
559
585
  };
560
586
  if (here.hookCounts)
@@ -576,9 +602,10 @@ export function formatDoctorReport(report) {
576
602
  const counts = report.registration?.hook_counts;
577
603
  lines.push([
578
604
  `${"plugin".padEnd(12)} installed=${plugin.installed ? "yes" : "no"}`,
579
- `version=${plugin.installed_version ?? "none"}`,
605
+ `version=${plugin.installed_version ?? "none"}${plugin.version_from ? `(${plugin.version_from})` : ""}`,
580
606
  `pinned_mcp=${plugin.pinned_mcp_version ?? "none"}`,
581
607
  `ref=${plugin.source_ref ?? "none"}`,
608
+ `source=${plugin.source_kind ?? "none"}${plugin.source_path ? `:${plugin.source_path}` : ""}`,
582
609
  `auto_update=${plugin.auto_update.effective ? "on" : "off"}(${plugin.auto_update.source})`,
583
610
  counts ? `hooks SessionStart=${counts.SessionStart} FileChanged=${counts.FileChanged}` : "hooks undetermined",
584
611
  ].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 {};