@consiliency/agent-board-cli 1.24.0 → 1.26.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.
Files changed (37) hide show
  1. package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
  2. package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
  3. package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
  4. package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
  5. package/dist/assets.d.ts +26 -0
  6. package/dist/assets.js +70 -0
  7. package/dist/assets.json +6 -0
  8. package/dist/claude_marketplace.d.ts +117 -0
  9. package/dist/claude_marketplace.js +306 -0
  10. package/dist/cli.d.ts +37 -3
  11. package/dist/cli.js +68 -26
  12. package/dist/config_edit.d.ts +37 -0
  13. package/dist/config_edit.js +549 -0
  14. package/dist/doctor.d.ts +24 -2
  15. package/dist/doctor.js +164 -4
  16. package/dist/doctor_scopes.d.ts +34 -2
  17. package/dist/doctor_scopes.js +128 -20
  18. package/dist/identity_writer.d.ts +9 -7
  19. package/dist/identity_writer.js +18 -30
  20. package/dist/setup.d.ts +116 -0
  21. package/dist/setup.js +603 -0
  22. package/dist/setup_storage.d.ts +117 -0
  23. package/dist/setup_storage.js +447 -0
  24. package/dist/setup_writers.d.ts +147 -0
  25. package/dist/setup_writers.js +916 -0
  26. package/keyslot/agent-board-keyslot-load@.service +20 -0
  27. package/keyslot/agent-board-keyslot@.service +20 -0
  28. package/keyslot/constants.sh +73 -0
  29. package/keyslot/keygen.mjs +67 -0
  30. package/keyslot/keyslot-load.sh +78 -0
  31. package/keyslot/lib.sh +599 -0
  32. package/keyslot/optin.sh +185 -0
  33. package/keyslot/probe.sh +135 -0
  34. package/keyslot/provision.sh +530 -0
  35. package/keyslot/seam.sh +48 -0
  36. package/keyslot/status.sh +254 -0
  37. package/package.json +9 -4
package/dist/setup.js ADDED
@@ -0,0 +1,603 @@
1
+ import * as nodeFs from "node:fs";
2
+ import { hostname as osHostname } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import { createInterface } from "node:readline";
5
+ import { IDENTITY_HARNESSES, createAgentBoardClient, enrollCommitMarker, identityConfigRoot, identityFilePath, normalizeKeyReference, readIdentityFile, readSessionRecords, resolveCredentials, resolveEnrollEndpoint, resolveEnrollmentDescriptor, resolveHostSegment, walkTrustedDirectory, } from "@consiliency/agent-board-client";
6
+ import { bakedAssets as defaultBakedAssets, resolveKeyslotDir } from "./assets.js";
7
+ import { authoredCode, fileIdentityEnv, runDoctor } from "./doctor.js";
8
+ import { identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, writeOwnerOnlyFile } from "./identity_writer.js";
9
+ import { SLOT_RE, defaultStorageRunner, repairKeyDir, keyslotSocketDir, selectKeyStorage as defaultSelectKeyStorage, } from "./setup_storage.js";
10
+ import { SETUP_SCOPES, defaultChildRunner, installHarness, scopeRefusal, which, } from "./setup_writers.js";
11
+ export const SETUP_STEPS = Object.freeze(["preflight", "deps", "key", "enroll", "identity", "board", "harness", "doctor"]);
12
+ export const DEFAULT_SUDO = "/usr/bin/sudo";
13
+ export const SECRET_SERVICE_NAME = "org.freedesktop.secrets";
14
+ /** `busctl --user call … NameHasOwner`: asks the BUS whether the name is owned; never calls the service. */
15
+ export const NAME_HAS_OWNER_ARGS = Object.freeze(["--user", "call", "org.freedesktop.DBus", "/", "org.freedesktop.DBus", "NameHasOwner", "s", SECRET_SERVICE_NAME]);
16
+ /**
17
+ * The keychain tool's install, one complete argv per package manager, first found wins. Each
18
+ * package name was re-checked against the distro's own index (throwaway containers, 2026-09-27):
19
+ * ubuntu 24.04 `libsecret-tools` ships ./usr/bin/secret-tool; fedora 44 `dnf repoquery
20
+ * --whatprovides /usr/bin/secret-tool` → libsecret; arch `pacman -F usr/bin/secret-tool` →
21
+ * core/libsecret; opensuse tumbleweed `zypper se --provides /usr/bin/secret-tool` → secret-tool
22
+ * (NOT libsecret-tools, which the plan text named).
23
+ */
24
+ export const KEYCHAIN_INSTALL_ARGV = Object.freeze([
25
+ { manager: "apt-get", argv: Object.freeze(["apt-get", "install", "-y", "libsecret-tools"]) },
26
+ { manager: "dnf", argv: Object.freeze(["dnf", "install", "-y", "libsecret"]) },
27
+ { manager: "pacman", argv: Object.freeze(["pacman", "-S", "--needed", "--noconfirm", "libsecret"]) },
28
+ { manager: "zypper", argv: Object.freeze(["zypper", "--non-interactive", "install", "secret-tool"]) },
29
+ ]);
30
+ const SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
31
+ const SLUG_MAX = 63;
32
+ /** `<short hostname>-<harness>`, lowercased to `[a-z0-9-]`. */
33
+ export function defaultSlug(host, harness) {
34
+ const short = host.split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
35
+ return `${short || "host"}-${harness}`.slice(0, SLUG_MAX).replace(/-$/, "");
36
+ }
37
+ export function pendingMarkerPath(harness, env) {
38
+ return join(dirname(identityFilePath(harness, env)), `.setup-enroll-pending-${harness}`);
39
+ }
40
+ /**
41
+ * Codes raised before any token exists (the issuer refused or the device code lapsed), so no
42
+ * /enroll call can have happened: the marker is removed. Read through `authoredCode`, so only a
43
+ * code this repository minted counts.
44
+ */
45
+ const PRE_COMMIT_ENROLL_CODES = new Set(["device_code_expired", "device_token_failed", "device_poll_exhausted"]);
46
+ /**
47
+ * Deployment-config refusals: every one is raised while the runner resolves its configuration,
48
+ * before any request, so nothing can have committed. Preflight resolves the same configuration
49
+ * first; this set is the backstop for a runner that resolves something preflight did not.
50
+ */
51
+ const CONFIG_RESOLUTION_CODES = new Set([
52
+ "missing_enroll_endpoint", "missing_enrollment_field", "missing_client_identity", "missing_device_endpoint",
53
+ "missing_board_url", "unsupported_generation_target", "agent_socket_no_generation", "identity_incomplete", "identity_secret_value",
54
+ ]);
55
+ /** A key reference of the right shape for preflight's descriptor check; the key step supplies the real one. */
56
+ const PREFLIGHT_KEY_REFERENCE = "os_keychain:agent-board/preflight/check";
57
+ /** The DB's refusal of a taken slug, surfaced by /enroll as a 403 detail (foreign text, matched only). */
58
+ const SLUG_TAKEN_DETAIL = /already bound to a different identity/;
59
+ async function identityState(harness, env) {
60
+ // Existence first, by lstat alone: a directory the key step will repair (#536: a user-owned
61
+ // 0755 agent-board/) must not stop a host that has NO identity yet. Only an identity that EXISTS
62
+ // is read through the trusted open, and refused when that open refuses.
63
+ try {
64
+ nodeFs.lstatSync(identityFilePath(harness, env));
65
+ }
66
+ catch (error) {
67
+ const errno = error.code;
68
+ if (errno === "ENOENT" || errno === "ENOTDIR")
69
+ return { kind: "absent" };
70
+ }
71
+ try {
72
+ return { kind: "present", values: (await readIdentityFile({ harness, env })).values };
73
+ }
74
+ catch (error) {
75
+ const code = authoredCode(error, "identity_unreadable");
76
+ return code === "identity_absent" ? { kind: "absent" } : { kind: "unreadable", reason: code };
77
+ }
78
+ }
79
+ const exists = (path) => {
80
+ try {
81
+ nodeFs.lstatSync(path);
82
+ return true;
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ };
88
+ function defaultPrompt(question, options) {
89
+ return new Promise((resolve) => {
90
+ const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
91
+ if (options.secret) {
92
+ const writer = rl;
93
+ process.stderr.write(question);
94
+ writer._writeToOutput = () => { };
95
+ rl.question("", (answer) => { rl.close(); process.stderr.write("\n"); resolve(answer); });
96
+ }
97
+ else {
98
+ rl.question(question, (answer) => { rl.close(); resolve(answer); });
99
+ }
100
+ });
101
+ }
102
+ function defaultReadStdin() {
103
+ return new Promise((resolve) => {
104
+ let data = "";
105
+ process.stdin.setEncoding("utf8");
106
+ process.stdin.on("data", (chunk) => { data += chunk; });
107
+ process.stdin.on("end", () => resolve(data));
108
+ process.stdin.on("error", () => resolve(data));
109
+ });
110
+ }
111
+ function renderDevicePrompt(prompt) {
112
+ return [
113
+ "",
114
+ "=== Approve this agent in the Portal (device authorization) ===",
115
+ ` 1. Open: ${prompt.verificationUri}`,
116
+ ` 2. Enter code: ${prompt.userCode}`,
117
+ ...(prompt.verificationUriComplete ? [` (or open ${prompt.verificationUriComplete})`] : []),
118
+ "",
119
+ ].join("\n");
120
+ }
121
+ function defaultBoardClient(fileEnv) {
122
+ const client = createAgentBoardClient({ resolveCredentials: () => resolveCredentials(undefined, fileEnv) });
123
+ return { redeemInviteLink: (input) => client.redeemInviteLink(input) };
124
+ }
125
+ class Run {
126
+ steps = [];
127
+ notes = [];
128
+ record(step, status, reason, fix) {
129
+ const entry = { step, status, reason };
130
+ this.steps.push(entry);
131
+ if (fix)
132
+ this.notes.push(`${step}: ${fix}`);
133
+ return entry;
134
+ }
135
+ report() {
136
+ const statuses = this.steps.map((step) => step.status);
137
+ const exit = statuses.includes("failed") ? 1 : statuses.includes("undetermined") ? 2 : 0;
138
+ return { steps: this.steps, exit, notes: this.notes };
139
+ }
140
+ }
141
+ /** The exact command a user runs to install the keychain tool on this host, or null. */
142
+ function installArgv(env) {
143
+ return KEYCHAIN_INSTALL_ARGV.find((entry) => which(entry.manager, env) !== null)?.argv ?? null;
144
+ }
145
+ export async function depsStep(ctx) {
146
+ if (ctx.platform === "darwin")
147
+ return { status: "already", reason: "macos_security_builtin" };
148
+ if (ctx.platform !== "linux")
149
+ return { status: "skipped", reason: "platform_unsupported" };
150
+ // Detection first, and it starts nothing: only a name ALREADY owned on the session bus counts.
151
+ const busctl = which("busctl", ctx.env);
152
+ const owner = busctl === null ? null : await ctx.run(busctl, NAME_HAS_OWNER_ARGS, { env: ctx.env });
153
+ // busctl exits 0 for `b false` too: the reply is parsed, and only exactly `b true` counts.
154
+ const owned = owner !== null && owner.code === 0 && owner.stdout.replace(/\n$/, "") === "b true";
155
+ if (!owned)
156
+ return { status: "skipped", reason: "no_secret_service" };
157
+ if (which("secret-tool", ctx.env) !== null)
158
+ return { status: "already", reason: "secret_tool_present" };
159
+ const argv = installArgv(ctx.env);
160
+ if (argv === null)
161
+ return { status: "skipped", reason: "deps_no_package_manager", fix: "install the package that provides secret-tool with your package manager, then re-run setup" };
162
+ const command = `sudo -- ${argv.join(" ")}`;
163
+ let nonInteractive = false;
164
+ if (ctx.interactive) {
165
+ const answer = (await ctx.prompt(`The OS keychain needs secret-tool. Install it now with \`${command}\`? [y/N] `, { secret: false })).trim();
166
+ // Default No: this answer is the approval, since cached sudo credentials may never prompt.
167
+ if (!/^y(es)?$/i.test(answer))
168
+ return { status: "skipped", reason: "deps_declined", installCommand: command, fix: `run \`${command}\`, then re-run setup` };
169
+ }
170
+ else if (ctx.installDeps) {
171
+ nonInteractive = true;
172
+ }
173
+ else {
174
+ return { status: "skipped", reason: "deps_not_installed", installCommand: command, fix: `run \`${command}\` (or re-run setup with --install-deps), then re-run setup` };
175
+ }
176
+ const sudoArgs = nonInteractive ? ["-n", "--", ...argv] : ["--", ...argv];
177
+ const result = await ctx.run(ctx.sudo, sudoArgs, { env: ctx.env });
178
+ if (result.code !== 0 || which("secret-tool", ctx.env) === null) {
179
+ return { status: "skipped", reason: "deps_install_failed", installCommand: command, fix: `the install failed (a package index never updated on a fresh host is common: sudo apt-get update first); run \`${command}\` yourself, then re-run setup` };
180
+ }
181
+ return { status: "done", reason: "secret_tool_installed" };
182
+ }
183
+ // ---------------------------------------------------------------------------------------------
184
+ // The run
185
+ // ---------------------------------------------------------------------------------------------
186
+ function inviteFromText(text) {
187
+ return text.trim();
188
+ }
189
+ /** The keyslot a key reference's socket names, when it is one of `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`. */
190
+ function keyslotOf(reference, env) {
191
+ let handle;
192
+ try {
193
+ const normalized = normalizeKeyReference(reference);
194
+ if (normalized.resolver !== "agent_socket")
195
+ return null;
196
+ handle = String(normalized.handle);
197
+ }
198
+ catch {
199
+ return null;
200
+ }
201
+ const socket = handle.split("#")[0];
202
+ const dir = keyslotSocketDir(env);
203
+ if (!dir || dirname(socket) !== dir || !socket.endsWith(".sock"))
204
+ return null;
205
+ const slot = socket.slice(dir.length + 1, -".sock".length);
206
+ // Only a name the keyslot scripts accept ever reaches their argv.
207
+ return SLOT_RE.test(slot) ? slot : null;
208
+ }
209
+ export async function runSetup(options, deps = {}) {
210
+ const env = deps.env ?? process.env;
211
+ const cwd = deps.cwd ?? process.cwd();
212
+ const platform = deps.platform ?? process.platform;
213
+ const interactive = deps.interactive ?? process.stdin.isTTY === true;
214
+ const prompt = deps.prompt ?? defaultPrompt;
215
+ const run = deps.run ?? defaultChildRunner;
216
+ const out = new Run();
217
+ // ---- preflight --------------------------------------------------------------------------
218
+ const harness = options.harness;
219
+ if (!IDENTITY_HARNESSES.includes(harness)) {
220
+ out.record("preflight", "failed", "harness_invalid", `--harness must be one of ${IDENTITY_HARNESSES.join(", ")}`);
221
+ return out.report();
222
+ }
223
+ const h = harness;
224
+ let markerPath;
225
+ let identityPath;
226
+ try {
227
+ identityPath = identityFilePath(h, env);
228
+ markerPath = pendingMarkerPath(h, env);
229
+ }
230
+ catch (error) {
231
+ out.record("preflight", "failed", authoredCode(error, "identity_path_invalid"), "the identity file's path needs an absolute HOME/XDG_CONFIG_HOME and a valid host name (or MESSAGE_BOARD_WAKE_MACHINE_ID)");
232
+ return out.report();
233
+ }
234
+ if (options.clearPending) {
235
+ // Runs no step: only this harness's marker, only with no identity file.
236
+ if (exists(identityPath)) {
237
+ out.record("clear_pending", "failed", "identity_present", `${h} has an identity file, so its enrollment is not pending; nothing was removed`);
238
+ }
239
+ else if (exists(markerPath)) {
240
+ nodeFs.unlinkSync(markerPath);
241
+ out.record("clear_pending", "done", "marker_removed");
242
+ }
243
+ else {
244
+ out.record("clear_pending", "already", "no_marker");
245
+ }
246
+ return out.report();
247
+ }
248
+ const scope = (options.scope ?? "user");
249
+ if (!SETUP_SCOPES.includes(scope)) {
250
+ out.record("preflight", "failed", "scope_invalid", "--scope is user or local");
251
+ return out.report();
252
+ }
253
+ const refusal = scopeRefusal(h, scope, { env, cwd, fs: deps.writer?.fs, claudeManagedDir: deps.writer?.claudeManagedDir });
254
+ if (refusal) {
255
+ out.record("preflight", "failed", refusal.reason, refusal.fix);
256
+ return out.report();
257
+ }
258
+ const slug = options.slug ?? defaultSlug(resolveHostSegment(env) ?? (deps.hostname ?? osHostname)(), h);
259
+ if (!SLUG.test(slug) || slug.length > SLUG_MAX) {
260
+ out.record("preflight", "failed", "slug_invalid", `--slug must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be at most ${SLUG_MAX} characters`);
261
+ return out.report();
262
+ }
263
+ let assets;
264
+ try {
265
+ assets = (deps.bakedAssets ?? defaultBakedAssets)();
266
+ }
267
+ catch {
268
+ out.record("preflight", "failed", "assets_unbaked", "this cli build has no baked versions (dist/assets.json): reinstall @consiliency/agent-board-cli");
269
+ return out.report();
270
+ }
271
+ const identity = await identityState(h, env);
272
+ if (identity.kind === "unreadable") {
273
+ out.record("preflight", "failed", identity.reason, `a ${h} identity file EXISTS but cannot be read safely (its directory or the file is not private to you, or it does not parse): run \`agent-board doctor --harness ${h}\` and fix what it names; setup never enrolls over an existing identity (the Portal token is single-use)`);
274
+ return out.report();
275
+ }
276
+ if (identity.kind === "absent" && exists(markerPath)) {
277
+ out.record("preflight", "failed", "enrolled_without_identity", `an earlier setup started enrolling ${h} and did not write its identity file, so an agent MAY exist in the Portal. Check the Portal: if no agent was created, run \`agent-board setup --harness ${h} --clear-pending\` and re-run setup; if one was, revoke it first`);
278
+ return out.report();
279
+ }
280
+ let deployment = null;
281
+ if (identity.kind === "absent") {
282
+ // The WHOLE deployment the enrollment will read — the board group (identity values and the
283
+ // enroll endpoint) and the issuer group — resolved here, before deps, key and the marker, so an
284
+ // incomplete env (an operator shell with an identity but no enroll endpoint, say) refuses
285
+ // while nothing has run and nothing can have been sent.
286
+ try {
287
+ deployment = resolveIdentityDeployment(env);
288
+ resolveEnrollEndpoint(env);
289
+ const descriptor = resolveEnrollmentDescriptor(env, { keyReference: PREFLIGHT_KEY_REFERENCE });
290
+ if (!descriptor.issuer.deviceAuthorizationEndpoint) {
291
+ out.record("preflight", "failed", "missing_device_endpoint", "the deployment has no device authorization endpoint (MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
292
+ return out.report();
293
+ }
294
+ }
295
+ catch (error) {
296
+ out.record("preflight", "failed", authoredCode(error, "enrollment_config_invalid"), "the deployment values are incomplete: unset the partial MESSAGE_BOARD_ENROLL_* / AGENT_BOARD_* names to use the shipped defaults, or set all of them");
297
+ return out.report();
298
+ }
299
+ }
300
+ let invite = null;
301
+ if (options.invite) {
302
+ invite = interactive
303
+ ? inviteFromText(await prompt("Invite code (hidden): ", { secret: true }))
304
+ : inviteFromText(await (deps.readStdin ?? defaultReadStdin)());
305
+ if (invite === "") {
306
+ out.record("preflight", "failed", "invite_missing", "--invite reads the code from the terminal or stdin; none was given");
307
+ return out.report();
308
+ }
309
+ }
310
+ out.record("preflight", "done", "ready");
311
+ // ---- deps -------------------------------------------------------------------------------
312
+ // With an identity the key is already stored: nothing to install, and no prompt for nothing.
313
+ // With --agent-socket the user named their own agent, which wins over the keychain: nothing to offer.
314
+ const deps_ = identity.kind === "present"
315
+ ? { status: "skipped", reason: "identity_present" }
316
+ : options.agentSocket !== undefined
317
+ ? { status: "skipped", reason: "agent_socket_given" }
318
+ : await depsStep({ env, platform, interactive, installDeps: options.installDeps === true, run, sudo: deps.sudoPath ?? DEFAULT_SUDO, prompt });
319
+ out.record("deps", deps_.status, deps_.reason, deps_.fix);
320
+ // ---- key --------------------------------------------------------------------------------
321
+ let keyReference;
322
+ if (identity.kind === "present") {
323
+ keyReference = identity.values.AGENT_BOARD_AGENT_KEY_REFERENCE;
324
+ const slot = keyslotOf(keyReference, env);
325
+ if (slot === null) {
326
+ out.record("key", "already", "identity_present");
327
+ }
328
+ else {
329
+ const loader = await refreshLoader(h, slot, env, deps);
330
+ out.record("key", loader.status, loader.reason, loader.fix);
331
+ if (loader.status === "failed")
332
+ return out.report();
333
+ }
334
+ }
335
+ else {
336
+ // #536 as a named step, for EVERY storage: the identity file needs a private agent-board/ too,
337
+ // and the keychain route never reaches the key slot's own repair.
338
+ const repaired = repairKeyDir(env, deps.storage?.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1));
339
+ for (const repair of repaired.repairs)
340
+ out.notes.push(`key: repaired ${repair}`);
341
+ if (repaired.problems.length > 0) {
342
+ out.record("key", "failed", "unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): make each named directory yours (or root's) and not group/other-writable, then re-run setup`);
343
+ return out.report();
344
+ }
345
+ const selection = await (deps.selectKeyStorage ?? defaultSelectKeyStorage)({
346
+ harness: h, env, platform, deps: deps_,
347
+ ...(options.allowUnsealedKey === true ? { allowUnsealedKey: true } : {}),
348
+ ...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
349
+ ...deps.storage,
350
+ });
351
+ for (const repair of selection.repairs)
352
+ out.notes.push(`key: repaired ${repair}`);
353
+ if (selection.storage === "refused") {
354
+ out.record("key", "failed", selection.reason, selection.fix);
355
+ return out.report();
356
+ }
357
+ if (selection.storage === "os_keychain") {
358
+ keyReference = `os_keychain:agent-board/${resolveHostSegment(env) ?? slug}/${h}`;
359
+ }
360
+ else if (selection.keyReference) {
361
+ keyReference = selection.keyReference;
362
+ }
363
+ else {
364
+ out.record("key", "failed", "key_reference_missing", "the key storage named no reference");
365
+ return out.report();
366
+ }
367
+ out.record("key", "done", selection.reason);
368
+ }
369
+ // ---- enroll + identity --------------------------------------------------------------------
370
+ if (identity.kind === "present") {
371
+ out.record("enroll", "already", "identity_present");
372
+ out.record("identity", "already", "identity_present");
373
+ }
374
+ else {
375
+ // The descriptor again, now with the key step's REAL reference (preflight checked the rest of
376
+ // the configuration with a stand-in), still before the marker is written.
377
+ try {
378
+ const descriptor = resolveEnrollmentDescriptor(env, { keyReference });
379
+ if (!descriptor.issuer.deviceAuthorizationEndpoint) {
380
+ out.record("enroll", "failed", "missing_device_endpoint", "the deployment has no device authorization endpoint");
381
+ return out.report();
382
+ }
383
+ }
384
+ catch (error) {
385
+ out.record("enroll", "failed", authoredCode(error, "enrollment_descriptor_invalid"), "the enrollment deployment values are incomplete: unset the partial MESSAGE_BOARD_ENROLL_* issuer names to use the shipped defaults, or set all of them");
386
+ return out.report();
387
+ }
388
+ try {
389
+ const root = join(identityConfigRoot(env), "agent-board");
390
+ nodeFs.mkdirSync(dirname(identityPath), { recursive: true, mode: 0o700 });
391
+ walkTrustedDirectory(dirname(identityPath), root);
392
+ await writeOwnerOnlyFile(markerPath, `${new Date().toISOString()}\n`);
393
+ }
394
+ catch (error) {
395
+ out.record("enroll", "failed", authoredCode(error, "identity_path_untrusted"), "the identity directory is not private to you: $XDG_CONFIG_HOME (or ~/.config) and agent-board/ beneath it must be yours, not group/world-writable");
396
+ return out.report();
397
+ }
398
+ if (!deps.enroll) {
399
+ nodeFs.rmSync(markerPath, { force: true });
400
+ out.record("enroll", "failed", "enroll_runner_missing", "this build wires no enrollment runner into setup");
401
+ return out.report();
402
+ }
403
+ let outcome;
404
+ try {
405
+ outcome = await deps.enroll({
406
+ slug, keyReference, env,
407
+ intent: { kind: "none" },
408
+ onDeviceAuthorization: (devicePrompt) => { process.stderr.write(renderDevicePrompt(devicePrompt)); },
409
+ });
410
+ }
411
+ catch (error) {
412
+ const marker = enrollCommitMarker(error);
413
+ const code = authoredCode(error, "enroll_failed");
414
+ const status = error?.status;
415
+ const message = String(error?.message ?? "");
416
+ if (!marker && status === 403 && SLUG_TAKEN_DETAIL.test(message)) {
417
+ nodeFs.rmSync(markerPath, { force: true });
418
+ out.record("enroll", "failed", "slug_taken", `the slug ${slug} is taken by another agent: re-run with --slug <another>`);
419
+ }
420
+ else if (!marker && PRE_COMMIT_ENROLL_CODES.has(code)) {
421
+ nodeFs.rmSync(markerPath, { force: true });
422
+ out.record("enroll", "failed", code, "the device authorization was denied or expired before any agent was created: re-run setup");
423
+ }
424
+ else if (!marker && CONFIG_RESOLUTION_CODES.has(code)) {
425
+ nodeFs.rmSync(markerPath, { force: true });
426
+ out.record("enroll", "failed", code, "the enrollment configuration is incomplete, so nothing was sent: unset the partial MESSAGE_BOARD_ENROLL_* / AGENT_BOARD_* names to use the shipped defaults, or set all of them");
427
+ }
428
+ else {
429
+ out.record("enroll", "failed", marker?.state === "committed" ? "enroll_committed_incomplete" : "enroll_outcome_unknown", `whether the Portal created the agent is unknown. Check the Portal: if no agent exists, run \`agent-board setup --harness ${h} --clear-pending\` and re-run; if one does, revoke it first`);
430
+ }
431
+ return out.report();
432
+ }
433
+ out.record("enroll", "done", "enrolled");
434
+ try {
435
+ await writeIdentityFile(h, identityValuesFromEnrollment(outcome, deployment), env);
436
+ }
437
+ catch (error) {
438
+ out.record("identity", "failed", "identity_write_failed_after_commit", `the agent was enrolled (${outcome.agentHandle}) but its identity file was not written (${authoredCode(error, "unknown")}). Do NOT re-run the enrollment: revoke that agent's key in the Portal, then run setup --clear-pending and re-run`);
439
+ return out.report();
440
+ }
441
+ nodeFs.rmSync(markerPath, { force: true });
442
+ out.record("identity", "done", "written");
443
+ }
444
+ // ---- board ------------------------------------------------------------------------------
445
+ // Always the identity file just written, never ambient env: the invite is single-use.
446
+ const written = await identityState(h, env);
447
+ if (written.kind !== "present") {
448
+ out.record("board", "failed", written.kind === "unreadable" ? written.reason : "identity_absent");
449
+ return out.report();
450
+ }
451
+ const fileEnv = fileIdentityEnv(env, written.values);
452
+ let boardSkipped = false;
453
+ if (invite === null) {
454
+ boardSkipped = true;
455
+ out.record("board", "skipped", "no_invite");
456
+ }
457
+ else {
458
+ try {
459
+ const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite });
460
+ if (result?.ok === false)
461
+ throw result;
462
+ out.record("board", "done", "joined");
463
+ }
464
+ catch (error) {
465
+ out.record("board", "failed", authoredCode(error, "invite_redeem_failed"), "the invite was not redeemed: ask the board owner for a fresh invite, then `agent-board --as " + h + " rooms link-redeem` (code on stdin)");
466
+ return out.report();
467
+ }
468
+ }
469
+ // ---- harness ----------------------------------------------------------------------------
470
+ const installed = await installHarness({
471
+ harness: h, scope, assets, env, cwd, keyReference: written.values.AGENT_BOARD_AGENT_KEY_REFERENCE ?? null, followSymlinks: options.followSymlinks !== false, run,
472
+ ...deps.writer,
473
+ });
474
+ out.record("harness", installed.status, installed.reason, installed.fix);
475
+ if (installed.status === "failed")
476
+ return out.report();
477
+ // ---- doctor -----------------------------------------------------------------------------
478
+ let report;
479
+ try {
480
+ report = await (deps.doctor ?? runDoctor)({ harness: h, env, cwd, ...deps.doctorOptions });
481
+ }
482
+ catch (error) {
483
+ out.record("doctor", "undetermined", authoredCode(error, "doctor_threw"));
484
+ return out.report();
485
+ }
486
+ // The runtime expectation is "nothing is running": the reason alone is not enough, since a live
487
+ // probe or tier0 record also leaves it. Read with doctor's own reader and liveness test.
488
+ let liveRecords = 0;
489
+ try {
490
+ liveRecords = readSessionRecords(h, { ...(deps.doctorOptions?.trust ?? {}), env, isAlive: deps.doctorOptions?.isAlive })
491
+ .filter((entry) => entry.status === "live").length;
492
+ }
493
+ catch {
494
+ liveRecords = -1;
495
+ }
496
+ const expected = [];
497
+ const failures = [];
498
+ const unknowns = [];
499
+ for (const [name, check] of Object.entries(report.checks)) {
500
+ if (check.status === "pass")
501
+ continue;
502
+ if (name === "runtime" && check.status === "undetermined" && check.reason === "no_live_wake_producer" && liveRecords === 0) {
503
+ expected.push(`runtime=${check.reason}`);
504
+ }
505
+ else if (name === "board_membership" && boardSkipped && check.status === "fail" && check.reason === "no_membership") {
506
+ expected.push(`board_membership=${check.reason}`);
507
+ }
508
+ else if (check.status === "fail")
509
+ failures.push(name);
510
+ else
511
+ unknowns.push(name);
512
+ }
513
+ for (const item of expected)
514
+ out.notes.push(`doctor: expected ${item.replace("=", ": ")}`);
515
+ if (failures.length > 0)
516
+ out.record("doctor", "failed", `doctor_failed:${failures.join("+")}`, `run \`agent-board doctor --harness ${h}\` for each check's reason`);
517
+ else if (unknowns.length > 0)
518
+ out.record("doctor", "undetermined", `doctor_undetermined:${unknowns.join("+")}`, `run \`agent-board doctor --harness ${h}\` for each check's reason`);
519
+ else
520
+ out.record("doctor", "done", expected.length > 0 ? `expected:${expected.join(",")}` : "passed");
521
+ out.notes.push(`next: start ${h} in ${scope === "local" ? "this directory" : "any directory"}, then run \`agent-board doctor --harness ${h}\``);
522
+ if (boardSkipped)
523
+ out.notes.push(`next: to join a board, ask its owner for an invite link, then run \`agent-board --as ${h} rooms link-redeem\` with the code on stdin`);
524
+ return out.report();
525
+ }
526
+ /**
527
+ * The keyslot loader refresh (A1): `status.sh` names the recorded and the running loader version;
528
+ * when they differ, `provision.sh --refresh-loader` (which never re-keys) points the slot at the
529
+ * running one, and the measurement is taken again.
530
+ */
531
+ async function refreshLoader(harness, slot, env, deps) {
532
+ const runStorage = deps.storage?.run ?? defaultStorageRunner;
533
+ let dir;
534
+ try {
535
+ dir = deps.storage?.keyslotDir ?? resolveKeyslotDir();
536
+ }
537
+ catch {
538
+ return { status: "undetermined", reason: "keyslot_scripts_absent", fix: "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli" };
539
+ }
540
+ const measure = async () => {
541
+ const status = await runStorage(join(dir, "status.sh"), ["--harness", harness, "--slot", slot, "--json"], { env });
542
+ try {
543
+ const loader = JSON.parse(status.stdout).loader;
544
+ return loader ? { recorded: loader.recorded_sha256, running: loader.running_sha256 } : null;
545
+ }
546
+ catch {
547
+ return null;
548
+ }
549
+ };
550
+ const before = await measure();
551
+ if (before === null || typeof before.running !== "string")
552
+ return { status: "undetermined", reason: "loader_unmeasured", fix: "status.sh did not report the loader: run the packaged status.sh --json" };
553
+ if (before.recorded === before.running)
554
+ return { status: "already", reason: "identity_present" };
555
+ const refresh = await runStorage(join(dir, "provision.sh"), ["--harness", harness, "--slot", slot, "--refresh-loader"], { env });
556
+ if (refresh.code !== 0)
557
+ return { status: "failed", reason: "loader_refresh_failed", fix: "provision.sh --refresh-loader failed: run it by hand and read what it reports" };
558
+ const after = await measure();
559
+ if (after === null || after.recorded !== after.running)
560
+ return { status: "failed", reason: "loader_refresh_unverified", fix: "after --refresh-loader the slot still runs another loader version: run the packaged status.sh --json" };
561
+ return { status: "done", reason: "loader_refreshed" };
562
+ }
563
+ export function formatSetupReport(report) {
564
+ return [...report.steps.map((step) => `${step.status.padEnd(12)} ${step.step}: ${step.reason}`), ...report.notes, `exit ${report.exit}`].join("\n");
565
+ }
566
+ export function addSetupCommand(program, io, setup) {
567
+ program
568
+ .command("setup")
569
+ .description("set this host up for one harness: key storage, enrollment, identity file, optional board invite, harness registration, then doctor")
570
+ // Not `requiredOption`: commander's own usage exit would bypass the report (#402).
571
+ .option("--harness <harness>", "the harness to set up (agy, claude, codex, grok, opencode, pi)")
572
+ .option("--slug <slug>", "the agent slug (default <short hostname>-<harness>)")
573
+ .option("--invite", "read a board invite code from the terminal or stdin (never argv) and redeem it after enrolling")
574
+ .option("--allow-unsealed-key", "accept an unsealed 0600 key under an opt-in record on a host that cannot seal")
575
+ .option("--agent-socket <ref>", "use your own SSH agent (with an external-agent declaration)")
576
+ .option("--scope <scope>", "user (default) or local (this directory)")
577
+ .option("--install-deps", "non-interactive: install the OS keychain tool with sudo -n when a Secret Service is running")
578
+ .option("--clear-pending", "remove this harness's pending-enrollment marker after confirming in the Portal that no agent was created; runs no step")
579
+ .option("--no-follow-symlinks", "refuse to write through a symlinked harness config")
580
+ .option("--json", "print the steps and exit code as JSON")
581
+ .action(async (options) => {
582
+ const report = await setup({
583
+ harness: String(options.harness ?? ""),
584
+ slug: options.slug,
585
+ invite: options.invite === true,
586
+ allowUnsealedKey: options.allowUnsealedKey === true,
587
+ agentSocket: options.agentSocket,
588
+ scope: options.scope,
589
+ installDeps: options.installDeps === true,
590
+ clearPending: options.clearPending === true,
591
+ followSymlinks: options.followSymlinks !== false,
592
+ });
593
+ if (options.json) {
594
+ io.writeStdout(`${JSON.stringify({ steps: report.steps, exit: report.exit })}\n`);
595
+ if (report.notes.length > 0)
596
+ io.writeStderr(`${report.notes.join("\n")}\n`);
597
+ }
598
+ else {
599
+ io.writeStdout(`${formatSetupReport(report)}\n`);
600
+ }
601
+ process.exitCode = report.exit;
602
+ });
603
+ }