@consiliency/agent-board-cli 1.28.0 → 1.29.1
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 +39 -12
- package/dist/doctor.d.ts +82 -2
- package/dist/doctor.js +170 -4
- package/dist/identity_writer.d.ts +1 -46
- package/dist/identity_writer.js +5 -174
- package/dist/keyslot_status.d.ts +17 -0
- package/dist/keyslot_status.js +108 -2
- package/dist/setup.d.ts +112 -5
- package/dist/setup.js +287 -61
- package/dist/setup_all.d.ts +51 -0
- package/dist/setup_all.js +164 -0
- package/dist/setup_storage.d.ts +39 -1
- package/dist/setup_storage.js +99 -37
- package/dist/setup_writers.d.ts +7 -0
- package/dist/setup_writers.js +11 -2
- package/keyslot/lib.sh +88 -47
- package/keyslot/status.sh +411 -246
- package/package.json +2 -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
|
@@ -147,8 +147,46 @@ export declare function macosKeychainRefusal(probe: OsKeychainProbeResult | null
|
|
|
147
147
|
reason: string;
|
|
148
148
|
fix: string;
|
|
149
149
|
};
|
|
150
|
+
/**
|
|
151
|
+
* ONBOARD SL-4: the key storage chosen and provisioned but NOT yet scanned. `selected` is final
|
|
152
|
+
* (the keychain, an external agent, or a refusal). `keyslot` holds a provisioned slot that may be
|
|
153
|
+
* used only once `finalizeKeyStorage` has seen a completed scan covering it (IF-552-3).
|
|
154
|
+
*/
|
|
155
|
+
export type PreparedKeyStorage = {
|
|
156
|
+
kind: "selected";
|
|
157
|
+
harness: string;
|
|
158
|
+
selection: KeyStorageSelection;
|
|
159
|
+
} | {
|
|
160
|
+
kind: "keyslot";
|
|
161
|
+
harness: string;
|
|
162
|
+
slot: string;
|
|
163
|
+
keyReference: string;
|
|
164
|
+
repairs: string[];
|
|
165
|
+
keyslotDir: string;
|
|
166
|
+
};
|
|
167
|
+
/** One measurement per keyslot slot, every one from ONE `measureKeyslots` call. */
|
|
168
|
+
export type KeyslotScan = ReadonlyMap<string, KeyslotMeasurement>;
|
|
169
|
+
/**
|
|
170
|
+
* The ONE measurement for every prepared keyslot pair (IF-0-ONBOARD-1 "a key scan at most once per
|
|
171
|
+
* host"), with setup's `key: ` notices; null when no pair is a keyslot (a keychain host never
|
|
172
|
+
* scans). One pair is measured exactly as a single-harness setup always was.
|
|
173
|
+
*/
|
|
174
|
+
export declare function measureKeyStorage(pairs: readonly PreparedKeyStorage[], ctx: Pick<KeyStorageContext, "env" | "run" | "statusBudgets" | "notify">): Promise<KeyslotScan | null>;
|
|
175
|
+
/**
|
|
176
|
+
* ONBOARD SL-4: each pair's storage. A keyslot pair gets its slot ONLY when `scan` is one completed
|
|
177
|
+
* measurement covering EVERY keyslot pair's key, and is clean for that key; with no scan, or one
|
|
178
|
+
* that misses any keyslot pair, every keyslot pair is refused `scan_not_completed`. Other pairs
|
|
179
|
+
* pass through unchanged. Order is the input's.
|
|
180
|
+
*/
|
|
181
|
+
export declare function finalizeKeyStorage(pairs: readonly PreparedKeyStorage[], scan: KeyslotScan | null): KeyStorageSelection[];
|
|
150
182
|
/**
|
|
151
183
|
* IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
|
|
152
|
-
* condition; a refusal carries the fix.
|
|
184
|
+
* condition; a refusal carries the fix. Prepare, then the one scan, then finalize: the same three
|
|
185
|
+
* steps `setup --all` runs over every pair.
|
|
153
186
|
*/
|
|
154
187
|
export declare function selectKeyStorage(ctx: KeyStorageContext): Promise<KeyStorageSelection>;
|
|
188
|
+
/**
|
|
189
|
+
* ONBOARD SL-4: the IF-0-KEYSEAL-6 order up to and including provision.sh, and NO scan. A slot is
|
|
190
|
+
* returned as `keyslot`, never as usable storage: only `finalizeKeyStorage` turns it into one.
|
|
191
|
+
*/
|
|
192
|
+
export declare function prepareKeyStorage(ctx: KeyStorageContext): Promise<PreparedKeyStorage>;
|
package/dist/setup_storage.js
CHANGED
|
@@ -24,7 +24,7 @@ import { chmodSync, closeSync, constants, fstatSync, lstatSync, mkdirSync, openS
|
|
|
24
24
|
import { dirname, isAbsolute, join } from "node:path";
|
|
25
25
|
import { OS_KEYCHAIN_SERVICE, probeOsKeychainRoundTrip } from "@consiliency/agent-board-client";
|
|
26
26
|
import { resolveKeyslotDir } from "./assets.js";
|
|
27
|
-
import { cacheStateOf, defaultStorageRunner, measureKeyslot, progressText, scanOf, scanSourceOf, } from "./keyslot_status.js";
|
|
27
|
+
import { cacheStateOf, defaultStorageRunner, measureKeyslot, measureKeyslots, progressText, scanOf, scanSourceOf, } from "./keyslot_status.js";
|
|
28
28
|
export { defaultStorageRunner };
|
|
29
29
|
export const SLOT_RE = /^[a-z][a-z0-9-]{0,31}$/;
|
|
30
30
|
const REFERENCE_LINE = /^agent_socket:\/\S+#[A-Za-z0-9_-]{43}$/;
|
|
@@ -456,23 +456,108 @@ export function macosKeychainRefusal(probe) {
|
|
|
456
456
|
}
|
|
457
457
|
}
|
|
458
458
|
}
|
|
459
|
+
/**
|
|
460
|
+
* The ONE measurement for every prepared keyslot pair (IF-0-ONBOARD-1 "a key scan at most once per
|
|
461
|
+
* host"), with setup's `key: ` notices; null when no pair is a keyslot (a keychain host never
|
|
462
|
+
* scans). One pair is measured exactly as a single-harness setup always was.
|
|
463
|
+
*/
|
|
464
|
+
export async function measureKeyStorage(pairs, ctx) {
|
|
465
|
+
const slots = pairs.filter((pair) => pair.kind === "keyslot");
|
|
466
|
+
if (slots.length === 0)
|
|
467
|
+
return null;
|
|
468
|
+
const notify = ctx.notify ?? ((text) => { process.stderr.write(`${text}\n`); });
|
|
469
|
+
// Several pairs are told apart by harness; one pair's notices are byte-identical to before.
|
|
470
|
+
const noticesFor = new Map(slots.map((pair) => [pair.slot, keyScanNotices(pair.harness, slots.length === 1 ? notify : (line) => notify(`[${pair.harness}] ${line}`))]));
|
|
471
|
+
// Setup's onFailure is `scan`: a phase-1 exit 1 beside a measured mode still gets its scan
|
|
472
|
+
// (status.sh scans even on a contradiction).
|
|
473
|
+
const env = ctx.env ?? process.env;
|
|
474
|
+
const measured = slots.length === 1
|
|
475
|
+
? new Map([[slots[0].slot, await measureKeyslot({
|
|
476
|
+
dir: slots[0].keyslotDir, harness: slots[0].harness, slot: slots[0].slot, env, scan: "use", onFailure: "scan", run: ctx.run, budgets: ctx.statusBudgets,
|
|
477
|
+
notify: noticesFor.get(slots[0].slot),
|
|
478
|
+
})]])
|
|
479
|
+
: await measureKeyslots({
|
|
480
|
+
// `--slots` names each slot as its own harness: setup's slot is always its harness.
|
|
481
|
+
dir: slots[0].keyslotDir, slots: slots.map((pair) => pair.slot), env, scan: "use", onFailure: "scan", run: ctx.run, budgets: ctx.statusBudgets,
|
|
482
|
+
notify: (slot, event) => {
|
|
483
|
+
if (slot !== null)
|
|
484
|
+
noticesFor.get(slot)?.(event);
|
|
485
|
+
else if (event.kind === "progress")
|
|
486
|
+
notify(`key: ${progressText(event.folders)}`);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
for (const pair of slots) {
|
|
490
|
+
const one = measured.get(pair.slot);
|
|
491
|
+
if (one && !one.phases.some((phase) => phase.mode !== "only") && scanSourceOf(one.report) === "cached") {
|
|
492
|
+
notify(`${slots.length === 1 ? "" : `[${pair.harness}] `}key: plaintext-copy scan cached at ${String(scanOf(one.report).scanned_at)}`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return measured;
|
|
496
|
+
}
|
|
497
|
+
/** The IF-552-3 gate on one provisioned slot, judged from its measurement. */
|
|
498
|
+
function judgeKeyslot(pair, measured) {
|
|
499
|
+
const { repairs, slot } = pair;
|
|
500
|
+
const mode = measured.report?.mode;
|
|
501
|
+
if (mode === "sealed" || mode === "unsealed") {
|
|
502
|
+
const gate = scanGate(pair.harness, measured);
|
|
503
|
+
if (gate)
|
|
504
|
+
return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
|
|
505
|
+
return { storage: mode, reason: `keyslot_${mode}`, keyReference: pair.keyReference, slot, repairs, keyScan: keyStepScan(measured.report) };
|
|
506
|
+
}
|
|
507
|
+
const gate = scanGate(pair.harness, measured);
|
|
508
|
+
// A scan that ran but whose report could not be read is a scan that did not complete.
|
|
509
|
+
const scanned = measured.phases.some((phase) => phase.mode !== "only");
|
|
510
|
+
if (gate && (gate.reason !== "scan_not_completed" || scanned))
|
|
511
|
+
return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
|
|
512
|
+
return refused("keyslot_mode_unmeasured", "provision.sh succeeded but status.sh does not measure the slot as sealed or unsealed: run the packaged status.sh --json and resolve what it reports", repairs);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* ONBOARD SL-4: each pair's storage. A keyslot pair gets its slot ONLY when `scan` is one completed
|
|
516
|
+
* measurement covering EVERY keyslot pair's key, and is clean for that key; with no scan, or one
|
|
517
|
+
* that misses any keyslot pair, every keyslot pair is refused `scan_not_completed`. Other pairs
|
|
518
|
+
* pass through unchanged. Order is the input's.
|
|
519
|
+
*/
|
|
520
|
+
export function finalizeKeyStorage(pairs, scan) {
|
|
521
|
+
const covered = scan !== null && pairs.every((pair) => pair.kind !== "keyslot" || scan.has(pair.slot));
|
|
522
|
+
return pairs.map((pair) => {
|
|
523
|
+
if (pair.kind === "selected")
|
|
524
|
+
return pair.selection;
|
|
525
|
+
if (!covered) {
|
|
526
|
+
return { storage: "refused", status: "failed", reason: "scan_not_completed", repairs: pair.repairs,
|
|
527
|
+
fix: `no completed plaintext-copy scan covered this key, so it is NOT enrolled: run the packaged status.sh --harness ${pair.harness} --json and resolve what it reports, then re-run setup` };
|
|
528
|
+
}
|
|
529
|
+
return judgeKeyslot(pair, scan.get(pair.slot));
|
|
530
|
+
});
|
|
531
|
+
}
|
|
459
532
|
/**
|
|
460
533
|
* IF-0-SETUP-3: the key storage, measured, in the IF-0-KEYSEAL-6 order. Never throws for a host
|
|
461
|
-
* condition; a refusal carries the fix.
|
|
534
|
+
* condition; a refusal carries the fix. Prepare, then the one scan, then finalize: the same three
|
|
535
|
+
* steps `setup --all` runs over every pair.
|
|
462
536
|
*/
|
|
463
537
|
export async function selectKeyStorage(ctx) {
|
|
538
|
+
const prepared = await prepareKeyStorage(ctx);
|
|
539
|
+
if (prepared.kind === "selected")
|
|
540
|
+
return prepared.selection;
|
|
541
|
+
return finalizeKeyStorage([prepared], await measureKeyStorage([prepared], ctx))[0];
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* ONBOARD SL-4: the IF-0-KEYSEAL-6 order up to and including provision.sh, and NO scan. A slot is
|
|
545
|
+
* returned as `keyslot`, never as usable storage: only `finalizeKeyStorage` turns it into one.
|
|
546
|
+
*/
|
|
547
|
+
export async function prepareKeyStorage(ctx) {
|
|
548
|
+
const selected = (selection) => ({ kind: "selected", harness: ctx.harness, selection });
|
|
464
549
|
const env = ctx.env ?? process.env;
|
|
465
550
|
const platform = ctx.platform ?? process.platform;
|
|
466
551
|
const uid = ctx.uid ?? currentUid();
|
|
467
552
|
const run = ctx.run ?? defaultStorageRunner;
|
|
468
553
|
const slot = ctx.slot ?? ctx.harness;
|
|
469
554
|
if (!SLOT_RE.test(ctx.harness) || !SLOT_RE.test(slot))
|
|
470
|
-
return refused("bad_slot", "the harness and slot must match ^[a-z][a-z0-9-]{0,31}$");
|
|
555
|
+
return selected(refused("bad_slot", "the harness and slot must match ^[a-z][a-z0-9-]{0,31}$"));
|
|
471
556
|
// 0. An explicit --agent-socket wins over everything discovery would find — the keychain and a
|
|
472
557
|
// key slot alike (code review r1, owner decision; a plan amendment to the IF-0-KEYSEAL-6
|
|
473
558
|
// order, recorded in SL-7): a user who names their own agent means it. Nothing is probed.
|
|
474
559
|
if (ctx.agentSocket !== undefined)
|
|
475
|
-
return externalAgent(ctx.agentSocket, env, uid, ctx.machineIdFile, []);
|
|
560
|
+
return selected(externalAgent(ctx.agentSocket, env, uid, ctx.machineIdFile, []));
|
|
476
561
|
// 1. A live keychain round trip.
|
|
477
562
|
let keychain = null;
|
|
478
563
|
try {
|
|
@@ -482,7 +567,7 @@ export async function selectKeyStorage(ctx) {
|
|
|
482
567
|
keychain = null;
|
|
483
568
|
}
|
|
484
569
|
if (keychain?.ok)
|
|
485
|
-
return { storage: "os_keychain", reason: "keychain_round_trip", repairs: [] };
|
|
570
|
+
return selected({ storage: "os_keychain", reason: "keychain_round_trip", repairs: [] });
|
|
486
571
|
// 2. Setup's own gate. A failure here skips the key slot, not the external agent.
|
|
487
572
|
let gate = null;
|
|
488
573
|
if (platform !== "linux") {
|
|
@@ -502,7 +587,7 @@ export async function selectKeyStorage(ctx) {
|
|
|
502
587
|
gate = { reason: "bash_too_old", fix: "the keyslot scripts need bash >= 4 at /bin/bash; install a current bash, or use a desktop keychain" };
|
|
503
588
|
}
|
|
504
589
|
}
|
|
505
|
-
// 3. The key slot: provision.sh selects the mode; status.sh measures it.
|
|
590
|
+
// 3. The key slot: provision.sh selects the mode; status.sh measures it, in finalize.
|
|
506
591
|
let slotRefusal = null;
|
|
507
592
|
let repairs = [];
|
|
508
593
|
if (gate === null) {
|
|
@@ -511,44 +596,21 @@ export async function selectKeyStorage(ctx) {
|
|
|
511
596
|
dir = ctx.keyslotDir ?? resolveKeyslotDir();
|
|
512
597
|
}
|
|
513
598
|
catch {
|
|
514
|
-
return refused("keyslot_scripts_absent", "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli");
|
|
599
|
+
return selected(refused("keyslot_scripts_absent", "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli"));
|
|
515
600
|
}
|
|
516
601
|
const repaired = repairKeyDir(env, uid);
|
|
517
602
|
repairs = repaired.repairs;
|
|
518
603
|
if (repaired.problems.length > 0) {
|
|
519
|
-
return refused("unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): chmod g-w,o-w on each named directory you own (for a fresh account: chmod g-w ${repaired.root ?? "~/.config"} && chmod 0700 ${repaired.root ?? "~/.config"}/agent-board), then re-run setup`, repairs);
|
|
604
|
+
return selected(refused("unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): chmod g-w,o-w on each named directory you own (for a fresh account: chmod g-w ${repaired.root ?? "~/.config"} && chmod 0700 ${repaired.root ?? "~/.config"}/agent-board), then re-run setup`, repairs));
|
|
520
605
|
}
|
|
521
606
|
const args = ["--harness", ctx.harness, "--slot", slot, ...(ctx.allowUnsealedKey === true ? ["--allow-unsealed-key"] : [])];
|
|
522
607
|
const provision = await run(join(dir, "provision.sh"), args, { env });
|
|
523
608
|
if (provision.code === 0) {
|
|
524
609
|
const line = provision.stdout.split("\n").map((entry) => entry.trim()).filter(Boolean).at(-1) ?? "";
|
|
525
610
|
if (!REFERENCE_LINE.test(line))
|
|
526
|
-
return refused("provision_output_unrecognised", "provision.sh exited 0 without printing an agent_socket: reference; run it by hand and report the output", repairs);
|
|
527
|
-
// #552:
|
|
528
|
-
|
|
529
|
-
// mode still gets its scan (status.sh scans even on a contradiction).
|
|
530
|
-
const notify = ctx.notify ?? ((text) => { process.stderr.write(`${text}\n`); });
|
|
531
|
-
const notices = keyScanNotices(ctx.harness, notify);
|
|
532
|
-
const measured = await measureKeyslot({
|
|
533
|
-
dir, harness: ctx.harness, slot, env, scan: "use", onFailure: "scan", run: ctx.run, budgets: ctx.statusBudgets,
|
|
534
|
-
notify: notices,
|
|
535
|
-
});
|
|
536
|
-
if (!measured.phases.some((phase) => phase.mode !== "only") && scanSourceOf(measured.report) === "cached") {
|
|
537
|
-
notify(`key: plaintext-copy scan cached at ${String(scanOf(measured.report).scanned_at)}`);
|
|
538
|
-
}
|
|
539
|
-
const mode = measured.report?.mode;
|
|
540
|
-
if (mode === "sealed" || mode === "unsealed") {
|
|
541
|
-
const gate = scanGate(ctx.harness, measured);
|
|
542
|
-
if (gate)
|
|
543
|
-
return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
|
|
544
|
-
return { storage: mode, reason: `keyslot_${mode}`, keyReference: line, slot, repairs, keyScan: keyStepScan(measured.report) };
|
|
545
|
-
}
|
|
546
|
-
const gate = scanGate(ctx.harness, measured);
|
|
547
|
-
// A scan that ran but whose report could not be read is a scan that did not complete.
|
|
548
|
-
const scanned = measured.phases.some((phase) => phase.mode !== "only");
|
|
549
|
-
if (gate && (gate.reason !== "scan_not_completed" || scanned))
|
|
550
|
-
return { storage: "refused", status: gate.status, reason: gate.reason, fix: gate.fix, repairs };
|
|
551
|
-
return refused("keyslot_mode_unmeasured", "provision.sh succeeded but status.sh does not measure the slot as sealed or unsealed: run the packaged status.sh --json and resolve what it reports", repairs);
|
|
611
|
+
return selected(refused("provision_output_unrecognised", "provision.sh exited 0 without printing an agent_socket: reference; run it by hand and report the output", repairs));
|
|
612
|
+
// #552: NO scan here. The slot is usable only after `finalizeKeyStorage` sees the one scan.
|
|
613
|
+
return { kind: "keyslot", harness: ctx.harness, slot, keyReference: line, repairs, keyslotDir: dir };
|
|
552
614
|
}
|
|
553
615
|
if (provision.code === 2) {
|
|
554
616
|
slotRefusal = /incapable_for_keyslot/.test(provision.stderr)
|
|
@@ -559,12 +621,12 @@ export async function selectKeyStorage(ctx) {
|
|
|
559
621
|
}
|
|
560
622
|
else {
|
|
561
623
|
const token = Object.keys(PROVISION_REFUSALS).find((name) => provision.stderr.includes(name));
|
|
562
|
-
return token
|
|
624
|
+
return selected(token
|
|
563
625
|
? refused(token, PROVISION_REFUSALS[token], repairs)
|
|
564
|
-
: refused("provision_failed", "provision.sh failed: run the packaged provision.sh by hand and read what it reports", repairs);
|
|
626
|
+
: refused("provision_failed", "provision.sh failed: run the packaged provision.sh by hand and read what it reports", repairs));
|
|
565
627
|
}
|
|
566
628
|
}
|
|
567
629
|
// 5. Refused, naming the fix.
|
|
568
630
|
const cause = gate ?? slotRefusal ?? { reason: "no_key_storage", fix: "no key storage applies" };
|
|
569
|
-
return refused(cause.reason, withDeps(cause.fix, ctx.deps), repairs);
|
|
631
|
+
return selected(refused(cause.reason, withDeps(cause.fix, ctx.deps), repairs));
|
|
570
632
|
}
|
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;
|
package/dist/setup_writers.js
CHANGED
|
@@ -243,6 +243,15 @@ export function which(name, env) {
|
|
|
243
243
|
}
|
|
244
244
|
return null;
|
|
245
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* ONBOARD (IF-0-ONBOARD-1) — the harness presence probe, one rule for every identity harness: the
|
|
248
|
+
* harness's CLI, named as the harness, is on `env`'s PATH. The pi, opencode and claude writers
|
|
249
|
+
* need it to install; codex, grok and agy registrations are config files setup writes itself, so
|
|
250
|
+
* their writer does not require it, and `setup --all` is its only reader for those three.
|
|
251
|
+
*/
|
|
252
|
+
export function harnessCliPath(harness, env) {
|
|
253
|
+
return which(harness, env);
|
|
254
|
+
}
|
|
246
255
|
function isSymlink(path) {
|
|
247
256
|
try {
|
|
248
257
|
return lstatSync(path).isSymbolicLink();
|
|
@@ -486,7 +495,7 @@ function packageSpecsSeen(harness, ctx) {
|
|
|
486
495
|
return { specs, unparseable: scopes.unparseable };
|
|
487
496
|
}
|
|
488
497
|
async function installPackage(ctx, scopeCtx) {
|
|
489
|
-
const cli =
|
|
498
|
+
const cli = harnessCliPath(ctx.harness, ctx.env);
|
|
490
499
|
if (cli === null)
|
|
491
500
|
return failed("harness_cli_missing", `the ${ctx.harness} CLI is not on PATH: install ${ctx.harness}, then re-run setup`);
|
|
492
501
|
const version = ctx.harness === "pi" ? ctx.assets.piVersion : ctx.assets.opencodeVersion;
|
|
@@ -805,7 +814,7 @@ function materializeMarketplace(ctx, scopeCtx) {
|
|
|
805
814
|
return { dir, rewrote: true, notes };
|
|
806
815
|
}
|
|
807
816
|
async function installClaude(ctx, scopeCtx) {
|
|
808
|
-
const cli =
|
|
817
|
+
const cli = harnessCliPath("claude", ctx.env);
|
|
809
818
|
if (cli === null)
|
|
810
819
|
return failed("harness_cli_missing", "the claude CLI is not on PATH: install Claude Code, then re-run setup");
|
|
811
820
|
const seen = boardEntries("claude", scopeCtx);
|