@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 +32 -6
- package/dist/cli.js +227 -71
- package/dist/resources/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- package/dist/resources/in-process-api.js +382 -0
- package/dist/resources/in-process.js +9 -0
- package/dist/resources/mcp-handler.js +14 -4
- package/dist/resources/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- package/docs/deployment-shapes.md +35 -0
- package/docs/deployment.md +2 -2
- package/docs/embedding-in-a-harper-app.md +174 -75
- package/docs/hosted-on-fabric.md +203 -0
- package/docs/secrets-and-keys.md +4 -4
- package/docs/standalone-local.md +243 -0
- package/docs/upgrade.md +7 -3
- package/package.json +7 -1
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
504
|
-
*
|
|
505
|
-
* function
|
|
506
|
-
*
|
|
507
|
-
*
|
|
508
|
-
*
|
|
509
|
-
*
|
|
510
|
-
*
|
|
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
|
-
.
|
|
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.
|
|
2644
|
-
//
|
|
2645
|
-
//
|
|
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
|
|
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
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
}
|
|
2931
|
-
|
|
2932
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
6236
|
-
//
|
|
6237
|
-
//
|
|
6238
|
-
//
|
|
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
|
-
|
|
6310
|
-
|
|
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`
|
|
6314
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
9673
|
-
|
|
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 `&` 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
|
-
|
|
9678
|
-
// containing `&` is on disk as `&`. 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;
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14056
|
-
|
|
14057
|
-
|
|
14058
|
-
|
|
14059
|
-
|
|
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
|
-
|
|
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>
|