@tpsdev-ai/flair 0.53.0 → 0.54.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +4 -1
  2. package/dist/build-info.json +3 -3
  3. package/dist/cli.js +1791 -15648
  4. package/dist/commands/agent.js +453 -0
  5. package/dist/commands/attention.js +121 -0
  6. package/dist/commands/backup.js +115 -0
  7. package/dist/commands/bootstrap.js +91 -0
  8. package/dist/commands/bridge.js +608 -0
  9. package/dist/commands/deploy.js +180 -0
  10. package/dist/commands/doctor.js +1654 -0
  11. package/dist/commands/export.js +110 -0
  12. package/dist/commands/federation.js +1575 -0
  13. package/dist/commands/fleet.js +73 -0
  14. package/dist/commands/grant.js +109 -0
  15. package/dist/commands/hook.js +193 -0
  16. package/dist/commands/idp.js +193 -0
  17. package/dist/commands/import.js +134 -0
  18. package/dist/commands/init.js +1203 -0
  19. package/dist/commands/inspect.js +45 -0
  20. package/dist/commands/keys.js +187 -0
  21. package/dist/commands/mcp.js +707 -0
  22. package/dist/commands/memory.js +501 -0
  23. package/dist/commands/migrate-harness-memory.js +270 -0
  24. package/dist/commands/orgevent.js +138 -0
  25. package/dist/commands/presence.js +76 -0
  26. package/dist/commands/principal.js +338 -0
  27. package/dist/commands/quality.js +1164 -0
  28. package/dist/commands/reembed.js +296 -0
  29. package/dist/commands/relationship.js +76 -0
  30. package/dist/commands/rem.js +1048 -0
  31. package/dist/commands/restore.js +130 -0
  32. package/dist/commands/search.js +244 -0
  33. package/dist/commands/service.js +315 -0
  34. package/dist/commands/session.js +184 -0
  35. package/dist/commands/soul.js +155 -0
  36. package/dist/commands/status.js +914 -0
  37. package/dist/commands/test.js +93 -0
  38. package/dist/commands/uninstall.js +143 -0
  39. package/dist/commands/upgrade.js +1592 -0
  40. package/dist/commands/workspace.js +114 -0
  41. package/dist/deploy.js +24 -0
  42. package/dist/fabric-npm-install.js +87 -0
  43. package/dist/federation-verify.js +498 -0
  44. package/dist/fleet-verify.js +144 -21
  45. package/dist/install/clients.js +167 -0
  46. package/dist/lib/auth-resolve.js +76 -1
  47. package/dist/lib/daemon-liveness.js +131 -2
  48. package/dist/lib/doctor-config-path.js +61 -0
  49. package/dist/lib/doctor-federation-driver.js +189 -0
  50. package/dist/lib/doctor-run.js +40 -0
  51. package/dist/lib/entity-vocab-cli.js +3 -3
  52. package/dist/lib/federation-pair-identity.js +47 -0
  53. package/dist/lib/launchd-repair.js +5 -4
  54. package/dist/lib/ops-api-bind.js +115 -0
  55. package/dist/lib/owned-pins.js +219 -0
  56. package/dist/lib/uninstall-purge.js +218 -0
  57. package/dist/rem/restore.js +8 -10
  58. package/dist/resources/AgentReadPosition.js +74 -0
  59. package/dist/resources/Federation.js +8 -2
  60. package/dist/resources/Memory.js +4 -3
  61. package/dist/resources/MemoryBootstrap.js +41 -25
  62. package/dist/resources/MemoryCandidate.js +5 -6
  63. package/dist/resources/OrgEventCatchup.js +126 -47
  64. package/dist/resources/agent-read-position-lib.js +83 -0
  65. package/dist/resources/agent-read-position.js +120 -0
  66. package/dist/resources/embeddings-boot.js +32 -0
  67. package/dist/resources/federation-peer-liveness.js +73 -0
  68. package/dist/resources/health.js +68 -19
  69. package/dist/resources/mcp-tools.js +43 -279
  70. package/dist/resources/memory-visibility.js +3 -3
  71. package/dist/resources/migration-boot.js +59 -18
  72. package/dist/resources/migrations/embedding-stamp.js +20 -1
  73. package/dist/resources/migrations/recheck.js +43 -0
  74. package/dist/resources/migrations/runner.js +6 -1
  75. package/dist/resources/migrations/stamp-outstanding.js +171 -0
  76. package/dist/resources/migrations/visibility-backfill.js +2 -2
  77. package/dist/resources/org-event-catchup-lib.js +47 -0
  78. package/dist/resources/record-owner-guard.js +1 -0
  79. package/dist/stamp-migration-verify.js +163 -0
  80. package/dist/stamp-outstanding.js +144 -0
  81. package/docs/api-reference.md +4 -2
  82. package/docs/deploying-on-fabric.md +11 -10
  83. package/docs/deployment.md +3 -1
  84. package/docs/federation.md +19 -0
  85. package/docs/hosted-on-fabric.md +3 -3
  86. package/docs/quickstart.md +2 -1
  87. package/docs/releasing.md +15 -7
  88. package/docs/spoke-bringup.md +10 -5
  89. package/docs/standalone-local.md +3 -1
  90. package/docs/upgrade.md +25 -6
  91. package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
  92. package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
  93. package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
  94. package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
  95. package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
  96. package/package.json +9 -4
  97. package/schemas/agent.graphql +15 -0
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * The fix is a classifier with FIVE states, not a boolean:
13
13
  *
14
- * RUNNING identity-verified pid alive + health 200
14
+ * RUNNING identity-verified pid alive + flair-identified /Health 2xx
15
15
  * NOT_RUNNING no pidfile, or verified dead + port refused
16
16
  * WEDGED identity-VERIFIED pid alive + not serving -> stop ACTS
17
17
  * DISAGREEMENT evidence conflicts, identity NOT verified -> stop REFUSES
@@ -36,6 +36,10 @@
36
36
  * reads, `kill(pid, 0)`, the health probe, the start-time readers) live in
37
37
  * `src/cli.ts`, so every branch here is unit-testable without a daemon.
38
38
  */
39
+ /** Something accepted a TCP connection and spoke HTTP — flair or not. */
40
+ export function healthIndicatesListener(health) {
41
+ return health.kind === "ok" || health.kind === "foreign";
42
+ }
39
43
  /**
40
44
  * Classify the gathered evidence into one of the five states.
41
45
  *
@@ -53,6 +57,9 @@ export function classifyDaemonState(ev, ctx) {
53
57
  const pid = ev.pidfile.kind === "present" ? ev.pidfile.pid : null;
54
58
  const liveness = ev.pidLiveness;
55
59
  // THE INVARIANT: WEDGED is reachable only through a VERIFIED identity.
60
+ // RUNNING requires flair-identified /Health — a foreign 200 is not RUNNING
61
+ // (flair#1478). Verified + alive + foreign lands in WEDGED: our pid is
62
+ // proven, but it is not serving flair's /Health.
56
63
  if (ev.identity.kind === "verified" && liveness?.kind === "alive") {
57
64
  if (ev.health.kind === "ok") {
58
65
  return { state: "RUNNING", pid: ev.identity.pid };
@@ -64,7 +71,7 @@ export function classifyDaemonState(ev, ctx) {
64
71
  if (ev.health.kind === "refused") {
65
72
  return { state: "NOT_RUNNING" };
66
73
  }
67
- if (ev.health.kind === "ok") {
74
+ if (healthIndicatesListener(ev.health)) {
68
75
  return {
69
76
  state: "DISAGREEMENT",
70
77
  detail: pid === null
@@ -205,3 +212,125 @@ export function parseSidecarJson(content) {
205
212
  return null;
206
213
  return { pid, startTimeMs, port, flairVersion };
207
214
  }
215
+ /**
216
+ * Flair's public `/Health` fingerprint (resources/search-readiness.ts
217
+ * `buildPublicHealthBody`). `ok: true` alone is not enough — a decoy can
218
+ * return that. Require the fields that distinguish flair from "an HTTP
219
+ * server answered": `version`, `searchReady`, and `buildCommit` (always
220
+ * present, string or null).
221
+ */
222
+ export function isFlairHealthBody(body) {
223
+ if (typeof body !== "object" || body === null || Array.isArray(body))
224
+ return false;
225
+ const rec = body;
226
+ if (typeof rec.ok !== "boolean")
227
+ return false;
228
+ if (typeof rec.version !== "string" || rec.version.length === 0)
229
+ return false;
230
+ if (typeof rec.searchReady !== "boolean")
231
+ return false;
232
+ if (!("buildCommit" in rec))
233
+ return false;
234
+ if (rec.buildCommit !== null && typeof rec.buildCommit !== "string")
235
+ return false;
236
+ return true;
237
+ }
238
+ /**
239
+ * Classify a finished HTTP probe or a network error. Pure so the adapter
240
+ * (fetch) stays in cli.ts and every branch is unit-testable.
241
+ */
242
+ export function classifyHealthProbe(input) {
243
+ if (input.kind === "network-error") {
244
+ if (input.code === "ECONNREFUSED" || input.code === "ConnectionRefused") {
245
+ return { kind: "refused" };
246
+ }
247
+ return { kind: "unreachable" };
248
+ }
249
+ if (input.status >= 200 && input.status < 300 && isFlairHealthBody(input.body)) {
250
+ return { kind: "ok" };
251
+ }
252
+ return { kind: "foreign" };
253
+ }
254
+ /** Trailing-slash-insensitive lexical compare — no filesystem, no realpath. */
255
+ export function canonicalLexicalPath(p) {
256
+ if (p === "/" || p === "")
257
+ return "/";
258
+ return p.replace(/[/\\]+$/, "");
259
+ }
260
+ /**
261
+ * Bind the launched pid to the port's listener set. Empty / null is
262
+ * unavailable (best-effort skip). A populated set that omits `launchedPid`
263
+ * is a stale/foreign holder — not healed.
264
+ */
265
+ export function classifyPortOwner(input) {
266
+ if (input.listenerPids === null || input.listenerPids.length === 0) {
267
+ return { kind: "unavailable" };
268
+ }
269
+ if (input.listenerPids.includes(input.launchedPid)) {
270
+ return { kind: "match" };
271
+ }
272
+ return { kind: "mismatch", listenerPids: input.listenerPids };
273
+ }
274
+ /**
275
+ * Confirm the listener is the flair instance we launched: worktree (cwd /
276
+ * cmdline resolves to `@tpsdev-ai/flair`) and dataDir (`ROOTPATH`). Each
277
+ * check is fail-closed when we could inspect, and skipped when we could not.
278
+ */
279
+ export function classifyInstanceMatch(input) {
280
+ if (input.servingFlairPackage === false) {
281
+ return { kind: "mismatch", reason: "listener is not a flair worktree" };
282
+ }
283
+ if (input.environReadable) {
284
+ if (!input.processRootPath) {
285
+ return { kind: "mismatch", reason: "listener process has no ROOTPATH" };
286
+ }
287
+ if (canonicalLexicalPath(input.processRootPath) !== canonicalLexicalPath(input.expectedDataDir)) {
288
+ return {
289
+ kind: "mismatch",
290
+ reason: `listener ROOTPATH ${input.processRootPath} is not dataDir ${input.expectedDataDir}`,
291
+ };
292
+ }
293
+ }
294
+ const worktreeKnown = input.servingFlairPackage === true;
295
+ const dataDirKnown = input.environReadable && input.processRootPath !== null;
296
+ if (worktreeKnown || dataDirKnown)
297
+ return { kind: "match" };
298
+ return { kind: "unavailable" };
299
+ }
300
+ /** `/proc/<pid>/environ` is NUL-separated `KEY=value`. */
301
+ export function parseNullSeparatedEnviron(raw) {
302
+ const out = {};
303
+ for (const entry of raw.split("\0")) {
304
+ if (!entry)
305
+ continue;
306
+ const eq = entry.indexOf("=");
307
+ if (eq <= 0)
308
+ continue;
309
+ out[entry.slice(0, eq)] = entry.slice(eq + 1);
310
+ }
311
+ return out;
312
+ }
313
+ export function extractRootPath(environ) {
314
+ const v = environ.ROOTPATH;
315
+ return typeof v === "string" && v.length > 0 ? v : null;
316
+ }
317
+ /**
318
+ * The #1454 sidecar self-heal gate (flair#1478). Adopt a reconstructed
319
+ * sidecar ONLY when /Health is flair-identified AND the port-owning pid
320
+ * is the launched instance (worktree/dataDir). Port-owner or instance
321
+ * `unavailable` is a best-effort skip — not a green light and not a
322
+ * refusal. `mismatch` on either is NOT healed. False-green is worse
323
+ * than no self-heal.
324
+ */
325
+ export function shouldAdoptMissingSidecar(input) {
326
+ if (!input.sidecarAbsent || !input.dataDirSafe || !input.pidfilePresent || !input.pidAlive) {
327
+ return false;
328
+ }
329
+ if (input.health.kind !== "ok")
330
+ return false;
331
+ if (input.portOwner.kind === "mismatch")
332
+ return false;
333
+ if (input.instanceMatch.kind === "mismatch")
334
+ return false;
335
+ return true;
336
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * doctor-config-path.ts — resolve config.yaml the way the runtime does (flair#1514).
3
+ *
4
+ * Harper loads the component's `config.yaml` from the directory it was
5
+ * started in (`harper run .` → `./config.yaml`). `flair mcp enable` already
6
+ * walks `./config.yaml` then `~/.flair/config.yaml`. Doctor used to consult
7
+ * ONLY `~/.flair/config.yaml`, so a wrapper-launched component dir
8
+ * (`~/agents/flair/config.yaml`) printed "No config file at ~/.flair/config.yaml
9
+ * — using defaults" while the running instance was on the real file.
10
+ *
11
+ * One resolver, used by doctor's config line and by the federation-driver
12
+ * peer-gate (the gate must see the same file Harper sees, or a peered
13
+ * install whose peers live only in the component config would be treated
14
+ * as "no peers" and the driver check would be silenced — the #1514 hazard).
15
+ */
16
+ import { existsSync, readFileSync } from "node:fs";
17
+ import { homedir } from "node:os";
18
+ import { join, resolve } from "node:path";
19
+ const CONFIG_BASENAMES = ["config.yaml", "config.yml"];
20
+ function configFilesIn(dir) {
21
+ return CONFIG_BASENAMES.map((name) => join(dir, name));
22
+ }
23
+ /**
24
+ * Candidate paths in runtime order: cwd, then the component dir (if distinct),
25
+ * then `~/.flair`. Existence is not checked — callers that want the first
26
+ * existing file use `resolveFlairConfigYaml`.
27
+ */
28
+ export function flairConfigYamlCandidates(opts = {}) {
29
+ const cwd = resolve(opts.cwd ?? process.cwd());
30
+ const homeDir = opts.homeDir ?? homedir();
31
+ const out = [...configFilesIn(cwd)];
32
+ if (opts.componentDir) {
33
+ const componentDir = resolve(opts.componentDir);
34
+ if (componentDir !== cwd)
35
+ out.push(...configFilesIn(componentDir));
36
+ }
37
+ out.push(...configFilesIn(join(homeDir, ".flair")));
38
+ return out;
39
+ }
40
+ /**
41
+ * First existing config.yaml along the runtime resolution order, or null.
42
+ */
43
+ export function resolveFlairConfigYaml(opts = {}) {
44
+ for (const p of flairConfigYamlCandidates(opts)) {
45
+ if (existsSync(p))
46
+ return p;
47
+ }
48
+ return null;
49
+ }
50
+ /** `port:` from a YAML file, same regex `readPortFromConfig` uses on ~/.flair. */
51
+ export function readPortFromYamlFile(path) {
52
+ try {
53
+ if (!existsSync(path))
54
+ return null;
55
+ const m = readFileSync(path, "utf-8").match(/port:\s*(\d+)/);
56
+ return m ? Number(m[1]) : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * doctor-federation-driver.ts — gate doctor's federation-driver check (flair#1514).
3
+ *
4
+ * `describeScheduledDriverFinding` treats unit-files-on-disk + not-loaded as
5
+ * ✗ INSTALLED BUT NOT LOADED. On a standalone install that never paired a
6
+ * hub there is nothing for the driver to run, so that ✗ is a false alarm
7
+ * (and a red one — operators tightening a fleet read it as broken sync).
8
+ *
9
+ * The gate:
10
+ * - zero peers configured → N/A / informational, never ✗, never an issue
11
+ * - peers ARE configured + driver present → the scheduled-driver verdict
12
+ * - peers ARE configured + driver missing/broken → still ✗
13
+ *
14
+ * "Peers configured" is an OR of the same places the issue asked us to look:
15
+ * live /FederationPeers, component config.yaml (resolved from the component
16
+ * dir — see doctor-config-path.ts), FLAIR_FEDERATION_* hub/peer env (not
17
+ * the require-* policy flags), and node-scoped federation keys.
18
+ *
19
+ * Policy flags (`FLAIR_FEDERATION_REQUIRE_RECORD_*`) do not mean peers exist.
20
+ * Treating them as configured would re-fail a standalone install that only
21
+ * set a harden-the-edge default.
22
+ */
23
+ import { existsSync, readFileSync } from "node:fs";
24
+ import { load as parseYaml } from "js-yaml";
25
+ import { describeScheduledDriverFinding, } from "./scheduler-platform.js";
26
+ /** Known policy-only FLAIR_FEDERATION_* keys — not a peer/hub declaration. */
27
+ export const FEDERATION_POLICY_ENV_KEYS = [
28
+ "FLAIR_FEDERATION_REQUIRE_RECORD_SIGNATURES",
29
+ "FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL",
30
+ ];
31
+ const POLICY_ENV = new Set(FEDERATION_POLICY_ENV_KEYS);
32
+ const ENV_ASSIGNMENT_RE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/;
33
+ /**
34
+ * True when any signal says this install has federation peers (or a hub)
35
+ * to sync with. Any positive signal wins — missing the component-dir
36
+ * config is how the check would go silent on a real peered install.
37
+ */
38
+ export function federationPeersConfigured(input) {
39
+ if (typeof input.livePeerCount === "number" && input.livePeerCount > 0)
40
+ return true;
41
+ if (input.nodeKeyIds && input.nodeKeyIds.length > 0)
42
+ return true;
43
+ if (envDeclaresFederationPeers(input.env ?? {}))
44
+ return true;
45
+ if (configDeclaresFederationPeers(input.configDoc))
46
+ return true;
47
+ return false;
48
+ }
49
+ export function envDeclaresFederationPeers(env) {
50
+ for (const [key, value] of Object.entries(env)) {
51
+ if (!key.startsWith("FLAIR_FEDERATION_"))
52
+ continue;
53
+ if (POLICY_ENV.has(key))
54
+ continue;
55
+ if (typeof value === "string" && value.trim().length > 0)
56
+ return true;
57
+ }
58
+ return false;
59
+ }
60
+ function looksLikePeerEntry(entry) {
61
+ if (!entry || typeof entry !== "object")
62
+ return false;
63
+ const o = entry;
64
+ const keys = ["endpoint", "url", "hub", "target", "publicKey", "role", "id"];
65
+ return keys.some((k) => typeof o[k] === "string" && String(o[k]).trim().length > 0);
66
+ }
67
+ function nonEmptyString(value) {
68
+ return typeof value === "string" && value.trim().length > 0;
69
+ }
70
+ function peersArrayDeclares(value) {
71
+ return Array.isArray(value) && value.length > 0 && value.some(looksLikePeerEntry);
72
+ }
73
+ /**
74
+ * Conservative read of config.yaml: only explicit federation hub/peer
75
+ * declarations count. A Harper component config with no `federation`
76
+ * block (the shipped default) is not configured.
77
+ */
78
+ export function configDeclaresFederationPeers(doc) {
79
+ if (!doc || typeof doc !== "object")
80
+ return false;
81
+ const root = doc;
82
+ const fed = root.federation;
83
+ if (fed && typeof fed === "object") {
84
+ const f = fed;
85
+ if (peersArrayDeclares(f.peers))
86
+ return true;
87
+ if (nonEmptyString(f.hub) || nonEmptyString(f.endpoint) || nonEmptyString(f.target) || nonEmptyString(f.url)) {
88
+ return true;
89
+ }
90
+ }
91
+ if (peersArrayDeclares(root.peers))
92
+ return true;
93
+ return false;
94
+ }
95
+ /** Load + parse a YAML file; unreadable/unparseable → null (not "no peers"). */
96
+ export function loadYamlDoc(path) {
97
+ try {
98
+ if (!existsSync(path))
99
+ return null;
100
+ return parseYaml(readFileSync(path, "utf-8")) ?? null;
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ }
106
+ /** KEY=value assignments from a .env body. Values are not interpreted beyond trim/quotes. */
107
+ export function parseEnvAssignments(text) {
108
+ const out = {};
109
+ for (const line of text.split(/\r?\n/)) {
110
+ const m = ENV_ASSIGNMENT_RE.exec(line);
111
+ if (!m)
112
+ continue;
113
+ let v = line.slice(line.indexOf("=") + 1).trim();
114
+ if ((v.startsWith('"') && v.endsWith('"') && v.length >= 2) ||
115
+ (v.startsWith("'") && v.endsWith("'") && v.length >= 2)) {
116
+ v = v.slice(1, -1);
117
+ }
118
+ out[m[1]] = v;
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * Merge process.env FLAIR_FEDERATION_* with assignments from component
124
+ * `.env` files. Process env wins (same as Harper loadEnv: already-set
125
+ * names are not overwritten).
126
+ */
127
+ export function collectFederationEnv(opts) {
128
+ const out = {};
129
+ const processEnv = opts.processEnv ?? process.env;
130
+ for (const [key, value] of Object.entries(processEnv)) {
131
+ if (key.startsWith("FLAIR_FEDERATION_"))
132
+ out[key] = value;
133
+ }
134
+ for (const p of opts.envFilePaths ?? []) {
135
+ if (!existsSync(p))
136
+ continue;
137
+ let text;
138
+ try {
139
+ text = readFileSync(p, "utf-8");
140
+ }
141
+ catch {
142
+ continue;
143
+ }
144
+ const parsed = parseEnvAssignments(text);
145
+ for (const [key, value] of Object.entries(parsed)) {
146
+ if (!key.startsWith("FLAIR_FEDERATION_"))
147
+ continue;
148
+ if (out[key] === undefined)
149
+ out[key] = value;
150
+ }
151
+ }
152
+ return out;
153
+ }
154
+ /**
155
+ * Doctor verdict for the federation sync driver (flair#1514).
156
+ *
157
+ * Not-configured is N/A: informational marker, never the fail marker,
158
+ * never an issue. Configured + missing/broken still ✗ — the check is
159
+ * gated, not silenced.
160
+ */
161
+ export function describeFederationDriverFinding(input) {
162
+ if (!input.peersConfigured) {
163
+ const idle = input.driver.installed
164
+ ? "not configured (driver installed, idle)"
165
+ : "not configured";
166
+ return {
167
+ state: "not-enabled",
168
+ icon: "info",
169
+ isIssue: false,
170
+ message: `Federation: ${idle}`,
171
+ detail: input.driver.installed
172
+ ? ["No peers configured — the sync driver has nothing to run. This is the healthy default."]
173
+ : ["No peers configured. Pair a hub (`flair federation pair`) before enabling the sync driver."],
174
+ };
175
+ }
176
+ if (!input.driver.installed) {
177
+ return {
178
+ state: "degraded",
179
+ icon: "error",
180
+ isIssue: true,
181
+ message: `${input.driver.label}: not installed — nothing will run federation sync`,
182
+ detail: [
183
+ "Peers are configured, but the sync driver is not installed, so nothing will push to them.",
184
+ `Fix: ${input.driver.enableCommand} # then check: ${input.driver.statusCommand}`,
185
+ ],
186
+ };
187
+ }
188
+ return describeScheduledDriverFinding(input.driver);
189
+ }
@@ -22,6 +22,8 @@
22
22
  import { existsSync, readdirSync } from "node:fs";
23
23
  import { checkClaudeMdBootstrap, checkSessionStartHook, effectiveFlairUrl, hookCommandIsSilenced, isFlairHookCommand, partitionKeyIds, planAgentIterations, readClientMcpBlock, } from "../doctor-client.js";
24
24
  import { hookInstallHint, hookSettingsPath, installHook, SUPPORTED_HARNESSES, } from "../hook-install.js";
25
+ import { staleHookRemedy, staleMcpClientPins, staleSessionStartHookPins } from "./owned-pins.js";
26
+ import { flairCliVersion, isResolvedVersion } from "./mcp-spec.js";
25
27
  import { isDetached, renderDetachedWarning, } from "./launchd-management.js";
26
28
  /**
27
29
  * The catalog. This is the contract upgrade asserts against.
@@ -70,6 +72,24 @@ function runMcpBlock(ctx) {
70
72
  detail: `no Flair MCP server wired into any detected client (${mcp.join(", ")}) — wire one with: flair init --client <id>`,
71
73
  });
72
74
  }
75
+ // flair#1485 / Bugbot: a failed MCP pin refresh used to print nothing and
76
+ // leave the old @tpsdev-ai/flair-mcp pin in place. Presence is not
77
+ // currency — the same catalogue upgrade refreshes must fail here too.
78
+ const expected = flairCliVersion();
79
+ if (isResolvedVersion(expected)) {
80
+ const stale = staleMcpClientPins(ctx.homeDir, expected)
81
+ .filter((r) => wired.includes(r.target.id));
82
+ if (stale.length > 0) {
83
+ const first = stale[0];
84
+ const detail = stale.length === 1
85
+ ? `MCP server (${first.target.id}): pinned to flair-mcp@${first.pin} (installed CLI is ${expected})`
86
+ : `MCP server: stale pins ${stale.map((s) => `${s.target.id}@${s.pin}`).join(", ")} (installed CLI is ${expected})`;
87
+ return result(id, label, "fail", {
88
+ detail,
89
+ remedy: "flair upgrade",
90
+ });
91
+ }
92
+ }
73
93
  return result(id, label, "pass", { detail: `configured for ${wired.join(", ")}` });
74
94
  }
75
95
  function runFlairUrl(ctx) {
@@ -155,6 +175,26 @@ function runSessionStartHook(ctx) {
155
175
  remedy: hookInstallHint(harness),
156
176
  });
157
177
  }
178
+ // flair#1485: "it runs" is not "it is current". A hook whose pin is not
179
+ // the installed CLI version still launches the old adapter on every
180
+ // session. Compare against flairCliVersion() — not the MCP client pin —
181
+ // so two equally-stale pins cannot hide each other. Same owned-pin
182
+ // catalogue `flair upgrade` refreshes.
183
+ const expected = flairCliVersion();
184
+ if (isResolvedVersion(expected)) {
185
+ const stale = staleSessionStartHookPins(ctx.homeDir, expected)
186
+ .filter((r) => harnesses.includes(r.target.id));
187
+ if (stale.length > 0) {
188
+ const first = stale[0];
189
+ const detail = stale.length === 1
190
+ ? `SessionStart hook (${first.target.id}): pinned to flair-mcp@${first.pin} (installed CLI is ${expected})`
191
+ : `SessionStart hook: stale pins ${stale.map((s) => `${s.target.id}@${s.pin}`).join(", ")} (installed CLI is ${expected})`;
192
+ return result(id, label, "fail", {
193
+ detail,
194
+ remedy: staleHookRemedy(stale),
195
+ });
196
+ }
197
+ }
158
198
  return result(id, label, "pass", {
159
199
  detail: `wired for ${harnesses.join(", ")}`,
160
200
  });
@@ -5,9 +5,9 @@
5
5
  * INLINED, not imported: cross-boundary imports from src/ into resources/
6
6
  * don't survive npm packaging — tsconfig.cli.json compiles with
7
7
  * `rootDir: "src"`, so dist/cli.js has no resources/ module it can resolve
8
- * at the same relative path. This is the same reason src/cli.ts inlines the
9
- * federation crypto helpers (see the note beside `sortKeys()` there) and the
10
- * private-visibility filter. The two files MUST stay in sync:
8
+ * at the same relative path. This is the same reason src/commands/federation.ts
9
+ * inlines the federation crypto helpers (see the note beside `sortKeys()` there)
10
+ * and the private-visibility filter. The two files MUST stay in sync:
11
11
  * test/unit/cli-entities-option.test.ts imports BOTH and pins ENTITY_TYPES
12
12
  * equality, validator parity across a known-answer table, and the
13
13
  * entityFormatHint() string — drift fails CI rather than shipping.
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Spoke pair identity (flair#822).
3
+ *
4
+ * Pair already returns `instance.{id,publicKey}` when the hub has a
5
+ * FederationInstance row (flair#213). An empty spoke hub-Peer key means
6
+ * that row was missing at pair time — a symptom of open #839, not a
7
+ * pair-response-shape bug. Chip is fail-closed: ERROR, never store
8
+ * `publicKey: ""`. Do not GET `/FederationInstance` from the spoke —
9
+ * that path is admin-gated (bootstrap Basic cannot read it) and
10
+ * find-or-creates a new Instance on a miss, which would invent a hub
11
+ * row. A spoke Peer write does not provision the missing hub row.
12
+ */
13
+ function nonEmptyString(value) {
14
+ return typeof value === "string" ? value.trim() : "";
15
+ }
16
+ function instancePublicKey(value) {
17
+ const row = value && typeof value === "object" ? value : null;
18
+ return {
19
+ id: nonEmptyString(row?.id),
20
+ publicKey: nonEmptyString(row?.publicKey),
21
+ };
22
+ }
23
+ /**
24
+ * Accept a pair JSON body only when `instance.publicKey` is non-empty.
25
+ * `id` may fall back to `"hub"` if the key is present but the id is not.
26
+ */
27
+ export function hubPeerFromPairResult(result) {
28
+ const inst = result && typeof result === "object"
29
+ ? result.instance
30
+ : undefined;
31
+ const { id, publicKey } = instancePublicKey(inst);
32
+ if (!publicKey)
33
+ return { ok: false, reason: "missing_public_key" };
34
+ return { ok: true, peer: { id: id || "hub", publicKey } };
35
+ }
36
+ export const EMPTY_HUB_PEER_KEY_ERROR = "hub pair response omitted instance.publicKey — refusing to store an empty hub Peer key. " +
37
+ "The hub likely has no FederationInstance row (flair#839); a spoke Peer write does not create one.";
38
+ /**
39
+ * Fail-closed: pair `instance.publicKey` or ERROR. Never returns an empty key.
40
+ * Does not fetch `/FederationInstance` (admin-gated + find-or-create).
41
+ */
42
+ export function resolveHubPeerIdentity(pairResult) {
43
+ const fromPair = hubPeerFromPairResult(pairResult);
44
+ if (fromPair.ok)
45
+ return { ...fromPair, source: "pair" };
46
+ return { ok: false, error: EMPTY_HUB_PEER_KEY_ERROR };
47
+ }
@@ -182,13 +182,14 @@ export function decideAdoptStop(state, postStopHealth) {
182
182
  remedy: ["flair stop", "flair doctor --fix"],
183
183
  };
184
184
  }
185
- // Proceed ONLY when the port is provably free (ECONNREFUSED). "ok" means
186
- // something is still serving; "unreachable" means a wedged daemon may still
187
- // be BOUND to the port (ignored SIGTERM) — both would EADDRINUSE on load.
185
+ // Proceed ONLY when the port is provably free (ECONNREFUSED). "ok" or
186
+ // "foreign" means something is still serving; "unreachable" means a wedged
187
+ // daemon may still be BOUND to the port (ignored SIGTERM) — both would
188
+ // EADDRINUSE on load.
188
189
  if (postStopHealth.kind !== "refused") {
189
190
  return {
190
191
  kind: "failed",
191
- detail: postStopHealth.kind === "ok"
192
+ detail: postStopHealth.kind === "ok" || postStopHealth.kind === "foreign"
192
193
  ? "port still occupied after stopping the direct process"
193
194
  : "port not confirmed free after stopping the direct process (a wedged process may still hold it)",
194
195
  remedy: ["flair stop", "flair doctor --fix"],
@@ -0,0 +1,115 @@
1
+ /**
2
+ * ops-api-bind.ts — one decision for "is the Harper ops API bound to all
3
+ * interfaces?", shared by `flair doctor` and `flair status` (flair#670,
4
+ * unified in flair#852).
5
+ *
6
+ * The Harper ops API used to bind all interfaces unconditionally. `flair init`
7
+ * now defaults it to loopback + the domain socket, with an escape hatch
8
+ * (`--ops-bind` / `FLAIR_OPS_BIND`) for deployments that genuinely need remote
9
+ * ops access (flair#670). That narrowing is only useful if every health surface
10
+ * agrees on what the running install actually bound, and it is what doctor
11
+ * reads out of Harper's own config. The predicate is an allow-list: ONLY a
12
+ * loopback host narrows the bind. A bare numeric port, a wildcard host
13
+ * (`0.0.0.0:19925`, `[::]:19925`, …), an empty/unspecified host and an
14
+ * unparseable value are all reported as exposed — a `host:port` string is not
15
+ * evidence of narrowing unless the host is actually loopback.
16
+ *
17
+ * That allow-list is the flair#852 wildcard blind spot. The detector originally
18
+ * treated ANY `host:port` as narrowed, so `flair init --ops-bind 0.0.0.0`
19
+ * persisted `0.0.0.0:19925` and both `flair status` and `flair doctor` printed
20
+ * green while the ops API was reachable off-box.
21
+ *
22
+ * flair#852 was the two surfaces disagreeing. `flair doctor` flagged the bare
23
+ * port while `flair status` printed "✓ all checks passing" — a security-relevant
24
+ * exposure visible to one command and invisible to the other, so users shipped
25
+ * with the ops API reachable off-box. The fix is structural: the decision lives
26
+ * HERE, both commands call it, and status folds the finding into the same
27
+ * warning verdict that drives its green line.
28
+ *
29
+ * Pure: parsing Harper's config is the caller's job (doctor/status both read it
30
+ * with `readHarperConfig`); this module only decides.
31
+ */
32
+ /**
33
+ * Hosts that genuinely narrow the ops-API bind to loopback. This is an
34
+ * allow-list on purpose: ANYTHING not in it — a wildcard (`0.0.0.0`, `::`,
35
+ * `[::]`, `0:0:0:0:0:0:0:0`), an explicit routable host, an empty/unspecified
36
+ * host, or a value we cannot parse — is reported as exposed. The cost of a
37
+ * missed exposure (a user ships an ops API reachable off-box) is worse than the
38
+ * cost of warning about a bind we did not recognise.
39
+ */
40
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
41
+ /**
42
+ * Best-effort host half of a persisted `operationsApi.network.port` value.
43
+ * Returns null when the value declares no host (a bare port) or the host is
44
+ * unparseable; the caller reports both as exposed.
45
+ *
46
+ * Handles the forms flair and Harper write:
47
+ * - `127.0.0.1:19925` → `127.0.0.1`
48
+ * - `[::1]:19925` → `::1` (bracketed IPv6, port stripped)
49
+ * - `[::]:19925` → `::`
50
+ * - `::1:19925` → `::1` (bare IPv6 with a trailing numeric port)
51
+ * - `::` → `::` (bare wildcard, nothing to strip)
52
+ * - `0:0:0:0:0:0:0:0` → `0:0:0:0:0:0:0` (still not loopback — flagged)
53
+ *
54
+ * Splits on the LAST colon so an IPv6 literal keeps its port, matching
55
+ * `harperPortValue`. A trailing all-digit segment is treated as the port; a
56
+ * bare IPv6 like `::` has no trailing port, so the whole value is the host.
57
+ */
58
+ function parseBindHost(str) {
59
+ if (str.startsWith("[")) {
60
+ const close = str.indexOf("]");
61
+ if (close === -1)
62
+ return null; // malformed bracket form — unparseable
63
+ return str.slice(1, close);
64
+ }
65
+ const lastColon = str.lastIndexOf(":");
66
+ if (lastColon === -1)
67
+ return null; // bare port — no host to narrow on
68
+ const tail = str.slice(lastColon + 1);
69
+ return /^\d+$/.test(tail) ? str.slice(0, lastColon) : str;
70
+ }
71
+ /**
72
+ * Decide whether a persisted `operationsApi.network.port` value (read back from
73
+ * harper-config.yaml) indicates an ops-API bind reachable off-box.
74
+ *
75
+ * A bare port number/numeric string is Harper's all-interfaces default (the
76
+ * pre-flair#670 behavior, or an install that predates the fix and has not been
77
+ * re-`init`ed). A `host:port` string narrows the bind ONLY when the host is
78
+ * loopback — a wildcard (`0.0.0.0:19925`, `[::]:19925`, `::19925`) is still
79
+ * all-interfaces, and is flagged (flair#852). Empty/unspecified and unparseable
80
+ * values are flagged too.
81
+ */
82
+ export function detectOpsApiAllInterfacesBind(portValue) {
83
+ if (portValue === undefined || portValue === null)
84
+ return { allInterfaces: false, boundHost: null };
85
+ const str = String(portValue).trim();
86
+ if (str === "")
87
+ return { allInterfaces: false, boundHost: null };
88
+ const host = parseBindHost(str);
89
+ if (host !== null && LOOPBACK_HOSTS.has(host.toLowerCase())) {
90
+ return { allInterfaces: false, boundHost: host };
91
+ }
92
+ return { allInterfaces: true, boundHost: null };
93
+ }
94
+ const OPS_API_BIND_REMEDY = "Single-host installs don't need this reachable off-box. Fix: flair init && flair restart " +
95
+ "(rebinds to loopback + domain socket; re-init reuses your existing admin password, so this is " +
96
+ "safe on a running install — pass --ops-bind for deliberate remote admin)";
97
+ /**
98
+ * The ops-API bind finding for a parsed harper-config, or null when the config
99
+ * declares no ops port (nothing to report). This is the single source of truth
100
+ * `flair doctor` and `flair status` both call, so they cannot disagree about
101
+ * the same instance.
102
+ */
103
+ export function opsApiBindFinding(harperConfig) {
104
+ const portValue = harperConfig?.operationsApi?.network?.port;
105
+ if (portValue === undefined || portValue === null || String(portValue).trim() === "")
106
+ return null;
107
+ const { allInterfaces, boundHost } = detectOpsApiAllInterfacesBind(portValue);
108
+ return {
109
+ allInterfaces,
110
+ portValue,
111
+ boundHost,
112
+ message: `Ops API bound to all interfaces (${String(portValue)})`,
113
+ remedy: OPS_API_BIND_REMEDY,
114
+ };
115
+ }