@tpsdev-ai/flair 0.29.0 → 0.31.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 +194 -377
- package/dist/cli-shim.cjs +1 -1
- package/dist/cli.js +1567 -366
- package/dist/deploy.js +241 -43
- package/dist/fabric-upgrade.js +77 -24
- package/dist/federation/scheduler.js +500 -0
- package/dist/fleet-verify.js +3 -3
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-enable.js +2 -2
- package/dist/lib/mcp-spec.js +128 -0
- package/dist/lib/safe-snapshot-extract.js +231 -0
- package/dist/lib/scheduler-platform.js +128 -0
- package/dist/lib/xml-escape.js +54 -0
- package/dist/rem/scheduler.js +212 -55
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/A2AAdapter.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +1 -1
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +1 -1
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +1 -1
- package/dist/resources/Credential.js +1 -1
- package/dist/resources/Federation.js +1 -1
- package/dist/resources/Instance.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +1 -1
- package/dist/resources/Memory.js +1 -1
- package/dist/resources/MemoryBootstrap.js +8 -9
- package/dist/resources/MemoryCandidate.js +168 -0
- package/dist/resources/MemoryConsolidate.js +1 -1
- package/dist/resources/MemoryDedupStats.js +1 -1
- package/dist/resources/MemoryFeed.js +1 -1
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +2 -2
- package/dist/resources/MemoryReindex.js +1 -1
- package/dist/resources/MemoryUsage.js +1 -1
- package/dist/resources/OAuth.js +1 -1
- package/dist/resources/OAuthClient.js +1 -1
- package/dist/resources/OrgEvent.js +1 -1
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/OrgEventMaintenance.js +1 -1
- package/dist/resources/PairingToken.js +1 -1
- package/dist/resources/Peer.js +1 -1
- package/dist/resources/Presence.js +3 -3
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +1 -1
- package/dist/resources/SemanticSearch.js +18 -46
- package/dist/resources/SkillScan.js +1 -1
- package/dist/resources/Soul.js +1 -1
- package/dist/resources/SoulFeed.js +1 -1
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/XAA.js +1 -1
- package/dist/resources/a2a-url.js +1 -1
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +1 -1
- package/dist/resources/auth-middleware.js +1 -1
- package/dist/resources/embeddings-boot.js +14 -16
- package/dist/resources/embeddings-provider.js +15 -12
- package/dist/resources/federation-cleanup.js +5 -5
- package/dist/resources/federation-nonce-store.js +5 -5
- package/dist/resources/health.js +53 -25
- package/dist/resources/in-process.js +225 -0
- package/dist/resources/instance-identity.js +1 -1
- package/dist/resources/mcp-client-metadata-fields.js +1 -1
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-oauth.js +2 -2
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/memory-read-scope.js +1 -1
- package/dist/resources/memory-reflect-lib.js +3 -3
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +81 -11
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/embedding-stamp.js +3 -3
- package/dist/resources/migrations/graph-heal.js +2 -2
- package/dist/resources/migrations/ledger.js +1 -1
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/registry.js +14 -5
- package/dist/resources/migrations/runner.js +93 -13
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/migrations/synthetic-test-migration.js +1 -1
- package/dist/resources/migrations/visibility-backfill.js +232 -0
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/record-types.js +28 -0
- package/dist/resources/semantic-retrieval-core.js +6 -5
- package/dist/resources/usage-recording.js +1 -1
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +397 -0
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +6 -0
- package/docs/embedding-in-a-harper-app.md +299 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +40 -4
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +100 -5
- package/package.json +4 -14
- package/schemas/memory.graphql +9 -1
- package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
- package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
- package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
- package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
- package/dist/resources/rerank-provider.js +0 -569
- package/docs/rerank-provisioning.md +0 -101
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* The previous mechanism (removed by this change) delivered the registration
|
|
8
8
|
* as a `models.embedding.default` block via the `HARPER_CONFIG` env var
|
|
9
9
|
* (src/cli.ts's old `buildEmbeddingsHarperConfigEnv`) — a "merge layer" that
|
|
10
|
-
* Harper's environment-manager (
|
|
10
|
+
* Harper's environment-manager (`harper`'s
|
|
11
11
|
* `config/harperConfigEnvVars.js`) reasserts on every boot AND PERSISTS into
|
|
12
12
|
* the instance-root `harper-config.yaml`. That file proved to be config-as-
|
|
13
13
|
* STATE, not config-as-intent: `flair#694`'s downgrade-and-revert CI lane
|
|
@@ -28,14 +28,14 @@
|
|
|
28
28
|
* to), leaving `models.embedding.default: {}` — an empty shell —
|
|
29
29
|
* persisted to disk.
|
|
30
30
|
* 3. The next boot's config schema validator
|
|
31
|
-
* (
|
|
31
|
+
* (`harper`'s `validation/configValidator.js`) resolves the
|
|
32
32
|
* entry's backend via `Joi.alternatives().conditional('.backend', {...,
|
|
33
33
|
* otherwise: unknownBackendEntrySchema})`; with `backend` absent, that
|
|
34
34
|
* falls through to `unknownBackendEntrySchema = Joi.object({backend:
|
|
35
35
|
* string.required()})`, which throws exactly: "Harper config file
|
|
36
36
|
* validation error: 'models.embedding.default.backend' is required" —
|
|
37
|
-
* Harper refuses to boot. Confirmed byte-identical in both
|
|
38
|
-
*
|
|
37
|
+
* Harper refuses to boot. Confirmed byte-identical in both harper
|
|
38
|
+
* 5.1.15 and 5.1.17 (this is upstream env-var-config behavior,
|
|
39
39
|
* not a schema difference between versions), and reproduced locally by
|
|
40
40
|
* replaying the exact downgrade-and-revert sequence.
|
|
41
41
|
*
|
|
@@ -98,19 +98,17 @@ const MODEL_NAME = "nomic-embed-text";
|
|
|
98
98
|
* table for the exact contract this PR bumps to).
|
|
99
99
|
*
|
|
100
100
|
* "mean" is nomic-embed-text-v1.5's actual pooling type — NOT assumed from
|
|
101
|
-
* the model's reputation, directly confirmed against the shipped GGUF
|
|
102
|
-
* `
|
|
103
|
-
* ~/.flair/data/models/nomic-embed-text-v1.5.Q4_K_M.gguf` reports
|
|
104
|
-
* `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
|
|
101
|
+
* the model's reputation, directly confirmed against the shipped GGUF by
|
|
102
|
+
* reading its metadata: `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
|
|
105
103
|
* `enum llama_pooling_type` maps 1 -> `LLAMA_POOLING_TYPE_MEAN` (0 = none,
|
|
106
|
-
* 1 = mean, 2 = cls, 3 = last, 4 = rank).
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
113
|
-
*
|
|
104
|
+
* 1 = mean, 2 = cls, 3 = last, 4 = rank). (That reading was originally taken
|
|
105
|
+
* with `node-llama-cpp inspect gguf`, which flair no longer depends on —
|
|
106
|
+
* flair#893. Any GGUF inspector reports the same metadata field; install the
|
|
107
|
+
* CLI ad hoc if the check needs repeating.) nomic-embed-text is a
|
|
108
|
+
* NomicBertModel architecture, not Qwen3 (last-token), and flair registers no
|
|
109
|
+
* Qwen3-class embedding model today. If a Qwen3-class (last-token-pooling)
|
|
110
|
+
* embedding model is ever registered here, it must declare `pooling: "last"`,
|
|
111
|
+
* not "mean".
|
|
114
112
|
*
|
|
115
113
|
* Applies to the bench-only `modelPath` override too (`benchModelPathOverride()`
|
|
116
114
|
* below): `FLAIR_RECALL_HARNESS_MODEL_PATH` lets an operator point this
|
|
@@ -36,11 +36,11 @@
|
|
|
36
36
|
* what this migration removes: `models.embed` is a process-wide singleton
|
|
37
37
|
* Harper initializes at boot, off the first-call dynamic-import race.
|
|
38
38
|
*
|
|
39
|
-
*
|
|
39
|
+
* `harper` is dynamic-imported here (deferred to first actual
|
|
40
40
|
* getEmbedding()/getMode()/getStatus() call), NOT statically imported like
|
|
41
41
|
* every other resource's `Resource`/`databases`/`server` — those work
|
|
42
42
|
* unmocked-import-free only because every test that (transitively) imports
|
|
43
|
-
* them already mocks
|
|
43
|
+
* them already mocks `harper` first. Statically importing
|
|
44
44
|
* `models` here would make *any* test that imports this file for
|
|
45
45
|
* `resolveModelsDir()` alone (test/unit/embeddings-models-dir.test.ts, which
|
|
46
46
|
* has no reason to know about Harper at all) also eagerly load Harper's real
|
|
@@ -152,7 +152,7 @@ const EMBEDDING_PREFIXES_ENABLED = true; // Phase 2 prefix flip ON — re-baseli
|
|
|
152
152
|
* embedding call, so this lets it override the ONE thing that matters
|
|
153
153
|
* (whether `models.embed` receives `inputType`, and whether `getModelId()`
|
|
154
154
|
* stamps the suffix) via the env var it already forwards to `startHarper()`
|
|
155
|
-
* (the same mechanism `FLAIR_HYBRID_RETRIEVAL
|
|
155
|
+
* (the same mechanism `FLAIR_HYBRID_RETRIEVAL` uses).
|
|
156
156
|
*
|
|
157
157
|
* Bidirectional, not two separate force-on/force-off hatches: THE GATE has
|
|
158
158
|
* flipped direction once already (off→on, this PR) and may again in the
|
|
@@ -186,7 +186,7 @@ function prefixesEnabled() {
|
|
|
186
186
|
* Build the options object passed to `models.embed()`. Pulled out as its own
|
|
187
187
|
* pure, harper-free function so the value-forwarding (and the reject-a-
|
|
188
188
|
* wrong-value guard) is unit-testable without touching the deferred
|
|
189
|
-
*
|
|
189
|
+
* `harper` import this file's header explains — see
|
|
190
190
|
* test/unit/embeddings-provider-input-type.test.ts.
|
|
191
191
|
*
|
|
192
192
|
* Gate ON (default): rejects anything other than the literal
|
|
@@ -217,7 +217,7 @@ let _modelsApi;
|
|
|
217
217
|
/**
|
|
218
218
|
* Resolve (and cache) Harper's `models` facade via a deferred import — see file header.
|
|
219
219
|
*
|
|
220
|
-
* NOTE for anyone using this as a reference: `models` (the
|
|
220
|
+
* NOTE for anyone using this as a reference: `models` (the `harper`
|
|
221
221
|
* package export) and a component's `scope.models` are the SAME boot-time
|
|
222
222
|
* singleton — Harper's jsLoader hands components `scope.models = <the global
|
|
223
223
|
* models>` (two accessors, one object; the model registry lives on that singleton).
|
|
@@ -229,19 +229,22 @@ let _modelsApi;
|
|
|
229
229
|
async function getModelsApi() {
|
|
230
230
|
if (_modelsApi)
|
|
231
231
|
return _modelsApi;
|
|
232
|
-
const harper = await import("
|
|
232
|
+
const harper = await import("harper");
|
|
233
233
|
_modelsApi = harper.models;
|
|
234
234
|
return _modelsApi;
|
|
235
235
|
}
|
|
236
236
|
/**
|
|
237
237
|
* Resolve the directory the embeddings model lives in / downloads into.
|
|
238
238
|
*
|
|
239
|
-
* Implementation
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
239
|
+
* Implementation lives in `resources/models-dir.ts` (extracted in flair#815)
|
|
240
|
+
* so any model consumer can share the exact same resolution without importing
|
|
241
|
+
* this module — several unit-isolated tests `mock.module()` this file
|
|
242
|
+
* wholesale, and an incomplete stub then breaks every file that imports it.
|
|
243
|
+
* See models-dir.ts's header for why that hazard makes the tiny standalone
|
|
244
|
+
* module worth keeping even now that embeddings are its only consumer.
|
|
245
|
+
* Re-exported here so existing consumers keep their import site:
|
|
246
|
+
* `resources/embeddings-boot.ts` (flair#694), which builds the `modelsDir` it
|
|
247
|
+
* passes to harper-fabric-embeddings' `register()`, and
|
|
245
248
|
* test/unit/embeddings-models-dir.test.ts, which pins the resolution order.
|
|
246
249
|
*/
|
|
247
250
|
export { resolveModelsDir } from "./models-dir.js";
|
|
@@ -11,13 +11,13 @@ let cleanupTimer = null;
|
|
|
11
11
|
* and performs housekeeping on expired/unconsumed token records.
|
|
12
12
|
*
|
|
13
13
|
* In test environments, callers pass mock serverOp/db via `opts` so this
|
|
14
|
-
* module never imports
|
|
14
|
+
* module never imports harper at the top level (which would
|
|
15
15
|
* crash when STORAGE_PATH isn't set).
|
|
16
16
|
*/
|
|
17
17
|
export async function initFederationCleanup(opts) {
|
|
18
18
|
const immediate = opts?.immediateTick ?? true;
|
|
19
19
|
// Resolve server / databases: use caller-supplied mocks when available,
|
|
20
|
-
// otherwise lazy-import
|
|
20
|
+
// otherwise lazy-import harper at call time.
|
|
21
21
|
let svr;
|
|
22
22
|
let db;
|
|
23
23
|
if (opts?.serverOp && opts?.db) {
|
|
@@ -26,12 +26,12 @@ export async function initFederationCleanup(opts) {
|
|
|
26
26
|
}
|
|
27
27
|
else {
|
|
28
28
|
try {
|
|
29
|
-
const harper = await import("
|
|
29
|
+
const harper = await import("harper");
|
|
30
30
|
svr = opts?.serverOp ?? harper.server.operation;
|
|
31
31
|
db = opts?.db ?? harper.databases;
|
|
32
32
|
}
|
|
33
33
|
catch (err) {
|
|
34
|
-
console.error("[federation-cleanup] failed to load
|
|
34
|
+
console.error("[federation-cleanup] failed to load harper:", err?.message ?? err);
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
}
|
|
@@ -94,7 +94,7 @@ export async function runCleanupTick(opts = {}) {
|
|
|
94
94
|
db = opts.db;
|
|
95
95
|
}
|
|
96
96
|
else {
|
|
97
|
-
const harper = await import("
|
|
97
|
+
const harper = await import("harper");
|
|
98
98
|
svr = opts.serverOp ?? harper.server.operation;
|
|
99
99
|
db = opts.db ?? harper.databases;
|
|
100
100
|
}
|
|
@@ -44,7 +44,7 @@ async function resolveDb(opts, cache) {
|
|
|
44
44
|
if (opts.db)
|
|
45
45
|
return opts.db;
|
|
46
46
|
if (!cache.db) {
|
|
47
|
-
cache.db = import("
|
|
47
|
+
cache.db = import("harper").then((h) => h.databases);
|
|
48
48
|
}
|
|
49
49
|
return cache.db;
|
|
50
50
|
}
|
|
@@ -123,7 +123,7 @@ export async function runNonceCleanupTick(opts = {}) {
|
|
|
123
123
|
db = opts.db;
|
|
124
124
|
}
|
|
125
125
|
else {
|
|
126
|
-
const harper = await import("
|
|
126
|
+
const harper = await import("harper");
|
|
127
127
|
db = harper.databases;
|
|
128
128
|
}
|
|
129
129
|
const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
@@ -159,7 +159,7 @@ export async function runNonceCleanupTick(opts = {}) {
|
|
|
159
159
|
* Initialise the periodic Nonce-table eviction sweep (5-min cadence).
|
|
160
160
|
*
|
|
161
161
|
* In test environments, callers pass a mock `db` via `opts` so this module
|
|
162
|
-
* never imports
|
|
162
|
+
* never imports harper at the top level (mirrors
|
|
163
163
|
* federation-cleanup.ts's `initFederationCleanup`).
|
|
164
164
|
*/
|
|
165
165
|
export async function initNonceStoreCleanup(opts) {
|
|
@@ -170,11 +170,11 @@ export async function initNonceStoreCleanup(opts) {
|
|
|
170
170
|
}
|
|
171
171
|
else {
|
|
172
172
|
try {
|
|
173
|
-
const harper = await import("
|
|
173
|
+
const harper = await import("harper");
|
|
174
174
|
db = harper.databases;
|
|
175
175
|
}
|
|
176
176
|
catch (err) {
|
|
177
|
-
console.error("[federation-nonce-store] failed to load
|
|
177
|
+
console.error("[federation-nonce-store] failed to load harper:", err?.message ?? err);
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
180
|
}
|
package/dist/resources/health.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { Resource, databases } from "
|
|
1
|
+
import { Resource, databases } from "harper";
|
|
2
2
|
import { promises as fsp, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
4
|
import { join, dirname } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { getRerankStatus } from "./rerank-provider.js";
|
|
7
6
|
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
8
7
|
import { getMigrationStatusSnapshot } from "./migrations/status.js";
|
|
8
|
+
import { resolveMigrationDataDirForRead } from "./migrations/data-dir.js";
|
|
9
9
|
import { REM_DEDUP_STATS_PATH } from "./dedup-cluster.js";
|
|
10
10
|
const db = databases;
|
|
11
11
|
const redactHome = (p) => {
|
|
@@ -452,13 +452,33 @@ export class HealthDetail extends Resource {
|
|
|
452
452
|
const lastLightAt = findLast("light");
|
|
453
453
|
const lastRapidAt = findLast("rapid");
|
|
454
454
|
const lastRestorativeAt = findLast("restorative");
|
|
455
|
+
// File presence only proves the scheduler templates were written —
|
|
456
|
+
// NOT that launchctl/systemctl actually loaded the job. A
|
|
457
|
+
// `systemctl --user enable --now` that fails (e.g. no session bus)
|
|
458
|
+
// still leaves the timer file on disk, so file-existence alone would
|
|
459
|
+
// report "enabled" for a scheduler that has nothing scheduled
|
|
460
|
+
// (flair#850). Query genuine active state once the file is known to
|
|
461
|
+
// exist; async + short-timeout so a slow/hung service manager can't
|
|
462
|
+
// stall this request.
|
|
455
463
|
let nightlyEnabled = null;
|
|
464
|
+
let nightlyInstalled = false;
|
|
456
465
|
const plat = platform();
|
|
457
|
-
if (plat === "darwin") {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
466
|
+
if (plat === "darwin" || plat === "linux") {
|
|
467
|
+
nightlyInstalled = plat === "darwin"
|
|
468
|
+
? await exists(join(homedir(), "Library", "LaunchAgents", "dev.flair.rem.nightly.plist"))
|
|
469
|
+
: await exists(join(homedir(), ".config", "systemd", "user", "flair-rem-nightly.timer"));
|
|
470
|
+
if (nightlyInstalled) {
|
|
471
|
+
try {
|
|
472
|
+
const { queryActiveStateAsync } = await import("../src/rem/scheduler.js");
|
|
473
|
+
nightlyEnabled = await queryActiveStateAsync(plat);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
nightlyEnabled = null;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
else {
|
|
480
|
+
nightlyEnabled = false;
|
|
481
|
+
}
|
|
462
482
|
}
|
|
463
483
|
const nightlyRecords = await tailJsonl(nightlyLog);
|
|
464
484
|
const lastNightlyRec = nightlyRecords[nightlyRecords.length - 1];
|
|
@@ -496,6 +516,9 @@ export class HealthDetail extends Resource {
|
|
|
496
516
|
if (nightlyEnabled && lastNightlyAt && nowMs - new Date(lastNightlyAt).getTime() > 48 * 3600 * 1000) {
|
|
497
517
|
warnings.push({ level: "warn", message: "nightly REM hasn't run in >48h" });
|
|
498
518
|
}
|
|
519
|
+
if (nightlyInstalled && nightlyEnabled === false) {
|
|
520
|
+
warnings.push({ level: "warn", message: "REM nightly scheduler files are written but not active — nothing is scheduled (see `flair rem nightly status`)" });
|
|
521
|
+
}
|
|
499
522
|
}
|
|
500
523
|
}
|
|
501
524
|
catch {
|
|
@@ -537,12 +560,21 @@ export class HealthDetail extends Resource {
|
|
|
537
560
|
// "pre-flight integrity check in progress" state the K&S verdict calls
|
|
538
561
|
// for; a halted migration surfaces here (with `reason`) AND as a
|
|
539
562
|
// warning below so it's visible without a separate lookup.
|
|
563
|
+
//
|
|
564
|
+
// flair#812: the dataDir is resolved by the shared, READ-ONLY resolver
|
|
565
|
+
// (resources/migrations/data-dir.ts) rather than the old
|
|
566
|
+
// `HDB_ROOT ?? homedir()/.flair/data` expression duplicated here — that
|
|
567
|
+
// env var is never set by anything, so the left branch was dead and the
|
|
568
|
+
// path silently disagreed with wherever the boot cycle actually wrote.
|
|
569
|
+
// `lastCycleError` is surfaced so `flair doctor` can name WHY a cycle
|
|
570
|
+
// didn't run, not merely that it didn't.
|
|
540
571
|
try {
|
|
541
|
-
const migrationsDataDir =
|
|
572
|
+
const migrationsDataDir = resolveMigrationDataDirForRead();
|
|
542
573
|
const snapshot = getMigrationStatusSnapshot(migrationsDataDir);
|
|
543
574
|
stats.migrations = {
|
|
544
575
|
cyclePhase: snapshot.cyclePhase,
|
|
545
576
|
lastCycleAt: snapshot.lastCycleAt ?? null,
|
|
577
|
+
lastCycleError: snapshot.lastCycleError ?? null,
|
|
546
578
|
migrations: snapshot.migrations,
|
|
547
579
|
};
|
|
548
580
|
for (const m of snapshot.migrations) {
|
|
@@ -550,13 +582,25 @@ export class HealthDetail extends Resource {
|
|
|
550
582
|
warnings.push({ level: "warn", message: `migration '${m.id}' ${m.state}${m.reason ? `: ${m.reason}` : ""} — see \`flair doctor\`` });
|
|
551
583
|
}
|
|
552
584
|
}
|
|
585
|
+
// The boot trigger sets `scheduled` synchronously at module load, so
|
|
586
|
+
// `idle` here means resources/migration-boot.js never loaded — the
|
|
587
|
+
// instance is running a build whose migration trigger is absent or
|
|
588
|
+
// failed to import, and NO migration will ever run on it.
|
|
589
|
+
if (snapshot.cyclePhase === "idle" && snapshot.migrations.length > 0) {
|
|
590
|
+
warnings.push({
|
|
591
|
+
level: "warn",
|
|
592
|
+
message: "migration boot cycle never fired on this instance (cyclePhase=idle) — no migration will run until this is resolved; see `flair doctor`",
|
|
593
|
+
});
|
|
594
|
+
}
|
|
553
595
|
}
|
|
554
596
|
catch {
|
|
555
597
|
stats.migrations = null;
|
|
556
598
|
}
|
|
557
599
|
// ── Disk ──
|
|
600
|
+
// flair#812: same shared read-only resolution as the migrations section
|
|
601
|
+
// above, so `disk.dataDir` names the directory migrations actually use.
|
|
558
602
|
try {
|
|
559
|
-
const dataDir =
|
|
603
|
+
const dataDir = resolveMigrationDataDirForRead();
|
|
560
604
|
const snapshotDir = join(homedir(), ".flair", "snapshots");
|
|
561
605
|
const dirSize = async (root, maxDepth = 6) => {
|
|
562
606
|
if (!(await exists(root)))
|
|
@@ -635,22 +679,6 @@ export class HealthDetail extends Resource {
|
|
|
635
679
|
catch {
|
|
636
680
|
stats.bridges = null;
|
|
637
681
|
}
|
|
638
|
-
// ── Reranker ──
|
|
639
|
-
// Cross-encoder rerank stage. Reports flag/model/mode + live counters so we
|
|
640
|
-
// can see if it's silently degrading (fallbackCount climbing) in prod.
|
|
641
|
-
try {
|
|
642
|
-
const rr = getRerankStatus();
|
|
643
|
-
stats.rerank = rr;
|
|
644
|
-
if (rr.enabled && rr.state === "failed") {
|
|
645
|
-
warnings.push({ level: "warn", message: `reranker enabled but unavailable: ${rr.error ?? "init failed"} — recall falling back to vector order` });
|
|
646
|
-
}
|
|
647
|
-
if (rr.enabled && rr.rerankCount > 0 && rr.fallbackCount > rr.rerankCount) {
|
|
648
|
-
warnings.push({ level: "warn", message: `reranker falling back more than reranking (${rr.fallbackCount} fallbacks / ${rr.rerankCount} reranks) — check latency budget / topN` });
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
catch {
|
|
652
|
-
stats.rerank = null;
|
|
653
|
-
}
|
|
654
682
|
// ── Warnings ──
|
|
655
683
|
stats.warnings = warnings;
|
|
656
684
|
// ── Process info ──
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─── The in-process call seam (one incantation, one place) ───────────────────
|
|
3
|
+
*
|
|
4
|
+
* How code running INSIDE the Harper process invokes a flair resource AS a
|
|
5
|
+
* specific agent — flair's own native MCP handler (resources/mcp-tools.ts), and
|
|
6
|
+
* any co-located application component that loads flair as a sub-component and
|
|
7
|
+
* calls it directly instead of over HTTP.
|
|
8
|
+
*
|
|
9
|
+
* Three things an in-process caller has to get right, none of which is visible
|
|
10
|
+
* from a resource's own source and none of which used to fail in a way that
|
|
11
|
+
* points at the cause:
|
|
12
|
+
*
|
|
13
|
+
* 1. **Identity.** Every flair resource resolves its caller through
|
|
14
|
+
* resources/agent-auth.ts's `resolveAgentAuth()`, whose FIRST hop is the
|
|
15
|
+
* `tpsAgent` annotation the HTTP auth middleware stamps on a verified
|
|
16
|
+
* request. There is no HTTP request in-process, so the caller supplies that
|
|
17
|
+
* annotation itself — {@link agentContext}.
|
|
18
|
+
*
|
|
19
|
+
* 2. **Not accidentally becoming an administrator.** A context with no usable
|
|
20
|
+
* agent id resolves to flair's trusted `internal` verdict, which is
|
|
21
|
+
* UNFILTERED. See the safety-design block below: this module's API shape is
|
|
22
|
+
* built around making that unreachable by accident.
|
|
23
|
+
*
|
|
24
|
+
* 3. **Collection binding.** A resource's `post()` — the create path — only
|
|
25
|
+
* works on a resource instance Harper has marked as a COLLECTION. That mark
|
|
26
|
+
* is a PRIVATE field (`#isCollection`) set inside Harper's own
|
|
27
|
+
* `getResource()`; the public `isCollection` is a GETTER WITH NO SETTER on
|
|
28
|
+
* `Resource.prototype`. So the obvious `new Cls(undefined, ctx)` +
|
|
29
|
+
* `h.isCollection = true` does not "not work" quietly — under ESM (always
|
|
30
|
+
* strict mode) the assignment throws
|
|
31
|
+
* `TypeError: Cannot set property isCollection ... which has only a getter`,
|
|
32
|
+
* and dropping the assignment instead yields
|
|
33
|
+
* `405 The <X> does not have a post method implemented` from Harper's base
|
|
34
|
+
* `Resource.post()`. {@link collectionResource} is the one supported way in:
|
|
35
|
+
* hand `getResource()` an `{ isCollection: true }` option and let Harper set
|
|
36
|
+
* its own private field.
|
|
37
|
+
*
|
|
38
|
+
* flair itself got (3) wrong in four MCP tool paths before this module existed —
|
|
39
|
+
* the same mistake a consumer reading only the resource classes would make.
|
|
40
|
+
*
|
|
41
|
+
* ─── SAFETY DESIGN: the dangerous thing has to look dangerous ────────────────
|
|
42
|
+
*
|
|
43
|
+
* MEASURED, against the real resolver:
|
|
44
|
+
*
|
|
45
|
+
* resolveAgentAuth({ request: { tpsAgent: undefined } }) -> { kind: "internal" }
|
|
46
|
+
* resolveAgentAuth({ request: { tpsAgent: "" } }) -> { kind: "internal" }
|
|
47
|
+
* allowAdmin({ request: { tpsAgent: undefined } }) -> true
|
|
48
|
+
*
|
|
49
|
+
* `resolveAgentAuth` tests `tpsAgent` for TRUTHINESS, so a missing or empty id
|
|
50
|
+
* is indistinguishable from "no identity was supplied at all" — and that is the
|
|
51
|
+
* trusted verdict. The consequence is that the most ordinary application bug
|
|
52
|
+
* there is — `agentContext(session.agentId)` where the field is undefined, a
|
|
53
|
+
* typo'd property, a lookup that found nothing — would not fail closed. It
|
|
54
|
+
* would silently grant unfiltered cross-agent reads and writes, and pass the
|
|
55
|
+
* admin-only gate on admin-only resources. No error, no 403, no log line.
|
|
56
|
+
*
|
|
57
|
+
* That is the same defect class as a check that cannot fail: **the failure mode
|
|
58
|
+
* of "I forgot the id" must never be "you are now an administrator."** So:
|
|
59
|
+
*
|
|
60
|
+
* - {@link agentContext} THROWS on a missing, empty or blank id. It is always
|
|
61
|
+
* a programming error, and an exception is the only outcome that cannot be
|
|
62
|
+
* mistaken for success.
|
|
63
|
+
* - {@link agentContext} takes NO options. It is structurally incapable of
|
|
64
|
+
* producing an admin context, so spreading a caller-influenced object into
|
|
65
|
+
* its arguments cannot escalate.
|
|
66
|
+
* - Admin is a SEPARATE, NAMED export ({@link adminContext}), and so is the
|
|
67
|
+
* unfiltered maintenance verdict ({@link internalContext}). Both are
|
|
68
|
+
* greppable: `git grep -n "adminContext\|internalContext"` enumerates every
|
|
69
|
+
* privileged call site in a codebase.
|
|
70
|
+
* - {@link collectionResource} REQUIRES a context and throws without one, so
|
|
71
|
+
* the privileged path can never be reached by leaving an argument off. The
|
|
72
|
+
* dangerous path is now the LONGEST path, not the shortest.
|
|
73
|
+
*
|
|
74
|
+
* These guards are runtime, not types, on purpose: an embedding application may
|
|
75
|
+
* be plain JavaScript, where a `string` parameter annotation buys exactly
|
|
76
|
+
* nothing. test/integration/in-process-agents.test.ts pins both the guards and
|
|
77
|
+
* the hazard they exist for, against a real Harper.
|
|
78
|
+
*
|
|
79
|
+
* ── Deliberately dependency-free ─────────────────────────────────────────────
|
|
80
|
+
* No `import { databases } from "harper"` (see resources/memory-visibility.ts
|
|
81
|
+
* for why that import is load-bearing to avoid): these helpers are pure shape,
|
|
82
|
+
* and an embedding app may want them before any table is resolved.
|
|
83
|
+
*/
|
|
84
|
+
/**
|
|
85
|
+
* Thrown when an in-process call is built in a way that would have silently
|
|
86
|
+
* escalated. Its own type so a caller can distinguish "I wired this wrong" from
|
|
87
|
+
* a resource's business-logic refusal (which arrives as a `Response`, not a
|
|
88
|
+
* throw).
|
|
89
|
+
*/
|
|
90
|
+
export class InProcessContextError extends Error {
|
|
91
|
+
constructor(message) {
|
|
92
|
+
super(message);
|
|
93
|
+
this.name = "InProcessContextError";
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** Reject anything that would resolve to the trusted `internal` verdict, or to a nonsense id. */
|
|
97
|
+
function requireAgentId(agentId, fnName) {
|
|
98
|
+
if (typeof agentId !== "string" || agentId.trim() === "") {
|
|
99
|
+
const got = agentId === undefined ? "undefined"
|
|
100
|
+
: agentId === null ? "null"
|
|
101
|
+
: typeof agentId !== "string" ? `a ${typeof agentId}`
|
|
102
|
+
: agentId === "" ? "an empty string"
|
|
103
|
+
: "a blank string";
|
|
104
|
+
throw new InProcessContextError(`${fnName}() requires a non-empty agent id, but received ${got}. ` +
|
|
105
|
+
"This is refused rather than defaulted because flair resolves a missing or empty " +
|
|
106
|
+
"tpsAgent to its trusted `internal` verdict, which reads and writes UNFILTERED across " +
|
|
107
|
+
"every agent and passes the admin-only gate — so returning a context here would have " +
|
|
108
|
+
"turned a missing id into silent administrator access. Resolve the agent id from your " +
|
|
109
|
+
"own server-side state before calling, and never from request data.");
|
|
110
|
+
}
|
|
111
|
+
return agentId;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The resource context that makes an in-process call act as ONE SPECIFIC agent.
|
|
115
|
+
* This is the call an application makes; the other two constructors below are
|
|
116
|
+
* privileged and deliberately harder to type.
|
|
117
|
+
*
|
|
118
|
+
* **`agentId` is asserted, not proven.** flair reads it and acts as that agent:
|
|
119
|
+
* no signature, no lookup against the `Agent` table, no registration
|
|
120
|
+
* requirement. That is right for a caller already inside the trust boundary —
|
|
121
|
+
* it could write the raw table anyway — and it is exactly why the id must come
|
|
122
|
+
* from YOUR OWN server-side state (the session you authenticated, the job
|
|
123
|
+
* record you dequeued) and **never from request data**. An agent id that
|
|
124
|
+
* reaches here from a body field, a query param, or a header you did not verify
|
|
125
|
+
* yourself is privilege escalation with no error and no trace.
|
|
126
|
+
*
|
|
127
|
+
* Throws {@link InProcessContextError} on a missing, empty or blank id — see the
|
|
128
|
+
* safety-design block at the top of this file for why that is not merely
|
|
129
|
+
* defensive.
|
|
130
|
+
*
|
|
131
|
+
* Takes no options, and in particular no way to ask for admin: use
|
|
132
|
+
* {@link adminContext} for that, so the escalation is a word you had to type.
|
|
133
|
+
*
|
|
134
|
+
* ```ts
|
|
135
|
+
* const Memory = server.resources.get("Memory").Resource;
|
|
136
|
+
* const h = await collectionResource(Memory, agentContext("planner"));
|
|
137
|
+
* const { id } = await h.post({ agentId: "planner", content: "…" });
|
|
138
|
+
* ```
|
|
139
|
+
*/
|
|
140
|
+
export function agentContext(agentId) {
|
|
141
|
+
return {
|
|
142
|
+
request: {
|
|
143
|
+
tpsAgent: requireAgentId(agentId, "agentContext"),
|
|
144
|
+
tpsAgentIsAdmin: false,
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Like {@link agentContext}, but with flair-admin authority: unfiltered
|
|
150
|
+
* cross-agent reads, and writes attributed to agents other than this one.
|
|
151
|
+
*
|
|
152
|
+
* Asserted, never checked — nothing validates that the named agent is actually
|
|
153
|
+
* an admin principal. **Treat a call to this exactly as you would a root
|
|
154
|
+
* shell:** provisioning and maintenance only, never a request handler's
|
|
155
|
+
* default, and never with an id or a flag derived from anything a caller
|
|
156
|
+
* supplied.
|
|
157
|
+
*
|
|
158
|
+
* It is a separate export rather than an option so that (a) no options object
|
|
159
|
+
* spread into {@link agentContext} can escalate, and (b) every privileged call
|
|
160
|
+
* site in a codebase is findable by name.
|
|
161
|
+
*
|
|
162
|
+
* Throws {@link InProcessContextError} on a missing, empty or blank id.
|
|
163
|
+
*/
|
|
164
|
+
export function adminContext(agentId) {
|
|
165
|
+
return {
|
|
166
|
+
request: {
|
|
167
|
+
tpsAgent: requireAgentId(agentId, "adminContext"),
|
|
168
|
+
tpsAgentIsAdmin: true,
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* The TRUSTED, UNATTRIBUTED, UNFILTERED context — flair's `internal` verdict.
|
|
174
|
+
* Reads see every agent's private records; writes are owned by nobody.
|
|
175
|
+
*
|
|
176
|
+
* This exists for work that is genuinely infrastructure rather than an agent's:
|
|
177
|
+
* provisioning a principal, a migration, a maintenance sweep. It is the same
|
|
178
|
+
* verdict a context-less call used to fall into by accident; naming it means an
|
|
179
|
+
* application can no longer reach it by forgetting an argument, and means
|
|
180
|
+
* `git grep internalContext` enumerates every place flair or an embedder took
|
|
181
|
+
* that authority deliberately.
|
|
182
|
+
*
|
|
183
|
+
* There is no id to validate: the verdict comes precisely from the ABSENCE of
|
|
184
|
+
* an identity annotation, so the returned object carries none. The marker field
|
|
185
|
+
* is inert — it documents intent at a debugger breakpoint and is read by
|
|
186
|
+
* nothing.
|
|
187
|
+
*
|
|
188
|
+
* ```ts
|
|
189
|
+
* // Registering a principal — infrastructure, not an agent's own write.
|
|
190
|
+
* const h = await collectionResource(Agent, internalContext());
|
|
191
|
+
* await h.post({ id, name: id, publicKey });
|
|
192
|
+
* ```
|
|
193
|
+
*/
|
|
194
|
+
export function internalContext() {
|
|
195
|
+
return { request: {}, __flairInternal: true };
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* A resource instance bound to `context` and marked as a COLLECTION, so its
|
|
199
|
+
* `post()` (the create path) works — see this module's header for why that mark
|
|
200
|
+
* cannot be applied from outside Harper.
|
|
201
|
+
*
|
|
202
|
+
* `context` is REQUIRED. Passing nothing used to yield the unfiltered
|
|
203
|
+
* `internal` verdict, which made the privileged path the shortest one to type;
|
|
204
|
+
* it now throws. Pass {@link agentContext} for an agent's own work,
|
|
205
|
+
* {@link adminContext} or {@link internalContext} when the authority is
|
|
206
|
+
* genuinely intended.
|
|
207
|
+
*
|
|
208
|
+
* Reads do not need this — `Cls.get(id, context)` and `Cls.search(query,
|
|
209
|
+
* context)` (Harper's static resource methods) already thread the context and
|
|
210
|
+
* start their own transaction.
|
|
211
|
+
*/
|
|
212
|
+
export async function collectionResource(Cls, context) {
|
|
213
|
+
if (context == null || typeof context !== "object") {
|
|
214
|
+
throw new InProcessContextError("collectionResource() requires an explicit context, but received " +
|
|
215
|
+
(context === undefined ? "undefined" : context === null ? "null" : `a ${typeof context}`) +
|
|
216
|
+
". Omitting it would resolve to flair's trusted `internal` verdict — unfiltered reads and " +
|
|
217
|
+
"unattributed writes across every agent — so the privileged path is never the one you get " +
|
|
218
|
+
"by leaving an argument off. Pass agentContext(id) to act as an agent, or internalContext() " +
|
|
219
|
+
"if this really is infrastructure work.");
|
|
220
|
+
}
|
|
221
|
+
// `{}` is a sufficient RequestTarget here: getResource() reads only `.id` off
|
|
222
|
+
// it (undefined ⇒ Harper mints the primary key), and takes the collection
|
|
223
|
+
// mark from the options argument.
|
|
224
|
+
return (await Cls.getResource({}, context, { isCollection: true }));
|
|
225
|
+
}
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
* `allowedHosts` configured → rejected; leaked private `d` in a JWK →
|
|
40
40
|
* rejected by the plugin even if our own build-time guard were bypassed).
|
|
41
41
|
*
|
|
42
|
-
* Kept free of any
|
|
42
|
+
* Kept free of any harper import (mirrors agentcard-fields.ts)
|
|
43
43
|
* so the document shape is unit-testable without spinning up Harper.
|
|
44
44
|
*/
|
|
45
45
|
/** The grant this agent's CIMD document targets — formalized by oauth#161, accepted by the published 2.2.0 validator. See module header. */
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* Harper HTTP listeners return `{ status, body, headers? }`. MCP messages are
|
|
24
24
|
* JSON-RPC 2.0, so we serialize the JSON-RPC response object as the body.
|
|
25
25
|
*/
|
|
26
|
-
import { databases } from "
|
|
26
|
+
import { databases } from "harper";
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
28
28
|
import { TOOLS, listToolDefs } from "./mcp-tools.js";
|
|
29
29
|
// The MCP protocol revision we implement (initialize handshake).
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* only meaningful when an operator has set the issuer + enabled the flag (see
|
|
18
18
|
* docs). The plugin serves DCR / authorize / token / JWKS / discovery.
|
|
19
19
|
*/
|
|
20
|
-
import * as harper from "
|
|
20
|
+
import * as harper from "harper";
|
|
21
21
|
import { mcpOAuthEnabled, mcpAuthConfig } from "./mcp-oauth-flag.js";
|
|
22
22
|
async function defaultLoadWithMCPAuth() {
|
|
23
23
|
// Dynamic import so the dep is only required when the surface is enabled.
|
|
@@ -53,7 +53,7 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
53
53
|
const handler = deps.mcpHandler ?? (await import("./mcp-handler.js")).mcpHandler;
|
|
54
54
|
// Read `server` lazily off the namespace (it's a runtime global on the Harper
|
|
55
55
|
// module, not a static named export) so this module links cleanly even where a
|
|
56
|
-
// stub build of
|
|
56
|
+
// stub build of harper lacks the export.
|
|
57
57
|
const srv = deps.server ?? (harper.server);
|
|
58
58
|
// Primary registration: urlPath subroute → own chain (flair's auth-middleware
|
|
59
59
|
// does not run here). `getConfig` pins iss/resource to the AS's values so the
|