@consiliency/agent-board-cli 1.27.0 → 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/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +2 -2
- package/dist/assets.json +2 -2
- package/dist/cli.js +2 -1
- package/dist/doctor.d.ts +61 -0
- package/dist/doctor.js +177 -36
- package/dist/keyslot_status.d.ts +117 -0
- package/dist/keyslot_status.js +242 -0
- package/dist/setup.js +59 -13
- package/dist/setup_storage.d.ts +35 -10
- package/dist/setup_storage.js +97 -16
- package/keyslot/lib.sh +793 -37
- package/keyslot/status.sh +115 -14
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-board",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.32.0",
|
|
4
4
|
"description": "Message Board for Claude Code: the agent-board MCP server and the local wake hook that nudges an idle session when its inbox has unhandled items.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Consiliency"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"command": "npx",
|
|
11
11
|
"args": [
|
|
12
12
|
"-y",
|
|
13
|
-
"@consiliency/agent-board-mcp@1.
|
|
13
|
+
"@consiliency/agent-board-mcp@1.32.0",
|
|
14
14
|
"--harness",
|
|
15
15
|
"claude"
|
|
16
16
|
]
|
package/dist/assets.json
CHANGED
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 {
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
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
|
|
348
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
const
|
|
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 (
|
|
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
|
-
|
|
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>;
|