@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/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +2 -2
- package/dist/assets.json +4 -4
- package/dist/cli.d.ts +5 -2
- package/dist/cli.js +41 -13
- package/dist/doctor.d.ts +143 -2
- package/dist/doctor.js +346 -39
- package/dist/identity_writer.d.ts +1 -46
- package/dist/identity_writer.js +5 -174
- package/dist/keyslot_status.d.ts +134 -0
- package/dist/keyslot_status.js +348 -0
- package/dist/setup.d.ts +112 -5
- package/dist/setup.js +345 -73
- package/dist/setup_all.d.ts +51 -0
- package/dist/setup_all.js +164 -0
- package/dist/setup_storage.d.ts +74 -11
- package/dist/setup_storage.js +173 -30
- package/dist/setup_writers.d.ts +7 -0
- package/dist/setup_writers.js +11 -2
- package/keyslot/lib.sh +835 -38
- package/keyslot/status.sh +439 -173
- package/package.json +2 -2
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 {
|
|
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 {
|
|
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
|
-
|
|
277
|
-
const
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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";
|