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