@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.
- package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
- package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
- package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
- package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
- package/dist/assets.d.ts +4 -0
- package/dist/assets.js +9 -0
- package/dist/assets.json +4 -4
- package/dist/claude_marketplace.d.ts +117 -0
- package/dist/claude_marketplace.js +306 -0
- package/dist/doctor.d.ts +19 -3
- package/dist/doctor.js +32 -5
- package/dist/doctor_scopes.d.ts +34 -2
- package/dist/doctor_scopes.js +128 -20
- package/dist/setup_storage.d.ts +12 -0
- package/dist/setup_storage.js +44 -2
- package/dist/setup_writers.d.ts +25 -6
- package/dist/setup_writers.js +295 -32
- package/package.json +3 -2
|
@@ -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(" "));
|
package/dist/doctor_scopes.d.ts
CHANGED
|
@@ -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
|
|
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 {};
|