@consiliency/agent-board-cli 1.27.1 → 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.
- package/dist/assets.json +1 -1
- package/dist/cli.js +2 -1
- package/dist/doctor.d.ts +61 -0
- package/dist/doctor.js +177 -36
- package/dist/keyslot_status.d.ts +117 -0
- package/dist/keyslot_status.js +242 -0
- package/dist/setup.js +59 -13
- package/dist/setup_storage.d.ts +35 -10
- package/dist/setup_storage.js +97 -16
- package/keyslot/lib.sh +793 -37
- package/keyslot/status.sh +115 -14
- package/package.json +1 -1
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
542
|
-
|
|
543
|
-
|
|
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
|
}
|
package/dist/setup_storage.d.ts
CHANGED
|
@@ -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[];
|
package/dist/setup_storage.js
CHANGED
|
@@ -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
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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
|
-
|
|
466
|
-
|
|
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
|
-
|
|
469
|
-
|
|
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) {
|