@consiliency/agent-board-cli 1.27.1 → 1.29.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/dist/doctor.js CHANGED
@@ -9,18 +9,33 @@
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";
13
12
  import { randomBytes } from "node:crypto";
14
- import { existsSync, readdirSync } from "node:fs";
13
+ import { readdirSync, statSync } from "node:fs";
15
14
  import { homedir, userInfo } from "node:os";
16
15
  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, osKeychainFailureOf, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
18
- import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
16
+ import { AUTHORED_CAUSE_CODES, AgentSocketSelectionError, GENERIC_SUPABASE_FIELDS, IDENTITY_FILE_KEYS, IDENTITY_TRIGGER_FIELDS, KeystoreUnavailableError, assertIdentityHarness, codeProvenance, createAgentBoardClient, createOsKeychainBackend, expectedWakeTier, listHarnessProcesses, normalizeKeyReference, osKeychainFailureOf, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
17
+ import { compareVersions } from "./claude_marketplace.js";
18
+ import { CLAUDE_PLUGIN_MCP_PACKAGE, judgeMcpEntry, judgeRegistration, nodeRegistrationFs, packageIdentity, readScopes, } from "./doctor_scopes.js";
19
19
  import { resolveKeyslotDir } from "./assets.js";
20
+ import { cacheStateOf, measureKeyslot, progressText, scanOf, scanSourceOf, STATUS_SCAN_TIMEOUT_MS, } from "./keyslot_status.js";
20
21
  import { SLOT_RE, externalDeclarationProblems, fileIdentity, keyslotSocketDir, machineIdOf, resolveAgentSocketPath, unsealedOptinProblems, } from "./setup_storage.js";
21
22
  export const DOCTOR_CHECKS = Object.freeze([
22
23
  "identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime",
23
24
  ]);
25
+ /**
26
+ * ONBOARD IF-0-ONBOARD-3 — `runtime`'s informational hint. It never changes `runtime`'s status or
27
+ * reason, or the exit code (pinned in `doctor_runtime_hint.test.ts`'s exit-invariance cases); where
28
+ * it overlaps `registration`'s existing `plugin_pin_mismatch`, that reason stays and the hint only
29
+ * explains it. Precedence when more than one applies: `session_predates_install`, then `no_session`
30
+ * (zero live records, and no pre-install process found either), then `stale_session_version`, then
31
+ * `no_target_first_session`, then `none`. `session_predates_install` is checked FIRST, ahead of
32
+ * `no_session`, because the motivating case (a harness that wrote no record at all before setup
33
+ * finished — the macmini claude case) has zero live records: a `no_session`-first order would
34
+ * mask it.
35
+ */
36
+ export const RUNTIME_HINTS = Object.freeze([
37
+ "no_session", "stale_session_version", "session_predates_install", "no_target_first_session", "none",
38
+ ]);
24
39
  export const REPORTABLE_TOKEN = /^[A-Za-z0-9._/-]{1,64}$/;
25
40
  /**
26
41
  * A path prints only when absolute and made of the token alphabet (no spaces, quotes or control
@@ -63,6 +78,42 @@ function pinCheck(verdict, entries, cwd, fs) {
63
78
  return { ...verdict, status: "fail", reason: [...earlier, "plugin_pin_mismatch"].join(",") };
64
79
  return { ...verdict, status: verdict.status === "fail" ? "fail" : "undetermined", reason: [...earlier, "plugin_pin_ambiguous"].join(",") };
65
80
  }
81
+ /** The IF-552-2 validity window, the same for the carry: `scannedAt ≤ now + 300` and `now − scannedAt < 86400`. */
82
+ export const SCAN_CACHE_TTL_S = 86_400;
83
+ export const SCAN_CACHE_FUTURE_S = 300;
84
+ /** Cache states that are STRUCTURAL: the cache cannot serve here at all. `lock_busy` never is. */
85
+ const STRUCTURAL_UNUSABLE = /^(untrusted:.+|state_root_relative|machine_id_unreadable)$/;
86
+ const UNWRITABLE_WRITE = new Set(["not_written:untrusted_dir", "not_written:write_failed"]);
87
+ const sameJson = (a, b) => {
88
+ if (a === b)
89
+ return true;
90
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object")
91
+ return false;
92
+ if (Array.isArray(a) !== Array.isArray(b))
93
+ return false;
94
+ const ka = Object.keys(a);
95
+ const kb = Object.keys(b);
96
+ return ka.length === kb.length && ka.every((k) => sameJson(a[k], b[k]));
97
+ };
98
+ /**
99
+ * IF-552-3's carry: setup's result serves instead of phase 2 only when (1) phase 1's `scan.inputs`
100
+ * deep-equals the carried inputs, (2) the cache could not serve STRUCTURALLY (phase 1 `unusable`
101
+ * with a structural reason, or `miss:absent` beside a key-step write of `not_written:untrusted_dir`
102
+ * or `write_failed`; `lock_busy` never qualifies: a held lock may be a tombstone being written), and
103
+ * (3) the carried scan is inside the validity window. Reads phase 1's `scan.cache`, never only
104
+ * `exit2_reasons`.
105
+ */
106
+ export function carryQualifies(phase1, carried, now) {
107
+ const inputs = scanOf(phase1).inputs;
108
+ if (!inputs || typeof inputs !== "object" || !sameJson(inputs, carried.inputs))
109
+ return false;
110
+ const { state, reason } = cacheStateOf(phase1);
111
+ const structural = (state === "unusable" && STRUCTURAL_UNUSABLE.test(reason))
112
+ || (state === "miss" && reason === "absent" && UNWRITABLE_WRITE.has(carried.cacheWrite));
113
+ if (!structural)
114
+ return false;
115
+ return carried.scannedAt <= now + SCAN_CACHE_FUTURE_S && now - carried.scannedAt < SCAN_CACHE_TTL_S;
116
+ }
66
117
  /** The binary's managed-settings directory per platform (`Zh()`, 2.1.283). */
67
118
  function defaultClaudeManagedDir(platform) {
68
119
  if (platform === "darwin")
@@ -273,17 +324,8 @@ async function keystoreCheck(values, env, probe) {
273
324
  * - status.sh absent, unrunnable, timed out or unparseable → 2.
274
325
  */
275
326
  const STATUS_MODES = new Set(["sealed", "unsealed", "contradiction", "undetermined"]);
276
- const STATUS_EXIT2_REASON = /^(nothing_committed|undeterminable|linger_off|upgrade_available|loader_outdated|loader_unrecorded|legacy_loader_present|capability_(error|incapable|incapable-for-keyslot))$/;
277
- const STATUS_TIMEOUT_MS = 180_000;
278
- function runStatus(file, args, env) {
279
- return new Promise((done) => {
280
- execFile(file, args, { env, timeout: STATUS_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout) => {
281
- const failure = error;
282
- const code = !failure ? 0 : typeof failure.code === "number" && !failure.killed ? failure.code : null;
283
- done({ code, stdout: String(stdout ?? "") });
284
- });
285
- });
286
- }
327
+ /** Every exit-2 cause status.sh can emit (KSC-8 derives the list from status.sh and probe.sh). */
328
+ export const STATUS_EXIT2_REASON = /^(nothing_committed|undeterminable|linger_off|upgrade_available|loader_outdated|loader_unrecorded|legacy_loader_present|scan_not_cached|scan_timed_out|capability_(error|incapable|incapable-for-keyslot))$/;
287
329
  /** The keyslot whose socket is the same file as `socket`, or null. */
288
330
  function matchKeyslot(socket, env) {
289
331
  const dir = keyslotSocketDir(env);
@@ -309,7 +351,18 @@ function matchKeyslot(socket, env) {
309
351
  }
310
352
  return null;
311
353
  }
354
+ /** A keyslot socket's shape: `<dir>/agent-board/<slot>.sock`, the slot a name the keyslot scripts accept. */
355
+ function keyslotShaped(socket) {
356
+ const match = /\/agent-board\/([^/]+)\.sock$/.exec(socket);
357
+ return match !== null && SLOT_RE.test(match[1]);
358
+ }
359
+ const isoOf = (value) => (typeof value === "string" ? value : null);
360
+ const numOf = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
312
361
  async function keyStorageCheck(harness, values, fileEnv, env, options) {
362
+ const judged = await keyStorageJudge(harness, values, fileEnv, env, options);
363
+ return "check" in judged ? judged : { check: judged };
364
+ }
365
+ async function keyStorageJudge(harness, values, fileEnv, env, options) {
313
366
  let reference;
314
367
  try {
315
368
  reference = normalizeKeyReference(values[KEY_REFERENCE_KEY]);
@@ -333,6 +386,10 @@ async function keyStorageCheck(harness, values, fileEnv, env, options) {
333
386
  const problems = externalDeclarationProblems(env, socket, uid, machineId);
334
387
  if (problems.length === 0)
335
388
  return check("pass", "external");
389
+ // #552: with no runtime dir the keyslot cannot be located at all. Only a socket of the keyslot's
390
+ // own shape says so; any other undeclared socket stays external_undeclared.
391
+ if (keyslotSocketDir(env) === null && keyslotShaped(socket))
392
+ return check("undetermined", "runtime_dir_unset");
336
393
  return check("undetermined", problems.includes("missing") ? "external_undeclared" : "external_declaration_mismatch");
337
394
  }
338
395
  let dir;
@@ -342,21 +399,54 @@ async function keyStorageCheck(harness, values, fileEnv, env, options) {
342
399
  catch {
343
400
  return check("undetermined", "status_absent");
344
401
  }
345
- if (!existsSync(join(dir, "status.sh")))
402
+ const writeStderr = options.writeStderr ?? ((line) => { process.stderr.write(`${line}\n`); });
403
+ const now = options.nowSeconds ?? (() => Math.floor(Date.now() / 1000));
404
+ let carried = false;
405
+ const measured = await measureKeyslot({
406
+ dir, harness, slot, env, scan: options.rescan ? "fresh" : "use", onFailure: "stop", run: options.statusRunner, budgets: options.statusBudgets,
407
+ skipScan: (phase1) => {
408
+ carried = options.setupScan !== undefined && carryQualifies(phase1, options.setupScan, now());
409
+ return carried;
410
+ },
411
+ notify: (event) => {
412
+ if (event.kind === "before") {
413
+ const { state, reason } = cacheStateOf(event.phase1);
414
+ const why = options.rescan ? "rescan requested" : `${state}${reason ? `:${reason}` : ""}` || "no cache";
415
+ writeStderr(`doctor: key_storage: scanning for plaintext copies of the key (${why}); this can take several minutes on a large home`);
416
+ }
417
+ else if (event.kind === "progress") {
418
+ writeStderr(`doctor: key_storage: ${progressText(event.folders)}`);
419
+ }
420
+ },
421
+ });
422
+ if (measured.absent)
346
423
  return check("undetermined", "status_absent");
347
- const run = await runStatus(join(dir, "status.sh"), ["--harness", harness, "--slot", slot, "--json"], env);
348
- if (run.code === null)
424
+ const scanPhase = measured.phases.find((phase) => phase.mode !== "only");
425
+ const phase1 = measured.phases.find((phase) => phase.mode === "only");
426
+ // A phase 1 that did not finish is status_did_not_complete; a SCAN that did not is scan_timed_out.
427
+ if (phase1 && phase1.code === null)
349
428
  return check("undetermined", "status_did_not_complete");
350
- let report;
351
- try {
352
- const parsed = JSON.parse(run.stdout);
353
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
354
- throw new Error("not an object");
355
- report = parsed;
429
+ // Only the helper's own budget is a timeout. Any other end of the scan without a report (SIGKILL:
430
+ // code null; TERM/INT, which status.sh traps: 143/130 with no JSON) is undetermined
431
+ // status_unreadable, the same for every signal: doctor could not finish checking, and AC-7 keeps
432
+ // doctor's key_storage reasons (#552 review r2). Setup, which must not enroll, refuses instead.
433
+ const budgetS = Math.round((options.statusBudgets?.scanMs ?? STATUS_SCAN_TIMEOUT_MS) / 1000);
434
+ // The helper's own scan budget ran out: scan_timed_out at once. Phase 1's report is never judged in
435
+ // its place (#552 review r2, gemini): it answers a different question (the cache), and judging it
436
+ // turned a timeout into whatever phase 1 said.
437
+ if (scanPhase !== undefined && scanPhase.timedOut) {
438
+ return {
439
+ check: check("undetermined", "scan_timed_out"),
440
+ scan: { source: "none", scanned_at: null, age_s: null, duration_s: null, cache_state: null, gave_up: { after_s: budgetS, folders: measured.folders } },
441
+ };
356
442
  }
357
- catch {
443
+ if (scanPhase !== undefined && scanPhase.code === null)
444
+ return check("undetermined", "status_unreadable");
445
+ if (scanPhase === undefined && measured.code === null)
446
+ return check("undetermined", "status_did_not_complete");
447
+ const report = measured.report;
448
+ if (report === null)
358
449
  return check("undetermined", "status_unreadable");
359
- }
360
450
  const seam = typeof env.KEYSEAL_TEST_ROOT === "string" && env.KEYSEAL_TEST_ROOT !== "";
361
451
  const own = {
362
452
  machine_id: (value) => machineId !== null && value === machineId,
@@ -371,24 +461,58 @@ async function keyStorageCheck(harness, values, fileEnv, env, options) {
371
461
  if (!matches(report[field]))
372
462
  return check("undetermined", `status_field_mismatch:${field}`);
373
463
  }
374
- if (run.code === 1)
375
- return check("fail", "keyslot_status_failed");
376
- if (run.code === 2) {
377
- const reasons = Array.isArray(report.exit2_reasons) ? report.exit2_reasons : [];
464
+ // The report is about this host, user and slot: now it may say when OUR scan ran.
465
+ const scanReport = (source, of) => {
466
+ const scan = scanOf(of);
467
+ const { state, reason } = cacheStateOf(of);
468
+ return {
469
+ source, scanned_at: isoOf(scan.scanned_at), age_s: numOf(scan.age_s), duration_s: numOf(scan.duration_s),
470
+ cache_state: state === "" ? null : `${state}${reason ? `:${reason}` : ""}`,
471
+ };
472
+ };
473
+ const gaveUp = () => ({
474
+ check: check("undetermined", "scan_timed_out"),
475
+ scan: { ...scanReport("none", report), gave_up: { after_s: budgetS, folders: measured.folders } },
476
+ });
477
+ const code = measured.code;
478
+ const reasons = Array.isArray(report.exit2_reasons) ? report.exit2_reasons : [];
479
+ let scan;
480
+ let effectiveCode = code;
481
+ if (carried && options.setupScan) {
482
+ // Setup's result stands in for phase 2: scan_not_cached is satisfied by it.
483
+ scan = {
484
+ source: "setup_run", scanned_at: new Date(options.setupScan.scannedAt * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
485
+ age_s: now() - options.setupScan.scannedAt, duration_s: numOf(options.setupScan.scan.duration_s), cache_state: scanReport("none", report).cache_state,
486
+ };
487
+ if (code === 2 && reasons.length === 1 && reasons[0] === "scan_not_cached")
488
+ effectiveCode = 0;
489
+ }
490
+ else {
491
+ const source = scanSourceOf(report);
492
+ if (source !== "none")
493
+ scan = scanReport(source, report);
494
+ }
495
+ const withScan = (result) => (scan ? { check: result, scan } : { check: result });
496
+ if (effectiveCode === 1)
497
+ return withScan(check("fail", "keyslot_status_failed"));
498
+ if (effectiveCode === 2) {
499
+ // status.sh's own self-bound: the scan gave up, and nothing else is undetermined.
500
+ if (scanPhase !== undefined && reasons.length === 1 && reasons[0] === "scan_timed_out")
501
+ return gaveUp();
378
502
  const named = reasons.length > 0 && reasons.every((reason) => typeof reason === "string" && STATUS_EXIT2_REASON.test(reason));
379
- return check("undetermined", named ? `keyslot_status_undetermined:${reasons.join(",")}` : "keyslot_status_undetermined");
503
+ return withScan(check("undetermined", named ? `keyslot_status_undetermined:${reasons.join(",")}` : "keyslot_status_undetermined"));
380
504
  }
381
- if (run.code !== 0)
382
- return check("undetermined", "status_unexpected_exit");
505
+ if (effectiveCode !== 0)
506
+ return withScan(check("undetermined", "status_unexpected_exit"));
383
507
  if (report.mode === "sealed")
384
- return check("pass", "sealed");
508
+ return withScan(check("pass", "sealed"));
385
509
  if (report.mode === "unsealed") {
386
510
  const optin = report.optin;
387
511
  if (optin?.ok !== true)
388
- return check("fail", "unsealed_optin_invalid");
389
- return unsealedOptinProblems(env, slot, uid, machineId).length === 0 ? check("pass", "unsealed") : check("fail", "unsealed_optin_invalid");
512
+ return withScan(check("fail", "unsealed_optin_invalid"));
513
+ return withScan(unsealedOptinProblems(env, slot, uid, machineId).length === 0 ? check("pass", "unsealed") : check("fail", "unsealed_optin_invalid"));
390
514
  }
391
- return check("undetermined", "status_mode_unexpected");
515
+ return withScan(check("undetermined", "status_mode_unexpected"));
392
516
  }
393
517
  async function whoamiCheck(client, expectedHandle) {
394
518
  let rows;
@@ -482,6 +606,120 @@ export function judgeRuntime(harness, entries, identity) {
482
606
  return check("undetermined", unique(unknowns));
483
607
  return check("pass", "wake_producer_live");
484
608
  }
609
+ /**
610
+ * ONBOARD IF-0-ONBOARD-3 — `runtime_hint`, over the SAME already-read `entries` `judgeRuntime` saw.
611
+ * Exported for its fixtures. Never reads `checks.runtime`'s own verdict, and never influences it:
612
+ * this is purely informational (see `RUNTIME_HINTS`'s doc comment for the precedence).
613
+ */
614
+ export function judgeRuntimeHint(harness, entries, fs, options) {
615
+ const live = entries.filter((entry) => entry.status === "live")
616
+ .map((entry) => entry.record);
617
+ if (options.installedAt) {
618
+ // Checked even with ZERO live records (the macmini claude case: the harness wrote no record at
619
+ // all): an empty `live` makes every candidate process uncovered by construction, exactly as it
620
+ // should. Checking this before the `live.length === 0` fallback below is load-bearing — that
621
+ // fallback would otherwise short-circuit the one case IF-0-ONBOARD-3 names first.
622
+ //
623
+ // The same real-path comparison `pinCheck` uses for a record's cwd against doctor's own: a
624
+ // process is covered by a live record that is EITHER that process's own writer (pid-equal,
625
+ // true for pi/opencode's in-process plugin) OR launched from the same directory (true for the
626
+ // mcp child claude/codex/grok/agy launch, whose own pid never matches the harness's). Two
627
+ // uncovered sessions sharing one cwd is a stated false negative of the cwd half.
628
+ const real = (path) => fs.realpath(path) ?? resolve(path);
629
+ const recordPids = new Set(live.map((record) => record.pid));
630
+ const recordCwds = new Set(live.map((record) => real(record.cwd)));
631
+ const installedAt = options.installedAt;
632
+ const raw = (options.harnessProcesses ?? listHarnessProcesses)(harness, { startedBefore: installedAt });
633
+ // `startedBefore` is passed through as an optimisation hint to the injected/real lister; this
634
+ // cutoff is enforced here too, so an injected function that ignores it cannot fabricate a hint.
635
+ const processes = raw.filter((process) => process.startedAt.getTime() < installedAt.getTime());
636
+ const uncovered = processes.some((process) => !recordPids.has(process.pid) && !(process.cwd !== null && recordCwds.has(real(process.cwd))));
637
+ if (uncovered)
638
+ return "session_predates_install";
639
+ }
640
+ if (live.length === 0)
641
+ return "no_session";
642
+ if (options.installedVersion) {
643
+ const stale = live.some((record) => {
644
+ const installed = options.installedVersion(record.packageName);
645
+ return installed !== null && (compareVersions(record.packageVersion, installed) ?? 0) < 0;
646
+ });
647
+ if (stale)
648
+ return "stale_session_version";
649
+ }
650
+ if (harness === "opencode"
651
+ && live.some((record) => record.role === "wake_producer" && record.wake.tier === "tier0" && record.wake.reason === "no_target")) {
652
+ return "no_target_first_session";
653
+ }
654
+ return "none";
655
+ }
656
+ /** Default `statMtime`: a file that cannot be stat'd proves nothing, never an error. */
657
+ function defaultStatMtime(path) {
658
+ try {
659
+ return statSync(path).mtime;
660
+ }
661
+ catch {
662
+ return null;
663
+ }
664
+ }
665
+ /**
666
+ * ONBOARD IF-0-ONBOARD-3 — the default `installedAt`/`installedVersion`, derived ONLY from what
667
+ * doctor already reads for its own `registration` check (never a new probe): each scope file's
668
+ * board-identified mcp entry (codex/grok/agy, and claude's own non-plugin mcp registrations — an
669
+ * `npx -y @consiliency/agent-board-mcp@<version> …` stanza) and `packages`/`plugin` spec (pi,
670
+ * opencode — a pinned `<package>@<version>` entry). `files` is in ascending precedence (SAME as
671
+ * `readScopes` returns it), so a later file's match for the same package overrides an earlier
672
+ * one's, exactly as the harness itself would resolve it.
673
+ *
674
+ * For claude, the plugin verdict `pinCheck` already computed and trusts (never an unvalidated
675
+ * directory source) is preferred over a raw config scan, per its own `versionFrom`/`sourceKind`
676
+ * guard — the same one `pinCheck` applies.
677
+ *
678
+ * `installedAt` is the LATEST mtime among every file that named a version this way: several files
679
+ * naming different packages (pi's own `packages` row plus an unrelated mcp row) both count, since
680
+ * either being freshly written is evidence this harness's board setup was just touched. A file (or
681
+ * a claude directory source) whose mtime cannot be read contributes nothing — not an error, and
682
+ * never lowers the claim to "no evidence at all" by itself.
683
+ *
684
+ * Exported for its fixtures.
685
+ */
686
+ export function defaultInstallEvidence(harness, plugin, files, fs, statMtime) {
687
+ const found = new Map();
688
+ const VERSIONED = /^\d+[\w.+-]*$/;
689
+ // A live record's own `packageName` (written by the package itself, e.g. pi's `extension.ts`
690
+ // `PACKAGE_NAME`) is always bare — never carries a source scheme. pi's OWN config spec does
691
+ // (`npm:@consiliency/agent-board-pi@<v>`, pi's install-manifest convention); opencode's does not.
692
+ // Strip the scheme here so both key the same way a live record does.
693
+ const bare = (identity) => (identity.startsWith("npm:") ? identity.slice("npm:".length) : identity);
694
+ for (const file of files) {
695
+ for (const entry of file.contribution.mcp) {
696
+ if (!judgeMcpEntry(entry, fs).board)
697
+ continue;
698
+ for (const word of [entry.command ?? "", ...entry.args]) {
699
+ const identity = packageIdentity(word);
700
+ const version = word.slice(identity.length + 1);
701
+ if (identity !== word && VERSIONED.test(version))
702
+ found.set(bare(identity), { version, path: file.path });
703
+ }
704
+ }
705
+ for (const spec of file.contribution.packages) {
706
+ const identity = packageIdentity(spec);
707
+ const version = spec.slice(identity.length + 1);
708
+ if (identity !== spec && VERSIONED.test(version))
709
+ found.set(bare(identity), { version, path: file.path });
710
+ }
711
+ }
712
+ if (harness === "claude" && plugin?.installed && plugin.version
713
+ && !(plugin.sourceKind === "directory" && plugin.versionFrom !== "source")) {
714
+ found.set(CLAUDE_PLUGIN_MCP_PACKAGE, { version: plugin.version, path: plugin.sourceKind === "directory" ? plugin.sourcePath : null });
715
+ }
716
+ const installedVersion = (packageName) => found.get(packageName)?.version ?? null;
717
+ const mtimes = [...found.values()]
718
+ .map((candidate) => (candidate.path ? statMtime(candidate.path) : null))
719
+ .filter((mtime) => mtime !== null);
720
+ const installedAt = mtimes.length > 0 ? new Date(Math.max(...mtimes.map((mtime) => mtime.getTime()))) : undefined;
721
+ return { installedAt, installedVersion };
722
+ }
485
723
  function worst(checks) {
486
724
  return checks.find((item) => item.status === "fail")
487
725
  ?? checks.find((item) => item.status === "undetermined")
@@ -494,6 +732,7 @@ export async function runDoctor(options) {
494
732
  const cwd = options.cwd ?? process.cwd();
495
733
  const trust = options.trust ?? {};
496
734
  const checks = {};
735
+ let keyStorageScan;
497
736
  let values = null;
498
737
  try {
499
738
  values = (await readIdentityFile({ ...trust, harness, env })).values;
@@ -511,7 +750,9 @@ export async function runDoctor(options) {
511
750
  else {
512
751
  const fileEnv = fileIdentityEnv(env, values);
513
752
  checks.keystore_key = await keystoreCheck(values, fileEnv, options.probeKeystore ?? defaultKeystoreProbe);
514
- checks.key_storage = await keyStorageCheck(harness, values, fileEnv, env, options);
753
+ const storage = await keyStorageCheck(harness, values, fileEnv, env, options);
754
+ checks.key_storage = storage.check;
755
+ keyStorageScan = storage.scan;
515
756
  let client = null;
516
757
  try {
517
758
  client = (options.createClient ?? defaultClient)(fileEnv);
@@ -530,17 +771,23 @@ export async function runDoctor(options) {
530
771
  checks.board_membership = check("undetermined", "whoami_not_passed");
531
772
  }
532
773
  }
774
+ const fs = options.registrationFs ?? nodeRegistrationFs;
533
775
  let entries = [];
776
+ // ONBOARD IF-0-ONBOARD-3: `runtime_hint` is computed further down, once records were read
777
+ // without throwing (a caught `records_unreadable` must never be misread as `no_session` from the
778
+ // empty `entries` the catch leaves behind) AND registration has been read (its scope files and
779
+ // claude's plugin verdict are the default install evidence).
780
+ let recordsReadable = false;
534
781
  try {
535
782
  entries = readSessionRecords(harness, { ...trust, env, isAlive: options.isAlive });
536
783
  checks.runtime = judgeRuntime(harness, entries, values && { handle: values[HANDLE_KEY], fingerprint: values[FINGERPRINT_KEY] });
784
+ recordsReadable = true;
537
785
  }
538
786
  catch (error) {
539
787
  checks.runtime = check("undetermined", codeOf(error, "records_unreadable"));
540
788
  }
541
789
  // Registration is read for this directory and for every live record's.
542
790
  const cwds = [...new Set([cwd, ...entries.flatMap((entry) => entry.status === "live" ? [entry.record.cwd] : [])])];
543
- const fs = options.registrationFs ?? nodeRegistrationFs;
544
791
  const home = env.HOME && env.HOME.trim() !== "" ? env.HOME : homedir();
545
792
  const platform = options.platform ?? process.platform;
546
793
  let userName = options.userName;
@@ -566,10 +813,41 @@ export async function runDoctor(options) {
566
813
  verdicts[0] = pinCheck(verdicts[0], entries, cwd, fs);
567
814
  const registration = worst(verdicts);
568
815
  checks.registration = check(registration.status, registration.reason);
816
+ // ONBOARD IF-0-ONBOARD-3: `runtime_hint`, now that registration (this cwd's scope files and
817
+ // claude's plugin verdict) has been read — the default install evidence, unless a caller
818
+ // overrides either half. Computing the default is best-effort: a throw here (an unreadable
819
+ // scope file, say) leaves both unset rather than failing the whole report.
820
+ let runtimeHint = "none";
821
+ if (recordsReadable) {
822
+ let defaultInstalledAt;
823
+ let defaultInstalledVersion;
824
+ if (options.installedAt === undefined || options.installedVersion === undefined) {
825
+ try {
826
+ const { files } = readScopes(harness, { cwd, home, env, fs, claudeManagedDir: options.claudeManagedDir ?? defaultClaudeManagedDir(platform),
827
+ platform, userName, regQuery: options.regQuery, claudeMarketplacePackaged: options.claudeMarketplacePackaged,
828
+ claudeMarketplaceTrust: options.claudeMarketplaceTrust });
829
+ const evidence = defaultInstallEvidence(harness, verdicts[0].plugin, files, fs, options.statMtime ?? defaultStatMtime);
830
+ defaultInstalledAt = evidence.installedAt;
831
+ defaultInstalledVersion = evidence.installedVersion;
832
+ }
833
+ catch {
834
+ // Leave both unset: the hint degrades to what it can prove, never a guess.
835
+ }
836
+ }
837
+ runtimeHint = judgeRuntimeHint(harness, entries, fs, {
838
+ installedAt: options.installedAt ?? defaultInstalledAt,
839
+ installedVersion: options.installedVersion ?? defaultInstalledVersion,
840
+ harnessProcesses: options.harnessProcesses,
841
+ });
842
+ }
569
843
  const ordered = Object.fromEntries(DOCTOR_CHECKS.map((name) => [name, checks[name]]));
570
844
  const statuses = Object.values(ordered).map((item) => item.status);
571
845
  const exit = statuses.every((status) => status === "pass") ? 0 : statuses.includes("fail") ? 1 : 2;
572
846
  const report = { harness, exit, checks: ordered };
847
+ if (runtimeHint !== "none")
848
+ report.runtime_hint = runtimeHint;
849
+ if (keyStorageScan)
850
+ report.key_storage_scan = keyStorageScan;
573
851
  const here = verdicts[0];
574
852
  if (harness === "claude") {
575
853
  const plugin = here.plugin;
@@ -594,9 +872,21 @@ export async function runDoctor(options) {
594
872
  }
595
873
  return report;
596
874
  }
875
+ /** An age in seconds, as a short human span. */
876
+ function ago(seconds) {
877
+ if (seconds === null)
878
+ return "?";
879
+ if (seconds < 120)
880
+ return `${seconds} s`;
881
+ if (seconds < 7200)
882
+ return `${Math.floor(seconds / 60)} min`;
883
+ return `${Math.floor(seconds / 3600)} h`;
884
+ }
597
885
  /** Human-readable form; `--json` prints the report object itself. */
598
886
  export function formatDoctorReport(report) {
599
887
  const lines = DOCTOR_CHECKS.map((name) => `${report.checks[name].status.padEnd(12)} ${name}: ${report.checks[name].reason}`);
888
+ if (report.runtime_hint)
889
+ lines.push(`${"runtime_hint".padEnd(12)} ${report.runtime_hint}`);
600
890
  if (report.plugin) {
601
891
  const { plugin } = report;
602
892
  const counts = report.registration?.hook_counts;
@@ -610,6 +900,23 @@ export function formatDoctorReport(report) {
610
900
  counts ? `hooks SessionStart=${counts.SessionStart} FileChanged=${counts.FileChanged}` : "hooks undetermined",
611
901
  ].join(" "));
612
902
  }
903
+ const scan = report.key_storage_scan;
904
+ if (scan) {
905
+ const rescan = `\`agent-board doctor --harness ${report.harness} --rescan\``;
906
+ if (scan.gave_up) {
907
+ const after = scan.gave_up.after_s > 0 && scan.gave_up.after_s % 60 === 0 ? `${scan.gave_up.after_s / 60} min` : `${scan.gave_up.after_s} s`;
908
+ lines.push(`${"scan".padEnd(12)} gave up after ${after} (${scan.gave_up.folders ?? "?"} folders so far); ${rescan} retries`);
909
+ }
910
+ else if (scan.source === "fresh") {
911
+ lines.push(`${"scan".padEnd(12)} fresh (${scan.duration_s ?? "?"} s)${report.checks.key_storage.status === "pass" ? ", no plaintext copy found" : ""}`);
912
+ }
913
+ else if (scan.source === "setup_run") {
914
+ lines.push(`${"scan".padEnd(12)} setup's scan at ${scan.scanned_at ?? "?"} (${ago(scan.age_s)} ago), no plaintext copy found`);
915
+ }
916
+ else if (scan.source === "cached") {
917
+ lines.push(`${"scan".padEnd(12)} cached at ${scan.scanned_at ?? "?"} (${ago(scan.age_s)} ago); ${rescan} scans again`);
918
+ }
919
+ }
613
920
  if (report.managed_policy) {
614
921
  const policy = report.managed_policy;
615
922
  lines.push([
@@ -1,46 +1 @@
1
- import { type EnrollmentOutcome } from "@consiliency/agent-board-client";
2
- export interface OwnerOnlyWriteOptions {
3
- syncDirectory?: (path: string) => Promise<void>;
4
- }
5
- /** Create `path` exclusively (never following a planted link) at 0600, write, fsync. */
6
- export declare function writeOwnerOnlyFile(path: string, content: string): Promise<void>;
7
- /**
8
- * Write `content` to `path` so a reader sees the old file or the whole new one, never a
9
- * part: an exclusive 0600 temp beside it, fsync'd, renamed over it, then the directory
10
- * fsync'd. On any throw before the rename the temp is removed.
11
- */
12
- export declare function writeOwnerOnlyFileAtomic(path: string, content: string, options?: OwnerOnlyWriteOptions): Promise<void>;
13
- export interface IdentityDeployment {
14
- boardUrl: string;
15
- sessionUrl: string;
16
- publishableKey: string;
17
- }
18
- /**
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
- */
25
- export declare const DEPLOYMENT_ENV_NAMES: Readonly<Record<keyof IdentityDeployment, readonly string[]>>;
26
- /**
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 →
31
- * `identity_secret_value`. Both name the variable, never the value.
32
- */
33
- export declare function resolveIdentityDeployment(env: NodeJS.ProcessEnv): IdentityDeployment;
34
- /**
35
- * The identity file's values for an enrolled agent. The URLs and publishable key come only
36
- * from `deployment`; everything else from the outcome. Validated by the client's serializer
37
- * before it is returned.
38
- */
39
- export declare function identityValuesFromEnrollment(outcome: EnrollmentOutcome, deployment: IdentityDeployment): Record<string, string>;
40
- /**
41
- * Write `harness`'s identity file for this host. Every refusal (bad harness, relative root,
42
- * invalid host, bad value) fires before the first filesystem change. The directory is then
43
- * put through the reader's own trusted walk, so the writer never produces a file the loader
44
- * would refuse to open. Returns the path written.
45
- */
46
- export declare function writeIdentityFile(harness: string, values: Readonly<Record<string, string>>, env: NodeJS.ProcessEnv, options?: OwnerOnlyWriteOptions): Promise<string>;
1
+ export { DEPLOYMENT_ENV_NAMES, identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, writeOwnerOnlyFile, writeOwnerOnlyFileAtomic, type IdentityDeployment, type OwnerOnlyWriteOptions, } from "@consiliency/agent-board-client";