@consiliency/agent-board-cli 1.27.1 → 1.28.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/assets.json CHANGED
@@ -2,5 +2,5 @@
2
2
  "mcpVersion": "1.32.0",
3
3
  "piVersion": "0.4.2",
4
4
  "opencodeVersion": "0.2.2",
5
- "releaseTag": "v1.56.0"
5
+ "releaseTag": "v1.57.0"
6
6
  }
package/dist/cli.js CHANGED
@@ -3189,6 +3189,7 @@ function addDoctorCommand(program, io, doctor) {
3189
3189
  // Not `requiredOption`: commander's own usage exit would bypass the JSON envelope (#402).
3190
3190
  .option("--harness <harness>", "the harness to check (agy, claude, codex, grok, opencode, pi)")
3191
3191
  .option("--json", "print the report as JSON")
3192
+ .option("--rescan", "scan for plaintext copies of a keyslot key now, instead of reusing a scan from the last 24 hours")
3192
3193
  .action(async (options) => {
3193
3194
  const harness = String(options.harness ?? "");
3194
3195
  let report;
@@ -3201,7 +3202,7 @@ function addDoctorCommand(program, io, doctor) {
3201
3202
  return;
3202
3203
  }
3203
3204
  try {
3204
- report = await doctor({ harness });
3205
+ report = await doctor({ harness, ...(options.rescan === true ? { rescan: true } : {}) });
3205
3206
  }
3206
3207
  catch (error) {
3207
3208
  writeFailureEnvelope(io, toOperationFailure("agent_board.doctor", error));
package/dist/doctor.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type IdentityHarness, type OperationSuccess, type OsKeychainBackendOptions, type OsKeychainExec, type SessionRecordEntry, type TrustOptions } from "@consiliency/agent-board-client";
2
2
  import { type ClaudePluginSummary, type RegistrationFs, type RegQuery, type RegistrationVerdict, type ScopeContext } from "./doctor_scopes.js";
3
+ import { type ScanBudgets, type StorageRunner } from "./keyslot_status.js";
3
4
  export type DoctorStatus = "pass" | "fail" | "undetermined";
4
5
  export declare const DOCTOR_CHECKS: readonly ["identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime"];
5
6
  export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number];
@@ -25,10 +26,31 @@ export interface PluginReport {
25
26
  version_from: "source" | "cache" | null;
26
27
  auto_update: ClaudePluginSummary["autoUpdate"];
27
28
  }
29
+ /**
30
+ * #552 IF-552-3 — when the plaintext-key scan behind `key_storage` ran, and from where. A report,
31
+ * never a check: it does not change the exit. Present only once status.sh's measurement fields
32
+ * matched doctor's own (a report about another host, user or slot does not say when OUR scan ran).
33
+ * `source`: `fresh` (this doctor scanned), `cached` (a valid scan cache), `setup_run` (setup's
34
+ * in-process result, carried only where the cache could not store it), or `none` with `gave_up`
35
+ * when the scan hit its 30-minute bound.
36
+ */
37
+ export interface KeyStorageScanReport {
38
+ source: "fresh" | "cached" | "setup_run" | "none";
39
+ scanned_at: string | null;
40
+ age_s: number | null;
41
+ duration_s: number | null;
42
+ cache_state: string | null;
43
+ gave_up?: {
44
+ after_s: number;
45
+ folders: number | null;
46
+ };
47
+ }
28
48
  export interface DoctorReport {
29
49
  harness: IdentityHarness;
30
50
  exit: 0 | 1 | 2;
31
51
  checks: Record<DoctorCheckName, DoctorCheck>;
52
+ /** keyslot identities only (IF-552-3). */
53
+ key_storage_scan?: KeyStorageScanReport;
32
54
  /** claude only. */
33
55
  plugin?: PluginReport;
34
56
  /** claude only: wake hooks per required event in doctor's cwd, across settings and plugins. */
@@ -113,7 +135,44 @@ export interface DoctorOptions {
113
135
  machineIdFile?: string;
114
136
  /** key_storage: doctor's own uid. Default `process.getuid()`. */
115
137
  uid?: number;
138
+ /** `--rescan`: phase 2 always runs, as `--scan-cache fresh` (IF-552-3). */
139
+ rescan?: boolean;
140
+ /**
141
+ * SETUP ONLY, never a CLI flag: the key step's FRESH, validated clean scan, carried in-process so
142
+ * setup scans once where the cache could not store it. Used only under IF-552-3's three conditions.
143
+ */
144
+ setupScan?: SetupScan;
145
+ /** key_storage: where the scan notices go. Default `process.stderr`. */
146
+ writeStderr?: (line: string) => void;
147
+ /** key_storage: the status.sh runner and budgets (tests). */
148
+ statusRunner?: StorageRunner;
149
+ statusBudgets?: Partial<ScanBudgets>;
150
+ /** key_storage: the clock the carry's validity window reads, epoch seconds. Default now. */
151
+ nowSeconds?: () => number;
152
+ }
153
+ /** The key step's clean fresh scan, as setup carries it to its own doctor (IF-552-3). */
154
+ export interface SetupScan {
155
+ /** `scan.inputs` of the key step's report: every field must deep-equal phase 1's. */
156
+ inputs: Record<string, unknown>;
157
+ /** The scan object itself (clean: `plaintext_matches` empty). */
158
+ scan: Record<string, unknown>;
159
+ /** The scan's start, epoch seconds. */
160
+ scannedAt: number;
161
+ /** The key step's own cache write, `<state>:<reason>` (e.g. `not_written:write_failed`). */
162
+ cacheWrite: string;
116
163
  }
164
+ /** The IF-552-2 validity window, the same for the carry: `scannedAt ≤ now + 300` and `now − scannedAt < 86400`. */
165
+ export declare const SCAN_CACHE_TTL_S = 86400;
166
+ export declare const SCAN_CACHE_FUTURE_S = 300;
167
+ /**
168
+ * IF-552-3's carry: setup's result serves instead of phase 2 only when (1) phase 1's `scan.inputs`
169
+ * deep-equals the carried inputs, (2) the cache could not serve STRUCTURALLY (phase 1 `unusable`
170
+ * with a structural reason, or `miss:absent` beside a key-step write of `not_written:untrusted_dir`
171
+ * or `write_failed`; `lock_busy` never qualifies: a held lock may be a tombstone being written), and
172
+ * (3) the carried scan is inside the validity window. Reads phase 1's `scan.cache`, never only
173
+ * `exit2_reasons`.
174
+ */
175
+ export declare function carryQualifies(phase1: Record<string, unknown>, carried: SetupScan, now: number): boolean;
117
176
  /**
118
177
  * An error's `code`, only when this repository wrote it: a member of the derived
119
178
  * AUTHORED_CAUSE_CODES that this instance carries under the authored provenance brand — the
@@ -151,6 +210,8 @@ export declare function osKeychainProbe(spawn?: OsKeychainExec, where?: Pick<OsK
151
210
  /** The default agent_socket probe: the loaded key's fingerprint, derived from the agent's identity list. */
152
211
  export declare const agentSocketProbe: KeystoreProbe;
153
212
  export declare const OS_KEYCHAIN_PRESENT = "present; key_id proven server-side by whoami; file fingerprint unverified for os_keychain";
213
+ /** Every exit-2 cause status.sh can emit (KSC-8 derives the list from status.sh and probe.sh). */
214
+ export declare const STATUS_EXIT2_REASON: RegExp;
154
215
  interface FileIdentity {
155
216
  handle: string;
156
217
  fingerprint: string;
package/dist/doctor.js CHANGED
@@ -9,14 +9,14 @@
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 } from "node:fs";
15
14
  import { homedir, userInfo } from "node:os";
16
15
  import { join, resolve } from "node:path";
17
16
  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
17
  import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
19
18
  import { resolveKeyslotDir } from "./assets.js";
19
+ import { cacheStateOf, measureKeyslot, progressText, scanOf, scanSourceOf, STATUS_SCAN_TIMEOUT_MS, } from "./keyslot_status.js";
20
20
  import { SLOT_RE, externalDeclarationProblems, fileIdentity, keyslotSocketDir, machineIdOf, resolveAgentSocketPath, unsealedOptinProblems, } from "./setup_storage.js";
21
21
  export const DOCTOR_CHECKS = Object.freeze([
22
22
  "identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime",
@@ -63,6 +63,42 @@ function pinCheck(verdict, entries, cwd, fs) {
63
63
  return { ...verdict, status: "fail", reason: [...earlier, "plugin_pin_mismatch"].join(",") };
64
64
  return { ...verdict, status: verdict.status === "fail" ? "fail" : "undetermined", reason: [...earlier, "plugin_pin_ambiguous"].join(",") };
65
65
  }
66
+ /** The IF-552-2 validity window, the same for the carry: `scannedAt ≤ now + 300` and `now − scannedAt < 86400`. */
67
+ export const SCAN_CACHE_TTL_S = 86_400;
68
+ export const SCAN_CACHE_FUTURE_S = 300;
69
+ /** Cache states that are STRUCTURAL: the cache cannot serve here at all. `lock_busy` never is. */
70
+ const STRUCTURAL_UNUSABLE = /^(untrusted:.+|state_root_relative|machine_id_unreadable)$/;
71
+ const UNWRITABLE_WRITE = new Set(["not_written:untrusted_dir", "not_written:write_failed"]);
72
+ const sameJson = (a, b) => {
73
+ if (a === b)
74
+ return true;
75
+ if (a === null || b === null || typeof a !== "object" || typeof b !== "object")
76
+ return false;
77
+ if (Array.isArray(a) !== Array.isArray(b))
78
+ return false;
79
+ const ka = Object.keys(a);
80
+ const kb = Object.keys(b);
81
+ return ka.length === kb.length && ka.every((k) => sameJson(a[k], b[k]));
82
+ };
83
+ /**
84
+ * IF-552-3's carry: setup's result serves instead of phase 2 only when (1) phase 1's `scan.inputs`
85
+ * deep-equals the carried inputs, (2) the cache could not serve STRUCTURALLY (phase 1 `unusable`
86
+ * with a structural reason, or `miss:absent` beside a key-step write of `not_written:untrusted_dir`
87
+ * or `write_failed`; `lock_busy` never qualifies: a held lock may be a tombstone being written), and
88
+ * (3) the carried scan is inside the validity window. Reads phase 1's `scan.cache`, never only
89
+ * `exit2_reasons`.
90
+ */
91
+ export function carryQualifies(phase1, carried, now) {
92
+ const inputs = scanOf(phase1).inputs;
93
+ if (!inputs || typeof inputs !== "object" || !sameJson(inputs, carried.inputs))
94
+ return false;
95
+ const { state, reason } = cacheStateOf(phase1);
96
+ const structural = (state === "unusable" && STRUCTURAL_UNUSABLE.test(reason))
97
+ || (state === "miss" && reason === "absent" && UNWRITABLE_WRITE.has(carried.cacheWrite));
98
+ if (!structural)
99
+ return false;
100
+ return carried.scannedAt <= now + SCAN_CACHE_FUTURE_S && now - carried.scannedAt < SCAN_CACHE_TTL_S;
101
+ }
66
102
  /** The binary's managed-settings directory per platform (`Zh()`, 2.1.283). */
67
103
  function defaultClaudeManagedDir(platform) {
68
104
  if (platform === "darwin")
@@ -273,17 +309,8 @@ async function keystoreCheck(values, env, probe) {
273
309
  * - status.sh absent, unrunnable, timed out or unparseable → 2.
274
310
  */
275
311
  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
- }
312
+ /** Every exit-2 cause status.sh can emit (KSC-8 derives the list from status.sh and probe.sh). */
313
+ 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
314
  /** The keyslot whose socket is the same file as `socket`, or null. */
288
315
  function matchKeyslot(socket, env) {
289
316
  const dir = keyslotSocketDir(env);
@@ -309,7 +336,18 @@ function matchKeyslot(socket, env) {
309
336
  }
310
337
  return null;
311
338
  }
339
+ /** A keyslot socket's shape: `<dir>/agent-board/<slot>.sock`, the slot a name the keyslot scripts accept. */
340
+ function keyslotShaped(socket) {
341
+ const match = /\/agent-board\/([^/]+)\.sock$/.exec(socket);
342
+ return match !== null && SLOT_RE.test(match[1]);
343
+ }
344
+ const isoOf = (value) => (typeof value === "string" ? value : null);
345
+ const numOf = (value) => (typeof value === "number" && Number.isFinite(value) ? value : null);
312
346
  async function keyStorageCheck(harness, values, fileEnv, env, options) {
347
+ const judged = await keyStorageJudge(harness, values, fileEnv, env, options);
348
+ return "check" in judged ? judged : { check: judged };
349
+ }
350
+ async function keyStorageJudge(harness, values, fileEnv, env, options) {
313
351
  let reference;
314
352
  try {
315
353
  reference = normalizeKeyReference(values[KEY_REFERENCE_KEY]);
@@ -333,6 +371,10 @@ async function keyStorageCheck(harness, values, fileEnv, env, options) {
333
371
  const problems = externalDeclarationProblems(env, socket, uid, machineId);
334
372
  if (problems.length === 0)
335
373
  return check("pass", "external");
374
+ // #552: with no runtime dir the keyslot cannot be located at all. Only a socket of the keyslot's
375
+ // own shape says so; any other undeclared socket stays external_undeclared.
376
+ if (keyslotSocketDir(env) === null && keyslotShaped(socket))
377
+ return check("undetermined", "runtime_dir_unset");
336
378
  return check("undetermined", problems.includes("missing") ? "external_undeclared" : "external_declaration_mismatch");
337
379
  }
338
380
  let dir;
@@ -342,21 +384,54 @@ async function keyStorageCheck(harness, values, fileEnv, env, options) {
342
384
  catch {
343
385
  return check("undetermined", "status_absent");
344
386
  }
345
- if (!existsSync(join(dir, "status.sh")))
387
+ const writeStderr = options.writeStderr ?? ((line) => { process.stderr.write(`${line}\n`); });
388
+ const now = options.nowSeconds ?? (() => Math.floor(Date.now() / 1000));
389
+ let carried = false;
390
+ const measured = await measureKeyslot({
391
+ dir, harness, slot, env, scan: options.rescan ? "fresh" : "use", onFailure: "stop", run: options.statusRunner, budgets: options.statusBudgets,
392
+ skipScan: (phase1) => {
393
+ carried = options.setupScan !== undefined && carryQualifies(phase1, options.setupScan, now());
394
+ return carried;
395
+ },
396
+ notify: (event) => {
397
+ if (event.kind === "before") {
398
+ const { state, reason } = cacheStateOf(event.phase1);
399
+ const why = options.rescan ? "rescan requested" : `${state}${reason ? `:${reason}` : ""}` || "no cache";
400
+ writeStderr(`doctor: key_storage: scanning for plaintext copies of the key (${why}); this can take several minutes on a large home`);
401
+ }
402
+ else if (event.kind === "progress") {
403
+ writeStderr(`doctor: key_storage: ${progressText(event.folders)}`);
404
+ }
405
+ },
406
+ });
407
+ if (measured.absent)
346
408
  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)
409
+ const scanPhase = measured.phases.find((phase) => phase.mode !== "only");
410
+ const phase1 = measured.phases.find((phase) => phase.mode === "only");
411
+ // A phase 1 that did not finish is status_did_not_complete; a SCAN that did not is scan_timed_out.
412
+ if (phase1 && phase1.code === null)
349
413
  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;
414
+ // Only the helper's own budget is a timeout. Any other end of the scan without a report (SIGKILL:
415
+ // code null; TERM/INT, which status.sh traps: 143/130 with no JSON) is undetermined
416
+ // status_unreadable, the same for every signal: doctor could not finish checking, and AC-7 keeps
417
+ // doctor's key_storage reasons (#552 review r2). Setup, which must not enroll, refuses instead.
418
+ const budgetS = Math.round((options.statusBudgets?.scanMs ?? STATUS_SCAN_TIMEOUT_MS) / 1000);
419
+ // The helper's own scan budget ran out: scan_timed_out at once. Phase 1's report is never judged in
420
+ // its place (#552 review r2, gemini): it answers a different question (the cache), and judging it
421
+ // turned a timeout into whatever phase 1 said.
422
+ if (scanPhase !== undefined && scanPhase.timedOut) {
423
+ return {
424
+ check: check("undetermined", "scan_timed_out"),
425
+ scan: { source: "none", scanned_at: null, age_s: null, duration_s: null, cache_state: null, gave_up: { after_s: budgetS, folders: measured.folders } },
426
+ };
356
427
  }
357
- catch {
428
+ if (scanPhase !== undefined && scanPhase.code === null)
429
+ return check("undetermined", "status_unreadable");
430
+ if (scanPhase === undefined && measured.code === null)
431
+ return check("undetermined", "status_did_not_complete");
432
+ const report = measured.report;
433
+ if (report === null)
358
434
  return check("undetermined", "status_unreadable");
359
- }
360
435
  const seam = typeof env.KEYSEAL_TEST_ROOT === "string" && env.KEYSEAL_TEST_ROOT !== "";
361
436
  const own = {
362
437
  machine_id: (value) => machineId !== null && value === machineId,
@@ -371,24 +446,58 @@ async function keyStorageCheck(harness, values, fileEnv, env, options) {
371
446
  if (!matches(report[field]))
372
447
  return check("undetermined", `status_field_mismatch:${field}`);
373
448
  }
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 : [];
449
+ // The report is about this host, user and slot: now it may say when OUR scan ran.
450
+ const scanReport = (source, of) => {
451
+ const scan = scanOf(of);
452
+ const { state, reason } = cacheStateOf(of);
453
+ return {
454
+ source, scanned_at: isoOf(scan.scanned_at), age_s: numOf(scan.age_s), duration_s: numOf(scan.duration_s),
455
+ cache_state: state === "" ? null : `${state}${reason ? `:${reason}` : ""}`,
456
+ };
457
+ };
458
+ const gaveUp = () => ({
459
+ check: check("undetermined", "scan_timed_out"),
460
+ scan: { ...scanReport("none", report), gave_up: { after_s: budgetS, folders: measured.folders } },
461
+ });
462
+ const code = measured.code;
463
+ const reasons = Array.isArray(report.exit2_reasons) ? report.exit2_reasons : [];
464
+ let scan;
465
+ let effectiveCode = code;
466
+ if (carried && options.setupScan) {
467
+ // Setup's result stands in for phase 2: scan_not_cached is satisfied by it.
468
+ scan = {
469
+ source: "setup_run", scanned_at: new Date(options.setupScan.scannedAt * 1000).toISOString().replace(/\.\d{3}Z$/, "Z"),
470
+ age_s: now() - options.setupScan.scannedAt, duration_s: numOf(options.setupScan.scan.duration_s), cache_state: scanReport("none", report).cache_state,
471
+ };
472
+ if (code === 2 && reasons.length === 1 && reasons[0] === "scan_not_cached")
473
+ effectiveCode = 0;
474
+ }
475
+ else {
476
+ const source = scanSourceOf(report);
477
+ if (source !== "none")
478
+ scan = scanReport(source, report);
479
+ }
480
+ const withScan = (result) => (scan ? { check: result, scan } : { check: result });
481
+ if (effectiveCode === 1)
482
+ return withScan(check("fail", "keyslot_status_failed"));
483
+ if (effectiveCode === 2) {
484
+ // status.sh's own self-bound: the scan gave up, and nothing else is undetermined.
485
+ if (scanPhase !== undefined && reasons.length === 1 && reasons[0] === "scan_timed_out")
486
+ return gaveUp();
378
487
  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");
488
+ return withScan(check("undetermined", named ? `keyslot_status_undetermined:${reasons.join(",")}` : "keyslot_status_undetermined"));
380
489
  }
381
- if (run.code !== 0)
382
- return check("undetermined", "status_unexpected_exit");
490
+ if (effectiveCode !== 0)
491
+ return withScan(check("undetermined", "status_unexpected_exit"));
383
492
  if (report.mode === "sealed")
384
- return check("pass", "sealed");
493
+ return withScan(check("pass", "sealed"));
385
494
  if (report.mode === "unsealed") {
386
495
  const optin = report.optin;
387
496
  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");
497
+ return withScan(check("fail", "unsealed_optin_invalid"));
498
+ return withScan(unsealedOptinProblems(env, slot, uid, machineId).length === 0 ? check("pass", "unsealed") : check("fail", "unsealed_optin_invalid"));
390
499
  }
391
- return check("undetermined", "status_mode_unexpected");
500
+ return withScan(check("undetermined", "status_mode_unexpected"));
392
501
  }
393
502
  async function whoamiCheck(client, expectedHandle) {
394
503
  let rows;
@@ -494,6 +603,7 @@ export async function runDoctor(options) {
494
603
  const cwd = options.cwd ?? process.cwd();
495
604
  const trust = options.trust ?? {};
496
605
  const checks = {};
606
+ let keyStorageScan;
497
607
  let values = null;
498
608
  try {
499
609
  values = (await readIdentityFile({ ...trust, harness, env })).values;
@@ -511,7 +621,9 @@ export async function runDoctor(options) {
511
621
  else {
512
622
  const fileEnv = fileIdentityEnv(env, values);
513
623
  checks.keystore_key = await keystoreCheck(values, fileEnv, options.probeKeystore ?? defaultKeystoreProbe);
514
- checks.key_storage = await keyStorageCheck(harness, values, fileEnv, env, options);
624
+ const storage = await keyStorageCheck(harness, values, fileEnv, env, options);
625
+ checks.key_storage = storage.check;
626
+ keyStorageScan = storage.scan;
515
627
  let client = null;
516
628
  try {
517
629
  client = (options.createClient ?? defaultClient)(fileEnv);
@@ -570,6 +682,8 @@ export async function runDoctor(options) {
570
682
  const statuses = Object.values(ordered).map((item) => item.status);
571
683
  const exit = statuses.every((status) => status === "pass") ? 0 : statuses.includes("fail") ? 1 : 2;
572
684
  const report = { harness, exit, checks: ordered };
685
+ if (keyStorageScan)
686
+ report.key_storage_scan = keyStorageScan;
573
687
  const here = verdicts[0];
574
688
  if (harness === "claude") {
575
689
  const plugin = here.plugin;
@@ -594,6 +708,16 @@ export async function runDoctor(options) {
594
708
  }
595
709
  return report;
596
710
  }
711
+ /** An age in seconds, as a short human span. */
712
+ function ago(seconds) {
713
+ if (seconds === null)
714
+ return "?";
715
+ if (seconds < 120)
716
+ return `${seconds} s`;
717
+ if (seconds < 7200)
718
+ return `${Math.floor(seconds / 60)} min`;
719
+ return `${Math.floor(seconds / 3600)} h`;
720
+ }
597
721
  /** Human-readable form; `--json` prints the report object itself. */
598
722
  export function formatDoctorReport(report) {
599
723
  const lines = DOCTOR_CHECKS.map((name) => `${report.checks[name].status.padEnd(12)} ${name}: ${report.checks[name].reason}`);
@@ -610,6 +734,23 @@ export function formatDoctorReport(report) {
610
734
  counts ? `hooks SessionStart=${counts.SessionStart} FileChanged=${counts.FileChanged}` : "hooks undetermined",
611
735
  ].join(" "));
612
736
  }
737
+ const scan = report.key_storage_scan;
738
+ if (scan) {
739
+ const rescan = `\`agent-board doctor --harness ${report.harness} --rescan\``;
740
+ if (scan.gave_up) {
741
+ 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`;
742
+ lines.push(`${"scan".padEnd(12)} gave up after ${after} (${scan.gave_up.folders ?? "?"} folders so far); ${rescan} retries`);
743
+ }
744
+ else if (scan.source === "fresh") {
745
+ lines.push(`${"scan".padEnd(12)} fresh (${scan.duration_s ?? "?"} s)${report.checks.key_storage.status === "pass" ? ", no plaintext copy found" : ""}`);
746
+ }
747
+ else if (scan.source === "setup_run") {
748
+ lines.push(`${"scan".padEnd(12)} setup's scan at ${scan.scanned_at ?? "?"} (${ago(scan.age_s)} ago), no plaintext copy found`);
749
+ }
750
+ else if (scan.source === "cached") {
751
+ lines.push(`${"scan".padEnd(12)} cached at ${scan.scanned_at ?? "?"} (${ago(scan.age_s)} ago); ${rescan} scans again`);
752
+ }
753
+ }
613
754
  if (report.managed_policy) {
614
755
  const policy = report.managed_policy;
615
756
  lines.push([
@@ -0,0 +1,117 @@
1
+ /** The status budget: phase 1 (`--scan-cache only`) and every call that never scans. */
2
+ export declare const STATUS_TIMEOUT_MS = 180000;
3
+ /** The scan budget (owner answer 2): phase 2, and `status.sh --scan-timeout` in seconds. */
4
+ export declare const STATUS_SCAN_TIMEOUT_MS = 1800000;
5
+ /**
6
+ * The helper's own kill fires this long after the scan budget: a backstop, never the bound. It must
7
+ * outlast everything `status.sh` legitimately does AFTER its stage's deadline: the stage's
8
+ * `--kill-after` grace, and a found copy's tombstone waiting out a held cache lock (#552 review r1: a
9
+ * shorter backstop turned a found copy into `scan_timed_out` with no tombstone). KSC-13 derives the
10
+ * floor from lib.sh.
11
+ */
12
+ export declare const SCAN_TOMBSTONE_WAIT_MS = 300000;
13
+ export declare const SCAN_KILL_AFTER_MS = 10000;
14
+ export declare const SCAN_LOCK_WAIT_MS = 10000;
15
+ /**
16
+ * The waits status.sh may spend outside its stage's deadline that lib.sh bounds: the start read's
17
+ * lock wait, the stage's `--kill-after` grace and status.sh's own group reap (each up to
18
+ * kill-after), then the longest commit (a tombstone's lock wait) (#552 review r1b: the budget must
19
+ * never cut a found copy's tombstone wait short). The checks status.sh runs BEFORE the scan (probe,
20
+ * units, chain, agent, the blob's decrypt) have no bound of their own here: only `marginMs` covers
21
+ * them (#552 review r2). KSC-13 derives the waits from lib.sh; SC-20 runs the formula scaled.
22
+ */
23
+ export declare function scanBackstopMs(waits: {
24
+ lockWaitMs: number;
25
+ killAfterMs: number;
26
+ tombstoneWaitMs: number;
27
+ marginMs: number;
28
+ }): number;
29
+ export declare const SCAN_BACKSTOP_MS: number;
30
+ export interface RunOutcome {
31
+ /** null when the child did not exit on its own (killed, timed out, not spawned). */
32
+ code: number | null;
33
+ stdout: string;
34
+ stderr: string;
35
+ /** The runner's own timeout fired. */
36
+ timedOut?: boolean;
37
+ }
38
+ export interface RunOptions {
39
+ env: NodeJS.ProcessEnv;
40
+ /** Default `STATUS_TIMEOUT_MS`. */
41
+ timeoutMs?: number;
42
+ /** Each complete stderr line, as it arrives (progress relay). */
43
+ onStderrLine?: (line: string) => void;
44
+ }
45
+ export type StorageRunner = (file: string, args: readonly string[], options: RunOptions) => Promise<RunOutcome>;
46
+ /**
47
+ * The default runner: detached (its own process group), stdout collected, stderr relayed line by line
48
+ * AS IT ARRIVES, and the whole group killed on the timeout or when this process exits or is
49
+ * interrupted. A signal handler removes itself and re-raises, so the process still dies by it.
50
+ */
51
+ export declare const defaultStorageRunner: StorageRunner;
52
+ export type ScanMode = "use" | "only" | "fresh";
53
+ export interface ScanBudgets {
54
+ statusMs: number;
55
+ scanMs: number;
56
+ backstopMs: number;
57
+ }
58
+ export declare const DEFAULT_BUDGETS: ScanBudgets;
59
+ export interface KeyslotPhase {
60
+ mode: ScanMode;
61
+ code: number | null;
62
+ timedOut: boolean;
63
+ /** The parsed JSON object, or null when stdout was not one. */
64
+ report: Record<string, unknown> | null;
65
+ }
66
+ /** What a caller is told while a scan runs. The caller words it (setup: `key: `, doctor: `doctor: key_storage: `). */
67
+ export type ScanEvent = {
68
+ kind: "before";
69
+ phase1: Record<string, unknown> | null;
70
+ } | {
71
+ kind: "progress";
72
+ folders: number;
73
+ } | {
74
+ kind: "result";
75
+ phase: KeyslotPhase;
76
+ };
77
+ export interface KeyslotMeasurement {
78
+ /** `status.sh` is not in the packaged directory (doctor: `status_absent`). */
79
+ absent: boolean;
80
+ /** The last phase's exit code (null: it did not exit on its own). */
81
+ code: number | null;
82
+ /** The last phase's report (phase 2's when it ran, else phase 1's). */
83
+ report: Record<string, unknown> | null;
84
+ phases: KeyslotPhase[];
85
+ /** The SCAN phase (phase 2, or a `fresh` call) hit the helper's backstop. */
86
+ timedOut: boolean;
87
+ /** The last `still scanning` count relayed, if any. */
88
+ folders: number | null;
89
+ }
90
+ export interface MeasureOptions {
91
+ dir: string;
92
+ harness: string;
93
+ slot: string;
94
+ env: NodeJS.ProcessEnv;
95
+ scan: ScanMode;
96
+ /** `stop` (doctor): a phase-1 exit 1 returns without phase 2. `scan` (setup): phase 2 still runs. Default `stop`. */
97
+ onFailure?: "stop" | "scan";
98
+ notify?: (event: ScanEvent) => void;
99
+ budgets?: Partial<ScanBudgets>;
100
+ run?: StorageRunner;
101
+ /** Doctor's carry: return true to skip phase 2 on this phase-1 report (setup's in-process result serves). */
102
+ skipScan?: (phase1: Record<string, unknown>) => boolean;
103
+ }
104
+ export declare const PROGRESS_LINE: RegExp;
105
+ /** The relayed progress text, before the caller's prefix. */
106
+ export declare const progressText: (folders: number) => string;
107
+ /** `scan` of a status report, or an empty object. */
108
+ export declare function scanOf(report: Record<string, unknown> | null | undefined): Record<string, unknown>;
109
+ /** `scan.source`: `fresh`, `cached` or `none`; anything else (an older status.sh, no scan at all) reads `none`. */
110
+ export declare function scanSourceOf(report: Record<string, unknown> | null | undefined): "fresh" | "cached" | "none";
111
+ /** `scan.cache` as `{state, reason}` strings (reason "" when null). */
112
+ export declare function cacheStateOf(report: Record<string, unknown> | null | undefined): {
113
+ state: string;
114
+ reason: string;
115
+ };
116
+ /** IF-552-3: measure a keyslot in up to two phases. Never throws for a child's failure. */
117
+ export declare function measureKeyslot(options: MeasureOptions): Promise<KeyslotMeasurement>;