@tpsdev-ai/flair 0.53.0 → 0.54.2

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.
Files changed (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1665 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +931 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1628 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/engine-version.js +12 -4
  43. package/dist/fabric-npm-install.js +87 -0
  44. package/dist/fabric-upgrade.js +30 -15
  45. package/dist/federation-verify.js +498 -0
  46. package/dist/fleet-verify.js +144 -21
  47. package/dist/install/clients.js +167 -0
  48. package/dist/lib/auth-resolve.js +76 -1
  49. package/dist/lib/daemon-liveness.js +131 -2
  50. package/dist/lib/doctor-config-path.js +61 -0
  51. package/dist/lib/doctor-federation-driver.js +189 -0
  52. package/dist/lib/doctor-run.js +40 -0
  53. package/dist/lib/entity-vocab-cli.js +3 -3
  54. package/dist/lib/federation-pair-identity.js +47 -0
  55. package/dist/lib/launchd-repair.js +5 -4
  56. package/dist/lib/npm-registry.js +578 -0
  57. package/dist/lib/ops-api-bind.js +115 -0
  58. package/dist/lib/owned-pins.js +219 -0
  59. package/dist/lib/uninstall-purge.js +218 -0
  60. package/dist/rem/restore.js +8 -10
  61. package/dist/resources/AgentReadPosition.js +74 -0
  62. package/dist/resources/Federation.js +8 -2
  63. package/dist/resources/Memory.js +4 -3
  64. package/dist/resources/MemoryBootstrap.js +41 -25
  65. package/dist/resources/MemoryCandidate.js +5 -6
  66. package/dist/resources/OrgEventCatchup.js +126 -47
  67. package/dist/resources/agent-read-position-lib.js +83 -0
  68. package/dist/resources/agent-read-position.js +120 -0
  69. package/dist/resources/embeddings-boot.js +32 -0
  70. package/dist/resources/federation-peer-liveness.js +73 -0
  71. package/dist/resources/health.js +68 -19
  72. package/dist/resources/mcp-tools.js +48 -279
  73. package/dist/resources/memory-visibility.js +3 -3
  74. package/dist/resources/migration-boot.js +59 -18
  75. package/dist/resources/migrations/embedding-stamp.js +20 -1
  76. package/dist/resources/migrations/recheck.js +43 -0
  77. package/dist/resources/migrations/runner.js +6 -1
  78. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  79. package/dist/resources/migrations/visibility-backfill.js +2 -2
  80. package/dist/resources/org-event-catchup-lib.js +47 -0
  81. package/dist/resources/record-owner-guard.js +1 -0
  82. package/dist/resources/tool-descriptors/index.js +669 -0
  83. package/dist/stamp-migration-verify.js +163 -0
  84. package/dist/stamp-outstanding.js +144 -0
  85. package/dist/version-check.js +29 -8
  86. package/docs/api-reference.md +4 -2
  87. package/docs/deploying-on-fabric.md +11 -10
  88. package/docs/deployment.md +3 -1
  89. package/docs/federation.md +19 -0
  90. package/docs/hosted-on-fabric.md +3 -3
  91. package/docs/quickstart.md +2 -1
  92. package/docs/releasing.md +20 -6
  93. package/docs/spoke-bringup.md +10 -5
  94. package/docs/standalone-local.md +3 -1
  95. package/docs/upgrade.md +25 -6
  96. package/package.json +4 -4
  97. package/schemas/agent.graphql +15 -0
@@ -0,0 +1,707 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join } from "node:path";
4
+ import nacl from "tweetnacl";
5
+ import * as render from "../render.js";
6
+ import { defaultKeysDir, resolveAdminUser, resolveLocalAdminPass } from "../lib/auth-resolve.js";
7
+ import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "../mcp-client-assertion.js";
8
+ import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "../lib/mcp-enable.js";
9
+ let cli;
10
+ /** Bind shared CLI helpers. cli.ts calls this immediately before register(program). */
11
+ export function bindCli(fns) {
12
+ cli = fns;
13
+ }
14
+ function resolveOpsPort(opts) {
15
+ return cli.resolveOpsPort(opts);
16
+ }
17
+ function privKeyPath(agentId, keysDir) {
18
+ return cli.privKeyPath(agentId, keysDir);
19
+ }
20
+ function pubKeyPath(agentId, keysDir) {
21
+ return cli.pubKeyPath(agentId, keysDir);
22
+ }
23
+ function b64url(bytes) {
24
+ return cli.b64url(bytes);
25
+ }
26
+ /** Default manifest path: `~/.flair/mcp-clients.json`, sibling to
27
+ * admin-pass/config.yaml — independent of --keys-dir (a custom keys dir
28
+ * doesn't imply a custom manifest location, and vice versa). */
29
+ export function defaultMcpClientManifestPath() {
30
+ return join(homedir(), ".flair", "mcp-clients.json");
31
+ }
32
+ /** Read the manifest; a missing file is "no clients granted yet", not an
33
+ * error. Malformed JSON is also treated as empty (defensive — a corrupt
34
+ * manifest must never crash `list`), never partially parsed. */
35
+ export function readMcpClientManifest(manifestPath) {
36
+ if (!existsSync(manifestPath))
37
+ return [];
38
+ try {
39
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
40
+ return Array.isArray(parsed) ? parsed : [];
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ /** Write the manifest, 0600 — it names every granted machine client's
47
+ * key-file path, which is operationally sensitive even though it carries
48
+ * no key material itself. */
49
+ function writeMcpClientManifest(manifestPath, entries) {
50
+ mkdirSync(dirname(manifestPath), { recursive: true });
51
+ writeFileSync(manifestPath, JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
52
+ chmodSync(manifestPath, 0o600);
53
+ }
54
+ /** Machine-readable "ready-to-paste" MCP config block for `grant`'s output.
55
+ * Pure — no I/O — so it's independently unit-testable. Mirrors the
56
+ * `mcpServers` top-level key src/install/clients.ts's jsonSnippet already
57
+ * established as this codebase's paste-target convention, pointed at the
58
+ * Model-2 OAuth `/mcp` HTTP surface (not that stdio-bridge path).
59
+ *
60
+ * The Authorization header CANNOT be a static, working value: a
61
+ * client_credentials access token is short-lived (default 300s TTL,
62
+ * server-side — see token.js's DEFAULT_CLIENT_CREDENTIALS_TTL) and this
63
+ * grant issues no refresh token by design. Printing a real-looking-but-
64
+ * dead token would be actively misleading, so the placeholder says exactly
65
+ * what to run instead — never a fabricated "it just works" static header.
66
+ */
67
+ export function buildMcpGrantConfig(params) {
68
+ return {
69
+ mcpServers: {
70
+ [params.name]: {
71
+ type: "http",
72
+ url: params.resource,
73
+ headers: {
74
+ Authorization: `Bearer <mint before each session: ` +
75
+ `flair mcp token --agent-id ${params.name} --json — copy .access_token here; ` +
76
+ `expires in minutes, not a long-lived credential>`,
77
+ },
78
+ },
79
+ },
80
+ note: `Key material lives at ${params.keyFile} (0600, never printed). ` +
81
+ `The Bearer token above is a placeholder — mint a fresh one with ` +
82
+ `\`flair mcp token --agent-id ${params.name}\` at connection time.`,
83
+ };
84
+ }
85
+ export class McpClientNameExistsError extends Error {
86
+ constructor(name) {
87
+ super(`Machine client '${name}' already exists — use \`flair mcp revoke ${name}\` first or pick a different name.`);
88
+ this.name = "McpClientNameExistsError";
89
+ }
90
+ }
91
+ export class McpClientAgentIdCollisionError extends Error {
92
+ constructor(name) {
93
+ super(`An agent named '${name}' already exists in Flair but is not an mcp-granted machine client ` +
94
+ `— pick a different name (or \`flair agent remove ${name}\` first if that's intentional).`);
95
+ this.name = "McpClientAgentIdCollisionError";
96
+ }
97
+ }
98
+ export class McpClientNotFoundError extends Error {
99
+ constructor(name) {
100
+ super(`No granted machine client named '${name}' (see \`flair mcp list\`).`);
101
+ this.name = "McpClientNotFoundError";
102
+ }
103
+ }
104
+ const MCP_CLIENT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
105
+ /**
106
+ * Core, testable `flair mcp grant` orchestration — no process.exit, no
107
+ * console output, so it's directly unit-testable with a mocked fetch and a
108
+ * temp dir (same split as classifyKeysDir/applyKeyPrune above). Throws
109
+ * typed errors; the CLI action below catches and formats them.
110
+ */
111
+ export async function grantMcpClient(params, deps = {}) {
112
+ const { name, keysDir, manifestPath, issuer, opsPortOrUrl, adminUser, adminPass } = params;
113
+ const fetchImpl = deps.fetchImpl ?? fetch;
114
+ const now = deps.now ?? (() => new Date().toISOString());
115
+ const generateKeyPair = deps.generateKeyPair ?? (() => nacl.sign.keyPair());
116
+ if (!MCP_CLIENT_NAME_PATTERN.test(name)) {
117
+ throw new Error(`Invalid machine client name '${name}' — must be 1-64 chars, start alphanumeric, ` +
118
+ `and contain only letters, digits, '_', '-' (it becomes an Agent id, a key filename, and a URL path segment).`);
119
+ }
120
+ const manifest = readMcpClientManifest(manifestPath);
121
+ if (manifest.some((e) => e.name === name)) {
122
+ throw new McpClientNameExistsError(name);
123
+ }
124
+ // Defense-in-depth: refuse to silently reuse/clobber an unrelated
125
+ // pre-existing Agent id (e.g. a human-run principal sharing this name).
126
+ const opsUrl = typeof opsPortOrUrl === "number" ? `http://127.0.0.1:${opsPortOrUrl}/` : `${opsPortOrUrl.replace(/\/$/, "")}/`;
127
+ const authHeader = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
128
+ const existingRes = await fetchImpl(opsUrl, {
129
+ method: "POST",
130
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
131
+ body: JSON.stringify({
132
+ operation: "search_by_value",
133
+ database: "flair",
134
+ table: "Agent",
135
+ search_attribute: "id",
136
+ search_value: name,
137
+ get_attributes: ["id"],
138
+ }),
139
+ });
140
+ if (existingRes.ok) {
141
+ const existing = await existingRes.json().catch(() => []);
142
+ if (Array.isArray(existing) && existing.length > 0) {
143
+ throw new McpClientAgentIdCollisionError(name);
144
+ }
145
+ }
146
+ mkdirSync(keysDir, { recursive: true });
147
+ const privPath = privKeyPath(name, keysDir);
148
+ const pubPath = pubKeyPath(name, keysDir);
149
+ const kp = generateKeyPair();
150
+ const seed = kp.secretKey.slice(0, 32);
151
+ const pubKeyB64url = b64url(kp.publicKey);
152
+ writeFileSync(privPath, Buffer.from(seed), { mode: 0o600 });
153
+ chmodSync(privPath, 0o600);
154
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
155
+ const nowIso = now();
156
+ const insertRes = await fetchImpl(opsUrl, {
157
+ method: "POST",
158
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
159
+ body: JSON.stringify({
160
+ operation: "insert",
161
+ database: "flair",
162
+ table: "Agent",
163
+ records: [{
164
+ id: name,
165
+ name,
166
+ type: "agent",
167
+ kind: "agent",
168
+ status: "active",
169
+ displayName: name,
170
+ admin: false,
171
+ defaultTrustTier: "unverified",
172
+ runtime: "headless",
173
+ publicKey: pubKeyB64url,
174
+ createdAt: nowIso,
175
+ updatedAt: nowIso,
176
+ }],
177
+ }),
178
+ });
179
+ if (!insertRes.ok) {
180
+ // Roll back the key files we just wrote — nothing left behind locally
181
+ // on a failed grant.
182
+ for (const p of [privPath, pubPath]) {
183
+ try {
184
+ const { unlinkSync } = await import("node:fs");
185
+ unlinkSync(p);
186
+ }
187
+ catch { /* best effort */ }
188
+ }
189
+ const text = await insertRes.text().catch(() => "");
190
+ throw new Error(`Failed to create Agent '${name}' via operations API (${insertRes.status}): ${text}`);
191
+ }
192
+ const clientId = `${issuer.replace(/\/+$/, "")}/MCPClientMetadata/${name}`;
193
+ const entry = {
194
+ name,
195
+ agentId: name,
196
+ clientId,
197
+ keyFile: privPath,
198
+ pubKeyFile: pubPath,
199
+ issuer,
200
+ createdAt: nowIso,
201
+ status: "active",
202
+ };
203
+ writeMcpClientManifest(manifestPath, [...manifest, entry]);
204
+ const resource = `${issuer.replace(/\/+$/, "")}/mcp`;
205
+ const config = buildMcpGrantConfig({ name, resource, keyFile: privPath });
206
+ return { entry, config };
207
+ }
208
+ /**
209
+ * Core, testable `flair mcp revoke` orchestration. SERVER-SIDE ack is
210
+ * mandatory before any local mutation: the Agent record backing this
211
+ * client's CIMD identity is DELETEd via the admin-authenticated ops API
212
+ * first; only a 2xx response ("ack") triggers local key-file deletion and
213
+ * manifest cleanup. A network error or non-2xx leaves everything local
214
+ * untouched and throws — the caller (CLI action) reports a clear failure
215
+ * and exits non-zero. This mirrors `agent remove`'s existing
216
+ * delete-then-cleanup ordering.
217
+ */
218
+ export async function revokeMcpClient(params, deps = {}) {
219
+ const { name, manifestPath, opsPortOrUrl, adminUser, adminPass, keepKeys } = params;
220
+ const fetchImpl = deps.fetchImpl ?? fetch;
221
+ const manifest = readMcpClientManifest(manifestPath);
222
+ const entry = manifest.find((e) => e.name === name);
223
+ if (!entry) {
224
+ throw new McpClientNotFoundError(name);
225
+ }
226
+ const opsUrl = typeof opsPortOrUrl === "number" ? `http://127.0.0.1:${opsPortOrUrl}/` : `${opsPortOrUrl.replace(/\/$/, "")}/`;
227
+ const authHeader = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
228
+ let delRes;
229
+ try {
230
+ delRes = await fetchImpl(opsUrl, {
231
+ method: "POST",
232
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
233
+ body: JSON.stringify({ operation: "delete", database: "flair", table: "Agent", ids: [entry.agentId] }),
234
+ });
235
+ }
236
+ catch (err) {
237
+ throw new Error(`Server-side revoke failed: could not reach the operations API to delete Agent '${entry.agentId}' ` +
238
+ `(${err?.message ?? err}). Nothing was deleted locally — retry once the instance is reachable.`);
239
+ }
240
+ if (!delRes.ok) {
241
+ const text = await delRes.text().catch(() => "");
242
+ throw new Error(`Server-side revoke failed (HTTP ${delRes.status}) deleting Agent '${entry.agentId}': ${text}. ` +
243
+ `Nothing was deleted locally.`);
244
+ }
245
+ // Server ack received — now safe to clean up locally.
246
+ if (!keepKeys) {
247
+ const { unlinkSync } = await import("node:fs");
248
+ for (const p of [entry.keyFile, entry.pubKeyFile]) {
249
+ if (p && existsSync(p)) {
250
+ try {
251
+ unlinkSync(p);
252
+ }
253
+ catch { /* best effort */ }
254
+ }
255
+ }
256
+ }
257
+ writeMcpClientManifest(manifestPath, manifest.filter((e) => e.name !== name));
258
+ return entry;
259
+ }
260
+ // ─── flair mcp enable / disable / status ────────────────────────────────────
261
+ // flair#719 — the last piece of the paved-paths command family. Automates
262
+ // docs/notes/mcp-oauth-model2.md's 8-step operator checklist into one
263
+ // command, per the design record + K&S verdicts on #719's thread (see
264
+ // src/lib/mcp-enable.ts's module header for the full binding design record,
265
+ // including the scenario addendum: `enable` targets the HOSTED shape only —
266
+ // it runs on the OPERATOR's machine, against a REMOTE instance, and refuses
267
+ // honestly against a local-origin instance rather than walking eight steps
268
+ // toward a connector that can never connect).
269
+ /** Simple y/N confirmation over readline — TTY-only, mirrors the existing
270
+ * restore-confirmation pattern (`flair snapshot restore`) above. */
271
+ async function confirmYesNo(question) {
272
+ if (!process.stdin.isTTY)
273
+ return false;
274
+ const { createInterface } = await import("node:readline");
275
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
276
+ const answer = await new Promise((res) => rl.question(`${question} [y/N] `, (a) => { rl.close(); res(a); }));
277
+ return /^y(es)?$/i.test(answer.trim());
278
+ }
279
+ /** Plain-text readline prompt (tests never exercise this — CLI-only). Used
280
+ * for --idp-client-id/--idp-client-secret/--idp-subject when a flag is
281
+ * omitted and stdin is a TTY. */
282
+ async function promptText(question) {
283
+ const { createInterface } = await import("node:readline");
284
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
285
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
286
+ return answer.trim();
287
+ }
288
+ function printEnableSteps(result) {
289
+ console.log(`\n${render.wrap(render.c.bold, "flair mcp enable")}${result.dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
290
+ for (const s of result.steps) {
291
+ console.log(` ${s.ok ? render.icons.ok : render.icons.error} ${render.wrap(render.c.dim, s.step)}`);
292
+ console.log(` ${s.detail}`);
293
+ }
294
+ console.log("");
295
+ }
296
+ export function register(program) {
297
+ const mcp = program.command("mcp").description("MCP client-credentials agent-auth (RFC 7523 private_key_jwt)");
298
+ mcp
299
+ .command("token")
300
+ .description("Build + sign an RFC 7523 client_assertion and request an MCP client_credentials " +
301
+ "access token. Caches the minted token (in-process) and reuses it until " +
302
+ "near-expiry — use --force-refresh to mint unconditionally.")
303
+ .requiredOption("--agent-id <id>", "Agent id — becomes the client_id (iss/sub claims)")
304
+ .option("--client-id <url>", "Client ID Metadata Document URL for this agent (defaults to this instance's " +
305
+ "MCPClientMetadata URL, derived from FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
306
+ .option("--token-endpoint <url>", "Token-endpoint URL — becomes the `aud` claim (defaults to this instance's " +
307
+ "own oauth token endpoint, same env vars)")
308
+ .option("--resource <url>", "RFC 8707 resource indicator for the token request (defaults to this instance's canonical /mcp URI)")
309
+ .option("--keys-dir <dir>", "Directory to look for <agentId>.key (else FLAIR_KEY_DIR, ~/.flair/keys, ~/.tps/secrets/flair)")
310
+ .option("--expires-in <seconds>", `Assertion exp - iat window, seconds (default + hard cap: ${MAX_ASSERTION_LIFETIME_SECONDS})`)
311
+ .option("--dry-run", "Sign the assertion and print what would be sent, but do not call the token endpoint")
312
+ .option("--force-refresh", "Mint a fresh token even if a cached, not-near-expiry one exists")
313
+ .option("--json", "Print machine-readable JSON instead of a human summary")
314
+ .action(async (opts) => {
315
+ const agentId = opts.agentId;
316
+ const keyPath = resolveAgentKeyPath(agentId, opts.keysDir);
317
+ if (!keyPath) {
318
+ console.error(`Error: no private key found for agent '${agentId}'. Checked --keys-dir, FLAIR_KEY_DIR, ` +
319
+ `~/.flair/keys, and ~/.tps/secrets/flair.`);
320
+ process.exit(1);
321
+ }
322
+ const clientId = opts.clientId ?? defaultMcpClientId(agentId);
323
+ if (!clientId) {
324
+ console.error("Error: --client-id is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive it from " +
325
+ "this instance's MCPClientMetadata URL).");
326
+ process.exit(1);
327
+ }
328
+ const tokenEndpoint = opts.tokenEndpoint ?? defaultMcpTokenEndpoint();
329
+ if (!tokenEndpoint) {
330
+ console.error("Error: --token-endpoint is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive " +
331
+ "this instance's own oauth token endpoint).");
332
+ process.exit(1);
333
+ }
334
+ const resource = opts.resource ?? defaultMcpResource();
335
+ const expiresIn = opts.expiresIn ? Number(opts.expiresIn) : undefined;
336
+ let privateKey;
337
+ try {
338
+ privateKey = loadEd25519PrivateKeyFromFile(keyPath);
339
+ }
340
+ catch (err) {
341
+ console.error(`Error: failed to load private key at ${keyPath}: ${err?.message ?? err}`);
342
+ process.exit(1);
343
+ }
344
+ if (opts.dryRun) {
345
+ const { assertion, claims } = signClientAssertion({
346
+ clientId,
347
+ tokenEndpoint,
348
+ privateKey,
349
+ expiresInSeconds: expiresIn,
350
+ });
351
+ const form = buildTokenRequestForm({ clientId, assertion, resource });
352
+ if (opts.json) {
353
+ console.log(JSON.stringify({ assertion, claims, wouldSendForm: form, tokenEndpoint }, null, 2));
354
+ return;
355
+ }
356
+ console.log(`client_assertion (RFC 7523, EdDSA):\n\n${assertion}\n`);
357
+ console.log(`claims: iss=sub=${claims.iss} aud=${claims.aud} exp-iat=${claims.exp - claims.iat}s jti=${claims.jti}`);
358
+ console.log(`\n--dry-run: NOT sent. This assertion is the client_assertion value for:\n` +
359
+ ` POST ${tokenEndpoint}\n ${JSON.stringify(form, null, 2).split("\n").join("\n ")}`);
360
+ return;
361
+ }
362
+ try {
363
+ const token = await getMcpAccessToken({
364
+ clientId,
365
+ tokenEndpoint,
366
+ privateKey,
367
+ resource,
368
+ expiresInSeconds: expiresIn,
369
+ forceRefresh: Boolean(opts.forceRefresh),
370
+ });
371
+ if (opts.json) {
372
+ console.log(JSON.stringify(token, null, 2));
373
+ return;
374
+ }
375
+ console.log(`access_token minted (${token.tokenType}, expires_in=${token.expiresIn}s):\n\n${token.accessToken}`);
376
+ if (token.scope)
377
+ console.log(`\nscope: ${token.scope}`);
378
+ }
379
+ catch (err) {
380
+ if (err instanceof McpTokenRequestError) {
381
+ console.error(`Error: token request failed (HTTP ${err.status}${err.error ? ` ${err.error}` : ""}): ${err.message}`);
382
+ }
383
+ else {
384
+ console.error(`Error: token request failed: ${err?.message ?? err}`);
385
+ }
386
+ process.exit(1);
387
+ }
388
+ });
389
+ mcp
390
+ .command("grant <name>")
391
+ .description("Provision a named, individually-revocable machine client for the /mcp OAuth surface " +
392
+ "(flair Agent + Ed25519 keypair; the existing CIMD path — see `flair mcp token` — makes it usable).")
393
+ .option("--issuer <url>", "Public origin for the CIMD client_id (defaults to FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
394
+ .option("--keys-dir <dir>", "Directory to write the new key pair into (else FLAIR_KEY_DIR, ~/.flair/keys)")
395
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
396
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
397
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
398
+ .option("--port <port>", "Harper HTTP port")
399
+ .option("--ops-port <port>", "Harper operations API port")
400
+ .option("--json", "Print machine-readable JSON instead of a human summary")
401
+ .action(async (name, opts) => {
402
+ const issuer = opts.issuer ?? defaultMcpIssuer();
403
+ if (!issuer) {
404
+ console.error("Error: --issuer is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL) — the CIMD client_id " +
405
+ "must be a stable, publicly-resolvable URL.");
406
+ process.exit(1);
407
+ }
408
+ // Workflow gate: proof `flair mcp enable` has actually run against this
409
+ // instance — a live probe of the OAuth metadata endpoint (flair#756;
410
+ // replaces the old DCR-gate-token presence check, which a CIMD-only
411
+ // instance legitimately can't satisfy).
412
+ const gate = await selfVerifyMcpMetadata(issuer);
413
+ if (!gate.ok) {
414
+ console.error(`Error: the /mcp OAuth surface isn't answering at ${issuer} (${gate.detail}). Run \`flair mcp enable\` first.`);
415
+ process.exit(1);
416
+ }
417
+ const adminPass = resolveLocalAdminPass(opts.adminPass);
418
+ if (!adminPass) {
419
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for `flair mcp grant` (needed to insert into the Agent table). " +
420
+ "Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
421
+ process.exit(1);
422
+ }
423
+ const keysDir = opts.keysDir ?? defaultKeysDir();
424
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
425
+ const opsPort = resolveOpsPort(opts);
426
+ try {
427
+ const { entry, config } = await grantMcpClient({
428
+ name,
429
+ keysDir,
430
+ manifestPath,
431
+ issuer,
432
+ opsPortOrUrl: opsPort,
433
+ adminUser: resolveAdminUser(opts.adminUser),
434
+ adminPass,
435
+ });
436
+ if (opts.json) {
437
+ console.log(render.asJSON({ entry, config }));
438
+ return;
439
+ }
440
+ console.log(`\n${render.wrap(render.c.bold, `✅ Machine client '${name}' granted`)}\n`);
441
+ console.log(render.kv("client_id", entry.clientId));
442
+ console.log(render.kv("key file", render.wrap(render.c.dim, entry.keyFile)));
443
+ console.log(render.kv("issuer", entry.issuer));
444
+ console.log(`\n${render.wrap(render.c.dim, "Ready-to-paste MCP config (references the key file, never inline key material):")}\n`);
445
+ console.log(render.asJSON(config));
446
+ console.log("");
447
+ }
448
+ catch (err) {
449
+ console.error(`Error: ${err.message}`);
450
+ process.exit(1);
451
+ }
452
+ });
453
+ mcp
454
+ .command("revoke <name>")
455
+ .description("Server-side revoke a granted machine client (deletes its backing Agent record), then clean up locally.")
456
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
457
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
458
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
459
+ .option("--issuer <url>", "Public origin of the /mcp OAuth surface — used only for the enable-gate probe (defaults to FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
460
+ .option("--ops-port <port>", "Harper operations API port")
461
+ .option("--port <port>", "Harper HTTP port")
462
+ .option("--keep-keys", "Do not delete local key files after a successful server-side revoke")
463
+ .action(async (name, opts) => {
464
+ const issuer = opts.issuer ?? defaultMcpIssuer();
465
+ if (!issuer) {
466
+ console.error("Error: --issuer is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL) to verify the /mcp OAuth surface before revoking.");
467
+ process.exit(1);
468
+ }
469
+ // Workflow gate — see the matching comment on `grant` above.
470
+ const gate = await selfVerifyMcpMetadata(issuer);
471
+ if (!gate.ok) {
472
+ console.error(`Error: the /mcp OAuth surface isn't answering at ${issuer} (${gate.detail}). Run \`flair mcp enable\` first.`);
473
+ process.exit(1);
474
+ }
475
+ const adminPass = resolveLocalAdminPass(opts.adminPass);
476
+ if (!adminPass) {
477
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for `flair mcp revoke`.");
478
+ process.exit(1);
479
+ }
480
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
481
+ const opsPort = resolveOpsPort(opts);
482
+ try {
483
+ await revokeMcpClient({
484
+ name,
485
+ manifestPath,
486
+ opsPortOrUrl: opsPort,
487
+ adminUser: resolveAdminUser(opts.adminUser),
488
+ adminPass,
489
+ keepKeys: !!opts.keepKeys,
490
+ });
491
+ console.log(`${render.icons.ok} Machine client '${name}' revoked (server-side Agent record deleted${opts.keepKeys ? "; local keys kept" : " and local keys removed"}).`);
492
+ }
493
+ catch (err) {
494
+ console.error(`${render.icons.error} ${err.message}`);
495
+ process.exit(1);
496
+ }
497
+ });
498
+ mcp
499
+ .command("list")
500
+ .description("List granted machine clients (name, client_id, status, created).")
501
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
502
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
503
+ .action((opts) => {
504
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
505
+ const entries = readMcpClientManifest(manifestPath);
506
+ const mode = render.resolveOutputMode(opts);
507
+ if (mode === "json") {
508
+ console.log(render.asJSON(entries));
509
+ return;
510
+ }
511
+ if (entries.length === 0) {
512
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no machine clients granted (see `flair mcp grant <name>`)")}`);
513
+ return;
514
+ }
515
+ console.log(`${render.wrap(render.c.bold, String(entries.length))} machine client(s)\n`);
516
+ const cols = [
517
+ { label: "name", key: "name", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
518
+ { label: "client_id", key: "clientId", format: (v) => render.wrap(render.c.dim, String(v ?? "—")) },
519
+ { label: "status", key: "status", format: (v) => (v === "active" ? render.wrap(render.c.green, String(v)) : String(v ?? "—")) },
520
+ { label: "created", key: "createdAt", format: (v) => render.relativeTime(v) },
521
+ ];
522
+ console.log(render.table(cols, entries));
523
+ });
524
+ mcp
525
+ .command("enable")
526
+ .description("One-command hosted-shape enablement of the OAuth /mcp surface for claude.ai — automates the " +
527
+ "docs/notes/mcp-oauth-model2.md checklist. Targets a REMOTE instance with a public HTTPS origin; " +
528
+ "refuses honestly against a local-origin instance.")
529
+ .option("--instance <url>", "Remote flair instance to enable against (else FLAIR_URL)")
530
+ .option("--issuer <url>", "Public origin claude.ai will use (else --instance)")
531
+ .option("--idp-provider <name>", "Upstream IdP provider", "github")
532
+ .option("--idp-client-id <id>", "IdP OAuth app client id (else prompted interactively)")
533
+ .option("--idp-client-secret <secret>", "IdP OAuth app client secret (else prompted interactively — prefer the prompt; an inline flag leaks to shell history)")
534
+ .option("--idp-subject <value>", "Your expected `sub`/login at the IdP (GitHub: your username; else prompted interactively)")
535
+ .option("--principal <id>", "Principal (Agent) to map your IdP identity to — personal-shape default", "self")
536
+ .option("--principal-kind <human|agent>", "Kind for a newly-created principal", "human")
537
+ .option("--secrets-mechanism <fabric-env-secrets|env-file>", "Override the shape-aware secrets mechanism (else auto-detected from --instance)")
538
+ .option("--secrets-path <path>", "Override the secrets staging file path")
539
+ .option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
540
+ .option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
541
+ .option("--admin-pass <pass>", "Admin password for the TARGET instance. Required explicitly for a remote target — FLAIR_ADMIN_PASS and ~/.flair/admin-pass are this machine's local credentials and are never sent to a remote instance")
542
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
543
+ .option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
544
+ .option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
545
+ .option("--json", "Print machine-readable JSON instead of a human summary")
546
+ .action(async (opts) => {
547
+ const instance = opts.instance ?? process.env.FLAIR_URL;
548
+ if (!instance) {
549
+ console.error("Error: --instance is required (or set FLAIR_URL) — `flair mcp enable` targets a specific remote instance.");
550
+ process.exit(1);
551
+ }
552
+ // Local-origin refusal short-circuits before we ask for anything else —
553
+ // never walk the operator through IdP app creation for a connector that
554
+ // can never connect.
555
+ const localCheck = checkLocalOriginRefusal(instance);
556
+ if (localCheck.refused) {
557
+ console.error(`${render.icons.error} ${localCheck.message}`);
558
+ process.exit(1);
559
+ }
560
+ const dryRun = Boolean(opts.dryRun);
561
+ // --instance is ALWAYS remote for this command (local is refused above)
562
+ // — isRemoteTarget=true so a missing --admin-pass/FLAIR_ADMIN_PASS never
563
+ // silently falls back to THIS machine's local ~/.flair/admin-pass file
564
+ // against someone else's instance (see resolveLocalAdminPass's doc comment).
565
+ const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
566
+ if (!dryRun && !adminPass) {
567
+ console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for a REMOTE target " +
568
+ "(the operations API on the target instance needs it for identity mapping + restart).\n" +
569
+ " FLAIR_ADMIN_PASS and ~/.flair/admin-pass are deliberately NOT used here: they are THIS machine's " +
570
+ "local admin credentials, and sending them to another instance is how a local secret ends up on someone " +
571
+ "else's Harper. Pass the target's own admin password explicitly.");
572
+ process.exit(1);
573
+ }
574
+ let idpClientId = opts.idpClientId;
575
+ let idpClientSecret = opts.idpClientSecret;
576
+ let idpSubject = opts.idpSubject;
577
+ if (!dryRun && process.stdin.isTTY) {
578
+ if (!idpClientId)
579
+ idpClientId = await promptText(`${opts.idpProvider} OAuth app client id: `);
580
+ if (!idpClientSecret)
581
+ idpClientSecret = await promptText(`${opts.idpProvider} OAuth app client secret: `);
582
+ if (!idpSubject)
583
+ idpSubject = await promptText(`Your expected ${opts.idpProvider} login/sub: `);
584
+ }
585
+ const secretsMechanism = opts.secretsMechanism;
586
+ if (secretsMechanism && secretsMechanism !== "fabric-env-secrets" && secretsMechanism !== "env-file") {
587
+ console.error(`Error: --secrets-mechanism must be "fabric-env-secrets" or "env-file", got "${secretsMechanism}"`);
588
+ process.exit(1);
589
+ }
590
+ const cimdAllowedHosts = opts.cimdAllowedHosts
591
+ ? String(opts.cimdAllowedHosts).split(",").map((h) => h.trim()).filter(Boolean)
592
+ : undefined;
593
+ const result = await enableMcp({
594
+ instance,
595
+ issuer: opts.issuer,
596
+ idpProvider: opts.idpProvider,
597
+ idpClientId,
598
+ idpClientSecret,
599
+ idpSubject,
600
+ principal: opts.principal,
601
+ principalKind: opts.principalKind,
602
+ adminUser: resolveAdminUser(opts.adminUser),
603
+ adminPass,
604
+ signingKeyFilePath: opts.signingKeyFile,
605
+ secretsMechanism,
606
+ secretsStagingPath: opts.secretsPath,
607
+ cimdAllowedHosts,
608
+ dryRun,
609
+ confirmSecretsApplied: Boolean(opts.confirmSecretsApplied),
610
+ }, { confirmPrompt: dryRun ? undefined : confirmYesNo });
611
+ if (opts.json) {
612
+ console.log(render.asJSON(result));
613
+ if (!result.ok)
614
+ process.exit(1);
615
+ return;
616
+ }
617
+ printEnableSteps(result);
618
+ if (result.refused) {
619
+ process.exit(1);
620
+ }
621
+ if (!result.ok) {
622
+ if (result.failedStep === "fabric-operator-deploy") {
623
+ // flair#1136: Fabric deployments require the operator to deploy the
624
+ // config change — we can't write to harperdb-config.yaml (Fabric
625
+ // regenerates it on every container restart).
626
+ console.error(`\n${render.icons.info} ${render.wrap(render.c.bold, "Fabric deployment detected.")}`);
627
+ console.error(` The @harperfast/oauth block ships in your component config.yaml with mcp.enabled: false.`);
628
+ console.error(` To activate: set mcp.enabled: true (literal boolean) in your deployed component`);
629
+ console.error(` config.yaml, ensure the staged secrets are live in the instance's process`);
630
+ console.error(` environment, and redeploy. Then re-run \`flair mcp enable\` — earlier steps`);
631
+ console.error(` are idempotent and will be reused.\n`);
632
+ }
633
+ else {
634
+ console.error(`${render.icons.error} enable failed at step "${result.failedStep}" — see detail above for the exact fix, then re-run \`flair mcp enable\` (earlier steps are idempotent and will be reused).`);
635
+ }
636
+ process.exit(1);
637
+ }
638
+ if (result.dryRun) {
639
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "dry-run: no remote calls were made.")}`);
640
+ return;
641
+ }
642
+ console.log(`${render.icons.ok} ${render.wrap(render.c.bold, "claude.ai can now connect.")}\n`);
643
+ console.log(result.pasteBlock ?? "");
644
+ console.log("");
645
+ });
646
+ mcp
647
+ .command("disable")
648
+ .description("Flag off + restart = byte-identical boot (Model-2 contract) — removes the /mcp OAuth surface.")
649
+ .option("--instance <url>", "Remote flair instance to disable against (else FLAIR_URL)")
650
+ .option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
651
+ .option("--admin-user <name>", "Admin username for Basic auth (env: FLAIR_ADMIN_USER; default: admin)")
652
+ .option("--confirm-flag-off", "Confirm FLAIR_MCP_OAUTH is already unset on the target instance's environment (skips the interactive confirm)")
653
+ .option("--json", "Print machine-readable JSON instead of a human summary")
654
+ .action(async (opts) => {
655
+ const instance = opts.instance ?? process.env.FLAIR_URL;
656
+ if (!instance) {
657
+ console.error("Error: --instance is required (or set FLAIR_URL).");
658
+ process.exit(1);
659
+ }
660
+ // --instance is always remote for this command — see the matching
661
+ // comment in `mcp enable` above.
662
+ const adminPass = resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
663
+ if (!adminPass) {
664
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required.");
665
+ process.exit(1);
666
+ }
667
+ const result = await disableMcp({ instance, adminUser: resolveAdminUser(opts.adminUser), adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
668
+ if (opts.json) {
669
+ console.log(render.asJSON(result));
670
+ if (!result.ok)
671
+ process.exit(1);
672
+ return;
673
+ }
674
+ console.log(`${result.ok ? render.icons.ok : render.icons.error} ${result.detail}`);
675
+ if (!result.ok)
676
+ process.exit(1);
677
+ });
678
+ mcp
679
+ .command("status")
680
+ .description("Surface the /mcp OAuth surface's live state: enabled? CIMD advertised? granted machine-client count.")
681
+ .option("--instance <url>", "Remote flair instance to check (else FLAIR_URL)")
682
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
683
+ .option("--json", "Print machine-readable JSON instead of a human summary")
684
+ .action(async (opts) => {
685
+ const instance = opts.instance ?? process.env.FLAIR_URL;
686
+ if (!instance) {
687
+ console.error("Error: --instance is required (or set FLAIR_URL).");
688
+ process.exit(1);
689
+ }
690
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
691
+ const result = await mcpStatus({ instance }, { countMachineClients: () => readMcpClientManifest(manifestPath).length });
692
+ if (opts.json) {
693
+ console.log(render.asJSON(result));
694
+ return;
695
+ }
696
+ console.log(`\n${render.wrap(render.c.bold, "flair mcp status")}\n`);
697
+ console.log(render.kv("instance", result.instance));
698
+ console.log(render.kv("enabled", result.enabled ? render.wrap(render.c.green, "yes") : render.wrap(render.c.yellow, "no")));
699
+ console.log(render.kv("metadata", result.detail));
700
+ // flair#756: CIMD is the only supported client-registration path — this
701
+ // reflects clientIdMetadataDocuments config presence on the target
702
+ // instance, the only signal `status` can see without admin credentials.
703
+ console.log(render.kv("CIMD", result.cimdSupported ? render.wrap(render.c.green, "advertised") : render.wrap(render.c.yellow, "not advertised")));
704
+ console.log(render.kv("machine clients", String(result.machineClientCount ?? 0)));
705
+ console.log("");
706
+ });
707
+ }