@consiliency/agent-board-cli 1.24.0 → 1.25.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/doctor.js CHANGED
@@ -9,13 +9,17 @@
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";
12
13
  import { randomBytes } from "node:crypto";
14
+ import { existsSync, readdirSync } from "node:fs";
13
15
  import { homedir, userInfo } from "node:os";
14
- import { resolve } from "node:path";
16
+ import { join, resolve } from "node:path";
15
17
  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
18
  import { judgeRegistration, nodeRegistrationFs, } from "./doctor_scopes.js";
19
+ import { resolveKeyslotDir } from "./assets.js";
20
+ import { SLOT_RE, externalDeclarationProblems, fileIdentity, keyslotSocketDir, machineIdOf, resolveAgentSocketPath, unsealedOptinProblems, } from "./setup_storage.js";
17
21
  export const DOCTOR_CHECKS = Object.freeze([
18
- "identity_file", "keystore_key", "whoami", "board_membership", "registration", "runtime",
22
+ "identity_file", "keystore_key", "key_storage", "whoami", "board_membership", "registration", "runtime",
19
23
  ]);
20
24
  export const REPORTABLE_TOKEN = /^[A-Za-z0-9._/-]{1,64}$/;
21
25
  const reportable = (value) => value === null ? null : REPORTABLE_TOKEN.test(value) ? value : "unrecognised";
@@ -228,6 +232,142 @@ async function keystoreCheck(values, env, probe) {
228
232
  ? check("pass", "fingerprint_matches")
229
233
  : check("fail", "fingerprint_mismatch");
230
234
  }
235
+ /**
236
+ * IF-0-SETUP-3 / IF-0-KEYSEAL-6 — which storage holds the identity's key, MEASURED: from the file's
237
+ * key reference plus the packaged `status.sh --json`, never from anything setup recorded.
238
+ *
239
+ * - an `os_keychain` reference → pass `os_keychain` (keystore_key proves the key is there);
240
+ * - an `agent_socket` reference: the socket keystore_key signs through (same resolution, including
241
+ * `MESSAGE_BOARD_AGENT_SOCKET_PATH`) is matched by DEVICE AND INODE, never spelling, against
242
+ * each `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`;
243
+ * - a keyslot → `status.sh --harness <h> --slot <slot> --json`. Its measurement fields
244
+ * (machine_id, uid, slot, seam, mode) are compared with doctor's own values FIRST: a report
245
+ * that is not about this host, user and slot says nothing, so a mismatch or a missing field is
246
+ * 2 even beside a status exit 1 (both are non-pass). Then exit 1 → 1, exit 2 → 2, and exit 0
247
+ * passes as `sealed`, or as `unsealed` only when doctor's own field-by-field read of the opt-in
248
+ * is valid (an invalid opt-in is 1, as A1-S2 requires of status.sh);
249
+ * - any other socket → `external`, which passes only with a valid declaration
250
+ * (`<configRoot>/agent-board/external-agent`); undeclared or mismatched → 2.
251
+ * - status.sh absent, unrunnable, timed out or unparseable → 2.
252
+ */
253
+ const STATUS_MODES = new Set(["sealed", "unsealed", "contradiction", "undetermined"]);
254
+ const STATUS_EXIT2_REASON = /^(nothing_committed|undeterminable|linger_off|upgrade_available|loader_outdated|loader_unrecorded|legacy_loader_present|capability_(error|incapable|incapable-for-keyslot))$/;
255
+ const STATUS_TIMEOUT_MS = 180_000;
256
+ function runStatus(file, args, env) {
257
+ return new Promise((done) => {
258
+ execFile(file, args, { env, timeout: STATUS_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout) => {
259
+ const failure = error;
260
+ const code = !failure ? 0 : typeof failure.code === "number" && !failure.killed ? failure.code : null;
261
+ done({ code, stdout: String(stdout ?? "") });
262
+ });
263
+ });
264
+ }
265
+ /** The keyslot whose socket is the same file as `socket`, or null. */
266
+ function matchKeyslot(socket, env) {
267
+ const dir = keyslotSocketDir(env);
268
+ const target = fileIdentity(socket);
269
+ if (!dir || !target)
270
+ return null;
271
+ let names;
272
+ try {
273
+ names = readdirSync(dir);
274
+ }
275
+ catch {
276
+ return null;
277
+ }
278
+ for (const name of names.sort()) {
279
+ if (!name.endsWith(".sock"))
280
+ continue;
281
+ const slot = name.slice(0, -".sock".length);
282
+ if (!SLOT_RE.test(slot))
283
+ continue;
284
+ const candidate = fileIdentity(join(dir, name));
285
+ if (candidate && candidate.dev === target.dev && candidate.ino === target.ino)
286
+ return slot;
287
+ }
288
+ return null;
289
+ }
290
+ async function keyStorageCheck(harness, values, fileEnv, env, options) {
291
+ let reference;
292
+ try {
293
+ reference = normalizeKeyReference(values[KEY_REFERENCE_KEY]);
294
+ }
295
+ catch {
296
+ return check("undetermined", "key_reference_invalid");
297
+ }
298
+ if (reference.resolver === "os_keychain")
299
+ return check("pass", "os_keychain");
300
+ if (reference.resolver !== "agent_socket")
301
+ return check("undetermined", "resolver_unsupported");
302
+ const socket = resolveAgentSocketPath(String(reference.handle), fileEnv);
303
+ if (socket === null)
304
+ return check("undetermined", "agent_socket_unresolved");
305
+ if (fileIdentity(socket) === null)
306
+ return check("undetermined", "agent_socket_absent");
307
+ const uid = options.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1);
308
+ const machineId = machineIdOf(options.machineIdFile);
309
+ const slot = matchKeyslot(socket, env);
310
+ if (slot === null) {
311
+ const problems = externalDeclarationProblems(env, socket, uid, machineId);
312
+ if (problems.length === 0)
313
+ return check("pass", "external");
314
+ return check("undetermined", problems.includes("missing") ? "external_undeclared" : "external_declaration_mismatch");
315
+ }
316
+ let dir;
317
+ try {
318
+ dir = options.keyslotDir ?? resolveKeyslotDir();
319
+ }
320
+ catch {
321
+ return check("undetermined", "status_absent");
322
+ }
323
+ if (!existsSync(join(dir, "status.sh")))
324
+ return check("undetermined", "status_absent");
325
+ const run = await runStatus(join(dir, "status.sh"), ["--harness", harness, "--slot", slot, "--json"], env);
326
+ if (run.code === null)
327
+ return check("undetermined", "status_did_not_complete");
328
+ let report;
329
+ try {
330
+ const parsed = JSON.parse(run.stdout);
331
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
332
+ throw new Error("not an object");
333
+ report = parsed;
334
+ }
335
+ catch {
336
+ return check("undetermined", "status_unreadable");
337
+ }
338
+ const seam = typeof env.KEYSEAL_TEST_ROOT === "string" && env.KEYSEAL_TEST_ROOT !== "";
339
+ const own = {
340
+ machine_id: (value) => machineId !== null && value === machineId,
341
+ uid: (value) => value === uid,
342
+ slot: (value) => value === slot,
343
+ seam: (value) => value === seam,
344
+ mode: (value) => typeof value === "string" && STATUS_MODES.has(value),
345
+ };
346
+ for (const [field, matches] of Object.entries(own)) {
347
+ if (!(field in report) || report[field] === null)
348
+ return check("undetermined", `status_field_missing:${field}`);
349
+ if (!matches(report[field]))
350
+ return check("undetermined", `status_field_mismatch:${field}`);
351
+ }
352
+ if (run.code === 1)
353
+ return check("fail", "keyslot_status_failed");
354
+ if (run.code === 2) {
355
+ const reasons = Array.isArray(report.exit2_reasons) ? report.exit2_reasons : [];
356
+ const named = reasons.length > 0 && reasons.every((reason) => typeof reason === "string" && STATUS_EXIT2_REASON.test(reason));
357
+ return check("undetermined", named ? `keyslot_status_undetermined:${reasons.join(",")}` : "keyslot_status_undetermined");
358
+ }
359
+ if (run.code !== 0)
360
+ return check("undetermined", "status_unexpected_exit");
361
+ if (report.mode === "sealed")
362
+ return check("pass", "sealed");
363
+ if (report.mode === "unsealed") {
364
+ const optin = report.optin;
365
+ if (optin?.ok !== true)
366
+ return check("fail", "unsealed_optin_invalid");
367
+ return unsealedOptinProblems(env, slot, uid, machineId).length === 0 ? check("pass", "unsealed") : check("fail", "unsealed_optin_invalid");
368
+ }
369
+ return check("undetermined", "status_mode_unexpected");
370
+ }
231
371
  async function whoamiCheck(client, expectedHandle) {
232
372
  let rows;
233
373
  try {
@@ -342,12 +482,14 @@ export async function runDoctor(options) {
342
482
  }
343
483
  if (values === null) {
344
484
  checks.keystore_key = check("undetermined", "no_identity_file");
485
+ checks.key_storage = check("undetermined", "no_identity_file");
345
486
  checks.whoami = check("undetermined", "no_identity_file");
346
487
  checks.board_membership = check("undetermined", "no_identity_file");
347
488
  }
348
489
  else {
349
490
  const fileEnv = fileIdentityEnv(env, values);
350
491
  checks.keystore_key = await keystoreCheck(values, fileEnv, options.probeKeystore ?? defaultKeystoreProbe);
492
+ checks.key_storage = await keyStorageCheck(harness, values, fileEnv, env, options);
351
493
  let client = null;
352
494
  try {
353
495
  client = (options.createClient ?? defaultClient)(fileEnv);
@@ -16,16 +16,18 @@ export interface IdentityDeployment {
16
16
  publishableKey: string;
17
17
  }
18
18
  /**
19
- * Where each deployment value is read, in precedence order: the enrollment's own names first
20
- * (so the file records the deployment the enrollment used — pinned against the client's
21
- * enrollment resolvers in the test), then the identity names. The generic Supabase names the
22
- * enrollment also accepts for the publishable key are NOT read: a generic key never fills an
23
- * identity field, here or in the loader.
19
+ * Where each deployment value is read, in precedence order: the client's one alias table
20
+ * (`DEPLOYMENT_ENV_ALIASES`), which puts the enrollment's own names first (so the file records
21
+ * the deployment the enrollment used) and then the identity names. The enrollment resolvers read
22
+ * the same table, so the two cannot disagree. The generic Supabase names are in no list: a
23
+ * generic key never fills an identity field, here or in the loader.
24
24
  */
25
25
  export declare const DEPLOYMENT_ENV_NAMES: Readonly<Record<keyof IdentityDeployment, readonly string[]>>;
26
26
  /**
27
- * The deployment half of an identity, read and shape-checked before enrollment commits.
28
- * Absent → `identity_incomplete`; not the non-secret shape its key requires →
27
+ * The deployment half of an identity, read and shape-checked before enrollment commits. Each
28
+ * value is the shipped default, or env under the board group's all-or-nothing rule (the
29
+ * client's `resolveDeploymentField`). Absent from an env-set group → `identity_incomplete`,
30
+ * naming the variable that switched the group off; not the non-secret shape its key requires →
29
31
  * `identity_secret_value`. Both name the variable, never the value.
30
32
  */
31
33
  export declare function resolveIdentityDeployment(env: NodeJS.ProcessEnv): IdentityDeployment;
@@ -11,7 +11,7 @@ import { createHash } from "node:crypto";
11
11
  import { constants as FS } from "node:fs";
12
12
  import * as fs from "node:fs/promises";
13
13
  import { dirname, join } from "node:path";
14
- import { CredentialResolutionError, ENV_ALIAS_FAMILIES, identityConfigRoot, identityFilePath, serializeIdentityFile, walkTrustedDirectory, } from "@consiliency/agent-board-client";
14
+ import { CredentialResolutionError, DEPLOYMENT_ENV_ALIASES, describeMissingDeploymentFields, identityConfigRoot, resolveDeploymentField, identityFilePath, serializeIdentityFile, walkTrustedDirectory, } from "@consiliency/agent-board-client";
15
15
  async function syncDirectory(path) {
16
16
  const handle = await fs.open(path, "r");
17
17
  try {
@@ -50,24 +50,17 @@ export async function writeOwnerOnlyFileAtomic(path, content, options = {}) {
50
50
  }
51
51
  await sync(dirname(path));
52
52
  }
53
- /** The identity-name alias family containing `canonical`, from the client's one declaration. */
54
- function identityFamily(canonical) {
55
- const family = ENV_ALIAS_FAMILIES.find((members) => members.includes(canonical));
56
- if (!family)
57
- throw new Error(`identity_writer: no alias family declares ${canonical}`);
58
- return family;
59
- }
60
53
  /**
61
- * Where each deployment value is read, in precedence order: the enrollment's own names first
62
- * (so the file records the deployment the enrollment used — pinned against the client's
63
- * enrollment resolvers in the test), then the identity names. The generic Supabase names the
64
- * enrollment also accepts for the publishable key are NOT read: a generic key never fills an
65
- * identity field, here or in the loader.
54
+ * Where each deployment value is read, in precedence order: the client's one alias table
55
+ * (`DEPLOYMENT_ENV_ALIASES`), which puts the enrollment's own names first (so the file records
56
+ * the deployment the enrollment used) and then the identity names. The enrollment resolvers read
57
+ * the same table, so the two cannot disagree. The generic Supabase names are in no list: a
58
+ * generic key never fills an identity field, here or in the loader.
66
59
  */
67
60
  export const DEPLOYMENT_ENV_NAMES = Object.freeze({
68
- boardUrl: Object.freeze(["MESSAGE_BOARD_ENROLL_BOARD_URL", ...identityFamily("AGENT_BOARD_URL")]),
69
- sessionUrl: Object.freeze(["MESSAGE_BOARD_ENROLL_SESSION_URL", "AGENT_BOARD_ENROLL_SESSION_URL", ...identityFamily("AGENT_BOARD_SESSION_URL")]),
70
- publishableKey: Object.freeze(["MESSAGE_BOARD_ENROLL_PUBLISHABLE_KEY", "AGENT_BOARD_ENROLL_PUBLISHABLE_KEY", ...identityFamily("AGENT_BOARD_PUBLISHABLE_KEY")]),
61
+ boardUrl: DEPLOYMENT_ENV_ALIASES.boardUrl,
62
+ sessionUrl: DEPLOYMENT_ENV_ALIASES.sessionUrl,
63
+ publishableKey: DEPLOYMENT_ENV_ALIASES.publishableKey,
71
64
  });
72
65
  const DEPLOYMENT_FILE_KEYS = {
73
66
  boardUrl: "AGENT_BOARD_URL",
@@ -89,27 +82,22 @@ const PREFLIGHT_STAND_INS = {
89
82
  AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT: `sha256:${"A".repeat(43)}`,
90
83
  MESSAGE_BOARD_AGENT_AUTH_MODE: "keystore_session",
91
84
  };
92
- function firstPresent(env, names) {
93
- for (const name of names) {
94
- const value = env[name];
95
- if (value !== undefined && value.trim() !== "")
96
- return { name, value: value.trim() };
97
- }
98
- return undefined;
99
- }
100
85
  /**
101
- * The deployment half of an identity, read and shape-checked before enrollment commits.
102
- * Absent → `identity_incomplete`; not the non-secret shape its key requires →
86
+ * The deployment half of an identity, read and shape-checked before enrollment commits. Each
87
+ * value is the shipped default, or env under the board group's all-or-nothing rule (the
88
+ * client's `resolveDeploymentField`). Absent from an env-set group → `identity_incomplete`,
89
+ * naming the variable that switched the group off; not the non-secret shape its key requires →
103
90
  * `identity_secret_value`. Both name the variable, never the value.
104
91
  */
105
92
  export function resolveIdentityDeployment(env) {
106
93
  const deployment = {};
107
94
  for (const field of Object.keys(DEPLOYMENT_ENV_NAMES)) {
108
- const names = DEPLOYMENT_ENV_NAMES[field];
109
- const found = firstPresent(env, names);
110
- if (!found) {
111
- throw new CredentialResolutionError("identity_incomplete", `cannot write an identity file: no ${DEPLOYMENT_FILE_KEYS[field]} for it (set one of: ${names.join(", ")})`);
95
+ const resolved = resolveDeploymentField(env, field);
96
+ if (resolved.value === undefined) {
97
+ throw new CredentialResolutionError("identity_incomplete", `cannot write an identity file: no ${DEPLOYMENT_FILE_KEYS[field]} for it ${describeMissingDeploymentFields([field], resolved.switchedBy)}`);
112
98
  }
99
+ // A default is named by the file key it fills: it came from no variable.
100
+ const found = { name: resolved.name ?? `the default ${DEPLOYMENT_FILE_KEYS[field]}`, value: resolved.value };
113
101
  try {
114
102
  serializeIdentityFile({ ...PREFLIGHT_STAND_INS, [DEPLOYMENT_FILE_KEYS[field]]: found.value });
115
103
  }
@@ -0,0 +1,116 @@
1
+ import type { Command } from "commander";
2
+ import { type DeviceAuthorizationPrompt, type EnrollmentIntent, type EnrollmentOutcome } from "@consiliency/agent-board-client";
3
+ import { type BakedAssets } from "./assets.js";
4
+ import { type DoctorOptions, type DoctorReport } from "./doctor.js";
5
+ import { type DepsOutcome, type KeyStorageContext, type KeyStorageSelection } from "./setup_storage.js";
6
+ import { type ChildRunner, type WriterContext } from "./setup_writers.js";
7
+ export declare const SETUP_STEPS: readonly ["preflight", "deps", "key", "enroll", "identity", "board", "harness", "doctor"];
8
+ export type SetupStepName = (typeof SETUP_STEPS)[number];
9
+ export type SetupStatus = "done" | "already" | "skipped" | "failed" | "undetermined";
10
+ export interface SetupStep {
11
+ step: SetupStepName | "clear_pending";
12
+ status: SetupStatus;
13
+ reason: string;
14
+ }
15
+ export interface SetupReport {
16
+ steps: SetupStep[];
17
+ exit: 0 | 1 | 2;
18
+ /** Human text: per-step fixes and the next steps. Never part of `--json`'s stdout. */
19
+ notes: string[];
20
+ }
21
+ export interface SetupOptions {
22
+ harness: string;
23
+ slug?: string;
24
+ invite?: boolean;
25
+ allowUnsealedKey?: boolean;
26
+ agentSocket?: string;
27
+ scope?: string;
28
+ installDeps?: boolean;
29
+ clearPending?: boolean;
30
+ /** Default true; `--no-follow-symlinks` refuses to write through a symlinked config. */
31
+ followSymlinks?: boolean;
32
+ }
33
+ /** The enrollment setup runs: the device flow, the key reference injected, intent always `none`. */
34
+ export type SetupEnroller = (input: {
35
+ slug: string;
36
+ intent: EnrollmentIntent;
37
+ keyReference: string;
38
+ env: NodeJS.ProcessEnv;
39
+ onDeviceAuthorization: (prompt: DeviceAuthorizationPrompt) => void | Promise<void>;
40
+ }) => Promise<EnrollmentOutcome>;
41
+ export interface BoardClient {
42
+ redeemInviteLink(input: {
43
+ p_code: string;
44
+ }): Promise<unknown>;
45
+ }
46
+ export interface SetupDeps {
47
+ env?: NodeJS.ProcessEnv;
48
+ cwd?: string;
49
+ platform?: NodeJS.Platform;
50
+ /** stdin is a terminal. Default `process.stdin.isTTY === true`. */
51
+ interactive?: boolean;
52
+ /** One line from the terminal; `secret` hides the echo. */
53
+ prompt?: (question: string, options: {
54
+ secret: boolean;
55
+ }) => Promise<string>;
56
+ /** All of stdin (non-interactive). */
57
+ readStdin?: () => Promise<string>;
58
+ /** Every child (busctl, sudo, the harness CLIs) runs through this, with no stdin. */
59
+ run?: ChildRunner;
60
+ /** Default `/usr/bin/sudo`: never a PATH lookup. */
61
+ sudoPath?: string;
62
+ hostname?: () => string;
63
+ bakedAssets?: () => BakedAssets;
64
+ selectKeyStorage?: (ctx: KeyStorageContext) => Promise<KeyStorageSelection>;
65
+ /** Seams passed through to the storage selector (probeKeychain, run, keyslotDir, …). */
66
+ storage?: Partial<KeyStorageContext>;
67
+ enroll?: SetupEnroller;
68
+ createBoardClient?: (fileEnv: NodeJS.ProcessEnv) => BoardClient;
69
+ doctor?: (options: DoctorOptions) => Promise<DoctorReport>;
70
+ /** Seams passed through to doctor (createClient, probeKeystore, isAlive, …). */
71
+ doctorOptions?: Partial<DoctorOptions>;
72
+ /** Seams passed through to the harness writer (fs, claudeManagedDir). */
73
+ writer?: Partial<Pick<WriterContext, "fs" | "claudeManagedDir">>;
74
+ }
75
+ export declare const DEFAULT_SUDO = "/usr/bin/sudo";
76
+ export declare const SECRET_SERVICE_NAME = "org.freedesktop.secrets";
77
+ /** `busctl --user call … NameHasOwner`: asks the BUS whether the name is owned; never calls the service. */
78
+ export declare const NAME_HAS_OWNER_ARGS: readonly string[];
79
+ /**
80
+ * The keychain tool's install, one complete argv per package manager, first found wins. Each
81
+ * package name was re-checked against the distro's own index (throwaway containers, 2026-09-27):
82
+ * ubuntu 24.04 `libsecret-tools` ships ./usr/bin/secret-tool; fedora 44 `dnf repoquery
83
+ * --whatprovides /usr/bin/secret-tool` → libsecret; arch `pacman -F usr/bin/secret-tool` →
84
+ * core/libsecret; opensuse tumbleweed `zypper se --provides /usr/bin/secret-tool` → secret-tool
85
+ * (NOT libsecret-tools, which the plan text named).
86
+ */
87
+ export declare const KEYCHAIN_INSTALL_ARGV: ReadonlyArray<{
88
+ manager: string;
89
+ argv: readonly string[];
90
+ }>;
91
+ /** `<short hostname>-<harness>`, lowercased to `[a-z0-9-]`. */
92
+ export declare function defaultSlug(host: string, harness: string): string;
93
+ export declare function pendingMarkerPath(harness: string, env: NodeJS.ProcessEnv): string;
94
+ interface DepsContext {
95
+ env: NodeJS.ProcessEnv;
96
+ platform: NodeJS.Platform;
97
+ interactive: boolean;
98
+ installDeps: boolean;
99
+ run: ChildRunner;
100
+ sudo: string;
101
+ prompt: (question: string, options: {
102
+ secret: boolean;
103
+ }) => Promise<string>;
104
+ }
105
+ export declare function depsStep(ctx: DepsContext): Promise<DepsOutcome & {
106
+ fix?: string;
107
+ }>;
108
+ export declare function runSetup(options: SetupOptions, deps?: SetupDeps): Promise<SetupReport>;
109
+ export type SetupRunner = (options: SetupOptions) => Promise<SetupReport>;
110
+ interface SetupIo {
111
+ writeStdout(message: string): void;
112
+ writeStderr(message: string): void;
113
+ }
114
+ export declare function formatSetupReport(report: SetupReport): string;
115
+ export declare function addSetupCommand(program: Command, io: SetupIo, setup: SetupRunner): void;
116
+ export {};