@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
package/dist/setup.js
CHANGED
|
@@ -2,13 +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
7
|
import { defaultStorageRunner } from "./keyslot_status.js";
|
|
8
8
|
import { authoredCode, fileIdentityEnv, runDoctor } from "./doctor.js";
|
|
9
9
|
import { identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, writeOwnerOnlyFile } from "./identity_writer.js";
|
|
10
10
|
import { cacheStateOf, measureKeyslot } from "./keyslot_status.js";
|
|
11
|
-
import { SLOT_RE, repairKeyDir, keyslotSocketDir,
|
|
11
|
+
import { SLOT_RE, finalizeKeyStorage, measureKeyStorage, repairKeyDir, keyslotSocketDir, prepareKeyStorage as defaultPrepareKeyStorage, } from "./setup_storage.js";
|
|
12
12
|
import { SETUP_SCOPES, defaultChildRunner, installHarness, scopeRefusal, which, } from "./setup_writers.js";
|
|
13
13
|
export const SETUP_STEPS = Object.freeze(["preflight", "deps", "key", "enroll", "identity", "board", "harness", "doctor"]);
|
|
14
14
|
export const DEFAULT_SUDO = "/usr/bin/sudo";
|
|
@@ -36,9 +36,6 @@ export function defaultSlug(host, harness) {
|
|
|
36
36
|
const short = host.split(".")[0].toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
37
37
|
return `${short || "host"}-${harness}`.slice(0, SLUG_MAX).replace(/-$/, "");
|
|
38
38
|
}
|
|
39
|
-
export function pendingMarkerPath(harness, env) {
|
|
40
|
-
return join(dirname(identityFilePath(harness, env)), `.setup-enroll-pending-${harness}`);
|
|
41
|
-
}
|
|
42
39
|
/**
|
|
43
40
|
* Codes raised before any token exists (the issuer refused or the device code lapsed), so no
|
|
44
41
|
* /enroll call can have happened: the marker is removed. Read through `authoredCode`, so only a
|
|
@@ -127,6 +124,7 @@ function defaultBoardClient(fileEnv) {
|
|
|
127
124
|
class Run {
|
|
128
125
|
steps = [];
|
|
129
126
|
notes = [];
|
|
127
|
+
staleSessions = [];
|
|
130
128
|
record(step, status, reason, fix) {
|
|
131
129
|
const entry = { step, status, reason };
|
|
132
130
|
this.steps.push(entry);
|
|
@@ -137,7 +135,7 @@ class Run {
|
|
|
137
135
|
report() {
|
|
138
136
|
const statuses = this.steps.map((step) => step.status);
|
|
139
137
|
const exit = statuses.includes("failed") ? 1 : statuses.includes("undetermined") ? 2 : 0;
|
|
140
|
-
return { steps: this.steps, exit, notes: this.notes };
|
|
138
|
+
return { steps: this.steps, exit, notes: this.notes, staleSessions: this.staleSessions };
|
|
141
139
|
}
|
|
142
140
|
}
|
|
143
141
|
/** The exact command a user runs to install the keychain tool on this host, or null. */
|
|
@@ -185,8 +183,76 @@ export async function depsStep(ctx) {
|
|
|
185
183
|
// ---------------------------------------------------------------------------------------------
|
|
186
184
|
// The run
|
|
187
185
|
// ---------------------------------------------------------------------------------------------
|
|
188
|
-
|
|
189
|
-
|
|
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");
|
|
190
256
|
}
|
|
191
257
|
/** The keyslot a key reference's socket names, when it is one of `$XDG_RUNTIME_DIR/agent-board/<slot>.sock`. */
|
|
192
258
|
function keyslotOf(reference, env) {
|
|
@@ -208,7 +274,16 @@ function keyslotOf(reference, env) {
|
|
|
208
274
|
// Only a name the keyslot scripts accept ever reaches their argv.
|
|
209
275
|
return SLOT_RE.test(slot) ? slot : null;
|
|
210
276
|
}
|
|
211
|
-
|
|
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) {
|
|
212
287
|
const env = deps.env ?? process.env;
|
|
213
288
|
const cwd = deps.cwd ?? process.cwd();
|
|
214
289
|
const platform = deps.platform ?? process.platform;
|
|
@@ -216,51 +291,72 @@ export async function runSetup(options, deps = {}) {
|
|
|
216
291
|
const prompt = deps.prompt ?? defaultPrompt;
|
|
217
292
|
const run = deps.run ?? defaultChildRunner;
|
|
218
293
|
const out = new Run();
|
|
294
|
+
const stopped = () => ({ kind: "stopped", report: out.report() });
|
|
219
295
|
// ---- preflight --------------------------------------------------------------------------
|
|
220
296
|
const harness = options.harness;
|
|
221
297
|
if (!IDENTITY_HARNESSES.includes(harness)) {
|
|
222
298
|
out.record("preflight", "failed", "harness_invalid", `--harness must be one of ${IDENTITY_HARNESSES.join(", ")}`);
|
|
223
|
-
return
|
|
299
|
+
return stopped();
|
|
224
300
|
}
|
|
225
301
|
const h = harness;
|
|
226
302
|
let markerPath;
|
|
227
303
|
let identityPath;
|
|
228
304
|
try {
|
|
229
305
|
identityPath = identityFilePath(h, env);
|
|
230
|
-
markerPath =
|
|
306
|
+
markerPath = pendingEnrollmentMarkerPath(h, env);
|
|
231
307
|
}
|
|
232
308
|
catch (error) {
|
|
233
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)");
|
|
234
|
-
return
|
|
310
|
+
return stopped();
|
|
235
311
|
}
|
|
236
312
|
if (options.clearPending) {
|
|
237
|
-
// Runs no step: only this harness's marker, only with no identity file
|
|
238
|
-
|
|
239
|
-
|
|
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);
|
|
240
321
|
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
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();
|
|
244
328
|
}
|
|
245
|
-
|
|
246
|
-
|
|
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
|
+
}
|
|
247
340
|
}
|
|
248
|
-
|
|
341
|
+
finally {
|
|
342
|
+
await lock.release();
|
|
343
|
+
}
|
|
344
|
+
return stopped();
|
|
249
345
|
}
|
|
250
346
|
const scope = (options.scope ?? "user");
|
|
251
347
|
if (!SETUP_SCOPES.includes(scope)) {
|
|
252
348
|
out.record("preflight", "failed", "scope_invalid", "--scope is user or local");
|
|
253
|
-
return
|
|
349
|
+
return stopped();
|
|
254
350
|
}
|
|
255
351
|
const refusal = scopeRefusal(h, scope, { env, cwd, fs: deps.writer?.fs, claudeManagedDir: deps.writer?.claudeManagedDir });
|
|
256
352
|
if (refusal) {
|
|
257
353
|
out.record("preflight", "failed", refusal.reason, refusal.fix);
|
|
258
|
-
return
|
|
354
|
+
return stopped();
|
|
259
355
|
}
|
|
260
356
|
const slug = options.slug ?? defaultSlug(resolveHostSegment(env) ?? (deps.hostname ?? osHostname)(), h);
|
|
261
357
|
if (!SLUG.test(slug) || slug.length > SLUG_MAX) {
|
|
262
358
|
out.record("preflight", "failed", "slug_invalid", `--slug must match ^[a-z0-9]+(-[a-z0-9]+)*$ and be at most ${SLUG_MAX} characters`);
|
|
263
|
-
return
|
|
359
|
+
return stopped();
|
|
264
360
|
}
|
|
265
361
|
let assets;
|
|
266
362
|
try {
|
|
@@ -268,16 +364,18 @@ export async function runSetup(options, deps = {}) {
|
|
|
268
364
|
}
|
|
269
365
|
catch {
|
|
270
366
|
out.record("preflight", "failed", "assets_unbaked", "this cli build has no baked versions (dist/assets.json): reinstall @consiliency/agent-board-cli");
|
|
271
|
-
return
|
|
367
|
+
return stopped();
|
|
272
368
|
}
|
|
273
369
|
const identity = await identityState(h, env);
|
|
274
370
|
if (identity.kind === "unreadable") {
|
|
275
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)`);
|
|
276
|
-
return
|
|
372
|
+
return stopped();
|
|
277
373
|
}
|
|
278
|
-
|
|
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)) {
|
|
279
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`);
|
|
280
|
-
return
|
|
378
|
+
return stopped();
|
|
281
379
|
}
|
|
282
380
|
let deployment = null;
|
|
283
381
|
if (identity.kind === "absent") {
|
|
@@ -291,23 +389,27 @@ export async function runSetup(options, deps = {}) {
|
|
|
291
389
|
const descriptor = resolveEnrollmentDescriptor(env, { keyReference: PREFLIGHT_KEY_REFERENCE });
|
|
292
390
|
if (!descriptor.issuer.deviceAuthorizationEndpoint) {
|
|
293
391
|
out.record("preflight", "failed", "missing_device_endpoint", "the deployment has no device authorization endpoint (MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
|
|
294
|
-
return
|
|
392
|
+
return stopped();
|
|
295
393
|
}
|
|
296
394
|
}
|
|
297
395
|
catch (error) {
|
|
298
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");
|
|
299
|
-
return
|
|
397
|
+
return stopped();
|
|
300
398
|
}
|
|
301
399
|
}
|
|
302
|
-
let invite
|
|
303
|
-
if (
|
|
304
|
-
invite =
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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();
|
|
310
409
|
}
|
|
410
|
+
invite = read.invite;
|
|
411
|
+
if (invite !== null)
|
|
412
|
+
out.notes.push(inviteBoardNote(invite));
|
|
311
413
|
}
|
|
312
414
|
out.record("preflight", "done", "ready");
|
|
313
415
|
// ---- deps -------------------------------------------------------------------------------
|
|
@@ -319,6 +421,40 @@ export async function runSetup(options, deps = {}) {
|
|
|
319
421
|
? { status: "skipped", reason: "agent_socket_given" }
|
|
320
422
|
: await depsStep({ env, platform, interactive, installDeps: options.installDeps === true, run, sudo: deps.sudoPath ?? DEFAULT_SUDO, prompt });
|
|
321
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;
|
|
322
458
|
// ---- key --------------------------------------------------------------------------------
|
|
323
459
|
let keyReference;
|
|
324
460
|
let keyScan;
|
|
@@ -336,21 +472,10 @@ export async function runSetup(options, deps = {}) {
|
|
|
336
472
|
}
|
|
337
473
|
}
|
|
338
474
|
else {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const repaired = repairKeyDir(env, deps.storage?.uid ?? (typeof process.getuid === "function" ? process.getuid() : -1));
|
|
342
|
-
for (const repair of repaired.repairs)
|
|
343
|
-
out.notes.push(`key: repaired ${repair}`);
|
|
344
|
-
if (repaired.problems.length > 0) {
|
|
345
|
-
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");
|
|
346
477
|
return out.report();
|
|
347
478
|
}
|
|
348
|
-
const selection = await (deps.selectKeyStorage ?? defaultSelectKeyStorage)({
|
|
349
|
-
harness: h, env, platform, deps: deps_,
|
|
350
|
-
...(options.allowUnsealedKey === true ? { allowUnsealedKey: true } : {}),
|
|
351
|
-
...(options.agentSocket !== undefined ? { agentSocket: options.agentSocket } : {}),
|
|
352
|
-
...deps.storage,
|
|
353
|
-
});
|
|
354
479
|
for (const repair of selection.repairs)
|
|
355
480
|
out.notes.push(`key: repaired ${repair}`);
|
|
356
481
|
if (selection.storage === "refused") {
|
|
@@ -403,7 +528,57 @@ export async function runSetup(options, deps = {}) {
|
|
|
403
528
|
const root = join(identityConfigRoot(env), "agent-board");
|
|
404
529
|
nodeFs.mkdirSync(dirname(identityPath), { recursive: true, mode: 0o700 });
|
|
405
530
|
walkTrustedDirectory(dirname(identityPath), root);
|
|
406
|
-
|
|
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()));
|
|
407
582
|
}
|
|
408
583
|
catch (error) {
|
|
409
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");
|
|
@@ -454,6 +629,7 @@ export async function runSetup(options, deps = {}) {
|
|
|
454
629
|
}
|
|
455
630
|
nodeFs.rmSync(markerPath, { force: true });
|
|
456
631
|
out.record("identity", "done", "written");
|
|
632
|
+
return null;
|
|
457
633
|
}
|
|
458
634
|
// ---- board ------------------------------------------------------------------------------
|
|
459
635
|
// Always the identity file just written, never ambient env: the invite is single-use.
|
|
@@ -470,17 +646,26 @@ export async function runSetup(options, deps = {}) {
|
|
|
470
646
|
}
|
|
471
647
|
else {
|
|
472
648
|
try {
|
|
473
|
-
const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite });
|
|
649
|
+
const result = await (deps.createBoardClient ?? defaultBoardClient)(fileEnv).redeemInviteLink({ p_code: invite.code });
|
|
474
650
|
if (result?.ok === false)
|
|
475
651
|
throw result;
|
|
476
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)}`);
|
|
477
657
|
}
|
|
478
658
|
catch (error) {
|
|
479
|
-
|
|
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)");
|
|
480
663
|
return out.report();
|
|
481
664
|
}
|
|
482
665
|
}
|
|
483
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();
|
|
484
669
|
const installed = await installHarness({
|
|
485
670
|
harness: h, scope, assets, env, cwd, keyReference: written.values.AGENT_BOARD_AGENT_KEY_REFERENCE ?? null, followSymlinks: options.followSymlinks !== false, run,
|
|
486
671
|
...deps.writer,
|
|
@@ -488,6 +673,14 @@ export async function runSetup(options, deps = {}) {
|
|
|
488
673
|
out.record("harness", installed.status, installed.reason, installed.fix);
|
|
489
674
|
if (installed.status === "failed")
|
|
490
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
|
+
}
|
|
491
684
|
// ---- doctor -----------------------------------------------------------------------------
|
|
492
685
|
let report;
|
|
493
686
|
try {
|
|
@@ -542,6 +735,18 @@ export async function runSetup(options, deps = {}) {
|
|
|
542
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`);
|
|
543
736
|
return out.report();
|
|
544
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
|
+
}
|
|
545
750
|
/**
|
|
546
751
|
* The keyslot loader refresh (A1): `status.sh` names the recorded and the running loader version;
|
|
547
752
|
* when they differ, `provision.sh --refresh-loader` (which never re-keys) points the slot at the
|
|
@@ -609,26 +814,47 @@ async function rereadBeforeEnroll(harness, slot, env, deps, keyScan) {
|
|
|
609
814
|
export function formatSetupReport(report) {
|
|
610
815
|
return [...report.steps.map((step) => `${step.status.padEnd(12)} ${step.step}: ${step.reason}`), ...report.notes, `exit ${report.exit}`].join("\n");
|
|
611
816
|
}
|
|
612
|
-
|
|
817
|
+
const collect = (value, previous = []) => [...previous, value];
|
|
818
|
+
export function addSetupCommand(program, io, setup, setupAll) {
|
|
613
819
|
program
|
|
614
820
|
.command("setup")
|
|
615
|
-
.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")
|
|
616
822
|
// Not `requiredOption`: commander's own usage exit would bypass the report (#402).
|
|
617
823
|
.option("--harness <harness>", "the harness to set up (agy, claude, codex, grok, opencode, pi)")
|
|
618
|
-
.option("--
|
|
619
|
-
.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")
|
|
620
828
|
.option("--allow-unsealed-key", "accept an unsealed 0600 key under an opt-in record on a host that cannot seal")
|
|
621
|
-
.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")
|
|
622
830
|
.option("--scope <scope>", "user (default) or local (this directory)")
|
|
623
831
|
.option("--install-deps", "non-interactive: install the OS keychain tool with sudo -n when a Secret Service is running")
|
|
624
|
-
.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")
|
|
625
833
|
.option("--no-follow-symlinks", "refuse to write through a symlinked harness config")
|
|
626
834
|
.option("--json", "print the steps and exit code as JSON")
|
|
627
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
|
+
}
|
|
628
852
|
const report = await setup({
|
|
629
853
|
harness: String(options.harness ?? ""),
|
|
630
|
-
slug:
|
|
854
|
+
// Without --all, --slug keeps its single-value form: the last one given, as before.
|
|
855
|
+
slug: slugs.at(-1),
|
|
631
856
|
invite: options.invite === true,
|
|
857
|
+
...(options.inviteFile !== undefined ? { inviteFile: String(options.inviteFile) } : {}),
|
|
632
858
|
allowUnsealedKey: options.allowUnsealedKey === true,
|
|
633
859
|
agentSocket: options.agentSocket,
|
|
634
860
|
scope: options.scope,
|
|
@@ -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>;
|