@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.
- package/README.md +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1791 -15648
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1665 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +931 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1628 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/engine-version.js +12 -4
- package/dist/fabric-npm-install.js +87 -0
- package/dist/fabric-upgrade.js +30 -15
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +5 -4
- package/dist/lib/npm-registry.js +578 -0
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +48 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/resources/tool-descriptors/index.js +669 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/dist/version-check.js +29 -8
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +20 -6
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/package.json +4 -4
- 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
|
+
}
|