@tpsdev-ai/flair 0.5.3 → 0.5.6
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
CHANGED
|
@@ -253,7 +253,7 @@ async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
|
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
255
|
async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
|
|
256
|
-
const url = `http://127.0.0.1:${httpPort}/
|
|
256
|
+
const url = `http://127.0.0.1:${httpPort}/Health`;
|
|
257
257
|
const deadline = Date.now() + timeoutMs;
|
|
258
258
|
let attempt = 0;
|
|
259
259
|
while (Date.now() < deadline) {
|
|
@@ -263,7 +263,9 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
|
|
|
263
263
|
headers: { Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}` },
|
|
264
264
|
signal: AbortSignal.timeout(2000),
|
|
265
265
|
});
|
|
266
|
-
|
|
266
|
+
// 2xx = healthy; 401 = Harper up but credentials wrong — still "reachable"
|
|
267
|
+
// enough for restart success. Anything else (5xx, 502 during shutdown) keeps polling.
|
|
268
|
+
if (res.ok || res.status === 401)
|
|
267
269
|
return;
|
|
268
270
|
}
|
|
269
271
|
catch { /* not ready yet */ }
|
|
@@ -271,6 +273,35 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
|
|
|
271
273
|
}
|
|
272
274
|
throw new Error(`Harper at port ${httpPort} did not respond within ${timeoutMs}ms (${attempt} attempts)`);
|
|
273
275
|
}
|
|
276
|
+
// Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
|
|
277
|
+
// Used during restart to confirm the old Harper process actually exited before
|
|
278
|
+
// we start polling /Health — otherwise the still-shutting-down old process can
|
|
279
|
+
// answer and we'd declare restart success while a gap is still ahead.
|
|
280
|
+
async function waitForProcessExit(pid, timeoutMs) {
|
|
281
|
+
const deadline = Date.now() + timeoutMs;
|
|
282
|
+
while (Date.now() < deadline) {
|
|
283
|
+
try {
|
|
284
|
+
process.kill(pid, 0);
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
await new Promise((r) => setTimeout(r, HEALTH_POLL_INTERVAL_MS));
|
|
290
|
+
}
|
|
291
|
+
throw new Error(`Process ${pid} did not exit within ${timeoutMs}ms`);
|
|
292
|
+
}
|
|
293
|
+
function readHarperPid(dataDir) {
|
|
294
|
+
const pidFile = join(dataDir, "hdb.pid");
|
|
295
|
+
if (!existsSync(pidFile))
|
|
296
|
+
return null;
|
|
297
|
+
try {
|
|
298
|
+
const n = Number(readFileSync(pidFile, "utf-8").trim());
|
|
299
|
+
return Number.isInteger(n) && n > 0 ? n : null;
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
274
305
|
async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass) {
|
|
275
306
|
const url = `http://127.0.0.1:${opsPort}/`;
|
|
276
307
|
const auth = Buffer.from(`${adminUser}:${adminPass}`).toString("base64");
|
|
@@ -1304,8 +1335,8 @@ program
|
|
|
1304
1335
|
table: "MemoryGrant",
|
|
1305
1336
|
records: [{
|
|
1306
1337
|
id: grantId,
|
|
1307
|
-
|
|
1308
|
-
|
|
1338
|
+
ownerId: fromAgent,
|
|
1339
|
+
granteeId: toAgent,
|
|
1309
1340
|
scope,
|
|
1310
1341
|
createdAt: new Date().toISOString(),
|
|
1311
1342
|
}],
|
|
@@ -2139,11 +2170,16 @@ program
|
|
|
2139
2170
|
execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
|
|
2140
2171
|
}
|
|
2141
2172
|
catch { }
|
|
2142
|
-
//
|
|
2173
|
+
// Capture the current PID *before* stopping so we can verify exit. Without
|
|
2174
|
+
// this, waitForHealth can race against the still-shutting-down old process
|
|
2175
|
+
// and return success before KeepAlive brings the new one up.
|
|
2176
|
+
const oldPid = readHarperPid(defaultDataDir());
|
|
2143
2177
|
try {
|
|
2144
2178
|
execSync(`launchctl stop ${label}`, { stdio: "pipe" });
|
|
2145
2179
|
}
|
|
2146
2180
|
catch { }
|
|
2181
|
+
if (oldPid)
|
|
2182
|
+
await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
|
|
2147
2183
|
await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
|
|
2148
2184
|
console.log("✅ Flair restarted");
|
|
2149
2185
|
return;
|
|
@@ -2178,16 +2214,22 @@ program
|
|
|
2178
2214
|
process.exit(1);
|
|
2179
2215
|
}
|
|
2180
2216
|
const dataDir = defaultDataDir();
|
|
2181
|
-
|
|
2217
|
+
// Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
|
|
2218
|
+
// Without this, `flair init --admin-pass X` (which only exports HDB_*
|
|
2219
|
+
// to the initial Harper spawn) followed by `flair restart` would silently
|
|
2220
|
+
// drop admin credentials — any subsequent auth'd call returns 401.
|
|
2221
|
+
const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
|
|
2182
2222
|
const env = {
|
|
2183
2223
|
...process.env,
|
|
2184
2224
|
ROOTPATH: dataDir,
|
|
2185
2225
|
DEFAULTS_MODE: "dev",
|
|
2186
2226
|
HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
|
|
2187
|
-
HDB_ADMIN_PASSWORD: adminPass,
|
|
2188
2227
|
HTTP_PORT: String(port),
|
|
2189
2228
|
LOCAL_STUDIO: "false",
|
|
2190
2229
|
};
|
|
2230
|
+
if (adminPass) {
|
|
2231
|
+
env.HDB_ADMIN_PASSWORD = adminPass;
|
|
2232
|
+
}
|
|
2191
2233
|
const proc = spawn(process.execPath, [bin, "run", "."], {
|
|
2192
2234
|
cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
|
|
2193
2235
|
});
|
|
@@ -41,24 +41,34 @@ function formatMemory(m, supersedes) {
|
|
|
41
41
|
}
|
|
42
42
|
export class BootstrapMemories extends Resource {
|
|
43
43
|
async post(data, _context) {
|
|
44
|
-
const { agentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
|
|
44
|
+
const { agentId: bodyAgentId, currentTask, maxTokens = 4000, includeSoul = true, since, channel, // e.g., "discord", "tps-mail", "claude-code"
|
|
45
45
|
surface, // e.g., "tps-build", "tps-review", "cli-session"
|
|
46
46
|
subjects, // e.g., ["flair", "auth"] — entities to preload context for
|
|
47
47
|
} = data || {};
|
|
48
|
-
|
|
48
|
+
// Authenticated identity lives on getContext().request — `this.request` is
|
|
49
|
+
// NOT populated on Harper v5 Resources. Reading it returned undefined and
|
|
50
|
+
// the scope check was silently bypassed, letting a non-admin agent read
|
|
51
|
+
// another agent's soul + memories by passing the victim's id in the body.
|
|
52
|
+
const ctx = this.getContext?.();
|
|
53
|
+
const request = ctx?.request ?? ctx;
|
|
54
|
+
const authenticatedAgent = request?.tpsAgent ?? request?.headers?.get?.("x-tps-agent");
|
|
55
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true;
|
|
56
|
+
if (!bodyAgentId && !authenticatedAgent) {
|
|
49
57
|
return new Response(JSON.stringify({ error: "agentId required" }), {
|
|
50
58
|
status: 400,
|
|
51
59
|
headers: { "Content-Type": "application/json" },
|
|
52
60
|
});
|
|
53
61
|
}
|
|
54
|
-
|
|
55
|
-
const authenticatedAgent = this.request?.headers?.get?.("x-tps-agent");
|
|
56
|
-
const callerIsAdmin = this.request?.tpsAgentIsAdmin === true;
|
|
57
|
-
if (authenticatedAgent && !callerIsAdmin && agentId !== authenticatedAgent) {
|
|
62
|
+
if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
|
|
58
63
|
return new Response(JSON.stringify({
|
|
59
64
|
error: "forbidden: agentId must match authenticated agent",
|
|
60
65
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
61
66
|
}
|
|
67
|
+
// Pin scope to the authenticated agent for non-admins; admins can bootstrap
|
|
68
|
+
// any agentId (needed for setup scripts and UI impersonation flows).
|
|
69
|
+
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
70
|
+
? authenticatedAgent
|
|
71
|
+
: bodyAgentId;
|
|
62
72
|
const sections = {
|
|
63
73
|
soul: [],
|
|
64
74
|
skills: [],
|
|
@@ -57,13 +57,22 @@ function evaluate(record, now, olderThanMs) {
|
|
|
57
57
|
}
|
|
58
58
|
export class ConsolidateMemories extends Resource {
|
|
59
59
|
async post(data) {
|
|
60
|
-
const { agentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
|
|
61
|
-
|
|
60
|
+
const { agentId: bodyAgentId, scope = "persistent", olderThan = "30d", limit = 20 } = data || {};
|
|
61
|
+
// See SemanticSearch / MemoryBootstrap — `this.request` isn't populated on
|
|
62
|
+
// Harper v5 Resources, so the prior actorId check was silently bypassed
|
|
63
|
+
// and bob could enumerate alice's consolidation candidates (her memory records).
|
|
64
|
+
const ctx = this.getContext?.();
|
|
65
|
+
const request = ctx?.request ?? ctx;
|
|
66
|
+
const actorId = request?.tpsAgent;
|
|
67
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true
|
|
68
|
+
|| (actorId ? await isAdmin(actorId) : false);
|
|
69
|
+
if (!bodyAgentId && !actorId) {
|
|
62
70
|
return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
|
|
63
|
-
|
|
64
|
-
if (actorId &&
|
|
71
|
+
}
|
|
72
|
+
if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
|
|
65
73
|
return new Response(JSON.stringify({ error: "forbidden: can only consolidate own memories" }), { status: 403 });
|
|
66
74
|
}
|
|
75
|
+
const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
|
|
67
76
|
const olderThanMs = parseDuration(olderThan);
|
|
68
77
|
const now = Date.now();
|
|
69
78
|
const candidates = [];
|
|
@@ -30,14 +30,23 @@ const FOCUS_PROMPTS = {
|
|
|
30
30
|
};
|
|
31
31
|
export class ReflectMemories extends Resource {
|
|
32
32
|
async post(data) {
|
|
33
|
-
const { agentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
|
|
34
|
-
|
|
33
|
+
const { agentId: bodyAgentId, scope = "recent", since, maxMemories = 50, focus = "lessons_learned", tag, } = data || {};
|
|
34
|
+
// Authenticated identity comes from getContext().request, not this.request
|
|
35
|
+
// (see SemanticSearch / MemoryBootstrap for the same bug class). The prior
|
|
36
|
+
// check was silently bypassed — bob could reflect on alice's memories and
|
|
37
|
+
// mutate alice's records via the lastReflected patchRecordSilent below.
|
|
38
|
+
const ctx = this.getContext?.();
|
|
39
|
+
const request = ctx?.request ?? ctx;
|
|
40
|
+
const actorId = request?.tpsAgent;
|
|
41
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true
|
|
42
|
+
|| (actorId ? await isAdmin(actorId) : false);
|
|
43
|
+
if (!bodyAgentId && !actorId) {
|
|
35
44
|
return new Response(JSON.stringify({ error: "agentId required" }), { status: 400 });
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (actorId && actorId !== agentId && !(await isAdmin(actorId))) {
|
|
45
|
+
}
|
|
46
|
+
if (actorId && !callerIsAdmin && bodyAgentId && bodyAgentId !== actorId) {
|
|
39
47
|
return new Response(JSON.stringify({ error: "forbidden: can only reflect on own memories" }), { status: 403 });
|
|
40
48
|
}
|
|
49
|
+
const agentId = (actorId && !callerIsAdmin) ? actorId : bodyAgentId;
|
|
41
50
|
const sinceDate = since ? new Date(since) : new Date(Date.now() - 24 * 3600_000);
|
|
42
51
|
const memories = [];
|
|
43
52
|
for await (const record of databases.flair.Memory.search()) {
|
|
@@ -46,13 +46,20 @@ function distanceToSimilarity(distance) {
|
|
|
46
46
|
const CANDIDATE_MULTIPLIER = 5;
|
|
47
47
|
export class SemanticSearch extends Resource {
|
|
48
48
|
async post(data) {
|
|
49
|
-
const { agentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
const { agentId: bodyAgentId, q, queryEmbedding, tag, subject, subjects, limit = 10, includeSuperseded = false, scoring = "composite", minScore = 0, since, asOf } = data || {};
|
|
50
|
+
// Authenticated identity lives on the Harper Resource context (getContext().request).
|
|
51
|
+
// `this.request` is NOT populated on Harper v5 Resources — prior reads here
|
|
52
|
+
// silently returned undefined and the defense-in-depth scope check below
|
|
53
|
+
// was bypassed, letting a non-admin agent read another agent's memories
|
|
54
|
+
// by putting the victim's id in the body.
|
|
55
|
+
const ctx = this.getContext?.();
|
|
56
|
+
const request = ctx?.request ?? ctx;
|
|
57
|
+
const authenticatedAgent = request?.tpsAgent ?? request?.headers?.get?.("x-tps-agent");
|
|
58
|
+
const callerIsAdmin = request?.tpsAgentIsAdmin === true;
|
|
59
|
+
// Rate limiting — use authenticated agent ID, not client-supplied body
|
|
60
|
+
if (authenticatedAgent) {
|
|
54
61
|
const bucket = q && !queryEmbedding ? "embedding" : "general";
|
|
55
|
-
const rl = checkRateLimit(
|
|
62
|
+
const rl = checkRateLimit(authenticatedAgent, bucket);
|
|
56
63
|
if (!rl.allowed)
|
|
57
64
|
return rateLimitResponse(rl.retryAfterMs, "search");
|
|
58
65
|
}
|
|
@@ -61,15 +68,19 @@ export class SemanticSearch extends Resource {
|
|
|
61
68
|
: subject
|
|
62
69
|
? new Set([subject.toLowerCase()])
|
|
63
70
|
: null;
|
|
64
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (authenticatedAgent && !callerIsAdmin &&
|
|
71
|
+
// Enforce agentId = authenticated agent for non-admins. A mismatched body
|
|
72
|
+
// agentId is a cross-agent read attempt — reject outright. Admins can query
|
|
73
|
+
// any agentId (needed for bootstrap / consolidation scripts).
|
|
74
|
+
if (authenticatedAgent && !callerIsAdmin && bodyAgentId && bodyAgentId !== authenticatedAgent) {
|
|
68
75
|
return new Response(JSON.stringify({
|
|
69
76
|
error: "forbidden: agentId must match authenticated agent",
|
|
70
77
|
}), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
71
78
|
}
|
|
72
|
-
//
|
|
79
|
+
// Scope search to the authenticated agent (own + granted). For admins or
|
|
80
|
+
// unauthenticated internal calls, honor the body-supplied agentId.
|
|
81
|
+
const agentId = (authenticatedAgent && !callerIsAdmin)
|
|
82
|
+
? authenticatedAgent
|
|
83
|
+
: bodyAgentId;
|
|
73
84
|
const searchAgentIds = new Set();
|
|
74
85
|
if (agentId)
|
|
75
86
|
searchAgentIds.add(agentId);
|
|
@@ -150,6 +150,12 @@ server.http(async (request, nextLayer) => {
|
|
|
150
150
|
request.headers.set("x-tps-agent", "admin");
|
|
151
151
|
if (request.headers.asObject)
|
|
152
152
|
request.headers.asObject["x-tps-agent"] = "admin";
|
|
153
|
+
// Basic admin auth IS admin — resource-level checks (SemanticSearch,
|
|
154
|
+
// MemoryBootstrap, MemoryReflect, MemoryConsolidate) gate cross-agent
|
|
155
|
+
// access on this flag. Prior to 0.5.5 the check was a no-op so this
|
|
156
|
+
// was never needed; now it must be set.
|
|
157
|
+
request.tpsAgent = "admin";
|
|
158
|
+
request.tpsAgentIsAdmin = true;
|
|
153
159
|
return nextLayer(request);
|
|
154
160
|
}
|
|
155
161
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.6",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"node": ">=22"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@harperfast/harper": "5.0.
|
|
55
|
+
"@harperfast/harper": "5.0.1",
|
|
56
56
|
"commander": "14.0.3",
|
|
57
57
|
"harper-fabric-embeddings": "0.2.2",
|
|
58
58
|
"jose": "^6.2.2",
|