@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
package/dist/setup.js
CHANGED
|
@@ -2,11 +2,13 @@ import * as nodeFs from "node:fs";
|
|
|
2
2
|
import { hostname as osHostname } from "node:os";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline";
|
|
5
|
-
import { IDENTITY_HARNESSES, createAgentBoardClient, enrollCommitMarker, identityConfigRoot, identityFilePath, normalizeKeyReference, readIdentityFile, readSessionRecords, resolveCredentials, resolveEnrollEndpoint, resolveEnrollmentDescriptor, resolveHostSegment, walkTrustedDirectory, } from "@consiliency/agent-board-client";
|
|
5
|
+
import { IDENTITY_HARNESSES, acquireEnrollmentLock, createAgentBoardClient, displayableInviteLabel, enrollCommitMarker, formatPendingEnrollmentMarker, identityConfigRoot, identityFilePath, listHarnessProcesses, normalizeKeyReference, parseInviteInput, pendingEnrollmentMarkerExists, pendingEnrollmentMarkerPath, 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, finalizeKeyStorage, measureKeyStorage, repairKeyDir, keyslotSocketDir, prepareKeyStorage as defaultPrepareKeyStorage, } 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";
|
|
@@ -34,9 +36,6 @@ export function defaultSlug(host, harness) {
|
|
|
34
36
|
const short = host.split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
35
37
|
return `${short || "host"}-${harness}`.slice(0, SLUG_MAX).replace(/-$/, "");
|
|
36
38
|
}
|
|
37
|
-
export function pendingMarkerPath(harness, env) {
|
|
38
|
-
return join(dirname(identityFilePath(harness, env)), `.setup-enroll-pending-${harness}`);
|
|
39
|
-
}
|
|
40
39
|
/**
|
|
41
40
|
* Codes raised before any token exists (the issuer refused or the device code lapsed), so no
|
|
42
41
|
* /enroll call can have happened: the marker is removed. Read through `authoredCode`, so only a
|
|
@@ -125,6 +124,7 @@ function defaultBoardClient(fileEnv) {
|
|
|
125
124
|
class Run {
|
|
126
125
|
steps = [];
|
|
127
126
|
notes = [];
|
|
127
|
+
staleSessions = [];
|
|
128
128
|
record(step, status, reason, fix) {
|
|
129
129
|
const entry = { step, status, reason };
|
|
130
130
|
this.steps.push(entry);
|
|
@@ -135,7 +135,7 @@ class Run {
|
|
|
135
135
|
report() {
|
|
136
136
|
const statuses = this.steps.map((step) => step.status);
|
|
137
137
|
const exit = statuses.includes("failed") ? 1 : statuses.includes("undetermined") ? 2 : 0;
|
|
138
|
-
return { steps: this.steps, exit, notes: this.notes };
|
|
138
|
+
return { steps: this.steps, exit, notes: this.notes, staleSessions: this.staleSessions };
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
/** The exact command a user runs to install the keychain tool on this host, or null. */
|
|
@@ -183,8 +183,76 @@ export async function depsStep(ctx) {
|
|
|
183
183
|
// ---------------------------------------------------------------------------------------------
|
|
184
184
|
// The run
|
|
185
185
|
// ---------------------------------------------------------------------------------------------
|
|
186
|
-
|
|
187
|
-
|
|
186
|
+
/** The most of an invite file setup reads: a rendered message is a few KiB. */
|
|
187
|
+
const INVITE_FILE_MAX_BYTES = 65_536;
|
|
188
|
+
/**
|
|
189
|
+
* IF-0-ONBOARD-2: the invite, read ONCE and parsed by the shared parser. `--invite-file` and a
|
|
190
|
+
* piped stdin may hold the whole invite message; a terminal takes one hidden line that must be a
|
|
191
|
+
* bare code. The code never reaches argv, a log or any message: a refusal names only its reason.
|
|
192
|
+
*/
|
|
193
|
+
export async function readSetupInvite(input, deps) {
|
|
194
|
+
const interactive = deps.interactive ?? process.stdin.isTTY === true;
|
|
195
|
+
if (input.invite === true && input.inviteFile !== undefined) {
|
|
196
|
+
return { reason: "invite_flags_conflict", fix: "pass --invite or --invite-file <path>, not both" };
|
|
197
|
+
}
|
|
198
|
+
let text;
|
|
199
|
+
let tty = false;
|
|
200
|
+
if (input.inviteFile !== undefined) {
|
|
201
|
+
try {
|
|
202
|
+
const fd = nodeFs.openSync(input.inviteFile, "r");
|
|
203
|
+
try {
|
|
204
|
+
const buffer = Buffer.alloc(INVITE_FILE_MAX_BYTES + 1);
|
|
205
|
+
const read = nodeFs.readSync(fd, buffer, 0, buffer.length, 0);
|
|
206
|
+
if (read > INVITE_FILE_MAX_BYTES)
|
|
207
|
+
return { reason: "invite_file_too_large", fix: `--invite-file holds more than ${INVITE_FILE_MAX_BYTES} bytes: save only the invite message to it` };
|
|
208
|
+
text = buffer.subarray(0, read).toString("utf8");
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
nodeFs.closeSync(fd);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch {
|
|
215
|
+
return { reason: "invite_file_unreadable", fix: "--invite-file names a file setup cannot read: save the invite message to a file you can read and pass its path" };
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else if (input.invite === true) {
|
|
219
|
+
tty = interactive;
|
|
220
|
+
text = interactive ? await (deps.prompt ?? defaultPrompt)("Invite code (hidden): ", { secret: true }) : await (deps.readStdin ?? defaultReadStdin)();
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
return { invite: null };
|
|
224
|
+
}
|
|
225
|
+
if (text.trim() === "") {
|
|
226
|
+
return { reason: "invite_missing", fix: "--invite reads the code from the terminal or stdin, and --invite-file from a file; none was given" };
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
return { invite: parseInviteInput(text, { tty }) };
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
// The parser's messages carry no byte of the input (IF-0-ONBOARD-7).
|
|
233
|
+
return { reason: authoredCode(error, "invite_unsupported_form"), fix: String(error?.message ?? "the invite could not be read") };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** A label from the message, printable: control characters (an escape sequence, say) removed. */
|
|
237
|
+
const printable = (text) => text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
|
238
|
+
/** IF-0-ONBOARD-2 "Board": reported, never checked (setup has no target board). Never the code. */
|
|
239
|
+
export function inviteBoardNote(invite) {
|
|
240
|
+
// The client's display rule, which re-checks the FINAL text for the code (the parser's own check
|
|
241
|
+
// is not relied on alone).
|
|
242
|
+
const label = displayableInviteLabel(invite);
|
|
243
|
+
if (label !== null)
|
|
244
|
+
return `preflight: the invite is for board "${label}" (as the invite message says; the redeem names the board joined)`;
|
|
245
|
+
if (invite.labelSuppressed || invite.boardLabel !== null)
|
|
246
|
+
return "preflight: the invite's board label contains its code, so it is not shown; the redeem names the board joined";
|
|
247
|
+
return "preflight: the invite names no board; the redeem names the board joined";
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* The redeem RPC's one uniform refusal (`42501 invite is not usable`) covers exhausted, revoked,
|
|
251
|
+
* expired, creator-suspended, ceiling and per-agent refusals ALIKE, and setup never tells them apart
|
|
252
|
+
* (IF-0-ONBOARD-1): every one is `invite_unusable`. Anything else keeps the authored code.
|
|
253
|
+
*/
|
|
254
|
+
function redeemRefusal(error) {
|
|
255
|
+
return error?.code === "42501" ? "invite_unusable" : authoredCode(error, "invite_redeem_failed");
|
|
188
256
|
}
|
|
189
257
|
/** The keyslot a key reference's socket names, when it is one of `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`. */
|
|
190
258
|
function keyslotOf(reference, env) {
|
|
@@ -206,7 +274,16 @@ function keyslotOf(reference, env) {
|
|
|
206
274
|
// Only a name the keyslot scripts accept ever reaches their argv.
|
|
207
275
|
return SLOT_RE.test(slot) ? slot : null;
|
|
208
276
|
}
|
|
209
|
-
|
|
277
|
+
/** The storage context the key step's scan reads (env, runner, budgets, notices). */
|
|
278
|
+
export function setupStorageContext(deps) {
|
|
279
|
+
return { env: deps.env ?? process.env, ...deps.storage };
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Preflight, deps and the key PREPARATION for one harness. `preset.invite` is an invite the caller
|
|
283
|
+
* already read and parsed (`setup --all` reads it once for every pair); without it, the invite is
|
|
284
|
+
* read here.
|
|
285
|
+
*/
|
|
286
|
+
export async function startSetupPair(options, deps = {}, preset) {
|
|
210
287
|
const env = deps.env ?? process.env;
|
|
211
288
|
const cwd = deps.cwd ?? process.cwd();
|
|
212
289
|
const platform = deps.platform ?? process.platform;
|
|
@@ -214,51 +291,72 @@ export async function runSetup(options, deps = {}) {
|
|
|
214
291
|
const prompt = deps.prompt ?? defaultPrompt;
|
|
215
292
|
const run = deps.run ?? defaultChildRunner;
|
|
216
293
|
const out = new Run();
|
|
294
|
+
const stopped = () => ({ kind: "stopped", report: out.report() });
|
|
217
295
|
// ---- preflight --------------------------------------------------------------------------
|
|
218
296
|
const harness = options.harness;
|
|
219
297
|
if (!IDENTITY_HARNESSES.includes(harness)) {
|
|
220
298
|
out.record("preflight", "failed", "harness_invalid", `--harness must be one of ${IDENTITY_HARNESSES.join(", ")}`);
|
|
221
|
-
return
|
|
299
|
+
return stopped();
|
|
222
300
|
}
|
|
223
301
|
const h = harness;
|
|
224
302
|
let markerPath;
|
|
225
303
|
let identityPath;
|
|
226
304
|
try {
|
|
227
305
|
identityPath = identityFilePath(h, env);
|
|
228
|
-
markerPath =
|
|
306
|
+
markerPath = pendingEnrollmentMarkerPath(h, env);
|
|
229
307
|
}
|
|
230
308
|
catch (error) {
|
|
231
309
|
out.record("preflight", "failed", authoredCode(error, "identity_path_invalid"), "the identity file's path needs an absolute HOME/XDG_CONFIG_HOME and a valid host name (or MESSAGE_BOARD_WAKE_MACHINE_ID)");
|
|
232
|
-
return
|
|
310
|
+
return stopped();
|
|
233
311
|
}
|
|
234
312
|
if (options.clearPending) {
|
|
235
|
-
// Runs no step: only this harness's marker, only with no identity file
|
|
236
|
-
|
|
237
|
-
|
|
313
|
+
// Runs no step: only this harness's marker, only with no identity file, and only under the
|
|
314
|
+
// enrollment lock. A marker whose enrollment is STILL RUNNING (a setup waiting on the device
|
|
315
|
+
// approval, or an in-chat sign-in) is live: the Portal shows nothing yet, and deleting it would
|
|
316
|
+
// leave a lost /enroll response with no record (phase review NEW-1). Such an enrollment holds
|
|
317
|
+
// the lock, so a held lock refuses and nothing is removed.
|
|
318
|
+
let lock;
|
|
319
|
+
try {
|
|
320
|
+
lock = await acquireEnrollmentLock(h, env);
|
|
238
321
|
}
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
out.record("clear_pending", "
|
|
322
|
+
catch (error) {
|
|
323
|
+
const code = authoredCode(error, "enroll_lock_failed");
|
|
324
|
+
out.record("clear_pending", "failed", code, code === "enroll_in_progress"
|
|
325
|
+
? `an enrollment of ${h} is running on this host right now (a setup waiting for approval, or an in-chat sign-in), so its marker is live and nothing was removed: finish or abandon that enrollment first`
|
|
326
|
+
: `setup could not take ${h}'s enrollment lock in its identity directory, so nothing was removed: run \`agent-board doctor --harness ${h}\` and fix what it names`);
|
|
327
|
+
return stopped();
|
|
242
328
|
}
|
|
243
|
-
|
|
244
|
-
|
|
329
|
+
try {
|
|
330
|
+
if (exists(identityPath)) {
|
|
331
|
+
out.record("clear_pending", "failed", "identity_present", `${h} has an identity file, so its enrollment is not pending; nothing was removed`);
|
|
332
|
+
}
|
|
333
|
+
else if (pendingEnrollmentMarkerExists(h, env)) {
|
|
334
|
+
nodeFs.unlinkSync(markerPath);
|
|
335
|
+
out.record("clear_pending", "done", "marker_removed");
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
out.record("clear_pending", "already", "no_marker");
|
|
339
|
+
}
|
|
245
340
|
}
|
|
246
|
-
|
|
341
|
+
finally {
|
|
342
|
+
await lock.release();
|
|
343
|
+
}
|
|
344
|
+
return stopped();
|
|
247
345
|
}
|
|
248
346
|
const scope = (options.scope ?? "user");
|
|
249
347
|
if (!SETUP_SCOPES.includes(scope)) {
|
|
250
348
|
out.record("preflight", "failed", "scope_invalid", "--scope is user or local");
|
|
251
|
-
return
|
|
349
|
+
return stopped();
|
|
252
350
|
}
|
|
253
351
|
const refusal = scopeRefusal(h, scope, { env, cwd, fs: deps.writer?.fs, claudeManagedDir: deps.writer?.claudeManagedDir });
|
|
254
352
|
if (refusal) {
|
|
255
353
|
out.record("preflight", "failed", refusal.reason, refusal.fix);
|
|
256
|
-
return
|
|
354
|
+
return stopped();
|
|
257
355
|
}
|
|
258
356
|
const slug = options.slug ?? defaultSlug(resolveHostSegment(env) ?? (deps.hostname ?? osHostname)(), h);
|
|
259
357
|
if (!SLUG.test(slug) || slug.length > SLUG_MAX) {
|
|
260
358
|
out.record("preflight", "failed", "slug_invalid", `--slug must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be at most ${SLUG_MAX} characters`);
|
|
261
|
-
return
|
|
359
|
+
return stopped();
|
|
262
360
|
}
|
|
263
361
|
let assets;
|
|
264
362
|
try {
|
|
@@ -266,16 +364,18 @@ export async function runSetup(options, deps = {}) {
|
|
|
266
364
|
}
|
|
267
365
|
catch {
|
|
268
366
|
out.record("preflight", "failed", "assets_unbaked", "this cli build has no baked versions (dist/assets.json): reinstall @consiliency/agent-board-cli");
|
|
269
|
-
return
|
|
367
|
+
return stopped();
|
|
270
368
|
}
|
|
271
369
|
const identity = await identityState(h, env);
|
|
272
370
|
if (identity.kind === "unreadable") {
|
|
273
371
|
out.record("preflight", "failed", identity.reason, `a ${h} identity file EXISTS but cannot be read safely (its directory or the file is not private to you, or it does not parse): run \`agent-board doctor --harness ${h}\` and fix what it names; setup never enrolls over an existing identity (the Portal token is single-use)`);
|
|
274
|
-
return
|
|
372
|
+
return stopped();
|
|
275
373
|
}
|
|
276
|
-
|
|
374
|
+
// IF-0-ONBOARD-9: the marker's EXISTENCE decides, whatever it holds (the in-chat form adds a
|
|
375
|
+
// `key_ref=` line; setup never reads it).
|
|
376
|
+
if (identity.kind === "absent" && pendingEnrollmentMarkerExists(h, env)) {
|
|
277
377
|
out.record("preflight", "failed", "enrolled_without_identity", `an earlier setup started enrolling ${h} and did not write its identity file, so an agent MAY exist in the Portal. Check the Portal: if no agent was created, run \`agent-board setup --harness ${h} --clear-pending\` and re-run setup; if one was, revoke it first`);
|
|
278
|
-
return
|
|
378
|
+
return stopped();
|
|
279
379
|
}
|
|
280
380
|
let deployment = null;
|
|
281
381
|
if (identity.kind === "absent") {
|
|
@@ -289,23 +389,27 @@ export async function runSetup(options, deps = {}) {
|
|
|
289
389
|
const descriptor = resolveEnrollmentDescriptor(env, { keyReference: PREFLIGHT_KEY_REFERENCE });
|
|
290
390
|
if (!descriptor.issuer.deviceAuthorizationEndpoint) {
|
|
291
391
|
out.record("preflight", "failed", "missing_device_endpoint", "the deployment has no device authorization endpoint (MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
|
|
292
|
-
return
|
|
392
|
+
return stopped();
|
|
293
393
|
}
|
|
294
394
|
}
|
|
295
395
|
catch (error) {
|
|
296
396
|
out.record("preflight", "failed", authoredCode(error, "enrollment_config_invalid"), "the deployment values are incomplete: unset the partial MESSAGE_BOARD_ENROLL_* / AGENT_BOARD_* names to use the shipped defaults, or set all of them");
|
|
297
|
-
return
|
|
397
|
+
return stopped();
|
|
298
398
|
}
|
|
299
399
|
}
|
|
300
|
-
let invite
|
|
301
|
-
if (
|
|
302
|
-
invite =
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
400
|
+
let invite;
|
|
401
|
+
if (preset) {
|
|
402
|
+
invite = preset.invite;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
const read = await readSetupInvite(options, deps);
|
|
406
|
+
if (!("invite" in read)) {
|
|
407
|
+
out.record("preflight", "failed", read.reason, read.fix);
|
|
408
|
+
return stopped();
|
|
308
409
|
}
|
|
410
|
+
invite = read.invite;
|
|
411
|
+
if (invite !== null)
|
|
412
|
+
out.notes.push(inviteBoardNote(invite));
|
|
309
413
|
}
|
|
310
414
|
out.record("preflight", "done", "ready");
|
|
311
415
|
// ---- deps -------------------------------------------------------------------------------
|
|
@@ -317,8 +421,43 @@ export async function runSetup(options, deps = {}) {
|
|
|
317
421
|
? { status: "skipped", reason: "agent_socket_given" }
|
|
318
422
|
: await depsStep({ env, platform, interactive, installDeps: options.installDeps === true, run, sudo: deps.sudoPath ?? DEFAULT_SUDO, prompt });
|
|
319
423
|
out.record("deps", deps_.status, deps_.reason, deps_.fix);
|
|
424
|
+
// ---- key (preparation) --------------------------------------------------------------------
|
|
425
|
+
const state = { out, options, deps, env, cwd, scope, assets, identity, deployment, invite, identityPath, markerPath };
|
|
426
|
+
if (identity.kind === "present")
|
|
427
|
+
return { kind: "started", pair: { harness: h, slug, prepared: null, state } };
|
|
428
|
+
// #536 as a named step, for EVERY storage: the identity file needs a private agent-board/ too,
|
|
429
|
+
// and the keychain route never reaches the key slot's own repair.
|
|
430
|
+
const repaired = repairKeyDir(env, deps.storage?.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1));
|
|
431
|
+
for (const repair of repaired.repairs)
|
|
432
|
+
out.notes.push(`key: repaired ${repair}`);
|
|
433
|
+
if (repaired.problems.length > 0) {
|
|
434
|
+
out.record("key", "failed", "unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): make each named directory yours (or root's) and not group/other-writable, then re-run setup`);
|
|
435
|
+
return stopped();
|
|
436
|
+
}
|
|
437
|
+
const ctx = {
|
|
438
|
+
harness: h, env, platform, deps: deps_,
|
|
439
|
+
...(options.allowUnsealedKey === true ? { allowUnsealedKey: true } : {}),
|
|
440
|
+
...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
|
|
441
|
+
...deps.storage,
|
|
442
|
+
};
|
|
443
|
+
// A caller that injects a whole selector gets it as a final selection (it did its own scan).
|
|
444
|
+
const prepared = deps.selectKeyStorage
|
|
445
|
+
? { kind: "selected", harness: h, selection: await deps.selectKeyStorage(ctx) }
|
|
446
|
+
: await (deps.prepareKeyStorage ?? defaultPrepareKeyStorage)(ctx);
|
|
447
|
+
return { kind: "started", pair: { harness: h, slug, prepared, state } };
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* The rest of one pair, from its FINAL key storage (`finalizeKeyStorage`'s answer for this pair;
|
|
451
|
+
* null for a pair whose identity exists): enroll, identity, board, harness, doctor.
|
|
452
|
+
*/
|
|
453
|
+
export async function finishSetupPair(pair, selection) {
|
|
454
|
+
const { out, options, deps, env, cwd, scope, assets, identity, deployment, invite, identityPath, markerPath } = pair.state;
|
|
455
|
+
const h = pair.harness;
|
|
456
|
+
const slug = pair.slug;
|
|
457
|
+
const run = deps.run ?? defaultChildRunner;
|
|
320
458
|
// ---- key --------------------------------------------------------------------------------
|
|
321
459
|
let keyReference;
|
|
460
|
+
let keyScan;
|
|
322
461
|
if (identity.kind === "present") {
|
|
323
462
|
keyReference = identity.values.AGENT_BOARD_AGENT_KEY_REFERENCE;
|
|
324
463
|
const slot = keyslotOf(keyReference, env);
|
|
@@ -333,27 +472,27 @@ export async function runSetup(options, deps = {}) {
|
|
|
333
472
|
}
|
|
334
473
|
}
|
|
335
474
|
else {
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const repaired = repairKeyDir(env, deps.storage?.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1));
|
|
339
|
-
for (const repair of repaired.repairs)
|
|
340
|
-
out.notes.push(`key: repaired ${repair}`);
|
|
341
|
-
if (repaired.problems.length > 0) {
|
|
342
|
-
out.record("key", "failed", "unsafe_key_dir", `the key directory is not safe (${repaired.problems.join("; ")}): make each named directory yours (or root's) and not group/other-writable, then re-run setup`);
|
|
475
|
+
if (selection === null) {
|
|
476
|
+
out.record("key", "failed", "scan_not_completed", "no key storage was finalized for this harness, so it is NOT enrolled: re-run setup");
|
|
343
477
|
return out.report();
|
|
344
478
|
}
|
|
345
|
-
const selection = await (deps.selectKeyStorage ?? defaultSelectKeyStorage)({
|
|
346
|
-
harness: h, env, platform, deps: deps_,
|
|
347
|
-
...(options.allowUnsealedKey === true ? { allowUnsealedKey: true } : {}),
|
|
348
|
-
...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
|
|
349
|
-
...deps.storage,
|
|
350
|
-
});
|
|
351
479
|
for (const repair of selection.repairs)
|
|
352
480
|
out.notes.push(`key: repaired ${repair}`);
|
|
353
481
|
if (selection.storage === "refused") {
|
|
354
|
-
|
|
482
|
+
// #552: a scan that gave up is undetermined (2); every other refusal, a found copy included, is 1.
|
|
483
|
+
out.record("key", selection.status ?? "failed", selection.reason, selection.fix);
|
|
355
484
|
return out.report();
|
|
356
485
|
}
|
|
486
|
+
if ((selection.storage === "sealed" || selection.storage === "unsealed") && selection.slot) {
|
|
487
|
+
// #552 IF-552-3 "Re-read before enrolling": a tombstone written after (or during) this step's
|
|
488
|
+
// scan stops setup here, before any enrollment call.
|
|
489
|
+
const reread = await rereadBeforeEnroll(h, selection.slot, env, deps, selection.keyScan);
|
|
490
|
+
if (reread) {
|
|
491
|
+
out.record("key", "failed", reread.reason, reread.fix);
|
|
492
|
+
return out.report();
|
|
493
|
+
}
|
|
494
|
+
keyScan = selection.keyScan;
|
|
495
|
+
}
|
|
357
496
|
if (selection.storage === "os_keychain") {
|
|
358
497
|
keyReference = `os_keychain:agent-board/${resolveHostSegment(env) ?? slug}/${h}`;
|
|
359
498
|
}
|
|
@@ -389,7 +528,57 @@ export async function runSetup(options, deps = {}) {
|
|
|
389
528
|
const root = join(identityConfigRoot(env), "agent-board");
|
|
390
529
|
nodeFs.mkdirSync(dirname(identityPath), { recursive: true, mode: 0o700 });
|
|
391
530
|
walkTrustedDirectory(dirname(identityPath), root);
|
|
392
|
-
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
out.record("enroll", "failed", authoredCode(error, "identity_path_untrusted"), "the identity directory is not private to you: $XDG_CONFIG_HOME (or ~/.config) and agent-board/ beneath it must be yours, not group/world-writable");
|
|
534
|
+
return out.report();
|
|
535
|
+
}
|
|
536
|
+
// The (host, harness) enrollment lock the MCP in-chat enrollment holds for ITS whole sign-in
|
|
537
|
+
// (IF-0-ONBOARD-9), held here from before the marker until after the identity write and the
|
|
538
|
+
// marker clear. Preflight's identity and marker reads are stale by now: an in-chat enrollment
|
|
539
|
+
// may have finished (or started) since, so both are read AGAIN under the lock (phase review,
|
|
540
|
+
// codex P1: setup overwrote an identity chat enrollment had just written).
|
|
541
|
+
let lock;
|
|
542
|
+
try {
|
|
543
|
+
lock = await acquireEnrollmentLock(h, env);
|
|
544
|
+
}
|
|
545
|
+
catch (error) {
|
|
546
|
+
const code = authoredCode(error, "enroll_lock_failed");
|
|
547
|
+
out.record("enroll", "failed", code, code === "enroll_in_progress"
|
|
548
|
+
? `another ${h} session on this host is enrolling right now (an in-chat sign-in, or another setup): finish or abandon it, then re-run setup; nothing was enrolled or written here`
|
|
549
|
+
: "setup could not take this harness's enrollment lock in its identity directory: run `agent-board doctor --harness " + h + "` and fix what it names, then re-run setup");
|
|
550
|
+
return out.report();
|
|
551
|
+
}
|
|
552
|
+
try {
|
|
553
|
+
const enrolled = await enrollUnderLock();
|
|
554
|
+
if (enrolled !== null)
|
|
555
|
+
return enrolled;
|
|
556
|
+
}
|
|
557
|
+
finally {
|
|
558
|
+
await lock.release();
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/** The enrollment proper, with the lock held. null = continue to the board step. */
|
|
562
|
+
async function enrollUnderLock() {
|
|
563
|
+
const current = await identityState(h, env);
|
|
564
|
+
if (current.kind === "present") {
|
|
565
|
+
// Enrolled by someone else since preflight: never enroll over it, never overwrite it.
|
|
566
|
+
out.record("enroll", "already", "identity_present");
|
|
567
|
+
out.record("identity", "already", "identity_present");
|
|
568
|
+
out.notes.push(`enroll: ${h} was enrolled by another session (an in-chat enrollment) after setup started; its identity file is kept, and the key setup prepared is not used`);
|
|
569
|
+
keyScan = undefined;
|
|
570
|
+
return null;
|
|
571
|
+
}
|
|
572
|
+
if (current.kind === "unreadable") {
|
|
573
|
+
out.record("enroll", "failed", current.reason, `a ${h} identity file appeared after setup started and cannot be read safely: run \`agent-board doctor --harness ${h}\`; nothing was enrolled`);
|
|
574
|
+
return out.report();
|
|
575
|
+
}
|
|
576
|
+
if (pendingEnrollmentMarkerExists(h, env)) {
|
|
577
|
+
out.record("enroll", "failed", "enrolled_without_identity", `an enrollment of ${h} started after setup did (a pending-enrollment marker appeared), so an agent MAY exist in the Portal. Check the Portal: if no agent was created, run \`agent-board setup --harness ${h} --clear-pending\` and re-run setup; if one was, revoke it first`);
|
|
578
|
+
return out.report();
|
|
579
|
+
}
|
|
580
|
+
try {
|
|
581
|
+
await writeOwnerOnlyFile(markerPath, formatPendingEnrollmentMarker(new Date()));
|
|
393
582
|
}
|
|
394
583
|
catch (error) {
|
|
395
584
|
out.record("enroll", "failed", authoredCode(error, "identity_path_untrusted"), "the identity directory is not private to you: $XDG_CONFIG_HOME (or ~/.config) and agent-board/ beneath it must be yours, not group/world-writable");
|
|
@@ -440,6 +629,7 @@ export async function runSetup(options, deps = {}) {
|
|
|
440
629
|
}
|
|
441
630
|
nodeFs.rmSync(markerPath, { force: true });
|
|
442
631
|
out.record("identity", "done", "written");
|
|
632
|
+
return null;
|
|
443
633
|
}
|
|
444
634
|
// ---- board ------------------------------------------------------------------------------
|
|
445
635
|
// Always the identity file just written, never ambient env: the invite is single-use.
|
|
@@ -456,17 +646,26 @@ export async function runSetup(options, deps = {}) {
|
|
|
456
646
|
}
|
|
457
647
|
else {
|
|
458
648
|
try {
|
|
459
|
-
const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite });
|
|
649
|
+
const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite.code });
|
|
460
650
|
if (result?.ok === false)
|
|
461
651
|
throw result;
|
|
462
652
|
out.record("board", "done", "joined");
|
|
653
|
+
// IF-0-ONBOARD-2 "Board": the redeem result names the board actually joined.
|
|
654
|
+
const joined = result?.data?.[0]?.board_id;
|
|
655
|
+
if (typeof joined === "string")
|
|
656
|
+
out.notes.push(`board: joined board ${printable(joined)}`);
|
|
463
657
|
}
|
|
464
658
|
catch (error) {
|
|
465
|
-
|
|
659
|
+
const reason = redeemRefusal(error);
|
|
660
|
+
out.record("board", "failed", reason, reason === "invite_unusable"
|
|
661
|
+
? `the board refused the invite for this agent (used up, revoked, expired, or not usable by this agent: the board does not say which): ask the board owner for a new invite, then \`agent-board --as ${h} rooms link-redeem\` (the invite on stdin)`
|
|
662
|
+
: "the invite was not redeemed: ask the board owner for a fresh invite, then `agent-board --as " + h + " rooms link-redeem` (code on stdin)");
|
|
466
663
|
return out.report();
|
|
467
664
|
}
|
|
468
665
|
}
|
|
469
666
|
// ---- harness ----------------------------------------------------------------------------
|
|
667
|
+
// The install's start: a running harness that started before it does not load what it wrote.
|
|
668
|
+
const installStartedAt = new Date();
|
|
470
669
|
const installed = await installHarness({
|
|
471
670
|
harness: h, scope, assets, env, cwd, keyReference: written.values.AGENT_BOARD_AGENT_KEY_REFERENCE ?? null, followSymlinks: options.followSymlinks !== false, run,
|
|
472
671
|
...deps.writer,
|
|
@@ -474,10 +673,23 @@ export async function runSetup(options, deps = {}) {
|
|
|
474
673
|
out.record("harness", installed.status, installed.reason, installed.fix);
|
|
475
674
|
if (installed.status === "failed")
|
|
476
675
|
return out.report();
|
|
676
|
+
if (installed.status === "done") {
|
|
677
|
+
// IF-0-ONBOARD-3: found by EXECUTABLE (#474 launcher rules), not through runtime records: a
|
|
678
|
+
// session with no board plugin writes no record, and it is the one that most needs a restart.
|
|
679
|
+
for (const stale of listHarnessProcesses(h, { startedBefore: installStartedAt, ...deps.processes })) {
|
|
680
|
+
out.staleSessions.push({ pid: stale.pid, startedAt: stale.startedAt.toISOString(), cwd: stale.cwd });
|
|
681
|
+
out.notes.push(`harness: restart the ${h} session pid ${stale.pid} (started ${stale.startedAt.toISOString()}${stale.cwd === null ? "" : `, in ${printable(stale.cwd)}`}): it started before this install and has not loaded it`);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
477
684
|
// ---- doctor -----------------------------------------------------------------------------
|
|
478
685
|
let report;
|
|
479
686
|
try {
|
|
480
|
-
|
|
687
|
+
// #552: the key step's FRESH scan is carried in-process; doctor uses it only where the cache
|
|
688
|
+
// could not store it (IF-552-3's three conditions). A cached key-step result carries nothing.
|
|
689
|
+
const setupScan = keyScan?.source === "fresh"
|
|
690
|
+
? { setupScan: { inputs: keyScan.inputs, scan: keyScan.scan, scannedAt: keyScan.scannedAt, cacheWrite: keyScan.cacheWrite } }
|
|
691
|
+
: {};
|
|
692
|
+
report = await (deps.doctor ?? runDoctor)({ harness: h, env, cwd, ...deps.doctorOptions, ...setupScan });
|
|
481
693
|
}
|
|
482
694
|
catch (error) {
|
|
483
695
|
out.record("doctor", "undetermined", authoredCode(error, "doctor_threw"));
|
|
@@ -523,13 +735,25 @@ export async function runSetup(options, deps = {}) {
|
|
|
523
735
|
out.notes.push(`next: to join a board, ask its owner for an invite link, then run \`agent-board --as ${h} rooms link-redeem\` with the code on stdin`);
|
|
524
736
|
return out.report();
|
|
525
737
|
}
|
|
738
|
+
/**
|
|
739
|
+
* One harness: start, the key step's one scan (prepare → measure → finalize, as `setup --all` does
|
|
740
|
+
* over every pair), then the rest.
|
|
741
|
+
*/
|
|
742
|
+
export async function runSetup(options, deps = {}) {
|
|
743
|
+
const start = await startSetupPair(options, deps);
|
|
744
|
+
if (start.kind === "stopped")
|
|
745
|
+
return start.report;
|
|
746
|
+
const { prepared } = start.pair;
|
|
747
|
+
const selection = prepared === null ? null : finalizeKeyStorage([prepared], await measureKeyStorage([prepared], setupStorageContext(deps)))[0];
|
|
748
|
+
return finishSetupPair(start.pair, selection);
|
|
749
|
+
}
|
|
526
750
|
/**
|
|
527
751
|
* The keyslot loader refresh (A1): `status.sh` names the recorded and the running loader version;
|
|
528
752
|
* when they differ, `provision.sh --refresh-loader` (which never re-keys) points the slot at the
|
|
529
753
|
* running one, and the measurement is taken again.
|
|
530
754
|
*/
|
|
531
755
|
async function refreshLoader(harness, slot, env, deps) {
|
|
532
|
-
const runStorage = deps.storage?.run
|
|
756
|
+
const runStorage = deps.storage?.run;
|
|
533
757
|
let dir;
|
|
534
758
|
try {
|
|
535
759
|
dir = deps.storage?.keyslotDir ?? resolveKeyslotDir();
|
|
@@ -537,22 +761,18 @@ async function refreshLoader(harness, slot, env, deps) {
|
|
|
537
761
|
catch {
|
|
538
762
|
return { status: "undetermined", reason: "keyslot_scripts_absent", fix: "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli" };
|
|
539
763
|
}
|
|
764
|
+
// #552: loader fields only, so `--scan-cache only`: this never scans (S-3).
|
|
540
765
|
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
|
-
}
|
|
766
|
+
const measured = await measureKeyslot({ dir, harness, slot, env, scan: "only", run: runStorage, budgets: deps.storage?.statusBudgets });
|
|
767
|
+
const loader = measured.report?.loader;
|
|
768
|
+
return loader ? { recorded: loader.recorded_sha256, running: loader.running_sha256 } : null;
|
|
549
769
|
};
|
|
550
770
|
const before = await measure();
|
|
551
771
|
if (before === null || typeof before.running !== "string")
|
|
552
772
|
return { status: "undetermined", reason: "loader_unmeasured", fix: "status.sh did not report the loader: run the packaged status.sh --json" };
|
|
553
773
|
if (before.recorded === before.running)
|
|
554
774
|
return { status: "already", reason: "identity_present" };
|
|
555
|
-
const refresh = await runStorage(join(dir, "provision.sh"), ["--harness", harness, "--slot", slot, "--refresh-loader"], { env });
|
|
775
|
+
const refresh = await (runStorage ?? defaultStorageRunner)(join(dir, "provision.sh"), ["--harness", harness, "--slot", slot, "--refresh-loader"], { env });
|
|
556
776
|
if (refresh.code !== 0)
|
|
557
777
|
return { status: "failed", reason: "loader_refresh_failed", fix: "provision.sh --refresh-loader failed: run it by hand and read what it reports" };
|
|
558
778
|
const after = await measure();
|
|
@@ -560,29 +780,81 @@ async function refreshLoader(harness, slot, env, deps) {
|
|
|
560
780
|
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
781
|
return { status: "done", reason: "loader_refreshed" };
|
|
562
782
|
}
|
|
783
|
+
/** Cache reads that are STRUCTURAL: the cache cannot serve on this host at all. */
|
|
784
|
+
const STRUCTURAL_UNUSABLE = /^(untrusted:.+|state_root_relative|machine_id_unreadable)$/;
|
|
785
|
+
/**
|
|
786
|
+
* #552 IF-552-3 "Re-read before enrolling": `status.sh --scan-cache only` once more, judged on its
|
|
787
|
+
* `scan.cache`. `miss:cleared` (a tombstone) → plaintext_copy_found; `hit` → proceed; a structural
|
|
788
|
+
* `unusable`, or `miss:absent` where the key step's own write was `not_written:untrusted_dir` or
|
|
789
|
+
* `write_failed` → proceed on the key step's own clean result; anything else (`lock_busy`, expired,
|
|
790
|
+
* future, format, input_changed) → scan_not_completed. null = proceed.
|
|
791
|
+
*/
|
|
792
|
+
async function rereadBeforeEnroll(harness, slot, env, deps, keyScan) {
|
|
793
|
+
let dir;
|
|
794
|
+
try {
|
|
795
|
+
dir = deps.storage?.keyslotDir ?? resolveKeyslotDir();
|
|
796
|
+
}
|
|
797
|
+
catch {
|
|
798
|
+
return { reason: "scan_not_completed", fix: "the packaged keyslot scripts are missing: reinstall @consiliency/agent-board-cli" };
|
|
799
|
+
}
|
|
800
|
+
const measured = await measureKeyslot({ dir, harness, slot, env, scan: "only", run: deps.storage?.run, budgets: deps.storage?.statusBudgets });
|
|
801
|
+
const { state, reason } = cacheStateOf(measured.report);
|
|
802
|
+
const statusCmd = `the packaged status.sh --harness ${harness} --json`;
|
|
803
|
+
if (state === "miss" && reason === "cleared") {
|
|
804
|
+
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` };
|
|
805
|
+
}
|
|
806
|
+
if (state === "hit")
|
|
807
|
+
return null;
|
|
808
|
+
if (state === "unusable" && STRUCTURAL_UNUSABLE.test(reason))
|
|
809
|
+
return null;
|
|
810
|
+
if (state === "miss" && reason === "absent" && (keyScan?.cacheWrite === "not_written:untrusted_dir" || keyScan?.cacheWrite === "not_written:write_failed"))
|
|
811
|
+
return null;
|
|
812
|
+
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}` };
|
|
813
|
+
}
|
|
563
814
|
export function formatSetupReport(report) {
|
|
564
815
|
return [...report.steps.map((step) => `${step.status.padEnd(12)} ${step.step}: ${step.reason}`), ...report.notes, `exit ${report.exit}`].join("\n");
|
|
565
816
|
}
|
|
566
|
-
|
|
817
|
+
const collect = (value, previous = []) => [...previous, value];
|
|
818
|
+
export function addSetupCommand(program, io, setup, setupAll) {
|
|
567
819
|
program
|
|
568
820
|
.command("setup")
|
|
569
|
-
.description("set this host up for one harness: key storage, enrollment, identity file, optional board invite, harness registration, then doctor")
|
|
821
|
+
.description("set this host up for one harness (--harness) or every harness on it (--all): key storage, enrollment, identity file, optional board invite, harness registration, then doctor")
|
|
570
822
|
// Not `requiredOption`: commander's own usage exit would bypass the report (#402).
|
|
571
823
|
.option("--harness <harness>", "the harness to set up (agy, claude, codex, grok, opencode, pi)")
|
|
572
|
-
.option("--
|
|
573
|
-
.option("--
|
|
824
|
+
.option("--all", "set up every harness whose CLI is on PATH, with one invite read and at most one key scan")
|
|
825
|
+
.option("--slug <slug>", "the agent slug (default <short hostname>-<harness>); with --all, <harness>=<slug>, repeatable", collect)
|
|
826
|
+
.option("--invite", "read a board invite (a bare code on a terminal; the code or the whole invite message on stdin) and redeem it after enrolling; never argv")
|
|
827
|
+
.option("--invite-file <path>", "read the board invite (the code, or the whole invite message) from a file and redeem it after enrolling")
|
|
574
828
|
.option("--allow-unsealed-key", "accept an unsealed 0600 key under an opt-in record on a host that cannot seal")
|
|
575
|
-
.option("--agent-socket <ref>", "use your own SSH agent (with an external-agent declaration)")
|
|
829
|
+
.option("--agent-socket <ref>", "use your own SSH agent (with an external-agent declaration); refused with --all")
|
|
576
830
|
.option("--scope <scope>", "user (default) or local (this directory)")
|
|
577
831
|
.option("--install-deps", "non-interactive: install the OS keychain tool with sudo -n when a Secret Service is running")
|
|
578
|
-
.option("--clear-pending", "remove this harness's pending-enrollment marker after confirming in the Portal that no agent was created; runs no step")
|
|
832
|
+
.option("--clear-pending", "remove this harness's pending-enrollment marker after confirming in the Portal that no agent was created; runs no step; refused with --all")
|
|
579
833
|
.option("--no-follow-symlinks", "refuse to write through a symlinked harness config")
|
|
580
834
|
.option("--json", "print the steps and exit code as JSON")
|
|
581
835
|
.action(async (options) => {
|
|
836
|
+
const slugs = options.slug ?? [];
|
|
837
|
+
if (options.all === true && setupAll) {
|
|
838
|
+
process.exitCode = await setupAll({
|
|
839
|
+
...(options.harness !== undefined ? { harness: String(options.harness) } : {}),
|
|
840
|
+
slugs,
|
|
841
|
+
invite: options.invite === true,
|
|
842
|
+
...(options.inviteFile !== undefined ? { inviteFile: String(options.inviteFile) } : {}),
|
|
843
|
+
allowUnsealedKey: options.allowUnsealedKey === true,
|
|
844
|
+
...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
|
|
845
|
+
scope: options.scope,
|
|
846
|
+
installDeps: options.installDeps === true,
|
|
847
|
+
clearPending: options.clearPending === true,
|
|
848
|
+
followSymlinks: options.followSymlinks !== false,
|
|
849
|
+
}, { json: options.json === true });
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
582
852
|
const report = await setup({
|
|
583
853
|
harness: String(options.harness ?? ""),
|
|
584
|
-
slug:
|
|
854
|
+
// Without --all, --slug keeps its single-value form: the last one given, as before.
|
|
855
|
+
slug: slugs.at(-1),
|
|
585
856
|
invite: options.invite === true,
|
|
857
|
+
...(options.inviteFile !== undefined ? { inviteFile: String(options.inviteFile) } : {}),
|
|
586
858
|
allowUnsealedKey: options.allowUnsealedKey === true,
|
|
587
859
|
agentSocket: options.agentSocket,
|
|
588
860
|
scope: options.scope,
|