@consiliency/agent-board-cli 1.27.0 → 1.28.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,242 @@
1
+ // #552 IF-552-3 — the ONE place in the cli that spawns the packaged keyslot `status.sh`. Setup (the
2
+ // key step, the loader refresh, the re-read before enrolling) and doctor (`key_storage`) all measure
3
+ // a keyslot through `measureKeyslot`, so the scan cache, the two budgets and the process-group
4
+ // cleanup are decided once. KSC-7 pins that no other cli source names `"status.sh"` as a call
5
+ // argument (by the TypeScript AST).
6
+ //
7
+ // phase 1 `status.sh --scan-cache only` under the STATUS budget (180 s): every measurement, and
8
+ // the scan only if a valid cache holds it. Never scans.
9
+ // phase 2 only when phase 1's report says `scan.source: "none"` (no valid cache) and phase 1 did
10
+ // not exit 1 (unless the caller asked `onFailure: "scan"`): the caller's `before`
11
+ // notice, then `--scan-cache use` (or `fresh`) under the SCAN budget (30 min), with
12
+ // `status.sh`'s own progress lines relayed as they arrive, then the `result` notice.
13
+ //
14
+ // The default runner spawns `status.sh` detached, in its own process group, and on its timeout, or on
15
+ // this process's exit, SIGINT or SIGTERM, kills that group; `status.sh` forwards to its scan stage
16
+ // (which `timeout` leads in a group of its own) and bounds it itself (`--scan-timeout`).
17
+ import { spawn } from "node:child_process";
18
+ import { existsSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ /** The status budget: phase 1 (`--scan-cache only`) and every call that never scans. */
21
+ export const STATUS_TIMEOUT_MS = 180_000;
22
+ /** The scan budget (owner answer 2): phase 2, and `status.sh --scan-timeout` in seconds. */
23
+ export const STATUS_SCAN_TIMEOUT_MS = 1_800_000;
24
+ /**
25
+ * The helper's own kill fires this long after the scan budget: a backstop, never the bound. It must
26
+ * outlast everything `status.sh` legitimately does AFTER its stage's deadline: the stage's
27
+ * `--kill-after` grace, and a found copy's tombstone waiting out a held cache lock (#552 review r1: a
28
+ * shorter backstop turned a found copy into `scan_timed_out` with no tombstone). KSC-13 derives the
29
+ * floor from lib.sh.
30
+ */
31
+ export const SCAN_TOMBSTONE_WAIT_MS = 300_000;
32
+ export const SCAN_KILL_AFTER_MS = 10_000;
33
+ export const SCAN_LOCK_WAIT_MS = 10_000;
34
+ /**
35
+ * The waits status.sh may spend outside its stage's deadline that lib.sh bounds: the start read's
36
+ * lock wait, the stage's `--kill-after` grace and status.sh's own group reap (each up to
37
+ * kill-after), then the longest commit (a tombstone's lock wait) (#552 review r1b: the budget must
38
+ * never cut a found copy's tombstone wait short). The checks status.sh runs BEFORE the scan (probe,
39
+ * units, chain, agent, the blob's decrypt) have no bound of their own here: only `marginMs` covers
40
+ * them (#552 review r2). KSC-13 derives the waits from lib.sh; SC-20 runs the formula scaled.
41
+ */
42
+ export function scanBackstopMs(waits) {
43
+ return waits.lockWaitMs + 2 * waits.killAfterMs + waits.tombstoneWaitMs + waits.marginMs;
44
+ }
45
+ export const SCAN_BACKSTOP_MS = scanBackstopMs({
46
+ lockWaitMs: SCAN_LOCK_WAIT_MS, killAfterMs: SCAN_KILL_AFTER_MS, tombstoneWaitMs: SCAN_TOMBSTONE_WAIT_MS, marginMs: 60_000,
47
+ });
48
+ const liveGroups = new Set();
49
+ let handlersInstalled = false;
50
+ const killGroup = (pid, signal = "SIGTERM") => {
51
+ try {
52
+ process.kill(-pid, signal);
53
+ }
54
+ catch {
55
+ // already gone
56
+ }
57
+ };
58
+ const onExit = () => {
59
+ for (const pid of liveGroups)
60
+ killGroup(pid);
61
+ };
62
+ const onSignal = (signal) => {
63
+ onExit();
64
+ uninstallHandlers();
65
+ // Die by that signal, as the process would have without this handler.
66
+ process.kill(process.pid, signal);
67
+ };
68
+ const onSigint = () => onSignal("SIGINT");
69
+ const onSigterm = () => onSignal("SIGTERM");
70
+ function installHandlers() {
71
+ if (handlersInstalled)
72
+ return;
73
+ handlersInstalled = true;
74
+ process.on("exit", onExit);
75
+ process.on("SIGINT", onSigint);
76
+ process.on("SIGTERM", onSigterm);
77
+ }
78
+ function uninstallHandlers() {
79
+ if (!handlersInstalled)
80
+ return;
81
+ handlersInstalled = false;
82
+ process.removeListener("exit", onExit);
83
+ process.removeListener("SIGINT", onSigint);
84
+ process.removeListener("SIGTERM", onSigterm);
85
+ }
86
+ /**
87
+ * The default runner: detached (its own process group), stdout collected, stderr relayed line by line
88
+ * AS IT ARRIVES, and the whole group killed on the timeout or when this process exits or is
89
+ * interrupted. A signal handler removes itself and re-raises, so the process still dies by it.
90
+ */
91
+ export const defaultStorageRunner = (file, args, { env, timeoutMs = STATUS_TIMEOUT_MS, onStderrLine }) => new Promise((resolve) => {
92
+ let child;
93
+ try {
94
+ child = spawn(file, [...args], { env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
95
+ }
96
+ catch {
97
+ resolve({ code: null, stdout: "", stderr: "" });
98
+ return;
99
+ }
100
+ const pid = child.pid;
101
+ let stdout = "";
102
+ let stderr = "";
103
+ let pending = "";
104
+ let timedOut = false;
105
+ let settled = false;
106
+ if (pid !== undefined) {
107
+ liveGroups.add(pid);
108
+ installHandlers();
109
+ }
110
+ const timer = setTimeout(() => {
111
+ timedOut = true;
112
+ if (pid !== undefined)
113
+ killGroup(pid);
114
+ }, timeoutMs);
115
+ child.stdout.setEncoding("utf8");
116
+ child.stderr.setEncoding("utf8");
117
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
118
+ child.stderr.on("data", (chunk) => {
119
+ stderr += chunk;
120
+ pending += chunk;
121
+ let newline = pending.indexOf("\n");
122
+ while (newline >= 0) {
123
+ const line = pending.slice(0, newline);
124
+ pending = pending.slice(newline + 1);
125
+ if (onStderrLine)
126
+ onStderrLine(line);
127
+ newline = pending.indexOf("\n");
128
+ }
129
+ });
130
+ const finish = (code) => {
131
+ if (settled)
132
+ return;
133
+ settled = true;
134
+ clearTimeout(timer);
135
+ if (pid !== undefined) {
136
+ liveGroups.delete(pid);
137
+ if (liveGroups.size === 0)
138
+ uninstallHandlers();
139
+ }
140
+ if (pending !== "" && onStderrLine)
141
+ onStderrLine(pending);
142
+ resolve({ code: timedOut ? null : code, stdout, stderr, ...(timedOut ? { timedOut: true } : {}) });
143
+ };
144
+ child.on("error", () => finish(null));
145
+ child.on("close", (exit) => finish(typeof exit === "number" ? exit : null));
146
+ });
147
+ export const DEFAULT_BUDGETS = Object.freeze({ statusMs: STATUS_TIMEOUT_MS, scanMs: STATUS_SCAN_TIMEOUT_MS, backstopMs: SCAN_BACKSTOP_MS });
148
+ export const PROGRESS_LINE = /^status: scan: still scanning \((\d+) folders so far\)$/;
149
+ /** The relayed progress text, before the caller's prefix. */
150
+ export const progressText = (folders) => `checking… still scanning (${folders} folders so far)`;
151
+ const parseReport = (stdout) => {
152
+ try {
153
+ const parsed = JSON.parse(stdout);
154
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
155
+ }
156
+ catch {
157
+ return null;
158
+ }
159
+ };
160
+ /** `scan` of a status report, or an empty object. */
161
+ export function scanOf(report) {
162
+ const scan = report?.scan;
163
+ return scan && typeof scan === "object" && !Array.isArray(scan) ? scan : {};
164
+ }
165
+ /** `scan.source`: `fresh`, `cached` or `none`; anything else (an older status.sh, no scan at all) reads `none`. */
166
+ export function scanSourceOf(report) {
167
+ const source = scanOf(report).source;
168
+ return source === "fresh" || source === "cached" ? source : "none";
169
+ }
170
+ /** `scan.cache` as `{state, reason}` strings (reason "" when null). */
171
+ export function cacheStateOf(report) {
172
+ const cache = scanOf(report).cache;
173
+ return {
174
+ state: typeof cache?.state === "string" ? cache.state : "",
175
+ reason: typeof cache?.reason === "string" ? cache.reason : "",
176
+ };
177
+ }
178
+ /** IF-552-3: measure a keyslot in up to two phases. Never throws for a child's failure. */
179
+ export async function measureKeyslot(options) {
180
+ const file = join(options.dir, "status.sh");
181
+ if (!existsSync(file))
182
+ return { absent: true, code: null, report: null, phases: [], timedOut: false, folders: null };
183
+ const phases = [];
184
+ let folders = null;
185
+ let timedOut = false;
186
+ let lastReport = null;
187
+ // The measurement so far: the LAST phase's exit and report, always together. An unreadable
188
+ // phase-2 report is null here, never phase 1's: phase 1 said `scan_not_cached`, and pairing its
189
+ // report with phase 2's exit 0 authorized a pass (#552 review r1b, codex). Callers that report a
190
+ // timeout read phase 1 from `phases` themselves.
191
+ const result = () => {
192
+ const last = phases.at(-1);
193
+ return { absent: false, code: last ? last.code : null, report: lastReport, phases, timedOut, folders };
194
+ };
195
+ const run = options.run ?? defaultStorageRunner;
196
+ const budgets = { ...DEFAULT_BUDGETS, ...options.budgets };
197
+ const base = ["--harness", options.harness, "--slot", options.slot, "--json"];
198
+ const notify = options.notify ?? (() => { });
199
+ const call = async (mode) => {
200
+ const scanning = mode !== "only";
201
+ const args = [...base, "--scan-cache", mode, ...(scanning ? ["--scan-timeout", String(Math.max(1, Math.ceil(budgets.scanMs / 1000)))] : [])];
202
+ const outcome = await run(file, args, {
203
+ // Arms status.sh's parent watch (its ticker ends the scan if this process dies); a hand run,
204
+ // which has no such variable naming its parent, is never killed for being reparented.
205
+ env: { ...options.env, KEYSEAL_SCAN_WATCH_PID: String(process.pid) },
206
+ timeoutMs: scanning ? budgets.scanMs + budgets.backstopMs : budgets.statusMs,
207
+ onStderrLine: scanning
208
+ ? (line) => {
209
+ const match = PROGRESS_LINE.exec(line);
210
+ if (!match)
211
+ return;
212
+ folders = Number(match[1]);
213
+ notify({ kind: "progress", folders });
214
+ }
215
+ : undefined,
216
+ });
217
+ const phase = { mode, code: outcome.code, timedOut: outcome.timedOut === true, report: parseReport(outcome.stdout) };
218
+ phases.push(phase);
219
+ lastReport = phase.report;
220
+ return phase;
221
+ };
222
+ let phase1 = null;
223
+ if (options.scan !== "fresh") {
224
+ phase1 = await call("only");
225
+ if (options.scan === "only")
226
+ return result();
227
+ // A phase 1 that did not complete, or said nothing, is the caller's to report (status_did_not_complete).
228
+ if (phase1.code === null || phase1.report === null)
229
+ return result();
230
+ if (scanSourceOf(phase1.report) !== "none")
231
+ return result();
232
+ if (phase1.code === 1 && (options.onFailure ?? "stop") === "stop")
233
+ return result();
234
+ if (options.skipScan?.(phase1.report))
235
+ return result();
236
+ }
237
+ notify({ kind: "before", phase1: phase1?.report ?? null });
238
+ const phase2 = await call(options.scan === "fresh" ? "fresh" : "use");
239
+ timedOut = phase2.timedOut;
240
+ notify({ kind: "result", phase: phase2 });
241
+ return result();
242
+ }
package/dist/setup.js CHANGED
@@ -4,9 +4,11 @@ import { dirname, join } from "node:path";
4
4
  import { createInterface } from "node:readline";
5
5
  import { IDENTITY_HARNESSES, createAgentBoardClient, enrollCommitMarker, identityConfigRoot, identityFilePath, normalizeKeyReference, 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, repairKeyDir, keyslotSocketDir, selectKeyStorage as defaultSelectKeyStorage, } 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";
@@ -319,6 +321,7 @@ export async function runSetup(options, deps = {}) {
319
321
  out.record("deps", deps_.status, deps_.reason, deps_.fix);
320
322
  // ---- key --------------------------------------------------------------------------------
321
323
  let keyReference;
324
+ let keyScan;
322
325
  if (identity.kind === "present") {
323
326
  keyReference = identity.values.AGENT_BOARD_AGENT_KEY_REFERENCE;
324
327
  const slot = keyslotOf(keyReference, env);
@@ -351,9 +354,20 @@ export async function runSetup(options, deps = {}) {
351
354
  for (const repair of selection.repairs)
352
355
  out.notes.push(`key: repaired ${repair}`);
353
356
  if (selection.storage === "refused") {
354
- out.record("key", "failed", selection.reason, selection.fix);
357
+ // #552: a scan that gave up is undetermined (2); every other refusal, a found copy included, is 1.
358
+ out.record("key", selection.status ?? "failed", selection.reason, selection.fix);
355
359
  return out.report();
356
360
  }
361
+ if ((selection.storage === "sealed" || selection.storage === "unsealed") && selection.slot) {
362
+ // #552 IF-552-3 "Re-read before enrolling": a tombstone written after (or during) this step's
363
+ // scan stops setup here, before any enrollment call.
364
+ const reread = await rereadBeforeEnroll(h, selection.slot, env, deps, selection.keyScan);
365
+ if (reread) {
366
+ out.record("key", "failed", reread.reason, reread.fix);
367
+ return out.report();
368
+ }
369
+ keyScan = selection.keyScan;
370
+ }
357
371
  if (selection.storage === "os_keychain") {
358
372
  keyReference = `os_keychain:agent-board/${resolveHostSegment(env) ?? slug}/${h}`;
359
373
  }
@@ -477,7 +491,12 @@ export async function runSetup(options, deps = {}) {
477
491
  // ---- doctor -----------------------------------------------------------------------------
478
492
  let report;
479
493
  try {
480
- report = await (deps.doctor ?? runDoctor)({ harness: h, env, cwd, ...deps.doctorOptions });
494
+ // #552: the key step's FRESH scan is carried in-process; doctor uses it only where the cache
495
+ // could not store it (IF-552-3's three conditions). A cached key-step result carries nothing.
496
+ const setupScan = keyScan?.source === "fresh"
497
+ ? { setupScan: { inputs: keyScan.inputs, scan: keyScan.scan, scannedAt: keyScan.scannedAt, cacheWrite: keyScan.cacheWrite } }
498
+ : {};
499
+ report = await (deps.doctor ?? runDoctor)({ harness: h, env, cwd, ...deps.doctorOptions, ...setupScan });
481
500
  }
482
501
  catch (error) {
483
502
  out.record("doctor", "undetermined", authoredCode(error, "doctor_threw"));
@@ -529,7 +548,7 @@ export async function runSetup(options, deps = {}) {
529
548
  * running one, and the measurement is taken again.
530
549
  */
531
550
  async function refreshLoader(harness, slot, env, deps) {
532
- const runStorage = deps.storage?.run ?? defaultStorageRunner;
551
+ const runStorage = deps.storage?.run;
533
552
  let dir;
534
553
  try {
535
554
  dir = deps.storage?.keyslotDir ?? resolveKeyslotDir();
@@ -537,22 +556,18 @@ async function refreshLoader(harness, slot, env, deps) {
537
556
  catch {
538
557
  return { status: "undetermined", reason: "keyslot_scripts_absent", fix: "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli" };
539
558
  }
559
+ // #552: loader fields only, so `--scan-cache only`: this never scans (S-3).
540
560
  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
- }
561
+ const measured = await measureKeyslot({ dir, harness, slot, env, scan: "only", run: runStorage, budgets: deps.storage?.statusBudgets });
562
+ const loader = measured.report?.loader;
563
+ return loader ? { recorded: loader.recorded_sha256, running: loader.running_sha256 } : null;
549
564
  };
550
565
  const before = await measure();
551
566
  if (before === null || typeof before.running !== "string")
552
567
  return { status: "undetermined", reason: "loader_unmeasured", fix: "status.sh did not report the loader: run the packaged status.sh --json" };
553
568
  if (before.recorded === before.running)
554
569
  return { status: "already", reason: "identity_present" };
555
- const refresh = await runStorage(join(dir, "provision.sh"), ["--harness", harness, "--slot", slot, "--refresh-loader"], { env });
570
+ const refresh = await (runStorage ?? defaultStorageRunner)(join(dir, "provision.sh"), ["--harness", harness, "--slot", slot, "--refresh-loader"], { env });
556
571
  if (refresh.code !== 0)
557
572
  return { status: "failed", reason: "loader_refresh_failed", fix: "provision.sh --refresh-loader failed: run it by hand and read what it reports" };
558
573
  const after = await measure();
@@ -560,6 +575,37 @@ async function refreshLoader(harness, slot, env, deps) {
560
575
  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
576
  return { status: "done", reason: "loader_refreshed" };
562
577
  }
578
+ /** Cache reads that are STRUCTURAL: the cache cannot serve on this host at all. */
579
+ const STRUCTURAL_UNUSABLE = /^(untrusted:.+|state_root_relative|machine_id_unreadable)$/;
580
+ /**
581
+ * #552 IF-552-3 "Re-read before enrolling": `status.sh --scan-cache only` once more, judged on its
582
+ * `scan.cache`. `miss:cleared` (a tombstone) → plaintext_copy_found; `hit` → proceed; a structural
583
+ * `unusable`, or `miss:absent` where the key step's own write was `not_written:untrusted_dir` or
584
+ * `write_failed` → proceed on the key step's own clean result; anything else (`lock_busy`, expired,
585
+ * future, format, input_changed) → scan_not_completed. null = proceed.
586
+ */
587
+ async function rereadBeforeEnroll(harness, slot, env, deps, keyScan) {
588
+ let dir;
589
+ try {
590
+ dir = deps.storage?.keyslotDir ?? resolveKeyslotDir();
591
+ }
592
+ catch {
593
+ return { reason: "scan_not_completed", fix: "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli" };
594
+ }
595
+ const measured = await measureKeyslot({ dir, harness, slot, env, scan: "only", run: deps.storage?.run, budgets: deps.storage?.statusBudgets });
596
+ const { state, reason } = cacheStateOf(measured.report);
597
+ const statusCmd = `the packaged status.sh --harness ${harness} --json`;
598
+ if (state === "miss" && reason === "cleared") {
599
+ 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` };
600
+ }
601
+ if (state === "hit")
602
+ return null;
603
+ if (state === "unusable" && STRUCTURAL_UNUSABLE.test(reason))
604
+ return null;
605
+ if (state === "miss" && reason === "absent" && (keyScan?.cacheWrite === "not_written:untrusted_dir" || keyScan?.cacheWrite === "not_written:write_failed"))
606
+ return null;
607
+ 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}` };
608
+ }
563
609
  export function formatSetupReport(report) {
564
610
  return [...report.steps.map((step) => `${step.status.padEnd(12)} ${step.step}: ${step.reason}`), ...report.notes, `exit ${report.exit}`].join("\n");
565
611
  }
@@ -1,7 +1,24 @@
1
1
  import { type OsKeychainProbeResult } from "@consiliency/agent-board-client";
2
+ import { defaultStorageRunner, measureKeyslot, type KeyslotMeasurement, type RunOutcome, type ScanBudgets, type StorageRunner } from "./keyslot_status.js";
3
+ export { defaultStorageRunner, type RunOutcome, type StorageRunner };
2
4
  export type KeyStorage = "os_keychain" | "sealed" | "unsealed" | "external" | "refused";
5
+ /**
6
+ * #552 IF-552-3: the key step's completed, clean scan of THIS key, as setup needs it afterwards:
7
+ * the re-read before enrolling, and (only when `source` is `fresh`) the carry to its own doctor.
8
+ */
9
+ export interface KeyStepScan {
10
+ source: "fresh" | "cached";
11
+ inputs: Record<string, unknown>;
12
+ scan: Record<string, unknown>;
13
+ /** The scan's start, epoch seconds (from `scan.scanned_at`). */
14
+ scannedAt: number;
15
+ /** The key step's own cache write, `<state>:<reason>`. */
16
+ cacheWrite: string;
17
+ }
3
18
  export interface KeyStorageSelection {
4
19
  storage: KeyStorage;
20
+ /** For a refusal: `failed` (exit 1, the default) or `undetermined` (exit 2: `scan_timed_out`). */
21
+ status?: "failed" | "undetermined";
5
22
  /** An authored code: never a path or any other file string. */
6
23
  reason: string;
7
24
  /** For a refusal: what the user runs or changes. */
@@ -15,16 +32,9 @@ export interface KeyStorageSelection {
15
32
  slot?: string;
16
33
  /** The #536 repairs performed, by name, in order. */
17
34
  repairs: string[];
35
+ /** sealed/unsealed: the key step's completed, clean scan (IF-552-3). */
36
+ keyScan?: KeyStepScan;
18
37
  }
19
- export interface RunOutcome {
20
- /** null when the child did not exit on its own (killed, timed out, not spawned). */
21
- code: number | null;
22
- stdout: string;
23
- stderr: string;
24
- }
25
- export type StorageRunner = (file: string, args: readonly string[], options: {
26
- env: NodeJS.ProcessEnv;
27
- }) => Promise<RunOutcome>;
28
38
  /** The `deps` step's result, as far as a refusal needs it. */
29
39
  export interface DepsOutcome {
30
40
  status: "done" | "already" | "skipped" | "failed" | "undetermined";
@@ -53,9 +63,12 @@ export interface KeyStorageContext {
53
63
  machineIdFile?: string;
54
64
  bash?: string;
55
65
  systemctl?: string;
66
+ /** #552 IF-552-4: the key step's notices (`key: …`), one line each. Default `process.stderr`. */
67
+ notify?: (line: string) => void;
68
+ /** #552: the status.sh budgets (tests). */
69
+ statusBudgets?: Partial<ScanBudgets>;
56
70
  }
57
71
  export declare const SLOT_RE: RegExp;
58
- export declare const defaultStorageRunner: StorageRunner;
59
72
  /** The opt-in record's host comparand: sha256 of the whole machine-id file, first 16 hex (`sha256sum <`). */
60
73
  export declare function machineIdOf(file?: string): string | null;
61
74
  /** `$XDG_RUNTIME_DIR/agent-board`, where each keyslot's agent socket lives (constants.sh). */
@@ -104,6 +117,18 @@ export declare const EXTERNAL_FIELDS: readonly ["machine_id", "uid", "socket"];
104
117
  * canonical `socket=` (absolute, its own realpath) that is the same device and inode. Empty = valid.
105
118
  */
106
119
  export declare function externalDeclarationProblems(env: NodeJS.ProcessEnv, socketPath: string, uid: number, machineId: string | null): string[];
120
+ /**
121
+ * IF-552-3 "Setup's enrollment gate": enroll only after a completed, clean scan of this key, fresh or
122
+ * cached. A found copy is `plaintext_copy_found` (1); a scan that gave up is `scan_timed_out` (2);
123
+ * anything else that is not a completed scan is `scan_not_completed` (1). null = the scan passed.
124
+ */
125
+ export declare function scanGate(harness: string, measured: KeyslotMeasurement): {
126
+ status: "failed" | "undetermined";
127
+ reason: string;
128
+ fix: string;
129
+ } | null;
130
+ /** IF-552-4: setup's `key: ` notices for one measurement. */
131
+ export declare function keyScanNotices(harness: string, notify: (line: string) => void): (event: Parameters<NonNullable<Parameters<typeof measureKeyslot>[0]["notify"]>>[0]) => void;
107
132
  /** The #536 repair, then the full ancestor walk. Returns the repairs, or the problems. */
108
133
  export declare function repairKeyDir(env: NodeJS.ProcessEnv, uid: number): {
109
134
  repairs: string[];
@@ -19,23 +19,17 @@
19
19
  // Every `reason` is an authored code, never file content; the human fix is in `fix`. The same
20
20
  // module holds the owner-only KEY=value record reader that doctor's `key_storage` uses for the
21
21
  // unsealed opt-in and the external-agent declaration (IF-0-KEYSEAL-6: "checked like the opt-in").
22
- import { execFile } from "node:child_process";
23
22
  import { createHash } from "node:crypto";
24
23
  import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, statSync } from "node:fs";
25
24
  import { dirname, isAbsolute, join } from "node:path";
26
25
  import { OS_KEYCHAIN_SERVICE, probeOsKeychainRoundTrip } from "@consiliency/agent-board-client";
27
26
  import { resolveKeyslotDir } from "./assets.js";
27
+ import { cacheStateOf, defaultStorageRunner, measureKeyslot, progressText, scanOf, scanSourceOf, } from "./keyslot_status.js";
28
+ export { defaultStorageRunner };
28
29
  export const SLOT_RE = /^[a-z][a-z0-9-]{0,31}$/;
29
30
  const REFERENCE_LINE = /^agent_socket:\/\S+#[A-Za-z0-9_-]{43}$/;
30
31
  /** States in which `systemctl --user` has a manager answering. */
31
32
  const MANAGER_REACHABLE = new Set(["initializing", "starting", "running", "degraded", "maintenance", "stopping"]);
32
- const RUN_TIMEOUT_MS = 180_000;
33
- export const defaultStorageRunner = (file, args, { env }) => new Promise((resolve) => {
34
- execFile(file, [...args], { env, timeout: RUN_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024, encoding: "utf8" }, (error, stdout, stderr) => {
35
- const code = error ? (typeof error.code === "number" && !error.killed ? error.code : null) : 0;
36
- resolve({ code, stdout: String(stdout ?? ""), stderr: String(stderr ?? "") });
37
- });
38
- });
39
33
  const currentUid = () => (typeof process.getuid === "function" ? process.getuid() : -1);
40
34
  const configRoot = (env) => {
41
35
  const xdg = env.XDG_CONFIG_HOME?.trim();
@@ -265,6 +259,79 @@ export function externalDeclarationProblems(env, socketPath, uid, machineId) {
265
259
  // selectKeyStorage
266
260
  // ---------------------------------------------------------------------------------------------
267
261
  const refused = (reason, fix, repairs = []) => ({ storage: "refused", reason, fix, repairs });
262
+ const epochOf = (iso) => {
263
+ if (typeof iso !== "string")
264
+ return null;
265
+ const ms = Date.parse(iso);
266
+ return Number.isFinite(ms) ? Math.floor(ms / 1000) : null;
267
+ };
268
+ const isoPlus = (epoch, seconds) => new Date((epoch + seconds) * 1000).toISOString().replace(/\.\d{3}Z$/, "Z");
269
+ /**
270
+ * IF-552-3 "Setup's enrollment gate": enroll only after a completed, clean scan of this key, fresh or
271
+ * cached. A found copy is `plaintext_copy_found` (1); a scan that gave up is `scan_timed_out` (2);
272
+ * anything else that is not a completed scan is `scan_not_completed` (1). null = the scan passed.
273
+ */
274
+ export function scanGate(harness, measured) {
275
+ const report = measured.report;
276
+ const scan = scanOf(report);
277
+ const reasons = Array.isArray(report?.exit2_reasons) ? report.exit2_reasons : [];
278
+ const scanPhase = measured.phases.find((phase) => phase.mode !== "only");
279
+ const statusCmd = `the packaged status.sh --harness ${harness} --json`;
280
+ // Only the helper's own scan budget, or status.sh's own self-bound, is a timeout. Any other kill
281
+ // of the scan is a scan that did not complete (#552 review r1).
282
+ if (measured.timedOut || reasons.includes("scan_timed_out")) {
283
+ return { status: "undetermined", reason: "scan_timed_out", fix: `the plaintext-copy scan gave up after 30 min, so this key is NOT enrolled; re-running setup is safe (provision.sh does not re-key a committed slot), or finish the scan by hand with ${statusCmd}` };
284
+ }
285
+ const source = scanSourceOf(report);
286
+ const matches = Array.isArray(scan.plaintext_matches) ? scan.plaintext_matches : null;
287
+ const checks = report?.checks;
288
+ if (source !== "none" && matches !== null && matches.length > 0) {
289
+ return { status: "failed", reason: "plaintext_copy_found", fix: `a plaintext copy of the new key was found, so it is NOT enrolled: delete the copies ${statusCmd} lists and re-run setup (a committed slot is not re-keyed); if a copy may have left this host, run provision.sh --harness ${harness} --rotate instead` };
290
+ }
291
+ if ((scanPhase && scanPhase.code === null) || source === "none" || matches === null || checks?.scan?.ok !== true) {
292
+ return { status: "failed", reason: "scan_not_completed", fix: `the plaintext-copy scan did not complete, so this key is NOT enrolled: run ${statusCmd} and resolve what it reports, then re-run setup` };
293
+ }
294
+ return null;
295
+ }
296
+ /** The key step's scan, recorded for the re-read and the carry (the gate passed). */
297
+ function keyStepScan(report) {
298
+ const scan = scanOf(report);
299
+ const source = scanSourceOf(report);
300
+ const inputs = scan.inputs;
301
+ const scannedAt = epochOf(scan.scanned_at);
302
+ if (source === "none" || !inputs || typeof inputs !== "object" || scannedAt === null)
303
+ return undefined;
304
+ const { state, reason } = cacheStateOf(report);
305
+ return { source, inputs: inputs, scan, scannedAt, cacheWrite: `${state}:${reason}` };
306
+ }
307
+ /** IF-552-4: setup's `key: ` notices for one measurement. */
308
+ export function keyScanNotices(harness, notify) {
309
+ return (event) => {
310
+ if (event.kind === "before") {
311
+ const scan = scanOf(event.phase1);
312
+ const cache = scan.cache;
313
+ const last = typeof cache?.last_scanned_at === "string" ? cache.last_scanned_at : "never";
314
+ const why = typeof cache?.read === "string" ? cache.read : "no cache";
315
+ notify(`key: checking your home and temp folders for stray plaintext copies of the key; on a large home this can take several minutes (last scan: ${last}, ${why})`);
316
+ }
317
+ else if (event.kind === "progress") {
318
+ notify(`key: ${progressText(event.folders)}`);
319
+ }
320
+ else {
321
+ const report = event.phase.report;
322
+ const scan = scanOf(report);
323
+ const matches = Array.isArray(scan.plaintext_matches) ? scan.plaintext_matches.length : null;
324
+ if (scanSourceOf(report) === "none" || matches === null)
325
+ return;
326
+ if (matches > 0) {
327
+ notify(`key: found ${matches} plaintext cop${matches === 1 ? "y" : "ies"} of the key; run the packaged status.sh --harness ${harness} --json to list them`);
328
+ return;
329
+ }
330
+ const start = epochOf(scan.scanned_at);
331
+ notify(`key: no plaintext copy of the key found (${typeof scan.candidates === "number" ? scan.candidates : "?"} candidate files, ${typeof scan.duration_s === "number" ? scan.duration_s : "?"} s); doctor reuses this until ${start === null ? "24 h after the scan" : isoPlus(start, 86_400)} or the key changes; \`agent-board doctor --harness ${harness} --rescan\` scans again`);
332
+ }
333
+ };
334
+ }
268
335
  /** The #536 repair, then the full ancestor walk. Returns the repairs, or the problems. */
269
336
  export function repairKeyDir(env, uid) {
270
337
  const repairs = [];
@@ -457,16 +524,30 @@ export async function selectKeyStorage(ctx) {
457
524
  const line = provision.stdout.split("\n").map((entry) => entry.trim()).filter(Boolean).at(-1) ?? "";
458
525
  if (!REFERENCE_LINE.test(line))
459
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);
460
- const status = await run(join(dir, "status.sh"), ["--harness", ctx.harness, "--slot", slot, "--json"], { env });
461
- let mode = null;
462
- try {
463
- mode = JSON.parse(status.stdout).mode;
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)}`);
464
538
  }
465
- catch {
466
- mode = null;
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) };
467
545
  }
468
- if (mode === "sealed" || mode === "unsealed")
469
- return { storage: mode, reason: `keyslot_${mode}`, keyReference: line, slot, repairs };
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 };
470
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);
471
552
  }
472
553
  if (provision.code === 2) {