@tpsdev-ai/flair 0.40.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";
@@ -774,6 +774,14 @@ export function buildDirectSpawnEnv(opts) {
774
774
  HTTP_PORT: String(opts.httpPort),
775
775
  OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opts.opsBindHost, opts.opsPort),
776
776
  LOCAL_STUDIO: "false",
777
+ // flair#905 / lrf5: Harper's forceDowngradePrompt reads CONFIRM_DOWNGRADE
778
+ // from the environment (via the `prompt` npm package's assignCmdEnvVariables
779
+ // override). Under launchd/systemd stdin is not a TTY, so the prompt gets
780
+ // EOF and Harper exits 0 without starting — leaving the instance DOWN with
781
+ // no error. Setting this to "yes" makes the prompt non-interactive: Harper
782
+ // proceeds without blocking, which is the correct default for a managed
783
+ // restart where the operator already chose to proceed.
784
+ CONFIRM_DOWNGRADE: "yes",
777
785
  };
778
786
  if (opts.adminPass)
779
787
  env.HDB_ADMIN_PASSWORD = opts.adminPass;
@@ -1332,11 +1340,9 @@ function b64url(bytes) {
1332
1340
  * out of scope for this HTTP/REST auth path.
1333
1341
  */
1334
1342
  async function api(method, path, body, options) {
1335
- // Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
1336
- // When baseUrl is provided (--target), use it directly.
1337
- const savedPort = readPortFromConfig();
1338
- const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
1339
- 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({})}`);
1340
1346
  // Extract agentId from FLAIR_AGENT_ID env, or the body (POST/PUT) / URL
1341
1347
  // query params (GET) — Harper-CLI-request-shape knowledge, not a generic
1342
1348
  // auth concern, so it stays here rather than in authedRequest.
@@ -3348,7 +3354,13 @@ program
3348
3354
  ? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
3349
3355
  : {};
3350
3356
  const existing = claudeJson.mcpServers?.flair;
3351
- 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) {
3352
3364
  console.log(` ✓ Claude Code already wired in ~/.claude.json`);
3353
3365
  wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
3354
3366
  }
@@ -3356,11 +3368,15 @@ program
3356
3368
  claudeJson.mcpServers = claudeJson.mcpServers || {};
3357
3369
  claudeJson.mcpServers.flair = flairMcpConfig;
3358
3370
  writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
3359
- const how = claudeJsonExisted ? "wired in ~/.claude.json" : "wired in ~/.claude.json (created)";
3360
- 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)`);
3361
3375
  wiringResults.push({
3362
3376
  client: "claude-code",
3363
- 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",
3364
3380
  wired: true,
3365
3381
  });
3366
3382
  }
@@ -4803,7 +4819,7 @@ mcp
4803
4819
  const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
4804
4820
  if (!dryRun && !adminPass) {
4805
4821
  console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for a REMOTE target " +
4806
- "(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" +
4807
4823
  " FLAIR_ADMIN_PASS and ~/.flair/admin-pass are deliberately NOT used here: they are THIS machine's " +
4808
4824
  "local admin credentials, and sending them to another instance is how a local secret ends up on someone " +
4809
4825
  "else's Harper. Pass the target's own admin password explicitly.");
@@ -4857,7 +4873,20 @@ mcp
4857
4873
  process.exit(1);
4858
4874
  }
4859
4875
  if (!result.ok) {
4860
- 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
+ }
4861
4890
  process.exit(1);
4862
4891
  }
4863
4892
  if (result.dryRun) {
@@ -9688,6 +9717,50 @@ program
9688
9717
  console.error(` Recover by hand: npm install -g @tpsdev-ai/flair@${toVersion} && flair start`);
9689
9718
  process.exit(1);
9690
9719
  }
9720
+ // flair#1053: when the engine (Harper) version changed, the pre-upgrade
9721
+ // snapshot is the ONLY way back — the old Harper cannot read data written
9722
+ // by the new one (e.g. 5.2 LZ4-compressed storage is unreadable by 5.1).
9723
+ // Restore it before restarting, or refuse loudly when none exists.
9724
+ if (engineVersionChanging) {
9725
+ if (snapshotPath) {
9726
+ console.log(`\nEngine version changed — restoring pre-upgrade snapshot before rollback...`);
9727
+ console.log(` snapshot: ${snapshotPath}`);
9728
+ console.log(` target: ${upgradeDataDir}`);
9729
+ try {
9730
+ await validateSnapshotArchive({ file: snapshotPath, targetDir: upgradeDataDir });
9731
+ rmSync(upgradeDataDir, { recursive: true, force: true });
9732
+ mkdirSync(upgradeDataDir, { recursive: true, mode: 0o700 });
9733
+ await extractSnapshotSafely({ file: snapshotPath, targetDir: upgradeDataDir });
9734
+ console.log(` ✅ snapshot restored`);
9735
+ }
9736
+ catch (err) {
9737
+ console.error(`❌ snapshot restore failed: ${err.message}`);
9738
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but the data directory could not be restored.`);
9739
+ console.error(` The snapshot itself is intact at ${snapshotPath} — restore it by hand:`);
9740
+ console.error(` flair snapshot restore "${snapshotPath}"`);
9741
+ console.error(` Then: flair start`);
9742
+ process.exit(1);
9743
+ }
9744
+ }
9745
+ else {
9746
+ // No snapshot exists — the old Harper WILL NOT BOOT against the new
9747
+ // data. Refuse loudly rather than attempting a guaranteed failure.
9748
+ console.error(`\n❌ Cannot roll back: the Harper engine version changed (${currentEngineVersion ?? "?"} → ${targetEngineVersion ?? "?"}) and no pre-upgrade snapshot exists.`);
9749
+ console.error(` The old Harper cannot read data written by the new engine — restarting without a snapshot restore would fail.`);
9750
+ console.error(` @tpsdev-ai/flair@${toVersion} is installed but NOT running.`);
9751
+ if (snapshotDecision === "nudge") {
9752
+ console.error(` A snapshot was skipped because --no-engine-snapshot was passed.`);
9753
+ console.error(` Recovery options:`);
9754
+ console.error(` 1. Re-upgrade to the version that wrote this data: npm install -g @tpsdev-ai/flair@${expectedFlairVersion ?? "latest"} && flair start`);
9755
+ console.error(` 2. Restore from a ` + "`flair backup`" + ` JSON export on a fresh data directory.`);
9756
+ }
9757
+ else {
9758
+ console.error(` No snapshot was taken (data directory may not have existed, or the snapshot step was skipped).`);
9759
+ console.error(` Recovery: re-upgrade to the version that wrote this data, or restore from a ` + "`flair backup`" + ` JSON export.`);
9760
+ }
9761
+ process.exit(1);
9762
+ }
9763
+ }
9691
9764
  // Same post-swap rule as the upgrade restart above: the rolled-back
9692
9765
  // version's own CLI is the thing that knows how to start it.
9693
9766
  const rolledBackCli = resolveInstalledFlairCli(flairPackageDir(), toVersion);
@@ -9814,6 +9887,55 @@ program
9814
9887
  authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
9815
9888
  });
9816
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
+ };
9817
9939
  if (verdict.kind === "ok") {
9818
9940
  // flair#1022: the verified facts are unchanged and still stated — the
9819
9941
  // upgrade did land. What changes is the MARKER and the claim around it.
@@ -9829,6 +9951,7 @@ program
9829
9951
  else
9830
9952
  console.log(line);
9831
9953
  }
9954
+ await refreshWiredClients();
9832
9955
  return;
9833
9956
  }
9834
9957
  // flair#741 follow-through: a healthy instance the verifier just couldn't
@@ -9855,6 +9978,7 @@ program
9855
9978
  console.error(line);
9856
9979
  }
9857
9980
  }
9981
+ await refreshWiredClients();
9858
9982
  return;
9859
9983
  }
9860
9984
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
@@ -10321,8 +10445,18 @@ async function stopFlairProcess(port, dataDir) {
10321
10445
  }
10322
10446
  catch { }
10323
10447
  }
10324
- // Wait briefly for shutdown
10325
- await new Promise((r) => setTimeout(r, 2000));
10448
+ // flair#905 / lrf5: wait for every signalled process to actually exit.
10449
+ // A blind 2-second sleep is not a guarantee — Harper may be flushing
10450
+ // RocksDB WAL/MANIFEST, and the next start will fail with a locked data
10451
+ // directory if the old process hasn't released it yet. The launchd path
10452
+ // above already does this via waitForProcessExit; the port-based path
10453
+ // must match that guarantee.
10454
+ for (const target of targets) {
10455
+ try {
10456
+ await waitForProcessExit(target, STARTUP_TIMEOUT_MS);
10457
+ }
10458
+ catch { /* best-effort — the next start will surface the real problem */ }
10459
+ }
10326
10460
  }
10327
10461
  /**
10328
10462
  * Start the local Flair (Harper) process — launchd `start` on darwin when a
@@ -15446,8 +15580,7 @@ program
15446
15580
  if (!dryRun) {
15447
15581
  console.log(` Writing to Flair...`);
15448
15582
  try {
15449
- const DEFAULT_PORT = 19926;
15450
- const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
15583
+ const httpUrl = `http://127.0.0.1:${resolveHttpPort({})}`;
15451
15584
  const agentKeyId = `${agentId}.key`;
15452
15585
  const keysDir = join(homedir(), ".flair", "keys");
15453
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/docs/upgrade.md CHANGED
@@ -357,7 +357,7 @@ if you want to see it scripted end-to-end.
357
357
  upgraded via `flair upgrade`. Real snapshots for production datasets run hundreds of
358
358
  megabytes — plan disk capacity accordingly. Cleanup of old engine snapshots is manual;
359
359
  `flair snapshot list` shows available snapshots, and you can delete entries you no longer
360
- need.
360
+ need (e.g. `rm ~/.flair/upgrade-snapshots/flair-data-<old>.tar.gz`).
361
361
 
362
362
  ## Rollback
363
363
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.40.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",
@@ -64,7 +64,7 @@
64
64
  "@harperfast/oauth": "2.4.0",
65
65
  "@types/js-yaml": "4.0.9",
66
66
  "commander": "14.0.3",
67
- "harper": "5.1.22",
67
+ "harper": "5.2.0",
68
68
  "harper-fabric-embeddings": "^0.5.0",
69
69
  "jose": "6.2.2",
70
70
  "js-yaml": "^4.3.1",