@consiliency/agent-board-cli 1.22.5 → 1.24.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/cli.d.ts +5 -2
- package/dist/cli.js +201 -17
- package/dist/doctor.d.ts +141 -0
- package/dist/doctor.js +453 -0
- package/dist/doctor_scopes.d.ts +288 -0
- package/dist/doctor_scopes.js +1535 -0
- package/dist/enroll_runtime.js +5 -13
- package/dist/identity_writer.d.ts +44 -0
- package/dist/identity_writer.js +186 -0
- package/package.json +2 -2
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
// CREDPATH IF-0-CREDPATH-4 — `agent-board doctor --harness <h>`, as a module. SL-6 registers it.
|
|
2
|
+
//
|
|
3
|
+
// Doctor judges ONE identity: the harness's identity FILE, read through the client's trusted
|
|
4
|
+
// open (`readIdentityFile`), never doctor's own shell environment. Every networked check is bound
|
|
5
|
+
// to that file's handle and fingerprint through a client whose credentials resolve from the
|
|
6
|
+
// file alone. Records written by live harness processes are judged against the same identity.
|
|
7
|
+
//
|
|
8
|
+
// Exit 0 iff every check passes; else 1 if any check fails; else 2. The `runtime` check passes
|
|
9
|
+
// only with a live `wake_producer` at its expected tier, so 0 is never returned without runtime
|
|
10
|
+
// evidence. Output is names and codes only: no identity value, key or path content is printed,
|
|
11
|
+
// and an error's `code` is printed only when this repository authored it (`authoredCode`).
|
|
12
|
+
import { randomBytes } from "node:crypto";
|
|
13
|
+
import { homedir, userInfo } from "node:os";
|
|
14
|
+
import { resolve } from "node:path";
|
|
15
|
+
import { AUTHORED_CAUSE_CODES, AgentSocketSelectionError, GENERIC_SUPABASE_FIELDS, IDENTITY_FILE_KEYS, IDENTITY_TRIGGER_FIELDS, KeystoreUnavailableError, assertIdentityHarness, codeProvenance, createAgentBoardClient, createOsKeychainBackend, expectedWakeTier, normalizeKeyReference, readIdentityFile, readSessionRecords, resolveCredentials, selectAgentSocketIdentity, } from "@consiliency/agent-board-client";
|
|
16
|
+
import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
|
|
17
|
+
export const DOCTOR_CHECKS = Object.freeze([
|
|
18
|
+
"identity_file", "keystore_key", "whoami", "board_membership", "registration", "runtime",
|
|
19
|
+
]);
|
|
20
|
+
export const REPORTABLE_TOKEN = /^[A-Za-z0-9._/-]{1,64}$/;
|
|
21
|
+
const reportable = (value) => value === null ? null : REPORTABLE_TOKEN.test(value) ? value : "unrecognised";
|
|
22
|
+
/**
|
|
23
|
+
* The pin, over the live wake_producers whose cwd (realpath) is doctor's cwd, and only while our
|
|
24
|
+
* plugin's server is registered there. All at the pin (our plugin's version) → unchanged; NONE at
|
|
25
|
+
* it → `plugin_pin_mismatch`; a MIXED set → `plugin_pin_ambiguous` (which one is ours is unknown).
|
|
26
|
+
* A record elsewhere was launched by whatever registration applies THERE. An earlier reason is
|
|
27
|
+
* kept and this one appended.
|
|
28
|
+
*/
|
|
29
|
+
function pinCheck(verdict, entries, cwd, fs) {
|
|
30
|
+
// Only while our plugin's server is registered here: one turned off by `disabledMcpServers`
|
|
31
|
+
// launched nothing whose version could be judged (panel S1).
|
|
32
|
+
const pin = verdict.plugin?.installed && verdict.plugin.serverActive ? verdict.plugin.version : null;
|
|
33
|
+
if (pin === null)
|
|
34
|
+
return verdict;
|
|
35
|
+
// Real paths on both sides (panel S2): a server records its physical cwd; doctor may be in a symlink.
|
|
36
|
+
const real = (path) => fs.realpath(path) ?? resolve(path);
|
|
37
|
+
const here = entries.flatMap((entry) => entry.status === "live" && entry.record.role === "wake_producer"
|
|
38
|
+
&& real(entry.record.cwd) === real(cwd) ? [entry.record] : []);
|
|
39
|
+
const atPin = here.filter((record) => record.packageVersion === pin).length;
|
|
40
|
+
// All at the pin → nothing to say; none → a mismatch; a mixed set → which one is ours is unknown (panel B1).
|
|
41
|
+
if (atPin === here.length)
|
|
42
|
+
return verdict;
|
|
43
|
+
// An earlier reason is kept, never overwritten (panel S5).
|
|
44
|
+
const earlier = verdict.status === "pass" ? [] : [verdict.reason];
|
|
45
|
+
if (atPin === 0)
|
|
46
|
+
return { ...verdict, status: "fail", reason: [...earlier, "plugin_pin_mismatch"].join(",") };
|
|
47
|
+
return { ...verdict, status: verdict.status === "fail" ? "fail" : "undetermined", reason: [...earlier, "plugin_pin_ambiguous"].join(",") };
|
|
48
|
+
}
|
|
49
|
+
/** The binary's managed-settings directory per platform (`Zh()`, 2.1.283). */
|
|
50
|
+
function defaultClaudeManagedDir(platform) {
|
|
51
|
+
if (platform === "darwin")
|
|
52
|
+
return "/Library/Application Support/ClaudeCode";
|
|
53
|
+
if (platform === "win32")
|
|
54
|
+
return "C:\\Program Files\\ClaudeCode";
|
|
55
|
+
return "/etc/claude-code";
|
|
56
|
+
}
|
|
57
|
+
const FINGERPRINT_KEY = "AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT";
|
|
58
|
+
const HANDLE_KEY = "AGENT_BOARD_EXPECTED_AGENT_HANDLE";
|
|
59
|
+
const KEY_REFERENCE_KEY = "AGENT_BOARD_AGENT_KEY_REFERENCE";
|
|
60
|
+
if (![FINGERPRINT_KEY, HANDLE_KEY, KEY_REFERENCE_KEY].every((key) => IDENTITY_FILE_KEYS.includes(key))) {
|
|
61
|
+
throw new Error("doctor: an identity key it reads is not in IDENTITY_FILE_KEYS");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* An error's `code`, only when this repository wrote it: a member of the derived
|
|
65
|
+
* AUTHORED_CAUSE_CODES that this instance carries under the authored provenance brand — the
|
|
66
|
+
* CLI's `namedCauseCode` gate (#403). Anything else — a code copied from a /session or PostgREST
|
|
67
|
+
* body, an errno, an unbranded error — is replaced by `fallback`, a fixed local classification.
|
|
68
|
+
* A shape test cannot do this: a server may put any well-shaped token in `code` (code review
|
|
69
|
+
* round 1, Astra 4: `sb_secret_…` passed the previous `^[A-Za-z0-9_.:-]{1,80}$` filter).
|
|
70
|
+
*/
|
|
71
|
+
export function authoredCode(error, fallback) {
|
|
72
|
+
const value = error?.code;
|
|
73
|
+
if (typeof value === "string" && AUTHORED_CAUSE_CODES.has(value) && codeProvenance(error) === "authored")
|
|
74
|
+
return value;
|
|
75
|
+
return fallback;
|
|
76
|
+
}
|
|
77
|
+
const codeOf = authoredCode;
|
|
78
|
+
/** A refusal the service marks retryable (5xx, 429, …) is about the service, not the identity. */
|
|
79
|
+
function isRetryable(error) {
|
|
80
|
+
let current = error;
|
|
81
|
+
for (let depth = 0; current && depth < 5; depth += 1) {
|
|
82
|
+
if (current.retryable === true)
|
|
83
|
+
return true;
|
|
84
|
+
current = current.cause;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
// Transport failures, by the errno/undici codes and error names Node's fetch surfaces. These
|
|
89
|
+
// say nothing about the identity, so they are undetermined; any other refusal is a fail.
|
|
90
|
+
const NETWORK_CODES = new Set([
|
|
91
|
+
"ECONNREFUSED", "ECONNRESET", "ENOTFOUND", "EAI_AGAIN", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH", "EPIPE",
|
|
92
|
+
"UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT", "UND_ERR_BODY_TIMEOUT", "UND_ERR_SOCKET", "UND_ERR_CLOSED",
|
|
93
|
+
]);
|
|
94
|
+
export function isNetworkError(error) {
|
|
95
|
+
let current = error;
|
|
96
|
+
for (let depth = 0; current && depth < 5; depth += 1) {
|
|
97
|
+
const candidate = current;
|
|
98
|
+
if (typeof candidate.code === "string" && NETWORK_CODES.has(candidate.code))
|
|
99
|
+
return true;
|
|
100
|
+
if (candidate.name === "AbortError" || candidate.name === "TimeoutError")
|
|
101
|
+
return true;
|
|
102
|
+
if (candidate.name === "TypeError" && candidate.message === "fetch failed")
|
|
103
|
+
return true;
|
|
104
|
+
current = candidate.cause;
|
|
105
|
+
}
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The session mint signs with the identity's key, so a keystore that cannot be reached fails
|
|
110
|
+
* `whoami` too. That says nothing about the identity: undetermined, like `keystore_key`.
|
|
111
|
+
*/
|
|
112
|
+
export function isKeystoreUnreachable(error) {
|
|
113
|
+
let current = error;
|
|
114
|
+
for (let depth = 0; current && depth < 5; depth += 1) {
|
|
115
|
+
if (current instanceof KeystoreUnavailableError)
|
|
116
|
+
return true;
|
|
117
|
+
if (current instanceof AgentSocketSelectionError && current.reason === "unreachable")
|
|
118
|
+
return true;
|
|
119
|
+
const code = current.code;
|
|
120
|
+
if (code === "keystore_unavailable" || code === "agent_socket_unreachable")
|
|
121
|
+
return true;
|
|
122
|
+
current = current.cause;
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
function rowsOf(result) {
|
|
127
|
+
const data = result?.data;
|
|
128
|
+
return Array.isArray(data) ? data.filter((row) => !!row && typeof row === "object") : [];
|
|
129
|
+
}
|
|
130
|
+
/** The ambient env minus every identity and generic name, plus the file: the file is the identity. */
|
|
131
|
+
export function fileIdentityEnv(ambient, values) {
|
|
132
|
+
const drop = new Set([...IDENTITY_TRIGGER_FIELDS, ...GENERIC_SUPABASE_FIELDS]);
|
|
133
|
+
const env = {};
|
|
134
|
+
for (const [name, value] of Object.entries(ambient))
|
|
135
|
+
if (!drop.has(name))
|
|
136
|
+
env[name] = value;
|
|
137
|
+
return { ...env, ...values };
|
|
138
|
+
}
|
|
139
|
+
function defaultClient(fileEnv) {
|
|
140
|
+
const client = createAgentBoardClient({ resolveCredentials: () => resolveCredentials(undefined, fileEnv) });
|
|
141
|
+
return {
|
|
142
|
+
whoami: (input) => client.whoami(input),
|
|
143
|
+
boardMemberships: (query) => client.boardMemberships(query),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* The default os_keychain probe. Doctor never holds private-key bytes, so it does not read the
|
|
148
|
+
* item: it asks the client's own backend to SIGN a nonce, exactly as a session mint would. A
|
|
149
|
+
* signature proves the key is present; it does not prove WHICH key, so the result is
|
|
150
|
+
* `present_unverified`.
|
|
151
|
+
*
|
|
152
|
+
* What `whoami` then proves, and what it does not (code review round 1, Opus B1 / Astra 2): the
|
|
153
|
+
* session mint sends `agent_slug`, `key_id`, `key_version` and a signature — never the file's
|
|
154
|
+
* fingerprint — and the server verifies the signature against the key enrolled under that
|
|
155
|
+
* key_id. So a passing `whoami` proves the keychain holds the enrolled key FOR THE FILE'S KEY_ID.
|
|
156
|
+
* It says nothing about the file's `AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT`, which stays
|
|
157
|
+
* unverified for os_keychain; the check's reason says so. By default the backend runs with the
|
|
158
|
+
* client's own exec; `spawn` (the client's exported exec seam) exists only so tests can stand in
|
|
159
|
+
* for the keychain CLI. Nothing here reads its output; doctor.test.ts K-0 pins that no
|
|
160
|
+
* key-decoding API is reachable.
|
|
161
|
+
*/
|
|
162
|
+
export function osKeychainProbe(spawn) {
|
|
163
|
+
return async (keyReference) => {
|
|
164
|
+
const reference = normalizeKeyReference(keyReference);
|
|
165
|
+
try {
|
|
166
|
+
const capability = await createOsKeychainBackend(spawn ? { exec: spawn } : {})(reference);
|
|
167
|
+
await capability.sign(randomBytes(32));
|
|
168
|
+
return { kind: "present_unverified" };
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
// The client raises one code for "no item" and "cannot reach the keychain"; only its
|
|
172
|
+
// message tells them apart (pinned by K-2 against the client's real backend).
|
|
173
|
+
if (error instanceof KeystoreUnavailableError && /no key found/.test(error.message)) {
|
|
174
|
+
return { kind: "absent", reason: "key_absent" };
|
|
175
|
+
}
|
|
176
|
+
return { kind: "unreachable", reason: codeOf(error, "keystore_unavailable") };
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
/** The default agent_socket probe: the loaded key's fingerprint, derived from the agent's identity list. */
|
|
181
|
+
export const agentSocketProbe = async (keyReference, expectedFingerprint, env) => {
|
|
182
|
+
const reference = normalizeKeyReference(keyReference);
|
|
183
|
+
// A `#<pub>` handle pins the key itself; otherwise the file's fingerprint is the only selector,
|
|
184
|
+
// and a match IS the comparison.
|
|
185
|
+
const pinned = { ...reference, publicKeyFingerprint: reference.handle.includes("#") ? null : expectedFingerprint };
|
|
186
|
+
try {
|
|
187
|
+
const selected = await selectAgentSocketIdentity(pinned, { env });
|
|
188
|
+
return { kind: "found", fingerprint: selected.publicKeyFingerprint };
|
|
189
|
+
}
|
|
190
|
+
catch (error) {
|
|
191
|
+
if (error instanceof AgentSocketSelectionError) {
|
|
192
|
+
return error.reason === "unreachable"
|
|
193
|
+
? { kind: "unreachable", reason: codeOf(error, "agent_socket_unreachable") }
|
|
194
|
+
: { kind: "absent", reason: codeOf(error, "agent_socket_no_match") };
|
|
195
|
+
}
|
|
196
|
+
// No socket to ask (`SSH_AUTH_SOCK` unset): nothing was learned about the key.
|
|
197
|
+
if (error instanceof KeystoreUnavailableError)
|
|
198
|
+
return { kind: "unreachable", reason: codeOf(error, "keystore_unavailable") };
|
|
199
|
+
return { kind: "absent", reason: codeOf(error, "key_reference_invalid") };
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const defaultKeystoreProbe = async (keyReference, expectedFingerprint, env) => {
|
|
203
|
+
const resolver = normalizeKeyReference(keyReference).resolver;
|
|
204
|
+
if (resolver === "agent_socket")
|
|
205
|
+
return agentSocketProbe(keyReference, expectedFingerprint, env);
|
|
206
|
+
if (resolver === "os_keychain")
|
|
207
|
+
return osKeychainProbe()(keyReference, expectedFingerprint, env);
|
|
208
|
+
return { kind: "unreachable", reason: `resolver_unsupported:${resolver}` };
|
|
209
|
+
};
|
|
210
|
+
const check = (status, reason) => ({ status, reason });
|
|
211
|
+
export const OS_KEYCHAIN_PRESENT = "present; key_id proven server-side by whoami; file fingerprint unverified for os_keychain";
|
|
212
|
+
async function keystoreCheck(values, env, probe) {
|
|
213
|
+
let result;
|
|
214
|
+
try {
|
|
215
|
+
result = await probe(values[KEY_REFERENCE_KEY], values[FINGERPRINT_KEY], env);
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
return check("undetermined", codeOf(error, "keystore_probe_error"));
|
|
219
|
+
}
|
|
220
|
+
if (result.kind === "unreachable")
|
|
221
|
+
return check("undetermined", result.reason);
|
|
222
|
+
if (result.kind === "absent")
|
|
223
|
+
return check("fail", result.reason);
|
|
224
|
+
// Presence, and the key_id is proven by `whoami`; the file's fingerprint is NOT (see osKeychainProbe).
|
|
225
|
+
if (result.kind === "present_unverified")
|
|
226
|
+
return check("pass", OS_KEYCHAIN_PRESENT);
|
|
227
|
+
return result.fingerprint === values[FINGERPRINT_KEY]
|
|
228
|
+
? check("pass", "fingerprint_matches")
|
|
229
|
+
: check("fail", "fingerprint_mismatch");
|
|
230
|
+
}
|
|
231
|
+
async function whoamiCheck(client, expectedHandle) {
|
|
232
|
+
let rows;
|
|
233
|
+
try {
|
|
234
|
+
rows = rowsOf(await client.whoami({}));
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
if (isNetworkError(error))
|
|
238
|
+
return { check: check("undetermined", "network_error"), agentId: null };
|
|
239
|
+
if (isKeystoreUnreachable(error))
|
|
240
|
+
return { check: check("undetermined", "keystore_unreachable"), agentId: null };
|
|
241
|
+
if (isRetryable(error))
|
|
242
|
+
return { check: check("undetermined", "service_unavailable"), agentId: null };
|
|
243
|
+
return { check: check("fail", codeOf(error, "whoami_refused")), agentId: null };
|
|
244
|
+
}
|
|
245
|
+
const row = rows[0];
|
|
246
|
+
if (!row)
|
|
247
|
+
return { check: check("fail", "no_acting_identity"), agentId: null };
|
|
248
|
+
if (row.handle !== expectedHandle)
|
|
249
|
+
return { check: check("fail", "handle_mismatch"), agentId: null };
|
|
250
|
+
const agentId = typeof row.agent_id === "string" ? row.agent_id : null;
|
|
251
|
+
if (!agentId)
|
|
252
|
+
return { check: check("fail", "no_agent_id"), agentId: null };
|
|
253
|
+
return { check: check("pass", "expected_handle"), agentId };
|
|
254
|
+
}
|
|
255
|
+
const ACTIVE_MEMBERSHIP = new Set(["joined", "subscribed"]);
|
|
256
|
+
async function membershipCheck(client, agentId) {
|
|
257
|
+
let rows;
|
|
258
|
+
try {
|
|
259
|
+
rows = rowsOf(await client.boardMemberships({ agentId, limit: 50 }));
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
if (isNetworkError(error))
|
|
263
|
+
return check("undetermined", "network_error");
|
|
264
|
+
if (isRetryable(error))
|
|
265
|
+
return check("undetermined", "service_unavailable");
|
|
266
|
+
return check("fail", codeOf(error, "membership_read_refused"));
|
|
267
|
+
}
|
|
268
|
+
const active = rows.filter((row) => row.agent_id === agentId && ACTIVE_MEMBERSHIP.has(String(row.membership_state)));
|
|
269
|
+
return active.length > 0 ? check("pass", "member") : check("fail", "no_membership");
|
|
270
|
+
}
|
|
271
|
+
const TIER_RANK = { tier0: 0, wake: 1 };
|
|
272
|
+
/** IF-0-CREDPATH-4 runtime, over already-read records. Exported for its fixtures. */
|
|
273
|
+
export function judgeRuntime(harness, entries, identity) {
|
|
274
|
+
const fails = [];
|
|
275
|
+
const unknowns = [];
|
|
276
|
+
if (entries.some((entry) => entry.status === "untrusted"))
|
|
277
|
+
fails.push("record_untrusted");
|
|
278
|
+
const live = entries.filter((entry) => entry.status === "live")
|
|
279
|
+
.map((entry) => entry.record);
|
|
280
|
+
for (const record of live) {
|
|
281
|
+
if (record.identitySource === "env")
|
|
282
|
+
unknowns.push("identity_source_env");
|
|
283
|
+
else if (identity === null)
|
|
284
|
+
unknowns.push("identity_unknown");
|
|
285
|
+
else if (record.identityHandle !== identity.handle || record.identityFingerprint !== identity.fingerprint)
|
|
286
|
+
fails.push("identity_mismatch");
|
|
287
|
+
}
|
|
288
|
+
const producers = live.filter((record) => record.role === "wake_producer");
|
|
289
|
+
const liveProducer = producers.length > 0;
|
|
290
|
+
for (const record of live) {
|
|
291
|
+
if (record.wake.tier === "arming") {
|
|
292
|
+
unknowns.push("arming");
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (record.role === "wake_producer") {
|
|
296
|
+
if (TIER_RANK[record.wake.tier] < TIER_RANK[expectedWakeTier(harness, record.flags)])
|
|
297
|
+
fails.push("below_expected_tier");
|
|
298
|
+
}
|
|
299
|
+
else if (record.wake.tier !== "tier0") {
|
|
300
|
+
fails.push(`${record.role}_above_tier0`);
|
|
301
|
+
}
|
|
302
|
+
else if (record.role === "tools_only" && !liveProducer) {
|
|
303
|
+
unknowns.push("tools_only_without_producer");
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (!liveProducer)
|
|
307
|
+
unknowns.push("no_live_wake_producer");
|
|
308
|
+
const byHash = new Map();
|
|
309
|
+
for (const record of live) {
|
|
310
|
+
if (record.wake.tier !== "wake" || record.sessionHash === null)
|
|
311
|
+
continue;
|
|
312
|
+
byHash.set(record.sessionHash, (byHash.get(record.sessionHash) ?? 0) + 1);
|
|
313
|
+
}
|
|
314
|
+
if ([...byHash.values()].some((count) => count > 1))
|
|
315
|
+
fails.push("duplicate_wake_server");
|
|
316
|
+
const unique = (list) => [...new Set(list)].join(",");
|
|
317
|
+
if (fails.length > 0)
|
|
318
|
+
return check("fail", unique(fails));
|
|
319
|
+
if (unknowns.length > 0)
|
|
320
|
+
return check("undetermined", unique(unknowns));
|
|
321
|
+
return check("pass", "wake_producer_live");
|
|
322
|
+
}
|
|
323
|
+
function worst(checks) {
|
|
324
|
+
return checks.find((item) => item.status === "fail")
|
|
325
|
+
?? checks.find((item) => item.status === "undetermined")
|
|
326
|
+
?? checks[0];
|
|
327
|
+
}
|
|
328
|
+
export async function runDoctor(options) {
|
|
329
|
+
assertIdentityHarness(options.harness);
|
|
330
|
+
const harness = options.harness;
|
|
331
|
+
const env = options.env ?? process.env;
|
|
332
|
+
const cwd = options.cwd ?? process.cwd();
|
|
333
|
+
const trust = options.trust ?? {};
|
|
334
|
+
const checks = {};
|
|
335
|
+
let values = null;
|
|
336
|
+
try {
|
|
337
|
+
values = (await readIdentityFile({ ...trust, harness, env })).values;
|
|
338
|
+
checks.identity_file = check("pass", "trusted");
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
checks.identity_file = check("fail", codeOf(error, "identity_unreadable"));
|
|
342
|
+
}
|
|
343
|
+
if (values === null) {
|
|
344
|
+
checks.keystore_key = check("undetermined", "no_identity_file");
|
|
345
|
+
checks.whoami = check("undetermined", "no_identity_file");
|
|
346
|
+
checks.board_membership = check("undetermined", "no_identity_file");
|
|
347
|
+
}
|
|
348
|
+
else {
|
|
349
|
+
const fileEnv = fileIdentityEnv(env, values);
|
|
350
|
+
checks.keystore_key = await keystoreCheck(values, fileEnv, options.probeKeystore ?? defaultKeystoreProbe);
|
|
351
|
+
let client = null;
|
|
352
|
+
try {
|
|
353
|
+
client = (options.createClient ?? defaultClient)(fileEnv);
|
|
354
|
+
}
|
|
355
|
+
catch (error) {
|
|
356
|
+
checks.whoami = check("fail", codeOf(error, "client_unavailable"));
|
|
357
|
+
}
|
|
358
|
+
if (client) {
|
|
359
|
+
const who = await whoamiCheck(client, values[HANDLE_KEY]);
|
|
360
|
+
checks.whoami = who.check;
|
|
361
|
+
checks.board_membership = who.agentId === null
|
|
362
|
+
? check("undetermined", "whoami_not_passed")
|
|
363
|
+
: await membershipCheck(client, who.agentId);
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
checks.board_membership = check("undetermined", "whoami_not_passed");
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
let entries = [];
|
|
370
|
+
try {
|
|
371
|
+
entries = readSessionRecords(harness, { ...trust, env, isAlive: options.isAlive });
|
|
372
|
+
checks.runtime = judgeRuntime(harness, entries, values && { handle: values[HANDLE_KEY], fingerprint: values[FINGERPRINT_KEY] });
|
|
373
|
+
}
|
|
374
|
+
catch (error) {
|
|
375
|
+
checks.runtime = check("undetermined", codeOf(error, "records_unreadable"));
|
|
376
|
+
}
|
|
377
|
+
// Registration is read for this directory and for every live record's.
|
|
378
|
+
const cwds = [...new Set([cwd, ...entries.flatMap((entry) => entry.status === "live" ? [entry.record.cwd] : [])])];
|
|
379
|
+
const fs = options.registrationFs ?? nodeRegistrationFs;
|
|
380
|
+
const home = env.HOME && env.HOME.trim() !== "" ? env.HOME : homedir();
|
|
381
|
+
const platform = options.platform ?? process.platform;
|
|
382
|
+
let userName = options.userName;
|
|
383
|
+
if (userName === undefined) {
|
|
384
|
+
try {
|
|
385
|
+
userName = (options.lookupUserName ?? (() => userInfo().username))() || undefined;
|
|
386
|
+
}
|
|
387
|
+
catch {
|
|
388
|
+
userName = undefined;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
const verdicts = cwds.map((dir) => {
|
|
392
|
+
try {
|
|
393
|
+
return judgeRegistration(harness, { cwd: dir, home, env, fs, claudeManagedDir: options.claudeManagedDir ?? defaultClaudeManagedDir(platform),
|
|
394
|
+
platform, userName, regQuery: options.regQuery });
|
|
395
|
+
}
|
|
396
|
+
catch (error) {
|
|
397
|
+
return check("undetermined", codeOf(error, "registration_unreadable"));
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
// `cwds[0]` is doctor's own cwd: the pin and the report are about it.
|
|
401
|
+
verdicts[0] = pinCheck(verdicts[0], entries, cwd, fs);
|
|
402
|
+
const registration = worst(verdicts);
|
|
403
|
+
checks.registration = check(registration.status, registration.reason);
|
|
404
|
+
const ordered = Object.fromEntries(DOCTOR_CHECKS.map((name) => [name, checks[name]]));
|
|
405
|
+
const statuses = Object.values(ordered).map((item) => item.status);
|
|
406
|
+
const exit = statuses.every((status) => status === "pass") ? 0 : statuses.includes("fail") ? 1 : 2;
|
|
407
|
+
const report = { harness, exit, checks: ordered };
|
|
408
|
+
const here = verdicts[0];
|
|
409
|
+
if (harness === "claude") {
|
|
410
|
+
const plugin = here.plugin;
|
|
411
|
+
report.plugin = {
|
|
412
|
+
installed: plugin?.installed ?? false,
|
|
413
|
+
installed_version: reportable(plugin?.version ?? null),
|
|
414
|
+
pinned_mcp_version: reportable(plugin?.pinnedMcpVersion ?? null),
|
|
415
|
+
source_ref: reportable(plugin?.sourceRef ?? null),
|
|
416
|
+
auto_update: plugin ? { effective: plugin.autoUpdate.effective, source: plugin.autoUpdate.source } : { effective: false, source: "undetermined" },
|
|
417
|
+
};
|
|
418
|
+
if (here.hookCounts)
|
|
419
|
+
report.registration = { hook_counts: { SessionStart: here.hookCounts.SessionStart, FileChanged: here.hookCounts.FileChanged } };
|
|
420
|
+
if (here.managedPolicy) {
|
|
421
|
+
report.managed_policy = {
|
|
422
|
+
checked: [...here.managedPolicy.checked], evidence: [...here.managedPolicy.evidence],
|
|
423
|
+
unreadable_sources_possible: here.managedPolicy.unreadableSourcesPossible, platform: here.managedPolicy.platformLabel,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
return report;
|
|
428
|
+
}
|
|
429
|
+
/** Human-readable form; `--json` prints the report object itself. */
|
|
430
|
+
export function formatDoctorReport(report) {
|
|
431
|
+
const lines = DOCTOR_CHECKS.map((name) => `${report.checks[name].status.padEnd(12)} ${name}: ${report.checks[name].reason}`);
|
|
432
|
+
if (report.plugin) {
|
|
433
|
+
const { plugin } = report;
|
|
434
|
+
const counts = report.registration?.hook_counts;
|
|
435
|
+
lines.push([
|
|
436
|
+
`${"plugin".padEnd(12)} installed=${plugin.installed ? "yes" : "no"}`,
|
|
437
|
+
`version=${plugin.installed_version ?? "none"}`,
|
|
438
|
+
`pinned_mcp=${plugin.pinned_mcp_version ?? "none"}`,
|
|
439
|
+
`ref=${plugin.source_ref ?? "none"}`,
|
|
440
|
+
`auto_update=${plugin.auto_update.effective ? "on" : "off"}(${plugin.auto_update.source})`,
|
|
441
|
+
counts ? `hooks SessionStart=${counts.SessionStart} FileChanged=${counts.FileChanged}` : "hooks undetermined",
|
|
442
|
+
].join(" "));
|
|
443
|
+
}
|
|
444
|
+
if (report.managed_policy) {
|
|
445
|
+
const policy = report.managed_policy;
|
|
446
|
+
lines.push([
|
|
447
|
+
`${"admin policy".padEnd(12)} checked ${policy.checked.join(",") || "none"}`,
|
|
448
|
+
policy.evidence.length > 0 ? `; found ${policy.evidence.join(",")}` : "",
|
|
449
|
+
policy.unreadable_sources_possible ? `; ${policy.platform} MDM profiles not fully readable` : "",
|
|
450
|
+
].join(""));
|
|
451
|
+
}
|
|
452
|
+
return [`agent-board doctor --harness ${report.harness}`, ...lines, `exit ${report.exit}`].join("\n");
|
|
453
|
+
}
|