@tpsdev-ai/flair 0.20.1 → 0.21.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 +29 -4
- package/SECURITY.md +28 -18
- package/dist/cli.js +465 -32
- package/dist/deploy.js +93 -11
- package/dist/doctor-client.js +312 -0
- package/dist/install/clients.js +18 -0
- package/dist/rem/restore.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 +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/Federation.js +98 -2
- package/dist/resources/IngestEvents.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/Memory.js +123 -17
- package/dist/resources/MemoryBootstrap.js +46 -36
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/OAuth.js +61 -4
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +55 -3
- package/dist/resources/Relationship.js +10 -1
- package/dist/resources/SemanticSearch.js +14 -14
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +24 -5
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +1 -1
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-provider.js +1 -1
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/version-check.js +167 -0
- package/package.json +2 -2
- package/schemas/agent.graphql +7 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +16 -0
package/dist/resources/OAuth.js
CHANGED
|
@@ -35,6 +35,27 @@ function nowISO() {
|
|
|
35
35
|
function futureISO(ms) {
|
|
36
36
|
return new Date(Date.now() + ms).toISOString();
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* 302 redirect — deliberately NOT `Response.redirect()`.
|
|
40
|
+
*
|
|
41
|
+
* FOUND while writing the real-Harper integration test for #604 (this exact
|
|
42
|
+
* gap is why the coverage doc above calls OAuthAuthorize.post() untestable
|
|
43
|
+
* without a running Harper instance — no test had ever hit this path over
|
|
44
|
+
* real HTTP before). Pre-existing on main, unrelated to the #604 auth fix:
|
|
45
|
+
* per the Fetch spec, `Response.redirect(url, status)` constructs a Response
|
|
46
|
+
* whose Headers guard is set to "immutable". Harper's own HTTP response
|
|
47
|
+
* pipeline mutates the handler's returned Response's headers afterward (e.g.
|
|
48
|
+
* CORS/instrumentation headers) — against an immutable-guarded Headers this
|
|
49
|
+
* throws `TypeError: immutable`, turning EVERY real POST /OAuthAuthorize
|
|
50
|
+
* (both "deny" and "approve") into a 500, verified via curl against a built
|
|
51
|
+
* origin/main with no other changes. A manually-constructed Response with
|
|
52
|
+
* the same status/Location header has the default (mutable) "response"
|
|
53
|
+
* guard, so Harper's later mutation succeeds — identical bytes on the wire,
|
|
54
|
+
* no behavior change, just a mutable Headers object underneath.
|
|
55
|
+
*/
|
|
56
|
+
function redirectTo(url, status = 302) {
|
|
57
|
+
return new Response(null, { status, headers: { Location: url } });
|
|
58
|
+
}
|
|
38
59
|
// ─── Discovery metadata ──────────────────────────────────────────────────────
|
|
39
60
|
export class OAuthMetadata extends Resource {
|
|
40
61
|
// OAuth discovery metadata is intentionally public — RFC 8414 § 3 requires
|
|
@@ -123,7 +144,7 @@ export class OAuthAuthorize extends Resource {
|
|
|
123
144
|
// In 1.0, this returns a simple HTML consent page.
|
|
124
145
|
// The user (Nathan) approves or denies, which POSTs back.
|
|
125
146
|
// Harper v5 does not populate this.request on Resource subclasses —
|
|
126
|
-
// getContext() is the only reliable path (
|
|
147
|
+
// getContext() is the only reliable path (the previous
|
|
127
148
|
// `(this as any).request` read was always undefined, so query params were
|
|
128
149
|
// always empty).
|
|
129
150
|
const ctx = this.getContext?.();
|
|
@@ -197,13 +218,13 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
197
218
|
}
|
|
198
219
|
if (action === "deny") {
|
|
199
220
|
const params = new URLSearchParams({ error: "access_denied", state });
|
|
200
|
-
return
|
|
221
|
+
return redirectTo(`${redirectUri}?${params}`);
|
|
201
222
|
}
|
|
202
223
|
// Determine authenticated principal. Harper v5 does not populate
|
|
203
224
|
// this.request on Resource subclasses, so `(this as any).request?.tpsAgent`
|
|
204
225
|
// was always undefined and this ALWAYS fell back to the hardcoded "admin" —
|
|
205
226
|
// every approved consent grant was minted for the admin principal
|
|
206
|
-
// regardless of who actually approved it (
|
|
227
|
+
// regardless of who actually approved it (identity spoof).
|
|
207
228
|
// resolveAgentAuth(getContext()) resolves Basic super_user/admin auth to
|
|
208
229
|
// { kind: "agent", agentId: username, isAdmin: true } (see agent-auth.ts).
|
|
209
230
|
// We do NOT silently fall back to "admin" on an unresolved principal — if
|
|
@@ -215,6 +236,42 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
215
236
|
// admin, or should any verified (non-admin) agent be able to approve its
|
|
216
237
|
// own consent grant? The previous code always resolved to "admin" for
|
|
217
238
|
// every caller, so this is a genuine policy decision, not just a bug fix.
|
|
239
|
+
//
|
|
240
|
+
// SECURITY FIX (#604 — authorizeLocal escalation): resolveAgentAuth's
|
|
241
|
+
// `context.user` fallback (agent-auth.ts, the super_user / username
|
|
242
|
+
// branches) ALSO matches Harper's `authorizeLocal` (config true) ambient
|
|
243
|
+
// super_user injection for ANY credential-less LOOPBACK request.
|
|
244
|
+
// /OAuthAuthorize sits on auth-middleware.ts's public early-return
|
|
245
|
+
// passthrough (any method), so a bare local POST never gets OUR
|
|
246
|
+
// middleware's tpsAgent/tpsAnonymous annotation either — resolveAgentAuth
|
|
247
|
+
// falls straight through to Harper's raw `context.user`, which
|
|
248
|
+
// authorizeLocal can forge with NO signature and NO password. A
|
|
249
|
+
// credential-less loopback POST therefore resolved to
|
|
250
|
+
// { kind: "agent", agentId: "admin", isAdmin: true } and minted a REAL
|
|
251
|
+
// admin authorization code, exchangeable at OAuthToken for a Bearer
|
|
252
|
+
// token — full local privilege escalation with zero credentials.
|
|
253
|
+
// authorizeLocal injects request.user=super_user ONLY when there is NO
|
|
254
|
+
// Authorization header present (see Presence.ts's get() doc for the
|
|
255
|
+
// fuller writeup of this exact mechanism) — a genuinely
|
|
256
|
+
// password-verified Basic admin/super_user, or a genuinely
|
|
257
|
+
// Ed25519-signed agent request, both carry a real header. Requiring one
|
|
258
|
+
// be present before trusting resolveAgentAuth's verdict closes the
|
|
259
|
+
// forged-approver path without touching resolveAgentAuth itself (the
|
|
260
|
+
// root-cause fix is a separate, larger follow-up — see #604): a
|
|
261
|
+
// credential-less local caller now gets 401 and no code is minted; a
|
|
262
|
+
// genuinely-authenticated approver (real Basic password, or a real
|
|
263
|
+
// TPS-Ed25519 signature verified by resolveAgentAuth's own fallback) is
|
|
264
|
+
// unaffected.
|
|
265
|
+
const ctx = this.getContext?.();
|
|
266
|
+
const request = ctx?.request ?? ctx;
|
|
267
|
+
const authHeader = request?.headers?.get?.("authorization") ??
|
|
268
|
+
request?.headers?.asObject?.authorization ??
|
|
269
|
+
"";
|
|
270
|
+
if (!authHeader) {
|
|
271
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
272
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
273
|
+
});
|
|
274
|
+
}
|
|
218
275
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
219
276
|
if (auth.kind !== "agent") {
|
|
220
277
|
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
@@ -238,7 +295,7 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
|
|
|
238
295
|
createdAt: now,
|
|
239
296
|
});
|
|
240
297
|
const params = new URLSearchParams({ code, state });
|
|
241
|
-
return
|
|
298
|
+
return redirectTo(`${redirectUri}?${params}`);
|
|
242
299
|
}
|
|
243
300
|
}
|
|
244
301
|
// ─── Token endpoint ──────────────────────────────────────────────────────────
|
|
@@ -23,7 +23,7 @@ export class OrgEventCatchup extends Resource {
|
|
|
23
23
|
async get(pathInfo) {
|
|
24
24
|
// Harper v5 does not populate this.request on Resource subclasses —
|
|
25
25
|
// getContext() is the only reliable path to the gate's tpsAgent/
|
|
26
|
-
// tpsAgentIsAdmin annotations (
|
|
26
|
+
// tpsAgentIsAdmin annotations (the previous `(this as
|
|
27
27
|
// any).request` read was always undefined, so the ownership check below
|
|
28
28
|
// never ran — fail-open cross-agent read).
|
|
29
29
|
const auth = await resolveAgentAuth(this.getContext?.());
|
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Auth:
|
|
10
10
|
* GET — public (returns only allowlisted fields; safe for public renderer).
|
|
11
|
+
* currentTask is additionally content-gated to verified agents only
|
|
12
|
+
* (#592) — anonymous callers get the roster with currentTask=null.
|
|
11
13
|
* POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
|
|
12
14
|
* own record; cross-agent writes are rejected (403).
|
|
13
15
|
*
|
|
@@ -15,9 +17,17 @@
|
|
|
15
17
|
* - Write: per-agent Ed25519 auth. Cross-agent → 403.
|
|
16
18
|
* - Read: field-allowlisted to public-safe set. No secrets, no admin data.
|
|
17
19
|
* - currentTask is agent-authored free text → cap length, escape on render.
|
|
20
|
+
* - currentTask CONTENT gate (#592): the roster (id/displayName/role/
|
|
21
|
+
* runtime/activity/presenceStatus/lastHeartbeatAt) is genuinely
|
|
22
|
+
* public-safe and stays world-readable, but currentTask is free text that
|
|
23
|
+
* the coordination convention (`presence set --task "investigating
|
|
24
|
+
* <host>: <symptom>"`) has put customer names and preprod hostnames in.
|
|
25
|
+
* sanitizeCurrentTask() only trims/caps length — it does not redact
|
|
26
|
+
* content. get() additionally gates currentTask itself to verified
|
|
27
|
+
* in-org agents only; see get()'s inline comment.
|
|
18
28
|
*/
|
|
19
29
|
import { databases } from "@harperfast/harper";
|
|
20
|
-
import { resolveAgentAuth } from "./agent-auth.js";
|
|
30
|
+
import { resolveAgentAuth, verifyAgentRequest } from "./agent-auth.js";
|
|
21
31
|
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
22
32
|
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
23
33
|
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
@@ -32,7 +42,7 @@ function offlineThresholdMs() {
|
|
|
32
42
|
}
|
|
33
43
|
// ─── Nonce replay + crypto helpers ─────────────────────────────────────────────
|
|
34
44
|
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
35
|
-
// importEd25519Key all live in ./ed25519-auth.ts
|
|
45
|
+
// importEd25519Key all live in ./ed25519-auth.ts — the single
|
|
36
46
|
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
37
47
|
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
38
48
|
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
@@ -98,8 +108,47 @@ export class Presence extends databases.flair.Presence {
|
|
|
98
108
|
*
|
|
99
109
|
* Joins Presence records with Agent metadata and derives presenceStatus.
|
|
100
110
|
* Only allowlisted fields are returned (no secrets, no admin data).
|
|
111
|
+
*
|
|
112
|
+
* currentTask CONTENT gate (#592): Harper routes every GET — collection
|
|
113
|
+
* (`GET /Presence`) AND single-record (`GET /Presence/<id>`) — through this
|
|
114
|
+
* SAME method (REST.js: `resource.get(target, request)`, one call site for
|
|
115
|
+
* both; this override ignores `target` and always returns the full roster
|
|
116
|
+
* array), so gating once here, before the loop, covers every read path with
|
|
117
|
+
* no separate return site to miss.
|
|
118
|
+
*
|
|
119
|
+
* The gate keys off a valid TPS-Ed25519 SIGNATURE (verifyAgentRequest),
|
|
120
|
+
* NOT resolveAgentAuth()/allowVerified — and that distinction is the whole
|
|
121
|
+
* fix. /Presence is a public-passthrough in auth-middleware.ts: the
|
|
122
|
+
* middleware early-returns WITHOUT annotating tpsAgent/tpsAnonymous, so a
|
|
123
|
+
* resource-level resolver never sees a gate annotation for this path. Worse,
|
|
124
|
+
* Harper's `authorizeLocal` (config default true) auto-authorizes any
|
|
125
|
+
* *credential-less* loopback request as super_user — it injects request.user
|
|
126
|
+
* ONLY "when there is no Authorization header" (node .../server/http.js). So a
|
|
127
|
+
* bare, unauthenticated `GET /Presence` from loopback (exactly the anonymous
|
|
128
|
+
* caller the issue is about, and what the integration test exercises against
|
|
129
|
+
* a real spawned Harper) arrives with request.user = super_user and NO
|
|
130
|
+
* signature. resolveAgentAuth() would classify that as `kind:"agent"` (its
|
|
131
|
+
* super_user branch) and leak currentTask — which it did, against real
|
|
132
|
+
* Harper, even though mocked unit tests using tpsAgent annotations passed.
|
|
133
|
+
* A TPS-Ed25519 signature, by contrast, cannot be manufactured by
|
|
134
|
+
* authorizeLocal (it requires the Authorization header, which suppresses the
|
|
135
|
+
* super_user injection), so verifyAgentRequest() cleanly separates a real
|
|
136
|
+
* in-org agent from an anonymous/loopback/Basic-admin caller. Only a valid
|
|
137
|
+
* agent signature gets currentTask; everything else (anonymous, loopback
|
|
138
|
+
* super_user, Basic-admin, internal in-process) gets currentTask=null.
|
|
139
|
+
* allowRead() is UNCHANGED (still `true`) — the roster itself stays public;
|
|
140
|
+
* only the free-text field is gated, per the issue's field-level option.
|
|
101
141
|
*/
|
|
102
142
|
async get() {
|
|
143
|
+
// Extract the raw request the same way post() does (getContext().request
|
|
144
|
+
// is populated for GET; fall back to the context itself). verifyAgentRequest
|
|
145
|
+
// returns the agent for a valid TPS-Ed25519 signature, else null — see the
|
|
146
|
+
// gate rationale above. Memoized per-request, so this is a no-op if any
|
|
147
|
+
// other path already verified the same request.
|
|
148
|
+
const ctx = this.getContext?.();
|
|
149
|
+
const request = ctx?.request ?? ctx;
|
|
150
|
+
const agentAuth = request ? await verifyAgentRequest(request) : null;
|
|
151
|
+
const includeCurrentTask = agentAuth !== null;
|
|
103
152
|
const now = Date.now();
|
|
104
153
|
const idleThreshold = idleThresholdMs();
|
|
105
154
|
const offlineThreshold = offlineThresholdMs();
|
|
@@ -124,7 +173,10 @@ export class Presence extends databases.flair.Presence {
|
|
|
124
173
|
presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
|
|
125
174
|
? row.lastHeartbeatAt
|
|
126
175
|
: Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
|
|
127
|
-
|
|
176
|
+
// Anonymous/unverified readers get `null` here (key stays present,
|
|
177
|
+
// schema-stable) instead of the sanitized task text — see the
|
|
178
|
+
// currentTask CONTENT gate doc above get().
|
|
179
|
+
currentTask: includeCurrentTask ? sanitizeCurrentTask(row?.currentTask) : null,
|
|
128
180
|
lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
|
|
129
181
|
? row.lastHeartbeatAt
|
|
130
182
|
: Number(row?.lastHeartbeatAt ?? 0),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
4
|
+
import { localInstanceId } from "./instance-identity.js";
|
|
4
5
|
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
6
7
|
* Relationship resource — entity-to-entity relationships with temporal validity.
|
|
@@ -16,7 +17,7 @@ const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { s
|
|
|
16
17
|
export class Relationship extends databases.flair.Relationship {
|
|
17
18
|
/**
|
|
18
19
|
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
19
|
-
* read-gate family fix
|
|
20
|
+
* read-gate family fix — same pattern as Memory.ts/Soul.ts/
|
|
20
21
|
* WorkspaceState.ts). Closes the same P0 leak: Harper routes
|
|
21
22
|
* `GET /Relationship/<id>` to get() and the collection describe
|
|
22
23
|
* (`GET /Relationship`) outside search(), so neither was gated before this
|
|
@@ -124,6 +125,14 @@ export class Relationship extends databases.flair.Relationship {
|
|
|
124
125
|
content.validFrom = content.validFrom || now;
|
|
125
126
|
// validTo left as null/undefined for active relationships
|
|
126
127
|
content.confidence = content.confidence ?? 1.0;
|
|
128
|
+
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
129
|
+
// 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
|
|
130
|
+
// full contract. No-op if already set (never fires for a genuine local
|
|
131
|
+
// write; a federation-synced record never reaches this method — the
|
|
132
|
+
// merge path writes via the raw table object, bypassing this class).
|
|
133
|
+
if (content.originatorInstanceId == null) {
|
|
134
|
+
content.originatorInstanceId = await localInstanceId();
|
|
135
|
+
}
|
|
127
136
|
return super.put(content);
|
|
128
137
|
}
|
|
129
138
|
async delete(_) {
|
|
@@ -11,7 +11,7 @@ import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidat
|
|
|
11
11
|
// relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
|
|
12
12
|
// unit-tested directly (see test/unit/temporal-scoring.test.ts).
|
|
13
13
|
import { compositeScore } from "./scoring.js";
|
|
14
|
-
// BM25 + union-RRF hybrid retrieval (
|
|
14
|
+
// BM25 + union-RRF hybrid retrieval (FLAIR-BM25-HYBRID-RETRIEVAL).
|
|
15
15
|
// Harper-free modules so the BM25 scoring, the candidate-union RRF, and the
|
|
16
16
|
// SECURITY conditions-filter are unit-tested against the shipped code.
|
|
17
17
|
import { buildBM25, fuseRrfNormalized, hybridEnabled, SEM_LIMIT } from "./bm25.js";
|
|
@@ -81,10 +81,10 @@ export class SemanticSearch extends Resource {
|
|
|
81
81
|
? authenticatedAgent
|
|
82
82
|
: bodyAgentId;
|
|
83
83
|
// Read-scope: own (any visibility) + granted owners' SHARED memories only
|
|
84
|
-
// (
|
|
84
|
+
// (Layer 1). Centralized in resolveReadScope() — this used to be
|
|
85
85
|
// an inline grant-resolution loop here PLUS a `visibility === "office"`
|
|
86
86
|
// global OR-clause below that leaked ANY authenticated agent's read of
|
|
87
|
-
// ANY other agent's office-visible memories
|
|
87
|
+
// ANY other agent's office-visible memories. Both are gone;
|
|
88
88
|
// this is the ONE scoping resolution for this endpoint now.
|
|
89
89
|
const scope = agentId ? await resolveReadScope(agentId) : null;
|
|
90
90
|
// Generate query embedding
|
|
@@ -162,13 +162,13 @@ export class SemanticSearch extends Resource {
|
|
|
162
162
|
// from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
|
|
163
163
|
// headroom. Scoped to the legacy (non-hybrid) vector path below — the
|
|
164
164
|
// hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
|
|
165
|
-
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union
|
|
165
|
+
// (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union; the
|
|
166
166
|
// reranker still applies to its output further down regardless of which
|
|
167
167
|
// path produced `filteredResults`.
|
|
168
168
|
const rerankOn = isRerankEnabled();
|
|
169
169
|
const rerankTopN = getRerankTopN();
|
|
170
170
|
if (hybrid) {
|
|
171
|
-
// ─── BM25 + union-RRF hybrid path
|
|
171
|
+
// ─── BM25 + union-RRF hybrid path ────────────────────────────────────
|
|
172
172
|
// 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
|
|
173
173
|
// over the SCOPED corpus. 3. SECURITY: the BM25 candidate set is filtered
|
|
174
174
|
// by the SAME conditions[] + temporal filters BEFORE fusion (the corpus
|
|
@@ -206,7 +206,7 @@ export class SemanticSearch extends Resource {
|
|
|
206
206
|
continue;
|
|
207
207
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
208
208
|
continue;
|
|
209
|
-
//
|
|
209
|
+
// Unconditional past-validTo exclusion (see legacy HNSW
|
|
210
210
|
// loop below for the full rationale) — applies regardless of asOf.
|
|
211
211
|
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
212
212
|
continue;
|
|
@@ -315,7 +315,7 @@ export class SemanticSearch extends Resource {
|
|
|
315
315
|
continue;
|
|
316
316
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
317
317
|
continue;
|
|
318
|
-
//
|
|
318
|
+
// A past validTo ALWAYS means the record has been closed out
|
|
319
319
|
// (server supersede path — Memory.ts closeSupersededRecord — sets
|
|
320
320
|
// validTo without necessarily setting `archived`). Unconditional, not
|
|
321
321
|
// gated on `asOf`, so a server-superseded record can't resurface in
|
|
@@ -330,9 +330,9 @@ export class SemanticSearch extends Resource {
|
|
|
330
330
|
semanticScore = distanceToSimilarity(record.$distance);
|
|
331
331
|
}
|
|
332
332
|
else {
|
|
333
|
-
// ───
|
|
333
|
+
// ─── Harper's cosine-sort query omits $distance for a
|
|
334
334
|
// SINGLETON post-filter result set — the SAME quirk root-caused and
|
|
335
|
-
// fixed for the dedup path
|
|
335
|
+
// fixed for the dedup path (resources/Memory.ts
|
|
336
336
|
// findConservativeDedupMatch / resources/dedup.ts cosineSimilarity).
|
|
337
337
|
// Sort ORDER is still correct; only the numeric `$distance`
|
|
338
338
|
// annotation is missing on that one record, regardless of the
|
|
@@ -340,7 +340,7 @@ export class SemanticSearch extends Resource {
|
|
|
340
340
|
// just limit=1 — the trigger is the post-filter MATCH COUNT, not the
|
|
341
341
|
// requested limit).
|
|
342
342
|
//
|
|
343
|
-
//
|
|
343
|
+
// Layer 1 made this common: the no-grants agent scope used
|
|
344
344
|
// to ALWAYS be a compound `{operator:"or", conditions:[{agentId},
|
|
345
345
|
// {visibility=="office"}]}` condition; resolveReadScope() now emits
|
|
346
346
|
// a PLAIN single `{agentId==X}` condition for the common (no-grants)
|
|
@@ -354,8 +354,8 @@ export class SemanticSearch extends Resource {
|
|
|
354
354
|
//
|
|
355
355
|
// Fix: point-lookup the record by id (a plain get(), unaffected by
|
|
356
356
|
// the sort-query quirk — selecting "embedding" directly on the SAME
|
|
357
|
-
// sort-by-embedding query comes back as a bare scalar per
|
|
358
|
-
//
|
|
357
|
+
// sort-by-embedding query comes back as a bare scalar, per the same
|
|
358
|
+
// investigation above) and compute cosine similarity ourselves in JS from its
|
|
359
359
|
// real stored `embedding` vector against this query's `qEmb`, via
|
|
360
360
|
// the same math as the ume4 fallback (dedup.ts's cosineSimilarity).
|
|
361
361
|
// Only done on this (rare) undefined-$distance branch — never adds
|
|
@@ -405,7 +405,7 @@ export class SemanticSearch extends Resource {
|
|
|
405
405
|
continue;
|
|
406
406
|
if (asOf && record.validTo && record.validTo <= asOf)
|
|
407
407
|
continue;
|
|
408
|
-
//
|
|
408
|
+
// Unconditional past-validTo exclusion (see legacy HNSW
|
|
409
409
|
// loop above for the full rationale) — applies regardless of asOf.
|
|
410
410
|
if (record.validTo && Date.parse(record.validTo) < Date.now())
|
|
411
411
|
continue;
|
|
@@ -449,7 +449,7 @@ export class SemanticSearch extends Resource {
|
|
|
449
449
|
// Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
|
|
450
450
|
// can't do) and reorders before the final slice. Reorders whatever
|
|
451
451
|
// `filteredResults` the retrieval stage above produced — legacy HNSW-only
|
|
452
|
-
// OR the BM25+union-RRF hybrid path
|
|
452
|
+
// OR the BM25+union-RRF hybrid path — since both converge into
|
|
453
453
|
// the same `results`/`filteredResults` shape before this point; hybrid
|
|
454
454
|
// retrieves+fuses, the reranker only reorders the fused set. Still gated
|
|
455
455
|
// on `qEmb` (an embedding was actually generated); the pure keyword-only
|
package/dist/resources/Soul.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { resolveAgentAuth, allowVerified } from "./agent-auth.js";
|
|
3
|
+
import { localInstanceId } from "./instance-identity.js";
|
|
3
4
|
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
5
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
6
|
/**
|
|
@@ -37,6 +38,14 @@ export class Soul extends databases.flair.Soul {
|
|
|
37
38
|
content.durability ||= "permanent";
|
|
38
39
|
content.createdAt = new Date().toISOString();
|
|
39
40
|
content.updatedAt = content.createdAt;
|
|
41
|
+
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
42
|
+
// 1) — see resources/Memory.ts's stampOriginatorInstanceId doc for the
|
|
43
|
+
// full contract. No-op if already set (never fires for a genuine local
|
|
44
|
+
// write; a federation-synced record never reaches this method — the
|
|
45
|
+
// merge path writes via the raw table object, bypassing this class).
|
|
46
|
+
if (content.originatorInstanceId == null) {
|
|
47
|
+
content.originatorInstanceId = await localInstanceId();
|
|
48
|
+
}
|
|
40
49
|
return super.post(content, context);
|
|
41
50
|
}
|
|
42
51
|
async put(content, context) {
|
|
@@ -44,6 +53,11 @@ export class Soul extends databases.flair.Soul {
|
|
|
44
53
|
if (denied)
|
|
45
54
|
return denied;
|
|
46
55
|
content.updatedAt = new Date().toISOString();
|
|
56
|
+
// Write-time originatorInstanceId stamp — see post() above / Memory.ts's
|
|
57
|
+
// stampOriginatorInstanceId doc. No-op if already set.
|
|
58
|
+
if (content.originatorInstanceId == null) {
|
|
59
|
+
content.originatorInstanceId = await localInstanceId();
|
|
60
|
+
}
|
|
47
61
|
return super.put(content, context);
|
|
48
62
|
}
|
|
49
63
|
}
|
|
@@ -16,7 +16,7 @@ export class WorkspaceLatest extends Resource {
|
|
|
16
16
|
async get(pathInfo) {
|
|
17
17
|
// Harper v5 does not populate this.context / this.request on Resource
|
|
18
18
|
// subclasses — getContext() is the only reliable path to the gate's
|
|
19
|
-
// tpsAgent/tpsAgentIsAdmin annotations (
|
|
19
|
+
// tpsAgent/tpsAgentIsAdmin annotations (the previous
|
|
20
20
|
// `(this as any).context?.request ?? (this as any).request` read was always
|
|
21
21
|
// undefined, so the ownership check below never ran — fail-open cross-agent
|
|
22
22
|
// read).
|
|
@@ -20,7 +20,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
20
20
|
}
|
|
21
21
|
/**
|
|
22
22
|
* Self-authorize now that the global gate is non-rejecting (memory-soul-
|
|
23
|
-
* read-gate family fix
|
|
23
|
+
* read-gate family fix — applying the Memory.ts/Soul.ts pattern
|
|
24
24
|
* to WorkspaceState/Relationship/Integration/MemoryGrant). Closes the same
|
|
25
25
|
* P0 leak: Harper routes `GET /WorkspaceState/<id>` to get() and the
|
|
26
26
|
* collection describe (`GET /WorkspaceState`) to a path outside search(),
|
|
@@ -26,7 +26,7 @@ import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuff
|
|
|
26
26
|
export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
27
27
|
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
28
28
|
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
29
|
-
// importEd25519Key all live in ./ed25519-auth.ts
|
|
29
|
+
// importEd25519Key all live in ./ed25519-auth.ts — the single
|
|
30
30
|
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
31
31
|
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
32
32
|
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
//
|
|
10
10
|
// Kept free of any @harperfast/harper import so the real logic is unit-testable
|
|
11
11
|
// without spinning up Harper (avoids the simulator-pattern that let the
|
|
12
|
-
// description-fallback leak ship untested
|
|
12
|
+
// description-fallback leak ship untested).
|
|
13
13
|
export function readSoulKind(entry) {
|
|
14
14
|
return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
|
|
15
15
|
}
|
|
@@ -20,7 +20,7 @@ export function readSoulContent(entry) {
|
|
|
20
20
|
* The public description is ONLY an explicit `kind="description"` soul.
|
|
21
21
|
*
|
|
22
22
|
* There is deliberately no fallback to "the first soul with any content": that
|
|
23
|
-
* was the
|
|
23
|
+
* was the description-fallback leak — an agent with no description soul would publish an
|
|
24
24
|
* arbitrary private soul on the unauthenticated card. Absent an explicit
|
|
25
25
|
* description, publish nothing.
|
|
26
26
|
*/
|
|
@@ -38,7 +38,7 @@ function getAdminPass() {
|
|
|
38
38
|
// an admin — one implementation guarantees they can't diverge.
|
|
39
39
|
// ─── Crypto + replay-guard helpers ────────────────────────────────────────────
|
|
40
40
|
// WINDOW_MS, isNonceReplay/recordNonce (the ONE shared nonce store), and
|
|
41
|
-
// importEd25519Key all live in ./ed25519-auth.ts
|
|
41
|
+
// importEd25519Key all live in ./ed25519-auth.ts — the single
|
|
42
42
|
// shared implementation imported by auth-middleware.ts, agent-auth.ts, and
|
|
43
43
|
// Presence.ts so a nonce recorded via any one of the three call sites is
|
|
44
44
|
// visible to the other two, and the crypto/decoder logic can't drift.
|
|
@@ -97,9 +97,28 @@ server.http(async (request, nextLayer) => {
|
|
|
97
97
|
// instances (rockit-local works only because authorizeLocal=true).
|
|
98
98
|
url.pathname === "/ObservationCenter" ||
|
|
99
99
|
// Presence roster is public-safe (field-allowlisted); GET serves the
|
|
100
|
-
// Office Space renderer without auth.
|
|
101
|
-
//
|
|
102
|
-
|
|
100
|
+
// Office Space renderer without auth. Scoped to GET only (#604): the
|
|
101
|
+
// exact-path match used to match ANY method, so a bare `PUT /Presence`
|
|
102
|
+
// (collection-level, no id — Harper routes it to the same .put() as
|
|
103
|
+
// by-id PUT) early-returned here too, skipping this middleware entirely.
|
|
104
|
+
// A credential-less loopback PUT then reached Presence.put()'s
|
|
105
|
+
// resolveAgentAuth() call with NO tpsAnonymous/tpsAgent annotation, which
|
|
106
|
+
// fell through to raw `context.user` — populated by Harper's
|
|
107
|
+
// `authorizeLocal` (config true) ambient super_user injection for ANY
|
|
108
|
+
// credential-less loopback request — so the ownership check saw an
|
|
109
|
+
// "admin" caller (isAdmin=true) and let the write through unauthenticated
|
|
110
|
+
// (`super.put()`, no signature, no password). Mirrors the A2A GET-only
|
|
111
|
+
// pattern above: POST/PUT/DELETE now always transit the general
|
|
112
|
+
// middleware path below, which marks a genuinely headerless request
|
|
113
|
+
// tpsAnonymous BEFORE Harper's ambient elevation lands (resolveAgentAuth
|
|
114
|
+
// checks tpsAnonymous first — see agent-auth.ts's resolution order), so
|
|
115
|
+
// the ownership check in Presence.put()/delete() correctly denies it.
|
|
116
|
+
// POST (the heartbeat) is unaffected in practice: it already prefers
|
|
117
|
+
// request.tpsAgent when the middleware set it, and falls back to its own
|
|
118
|
+
// Ed25519 header parse otherwise — transiting the general path now just
|
|
119
|
+
// means a genuinely headerless POST gets marked anonymous (still 401)
|
|
120
|
+
// instead of skipping straight to that fallback parse.
|
|
121
|
+
(request.method === "GET" && url.pathname === "/Presence"))
|
|
103
122
|
return nextLayer(request);
|
|
104
123
|
// If Harper has already authorized this request (e.g. Basic admin, or
|
|
105
124
|
// authorizeLocal=true on localhost), trust Harper's auth decision and pass
|
|
@@ -477,7 +496,7 @@ server.http(async (request, nextLayer) => {
|
|
|
477
496
|
if (memId) {
|
|
478
497
|
const record = await databases.flair.Memory.get(memId);
|
|
479
498
|
if (record && record.agentId && record.agentId !== agentId) {
|
|
480
|
-
// Centralized read-scope (
|
|
499
|
+
// Centralized read-scope (Layer 1): a grant only covers
|
|
481
500
|
// the owner's SHARED memories, never their private ones. This
|
|
482
501
|
// used to be a `visibility === "office"` bypass (any authenticated
|
|
483
502
|
// agent, no grant needed) — that's gone; the private-exclusion is
|
|
@@ -72,7 +72,7 @@ export function passesRecordFilters(record, f = {}) {
|
|
|
72
72
|
return false;
|
|
73
73
|
if (f.asOf && record?.validTo && record.validTo <= f.asOf)
|
|
74
74
|
return false;
|
|
75
|
-
//
|
|
75
|
+
// A past validTo ALWAYS means the record has been closed out —
|
|
76
76
|
// either by the server supersede path (Memory.ts closeSupersededRecord sets
|
|
77
77
|
// validTo without necessarily setting `archived`) or any other writer. This
|
|
78
78
|
// is unconditional (not gated on `f.asOf`) so a server-superseded record
|
package/dist/resources/bm25.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// ─── BM25 + union-RRF hybrid retrieval (Harper-free, unit-testable) ──────────
|
|
2
|
-
// Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved
|
|
2
|
+
// Per spec FLAIR-BM25-HYBRID-RETRIEVAL (Kern-approved). This module is
|
|
3
3
|
// deliberately Harper-free — same rationale as ./scoring.ts — so the BM25
|
|
4
4
|
// scoring, the candidate-union RRF fusion, and the security conditions-filter
|
|
5
5
|
// can be unit-tested directly against the SHIPPED code without a live Harper.
|
package/dist/resources/dedup.js
CHANGED
|
@@ -30,10 +30,10 @@ export const DEDUP_MIN_CONTENT_LENGTH = 20;
|
|
|
30
30
|
* Cosine similarity of two equal-length embedding vectors, computed directly
|
|
31
31
|
* in JS. Used as a fallback for Harper's HNSW cosine-sort query omitting a
|
|
32
32
|
* computed `$distance` on its top candidate when the query's post-filter
|
|
33
|
-
* result set contains exactly ONE matching record (
|
|
33
|
+
* result set contains exactly ONE matching record (found in
|
|
34
34
|
* resources/Memory.ts's findConservativeDedupMatch; the identical quirk is
|
|
35
35
|
* also fixed in resources/SemanticSearch.ts's scoring loop for the same
|
|
36
|
-
* reason
|
|
36
|
+
* reason). A mismatched length, empty vector, or
|
|
37
37
|
* zero-magnitude side yields 0 (no signal, never treated as "identical" by a
|
|
38
38
|
* degenerate computation).
|
|
39
39
|
*/
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* `importEd25519Key`. Three independent replay windows meant a nonce
|
|
12
12
|
* recorded as "seen" via one path was invisible to the other two — a
|
|
13
13
|
* defense-in-depth gap, and a drift hazard (any future fix to one copy
|
|
14
|
-
* silently didn't apply to the other two).
|
|
15
|
-
*
|
|
14
|
+
* silently didn't apply to the other two). Consolidating all three into
|
|
15
|
+
* this one module means there is exactly ONE replay guard and ONE
|
|
16
16
|
* key-import implementation, imported by all 3 sites.
|
|
17
17
|
*
|
|
18
18
|
* `b64ToArrayBuffer` was already unified into resources/b64.ts in a prior
|
|
@@ -65,7 +65,7 @@ async function ensureInit() {
|
|
|
65
65
|
// Not initialized — init with modelsDir pointing at a USER-WRITABLE
|
|
66
66
|
// location. On a sudo/root-owned global install the Flair package dir
|
|
67
67
|
// (process.cwd()) is root-owned, so a model download into <cwd>/models
|
|
68
|
-
// fails with EACCES and semantic search silently dies
|
|
68
|
+
// fails with EACCES and semantic search silently dies. The
|
|
69
69
|
// model — and everything else Flair writes — must live under ~/.flair.
|
|
70
70
|
//
|
|
71
71
|
// NOTE: import.meta.dirname and __dirname are both undefined in Harper v5's
|