@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 +28 -2
- package/dist/cli.js +103 -30
- package/dist/enroll_runtime.d.ts +59 -0
- package/dist/enroll_runtime.js +273 -0
- package/package.json +2 -2
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
|
|
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,
|
|
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
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
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
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
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
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
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("--
|
|
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 (
|
|
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) =>
|
|
1312
|
-
|
|
1313
|
-
code
|
|
1314
|
-
|
|
1315
|
-
|
|
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.
|
|
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.
|
|
34
|
+
"@consiliency/agent-board-client": "1.8.0",
|
|
35
35
|
"commander": "^14.0.1"
|
|
36
36
|
},
|
|
37
37
|
"license": "Apache-2.0"
|