@consiliency/agent-board-cli 1.9.0 → 1.10.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.
@@ -1,8 +1,74 @@
1
- import { type PendingEnrollment, type PendingEnrollmentStore } from "@consiliency/agent-board-client";
1
+ import type { EnrollmentOutcome, FinalizedFleetHarnessTemplate, PendingFleetHarnessTemplate } from "@consiliency/agent-board-client";
2
+ import { type EnrollmentIntent, type PendingEnrollment, type PendingEnrollmentStore } from "@consiliency/agent-board-client";
2
3
  export declare class EnrollRuntimeError extends Error {
3
4
  code: string;
4
5
  constructor(code: string, message: string);
5
6
  }
7
+ /** Versioned, non-secret continuation for an interrupted fleet device flow. */
8
+ export interface FleetDeviceContinuation {
9
+ version: "fleet_device_continuation.v1";
10
+ enrollmentId: string;
11
+ runtimeOutput: string;
12
+ publishableKeyFieldReference: string;
13
+ keyReference: string;
14
+ pendingTemplate: PendingFleetHarnessTemplate;
15
+ /** Persisted after the server transaction so local publication can retry without polling again. */
16
+ outcome?: EnrollmentOutcome;
17
+ }
18
+ /** Secret-bearing RFC 8628 state. This is never rendered by the CLI. */
19
+ export interface DevicePendingEnrollment {
20
+ flow: "device_code";
21
+ enrollmentId: string;
22
+ deviceCode: string;
23
+ interval: number;
24
+ expiresAt?: number;
25
+ expiresIn?: number;
26
+ userCode: string;
27
+ verificationUri: string;
28
+ verificationUriComplete?: string;
29
+ intent: EnrollmentIntent;
30
+ slug: string;
31
+ agentKind?: string;
32
+ requestedScopes?: string[];
33
+ requestedBoardScopes?: string[];
34
+ }
35
+ export declare class FleetDeviceContinuationStore {
36
+ private readonly dir;
37
+ constructor(dir: string);
38
+ private path;
39
+ private guardDir;
40
+ put(value: FleetDeviceContinuation): Promise<void>;
41
+ get(id: string): Promise<FleetDeviceContinuation | undefined>;
42
+ delete(id: string): Promise<void>;
43
+ }
44
+ export declare class DevicePendingEnrollmentStore {
45
+ private readonly dir;
46
+ constructor(dir: string);
47
+ private path;
48
+ private guardDir;
49
+ put(value: DevicePendingEnrollment): Promise<void>;
50
+ get(id: string): Promise<DevicePendingEnrollment | undefined>;
51
+ delete(id: string): Promise<void>;
52
+ }
53
+ export interface FleetRuntimeFilesystemOptions {
54
+ syncDirectory?: (path: string) => Promise<void>;
55
+ }
56
+ /** Prepare and durably validate the local destination before bounded enrollment. */
57
+ export declare function prepareFleetRuntimeOutput(root: string, options?: FleetRuntimeFilesystemOptions): Promise<void>;
58
+ /**
59
+ * Publish a completed fleet template as one generation. The stable `current`
60
+ * link is replaced only after both owner-only files have been fsync'd and the
61
+ * generation directory has been renamed into place.
62
+ */
63
+ export declare function publishFinalizedFleetTemplate(root: string, template: FinalizedFleetHarnessTemplate, options?: FleetRuntimeFilesystemOptions): Promise<{
64
+ generation: string;
65
+ digest: string;
66
+ }>;
67
+ /** Validate the complete batch before the first filesystem mutation. */
68
+ export declare function publishFinalizedFleetTemplates(root: string, templates: readonly FinalizedFleetHarnessTemplate[]): Promise<readonly {
69
+ generation: string;
70
+ digest: string;
71
+ }[]>;
6
72
  export interface FilePendingEnrollmentStoreOptions {
7
73
  /** Override the state directory (default: $XDG_STATE_HOME/agent-board/pending-enroll). */
8
74
  dir?: string;
@@ -7,7 +7,8 @@ import { spawn } from "node:child_process";
7
7
  import { constants as FS } from "node:fs";
8
8
  import * as fs from "node:fs/promises";
9
9
  import { homedir } from "node:os";
10
- import { join } from "node:path";
10
+ import { dirname, join } from "node:path";
11
+ import { validateFinalizedFleetHarnessTemplate } from "@consiliency/agent-board-client";
11
12
  import { resolveEnrollmentDescriptor, } from "@consiliency/agent-board-client";
12
13
  export class EnrollRuntimeError extends Error {
13
14
  code;
@@ -17,6 +18,261 @@ export class EnrollRuntimeError extends Error {
17
18
  this.code = code;
18
19
  }
19
20
  }
21
+ export class FleetDeviceContinuationStore {
22
+ dir;
23
+ constructor(dir) {
24
+ this.dir = dir;
25
+ }
26
+ path(id) {
27
+ if (!/^enr-[A-Za-z0-9-]+$/.test(id))
28
+ throw new EnrollRuntimeError("invalid_fleet_continuation_id", "fleet continuation id is invalid");
29
+ return join(this.dir, `${id}.fleet.json`);
30
+ }
31
+ async guardDir() {
32
+ const st = await fs.lstat(this.dir);
33
+ if (!st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (typeof process.getuid === "function" && st.uid !== process.getuid()))
34
+ throw new EnrollRuntimeError("insecure_fleet_continuation", "fleet continuation directory is unsafe");
35
+ }
36
+ async put(value) {
37
+ if (value.version !== "fleet_device_continuation.v1" || !value.enrollmentId || !value.runtimeOutput || !value.keyReference)
38
+ throw new EnrollRuntimeError("invalid_fleet_continuation", "fleet continuation is incomplete");
39
+ await fs.mkdir(this.dir, { recursive: true, mode: 0o700 });
40
+ await this.guardDir();
41
+ const target = this.path(value.enrollmentId);
42
+ const temp = `${target}.tmp-${process.pid}-${Date.now()}`;
43
+ const handle = await fs.open(temp, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
44
+ try {
45
+ await handle.writeFile(JSON.stringify(value), "utf8");
46
+ await handle.sync();
47
+ }
48
+ finally {
49
+ await handle.close();
50
+ }
51
+ await fs.rename(temp, target);
52
+ }
53
+ async get(id) {
54
+ try {
55
+ await this.guardDir();
56
+ const path = this.path(id);
57
+ const handle = await fs.open(path, FS.O_RDONLY | FS.O_NOFOLLOW);
58
+ let raw;
59
+ try {
60
+ const stat = await handle.stat();
61
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid()))
62
+ throw new EnrollRuntimeError("insecure_fleet_continuation", "fleet continuation must be owner-only regular file");
63
+ raw = await handle.readFile("utf8");
64
+ }
65
+ finally {
66
+ await handle.close();
67
+ }
68
+ let value;
69
+ try {
70
+ value = JSON.parse(raw);
71
+ }
72
+ catch {
73
+ throw new EnrollRuntimeError("invalid_fleet_continuation", "fleet continuation is corrupt");
74
+ }
75
+ if (value.version !== "fleet_device_continuation.v1" || value.enrollmentId !== id || !value.runtimeOutput || !value.keyReference || value.pendingTemplate?.templateContractVersion !== "fleet_harness_template.pending.v1")
76
+ throw new EnrollRuntimeError("invalid_fleet_continuation", "fleet continuation is corrupt");
77
+ return value;
78
+ }
79
+ catch (error) {
80
+ if (error.code === "ENOENT")
81
+ return undefined;
82
+ throw error;
83
+ }
84
+ }
85
+ async delete(id) { await fs.unlink(this.path(id)).catch((error) => { if (error.code !== "ENOENT")
86
+ throw error; }); }
87
+ }
88
+ export class DevicePendingEnrollmentStore {
89
+ dir;
90
+ constructor(dir) {
91
+ this.dir = dir;
92
+ }
93
+ path(id) {
94
+ if (!/^enr-[A-Za-z0-9-]+$/.test(id))
95
+ throw new EnrollRuntimeError("invalid_device_enrollment_id", "device enrollment id is invalid");
96
+ return join(this.dir, `${id}.device.json`);
97
+ }
98
+ async guardDir() {
99
+ const stat = await fs.lstat(this.dir);
100
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
101
+ throw new EnrollRuntimeError("insecure_device_pending", "device pending directory must be owner-only");
102
+ }
103
+ }
104
+ async put(value) {
105
+ const target = this.path(value.enrollmentId);
106
+ if (value.flow !== "device_code" || !value.deviceCode || /[\r\n\0]/.test(value.deviceCode) || !Number.isFinite(value.interval) || value.interval <= 0 || !value.slug || !value.intent)
107
+ throw new EnrollRuntimeError("invalid_device_pending", "device pending record is invalid");
108
+ await fs.mkdir(this.dir, { recursive: true, mode: 0o700 });
109
+ await this.guardDir();
110
+ const tmp = `${target}.tmp-${process.pid}-${Date.now()}`;
111
+ const handle = await fs.open(tmp, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
112
+ try {
113
+ await handle.writeFile(JSON.stringify(value), "utf8");
114
+ await handle.sync();
115
+ }
116
+ finally {
117
+ await handle.close();
118
+ }
119
+ await fs.rename(tmp, target);
120
+ }
121
+ async get(id) {
122
+ const path = this.path(id);
123
+ try {
124
+ await this.guardDir();
125
+ const handle = await fs.open(path, FS.O_RDONLY | FS.O_NOFOLLOW);
126
+ let raw;
127
+ try {
128
+ const stat = await handle.stat();
129
+ if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o077) !== 0 || (typeof process.getuid === "function" && stat.uid !== process.getuid()))
130
+ throw new EnrollRuntimeError("insecure_device_pending", "device pending record is unsafe");
131
+ raw = await handle.readFile("utf8");
132
+ }
133
+ finally {
134
+ await handle.close();
135
+ }
136
+ let value;
137
+ try {
138
+ value = JSON.parse(raw);
139
+ }
140
+ catch {
141
+ throw new EnrollRuntimeError("invalid_device_pending", "device pending record is corrupt or expired");
142
+ }
143
+ if (value.flow !== "device_code" || value.enrollmentId !== id || !value.deviceCode || !value.slug || !value.intent || (value.expiresAt !== undefined && value.expiresAt <= Math.floor(Date.now() / 1000)))
144
+ throw new EnrollRuntimeError("invalid_device_pending", "device pending record is corrupt or expired");
145
+ return value;
146
+ }
147
+ catch (error) {
148
+ if (error.code === "ENOENT")
149
+ return undefined;
150
+ throw error;
151
+ }
152
+ }
153
+ async delete(id) { await fs.unlink(this.path(id)).catch((error) => { if (error.code !== "ENOENT")
154
+ throw error; }); }
155
+ }
156
+ async function syncDirectory(path) {
157
+ const handle = await fs.open(path, "r");
158
+ try {
159
+ await handle.sync();
160
+ }
161
+ finally {
162
+ await handle.close();
163
+ }
164
+ }
165
+ async function requireOwnerOnlyDirectory(path) {
166
+ const stat = await fs.lstat(path);
167
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0
168
+ || (typeof process.getuid === "function" && stat.uid !== process.getuid())) {
169
+ throw new EnrollRuntimeError("invalid_runtime_output", "runtime output must be an owner-only directory");
170
+ }
171
+ }
172
+ /** Prepare and durably validate the local destination before bounded enrollment. */
173
+ export async function prepareFleetRuntimeOutput(root, options = {}) {
174
+ if (!root || root.includes("\0"))
175
+ throw new EnrollRuntimeError("invalid_runtime_output", "runtime output path is invalid");
176
+ const sync = options.syncDirectory ?? syncDirectory;
177
+ await fs.mkdir(root, { recursive: true, mode: 0o700 }).catch((error) => {
178
+ if (error.code !== "EEXIST")
179
+ throw error;
180
+ });
181
+ await requireOwnerOnlyDirectory(root);
182
+ await sync(dirname(root));
183
+ await sync(root);
184
+ const generationRoot = join(root, "generations");
185
+ await fs.mkdir(generationRoot, { mode: 0o700 }).catch((error) => {
186
+ if (error.code !== "EEXIST")
187
+ throw error;
188
+ });
189
+ await requireOwnerOnlyDirectory(generationRoot);
190
+ await sync(root);
191
+ await sync(generationRoot);
192
+ }
193
+ /**
194
+ * Publish a completed fleet template as one generation. The stable `current`
195
+ * link is replaced only after both owner-only files have been fsync'd and the
196
+ * generation directory has been renamed into place.
197
+ */
198
+ export async function publishFinalizedFleetTemplate(root, template, options = {}) {
199
+ validateFinalizedFleetHarnessTemplate(template);
200
+ const sync = options.syncDirectory ?? syncDirectory;
201
+ await prepareFleetRuntimeOutput(root, { syncDirectory: sync });
202
+ const generationRoot = join(root, "generations");
203
+ const generation = join(generationRoot, template.receiptDigest);
204
+ const runtimeEnv = Object.entries(template.runtimeEnv).map(([key, value]) => `${key}=${JSON.stringify(value)}`).join("\n") + "\n";
205
+ const pmcp = JSON.stringify(template.pmcpStanza) + "\n";
206
+ const switchCurrent = async () => {
207
+ const next = join(root, `.current-${process.pid}-${Date.now()}`);
208
+ await fs.symlink(join("generations", template.receiptDigest), next);
209
+ try {
210
+ await sync(root);
211
+ await fs.rename(next, join(root, "current"));
212
+ await sync(root);
213
+ }
214
+ catch (error) {
215
+ await fs.unlink(next).catch(() => { });
216
+ throw error;
217
+ }
218
+ };
219
+ try {
220
+ const stat = await fs.lstat(generation);
221
+ if (!stat.isDirectory() || stat.isSymbolicLink())
222
+ throw new EnrollRuntimeError("runtime_generation_exists", "runtime generation is unsafe");
223
+ if (await fs.readFile(join(generation, "runtime.env"), "utf8") !== runtimeEnv || await fs.readFile(join(generation, "pmcp.json"), "utf8") !== pmcp)
224
+ throw new EnrollRuntimeError("runtime_generation_exists", "runtime generation already exists with different content");
225
+ await switchCurrent();
226
+ return { generation, digest: template.receiptDigest };
227
+ }
228
+ catch (error) {
229
+ if (error.code !== "ENOENT")
230
+ throw error;
231
+ }
232
+ const staged = join(generationRoot, `.${template.receiptDigest}.staged-${process.pid}-${Date.now()}`);
233
+ await fs.mkdir(staged, { mode: 0o700 });
234
+ try {
235
+ const write = async (name, content) => {
236
+ const path = join(staged, name);
237
+ const handle = await fs.open(path, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
238
+ try {
239
+ await handle.writeFile(content, "utf8");
240
+ await handle.sync();
241
+ }
242
+ finally {
243
+ await handle.close();
244
+ }
245
+ };
246
+ await write("runtime.env", runtimeEnv);
247
+ await write("pmcp.json", pmcp);
248
+ await sync(staged);
249
+ await fs.rename(staged, generation);
250
+ await sync(generationRoot);
251
+ await switchCurrent();
252
+ return { generation, digest: template.receiptDigest };
253
+ }
254
+ catch (error) {
255
+ await fs.rm(staged, { recursive: true, force: true }).catch(() => { });
256
+ throw error;
257
+ }
258
+ }
259
+ /** Validate the complete batch before the first filesystem mutation. */
260
+ export async function publishFinalizedFleetTemplates(root, templates) {
261
+ if (templates.length === 0)
262
+ throw new EnrollRuntimeError("invalid_runtime_batch", "runtime template batch is empty");
263
+ for (const template of templates)
264
+ validateFinalizedFleetHarnessTemplate(template);
265
+ const stanzaNames = templates.map((template) => template.stanzaName);
266
+ if (new Set(stanzaNames).size !== stanzaNames.length)
267
+ throw new EnrollRuntimeError("runtime_batch_collision", "runtime template batch has duplicate stanza names");
268
+ const environments = templates.map((template) => JSON.stringify(template.runtimeEnv));
269
+ if (new Set(environments).size !== environments.length)
270
+ throw new EnrollRuntimeError("runtime_batch_collision", "runtime template batch has duplicate environments");
271
+ const results = [];
272
+ for (const template of templates)
273
+ results.push(await publishFinalizedFleetTemplate(root, template));
274
+ return results;
275
+ }
20
276
  function nowSeconds(now) {
21
277
  return now ? now() : Math.floor(Date.now() / 1000);
22
278
  }
@@ -86,6 +342,7 @@ export class FilePendingEnrollmentStore {
86
342
  redirectMode: pending.redirectMode,
87
343
  clientId: pending.clientId,
88
344
  expiresAt: pending.expiresAt,
345
+ deviceCode: pending.deviceCode,
89
346
  };
90
347
  const finalPath = this.fileFor(pending.enrollmentId);
91
348
  const tempPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
@@ -165,6 +422,7 @@ export class FilePendingEnrollmentStore {
165
422
  redirectMode: parsed.redirectMode ?? "oob",
166
423
  clientId: parsed.clientId,
167
424
  expiresAt: parsed.expiresAt,
425
+ deviceCode: parsed.deviceCode,
168
426
  };
169
427
  }
170
428
  async delete(enrollmentId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-cli",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "repository": "https://github.com/Consiliency/message-board.git",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -31,7 +31,7 @@
31
31
  "registry": "https://registry.npmjs.org"
32
32
  },
33
33
  "dependencies": {
34
- "@consiliency/agent-board-client": "1.15.0",
34
+ "@consiliency/agent-board-client": "1.16.0",
35
35
  "commander": "^14.0.1"
36
36
  },
37
37
  "license": "Apache-2.0"