@consiliency/agent-board-cli 1.4.0 → 1.6.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,32 @@ 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
+ /** /session override for the post-enroll board op. Default: resolveEnrollSessionUrl(env). */
141
+ sessionUrl?: string;
142
+ /** Anon apikey for the post-enroll board RPCs. Default: resolveEnrollPublishableKey(env). */
143
+ publishableKey?: string;
144
+ /** Clock (tests). Default: wall clock. */
145
+ now?: () => number;
146
+ /** Writer for the default browser opener's printed URL. Default: process std streams. */
147
+ io?: {
148
+ writeStdout(message: string): void;
149
+ writeStderr(message: string): void;
150
+ };
151
+ }
152
+ export declare function defaultEnrollmentRunner(env?: NodeJS.ProcessEnv, deps?: DefaultEnrollmentRunnerDeps): EnrollmentCommandRunner;
127
153
  export declare function createProgram(client: ClientLike, io: CliIo, enrollment?: EnrollmentCommandRunner): Command;
128
154
  export declare function runCli(argv: string[], io?: CliIo, client?: ClientLike, enrollment?: EnrollmentCommandRunner): Promise<void>;
129
155
  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, resolveEnrollSessionUrl, resolveEnrollPublishableKey, 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,79 @@ 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;
1222
+ // Post-enroll board-op wiring (raw Supabase domain): explicit /session URL + anon
1223
+ // apikey. Optional — absent preserves the ${boardUrl}/session default + no apikey.
1224
+ const sessionUrl = deps.sessionUrl ?? resolveEnrollSessionUrl(env);
1225
+ const publishableKey = deps.publishableKey ?? resolveEnrollPublishableKey(env);
1219
1226
  return {
1220
1227
  async run(input) {
1221
- resolveEnrollmentDescriptor(env);
1222
- void beginEnrollment;
1223
- void completeEnrollment;
1224
- void runEnrollment;
1225
- void store;
1226
- void input;
1227
- return notConfigured();
1228
+ const descriptor = resolveEnrollmentDescriptor(env);
1229
+ const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1230
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1231
+ const openAuthorization = deps.openAuthorization ?? makeBrowserOpener(deps.io ?? processWriter);
1232
+ return runEnrollment(descriptor, input.intent, {
1233
+ slug: input.slug,
1234
+ agentKind: input.agentKind,
1235
+ requestedScopes: input.requestedScopes,
1236
+ requestedBoardScopes: input.requestedBoardScopes,
1237
+ }, {
1238
+ fetch,
1239
+ enrollEndpoint,
1240
+ keystore,
1241
+ boardFetch,
1242
+ keystoreBackends: keystore.backends,
1243
+ sessionUrl,
1244
+ publishableKey,
1245
+ openAuthorization,
1246
+ now: deps.now,
1247
+ });
1228
1248
  },
1229
1249
  async begin(input) {
1230
- resolveEnrollmentDescriptor(env);
1231
- void input;
1232
- return notConfigured();
1250
+ // Force OOB: a loopback receiver cannot bridge two separate CLI processes, so
1251
+ // the two-step path is always out-of-band paste (no redirectUri ⇒ OOB).
1252
+ const descriptor = resolveEnrollmentDescriptor(env);
1253
+ const store = deps.store ?? new FilePendingEnrollmentStore(env);
1254
+ return beginEnrollment({
1255
+ descriptor,
1256
+ intent: input.intent,
1257
+ slug: input.slug,
1258
+ agentKind: input.agentKind,
1259
+ requestedScopes: input.requestedScopes,
1260
+ requestedBoardScopes: input.requestedBoardScopes,
1261
+ redirectMode: "oob",
1262
+ }, store, { fetch, clientId: descriptor.issuer.clientId, now: deps.now });
1233
1263
  },
1234
1264
  async complete(input) {
1235
- void input;
1236
- return notConfigured();
1265
+ const descriptor = resolveEnrollmentDescriptor(env);
1266
+ const enrollEndpoint = deps.enrollEndpoint ?? resolveEnrollEndpoint(env);
1267
+ const keystore = deps.keystore ?? buildEnrollKeystore(descriptor, env);
1268
+ const store = deps.store ?? new FilePendingEnrollmentStore(env);
1269
+ return completeEnrollment({
1270
+ enrollmentId: input.enrollmentId,
1271
+ code: input.code,
1272
+ redirectResponse: input.redirectResponse,
1273
+ slug: input.slug,
1274
+ }, store, {
1275
+ fetch,
1276
+ enrollEndpoint,
1277
+ keystore,
1278
+ boardFetch,
1279
+ keystoreBackends: keystore.backends,
1280
+ sessionUrl,
1281
+ publishableKey,
1282
+ now: deps.now,
1283
+ });
1237
1284
  },
1238
1285
  };
1239
1286
  }
@@ -1291,29 +1338,55 @@ function addEnrollCommands(program, io, enrollment) {
1291
1338
  .option("--agent-kind <kind>", "agent kind")
1292
1339
  .option("--create-board <slug>", "create-board intent slug")
1293
1340
  .option("--board-name <name>", "board name for create-board")
1341
+ .option("--board-description <description>", "board description for create-board")
1342
+ .option("--visibility <visibility>", "board visibility for create-board")
1294
1343
  .option("--join-board <boardId>", "join-board intent board id")
1295
1344
  .option("--requested-scopes <csv>", "requested scopes (comma-separated)")
1296
- .option("--oob", "use the out-of-band paste flow instead of loopback")
1345
+ .option("--requested-board-scopes <csv>", "requested board scopes (comma-separated)")
1346
+ // A6: `begin` is ALWAYS out-of-band (a loopback receiver cannot bridge the two
1347
+ // separate begin/complete CLI processes), so this flag is a vestigial no-op kept
1348
+ // only for backward compatibility. Use `enroll run` for the loopback flow.
1349
+ .option("--oob", "(deprecated, no-op) begin is always out-of-band; kept for compatibility")
1297
1350
  .action((options) => runHandler(io, "agent_board.begin_enrollment", () => enrollment.begin({
1298
1351
  slug: options.slug,
1299
1352
  agentKind: options.agentKind,
1300
1353
  intent: intentFromOptions(options),
1301
1354
  requestedScopes: csv(options.requestedScopes),
1355
+ requestedBoardScopes: csv(options.requestedBoardScopes),
1302
1356
  oob: Boolean(options.oob),
1303
1357
  })));
1304
1358
  enroll
1305
1359
  .command("complete")
1306
1360
  .description("complete an enrollment with the authorization code")
1307
1361
  .requiredOption("--enrollment-id <id>", "the enrollment id from begin")
1308
- .option("--code <code>", "the authorization code (OOB paste)")
1362
+ .option("--code <code>", "the authorization code (prefer stdin; argv leaks via /proc/<pid>/cmdline)")
1309
1363
  .option("--redirect-response <url>", "the full redirect response URL")
1310
1364
  .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
- })));
1365
+ .action(async (options) => {
1366
+ // A5: keep the auth code off argv where practical. When neither --code nor
1367
+ // --redirect-response is supplied and stdin is piped, read the code from stdin.
1368
+ let code = options.code;
1369
+ if (!code && !options.redirectResponse) {
1370
+ code = await readCodeFromStdin();
1371
+ }
1372
+ return runHandler(io, "agent_board.complete_enrollment", () => enrollment.complete({
1373
+ enrollmentId: options.enrollmentId,
1374
+ code,
1375
+ redirectResponse: options.redirectResponse,
1376
+ slug: options.slug,
1377
+ }));
1378
+ });
1379
+ }
1380
+ /** Read a single-line auth code from a piped stdin (A5). Returns undefined on a TTY. */
1381
+ async function readCodeFromStdin() {
1382
+ if (process.stdin.isTTY)
1383
+ return undefined;
1384
+ const chunks = [];
1385
+ for await (const chunk of process.stdin) {
1386
+ chunks.push(chunk);
1387
+ }
1388
+ const text = Buffer.concat(chunks).toString("utf8").trim();
1389
+ return text.length > 0 ? text : undefined;
1317
1390
  }
1318
1391
  export function createProgram(client, io, enrollment = defaultEnrollmentRunner()) {
1319
1392
  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.4.0",
3
+ "version": "1.6.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.6.0",
34
+ "@consiliency/agent-board-client": "1.8.0",
35
35
  "commander": "^14.0.1"
36
36
  },
37
37
  "license": "Apache-2.0"