@consiliency/agent-board-cli 1.28.0 → 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.
@@ -0,0 +1,164 @@
1
+ // ONBOARD IF-0-ONBOARD-1 — `agent-board setup --all`: every identity harness on this host in one run.
2
+ //
3
+ // preflight run-level refusals (`--agent-socket`, `--clear-pending`, `--harness`, a malformed
4
+ // `--slug <harness>=<slug>`), then the invite, read and parsed ONCE: a malformed
5
+ // invite refuses the whole run before any pair enrolls
6
+ // the set derived from IDENTITY_HARNESSES, never listed: a harness is present when its CLI is on
7
+ // PATH (`harnessCliPath`); an absent one is reported `absent`, never skipped silently
8
+ // pairs every present pair runs setup's preflight, deps and key PREPARATION; then ONE key
9
+ // scan covers every keyslot pair's key (`status.sh --slots`, one walk; a keychain host
10
+ // never scans); then each pair's storage is finalized from that scan; then each pair
11
+ // enrolls, writes its identity, redeems the invite, registers and runs doctor, in turn
12
+ // failures a failed pair never stops the others; a refused redeem is `invite_unusable` for that
13
+ // pair and the later pairs still try their own
14
+ // exit the worst pair's: failed (1) outranks undetermined (2), which outranks 0; `absent`
15
+ // counts as 0
16
+ //
17
+ // Capacity is never pre-checked: an invite's remaining uses and board are creator-only reads, and a
18
+ // fresh host has no identity to read them with. The redeem RPC's refusal is uniform, and setup never
19
+ // tells its causes apart.
20
+ import { IDENTITY_HARNESSES, } from "@consiliency/agent-board-client";
21
+ import { finishSetupPair, inviteBoardNote, readSetupInvite, setupStorageContext, startSetupPair, } from "./setup.js";
22
+ import { finalizeKeyStorage, measureKeyStorage } from "./setup_storage.js";
23
+ import { harnessCliPath } from "./setup_writers.js";
24
+ const worst = (statuses) => (statuses.includes("failed") ? 1 : statuses.includes("undetermined") ? 2 : 0);
25
+ /**
26
+ * A pair is `already` when this host already had its identity AND its registration was exact: the
27
+ * enroll and harness steps both say so. A failed or undetermined step outranks it.
28
+ */
29
+ export function pairStatus(report) {
30
+ const exit = worst(report.steps.map((step) => step.status));
31
+ if (exit === 1)
32
+ return "failed";
33
+ if (exit === 2)
34
+ return "undetermined";
35
+ const status = (name) => report.steps.find((step) => step.step === name)?.status;
36
+ return status("enroll") === "already" && status("harness") === "already" ? "already" : "done";
37
+ }
38
+ /** `--slug <harness>=<slug>`, each harness at most once. The slug itself is judged by the pair's preflight. */
39
+ function parseSlugOverrides(entries) {
40
+ const slugs = new Map();
41
+ for (const entry of entries) {
42
+ const eq = entry.indexOf("=");
43
+ const harness = eq > 0 ? entry.slice(0, eq) : "";
44
+ if (!IDENTITY_HARNESSES.includes(harness)) {
45
+ return { reason: "slug_override_invalid", fix: `with --all, --slug takes <harness>=<slug>, where <harness> is one of ${IDENTITY_HARNESSES.join(", ")}` };
46
+ }
47
+ if (slugs.has(harness))
48
+ return { reason: "slug_override_repeated", fix: `--slug names ${harness} more than once` };
49
+ slugs.set(harness, entry.slice(eq + 1));
50
+ }
51
+ return slugs;
52
+ }
53
+ export async function runSetupAll(options, deps = {}) {
54
+ const env = deps.env ?? process.env;
55
+ const steps = [];
56
+ const notes = [];
57
+ const refuse = (reason, fix) => {
58
+ steps.push({ step: "preflight", status: "failed", reason });
59
+ notes.push(`preflight: ${fix}`);
60
+ return { steps, pairs: [], exit: 1, notes };
61
+ };
62
+ // ---- run-level preflight --------------------------------------------------------------------
63
+ if (options.harness !== undefined)
64
+ return refuse("harness_with_all", "--all sets up every harness on this host: drop --harness, or drop --all");
65
+ if (options.agentSocket !== undefined)
66
+ return refuse("agent_socket_with_all", "--agent-socket names one key, so it is refused with --all: set up that harness alone with `agent-board setup --harness <h> --agent-socket <ref>`");
67
+ if (options.clearPending)
68
+ return refuse("clear_pending_with_all", "--clear-pending clears one harness's marker, so it is refused with --all: run `agent-board setup --harness <h> --clear-pending`");
69
+ const slugs = parseSlugOverrides(options.slugs ?? []);
70
+ if (!(slugs instanceof Map))
71
+ return refuse(slugs.reason, slugs.fix);
72
+ // The invite, ONCE, before any pair: every pair redeems this same parsed code.
73
+ const read = await readSetupInvite(options, deps);
74
+ if (!("invite" in read))
75
+ return refuse(read.reason, read.fix);
76
+ const invite = read.invite;
77
+ if (invite !== null)
78
+ notes.push(inviteBoardNote(invite));
79
+ steps.push({ step: "preflight", status: "done", reason: "ready" });
80
+ // ---- the harness set, derived -----------------------------------------------------------------
81
+ const present = new Set(IDENTITY_HARNESSES.filter((harness) => harnessCliPath(harness, env) !== null));
82
+ for (const harness of slugs.keys()) {
83
+ if (!present.has(harness))
84
+ notes.push(`preflight: --slug ${harness}=… is unused: no ${harness} CLI is on PATH`);
85
+ }
86
+ // ---- every pair up to its key preparation -------------------------------------------------------
87
+ const reports = new Map();
88
+ const started = [];
89
+ for (const harness of IDENTITY_HARNESSES) {
90
+ if (!present.has(harness))
91
+ continue;
92
+ const start = await startSetupPair({
93
+ harness,
94
+ ...(slugs.has(harness) ? { slug: slugs.get(harness) } : {}),
95
+ ...(options.scope !== undefined ? { scope: options.scope } : {}),
96
+ allowUnsealedKey: options.allowUnsealedKey === true,
97
+ installDeps: options.installDeps === true,
98
+ followSymlinks: options.followSymlinks !== false,
99
+ }, deps, { invite });
100
+ if (start.kind === "stopped")
101
+ reports.set(harness, start.report);
102
+ else
103
+ started.push(start.pair);
104
+ }
105
+ // ---- ONE key scan for every keyslot pair, then each pair's storage ------------------------------
106
+ const prepared = started.map((pair) => pair.prepared).filter((entry) => entry !== null);
107
+ const finalized = finalizeKeyStorage(prepared, await measureKeyStorage(prepared, setupStorageContext(deps)));
108
+ const selectionOf = new Map(prepared.map((entry, index) => [entry, finalized[index]]));
109
+ // ---- the rest, pair by pair, continuing past a failure ------------------------------------------
110
+ for (const pair of started) {
111
+ reports.set(pair.harness, await finishSetupPair(pair, pair.prepared === null ? null : selectionOf.get(pair.prepared) ?? null));
112
+ }
113
+ const pairs = IDENTITY_HARNESSES.map((harness) => {
114
+ const report = reports.get(harness);
115
+ if (report === undefined) {
116
+ return { harness, status: "absent", slug: null, steps: [], notes: [`${harness}: no ${harness} CLI on PATH; install it, then re-run setup --all`], staleSessions: [] };
117
+ }
118
+ const slug = started.find((pair) => pair.harness === harness)?.slug ?? null;
119
+ return { harness, status: pairStatus(report), slug, steps: report.steps, notes: report.notes, staleSessions: report.staleSessions };
120
+ });
121
+ const refused = pairs.filter((pair) => pair.steps.some((step) => step.step === "board" && step.status === "failed" && step.reason === "invite_unusable"));
122
+ if (refused.length > 0) {
123
+ notes.push(`board: the invite was refused for ${refused.map((pair) => pair.harness).join(", ")}; the board does not say why (used up, revoked, expired, or not usable by that agent), so a new invite may be needed: ask the board owner`);
124
+ }
125
+ return { steps, pairs, exit: worst([...steps.map((step) => step.status), ...pairs.map((pair) => pair.status)]), notes };
126
+ }
127
+ export function formatSetupAllReport(report) {
128
+ const lines = report.steps.map((step) => `${step.status.padEnd(12)} ${step.step}: ${step.reason}`);
129
+ if (report.pairs.length > 0) {
130
+ lines.push(`${"harness".padEnd(10)} ${"status".padEnd(12)} ${"slug".padEnd(28)} detail`);
131
+ for (const pair of report.pairs) {
132
+ const stop = pair.steps.find((step) => step.status === "failed" || step.status === "undetermined");
133
+ lines.push(`${pair.harness.padEnd(10)} ${pair.status.padEnd(12)} ${(pair.slug ?? "-").padEnd(28)} ${stop ? `${stop.step}: ${stop.status} ${stop.reason}` : ""}`.trimEnd());
134
+ }
135
+ for (const pair of report.pairs)
136
+ for (const note of pair.notes)
137
+ lines.push(`[${pair.harness}] ${note}`);
138
+ }
139
+ return [...lines, ...report.notes, `exit ${report.exit}`].join("\n");
140
+ }
141
+ /** `--json`: the run's steps and each pair's step objects. Notes stay human (stderr). */
142
+ export function setupAllJson(report) {
143
+ return {
144
+ steps: report.steps,
145
+ pairs: report.pairs.map((pair) => ({ harness: pair.harness, status: pair.status, slug: pair.slug, steps: pair.steps, stale_sessions: pair.staleSessions })),
146
+ exit: report.exit,
147
+ };
148
+ }
149
+ /** The `setup --all` command body: run, print, return the exit. */
150
+ export function setupAllCommand(run, io) {
151
+ return async (options, output) => {
152
+ const report = await run(options);
153
+ if (output.json) {
154
+ io.writeStdout(`${JSON.stringify(setupAllJson(report))}\n`);
155
+ const notes = [...report.pairs.flatMap((pair) => pair.notes.map((note) => `[${pair.harness}] ${note}`)), ...report.notes];
156
+ if (notes.length > 0)
157
+ io.writeStderr(`${notes.join("\n")}\n`);
158
+ }
159
+ else {
160
+ io.writeStdout(`${formatSetupAllReport(report)}\n`);
161
+ }
162
+ return report.exit;
163
+ };
164
+ }
@@ -147,8 +147,46 @@ export declare function macosKeychainRefusal(probe: OsKeychainProbeResult | null
147
147
  reason: string;
148
148
  fix: string;
149
149
  };
150
+ /**
151
+ * ONBOARD SL-4: the key storage chosen and provisioned but NOT yet scanned. `selected` is final
152
+ * (the keychain, an external agent, or a refusal). `keyslot` holds a provisioned slot that may be
153
+ * used only once `finalizeKeyStorage` has seen a completed scan covering it (IF-552-3).
154
+ */
155
+ export type PreparedKeyStorage = {
156
+ kind: "selected";
157
+ harness: string;
158
+ selection: KeyStorageSelection;
159
+ } | {
160
+ kind: "keyslot";
161
+ harness: string;
162
+ slot: string;
163
+ keyReference: string;
164
+ repairs: string[];
165
+ keyslotDir: string;
166
+ };
167
+ /** One measurement per keyslot slot, every one from ONE `measureKeyslots` call. */
168
+ export type KeyslotScan = ReadonlyMap<string, KeyslotMeasurement>;
169
+ /**
170
+ * The ONE measurement for every prepared keyslot pair (IF-0-ONBOARD-1 "a key scan at most once per
171
+ * host"), with setup's `key: ` notices; null when no pair is a keyslot (a keychain host never
172
+ * scans). One pair is measured exactly as a single-harness setup always was.
173
+ */
174
+ export declare function measureKeyStorage(pairs: readonly PreparedKeyStorage[], ctx: Pick<KeyStorageContext, "env" | "run" | "statusBudgets" | "notify">): Promise<KeyslotScan | null>;
175
+ /**
176
+ * ONBOARD SL-4: each pair's storage. A keyslot pair gets its slot ONLY when `scan` is one completed
177
+ * measurement covering EVERY keyslot pair's key, and is clean for that key; with no scan, or one
178
+ * that misses any keyslot pair, every keyslot pair is refused `scan_not_completed`. Other pairs
179
+ * pass through unchanged. Order is the input's.
180
+ */
181
+ export declare function finalizeKeyStorage(pairs: readonly PreparedKeyStorage[], scan: KeyslotScan | null): KeyStorageSelection[];
150
182
  /**
151
183
  * IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
152
- * condition; a refusal carries the fix.
184
+ * condition; a refusal carries the fix. Prepare, then the one scan, then finalize: the same three
185
+ * steps `setup --all` runs over every pair.
153
186
  */
154
187
  export declare function selectKeyStorage(ctx: KeyStorageContext): Promise<KeyStorageSelection>;
188
+ /**
189
+ * ONBOARD SL-4: the IF-0-KEYSEAL-6 order up to and including provision.sh, and NO scan. A slot is
190
+ * returned as `keyslot`, never as usable storage: only `finalizeKeyStorage` turns it into one.
191
+ */
192
+ export declare function prepareKeyStorage(ctx: KeyStorageContext): Promise<PreparedKeyStorage>;
@@ -24,7 +24,7 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openS
24
24
  import { dirname, isAbsolute, join } from "node:path";
25
25
  import { OS_KEYCHAIN_SERVICE, probeOsKeychainRoundTrip } from "@consiliency/agent-board-client";
26
26
  import { resolveKeyslotDir } from "./assets.js";
27
- import { cacheStateOf, defaultStorageRunner, measureKeyslot, progressText, scanOf, scanSourceOf, } from "./keyslot_status.js";
27
+ import { cacheStateOf, defaultStorageRunner, measureKeyslot, measureKeyslots, progressText, scanOf, scanSourceOf, } from "./keyslot_status.js";
28
28
  export { defaultStorageRunner };
29
29
  export const SLOT_RE = /^[a-z][a-z0-9-]{0,31}$/;
30
30
  const REFERENCE_LINE = /^agent_socket:\/\S+#[A-Za-z0-9_-]{43}$/;
@@ -456,23 +456,108 @@ export function macosKeychainRefusal(probe) {
456
456
  }
457
457
  }
458
458
  }
459
+ /**
460
+ * The ONE measurement for every prepared keyslot pair (IF-0-ONBOARD-1 "a key scan at most once per
461
+ * host"), with setup's `key: ` notices; null when no pair is a keyslot (a keychain host never
462
+ * scans). One pair is measured exactly as a single-harness setup always was.
463
+ */
464
+ export async function measureKeyStorage(pairs, ctx) {
465
+ const slots = pairs.filter((pair) => pair.kind === "keyslot");
466
+ if (slots.length === 0)
467
+ return null;
468
+ const notify = ctx.notify ?? ((text) => { process.stderr.write(`${text}\n`); });
469
+ // Several pairs are told apart by harness; one pair's notices are byte-identical to before.
470
+ const noticesFor = new Map(slots.map((pair) => [pair.slot, keyScanNotices(pair.harness, slots.length === 1 ? notify : (line) => notify(`[${pair.harness}] ${line}`))]));
471
+ // Setup's onFailure is `scan`: a phase-1 exit 1 beside a measured mode still gets its scan
472
+ // (status.sh scans even on a contradiction).
473
+ const env = ctx.env ?? process.env;
474
+ const measured = slots.length === 1
475
+ ? new Map([[slots[0].slot, await measureKeyslot({
476
+ dir: slots[0].keyslotDir, harness: slots[0].harness, slot: slots[0].slot, env, scan: "use", onFailure: "scan", run: ctx.run, budgets: ctx.statusBudgets,
477
+ notify: noticesFor.get(slots[0].slot),
478
+ })]])
479
+ : await measureKeyslots({
480
+ // `--slots` names each slot as its own harness: setup's slot is always its harness.
481
+ dir: slots[0].keyslotDir, slots: slots.map((pair) => pair.slot), env, scan: "use", onFailure: "scan", run: ctx.run, budgets: ctx.statusBudgets,
482
+ notify: (slot, event) => {
483
+ if (slot !== null)
484
+ noticesFor.get(slot)?.(event);
485
+ else if (event.kind === "progress")
486
+ notify(`key: ${progressText(event.folders)}`);
487
+ },
488
+ });
489
+ for (const pair of slots) {
490
+ const one = measured.get(pair.slot);
491
+ if (one && !one.phases.some((phase) => phase.mode !== "only") && scanSourceOf(one.report) === "cached") {
492
+ notify(`${slots.length === 1 ? "" : `[${pair.harness}] `}key: plaintext-copy scan cached at ${String(scanOf(one.report).scanned_at)}`);
493
+ }
494
+ }
495
+ return measured;
496
+ }
497
+ /** The IF-552-3 gate on one provisioned slot, judged from its measurement. */
498
+ function judgeKeyslot(pair, measured) {
499
+ const { repairs, slot } = pair;
500
+ const mode = measured.report?.mode;
501
+ if (mode === "sealed" || mode === "unsealed") {
502
+ const gate = scanGate(pair.harness, measured);
503
+ if (gate)
504
+ return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
505
+ return { storage: mode, reason: `keyslot_${mode}`, keyReference: pair.keyReference, slot, repairs, keyScan: keyStepScan(measured.report) };
506
+ }
507
+ const gate = scanGate(pair.harness, measured);
508
+ // A scan that ran but whose report could not be read is a scan that did not complete.
509
+ const scanned = measured.phases.some((phase) => phase.mode !== "only");
510
+ if (gate && (gate.reason !== "scan_not_completed" || scanned))
511
+ return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
512
+ return refused("keyslot_mode_unmeasured", "provision.sh succeeded but status.sh does not measure the slot as sealed or unsealed: run the packaged status.sh --json and resolve what it reports", repairs);
513
+ }
514
+ /**
515
+ * ONBOARD SL-4: each pair's storage. A keyslot pair gets its slot ONLY when `scan` is one completed
516
+ * measurement covering EVERY keyslot pair's key, and is clean for that key; with no scan, or one
517
+ * that misses any keyslot pair, every keyslot pair is refused `scan_not_completed`. Other pairs
518
+ * pass through unchanged. Order is the input's.
519
+ */
520
+ export function finalizeKeyStorage(pairs, scan) {
521
+ const covered = scan !== null && pairs.every((pair) => pair.kind !== "keyslot" || scan.has(pair.slot));
522
+ return pairs.map((pair) => {
523
+ if (pair.kind === "selected")
524
+ return pair.selection;
525
+ if (!covered) {
526
+ return { storage: "refused", status: "failed", reason: "scan_not_completed", repairs: pair.repairs,
527
+ fix: `no completed plaintext-copy scan covered this key, so it is NOT enrolled: run the packaged status.sh --harness ${pair.harness} --json and resolve what it reports, then re-run setup` };
528
+ }
529
+ return judgeKeyslot(pair, scan.get(pair.slot));
530
+ });
531
+ }
459
532
  /**
460
533
  * IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
461
- * condition; a refusal carries the fix.
534
+ * condition; a refusal carries the fix. Prepare, then the one scan, then finalize: the same three
535
+ * steps `setup --all` runs over every pair.
462
536
  */
463
537
  export async function selectKeyStorage(ctx) {
538
+ const prepared = await prepareKeyStorage(ctx);
539
+ if (prepared.kind === "selected")
540
+ return prepared.selection;
541
+ return finalizeKeyStorage([prepared], await measureKeyStorage([prepared], ctx))[0];
542
+ }
543
+ /**
544
+ * ONBOARD SL-4: the IF-0-KEYSEAL-6 order up to and including provision.sh, and NO scan. A slot is
545
+ * returned as `keyslot`, never as usable storage: only `finalizeKeyStorage` turns it into one.
546
+ */
547
+ export async function prepareKeyStorage(ctx) {
548
+ const selected = (selection) => ({ kind: "selected", harness: ctx.harness, selection });
464
549
  const env = ctx.env ?? process.env;
465
550
  const platform = ctx.platform ?? process.platform;
466
551
  const uid = ctx.uid ?? currentUid();
467
552
  const run = ctx.run ?? defaultStorageRunner;
468
553
  const slot = ctx.slot ?? ctx.harness;
469
554
  if (!SLOT_RE.test(ctx.harness) || !SLOT_RE.test(slot))
470
- return refused("bad_slot", "the harness and slot must match ^[a-z][a-z0-9-]{0,31}$");
555
+ return selected(refused("bad_slot", "the harness and slot must match ^[a-z][a-z0-9-]{0,31}$"));
471
556
  // 0. An explicit --agent-socket wins over everything discovery would find — the keychain and a
472
557
  // key slot alike (code review r1, owner decision; a plan amendment to the IF-0-KEYSEAL-6
473
558
  // order, recorded in SL-7): a user who names their own agent means it. Nothing is probed.
474
559
  if (ctx.agentSocket !== undefined)
475
- return externalAgent(ctx.agentSocket, env, uid, ctx.machineIdFile, []);
560
+ return selected(externalAgent(ctx.agentSocket, env, uid, ctx.machineIdFile, []));
476
561
  // 1. A live keychain round trip.
477
562
  let keychain = null;
478
563
  try {
@@ -482,7 +567,7 @@ export async function selectKeyStorage(ctx) {
482
567
  keychain = null;
483
568
  }
484
569
  if (keychain?.ok)
485
- return { storage: "os_keychain", reason: "keychain_round_trip", repairs: [] };
570
+ return selected({ storage: "os_keychain", reason: "keychain_round_trip", repairs: [] });
486
571
  // 2. Setup's own gate. A failure here skips the key slot, not the external agent.
487
572
  let gate = null;
488
573
  if (platform !== "linux") {
@@ -502,7 +587,7 @@ export async function selectKeyStorage(ctx) {
502
587
  gate = { reason: "bash_too_old", fix: "the keyslot scripts need bash >= 4 at /bin/bash; install a current bash, or use a desktop keychain" };
503
588
  }
504
589
  }
505
- // 3. The key slot: provision.sh selects the mode; status.sh measures it.
590
+ // 3. The key slot: provision.sh selects the mode; status.sh measures it, in finalize.
506
591
  let slotRefusal = null;
507
592
  let repairs = [];
508
593
  if (gate === null) {
@@ -511,44 +596,21 @@ export async function selectKeyStorage(ctx) {
511
596
  dir = ctx.keyslotDir ?? resolveKeyslotDir();
512
597
  }
513
598
  catch {
514
- return refused("keyslot_scripts_absent", "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli");
599
+ return selected(refused("keyslot_scripts_absent", "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli"));
515
600
  }
516
601
  const repaired = repairKeyDir(env, uid);
517
602
  repairs = repaired.repairs;
518
603
  if (repaired.problems.length > 0) {
519
- return refused("unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): chmod g-w,o-w on each named directory you own (for a fresh account: chmod g-w ${repaired.root ?? "~/.config"} && chmod 0700 ${repaired.root ?? "~/.config"}/agent-board), then re-run setup`, repairs);
604
+ return selected(refused("unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): chmod g-w,o-w on each named directory you own (for a fresh account: chmod g-w ${repaired.root ?? "~/.config"} && chmod 0700 ${repaired.root ?? "~/.config"}/agent-board), then re-run setup`, repairs));
520
605
  }
521
606
  const args = ["--harness", ctx.harness, "--slot", slot, ...(ctx.allowUnsealedKey === true ? ["--allow-unsealed-key"] : [])];
522
607
  const provision = await run(join(dir, "provision.sh"), args, { env });
523
608
  if (provision.code === 0) {
524
609
  const line = provision.stdout.split("\n").map((entry) => entry.trim()).filter(Boolean).at(-1) ?? "";
525
610
  if (!REFERENCE_LINE.test(line))
526
- return refused("provision_output_unrecognised", "provision.sh exited 0 without printing an agent_socket: reference; run it by hand and report the output", repairs);
527
- // #552: one measurement, scanning only when no valid cache holds this key's scan, with
528
- // notices before and after. Setup's onFailure is `scan`: a phase-1 exit 1 beside a measured
529
- // mode still gets its scan (status.sh scans even on a contradiction).
530
- const notify = ctx.notify ?? ((text) => { process.stderr.write(`${text}\n`); });
531
- const notices = keyScanNotices(ctx.harness, notify);
532
- const measured = await measureKeyslot({
533
- dir, harness: ctx.harness, slot, env, scan: "use", onFailure: "scan", run: ctx.run, budgets: ctx.statusBudgets,
534
- notify: notices,
535
- });
536
- if (!measured.phases.some((phase) => phase.mode !== "only") && scanSourceOf(measured.report) === "cached") {
537
- notify(`key: plaintext-copy scan cached at ${String(scanOf(measured.report).scanned_at)}`);
538
- }
539
- const mode = measured.report?.mode;
540
- if (mode === "sealed" || mode === "unsealed") {
541
- const gate = scanGate(ctx.harness, measured);
542
- if (gate)
543
- return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
544
- return { storage: mode, reason: `keyslot_${mode}`, keyReference: line, slot, repairs, keyScan: keyStepScan(measured.report) };
545
- }
546
- const gate = scanGate(ctx.harness, measured);
547
- // A scan that ran but whose report could not be read is a scan that did not complete.
548
- const scanned = measured.phases.some((phase) => phase.mode !== "only");
549
- if (gate && (gate.reason !== "scan_not_completed" || scanned))
550
- return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
551
- return refused("keyslot_mode_unmeasured", "provision.sh succeeded but status.sh does not measure the slot as sealed or unsealed: run the packaged status.sh --json and resolve what it reports", repairs);
611
+ return selected(refused("provision_output_unrecognised", "provision.sh exited 0 without printing an agent_socket: reference; run it by hand and report the output", repairs));
612
+ // #552: NO scan here. The slot is usable only after `finalizeKeyStorage` sees the one scan.
613
+ return { kind: "keyslot", harness: ctx.harness, slot, keyReference: line, repairs, keyslotDir: dir };
552
614
  }
553
615
  if (provision.code === 2) {
554
616
  slotRefusal = /incapable_for_keyslot/.test(provision.stderr)
@@ -559,12 +621,12 @@ export async function selectKeyStorage(ctx) {
559
621
  }
560
622
  else {
561
623
  const token = Object.keys(PROVISION_REFUSALS).find((name) => provision.stderr.includes(name));
562
- return token
624
+ return selected(token
563
625
  ? refused(token, PROVISION_REFUSALS[token], repairs)
564
- : refused("provision_failed", "provision.sh failed: run the packaged provision.sh by hand and read what it reports", repairs);
626
+ : refused("provision_failed", "provision.sh failed: run the packaged provision.sh by hand and read what it reports", repairs));
565
627
  }
566
628
  }
567
629
  // 5. Refused, naming the fix.
568
630
  const cause = gate ?? slotRefusal ?? { reason: "no_key_storage", fix: "no key storage applies" };
569
- return refused(cause.reason, withDeps(cause.fix, ctx.deps), repairs);
631
+ return selected(refused(cause.reason, withDeps(cause.fix, ctx.deps), repairs));
570
632
  }
@@ -89,6 +89,13 @@ export type ChildRunner = (file: string, args: readonly string[], options: {
89
89
  export declare const defaultChildRunner: ChildRunner;
90
90
  /** An executable named `name` on `env`'s PATH (never `process.env`'s), or null. */
91
91
  export declare function which(name: string, env: NodeJS.ProcessEnv): string | null;
92
+ /**
93
+ * ONBOARD (IF-0-ONBOARD-1) — the harness presence probe, one rule for every identity harness: the
94
+ * harness's CLI, named as the harness, is on `env`'s PATH. The pi, opencode and claude writers
95
+ * need it to install; codex, grok and agy registrations are config files setup writes itself, so
96
+ * their writer does not require it, and `setup --all` is its only reader for those three.
97
+ */
98
+ export declare function harnessCliPath(harness: IdentityHarness, env: NodeJS.ProcessEnv): string | null;
92
99
  export interface WriterContext {
93
100
  harness: IdentityHarness;
94
101
  scope: SetupScope;
@@ -243,6 +243,15 @@ export function which(name, env) {
243
243
  }
244
244
  return null;
245
245
  }
246
+ /**
247
+ * ONBOARD (IF-0-ONBOARD-1) — the harness presence probe, one rule for every identity harness: the
248
+ * harness's CLI, named as the harness, is on `env`'s PATH. The pi, opencode and claude writers
249
+ * need it to install; codex, grok and agy registrations are config files setup writes itself, so
250
+ * their writer does not require it, and `setup --all` is its only reader for those three.
251
+ */
252
+ export function harnessCliPath(harness, env) {
253
+ return which(harness, env);
254
+ }
246
255
  function isSymlink(path) {
247
256
  try {
248
257
  return lstatSync(path).isSymbolicLink();
@@ -486,7 +495,7 @@ function packageSpecsSeen(harness, ctx) {
486
495
  return { specs, unparseable: scopes.unparseable };
487
496
  }
488
497
  async function installPackage(ctx, scopeCtx) {
489
- const cli = which(ctx.harness, ctx.env);
498
+ const cli = harnessCliPath(ctx.harness, ctx.env);
490
499
  if (cli === null)
491
500
  return failed("harness_cli_missing", `the ${ctx.harness} CLI is not on PATH: install ${ctx.harness}, then re-run setup`);
492
501
  const version = ctx.harness === "pi" ? ctx.assets.piVersion : ctx.assets.opencodeVersion;
@@ -805,7 +814,7 @@ function materializeMarketplace(ctx, scopeCtx) {
805
814
  return { dir, rewrote: true, notes };
806
815
  }
807
816
  async function installClaude(ctx, scopeCtx) {
808
- const cli = which("claude", ctx.env);
817
+ const cli = harnessCliPath("claude", ctx.env);
809
818
  if (cli === null)
810
819
  return failed("harness_cli_missing", "the claude CLI is not on PATH: install Claude Code, then re-run setup");
811
820
  const seen = boardEntries("claude", scopeCtx);