@tpsdev-ai/flair 0.20.1 → 0.22.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 +39 -11
- package/SECURITY.md +52 -20
- package/config.yaml +32 -5
- package/dist/cli.js +2235 -260
- package/dist/deploy.js +96 -15
- package/dist/doctor-client.js +312 -0
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/install/clients.js +18 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/restore.js +1 -1
- package/dist/rem/runner.js +82 -12
- 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 +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +121 -2
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +127 -22
- package/dist/resources/MemoryBootstrap.js +441 -85
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +69 -5
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +268 -17
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +108 -26
- package/dist/resources/SemanticSearch.js +88 -320
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +12 -1
- package/dist/resources/agent-auth.js +61 -3
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +42 -14
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +13 -7
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +456 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +73 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-check.js +167 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/agent.graphql +7 -0
- package/schemas/event.graphql +5 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +82 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- package/ui/observation-center.html +0 -385
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation-nonce-store.ts — persistent backing for the federation
|
|
3
|
+
* anti-replay NonceStore (federation-edge-hardening slice 4).
|
|
4
|
+
*
|
|
5
|
+
* The gap: `federationNonceStore` in Federation.ts was a bare in-memory Map
|
|
6
|
+
* (`createNonceStore()` in federation-crypto.ts). On process restart it's
|
|
7
|
+
* wiped, so a signed federation request captured within its ±30s freshness
|
|
8
|
+
* window (verifyBodySignatureFresh) could be replayed once, post-restart.
|
|
9
|
+
* This module closes that window by backing the SAME `NonceStore` interface
|
|
10
|
+
* with the `Nonce` Harper table, while keeping every method synchronous —
|
|
11
|
+
* `verifyBodySignatureFresh` and its 2 call sites in Federation.ts are
|
|
12
|
+
* UNCHANGED (they still call `.has()` / `.set()` / `.evict()` with no
|
|
13
|
+
* `await`), so the entire existing federation-crypto-replay test suite
|
|
14
|
+
* (all synchronous assertions) keeps working unmodified.
|
|
15
|
+
*
|
|
16
|
+
* How the sync interface stays sync against an inherently-async DB API:
|
|
17
|
+
* - `has()` / `evict()` read/mutate an in-memory Map (via the existing
|
|
18
|
+
* `createNonceStore()`), exactly as before — no DB round trip.
|
|
19
|
+
* - `set()` updates that same in-memory Map synchronously (so replay
|
|
20
|
+
* detection within THIS process's lifetime is unchanged), and fires an
|
|
21
|
+
* async `Nonce.put()` in the background (not awaited) to persist the
|
|
22
|
+
* nonce for the NEXT restart. A failed/slow persist never blocks or
|
|
23
|
+
* fails request handling — see the perf/durability tradeoff note below.
|
|
24
|
+
* - `hydrate()` is the new piece: an explicit async step that loads
|
|
25
|
+
* not-yet-expired rows from the `Nonce` table into the in-memory Map.
|
|
26
|
+
* Call it once, on startup, BEFORE the store starts guarding live
|
|
27
|
+
* traffic — that's what makes a nonce recorded by the PREVIOUS process
|
|
28
|
+
* visible to a FRESH store instance after a restart (the whole point).
|
|
29
|
+
*
|
|
30
|
+
* Known tradeoff (flagged, not fixed here — would require making NonceStore
|
|
31
|
+
* async, which ripples into ~40 synchronous call sites across
|
|
32
|
+
* federation-crypto-replay.test.ts + federation-sync-e2e.test.ts):
|
|
33
|
+
* `set()`'s persistence is fire-and-forget. A crash in the narrow gap
|
|
34
|
+
* between "response sent" and "async put() resolved" can still lose that
|
|
35
|
+
* one nonce — narrowing the replay window to that gap, not eliminating it
|
|
36
|
+
* outright. Defense-in-depth: the ±30s freshness check + signature
|
|
37
|
+
* verification are still the primary guards; this closes the common case
|
|
38
|
+
* (graceful restart / redeploy), not the crash-mid-request edge case.
|
|
39
|
+
*/
|
|
40
|
+
import { createNonceStore } from "./federation-crypto.js";
|
|
41
|
+
/** 2x the standard 30s freshness window — matches the in-memory `evict()` cutoff already used by verifyBodySignatureFresh. */
|
|
42
|
+
export const DEFAULT_RETENTION_MS = 60_000;
|
|
43
|
+
async function resolveDb(opts, cache) {
|
|
44
|
+
if (opts.db)
|
|
45
|
+
return opts.db;
|
|
46
|
+
if (!cache.db) {
|
|
47
|
+
cache.db = import("@harperfast/harper").then((h) => h.databases);
|
|
48
|
+
}
|
|
49
|
+
return cache.db;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Create a `NonceStore` backed by the `Nonce` Harper table.
|
|
53
|
+
*
|
|
54
|
+
* Wraps the existing in-memory `createNonceStore()` (unchanged behavior for
|
|
55
|
+
* `has()`/`set()`/`evict()` within this process) and adds:
|
|
56
|
+
* - background persistence of newly-set nonces (`set()`)
|
|
57
|
+
* - an explicit `hydrate()` to reload previously-persisted nonces (used
|
|
58
|
+
* at startup, and by tests simulating a restart with a fresh instance)
|
|
59
|
+
*/
|
|
60
|
+
export function createPersistentNonceStore(opts = {}) {
|
|
61
|
+
const memory = createNonceStore();
|
|
62
|
+
const dbCache = {};
|
|
63
|
+
return {
|
|
64
|
+
has(key) {
|
|
65
|
+
return memory.has(key);
|
|
66
|
+
},
|
|
67
|
+
evict(olderThan) {
|
|
68
|
+
memory.evict(olderThan);
|
|
69
|
+
},
|
|
70
|
+
set(key, value) {
|
|
71
|
+
memory.set(key, value);
|
|
72
|
+
// Fire-and-forget persistence — see module header for the tradeoff.
|
|
73
|
+
void (async () => {
|
|
74
|
+
try {
|
|
75
|
+
const db = await resolveDb(opts, dbCache);
|
|
76
|
+
await db.flair.Nonce.put({ id: key, seenAt: value });
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
console.error("[federation-nonce-store] failed to persist nonce (in-memory record already made — this process's replay guard is unaffected):", err?.message ?? err);
|
|
80
|
+
}
|
|
81
|
+
})();
|
|
82
|
+
},
|
|
83
|
+
async hydrate(hydrateOpts) {
|
|
84
|
+
const retentionMs = hydrateOpts?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
85
|
+
const now = hydrateOpts?.now ?? Date.now();
|
|
86
|
+
const cutoff = now - retentionMs;
|
|
87
|
+
try {
|
|
88
|
+
const db = await resolveDb(opts, dbCache);
|
|
89
|
+
for await (const row of db.flair.Nonce.search()) {
|
|
90
|
+
if (row?.id != null && typeof row.seenAt === "number" && row.seenAt >= cutoff) {
|
|
91
|
+
memory.set(String(row.id), row.seenAt);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
console.error("[federation-nonce-store] failed to hydrate from Nonce table (starting with an empty replay guard):", err?.message ?? err);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
// ─── Eviction sweep — periodic Nonce table hygiene ─────────────────────────
|
|
102
|
+
//
|
|
103
|
+
// Mirrors federation-cleanup.ts's 5-min setInterval hub-pattern. Decoupled
|
|
104
|
+
// from the in-memory `evict()` above (which runs per-request and only trims
|
|
105
|
+
// the local Map): this sweep deletes rows from the `Nonce` TABLE itself so
|
|
106
|
+
// it doesn't grow unbounded across restarts. No native TTL in this repo —
|
|
107
|
+
// same app-managed-sweep idiom as PairingToken cleanup.
|
|
108
|
+
//
|
|
109
|
+
// Runs on every instance (hub AND spoke) — unlike PairingToken cleanup
|
|
110
|
+
// (hub-only, since only hubs issue pairing tokens), both FederationPair and
|
|
111
|
+
// FederationSync can be the RECEIVING side of a signed request on either
|
|
112
|
+
// role, so both roles accumulate rows in the Nonce table and both need the
|
|
113
|
+
// sweep.
|
|
114
|
+
const NONCE_CLEANUP_INTERVAL_MS = 300_000; // 5 minutes
|
|
115
|
+
let nonceCleanupTimer = null;
|
|
116
|
+
/**
|
|
117
|
+
* Core sweep logic — exposed for unit testing. Deletes `Nonce` rows with
|
|
118
|
+
* `seenAt` older than `retentionMs` (default: 2x the 30s freshness window).
|
|
119
|
+
*/
|
|
120
|
+
export async function runNonceCleanupTick(opts = {}) {
|
|
121
|
+
let db;
|
|
122
|
+
if (opts.db) {
|
|
123
|
+
db = opts.db;
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const harper = await import("@harperfast/harper");
|
|
127
|
+
db = harper.databases;
|
|
128
|
+
}
|
|
129
|
+
const retentionMs = opts.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
130
|
+
const now = opts.now ?? Date.now();
|
|
131
|
+
const cutoff = now - retentionMs;
|
|
132
|
+
const toDelete = [];
|
|
133
|
+
let scanned = 0;
|
|
134
|
+
try {
|
|
135
|
+
for await (const row of db.flair.Nonce.search()) {
|
|
136
|
+
scanned++;
|
|
137
|
+
if (typeof row?.seenAt === "number" && row.seenAt < cutoff) {
|
|
138
|
+
toDelete.push(row.id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
catch (err) {
|
|
143
|
+
console.error("[federation-nonce-store] failed to query Nonce records:", err?.message ?? err);
|
|
144
|
+
return { deleted: 0, scanned: 0 };
|
|
145
|
+
}
|
|
146
|
+
let deleted = 0;
|
|
147
|
+
for (const id of toDelete) {
|
|
148
|
+
try {
|
|
149
|
+
await db.flair.Nonce.delete(id);
|
|
150
|
+
deleted++;
|
|
151
|
+
}
|
|
152
|
+
catch (err) {
|
|
153
|
+
console.error("[federation-nonce-store] failed to delete expired nonce:", err?.message ?? err);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { deleted, scanned };
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Initialise the periodic Nonce-table eviction sweep (5-min cadence).
|
|
160
|
+
*
|
|
161
|
+
* In test environments, callers pass a mock `db` via `opts` so this module
|
|
162
|
+
* never imports @harperfast/harper at the top level (mirrors
|
|
163
|
+
* federation-cleanup.ts's `initFederationCleanup`).
|
|
164
|
+
*/
|
|
165
|
+
export async function initNonceStoreCleanup(opts) {
|
|
166
|
+
const immediate = opts?.immediateTick ?? true;
|
|
167
|
+
let db;
|
|
168
|
+
if (opts?.db) {
|
|
169
|
+
db = opts.db;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
try {
|
|
173
|
+
const harper = await import("@harperfast/harper");
|
|
174
|
+
db = harper.databases;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
console.error("[federation-nonce-store] failed to load @harperfast/harper:", err?.message ?? err);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
const retentionMs = opts?.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
182
|
+
console.log("[federation-nonce-store] starting nonce eviction sweep (5-min cadence)");
|
|
183
|
+
if (nonceCleanupTimer)
|
|
184
|
+
clearInterval(nonceCleanupTimer);
|
|
185
|
+
nonceCleanupTimer = setInterval(() => {
|
|
186
|
+
runNonceCleanupTick({ db, retentionMs }).catch((err) => {
|
|
187
|
+
console.error("[federation-nonce-store] cleanup tick error:", err?.message ?? err);
|
|
188
|
+
});
|
|
189
|
+
}, NONCE_CLEANUP_INTERVAL_MS);
|
|
190
|
+
if (immediate) {
|
|
191
|
+
runNonceCleanupTick({ db, retentionMs }).catch((err) => {
|
|
192
|
+
console.error("[federation-nonce-store] initial cleanup tick error:", err?.message ?? err);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
-
import { promises as fsp } from "node:fs";
|
|
2
|
+
import { promises as fsp, existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { homedir, platform } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
4
|
+
import { join, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
5
6
|
import { getRerankStatus } from "./rerank-provider.js";
|
|
7
|
+
import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
|
|
8
|
+
import { getMigrationStatusSnapshot } from "./migrations/status.js";
|
|
6
9
|
const db = databases;
|
|
7
10
|
const redactHome = (p) => {
|
|
8
11
|
const home = homedir();
|
|
@@ -17,6 +20,35 @@ const exists = async (path) => {
|
|
|
17
20
|
return false;
|
|
18
21
|
}
|
|
19
22
|
};
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the runtime package version from package.json (duplicated from
|
|
25
|
+
* admin-layout.ts / AdminInstance.ts — same "keep in sync" idiom as the
|
|
26
|
+
* federation-crypto helpers in src/cli.ts; see those two files' comments for
|
|
27
|
+
* why it's inlined rather than imported). `process.env.npm_package_version`
|
|
28
|
+
* is only populated inside `npm run`, so reading package.json relative to
|
|
29
|
+
* THIS running module is the only way to report the version of the code
|
|
30
|
+
* that's actually executing — which is exactly what `flair upgrade`'s
|
|
31
|
+
* post-restart verification (flair#635) needs: proof the NEW package is
|
|
32
|
+
* what's serving, not just what landed on disk before a restart.
|
|
33
|
+
*/
|
|
34
|
+
function resolveVersion() {
|
|
35
|
+
try {
|
|
36
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const candidates = [
|
|
38
|
+
join(here, "..", "..", "package.json"),
|
|
39
|
+
join(here, "..", "package.json"),
|
|
40
|
+
];
|
|
41
|
+
for (const p of candidates) {
|
|
42
|
+
if (existsSync(p)) {
|
|
43
|
+
const pkg = JSON.parse(readFileSync(p, "utf-8"));
|
|
44
|
+
if (pkg.version)
|
|
45
|
+
return pkg.version;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
catch { /* fall through */ }
|
|
50
|
+
return process.env.npm_package_version ?? "dev";
|
|
51
|
+
}
|
|
20
52
|
/**
|
|
21
53
|
* Health endpoint — truly public, returns only { ok: true }.
|
|
22
54
|
*
|
|
@@ -45,25 +77,57 @@ export class Health extends Resource {
|
|
|
45
77
|
return true;
|
|
46
78
|
}
|
|
47
79
|
async get() {
|
|
48
|
-
|
|
80
|
+
// `version` (flair#695, the CLI↔server handshake — src/version-handshake.ts):
|
|
81
|
+
// exposed on the PUBLIC endpoint deliberately, so the check works even
|
|
82
|
+
// before an agent identity/key exists (fresh install, pre-`flair init`).
|
|
83
|
+
// Sherlock verdict: fine while locally bound; if /Health is ever fronted
|
|
84
|
+
// PUBLICLY over Fabric, the public surface must omit this field (the
|
|
85
|
+
// richer, auth-gated /HealthDetail already carried version pre-existing —
|
|
86
|
+
// this only adds it to the anonymous endpoint too).
|
|
87
|
+
return { ok: true, version: resolveVersion() };
|
|
49
88
|
}
|
|
50
89
|
}
|
|
51
90
|
/**
|
|
52
91
|
* Authenticated health detail — returns memory/agent/soul stats + process info.
|
|
53
92
|
* Requires Ed25519 agent auth or admin basic auth.
|
|
54
93
|
*
|
|
94
|
+
* allowRead()=allowVerified (authorizeLocal-escalation-class follow-up to
|
|
95
|
+
* #601/#604/#609/#612 — flair#614's backstop found this resource had NO
|
|
96
|
+
* allow* at all, despite this docstring's stated intent). Harper's own
|
|
97
|
+
* default (`user?.role.permission.super_user`, satisfiable only by a genuine
|
|
98
|
+
* admin OR authorizeLocal's forged loopback super_user) was silently
|
|
99
|
+
* standing in instead. Any verified agent may read — get() below still
|
|
100
|
+
* filters sensitive fields (peer/OAuth-client lists, absolute paths, full
|
|
101
|
+
* agent roster) down to admin-only.
|
|
102
|
+
*
|
|
55
103
|
* Every optional-subsystem lookup is wrapped so a missing table or absent
|
|
56
104
|
* schema downgrades to "not configured" rather than failing the whole call.
|
|
57
105
|
*/
|
|
58
106
|
export class HealthDetail extends Resource {
|
|
107
|
+
async allowRead() {
|
|
108
|
+
return allowVerified(this.getContext?.());
|
|
109
|
+
}
|
|
59
110
|
async get() {
|
|
60
111
|
const stats = { ok: true };
|
|
61
112
|
const nowMs = Date.now();
|
|
62
113
|
const warnings = [];
|
|
63
114
|
const ctx = this.getContext?.();
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
115
|
+
// #614 fix: resolve identity via the shared three-way verdict
|
|
116
|
+
// (internal/agent/anonymous — agent-auth.ts) instead of reading
|
|
117
|
+
// tpsAgent/tpsAgentIsAdmin off the raw request directly. The OLD
|
|
118
|
+
// computation was `request?.tpsAgentIsAdmin === true || !callerAgent` —
|
|
119
|
+
// the `|| !callerAgent` half meant an UNRESOLVED caller (no tpsAgent at
|
|
120
|
+
// all — i.e. anonymous) defaulted to isAdmin=TRUE, backwards from every
|
|
121
|
+
// other resource in this codebase and the opposite of fail-safe.
|
|
122
|
+
// allowRead() above already denies a genuine anonymous HTTP caller
|
|
123
|
+
// before get() ever runs; this fixes the internal computation to match
|
|
124
|
+
// (defense-in-depth, and correct semantics if get() is ever reached
|
|
125
|
+
// another way). Only a true "internal" verdict (no HTTP request at all —
|
|
126
|
+
// a programmatic/in-process call) or a verified admin agent is isAdmin;
|
|
127
|
+
// an unresolved/anonymous HTTP caller is never treated as admin.
|
|
128
|
+
const auth = await resolveAgentAuth(ctx);
|
|
129
|
+
const callerAgent = auth.kind === "agent" ? auth.agentId : undefined;
|
|
130
|
+
const isAdmin = auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin);
|
|
67
131
|
stats.caller = { agentId: callerAgent ?? null, isAdmin };
|
|
68
132
|
let memoriesList = [];
|
|
69
133
|
// ── Memory stats ──
|
|
@@ -435,6 +499,29 @@ export class HealthDetail extends Resource {
|
|
|
435
499
|
catch {
|
|
436
500
|
stats.rem = null;
|
|
437
501
|
}
|
|
502
|
+
// ── Migrations (flair#695: zero-touch boot-keyed auto-migration) ──
|
|
503
|
+
// `{ id, rowsDone, rowsRemaining, state }` per registered migration, per
|
|
504
|
+
// flair#695 §A. `cyclePhase: "pre-hash"` is the
|
|
505
|
+
// "pre-flight integrity check in progress" state the K&S verdict calls
|
|
506
|
+
// for; a halted migration surfaces here (with `reason`) AND as a
|
|
507
|
+
// warning below so it's visible without a separate lookup.
|
|
508
|
+
try {
|
|
509
|
+
const migrationsDataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
|
|
510
|
+
const snapshot = getMigrationStatusSnapshot(migrationsDataDir);
|
|
511
|
+
stats.migrations = {
|
|
512
|
+
cyclePhase: snapshot.cyclePhase,
|
|
513
|
+
lastCycleAt: snapshot.lastCycleAt ?? null,
|
|
514
|
+
migrations: snapshot.migrations,
|
|
515
|
+
};
|
|
516
|
+
for (const m of snapshot.migrations) {
|
|
517
|
+
if (m.state === "halted" || m.state === "failed") {
|
|
518
|
+
warnings.push({ level: "warn", message: `migration '${m.id}' ${m.state}${m.reason ? `: ${m.reason}` : ""} — see \`flair doctor\`` });
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
catch {
|
|
523
|
+
stats.migrations = null;
|
|
524
|
+
}
|
|
438
525
|
// ── Disk ──
|
|
439
526
|
try {
|
|
440
527
|
const dataDir = process.env.HDB_ROOT ?? join(homedir(), ".flair", "data");
|
|
@@ -535,6 +622,10 @@ export class HealthDetail extends Resource {
|
|
|
535
622
|
// ── Warnings ──
|
|
536
623
|
stats.warnings = warnings;
|
|
537
624
|
// ── Process info ──
|
|
625
|
+
// version: the RUNNING process's own package.json — used by `flair
|
|
626
|
+
// upgrade`'s post-restart verification (flair#635) to prove the new
|
|
627
|
+
// code is actually serving, not just installed on disk.
|
|
628
|
+
stats.version = resolveVersion();
|
|
538
629
|
stats.pid = process.pid;
|
|
539
630
|
stats.uptimeSeconds = Math.floor(process.uptime());
|
|
540
631
|
return stats;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
/**
|
|
3
|
+
* ─── Local instance identity (federation-edge-hardening slice 1) ────────────
|
|
4
|
+
*
|
|
5
|
+
* The write-time `originatorInstanceId` stamp (Memory.ts/Soul.ts/Agent.ts/
|
|
6
|
+
* Relationship.ts post()/put()) needs to know THIS instance's own federation
|
|
7
|
+
* identity — the same `id` FederationInstance.get() (resources/Federation.ts)
|
|
8
|
+
* finds-or-creates on first boot and persists in the `Instance` table
|
|
9
|
+
* (schemas/federation.graphql). Exactly one row is expected in a given
|
|
10
|
+
* instance's own Instance table — that row IS this instance; other instances
|
|
11
|
+
* it has paired with live in the separate `Peer` table, never here.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately READ-ONLY: this module never creates an Instance row — that
|
|
14
|
+
* first-boot bootstrap (keypair generation + keystore write) stays
|
|
15
|
+
* FederationInstance.get()'s job (resources/Federation.ts). If no Instance
|
|
16
|
+
* row exists yet (a fresh, never-federated instance, or a unit-test
|
|
17
|
+
* environment with no Instance table at all), localInstanceId() resolves to
|
|
18
|
+
* null and callers stamp nothing — originatorInstanceId is nullable by
|
|
19
|
+
* design (schemas/memory.graphql), and a null tag reads as "pre-tag /
|
|
20
|
+
* local-origin by default" per the federation-edge-hardening design.
|
|
21
|
+
*
|
|
22
|
+
* Cached at module scope after the FIRST successful resolution — a write
|
|
23
|
+
* must not pay a DB lookup every call. An unresolved (null) result is NOT
|
|
24
|
+
* cached, since that state can legitimately change later (federation gets
|
|
25
|
+
* bootstrapped after this process already started serving writes) and the
|
|
26
|
+
* cost of re-checking only applies to instances that have never federated.
|
|
27
|
+
*/
|
|
28
|
+
let cachedInstanceId = null;
|
|
29
|
+
export async function localInstanceId() {
|
|
30
|
+
if (cachedInstanceId)
|
|
31
|
+
return cachedInstanceId;
|
|
32
|
+
try {
|
|
33
|
+
for await (const row of databases.flair.Instance.search()) {
|
|
34
|
+
if (row?.id) {
|
|
35
|
+
cachedInstanceId = row.id;
|
|
36
|
+
}
|
|
37
|
+
break; // exactly one row expected — this instance's own identity
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Instance table not present (test env / not yet migrated) — no local id.
|
|
42
|
+
}
|
|
43
|
+
return cachedInstanceId;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Test-only: force re-resolution on the next localInstanceId() call. The
|
|
47
|
+
* module-level cache otherwise persists across test files that share the
|
|
48
|
+
* same `bun test` process (same collision class documented in
|
|
49
|
+
* memory-integrity.test.ts re: the Memory class singleton).
|
|
50
|
+
*/
|
|
51
|
+
export function _resetLocalInstanceIdCacheForTests() {
|
|
52
|
+
cachedInstanceId = null;
|
|
53
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client ID Metadata Document (CIMD) field logic for Flair agent identities.
|
|
3
|
+
*
|
|
4
|
+
* Produces the JSON metadata document an OAuth authorization server fetches
|
|
5
|
+
* when a client_id is an HTTPS URL — draft-ietf-oauth-client-id-metadata-
|
|
6
|
+
* document-00, adopted by the MCP draft authorization spec IN PLACE OF DCR
|
|
7
|
+
* (no registration row to create or replicate across nodes; the served
|
|
8
|
+
* document IS the registration).
|
|
9
|
+
*
|
|
10
|
+
* Shape is pinned to HarperFast/oauth **issue #161** ("client_credentials
|
|
11
|
+
* (2/4): CIMD-first client resolution for private_key_jwt agents") — the
|
|
12
|
+
* FORMAL shape spec for this document, shipped in @harperfast/oauth@2.2.0
|
|
13
|
+
* via PR #170 (with #167's CIMD resolution layer). #161 requires:
|
|
14
|
+
*
|
|
15
|
+
* - `grant_types: ["client_credentials"]`;
|
|
16
|
+
* `token_endpoint_auth_method: "private_key_jwt"`.
|
|
17
|
+
* - `jwks` = JWK Set of PUBLIC OKP/Ed25519 keys ONLY — reject any key
|
|
18
|
+
* carrying a private `d`, reject empty sets, reject non-OKP/non-Ed25519
|
|
19
|
+
* keys.
|
|
20
|
+
* - client_credentials-only clients carry NEITHER `redirect_uris` NOR
|
|
21
|
+
* `response_types` — #161 explicitly blesses this as a documented
|
|
22
|
+
* deviation from the CIMD draft's general required-fields list (which
|
|
23
|
+
* includes `redirect_uris` for interactive/DCR-style clients).
|
|
24
|
+
* - `client_credentials` combined with `refresh_token` is rejected.
|
|
25
|
+
* - Security gate (AS-side, enforced by the plugin, documented here for
|
|
26
|
+
* deployment coordination): `clientIdMetadataDocuments.allowedHosts`
|
|
27
|
+
* MUST be configured and this document's `client_id` host MUST be on
|
|
28
|
+
* it — merely hosting a reachable document must never be sufficient to
|
|
29
|
+
* mint tokens. Replaces the old DCR `initialAccessToken` gate.
|
|
30
|
+
*
|
|
31
|
+
* The AS-side machinery consuming this document (fetch/validate/cache —
|
|
32
|
+
* #167's CIMD resolution layer, extended by #170 for client_credentials +
|
|
33
|
+
* private_key_jwt) is SHIPPED in the published @harperfast/oauth@2.2.0.
|
|
34
|
+
* This module's output is proven against that real published code — not a
|
|
35
|
+
* mirror — in test/unit/mcp-client-credentials-live-package.test.ts, which
|
|
36
|
+
* drives 2.2.0's actual `resolveCimdClient` pipeline (via its exported
|
|
37
|
+
* `_setDnsLookup`/`_setFetch` test hooks) and confirms the document
|
|
38
|
+
* resolves and validates end-to-end, plus the fail-closed negatives (no
|
|
39
|
+
* `allowedHosts` configured → rejected; leaked private `d` in a JWK →
|
|
40
|
+
* rejected by the plugin even if our own build-time guard were bypassed).
|
|
41
|
+
*
|
|
42
|
+
* Kept free of any @harperfast/harper import (mirrors agentcard-fields.ts)
|
|
43
|
+
* so the document shape is unit-testable without spinning up Harper.
|
|
44
|
+
*/
|
|
45
|
+
/** The grant this agent's CIMD document targets — formalized by oauth#161, accepted by the published 2.2.0 validator. See module header. */
|
|
46
|
+
export const CIMD_TARGET_GRANT_TYPES = ["client_credentials"];
|
|
47
|
+
/** The auth method this agent's CIMD document targets — formalized by oauth#161, accepted by the published 2.2.0 validator. See module header. */
|
|
48
|
+
export const CIMD_TARGET_AUTH_METHOD = "private_key_jwt";
|
|
49
|
+
/** Build a Client ID Metadata Document for a headless Flair agent. */
|
|
50
|
+
export function buildCimdDocument(params) {
|
|
51
|
+
const { clientId, clientName, jwk } = params;
|
|
52
|
+
if (!clientId)
|
|
53
|
+
throw new Error("clientId is required");
|
|
54
|
+
if (!clientName)
|
|
55
|
+
throw new Error("clientName is required");
|
|
56
|
+
if (!jwk || jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string" || !jwk.x) {
|
|
57
|
+
throw new Error("jwk must be a public Ed25519 JWK ({kty:'OKP', crv:'Ed25519', x:'...'})");
|
|
58
|
+
}
|
|
59
|
+
// Belt-and-suspenders (oauth#161: "reject any key carrying private `d`").
|
|
60
|
+
// Ed25519Jwk's TS type has no `d` field, so a well-typed caller can't
|
|
61
|
+
// construct one — but this function's argument still crosses a runtime
|
|
62
|
+
// boundary (a JWK read from disk/network, or a caller using `as any`), so
|
|
63
|
+
// check for it explicitly rather than trust the type alone. A CIMD
|
|
64
|
+
// document is fetched by an unauthenticated AS over the public internet;
|
|
65
|
+
// there is no scenario where leaking `d` here is acceptable.
|
|
66
|
+
if (typeof jwk.d !== "undefined") {
|
|
67
|
+
throw new Error("jwk must be a PUBLIC key — refusing to build a CIMD document from a JWK carrying a private 'd' component");
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
client_id: clientId,
|
|
71
|
+
client_name: clientName,
|
|
72
|
+
// Exactly one key, always non-empty and always OKP/Ed25519 — both are
|
|
73
|
+
// structurally guaranteed by the validation above (oauth#161 also
|
|
74
|
+
// requires rejecting an EMPTY jwks set and non-OKP/non-Ed25519 keys;
|
|
75
|
+
// this function's single-already-validated-jwk API makes both
|
|
76
|
+
// impossible to violate from here).
|
|
77
|
+
jwks: { keys: [jwk] },
|
|
78
|
+
// pending oauth#161 — rejected by today's open-draft #167 validator; see module header.
|
|
79
|
+
token_endpoint_auth_method: CIMD_TARGET_AUTH_METHOD,
|
|
80
|
+
grant_types: [...CIMD_TARGET_GRANT_TYPES],
|
|
81
|
+
// redirect_uris AND response_types are both deliberately omitted — #161
|
|
82
|
+
// blesses this explicitly for client_credentials-only CIMD clients (no
|
|
83
|
+
// redirect-based flow is ever possible for a headless agent). Neither
|
|
84
|
+
// field is declared on CimdDocument, so neither can slip back in via
|
|
85
|
+
// this object literal.
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Normalize a Flair `Agent.publicKey` value into a JWK OKP `x`. Accepts both
|
|
90
|
+
* encodings `Agent.publicKey` is written in across this repo (hex 64-char,
|
|
91
|
+
* or base64/base64url 44-char raw 32-byte key) — the same two forms
|
|
92
|
+
* resources/ed25519-auth.ts's `importEd25519Key` accepts, so a document
|
|
93
|
+
* built from this always matches what the verified-request path already
|
|
94
|
+
* treats as the agent's key.
|
|
95
|
+
*/
|
|
96
|
+
export function agentPublicKeyToJwk(publicKeyStr, kid) {
|
|
97
|
+
const trimmed = (publicKeyStr ?? "").trim();
|
|
98
|
+
let raw;
|
|
99
|
+
if (/^[0-9a-f]{64}$/i.test(trimmed)) {
|
|
100
|
+
raw = Buffer.from(trimmed, "hex");
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
raw = Buffer.from(trimmed.replace(/-/g, "+").replace(/_/g, "/"), "base64");
|
|
104
|
+
}
|
|
105
|
+
if (raw.length !== 32) {
|
|
106
|
+
throw new Error(`agent public key must decode to 32 bytes (got ${raw.length})`);
|
|
107
|
+
}
|
|
108
|
+
const jwk = { kty: "OKP", crv: "Ed25519", x: raw.toString("base64url") };
|
|
109
|
+
if (kid)
|
|
110
|
+
jwk.kid = kid;
|
|
111
|
+
return jwk;
|
|
112
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* mcp-handler.ts — the Model-2 custom MCP protocol handler.
|
|
3
3
|
*
|
|
4
|
-
* A minimal in-process MCP (JSON-RPC 2.0) handler serving the
|
|
4
|
+
* A minimal in-process MCP (JSON-RPC 2.0) handler serving the 12 curated flair
|
|
5
5
|
* tools over Streamable HTTP. It is wrapped by `@harperfast/oauth`'s
|
|
6
6
|
* `withMCPAuth` (see mcp-oauth.ts), which fails closed on any missing/invalid
|
|
7
7
|
* Bearer token BEFORE this handler runs and, on success, sets
|