@tpsdev-ai/flair 0.31.0 → 0.32.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/README.md CHANGED
@@ -40,7 +40,19 @@ Self-hosted on [Harper](https://harper.fast) as a single process. No sidecars, n
40
40
 
41
41
  ## Quick start
42
42
 
43
- Needs **Node.js 22+** and a **user-writable npm global prefix**.
43
+ Two front doors. Pick by where your code already runs.
44
+
45
+ ### Already on Harper? Load Flair as a component
46
+
47
+ Flair *is* a Harper component. Deploy it into the instance your application already runs in and call its resources directly — `await h.post({ agentId, content })` is a **method call**, not a network call. No second service to operate, no HTTP round trip, and no key to distribute: a caller in the same process is already inside the trust boundary and names the agent it is acting as, per call.
48
+
49
+ Adding it takes nothing away. The HTTP surface keeps serving MCP clients and remote agents exactly as before.
50
+
51
+ **→ [Embedding Flair in a Harper app](docs/embedding-in-a-harper-app.md)** — the whole in-process contract, measured against a real instance: resolving the resource, the table-vs-resource distinction that decides whether your memories are scoped at all, N agents in one process, and registering agents with no shell on the node.
52
+
53
+ ### Everywhere else — install the CLI
54
+
55
+ A laptop, a VPS, an MCP client, or any language over HTTP. Needs **Node.js 22+** and a **user-writable npm global prefix**.
44
56
 
45
57
  > ⚠️ **Never `sudo npm install -g @tpsdev-ai/flair`.** A root-owned install can't write the embedding model into its own package directory, so semantic search silently degrades to keyword-only. `flair init` and `flair doctor` will warn you loudly. Use `nvm`, or point npm at your home directory: `npm config set prefix ~/.npm-global` and add `~/.npm-global/bin` to `PATH`.
46
58
 
@@ -73,6 +85,14 @@ That trailing figure is a rank score, normalized so the top hit is always near 1
73
85
 
74
86
  Full walkthrough with expected output at every step: **[docs/quickstart.md](docs/quickstart.md)**.
75
87
 
88
+ ### Where the agent's key lives
89
+
90
+ `flair init --agent mybot` writes the private key to `~/.flair/keys/mybot.key` (mode `0600`) and the public half beside it as `mybot.pub`. Only the **public** key is registered on the instance; the private key never leaves the machine. `--keys-dir` writes both somewhere else.
91
+
92
+ **Back that file up — it is the agent's identity, and there is one copy.** Memories are not encrypted with it, so losing it costs the identity, not the data: the agent can no longer sign, and every HTTP call it makes fails. Recovery is `flair agent rotate-key mybot`, which mints a new pair and re-registers the public half — it needs the admin password `flair init` wrote to `~/.flair/admin-pass`, so back that up too. Otherwise treat the key like an SSH key: one per agent per host, never copied between machines ([docs/secrets-and-keys.md](docs/secrets-and-keys.md)).
93
+
94
+ **The in-process path needs no key at all.** Keys are how an agent *outside* the process proves who it is. Code running inside the same Harper instance asserts identity through the call context instead — `agentContext("mybot")` — which Flair reads and acts on with no signature, no `Agent`-table lookup and no registration. That is deliberate: same-process code could write the storage tables directly, so demanding a signature from it would be theatre. It is also why that id must come from your own server-side state and never from request data.
95
+
76
96
  ### Useful flags
77
97
 
78
98
  ```bash
@@ -278,24 +298,30 @@ Sign `agentId:timestamp:nonce:METHOD:/path` with the agent's private key. Protoc
278
298
 
279
299
  ### Embedded in a Harper app (in-process)
280
300
 
281
- Flair *is* a Harper component. If your application already runs on Harper, load Flair into the same instance and call its resources directly — a method call instead of an HTTP round trip.
301
+ Flair *is* a Harper component. If your application already runs on Harper, load Flair into the same instance and call its resources directly — a method call instead of an HTTP round trip, and no key anywhere.
282
302
 
283
303
  ```javascript
284
304
  import { server } from "harper";
305
+ import { agentContext, collectionResource } from "@tpsdev-ai/flair/dist/resources/in-process.js";
306
+
307
+ // The RESOURCE — auth, read-scoping, visibility, embedding.
308
+ // Registry keys carry no leading slash: get("Memory"), never get("/Memory").
309
+ const Memory = server.resources.get("Memory").Resource;
285
310
 
286
- const Memory = server.resources.get("Memory").Resource; // the resource, not the table
287
- const h = new Memory(undefined, { request: { tpsAgent: "mybot" } });
311
+ const h = await collectionResource(Memory, agentContext("mybot"));
288
312
  await h.post({ agentId: "mybot", content: "...", durability: "standard" });
289
313
  ```
290
314
 
291
- `databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live. A context-less call runs unfiltered. Full guide: **[docs/embedding-in-a-harper-app.md](docs/embedding-in-a-harper-app.md)**.
315
+ `databases.flair.Memory` is the **table** (raw storage); the exported `Memory` class is the **resource**, where auth, read-scoping, visibility and embedding live. `new Memory(...)` is not a substitute for `collectionResource` — a create needs a collection-bound instance only Harper can produce. Both helpers refuse a missing agent id rather than defaulting it, because a resource invoked with no context resolves to Flair's trusted `internal` verdict and runs unfiltered across every agent. Full guide: **[docs/embedding-in-a-harper-app.md](docs/embedding-in-a-harper-app.md)**.
292
316
 
293
317
  ### Auth across surfaces
294
318
 
295
- The default everywhere is **Ed25519 per-agent**: each agent holds its own key at `~/.flair/keys/<agent>.key` and signs every request. That gives write isolation — no agent can write as another — and identity-verified reads. It does *not* refuse cross-agent reads: within one instance, any verified agent can read any other agent's non-private memory by design. The hard boundary is the federation edge, not intra-instance reads. See [SECURITY.md](SECURITY.md).
319
+ For every caller that reaches Flair over the network the default is **Ed25519 per-agent**: each agent holds its own key at `~/.flair/keys/<agent>.key` and signs every request. That gives write isolation — no agent can write as another — and identity-verified reads. It does *not* refuse cross-agent reads: within one instance, any verified agent can read any other agent's non-private memory by design. The hard boundary is the federation edge, not intra-instance reads. See [SECURITY.md](SECURITY.md).
296
320
 
297
321
  One exception: the **`n8n-nodes-flair`** node authenticates with the Harper **admin password** (Basic auth), which bypasses agent scoping entirely — it can read other agents' `visibility: private` memories and write as anyone. That is acceptable only on a single-tenant, operator-controlled n8n with trusted workflow inputs. Otherwise prefer the Ed25519 path. Full breakdown in **[docs/auth.md](docs/auth.md#auth-across-surfaces-read-this-first)**.
298
322
 
323
+ In-process callers are a different model, not an exception to this one: they never sign, because identity is asserted through the call context rather than proven. Co-location *is* the grant — which is why Flair beside untrusted co-tenants on a shared instance is a different proposition to Flair inside your own app.
324
+
299
325
  ## Deployment
300
326
 
301
327
  ### Local (default)
package/dist/cli.js CHANGED
@@ -269,7 +269,19 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
269
269
  }
270
270
  catch { /* best effort */ }
271
271
  const legacyContent = readFileSync(resolved.plistPath, "utf-8");
272
- const newContent = legacyContent.replace(`<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`, `<key>Label</key><string>${newLabel}</string>`);
272
+ // Use a function replacer to avoid $-sensitivity in the replacement
273
+ // string (flair#919). String.prototype.replace interprets $&, $', $`
274
+ // etc. in the replacement even when the search value is a plain string.
275
+ const labelSearch = `<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`;
276
+ const labelReplacement = `<key>Label</key><string>${newLabel}</string>`;
277
+ const newContent = legacyContent.replace(labelSearch, () => labelReplacement);
278
+ // Refuse to propagate a malformed plist: if the Label wasn't found,
279
+ // the plist is not what we expect and migration must not write it.
280
+ if (newContent === legacyContent) {
281
+ throw new Error(`Legacy plist at ${resolved.plistPath} does not contain the expected ` +
282
+ `Label key — it may be malformed or from an unknown Flair version. ` +
283
+ `Remove it manually and re-run 'flair init'.`);
284
+ }
273
285
  writeFileSync(newPlistPath, newContent);
274
286
  try {
275
287
  unlinkSync(resolved.plistPath);
@@ -277,6 +289,58 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
277
289
  catch { /* best effort */ }
278
290
  return { migrated: true, label: newLabel, plistPath: newPlistPath };
279
291
  }
292
+ /**
293
+ * Clean up a pre-flair#693 legacy launchd plist (ai.tpsdev.flair) during
294
+ * init, but ONLY when it belongs to the data dir being initialised.
295
+ *
296
+ * flair#966: the legacy plist is a single global label — init must not
297
+ * unload/delete it unless ROOTPATH proves it serves this data dir.
298
+ *
299
+ * `runLaunchctl` is injected so tests can record/mock without touching
300
+ * real launchd. The caller (init) passes a real execSync wrapper; tests
301
+ * pass a recording stub.
302
+ */
303
+ function cleanupLegacyLaunchdPlist(dataDir, plistDir, runLaunchctl) {
304
+ const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
305
+ if (!existsSync(legacyPlistPath))
306
+ return { action: "none" };
307
+ const legacyRootPath = readPlistRootPath(legacyPlistPath);
308
+ const legacyOwnedByUs = legacyRootPath !== null && resolve(legacyRootPath) === resolve(dataDir);
309
+ if (legacyOwnedByUs) {
310
+ let unloadFailed;
311
+ let deleteFailed;
312
+ try {
313
+ runLaunchctl(`launchctl unload "${legacyPlistPath}"`);
314
+ }
315
+ catch (err) {
316
+ unloadFailed = err?.message ?? String(err);
317
+ console.error(`Failed to unload legacy launchd service (${LEGACY_LAUNCHD_LABEL}): ` +
318
+ `${unloadFailed}. ` +
319
+ `The plist at ${legacyPlistPath} may still be loaded — ` +
320
+ `unload it manually with: launchctl unload "${legacyPlistPath}"`);
321
+ }
322
+ try {
323
+ unlinkSync(legacyPlistPath);
324
+ console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
325
+ }
326
+ catch (err) {
327
+ deleteFailed = err?.message ?? String(err);
328
+ console.error(`Failed to remove legacy launchd plist at ${legacyPlistPath}: ` +
329
+ `${deleteFailed}. Remove it manually.`);
330
+ }
331
+ return { action: "unloaded", unloadFailed, deleteFailed };
332
+ }
333
+ if (legacyRootPath !== null) {
334
+ console.log(`Skipped legacy launchd cleanup: the plist at ${legacyPlistPath} ` +
335
+ `belongs to data dir ${resolve(legacyRootPath)}, not ${resolve(dataDir)} — ` +
336
+ `that is a different Flair instance.`);
337
+ return { action: "skipped-foreign", foreignDataDir: resolve(legacyRootPath) };
338
+ }
339
+ console.log(`Skipped legacy launchd cleanup: could not determine which data dir ` +
340
+ `the plist at ${legacyPlistPath} serves. ` +
341
+ `If it is yours, remove it manually with: rm "${legacyPlistPath}"`);
342
+ return { action: "skipped-unknown" };
343
+ }
280
344
  /**
281
345
  * Load + start `dataDir`'s launchd service, migrating off a pre-flair#693
282
346
  * legacy registration FIRST if one is found (migrateLegacyLaunchdLabel
@@ -290,6 +354,13 @@ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defa
290
354
  */
291
355
  function ensureLaunchdServiceLoaded(dataDir, runLaunchctl, launchAgentsDir = defaultLaunchAgentsDir()) {
292
356
  const migration = migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir);
357
+ // Unload first so a rewritten plist is re-read (flair#872).
358
+ // launchd caches the environment of an already-loaded job; load
359
+ // alone does not pick up changes to the plist on disk.
360
+ try {
361
+ runLaunchctl(`launchctl unload "${migration.plistPath}"`);
362
+ }
363
+ catch { /* not loaded, etc. — best effort */ }
293
364
  try {
294
365
  runLaunchctl(`launchctl load "${migration.plistPath}"`);
295
366
  }
@@ -500,15 +571,14 @@ function readOpsPortFromConfig(path = configPath()) {
500
571
  * a clean install has always done, instead of the refusal that would make
501
572
  * `flair init --data-dir <new>` impossible.
502
573
  *
503
- * Note that `init`'s own `--port` option carries a commander default of
504
- * DEFAULT_PORT, so in practice `opts.port` is always set there and this
505
- * function returns on the first rung — including when re-initialising an
506
- * instance that already serves a different port, which is silently renumbered
507
- * to DEFAULT_PORT. That is pre-existing (it is equally true of the default
508
- * install on a custom port, where re-running init is `flair doctor`'s standing
509
- * remedy) and is tracked separately; the "create" rung is what this function
510
- * would answer if that default were removed, and is what keeps the mode
511
- * distinction honest rather than hypothetical.
574
+ * The "create" rung is load-bearing rather than hypothetical (flair#928).
575
+ * `init`'s `--port` used to carry a commander default of DEFAULT_PORT, so
576
+ * `opts.port` was ALWAYS set there and this function returned on the first rung
577
+ * including when re-initialising an instance already serving a different
578
+ * port, which was silently renumbered to DEFAULT_PORT. Commander cannot tell
579
+ * "the user passed the default" from "the user passed nothing", so the default
580
+ * was the bug. It is gone; a bare `init` now falls through to the ladder, and
581
+ * only a directory no instance has ever been served from reaches DEFAULT_PORT.
512
582
  *
513
583
  * Nothing is copied, migrated or written here. Harper's config already IS the
514
584
  * per-instance record, so there is no second file to keep in step — which is
@@ -2386,6 +2456,14 @@ const __pkgVersion = flairCliVersion();
2386
2456
  export { mcpServerSpec };
2387
2457
  const program = new Command();
2388
2458
  program.name("flair").version(__pkgVersion, "-v, --version");
2459
+ // flair#926: an option declared on a parent is consumed by the PARENT even when
2460
+ // it appears after a subcommand name, so a subcommand must NOT redeclare one —
2461
+ // the duplicate never receives a value, it only makes the flag look local.
2462
+ // Removing those duplicates would have hidden working flags from the
2463
+ // subcommand's help, so the help is taught to show inherited options instead.
2464
+ // This is commander's own answer to the problem, and it applies to every
2465
+ // subcommand at once rather than one hand-maintained list of exceptions.
2466
+ program.configureHelp({ showGlobalOptions: true });
2389
2467
  // ─── CLI↔server version handshake (flair#695 §B) ────────────────────────────
2390
2468
  // Every command invocation gets a cheap, cached (~60s), short-timeout check
2391
2469
  // of the running server's version against this CLI's own — catches the
@@ -2434,7 +2512,12 @@ program
2434
2512
  .description("One-command Flair setup — bootstrap the instance, register an agent, and wire MCP clients")
2435
2513
  .option("--agent-id <id>", "Agent ID to register (omit to bootstrap instance without agent)")
2436
2514
  .option("--agent <id>", "Alias for --agent-id")
2437
- .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
2515
+ // No commander default (flair#928). A default here is indistinguishable from
2516
+ // the user typing it, so a BARE `flair init` used to state DEFAULT_PORT and
2517
+ // renumber an instance already serving a custom one. Absent means absent, and
2518
+ // resolveHttpPort's "create" ladder supplies DEFAULT_PORT for a genuinely new
2519
+ // instance — which is the only case that ever wanted one.
2520
+ .option("--port <port>", "Harper HTTP port (default: this instance's current port, or 19926 for a new one)")
2438
2521
  .option("--ops-port <port>", "Harper operations API port")
2439
2522
  .option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
2440
2523
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
@@ -2640,9 +2723,14 @@ program
2640
2723
  // directory with no recorded port is a new instance taking the default,
2641
2724
  // not the hard error every other caller gets — otherwise `flair init
2642
2725
  // --data-dir <new>` could never succeed. `dataDir` is resolved first so
2643
- // this is never asked before the instance is known. (`--port` carries a
2644
- // commander default, so this usually returns on the flag rung; see
2645
- // resolveHttpPort's doc comment.)
2726
+ // this is never asked before the instance is known.
2727
+ //
2728
+ // flair#928: `--port` deliberately carries NO commander default, so a bare
2729
+ // `init` reaches the ladder below instead of restating DEFAULT_PORT and
2730
+ // renumbering an instance that already serves a custom port. `init` is
2731
+ // `flair doctor`'s standing remedy and is recommended in ten places, so the
2732
+ // command handed to an operator whose install is already wrong must not be
2733
+ // the one that moves their port.
2646
2734
  const httpPort = resolveHttpPort(opts, "create");
2647
2735
  // The already-resolved port is handed to the ops resolver rather than
2648
2736
  // letting it re-resolve — its last rung is `resolveHttpPort(opts) - 1`,
@@ -2916,25 +3004,20 @@ program
2916
3004
  const plistDir = defaultLaunchAgentsDir();
2917
3005
  mkdirSync(plistDir, { recursive: true });
2918
3006
  const plistPath = launchdPlistPath(label, plistDir);
2919
- // flair#693 migration: a pre-flair#693 install registered under
3007
+ // flair#693 + flair#966: a pre-flair#693 install registered under
2920
3008
  // the bare LEGACY_LAUNCHD_LABEL. init always writes fresh plist
2921
3009
  // content below (it has the current ports/creds in hand), so
2922
3010
  // migration here is just "clean up the old registration" —
2923
3011
  // unload + remove it BEFORE writing the new one, so re-running
2924
3012
  // init never leaves two services behind for this data dir.
2925
- const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
2926
- if (existsSync(legacyPlistPath)) {
2927
- try {
2928
- const { execSync } = await import("node:child_process");
2929
- execSync(`launchctl unload "${legacyPlistPath}"`, { stdio: "pipe" });
2930
- }
2931
- catch { /* best effort */ }
2932
- try {
2933
- unlinkSync(legacyPlistPath);
2934
- }
2935
- catch { /* best effort */ }
2936
- console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
2937
- }
3013
+ //
3014
+ // flair#966: the legacy plist is NOT scoped to this data dir —
3015
+ // it is a single global label. cleanupLegacyLaunchdPlist reads
3016
+ // ROOTPATH to establish ownership before touching it.
3017
+ cleanupLegacyLaunchdPlist(dataDir, plistDir, (cmd) => {
3018
+ const { execSync } = require("node:child_process");
3019
+ execSync(cmd, { stdio: "pipe" });
3020
+ });
2938
3021
  const opsSocket = join(dataDir, "operations-server");
2939
3022
  // authorizeLocal: false (flair#654) — same posture as the initial spawn
2940
3023
  // above; the launchd-managed process must not diverge from it.
@@ -3601,7 +3684,8 @@ agent
3601
3684
  }
3602
3685
  if (out.defaultTrustTier)
3603
3686
  console.log(render.kv("trust tier", String(out.defaultTrustTier)));
3604
- if (out.admin)
3687
+ // flair#941 — read the authority, not the mirror. See `principal show`.
3688
+ if (agentRecordIsAdmin(out))
3605
3689
  console.log(render.kv("admin", render.wrap(render.c.magenta, "yes")));
3606
3690
  if (out.runtime)
3607
3691
  console.log(render.kv("runtime", String(out.runtime)));
@@ -4753,6 +4837,23 @@ mcp
4753
4837
  // ─── flair principal ─────────────────────────────────────────────────────────
4754
4838
  // 1.0 identity management. The Principal model extends Agent — this is the
4755
4839
  // preferred CLI surface for managing identities going forward.
4840
+ /**
4841
+ * The exact `role` value that denotes a flair administrator, and the predicate
4842
+ * that reads it.
4843
+ *
4844
+ * DUPLICATED FROM resources/agent-admin.ts on purpose — the same deliberate
4845
+ * copy as the federation crypto helpers above: src/cli.ts must not import from
4846
+ * resources/, because those imports don't survive npm packaging. The two must
4847
+ * stay in sync.
4848
+ *
4849
+ * flair#941: `role` is the authority and `admin` is its mirror. The CLI used to
4850
+ * both write and display ONLY the mirror, so `principal add --admin` created a
4851
+ * principal the gate refuses and `principal show` printed "admin: yes" for it.
4852
+ */
4853
+ const ADMIN_ROLE = "admin";
4854
+ function agentRecordIsAdmin(record) {
4855
+ return record?.role === ADMIN_ROLE;
4856
+ }
4756
4857
  const principal = program.command("principal").description("Manage principals (humans and agents)");
4757
4858
  principal
4758
4859
  .command("add <id>")
@@ -4822,6 +4923,11 @@ principal
4822
4923
  status: "active",
4823
4924
  publicKey: pubKeyB64url,
4824
4925
  defaultTrustTier: trustTier,
4926
+ // flair#941 — write BOTH. This is an ops-API upsert, so the Agent
4927
+ // resource's reconciliation never runs; writing only the `admin` mirror
4928
+ // is what made `--admin` a no-op at the gate for every principal this
4929
+ // command has ever created.
4930
+ role: isAdmin ? ADMIN_ROLE : "agent",
4825
4931
  admin: isAdmin,
4826
4932
  runtime: runtime ?? null,
4827
4933
  createdAt: new Date().toISOString(),
@@ -4874,7 +4980,10 @@ principal
4874
4980
  table: "Agent",
4875
4981
  operator: "and",
4876
4982
  conditions,
4877
- get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "admin", "runtime", "createdAt"],
4983
+ // `role` is the authority behind admin status (flair#941); the
4984
+ // projection used to omit it, so this listing could only ever report
4985
+ // the mirror.
4986
+ get_attributes: ["id", "name", "kind", "status", "defaultTrustTier", "role", "admin", "runtime", "createdAt"],
4878
4987
  }),
4879
4988
  });
4880
4989
  if (!res.ok) {
@@ -4908,7 +5017,14 @@ principal
4908
5017
  {
4909
5018
  label: "admin",
4910
5019
  key: "admin",
4911
- format: (v) => (v ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no")),
5020
+ // Report the status the gate will apply, and flag a record whose two
5021
+ // fields disagree rather than picking a side silently (flair#941).
5022
+ format: (_v, row) => {
5023
+ const isAdmin = agentRecordIsAdmin(row);
5024
+ const mismatch = isAdmin !== (row.admin === true);
5025
+ const base = isAdmin ? render.wrap(render.c.red, "yes") : render.wrap(render.c.dim, "no");
5026
+ return mismatch ? `${base} ${render.wrap(render.c.yellow, "(!)")}` : base;
5027
+ },
4912
5028
  },
4913
5029
  {
4914
5030
  label: "status",
@@ -4950,8 +5066,13 @@ principal
4950
5066
  }
4951
5067
  if (result.defaultTrustTier)
4952
5068
  console.log(render.kv("trust tier", String(result.defaultTrustTier)));
4953
- if (result.admin)
5069
+ // flair#941 — read the authority, not the mirror, and say so when the two
5070
+ // disagree (only reachable via a raw table write).
5071
+ if (agentRecordIsAdmin(result))
4954
5072
  console.log(render.kv("admin", render.wrap(render.c.red, "yes")));
5073
+ if (agentRecordIsAdmin(result) !== (result.admin === true)) {
5074
+ console.log(render.kv("admin", render.wrap(render.c.yellow, `record is inconsistent (role=${result.role ?? "unset"}, admin=${result.admin ?? "unset"}) — re-issue the grant to repair`)));
5075
+ }
4955
5076
  if (result.runtime)
4956
5077
  console.log(render.kv("runtime", String(result.runtime)));
4957
5078
  if (result.email)
@@ -6222,23 +6343,29 @@ federationSync
6222
6343
  .command("enable")
6223
6344
  .description("Install the sync driver (launchd on macOS, systemd timer on Linux)")
6224
6345
  .option("--interval <seconds>", `Seconds between syncs (default ${FEDERATION_SYNC_DEFAULT_INTERVAL})`, String(FEDERATION_SYNC_DEFAULT_INTERVAL))
6225
- .option("--admin-pass-file <path>", "Path to a 0600 file holding the admin password (default ~/.flair/admin-pass when it exists). The PATH is stored in the unit — never the password.")
6226
6346
  // Deliberately NOT `--no-admin-pass-file`: commander treats a `--no-x` flag
6227
6347
  // as the negation of `--x`, and declaring both on one command makes the
6228
6348
  // POSITIVE option silently parse to undefined — `--admin-pass-file /path`
6229
6349
  // would be accepted and dropped, producing a driver that fails auth every
6230
6350
  // cycle with no error anywhere. Verified against commander 14.
6231
6351
  .option("--no-credentials", "Do not wire any credential file into the unit")
6232
- .option("--target <url>", "Remote Flair URL to sync (default: the local instance)")
6352
+ // `--admin-pass-file` and `--target` are NOT redeclared here (flair#926).
6353
+ // The parent `flair federation sync` owns both, and commander matches an
6354
+ // option against the parent's list before dispatching — so a duplicate
6355
+ // declaration here never receives a value, it only makes the option LOOK
6356
+ // local. Both flags still work on this command; they arrive via
6357
+ // optsWithGlobals() below and are listed under "Global Options" in --help.
6358
+ .addHelpText("after", "\nCredentials:\n"
6359
+ + " --admin-pass-file defaults to ~/.flair/admin-pass when that file exists.\n"
6360
+ + " The PATH is stored in the unit — never the password.\n")
6233
6361
  .action(async (_opts, cmd) => {
6234
6362
  // optsWithGlobals(), NOT the action's first argument: `--admin-pass-file`
6235
- // and `--target` are declared on BOTH this subcommand and its parent
6236
- // (`flair federation sync`), and when a parent declares the same option
6237
- // name commander binds the value to the PARENT the subcommand's own
6238
- // opts come back undefined. Reading only the local opts silently dropped
6239
- // `--admin-pass-file <path>` here, which would have installed a driver
6363
+ // and `--target` are declared on the PARENT (`flair federation sync`), and
6364
+ // commander binds their values there. The subcommand's own opts() has no
6365
+ // entry for them at all. Reading only the local opts silently dropped
6366
+ // `--admin-pass-file <path>` here (flair#923), which installed a driver
6240
6367
  // that failed auth every cycle with no error anywhere. Verified against
6241
- // commander 14.
6368
+ // commander 14; test/unit/cli-option-collisions.test.ts pins the rule.
6242
6369
  const opts = cmd.optsWithGlobals();
6243
6370
  const intervalSeconds = Number(opts.interval);
6244
6371
  if (!Number.isFinite(intervalSeconds)) {
@@ -6306,12 +6433,14 @@ federationSync
6306
6433
  federationSync
6307
6434
  .command("status")
6308
6435
  .description("Show whether a sync driver is installed and genuinely active")
6309
- .option("--port <port>", "Harper HTTP port")
6310
- .option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
6436
+ // `--port` and `--target` are NOT redeclared here (flair#926) — the parent
6437
+ // `flair federation sync` owns them and commander binds them there. They
6438
+ // still work on this command, via optsWithGlobals() below.
6311
6439
  .option("--json", "Emit JSON")
6312
6440
  .action(async (_opts, cmd) => {
6313
- // See the comment on `enable` above: `--target`/`--port` are declared on
6314
- // the parent too, so commander binds them there.
6441
+ // See the comment on `enable` above: `--target`/`--port` live on the
6442
+ // parent, so commander binds them there and only optsWithGlobals() sees
6443
+ // them.
6315
6444
  const opts = cmd.optsWithGlobals();
6316
6445
  const { schedulerStatus, formatStatusReport, assessDriver } = await import("./federation/scheduler.js");
6317
6446
  try {
@@ -8482,7 +8611,9 @@ async function runFabricUpgrade(opts) {
8482
8611
  const upgradeOpts = {
8483
8612
  target: opts.target,
8484
8613
  project: opts.project,
8485
- version: opts.version,
8614
+ // flair#926: `--flair-version`, never `opts.version` — that attribute name
8615
+ // belongs to the program's `-v, --version` and never reaches this action.
8616
+ version: opts.flairVersion,
8486
8617
  harperVersion: opts.harperVersion,
8487
8618
  fabricUser,
8488
8619
  fabricPassword,
@@ -8992,7 +9123,14 @@ program
8992
9123
  .option("--fabric-user <user>", "Fabric admin username — for --target (env: FABRIC_USER preferred; inline leaks to shell history)")
8993
9124
  .option("--fabric-password <pass>", "Fabric admin password — for --target (prefer FABRIC_PASSWORD env or --fabric-password-file; inline leaks to shell history)")
8994
9125
  .option("--fabric-password-file <path>", "Read the Fabric admin password from a file (chmod 600) — for --target")
8995
- .option("--version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
9126
+ // NOT `--version` (flair#926). The program declares `-v, --version`, and
9127
+ // commander matches an option against the PARENT's list before dispatching to
9128
+ // the subcommand — so `flair upgrade --target X --version 1.2.3` printed the
9129
+ // CLI's own version and exited 0, never running the Fabric upgrade at all.
9130
+ // A colliding name is normally recoverable via optsWithGlobals(); this one is
9131
+ // not, because commander's version listener exits the process. The name had
9132
+ // to change. `--harper-version` below is the symmetry this follows.
9133
+ .option("--flair-version <semver>", "Flair version to deploy with --target (default: latest published @tpsdev-ai/flair)")
8996
9134
  .option("--harper-version <semver>", "Pin harper to this version for --target (default: registry latest, floored at the flair#513 fix)")
8997
9135
  .option("--project <name>", "Fabric component name for --target", "flair")
8998
9136
  .option("--no-replicated", "Disable cluster-wide replication for --target (default: replicated=true)")
@@ -9669,20 +9807,28 @@ program
9669
9807
  * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD. Only the
9670
9808
  * extracted ROOTPATH path ever reaches a message.
9671
9809
  */
9672
- export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9673
- let declared = null;
9810
+ /**
9811
+ * Read the ROOTPATH declared in a launchd plist, or null if it cannot be
9812
+ * determined (file missing, unreadable, or no ROOTPATH key).
9813
+ *
9814
+ * The plist stores this XML-escaped (buildLaunchdPlist), so the returned
9815
+ * value is decoded through unescapeXml before being returned — a data dir
9816
+ * containing `&` is on disk as `&amp;` and this returns the literal `&`.
9817
+ *
9818
+ * Never logs plist contents — the plist embeds HDB_ADMIN_PASSWORD.
9819
+ */
9820
+ export function readPlistRootPath(plistPath) {
9674
9821
  try {
9675
9822
  const raw = readFileSync(plistPath, "utf-8");
9676
9823
  const m = raw.match(/<key>ROOTPATH<\/key>\s*<string>([^<]*)<\/string>/);
9677
- // The plist stores this XML-escaped (buildLaunchdPlist), so a data dir
9678
- // containing `&` is on disk as `&amp;`. Decode before comparing, or the
9679
- // path would never equal itself and this guard would refuse a legitimate
9680
- // stop/start on any instance whose path contains an escaped character.
9681
- declared = m ? unescapeXml(m[1]) : null;
9824
+ return m ? unescapeXml(m[1]) : null;
9682
9825
  }
9683
9826
  catch {
9684
- return; // unreadable — no evidence, don't block
9827
+ return null;
9685
9828
  }
9829
+ }
9830
+ export function assertLaunchdServiceOwnedBy(dataDir, label, plistPath, action) {
9831
+ const declared = readPlistRootPath(plistPath);
9686
9832
  if (declared === null)
9687
9833
  return;
9688
9834
  if (resolve(declared) === resolve(dataDir))
@@ -9763,18 +9909,15 @@ async function stopFlairProcess(port, dataDir) {
9763
9909
  assertLaunchdServiceOwnedBy(dataDir, label, plistPath, "stop");
9764
9910
  try {
9765
9911
  const { execSync } = await import("node:child_process");
9766
- // Ensure the service is loaded (init writes the plist but doesn't load it)
9767
- try {
9768
- execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
9769
- }
9770
- catch { }
9771
- // Capture the current PID *before* stopping so callers that
9912
+ // Capture the current PID *before* unloading so callers that
9772
9913
  // immediately restart can verify exit. Without this, waitForHealth
9773
9914
  // can race against the still-shutting-down old process and return
9774
- // success before KeepAlive brings the new one up.
9915
+ // success before the new one comes up.
9775
9916
  const oldPid = readHarperPid(dataDir);
9917
+ // unload stops the job AND prevents KeepAlive from respawning it.
9918
+ // launchctl stop alone is insufficient for a KeepAlive job (flair#874).
9776
9919
  try {
9777
- execSync(`launchctl stop ${label}`, { stdio: "pipe" });
9920
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
9778
9921
  }
9779
9922
  catch { }
9780
9923
  if (oldPid)
@@ -13996,6 +14139,19 @@ program
13996
14139
  console.error("Error: --admin-pass, --admin-pass-file, or FLAIR_ADMIN_PASS required for backup");
13997
14140
  process.exit(1);
13998
14141
  }
14142
+ // flair#968: `flair backup > file.json` captures the progress report, not
14143
+ // the archive (which goes to --output, defaulting to ~/.flair/backups/...).
14144
+ // The result was exit 0 and a plausible-looking file of a few hundred bytes —
14145
+ // a false success immediately before a destructive upgrade.
14146
+ //
14147
+ // When stdout is not a TTY, route progress output to stderr. The archive
14148
+ // still goes to --output / the default path. This makes `flair backup >
14149
+ // file.json` produce an EMPTY file — unmistakably not a valid archive —
14150
+ // while leaving default-path callers (schedulers, cron) completely
14151
+ // unaffected.
14152
+ const log = process.stdout.isTTY
14153
+ ? console.log.bind(console)
14154
+ : console.error.bind(console);
13999
14155
  const auth = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
14000
14156
  async function adminGet(path) {
14001
14157
  const res = await fetch(`${baseUrl}${path}`, {
@@ -14008,11 +14164,11 @@ program
14008
14164
  }
14009
14165
  return res.json();
14010
14166
  }
14011
- console.log("Fetching agents...");
14167
+ log("Fetching agents...");
14012
14168
  const allAgents = await adminGet("/Agent/");
14013
14169
  const filterIds = opts.agents ? opts.agents.split(",").map((s) => s.trim()) : null;
14014
14170
  const agents = filterIds ? allAgents.filter((a) => filterIds.includes(a.id)) : allAgents;
14015
- console.log(`Fetching memories for ${agents.length} agent(s)...`);
14171
+ log(`Fetching memories for ${agents.length} agent(s)...`);
14016
14172
  const memories = [];
14017
14173
  for (const agent of agents) {
14018
14174
  try {
@@ -14024,7 +14180,7 @@ program
14024
14180
  console.warn(` Warning: could not fetch memories for ${agent.id}: ${err.message}`);
14025
14181
  }
14026
14182
  }
14027
- console.log("Fetching souls...");
14183
+ log("Fetching souls...");
14028
14184
  const souls = [];
14029
14185
  for (const agent of agents) {
14030
14186
  try {
@@ -14052,11 +14208,11 @@ program
14052
14208
  const tmp = outputPath + ".tmp";
14053
14209
  writeFileSync(tmp, JSON.stringify(backup, null, 2) + "\n", "utf-8");
14054
14210
  renameSync(tmp, outputPath);
14055
- console.log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
14056
- console.log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
14057
- console.log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
14058
- console.log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
14059
- console.log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
14211
+ log(`\n${render.icons.ok} ${render.wrap(render.c.green, "Backup complete")}`);
14212
+ log(render.kv("Agents", render.wrap(render.c.bold, String(agents.length))));
14213
+ log(render.kv("Memories", render.wrap(render.c.bold, String(memories.length))));
14214
+ log(render.kv("Souls", render.wrap(render.c.bold, String(souls.length))));
14215
+ log(render.kv("Output", render.wrap(render.c.dim, outputPath)));
14060
14216
  });
14061
14217
  // ─── flair restore ────────────────────────────────────────────────────────────
14062
14218
  program
@@ -15065,4 +15221,4 @@ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, readOpsBi
15065
15221
  // Harper's own config — the per-instance port record (flair#914)
15066
15222
  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,
15067
15223
  // launchd label (flair#693)
15068
- LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
15224
+ LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, cleanupLegacyLaunchdPlist, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };
@@ -1,6 +1,7 @@
1
1
  import { Resource, databases } from "harper";
2
2
  import { layout, htmlResponse, esc } from "./admin-layout.js";
3
3
  import { allowAdmin } from "./agent-auth.js";
4
+ import { adminFieldsDisagree, agentRecordIsAdmin } from "./agent-admin.js";
4
5
  /**
5
6
  * GET /AdminPrincipals — list all principals with kind, trust, status.
6
7
  *
@@ -39,7 +40,15 @@ export class AdminPrincipals extends Resource {
39
40
  const statusBadge = status === "active"
40
41
  ? `<span class="badge badge-green">${status}</span>`
41
42
  : `<span class="badge badge-gray">${status}</span>`;
42
- const admin = p.admin ? "yes" : "";
43
+ // flair#941 report the status the GATE will actually apply, not the
44
+ // `admin` mirror. This column used to read the mirror alone, so a
45
+ // principal created by `flair principal add --admin` showed "yes" while
46
+ // allowAdmin() rejected it. A record whose two fields disagree (only
47
+ // reachable by a raw table write) is flagged rather than silently
48
+ // resolved, so an operator can see that it needs repairing.
49
+ const admin = agentRecordIsAdmin(p)
50
+ ? (adminFieldsDisagree(p) ? "yes <small>(inconsistent record)</small>" : "yes")
51
+ : (adminFieldsDisagree(p) ? "no <small>(inconsistent record)</small>" : "");
43
52
  const created = p.createdAt?.slice(0, 10) ?? "—";
44
53
  tableRows += `
45
54
  <tr>