@consiliency/agent-board-cli 1.27.1 → 1.29.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/setup.js CHANGED
@@ -2,11 +2,13 @@ import * as nodeFs from "node:fs";
2
2
  import { hostname as osHostname } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
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";
5
+ import { IDENTITY_HARNESSES, acquireEnrollmentLock, createAgentBoardClient, displayableInviteLabel, enrollCommitMarker, formatPendingEnrollmentMarker, identityConfigRoot, identityFilePath, listHarnessProcesses, normalizeKeyReference, parseInviteInput, pendingEnrollmentMarkerExists, pendingEnrollmentMarkerPath, readIdentityFile, readSessionRecords, resolveCredentials, resolveEnrollEndpoint, resolveEnrollmentDescriptor, resolveHostSegment, walkTrustedDirectory, } from "@consiliency/agent-board-client";
6
6
  import { bakedAssets as defaultBakedAssets, resolveKeyslotDir } from "./assets.js";
7
+ import { defaultStorageRunner } from "./keyslot_status.js";
7
8
  import { authoredCode, fileIdentityEnv, runDoctor } from "./doctor.js";
8
9
  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 { cacheStateOf, measureKeyslot } from "./keyslot_status.js";
11
+ import { SLOT_RE, finalizeKeyStorage, measureKeyStorage, repairKeyDir, keyslotSocketDir, prepareKeyStorage as defaultPrepareKeyStorage, } from "./setup_storage.js";
10
12
  import { SETUP_SCOPES, defaultChildRunner, installHarness, scopeRefusal, which, } from "./setup_writers.js";
11
13
  export const SETUP_STEPS = Object.freeze(["preflight", "deps", "key", "enroll", "identity", "board", "harness", "doctor"]);
12
14
  export const DEFAULT_SUDO = "/usr/bin/sudo";
@@ -34,9 +36,6 @@ export function defaultSlug(host, harness) {
34
36
  const short = host.split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
35
37
  return `${short || "host"}-${harness}`.slice(0, SLUG_MAX).replace(/-$/, "");
36
38
  }
37
- export function pendingMarkerPath(harness, env) {
38
- return join(dirname(identityFilePath(harness, env)), `.setup-enroll-pending-${harness}`);
39
- }
40
39
  /**
41
40
  * Codes raised before any token exists (the issuer refused or the device code lapsed), so no
42
41
  * /enroll call can have happened: the marker is removed. Read through `authoredCode`, so only a
@@ -125,6 +124,7 @@ function defaultBoardClient(fileEnv) {
125
124
  class Run {
126
125
  steps = [];
127
126
  notes = [];
127
+ staleSessions = [];
128
128
  record(step, status, reason, fix) {
129
129
  const entry = { step, status, reason };
130
130
  this.steps.push(entry);
@@ -135,7 +135,7 @@ class Run {
135
135
  report() {
136
136
  const statuses = this.steps.map((step) => step.status);
137
137
  const exit = statuses.includes("failed") ? 1 : statuses.includes("undetermined") ? 2 : 0;
138
- return { steps: this.steps, exit, notes: this.notes };
138
+ return { steps: this.steps, exit, notes: this.notes, staleSessions: this.staleSessions };
139
139
  }
140
140
  }
141
141
  /** The exact command a user runs to install the keychain tool on this host, or null. */
@@ -183,8 +183,76 @@ export async function depsStep(ctx) {
183
183
  // ---------------------------------------------------------------------------------------------
184
184
  // The run
185
185
  // ---------------------------------------------------------------------------------------------
186
- function inviteFromText(text) {
187
- return text.trim();
186
+ /** The most of an invite file setup reads: a rendered message is a few KiB. */
187
+ const INVITE_FILE_MAX_BYTES = 65_536;
188
+ /**
189
+ * IF-0-ONBOARD-2: the invite, read ONCE and parsed by the shared parser. `--invite-file` and a
190
+ * piped stdin may hold the whole invite message; a terminal takes one hidden line that must be a
191
+ * bare code. The code never reaches argv, a log or any message: a refusal names only its reason.
192
+ */
193
+ export async function readSetupInvite(input, deps) {
194
+ const interactive = deps.interactive ?? process.stdin.isTTY === true;
195
+ if (input.invite === true && input.inviteFile !== undefined) {
196
+ return { reason: "invite_flags_conflict", fix: "pass --invite or --invite-file <path>, not both" };
197
+ }
198
+ let text;
199
+ let tty = false;
200
+ if (input.inviteFile !== undefined) {
201
+ try {
202
+ const fd = nodeFs.openSync(input.inviteFile, "r");
203
+ try {
204
+ const buffer = Buffer.alloc(INVITE_FILE_MAX_BYTES + 1);
205
+ const read = nodeFs.readSync(fd, buffer, 0, buffer.length, 0);
206
+ if (read > INVITE_FILE_MAX_BYTES)
207
+ return { reason: "invite_file_too_large", fix: `--invite-file holds more than ${INVITE_FILE_MAX_BYTES} bytes: save only the invite message to it` };
208
+ text = buffer.subarray(0, read).toString("utf8");
209
+ }
210
+ finally {
211
+ nodeFs.closeSync(fd);
212
+ }
213
+ }
214
+ catch {
215
+ return { reason: "invite_file_unreadable", fix: "--invite-file names a file setup cannot read: save the invite message to a file you can read and pass its path" };
216
+ }
217
+ }
218
+ else if (input.invite === true) {
219
+ tty = interactive;
220
+ text = interactive ? await (deps.prompt ?? defaultPrompt)("Invite code (hidden): ", { secret: true }) : await (deps.readStdin ?? defaultReadStdin)();
221
+ }
222
+ else {
223
+ return { invite: null };
224
+ }
225
+ if (text.trim() === "") {
226
+ return { reason: "invite_missing", fix: "--invite reads the code from the terminal or stdin, and --invite-file from a file; none was given" };
227
+ }
228
+ try {
229
+ return { invite: parseInviteInput(text, { tty }) };
230
+ }
231
+ catch (error) {
232
+ // The parser's messages carry no byte of the input (IF-0-ONBOARD-7).
233
+ return { reason: authoredCode(error, "invite_unsupported_form"), fix: String(error?.message ?? "the invite could not be read") };
234
+ }
235
+ }
236
+ /** A label from the message, printable: control characters (an escape sequence, say) removed. */
237
+ const printable = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
238
+ /** IF-0-ONBOARD-2 "Board": reported, never checked (setup has no target board). Never the code. */
239
+ export function inviteBoardNote(invite) {
240
+ // The client's display rule, which re-checks the FINAL text for the code (the parser's own check
241
+ // is not relied on alone).
242
+ const label = displayableInviteLabel(invite);
243
+ if (label !== null)
244
+ return `preflight: the invite is for board "${label}" (as the invite message says; the redeem names the board joined)`;
245
+ if (invite.labelSuppressed || invite.boardLabel !== null)
246
+ return "preflight: the invite's board label contains its code, so it is not shown; the redeem names the board joined";
247
+ return "preflight: the invite names no board; the redeem names the board joined";
248
+ }
249
+ /**
250
+ * The redeem RPC's one uniform refusal (`42501 invite is not usable`) covers exhausted, revoked,
251
+ * expired, creator-suspended, ceiling and per-agent refusals ALIKE, and setup never tells them apart
252
+ * (IF-0-ONBOARD-1): every one is `invite_unusable`. Anything else keeps the authored code.
253
+ */
254
+ function redeemRefusal(error) {
255
+ return error?.code === "42501" ? "invite_unusable" : authoredCode(error, "invite_redeem_failed");
188
256
  }
189
257
  /** The keyslot a key reference's socket names, when it is one of `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`. */
190
258
  function keyslotOf(reference, env) {
@@ -206,7 +274,16 @@ function keyslotOf(reference, env) {
206
274
  // Only a name the keyslot scripts accept ever reaches their argv.
207
275
  return SLOT_RE.test(slot) ? slot : null;
208
276
  }
209
- export async function runSetup(options, deps = {}) {
277
+ /** The storage context the key step's scan reads (env, runner, budgets, notices). */
278
+ export function setupStorageContext(deps) {
279
+ return { env: deps.env ?? process.env, ...deps.storage };
280
+ }
281
+ /**
282
+ * Preflight, deps and the key PREPARATION for one harness. `preset.invite` is an invite the caller
283
+ * already read and parsed (`setup --all` reads it once for every pair); without it, the invite is
284
+ * read here.
285
+ */
286
+ export async function startSetupPair(options, deps = {}, preset) {
210
287
  const env = deps.env ?? process.env;
211
288
  const cwd = deps.cwd ?? process.cwd();
212
289
  const platform = deps.platform ?? process.platform;
@@ -214,51 +291,72 @@ export async function runSetup(options, deps = {}) {
214
291
  const prompt = deps.prompt ?? defaultPrompt;
215
292
  const run = deps.run ?? defaultChildRunner;
216
293
  const out = new Run();
294
+ const stopped = () => ({ kind: "stopped", report: out.report() });
217
295
  // ---- preflight --------------------------------------------------------------------------
218
296
  const harness = options.harness;
219
297
  if (!IDENTITY_HARNESSES.includes(harness)) {
220
298
  out.record("preflight", "failed", "harness_invalid", `--harness must be one of ${IDENTITY_HARNESSES.join(", ")}`);
221
- return out.report();
299
+ return stopped();
222
300
  }
223
301
  const h = harness;
224
302
  let markerPath;
225
303
  let identityPath;
226
304
  try {
227
305
  identityPath = identityFilePath(h, env);
228
- markerPath = pendingMarkerPath(h, env);
306
+ markerPath = pendingEnrollmentMarkerPath(h, env);
229
307
  }
230
308
  catch (error) {
231
309
  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();
310
+ return stopped();
233
311
  }
234
312
  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`);
313
+ // Runs no step: only this harness's marker, only with no identity file, and only under the
314
+ // enrollment lock. A marker whose enrollment is STILL RUNNING (a setup waiting on the device
315
+ // approval, or an in-chat sign-in) is live: the Portal shows nothing yet, and deleting it would
316
+ // leave a lost /enroll response with no record (phase review NEW-1). Such an enrollment holds
317
+ // the lock, so a held lock refuses and nothing is removed.
318
+ let lock;
319
+ try {
320
+ lock = await acquireEnrollmentLock(h, env);
238
321
  }
239
- else if (exists(markerPath)) {
240
- nodeFs.unlinkSync(markerPath);
241
- out.record("clear_pending", "done", "marker_removed");
322
+ catch (error) {
323
+ const code = authoredCode(error, "enroll_lock_failed");
324
+ out.record("clear_pending", "failed", code, code === "enroll_in_progress"
325
+ ? `an enrollment of ${h} is running on this host right now (a setup waiting for approval, or an in-chat sign-in), so its marker is live and nothing was removed: finish or abandon that enrollment first`
326
+ : `setup could not take ${h}'s enrollment lock in its identity directory, so nothing was removed: run \`agent-board doctor --harness ${h}\` and fix what it names`);
327
+ return stopped();
242
328
  }
243
- else {
244
- out.record("clear_pending", "already", "no_marker");
329
+ try {
330
+ if (exists(identityPath)) {
331
+ out.record("clear_pending", "failed", "identity_present", `${h} has an identity file, so its enrollment is not pending; nothing was removed`);
332
+ }
333
+ else if (pendingEnrollmentMarkerExists(h, env)) {
334
+ nodeFs.unlinkSync(markerPath);
335
+ out.record("clear_pending", "done", "marker_removed");
336
+ }
337
+ else {
338
+ out.record("clear_pending", "already", "no_marker");
339
+ }
245
340
  }
246
- return out.report();
341
+ finally {
342
+ await lock.release();
343
+ }
344
+ return stopped();
247
345
  }
248
346
  const scope = (options.scope ?? "user");
249
347
  if (!SETUP_SCOPES.includes(scope)) {
250
348
  out.record("preflight", "failed", "scope_invalid", "--scope is user or local");
251
- return out.report();
349
+ return stopped();
252
350
  }
253
351
  const refusal = scopeRefusal(h, scope, { env, cwd, fs: deps.writer?.fs, claudeManagedDir: deps.writer?.claudeManagedDir });
254
352
  if (refusal) {
255
353
  out.record("preflight", "failed", refusal.reason, refusal.fix);
256
- return out.report();
354
+ return stopped();
257
355
  }
258
356
  const slug = options.slug ?? defaultSlug(resolveHostSegment(env) ?? (deps.hostname ?? osHostname)(), h);
259
357
  if (!SLUG.test(slug) || slug.length > SLUG_MAX) {
260
358
  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();
359
+ return stopped();
262
360
  }
263
361
  let assets;
264
362
  try {
@@ -266,16 +364,18 @@ export async function runSetup(options, deps = {}) {
266
364
  }
267
365
  catch {
268
366
  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();
367
+ return stopped();
270
368
  }
271
369
  const identity = await identityState(h, env);
272
370
  if (identity.kind === "unreadable") {
273
371
  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();
372
+ return stopped();
275
373
  }
276
- if (identity.kind === "absent" && exists(markerPath)) {
374
+ // IF-0-ONBOARD-9: the marker's EXISTENCE decides, whatever it holds (the in-chat form adds a
375
+ // `key_ref=` line; setup never reads it).
376
+ if (identity.kind === "absent" && pendingEnrollmentMarkerExists(h, env)) {
277
377
  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();
378
+ return stopped();
279
379
  }
280
380
  let deployment = null;
281
381
  if (identity.kind === "absent") {
@@ -289,23 +389,27 @@ export async function runSetup(options, deps = {}) {
289
389
  const descriptor = resolveEnrollmentDescriptor(env, { keyReference: PREFLIGHT_KEY_REFERENCE });
290
390
  if (!descriptor.issuer.deviceAuthorizationEndpoint) {
291
391
  out.record("preflight", "failed", "missing_device_endpoint", "the deployment has no device authorization endpoint (MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
292
- return out.report();
392
+ return stopped();
293
393
  }
294
394
  }
295
395
  catch (error) {
296
396
  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();
397
+ return stopped();
298
398
  }
299
399
  }
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();
400
+ let invite;
401
+ if (preset) {
402
+ invite = preset.invite;
403
+ }
404
+ else {
405
+ const read = await readSetupInvite(options, deps);
406
+ if (!("invite" in read)) {
407
+ out.record("preflight", "failed", read.reason, read.fix);
408
+ return stopped();
308
409
  }
410
+ invite = read.invite;
411
+ if (invite !== null)
412
+ out.notes.push(inviteBoardNote(invite));
309
413
  }
310
414
  out.record("preflight", "done", "ready");
311
415
  // ---- deps -------------------------------------------------------------------------------
@@ -317,8 +421,43 @@ export async function runSetup(options, deps = {}) {
317
421
  ? { status: "skipped", reason: "agent_socket_given" }
318
422
  : await depsStep({ env, platform, interactive, installDeps: options.installDeps === true, run, sudo: deps.sudoPath ?? DEFAULT_SUDO, prompt });
319
423
  out.record("deps", deps_.status, deps_.reason, deps_.fix);
424
+ // ---- key (preparation) --------------------------------------------------------------------
425
+ const state = { out, options, deps, env, cwd, scope, assets, identity, deployment, invite, identityPath, markerPath };
426
+ if (identity.kind === "present")
427
+ return { kind: "started", pair: { harness: h, slug, prepared: null, state } };
428
+ // #536 as a named step, for EVERY storage: the identity file needs a private agent-board/ too,
429
+ // and the keychain route never reaches the key slot's own repair.
430
+ const repaired = repairKeyDir(env, deps.storage?.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1));
431
+ for (const repair of repaired.repairs)
432
+ out.notes.push(`key: repaired ${repair}`);
433
+ if (repaired.problems.length > 0) {
434
+ 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`);
435
+ return stopped();
436
+ }
437
+ const ctx = {
438
+ harness: h, env, platform, deps: deps_,
439
+ ...(options.allowUnsealedKey === true ? { allowUnsealedKey: true } : {}),
440
+ ...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
441
+ ...deps.storage,
442
+ };
443
+ // A caller that injects a whole selector gets it as a final selection (it did its own scan).
444
+ const prepared = deps.selectKeyStorage
445
+ ? { kind: "selected", harness: h, selection: await deps.selectKeyStorage(ctx) }
446
+ : await (deps.prepareKeyStorage ?? defaultPrepareKeyStorage)(ctx);
447
+ return { kind: "started", pair: { harness: h, slug, prepared, state } };
448
+ }
449
+ /**
450
+ * The rest of one pair, from its FINAL key storage (`finalizeKeyStorage`'s answer for this pair;
451
+ * null for a pair whose identity exists): enroll, identity, board, harness, doctor.
452
+ */
453
+ export async function finishSetupPair(pair, selection) {
454
+ const { out, options, deps, env, cwd, scope, assets, identity, deployment, invite, identityPath, markerPath } = pair.state;
455
+ const h = pair.harness;
456
+ const slug = pair.slug;
457
+ const run = deps.run ?? defaultChildRunner;
320
458
  // ---- key --------------------------------------------------------------------------------
321
459
  let keyReference;
460
+ let keyScan;
322
461
  if (identity.kind === "present") {
323
462
  keyReference = identity.values.AGENT_BOARD_AGENT_KEY_REFERENCE;
324
463
  const slot = keyslotOf(keyReference, env);
@@ -333,27 +472,27 @@ export async function runSetup(options, deps = {}) {
333
472
  }
334
473
  }
335
474
  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`);
475
+ if (selection === null) {
476
+ out.record("key", "failed", "scan_not_completed", "no key storage was finalized for this harness, so it is NOT enrolled: re-run setup");
343
477
  return out.report();
344
478
  }
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
479
  for (const repair of selection.repairs)
352
480
  out.notes.push(`key: repaired ${repair}`);
353
481
  if (selection.storage === "refused") {
354
- out.record("key", "failed", selection.reason, selection.fix);
482
+ // #552: a scan that gave up is undetermined (2); every other refusal, a found copy included, is 1.
483
+ out.record("key", selection.status ?? "failed", selection.reason, selection.fix);
355
484
  return out.report();
356
485
  }
486
+ if ((selection.storage === "sealed" || selection.storage === "unsealed") && selection.slot) {
487
+ // #552 IF-552-3 "Re-read before enrolling": a tombstone written after (or during) this step's
488
+ // scan stops setup here, before any enrollment call.
489
+ const reread = await rereadBeforeEnroll(h, selection.slot, env, deps, selection.keyScan);
490
+ if (reread) {
491
+ out.record("key", "failed", reread.reason, reread.fix);
492
+ return out.report();
493
+ }
494
+ keyScan = selection.keyScan;
495
+ }
357
496
  if (selection.storage === "os_keychain") {
358
497
  keyReference = `os_keychain:agent-board/${resolveHostSegment(env) ?? slug}/${h}`;
359
498
  }
@@ -389,7 +528,57 @@ export async function runSetup(options, deps = {}) {
389
528
  const root = join(identityConfigRoot(env), "agent-board");
390
529
  nodeFs.mkdirSync(dirname(identityPath), { recursive: true, mode: 0o700 });
391
530
  walkTrustedDirectory(dirname(identityPath), root);
392
- await writeOwnerOnlyFile(markerPath, `${new Date().toISOString()}\n`);
531
+ }
532
+ catch (error) {
533
+ 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");
534
+ return out.report();
535
+ }
536
+ // The (host, harness) enrollment lock the MCP in-chat enrollment holds for ITS whole sign-in
537
+ // (IF-0-ONBOARD-9), held here from before the marker until after the identity write and the
538
+ // marker clear. Preflight's identity and marker reads are stale by now: an in-chat enrollment
539
+ // may have finished (or started) since, so both are read AGAIN under the lock (phase review,
540
+ // codex P1: setup overwrote an identity chat enrollment had just written).
541
+ let lock;
542
+ try {
543
+ lock = await acquireEnrollmentLock(h, env);
544
+ }
545
+ catch (error) {
546
+ const code = authoredCode(error, "enroll_lock_failed");
547
+ out.record("enroll", "failed", code, code === "enroll_in_progress"
548
+ ? `another ${h} session on this host is enrolling right now (an in-chat sign-in, or another setup): finish or abandon it, then re-run setup; nothing was enrolled or written here`
549
+ : "setup could not take this harness's enrollment lock in its identity directory: run `agent-board doctor --harness " + h + "` and fix what it names, then re-run setup");
550
+ return out.report();
551
+ }
552
+ try {
553
+ const enrolled = await enrollUnderLock();
554
+ if (enrolled !== null)
555
+ return enrolled;
556
+ }
557
+ finally {
558
+ await lock.release();
559
+ }
560
+ }
561
+ /** The enrollment proper, with the lock held. null = continue to the board step. */
562
+ async function enrollUnderLock() {
563
+ const current = await identityState(h, env);
564
+ if (current.kind === "present") {
565
+ // Enrolled by someone else since preflight: never enroll over it, never overwrite it.
566
+ out.record("enroll", "already", "identity_present");
567
+ out.record("identity", "already", "identity_present");
568
+ out.notes.push(`enroll: ${h} was enrolled by another session (an in-chat enrollment) after setup started; its identity file is kept, and the key setup prepared is not used`);
569
+ keyScan = undefined;
570
+ return null;
571
+ }
572
+ if (current.kind === "unreadable") {
573
+ out.record("enroll", "failed", current.reason, `a ${h} identity file appeared after setup started and cannot be read safely: run \`agent-board doctor --harness ${h}\`; nothing was enrolled`);
574
+ return out.report();
575
+ }
576
+ if (pendingEnrollmentMarkerExists(h, env)) {
577
+ out.record("enroll", "failed", "enrolled_without_identity", `an enrollment of ${h} started after setup did (a pending-enrollment marker appeared), 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`);
578
+ return out.report();
579
+ }
580
+ try {
581
+ await writeOwnerOnlyFile(markerPath, formatPendingEnrollmentMarker(new Date()));
393
582
  }
394
583
  catch (error) {
395
584
  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");
@@ -440,6 +629,7 @@ export async function runSetup(options, deps = {}) {
440
629
  }
441
630
  nodeFs.rmSync(markerPath, { force: true });
442
631
  out.record("identity", "done", "written");
632
+ return null;
443
633
  }
444
634
  // ---- board ------------------------------------------------------------------------------
445
635
  // Always the identity file just written, never ambient env: the invite is single-use.
@@ -456,17 +646,26 @@ export async function runSetup(options, deps = {}) {
456
646
  }
457
647
  else {
458
648
  try {
459
- const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite });
649
+ const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite.code });
460
650
  if (result?.ok === false)
461
651
  throw result;
462
652
  out.record("board", "done", "joined");
653
+ // IF-0-ONBOARD-2 "Board": the redeem result names the board actually joined.
654
+ const joined = result?.data?.[0]?.board_id;
655
+ if (typeof joined === "string")
656
+ out.notes.push(`board: joined board ${printable(joined)}`);
463
657
  }
464
658
  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)");
659
+ const reason = redeemRefusal(error);
660
+ out.record("board", "failed", reason, reason === "invite_unusable"
661
+ ? `the board refused the invite for this agent (used up, revoked, expired, or not usable by this agent: the board does not say which): ask the board owner for a new invite, then \`agent-board --as ${h} rooms link-redeem\` (the invite on stdin)`
662
+ : "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
663
  return out.report();
467
664
  }
468
665
  }
469
666
  // ---- harness ----------------------------------------------------------------------------
667
+ // The install's start: a running harness that started before it does not load what it wrote.
668
+ const installStartedAt = new Date();
470
669
  const installed = await installHarness({
471
670
  harness: h, scope, assets, env, cwd, keyReference: written.values.AGENT_BOARD_AGENT_KEY_REFERENCE ?? null, followSymlinks: options.followSymlinks !== false, run,
472
671
  ...deps.writer,
@@ -474,10 +673,23 @@ export async function runSetup(options, deps = {}) {
474
673
  out.record("harness", installed.status, installed.reason, installed.fix);
475
674
  if (installed.status === "failed")
476
675
  return out.report();
676
+ if (installed.status === "done") {
677
+ // IF-0-ONBOARD-3: found by EXECUTABLE (#474 launcher rules), not through runtime records: a
678
+ // session with no board plugin writes no record, and it is the one that most needs a restart.
679
+ for (const stale of listHarnessProcesses(h, { startedBefore: installStartedAt, ...deps.processes })) {
680
+ out.staleSessions.push({ pid: stale.pid, startedAt: stale.startedAt.toISOString(), cwd: stale.cwd });
681
+ out.notes.push(`harness: restart the ${h} session pid ${stale.pid} (started ${stale.startedAt.toISOString()}${stale.cwd === null ? "" : `, in ${printable(stale.cwd)}`}): it started before this install and has not loaded it`);
682
+ }
683
+ }
477
684
  // ---- doctor -----------------------------------------------------------------------------
478
685
  let report;
479
686
  try {
480
- report = await (deps.doctor ?? runDoctor)({ harness: h, env, cwd, ...deps.doctorOptions });
687
+ // #552: the key step's FRESH scan is carried in-process; doctor uses it only where the cache
688
+ // could not store it (IF-552-3's three conditions). A cached key-step result carries nothing.
689
+ const setupScan = keyScan?.source === "fresh"
690
+ ? { setupScan: { inputs: keyScan.inputs, scan: keyScan.scan, scannedAt: keyScan.scannedAt, cacheWrite: keyScan.cacheWrite } }
691
+ : {};
692
+ report = await (deps.doctor ?? runDoctor)({ harness: h, env, cwd, ...deps.doctorOptions, ...setupScan });
481
693
  }
482
694
  catch (error) {
483
695
  out.record("doctor", "undetermined", authoredCode(error, "doctor_threw"));
@@ -523,13 +735,25 @@ export async function runSetup(options, deps = {}) {
523
735
  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
736
  return out.report();
525
737
  }
738
+ /**
739
+ * One harness: start, the key step's one scan (prepare → measure → finalize, as `setup --all` does
740
+ * over every pair), then the rest.
741
+ */
742
+ export async function runSetup(options, deps = {}) {
743
+ const start = await startSetupPair(options, deps);
744
+ if (start.kind === "stopped")
745
+ return start.report;
746
+ const { prepared } = start.pair;
747
+ const selection = prepared === null ? null : finalizeKeyStorage([prepared], await measureKeyStorage([prepared], setupStorageContext(deps)))[0];
748
+ return finishSetupPair(start.pair, selection);
749
+ }
526
750
  /**
527
751
  * The keyslot loader refresh (A1): `status.sh` names the recorded and the running loader version;
528
752
  * when they differ, `provision.sh --refresh-loader` (which never re-keys) points the slot at the
529
753
  * running one, and the measurement is taken again.
530
754
  */
531
755
  async function refreshLoader(harness, slot, env, deps) {
532
- const runStorage = deps.storage?.run ?? defaultStorageRunner;
756
+ const runStorage = deps.storage?.run;
533
757
  let dir;
534
758
  try {
535
759
  dir = deps.storage?.keyslotDir ?? resolveKeyslotDir();
@@ -537,22 +761,18 @@ async function refreshLoader(harness, slot, env, deps) {
537
761
  catch {
538
762
  return { status: "undetermined", reason: "keyslot_scripts_absent", fix: "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli" };
539
763
  }
764
+ // #552: loader fields only, so `--scan-cache only`: this never scans (S-3).
540
765
  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
- }
766
+ const measured = await measureKeyslot({ dir, harness, slot, env, scan: "only", run: runStorage, budgets: deps.storage?.statusBudgets });
767
+ const loader = measured.report?.loader;
768
+ return loader ? { recorded: loader.recorded_sha256, running: loader.running_sha256 } : null;
549
769
  };
550
770
  const before = await measure();
551
771
  if (before === null || typeof before.running !== "string")
552
772
  return { status: "undetermined", reason: "loader_unmeasured", fix: "status.sh did not report the loader: run the packaged status.sh --json" };
553
773
  if (before.recorded === before.running)
554
774
  return { status: "already", reason: "identity_present" };
555
- const refresh = await runStorage(join(dir, "provision.sh"), ["--harness", harness, "--slot", slot, "--refresh-loader"], { env });
775
+ const refresh = await (runStorage ?? defaultStorageRunner)(join(dir, "provision.sh"), ["--harness", harness, "--slot", slot, "--refresh-loader"], { env });
556
776
  if (refresh.code !== 0)
557
777
  return { status: "failed", reason: "loader_refresh_failed", fix: "provision.sh --refresh-loader failed: run it by hand and read what it reports" };
558
778
  const after = await measure();
@@ -560,29 +780,81 @@ async function refreshLoader(harness, slot, env, deps) {
560
780
  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
781
  return { status: "done", reason: "loader_refreshed" };
562
782
  }
783
+ /** Cache reads that are STRUCTURAL: the cache cannot serve on this host at all. */
784
+ const STRUCTURAL_UNUSABLE = /^(untrusted:.+|state_root_relative|machine_id_unreadable)$/;
785
+ /**
786
+ * #552 IF-552-3 "Re-read before enrolling": `status.sh --scan-cache only` once more, judged on its
787
+ * `scan.cache`. `miss:cleared` (a tombstone) → plaintext_copy_found; `hit` → proceed; a structural
788
+ * `unusable`, or `miss:absent` where the key step's own write was `not_written:untrusted_dir` or
789
+ * `write_failed` → proceed on the key step's own clean result; anything else (`lock_busy`, expired,
790
+ * future, format, input_changed) → scan_not_completed. null = proceed.
791
+ */
792
+ async function rereadBeforeEnroll(harness, slot, env, deps, keyScan) {
793
+ let dir;
794
+ try {
795
+ dir = deps.storage?.keyslotDir ?? resolveKeyslotDir();
796
+ }
797
+ catch {
798
+ return { reason: "scan_not_completed", fix: "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli" };
799
+ }
800
+ const measured = await measureKeyslot({ dir, harness, slot, env, scan: "only", run: deps.storage?.run, budgets: deps.storage?.statusBudgets });
801
+ const { state, reason } = cacheStateOf(measured.report);
802
+ const statusCmd = `the packaged status.sh --harness ${harness} --json`;
803
+ if (state === "miss" && reason === "cleared") {
804
+ return { reason: "plaintext_copy_found", fix: `another scan found a plaintext copy of this key after setup's own scan, so it is NOT enrolled: run ${statusCmd} to list the copies, delete them and re-run setup; if a copy may have left this host, run provision.sh --harness ${harness} --rotate instead` };
805
+ }
806
+ if (state === "hit")
807
+ return null;
808
+ if (state === "unusable" && STRUCTURAL_UNUSABLE.test(reason))
809
+ return null;
810
+ if (state === "miss" && reason === "absent" && (keyScan?.cacheWrite === "not_written:untrusted_dir" || keyScan?.cacheWrite === "not_written:write_failed"))
811
+ return null;
812
+ return { reason: "scan_not_completed", fix: `the scan cache could not be confirmed before enrolling (${state || "no report"}${reason ? `:${reason}` : ""}), so this key is NOT enrolled: re-run setup, or run ${statusCmd}` };
813
+ }
563
814
  export function formatSetupReport(report) {
564
815
  return [...report.steps.map((step) => `${step.status.padEnd(12)} ${step.step}: ${step.reason}`), ...report.notes, `exit ${report.exit}`].join("\n");
565
816
  }
566
- export function addSetupCommand(program, io, setup) {
817
+ const collect = (value, previous = []) => [...previous, value];
818
+ export function addSetupCommand(program, io, setup, setupAll) {
567
819
  program
568
820
  .command("setup")
569
- .description("set this host up for one harness: key storage, enrollment, identity file, optional board invite, harness registration, then doctor")
821
+ .description("set this host up for one harness (--harness) or every harness on it (--all): key storage, enrollment, identity file, optional board invite, harness registration, then doctor")
570
822
  // Not `requiredOption`: commander's own usage exit would bypass the report (#402).
571
823
  .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")
824
+ .option("--all", "set up every harness whose CLI is on PATH, with one invite read and at most one key scan")
825
+ .option("--slug <slug>", "the agent slug (default <short hostname>-<harness>); with --all, <harness>=<slug>, repeatable", collect)
826
+ .option("--invite", "read a board invite (a bare code on a terminal; the code or the whole invite message on stdin) and redeem it after enrolling; never argv")
827
+ .option("--invite-file <path>", "read the board invite (the code, or the whole invite message) from a file and redeem it after enrolling")
574
828
  .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)")
829
+ .option("--agent-socket <ref>", "use your own SSH agent (with an external-agent declaration); refused with --all")
576
830
  .option("--scope <scope>", "user (default) or local (this directory)")
577
831
  .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")
832
+ .option("--clear-pending", "remove this harness's pending-enrollment marker after confirming in the Portal that no agent was created; runs no step; refused with --all")
579
833
  .option("--no-follow-symlinks", "refuse to write through a symlinked harness config")
580
834
  .option("--json", "print the steps and exit code as JSON")
581
835
  .action(async (options) => {
836
+ const slugs = options.slug ?? [];
837
+ if (options.all === true && setupAll) {
838
+ process.exitCode = await setupAll({
839
+ ...(options.harness !== undefined ? { harness: String(options.harness) } : {}),
840
+ slugs,
841
+ invite: options.invite === true,
842
+ ...(options.inviteFile !== undefined ? { inviteFile: String(options.inviteFile) } : {}),
843
+ allowUnsealedKey: options.allowUnsealedKey === true,
844
+ ...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
845
+ scope: options.scope,
846
+ installDeps: options.installDeps === true,
847
+ clearPending: options.clearPending === true,
848
+ followSymlinks: options.followSymlinks !== false,
849
+ }, { json: options.json === true });
850
+ return;
851
+ }
582
852
  const report = await setup({
583
853
  harness: String(options.harness ?? ""),
584
- slug: options.slug,
854
+ // Without --all, --slug keeps its single-value form: the last one given, as before.
855
+ slug: slugs.at(-1),
585
856
  invite: options.invite === true,
857
+ ...(options.inviteFile !== undefined ? { inviteFile: String(options.inviteFile) } : {}),
586
858
  allowUnsealedKey: options.allowUnsealedKey === true,
587
859
  agentSocket: options.agentSocket,
588
860
  scope: options.scope,