@tpsdev-ai/flair 0.22.1 → 0.24.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/dist/cli.js +1836 -523
- package/dist/deploy.js +16 -1
- package/dist/doctor-client.js +176 -0
- package/dist/hook-install.js +324 -0
- package/dist/install/clients.js +46 -11
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +98 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +3 -3
- package/schemas/memory.graphql +2 -2
package/dist/resources/Soul.js
CHANGED
|
@@ -1,24 +1,32 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
-
import { resolveAgentAuth
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
3
|
import { localInstanceId } from "./instance-identity.js";
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
import { makeAuthGate, stampAttribution, UNAUTH } from "./record-type-kit.js";
|
|
5
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
6
6
|
/**
|
|
7
7
|
* Deny anonymous; enforce per-agent write ownership for non-admin agents.
|
|
8
8
|
* The previous header-based check only fired when an agent WAS present (it read
|
|
9
9
|
* x-tps-agent), so an anonymous request — which carries no x-tps-agent — slipped
|
|
10
10
|
* through. With the non-rejecting gate, each write path self-enforces (resolveAgentAuth
|
|
11
11
|
* distinguishes internal/agent/anonymous). Mirrors the WorkspaceState pattern.
|
|
12
|
+
*
|
|
13
|
+
* No-forge attribution — mode/field drawn from RECORD_TYPES.Soul (record-
|
|
14
|
+
* types slice 2, flair#520) rather than hand-typed literals. "validate-
|
|
15
|
+
* truthy" (see record-type-kit.ts's stampAttribution doc) — rejects a
|
|
16
|
+
* PRESENT, mismatched agentId; passes through untouched when absent. Same
|
|
17
|
+
* idiom as Memory.post()/put().
|
|
12
18
|
*/
|
|
13
19
|
async function enforceWriteAuth(self, data) {
|
|
14
20
|
const auth = await resolveAgentAuth(self.getContext?.());
|
|
15
21
|
if (auth.kind === "anonymous")
|
|
16
22
|
return UNAUTH();
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
}
|
|
20
|
-
return null;
|
|
23
|
+
const attr = stampAttribution(auth, data, RECORD_TYPES.Soul.ownerField, RECORD_TYPES.Soul.attribution.post, "forbidden: agentId must match authenticated agent");
|
|
24
|
+
return attr.denied ?? null;
|
|
21
25
|
}
|
|
26
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
27
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
28
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
29
|
+
const soulAuthGate = makeAuthGate();
|
|
22
30
|
export class Soul extends databases.flair.Soul {
|
|
23
31
|
/**
|
|
24
32
|
* Self-authorize now that the global gate is non-rejecting. Closes the P0
|
|
@@ -30,7 +38,7 @@ export class Soul extends databases.flair.Soul {
|
|
|
30
38
|
* verified agent — same posture as Agent.ts's allowRead. Write ownership
|
|
31
39
|
* is unaffected — enforceWriteAuth() below already gates post()/put().
|
|
32
40
|
*/
|
|
33
|
-
allowRead() { return
|
|
41
|
+
allowRead() { return soulAuthGate.call(this); }
|
|
34
42
|
async post(content, context) {
|
|
35
43
|
const denied = await enforceWriteAuth(this, content);
|
|
36
44
|
if (denied)
|
|
@@ -8,11 +8,23 @@
|
|
|
8
8
|
* Use this.getContext() to access request context (tpsAgent, tpsAgentIsAdmin).
|
|
9
9
|
*/
|
|
10
10
|
import { databases } from "@harperfast/harper";
|
|
11
|
-
import { resolveAgentAuth
|
|
11
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
12
12
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
import { makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
14
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
15
|
+
// Parameterized from RECORD_TYPES.WorkspaceState (record-types slice 2,
|
|
16
|
+
// flair#520) rather than a hand-typed "owner-only" literal — the registry is
|
|
17
|
+
// now the single source of truth this class draws its read-scope mode from.
|
|
18
|
+
// Exported solely so test/unit/record-types-registry.test.ts's drift
|
|
19
|
+
// tripwire can introspect the composed resolver's tagged `.mode`/
|
|
20
|
+
// `.ownerField` against RECORD_TYPES.WorkspaceState — not for any other
|
|
21
|
+
// runtime consumer.
|
|
22
|
+
export const workspaceReadScope = makeReadScope(RECORD_TYPES.WorkspaceState.readScope, RECORD_TYPES.WorkspaceState.ownerField);
|
|
23
|
+
const workspaceByIdReadGate = makeByIdReadGate(workspaceReadScope);
|
|
24
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
25
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
26
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
27
|
+
const workspaceAuthGate = makeAuthGate();
|
|
16
28
|
export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
17
29
|
/** Auth verdict from the request context. internal = trusted in-process call;
|
|
18
30
|
* agent = verified Ed25519; anonymous = HTTP with no valid agent → deny. */
|
|
@@ -29,13 +41,15 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
29
41
|
* full record content. Per-record ownership scoping happens in get() below;
|
|
30
42
|
* the collection scope is still in search().
|
|
31
43
|
*/
|
|
32
|
-
allowRead() { return
|
|
44
|
+
allowRead() { return workspaceAuthGate.call(this); }
|
|
33
45
|
/**
|
|
34
46
|
* Override get() to scope by-id reads the same way search() scopes
|
|
35
47
|
* collection reads (memory-soul-read-gate family fix). Never distinguishes
|
|
36
48
|
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
37
49
|
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
38
|
-
* workspace-state ids.
|
|
50
|
+
* workspace-state ids. Wired through record-type-kit.ts's
|
|
51
|
+
* makeByIdReadGate, scoped "owner-only" — same dispatch shape
|
|
52
|
+
* Memory.ts/Relationship.ts's get() use.
|
|
39
53
|
*/
|
|
40
54
|
async get(target) {
|
|
41
55
|
// Collection / query reads — the `GET /WorkspaceState/?<query>` form and
|
|
@@ -49,23 +63,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
49
63
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
50
64
|
return this.search(target);
|
|
51
65
|
}
|
|
52
|
-
|
|
53
|
-
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
54
|
-
// this is defense-in-depth if get() is ever reached directly.
|
|
55
|
-
if (auth.kind === "anonymous") {
|
|
56
|
-
return NOT_FOUND();
|
|
57
|
-
}
|
|
58
|
-
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
59
|
-
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
60
|
-
return super.get(target);
|
|
61
|
-
}
|
|
62
|
-
// Non-admin agent: only its own workspace-state records.
|
|
63
|
-
const record = await super.get(target);
|
|
64
|
-
if (!record)
|
|
65
|
-
return NOT_FOUND();
|
|
66
|
-
if (record.agentId !== auth.agentId)
|
|
67
|
-
return NOT_FOUND();
|
|
68
|
-
return record;
|
|
66
|
+
return workspaceByIdReadGate.call(this, target, (t) => super.get(t));
|
|
69
67
|
}
|
|
70
68
|
/**
|
|
71
69
|
* Override search() to scope collection GETs to the authenticated agent's own
|
|
@@ -73,13 +71,15 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
73
71
|
* (previously `!authAgent` was treated as unfiltered — the anonymous-read leak).
|
|
74
72
|
*/
|
|
75
73
|
async search(query) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
if (
|
|
74
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
75
|
+
// three-way branch Memory.ts/Relationship.ts's search() use.
|
|
76
|
+
const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
|
|
77
|
+
if (gate.kind === "denied")
|
|
78
|
+
return gate.response;
|
|
79
|
+
if (gate.kind === "unfiltered")
|
|
80
80
|
return super.search(query);
|
|
81
|
-
|
|
82
|
-
const agentIdCondition =
|
|
81
|
+
const scope = await workspaceReadScope(gate.agentId);
|
|
82
|
+
const agentIdCondition = scope.condition;
|
|
83
83
|
// Harper passes `query` as a request target object (pathname, id, isCollection…).
|
|
84
84
|
// Inject the scope condition into its `.conditions` array.
|
|
85
85
|
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
@@ -100,19 +100,20 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
100
100
|
// there was no authenticated agent, so anonymous could write any record).
|
|
101
101
|
if (auth.kind === "anonymous")
|
|
102
102
|
return UNAUTH();
|
|
103
|
-
// No-forge
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
103
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.WorkspaceState
|
|
104
|
+
// (record-types slice 2, flair#520) rather than hand-typed literals.
|
|
105
|
+
// "stamp-default" (see record-type-kit.ts's stampAttribution doc): a
|
|
106
|
+
// non-admin agent's workspace record is ALWAYS attributed to the
|
|
107
|
+
// authenticated identity (from the Ed25519 signature), never the body.
|
|
108
|
+
// We do NOT trust `content.agentId` — overwriting it (rather than
|
|
109
|
+
// 403'ing a mismatch) mirrors Presence.post(): "agentId from signature,
|
|
110
|
+
// NOT from body". An admin may write on behalf of another agent
|
|
111
|
+
// (content.agentId honored if present, else defaults to the admin's own
|
|
112
|
+
// id). Internal in-process callers keep whatever agentId they pass.
|
|
113
|
+
// "stamp-default" never denies (no rejection branch for non-admin) —
|
|
114
|
+
// the forbiddenMessage arg is dead for this mode, passed for signature
|
|
115
|
+
// completeness only.
|
|
116
|
+
stampAttribution(auth, content, RECORD_TYPES.WorkspaceState.ownerField, RECORD_TYPES.WorkspaceState.attribution.post, "forbidden: unreachable for stamp-default");
|
|
116
117
|
content.createdAt = new Date().toISOString();
|
|
117
118
|
content.timestamp ||= content.createdAt;
|
|
118
119
|
// attention-plane vocabulary gate (flair#675): `entities`, if present,
|
|
@@ -127,9 +128,13 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
127
128
|
const auth = await this._auth();
|
|
128
129
|
if (auth.kind === "anonymous")
|
|
129
130
|
return UNAUTH();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.WorkspaceState.
|
|
132
|
+
// "validate-strict" (see record-type-kit.ts's stampAttribution doc):
|
|
133
|
+
// rejects a mismatch INCLUDING when agentId is absent (a bare `!==`
|
|
134
|
+
// compare, no truthy guard).
|
|
135
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.WorkspaceState.ownerField, RECORD_TYPES.WorkspaceState.attribution.put, "forbidden: cannot write workspace state for another agent");
|
|
136
|
+
if (attr.denied)
|
|
137
|
+
return attr.denied;
|
|
133
138
|
// attention-plane vocabulary gate (flair#675) — same as post() above.
|
|
134
139
|
const entitiesError = invalidEntitiesResponse(content.entities);
|
|
135
140
|
if (entitiesError)
|
|
@@ -137,15 +142,14 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
137
142
|
return super.put(content);
|
|
138
143
|
}
|
|
139
144
|
async delete(id) {
|
|
140
|
-
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
if (
|
|
144
|
-
return
|
|
145
|
-
if (
|
|
145
|
+
// Dispatch shape shared via record-type-kit.ts's resolveAuthGate — same
|
|
146
|
+
// three-way branch get()/search() above use.
|
|
147
|
+
const gate = await resolveAuthGate(this.getContext?.(), UNAUTH());
|
|
148
|
+
if (gate.kind === "denied")
|
|
149
|
+
return gate.response;
|
|
150
|
+
if (gate.kind === "unfiltered")
|
|
146
151
|
return super.delete(id);
|
|
147
|
-
|
|
148
|
-
// Use super.get(id), NOT this.get(id): the new get() override above 404s
|
|
152
|
+
// Use super.get(id), NOT this.get(id): the get() override above 404s
|
|
149
153
|
// (a truthy Response) for a non-owner id, which would otherwise defeat
|
|
150
154
|
// the `if (!record)` check below and mis-route a genuinely-missing
|
|
151
155
|
// record into the FORBIDDEN branch instead of a clean super.delete(id)
|
|
@@ -153,7 +157,7 @@ export class WorkspaceState extends databases.flair.WorkspaceState {
|
|
|
153
157
|
const record = await super.get(id);
|
|
154
158
|
if (!record)
|
|
155
159
|
return super.delete(id);
|
|
156
|
-
if (record.agentId !==
|
|
160
|
+
if (record.agentId !== gate.agentId) {
|
|
157
161
|
return FORBIDDEN("forbidden: cannot delete workspace state for another agent");
|
|
158
162
|
}
|
|
159
163
|
return super.delete(id);
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 30s timestamp window + a per-(agent,nonce) seen-set pruned to that window.
|
|
17
17
|
*/
|
|
18
18
|
import { databases } from "@harperfast/harper";
|
|
19
|
-
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
19
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
20
20
|
/**
|
|
21
21
|
* Shared Harper user that verified Ed25519 agents resolve to (least-privilege
|
|
22
22
|
* `flair_agent` role), replacing the old admin super_user elevation. Single
|
|
@@ -56,15 +56,14 @@ async function getAdminAgents() {
|
|
|
56
56
|
export async function isAdmin(agentId) {
|
|
57
57
|
return (await getAdminAgents()).has(agentId);
|
|
58
58
|
}
|
|
59
|
-
const HEADER_RE = /^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/;
|
|
60
59
|
async function doVerify(request) {
|
|
61
60
|
const header = request?.headers?.get?.("authorization") ??
|
|
62
61
|
request?.headers?.asObject?.authorization ??
|
|
63
62
|
"";
|
|
64
|
-
const
|
|
65
|
-
if (!
|
|
63
|
+
const parsed = parseTpsEd25519Header(header);
|
|
64
|
+
if (!parsed)
|
|
66
65
|
return null;
|
|
67
|
-
const
|
|
66
|
+
const { agentId, tsRaw, nonce, signatureB64 } = parsed;
|
|
68
67
|
const ts = Number(tsRaw);
|
|
69
68
|
const now = Date.now();
|
|
70
69
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
@@ -2,7 +2,7 @@ import { patchRecord } from "./table-helpers.js";
|
|
|
2
2
|
import { server, databases } from "@harperfast/harper";
|
|
3
3
|
import { getEmbedding } from "./embeddings-provider.js";
|
|
4
4
|
import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
5
|
-
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer } from "./ed25519-auth.js";
|
|
5
|
+
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
6
6
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
7
|
// --- Admin credentials ---
|
|
8
8
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
@@ -230,8 +230,8 @@ server.http(async (request, nextLayer) => {
|
|
|
230
230
|
return nextLayer(request);
|
|
231
231
|
}
|
|
232
232
|
// ── Ed25519 agent auth ────────────────────────────────────────────────────
|
|
233
|
-
const
|
|
234
|
-
if (!
|
|
233
|
+
const parsed = parseTpsEd25519Header(header);
|
|
234
|
+
if (!parsed) {
|
|
235
235
|
// For browser-accessible admin pages, emit `WWW-Authenticate: Basic` so
|
|
236
236
|
// the browser shows a native auth dialog instead of a bare 401 page.
|
|
237
237
|
// JSON API endpoints don't get this — they should keep the structured
|
|
@@ -260,7 +260,7 @@ server.http(async (request, nextLayer) => {
|
|
|
260
260
|
catch { /* frozen headers — annotation on the request object still applies */ }
|
|
261
261
|
return nextLayer(request);
|
|
262
262
|
}
|
|
263
|
-
const
|
|
263
|
+
const { agentId, tsRaw, nonce, signatureB64 } = parsed;
|
|
264
264
|
const ts = Number(tsRaw);
|
|
265
265
|
const now = Date.now();
|
|
266
266
|
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS)
|
|
@@ -42,6 +42,43 @@ export { b64ToArrayBuffer };
|
|
|
42
42
|
* stated "plugin-shaped, config via env" design intent.
|
|
43
43
|
*/
|
|
44
44
|
export const WINDOW_MS = Number(process.env.FLAIR_AGENT_AUTH_WINDOW_MS) || 30_000;
|
|
45
|
+
// ─── Auth header parsing (single shared implementation) ────────────────────
|
|
46
|
+
//
|
|
47
|
+
// One parser for the `Authorization: TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`
|
|
48
|
+
// header, used by all 3 call sites (auth-middleware.ts, agent-auth.ts,
|
|
49
|
+
// Presence.ts) so the grammar and its input bounds can't drift.
|
|
50
|
+
/**
|
|
51
|
+
* Upper bound on the accepted Authorization header length. A well-formed
|
|
52
|
+
* TPS-Ed25519 header (`TPS-Ed25519 <id>:<ts>:<nonce>:<sig>`) is a few hundred
|
|
53
|
+
* chars at most; anything materially larger is malformed. The header is
|
|
54
|
+
* untrusted client input, so we cap its length before running the regex,
|
|
55
|
+
* keeping parse cost bounded regardless of the input's shape.
|
|
56
|
+
*/
|
|
57
|
+
export const MAX_AUTH_HEADER_LEN = 4096;
|
|
58
|
+
/**
|
|
59
|
+
* TPS-Ed25519 auth header grammar.
|
|
60
|
+
*
|
|
61
|
+
* The two colon-delimited text captures use `[^:\s]+` (not `[^:]+`) so they are
|
|
62
|
+
* disjoint from the preceding `\s+`: with no character shared between the two
|
|
63
|
+
* adjacent quantifiers there is exactly one way to split any input, so matching
|
|
64
|
+
* is linear-time on every input (including long, degenerate ones). A real
|
|
65
|
+
* agentId / nonce / signature never contains whitespace, so excluding it is
|
|
66
|
+
* behavior-preserving for well-formed headers.
|
|
67
|
+
*/
|
|
68
|
+
export const TPS_ED25519_HEADER_RE = /^TPS-Ed25519\s+([^:\s]+):(\d+):([^:\s]+):(.+)$/;
|
|
69
|
+
/**
|
|
70
|
+
* Parse a TPS-Ed25519 `Authorization` header value into its fields, or return
|
|
71
|
+
* null if it is over `MAX_AUTH_HEADER_LEN` or doesn't match the grammar.
|
|
72
|
+
* Callers treat null exactly as a header that carries no valid agent auth.
|
|
73
|
+
*/
|
|
74
|
+
export function parseTpsEd25519Header(header) {
|
|
75
|
+
if (!header || header.length > MAX_AUTH_HEADER_LEN)
|
|
76
|
+
return null;
|
|
77
|
+
const m = TPS_ED25519_HEADER_RE.exec(header);
|
|
78
|
+
if (!m)
|
|
79
|
+
return null;
|
|
80
|
+
return { agentId: m[1], tsRaw: m[2], nonce: m[3], signatureB64: m[4] };
|
|
81
|
+
}
|
|
45
82
|
// ─── Replay guard (single shared instance) ─────────────────────────────────
|
|
46
83
|
//
|
|
47
84
|
// nonceSeen is the ONE module-level singleton — the whole point of this
|
|
@@ -87,6 +87,41 @@
|
|
|
87
87
|
import { resolveModelsDir } from "./embeddings-provider.js";
|
|
88
88
|
const LOGICAL_NAME = "default";
|
|
89
89
|
const MODEL_NAME = "nomic-embed-text";
|
|
90
|
+
/**
|
|
91
|
+
* Pooling declaration (HFE 0.5.0+, harper-fabric-embeddings' `init()`
|
|
92
|
+
* `pooling` option). Verification, not override: the native addon has no
|
|
93
|
+
* pooling knob of its own — llama.cpp always pools by whatever the GGUF's
|
|
94
|
+
* own `<arch>.pooling_type` metadata says. Declaring the expectation makes
|
|
95
|
+
* HFE assert it at init and fail loudly on absent/mismatched metadata,
|
|
96
|
+
* instead of a metadata-less or mismatched conversion silently pooling the
|
|
97
|
+
* wrong way (see node_modules/harper-fabric-embeddings/README.md's `init()`
|
|
98
|
+
* table for the exact contract this PR bumps to).
|
|
99
|
+
*
|
|
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
|
+
* `node_modules/.bin/node-llama-cpp inspect gguf
|
|
103
|
+
* ~/.flair/data/models/nomic-embed-text-v1.5.Q4_K_M.gguf` reports
|
|
104
|
+
* `"nomic-bert": { pooling_type: 1 }`, and llama.cpp's
|
|
105
|
+
* `enum llama_pooling_type` maps 1 -> `LLAMA_POOLING_TYPE_MEAN` (0 = none,
|
|
106
|
+
* 1 = mean, 2 = cls, 3 = last, 4 = rank). nomic-embed-text is a
|
|
107
|
+
* NomicBertModel architecture, not Qwen3 (last-token) — flair registers no
|
|
108
|
+
* Qwen3-class embedding model today (the Qwen3-Reranker-0.6B in
|
|
109
|
+
* resources/rerank-provider.ts is a SEPARATE code path that calls
|
|
110
|
+
* node-llama-cpp directly for generative yes/no scoring, never goes through
|
|
111
|
+
* HFE's register()/init(), and has no embedding-pooling context at all — see
|
|
112
|
+
* that file's header). If a Qwen3-class (last-token-pooling) embedding model
|
|
113
|
+
* is ever registered here, it must declare `pooling: "last"`, not "mean".
|
|
114
|
+
*
|
|
115
|
+
* Applies to the bench-only `modelPath` override too (`benchModelPathOverride()`
|
|
116
|
+
* below): `FLAIR_RECALL_HARNESS_MODEL_PATH` lets an operator point this
|
|
117
|
+
* registration at an arbitrary GGUF for a Q4/Q8 bakeoff, and this constant
|
|
118
|
+
* assumes whatever file lands there is still nomic-family (mean-pooling) —
|
|
119
|
+
* true for every bakeoff run to date (same base model, different quant).
|
|
120
|
+
* HFE 0.5.0's verification is exactly the safety net that turns "someone
|
|
121
|
+
* points --model-file at a non-nomic, non-mean-pooling GGUF" into a loud
|
|
122
|
+
* boot-time failure instead of a silently-wrong pooling pass.
|
|
123
|
+
*/
|
|
124
|
+
const EMBEDDING_POOLING = "mean";
|
|
90
125
|
/**
|
|
91
126
|
* BENCH-ONLY escape hatch — NOT a production feature flag, never documented
|
|
92
127
|
* as an operator setting, and never read anywhere else in this codebase.
|
|
@@ -131,7 +166,9 @@ export async function registerEmbeddingsBackend() {
|
|
|
131
166
|
await register({
|
|
132
167
|
logicalName: LOGICAL_NAME,
|
|
133
168
|
kind: "embedding",
|
|
134
|
-
config: modelPath
|
|
169
|
+
config: modelPath
|
|
170
|
+
? { modelPath, pooling: EMBEDDING_POOLING }
|
|
171
|
+
: { modelName: MODEL_NAME, modelsDir: resolveModelsDir(), pooling: EMBEDDING_POOLING },
|
|
135
172
|
});
|
|
136
173
|
}
|
|
137
174
|
catch (err) {
|
|
@@ -65,12 +65,21 @@ function jitProvisionEnabled() {
|
|
|
65
65
|
* XAA's ID-JAG path uses (resources/XAA.ts resolveOrCreatePrincipal) — one
|
|
66
66
|
* identity model, keyed on the IdP subject.
|
|
67
67
|
*
|
|
68
|
+
* `clientId` (flair#718 authorship-provenance) is NOT part of sub resolution —
|
|
69
|
+
* it rides along from the verified token's `client_id` claim (see
|
|
70
|
+
* handleToolCall's stamp-site comment for why `client_id` and never
|
|
71
|
+
* `client_name`) and is copied onto the resolved agent unchanged so downstream
|
|
72
|
+
* write tools (memory_store/memory_update) can thread it into `claimedClient`.
|
|
73
|
+
* Omitted from the returned object entirely when absent — never stamped as
|
|
74
|
+
* `undefined` — so existing callers/tests that don't pass one see the exact
|
|
75
|
+
* same `{ agentId, isAdmin }` shape as before this field existed.
|
|
76
|
+
*
|
|
68
77
|
* Returns:
|
|
69
|
-
* - `{ agentId, isAdmin }` when a Credential maps the sub to an Agent.
|
|
78
|
+
* - `{ agentId, isAdmin, clientId? }` when a Credential maps the sub to an Agent.
|
|
70
79
|
* - null when no Credential maps the sub AND JIT-provisioning is disabled or
|
|
71
80
|
* failed → the handler denies the tool call (sub is unresolvable).
|
|
72
81
|
*/
|
|
73
|
-
export async function resolveAgentFromSub(sub) {
|
|
82
|
+
export async function resolveAgentFromSub(sub, clientId) {
|
|
74
83
|
if (!sub)
|
|
75
84
|
return null;
|
|
76
85
|
// 1. Existing IdP credential → its principalId is the Agent id.
|
|
@@ -87,7 +96,10 @@ export async function resolveAgentFromSub(sub) {
|
|
|
87
96
|
await databases.flair.Credential.put({ ...cred, lastUsedAt: new Date().toISOString() });
|
|
88
97
|
}
|
|
89
98
|
catch { /* non-fatal */ }
|
|
90
|
-
|
|
99
|
+
const resolved = { agentId: String(cred.principalId), isAdmin: await isAgentAdmin(cred.principalId) };
|
|
100
|
+
if (clientId)
|
|
101
|
+
resolved.clientId = clientId;
|
|
102
|
+
return resolved;
|
|
91
103
|
}
|
|
92
104
|
}
|
|
93
105
|
}
|
|
@@ -98,7 +110,10 @@ export async function resolveAgentFromSub(sub) {
|
|
|
98
110
|
try {
|
|
99
111
|
const principalId = await jitProvisionPrincipal(sub);
|
|
100
112
|
// A JIT-provisioned principal is a fresh, non-admin agent by construction.
|
|
101
|
-
|
|
113
|
+
const resolved = { agentId: principalId, isAdmin: false };
|
|
114
|
+
if (clientId)
|
|
115
|
+
resolved.clientId = clientId;
|
|
116
|
+
return resolved;
|
|
102
117
|
}
|
|
103
118
|
catch {
|
|
104
119
|
return null;
|
|
@@ -221,7 +236,16 @@ async function handleToolCall(request, id, params) {
|
|
|
221
236
|
if (!sub) {
|
|
222
237
|
return rpcError(id, -32001, "unauthorized: no verified token subject");
|
|
223
238
|
}
|
|
224
|
-
|
|
239
|
+
// flair#718 authorship-provenance, Sherlock's binding refinement: source
|
|
240
|
+
// the authorship stamp from `client_id` (the server-generated
|
|
241
|
+
// `flair_cl_...` machine id — resources/OAuth.ts, an RS256-verified JWT
|
|
242
|
+
// claim, not a secret) — NEVER `client_name` (a free-text label the client
|
|
243
|
+
// supplied at Dynamic Client Registration time and fully controls). Do NOT
|
|
244
|
+
// "helpfully" switch this to `client_name` for a prettier label; that would
|
|
245
|
+
// turn a server-verified, stable id into caller-forgeable data landing in
|
|
246
|
+
// stored provenance. Absent/non-string client_id → omitted, not invented.
|
|
247
|
+
const clientId = typeof request?.mcp?.client_id === "string" ? request.mcp.client_id : undefined;
|
|
248
|
+
const agent = await resolveAgentFromSub(String(sub), clientId);
|
|
225
249
|
if (!agent) {
|
|
226
250
|
// Sub verified by the AS but not mapped to a flair Agent (and JIT disabled /
|
|
227
251
|
// failed). Deny — do NOT fall back to anonymous or admin.
|
|
@@ -123,13 +123,20 @@ async function memoryStore(agent, args) {
|
|
|
123
123
|
// agentId is the RESOLVED agent — Memory.post also re-checks ownership via
|
|
124
124
|
// resolveAgentAuth, so a mismatched body agentId would 403 anyway; we set it
|
|
125
125
|
// to the verified id so the write is correctly owned.
|
|
126
|
-
|
|
126
|
+
const body = {
|
|
127
127
|
agentId: agent.agentId,
|
|
128
128
|
content: args?.content,
|
|
129
129
|
type: args?.type ?? "session",
|
|
130
130
|
durability: args?.durability ?? "standard",
|
|
131
131
|
tags: args?.tags,
|
|
132
|
-
}
|
|
132
|
+
};
|
|
133
|
+
// flair#718 authorship-provenance: forward the resolved OAuth client_id
|
|
134
|
+
// (never a tool argument — no forging) as claimedClient; Memory.post()
|
|
135
|
+
// folds it into provenance.claimed.client and strips it from the row.
|
|
136
|
+
// Omitted entirely when the token carried no client_id.
|
|
137
|
+
if (agent.clientId)
|
|
138
|
+
body.claimedClient = agent.clientId;
|
|
139
|
+
return unwrap(await h.post(body));
|
|
133
140
|
}
|
|
134
141
|
/**
|
|
135
142
|
* memory_update — id-targeted, dedup-BYPASSED overwrite/version path (memory-
|
|
@@ -175,12 +182,20 @@ async function memoryUpdate(agent, args) {
|
|
|
175
182
|
delete record.validFrom;
|
|
176
183
|
delete record.validTo;
|
|
177
184
|
delete record.archivedAt;
|
|
185
|
+
// flair#718 authorship-provenance — see memoryStore's comment: forward
|
|
186
|
+
// the resolved OAuth client_id (never forgeable via args) so the NEW
|
|
187
|
+
// version's provenance records which client authored this update.
|
|
188
|
+
if (agent.clientId)
|
|
189
|
+
record.claimedClient = agent.clientId;
|
|
178
190
|
h.isCollection = true;
|
|
179
191
|
return unwrap(await h.post(record));
|
|
180
192
|
}
|
|
181
193
|
const merged = { ...existing, content, updatedAt: new Date().toISOString() };
|
|
182
194
|
delete merged.embedding;
|
|
183
195
|
delete merged.embeddingModel;
|
|
196
|
+
// flair#718 authorship-provenance — see memoryStore's comment above.
|
|
197
|
+
if (agent.clientId)
|
|
198
|
+
merged.claimedClient = agent.clientId;
|
|
184
199
|
return unwrap(await h.put(merged));
|
|
185
200
|
}
|
|
186
201
|
async function memoryGet(agent, args) {
|
|
@@ -287,6 +302,38 @@ async function recordUsage(agent, args) {
|
|
|
287
302
|
: typeof args?.memoryId === "string" ? [args.memoryId] : undefined;
|
|
288
303
|
return unwrap(await h.post({ memoryIds, attribution: args?.attribution }));
|
|
289
304
|
}
|
|
305
|
+
/**
|
|
306
|
+
* Verb→tool-name overrides — the three naming quirks where the shipped tool
|
|
307
|
+
* name isn't record-types.ts's default `${toolPrefix}_${verb}` shape (see
|
|
308
|
+
* that module's `mcp` header doc, and RECORD_TYPES.<Table>.mcp itself,
|
|
309
|
+
* record-types slice 3, flair#520). Keyed by table name + verb; consumed by
|
|
310
|
+
* `mcpToolName()` below and by test/unit/mcp-surface-tripwire.test.ts to
|
|
311
|
+
* compute the expected tool name for every declared registry verb.
|
|
312
|
+
*
|
|
313
|
+
* Placement here (not in record-types.ts) is deliberate — per Kern/
|
|
314
|
+
* Sherlock's unanimous slice-3 verdict, the registry declares WHAT is
|
|
315
|
+
* exposed (capability: toolPrefix + verbs); this map declares HOW that
|
|
316
|
+
* capability's tool is actually named (presentation). Coupling names into
|
|
317
|
+
* the registry would mix the policy layer with the presentation layer,
|
|
318
|
+
* exactly what the registry's separation is meant to avoid.
|
|
319
|
+
*/
|
|
320
|
+
export const TOOL_NAME_OVERRIDES = {
|
|
321
|
+
Soul: { store: "soul_set" },
|
|
322
|
+
WorkspaceState: { store: "flair_workspace_set" },
|
|
323
|
+
OrgEvent: { store: "flair_orgevent" },
|
|
324
|
+
};
|
|
325
|
+
/**
|
|
326
|
+
* Resolve the actual `TOOLS` key for a declared (table, verb) pair: the
|
|
327
|
+
* `TOOL_NAME_OVERRIDES` entry if one exists, else the default
|
|
328
|
+
* `${toolPrefix}_${verb}` shape. `toolPrefix` is passed in (rather than
|
|
329
|
+
* looked up from RECORD_TYPES here) so this stays a pure naming function —
|
|
330
|
+
* both this module and the tripwire test call it with the registry's own
|
|
331
|
+
* `toolPrefix` value, keeping the naming rule in exactly one place.
|
|
332
|
+
*/
|
|
333
|
+
export function mcpToolName(table, toolPrefix, verb) {
|
|
334
|
+
const override = TOOL_NAME_OVERRIDES[table]?.[verb];
|
|
335
|
+
return override ?? `${toolPrefix}_${verb}`;
|
|
336
|
+
}
|
|
290
337
|
export const TOOLS = {
|
|
291
338
|
memory_search: {
|
|
292
339
|
def: {
|
|
@@ -489,7 +536,7 @@ export const TOOLS = {
|
|
|
489
536
|
impl: recordUsage,
|
|
490
537
|
},
|
|
491
538
|
};
|
|
492
|
-
/** The tool definitions for a tools/list response (exactly the
|
|
539
|
+
/** The tool definitions for a tools/list response (exactly the 12 curated tools). */
|
|
493
540
|
export function listToolDefs() {
|
|
494
541
|
return Object.values(TOOLS).map((t) => t.def);
|
|
495
542
|
}
|
|
@@ -1,5 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Sanitize an optional, unverified `claimed.*` passthrough value (memory-
|
|
3
|
+
* provenance slice 1 + flair#718 authorship-provenance). Shared by BOTH
|
|
4
|
+
* `claimed.model` and `claimed.client` — same authority level, same
|
|
5
|
+
* discipline, one implementation so they can't drift:
|
|
6
|
+
*
|
|
7
|
+
* 1. Must be a `string` — anything else (number, object, array) is dropped.
|
|
8
|
+
* 2. Control characters (C0 + DEL, `\x00`-`\x1F`,`\x7F`) are stripped —
|
|
9
|
+
* this is caller-supplied, unverified data landing in a stored JSON
|
|
10
|
+
* blob; no newlines/nulls smuggled into logs or downstream renders.
|
|
11
|
+
* 3. Trimmed.
|
|
12
|
+
* 4. Length-capped at 200 chars (truncated, not rejected — a label this
|
|
13
|
+
* long is almost certainly malformed, but the write must never fail
|
|
14
|
+
* because of it).
|
|
15
|
+
* 5. Dropped (returns `undefined`) if empty after the above — an
|
|
16
|
+
* all-control-chars or all-whitespace input is treated as absent, not
|
|
17
|
+
* stamped as `""`.
|
|
18
|
+
*
|
|
19
|
+
* Sherlock flair#718 review: `claimed.model` previously had only a
|
|
20
|
+
* truthiness check (no cap, no sanitize) — folded into this same function
|
|
21
|
+
* "while touching the same code" per that review's non-blocking recommendation.
|
|
22
|
+
*/
|
|
23
|
+
function sanitizeClaim(value) {
|
|
24
|
+
if (typeof value !== "string")
|
|
25
|
+
return undefined;
|
|
26
|
+
const cleaned = value.replace(/[\x00-\x1F\x7F]/g, "").trim();
|
|
27
|
+
if (cleaned.length === 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
return cleaned.length > 200 ? cleaned.slice(0, 200) : cleaned;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* ─── Write-time provenance stamp (memory-provenance slice 1; claimed.client
|
|
33
|
+
* added by flair#718 authorship-provenance) ──────────────────────────────────
|
|
3
34
|
*
|
|
4
35
|
* Foundational capture for an emergent-trust model: every write gets a
|
|
5
36
|
* structured, versioned `provenance` JSON blob recording what the server can
|
|
@@ -8,7 +39,7 @@
|
|
|
8
39
|
*
|
|
9
40
|
* { v: 1,
|
|
10
41
|
* verified: { agentId: <string|null>, timestamp: <ISO string> },
|
|
11
|
-
* claimed?: { model
|
|
42
|
+
* claimed?: { model?: <string>, client?: <string> } }
|
|
12
43
|
*
|
|
13
44
|
* - `verified.agentId` comes from the ALREADY-RESOLVED auth verdict
|
|
14
45
|
* (resolveAgentAuth) — never from anything the caller can forge on the
|
|
@@ -23,10 +54,25 @@
|
|
|
23
54
|
* `embeddingModel = getModelId()` stamp in resources/Memory.ts.
|
|
24
55
|
* - `claimed.model` is an OPTIONAL, UNVERIFIED passthrough: included only
|
|
25
56
|
* when the incoming write payload itself already carries a non-empty
|
|
26
|
-
* string `model` field
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
57
|
+
* string `model` field (sanitized via sanitizeClaim above). Never
|
|
58
|
+
* invented, never defaulted.
|
|
59
|
+
* - `claimed.client` (flair#718) is the SAME kind of OPTIONAL, UNVERIFIED
|
|
60
|
+
* passthrough, sourced from `content.claimedClient` (a deliberately
|
|
61
|
+
* distinct body-field name from the output key — see the write paths in
|
|
62
|
+
* resources/Memory.ts / resources/Relationship.ts, which strip this field
|
|
63
|
+
* from the row after calling buildProvenance so it is NEVER persisted
|
|
64
|
+
* outside this provenance blob). Records WHICH CLIENT authored a write
|
|
65
|
+
* under one shared principal (the personal deployment shape — see
|
|
66
|
+
* docs/auth.md "Deployment shapes"). `claimed` — never `verified` —
|
|
67
|
+
* because this is self-reported by an authenticated principal, not
|
|
68
|
+
* independently corroborated: it MUST grant zero authority anywhere
|
|
69
|
+
* (never read for access control, attribution weighting, or dedup
|
|
70
|
+
* decisions — Sherlock flair#718 binding refinement). On the native /mcp
|
|
71
|
+
* OAuth path, the caller is required to source this from the verified
|
|
72
|
+
* `client_id` token claim, never the user-controlled `client_name` — see
|
|
73
|
+
* resources/mcp-handler.ts's handleToolCall for that stamp site.
|
|
74
|
+
* - The `claimed` key is omitted entirely (not stamped as `{}`) when both
|
|
75
|
+
* `model` and `client` are absent.
|
|
30
76
|
*
|
|
31
77
|
* Originally introduced in resources/Memory.ts (Memory.post()/Memory.put());
|
|
32
78
|
* extracted here so Relationship.ts (and any future write path) can reuse the
|
|
@@ -43,8 +89,14 @@ export function buildProvenance(auth, createdAt, content) {
|
|
|
43
89
|
timestamp: createdAt,
|
|
44
90
|
},
|
|
45
91
|
};
|
|
46
|
-
|
|
47
|
-
|
|
92
|
+
const model = sanitizeClaim(content?.model);
|
|
93
|
+
const client = sanitizeClaim(content?.claimedClient);
|
|
94
|
+
if (model !== undefined || client !== undefined) {
|
|
95
|
+
provenance.claimed = {};
|
|
96
|
+
if (model !== undefined)
|
|
97
|
+
provenance.claimed.model = model;
|
|
98
|
+
if (client !== undefined)
|
|
99
|
+
provenance.claimed.client = client;
|
|
48
100
|
}
|
|
49
101
|
return JSON.stringify(provenance);
|
|
50
102
|
}
|