@tpsdev-ai/flair 0.6.1 → 0.7.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.
@@ -0,0 +1,225 @@
1
+ // ─── Client Detection & Wiring ──────────────────────────────────────────────────────
2
+ //
3
+ // Detects locally installed MCP clients and wires them to Flair.
4
+ // Each client has:
5
+ // - detect(): boolean - returns true if client is installed
6
+ // - wire(options: { agentId: string; flairUrl: string }): { ok: boolean; message: string }
7
+ //
8
+ // ---- Detection helpers ----------------------------------------------------------
9
+ import { spawnSync } from "node:child_process";
10
+ import { accessSync, constants } from "node:fs";
11
+ /**
12
+ * Check if a command exists in PATH (cross-platform alternative to `which`).
13
+ * Does not spawn a child process — pure filesystem check.
14
+ */
15
+ function binInPath(name) {
16
+ try {
17
+ const sep = process.platform === "win32" ? ";" : ":";
18
+ const dirs = (process.env.PATH || "").split(sep);
19
+ const exts = process.platform === "win32" ? [".exe", ".cmd", ".bat", ".ps1"] : [];
20
+ for (const dir of dirs) {
21
+ if (!dir)
22
+ continue;
23
+ const base = `${dir}/${name}`;
24
+ try {
25
+ accessSync(base, constants.X_OK);
26
+ return true;
27
+ }
28
+ catch { /* not here */ }
29
+ for (const ext of exts) {
30
+ try {
31
+ accessSync(`${base}${ext}`, constants.X_OK);
32
+ return true;
33
+ }
34
+ catch { /* not here */ }
35
+ }
36
+ }
37
+ return false;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ function claudeCodeDetect() {
44
+ try {
45
+ const result = spawnSync("npm", ["list", "-g", "@anthropic-ai/claude-code"], {
46
+ stdio: ["ignore", "ignore", "ignore"],
47
+ });
48
+ return result.status === 0;
49
+ }
50
+ catch (_e) {
51
+ return false;
52
+ }
53
+ }
54
+ function codexDetect() {
55
+ try {
56
+ if (binInPath("codex"))
57
+ return true;
58
+ const npmResult = spawnSync("npm", ["list", "-g", "@openai/codex"], {
59
+ stdio: ["ignore", "ignore", "ignore"],
60
+ });
61
+ return npmResult.status === 0;
62
+ }
63
+ catch (_e) {
64
+ return false;
65
+ }
66
+ }
67
+ function geminiDetect() {
68
+ try {
69
+ if (binInPath("gemini"))
70
+ return true;
71
+ const npmResult = spawnSync("npm", ["list", "-g", "@google/generative-ai"], {
72
+ stdio: ["ignore", "ignore", "ignore"],
73
+ });
74
+ return npmResult.status === 0;
75
+ }
76
+ catch (_e) {
77
+ return false;
78
+ }
79
+ }
80
+ function cursorDetect() {
81
+ try {
82
+ return binInPath("cursor");
83
+ }
84
+ catch (_e) {
85
+ return false;
86
+ }
87
+ }
88
+ // ---- Internal wiring functions --------------------------------------------------
89
+ function _wireClaudeCode(env) {
90
+ return {
91
+ ok: false,
92
+ message: `Manual wiring required for Claude Code:\n` +
93
+ `1. Locate Claude Code's settings file (usually ~/Library/Application Support/Claude/settings.json on macOS or %APPDATA%/Claude/settings.json on Windows)\n` +
94
+ `2. Add or update the "mcpServers" section:\n` +
95
+ ` {\n` +
96
+ ` "mcpServers": {\n` +
97
+ ` "flair": {\n` +
98
+ ` "command": "npx",\n` +
99
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
100
+ ` "env": {\n` +
101
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
102
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
103
+ ` }\n` +
104
+ ` }\n` +
105
+ ` }\n` +
106
+ ` }\n` +
107
+ `3. Restart Claude Code\n` +
108
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Claude Code's settings due to security restrictions.`,
109
+ };
110
+ }
111
+ function _wireCodex(env) {
112
+ return {
113
+ ok: false,
114
+ message: `Manual wiring required for Codex:\n` +
115
+ `1. Locate Codex's configuration (check ~/.codex/config or similar)\n` +
116
+ `2. Add the Flair MCP server configuration:\n` +
117
+ ` {\n` +
118
+ ` "mcpServers": {\n` +
119
+ ` "flair": {\n` +
120
+ ` "command": "npx",\n` +
121
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
122
+ ` "env": {\n` +
123
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
124
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
125
+ ` }\n` +
126
+ ` }\n` +
127
+ ` }\n` +
128
+ ` }\n` +
129
+ `3. Restart Codex\n` +
130
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Codex's configuration due to security restrictions.`,
131
+ };
132
+ }
133
+ function _wireGemini(env) {
134
+ return {
135
+ ok: false,
136
+ message: `Manual wiring required for Gemini:\n` +
137
+ `1. Locate Gemini's configuration (check ~/.gemini/config or similar)\n` +
138
+ `2. Add the Flair MCP server configuration:\n` +
139
+ ` {\n` +
140
+ ` "mcpServers": {\n` +
141
+ ` "flair": {\n` +
142
+ ` "command": "npx",\n` +
143
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
144
+ ` "env": {\n` +
145
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
146
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
147
+ ` }\n` +
148
+ ` }\n` +
149
+ ` }\n` +
150
+ ` }\n` +
151
+ `3. Restart Gemini\n` +
152
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Gemini's configuration due to security restrictions.`,
153
+ };
154
+ }
155
+ function _wireCursor(env) {
156
+ return {
157
+ ok: false,
158
+ message: `Manual wiring required for Cursor:\n` +
159
+ `1. Locate Cursor's settings file (usually ~/.cursor/settings.json)\n` +
160
+ `2. Add or update the "mcpServers" section:\n` +
161
+ ` {\n` +
162
+ ` "mcpServers": {\n` +
163
+ ` "flair": {\n` +
164
+ ` "command": "npx",\n` +
165
+ ` "args": ["-y", "@tpsdev-ai/flair-mcp"],\n` +
166
+ ` "env": {\n` +
167
+ ` "FLAIR_AGENT_ID": "${env.FLAIR_AGENT_ID}",\n` +
168
+ ` "FLAIR_URL": "${env.FLAIR_URL}"\n` +
169
+ ` }\n` +
170
+ ` }\n` +
171
+ ` }\n` +
172
+ ` }\n` +
173
+ `3. Restart Cursor\n` +
174
+ `Note: This is a manual step - the Flair CLI cannot automatically modify Cursor's settings due to security restrictions.`,
175
+ };
176
+ }
177
+ // ---- Exported detection & wiring array ------------------------------------------
178
+ export const ALL_CLIENTS = [
179
+ {
180
+ id: "claude-code",
181
+ label: "Claude Code",
182
+ wire: _wireClaudeCode,
183
+ },
184
+ {
185
+ id: "codex",
186
+ label: "Codex",
187
+ wire: _wireCodex,
188
+ },
189
+ {
190
+ id: "gemini",
191
+ label: "Gemini",
192
+ wire: _wireGemini,
193
+ },
194
+ {
195
+ id: "cursor",
196
+ label: "Cursor",
197
+ wire: _wireCursor,
198
+ },
199
+ ];
200
+ export function detectClients() {
201
+ return ALL_CLIENTS.map((client) => ({
202
+ ...client,
203
+ detected: client.id === "claude-code"
204
+ ? claudeCodeDetect()
205
+ : client.id === "codex"
206
+ ? codexDetect()
207
+ : client.id === "gemini"
208
+ ? geminiDetect()
209
+ : client.id === "cursor"
210
+ ? cursorDetect()
211
+ : false,
212
+ }));
213
+ }
214
+ export function wireClaudeCode(env) {
215
+ return _wireClaudeCode(env);
216
+ }
217
+ export function wireCodex(env) {
218
+ return _wireCodex(env);
219
+ }
220
+ export function wireGemini(env) {
221
+ return _wireGemini(env);
222
+ }
223
+ export function wireCursor(env) {
224
+ return _wireCursor(env);
225
+ }
@@ -82,6 +82,14 @@ export class FederationInstance extends Resource {
82
82
  * Requires a one-time pairing token generated by `flair federation token`.
83
83
  */
84
84
  export class FederationPair extends Resource {
85
+ // Pairing is a public endpoint by design — spokes have no Harper credentials
86
+ // on the hub yet. The handler self-validates via PairingToken + Ed25519
87
+ // body-signature. Bypassing Harper's role gate is required because Harper's
88
+ // auth layer (security/auth.js) claims any "Bearer X" header for itself,
89
+ // so we route the token through the body instead.
90
+ allowCreate(_user, _record, _context) {
91
+ return true;
92
+ }
85
93
  async post(data) {
86
94
  const { instanceId, publicKey, role, endpoint, signature, pairingToken } = data || {};
87
95
  if (!instanceId || !publicKey) {
@@ -119,36 +127,34 @@ export class FederationPair extends Resource {
119
127
  });
120
128
  }
121
129
  else {
122
- // New peer — require a valid pairing token
130
+ // New peer — require a valid pairing token in the request body.
131
+ // The handler self-validates because the endpoint is public at the
132
+ // Harper-auth and middleware layers (Bearer is not usable on Harper —
133
+ // its auth layer claims unknown Bearer values and 401s).
123
134
  if (!pairingToken) {
124
135
  return new Response(JSON.stringify({
125
- error: "pairingToken required generate one with 'flair federation token'",
136
+ error: "pairingToken required in body for first-time pair",
126
137
  }), { status: 401, headers: { "content-type": "application/json" } });
127
138
  }
128
- // Validate and consume the token
129
- let token = null;
139
+ // Fetch the token record for consumption
140
+ let tokenRecord = null;
130
141
  try {
131
- token = await databases.flair.PairingToken.get(pairingToken);
142
+ tokenRecord = await databases.flair.PairingToken.get(pairingToken);
132
143
  }
133
144
  catch { /* table may not exist */ }
134
- if (!token) {
135
- return new Response(JSON.stringify({ error: "invalid pairing token" }), {
136
- status: 401, headers: { "content-type": "application/json" },
137
- });
138
- }
139
- if (token.consumedBy) {
140
- return new Response(JSON.stringify({ error: "pairing token already used" }), {
145
+ if (!tokenRecord || tokenRecord.consumedBy) {
146
+ return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
141
147
  status: 401, headers: { "content-type": "application/json" },
142
148
  });
143
149
  }
144
- if (token.expiresAt && new Date(token.expiresAt) < new Date()) {
145
- return new Response(JSON.stringify({ error: "pairing token expired" }), {
150
+ if (tokenRecord.expiresAt && new Date(tokenRecord.expiresAt) < new Date()) {
151
+ return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
146
152
  status: 401, headers: { "content-type": "application/json" },
147
153
  });
148
154
  }
149
155
  // Consume the token
150
156
  await databases.flair.PairingToken.put({
151
- ...token,
157
+ ...tokenRecord,
152
158
  consumedBy: instanceId,
153
159
  consumedAt: new Date().toISOString(),
154
160
  });
@@ -117,28 +117,49 @@ server.http(async (request, nextLayer) => {
117
117
  url.pathname === "/AgentCard" ||
118
118
  url.pathname.startsWith("/A2AAdapter/") ||
119
119
  url.pathname.startsWith("/AgentCard/") ||
120
- // Federation endpoints handle their own auth via Ed25519 body signatures
121
- url.pathname === "/FederationPair" ||
120
+ // FederationSync uses Ed25519 body-signature auth (handled by the resource)
122
121
  url.pathname === "/FederationSync" ||
122
+ // FederationPair uses one-time PairingToken in the request body, validated
123
+ // by the resource itself (allowCreate=true on the Resource lets anonymous
124
+ // POST through Harper's role gate). Bearer can't be used here because
125
+ // Harper's auth layer claims any "Bearer X" Authorization header for itself.
126
+ url.pathname === "/FederationPair" ||
123
127
  // OAuth 2.1 public endpoints (spec requires no pre-auth)
124
128
  url.pathname === "/OAuthRegister" ||
125
129
  url.pathname === "/OAuthAuthorize" ||
126
130
  url.pathname === "/OAuthToken" ||
127
131
  url.pathname === "/OAuthRevoke" ||
128
132
  url.pathname === "/.well-known/oauth-authorization-server" ||
129
- url.pathname === "/OAuthMetadata")
133
+ url.pathname === "/OAuthMetadata" ||
134
+ // ObservationCenter HTML shell is public — the page itself is just markup
135
+ // and inline JS, with no embedded data. The JS prompts for admin-pass and
136
+ // auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
137
+ // Without this allow-list entry, the HTML is 401-blocked on hosted Flair
138
+ // instances (rockit-local works only because authorizeLocal=true).
139
+ url.pathname === "/ObservationCenter")
140
+ return nextLayer(request);
141
+ // If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
142
+ // trust Harper's auth decision and pass through without requiring additional headers.
143
+ if (request.user?.role?.permission?.super_user === true) {
130
144
  return nextLayer(request);
145
+ }
131
146
  // Skip re-entry: if we already swapped auth to Basic, pass through
132
147
  if (request._tpsAuthVerified)
133
148
  return nextLayer(request);
134
149
  const header = request.headers.get("authorization") || request.headers?.asObject?.authorization || "";
135
- // ── Basic admin auth ──────────────────────────────────────────────────────
136
- // Allow Basic auth with the admin password for CLI operations (backup, etc.)
137
- // This is checked BEFORE Ed25519 so admin tools can use simple auth.
150
+ // ── Basic admin / super_user auth ──────────────────────────────────────────
151
+ // Allow Basic auth for CLI operations (backup, etc.). Two paths:
152
+ // 1. HDB_ADMIN_PASSWORD env-var fast-path (user must be "admin" with exact pass)
153
+ // 2. Harper super_user check — any user with super_user:true permission accepted
154
+ // Checked BEFORE Ed25519 so admin tools can use simple auth.
138
155
  if (header.startsWith("Basic ")) {
139
156
  try {
140
157
  const decoded = Buffer.from(header.slice(6), "base64").toString("utf-8");
141
- const [user, pass] = decoded.split(":");
158
+ const colonIdx = decoded.indexOf(":");
159
+ const user = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
160
+ const pass = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : "";
161
+ // Path 1: Env-var fast-path (back-compat). Only matches user==="admin"
162
+ // with exact HDB_ADMIN_PASSWORD. Non-match falls through to Path 2.
142
163
  const adminPass = getAdminPass();
143
164
  if (adminPass !== null && user === "admin" && pass === adminPass) {
144
165
  // Mark as verified and set Harper user directly
@@ -150,14 +171,26 @@ server.http(async (request, nextLayer) => {
150
171
  request.headers.set("x-tps-agent", "admin");
151
172
  if (request.headers.asObject)
152
173
  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
174
  request.tpsAgent = "admin";
158
175
  request.tpsAgentIsAdmin = true;
159
176
  return nextLayer(request);
160
177
  }
178
+ // Path 2: Harper super_user check — any user with super_user:true
179
+ let harperUser = null;
180
+ try {
181
+ harperUser = await server.getUser(user, pass, request);
182
+ }
183
+ catch { /* fall through — invalid creds, non-existent user, etc. */ }
184
+ if (harperUser?.role?.permission?.super_user === true) {
185
+ request._tpsAuthVerified = true;
186
+ request.user = harperUser;
187
+ request.headers.set("x-tps-agent", user);
188
+ if (request.headers.asObject)
189
+ request.headers.asObject["x-tps-agent"] = user;
190
+ request.tpsAgent = user;
191
+ request.tpsAgentIsAdmin = true;
192
+ return nextLayer(request);
193
+ }
161
194
  }
162
195
  catch { /* fall through to Ed25519 check */ }
163
196
  return new Response(JSON.stringify({ error: "invalid_admin_credentials" }), { status: 401 });
@@ -150,7 +150,7 @@ export class HealthDetail extends Resource {
150
150
  const perAgentFull = Array.from(perAgentMap.values()).sort((a, b) => b.memoryCount - a.memoryCount);
151
151
  const perAgent = isAdmin
152
152
  ? perAgentFull
153
- : perAgentFull.filter((r) => r.id === callerAgent);
153
+ : perAgentFull.filter((r) => r.id === callerAgent || r.memoryCount > 0);
154
154
  stats.agents = {
155
155
  count: agents.length,
156
156
  names: isAdmin ? agents.map((a) => a.id).filter(Boolean) : undefined,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
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",
@@ -59,11 +59,13 @@
59
59
  "harper-fabric-embeddings": "0.2.3",
60
60
  "jose": "^6.2.2",
61
61
  "js-yaml": "^4.1.1",
62
+ "tar": "^7.5.13",
62
63
  "tweetnacl": "1.0.3"
63
64
  },
64
65
  "devDependencies": {
65
66
  "@playwright/test": "^1.59.1",
66
67
  "@types/node": "24.11.0",
68
+ "@types/tar": "^7.0.87",
67
69
  "bun-types": "1.3.11",
68
70
  "typescript": "5.9.3"
69
71
  },