@consiliency/agent-board-cli 1.27.1 → 1.29.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,174 +1,5 @@
1
- // CREDPATH SL-2 — the identity file WRITER. The contract (key set, value shapes, path, trusted
2
- // walk) is the client's `identity_file.ts`; nothing here restates it. This module only decides
3
- // which VALUES go in, and writes them the way the reader will accept.
4
- //
5
- // Order matters, because the Portal enrollment token is single-use
6
- // (Consiliency/message-board#521): `resolveIdentityDeployment` runs BEFORE enrollment commits,
7
- // so a missing or secret-shaped deployment value is refused while the token is still unspent.
8
- // Only then does enrollment run, and `identityValuesFromEnrollment` + `writeIdentityFile` turn
9
- // its outcome into the file.
10
- import { createHash } from "node:crypto";
11
- import { constants as FS } from "node:fs";
12
- import * as fs from "node:fs/promises";
13
- import { dirname, join } from "node:path";
14
- import { CredentialResolutionError, DEPLOYMENT_ENV_ALIASES, describeMissingDeploymentFields, identityConfigRoot, resolveDeploymentField, identityFilePath, serializeIdentityFile, walkTrustedDirectory, } from "@consiliency/agent-board-client";
15
- async function syncDirectory(path) {
16
- const handle = await fs.open(path, "r");
17
- try {
18
- await handle.sync();
19
- }
20
- finally {
21
- await handle.close();
22
- }
23
- }
24
- /** Create `path` exclusively (never following a planted link) at 0600, write, fsync. */
25
- export async function writeOwnerOnlyFile(path, content) {
26
- const handle = await fs.open(path, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
27
- try {
28
- await handle.writeFile(content, "utf8");
29
- await handle.sync();
30
- }
31
- finally {
32
- await handle.close();
33
- }
34
- }
35
- /**
36
- * Write `content` to `path` so a reader sees the old file or the whole new one, never a
37
- * part: an exclusive 0600 temp beside it, fsync'd, renamed over it, then the directory
38
- * fsync'd. On any throw before the rename the temp is removed.
39
- */
40
- export async function writeOwnerOnlyFileAtomic(path, content, options = {}) {
41
- const sync = options.syncDirectory ?? syncDirectory;
42
- const temp = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
43
- try {
44
- await writeOwnerOnlyFile(temp, content);
45
- await fs.rename(temp, path);
46
- }
47
- catch (error) {
48
- await fs.unlink(temp).catch(() => { });
49
- throw error;
50
- }
51
- await sync(dirname(path));
52
- }
53
- /**
54
- * Where each deployment value is read, in precedence order: the client's one alias table
55
- * (`DEPLOYMENT_ENV_ALIASES`), which puts the enrollment's own names first (so the file records
56
- * the deployment the enrollment used) and then the identity names. The enrollment resolvers read
57
- * the same table, so the two cannot disagree. The generic Supabase names are in no list: a
58
- * generic key never fills an identity field, here or in the loader.
59
- */
60
- export const DEPLOYMENT_ENV_NAMES = Object.freeze({
61
- boardUrl: DEPLOYMENT_ENV_ALIASES.boardUrl,
62
- sessionUrl: DEPLOYMENT_ENV_ALIASES.sessionUrl,
63
- publishableKey: DEPLOYMENT_ENV_ALIASES.publishableKey,
64
- });
65
- const DEPLOYMENT_FILE_KEYS = {
66
- boardUrl: "AGENT_BOARD_URL",
67
- sessionUrl: "AGENT_BOARD_SESSION_URL",
68
- publishableKey: "AGENT_BOARD_PUBLISHABLE_KEY",
69
- };
70
- // Well-shaped stand-ins for the values only the enrollment outcome supplies. The preflight
71
- // substitutes ONE deployment value into this map and lets `serializeIdentityFile` judge it, so
72
- // the shape rule applied here is the parser's own, never a second copy.
73
- const PREFLIGHT_STAND_INS = {
74
- AGENT_BOARD_URL: "https://preflight.invalid",
75
- AGENT_BOARD_SESSION_URL: "https://preflight.invalid/session",
76
- AGENT_BOARD_PUBLISHABLE_KEY: "sb_publishable_preflight",
77
- AGENT_BOARD_AGENT_SLUG: "preflight",
78
- AGENT_BOARD_EXPECTED_AGENT_HANDLE: "preflight",
79
- AGENT_BOARD_AGENT_KEY_REFERENCE: "os_keychain:preflight",
80
- AGENT_BOARD_SIGNING_KEY_ID: "preflight",
81
- AGENT_BOARD_SIGNING_KEY_VERSION: "1",
82
- AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT: `sha256:${"A".repeat(43)}`,
83
- MESSAGE_BOARD_AGENT_AUTH_MODE: "keystore_session",
84
- };
85
- /**
86
- * The deployment half of an identity, read and shape-checked before enrollment commits. Each
87
- * value is the shipped default, or env under the board group's all-or-nothing rule (the
88
- * client's `resolveDeploymentField`). Absent from an env-set group → `identity_incomplete`,
89
- * naming the variable that switched the group off; not the non-secret shape its key requires →
90
- * `identity_secret_value`. Both name the variable, never the value.
91
- */
92
- export function resolveIdentityDeployment(env) {
93
- const deployment = {};
94
- for (const field of Object.keys(DEPLOYMENT_ENV_NAMES)) {
95
- const resolved = resolveDeploymentField(env, field);
96
- if (resolved.value === undefined) {
97
- throw new CredentialResolutionError("identity_incomplete", `cannot write an identity file: no ${DEPLOYMENT_FILE_KEYS[field]} for it ${describeMissingDeploymentFields([field], resolved.switchedBy)}`);
98
- }
99
- // A default is named by the file key it fills: it came from no variable.
100
- const found = { name: resolved.name ?? `the default ${DEPLOYMENT_FILE_KEYS[field]}`, value: resolved.value };
101
- try {
102
- serializeIdentityFile({ ...PREFLIGHT_STAND_INS, [DEPLOYMENT_FILE_KEYS[field]]: found.value });
103
- }
104
- catch (error) {
105
- if (error.code !== "identity_secret_value")
106
- throw error;
107
- throw new CredentialResolutionError("identity_secret_value", `${found.name} is not the non-secret shape ${DEPLOYMENT_FILE_KEYS[field]} requires; an identity file carries public values only`);
108
- }
109
- deployment[field] = found.value;
110
- }
111
- return deployment;
112
- }
113
- // ---------------------------------------------------------------------------------------------
114
- // Outcome → values → file
115
- // ---------------------------------------------------------------------------------------------
116
- /** `sha256:` + base64url, over the DECODED public key — exactly what fleet finalize recomputes. */
117
- function publicKeyFingerprint(agentPublicKey) {
118
- const publicKey = Buffer.from(agentPublicKey, "base64url");
119
- // The same refusal finalize makes: a value that is not canonical base64url of 32 bytes would
120
- // still hash to a plausible fingerprint, of a key that is not the enrolled one. The
121
- // re-encode comparison is also the charset and padding check.
122
- if (publicKey.length !== 32 || publicKey.toString("base64url") !== agentPublicKey) {
123
- throw new CredentialResolutionError("identity_public_key_invalid", "the enrollment outcome's public key is not a 32-byte base64url key");
124
- }
125
- return `sha256:${createHash("sha256").update(publicKey).digest("base64url")}`;
126
- }
127
- /**
128
- * The identity file's values for an enrolled agent. The URLs and publishable key come only
129
- * from `deployment`; everything else from the outcome. Validated by the client's serializer
130
- * before it is returned.
131
- */
132
- export function identityValuesFromEnrollment(outcome, deployment) {
133
- const values = {
134
- AGENT_BOARD_URL: deployment.boardUrl,
135
- AGENT_BOARD_SESSION_URL: deployment.sessionUrl,
136
- AGENT_BOARD_PUBLISHABLE_KEY: deployment.publishableKey,
137
- AGENT_BOARD_AGENT_SLUG: outcome.agentHandle,
138
- AGENT_BOARD_EXPECTED_AGENT_HANDLE: outcome.agentHandle,
139
- // The form `fleet_onboarding.ts` checks: `${resolver}:${handle}`.
140
- AGENT_BOARD_AGENT_KEY_REFERENCE: `${outcome.keyReference.resolver}:${outcome.keyReference.handle}`,
141
- // `keyId`, the server's key identity /session mints against — NOT `signingKeyId`, the row.
142
- AGENT_BOARD_SIGNING_KEY_ID: outcome.keyId,
143
- AGENT_BOARD_SIGNING_KEY_VERSION: String(outcome.keyVersion),
144
- AGENT_BOARD_SIGNING_PUBLIC_KEY_FINGERPRINT: publicKeyFingerprint(outcome.agentPublicKey),
145
- MESSAGE_BOARD_AGENT_AUTH_MODE: "keystore_session",
146
- };
147
- serializeIdentityFile(values);
148
- return values;
149
- }
150
- async function mkdirPrivate(path) {
151
- await fs.mkdir(path, { mode: 0o700 }).catch((error) => {
152
- if (error.code !== "EEXIST")
153
- throw error;
154
- });
155
- }
156
- /**
157
- * Write `harness`'s identity file for this host. Every refusal (bad harness, relative root,
158
- * invalid host, bad value) fires before the first filesystem change. The directory is then
159
- * put through the reader's own trusted walk, so the writer never produces a file the loader
160
- * would refuse to open. Returns the path written.
161
- */
162
- export async function writeIdentityFile(harness, values, env, options = {}) {
163
- const path = identityFilePath(harness, env);
164
- const content = serializeIdentityFile(values);
165
- const configRoot = identityConfigRoot(env);
166
- const strictFrom = join(configRoot, "agent-board");
167
- await fs.mkdir(configRoot, { recursive: true, mode: 0o700 });
168
- await mkdirPrivate(strictFrom);
169
- await mkdirPrivate(join(strictFrom, "identities"));
170
- await mkdirPrivate(dirname(path));
171
- walkTrustedDirectory(dirname(path), strictFrom);
172
- await writeOwnerOnlyFileAtomic(path, content, options);
173
- return path;
174
- }
1
+ // CREDPATH SL-2 — the identity file writer. ONBOARD SL-1 (IF-0-ONBOARD-9) moved its bodies to the
2
+ // client (`@consiliency/agent-board-client`, `identity_writer.ts`) so the MCP server's in-chat
3
+ // enrollment writes the same file through the same code; this module keeps every export, as a
4
+ // re-export, so no cli caller changes.
5
+ export { DEPLOYMENT_ENV_NAMES, identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, writeOwnerOnlyFile, writeOwnerOnlyFileAtomic, } from "@consiliency/agent-board-client";
@@ -0,0 +1,134 @@
1
+ /** The status budget: phase 1 (`--scan-cache only`) and every call that never scans. */
2
+ export declare const STATUS_TIMEOUT_MS = 180000;
3
+ /** The scan budget (owner answer 2): phase 2, and `status.sh --scan-timeout` in seconds. */
4
+ export declare const STATUS_SCAN_TIMEOUT_MS = 1800000;
5
+ /**
6
+ * The helper's own kill fires this long after the scan budget: a backstop, never the bound. It must
7
+ * outlast everything `status.sh` legitimately does AFTER its stage's deadline: the stage's
8
+ * `--kill-after` grace, and a found copy's tombstone waiting out a held cache lock (#552 review r1: a
9
+ * shorter backstop turned a found copy into `scan_timed_out` with no tombstone). KSC-13 derives the
10
+ * floor from lib.sh.
11
+ */
12
+ export declare const SCAN_TOMBSTONE_WAIT_MS = 300000;
13
+ export declare const SCAN_KILL_AFTER_MS = 10000;
14
+ export declare const SCAN_LOCK_WAIT_MS = 10000;
15
+ /**
16
+ * The waits status.sh may spend outside its stage's deadline that lib.sh bounds: the start read's
17
+ * lock wait, the stage's `--kill-after` grace and status.sh's own group reap (each up to
18
+ * kill-after), then the longest commit (a tombstone's lock wait) (#552 review r1b: the budget must
19
+ * never cut a found copy's tombstone wait short). The checks status.sh runs BEFORE the scan (probe,
20
+ * units, chain, agent, the blob's decrypt) have no bound of their own here: only `marginMs` covers
21
+ * them (#552 review r2). KSC-13 derives the waits from lib.sh; SC-20 runs the formula scaled.
22
+ */
23
+ export declare function scanBackstopMs(waits: {
24
+ lockWaitMs: number;
25
+ killAfterMs: number;
26
+ tombstoneWaitMs: number;
27
+ marginMs: number;
28
+ }): number;
29
+ export declare const SCAN_BACKSTOP_MS: number;
30
+ export interface RunOutcome {
31
+ /** null when the child did not exit on its own (killed, timed out, not spawned). */
32
+ code: number | null;
33
+ stdout: string;
34
+ stderr: string;
35
+ /** The runner's own timeout fired. */
36
+ timedOut?: boolean;
37
+ }
38
+ export interface RunOptions {
39
+ env: NodeJS.ProcessEnv;
40
+ /** Default `STATUS_TIMEOUT_MS`. */
41
+ timeoutMs?: number;
42
+ /** Each complete stderr line, as it arrives (progress relay). */
43
+ onStderrLine?: (line: string) => void;
44
+ }
45
+ export type StorageRunner = (file: string, args: readonly string[], options: RunOptions) => Promise<RunOutcome>;
46
+ /**
47
+ * The default runner: detached (its own process group), stdout collected, stderr relayed line by line
48
+ * AS IT ARRIVES, and the whole group killed on the timeout or when this process exits or is
49
+ * interrupted. A signal handler removes itself and re-raises, so the process still dies by it.
50
+ */
51
+ export declare const defaultStorageRunner: StorageRunner;
52
+ export type ScanMode = "use" | "only" | "fresh";
53
+ export interface ScanBudgets {
54
+ statusMs: number;
55
+ scanMs: number;
56
+ backstopMs: number;
57
+ }
58
+ export declare const DEFAULT_BUDGETS: ScanBudgets;
59
+ export interface KeyslotPhase {
60
+ mode: ScanMode;
61
+ code: number | null;
62
+ timedOut: boolean;
63
+ /** The parsed JSON object, or null when stdout was not one. */
64
+ report: Record<string, unknown> | null;
65
+ }
66
+ /** What a caller is told while a scan runs. The caller words it (setup: `key: `, doctor: `doctor: key_storage: `). */
67
+ export type ScanEvent = {
68
+ kind: "before";
69
+ phase1: Record<string, unknown> | null;
70
+ } | {
71
+ kind: "progress";
72
+ folders: number;
73
+ } | {
74
+ kind: "result";
75
+ phase: KeyslotPhase;
76
+ };
77
+ export interface KeyslotMeasurement {
78
+ /** `status.sh` is not in the packaged directory (doctor: `status_absent`). */
79
+ absent: boolean;
80
+ /** The last phase's exit code (null: it did not exit on its own). */
81
+ code: number | null;
82
+ /** The last phase's report (phase 2's when it ran, else phase 1's). */
83
+ report: Record<string, unknown> | null;
84
+ phases: KeyslotPhase[];
85
+ /** The SCAN phase (phase 2, or a `fresh` call) hit the helper's backstop. */
86
+ timedOut: boolean;
87
+ /** The last `still scanning` count relayed, if any. */
88
+ folders: number | null;
89
+ }
90
+ export interface MeasureOptions {
91
+ dir: string;
92
+ harness: string;
93
+ slot: string;
94
+ env: NodeJS.ProcessEnv;
95
+ scan: ScanMode;
96
+ /** `stop` (doctor): a phase-1 exit 1 returns without phase 2. `scan` (setup): phase 2 still runs. Default `stop`. */
97
+ onFailure?: "stop" | "scan";
98
+ notify?: (event: ScanEvent) => void;
99
+ budgets?: Partial<ScanBudgets>;
100
+ run?: StorageRunner;
101
+ /** Doctor's carry: return true to skip phase 2 on this phase-1 report (setup's in-process result serves). */
102
+ skipScan?: (phase1: Record<string, unknown>) => boolean;
103
+ }
104
+ export declare const PROGRESS_LINE: RegExp;
105
+ /** The relayed progress text, before the caller's prefix. */
106
+ export declare const progressText: (folders: number) => string;
107
+ /** `scan` of a status report, or an empty object. */
108
+ export declare function scanOf(report: Record<string, unknown> | null | undefined): Record<string, unknown>;
109
+ /** `scan.source`: `fresh`, `cached` or `none`; anything else (an older status.sh, no scan at all) reads `none`. */
110
+ export declare function scanSourceOf(report: Record<string, unknown> | null | undefined): "fresh" | "cached" | "none";
111
+ /** `scan.cache` as `{state, reason}` strings (reason "" when null). */
112
+ export declare function cacheStateOf(report: Record<string, unknown> | null | undefined): {
113
+ state: string;
114
+ reason: string;
115
+ };
116
+ /** IF-552-3: measure a keyslot in up to two phases. Never throws for a child's failure. */
117
+ export declare function measureKeyslot(options: MeasureOptions): Promise<KeyslotMeasurement>;
118
+ export interface MeasureSlotsOptions extends Omit<MeasureOptions, "harness" | "slot" | "notify" | "skipScan"> {
119
+ /** The slots to measure. Each name is ALSO its harness: `status.sh --slots` takes no `--harness`. */
120
+ slots: readonly string[];
121
+ /** Per-slot events; `progress` is the one shared walk's, so its slot is null. */
122
+ notify?: (slot: string | null, event: ScanEvent) => void;
123
+ }
124
+ /**
125
+ * ONBOARD SL-4 (IF-0-ONBOARD-1 "a key scan at most once per host"): several keyslots measured with
126
+ * ONE filesystem walk, in the same two phases as `measureKeyslot`. One slot IS `measureKeyslot`
127
+ * (the `--harness` form, byte-identical to a single-harness setup). Two or more use
128
+ * `status.sh --slots a,b,… --json`: phase 1 (`--scan-cache only`) for every slot, then ONE phase 2
129
+ * over just the slots phase 1 left without a valid cache. That form has no `--harness`, so each
130
+ * name is both the harness and the slot — true for setup, which never names a slot of its own.
131
+ * Each slot's phase `code` is its own line's `exit`; the process exit is only the worst of them.
132
+ * Lines are matched by their `slot` field, never by position. Never throws for a child's failure.
133
+ */
134
+ export declare function measureKeyslots(options: MeasureSlotsOptions): Promise<Map<string, KeyslotMeasurement>>;
@@ -0,0 +1,348 @@
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. `setup --all` measures several slots through `measureKeyslots`
5
+ // (`status.sh --slots`, one walk). KSC-7 pins that no other cli source names `"status.sh"` as a
6
+ // call argument (by the TypeScript AST).
7
+ //
8
+ // phase 1 `status.sh --scan-cache only` under the STATUS budget (180 s): every measurement, and
9
+ // the scan only if a valid cache holds it. Never scans.
10
+ // phase 2 only when phase 1's report says `scan.source: "none"` (no valid cache) and phase 1 did
11
+ // not exit 1 (unless the caller asked `onFailure: "scan"`): the caller's `before`
12
+ // notice, then `--scan-cache use` (or `fresh`) under the SCAN budget (30 min), with
13
+ // `status.sh`'s own progress lines relayed as they arrive, then the `result` notice.
14
+ //
15
+ // The default runner spawns `status.sh` detached, in its own process group, and on its timeout, or on
16
+ // this process's exit, SIGINT or SIGTERM, kills that group; `status.sh` forwards to its scan stage
17
+ // (which `timeout` leads in a group of its own) and bounds it itself (`--scan-timeout`).
18
+ import { spawn } from "node:child_process";
19
+ import { existsSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ /** The status budget: phase 1 (`--scan-cache only`) and every call that never scans. */
22
+ export const STATUS_TIMEOUT_MS = 180_000;
23
+ /** The scan budget (owner answer 2): phase 2, and `status.sh --scan-timeout` in seconds. */
24
+ export const STATUS_SCAN_TIMEOUT_MS = 1_800_000;
25
+ /**
26
+ * The helper's own kill fires this long after the scan budget: a backstop, never the bound. It must
27
+ * outlast everything `status.sh` legitimately does AFTER its stage's deadline: the stage's
28
+ * `--kill-after` grace, and a found copy's tombstone waiting out a held cache lock (#552 review r1: a
29
+ * shorter backstop turned a found copy into `scan_timed_out` with no tombstone). KSC-13 derives the
30
+ * floor from lib.sh.
31
+ */
32
+ export const SCAN_TOMBSTONE_WAIT_MS = 300_000;
33
+ export const SCAN_KILL_AFTER_MS = 10_000;
34
+ export const SCAN_LOCK_WAIT_MS = 10_000;
35
+ /**
36
+ * The waits status.sh may spend outside its stage's deadline that lib.sh bounds: the start read's
37
+ * lock wait, the stage's `--kill-after` grace and status.sh's own group reap (each up to
38
+ * kill-after), then the longest commit (a tombstone's lock wait) (#552 review r1b: the budget must
39
+ * never cut a found copy's tombstone wait short). The checks status.sh runs BEFORE the scan (probe,
40
+ * units, chain, agent, the blob's decrypt) have no bound of their own here: only `marginMs` covers
41
+ * them (#552 review r2). KSC-13 derives the waits from lib.sh; SC-20 runs the formula scaled.
42
+ */
43
+ export function scanBackstopMs(waits) {
44
+ return waits.lockWaitMs + 2 * waits.killAfterMs + waits.tombstoneWaitMs + waits.marginMs;
45
+ }
46
+ export const SCAN_BACKSTOP_MS = scanBackstopMs({
47
+ lockWaitMs: SCAN_LOCK_WAIT_MS, killAfterMs: SCAN_KILL_AFTER_MS, tombstoneWaitMs: SCAN_TOMBSTONE_WAIT_MS, marginMs: 60_000,
48
+ });
49
+ const liveGroups = new Set();
50
+ let handlersInstalled = false;
51
+ const killGroup = (pid, signal = "SIGTERM") => {
52
+ try {
53
+ process.kill(-pid, signal);
54
+ }
55
+ catch {
56
+ // already gone
57
+ }
58
+ };
59
+ const onExit = () => {
60
+ for (const pid of liveGroups)
61
+ killGroup(pid);
62
+ };
63
+ const onSignal = (signal) => {
64
+ onExit();
65
+ uninstallHandlers();
66
+ // Die by that signal, as the process would have without this handler.
67
+ process.kill(process.pid, signal);
68
+ };
69
+ const onSigint = () => onSignal("SIGINT");
70
+ const onSigterm = () => onSignal("SIGTERM");
71
+ function installHandlers() {
72
+ if (handlersInstalled)
73
+ return;
74
+ handlersInstalled = true;
75
+ process.on("exit", onExit);
76
+ process.on("SIGINT", onSigint);
77
+ process.on("SIGTERM", onSigterm);
78
+ }
79
+ function uninstallHandlers() {
80
+ if (!handlersInstalled)
81
+ return;
82
+ handlersInstalled = false;
83
+ process.removeListener("exit", onExit);
84
+ process.removeListener("SIGINT", onSigint);
85
+ process.removeListener("SIGTERM", onSigterm);
86
+ }
87
+ /**
88
+ * The default runner: detached (its own process group), stdout collected, stderr relayed line by line
89
+ * AS IT ARRIVES, and the whole group killed on the timeout or when this process exits or is
90
+ * interrupted. A signal handler removes itself and re-raises, so the process still dies by it.
91
+ */
92
+ export const defaultStorageRunner = (file, args, { env, timeoutMs = STATUS_TIMEOUT_MS, onStderrLine }) => new Promise((resolve) => {
93
+ let child;
94
+ try {
95
+ child = spawn(file, [...args], { env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
96
+ }
97
+ catch {
98
+ resolve({ code: null, stdout: "", stderr: "" });
99
+ return;
100
+ }
101
+ const pid = child.pid;
102
+ let stdout = "";
103
+ let stderr = "";
104
+ let pending = "";
105
+ let timedOut = false;
106
+ let settled = false;
107
+ if (pid !== undefined) {
108
+ liveGroups.add(pid);
109
+ installHandlers();
110
+ }
111
+ const timer = setTimeout(() => {
112
+ timedOut = true;
113
+ if (pid !== undefined)
114
+ killGroup(pid);
115
+ }, timeoutMs);
116
+ child.stdout.setEncoding("utf8");
117
+ child.stderr.setEncoding("utf8");
118
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
119
+ child.stderr.on("data", (chunk) => {
120
+ stderr += chunk;
121
+ pending += chunk;
122
+ let newline = pending.indexOf("\n");
123
+ while (newline >= 0) {
124
+ const line = pending.slice(0, newline);
125
+ pending = pending.slice(newline + 1);
126
+ if (onStderrLine)
127
+ onStderrLine(line);
128
+ newline = pending.indexOf("\n");
129
+ }
130
+ });
131
+ const finish = (code) => {
132
+ if (settled)
133
+ return;
134
+ settled = true;
135
+ clearTimeout(timer);
136
+ if (pid !== undefined) {
137
+ liveGroups.delete(pid);
138
+ if (liveGroups.size === 0)
139
+ uninstallHandlers();
140
+ }
141
+ if (pending !== "" && onStderrLine)
142
+ onStderrLine(pending);
143
+ resolve({ code: timedOut ? null : code, stdout, stderr, ...(timedOut ? { timedOut: true } : {}) });
144
+ };
145
+ child.on("error", () => finish(null));
146
+ child.on("close", (exit) => finish(typeof exit === "number" ? exit : null));
147
+ });
148
+ export const DEFAULT_BUDGETS = Object.freeze({ statusMs: STATUS_TIMEOUT_MS, scanMs: STATUS_SCAN_TIMEOUT_MS, backstopMs: SCAN_BACKSTOP_MS });
149
+ export const PROGRESS_LINE = /^status: scan: still scanning \((\d+) folders so far\)$/;
150
+ /** The relayed progress text, before the caller's prefix. */
151
+ export const progressText = (folders) => `checking… still scanning (${folders} folders so far)`;
152
+ const parseReport = (stdout) => {
153
+ try {
154
+ const parsed = JSON.parse(stdout);
155
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
156
+ }
157
+ catch {
158
+ return null;
159
+ }
160
+ };
161
+ /** `scan` of a status report, or an empty object. */
162
+ export function scanOf(report) {
163
+ const scan = report?.scan;
164
+ return scan && typeof scan === "object" && !Array.isArray(scan) ? scan : {};
165
+ }
166
+ /** `scan.source`: `fresh`, `cached` or `none`; anything else (an older status.sh, no scan at all) reads `none`. */
167
+ export function scanSourceOf(report) {
168
+ const source = scanOf(report).source;
169
+ return source === "fresh" || source === "cached" ? source : "none";
170
+ }
171
+ /** `scan.cache` as `{state, reason}` strings (reason "" when null). */
172
+ export function cacheStateOf(report) {
173
+ const cache = scanOf(report).cache;
174
+ return {
175
+ state: typeof cache?.state === "string" ? cache.state : "",
176
+ reason: typeof cache?.reason === "string" ? cache.reason : "",
177
+ };
178
+ }
179
+ /** IF-552-3: measure a keyslot in up to two phases. Never throws for a child's failure. */
180
+ export async function measureKeyslot(options) {
181
+ const file = join(options.dir, "status.sh");
182
+ if (!existsSync(file))
183
+ return { absent: true, code: null, report: null, phases: [], timedOut: false, folders: null };
184
+ const phases = [];
185
+ let folders = null;
186
+ let timedOut = false;
187
+ let lastReport = null;
188
+ // The measurement so far: the LAST phase's exit and report, always together. An unreadable
189
+ // phase-2 report is null here, never phase 1's: phase 1 said `scan_not_cached`, and pairing its
190
+ // report with phase 2's exit 0 authorized a pass (#552 review r1b, codex). Callers that report a
191
+ // timeout read phase 1 from `phases` themselves.
192
+ const result = () => {
193
+ const last = phases.at(-1);
194
+ return { absent: false, code: last ? last.code : null, report: lastReport, phases, timedOut, folders };
195
+ };
196
+ const run = options.run ?? defaultStorageRunner;
197
+ const budgets = { ...DEFAULT_BUDGETS, ...options.budgets };
198
+ const base = ["--harness", options.harness, "--slot", options.slot, "--json"];
199
+ const notify = options.notify ?? (() => { });
200
+ const call = async (mode) => {
201
+ const scanning = mode !== "only";
202
+ const args = [...base, "--scan-cache", mode, ...(scanning ? ["--scan-timeout", String(Math.max(1, Math.ceil(budgets.scanMs / 1000)))] : [])];
203
+ const outcome = await run(file, args, {
204
+ // Arms status.sh's parent watch (its ticker ends the scan if this process dies); a hand run,
205
+ // which has no such variable naming its parent, is never killed for being reparented.
206
+ env: { ...options.env, KEYSEAL_SCAN_WATCH_PID: String(process.pid) },
207
+ timeoutMs: scanning ? budgets.scanMs + budgets.backstopMs : budgets.statusMs,
208
+ onStderrLine: scanning
209
+ ? (line) => {
210
+ const match = PROGRESS_LINE.exec(line);
211
+ if (!match)
212
+ return;
213
+ folders = Number(match[1]);
214
+ notify({ kind: "progress", folders });
215
+ }
216
+ : undefined,
217
+ });
218
+ const phase = { mode, code: outcome.code, timedOut: outcome.timedOut === true, report: parseReport(outcome.stdout) };
219
+ phases.push(phase);
220
+ lastReport = phase.report;
221
+ return phase;
222
+ };
223
+ let phase1 = null;
224
+ if (options.scan !== "fresh") {
225
+ phase1 = await call("only");
226
+ if (options.scan === "only")
227
+ return result();
228
+ // A phase 1 that did not complete, or said nothing, is the caller's to report (status_did_not_complete).
229
+ if (phase1.code === null || phase1.report === null)
230
+ return result();
231
+ if (scanSourceOf(phase1.report) !== "none")
232
+ return result();
233
+ if (phase1.code === 1 && (options.onFailure ?? "stop") === "stop")
234
+ return result();
235
+ if (options.skipScan?.(phase1.report))
236
+ return result();
237
+ }
238
+ notify({ kind: "before", phase1: phase1?.report ?? null });
239
+ const phase2 = await call(options.scan === "fresh" ? "fresh" : "use");
240
+ timedOut = phase2.timedOut;
241
+ notify({ kind: "result", phase: phase2 });
242
+ return result();
243
+ }
244
+ /** status.sh `--slots` output: one JSON object per line, each naming its own slot. */
245
+ const parseSlotReports = (stdout) => {
246
+ const reports = new Map();
247
+ for (const line of stdout.split("\n")) {
248
+ const report = parseReport(line);
249
+ if (report && typeof report.slot === "string" && !reports.has(report.slot))
250
+ reports.set(report.slot, report);
251
+ }
252
+ return reports;
253
+ };
254
+ /**
255
+ * ONBOARD SL-4 (IF-0-ONBOARD-1 "a key scan at most once per host"): several keyslots measured with
256
+ * ONE filesystem walk, in the same two phases as `measureKeyslot`. One slot IS `measureKeyslot`
257
+ * (the `--harness` form, byte-identical to a single-harness setup). Two or more use
258
+ * `status.sh --slots a,b,… --json`: phase 1 (`--scan-cache only`) for every slot, then ONE phase 2
259
+ * over just the slots phase 1 left without a valid cache. That form has no `--harness`, so each
260
+ * name is both the harness and the slot — true for setup, which never names a slot of its own.
261
+ * Each slot's phase `code` is its own line's `exit`; the process exit is only the worst of them.
262
+ * Lines are matched by their `slot` field, never by position. Never throws for a child's failure.
263
+ */
264
+ export async function measureKeyslots(options) {
265
+ const slots = [...new Set(options.slots)];
266
+ const measured = new Map();
267
+ if (slots.length === 1) {
268
+ const [slot] = slots;
269
+ const { notify, slots: _slots, ...rest } = options;
270
+ void _slots;
271
+ measured.set(slot, await measureKeyslot({ ...rest, harness: slot, slot, ...(notify ? { notify: (event) => notify(slot, event) } : {}) }));
272
+ return measured;
273
+ }
274
+ if (slots.length === 0)
275
+ return measured;
276
+ const file = join(options.dir, "status.sh");
277
+ if (!existsSync(file)) {
278
+ for (const slot of slots)
279
+ measured.set(slot, { absent: true, code: null, report: null, phases: [], timedOut: false, folders: null });
280
+ return measured;
281
+ }
282
+ const run = options.run ?? defaultStorageRunner;
283
+ const budgets = { ...DEFAULT_BUDGETS, ...options.budgets };
284
+ const notify = options.notify ?? (() => { });
285
+ const phases = new Map(slots.map((slot) => [slot, []]));
286
+ const scanTimedOut = new Set();
287
+ let folders = null;
288
+ const call = async (mode, names) => {
289
+ const scanning = mode !== "only";
290
+ const args = ["--slots", names.join(","), "--json", "--scan-cache", mode, ...(scanning ? ["--scan-timeout", String(Math.max(1, Math.ceil(budgets.scanMs / 1000)))] : [])];
291
+ const outcome = await run(file, args, {
292
+ env: { ...options.env, KEYSEAL_SCAN_WATCH_PID: String(process.pid) },
293
+ timeoutMs: scanning ? budgets.scanMs + budgets.backstopMs : budgets.statusMs,
294
+ onStderrLine: scanning
295
+ ? (line) => {
296
+ const match = PROGRESS_LINE.exec(line);
297
+ if (!match)
298
+ return;
299
+ folders = Number(match[1]);
300
+ notify(null, { kind: "progress", folders });
301
+ }
302
+ : undefined,
303
+ });
304
+ const reports = parseSlotReports(outcome.stdout);
305
+ const called = new Map();
306
+ for (const name of names) {
307
+ const report = reports.get(name) ?? null;
308
+ // A process that did not exit on its own judged nothing, whatever it printed first.
309
+ const code = outcome.code === null ? null : typeof report?.exit === "number" ? report.exit : outcome.code;
310
+ const phase = { mode, code, timedOut: outcome.timedOut === true, report: outcome.code === null ? null : report };
311
+ phases.get(name).push(phase);
312
+ called.set(name, phase);
313
+ }
314
+ return called;
315
+ };
316
+ let toScan = slots;
317
+ const phase1 = options.scan === "fresh" ? null : await call("only", slots);
318
+ if (phase1 !== null) {
319
+ if (options.scan === "only")
320
+ toScan = [];
321
+ else {
322
+ toScan = slots.filter((slot) => {
323
+ const phase = phase1.get(slot);
324
+ if (phase.code === null || phase.report === null)
325
+ return false;
326
+ if (scanSourceOf(phase.report) !== "none")
327
+ return false;
328
+ return !(phase.code === 1 && (options.onFailure ?? "stop") === "stop");
329
+ });
330
+ }
331
+ }
332
+ if (toScan.length > 0) {
333
+ for (const slot of toScan)
334
+ notify(slot, { kind: "before", phase1: phase1?.get(slot)?.report ?? null });
335
+ const phase2 = await call(options.scan === "fresh" ? "fresh" : "use", toScan);
336
+ for (const [slot, phase] of phase2) {
337
+ if (phase.timedOut)
338
+ scanTimedOut.add(slot);
339
+ notify(slot, { kind: "result", phase });
340
+ }
341
+ }
342
+ for (const slot of slots) {
343
+ const list = phases.get(slot);
344
+ const last = list.at(-1);
345
+ measured.set(slot, { absent: false, code: last ? last.code : null, report: last ? last.report : null, phases: list, timedOut: scanTimedOut.has(slot), folders });
346
+ }
347
+ return measured;
348
+ }