@tpsdev-ai/flair 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -6
- package/dist/cli.js +227 -71
- package/dist/resources/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- package/dist/resources/in-process-api.js +382 -0
- package/dist/resources/in-process.js +9 -0
- package/dist/resources/mcp-handler.js +14 -4
- package/dist/resources/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- package/docs/deployment-shapes.md +35 -0
- package/docs/deployment.md +2 -2
- package/docs/embedding-in-a-harper-app.md +174 -75
- package/docs/hosted-on-fabric.md +203 -0
- package/docs/secrets-and-keys.md +4 -4
- package/docs/standalone-local.md +243 -0
- package/docs/upgrade.md +7 -3
- package/package.json +7 -1
|
@@ -4,6 +4,7 @@ import { getEmbedding } from "./embeddings-provider.js";
|
|
|
4
4
|
import { isAdmin, FLAIR_AGENT_USERNAME } from "./agent-auth.js";
|
|
5
5
|
import { WINDOW_MS, isNonceReplay, recordNonce, importEd25519Key, b64ToArrayBuffer, parseTpsEd25519Header } from "./ed25519-auth.js";
|
|
6
6
|
import { resolveReadScope } from "./memory-read-scope.js";
|
|
7
|
+
import { isForbiddenOwnerMutation, resolveGuardedRecord } from "./record-owner-guard.js";
|
|
7
8
|
// --- Admin credentials ---
|
|
8
9
|
// Admin auth is sourced exclusively from Harper's own environment variables
|
|
9
10
|
// (HDB_ADMIN_PASSWORD / FLAIR_ADMIN_PASSWORD). No filesystem token file.
|
|
@@ -350,6 +351,37 @@ server.http(async (request, nextLayer) => {
|
|
|
350
351
|
// ── Server-side permission guards ──────────────────────────────────────────
|
|
351
352
|
const method = request.method.toUpperCase();
|
|
352
353
|
const isMutation = method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE";
|
|
354
|
+
// ── THE record-ownership rule, for every table, on every mutating verb ─────
|
|
355
|
+
//
|
|
356
|
+
// One enforcement point rather than one per resource. See
|
|
357
|
+
// resources/record-owner-guard.ts for why this is not written into each
|
|
358
|
+
// resource's put(): Harper maps verbs to methods one-to-one, so a rule living
|
|
359
|
+
// in put() is enforced on PUT alone, and nearly every resource wrote its rules
|
|
360
|
+
// there. Doing this per-resource would be N chances to get one wrong and would
|
|
361
|
+
// still leave the next resource broken by default.
|
|
362
|
+
//
|
|
363
|
+
// Ownership is read from the STORED record named by the path — never from the
|
|
364
|
+
// request body, which is the caller's claim about who owns the row and was how
|
|
365
|
+
// a body that simply omitted the field passed unchecked.
|
|
366
|
+
//
|
|
367
|
+
// Deliberately scoped to records that ALREADY EXIST: creation is left to each
|
|
368
|
+
// resource's own no-forge attribution. That keeps this incapable of breaking a
|
|
369
|
+
// create, a self-write, or a legitimate cross-agent field like MemoryGrant's
|
|
370
|
+
// granteeId — it can only narrow mutation of another agent's stored row.
|
|
371
|
+
if (isMutation && !request.tpsAgentIsAdmin) {
|
|
372
|
+
const guarded = resolveGuardedRecord(url.pathname);
|
|
373
|
+
if (guarded) {
|
|
374
|
+
try {
|
|
375
|
+
const record = await databases.flair[guarded.table]?.get(guarded.id);
|
|
376
|
+
if (isForbiddenOwnerMutation(record, guarded.ownerField, agentId)) {
|
|
377
|
+
return new Response(JSON.stringify({
|
|
378
|
+
error: `forbidden: cannot modify ${guarded.table} owned by another principal`,
|
|
379
|
+
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
catch { /* unreadable row → fall through to the resource's own rules */ }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
353
385
|
if (isMutation) {
|
|
354
386
|
// OrgEvent: authorId must match authenticated agent
|
|
355
387
|
if ((url.pathname === "/OrgEvent" || url.pathname.startsWith("/OrgEvent/")) &&
|
|
@@ -419,19 +451,59 @@ server.http(async (request, nextLayer) => {
|
|
|
419
451
|
catch { }
|
|
420
452
|
}
|
|
421
453
|
}
|
|
422
|
-
// Soul
|
|
423
|
-
|
|
454
|
+
// Soul mutations: only the owner or an admin may write a soul entry.
|
|
455
|
+
//
|
|
456
|
+
// This guard had two independent holes, either of which alone let one agent
|
|
457
|
+
// rewrite another's identity data:
|
|
458
|
+
//
|
|
459
|
+
// 1. THE VERB LIST enumerated PUT and POST only. Its three siblings above
|
|
460
|
+
// (OrgEvent, WorkspaceState, Memory) all include PATCH, and Memory
|
|
461
|
+
// includes DELETE; this one did neither. Harper routes PATCH to a
|
|
462
|
+
// resource method that carries no ownership check of its own
|
|
463
|
+
// (Soul.ts's enforceWriteAuth covers post()/put()), and DELETE reached
|
|
464
|
+
// the table with no per-record check at all. Both were live.
|
|
465
|
+
//
|
|
466
|
+
// 2. IT COMPARED THE BODY, not the target. `body.agentId` is the owner
|
|
467
|
+
// the CALLER claims, and the check only fired when that field was
|
|
468
|
+
// present and mismatched — so a body omitting it, which a partial
|
|
469
|
+
// write naturally does, was compared against nothing and passed
|
|
470
|
+
// whatever record the URL pointed at. The resource-level check behind
|
|
471
|
+
// it is "validate-truthy" attribution, which by design also passes an
|
|
472
|
+
// ABSENT owner field, so nothing downstream caught it either. (A PUT
|
|
473
|
+
// happened to fail anyway, but on `agentId: String!` schema
|
|
474
|
+
// validation — a 400 for the wrong reason, not an authorization
|
|
475
|
+
// decision, and not a defence to rely on.)
|
|
476
|
+
//
|
|
477
|
+
// Closing one hole leaves the other reachable through the remaining verbs,
|
|
478
|
+
// so both are closed here: every mutating verb is covered, and ownership is
|
|
479
|
+
// resolved from the STORED RECORD named by the path. That is the same shape
|
|
480
|
+
// as the Memory ownership guard below, which is the resource in this file
|
|
481
|
+
// that already had it right — worth copying rather than reinventing.
|
|
482
|
+
//
|
|
483
|
+
// The path test stays `startsWith("/Soul")` so sibling routes keep the
|
|
484
|
+
// body check they already had; the record lookup is scoped to the real
|
|
485
|
+
// `/Soul/<id>` collection so it never resolves an unrelated id.
|
|
486
|
+
if (url.pathname.startsWith("/Soul") &&
|
|
487
|
+
(method === "POST" || method === "PUT" || method === "PATCH" || method === "DELETE")) {
|
|
424
488
|
if (!request.tpsAgentIsAdmin) {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
489
|
+
// (a) A PRESENT, mismatched body agentId is a forged attribution.
|
|
490
|
+
if (method !== "DELETE") {
|
|
491
|
+
let bodyAgentId = null;
|
|
492
|
+
try {
|
|
493
|
+
const clone = request.clone();
|
|
494
|
+
const body = await clone.json();
|
|
495
|
+
bodyAgentId = body?.agentId ?? null;
|
|
496
|
+
}
|
|
497
|
+
catch { }
|
|
498
|
+
if (bodyAgentId && bodyAgentId !== agentId) {
|
|
499
|
+
return new Response(JSON.stringify({ error: "forbidden: non-admin cannot modify another agent's soul" }), { status: 403 });
|
|
500
|
+
}
|
|
434
501
|
}
|
|
502
|
+
// (b) The owner of the record actually being written — the half that
|
|
503
|
+
// catches a body simply leaving agentId out — is now the shared
|
|
504
|
+
// record-ownership rule at the top of this block, which applies it to
|
|
505
|
+
// every table on every mutating verb. Deliberately NOT repeated here:
|
|
506
|
+
// one condition enforced in two places is how the two drift apart.
|
|
435
507
|
}
|
|
436
508
|
}
|
|
437
509
|
// Memory promotion guard: only admin can approve or set durability=permanent
|
|
@@ -453,21 +525,22 @@ server.http(async (request, nextLayer) => {
|
|
|
453
525
|
catch { }
|
|
454
526
|
}
|
|
455
527
|
}
|
|
456
|
-
// Memory
|
|
528
|
+
// Memory DELETE: permanent memories are admin-only to purge.
|
|
529
|
+
//
|
|
530
|
+
// The OWNERSHIP half of this guard — which was the model the shared
|
|
531
|
+
// record-ownership rule above was built from, and the only one in this file
|
|
532
|
+
// that already covered PATCH — now lives there and covers every table. What
|
|
533
|
+
// remains here is the part that is NOT about ownership: durability. An agent
|
|
534
|
+
// owning a permanent memory still may not purge it.
|
|
457
535
|
if (((url.pathname === "/Memory" || url.pathname.startsWith("/Memory/") || url.pathname === "/memory" || url.pathname.startsWith("/memory/"))) &&
|
|
458
|
-
|
|
536
|
+
method === "DELETE") {
|
|
459
537
|
if (!request.tpsAgentIsAdmin) {
|
|
460
538
|
try {
|
|
461
539
|
const pathParts = url.pathname.split("/").filter(Boolean);
|
|
462
540
|
const memId = pathParts[1] ? decodeURIComponent(pathParts[1]) : null;
|
|
463
541
|
if (memId) {
|
|
464
542
|
const record = await databases.flair.Memory.get(memId);
|
|
465
|
-
if (record
|
|
466
|
-
return new Response(JSON.stringify({
|
|
467
|
-
error: `forbidden: cannot modify memory owned by ${record.agentId}`
|
|
468
|
-
}), { status: 403 });
|
|
469
|
-
}
|
|
470
|
-
if (method === "DELETE" && record?.durability === "permanent") {
|
|
543
|
+
if (record?.durability === "permanent") {
|
|
471
544
|
return new Response(JSON.stringify({
|
|
472
545
|
error: "forbidden: only admins can purge permanent memories"
|
|
473
546
|
}), { status: 403 });
|
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ─── Public in-process API (flair#956) ───────────────────────────────────────
|
|
3
|
+
*
|
|
4
|
+
* The facade that hides four internal implementation details a Harper engineer
|
|
5
|
+
* should never have to learn:
|
|
6
|
+
*
|
|
7
|
+
* 1. A deep import path into our dist/
|
|
8
|
+
* 2. That server.resources is keyed by REST path with no leading slash
|
|
9
|
+
* 3. That the registry entry wraps the class in .Resource
|
|
10
|
+
* 4. That creates need collectionResource() while reads do not
|
|
11
|
+
*
|
|
12
|
+
* And collapses the agentId double-pass (context + body) into one.
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { Flair } from "@tpsdev-ai/flair";
|
|
16
|
+
* const flair = new Flair(server);
|
|
17
|
+
* const planner = flair.as("planner");
|
|
18
|
+
* await planner.memory.write("deploy runs at 0200 UTC");
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* The facade does NOT hide the security boundary. In-process identity is
|
|
22
|
+
* asserted, not verified — co-location IS the grant. flair.as(id) requires a
|
|
23
|
+
* non-empty id (runtime throw). flair.admin and flair.internal are separate,
|
|
24
|
+
* greppable properties. The docs say plainly: build the context from your own
|
|
25
|
+
* server-side state, never from request data.
|
|
26
|
+
*
|
|
27
|
+
* ── Internal implementation ─────────────────────────────────────────────────
|
|
28
|
+
* Every operation delegates to the existing primitives in ./in-process.js
|
|
29
|
+
* (agentContext, adminContext, internalContext, collectionResource). The facade
|
|
30
|
+
* is additive — existing code using the raw seam continues to work.
|
|
31
|
+
*/
|
|
32
|
+
import { agentContext, adminContext, internalContext, collectionResource, InProcessContextError, } from "./in-process.js";
|
|
33
|
+
// ─── Re-export for the "./server" entry point ────────────────────────────────
|
|
34
|
+
export { agentContext, adminContext, internalContext, collectionResource, InProcessContextError };
|
|
35
|
+
// ─── Resource resolution ─────────────────────────────────────────────────────
|
|
36
|
+
/**
|
|
37
|
+
* Resolve a Flair resource class from the Harper server registry.
|
|
38
|
+
* Throws with a helpful message listing available resources if not found.
|
|
39
|
+
*/
|
|
40
|
+
function resolveResource(server, name) {
|
|
41
|
+
const entry = server.resources.get?.(name) ?? server.resources.getMatch?.(name);
|
|
42
|
+
if (!entry?.Resource) {
|
|
43
|
+
const keys = [...server.resources.keys()].sort();
|
|
44
|
+
const available = keys.length > 0 ? keys.join(", ") : "(none)";
|
|
45
|
+
throw new Error(`Flair is not loaded in this Harper instance.\n` +
|
|
46
|
+
`The '${name}' resource was not found in the registry.\n` +
|
|
47
|
+
`Available: [${available}]\n` +
|
|
48
|
+
`Make sure @tpsdev-ai/flair is installed as a component of this instance.`);
|
|
49
|
+
}
|
|
50
|
+
return entry.Resource;
|
|
51
|
+
}
|
|
52
|
+
// ─── AgentHandle ─────────────────────────────────────────────────────────────
|
|
53
|
+
/**
|
|
54
|
+
* A handle that carries agent identity and scopes every operation to that agent.
|
|
55
|
+
*
|
|
56
|
+
* Returned by {@link Flair.as}. The agentId is validated at construction time
|
|
57
|
+
* (runtime, not types) — missing, empty, blank, or non-string throws
|
|
58
|
+
* {@link InProcessContextError}.
|
|
59
|
+
*
|
|
60
|
+
* **Security:** in-process identity is asserted, not verified. Build the
|
|
61
|
+
* agentId from your own server-side state, never from request data.
|
|
62
|
+
*/
|
|
63
|
+
export class AgentHandle {
|
|
64
|
+
agentId;
|
|
65
|
+
#server;
|
|
66
|
+
#ctx;
|
|
67
|
+
constructor(server, agentId) {
|
|
68
|
+
this.#server = server;
|
|
69
|
+
this.agentId = agentId;
|
|
70
|
+
// Throws InProcessContextError on missing/empty/blank id — see
|
|
71
|
+
// resources/in-process.ts's safety-design block for why this is
|
|
72
|
+
// not merely defensive.
|
|
73
|
+
this.#ctx = agentContext(agentId);
|
|
74
|
+
}
|
|
75
|
+
/** Memory operations scoped to this agent. */
|
|
76
|
+
get memory() {
|
|
77
|
+
return new AgentMemory(this.#server, this.#ctx, this.agentId);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Semantic search scoped to this agent.
|
|
81
|
+
*
|
|
82
|
+
* ```ts
|
|
83
|
+
* const hits = await planner.recall("deploy schedule", { limit: 5 });
|
|
84
|
+
* ```
|
|
85
|
+
*/
|
|
86
|
+
async recall(query, opts) {
|
|
87
|
+
const Cls = resolveResource(this.#server, "SemanticSearch");
|
|
88
|
+
const h = new Cls(undefined, this.#ctx);
|
|
89
|
+
const body = { q: query, limit: opts?.limit ?? 5 };
|
|
90
|
+
if (opts?.includeTrust === true)
|
|
91
|
+
body.includeTrust = true;
|
|
92
|
+
if (opts?.abstain === true)
|
|
93
|
+
body.abstain = true;
|
|
94
|
+
if (opts?.scoring)
|
|
95
|
+
body.scoring = opts.scoring;
|
|
96
|
+
if (opts?.minScore !== undefined)
|
|
97
|
+
body.minScore = opts.minScore;
|
|
98
|
+
if (opts?.since)
|
|
99
|
+
body.since = opts.since;
|
|
100
|
+
if (opts?.asOf)
|
|
101
|
+
body.asOf = opts.asOf;
|
|
102
|
+
if (opts?.tag)
|
|
103
|
+
body.tag = opts.tag;
|
|
104
|
+
if (opts?.subject)
|
|
105
|
+
body.subject = opts.subject;
|
|
106
|
+
if (opts?.subjects)
|
|
107
|
+
body.subjects = opts.subjects;
|
|
108
|
+
return unwrap(await h.post(body));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// ─── AgentMemory (per-agent memory operations) ───────────────────────────────
|
|
112
|
+
class AgentMemory {
|
|
113
|
+
#server;
|
|
114
|
+
#ctx;
|
|
115
|
+
#agentId;
|
|
116
|
+
constructor(server, ctx, agentId) {
|
|
117
|
+
this.#server = server;
|
|
118
|
+
this.#ctx = ctx;
|
|
119
|
+
this.#agentId = agentId;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Write a memory as this agent.
|
|
123
|
+
*
|
|
124
|
+
* The agentId is stamped from the handle's context — the caller never
|
|
125
|
+
* passes it, and any agentId in opts is overwritten. This collapses the
|
|
126
|
+
* double-pass (context + body) into one.
|
|
127
|
+
*/
|
|
128
|
+
async write(content, opts) {
|
|
129
|
+
const Cls = resolveResource(this.#server, "Memory");
|
|
130
|
+
const h = await collectionResource(Cls, this.#ctx);
|
|
131
|
+
const body = {
|
|
132
|
+
agentId: this.#agentId,
|
|
133
|
+
content,
|
|
134
|
+
};
|
|
135
|
+
if (opts?.durability)
|
|
136
|
+
body.durability = opts.durability;
|
|
137
|
+
if (opts?.visibility)
|
|
138
|
+
body.visibility = opts.visibility;
|
|
139
|
+
if (opts?.tags)
|
|
140
|
+
body.tags = opts.tags;
|
|
141
|
+
if (opts?.type)
|
|
142
|
+
body.type = opts.type;
|
|
143
|
+
if (opts?.id)
|
|
144
|
+
body.id = opts.id;
|
|
145
|
+
return unwrap(await h.post(body));
|
|
146
|
+
}
|
|
147
|
+
/** Get a memory by id, scoped to this agent. */
|
|
148
|
+
async get(id) {
|
|
149
|
+
const Cls = resolveResource(this.#server, "Memory");
|
|
150
|
+
const h = new Cls(undefined, this.#ctx);
|
|
151
|
+
return unwrap(await h.get(id));
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Search memories scoped to this agent.
|
|
155
|
+
*
|
|
156
|
+
* Delegates to Memory.search() which applies the agent's read scope
|
|
157
|
+
* (own memories + granted owners' shared memories).
|
|
158
|
+
*/
|
|
159
|
+
async search(opts) {
|
|
160
|
+
const Cls = resolveResource(this.#server, "Memory");
|
|
161
|
+
const h = new Cls(undefined, this.#ctx);
|
|
162
|
+
const conditions = [];
|
|
163
|
+
if (opts?.tags) {
|
|
164
|
+
for (const tag of opts.tags) {
|
|
165
|
+
conditions.push({ search_attribute: "tags", search_type: "contains", search_value: tag });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
if (opts?.type) {
|
|
169
|
+
conditions.push({ search_attribute: "type", search_type: "equals", search_value: opts.type });
|
|
170
|
+
}
|
|
171
|
+
if (opts?.durability) {
|
|
172
|
+
conditions.push({ search_attribute: "durability", search_type: "equals", search_value: opts.durability });
|
|
173
|
+
}
|
|
174
|
+
if (opts?.visibility) {
|
|
175
|
+
conditions.push({ search_attribute: "visibility", search_type: "equals", search_value: opts.visibility });
|
|
176
|
+
}
|
|
177
|
+
const query = conditions.length > 0 ? { conditions, operator: "and" } : undefined;
|
|
178
|
+
return unwrap(await h.search(query));
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// ─── AdminHandle ─────────────────────────────────────────────────────────────
|
|
182
|
+
/**
|
|
183
|
+
* Flair-admin operations — unfiltered reads, cross-agent writes.
|
|
184
|
+
*
|
|
185
|
+
* **This is a root shell.** Every call site is greppable via
|
|
186
|
+
* `git grep "flair.admin"`. Use for provisioning and maintenance only,
|
|
187
|
+
* never as a request handler's default.
|
|
188
|
+
*
|
|
189
|
+
* The admin agentId is validated at construction time (same guard as
|
|
190
|
+
* {@link AgentHandle}).
|
|
191
|
+
*/
|
|
192
|
+
export class AdminHandle {
|
|
193
|
+
agentId;
|
|
194
|
+
#server;
|
|
195
|
+
#ctx;
|
|
196
|
+
constructor(server, agentId) {
|
|
197
|
+
this.#server = server;
|
|
198
|
+
this.agentId = agentId;
|
|
199
|
+
this.#ctx = adminContext(agentId);
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Register an agent through the Agent resource (full Principal shape).
|
|
203
|
+
*
|
|
204
|
+
* ```ts
|
|
205
|
+
* await flair.admin.registerAgent("planner", { publicKey: "pending" });
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
async registerAgent(id, opts) {
|
|
209
|
+
const Cls = resolveResource(this.#server, "Agent");
|
|
210
|
+
const h = await collectionResource(Cls, this.#ctx);
|
|
211
|
+
const body = {
|
|
212
|
+
id,
|
|
213
|
+
name: id,
|
|
214
|
+
displayName: opts?.displayName ?? id,
|
|
215
|
+
publicKey: opts?.publicKey ?? "pending",
|
|
216
|
+
runtime: opts?.runtime ?? "headless",
|
|
217
|
+
};
|
|
218
|
+
if (opts?.admin === true)
|
|
219
|
+
body.admin = true;
|
|
220
|
+
return unwrap(await h.post(body));
|
|
221
|
+
}
|
|
222
|
+
/** Memory operations with admin authority (unfiltered reads, cross-agent writes). */
|
|
223
|
+
get memory() {
|
|
224
|
+
return new AdminMemory(this.#server, this.#ctx, this.agentId);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
// ─── AdminMemory ─────────────────────────────────────────────────────────────
|
|
228
|
+
class AdminMemory {
|
|
229
|
+
#server;
|
|
230
|
+
#ctx;
|
|
231
|
+
#agentId;
|
|
232
|
+
constructor(server, ctx, agentId) {
|
|
233
|
+
this.#server = server;
|
|
234
|
+
this.#ctx = ctx;
|
|
235
|
+
this.#agentId = agentId;
|
|
236
|
+
}
|
|
237
|
+
/** Read any memory by id, unfiltered. */
|
|
238
|
+
async get(id) {
|
|
239
|
+
const Cls = resolveResource(this.#server, "Memory");
|
|
240
|
+
const h = new Cls(undefined, this.#ctx);
|
|
241
|
+
return unwrap(await h.get(id));
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Write a memory attributed to another agent.
|
|
245
|
+
*
|
|
246
|
+
* ```ts
|
|
247
|
+
* await flair.admin.memory.write("researcher", "provisioned memory", { visibility: "shared" });
|
|
248
|
+
* ```
|
|
249
|
+
*/
|
|
250
|
+
async write(asAgentId, content, opts) {
|
|
251
|
+
const Cls = resolveResource(this.#server, "Memory");
|
|
252
|
+
// Use adminContext for the acting admin, but stamp the target agentId
|
|
253
|
+
// on the body so the memory is owned by the target agent.
|
|
254
|
+
const h = await collectionResource(Cls, this.#ctx);
|
|
255
|
+
const body = {
|
|
256
|
+
agentId: asAgentId,
|
|
257
|
+
content,
|
|
258
|
+
};
|
|
259
|
+
if (opts?.durability)
|
|
260
|
+
body.durability = opts.durability;
|
|
261
|
+
if (opts?.visibility)
|
|
262
|
+
body.visibility = opts.visibility;
|
|
263
|
+
if (opts?.tags)
|
|
264
|
+
body.tags = opts.tags;
|
|
265
|
+
if (opts?.type)
|
|
266
|
+
body.type = opts.type;
|
|
267
|
+
if (opts?.id)
|
|
268
|
+
body.id = opts.id;
|
|
269
|
+
return unwrap(await h.post(body));
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// ─── InternalHandle ──────────────────────────────────────────────────────────
|
|
273
|
+
/**
|
|
274
|
+
* Trusted, unattributed, unfiltered operations — Flair's `internal` verdict.
|
|
275
|
+
*
|
|
276
|
+
* Reads see every agent's private records; writes are owned by nobody.
|
|
277
|
+
* This exists for work that is genuinely infrastructure: provisioning a
|
|
278
|
+
* principal, a migration, a maintenance sweep.
|
|
279
|
+
*
|
|
280
|
+
* Every call site is greppable via `git grep "flair.internal"`.
|
|
281
|
+
*/
|
|
282
|
+
export class InternalHandle {
|
|
283
|
+
#server;
|
|
284
|
+
#ctx;
|
|
285
|
+
constructor(server) {
|
|
286
|
+
this.#server = server;
|
|
287
|
+
this.#ctx = internalContext();
|
|
288
|
+
}
|
|
289
|
+
/** Raw Agent table access for provisioning. */
|
|
290
|
+
get agentTable() {
|
|
291
|
+
return new InternalAgentTable(this.#server, this.#ctx);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
// ─── InternalAgentTable ──────────────────────────────────────────────────────
|
|
295
|
+
class InternalAgentTable {
|
|
296
|
+
#server;
|
|
297
|
+
#ctx;
|
|
298
|
+
constructor(server, ctx) {
|
|
299
|
+
this.#server = server;
|
|
300
|
+
this.#ctx = ctx;
|
|
301
|
+
}
|
|
302
|
+
/** Write directly to the Agent resource (bypasses admin gate via internal context). */
|
|
303
|
+
async put(record) {
|
|
304
|
+
const Cls = resolveResource(this.#server, "Agent");
|
|
305
|
+
const h = await collectionResource(Cls, this.#ctx);
|
|
306
|
+
return unwrap(await h.post(record));
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// ─── Flair (the facade) ──────────────────────────────────────────────────────
|
|
310
|
+
/**
|
|
311
|
+
* The public in-process API for Flair embedded in a Harper app.
|
|
312
|
+
*
|
|
313
|
+
* One handle per Harper instance. Resolves resources lazily on first use.
|
|
314
|
+
*
|
|
315
|
+
* ```ts
|
|
316
|
+
* import { Flair } from "@tpsdev-ai/flair";
|
|
317
|
+
* const flair = new Flair(server);
|
|
318
|
+
* const planner = flair.as("planner");
|
|
319
|
+
* await planner.memory.write("deploy runs at 0200 UTC");
|
|
320
|
+
* ```
|
|
321
|
+
*
|
|
322
|
+
* **Security:** In-process identity is asserted, not verified — co-location
|
|
323
|
+
* IS the grant. Build the agentId from your own server-side state, never
|
|
324
|
+
* from request data. `flair.as(id)` requires a non-empty id (runtime throw).
|
|
325
|
+
* `flair.admin` and `flair.internal` are separate, greppable properties for
|
|
326
|
+
* deliberate escalation.
|
|
327
|
+
*/
|
|
328
|
+
export class Flair {
|
|
329
|
+
#server;
|
|
330
|
+
constructor(server) {
|
|
331
|
+
this.#server = server;
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Return a handle that acts as the given agent.
|
|
335
|
+
*
|
|
336
|
+
* The agentId is runtime-validated: missing, empty, blank, or non-string
|
|
337
|
+
* throws {@link InProcessContextError}. Build it from your own server-side
|
|
338
|
+
* state, never from request data.
|
|
339
|
+
*/
|
|
340
|
+
as(agentId) {
|
|
341
|
+
return new AgentHandle(this.#server, agentId);
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Admin operations — unfiltered reads, cross-agent writes.
|
|
345
|
+
*
|
|
346
|
+
* **This is a root shell.** Every call site is greppable via
|
|
347
|
+
* `git grep "flair.admin"`. Use for provisioning and maintenance only.
|
|
348
|
+
*/
|
|
349
|
+
get admin() {
|
|
350
|
+
// AdminHandle requires an agentId for attribution. We use a sentinel
|
|
351
|
+
// that makes the admin identity visible in audit logs. The caller
|
|
352
|
+
// should use a real admin agent id when possible.
|
|
353
|
+
return new AdminHandle(this.#server, "_admin");
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* Internal operations — trusted, unattributed, unfiltered.
|
|
357
|
+
*
|
|
358
|
+
* Every call site is greppable via `git grep "flair.internal"`.
|
|
359
|
+
* Use for infrastructure work only: provisioning, migrations, maintenance.
|
|
360
|
+
*/
|
|
361
|
+
get internal() {
|
|
362
|
+
return new InternalHandle(this.#server);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
366
|
+
/**
|
|
367
|
+
* Unwrap a handler return value into a plain object.
|
|
368
|
+
* Handlers may return a `Response` (the 401/403/400 guards) — surface its
|
|
369
|
+
* JSON body so the caller sees the structured error rather than an opaque object.
|
|
370
|
+
*/
|
|
371
|
+
async function unwrap(value) {
|
|
372
|
+
if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
|
|
373
|
+
try {
|
|
374
|
+
const body = await value.json();
|
|
375
|
+
return { error: body?.error ?? "request failed", status: value.status, ...body };
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return { error: "request failed", status: value.status };
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return value;
|
|
382
|
+
}
|
|
@@ -208,6 +208,15 @@ export function internalContext() {
|
|
|
208
208
|
* Reads do not need this — `Cls.get(id, context)` and `Cls.search(query,
|
|
209
209
|
* context)` (Harper's static resource methods) already thread the context and
|
|
210
210
|
* start their own transaction.
|
|
211
|
+
*
|
|
212
|
+
* **They do still need the CONTEXT.** Only the collection binding is
|
|
213
|
+
* unnecessary for a read, not the identity. `Cls.search(query)` with the second
|
|
214
|
+
* argument left off does not read "as nobody" — outside a Harper request scope
|
|
215
|
+
* (a boot hook, a timer, a queue worker, a detached promise) it resolves to the
|
|
216
|
+
* same trusted `internal` verdict this module exists to keep out of reach, and
|
|
217
|
+
* returns every agent's private records unfiltered. On that path the resource's
|
|
218
|
+
* own `allow*` gate is not consulted at all, so nothing else stands in the way.
|
|
219
|
+
* Pass the context to every call, read or write (flair#936).
|
|
211
220
|
*/
|
|
212
221
|
export async function collectionResource(Cls, context) {
|
|
213
222
|
if (context == null || typeof context !== "object") {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
import { databases } from "harper";
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
28
28
|
import { TOOLS, listToolDefs } from "./mcp-tools.js";
|
|
29
|
+
import { agentRecordIsAdmin } from "./agent-admin.js";
|
|
29
30
|
// The MCP protocol revision we implement (initialize handshake).
|
|
30
31
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
31
32
|
const JSON_HEADERS = { "content-type": "application/json" };
|
|
@@ -157,14 +158,23 @@ async function jitProvisionPrincipal(sub) {
|
|
|
157
158
|
return principalId;
|
|
158
159
|
}
|
|
159
160
|
/**
|
|
160
|
-
* Is this Principal a flair admin?
|
|
161
|
-
*
|
|
162
|
-
*
|
|
161
|
+
* Is this Principal a flair admin? A MCP-OAuth agent is NON-admin unless an
|
|
162
|
+
* operator has explicitly marked its Agent record admin — the MCP surface never
|
|
163
|
+
* elevates on its own.
|
|
164
|
+
*
|
|
165
|
+
* flair#941: this used to OR the two admin fields together while the primary
|
|
166
|
+
* HTTP gate (resources/agent-auth.ts's isAdmin) read only `role`, so the same
|
|
167
|
+
* record could be an administrator here and an ordinary agent there. It now
|
|
168
|
+
* resolves through the one shared predicate, so both surfaces answer
|
|
169
|
+
* identically. A record carrying `admin: true` alone — which no flair write
|
|
170
|
+
* path produces, and which was never an admin on the HTTP gate — is no longer
|
|
171
|
+
* an admin here either; see resources/agent-admin.ts for the remedy. This
|
|
172
|
+
* surface is gated behind FLAIR_MCP_OAUTH and is default-OFF.
|
|
163
173
|
*/
|
|
164
174
|
async function isAgentAdmin(principalId) {
|
|
165
175
|
try {
|
|
166
176
|
const agent = await databases.flair.Agent.get(principalId);
|
|
167
|
-
return agent
|
|
177
|
+
return agentRecordIsAdmin(agent);
|
|
168
178
|
}
|
|
169
179
|
catch {
|
|
170
180
|
return false;
|
|
@@ -2,7 +2,12 @@ function presenceDelegationContext(auth) {
|
|
|
2
2
|
const agentAuth = auth.kind === "agent"
|
|
3
3
|
? { agentId: auth.agentId, isAdmin: auth.isAdmin }
|
|
4
4
|
: { agentId: "internal", isAdmin: true };
|
|
5
|
-
|
|
5
|
+
// `__flairInternal` marks the `internal` branch as DELIBERATE (flair#936) —
|
|
6
|
+
// an "internal" verdict relayed here is a trusted in-process call this file
|
|
7
|
+
// has already established, not a caller who forgot to pass a context. It is
|
|
8
|
+
// read only by resources/agent-auth.ts's accidental-omission warning; it
|
|
9
|
+
// grants nothing and is never consulted for an authorization decision.
|
|
10
|
+
return { request: { _flairAgentAuth: agentAuth }, __flairInternal: true };
|
|
6
11
|
}
|
|
7
12
|
/**
|
|
8
13
|
* The full presence roster (one row per agent — bounded, per the K&S
|