@tpsdev-ai/flair 0.5.2 → 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
@@ -7,7 +7,28 @@ import { join, resolve as resolvePath } from "node:path";
7
7
  import { spawn } from "node:child_process";
8
8
  import { createPrivateKey, sign as nodeCryptoSign, randomUUID } from "node:crypto";
9
9
  import { keystore } from "./keystore.js";
10
- import { signBody } from "../resources/federation-crypto.js";
10
+ // Federation crypto helpers inlined to avoid cross-boundary imports from
11
+ // src/ into resources/, which don't survive npm packaging (see also
12
+ // resources/federation-crypto.ts; the two must stay in sync).
13
+ function sortKeys(val) {
14
+ if (val === null || val === undefined || typeof val !== "object")
15
+ return val;
16
+ if (Array.isArray(val))
17
+ return val.map(sortKeys);
18
+ const sorted = {};
19
+ for (const key of Object.keys(val).sort()) {
20
+ sorted[key] = sortKeys(val[key]);
21
+ }
22
+ return sorted;
23
+ }
24
+ function canonicalize(obj) {
25
+ return JSON.stringify(sortKeys(obj));
26
+ }
27
+ function signBody(body, secretKey) {
28
+ const message = new TextEncoder().encode(canonicalize(body));
29
+ const sig = nacl.sign.detached(message, secretKey);
30
+ return Buffer.from(sig).toString("base64url");
31
+ }
11
32
  // ─── Defaults ────────────────────────────────────────────────────────────────
12
33
  const DEFAULT_PORT = 19926;
13
34
  const DEFAULT_OPS_PORT = 19925;
@@ -232,7 +253,7 @@ async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
232
253
  });
233
254
  }
234
255
  async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
235
- const url = `http://127.0.0.1:${httpPort}/health`;
256
+ const url = `http://127.0.0.1:${httpPort}/Health`;
236
257
  const deadline = Date.now() + timeoutMs;
237
258
  let attempt = 0;
238
259
  while (Date.now() < deadline) {
@@ -242,7 +263,9 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
242
263
  headers: { Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}` },
243
264
  signal: AbortSignal.timeout(2000),
244
265
  });
245
- if (res.status > 0)
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)
246
269
  return;
247
270
  }
248
271
  catch { /* not ready yet */ }
@@ -250,6 +273,35 @@ async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
250
273
  }
251
274
  throw new Error(`Harper at port ${httpPort} did not respond within ${timeoutMs}ms (${attempt} attempts)`);
252
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
+ }
253
305
  async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adminPass) {
254
306
  const url = `http://127.0.0.1:${opsPort}/`;
255
307
  const auth = Buffer.from(`${adminUser}:${adminPass}`).toString("base64");
@@ -1283,8 +1335,8 @@ program
1283
1335
  table: "MemoryGrant",
1284
1336
  records: [{
1285
1337
  id: grantId,
1286
- fromAgentId: fromAgent,
1287
- toAgentId: toAgent,
1338
+ ownerId: fromAgent,
1339
+ granteeId: toAgent,
1288
1340
  scope,
1289
1341
  createdAt: new Date().toISOString(),
1290
1342
  }],
@@ -2118,11 +2170,16 @@ program
2118
2170
  execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
2119
2171
  }
2120
2172
  catch { }
2121
- // Stop the service KeepAlive=true in the plist auto-restarts it
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());
2122
2177
  try {
2123
2178
  execSync(`launchctl stop ${label}`, { stdio: "pipe" });
2124
2179
  }
2125
2180
  catch { }
2181
+ if (oldPid)
2182
+ await waitForProcessExit(oldPid, STARTUP_TIMEOUT_MS);
2126
2183
  await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
2127
2184
  console.log("✅ Flair restarted");
2128
2185
  return;
@@ -2157,16 +2214,22 @@ program
2157
2214
  process.exit(1);
2158
2215
  }
2159
2216
  const dataDir = defaultDataDir();
2160
- const adminPass = process.env.HDB_ADMIN_PASSWORD ?? "";
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 || "";
2161
2222
  const env = {
2162
2223
  ...process.env,
2163
2224
  ROOTPATH: dataDir,
2164
2225
  DEFAULTS_MODE: "dev",
2165
2226
  HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
2166
- HDB_ADMIN_PASSWORD: adminPass,
2167
2227
  HTTP_PORT: String(port),
2168
2228
  LOCAL_STUDIO: "false",
2169
2229
  };
2230
+ if (adminPass) {
2231
+ env.HDB_ADMIN_PASSWORD = adminPass;
2232
+ }
2170
2233
  const proc = spawn(process.execPath, [bin, "run", "."], {
2171
2234
  cwd: flairPackageDir(), env, detached: true, stdio: "ignore",
2172
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
- if (!agentId) {
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
- // Defense-in-depth: agentId must match authenticated agent
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
- if (!agentId)
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
- const actorId = this.request?.tpsAgent;
64
- if (actorId && actorId !== agentId && !(await isAdmin(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
- if (!agentId)
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
- // Auth: agent can only reflect on own memories unless admin
37
- const actorId = this.request?.tpsAgent;
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
- // Rate limiting use authenticated agent ID from request context, not client-supplied body
51
- const rateLimitAgent = this.request?.headers?.get?.("x-tps-agent")
52
- ?? this.request?.tpsAgent;
53
- if (rateLimitAgent) {
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(rateLimitAgent, bucket);
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
- // Defense-in-depth: verify agentId matches authenticated agent.
65
- const authenticatedAgent = this.request?.headers?.get?.("x-tps-agent");
66
- const callerIsAdmin = this.request?.tpsAgentIsAdmin === true;
67
- if (authenticatedAgent && !callerIsAdmin && agentId && agentId !== authenticatedAgent) {
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
- // Determine searchable agent IDs (own + granted)
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.2",
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.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",