@tpsdev-ai/flair 0.41.0 → 0.44.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";
@@ -136,6 +136,7 @@ function shouldShowInlineSecretWarning(optValue, fromEnv, secretFlagNames, flagN
136
136
  // ─── Defaults ────────────────────────────────────────────────────────────────
137
137
  const DEFAULT_PORT = 19926;
138
138
  const DEFAULT_OPS_PORT = 19925;
139
+ const FABRIC_OPS_PORT = 9925;
139
140
  const DEFAULT_ADMIN_USER = "admin";
140
141
  const STARTUP_TIMEOUT_MS = 60_000;
141
142
  const HEALTH_POLL_INTERVAL_MS = 500;
@@ -1076,10 +1077,15 @@ function resolveOpsTarget(opts) {
1076
1077
  return opts.opsTarget || process.env.FLAIR_OPS_TARGET || undefined;
1077
1078
  }
1078
1079
  /** Derive the ops API URL from a Flair base URL.
1079
- * Convention: ops port = HTTP port - 1.
1080
- * If target has an explicit port, use port-1 (validated: must be 1-65535).
1081
- * If no explicit port: https → 442 (443-1), http → 19925 (19926-1), bare host → https://<host>:19925.
1082
- * Throws on unparseable URLs.
1080
+ * https with effective port 443 (no explicit port, or explicit :443): returns
1081
+ * <host>:9925 (FABRIC_OPS_PORT) the Fabric managed case where port-1/:442
1082
+ * is a dead-end.
1083
+ * All other cases unchanged:
1084
+ * https with non-443 explicit port → port-1 (self-hosted TLS: 19926→19925, 8443→8442)
1085
+ * http with explicit port → port-1 (19926→19925)
1086
+ * http with no port → DEFAULT_OPS_PORT (19925)
1087
+ * Bare hosts are normalised to https:// (effective-443 → Fabric path).
1088
+ * Throws on unparseable URLs or out-of-range ports.
1083
1089
  */
1084
1090
  /** Compute the effective ops API URL for remote commands.
1085
1091
  * - If --ops-target is set, use it directly (no derivation).
@@ -1099,6 +1105,17 @@ function resolveOpsUrlFromTarget(targetUrl) {
1099
1105
  // Normalise bare hosts: add https:// prefix so URL parser can handle them.
1100
1106
  const normalised = targetUrl.includes("://") ? targetUrl : `https://${targetUrl}`;
1101
1107
  const url = new URL(normalised);
1108
+ // https target with effective port 443 (no explicit port, or explicit :443):
1109
+ // this is the Fabric managed case — the ops API is on the well-known Fabric
1110
+ // ops port, never REST-adjacent. The port-1 / :442 logic is a dead-end here.
1111
+ if (url.protocol === "https:" && (url.port === "" || url.port === "443")) {
1112
+ url.port = String(FABRIC_OPS_PORT);
1113
+ return url.toString().replace(/\/$/, "");
1114
+ }
1115
+ // All other cases: unchanged port-1 convention.
1116
+ // https with non-443 explicit port → port-1 (self-hosted TLS: 19926→19925, 8443→8442)
1117
+ // http with explicit port → port-1 (19926→19925)
1118
+ // http with no port → DEFAULT_OPS_PORT (19925)
1102
1119
  const port = parseInt(url.port, 10);
1103
1120
  if (!isNaN(port) && port > 0 && port <= 65535) {
1104
1121
  const opsPort = port - 1;
@@ -1111,13 +1128,8 @@ function resolveOpsUrlFromTarget(targetUrl) {
1111
1128
  if (url.port !== "" && url.port !== undefined) {
1112
1129
  throw new Error(`Invalid target port: ${url.port} (must be 1-65535)`);
1113
1130
  }
1114
- // No explicit port — infer from scheme
1115
- if (url.protocol === "https:") {
1116
- url.port = "442";
1117
- }
1118
- else {
1119
- url.port = String(DEFAULT_OPS_PORT);
1120
- }
1131
+ // No explicit port on http use the default ops port.
1132
+ url.port = String(DEFAULT_OPS_PORT);
1121
1133
  return url.toString().replace(/\/$/, "");
1122
1134
  }
1123
1135
  /**
@@ -1340,11 +1352,9 @@ function b64url(bytes) {
1340
1352
  * out of scope for this HTTP/REST auth path.
1341
1353
  */
1342
1354
  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);
1355
+ // Resolve port via the canonical path (flair#1129): options.baseUrl > FLAIR_URL > resolveHttpPort.
1356
+ // api() callers mean the default install, so resolveHttpPort({}) with no --data-dir is correct.
1357
+ const base = options?.baseUrl ?? (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`);
1348
1358
  // Extract agentId from FLAIR_AGENT_ID env, or the body (POST/PUT) / URL
1349
1359
  // query params (GET) — Harper-CLI-request-shape knowledge, not a generic
1350
1360
  // auth concern, so it stays here rather than in authedRequest.
@@ -1784,12 +1794,21 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
1784
1794
  export async function callOpsApi(opsUrl, body, user, pass) {
1785
1795
  const url = `${opsUrl.replace(/\/$/, "")}/`;
1786
1796
  const auth = Buffer.from(`${user}:${pass}`).toString("base64");
1787
- const res = await fetch(url, {
1788
- method: "POST",
1789
- headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
1790
- body: JSON.stringify(body),
1791
- signal: AbortSignal.timeout(30_000),
1792
- });
1797
+ let res;
1798
+ try {
1799
+ res = await fetch(url, {
1800
+ method: "POST",
1801
+ headers: { "Content-Type": "application/json", ...(auth ? { Authorization: `Basic ${auth}` } : {}) },
1802
+ body: JSON.stringify(body),
1803
+ signal: AbortSignal.timeout(30_000),
1804
+ });
1805
+ }
1806
+ catch (err) {
1807
+ const message = err instanceof Error ? err.message : String(err);
1808
+ throw new Error(`ops API unreachable at ${opsUrl} (derived from --target). ` +
1809
+ `Set --ops-target or FLAIR_OPS_TARGET to override. ` +
1810
+ `(${message})`);
1811
+ }
1793
1812
  if (!res.ok) {
1794
1813
  const text = await res.text().catch(() => "");
1795
1814
  throw new Error(`Ops API call failed (${res.status}): ${text}`);
@@ -3356,7 +3375,13 @@ program
3356
3375
  ? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
3357
3376
  : {};
3358
3377
  const existing = claudeJson.mcpServers?.flair;
3359
- if (existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId) {
3378
+ const currentSpec = mcpServerSpec();
3379
+ const existingArgs = existing?.args;
3380
+ const argsMatch = Array.isArray(existingArgs) && existingArgs.includes(currentSpec);
3381
+ const urlAgentMatch = existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId;
3382
+ // flair#1135: the pin in `args` must match the current mcpServerSpec().
3383
+ // A matching pin stays a no-op (idempotent); only a stale pin triggers a re-write.
3384
+ if (urlAgentMatch && argsMatch) {
3360
3385
  console.log(` ✓ Claude Code already wired in ~/.claude.json`);
3361
3386
  wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
3362
3387
  }
@@ -3364,11 +3389,15 @@ program
3364
3389
  claudeJson.mcpServers = claudeJson.mcpServers || {};
3365
3390
  claudeJson.mcpServers.flair = flairMcpConfig;
3366
3391
  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)`);
3392
+ const action = urlAgentMatch ? "refreshed pin in ~/.claude.json"
3393
+ : claudeJsonExisted ? "wired in ~/.claude.json"
3394
+ : "wired in ~/.claude.json (created)";
3395
+ console.log(` ✓ Claude Code ${action} (restart Claude Code to pick it up)`);
3369
3396
  wiringResults.push({
3370
3397
  client: "claude-code",
3371
- message: claudeJsonExisted ? "wired ~/.claude.json" : "created and wired ~/.claude.json",
3398
+ message: urlAgentMatch ? "refreshed pin in ~/.claude.json"
3399
+ : claudeJsonExisted ? "wired ~/.claude.json"
3400
+ : "created and wired ~/.claude.json",
3372
3401
  wired: true,
3373
3402
  });
3374
3403
  }
@@ -4811,7 +4840,7 @@ mcp
4811
4840
  const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
4812
4841
  if (!dryRun && !adminPass) {
4813
4842
  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" +
4843
+ "(the operations API on the target instance needs it for identity mapping + restart).\n" +
4815
4844
  " FLAIR_ADMIN_PASS and ~/.flair/admin-pass are deliberately NOT used here: they are THIS machine's " +
4816
4845
  "local admin credentials, and sending them to another instance is how a local secret ends up on someone " +
4817
4846
  "else's Harper. Pass the target's own admin password explicitly.");
@@ -4865,7 +4894,20 @@ mcp
4865
4894
  process.exit(1);
4866
4895
  }
4867
4896
  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).`);
4897
+ if (result.failedStep === "fabric-operator-deploy") {
4898
+ // flair#1136: Fabric deployments require the operator to deploy the
4899
+ // config change — we can't write to harperdb-config.yaml (Fabric
4900
+ // regenerates it on every container restart).
4901
+ console.error(`\n${render.icons.info} ${render.wrap(render.c.bold, "Fabric deployment detected.")}`);
4902
+ console.error(` The @harperfast/oauth block ships in your component config.yaml with mcp.enabled: false.`);
4903
+ console.error(` To activate: set mcp.enabled: true (literal boolean) in your deployed component`);
4904
+ console.error(` config.yaml, ensure the staged secrets are live in the instance's process`);
4905
+ console.error(` environment, and redeploy. Then re-run \`flair mcp enable\` — earlier steps`);
4906
+ console.error(` are idempotent and will be reused.\n`);
4907
+ }
4908
+ else {
4909
+ 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).`);
4910
+ }
4869
4911
  process.exit(1);
4870
4912
  }
4871
4913
  if (result.dryRun) {
@@ -9866,6 +9908,55 @@ program
9866
9908
  authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
9867
9909
  });
9868
9910
  const verdict = decideAfterVerify(verify, previousFlairVersion);
9911
+ // ── Refresh wired MCP client configs (flair#1135) ──────────────────────
9912
+ // After a successful upgrade, the flair-mcp package on disk is newer than
9913
+ // the pinned version in wired client configs. Re-run wiring for
9914
+ // already-wired clients so the pin stays in lockstep with the installed
9915
+ // version. Best-effort: failures warn but never fail the upgrade.
9916
+ const refreshWiredClients = async () => {
9917
+ const agentId = resolveAgentIdOrEnv({}) ?? (() => {
9918
+ try {
9919
+ const keyFiles = readdirSync(defaultKeysDir()).filter((f) => f.endsWith(".key"));
9920
+ return keyFiles.length > 0 ? keyFiles[0].replace(/\.key$/, "") : null;
9921
+ }
9922
+ catch {
9923
+ return null;
9924
+ }
9925
+ })();
9926
+ if (!agentId) {
9927
+ console.log("\n (no agent id known — skip MCP client pin refresh; run `flair init` to refresh manually)");
9928
+ return;
9929
+ }
9930
+ const httpUrl = `http://127.0.0.1:${upgradePort}`;
9931
+ const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
9932
+ const detected = detectClients().filter(c => c.detected);
9933
+ if (detected.length === 0)
9934
+ return;
9935
+ console.log("\n Refreshing MCP client pins...");
9936
+ for (const client of detected) {
9937
+ const configPath = clientConfigPath(client.id);
9938
+ if (!existsSync(configPath))
9939
+ continue;
9940
+ // Only refresh clients that are already wired — don't wire new ones.
9941
+ let hasFlair = false;
9942
+ try {
9943
+ const raw = readFileSync(configPath, "utf-8");
9944
+ if (client.id === "codex") {
9945
+ hasFlair = codexConfigHasFlairSection(raw);
9946
+ }
9947
+ else {
9948
+ const cfg = JSON.parse(raw);
9949
+ hasFlair = !!cfg.mcpServers?.flair;
9950
+ }
9951
+ }
9952
+ catch { /* unreadable/malformed — skip */ }
9953
+ if (!hasFlair)
9954
+ continue;
9955
+ const env = { ...mcpEnv, FLAIR_CLIENT: client.id };
9956
+ const result = client.wire(env);
9957
+ console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
9958
+ }
9959
+ };
9869
9960
  if (verdict.kind === "ok") {
9870
9961
  // flair#1022: the verified facts are unchanged and still stated — the
9871
9962
  // upgrade did land. What changes is the MARKER and the claim around it.
@@ -9881,6 +9972,7 @@ program
9881
9972
  else
9882
9973
  console.log(line);
9883
9974
  }
9975
+ await refreshWiredClients();
9884
9976
  return;
9885
9977
  }
9886
9978
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -9907,6 +9999,7 @@ program
9907
9999
  console.error(line);
9908
10000
  }
9909
10001
  }
10002
+ await refreshWiredClients();
9910
10003
  return;
9911
10004
  }
9912
10005
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -12013,22 +12106,34 @@ program
12013
12106
  const hook = inspectSessionStartHook(homedir());
12014
12107
  if (hook.present) {
12015
12108
  if (hook.execution === "broken") {
12016
- // Reported in full, with the remedy but NOT counted as an issue,
12017
- // so it never flips doctor's exit code on its own. This is a
12018
- // verification of the environment at the moment doctor runs (a
12019
- // cold `npx` cache, an offline machine, a slow registry), exactly
12020
- // like the FLAIR_URL reachability and agent-registration
12021
- // verifications above, which are warnings for the same reason. A
12022
- // fresh, correct install on a machine that simply has not fetched
12023
- // the adapter yet must not be told it is broken in the exit code.
12024
- // The unsilenced finding below IS counted: that one is a fact
12025
- // about the file, true regardless of the environment.
12026
- console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
12027
- console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
12028
- console.log(` ${render.wrap(render.c.dim, "If this persists, the Node runtime probably changed and the globally")}`);
12029
- console.log(` ${render.wrap(render.c.dim, "installed @tpsdev-ai/flair-mcp no longer resolves for it.")}`);
12030
- console.log(` ${render.wrap(render.c.dim, "Fix:")} npm install -g @tpsdev-ai/flair-mcp ${render.wrap(render.c.dim, "(reinstall for the runtime you use now)")}`);
12031
- console.log(` ${render.wrap(render.c.dim, "Or, if you no longer want ambient memory:")} flair hook uninstall`);
12109
+ // Two very different states that share one probe outcome:
12110
+ //
12111
+ // 1. Silenced (current) command that didn't run the npx cache
12112
+ // is cold, the machine is offline, or the adapter hasn't been
12113
+ // fetched yet. On a fresh install this is NORMAL: the hook is
12114
+ // wired but no Claude Code session has exercised it yet.
12115
+ // Report as informational, not a warning, and never suggest
12116
+ // reinstall the setup is correct, the environment just
12117
+ // hasn't warmed yet.
12118
+ //
12119
+ // 2. Unsilenced (legacy) command that didn't run the hook has
12120
+ // been in place long enough that a cold cache is not the
12121
+ // explanation. This IS a genuine failure: warn and name the
12122
+ // actual state with a fitting remedy.
12123
+ if (hook.silenced) {
12124
+ console.log(` ${render.icons.ok} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)} not yet exercised`);
12125
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
12126
+ console.log(` ${render.wrap(render.c.dim, "The hook is correctly wired but the adapter has not been fetched yet.")}`);
12127
+ console.log(` ${render.wrap(render.c.dim, "This is normal on a fresh install — the first Claude Code session will warm the npx cache.")}`);
12128
+ }
12129
+ else {
12130
+ console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but its command did not run just now`);
12131
+ console.log(` ${render.wrap(render.c.dim, hook.detail ?? "")}`);
12132
+ console.log(` ${render.wrap(render.c.dim, "The hook command could not be executed. Check that npx can resolve")}`);
12133
+ console.log(` ${render.wrap(render.c.dim, "@tpsdev-ai/flair-mcp — a cold cache, missing global install, or network")}`);
12134
+ console.log(` ${render.wrap(render.c.dim, "issue can prevent the adapter from running on its first invocation.")}`);
12135
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites the hook to the current silent-failure form)")}`);
12136
+ }
12032
12137
  }
12033
12138
  else if (hook.execution === "unknown") {
12034
12139
  console.log(` ${render.icons.warn} SessionStart hook: wired in ${render.wrap(render.c.dim, hook.path)}, but could not be verified ${render.wrap(render.c.dim, `(${hook.detail ?? "no detail"})`)}`);
@@ -15508,8 +15613,7 @@ program
15508
15613
  if (!dryRun) {
15509
15614
  console.log(` Writing to Flair...`);
15510
15615
  try {
15511
- const DEFAULT_PORT = 19926;
15512
- const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
15616
+ const httpUrl = `http://127.0.0.1:${resolveHttpPort({})}`;
15513
15617
  const agentKeyId = `${agentId}.key`;
15514
15618
  const keysDir = join(homedir(), ".flair", "keys");
15515
15619
  const keyPath = join(keysDir, agentKeyId);
@@ -15987,7 +16091,7 @@ if (import.meta.main) {
15987
16091
  // ─── Exported for testing ─────────────────────────────────────────────────────
15988
16092
  export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBindFromConfig, readOpsPortFromConfig, writeConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost,
15989
16093
  // Harper's own config — the per-instance port record (flair#914)
15990
- harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
16094
+ harperConfigPath, readHarperConfig, readPortFromHarperConfig, persistDefaultInstallCoordinates, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, FABRIC_OPS_PORT, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
15991
16095
  // launchd label (flair#693)
15992
16096
  LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded,
15993
16097
  // launchd management observation (flair#1022)
@@ -41,7 +41,7 @@ export const SESSION_START_HOOK_MARKER = "flair-session-start";
41
41
  //
42
42
  // WHY THE INVOCATION IS WRAPPED
43
43
  // -----------------------------
44
- // The hook runs `npx -y @tpsdev-ai/flair-mcp flair-session-start`: it resolves
44
+ // The hook runs `npx -y -p @tpsdev-ai/flair-mcp flair-session-start`: it resolves
45
45
  // a package binary through whatever Node runtime the user's shell happens to
46
46
  // expose. Under a Node version manager, globally installed packages are
47
47
  // per-runtime-version, so a routine and entirely unrelated runtime upgrade
@@ -117,7 +117,7 @@ export function buildSessionStartHookCommand(agentId, flairUrl) {
117
117
  throw new Error(`Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
118
118
  }
119
119
  const env = flairUrl ? `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl}` : `FLAIR_AGENT_ID=${agentId}`;
120
- const invocation = `${env} npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
120
+ const invocation = `${env} npx -y -p @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
121
121
  return `sh -c 'out=$(${invocation} 2>/dev/null) && printf %s "$out" || true'`;
122
122
  }
123
123
  /**
@@ -525,6 +525,18 @@ export function inspectSessionStartHook(homeDir, opts = {}) {
525
525
  const verdict = classifyHookProbe(outcome);
526
526
  return { path: found.path, present: true, command, ours, silenced, upgradable, execution: verdict.execution, detail: verdict.detail };
527
527
  }
528
+ export function classifyHookReadiness(report) {
529
+ if (!report.present)
530
+ return "absent";
531
+ if (!report.ours)
532
+ return "custom";
533
+ if (report.execution === "runs")
534
+ return "runs";
535
+ if (report.execution === "broken") {
536
+ return report.silenced ? "not-yet-exercised" : "genuinely-broken";
537
+ }
538
+ return "unverified";
539
+ }
528
540
  /**
529
541
  * Rewrite an existing Flair-authored hook command to the current canonical
530
542
  * form, in place, preserving the agent id and URL the entry already carries —
@@ -344,7 +344,7 @@ export function hookStatus(homeDir, harness) {
344
344
  }
345
345
  const hookEntry = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex];
346
346
  const command = typeof hookEntry?.command === "string" ? hookEntry.command : "";
347
- const correctShape = hookEntry?.type === "command" && command.includes(`npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
347
+ const correctShape = hookEntry?.type === "command" && command.includes(`npx -y -p @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
348
348
  const env = parseHookCommandEnv(command);
349
349
  return {
350
350
  harness, path, wired: true, correctShape,
@@ -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
@@ -285,10 +286,12 @@ export function readSigningKeyFile(path) {
285
286
  /**
286
287
  * The `@harperfast/oauth` config block, matching the installed 2.2.0
287
288
  * package's field names (node_modules/@harperfast/oauth/dist/types.d.ts).
288
- * Secrets are `${ENV_VAR}` placeholders — never literal values — so this
289
- * block is safe to write to harperdb-config.yaml via `set_configuration`
290
- * (the config file itself carries no secret material; see the
291
- * secrets-provisioning step for how the referenced env vars land).
289
+ * Secrets are `${ENV_VAR}` placeholders — never literal values.
290
+ *
291
+ * flair#1136: set_configuration delivery was removed. Fabric regenerates
292
+ * the root harperdb-config.yaml; the component's own config.yaml is the
293
+ * source of truth for the oauth block. This function builds the block that
294
+ * ships in config.yaml — it is never written to harperdb-config.yaml.
292
295
  *
293
296
  * flair#756: `dynamicClientRegistration: { enabled: false }` is written
294
297
  * EXPLICITLY — never omitted. See the module header's "Leaving
@@ -303,6 +306,7 @@ export function buildMcpOAuthConfigBlock(params) {
303
306
  const provider = params.idpProvider;
304
307
  const envPrefix = `OAUTH_${provider.toUpperCase()}`;
305
308
  const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
309
+ const enabled = params.enabled ?? true;
306
310
  return {
307
311
  "@harperfast/oauth": {
308
312
  package: "@harperfast/oauth",
@@ -313,7 +317,7 @@ export function buildMcpOAuthConfigBlock(params) {
313
317
  },
314
318
  },
315
319
  mcp: {
316
- enabled: true,
320
+ enabled,
317
321
  issuer: "${FLAIR_MCP_ISSUER}",
318
322
  resource: "${FLAIR_MCP_ISSUER}/mcp",
319
323
  accessTokenTtl: REQUIRED_ACCESS_TOKEN_TTL,
@@ -331,6 +335,85 @@ export function buildMcpOAuthConfigBlock(params) {
331
335
  },
332
336
  };
333
337
  }
338
+ // ─── Local config.yaml update (flair#1136) ──────────────────────────────────
339
+ /**
340
+ * Flip mcp.enabled in a local component config.yaml. Best-effort: returns
341
+ * `{ ok: false }` with a reason when the file can't be found or parsed.
342
+ *
343
+ * Looks for config.yaml at `explicitPath`, then `./config.yaml`, then
344
+ * `~/.flair/config.yaml`. When found, replaces `mcp:\n enabled: false`
345
+ * with `mcp:\n enabled: true` (exact string match — avoids a YAML parser
346
+ * dependency for a single boolean flip).
347
+ */
348
+ export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
349
+ const candidates = explicitPath
350
+ ? [explicitPath]
351
+ : ["config.yaml", join(homedir(), ".flair", "config.yaml")];
352
+ let configPath = null;
353
+ for (const p of candidates) {
354
+ if (existsSync(p)) {
355
+ configPath = p;
356
+ break;
357
+ }
358
+ }
359
+ if (!configPath) {
360
+ return {
361
+ ok: false,
362
+ detail: `local config.yaml not found (tried: ${candidates.join(", ")}). ` +
363
+ `Set mcp.enabled: ${enabled} in your component config.yaml manually, then restart.`,
364
+ };
365
+ }
366
+ let raw;
367
+ try {
368
+ raw = readFileSync(configPath, "utf-8");
369
+ }
370
+ catch (err) {
371
+ return { ok: false, detail: `cannot read ${configPath}: ${err.message}` };
372
+ }
373
+ // Parse the YAML to navigate to the exact key — avoids the ambiguity of
374
+ // string-matching `enabled:` when the block has multiple enabled keys
375
+ // (mcp.enabled vs dynamicClientRegistration.enabled).
376
+ let doc;
377
+ try {
378
+ doc = yaml.load(raw);
379
+ }
380
+ catch (err) {
381
+ return { ok: false, detail: `cannot parse ${configPath} as YAML: ${err.message}` };
382
+ }
383
+ if (!doc || typeof doc !== "object") {
384
+ return { ok: false, detail: `${configPath} is empty or not a YAML mapping` };
385
+ }
386
+ const oauth = doc["@harperfast/oauth"];
387
+ if (!oauth || typeof oauth !== "object") {
388
+ return {
389
+ ok: false,
390
+ detail: `@harperfast/oauth block not found in ${configPath}. ` +
391
+ `Ensure the component block is present with mcp.enabled: ${enabled}.`,
392
+ };
393
+ }
394
+ const mcp = oauth.mcp;
395
+ if (!mcp || typeof mcp !== "object") {
396
+ return {
397
+ ok: false,
398
+ detail: `mcp key not found under @harperfast/oauth in ${configPath}. ` +
399
+ `Ensure the mcp block is present with enabled: ${enabled}.`,
400
+ };
401
+ }
402
+ const current = mcp.enabled;
403
+ if (current === enabled) {
404
+ return { ok: true, detail: `mcp.enabled already ${enabled} in ${configPath}` };
405
+ }
406
+ // Mutate the parsed document and re-emit.
407
+ mcp.enabled = enabled;
408
+ const updated = yaml.dump(doc, { lineWidth: -1, noCompatMode: true });
409
+ try {
410
+ writeFileSync(configPath, updated, { encoding: "utf-8" });
411
+ }
412
+ catch (err) {
413
+ return { ok: false, detail: `cannot write ${configPath}: ${err.message}` };
414
+ }
415
+ return { ok: true, detail: `mcp.enabled set to ${enabled} in ${configPath}` };
416
+ }
334
417
  /** The exact callback URL to hand the operator when they create the IdP
335
418
  * OAuth app ("with the exact GitHub callback URL printed"). */
336
419
  export function idpCallbackUrl(issuer, idpProvider) {
@@ -553,32 +636,7 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
553
636
  }
554
637
  return { principalCreated, credentialId, credentialReused: Boolean(existing) };
555
638
  }
556
- /** `set_configuration` (writes harperdb-config.yaml) then `restart`
557
- * (whole-process restart) — the genuine Harper Operations API operations
558
- * this module's header documents. Throws on either non-2xx response. */
559
- export async function applyRemoteConfigAndRestart(params, deps = {}) {
560
- const fetchImpl = deps.fetchImpl ?? fetch;
561
- const opsUrl = opsBaseUrl(params.opsPortOrUrl);
562
- const authHeader = basicAuthHeader(params.adminUser, params.adminPass);
563
- const setRes = await fetchImpl(opsUrl, {
564
- method: "POST",
565
- headers: { "Content-Type": "application/json", Authorization: authHeader },
566
- body: JSON.stringify({ operation: "set_configuration", ...params.configBlock }),
567
- });
568
- if (!setRes.ok) {
569
- const text = await setRes.text().catch(() => "");
570
- throw new Error(`set_configuration failed (HTTP ${setRes.status}): ${text}`);
571
- }
572
- const restartRes = await fetchImpl(opsUrl, {
573
- method: "POST",
574
- headers: { "Content-Type": "application/json", Authorization: authHeader },
575
- body: JSON.stringify({ operation: "restart" }),
576
- });
577
- if (!restartRes.ok) {
578
- const text = await restartRes.text().catch(() => "");
579
- throw new Error(`restart failed (HTTP ${restartRes.status}): ${text}`);
580
- }
581
- }
639
+ // ─── Restart only ────────────────────────────────────────────────────────────
582
640
  /** `restart` only — used by `disableMcp` (flag off + restart, no config
583
641
  * rewrite: the `@harperfast/oauth` config block is left in place; it is
584
642
  * inert whenever `FLAIR_MCP_OAUTH` is unset, per the byte-identical-boot
@@ -871,11 +929,13 @@ export async function enableMcp(params, deps = {}) {
871
929
  currentStep = "signing-key";
872
930
  const keyResult = ensureSigningKeyFile(params.signingKeyFilePath, { generate: deps.generateRsaKeyPair });
873
931
  push(true, `signing key ${keyResult.reused ? "reused" : "generated"} at ${keyResult.path} (0600)`);
874
- // ── @harperfast/oauth config block (CIMD-only; DCR explicitly disabled) ──
932
+ // ── @harperfast/oauth config (flair#1136: shipped in config.yaml) ──────
933
+ // The block ships uncommented with mcp.enabled: false (inert default).
934
+ // set_configuration is removed — the block lives in the component's own
935
+ // config.yaml, not in harperdb-config.yaml where Fabric would wipe it.
875
936
  const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
876
937
  currentStep = "config-block";
877
- const configBlock = buildMcpOAuthConfigBlock({ idpProvider, cimdAllowedHosts });
878
- push(true, `built the @harperfast/oauth mcp config block (accessTokenTtl=${REQUIRED_ACCESS_TOKEN_TTL}, ` +
938
+ push(true, `@harperfast/oauth config ships in config.yaml (mcp.enabled=false, ` +
879
939
  `dynamicClientRegistration.enabled=false, clientIdMetadataDocuments.allowedHosts=${JSON.stringify(cimdAllowedHosts)})`);
880
940
  // ── IdP OAuth-app credential intake ───────────────────────────────────────
881
941
  currentStep = "idp-credentials";
@@ -973,18 +1033,54 @@ export async function enableMcp(params, deps = {}) {
973
1033
  }
974
1034
  if (!confirmed) {
975
1035
  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 };
1036
+ return { ok: false, dryRun, steps, failedStep: "secrets-provisioning", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
977
1037
  }
978
- currentStep = "apply-config-and-restart";
979
- // ── Capture boot discriminator BEFORE restart (flair#1120) ─────────────
1038
+ // ── flair#1136: config delivery is now SHIPPED in config.yaml ────────────
1039
+ // The @harperfast/oauth block ships uncommented with mcp.enabled: false
1040
+ // (inert default). set_configuration is REMOVED — Fabric regenerates
1041
+ // harperdb-config.yaml on every container restart, so writing the block
1042
+ // there was always a race against the next deploy. Instead:
1043
+ //
1044
+ // - Standalone-local: flip mcp.enabled to true in the local config.yaml,
1045
+ // restart, self-verify.
1046
+ // - Fabric: the operator must set mcp.enabled: true in their deployed
1047
+ // component config.yaml. Report the requirement LOUDLY — never report
1048
+ // success with /mcp still dark.
1049
+ const isFabric = isFabricOrigin(params.instance);
1050
+ if (isFabric) {
1051
+ // ── Fabric: operator-deploy requirement ──────────────────────────────
1052
+ currentStep = "fabric-operator-deploy";
1053
+ const msg = [
1054
+ `Fabric deployment detected (${new URL(params.instance).hostname}).`,
1055
+ `The @harperfast/oauth block ships in config.yaml with mcp.enabled: false.`,
1056
+ `To activate: set mcp.enabled: true (literal boolean) in your deployed component config.yaml,`,
1057
+ `ensure the staged secrets are live in the instance's process environment, and redeploy.`,
1058
+ `Then re-run \`flair mcp enable\` — earlier steps are idempotent and will be reused.`,
1059
+ ].join(" ");
1060
+ push(false, msg);
1061
+ return {
1062
+ ok: false,
1063
+ dryRun,
1064
+ steps,
1065
+ failedStep: "fabric-operator-deploy",
1066
+ issuer,
1067
+ resource: `${issuer}/mcp`,
1068
+ secretsMechanism: secretsResult.mechanism,
1069
+ secretsPath: secretsResult.path,
1070
+ signingKeyFilePath: keyResult.path,
1071
+ callbackUrl,
1072
+ };
1073
+ }
1074
+ // ── Standalone (non-Fabric): update local config + restart ────────────
1075
+ currentStep = "local-config-update";
1076
+ const localConfigResult = updateLocalConfigMcpEnabled(true, params.localConfigPath);
1077
+ push(localConfigResult.ok, localConfigResult.detail);
1078
+ // ── Restart ───────────────────────────────────────────────────────────
1079
+ currentStep = "restart";
980
1080
  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}`);
1081
+ await triggerRemoteRestart(params.instance, params.adminUser, params.adminPass, { fetchImpl: deps.fetchImpl });
1082
+ push(true, `restart triggered against ${params.instance}`);
983
1083
  // ── 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
1084
  currentStep = "verify-restart";
989
1085
  const postDiscriminator = await waitForOpsApi(resolveOpsUrl(params.instance), basicAuthHeader(params.adminUser, params.adminPass), preDiscriminator.pid, {
990
1086
  fetchImpl: deps.fetchImpl,
@@ -996,7 +1092,7 @@ export async function enableMcp(params, deps = {}) {
996
1092
  currentStep = "self-verify";
997
1093
  const verify = await selfVerifyMcpMetadata(issuer, { fetchImpl: deps.fetchImpl });
998
1094
  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.`);
1095
+ push(false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry.`);
1000
1096
  return {
1001
1097
  ok: false,
1002
1098
  dryRun,
@@ -129,14 +129,17 @@ where nothing answers:
129
129
  # --target https://<fabric-node>:19926/<instance> → ops derived as :19925 ✓
130
130
  ```
131
131
 
132
+ **Fabric's ops API runs on the same hostname at port 9925** <!-- docs-freshness-allow: Fabric ops API port, not legacy data port --> (the deploy/upgrade path already targets this port). For a managed `*.harperfabric.com` instance:
133
+
134
+ ```bash
135
+ # Same hostname, port 9925 — not port 442 <!-- docs-freshness-allow: Fabric ops API -->
136
+ flair init --target https://<cluster>.<org>.harperfabric.com \
137
+ --ops-target https://<cluster>.<org>.harperfabric.com:9925 <!-- docs-freshness-allow: Fabric ops API -->
138
+ ```
139
+
132
140
  **Pass `--ops-target <url>` explicitly** (or set `FLAIR_OPS_TARGET`) on any command that
133
141
  touches the ops API: `init --target`, `agent add --target`, `federation token --target`.
134
142
 
135
- > **Gap — needs a Fabric account to verify.** This guide does not state the correct
136
- > ops-API URL for a managed `*.harperfabric.com` instance, or whether the ops API is
137
- > reachable remotely there at all. The derivation above is certain (read from source);
138
- > the right value to pass is not.
139
-
140
143
  Precedence: `--target` > `--url` > `FLAIR_TARGET` > `FLAIR_URL` > localhost. For ops:
141
144
  `--ops-target` > `FLAIR_OPS_TARGET` > derived > localhost.
142
145
 
@@ -250,8 +253,9 @@ and shells out to `lsof`. The command you'd reach for when something breaks is u
250
253
  here. Unavailable too: `start`, `stop`, `restart`, `snapshot`, `reembed`, `rem`, `bridge`.
251
254
 
252
255
  **Fabric's own cluster topology is invisible.** `fleet verify` sweeps *Flair's* federation
253
- peer table, not Harper's cluster nodes — `cluster_status` is harper-pro-only and absent
254
- from the OSS `harper` build. **`0 peers known` means "0 on file", never "0 exist."**
256
+ peer table, not Harper's cluster nodes. **`cluster_status` works on Fabric** — Fabric
257
+ always runs harper-pro (not the OSS harper build), so cluster_status is available over
258
+ the ops API. `0 peers known` means "0 on file", never "0 exist."
255
259
 
256
260
  **There is no disk or quota telemetry.** `flair status` reports usage for two directories:
257
261
  no free space, no total, no quota, no warning threshold, walk capped at six levels, no
@@ -263,6 +267,35 @@ with nothing saying so. The one indirect signal is a migration halting for space
263
267
  > wouldn't matter: Flair calls it only as a post-failure convergence oracle and discards
264
268
  > the `size` field. Component disk usage is invisible structurally.
265
269
 
270
+ ### The `mcp.enabled` operator step
271
+
272
+ MCP is **off by default**. The shipped component `config.yaml` contains
273
+ `@harperfast/oauth` → `mcp` → `enabled: false`. Until [flair#1152](https://github.com/tpsdev-ai/flair/issues/1152)
274
+ lands (interpolate from env — *ON HOLD*), you must flip this manually:
275
+
276
+ 1. In your deployed component's `config.yaml`, change:
277
+ ```yaml
278
+ '@harperfast/oauth':
279
+ mcp:
280
+ enabled: true # was: false
281
+ ```
282
+ 2. Re-deploy the component so Harper picks up the new value.
283
+ 3. **Verify the `/mcp` surface is actually serving** (the flag alone does not
284
+ guarantee it — a secret that is stored but never decrypted fails at self-verify):
285
+ ```bash
286
+ # Check /mcp is reachable and returning MCP protocol (not a loopback proxy or 404)
287
+ curl -sf https://\<cluster\>.\<org\>.harperfabric.com/mcp
288
+ # Should return MCP JSON-RPC content; if you get HTML redirect or 404 the flag
289
+ # is not effective
290
+ ```
291
+
292
+ **⚠ SECURITY CAVEAT — the upgrade-reverts trap.** Any package update or fleet component
293
+ update re-ships the literal `enabled: false` and silently darkens a live `/mcp` surface.
294
+ You must **re-flip to `true` after every upgrade** and re-deploy. An updated component
295
+ without this re-flip will appear healthy (`/Health` green) while its MCP tools are
296
+ dark to every connected client. If you rely on MCP, add the re-flip to your upgrade
297
+ runbook.
298
+
266
299
  ### Known hazard: unbounded npm cache
267
300
 
268
301
  **Open — [flair#886](https://github.com/tpsdev-ai/flair/issues/886).** Every deploy runs a
@@ -79,6 +79,35 @@ The staging file is written in every case, so a fallback never strands you mid-r
79
79
 
80
80
  `--secrets-mechanism <fabric-env-secrets|env-file>` remains an explicit override and skips the probe entirely.
81
81
 
82
+ ### The `mcp.enabled` operator step (Fabric)
83
+
84
+ MCP is **off by default**. The shipped component `config.yaml` contains
85
+ `@harperfast/oauth` → `mcp` → `enabled: false`. Until [flair#1152](https://github.com/tpsdev-ai/flair/issues/1152)
86
+ lands (interpolate from env — *ON HOLD*), you must flip this manually:
87
+
88
+ 1. In your deployed component's `config.yaml`, change:
89
+ ```yaml
90
+ '@harperfast/oauth':
91
+ mcp:
92
+ enabled: true # was: false
93
+ ```
94
+ 2. Re-deploy the component so Harper picks up the new value.
95
+ 3. **Verify the `/mcp` surface is actually serving** (the flag alone does not
96
+ guarantee it — a secret that is stored but never decrypted fails at self-verify):
97
+ ```bash
98
+ # Check /mcp is reachable and returning MCP protocol (not a loopback proxy or 404)
99
+ curl -sf https://<cluster>.<org>.harperfabric.com/mcp
100
+ # Should return MCP JSON-RPC content; if you get HTML redirect or 404 the flag
101
+ # is not effective
102
+ ```
103
+
104
+ **⚠ SECURITY CAVEAT — the upgrade-reverts trap.** Any package update or fleet component
105
+ update re-ships the literal `enabled: false` and silently darkens a live `/mcp` surface.
106
+ You must **re-flip to `true` after every upgrade** and re-deploy. An updated component
107
+ without this re-flip will appear healthy (`/Health` green) while its MCP tools are
108
+ dark to every connected client. If you rely on MCP, add the re-flip to your upgrade
109
+ runbook.
110
+
82
111
  ---
83
112
 
84
113
  ## Agent authentication
@@ -140,7 +169,7 @@ flair fleet verify --target https://<cluster>.<org>.harperfabric.com
140
169
 
141
170
  **`flair doctor`** takes no `--target` — it hardcodes localhost, reads a local PID file, and shells out to `lsof`. Unavailable too: `start`, `stop`, `restart`, `snapshot`, `reembed`, `rem`, `bridge`.
142
171
 
143
- **Fabric's own cluster topology is invisible.** `fleet verify` sweeps *Flair's* federation peer table, not Harper's cluster nodes. `cluster_status` is harper-pro-only. `0 peers known` means "0 on file", never "0 exist."
172
+ **Fabric's own cluster topology is invisible.** `fleet verify` sweeps *Flair's* federation peer table, not Harper's cluster nodes. **`cluster_status` works on Fabric** — Fabric always runs harper-pro (not the OSS harper build), so cluster_status is available over the ops API. `0 peers known` means "0 on file", never "0 exist."
144
173
 
145
174
  ---
146
175
 
@@ -109,7 +109,7 @@ Or wire it by hand — add a `SessionStart` hook to `~/.claude/settings.json`:
109
109
  "hooks": [
110
110
  {
111
111
  "type": "command",
112
- "command": "sh -c 'out=$(FLAIR_AGENT_ID=me npx -y @tpsdev-ai/flair-mcp flair-session-start 2>/dev/null) && printf %s \"$out\" || true'"
112
+ "command": "sh -c 'out=$(FLAIR_AGENT_ID=me npx -y -p @tpsdev-ai/flair-mcp flair-session-start 2>/dev/null) && printf %s \"$out\" || true'"
113
113
  }
114
114
  ]
115
115
  }
@@ -154,6 +154,8 @@ flair search --agent local "native addon loading in sandboxed runtimes"
154
154
 
155
155
  You searched for a concept, not the keywords. The line under each hit is its creation date, durability tier, and rank score.
156
156
 
157
+ > **When stdout is not a terminal** — piped to another command, captured in a script, or run in CI — the same `flair search` command emits a JSON array instead of the formatted prose above. Each hit is an object with `id`, `text`, `createdAt`, `durability`, and `_score` fields. Add `--explain` to include an `_explain` ranking breakdown on each hit. Use `flair search --json` to force JSON output even in a terminal, or `flair memory search` for the raw JSON form in all contexts.
158
+
157
159
  > The percentage is a **rank-fusion score, not a similarity**. It is normalized so the top result is always near 100%. Read it as ordering within these results, never as confidence that the match is good.
158
160
 
159
161
  Add `--explain` to see the ranking inputs per hit — the raw score, the composite score under `--scoring composite`, and the record's durability, age and usage count. When output is JSON (`--json`, or any time stdout is not a terminal) the same breakdown arrives as an `_explain` object on each hit, so scripts get it too. Use `--limit`, `--tag`, `--since 7d` to narrow the search. `flair memory search` runs the same query but always prints raw JSON — use it when piping to a script.
@@ -23,7 +23,8 @@ If it fails to start:
23
23
  lsof -i :19926
24
24
 
25
25
  # Check logs (macOS)
26
- cat ~/.flair/data/log/hdb.log | tail -50
26
+ cat ~/.flair/data/log/hdb.log | tail -50 # Harper ≤ 5.1 only (see below)
27
+ cat ~/.flair/data/log/system.log | tail -50 # Harper 5.2+ (live log)
27
28
 
28
29
  # Check logs (Linux)
29
30
  journalctl --user -u flair --since "10 minutes ago"
@@ -242,6 +243,14 @@ flair --help # all commands
242
243
  flair <command> -h # command-specific help
243
244
  ```
244
245
 
245
- Logs: `~/.flair/data/log/hdb.log`
246
+ **Log paths depend on Harper version:**
247
+
248
+ - **Harper ≤ 5.1:** `~/.flair/data/log/hdb.log`
249
+ - **Harper 5.2+:** `~/.flair/data/log/system.log`
250
+
251
+ > ⚠ **On Harper 5.2+, `hdb.log` freezes at the upgrade boundary.** The live log is
252
+ > `system.log`. The Harper ops `read_log` endpoint keeps serving the frozen `hdb.log`,
253
+ > so remote diagnosis reads days-stale entries that look current. Always check
254
+ > `system.log` after upgrading to 5.2+.
246
255
 
247
256
  File issues: [github.com/tpsdev-ai/flair/issues](https://github.com/tpsdev-ai/flair/issues)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.41.0",
3
+ "version": "0.44.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",