@tpsdev-ai/flair 0.41.0 → 0.42.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/config.yaml CHANGED
@@ -1,100 +1,29 @@
1
1
  name: flair
2
2
  rest: true
3
-
4
- ## Port is configured via CLI (flair init --port) or HTTP_PORT env var.
5
- ## Omitted here to avoid conflicts with different deployment scenarios.
6
- # http:
7
- # port: 19926
8
-
9
- # Harper does not read a component's `.env` implicitly — it only loads env
10
- # files a component ASKS for, via this plugin. Without this block a `.env`
11
- # sitting next to config.yaml is inert: the file is present and its values
12
- # never reach `process.env`. That is exactly what a deployed instance hit —
13
- # `FLAIR_PUBLIC_URL` was set in the deployed component's `.env` and OAuth
14
- # discovery kept advertising a loopback issuer (flair#1005, #1000).
15
- #
16
- # MUST STAY FIRST. Config keys are iterated in file order by Harper's
17
- # component loader, and each plugin's initial entry load is awaited before
18
- # the next key is processed — so declaring this above `jsResource` is what
19
- # guarantees `process.env` is populated before `dist/resources/*.js` are
20
- # imported. Most consumers read `process.env` per request and would not care
21
- # (resources/OAuth.ts, resources/AdminInstance.ts, resources/XAA.ts,
22
- # resources/a2a-url.ts), but `resources/mcp-oauth.ts` decides at MODULE LOAD
23
- # whether to mount `/mcp`; move this below `jsResource` and that decision is
24
- # made against an env that has not been loaded yet.
25
- #
26
- # No `.env` is required, which is the case for essentially every local
27
- # install: when the glob matches nothing the plugin never fires and emits
28
- # nothing. Measured — a boot log with this block and no `.env` differs from
29
- # one without the block only in the PID and in non-deterministic table-init
30
- # ordering. (A MALFORMED declaration is loud, not silent: a pattern
31
- # containing '..' produced both an `Ignoring invalid loadEnv files pattern`
32
- # warning and a `Could not load component 'loadEnv'` error, which is the
33
- # positive control for that silence.)
34
- #
35
- # Application variables only. Harper composes its OWN configuration before
36
- # component `.env` files load, so Harper-level settings cannot be set this
37
- # way; `HARPER_CONFIG` / `HARPER_DEFAULT_CONFIG` / `HARPER_SET_CONFIG` are
38
- # refused at the injection point and warned about (harper#1513). Those
39
- # belong in the process environment or harper-config.yaml.
40
3
  loadEnv:
41
- files: '.env'
42
-
4
+ files: .env
43
5
  graphqlSchema:
44
6
  files: schemas/*.graphql
45
-
46
7
  jsResource:
47
8
  files: dist/resources/*.js
48
-
49
- # Phase 1 (flair#504): embeddings now run through Harper's native
50
- # models.embed() facade, backed by harper-fabric-embeddings registered as the
51
- # `embedding` backend. That registration is NOT configured here, and (as of
52
- # flair#694) is no longer config-driven anywhere: a `models:` block in THIS
53
- # file would silently never be read (this application always loads with
54
- # `isRoot: false` — components/componentLoader.ts gates `bootstrapModels()`
55
- # on `isRoot`), and the earlier fix for that — reasserting the block into the
56
- # Harper INSTANCE-ROOT config via the HARPER_CONFIG env var on every spawn —
57
- # turned out to PERSIST that block into harper-config.yaml, which an
58
- # older/downgraded build's boot (never having set the env var) would tear
59
- # down to an invalid empty shell and refuse to boot against (flair#694; see
60
- # flair#695 for the invariant this violated). The registration now happens
61
- # in-process instead: `dist/resources/embeddings-boot.js` (built from
62
- # resources/embeddings-boot.ts, loaded by the `jsResource` glob below like
63
- # every other file under resources/) calls harper-fabric-embeddings'
64
- # `register()` factory directly on every boot — nothing is ever written to
65
- # the config file, so there is nothing for a downgrade to trip over. This
66
- # also means a `package:`-style sub-component entry
67
- # (`'harper-fabric-embeddings': { package: ... }`, which used to sit here and
68
- # drove `handleApplication`) still must not be reintroduced alongside it —
69
- # that hook populates a SEPARATE raw-API engine, not `models.embed()`, and
70
- # running both would double-init (two separate EmbeddingEngine instances:
71
- # one unused, one backing models.embed).
72
-
73
9
  authentication:
74
- # Default secure (flair#654): a credential-less loopback request to the
75
- # Harper ops API (:9925) is no longer auto-authorized as super_user. Local
76
- # admin operations now require a real credential — ~/.flair/admin-pass
77
- # (written by `flair init`), --admin-pass, or FLAIR_ADMIN_PASS. flair's own
78
- # application-layer resources were already immune to this forgery (#655's
79
- # credential-evidence gate); this closes the remaining gap below it, in the
80
- # raw Harper ops API itself. Set true only for local development, at your
81
- # own risk.
82
10
  authorizeLocal: false
83
11
  enableSessions: true
84
-
85
- # @harperfast/oauth — authorization server component declaration.
86
- # Commented out by default: the plugin is only needed when FLAIR_MCP_OAUTH=on.
87
- # When you enable the flag without uncommenting this block, the boot guard
88
- # (resources/mcp-oauth.ts) logs an error with the exact YAML to add — uncomment
89
- # the block below and add mcp.* config. The issuer is derived at runtime from
90
- # FLAIR_MCP_ISSUER / FLAIR_PUBLIC_URL; do not hardcode one here.
91
- #
92
- # "@harperfast/oauth":
93
- # providers:
94
- # default:
95
- # authorizationEndpoint: "/OAuthAuthorize"
96
- # tokenEndpoint: "/OAuthToken"
97
- # revocationEndpoint: "/OAuthRevoke"
98
- # registrationEndpoint: "/OAuthRegister"
99
- # jwksUri: "/.well-known/jwks.json"
100
- # discoveryEndpoint: "/.well-known/oauth-authorization-server"
12
+ '@harperfast/oauth':
13
+ package: '@harperfast/oauth'
14
+ providers:
15
+ github:
16
+ clientId: ${OAUTH_GITHUB_CLIENT_ID}
17
+ clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET}
18
+ mcp:
19
+ enabled: false
20
+ issuer: ${FLAIR_MCP_ISSUER}
21
+ resource: ${FLAIR_MCP_ISSUER}/mcp
22
+ accessTokenTtl: 900
23
+ dynamicClientRegistration:
24
+ enabled: false
25
+ clientIdMetadataDocuments:
26
+ allowedHosts:
27
+ - claude.ai
28
+ - claude.com
29
+ signingKeyPem: ${FLAIR_MCP_SIGNING_KEY_PEM}
package/dist/cli.js CHANGED
@@ -19,7 +19,7 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
19
19
  import { probeInstance } from "./probe.js";
20
20
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
21
21
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
22
- import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
22
+ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
23
23
  import { flairCliVersion, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
24
24
  import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
25
25
  import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
@@ -1340,11 +1340,9 @@ function b64url(bytes) {
1340
1340
  * out of scope for this HTTP/REST auth path.
1341
1341
  */
1342
1342
  async function api(method, path, body, options) {
1343
- // Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
1344
- // When baseUrl is provided (--target), use it directly.
1345
- const savedPort = readPortFromConfig();
1346
- const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
1347
- const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
1343
+ // Resolve port via the canonical path (flair#1129): options.baseUrl > FLAIR_URL > resolveHttpPort.
1344
+ // api() callers mean the default install, so resolveHttpPort({}) with no --data-dir is correct.
1345
+ const base = options?.baseUrl ?? (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`);
1348
1346
  // Extract agentId from FLAIR_AGENT_ID env, or the body (POST/PUT) / URL
1349
1347
  // query params (GET) — Harper-CLI-request-shape knowledge, not a generic
1350
1348
  // auth concern, so it stays here rather than in authedRequest.
@@ -3356,7 +3354,13 @@ program
3356
3354
  ? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
3357
3355
  : {};
3358
3356
  const existing = claudeJson.mcpServers?.flair;
3359
- if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
3357
+ const currentSpec = mcpServerSpec();
3358
+ const existingArgs = existing?.args;
3359
+ const argsMatch = Array.isArray(existingArgs) && existingArgs.includes(currentSpec);
3360
+ const urlAgentMatch = existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId;
3361
+ // flair#1135: the pin in `args` must match the current mcpServerSpec().
3362
+ // A matching pin stays a no-op (idempotent); only a stale pin triggers a re-write.
3363
+ if (urlAgentMatch && argsMatch) {
3360
3364
  console.log(` ✓ Claude Code already wired in ~/.claude.json`);
3361
3365
  wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
3362
3366
  }
@@ -3364,11 +3368,15 @@ program
3364
3368
  claudeJson.mcpServers = claudeJson.mcpServers || {};
3365
3369
  claudeJson.mcpServers.flair = flairMcpConfig;
3366
3370
  writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
3367
- const how = claudeJsonExisted ? "wired in ~/.claude.json" : "wired in ~/.claude.json (created)";
3368
- console.log(` ✓ Claude Code ${how} (restart Claude Code to pick it up)`);
3371
+ const action = urlAgentMatch ? "refreshed pin in ~/.claude.json"
3372
+ : claudeJsonExisted ? "wired in ~/.claude.json"
3373
+ : "wired in ~/.claude.json (created)";
3374
+ console.log(` ✓ Claude Code ${action} (restart Claude Code to pick it up)`);
3369
3375
  wiringResults.push({
3370
3376
  client: "claude-code",
3371
- message: claudeJsonExisted ? "wired ~/.claude.json" : "created and wired ~/.claude.json",
3377
+ message: urlAgentMatch ? "refreshed pin in ~/.claude.json"
3378
+ : claudeJsonExisted ? "wired ~/.claude.json"
3379
+ : "created and wired ~/.claude.json",
3372
3380
  wired: true,
3373
3381
  });
3374
3382
  }
@@ -4811,7 +4819,7 @@ mcp
4811
4819
  const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
4812
4820
  if (!dryRun && !adminPass) {
4813
4821
  console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for a REMOTE target " +
4814
- "(the operations API on the target instance needs it for identity mapping + set_configuration + restart).\n" +
4822
+ "(the operations API on the target instance needs it for identity mapping + restart).\n" +
4815
4823
  " FLAIR_ADMIN_PASS and ~/.flair/admin-pass are deliberately NOT used here: they are THIS machine's " +
4816
4824
  "local admin credentials, and sending them to another instance is how a local secret ends up on someone " +
4817
4825
  "else's Harper. Pass the target's own admin password explicitly.");
@@ -4865,7 +4873,20 @@ mcp
4865
4873
  process.exit(1);
4866
4874
  }
4867
4875
  if (!result.ok) {
4868
- 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).`);
4876
+ if (result.failedStep === "fabric-operator-deploy") {
4877
+ // flair#1136: Fabric deployments require the operator to deploy the
4878
+ // config change — we can't write to harperdb-config.yaml (Fabric
4879
+ // regenerates it on every container restart).
4880
+ console.error(`\n${render.icons.info} ${render.wrap(render.c.bold, "Fabric deployment detected.")}`);
4881
+ console.error(` The @harperfast/oauth block ships in your component config.yaml with mcp.enabled: false.`);
4882
+ console.error(` To activate: set mcp.enabled: true (literal boolean) in your deployed component`);
4883
+ console.error(` config.yaml, ensure the staged secrets are live in the instance's process`);
4884
+ console.error(` environment, and redeploy. Then re-run \`flair mcp enable\` — earlier steps`);
4885
+ console.error(` are idempotent and will be reused.\n`);
4886
+ }
4887
+ else {
4888
+ 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).`);
4889
+ }
4869
4890
  process.exit(1);
4870
4891
  }
4871
4892
  if (result.dryRun) {
@@ -9866,6 +9887,55 @@ program
9866
9887
  authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
9867
9888
  });
9868
9889
  const verdict = decideAfterVerify(verify, previousFlairVersion);
9890
+ // ── Refresh wired MCP client configs (flair#1135) ──────────────────────
9891
+ // After a successful upgrade, the flair-mcp package on disk is newer than
9892
+ // the pinned version in wired client configs. Re-run wiring for
9893
+ // already-wired clients so the pin stays in lockstep with the installed
9894
+ // version. Best-effort: failures warn but never fail the upgrade.
9895
+ const refreshWiredClients = async () => {
9896
+ const agentId = resolveAgentIdOrEnv({}) ?? (() => {
9897
+ try {
9898
+ const keyFiles = readdirSync(defaultKeysDir()).filter((f) => f.endsWith(".key"));
9899
+ return keyFiles.length > 0 ? keyFiles[0].replace(/\.key$/, "") : null;
9900
+ }
9901
+ catch {
9902
+ return null;
9903
+ }
9904
+ })();
9905
+ if (!agentId) {
9906
+ console.log("\n (no agent id known — skip MCP client pin refresh; run `flair init` to refresh manually)");
9907
+ return;
9908
+ }
9909
+ const httpUrl = `http://127.0.0.1:${upgradePort}`;
9910
+ const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
9911
+ const detected = detectClients().filter(c => c.detected);
9912
+ if (detected.length === 0)
9913
+ return;
9914
+ console.log("\n Refreshing MCP client pins...");
9915
+ for (const client of detected) {
9916
+ const configPath = clientConfigPath(client.id);
9917
+ if (!existsSync(configPath))
9918
+ continue;
9919
+ // Only refresh clients that are already wired — don't wire new ones.
9920
+ let hasFlair = false;
9921
+ try {
9922
+ const raw = readFileSync(configPath, "utf-8");
9923
+ if (client.id === "codex") {
9924
+ hasFlair = codexConfigHasFlairSection(raw);
9925
+ }
9926
+ else {
9927
+ const cfg = JSON.parse(raw);
9928
+ hasFlair = !!cfg.mcpServers?.flair;
9929
+ }
9930
+ }
9931
+ catch { /* unreadable/malformed — skip */ }
9932
+ if (!hasFlair)
9933
+ continue;
9934
+ const env = { ...mcpEnv, FLAIR_CLIENT: client.id };
9935
+ const result = client.wire(env);
9936
+ console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
9937
+ }
9938
+ };
9869
9939
  if (verdict.kind === "ok") {
9870
9940
  // flair#1022: the verified facts are unchanged and still stated — the
9871
9941
  // upgrade did land. What changes is the MARKER and the claim around it.
@@ -9881,6 +9951,7 @@ program
9881
9951
  else
9882
9952
  console.log(line);
9883
9953
  }
9954
+ await refreshWiredClients();
9884
9955
  return;
9885
9956
  }
9886
9957
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -9907,6 +9978,7 @@ program
9907
9978
  console.error(line);
9908
9979
  }
9909
9980
  }
9981
+ await refreshWiredClients();
9910
9982
  return;
9911
9983
  }
9912
9984
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -15508,8 +15580,7 @@ program
15508
15580
  if (!dryRun) {
15509
15581
  console.log(` Writing to Flair...`);
15510
15582
  try {
15511
- const DEFAULT_PORT = 19926;
15512
- const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
15583
+ const httpUrl = `http://127.0.0.1:${resolveHttpPort({})}`;
15513
15584
  const agentKeyId = `${agentId}.key`;
15514
15585
  const keysDir = join(homedir(), ".flair", "keys");
15515
15586
  const keyPath = join(keysDir, agentKeyId);
@@ -165,6 +165,40 @@ export function appendCodexFlairBlock(raw, env) {
165
165
  const separator = raw.length === 0 ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
166
166
  return raw + separator + tomlSnippet(env) + "\n";
167
167
  }
168
+ /**
169
+ * flair#1135: does the existing `[mcp_servers.flair]` TOML section carry the
170
+ * CURRENT pinned mcpServerSpec()? Pure string scan — no TOML parser needed
171
+ * (same rationale as codexConfigHasFlairSection).
172
+ */
173
+ function codexFlairSectionHasCurrentPin(raw) {
174
+ const idx = raw.indexOf("[mcp_servers.flair]");
175
+ if (idx === -1)
176
+ return false;
177
+ const after = raw.slice(idx);
178
+ // Find the end of the section: the next top-level [header] that is NOT a
179
+ // sub-table of mcp_servers.flair (e.g. [mcp_servers.flair.env] is part of
180
+ // the same logical section and must not terminate the scan).
181
+ const nextHeader = after.slice("[mcp_servers.flair]".length).search(/\n\[(?!mcp_servers\.flair\.)/);
182
+ const section = nextHeader === -1 ? after : after.slice(0, "[mcp_servers.flair]".length + nextHeader);
183
+ return section.includes(mcpServerSpec());
184
+ }
185
+ /**
186
+ * flair#1135: replace the existing `[mcp_servers.flair]` TOML section with a
187
+ * fresh one carrying the current pin. Preserves everything else in the file.
188
+ */
189
+ function replaceCodexFlairBlock(raw, env) {
190
+ const idx = raw.indexOf("[mcp_servers.flair]");
191
+ if (idx === -1)
192
+ return appendCodexFlairBlock(raw, env);
193
+ const before = raw.slice(0, idx);
194
+ const after = raw.slice(idx);
195
+ const nextHeader = after.slice("[mcp_servers.flair]".length).search(/\n\[(?!mcp_servers\.flair\.)/);
196
+ const rest = nextHeader === -1 ? "" : after.slice("[mcp_servers.flair]".length + nextHeader);
197
+ const newBlock = tomlSnippet(env) + "\n";
198
+ // Preserve the separator between the new block and whatever follows.
199
+ const sep = rest.length === 0 ? "" : rest.startsWith("\n") ? "" : "\n";
200
+ return before + newBlock + sep + rest;
201
+ }
168
202
  /**
169
203
  * Merge the Flair MCP server into a JSON config file with an `mcpServers` map.
170
204
  * Creates the file (and parent dir) if absent; preserves existing servers and
@@ -182,13 +216,20 @@ function wireJsonMcp(configPath, label, env) {
182
216
  }
183
217
  config.mcpServers = config.mcpServers || {};
184
218
  const existing = config.mcpServers.flair;
185
- if (existing && existing.env?.FLAIR_URL === env.FLAIR_URL && existing.env?.FLAIR_AGENT_ID === env.FLAIR_AGENT_ID) {
219
+ const currentSpec = mcpServerSpec();
220
+ const existingArgs = existing?.args;
221
+ const argsMatch = Array.isArray(existingArgs) && existingArgs.includes(currentSpec);
222
+ const urlAgentMatch = existing && existing.env?.FLAIR_URL === env.FLAIR_URL && existing.env?.FLAIR_AGENT_ID === env.FLAIR_AGENT_ID;
223
+ // flair#1135: the pin in `args` must match the current mcpServerSpec().
224
+ // A matching pin stays a no-op (idempotent); only a stale pin triggers a re-write.
225
+ if (urlAgentMatch && argsMatch) {
186
226
  return { ok: true, message: `${label}: already wired in ${display}` };
187
227
  }
188
228
  config.mcpServers.flair = flairMcpEntry(env);
189
229
  mkdirSync(dirname(configPath), { recursive: true });
190
230
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
191
- return { ok: true, message: `${label}: wired ${display} (restart ${label} to pick it up)` };
231
+ const action = urlAgentMatch ? "refreshed pin in" : "wired";
232
+ return { ok: true, message: `${label}: ${action} ${display} (restart ${label} to pick it up)` };
192
233
  }
193
234
  catch (err) {
194
235
  const reason = err instanceof Error ? err.message : String(err);
@@ -250,17 +291,23 @@ function _wireCodex(env) {
250
291
  // parser, but appending a new top-level table at EOF is safe TOML when the
251
292
  // exact header isn't already present (flair#727) — so an existing file only
252
293
  // forces the manual-print fallback when it's genuinely unreadable/
253
- // unwritable (permissions, I/O error), never merely "exists". A file that
254
- // already has the section is reported already-wired, matching the JSON
255
- // clients' idempotency (wireJsonMcp above).
294
+ // unwritable (permissions, I/O error), never merely "exists".
295
+ //
296
+ // flair#1135: the "already wired" check is now version-aware — a section
297
+ // with a stale pin triggers a re-write instead of a no-op.
256
298
  const path = codexConfigPath();
257
299
  const display = "~/.codex/config.toml";
258
300
  try {
259
301
  if (existsSync(path)) {
260
302
  const raw = readFileSync(path, "utf-8");
261
- if (codexConfigHasFlairSection(raw)) {
303
+ if (codexFlairSectionHasCurrentPin(raw)) {
262
304
  return { ok: true, message: `Codex: already wired in ${display}` };
263
305
  }
306
+ if (codexConfigHasFlairSection(raw)) {
307
+ // Section exists but pin is stale — replace it.
308
+ writeFileSync(path, replaceCodexFlairBlock(raw, env));
309
+ return { ok: true, message: `Codex: refreshed pin in ${display} (restart Codex to pick it up)` };
310
+ }
264
311
  writeFileSync(path, appendCodexFlairBlock(raw, env));
265
312
  return { ok: true, message: `Codex: wired ${display} (restart Codex to pick it up)` };
266
313
  }
@@ -144,6 +144,7 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from "n
144
144
  import { homedir } from "node:os";
145
145
  import { join, dirname } from "node:path";
146
146
  import { generateKeyPairSync, randomBytes } from "node:crypto";
147
+ import yaml from "js-yaml";
147
148
  // ─── CIMD constants ──────────────────────────────────────────────────────────
148
149
  /** Default `clientIdMetadataDocuments.allowedHosts` allowlist — see the
149
150
  * module header's "claude.ai CIMD/redirect-URI allowlist hosts" note for
@@ -303,6 +304,7 @@ export function buildMcpOAuthConfigBlock(params) {
303
304
  const provider = params.idpProvider;
304
305
  const envPrefix = `OAUTH_${provider.toUpperCase()}`;
305
306
  const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
307
+ const enabled = params.enabled ?? true;
306
308
  return {
307
309
  "@harperfast/oauth": {
308
310
  package: "@harperfast/oauth",
@@ -313,7 +315,7 @@ export function buildMcpOAuthConfigBlock(params) {
313
315
  },
314
316
  },
315
317
  mcp: {
316
- enabled: true,
318
+ enabled,
317
319
  issuer: "${FLAIR_MCP_ISSUER}",
318
320
  resource: "${FLAIR_MCP_ISSUER}/mcp",
319
321
  accessTokenTtl: REQUIRED_ACCESS_TOKEN_TTL,
@@ -331,6 +333,85 @@ export function buildMcpOAuthConfigBlock(params) {
331
333
  },
332
334
  };
333
335
  }
336
+ // ─── Local config.yaml update (flair#1136) ──────────────────────────────────
337
+ /**
338
+ * Flip mcp.enabled in a local component config.yaml. Best-effort: returns
339
+ * `{ ok: false }` with a reason when the file can't be found or parsed.
340
+ *
341
+ * Looks for config.yaml at `explicitPath`, then `./config.yaml`, then
342
+ * `~/.flair/config.yaml`. When found, replaces `mcp:\n enabled: false`
343
+ * with `mcp:\n enabled: true` (exact string match — avoids a YAML parser
344
+ * dependency for a single boolean flip).
345
+ */
346
+ export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
347
+ const candidates = explicitPath
348
+ ? [explicitPath]
349
+ : ["config.yaml", join(homedir(), ".flair", "config.yaml")];
350
+ let configPath = null;
351
+ for (const p of candidates) {
352
+ if (existsSync(p)) {
353
+ configPath = p;
354
+ break;
355
+ }
356
+ }
357
+ if (!configPath) {
358
+ return {
359
+ ok: false,
360
+ detail: `local config.yaml not found (tried: ${candidates.join(", ")}). ` +
361
+ `Set mcp.enabled: ${enabled} in your component config.yaml manually, then restart.`,
362
+ };
363
+ }
364
+ let raw;
365
+ try {
366
+ raw = readFileSync(configPath, "utf-8");
367
+ }
368
+ catch (err) {
369
+ return { ok: false, detail: `cannot read ${configPath}: ${err.message}` };
370
+ }
371
+ // Parse the YAML to navigate to the exact key — avoids the ambiguity of
372
+ // string-matching `enabled:` when the block has multiple enabled keys
373
+ // (mcp.enabled vs dynamicClientRegistration.enabled).
374
+ let doc;
375
+ try {
376
+ doc = yaml.load(raw);
377
+ }
378
+ catch (err) {
379
+ return { ok: false, detail: `cannot parse ${configPath} as YAML: ${err.message}` };
380
+ }
381
+ if (!doc || typeof doc !== "object") {
382
+ return { ok: false, detail: `${configPath} is empty or not a YAML mapping` };
383
+ }
384
+ const oauth = doc["@harperfast/oauth"];
385
+ if (!oauth || typeof oauth !== "object") {
386
+ return {
387
+ ok: false,
388
+ detail: `@harperfast/oauth block not found in ${configPath}. ` +
389
+ `Ensure the component block is present with mcp.enabled: ${enabled}.`,
390
+ };
391
+ }
392
+ const mcp = oauth.mcp;
393
+ if (!mcp || typeof mcp !== "object") {
394
+ return {
395
+ ok: false,
396
+ detail: `mcp key not found under @harperfast/oauth in ${configPath}. ` +
397
+ `Ensure the mcp block is present with enabled: ${enabled}.`,
398
+ };
399
+ }
400
+ const current = mcp.enabled;
401
+ if (current === enabled) {
402
+ return { ok: true, detail: `mcp.enabled already ${enabled} in ${configPath}` };
403
+ }
404
+ // Mutate the parsed document and re-emit.
405
+ mcp.enabled = enabled;
406
+ const updated = yaml.dump(doc, { lineWidth: -1, noCompatMode: true });
407
+ try {
408
+ writeFileSync(configPath, updated, { encoding: "utf-8" });
409
+ }
410
+ catch (err) {
411
+ return { ok: false, detail: `cannot write ${configPath}: ${err.message}` };
412
+ }
413
+ return { ok: true, detail: `mcp.enabled set to ${enabled} in ${configPath}` };
414
+ }
334
415
  /** The exact callback URL to hand the operator when they create the IdP
335
416
  * OAuth app ("with the exact GitHub callback URL printed"). */
336
417
  export function idpCallbackUrl(issuer, idpProvider) {
@@ -871,11 +952,13 @@ export async function enableMcp(params, deps = {}) {
871
952
  currentStep = "signing-key";
872
953
  const keyResult = ensureSigningKeyFile(params.signingKeyFilePath, { generate: deps.generateRsaKeyPair });
873
954
  push(true, `signing key ${keyResult.reused ? "reused" : "generated"} at ${keyResult.path} (0600)`);
874
- // ── @harperfast/oauth config block (CIMD-only; DCR explicitly disabled) ──
955
+ // ── @harperfast/oauth config (flair#1136: shipped in config.yaml) ──────
956
+ // The block ships uncommented with mcp.enabled: false (inert default).
957
+ // set_configuration is removed — the block lives in the component's own
958
+ // config.yaml, not in harperdb-config.yaml where Fabric would wipe it.
875
959
  const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
876
960
  currentStep = "config-block";
877
- const configBlock = buildMcpOAuthConfigBlock({ idpProvider, cimdAllowedHosts });
878
- push(true, `built the @harperfast/oauth mcp config block (accessTokenTtl=${REQUIRED_ACCESS_TOKEN_TTL}, ` +
961
+ push(true, `@harperfast/oauth config ships in config.yaml (mcp.enabled=false, ` +
879
962
  `dynamicClientRegistration.enabled=false, clientIdMetadataDocuments.allowedHosts=${JSON.stringify(cimdAllowedHosts)})`);
880
963
  // ── IdP OAuth-app credential intake ───────────────────────────────────────
881
964
  currentStep = "idp-credentials";
@@ -973,18 +1056,54 @@ export async function enableMcp(params, deps = {}) {
973
1056
  }
974
1057
  if (!confirmed) {
975
1058
  push(false, `not applied: pass --confirm-secrets-applied once the staged secrets are live on ${params.instance}, then re-run \`flair mcp enable\` (earlier steps are idempotent and will reuse what's already provisioned).`);
976
- return { ok: false, dryRun, steps, failedStep: "apply-config-and-restart", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
1059
+ return { ok: false, dryRun, steps, failedStep: "secrets-provisioning", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
1060
+ }
1061
+ // ── flair#1136: config delivery is now SHIPPED in config.yaml ────────────
1062
+ // The @harperfast/oauth block ships uncommented with mcp.enabled: false
1063
+ // (inert default). set_configuration is REMOVED — Fabric regenerates
1064
+ // harperdb-config.yaml on every container restart, so writing the block
1065
+ // there was always a race against the next deploy. Instead:
1066
+ //
1067
+ // - Standalone-local: flip mcp.enabled to true in the local config.yaml,
1068
+ // restart, self-verify.
1069
+ // - Fabric: the operator must set mcp.enabled: true in their deployed
1070
+ // component config.yaml. Report the requirement LOUDLY — never report
1071
+ // success with /mcp still dark.
1072
+ const isFabric = isFabricOrigin(params.instance);
1073
+ if (isFabric) {
1074
+ // ── Fabric: operator-deploy requirement ──────────────────────────────
1075
+ currentStep = "fabric-operator-deploy";
1076
+ const msg = [
1077
+ `Fabric deployment detected (${new URL(params.instance).hostname}).`,
1078
+ `The @harperfast/oauth block ships in config.yaml with mcp.enabled: false.`,
1079
+ `To activate: set mcp.enabled: true (literal boolean) in your deployed component config.yaml,`,
1080
+ `ensure the staged secrets are live in the instance's process environment, and redeploy.`,
1081
+ `Then re-run \`flair mcp enable\` — earlier steps are idempotent and will be reused.`,
1082
+ ].join(" ");
1083
+ push(false, msg);
1084
+ return {
1085
+ ok: false,
1086
+ dryRun,
1087
+ steps,
1088
+ failedStep: "fabric-operator-deploy",
1089
+ issuer,
1090
+ resource: `${issuer}/mcp`,
1091
+ secretsMechanism: secretsResult.mechanism,
1092
+ secretsPath: secretsResult.path,
1093
+ signingKeyFilePath: keyResult.path,
1094
+ callbackUrl,
1095
+ };
977
1096
  }
978
- currentStep = "apply-config-and-restart";
979
- // ── Capture boot discriminator BEFORE restart (flair#1120) ─────────────
1097
+ // ── Standalone (non-Fabric): update local config + restart ────────────
1098
+ currentStep = "local-config-update";
1099
+ const localConfigResult = updateLocalConfigMcpEnabled(true, params.localConfigPath);
1100
+ push(localConfigResult.ok, localConfigResult.detail);
1101
+ // ── Restart ───────────────────────────────────────────────────────────
1102
+ currentStep = "restart";
980
1103
  const preDiscriminator = await captureBootDiscriminator(params.instance, params.adminUser, params.adminPass, { fetchImpl: deps.fetchImpl });
981
- await applyRemoteConfigAndRestart({ opsPortOrUrl: params.instance, adminUser: params.adminUser, adminPass: params.adminPass, configBlock }, { fetchImpl: deps.fetchImpl });
982
- push(true, `set_configuration + restart succeeded against ${params.instance}`);
1104
+ await triggerRemoteRestart(params.instance, params.adminUser, params.adminPass, { fetchImpl: deps.fetchImpl });
1105
+ push(true, `restart triggered against ${params.instance}`);
983
1106
  // ── Verify the process actually restarted (flair#1120) ──────────────────
984
- // Poll the ops API until the PID changes — the old process can briefly
985
- // still answer after a real restart, so a single post-capture is unreliable.
986
- // waitForOpsApi guarantees PID change (or throws on timeout), so the restart
987
- // is confirmed when this call returns.
988
1107
  currentStep = "verify-restart";
989
1108
  const postDiscriminator = await waitForOpsApi(resolveOpsUrl(params.instance), basicAuthHeader(params.adminUser, params.adminPass), preDiscriminator.pid, {
990
1109
  fetchImpl: deps.fetchImpl,
@@ -996,7 +1115,7 @@ export async function enableMcp(params, deps = {}) {
996
1115
  currentStep = "self-verify";
997
1116
  const verify = await selfVerifyMcpMetadata(issuer, { fetchImpl: deps.fetchImpl });
998
1117
  if (!verify.ok) {
999
- push(false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry the apply-config-and-restart step.`);
1118
+ push(false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry.`);
1000
1119
  return {
1001
1120
  ok: false,
1002
1121
  dryRun,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.41.0",
3
+ "version": "0.42.0",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",