@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.
- package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
- package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
- package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
- package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
- package/dist/assets.d.ts +26 -0
- package/dist/assets.js +70 -0
- package/dist/assets.json +6 -0
- package/dist/claude_marketplace.d.ts +117 -0
- package/dist/claude_marketplace.js +306 -0
- package/dist/cli.d.ts +37 -3
- package/dist/cli.js +68 -26
- package/dist/config_edit.d.ts +37 -0
- package/dist/config_edit.js +549 -0
- package/dist/doctor.d.ts +24 -2
- package/dist/doctor.js +164 -4
- package/dist/doctor_scopes.d.ts +34 -2
- package/dist/doctor_scopes.js +128 -20
- package/dist/identity_writer.d.ts +9 -7
- package/dist/identity_writer.js +18 -30
- package/dist/setup.d.ts +116 -0
- package/dist/setup.js +603 -0
- package/dist/setup_storage.d.ts +117 -0
- package/dist/setup_storage.js +447 -0
- package/dist/setup_writers.d.ts +147 -0
- package/dist/setup_writers.js +916 -0
- package/keyslot/agent-board-keyslot-load@.service +20 -0
- package/keyslot/agent-board-keyslot@.service +20 -0
- package/keyslot/constants.sh +73 -0
- package/keyslot/keygen.mjs +67 -0
- package/keyslot/keyslot-load.sh +78 -0
- package/keyslot/lib.sh +599 -0
- package/keyslot/optin.sh +185 -0
- package/keyslot/probe.sh +135 -0
- package/keyslot/provision.sh +530 -0
- package/keyslot/seam.sh +48 -0
- package/keyslot/status.sh +254 -0
- 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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
3233
|
-
const
|
|
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;
|