@consiliency/agent-board-cli 1.28.0 → 1.29.1

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,13 +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
7
  import { defaultStorageRunner } from "./keyslot_status.js";
8
8
  import { authoredCode, fileIdentityEnv, runDoctor } from "./doctor.js";
9
9
  import { identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, writeOwnerOnlyFile } from "./identity_writer.js";
10
10
  import { cacheStateOf, measureKeyslot } from "./keyslot_status.js";
11
- import { SLOT_RE, repairKeyDir, keyslotSocketDir, selectKeyStorage as defaultSelectKeyStorage, } from "./setup_storage.js";
11
+ import { SLOT_RE, finalizeKeyStorage, measureKeyStorage, repairKeyDir, keyslotSocketDir, prepareKeyStorage as defaultPrepareKeyStorage, } from "./setup_storage.js";
12
12
  import { SETUP_SCOPES, defaultChildRunner, installHarness, scopeRefusal, which, } from "./setup_writers.js";
13
13
  export const SETUP_STEPS = Object.freeze(["preflight", "deps", "key", "enroll", "identity", "board", "harness", "doctor"]);
14
14
  export const DEFAULT_SUDO = "/usr/bin/sudo";
@@ -36,9 +36,6 @@ export function defaultSlug(host, harness) {
36
36
  const short = host.split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
37
37
  return `${short || "host"}-${harness}`.slice(0, SLUG_MAX).replace(/-$/, "");
38
38
  }
39
- export function pendingMarkerPath(harness, env) {
40
- return join(dirname(identityFilePath(harness, env)), `.setup-enroll-pending-${harness}`);
41
- }
42
39
  /**
43
40
  * Codes raised before any token exists (the issuer refused or the device code lapsed), so no
44
41
  * /enroll call can have happened: the marker is removed. Read through `authoredCode`, so only a
@@ -127,6 +124,7 @@ function defaultBoardClient(fileEnv) {
127
124
  class Run {
128
125
  steps = [];
129
126
  notes = [];
127
+ staleSessions = [];
130
128
  record(step, status, reason, fix) {
131
129
  const entry = { step, status, reason };
132
130
  this.steps.push(entry);
@@ -137,7 +135,7 @@ class Run {
137
135
  report() {
138
136
  const statuses = this.steps.map((step) => step.status);
139
137
  const exit = statuses.includes("failed") ? 1 : statuses.includes("undetermined") ? 2 : 0;
140
- return { steps: this.steps, exit, notes: this.notes };
138
+ return { steps: this.steps, exit, notes: this.notes, staleSessions: this.staleSessions };
141
139
  }
142
140
  }
143
141
  /** The exact command a user runs to install the keychain tool on this host, or null. */
@@ -185,8 +183,76 @@ export async function depsStep(ctx) {
185
183
  // ---------------------------------------------------------------------------------------------
186
184
  // The run
187
185
  // ---------------------------------------------------------------------------------------------
188
- function inviteFromText(text) {
189
- 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");
190
256
  }
191
257
  /** The keyslot a key reference's socket names, when it is one of `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`. */
192
258
  function keyslotOf(reference, env) {
@@ -208,7 +274,16 @@ function keyslotOf(reference, env) {
208
274
  // Only a name the keyslot scripts accept ever reaches their argv.
209
275
  return SLOT_RE.test(slot) ? slot : null;
210
276
  }
211
- 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) {
212
287
  const env = deps.env ?? process.env;
213
288
  const cwd = deps.cwd ?? process.cwd();
214
289
  const platform = deps.platform ?? process.platform;
@@ -216,51 +291,72 @@ export async function runSetup(options, deps = {}) {
216
291
  const prompt = deps.prompt ?? defaultPrompt;
217
292
  const run = deps.run ?? defaultChildRunner;
218
293
  const out = new Run();
294
+ const stopped = () => ({ kind: "stopped", report: out.report() });
219
295
  // ---- preflight --------------------------------------------------------------------------
220
296
  const harness = options.harness;
221
297
  if (!IDENTITY_HARNESSES.includes(harness)) {
222
298
  out.record("preflight", "failed", "harness_invalid", `--harness must be one of ${IDENTITY_HARNESSES.join(", ")}`);
223
- return out.report();
299
+ return stopped();
224
300
  }
225
301
  const h = harness;
226
302
  let markerPath;
227
303
  let identityPath;
228
304
  try {
229
305
  identityPath = identityFilePath(h, env);
230
- markerPath = pendingMarkerPath(h, env);
306
+ markerPath = pendingEnrollmentMarkerPath(h, env);
231
307
  }
232
308
  catch (error) {
233
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)");
234
- return out.report();
310
+ return stopped();
235
311
  }
236
312
  if (options.clearPending) {
237
- // Runs no step: only this harness's marker, only with no identity file.
238
- if (exists(identityPath)) {
239
- 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);
240
321
  }
241
- else if (exists(markerPath)) {
242
- nodeFs.unlinkSync(markerPath);
243
- 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();
244
328
  }
245
- else {
246
- 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
+ }
247
340
  }
248
- return out.report();
341
+ finally {
342
+ await lock.release();
343
+ }
344
+ return stopped();
249
345
  }
250
346
  const scope = (options.scope ?? "user");
251
347
  if (!SETUP_SCOPES.includes(scope)) {
252
348
  out.record("preflight", "failed", "scope_invalid", "--scope is user or local");
253
- return out.report();
349
+ return stopped();
254
350
  }
255
351
  const refusal = scopeRefusal(h, scope, { env, cwd, fs: deps.writer?.fs, claudeManagedDir: deps.writer?.claudeManagedDir });
256
352
  if (refusal) {
257
353
  out.record("preflight", "failed", refusal.reason, refusal.fix);
258
- return out.report();
354
+ return stopped();
259
355
  }
260
356
  const slug = options.slug ?? defaultSlug(resolveHostSegment(env) ?? (deps.hostname ?? osHostname)(), h);
261
357
  if (!SLUG.test(slug) || slug.length > SLUG_MAX) {
262
358
  out.record("preflight", "failed", "slug_invalid", `--slug must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be at most ${SLUG_MAX} characters`);
263
- return out.report();
359
+ return stopped();
264
360
  }
265
361
  let assets;
266
362
  try {
@@ -268,16 +364,18 @@ export async function runSetup(options, deps = {}) {
268
364
  }
269
365
  catch {
270
366
  out.record("preflight", "failed", "assets_unbaked", "this cli build has no baked versions (dist/assets.json): reinstall @consiliency/agent-board-cli");
271
- return out.report();
367
+ return stopped();
272
368
  }
273
369
  const identity = await identityState(h, env);
274
370
  if (identity.kind === "unreadable") {
275
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)`);
276
- return out.report();
372
+ return stopped();
277
373
  }
278
- 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)) {
279
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`);
280
- return out.report();
378
+ return stopped();
281
379
  }
282
380
  let deployment = null;
283
381
  if (identity.kind === "absent") {
@@ -291,23 +389,27 @@ export async function runSetup(options, deps = {}) {
291
389
  const descriptor = resolveEnrollmentDescriptor(env, { keyReference: PREFLIGHT_KEY_REFERENCE });
292
390
  if (!descriptor.issuer.deviceAuthorizationEndpoint) {
293
391
  out.record("preflight", "failed", "missing_device_endpoint", "the deployment has no device authorization endpoint (MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
294
- return out.report();
392
+ return stopped();
295
393
  }
296
394
  }
297
395
  catch (error) {
298
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");
299
- return out.report();
397
+ return stopped();
300
398
  }
301
399
  }
302
- let invite = null;
303
- if (options.invite) {
304
- invite = interactive
305
- ? inviteFromText(await prompt("Invite code (hidden): ", { secret: true }))
306
- : inviteFromText(await (deps.readStdin ?? defaultReadStdin)());
307
- if (invite === "") {
308
- out.record("preflight", "failed", "invite_missing", "--invite reads the code from the terminal or stdin; none was given");
309
- 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();
310
409
  }
410
+ invite = read.invite;
411
+ if (invite !== null)
412
+ out.notes.push(inviteBoardNote(invite));
311
413
  }
312
414
  out.record("preflight", "done", "ready");
313
415
  // ---- deps -------------------------------------------------------------------------------
@@ -319,6 +421,40 @@ export async function runSetup(options, deps = {}) {
319
421
  ? { status: "skipped", reason: "agent_socket_given" }
320
422
  : await depsStep({ env, platform, interactive, installDeps: options.installDeps === true, run, sudo: deps.sudoPath ?? DEFAULT_SUDO, prompt });
321
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;
322
458
  // ---- key --------------------------------------------------------------------------------
323
459
  let keyReference;
324
460
  let keyScan;
@@ -336,21 +472,10 @@ export async function runSetup(options, deps = {}) {
336
472
  }
337
473
  }
338
474
  else {
339
- // #536 as a named step, for EVERY storage: the identity file needs a private agent-board/ too,
340
- // and the keychain route never reaches the key slot's own repair.
341
- const repaired = repairKeyDir(env, deps.storage?.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1));
342
- for (const repair of repaired.repairs)
343
- out.notes.push(`key: repaired ${repair}`);
344
- if (repaired.problems.length > 0) {
345
- 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");
346
477
  return out.report();
347
478
  }
348
- const selection = await (deps.selectKeyStorage ?? defaultSelectKeyStorage)({
349
- harness: h, env, platform, deps: deps_,
350
- ...(options.allowUnsealedKey === true ? { allowUnsealedKey: true } : {}),
351
- ...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
352
- ...deps.storage,
353
- });
354
479
  for (const repair of selection.repairs)
355
480
  out.notes.push(`key: repaired ${repair}`);
356
481
  if (selection.storage === "refused") {
@@ -403,7 +528,57 @@ export async function runSetup(options, deps = {}) {
403
528
  const root = join(identityConfigRoot(env), "agent-board");
404
529
  nodeFs.mkdirSync(dirname(identityPath), { recursive: true, mode: 0o700 });
405
530
  walkTrustedDirectory(dirname(identityPath), root);
406
- 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()));
407
582
  }
408
583
  catch (error) {
409
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");
@@ -454,6 +629,7 @@ export async function runSetup(options, deps = {}) {
454
629
  }
455
630
  nodeFs.rmSync(markerPath, { force: true });
456
631
  out.record("identity", "done", "written");
632
+ return null;
457
633
  }
458
634
  // ---- board ------------------------------------------------------------------------------
459
635
  // Always the identity file just written, never ambient env: the invite is single-use.
@@ -470,17 +646,26 @@ export async function runSetup(options, deps = {}) {
470
646
  }
471
647
  else {
472
648
  try {
473
- const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite });
649
+ const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite.code });
474
650
  if (result?.ok === false)
475
651
  throw result;
476
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)}`);
477
657
  }
478
658
  catch (error) {
479
- 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)");
480
663
  return out.report();
481
664
  }
482
665
  }
483
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();
484
669
  const installed = await installHarness({
485
670
  harness: h, scope, assets, env, cwd, keyReference: written.values.AGENT_BOARD_AGENT_KEY_REFERENCE ?? null, followSymlinks: options.followSymlinks !== false, run,
486
671
  ...deps.writer,
@@ -488,6 +673,14 @@ export async function runSetup(options, deps = {}) {
488
673
  out.record("harness", installed.status, installed.reason, installed.fix);
489
674
  if (installed.status === "failed")
490
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
+ }
491
684
  // ---- doctor -----------------------------------------------------------------------------
492
685
  let report;
493
686
  try {
@@ -542,6 +735,18 @@ export async function runSetup(options, deps = {}) {
542
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`);
543
736
  return out.report();
544
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
+ }
545
750
  /**
546
751
  * The keyslot loader refresh (A1): `status.sh` names the recorded and the running loader version;
547
752
  * when they differ, `provision.sh --refresh-loader` (which never re-keys) points the slot at the
@@ -609,26 +814,47 @@ async function rereadBeforeEnroll(harness, slot, env, deps, keyScan) {
609
814
  export function formatSetupReport(report) {
610
815
  return [...report.steps.map((step) => `${step.status.padEnd(12)} ${step.step}: ${step.reason}`), ...report.notes, `exit ${report.exit}`].join("\n");
611
816
  }
612
- export function addSetupCommand(program, io, setup) {
817
+ const collect = (value, previous = []) => [...previous, value];
818
+ export function addSetupCommand(program, io, setup, setupAll) {
613
819
  program
614
820
  .command("setup")
615
- .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")
616
822
  // Not `requiredOption`: commander's own usage exit would bypass the report (#402).
617
823
  .option("--harness <harness>", "the harness to set up (agy, claude, codex, grok, opencode, pi)")
618
- .option("--slug <slug>", "the agent slug (default <short hostname>-<harness>)")
619
- .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")
620
828
  .option("--allow-unsealed-key", "accept an unsealed 0600 key under an opt-in record on a host that cannot seal")
621
- .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")
622
830
  .option("--scope <scope>", "user (default) or local (this directory)")
623
831
  .option("--install-deps", "non-interactive: install the OS keychain tool with sudo -n when a Secret Service is running")
624
- .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")
625
833
  .option("--no-follow-symlinks", "refuse to write through a symlinked harness config")
626
834
  .option("--json", "print the steps and exit code as JSON")
627
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
+ }
628
852
  const report = await setup({
629
853
  harness: String(options.harness ?? ""),
630
- slug: options.slug,
854
+ // Without --all, --slug keeps its single-value form: the last one given, as before.
855
+ slug: slugs.at(-1),
631
856
  invite: options.invite === true,
857
+ ...(options.inviteFile !== undefined ? { inviteFile: String(options.inviteFile) } : {}),
632
858
  allowUnsealedKey: options.allowUnsealedKey === true,
633
859
  agentSocket: options.agentSocket,
634
860
  scope: options.scope,
@@ -0,0 +1,51 @@
1
+ import { type IdentityHarness } from "@consiliency/agent-board-client";
2
+ import { type SetupDeps, type SetupReport, type SetupStep, type StaleSession } from "./setup.js";
3
+ export interface SetupAllOptions {
4
+ invite?: boolean;
5
+ inviteFile?: string;
6
+ scope?: string;
7
+ /** Each `<harness>=<slug>`; a harness without one gets `<short host>-<harness>`. */
8
+ slugs?: readonly string[];
9
+ allowUnsealedKey?: boolean;
10
+ installDeps?: boolean;
11
+ /** Default true; `--no-follow-symlinks` is passed to every pair. */
12
+ followSymlinks?: boolean;
13
+ harness?: string;
14
+ agentSocket?: string;
15
+ clearPending?: boolean;
16
+ }
17
+ export type SetupPairStatus = "done" | "already" | "failed" | "undetermined" | "absent";
18
+ export interface SetupAllPair {
19
+ harness: IdentityHarness;
20
+ status: SetupPairStatus;
21
+ /** null for an absent harness. */
22
+ slug: string | null;
23
+ steps: SetupStep[];
24
+ notes: string[];
25
+ staleSessions: StaleSession[];
26
+ }
27
+ export interface SetupAllReport {
28
+ /** The run's own steps (preflight). */
29
+ steps: SetupStep[];
30
+ /** One per identity harness, in IDENTITY_HARNESSES order. */
31
+ pairs: SetupAllPair[];
32
+ exit: 0 | 1 | 2;
33
+ notes: string[];
34
+ }
35
+ export type SetupAllRunner = (options: SetupAllOptions) => Promise<SetupAllReport>;
36
+ /**
37
+ * A pair is `already` when this host already had its identity AND its registration was exact: the
38
+ * enroll and harness steps both say so. A failed or undetermined step outranks it.
39
+ */
40
+ export declare function pairStatus(report: SetupReport): Exclude<SetupPairStatus, "absent">;
41
+ export declare function runSetupAll(options: SetupAllOptions, deps?: SetupDeps): Promise<SetupAllReport>;
42
+ export declare function formatSetupAllReport(report: SetupAllReport): string;
43
+ /** `--json`: the run's steps and each pair's step objects. Notes stay human (stderr). */
44
+ export declare function setupAllJson(report: SetupAllReport): Record<string, unknown>;
45
+ /** The `setup --all` command body: run, print, return the exit. */
46
+ export declare function setupAllCommand(run: SetupAllRunner, io: {
47
+ writeStdout(message: string): void;
48
+ writeStderr(message: string): void;
49
+ }): (options: SetupAllOptions, output: {
50
+ json: boolean;
51
+ }) => Promise<0 | 1 | 2>;