@consiliency/agent-board-cli 1.22.5 → 1.23.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 CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Command } from "commander";
2
2
  import { type EnrollKeystore, type EnrollmentIntent, type FleetOnboardingIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike, type AgentJoinDeployment, type AgentJoinInstructions, type DeviceAuthorizationPrompt } from "@consiliency/agent-board-client";
3
+ import { type DoctorOptions, type DoctorReport } from "./doctor.js";
3
4
  import { DevicePendingEnrollmentStore } from "./enroll_runtime.js";
4
5
  type JsonObject = Record<string, unknown>;
5
6
  export interface CliIo {
@@ -303,6 +304,8 @@ export type StringFieldsOf<T> = {
303
304
  }[keyof T];
304
305
  type FleetTupleField = StringFieldsOf<FleetOnboardingIntent>;
305
306
  export declare const FLEET_TUPLE_DISPOSITION: Readonly<Record<FleetTupleField, FleetTupleDisposition>>;
306
- export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner): Command;
307
- export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner): Promise<void>;
307
+ /** `runDoctor`'s shape; a seam so the exit-code wiring is testable without a live board. */
308
+ export type DoctorRunner = (options: DoctorOptions) => Promise<DoctorReport>;
309
+ export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner): Command;
310
+ export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner): Promise<void>;
308
311
  export {};
package/dist/cli.js CHANGED
@@ -1,8 +1,11 @@
1
1
  import { Command, Option } from "commander";
2
2
  import { createHash, randomUUID } from "node:crypto";
3
+ import * as nodeFs from "node:fs";
3
4
  import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeFleetTupleValue, normalizeKeyReference, enrollCommitMarker, assertPresentationSafe, FLEET_TEMPLATE_FAILURE_MESSAGE, PROHIBITED_PRESENTATION_REMOVE_CLASS, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, AUTHORED_CAUSE_CODES, codeProvenance, markAuthoredCode, } from "@consiliency/agent-board-client";
5
+ import { dirname, join } from "node:path";
6
+ import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeFleetTupleValue, normalizeKeyReference, enrollCommitMarker, assertPresentationSafe, FLEET_TEMPLATE_FAILURE_MESSAGE, PROHIBITED_PRESENTATION_REMOVE_CLASS, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, AUTHORED_CAUSE_CODES, codeProvenance, markAuthoredCode, assertIdentityHarness, identityFilePath, resolveHostSegment, identityConfigRoot, walkTrustedDirectory, IDENTITY_HARNESSES, CredentialResolutionError, } from "@consiliency/agent-board-client";
7
+ import { formatDoctorReport, runDoctor } from "./doctor.js";
8
+ import { identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, } from "./identity_writer.js";
6
9
  import { DevicePendingEnrollmentStore, FilePendingEnrollmentStore, FleetDeviceContinuationStore, makeBrowserOpener, prepareFleetRuntimeOutput, publishFinalizedFleetTemplate, } from "./enroll_runtime.js";
7
10
  function isRuntimeStatus(value) {
8
11
  return Boolean(value && typeof value === "object" && "runtime" in value);
@@ -2052,8 +2055,74 @@ function buildPendingFleetTemplate(intent, publishableKeyFieldReference = DEFAUL
2052
2055
  const mismatch = localKeyMismatchFailure(pending, intent, ref);
2053
2056
  if (mismatch)
2054
2057
  throw new FleetSafeMessageError("fleet_onboarding_local_key_mismatch", mismatch);
2058
+ fleetIdentityFilePreflight(pending, host);
2055
2059
  return pending;
2056
2060
  }
2061
+ function isIdentityHarness(kind) {
2062
+ return IDENTITY_HARNESSES.includes(kind);
2063
+ }
2064
+ const FLEET_HOST_MISMATCH_MESSAGE = "this host's identity file would be written for a different host than the one "
2065
+ + '--expected-agent-handle names: the part of the handle before "-<harness-kind>" must equal this host\'s short '
2066
+ + "hostname, or MESSAGE_BOARD_WAKE_MACHINE_ID when it is set. Set MESSAGE_BOARD_WAKE_MACHINE_ID to that part if "
2067
+ + "this is the right machine. Neither value is echoed: both can carry operator-supplied text.";
2068
+ /**
2069
+ * CREDPATH SL-6 — the fleet half of the identity-file preflight, for identity harnesses only.
2070
+ *
2071
+ * Called from `buildPendingFleetTemplate`, which BOTH fleet paths run before the server is
2072
+ * asked to commit (run, device begin, and the device resume's candidate), so a value the file
2073
+ * would refuse is refused while the bounded invite use is unspent (#176, and #521's
2074
+ * single-use rule). Three things are checked, all before any write:
2075
+ * - the host the file's path names (`resolveHostSegment`) is the host the handle names;
2076
+ * - the config root is absolute;
2077
+ * - the board URL, session URL and publishable key are the non-secret shapes the file
2078
+ * requires, judged by SL-2's own preflight so there is one shape rule. An ABSENT key is
2079
+ * left to `resolveFleetPublishableKey`, which names the variable.
2080
+ * Every refusal is a literal this file writes; no operand is echoed.
2081
+ */
2082
+ const TRUSTED_WALK_CAUSES = new Set(["identity_path_symlink", "identity_path_foreign_owner",
2083
+ "identity_path_permissions", "identity_absent"]);
2084
+ function fleetIdentityFilePreflight(pending, host) {
2085
+ if (!isIdentityHarness(pending.harnessKind))
2086
+ return;
2087
+ if (resolveHostSegment(process.env) !== host) {
2088
+ throw new FleetSafeMessageError("identity_host_mismatch", FLEET_HOST_MISMATCH_MESSAGE);
2089
+ }
2090
+ try {
2091
+ identityFilePath(pending.harnessKind, process.env);
2092
+ }
2093
+ catch {
2094
+ throw new FleetSafeMessageError("identity_config_root_relative", "the identity file's config root must be absolute: XDG_CONFIG_HOME (or HOME) is relative. Nothing was sent.");
2095
+ }
2096
+ try {
2097
+ identityDirectoryPreflight(pending.harnessKind);
2098
+ }
2099
+ catch (error) {
2100
+ // The client's message names the path, and the path carries the handle's host — operator
2101
+ // text. Only the client's code crosses, and only from its closed walk vocabulary.
2102
+ const raw = error?.code;
2103
+ const cause = typeof raw === "string" && TRUSTED_WALK_CAUSES.has(raw) ? raw : "unknown";
2104
+ throw new FleetSafeMessageError("identity_path_untrusted", `the directory the harness's identity file would be written to is not private to you (${cause}): `
2105
+ + "$XDG_CONFIG_HOME (or ~/.config) and every directory above it must not be a user-owned symlink, "
2106
+ + "foreign-owned, world-writable, or group-writable by a group other than your private one, and "
2107
+ + "agent-board/ beneath it must be yours and mode 700. The path is not echoed: it carries the handle's host. "
2108
+ + "Nothing was sent.");
2109
+ }
2110
+ const publishableKey = process.env[pending.publishableKeyFieldReference]?.trim() ?? "";
2111
+ if (!publishableKey)
2112
+ return;
2113
+ try {
2114
+ resolveIdentityDeployment({
2115
+ MESSAGE_BOARD_ENROLL_BOARD_URL: pending.staticMcpEnvironment.AGENT_BOARD_URL,
2116
+ MESSAGE_BOARD_ENROLL_SESSION_URL: pending.staticMcpEnvironment.AGENT_BOARD_SESSION_URL,
2117
+ MESSAGE_BOARD_ENROLL_PUBLISHABLE_KEY: publishableKey,
2118
+ });
2119
+ }
2120
+ catch {
2121
+ throw new FleetSafeMessageError("identity_secret_value", "a value this fleet enrollment would write into the harness's identity file (the board URL, the session URL "
2122
+ + "or the publishable key) is not the non-secret shape that file requires; an identity file carries public "
2123
+ + "values only. The value is not echoed. Nothing was sent.");
2124
+ }
2125
+ }
2057
2126
  async function finalizeFleetOutcome(options, intent, result, frozenPending) {
2058
2127
  if (intent.kind !== "fleet_onboarding")
2059
2128
  return result;
@@ -2106,6 +2175,14 @@ async function finalizeFleetOutcomeInner(options, intent, result, frozenPending)
2106
2175
  // Shaped (and therefore content-checked) BEFORE anything is written to disk.
2107
2176
  const enrollment = fleetSafeOutput(outcome);
2108
2177
  const published = await publishFinalizedFleetTemplate(options.runtimeOutput, finalized);
2178
+ // CREDPATH SL-6 — the declared `identityEnvPath`, written. Its host was matched against this
2179
+ // process's environment by `fleetIdentityFilePreflight`, in this same process: `run` built
2180
+ // `pending` above the commit, and a device resume builds its candidate template (compared
2181
+ // equal to the stored one) before `deviceComplete`, so a resume from another host is refused
2182
+ // there.
2183
+ if (isIdentityHarness(pending.harnessKind)) {
2184
+ await writeIdentityFile(pending.harnessKind, finalized.runtimeEnv, process.env);
2185
+ }
2109
2186
  return { enrollment, materialization: { generation: published.generation, digest: published.digest } };
2110
2187
  }
2111
2188
  export const FLEET_TUPLE_DISPOSITION = Object.freeze({
@@ -2361,6 +2438,64 @@ function fleetSafeOutput(result) {
2361
2438
  throughPresentationGate(() => assertPresentationSafe(shaped, "fleet_enrollment_output"));
2362
2439
  return shaped;
2363
2440
  }
2441
+ /**
2442
+ * The filesystem as `writeIdentityFile` will leave it: every component that exists is read for
2443
+ * real, and every one that does not is the 0700, caller-owned directory the writer creates
2444
+ * there. So the reader's own trusted walk (`walkTrustedDirectory`), run over this BEFORE the
2445
+ * commit, refuses exactly what the writer's walk would refuse after it — a group-writable
2446
+ * ~/.config, a foreign owner, a symlink, a non-private `agent-board/` — while the token is
2447
+ * still unspent. Only ENOENT is planned over; anything else is the real answer.
2448
+ */
2449
+ const PLANNED_IDENTITY_DIRECTORY_FS = {
2450
+ lstatSync(path) {
2451
+ try {
2452
+ return nodeFs.lstatSync(path);
2453
+ }
2454
+ catch (error) {
2455
+ if (error.code !== "ENOENT")
2456
+ throw error;
2457
+ return { uid: process.getuid?.() ?? -1, gid: process.getgid?.() ?? -1, mode: nodeFs.constants.S_IFDIR | 0o700,
2458
+ dev: 0, ino: 0, size: 0, isSymbolicLink: () => false, isDirectory: () => true, isFile: () => false };
2459
+ }
2460
+ },
2461
+ readlinkSync: (path) => nodeFs.readlinkSync(path),
2462
+ openSync: (path, flags) => nodeFs.openSync(path, flags),
2463
+ fstatSync: (fd) => nodeFs.fstatSync(fd),
2464
+ readSync: (fd, buffer, offset, length, position) => nodeFs.readSync(fd, buffer, offset, length, position),
2465
+ closeSync: (fd) => nodeFs.closeSync(fd),
2466
+ };
2467
+ /** `identityFilePath`'s refusals, then the directory walk the writer will make, both pre-commit. */
2468
+ function identityDirectoryPreflight(harness) {
2469
+ const path = identityFilePath(harness, process.env);
2470
+ walkTrustedDirectory(dirname(path), join(identityConfigRoot(process.env), "agent-board"), { fs: PLANNED_IDENTITY_DIRECTORY_FS });
2471
+ }
2472
+ const HARNESS_OPTION_DESCRIPTION = "write this harness's identity file (agy, claude, codex, grok, opencode, pi) after enrolling";
2473
+ function identityFilePreflight(options) {
2474
+ if (options.harness === undefined)
2475
+ return null;
2476
+ if (options.fleetOnboarding) {
2477
+ throw new FleetPreflightError("enroll_harness_with_fleet", "--harness is for a plain enrollment; a fleet enrollment names its harness with --harness-kind. Nothing was sent.");
2478
+ }
2479
+ identityDirectoryPreflight(options.harness);
2480
+ return { harness: options.harness, deployment: resolveIdentityDeployment(process.env) };
2481
+ }
2482
+ const IDENTITY_WRITE_CAUSE = /^[a-z0-9_]{1,80}$|^E[A-Z]{2,20}$/;
2483
+ async function writeEnrolledIdentityFile(plan, result) {
2484
+ if (plan === null)
2485
+ return result;
2486
+ const outcome = result;
2487
+ try {
2488
+ const path = await writeIdentityFile(plan.harness, identityValuesFromEnrollment(outcome, plan.deployment), process.env);
2489
+ return { ...result, identityFile: path };
2490
+ }
2491
+ catch (error) {
2492
+ const raw = error?.code;
2493
+ const cause = typeof raw === "string" && IDENTITY_WRITE_CAUSE.test(raw) ? raw : "unknown";
2494
+ throw new CredentialResolutionError("identity_write_failed_after_commit", `enrollment COMMITTED on the server (agentHandle=${outcome.agentHandle}); only writing the ${plan.harness} `
2495
+ + `identity file failed (${cause}). Do NOT re-run the enrollment: its token is spent. Fix the cause and write `
2496
+ + "the file from this enrollment's values, or revoke the agent's key before enrolling again.");
2497
+ }
2498
+ }
2364
2499
  function addFleetOptions(command) {
2365
2500
  return command
2366
2501
  .option("--fleet-onboarding", "atomically enroll and bind the creator-authorized fleet tuple")
@@ -2404,7 +2539,8 @@ function addEnrollCommands(program, io, enrollment) {
2404
2539
  .option("--join-board <boardId>", "join-board intent board id")
2405
2540
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
2406
2541
  .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
2407
- .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
2542
+ .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
2543
+ .option("--harness <harness>", HARNESS_OPTION_DESCRIPTION);
2408
2544
  addFleetOptions(runCommand)
2409
2545
  .action((options) => runHandler(io, "agent_board.enroll", async () => {
2410
2546
  // The non-fleet half of what `requiredOption` used to do. The fleet half is the
@@ -2413,6 +2549,7 @@ function addEnrollCommands(program, io, enrollment) {
2413
2549
  if (!options.fleetOnboarding && !String(options.slug ?? "").trim()) {
2414
2550
  throw new FleetPreflightError("enroll_run_slug_required", "enroll run requires --slug");
2415
2551
  }
2552
+ const identityPlan = identityFilePreflight(options);
2416
2553
  // ORDER IS THE CONTRACT on this path (#254). The intent is built and the whole
2417
2554
  // pending template is constructed BEFORE the filesystem is touched and before the
2418
2555
  // server is asked to commit anything — which is the order `enroll device` (begin
@@ -2432,8 +2569,9 @@ function addEnrollCommands(program, io, enrollment) {
2432
2569
  requestedScopes: csv(options.requestedScopes),
2433
2570
  requestedBoardScopes: csv(options.requestedBoardScopes),
2434
2571
  });
2435
- if (intent.kind !== "fleet_onboarding")
2436
- return finalizeFleetOutcome(options, intent, await call());
2572
+ if (intent.kind !== "fleet_onboarding") {
2573
+ return writeEnrolledIdentityFile(identityPlan, await finalizeFleetOutcome(options, intent, await call()));
2574
+ }
2437
2575
  const pending = throughPresentationGate(() => buildPendingFleetTemplate(intent, options.publishableKeyFieldReference));
2438
2576
  // BOTH local prerequisites of fleet finalization, checked before the server is
2439
2577
  // asked to commit anything. The publishable key used to be read only inside
@@ -2481,6 +2619,7 @@ function addEnrollCommands(program, io, enrollment) {
2481
2619
  .option("--code <code>", "the authorization code (prefer stdin; argv leaks via /proc/<pid>/cmdline)")
2482
2620
  .option("--redirect-response <url>", "the full redirect response URL")
2483
2621
  .option("--slug <slug>", "override the agent slug")
2622
+ .option("--harness <harness>", HARNESS_OPTION_DESCRIPTION)
2484
2623
  .action(async (options) => {
2485
2624
  // A5: keep the auth code off argv where practical. When neither --code nor
2486
2625
  // --redirect-response is supplied and stdin is piped, read the code from stdin.
@@ -2488,12 +2627,16 @@ function addEnrollCommands(program, io, enrollment) {
2488
2627
  if (!code && !options.redirectResponse) {
2489
2628
  code = await readCodeFromStdin();
2490
2629
  }
2491
- return runHandler(io, "agent_board.complete_enrollment", () => enrollment.complete({
2492
- enrollmentId: options.enrollmentId,
2493
- code,
2494
- redirectResponse: options.redirectResponse,
2495
- slug: options.slug,
2496
- }));
2630
+ return runHandler(io, "agent_board.complete_enrollment", async () => {
2631
+ // `complete` is where begin/complete commits, so the preflight sits here, not in `begin`.
2632
+ const identityPlan = identityFilePreflight(options);
2633
+ return writeEnrolledIdentityFile(identityPlan, await enrollment.complete({
2634
+ enrollmentId: options.enrollmentId,
2635
+ code,
2636
+ redirectResponse: options.redirectResponse,
2637
+ slug: options.slug,
2638
+ }));
2639
+ });
2497
2640
  });
2498
2641
  // RFC 8628 device authorization — the correct flow for a HEADLESS host. `run`'s
2499
2642
  // loopback receiver binds the SERVER's localhost (useless when the human's browser
@@ -2513,9 +2656,12 @@ function addEnrollCommands(program, io, enrollment) {
2513
2656
  .option("--join-board <boardId>", "join-board intent board id")
2514
2657
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
2515
2658
  .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
2516
- .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)");
2659
+ .option("--no-board", "enroll WITHOUT provisioning any board (no create, no join)")
2660
+ .option("--harness <harness>", HARNESS_OPTION_DESCRIPTION);
2517
2661
  addFleetOptions(deviceCommand)
2518
2662
  .action((options) => runHandler(io, "agent_board.enroll_device", async () => {
2663
+ // Before either branch: a non-fleet resume commits in `deviceComplete` too.
2664
+ const identityPlan = identityFilePreflight(options);
2519
2665
  if (options.enrollmentId) {
2520
2666
  if (!enrollment.deviceComplete) {
2521
2667
  // #402 — a SAFE message rather than a plain `Error`. These two fire BEFORE the
@@ -2542,7 +2688,7 @@ function addEnrollCommands(program, io, enrollment) {
2542
2688
  // `Boolean(options.fleetOnboarding)`. Do NOT make it outcome-keyed to match:
2543
2689
  // it governs FAILURE text, and turning it on here would blank the guard's
2544
2690
  // refusal message into "fleet enrollment failed before completion".
2545
- return fleetSafeOutput(await enrollment.deviceComplete({ enrollmentId: options.enrollmentId }));
2691
+ return writeEnrolledIdentityFile(identityPlan, fleetSafeOutput(await enrollment.deviceComplete({ enrollmentId: options.enrollmentId })));
2546
2692
  }
2547
2693
  if (!enrollment.deviceDeletePending) {
2548
2694
  throw new FleetSafeMessageError("fleet_device_resume_runner_incomplete", "fleet device resume is not supported by this runner (EnrollmentCommandRunner.deviceDeletePending is "
@@ -2713,7 +2859,7 @@ function addEnrollCommands(program, io, enrollment) {
2713
2859
  // for the final JSON outcome). The device_code secret is never surfaced.
2714
2860
  onDeviceAuthorization: (prompt) => io.writeStderr(renderDevicePrompt(prompt)),
2715
2861
  });
2716
- return result;
2862
+ return writeEnrolledIdentityFile(identityPlan, result);
2717
2863
  });
2718
2864
  }, Boolean(options.fleetOnboarding)));
2719
2865
  }
@@ -3016,7 +3162,44 @@ function addInviteCommands(program, client, io) {
3016
3162
  return { result, instructions: buildAgentJoinInstructions({ invite: branded, deployment: { ...instructionDeployment, fleetHarnessTemplate: instructionTemplate, fleetOnboardingInvite: branded } }) };
3017
3163
  }));
3018
3164
  }
3019
- export function createProgram(client, io, enrollment = defaultEnrollmentRunner()) {
3165
+ /**
3166
+ * CREDPATH IF-0-CREDPATH-4 — top-level `agent-board doctor --harness <h> [--json]`. Distinct
3167
+ * from `runtime doctor` (the runtime-discovery diagnostics), which it does not replace. The
3168
+ * process exits with the report's own code: 0 every check passed, 1 one failed, 2 one could
3169
+ * not be determined. A refused `--harness` is a usage error (1); doctor itself throwing is 2,
3170
+ * because nothing was judged.
3171
+ */
3172
+ function addDoctorCommand(program, io, doctor) {
3173
+ program
3174
+ .command("doctor")
3175
+ .description("check one harness's identity file, keystore key, board access, registration and live wake runtime")
3176
+ // Not `requiredOption`: commander's own usage exit would bypass the JSON envelope (#402).
3177
+ .option("--harness <harness>", "the harness to check (agy, claude, codex, grok, opencode, pi)")
3178
+ .option("--json", "print the report as JSON")
3179
+ .action(async (options) => {
3180
+ const harness = String(options.harness ?? "");
3181
+ let report;
3182
+ try {
3183
+ assertIdentityHarness(harness);
3184
+ }
3185
+ catch (error) {
3186
+ writeFailureEnvelope(io, toOperationFailure("agent_board.doctor", error));
3187
+ process.exitCode = 1;
3188
+ return;
3189
+ }
3190
+ try {
3191
+ report = await doctor({ harness });
3192
+ }
3193
+ catch (error) {
3194
+ writeFailureEnvelope(io, toOperationFailure("agent_board.doctor", error));
3195
+ process.exitCode = 2;
3196
+ return;
3197
+ }
3198
+ io.writeStdout(options.json ? `${JSON.stringify(report, null, 2)}\n` : `${formatDoctorReport(report)}\n`);
3199
+ process.exitCode = report.exit;
3200
+ });
3201
+ }
3202
+ export function createProgram(client, io, enrollment = defaultEnrollmentRunner(), doctor = runDoctor) {
3020
3203
  const program = new Command()
3021
3204
  .name("agent-board")
3022
3205
  .description("Thin CLI surface for the agent_board RPC and canonical read contracts")
@@ -3040,12 +3223,13 @@ export function createProgram(client, io, enrollment = defaultEnrollmentRunner()
3040
3223
  addRoomsCommands(program, client, io);
3041
3224
  addInviteCommands(program, client, io);
3042
3225
  addEnrollCommands(program, io, enrollment);
3226
+ addDoctorCommand(program, io, doctor);
3043
3227
  return program;
3044
3228
  }
3045
3229
  export async function runCli(argv, io = {
3046
3230
  writeStdout: (message) => process.stdout.write(message),
3047
3231
  writeStderr: (message) => process.stderr.write(message),
3048
- }, client = createAgentBoardClient(), enrollment = defaultEnrollmentRunner()) {
3049
- const program = createProgram(client, io, enrollment);
3232
+ }, client = createAgentBoardClient(), enrollment = defaultEnrollmentRunner(), doctor = runDoctor) {
3233
+ const program = createProgram(client, io, enrollment, doctor);
3050
3234
  await program.parseAsync(argv, { from: "user" });
3051
3235
  }
@@ -0,0 +1,104 @@
1
+ import { type IdentityHarness, type OperationSuccess, type OsKeychainExec, type SessionRecordEntry, type TrustOptions } from "@consiliency/agent-board-client";
2
+ import { type RegistrationFs } from "./doctor_scopes.js";
3
+ export type DoctorStatus = "pass" | "fail" | "undetermined";
4
+ export declare const DOCTOR_CHECKS: readonly ["identity_file", "keystore_key", "whoami", "board_membership", "registration", "runtime"];
5
+ export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number];
6
+ export interface DoctorCheck {
7
+ status: DoctorStatus;
8
+ reason: string;
9
+ }
10
+ export interface DoctorReport {
11
+ harness: IdentityHarness;
12
+ exit: 0 | 1 | 2;
13
+ checks: Record<DoctorCheckName, DoctorCheck>;
14
+ }
15
+ /**
16
+ * The two reads doctor makes, on a client whose credentials come from the identity file. Typed
17
+ * as the client's own `OperationSuccess` envelope, so `defaultClient` below stops compiling if
18
+ * the rows move out of `data`.
19
+ */
20
+ type RowsEnvelope = Pick<OperationSuccess<ReadonlyArray<Record<string, unknown>>>, "data">;
21
+ export interface DoctorClient {
22
+ whoami(input: Record<string, never>): Promise<RowsEnvelope>;
23
+ boardMemberships(query: {
24
+ agentId: string;
25
+ limit?: number;
26
+ }): Promise<RowsEnvelope>;
27
+ }
28
+ export type KeystoreProbeResult = {
29
+ kind: "found";
30
+ fingerprint: string;
31
+ }
32
+ /** The key signed, but its fingerprint was not derivable here; `whoami` proves it server-side. */
33
+ | {
34
+ kind: "present_unverified";
35
+ } | {
36
+ kind: "absent";
37
+ reason: string;
38
+ } | {
39
+ kind: "unreachable";
40
+ reason: string;
41
+ };
42
+ /** Find the key a reference names; a fingerprint, when reported, is derived from the key, never echoed from the reference. */
43
+ export type KeystoreProbe = (keyReference: string, expectedFingerprint: string, env: NodeJS.ProcessEnv) => Promise<KeystoreProbeResult>;
44
+ export interface DoctorOptions {
45
+ harness: string;
46
+ /** Path inputs only (HOME, XDG_*, host id, harness config dirs). Default `process.env`. */
47
+ env?: NodeJS.ProcessEnv;
48
+ /** Default `process.cwd()`. */
49
+ cwd?: string;
50
+ trust?: TrustOptions;
51
+ isAlive?: (pid: number, processStartedAt: string | null) => boolean;
52
+ createClient?: (fileEnv: NodeJS.ProcessEnv) => DoctorClient;
53
+ probeKeystore?: KeystoreProbe;
54
+ registrationFs?: RegistrationFs;
55
+ /** Claude Code's managed directory. Default `/etc/claude-code`. */
56
+ claudeManagedDir?: string;
57
+ }
58
+ /**
59
+ * An error's `code`, only when this repository wrote it: a member of the derived
60
+ * AUTHORED_CAUSE_CODES that this instance carries under the authored provenance brand — the
61
+ * CLI's `namedCauseCode` gate (#403). Anything else — a code copied from a /session or PostgREST
62
+ * body, an errno, an unbranded error — is replaced by `fallback`, a fixed local classification.
63
+ * A shape test cannot do this: a server may put any well-shaped token in `code` (code review
64
+ * round 1, Astra 4: `sb_secret_…` passed the previous `^[A-Za-z0-9_.:-]{1,80}$` filter).
65
+ */
66
+ export declare function authoredCode(error: unknown, fallback: string): string;
67
+ export declare function isNetworkError(error: unknown): boolean;
68
+ /**
69
+ * The session mint signs with the identity's key, so a keystore that cannot be reached fails
70
+ * `whoami` too. That says nothing about the identity: undetermined, like `keystore_key`.
71
+ */
72
+ export declare function isKeystoreUnreachable(error: unknown): boolean;
73
+ /** The ambient env minus every identity and generic name, plus the file: the file is the identity. */
74
+ export declare function fileIdentityEnv(ambient: NodeJS.ProcessEnv, values: Record<string, string>): NodeJS.ProcessEnv;
75
+ /**
76
+ * The default os_keychain probe. Doctor never holds private-key bytes, so it does not read the
77
+ * item: it asks the client's own backend to SIGN a nonce, exactly as a session mint would. A
78
+ * signature proves the key is present; it does not prove WHICH key, so the result is
79
+ * `present_unverified`.
80
+ *
81
+ * What `whoami` then proves, and what it does not (code review round 1, Opus B1 / Astra 2): the
82
+ * session mint sends `agent_slug`, `key_id`, `key_version` and a signature — never the file's
83
+ * fingerprint — and the server verifies the signature against the key enrolled under that
84
+ * key_id. So a passing `whoami` proves the keychain holds the enrolled key FOR THE FILE'S KEY_ID.
85
+ * It says nothing about the file's `AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT`, which stays
86
+ * unverified for os_keychain; the check's reason says so. By default the backend runs with the
87
+ * client's own exec; `spawn` (the client's exported exec seam) exists only so tests can stand in
88
+ * for the keychain CLI. Nothing here reads its output; doctor.test.ts K-0 pins that no
89
+ * key-decoding API is reachable.
90
+ */
91
+ export declare function osKeychainProbe(spawn?: OsKeychainExec): KeystoreProbe;
92
+ /** The default agent_socket probe: the loaded key's fingerprint, derived from the agent's identity list. */
93
+ export declare const agentSocketProbe: KeystoreProbe;
94
+ export declare const OS_KEYCHAIN_PRESENT = "present; key_id proven server-side by whoami; file fingerprint unverified for os_keychain";
95
+ interface FileIdentity {
96
+ handle: string;
97
+ fingerprint: string;
98
+ }
99
+ /** IF-0-CREDPATH-4 runtime, over already-read records. Exported for its fixtures. */
100
+ export declare function judgeRuntime(harness: IdentityHarness, entries: readonly SessionRecordEntry[], identity: FileIdentity | null): DoctorCheck;
101
+ export declare function runDoctor(options: DoctorOptions): Promise<DoctorReport>;
102
+ /** Human-readable form; `--json` prints the report object itself. */
103
+ export declare function formatDoctorReport(report: DoctorReport): string;
104
+ export {};