@consiliency/agent-board-cli 1.24.0 → 1.26.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.
Files changed (37) hide show
  1. package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
  2. package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
  3. package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
  4. package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
  5. package/dist/assets.d.ts +26 -0
  6. package/dist/assets.js +70 -0
  7. package/dist/assets.json +6 -0
  8. package/dist/claude_marketplace.d.ts +117 -0
  9. package/dist/claude_marketplace.js +306 -0
  10. package/dist/cli.d.ts +37 -3
  11. package/dist/cli.js +68 -26
  12. package/dist/config_edit.d.ts +37 -0
  13. package/dist/config_edit.js +549 -0
  14. package/dist/doctor.d.ts +24 -2
  15. package/dist/doctor.js +164 -4
  16. package/dist/doctor_scopes.d.ts +34 -2
  17. package/dist/doctor_scopes.js +128 -20
  18. package/dist/identity_writer.d.ts +9 -7
  19. package/dist/identity_writer.js +18 -30
  20. package/dist/setup.d.ts +116 -0
  21. package/dist/setup.js +603 -0
  22. package/dist/setup_storage.d.ts +117 -0
  23. package/dist/setup_storage.js +447 -0
  24. package/dist/setup_writers.d.ts +147 -0
  25. package/dist/setup_writers.js +916 -0
  26. package/keyslot/agent-board-keyslot-load@.service +20 -0
  27. package/keyslot/agent-board-keyslot@.service +20 -0
  28. package/keyslot/constants.sh +73 -0
  29. package/keyslot/keygen.mjs +67 -0
  30. package/keyslot/keyslot-load.sh +78 -0
  31. package/keyslot/lib.sh +599 -0
  32. package/keyslot/optin.sh +185 -0
  33. package/keyslot/probe.sh +135 -0
  34. package/keyslot/provision.sh +530 -0
  35. package/keyslot/seam.sh +48 -0
  36. package/keyslot/status.sh +254 -0
  37. package/package.json +9 -4
@@ -0,0 +1,306 @@
1
+ // message-board#549 — the per-user Claude Code marketplace directory, shared by setup (which writes
2
+ // it) and doctor (which judges it). ONE implementation of each rule, imported by both, so the two
3
+ // cannot disagree about where the directory is, what "the same content" means, or which chain of
4
+ // directories is trusted.
5
+ //
6
+ // claudeMarketplaceDir `${XDG_DATA_HOME}` if absolute, else `$HOME/.local/share`, then
7
+ // `/agent-board/claude-marketplace`. Claude Code 2.1.283 stores a directory
8
+ // marketplace path AS GIVEN (a trailing slash stripped, symlinks NOT
9
+ // resolved: measured), so setup and doctor compare paths lexically after
10
+ // `resolve()` under one env.
11
+ // readTree / sameTree every regular file, relative path (always `/`-separated, on every OS) →
12
+ // bytes and exec bit; a symlink, device, socket or FIFO anywhere inside makes
13
+ // the tree unusable. Equal = same file set, same bytes, same exec bit.
14
+ // resolveTrustChain (round 4: replaces the lexical + real-path chains) walks the path as the
15
+ // kernel does, from `/`, one component at a time. Every directory ENTERED:
16
+ // * the marketplace directory and its `agent-board` parent: a real
17
+ // directory (never a symlink), owned by this uid, not group/world-
18
+ // writable;
19
+ // * every other: owned by this uid or root, and not group/world-writable,
20
+ // except root-owned with the sticky bit (`/tmp`), or owned by this uid and
21
+ // group-writable (never world) by the user's PRIVATE group, proven from
22
+ // `/etc/group` AND `/etc/passwd` (stock Ubuntu/Fedora `~/.local` is 0775
23
+ // `<user>:<user>`).
24
+ // A symlink component: its own owner must be this uid or root, and its target
25
+ // is spliced into the remaining components, so every hop's containing
26
+ // directory is entered and judged. More than 40 links: a loop, refused.
27
+ // A same-uid attacker is out of scope (SETUP ruling). On win32 there is no
28
+ // uid (getuid() is -1) and no POSIX mode, so only the symlink rule applies.
29
+ // plugin versions `pluginVersionIn` / `compareVersions`, shared by setup (never downgrade a
30
+ // TRUSTED newer directory; an untrusted one is rebuilt) and doctor (report version skew).
31
+ import { lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
32
+ import { userInfo } from "node:os";
33
+ import { dirname, isAbsolute, join, resolve } from "node:path";
34
+ /** The manifest's key in a tree read by `readTree`: always `/`-separated. */
35
+ export const MARKETPLACE_MANIFEST_KEY = ".claude-plugin/marketplace.json";
36
+ /** THE per-user directory setup materialises the packaged marketplace into. User and local scope share it. */
37
+ export function claudeMarketplaceDir(env, home) {
38
+ const data = env.XDG_DATA_HOME;
39
+ const root = data !== undefined && data.trim() !== "" && isAbsolute(data) ? data : join(home, ".local", "share");
40
+ return join(root, "agent-board", "claude-marketplace");
41
+ }
42
+ /** A declared directory path is ours when it resolves to `claudeMarketplaceDir` for this env. */
43
+ export function isOurMarketplacePath(path, env, home) {
44
+ return typeof path === "string" && isAbsolute(path) && resolve(path) === resolve(claudeMarketplaceDir(env, home));
45
+ }
46
+ /**
47
+ * Every regular file under `dir`. `missing` when `dir` does not exist; `unusable` when it is not a
48
+ * plain tree. With `trust` (the materialised directory, never the packaged copy, which a root-owned
49
+ * global install legitimately owns): every directory and file under `dir` must be owned by this uid
50
+ * and not group/world-writable, else `untrusted` naming the first offender — a hook another uid can
51
+ * rewrite is a hook another uid runs as you. Skipped on win32, as elsewhere.
52
+ */
53
+ export function readTree(dir, trust) {
54
+ let top;
55
+ try {
56
+ top = lstatSync(dir);
57
+ }
58
+ catch (error) {
59
+ if (error.code === "ENOENT")
60
+ return { kind: "missing" };
61
+ return { kind: "unusable" };
62
+ }
63
+ if (!top.isDirectory())
64
+ return { kind: "unusable" };
65
+ const files = new Map();
66
+ const judged = trust !== undefined && (trust.platform ?? process.platform) !== "win32";
67
+ let offender = null;
68
+ const untrusted = (path, stats) => {
69
+ if (!judged)
70
+ return false;
71
+ const owned = trust.lstat ? trust.lstat(path) : stats;
72
+ if (owned.uid === trust.uid && (owned.mode & 0o022) === 0)
73
+ return false;
74
+ offender = path;
75
+ return true;
76
+ };
77
+ const walk = (relative) => {
78
+ for (const name of readdirSync(relative === "" ? dir : join(dir, ...relative.split("/"))).sort()) {
79
+ const child = relative === "" ? name : `${relative}/${name}`;
80
+ const absolute = join(dir, ...child.split("/"));
81
+ const stats = lstatSync(absolute);
82
+ if ((stats.isDirectory() || stats.isFile()) && untrusted(absolute, stats))
83
+ return false;
84
+ if (stats.isDirectory()) {
85
+ if (!walk(child))
86
+ return false;
87
+ }
88
+ else if (stats.isFile()) {
89
+ files.set(child, { bytes: readFileSync(absolute), executable: (stats.mode & 0o111) !== 0 });
90
+ }
91
+ else {
92
+ return false;
93
+ }
94
+ }
95
+ return true;
96
+ };
97
+ try {
98
+ if (walk(""))
99
+ return { kind: "ok", files };
100
+ return offender !== null ? { kind: "untrusted", path: offender } : { kind: "unusable" };
101
+ }
102
+ catch {
103
+ return { kind: "unusable" };
104
+ }
105
+ }
106
+ /** Same file set, same bytes, same exec bit. */
107
+ export function sameTree(a, b) {
108
+ if (a.size !== b.size)
109
+ return false;
110
+ for (const [path, file] of a) {
111
+ const other = b.get(path);
112
+ if (!other || other.executable !== file.executable || !other.bytes.equals(file.bytes))
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ /** Symlinks followed before a path is refused as a loop (Linux's MAXSYMLINKS). */
118
+ export const MAX_SYMLINK_HOPS = 40;
119
+ const readOr = (path) => {
120
+ try {
121
+ return readFileSync(path, "utf8");
122
+ }
123
+ catch {
124
+ return null;
125
+ }
126
+ };
127
+ /**
128
+ * The user's PRIVATE group (USERGROUPS_ENAB: Ubuntu, Debian, Fedora, RHEL make `~/.local` 0775 owned
129
+ * `<user>:<user>`). Proven only from the local files, and only when BOTH say no one else is in it:
130
+ * * `/etc/group` has exactly one entry for the gid, named as the user, whose members are a subset
131
+ * of {the user} (some tools list the user in their own group);
132
+ * * `/etc/passwd` has exactly one account whose primary gid is that gid, and it is this user (name
133
+ * AND uid) — two accounts sharing a primary gid share its write bit (round 4).
134
+ * The gid must also be this process's primary gid. An account or group only in NSS/LDAP, a shared
135
+ * group (SUSE `users`) or an unreadable file proves nothing: refused.
136
+ */
137
+ export function isPrivateGroup(gid, options) {
138
+ const primary = options.gid ?? process.getgid?.();
139
+ let name = options.userName;
140
+ if (name === undefined) {
141
+ try {
142
+ name = userInfo().username;
143
+ }
144
+ catch {
145
+ return false;
146
+ }
147
+ }
148
+ const groupText = options.groupFile === undefined ? readOr("/etc/group") : options.groupFile;
149
+ const passwdText = options.passwdFile === undefined ? readOr("/etc/passwd") : options.passwdFile;
150
+ if (primary === undefined || gid !== primary || groupText === null || passwdText === null)
151
+ return false;
152
+ const rows = (text) => text.split("\n").map((line) => line.split(":"));
153
+ const groups = rows(groupText).filter((fields) => fields.length >= 4 && fields[2] !== "" && Number(fields[2]) === gid);
154
+ if (groups.length !== 1 || groups[0][0] !== name)
155
+ return false;
156
+ const members = groups[0][3].split(",").map((member) => member.trim()).filter(Boolean);
157
+ if (!members.every((member) => member === name))
158
+ return false;
159
+ const accounts = rows(passwdText).filter((fields) => fields.length >= 4 && fields[3] !== "" && Number(fields[3]) === gid);
160
+ return accounts.length === 1 && accounts[0][0] === name && Number(accounts[0][2]) === options.uid;
161
+ }
162
+ /**
163
+ * Round 4: ONE resolver, walking `dir` as the kernel does — from `/`, one component at a time. Every
164
+ * directory ENTERED is judged (the marketplace directory and its `agent-board` parent strictly; every
165
+ * other by the ancestor rules). A symlink component is judged by its own owner (its containing
166
+ * directory was judged when entered), and its `readlink` is spliced into the remaining components
167
+ * (absolute → from `/`, relative → from the current directory), so EVERY hop's containing
168
+ * directory is judged, however many hops there are. More than `MAX_SYMLINK_HOPS` links is a loop:
169
+ * refused. The walk stops at the first missing component: what setup will create. The marketplace
170
+ * directory and its parent are the last two components however the path is spliced, and neither may
171
+ * be a symlink. On win32 (no uid, no POSIX mode) only that symlink rule applies.
172
+ */
173
+ export function resolveTrustChain(dir, options) {
174
+ const windows = (options.platform ?? process.platform) === "win32";
175
+ const lstat = options.lstat ?? ((path) => lstatSync(path));
176
+ const readlink = options.readlink ?? ((path) => readlinkSync(path));
177
+ const target = resolve(dir);
178
+ const visited = [];
179
+ const done = (problem) => ({ problem, visited });
180
+ if (windows) {
181
+ for (const path of [dirname(target), target]) {
182
+ try {
183
+ if (lstat(path).isSymbolicLink())
184
+ return done({ path, cause: "symlink", tightenable: false });
185
+ }
186
+ catch {
187
+ break;
188
+ }
189
+ }
190
+ return done(null);
191
+ }
192
+ const ownedOrRoot = (stats) => stats.uid === options.uid || stats.uid === 0;
193
+ const judge = (path, stats, own) => {
194
+ const root = stats.uid === 0;
195
+ if (own ? stats.uid !== options.uid : !ownedOrRoot(stats))
196
+ return { path, cause: "foreign_owner", tightenable: false };
197
+ const worldWritable = (stats.mode & 0o002) !== 0;
198
+ const groupWritable = (stats.mode & 0o020) !== 0;
199
+ if (!worldWritable && !groupWritable)
200
+ return null;
201
+ if (!own && root && (stats.mode & 0o1000) !== 0)
202
+ return null; // /tmp
203
+ if (!own && !worldWritable && stats.uid === options.uid && isPrivateGroup(stats.gid, options))
204
+ return null;
205
+ return { path, cause: "writable", tightenable: own, command: `chmod ${worldWritable && groupWritable ? "go-w" : worldWritable ? "o-w" : "g-w"} ${path}` };
206
+ };
207
+ const enter = (path, role) => {
208
+ const stats = lstat(path);
209
+ if (!stats.isDirectory())
210
+ return { path, cause: "not_directory", tightenable: false };
211
+ const problem = judge(path, stats, role !== "ancestor");
212
+ if (problem)
213
+ return problem;
214
+ visited.push({ path, dev: stats.dev ?? -1, ino: stats.ino ?? -1, role });
215
+ return null;
216
+ };
217
+ const rootProblem = enter("/", "ancestor");
218
+ if (rootProblem)
219
+ return done(rootProblem);
220
+ let current = "/";
221
+ const queue = target.split("/").filter(Boolean);
222
+ let hops = 0;
223
+ while (queue.length > 0) {
224
+ const name = queue.shift();
225
+ // The tail is never spliced (a symlink there is refused), so the last two are always ours.
226
+ const role = queue.length === 0 ? "dir" : queue.length === 1 ? "parent" : "ancestor";
227
+ if (name === ".")
228
+ continue;
229
+ if (name === "..") {
230
+ current = dirname(current);
231
+ continue;
232
+ }
233
+ const path = current === "/" ? `/${name}` : `${current}/${name}`;
234
+ let link;
235
+ try {
236
+ link = lstat(path);
237
+ }
238
+ catch {
239
+ break; // missing from here down: what setup creates
240
+ }
241
+ if (link.isSymbolicLink()) {
242
+ if (role !== "ancestor")
243
+ return done({ path, cause: "symlink", tightenable: false });
244
+ if (!ownedOrRoot(link))
245
+ return done({ path, cause: "foreign_owner", tightenable: false });
246
+ if (++hops > MAX_SYMLINK_HOPS)
247
+ return done({ path, cause: "symlink_loop", tightenable: false });
248
+ let to;
249
+ try {
250
+ to = readlink(path);
251
+ }
252
+ catch {
253
+ return done({ path, cause: "not_directory", tightenable: false });
254
+ }
255
+ if (to.startsWith("/"))
256
+ current = "/";
257
+ queue.unshift(...to.split("/").filter(Boolean));
258
+ continue;
259
+ }
260
+ const problem = enter(path, role);
261
+ if (problem)
262
+ return done(problem);
263
+ current = path;
264
+ }
265
+ return done(null);
266
+ }
267
+ /** The first untrusted path, or null (see `resolveTrustChain`). */
268
+ export function checkTrustChain(dir, options) {
269
+ return resolveTrustChain(dir, options).problem;
270
+ }
271
+ /** The human words for a trust problem, for a `fix`. */
272
+ export function describeTrustProblem(problem) {
273
+ const words = {
274
+ symlink: "is a symlink",
275
+ not_directory: "is not a directory",
276
+ foreign_owner: "is owned by another user",
277
+ writable: "is writable by other users",
278
+ symlink_loop: `resolves through more than ${MAX_SYMLINK_HOPS} symlinks (a loop)`,
279
+ };
280
+ return `${problem.path} ${words[problem.cause]}${problem.command ? ` (run \`${problem.command}\`)` : ""}`;
281
+ }
282
+ /** The version in a marketplace tree's `plugins/agent-board/.claude-plugin/plugin.json`, or null. */
283
+ export function pluginVersionIn(files) {
284
+ const file = files.get("plugins/agent-board/.claude-plugin/plugin.json");
285
+ if (!file)
286
+ return null;
287
+ try {
288
+ const version = JSON.parse(file.bytes.toString("utf8"))?.version;
289
+ return typeof version === "string" ? version : null;
290
+ }
291
+ catch {
292
+ return null;
293
+ }
294
+ }
295
+ /** `x.y.z` numeric comparison (sign), or null when either side is not a plain release version. */
296
+ export function compareVersions(a, b) {
297
+ const parse = (value) => (value !== null && /^\d+\.\d+\.\d+$/.test(value) ? value.split(".").map(Number) : null);
298
+ const x = parse(a);
299
+ const y = parse(b);
300
+ if (x === null || y === null)
301
+ return null;
302
+ for (let i = 0; i < 3; i++)
303
+ if (x[i] !== y[i])
304
+ return Math.sign(x[i] - y[i]);
305
+ return 0;
306
+ }
package/dist/cli.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { Command } from "commander";
2
- import { type EnrollKeystore, type EnrollmentIntent, type FleetOnboardingIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike, type AgentJoinDeployment, type AgentJoinInstructions, type DeviceAuthorizationPrompt } from "@consiliency/agent-board-client";
2
+ import { createAgentBoardClient, type EnrollKeystore, type EnrollmentIntent, type FleetOnboardingIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike, type AgentJoinDeployment, type AgentJoinInstructions, type DeviceAuthorizationPrompt, resolveCredentials } from "@consiliency/agent-board-client";
3
3
  import { type DoctorOptions, type DoctorReport } from "./doctor.js";
4
+ import { type SetupEnroller, type SetupRunner } from "./setup.js";
4
5
  import { DevicePendingEnrollmentStore } from "./enroll_runtime.js";
5
6
  type JsonObject = Record<string, unknown>;
6
7
  export interface CliIo {
@@ -208,6 +209,11 @@ export interface DefaultEnrollmentRunnerDeps {
208
209
  sessionUrl?: string;
209
210
  /** Anon apikey for the post-enroll board RPCs. Default: resolveEnrollPublishableKey(env). */
210
211
  publishableKey?: string;
212
+ /**
213
+ * SETUP: the key reference the `key` step produced, handed over directly (never an env var the
214
+ * user must set). Wins over `MESSAGE_BOARD_ENROLL_KEY_REFERENCE`. Default: env.
215
+ */
216
+ keyReference?: string;
211
217
  /** Clock (tests). Default: wall clock. */
212
218
  now?: () => number;
213
219
  /**
@@ -224,6 +230,11 @@ export interface DefaultEnrollmentRunnerDeps {
224
230
  };
225
231
  }
226
232
  export declare function defaultEnrollmentRunner(env?: NodeJS.ProcessEnv, deps?: DefaultEnrollmentRunnerDeps): EnrollmentCommandRunner;
233
+ /**
234
+ * SETUP — the enrollment `agent-board setup` runs: this runner's device flow, built from setup's
235
+ * env with the `key` step's reference injected (never an env var the user must set).
236
+ */
237
+ export declare const setupEnroller: SetupEnroller;
227
238
  /**
228
239
  * How each field of a `FleetOnboardingIntent` is turned from an argv string into the value
229
240
  * that goes on the wire and into the template (Consiliency/message-board#254).
@@ -306,6 +317,29 @@ type FleetTupleField = StringFieldsOf<FleetOnboardingIntent>;
306
317
  export declare const FLEET_TUPLE_DISPOSITION: Readonly<Record<FleetTupleField, FleetTupleDisposition>>;
307
318
  /** `runDoctor`'s shape; a seam so the exit-code wiring is testable without a live board. */
308
319
  export type DoctorRunner = (options: DoctorOptions) => Promise<DoctorReport>;
309
- export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner): Command;
310
- export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner): Promise<void>;
320
+ /**
321
+ * SETUP IF-0-SETUP-1 — the global `--as <harness>`: which harness's identity FILE every command's
322
+ * credentials come from. The client is constructed before argv is parsed, so the choice is a
323
+ * holder the parse fills and the client's credential resolver reads at FIRST USE (see
324
+ * `fileAwareCredentials`). Unset, the resolver is today's env chain, unchanged.
325
+ */
326
+ export interface CredentialSelection {
327
+ as?: string;
328
+ }
329
+ /**
330
+ * The lazy credential source. With `--as <h>`: `<h>`'s identity file through the client's trusted
331
+ * open, and ONLY the file — `fileIdentityEnv` strips every ambient identity and generic name, as
332
+ * doctor does, because `--as` is an explicit choice of the file. The runtime descriptor is ignored
333
+ * for the same reason. Without it: `resolveCredentials(descriptor, env)`, exactly as before.
334
+ */
335
+ export declare function fileAwareCredentials(selection: CredentialSelection, env?: NodeJS.ProcessEnv): (descriptor?: Parameters<typeof resolveCredentials>[0]) => Promise<import("@consiliency/agent-board-client").ResolvedCredentials>;
336
+ export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner, selection?: CredentialSelection, setup?: SetupRunner): Command;
337
+ export interface RunCliOptions {
338
+ /** The env the default client's credential resolver reads. Default `process.env`. */
339
+ env?: NodeJS.ProcessEnv;
340
+ /** Extra options for the DEFAULT client (tests: a `dataSourceFactory`); never its resolver. */
341
+ clientOptions?: Omit<NonNullable<Parameters<typeof createAgentBoardClient>[0]>, "resolveCredentials">;
342
+ setup?: SetupRunner;
343
+ }
344
+ export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner, doctor?: DoctorRunner, options?: RunCliOptions): Promise<void>;
311
345
  export {};
package/dist/cli.js CHANGED
@@ -3,8 +3,9 @@ import { createHash, randomUUID } from "node:crypto";
3
3
  import * as nodeFs from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
- import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeFleetTupleValue, normalizeKeyReference, enrollCommitMarker, assertPresentationSafe, FLEET_TEMPLATE_FAILURE_MESSAGE, PROHIBITED_PRESENTATION_REMOVE_CLASS, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, AUTHORED_CAUSE_CODES, codeProvenance, markAuthoredCode, assertIdentityHarness, identityFilePath, resolveHostSegment, identityConfigRoot, walkTrustedDirectory, IDENTITY_HARNESSES, CredentialResolutionError, } from "@consiliency/agent-board-client";
7
- import { formatDoctorReport, runDoctor } from "./doctor.js";
6
+ import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, beginDeviceEnrollment, completeDeviceEnrollment, runEnrollment, runDeviceEnrollment, resolveEnrollmentDescriptor, EnrollmentDescriptorError, EnrollmentCommandError, resolveEnrollEndpoint, resolveEnrollSessionUrl, resolveEnrollPublishableKey, resolveJoinDeployment, buildFleetHarnessTemplate, finalizeFleetHarnessTemplate, normalizePublishableKeyFieldReference, normalizeFleetTupleValue, normalizeKeyReference, enrollCommitMarker, assertPresentationSafe, FLEET_TEMPLATE_FAILURE_MESSAGE, PROHIBITED_PRESENTATION_REMOVE_CLASS, buildAgentJoinInstructions, nodeFetchAdapter, buildEnrollKeystore, AUTHORED_CAUSE_CODES, codeProvenance, markAuthoredCode, assertIdentityHarness, identityFilePath, resolveHostSegment, identityConfigRoot, walkTrustedDirectory, IDENTITY_HARNESSES, CredentialResolutionError, readIdentityFile, resolveCredentials, } from "@consiliency/agent-board-client";
7
+ import { fileIdentityEnv, formatDoctorReport, runDoctor } from "./doctor.js";
8
+ import { addSetupCommand, runSetup } from "./setup.js";
8
9
  import { identityValuesFromEnrollment, resolveIdentityDeployment, writeIdentityFile, } from "./identity_writer.js";
9
10
  import { DevicePendingEnrollmentStore, FilePendingEnrollmentStore, FleetDeviceContinuationStore, makeBrowserOpener, prepareFleetRuntimeOutput, publishFinalizedFleetTemplate, } from "./enroll_runtime.js";
10
11
  function isRuntimeStatus(value) {
@@ -1628,11 +1629,15 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1628
1629
  const boardFetch = deps.boardFetch ?? nodeFetchAdapter;
1629
1630
  // Post-enroll board-op wiring (raw Supabase domain): explicit /session URL + anon
1630
1631
  // apikey. Optional — absent preserves the ${boardUrl}/session default + no apikey.
1631
- const sessionUrl = deps.sessionUrl ?? resolveEnrollSessionUrl(env);
1632
- const publishableKey = deps.publishableKey ?? resolveEnrollPublishableKey(env);
1632
+ // LAZY (SETUP): the runner is built before parsing, for every command, so constructing it
1633
+ // reads nothing from env; each flow method resolves these when enrollment actually runs.
1634
+ const sessionUrl = () => deps.sessionUrl ?? resolveEnrollSessionUrl(env);
1635
+ const publishableKey = () => deps.publishableKey ?? resolveEnrollPublishableKey(env);
1636
+ // SETUP: the `key` step's reference is injected, and wins over env.
1637
+ const descriptorOf = () => resolveEnrollmentDescriptor(env, deps.keyReference === undefined ? {} : { keyReference: deps.keyReference });
1633
1638
  return {
1634
1639
  async run(input) {
1635
- const descriptor = resolveEnrollmentDescriptor(env);
1640
+ const descriptor = descriptorOf();
1636
1641
  const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1637
1642
  const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env, {
1638
1643
  ...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
@@ -1649,14 +1654,14 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1649
1654
  keystore,
1650
1655
  boardFetch,
1651
1656
  keystoreBackends: keystore.backends,
1652
- sessionUrl,
1653
- publishableKey,
1657
+ sessionUrl: sessionUrl(),
1658
+ publishableKey: publishableKey(),
1654
1659
  openAuthorization,
1655
1660
  now: deps.now,
1656
1661
  });
1657
1662
  },
1658
1663
  async device(input) {
1659
- const descriptor = resolveEnrollmentDescriptor(env);
1664
+ const descriptor = descriptorOf();
1660
1665
  // Fail CLEAN and actionable when the host descriptor carries no device
1661
1666
  // endpoint — otherwise the shared flow throws a bare `no_device_endpoint`.
1662
1667
  // The endpoint is optional in resolveEnrollmentDescriptor (it is not required
@@ -1684,15 +1689,15 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1684
1689
  keystore,
1685
1690
  boardFetch,
1686
1691
  keystoreBackends: keystore.backends,
1687
- sessionUrl,
1688
- publishableKey,
1692
+ sessionUrl: sessionUrl(),
1693
+ publishableKey: publishableKey(),
1689
1694
  onDeviceAuthorization: input.onDeviceAuthorization,
1690
1695
  sleep: deps.sleep,
1691
1696
  now: deps.now,
1692
1697
  });
1693
1698
  },
1694
1699
  async deviceBegin(input) {
1695
- const descriptor = resolveEnrollmentDescriptor(env);
1700
+ const descriptor = descriptorOf();
1696
1701
  if (!descriptor.issuer.deviceAuthorizationEndpoint) {
1697
1702
  throw new EnrollmentDescriptorError("missing_device_endpoint", "device enrollment needs a device authorization endpoint (set MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
1698
1703
  }
@@ -1703,8 +1708,8 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1703
1708
  ...(input.intent.kind === "fleet_onboarding" ? { fleetOnboarding: input.intent } : {}),
1704
1709
  }),
1705
1710
  boardFetch,
1706
- sessionUrl,
1707
- publishableKey,
1711
+ sessionUrl: sessionUrl(),
1712
+ publishableKey: publishableKey(),
1708
1713
  onDeviceAuthorization: async () => { },
1709
1714
  now: deps.now,
1710
1715
  });
@@ -1776,12 +1781,12 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1776
1781
  if (pending.slug !== input.expectedFleet.slug) {
1777
1782
  throw new EnrollmentCommandError("fleet_device_slug_mismatch", "device enrollment agent slug does not match");
1778
1783
  }
1779
- const currentReference = normalizeKeyReference(resolveEnrollmentDescriptor(env).keyReference);
1784
+ const currentReference = normalizeKeyReference(descriptorOf().keyReference);
1780
1785
  if (`${currentReference.resolver}:${currentReference.handle}` !== input.expectedFleet.keyReference) {
1781
1786
  throw new EnrollmentCommandError("fleet_device_key_reference_mismatch", "device enrollment key reference does not match current configuration");
1782
1787
  }
1783
1788
  }
1784
- const descriptor = resolveEnrollmentDescriptor(env);
1789
+ const descriptor = descriptorOf();
1785
1790
  if (!descriptor.issuer.deviceAuthorizationEndpoint) {
1786
1791
  throw new EnrollmentDescriptorError("missing_device_endpoint", "device enrollment needs a device authorization endpoint (set MESSAGE_BOARD_ENROLL_DEVICE_AUTHORIZATION_ENDPOINT)");
1787
1792
  }
@@ -1809,8 +1814,8 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1809
1814
  keystore,
1810
1815
  boardFetch,
1811
1816
  keystoreBackends: keystore.backends,
1812
- sessionUrl,
1813
- publishableKey,
1817
+ sessionUrl: sessionUrl(),
1818
+ publishableKey: publishableKey(),
1814
1819
  onDeviceAuthorization: async () => { },
1815
1820
  sleep: deps.sleep,
1816
1821
  now: deps.now,
@@ -1846,7 +1851,7 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1846
1851
  async begin(input) {
1847
1852
  // Force OOB: a loopback receiver cannot bridge two separate CLI processes, so
1848
1853
  // the two-step path is always out-of-band paste (no redirectUri ⇒ OOB).
1849
- const descriptor = resolveEnrollmentDescriptor(env);
1854
+ const descriptor = descriptorOf();
1850
1855
  const store = deps.store ?? new FilePendingEnrollmentStore(env);
1851
1856
  return beginEnrollment({
1852
1857
  descriptor,
@@ -1861,13 +1866,13 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1861
1866
  clientId: descriptor.issuer.clientId,
1862
1867
  // #173 — pass the provisioning config so begin refuses a doomed raw-Supabase
1863
1868
  // enrollment BEFORE the human is asked to approve anything.
1864
- sessionUrl,
1865
- publishableKey,
1869
+ sessionUrl: sessionUrl(),
1870
+ publishableKey: publishableKey(),
1866
1871
  now: deps.now,
1867
1872
  });
1868
1873
  },
1869
1874
  async complete(input) {
1870
- const descriptor = resolveEnrollmentDescriptor(env);
1875
+ const descriptor = descriptorOf();
1871
1876
  const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1872
1877
  const store = deps.store ?? new FilePendingEnrollmentStore(env);
1873
1878
  const pending = await store.get(input.enrollmentId);
@@ -1885,13 +1890,21 @@ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1885
1890
  keystore,
1886
1891
  boardFetch,
1887
1892
  keystoreBackends: keystore.backends,
1888
- sessionUrl,
1889
- publishableKey,
1893
+ sessionUrl: sessionUrl(),
1894
+ publishableKey: publishableKey(),
1890
1895
  now: deps.now,
1891
1896
  });
1892
1897
  },
1893
1898
  };
1894
1899
  }
1900
+ /**
1901
+ * SETUP — the enrollment `agent-board setup` runs: this runner's device flow, built from setup's
1902
+ * env with the `key` step's reference injected (never an env var the user must set).
1903
+ */
1904
+ export const setupEnroller = async (input) => {
1905
+ const runner = defaultEnrollmentRunner(input.env, { keyReference: input.keyReference });
1906
+ return (await runner.device({ slug: input.slug, intent: input.intent, onDeviceAuthorization: input.onDeviceAuthorization }));
1907
+ };
1895
1908
  function intentFromOptions(options) {
1896
1909
  if (options.board === false) {
1897
1910
  return { kind: "none" };
@@ -3199,11 +3212,33 @@ function addDoctorCommand(program, io, doctor) {
3199
3212
  process.exitCode = report.exit;
3200
3213
  });
3201
3214
  }
3202
- export function createProgram(client, io, enrollment = defaultEnrollmentRunner(), doctor = runDoctor) {
3215
+ /**
3216
+ * The lazy credential source. With `--as <h>`: `<h>`'s identity file through the client's trusted
3217
+ * open, and ONLY the file — `fileIdentityEnv` strips every ambient identity and generic name, as
3218
+ * doctor does, because `--as` is an explicit choice of the file. The runtime descriptor is ignored
3219
+ * for the same reason. Without it: `resolveCredentials(descriptor, env)`, exactly as before.
3220
+ */
3221
+ export function fileAwareCredentials(selection, env = process.env) {
3222
+ return async (descriptor) => {
3223
+ const harness = selection.as;
3224
+ if (harness === undefined)
3225
+ return resolveCredentials(descriptor, env);
3226
+ assertIdentityHarness(harness);
3227
+ const { values } = await readIdentityFile({ harness, env });
3228
+ return resolveCredentials(undefined, fileIdentityEnv(env, values));
3229
+ };
3230
+ }
3231
+ export function createProgram(client, io, enrollment = defaultEnrollmentRunner(), doctor = runDoctor, selection = {}, setup) {
3203
3232
  const program = new Command()
3204
3233
  .name("agent-board")
3205
3234
  .description("Thin CLI surface for the agent_board RPC and canonical read contracts")
3235
+ .option("--as <harness>", "take credentials from this harness's identity file (agy, claude, codex, grok, opencode, pi), ignoring any identity in the environment")
3206
3236
  .addHelpText("after", "\nAuth: user-scoped agent sessions are the default. Service-role execution is reserved for callback workers and vetted operational scripts through explicit client configuration, not the normal agent CLI path. Signing configuration is inherited from metadata-safe descriptors; raw private-key arguments are not accepted.\n");
3237
+ // Filled at parse time, read by the client's resolver at first use.
3238
+ program.hook("preAction", () => {
3239
+ const chosen = program.opts().as;
3240
+ selection.as = chosen;
3241
+ });
3207
3242
  program
3208
3243
  .command("signing-status")
3209
3244
  .description("Print metadata-safe signing descriptor status; raw private-key arguments are not accepted")
@@ -3224,12 +3259,19 @@ export function createProgram(client, io, enrollment = defaultEnrollmentRunner()
3224
3259
  addInviteCommands(program, client, io);
3225
3260
  addEnrollCommands(program, io, enrollment);
3226
3261
  addDoctorCommand(program, io, doctor);
3262
+ addSetupCommand(program, io, setup ?? ((options) => runSetup(options, { enroll: setupEnroller })));
3227
3263
  return program;
3228
3264
  }
3229
3265
  export async function runCli(argv, io = {
3230
3266
  writeStdout: (message) => process.stdout.write(message),
3231
3267
  writeStderr: (message) => process.stderr.write(message),
3232
- }, client = createAgentBoardClient(), enrollment = defaultEnrollmentRunner(), doctor = runDoctor) {
3233
- const program = createProgram(client, io, enrollment, doctor);
3268
+ }, client, enrollment = defaultEnrollmentRunner(), doctor = runDoctor, options = {}) {
3269
+ const selection = {};
3270
+ // Built BEFORE parsing, as always; `--as` reaches it through `selection` at first use.
3271
+ const resolved = client ?? createAgentBoardClient({
3272
+ ...options.clientOptions,
3273
+ resolveCredentials: fileAwareCredentials(selection, options.env ?? process.env),
3274
+ });
3275
+ const program = createProgram(resolved, io, enrollment, doctor, selection, options.setup);
3234
3276
  await program.parseAsync(argv, { from: "user" });
3235
3277
  }
@@ -0,0 +1,37 @@
1
+ /** What an edit found and produced. `text` equals the input when `prior` is `exact`. */
2
+ export interface ConfigEdit {
3
+ prior: "absent" | "exact" | "differs";
4
+ text: string;
5
+ }
6
+ /** A refusal: the reason is an authored code, never file content. */
7
+ export declare class ConfigEditRefused extends Error {
8
+ readonly reason: "config_unparseable" | "config_layout_unrecognised" | "config_not_an_object" | "config_inline_table" | "config_array_table";
9
+ constructor(reason: "config_unparseable" | "config_layout_unrecognised" | "config_not_an_object" | "config_inline_table" | "config_array_table");
10
+ }
11
+ /** A value an entry may carry: what setup writes, plus whatever a user kept in the entry. */
12
+ export type ConfigValue = string | number | boolean | readonly ConfigValue[] | {
13
+ readonly [key: string]: ConfigValue;
14
+ };
15
+ export type ConfigEntry = {
16
+ readonly [key: string]: ConfigValue;
17
+ };
18
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
19
+ /** The block setup writes for `path` (a table header plus one line per key, in the entry's order). */
20
+ export declare function renderTomlTable(path: readonly string[], entry: ConfigEntry): string;
21
+ /**
22
+ * Put `entry` at the table `path` (e.g. `["mcp_servers", "agent-board"]`) of a TOML document.
23
+ * Absent → appended as its own block; equal → the text unchanged; different → the table's own
24
+ * block(s) (its header, its sub-tables' headers, and their keys) replaced in place by the rendered
25
+ * block. Keys defined for it any other way (a dotted key under a parent table, an inline table)
26
+ * are a layout this edit does not own: refused, never appended beside.
27
+ */
28
+ export declare function upsertTomlTable(text: string, path: readonly string[], entry: ConfigEntry): ConfigEdit;
29
+ /**
30
+ * Put `entry` at `path` of a JSON/JSONC object document. Empty text is an empty object. Missing
31
+ * intermediate objects are created; an existing member is replaced in its span; a new member is
32
+ * inserted before its object's closing brace. `strict` also requires the input to be plain JSON,
33
+ * for a harness whose reader is strict (an edit never makes a file unreadable to its harness).
34
+ */
35
+ export declare function upsertJsonMember(text: string, path: readonly string[], entry: ConfigEntry, options?: {
36
+ strict?: boolean;
37
+ }): ConfigEdit;