@consiliency/agent-board-cli 1.3.0 → 1.5.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/dist/cli.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Command } from "commander";
2
- import { type EnrollmentIntent, type PendingEnrollmentStore } from "@consiliency/agent-board-client";
2
+ import { type EnrollKeystore, type EnrollmentIntent, type PendingEnrollmentStore, type OAuthFetchLike, type FetchLike } from "@consiliency/agent-board-client";
3
3
  type JsonObject = Record<string, unknown>;
4
4
  export interface CliIo {
5
5
  writeStdout(message: string): void;
@@ -114,6 +114,7 @@ export interface EnrollmentCommandRunner {
114
114
  intent: EnrollmentIntent;
115
115
  agentKind?: string;
116
116
  requestedScopes?: string[];
117
+ requestedBoardScopes?: string[];
117
118
  oob?: boolean;
118
119
  }): Promise<unknown>;
119
120
  complete(input: {
@@ -123,7 +124,28 @@ export interface EnrollmentCommandRunner {
123
124
  slug?: string;
124
125
  }): Promise<unknown>;
125
126
  }
126
- export declare function defaultEnrollmentRunner(env?: NodeJS.ProcessEnv, store?: PendingEnrollmentStore): EnrollmentCommandRunner;
127
+ export interface DefaultEnrollmentRunnerDeps {
128
+ /** Pending store bridging begin→complete. Default: FilePendingEnrollmentStore(env). */
129
+ store?: PendingEnrollmentStore;
130
+ /** OAuth/enroll fetch. Default: nodeFetchAdapter over globalThis.fetch. */
131
+ fetch?: OAuthFetchLike;
132
+ /** Board-native fetch for the post-enroll SessionClient. Default: nodeFetchAdapter. */
133
+ boardFetch?: FetchLike;
134
+ /** Composed EnrollKeystore. Default: buildEnrollKeystore(descriptor, env) (os_keychain). */
135
+ keystore?: EnrollKeystore;
136
+ /** Browser opener for the one-shot loopback flow. Default: makeBrowserOpener. */
137
+ openAuthorization?: (authorizationUrl: string) => void | Promise<void>;
138
+ /** The deployed /enroll URL. Default: resolveEnrollEndpoint(env). */
139
+ enrollEndpoint?: string;
140
+ /** Clock (tests). Default: wall clock. */
141
+ now?: () => number;
142
+ /** Writer for the default browser opener's printed URL. Default: process std streams. */
143
+ io?: {
144
+ writeStdout(message: string): void;
145
+ writeStderr(message: string): void;
146
+ };
147
+ }
148
+ export declare function defaultEnrollmentRunner(env?: NodeJS.ProcessEnv, deps?: DefaultEnrollmentRunnerDeps): EnrollmentCommandRunner;
127
149
  export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner): Command;
128
150
  export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner): Promise<void>;
129
151
  export {};
package/dist/cli.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Command, Option } from "commander";
2
- import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, runEnrollment, resolveEnrollmentDescriptor, InMemoryPendingEnrollmentStore, } from "@consiliency/agent-board-client";
2
+ import { createAgentBoardClient, toOperationFailure, beginEnrollment, completeEnrollment, runEnrollment, resolveEnrollmentDescriptor, resolveEnrollEndpoint, nodeFetchAdapter, buildEnrollKeystore, } from "@consiliency/agent-board-client";
3
+ import { FilePendingEnrollmentStore, makeBrowserOpener } from "./enroll_runtime.js";
3
4
  function isRuntimeStatus(value) {
4
5
  return Boolean(value && typeof value === "object" && "runtime" in value);
5
6
  }
@@ -1207,33 +1208,71 @@ function addReadCommands(program, client, io) {
1207
1208
  limit: options.limit,
1208
1209
  })));
1209
1210
  }
1210
- // The default runner resolves the env descriptor (proving configuration) and then
1211
- // requires a host-provided keystore integration. Live enrollment (generating the
1212
- // agent key in an OS keychain/TPM/HSM and opening the browser) is gated on the
1213
- // external Portal integration (E1/E2); the command + wiring exist here.
1214
- export function defaultEnrollmentRunner(env = process.env, store = new InMemoryPendingEnrollmentStore()) {
1215
- const notConfigured = () => {
1216
- throw new Error("live enrollment requires a configured keystore backend (os_keychain/tpm/agent_socket/hsm) " +
1217
- "and browser opener; the environment descriptor resolved but no keystore integration is wired");
1218
- };
1211
+ const processWriter = {
1212
+ writeStdout: (message) => process.stdout.write(message),
1213
+ writeStderr: (message) => process.stderr.write(message),
1214
+ };
1215
+ // The default runner drives the live enrollment flow: `run` = one-shot loopback +
1216
+ // browser opener; `begin`/`complete` = headless OOB two-step bridged by a
1217
+ // file-backed store (loopback cannot span two CLI processes). Every seam is
1218
+ // injectable so tests assert the wiring against a mock issuer without a live Portal.
1219
+ export function defaultEnrollmentRunner(env = process.env, deps = {}) {
1220
+ const fetch = deps.fetch ?? nodeFetchAdapter;
1221
+ const boardFetch = deps.boardFetch ?? nodeFetchAdapter;
1219
1222
  return {
1220
1223
  async run(input) {
1221
- resolveEnrollmentDescriptor(env);
1222
- void beginEnrollment;
1223
- void completeEnrollment;
1224
- void runEnrollment;
1225
- void store;
1226
- void input;
1227
- return notConfigured();
1224
+ const descriptor = resolveEnrollmentDescriptor(env);
1225
+ const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1226
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1227
+ const openAuthorization = deps.openAuthorization ?? makeBrowserOpener(deps.io ?? processWriter);
1228
+ return runEnrollment(descriptor, input.intent, {
1229
+ slug: input.slug,
1230
+ agentKind: input.agentKind,
1231
+ requestedScopes: input.requestedScopes,
1232
+ requestedBoardScopes: input.requestedBoardScopes,
1233
+ }, {
1234
+ fetch,
1235
+ enrollEndpoint,
1236
+ keystore,
1237
+ boardFetch,
1238
+ keystoreBackends: keystore.backends,
1239
+ openAuthorization,
1240
+ now: deps.now,
1241
+ });
1228
1242
  },
1229
1243
  async begin(input) {
1230
- resolveEnrollmentDescriptor(env);
1231
- void input;
1232
- return notConfigured();
1244
+ // Force OOB: a loopback receiver cannot bridge two separate CLI processes, so
1245
+ // the two-step path is always out-of-band paste (no redirectUri ⇒ OOB).
1246
+ const descriptor = resolveEnrollmentDescriptor(env);
1247
+ const store = deps.store ?? new FilePendingEnrollmentStore(env);
1248
+ return beginEnrollment({
1249
+ descriptor,
1250
+ intent: input.intent,
1251
+ slug: input.slug,
1252
+ agentKind: input.agentKind,
1253
+ requestedScopes: input.requestedScopes,
1254
+ requestedBoardScopes: input.requestedBoardScopes,
1255
+ redirectMode: "oob",
1256
+ }, store, { fetch, clientId: descriptor.issuer.clientId, now: deps.now });
1233
1257
  },
1234
1258
  async complete(input) {
1235
- void input;
1236
- return notConfigured();
1259
+ const descriptor = resolveEnrollmentDescriptor(env);
1260
+ const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1261
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1262
+ const store = deps.store ?? new FilePendingEnrollmentStore(env);
1263
+ return completeEnrollment({
1264
+ enrollmentId: input.enrollmentId,
1265
+ code: input.code,
1266
+ redirectResponse: input.redirectResponse,
1267
+ slug: input.slug,
1268
+ }, store, {
1269
+ fetch,
1270
+ enrollEndpoint,
1271
+ keystore,
1272
+ boardFetch,
1273
+ keystoreBackends: keystore.backends,
1274
+ now: deps.now,
1275
+ });
1237
1276
  },
1238
1277
  };
1239
1278
  }
@@ -1291,29 +1330,55 @@ function addEnrollCommands(program, io, enrollment) {
1291
1330
  .option("--agent-kind <kind>", "agent kind")
1292
1331
  .option("--create-board <slug>", "create-board intent slug")
1293
1332
  .option("--board-name <name>", "board name for create-board")
1333
+ .option("--board-description <description>", "board description for create-board")
1334
+ .option("--visibility <visibility>", "board visibility for create-board")
1294
1335
  .option("--join-board <boardId>", "join-board intent board id")
1295
1336
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
1296
- .option("--oob", "use the out-of-band paste flow instead of loopback")
1337
+ .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
1338
+ // A6: `begin` is ALWAYS out-of-band (a loopback receiver cannot bridge the two
1339
+ // separate begin/complete CLI processes), so this flag is a vestigial no-op kept
1340
+ // only for backward compatibility. Use `enroll run` for the loopback flow.
1341
+ .option("--oob", "(deprecated, no-op) begin is always out-of-band; kept for compatibility")
1297
1342
  .action((options) => runHandler(io, "agent_board.begin_enrollment", () => enrollment.begin({
1298
1343
  slug: options.slug,
1299
1344
  agentKind: options.agentKind,
1300
1345
  intent: intentFromOptions(options),
1301
1346
  requestedScopes: csv(options.requestedScopes),
1347
+ requestedBoardScopes: csv(options.requestedBoardScopes),
1302
1348
  oob: Boolean(options.oob),
1303
1349
  })));
1304
1350
  enroll
1305
1351
  .command("complete")
1306
1352
  .description("complete an enrollment with the authorization code")
1307
1353
  .requiredOption("--enrollment-id <id>", "the enrollment id from begin")
1308
- .option("--code <code>", "the authorization code (OOB paste)")
1354
+ .option("--code <code>", "the authorization code (prefer stdin; argv leaks via /proc/<pid>/cmdline)")
1309
1355
  .option("--redirect-response <url>", "the full redirect response URL")
1310
1356
  .option("--slug <slug>", "override the agent slug")
1311
- .action((options) => runHandler(io, "agent_board.complete_enrollment", () => enrollment.complete({
1312
- enrollmentId: options.enrollmentId,
1313
- code: options.code,
1314
- redirectResponse: options.redirectResponse,
1315
- slug: options.slug,
1316
- })));
1357
+ .action(async (options) => {
1358
+ // A5: keep the auth code off argv where practical. When neither --code nor
1359
+ // --redirect-response is supplied and stdin is piped, read the code from stdin.
1360
+ let code = options.code;
1361
+ if (!code && !options.redirectResponse) {
1362
+ code = await readCodeFromStdin();
1363
+ }
1364
+ return runHandler(io, "agent_board.complete_enrollment", () => enrollment.complete({
1365
+ enrollmentId: options.enrollmentId,
1366
+ code,
1367
+ redirectResponse: options.redirectResponse,
1368
+ slug: options.slug,
1369
+ }));
1370
+ });
1371
+ }
1372
+ /** Read a single-line auth code from a piped stdin (A5). Returns undefined on a TTY. */
1373
+ async function readCodeFromStdin() {
1374
+ if (process.stdin.isTTY)
1375
+ return undefined;
1376
+ const chunks = [];
1377
+ for await (const chunk of process.stdin) {
1378
+ chunks.push(chunk);
1379
+ }
1380
+ const text = Buffer.concat(chunks).toString("utf8").trim();
1381
+ return text.length > 0 ? text : undefined;
1317
1382
  }
1318
1383
  export function createProgram(client, io, enrollment = defaultEnrollmentRunner()) {
1319
1384
  const program = new Command()
@@ -0,0 +1,59 @@
1
+ import { type PendingEnrollment, type PendingEnrollmentStore } from "@consiliency/agent-board-client";
2
+ export declare class EnrollRuntimeError extends Error {
3
+ code: string;
4
+ constructor(code: string, message: string);
5
+ }
6
+ export interface FilePendingEnrollmentStoreOptions {
7
+ /** Override the state directory (default: $XDG_STATE_HOME/agent-board/pending-enroll). */
8
+ dir?: string;
9
+ /** Clock for the TTL sweep (default: Date.now()/1000). */
10
+ now?: () => number;
11
+ /** Non-sensitive warning sink (default: console.warn). */
12
+ warn?: (message: string) => void;
13
+ }
14
+ export declare class FilePendingEnrollmentStore implements PendingEnrollmentStore {
15
+ private readonly dir;
16
+ private readonly env;
17
+ private readonly now?;
18
+ private readonly warn;
19
+ constructor(env?: NodeJS.ProcessEnv, options?: FilePendingEnrollmentStoreOptions);
20
+ private fileFor;
21
+ private ensureDir;
22
+ /**
23
+ * Refuse a dir/file that is not a regular dir/file, not uid-owned, or too permissive.
24
+ * The pending FILE holds the PKCE `code_verifier` (a secret), so it must be
25
+ * owner-only (reject ANY group/other bit, 0o077); the dir need only be non-
26
+ * group/other-writable (0o022).
27
+ */
28
+ private guard;
29
+ put(pending: PendingEnrollment): Promise<void>;
30
+ get(enrollmentId: string): Promise<PendingEnrollment | undefined>;
31
+ delete(enrollmentId: string): Promise<void>;
32
+ /** Opportunistic TTL sweep (A2.5): delete expired files, skipping `exceptId`. */
33
+ private sweep;
34
+ }
35
+ /** Minimal stdout/stderr sink (structurally compatible with the CLI's CliIo). */
36
+ export interface EnrollWriter {
37
+ writeStdout(message: string): void;
38
+ writeStderr(message: string): void;
39
+ }
40
+ /** Injectable spawn seam (tests drive a fake; production wraps child_process.spawn). */
41
+ export type SpawnLike = (command: string, args: readonly string[], options: {
42
+ shell: false;
43
+ detached: boolean;
44
+ stdio: "ignore";
45
+ }) => {
46
+ unref(): void;
47
+ };
48
+ export interface BrowserOpenerOptions {
49
+ spawn?: SpawnLike;
50
+ platform?: NodeJS.Platform;
51
+ }
52
+ /**
53
+ * Build an `openAuthorization(url)` that (A3): asserts the URL is http(s) BEFORE
54
+ * doing anything with it; ALWAYS prints the URL first so headless/CI works even
55
+ * with no browser; then spawns the platform launcher with `shell:false` (no shell
56
+ * re-parse), detached + unref'd. A spawn failure is logged (message only), NEVER
57
+ * thrown — the printed URL is the fallback.
58
+ */
59
+ export declare function makeBrowserOpener(io: EnrollWriter, options?: BrowserOpenerOptions): (url: string) => void;
@@ -0,0 +1,273 @@
1
+ // MBENROLL SL-3 — CLI-only enrollment runtime: file-backed pending store + browser
2
+ // opener. These need node fs / child_process, so they live in the CLI package (the
3
+ // shared, node-agnostic host helpers live in the client's enroll_host.ts). The MCP
4
+ // server uses neither — it bridges begin→complete via an in-process singleton and
5
+ // has no local browser reachable by the remote human.
6
+ import { spawn } from "node:child_process";
7
+ import { constants as FS } from "node:fs";
8
+ import * as fs from "node:fs/promises";
9
+ import { homedir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { resolveEnrollmentDescriptor, } from "@consiliency/agent-board-client";
12
+ export class EnrollRuntimeError extends Error {
13
+ code;
14
+ constructor(code, message) {
15
+ super(message);
16
+ this.name = "EnrollRuntimeError";
17
+ this.code = code;
18
+ }
19
+ }
20
+ function nowSeconds(now) {
21
+ return now ? now() : Math.floor(Date.now() / 1000);
22
+ }
23
+ function defaultStateDir(env) {
24
+ const xdg = env.XDG_STATE_HOME && env.XDG_STATE_HOME.trim().length > 0 ? env.XDG_STATE_HOME.trim() : null;
25
+ const base = xdg ?? join(homedir(), ".local", "state");
26
+ return join(base, "agent-board", "pending-enroll");
27
+ }
28
+ const ENR_FILE = /^enr-[A-Za-z0-9-]+\.json$/;
29
+ // Transient write-temp (`<file>.tmp-<pid>-<ts>`); only exists mid-put(). A lingering
30
+ // one is orphaned by a crash between open() and rename() and holds the code_verifier.
31
+ const TEMP_FILE = /^enr-[A-Za-z0-9-]+\.json\.tmp-\d+-\d+$/;
32
+ const ORPHAN_TEMP_MS = 120_000;
33
+ export class FilePendingEnrollmentStore {
34
+ dir;
35
+ env;
36
+ now;
37
+ warn;
38
+ constructor(env = process.env, options = {}) {
39
+ this.env = env;
40
+ this.dir = options.dir ?? defaultStateDir(env);
41
+ this.now = options.now;
42
+ this.warn = options.warn ?? ((m) => console.warn(m));
43
+ }
44
+ fileFor(enrollmentId) {
45
+ return join(this.dir, `${enrollmentId}.json`);
46
+ }
47
+ async ensureDir() {
48
+ await fs.mkdir(this.dir, { recursive: true, mode: 0o700 });
49
+ }
50
+ /**
51
+ * Refuse a dir/file that is not a regular dir/file, not uid-owned, or too permissive.
52
+ * The pending FILE holds the PKCE `code_verifier` (a secret), so it must be
53
+ * owner-only (reject ANY group/other bit, 0o077); the dir need only be non-
54
+ * group/other-writable (0o022).
55
+ */
56
+ async guard(path, kind) {
57
+ const st = await fs.lstat(path);
58
+ if (kind === "dir" ? !st.isDirectory() : !st.isFile()) {
59
+ throw new EnrollRuntimeError("insecure_state_path", `refusing ${kind} ${path}: not a regular ${kind} (possible symlink/tamper)`);
60
+ }
61
+ if (typeof process.getuid === "function" && st.uid !== process.getuid()) {
62
+ throw new EnrollRuntimeError("insecure_state_path", `refusing ${kind} ${path}: not owned by the current user`);
63
+ }
64
+ const forbidden = kind === "file" ? 0o077 : 0o022;
65
+ if ((st.mode & forbidden) !== 0) {
66
+ throw new EnrollRuntimeError("insecure_state_path", kind === "file"
67
+ ? `refusing ${kind} ${path}: is group/other-accessible (secret must be 0600)`
68
+ : `refusing ${kind} ${path}: is group/other-writable`);
69
+ }
70
+ }
71
+ async put(pending) {
72
+ await this.ensureDir();
73
+ await this.guard(this.dir, "dir");
74
+ await this.sweep();
75
+ // Persist ONLY the begin-time binding — NOT the descriptor's endpoint set (A2.1).
76
+ const record = {
77
+ enrollmentId: pending.enrollmentId,
78
+ intent: pending.intent,
79
+ slug: pending.slug,
80
+ agentKind: pending.agentKind,
81
+ requestedScopes: pending.requestedScopes,
82
+ requestedBoardScopes: pending.requestedBoardScopes,
83
+ pkce: pending.pkce,
84
+ state: pending.state,
85
+ redirectUri: pending.redirectUri,
86
+ redirectMode: pending.redirectMode,
87
+ clientId: pending.clientId,
88
+ expiresAt: pending.expiresAt,
89
+ };
90
+ const finalPath = this.fileFor(pending.enrollmentId);
91
+ const tempPath = `${finalPath}.tmp-${process.pid}-${Date.now()}`;
92
+ // O_CREAT|O_EXCL|O_WRONLY|O_NOFOLLOW at 0600 — never follow a pre-planted symlink.
93
+ const handle = await fs.open(tempPath, FS.O_CREAT | FS.O_EXCL | FS.O_WRONLY | FS.O_NOFOLLOW, 0o600);
94
+ try {
95
+ await handle.writeFile(JSON.stringify(record), "utf8");
96
+ await handle.sync();
97
+ }
98
+ finally {
99
+ await handle.close();
100
+ }
101
+ try {
102
+ await fs.rename(tempPath, finalPath);
103
+ }
104
+ catch (err) {
105
+ // Never orphan a temp file holding the code_verifier if the rename fails.
106
+ await fs.unlink(tempPath).catch(() => { });
107
+ throw err;
108
+ }
109
+ }
110
+ async get(enrollmentId) {
111
+ // Reap OTHER abandoned files, but NEVER the requested id — completeEnrollment
112
+ // relies on get() returning an expired record so it can raise `enrollment_expired`
113
+ // (an expired requested id must not degrade to `unknown_enrollment`).
114
+ await this.sweep(enrollmentId);
115
+ const path = this.fileFor(enrollmentId);
116
+ let raw;
117
+ try {
118
+ await this.guard(path, "file");
119
+ const handle = await fs.open(path, FS.O_RDONLY | FS.O_NOFOLLOW);
120
+ try {
121
+ raw = await handle.readFile("utf8");
122
+ }
123
+ finally {
124
+ await handle.close();
125
+ }
126
+ }
127
+ catch (err) {
128
+ const code = err.code;
129
+ if (code === "ENOENT")
130
+ return undefined; // absent
131
+ if (err instanceof EnrollRuntimeError)
132
+ throw err; // tamper — fail loud
133
+ if (code === "ELOOP") {
134
+ throw new EnrollRuntimeError("insecure_state_path", `refusing ${path}: symlink encountered on read`);
135
+ }
136
+ throw err;
137
+ }
138
+ let parsed;
139
+ try {
140
+ parsed = JSON.parse(raw);
141
+ }
142
+ catch {
143
+ return undefined; // corrupt → absent (never log contents)
144
+ }
145
+ // A2.1: reconstruct the descriptor FRESH from trusted env — the on-disk value
146
+ // (if any) is IGNORED so a tampered file cannot redirect the exchange/JWKS/board.
147
+ const descriptor = resolveEnrollmentDescriptor(this.env);
148
+ if (parsed.descriptor && typeof parsed.descriptor === "object") {
149
+ const diskEndpoint = parsed.descriptor.issuer?.tokenEndpoint;
150
+ if (diskEndpoint && diskEndpoint !== descriptor.issuer.tokenEndpoint) {
151
+ this.warn(`agent-board: ignoring an on-disk enrollment descriptor that disagrees with the current environment`);
152
+ }
153
+ }
154
+ return {
155
+ enrollmentId: parsed.enrollmentId,
156
+ descriptor,
157
+ intent: parsed.intent,
158
+ slug: parsed.slug,
159
+ agentKind: parsed.agentKind,
160
+ requestedScopes: parsed.requestedScopes,
161
+ requestedBoardScopes: parsed.requestedBoardScopes,
162
+ pkce: parsed.pkce,
163
+ state: parsed.state,
164
+ redirectUri: parsed.redirectUri,
165
+ redirectMode: parsed.redirectMode ?? "oob",
166
+ clientId: parsed.clientId,
167
+ expiresAt: parsed.expiresAt,
168
+ };
169
+ }
170
+ async delete(enrollmentId) {
171
+ try {
172
+ await fs.unlink(this.fileFor(enrollmentId));
173
+ }
174
+ catch (err) {
175
+ if (err.code !== "ENOENT")
176
+ throw err; // ENOENT-tolerant
177
+ }
178
+ }
179
+ /** Opportunistic TTL sweep (A2.5): delete expired files, skipping `exceptId`. */
180
+ async sweep(exceptId) {
181
+ let entries;
182
+ try {
183
+ entries = await fs.readdir(this.dir);
184
+ }
185
+ catch {
186
+ return; // dir absent yet — nothing to sweep
187
+ }
188
+ const cutoff = nowSeconds(this.now);
189
+ const wallNow = Date.now();
190
+ for (const name of entries) {
191
+ // Reap crash-orphaned write-temps (they carry the code_verifier). Transient by
192
+ // design, so any older than ORPHAN_TEMP_MS is orphaned; use wall-clock mtime
193
+ // (independent of the injectable TTL clock).
194
+ if (TEMP_FILE.test(name)) {
195
+ try {
196
+ const st = await fs.lstat(join(this.dir, name));
197
+ if (st.isFile() && wallNow - st.mtimeMs > ORPHAN_TEMP_MS) {
198
+ await fs.unlink(join(this.dir, name)).catch(() => { });
199
+ }
200
+ }
201
+ catch {
202
+ /* racing unlink / tamper — skip */
203
+ }
204
+ continue;
205
+ }
206
+ if (!ENR_FILE.test(name))
207
+ continue;
208
+ const id = name.slice(0, -".json".length);
209
+ if (id === exceptId)
210
+ continue;
211
+ const path = join(this.dir, name);
212
+ try {
213
+ await this.guard(path, "file");
214
+ const handle = await fs.open(path, FS.O_RDONLY | FS.O_NOFOLLOW);
215
+ let raw;
216
+ try {
217
+ raw = await handle.readFile("utf8");
218
+ }
219
+ finally {
220
+ await handle.close();
221
+ }
222
+ const rec = JSON.parse(raw);
223
+ if (typeof rec.expiresAt === "number" && cutoff > rec.expiresAt) {
224
+ await fs.unlink(path).catch(() => { });
225
+ }
226
+ }
227
+ catch {
228
+ // Corrupt / tampered / racing-unlink file — skip (never throw from a sweep).
229
+ }
230
+ }
231
+ }
232
+ }
233
+ function launcherFor(platform, url) {
234
+ if (platform === "darwin")
235
+ return { command: "open", args: [url] };
236
+ // NON-reparsing on win32: cmd /c start "" re-tokenizes `&` in OAuth URLs, so use
237
+ // rundll32 FileProtocolHandler which takes the URL as a single, un-split argument.
238
+ if (platform === "win32")
239
+ return { command: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
240
+ return { command: "xdg-open", args: [url] };
241
+ }
242
+ /**
243
+ * Build an `openAuthorization(url)` that (A3): asserts the URL is http(s) BEFORE
244
+ * doing anything with it; ALWAYS prints the URL first so headless/CI works even
245
+ * with no browser; then spawns the platform launcher with `shell:false` (no shell
246
+ * re-parse), detached + unref'd. A spawn failure is logged (message only), NEVER
247
+ * thrown — the printed URL is the fallback.
248
+ */
249
+ export function makeBrowserOpener(io, options = {}) {
250
+ const spawnImpl = options.spawn ?? ((command, args, opts) => spawn(command, [...args], opts));
251
+ const platform = options.platform ?? process.platform;
252
+ return (url) => {
253
+ let parsed;
254
+ try {
255
+ parsed = new URL(url);
256
+ }
257
+ catch {
258
+ throw new EnrollRuntimeError("invalid_authorization_url", "the authorization URL is not a valid URL");
259
+ }
260
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
261
+ throw new EnrollRuntimeError("unsafe_authorization_scheme", `refusing to open a non-http(s) authorization URL (scheme ${parsed.protocol})`);
262
+ }
263
+ io.writeStdout(`Open this URL to authorize:\n${url}\n`);
264
+ const { command, args } = launcherFor(platform, url);
265
+ try {
266
+ const child = spawnImpl(command, args, { shell: false, detached: true, stdio: "ignore" });
267
+ child.unref();
268
+ }
269
+ catch (err) {
270
+ io.writeStderr(`(could not launch a browser automatically: ${err.message}; open the URL above manually)\n`);
271
+ }
272
+ };
273
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-cli",
3
- "version": "1.3.0",
3
+ "version": "1.5.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.3.0",
34
+ "@consiliency/agent-board-client": "1.7.0",
35
35
  "commander": "^14.0.1"
36
36
  },
37
37
  "license": "Apache-2.0"