@consiliency/agent-board-cli 1.24.0 → 1.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +26 -0
- package/dist/assets.js +70 -0
- package/dist/assets.json +6 -0
- package/dist/claude_marketplace.d.ts +117 -0
- package/dist/claude_marketplace.js +306 -0
- package/dist/cli.d.ts +37 -3
- package/dist/cli.js +68 -26
- package/dist/config_edit.d.ts +37 -0
- package/dist/config_edit.js +549 -0
- package/dist/doctor.d.ts +24 -2
- package/dist/doctor.js +164 -4
- package/dist/doctor_scopes.d.ts +34 -2
- package/dist/doctor_scopes.js +128 -20
- package/dist/identity_writer.d.ts +9 -7
- package/dist/identity_writer.js +18 -30
- package/dist/setup.d.ts +116 -0
- package/dist/setup.js +603 -0
- package/dist/setup_storage.d.ts +117 -0
- package/dist/setup_storage.js +447 -0
- package/dist/setup_writers.d.ts +147 -0
- package/dist/setup_writers.js +916 -0
- package/keyslot/agent-board-keyslot-load@.service +20 -0
- package/keyslot/agent-board-keyslot@.service +20 -0
- package/keyslot/constants.sh +73 -0
- package/keyslot/keygen.mjs +67 -0
- package/keyslot/keyslot-load.sh +78 -0
- package/keyslot/lib.sh +599 -0
- package/keyslot/optin.sh +185 -0
- package/keyslot/probe.sh +135 -0
- package/keyslot/provision.sh +530 -0
- package/keyslot/seam.sh +48 -0
- package/keyslot/status.sh +254 -0
- package/package.json +9 -4
package/dist/doctor.js
CHANGED
|
@@ -9,15 +9,26 @@
|
|
|
9
9
|
// only with a live `wake_producer` at its expected tier, so 0 is never returned without runtime
|
|
10
10
|
// evidence. Output is names and codes only: no identity value, key or path content is printed,
|
|
11
11
|
// and an error's `code` is printed only when this repository authored it (`authoredCode`).
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
12
13
|
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
13
15
|
import { homedir, userInfo } from "node:os";
|
|
14
|
-
import { resolve } from "node:path";
|
|
16
|
+
import { join, resolve } from "node:path";
|
|
15
17
|
import { AUTHORED_CAUSE_CODES, AgentSocketSelectionError, GENERIC_SUPABASE_FIELDS, IDENTITY_FILE_KEYS, IDENTITY_TRIGGER_FIELDS, KeystoreUnavailableError, assertIdentityHarness, codeProvenance, createAgentBoardClient, createOsKeychainBackend, expectedWakeTier, normalizeKeyReference, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
|
|
16
18
|
import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
|
|
19
|
+
import { resolveKeyslotDir } from "./assets.js";
|
|
20
|
+
import { SLOT_RE, externalDeclarationProblems, fileIdentity, keyslotSocketDir, machineIdOf, resolveAgentSocketPath, unsealedOptinProblems, } from "./setup_storage.js";
|
|
17
21
|
export const DOCTOR_CHECKS = Object.freeze([
|
|
18
|
-
"identity_file", "keystore_key", "whoami", "board_membership", "registration", "runtime",
|
|
22
|
+
"identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime",
|
|
19
23
|
]);
|
|
20
24
|
export const REPORTABLE_TOKEN = /^[A-Za-z0-9._/-]{1,64}$/;
|
|
25
|
+
/**
|
|
26
|
+
* A path prints only when absolute and made of the token alphabet (no spaces, quotes or control
|
|
27
|
+
* characters), up to 1024 characters; else `unrecognised`. It is only ever doctor's own derived
|
|
28
|
+
* per-user directory (#549), never a string read from a file, but `$HOME` is still input.
|
|
29
|
+
*/
|
|
30
|
+
export const REPORTABLE_PATH = /^\/[A-Za-z0-9._/-]{0,1023}$/;
|
|
31
|
+
const reportablePath = (value) => value === null ? null : REPORTABLE_PATH.test(value) ? value : "unrecognised";
|
|
21
32
|
const reportable = (value) => value === null ? null : REPORTABLE_TOKEN.test(value) ? value : "unrecognised";
|
|
22
33
|
/**
|
|
23
34
|
* The pin, over the live wake_producers whose cwd (realpath) is doctor's cwd, and only while our
|
|
@@ -32,6 +43,12 @@ function pinCheck(verdict, entries, cwd, fs) {
|
|
|
32
43
|
const pin = verdict.plugin?.installed && verdict.plugin.serverActive ? verdict.plugin.version : null;
|
|
33
44
|
if (pin === null)
|
|
34
45
|
return verdict;
|
|
46
|
+
// Round 4: a directory-sourced plugin runs from its directory. When that directory was not read as
|
|
47
|
+
// the effective, sound source (newer, stale, untrusted, unverifiable), `pin` is cache metadata,
|
|
48
|
+
// which says nothing about what runs: no mismatch is declared from it. The directory's own reason
|
|
49
|
+
// already stands (fail or undetermined).
|
|
50
|
+
if (verdict.plugin?.sourceKind === "directory" && verdict.plugin.versionFrom !== "source")
|
|
51
|
+
return verdict;
|
|
35
52
|
// Real paths on both sides (panel S2): a server records its physical cwd; doctor may be in a symlink.
|
|
36
53
|
const real = (path) => fs.realpath(path) ?? resolve(path);
|
|
37
54
|
const here = entries.flatMap((entry) => entry.status === "live" && entry.record.role === "wake_producer"
|
|
@@ -228,6 +245,142 @@ async function keystoreCheck(values, env, probe) {
|
|
|
228
245
|
? check("pass", "fingerprint_matches")
|
|
229
246
|
: check("fail", "fingerprint_mismatch");
|
|
230
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* IF-0-SETUP-3 / IF-0-KEYSEAL-6 — which storage holds the identity's key, MEASURED: from the file's
|
|
250
|
+
* key reference plus the packaged `status.sh --json`, never from anything setup recorded.
|
|
251
|
+
*
|
|
252
|
+
* - an `os_keychain` reference → pass `os_keychain` (keystore_key proves the key is there);
|
|
253
|
+
* - an `agent_socket` reference: the socket keystore_key signs through (same resolution, including
|
|
254
|
+
* `MESSAGE_BOARD_AGENT_SOCKET_PATH`) is matched by DEVICE AND INODE, never spelling, against
|
|
255
|
+
* each `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`;
|
|
256
|
+
* - a keyslot → `status.sh --harness <h> --slot <slot> --json`. Its measurement fields
|
|
257
|
+
* (machine_id, uid, slot, seam, mode) are compared with doctor's own values FIRST: a report
|
|
258
|
+
* that is not about this host, user and slot says nothing, so a mismatch or a missing field is
|
|
259
|
+
* 2 even beside a status exit 1 (both are non-pass). Then exit 1 → 1, exit 2 → 2, and exit 0
|
|
260
|
+
* passes as `sealed`, or as `unsealed` only when doctor's own field-by-field read of the opt-in
|
|
261
|
+
* is valid (an invalid opt-in is 1, as A1-S2 requires of status.sh);
|
|
262
|
+
* - any other socket → `external`, which passes only with a valid declaration
|
|
263
|
+
* (`<configRoot>/agent-board/external-agent`); undeclared or mismatched → 2.
|
|
264
|
+
* - status.sh absent, unrunnable, timed out or unparseable → 2.
|
|
265
|
+
*/
|
|
266
|
+
const STATUS_MODES = new Set(["sealed", "unsealed", "contradiction", "undetermined"]);
|
|
267
|
+
const STATUS_EXIT2_REASON = /^(nothing_committed|undeterminable|linger_off|upgrade_available|loader_outdated|loader_unrecorded|legacy_loader_present|capability_(error|incapable|incapable-for-keyslot))$/;
|
|
268
|
+
const STATUS_TIMEOUT_MS = 180_000;
|
|
269
|
+
function runStatus(file, args, env) {
|
|
270
|
+
return new Promise((done) => {
|
|
271
|
+
execFile(file, args, { env, timeout: STATUS_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout) => {
|
|
272
|
+
const failure = error;
|
|
273
|
+
const code = !failure ? 0 : typeof failure.code === "number" && !failure.killed ? failure.code : null;
|
|
274
|
+
done({ code, stdout: String(stdout ?? "") });
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
/** The keyslot whose socket is the same file as `socket`, or null. */
|
|
279
|
+
function matchKeyslot(socket, env) {
|
|
280
|
+
const dir = keyslotSocketDir(env);
|
|
281
|
+
const target = fileIdentity(socket);
|
|
282
|
+
if (!dir || !target)
|
|
283
|
+
return null;
|
|
284
|
+
let names;
|
|
285
|
+
try {
|
|
286
|
+
names = readdirSync(dir);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return null;
|
|
290
|
+
}
|
|
291
|
+
for (const name of names.sort()) {
|
|
292
|
+
if (!name.endsWith(".sock"))
|
|
293
|
+
continue;
|
|
294
|
+
const slot = name.slice(0, -".sock".length);
|
|
295
|
+
if (!SLOT_RE.test(slot))
|
|
296
|
+
continue;
|
|
297
|
+
const candidate = fileIdentity(join(dir, name));
|
|
298
|
+
if (candidate && candidate.dev === target.dev && candidate.ino === target.ino)
|
|
299
|
+
return slot;
|
|
300
|
+
}
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
async function keyStorageCheck(harness, values, fileEnv, env, options) {
|
|
304
|
+
let reference;
|
|
305
|
+
try {
|
|
306
|
+
reference = normalizeKeyReference(values[KEY_REFERENCE_KEY]);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return check("undetermined", "key_reference_invalid");
|
|
310
|
+
}
|
|
311
|
+
if (reference.resolver === "os_keychain")
|
|
312
|
+
return check("pass", "os_keychain");
|
|
313
|
+
if (reference.resolver !== "agent_socket")
|
|
314
|
+
return check("undetermined", "resolver_unsupported");
|
|
315
|
+
const socket = resolveAgentSocketPath(String(reference.handle), fileEnv);
|
|
316
|
+
if (socket === null)
|
|
317
|
+
return check("undetermined", "agent_socket_unresolved");
|
|
318
|
+
if (fileIdentity(socket) === null)
|
|
319
|
+
return check("undetermined", "agent_socket_absent");
|
|
320
|
+
const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1);
|
|
321
|
+
const machineId = machineIdOf(options.machineIdFile);
|
|
322
|
+
const slot = matchKeyslot(socket, env);
|
|
323
|
+
if (slot === null) {
|
|
324
|
+
const problems = externalDeclarationProblems(env, socket, uid, machineId);
|
|
325
|
+
if (problems.length === 0)
|
|
326
|
+
return check("pass", "external");
|
|
327
|
+
return check("undetermined", problems.includes("missing") ? "external_undeclared" : "external_declaration_mismatch");
|
|
328
|
+
}
|
|
329
|
+
let dir;
|
|
330
|
+
try {
|
|
331
|
+
dir = options.keyslotDir ?? resolveKeyslotDir();
|
|
332
|
+
}
|
|
333
|
+
catch {
|
|
334
|
+
return check("undetermined", "status_absent");
|
|
335
|
+
}
|
|
336
|
+
if (!existsSync(join(dir, "status.sh")))
|
|
337
|
+
return check("undetermined", "status_absent");
|
|
338
|
+
const run = await runStatus(join(dir, "status.sh"), ["--harness", harness, "--slot", slot, "--json"], env);
|
|
339
|
+
if (run.code === null)
|
|
340
|
+
return check("undetermined", "status_did_not_complete");
|
|
341
|
+
let report;
|
|
342
|
+
try {
|
|
343
|
+
const parsed = JSON.parse(run.stdout);
|
|
344
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
345
|
+
throw new Error("not an object");
|
|
346
|
+
report = parsed;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return check("undetermined", "status_unreadable");
|
|
350
|
+
}
|
|
351
|
+
const seam = typeof env.KEYSEAL_TEST_ROOT === "string" && env.KEYSEAL_TEST_ROOT !== "";
|
|
352
|
+
const own = {
|
|
353
|
+
machine_id: (value) => machineId !== null && value === machineId,
|
|
354
|
+
uid: (value) => value === uid,
|
|
355
|
+
slot: (value) => value === slot,
|
|
356
|
+
seam: (value) => value === seam,
|
|
357
|
+
mode: (value) => typeof value === "string" && STATUS_MODES.has(value),
|
|
358
|
+
};
|
|
359
|
+
for (const [field, matches] of Object.entries(own)) {
|
|
360
|
+
if (!(field in report) || report[field] === null)
|
|
361
|
+
return check("undetermined", `status_field_missing:${field}`);
|
|
362
|
+
if (!matches(report[field]))
|
|
363
|
+
return check("undetermined", `status_field_mismatch:${field}`);
|
|
364
|
+
}
|
|
365
|
+
if (run.code === 1)
|
|
366
|
+
return check("fail", "keyslot_status_failed");
|
|
367
|
+
if (run.code === 2) {
|
|
368
|
+
const reasons = Array.isArray(report.exit2_reasons) ? report.exit2_reasons : [];
|
|
369
|
+
const named = reasons.length > 0 && reasons.every((reason) => typeof reason === "string" && STATUS_EXIT2_REASON.test(reason));
|
|
370
|
+
return check("undetermined", named ? `keyslot_status_undetermined:${reasons.join(",")}` : "keyslot_status_undetermined");
|
|
371
|
+
}
|
|
372
|
+
if (run.code !== 0)
|
|
373
|
+
return check("undetermined", "status_unexpected_exit");
|
|
374
|
+
if (report.mode === "sealed")
|
|
375
|
+
return check("pass", "sealed");
|
|
376
|
+
if (report.mode === "unsealed") {
|
|
377
|
+
const optin = report.optin;
|
|
378
|
+
if (optin?.ok !== true)
|
|
379
|
+
return check("fail", "unsealed_optin_invalid");
|
|
380
|
+
return unsealedOptinProblems(env, slot, uid, machineId).length === 0 ? check("pass", "unsealed") : check("fail", "unsealed_optin_invalid");
|
|
381
|
+
}
|
|
382
|
+
return check("undetermined", "status_mode_unexpected");
|
|
383
|
+
}
|
|
231
384
|
async function whoamiCheck(client, expectedHandle) {
|
|
232
385
|
let rows;
|
|
233
386
|
try {
|
|
@@ -342,12 +495,14 @@ export async function runDoctor(options) {
|
|
|
342
495
|
}
|
|
343
496
|
if (values === null) {
|
|
344
497
|
checks.keystore_key = check("undetermined", "no_identity_file");
|
|
498
|
+
checks.key_storage = check("undetermined", "no_identity_file");
|
|
345
499
|
checks.whoami = check("undetermined", "no_identity_file");
|
|
346
500
|
checks.board_membership = check("undetermined", "no_identity_file");
|
|
347
501
|
}
|
|
348
502
|
else {
|
|
349
503
|
const fileEnv = fileIdentityEnv(env, values);
|
|
350
504
|
checks.keystore_key = await keystoreCheck(values, fileEnv, options.probeKeystore ?? defaultKeystoreProbe);
|
|
505
|
+
checks.key_storage = await keyStorageCheck(harness, values, fileEnv, env, options);
|
|
351
506
|
let client = null;
|
|
352
507
|
try {
|
|
353
508
|
client = (options.createClient ?? defaultClient)(fileEnv);
|
|
@@ -391,7 +546,8 @@ export async function runDoctor(options) {
|
|
|
391
546
|
const verdicts = cwds.map((dir) => {
|
|
392
547
|
try {
|
|
393
548
|
return judgeRegistration(harness, { cwd: dir, home, env, fs, claudeManagedDir: options.claudeManagedDir ?? defaultClaudeManagedDir(platform),
|
|
394
|
-
platform, userName, regQuery: options.regQuery
|
|
549
|
+
platform, userName, regQuery: options.regQuery, claudeMarketplacePackaged: options.claudeMarketplacePackaged,
|
|
550
|
+
claudeMarketplaceTrust: options.claudeMarketplaceTrust });
|
|
395
551
|
}
|
|
396
552
|
catch (error) {
|
|
397
553
|
return check("undetermined", codeOf(error, "registration_unreadable"));
|
|
@@ -413,6 +569,9 @@ export async function runDoctor(options) {
|
|
|
413
569
|
installed_version: reportable(plugin?.version ?? null),
|
|
414
570
|
pinned_mcp_version: reportable(plugin?.pinnedMcpVersion ?? null),
|
|
415
571
|
source_ref: reportable(plugin?.sourceRef ?? null),
|
|
572
|
+
source_kind: plugin?.sourceKind ?? null,
|
|
573
|
+
source_path: reportablePath(plugin?.sourcePath ?? null),
|
|
574
|
+
version_from: plugin?.versionFrom ?? null,
|
|
416
575
|
auto_update: plugin ? { effective: plugin.autoUpdate.effective, source: plugin.autoUpdate.source } : { effective: false, source: "undetermined" },
|
|
417
576
|
};
|
|
418
577
|
if (here.hookCounts)
|
|
@@ -434,9 +593,10 @@ export function formatDoctorReport(report) {
|
|
|
434
593
|
const counts = report.registration?.hook_counts;
|
|
435
594
|
lines.push([
|
|
436
595
|
`${"plugin".padEnd(12)} installed=${plugin.installed ? "yes" : "no"}`,
|
|
437
|
-
`version=${plugin.installed_version ?? "none"}`,
|
|
596
|
+
`version=${plugin.installed_version ?? "none"}${plugin.version_from ? `(${plugin.version_from})` : ""}`,
|
|
438
597
|
`pinned_mcp=${plugin.pinned_mcp_version ?? "none"}`,
|
|
439
598
|
`ref=${plugin.source_ref ?? "none"}`,
|
|
599
|
+
`source=${plugin.source_kind ?? "none"}${plugin.source_path ? `:${plugin.source_path}` : ""}`,
|
|
440
600
|
`auto_update=${plugin.auto_update.effective ? "on" : "off"}(${plugin.auto_update.source})`,
|
|
441
601
|
counts ? `hooks SessionStart=${counts.SessionStart} FileChanged=${counts.FileChanged}` : "hooks undetermined",
|
|
442
602
|
].join(" "));
|
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 {};
|
package/dist/doctor_scopes.js
CHANGED
|
@@ -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).
|
|
88
|
-
// directory
|
|
89
|
-
//
|
|
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
|
-
/**
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
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) =>
|
|
1303
|
+
&& everyDeclaration.every((declaration) => marketplaceSourceKind(own(declaration, "source"), ctx) !== null);
|
|
1276
1304
|
if (marketplaceIsOurs) {
|
|
1277
|
-
// The winning declaration
|
|
1278
|
-
const
|
|
1279
|
-
summary.
|
|
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,
|
|
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:
|
|
1366
|
+
files.push({ row: ours ? CLAUDE_PLUGIN_ROW : CLAUDE_FOREIGN_PLUGIN_ROW, path: treePath, contribution, pluginId: id });
|
|
1322
1367
|
if (ours) {
|
|
1323
1368
|
const version = own(manifest, "version");
|
|
1324
1369
|
summary.installed = true;
|
|
1370
|
+
summary.versionFrom = fromSource ? "source" : "cache";
|
|
1325
1371
|
summary.version = typeof version === "string" ? version : null;
|
|
1326
1372
|
const pinArg = contribution.mcp[0]?.args.find((arg) => arg.startsWith(`${CLAUDE_PLUGIN_MCP_PACKAGE}@`));
|
|
1327
1373
|
summary.pinnedMcpVersion = pinArg === undefined ? null : pinArg.slice(CLAUDE_PLUGIN_MCP_PACKAGE.length + 1);
|
|
@@ -1335,7 +1381,68 @@ export function readClaudePluginScopes(ctx) {
|
|
|
1335
1381
|
failures.add("foreign_board_plugin");
|
|
1336
1382
|
}
|
|
1337
1383
|
}
|
|
1338
|
-
return { files, unparseable: [...unparseable], untrusted, failures: [...failures], summary, hookPolicy };
|
|
1384
|
+
return { files, unparseable: [...unparseable], untrusted, sourceUndetermined, failures: [...failures], summary, hookPolicy };
|
|
1385
|
+
}
|
|
1386
|
+
/**
|
|
1387
|
+
* Our directory source, judged as what RUNS: a directory-sourced plugin runs from its source
|
|
1388
|
+
* directory, not the cache copy doctor reads above (CCPLUGIN recon; the header's `tree`). So the
|
|
1389
|
+
* directory itself must be trusted (`checkTrustChain`, shared with setup) and byte-equal to this cli's
|
|
1390
|
+
* packaged marketplace (`readTree`/`sameTree`, shared with setup). Gone → `plugin_source_missing`
|
|
1391
|
+
* (Claude Code then fails to load it, measured). Doctor never tightens a mode: that is setup's.
|
|
1392
|
+
*/
|
|
1393
|
+
function judgeOurSourceDirectory(ctx, dir) {
|
|
1394
|
+
if (!ctx.fs.exists(dir))
|
|
1395
|
+
return "plugin_source_missing";
|
|
1396
|
+
if (checkTrustChain(dir, { ...ctx.claudeMarketplaceTrust, uid: ctx.fs.getuid(), platform: ctx.platform }) !== null)
|
|
1397
|
+
return "plugin_source_untrusted";
|
|
1398
|
+
let packagedDir;
|
|
1399
|
+
try {
|
|
1400
|
+
packagedDir = ctx.claudeMarketplacePackaged ?? resolveMarketplaceDir();
|
|
1401
|
+
}
|
|
1402
|
+
catch {
|
|
1403
|
+
return "plugin_source_unverifiable";
|
|
1404
|
+
}
|
|
1405
|
+
const packaged = readTree(packagedDir);
|
|
1406
|
+
if (packaged.kind !== "ok" || !packaged.files.has(MARKETPLACE_MANIFEST_KEY))
|
|
1407
|
+
return "plugin_source_unverifiable";
|
|
1408
|
+
// Round 5: every entry inside the directory is judged too (owner, no group/world write): it is
|
|
1409
|
+
// what Claude Code executes. The offending path is not echoed: reasons are authored codes.
|
|
1410
|
+
const current = readTree(dir, { uid: ctx.fs.getuid(), platform: ctx.platform, lstat: ctx.claudeMarketplaceTrust?.lstat });
|
|
1411
|
+
if (current.kind === "missing")
|
|
1412
|
+
return "plugin_source_missing";
|
|
1413
|
+
if (current.kind === "untrusted")
|
|
1414
|
+
return "plugin_source_untrusted";
|
|
1415
|
+
if (current.kind === "ok" && sameTree(current.files, packaged.files))
|
|
1416
|
+
return null;
|
|
1417
|
+
// Round 2, version skew: a directory another cli version wrote. OLDER (or the same version with
|
|
1418
|
+
// other bytes, or unreadable) is stale: re-run setup with this cli. NEWER means THIS doctor is
|
|
1419
|
+
// the old one; it cannot judge a plugin it does not ship, so that is undetermined, not a failure.
|
|
1420
|
+
const skew = current.kind === "ok" ? compareVersions(pluginVersionIn(current.files), pluginVersionIn(packaged.files)) : null;
|
|
1421
|
+
return skew !== null && skew > 0 ? "plugin_source_newer" : "plugin_source_stale";
|
|
1422
|
+
}
|
|
1423
|
+
/**
|
|
1424
|
+
* Every settings layer that declares OUR marketplace name, in ascending precedence (the last one wins),
|
|
1425
|
+
* with whether its source is exactly our per-user directory. Setup uses it to migrate each layer it
|
|
1426
|
+
* may write and to name the one it may not.
|
|
1427
|
+
*/
|
|
1428
|
+
export function claudeMarketplaceDeclarations(ctx) {
|
|
1429
|
+
const managed = new Set(managedSettingsPaths(ctx));
|
|
1430
|
+
const layerOf = (path, index) => managed.has(path) ? "managed" : index === 0 ? "user" : index === 1 ? "project" : "local";
|
|
1431
|
+
const out = [];
|
|
1432
|
+
claudeSettingsLayers(ctx).forEach((path, index) => {
|
|
1433
|
+
let document;
|
|
1434
|
+
try {
|
|
1435
|
+
document = readJsonFile(ctx.fs, path);
|
|
1436
|
+
}
|
|
1437
|
+
catch {
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
const declaration = own(own(document, "extraKnownMarketplaces"), CLAUDE_PLUGIN_MARKETPLACE);
|
|
1441
|
+
if (declaration === undefined)
|
|
1442
|
+
return;
|
|
1443
|
+
out.push({ layer: layerOf(path, index), path, directory: marketplaceSourceKind(own(declaration, "source"), ctx) === "directory" });
|
|
1444
|
+
});
|
|
1445
|
+
return out;
|
|
1339
1446
|
}
|
|
1340
1447
|
// ---------------------------------------------------------------------------------------------
|
|
1341
1448
|
// Judgement
|
|
@@ -1516,7 +1623,7 @@ export function judgeRegistration(harness, ctx) {
|
|
|
1516
1623
|
// An absence is only evidence when every file was read: the missing entry may be in the file
|
|
1517
1624
|
// that did not parse, under the plugin path doctor refused to follow, or a handler doctor
|
|
1518
1625
|
// cannot judge. A duplicate or the flag, seen in files that DID parse, stands.
|
|
1519
|
-
const complete = unparseable.length === 0 && !plugins?.untrusted && !unjudgeable && !policyUnjudgeable && policyCheckFailed.length === 0;
|
|
1626
|
+
const complete = unparseable.length === 0 && !plugins?.untrusted && (plugins?.sourceUndetermined.length ?? 0) === 0 && !unjudgeable && !policyUnjudgeable && policyCheckFailed.length === 0;
|
|
1520
1627
|
const standing = complete
|
|
1521
1628
|
? failures
|
|
1522
1629
|
: failures.filter((failure) => failure !== "no_registration" && !failure.startsWith("missing_hook:"));
|
|
@@ -1525,6 +1632,7 @@ export function judgeRegistration(harness, ctx) {
|
|
|
1525
1632
|
const unknowns = [
|
|
1526
1633
|
...(unparseable.length > 0 ? [`unparseable:${[...new Set(unparseable)].join("+")}`] : []),
|
|
1527
1634
|
...(plugins?.untrusted ? ["plugin_path_untrusted"] : []),
|
|
1635
|
+
...(plugins?.sourceUndetermined ?? []),
|
|
1528
1636
|
...(unjudgeable ? ["unjudgeable_hook"] : []),
|
|
1529
1637
|
...(policyUnjudgeable ? ["hook_policy_unjudgeable"] : []),
|
|
1530
1638
|
...policyCheckFailed,
|
|
@@ -16,16 +16,18 @@ export interface IdentityDeployment {
|
|
|
16
16
|
publishableKey: string;
|
|
17
17
|
}
|
|
18
18
|
/**
|
|
19
|
-
* Where each deployment value is read, in precedence order: the
|
|
20
|
-
* (
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* identity field, here or in the loader.
|
|
19
|
+
* Where each deployment value is read, in precedence order: the client's one alias table
|
|
20
|
+
* (`DEPLOYMENT_ENV_ALIASES`), which puts the enrollment's own names first (so the file records
|
|
21
|
+
* the deployment the enrollment used) and then the identity names. The enrollment resolvers read
|
|
22
|
+
* the same table, so the two cannot disagree. The generic Supabase names are in no list: a
|
|
23
|
+
* generic key never fills an identity field, here or in the loader.
|
|
24
24
|
*/
|
|
25
25
|
export declare const DEPLOYMENT_ENV_NAMES: Readonly<Record<keyof IdentityDeployment, readonly string[]>>;
|
|
26
26
|
/**
|
|
27
|
-
* The deployment half of an identity, read and shape-checked before enrollment commits.
|
|
28
|
-
*
|
|
27
|
+
* The deployment half of an identity, read and shape-checked before enrollment commits. Each
|
|
28
|
+
* value is the shipped default, or env under the board group's all-or-nothing rule (the
|
|
29
|
+
* client's `resolveDeploymentField`). Absent from an env-set group → `identity_incomplete`,
|
|
30
|
+
* naming the variable that switched the group off; not the non-secret shape its key requires →
|
|
29
31
|
* `identity_secret_value`. Both name the variable, never the value.
|
|
30
32
|
*/
|
|
31
33
|
export declare function resolveIdentityDeployment(env: NodeJS.ProcessEnv): IdentityDeployment;
|