@tpsdev-ai/flair 0.6.2 → 0.8.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 +19 -6
- package/dist/bridges/builtins/index.js +28 -11
- package/dist/bridges/builtins/markdown.js +66 -0
- package/dist/bridges/runtime/context.js +2 -1
- package/dist/bridges/runtime/formats.js +97 -9
- package/dist/bridges/runtime/load-bridge.js +15 -5
- package/dist/cli.js +3113 -298
- package/dist/install/clients.js +225 -0
- package/dist/resources/Federation.js +87 -24
- package/dist/resources/Memory.js +17 -24
- package/dist/resources/MemoryBootstrap.js +11 -2
- package/dist/resources/auth-middleware.js +66 -11
- package/dist/resources/content-safety.js +21 -0
- package/dist/resources/federation-cleanup.js +161 -0
- package/dist/resources/federation-crypto.js +100 -1
- package/dist/resources/health.js +1 -1
- package/package.json +8 -6
- package/schemas/memory.graphql +28 -0
- package/ui/observation-center.html +356 -62
|
@@ -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
|
+
}
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { Resource, databases } from "@harperfast/harper";
|
|
1
|
+
import { Resource, databases, server } from "@harperfast/harper";
|
|
2
2
|
import { randomBytes } from "node:crypto";
|
|
3
3
|
import nacl from "tweetnacl";
|
|
4
|
-
import { canonicalize, signBody, verifyBodySignature } from "./federation-crypto.js";
|
|
4
|
+
import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, createNonceStore, generateNonce, } from "./federation-crypto.js";
|
|
5
|
+
import { initFederationCleanup } from "./federation-cleanup.js";
|
|
6
|
+
// Module-level nonce store for federation anti-replay.
|
|
7
|
+
// Shared across FederationPair + FederationSync — nonces are globally unique
|
|
8
|
+
// (generated by signBodyFresh per request with 128-bit random nonces).
|
|
9
|
+
const federationNonceStore = createNonceStore();
|
|
5
10
|
// Re-export for consumers that import from Federation.ts
|
|
6
|
-
export { canonicalize, signBody, verifyBodySignature };
|
|
11
|
+
export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce };
|
|
7
12
|
// ─── Conflict resolution ─────────────────────────────────────────────────────
|
|
8
13
|
/**
|
|
9
14
|
* Field-level Last-Write-Wins merge.
|
|
@@ -82,6 +87,14 @@ export class FederationInstance extends Resource {
|
|
|
82
87
|
* Requires a one-time pairing token generated by `flair federation token`.
|
|
83
88
|
*/
|
|
84
89
|
export class FederationPair extends Resource {
|
|
90
|
+
// Pairing is a public endpoint by design — spokes have no Harper credentials
|
|
91
|
+
// on the hub yet. The handler self-validates via PairingToken + Ed25519
|
|
92
|
+
// body-signature. Bypassing Harper's role gate is required because Harper's
|
|
93
|
+
// auth layer (security/auth.js) claims any "Bearer X" header for itself,
|
|
94
|
+
// so we route the token through the body instead.
|
|
95
|
+
allowCreate(_user, _record, _context) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
85
98
|
async post(data) {
|
|
86
99
|
const { instanceId, publicKey, role, endpoint, signature, pairingToken } = data || {};
|
|
87
100
|
if (!instanceId || !publicKey) {
|
|
@@ -89,15 +102,21 @@ export class FederationPair extends Resource {
|
|
|
89
102
|
status: 400, headers: { "content-type": "application/json" },
|
|
90
103
|
});
|
|
91
104
|
}
|
|
92
|
-
// Verify signature
|
|
93
|
-
//
|
|
105
|
+
// Verify request signature against the public key (proves key ownership).
|
|
106
|
+
// Uses fresh verification with anti-replay — even though pair has implicit
|
|
107
|
+
// replay protection via single-use pairingToken, explicit anti-replay is
|
|
108
|
+
// defense-in-depth and keeps signature semantics consistent across endpoints.
|
|
94
109
|
if (!signature) {
|
|
95
110
|
return new Response(JSON.stringify({ error: "signature required — request must be signed" }), {
|
|
96
111
|
status: 401, headers: { "content-type": "application/json" },
|
|
97
112
|
});
|
|
98
113
|
}
|
|
99
|
-
|
|
100
|
-
|
|
114
|
+
const verifyResult = verifyBodySignatureFresh(data, publicKey, {
|
|
115
|
+
windowMs: 30_000,
|
|
116
|
+
nonceStore: federationNonceStore,
|
|
117
|
+
});
|
|
118
|
+
if (!verifyResult.ok) {
|
|
119
|
+
return new Response(JSON.stringify({ error: `invalid signature — ${verifyResult.reason}` }), {
|
|
101
120
|
status: 401, headers: { "content-type": "application/json" },
|
|
102
121
|
});
|
|
103
122
|
}
|
|
@@ -119,36 +138,46 @@ export class FederationPair extends Resource {
|
|
|
119
138
|
});
|
|
120
139
|
}
|
|
121
140
|
else {
|
|
122
|
-
// New peer — require a valid pairing token
|
|
141
|
+
// New peer — require a valid pairing token in the request body.
|
|
142
|
+
// The handler self-validates because the endpoint is public at the
|
|
143
|
+
// Harper-auth and middleware layers (Bearer is not usable on Harper —
|
|
144
|
+
// its auth layer claims unknown Bearer values and 401s).
|
|
123
145
|
if (!pairingToken) {
|
|
124
146
|
return new Response(JSON.stringify({
|
|
125
|
-
error: "pairingToken required
|
|
147
|
+
error: "pairingToken required in body for first-time pair",
|
|
126
148
|
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
127
149
|
}
|
|
128
|
-
//
|
|
129
|
-
let
|
|
150
|
+
// Fetch the token record for consumption
|
|
151
|
+
let tokenRecord = null;
|
|
130
152
|
try {
|
|
131
|
-
|
|
153
|
+
tokenRecord = await databases.flair.PairingToken.get(pairingToken);
|
|
132
154
|
}
|
|
133
155
|
catch { /* table may not exist */ }
|
|
134
|
-
if (!
|
|
135
|
-
return new Response(JSON.stringify({ error: "
|
|
156
|
+
if (!tokenRecord || tokenRecord.consumedBy) {
|
|
157
|
+
return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
|
|
136
158
|
status: 401, headers: { "content-type": "application/json" },
|
|
137
159
|
});
|
|
138
160
|
}
|
|
139
|
-
if (
|
|
140
|
-
return new Response(JSON.stringify({ error: "
|
|
161
|
+
if (tokenRecord.expiresAt && new Date(tokenRecord.expiresAt) < new Date()) {
|
|
162
|
+
return new Response(JSON.stringify({ error: "invalid_or_expired_pairing_token" }), {
|
|
141
163
|
status: 401, headers: { "content-type": "application/json" },
|
|
142
164
|
});
|
|
143
165
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
166
|
+
// PR-4: bind authenticated bootstrap user to specific pairing token
|
|
167
|
+
const ctx = this.getContext?.() ?? {};
|
|
168
|
+
const authedUser = ctx.request?.tpsAgent;
|
|
169
|
+
const isAdmin = ctx.request?.tpsAgentIsAdmin === true;
|
|
170
|
+
if (!isAdmin && authedUser) {
|
|
171
|
+
const expectedBootstrap = `pair-bootstrap-${pairingToken.slice(0, 8)}`;
|
|
172
|
+
if (authedUser !== expectedBootstrap) {
|
|
173
|
+
return new Response(JSON.stringify({
|
|
174
|
+
error: "bootstrap_user_token_mismatch",
|
|
175
|
+
}), { status: 401, headers: { "content-type": "application/json" } });
|
|
176
|
+
}
|
|
148
177
|
}
|
|
149
178
|
// Consume the token
|
|
150
179
|
await databases.flair.PairingToken.put({
|
|
151
|
-
...
|
|
180
|
+
...tokenRecord,
|
|
152
181
|
consumedBy: instanceId,
|
|
153
182
|
consumedAt: new Date().toISOString(),
|
|
154
183
|
});
|
|
@@ -163,6 +192,15 @@ export class FederationPair extends Resource {
|
|
|
163
192
|
createdAt: new Date().toISOString(),
|
|
164
193
|
updatedAt: new Date().toISOString(),
|
|
165
194
|
});
|
|
195
|
+
// Drop the bootstrap user that was created alongside this pairing token.
|
|
196
|
+
// Failure here is non-fatal: the cleanup sweep (PR-5) will catch stragglers.
|
|
197
|
+
try {
|
|
198
|
+
const bootstrapUsername = `pair-bootstrap-${pairingToken.slice(0, 8)}`;
|
|
199
|
+
await server.operation({ operation: "drop_user", username: bootstrapUsername }, ctx, false);
|
|
200
|
+
}
|
|
201
|
+
catch (err) {
|
|
202
|
+
console.warn(`[federation] drop_user failed: ${err?.message} — cleanup sweep will retry`);
|
|
203
|
+
}
|
|
166
204
|
}
|
|
167
205
|
// Return our own identity for the peer to record
|
|
168
206
|
let ourInstance = null;
|
|
@@ -190,6 +228,12 @@ export class FederationPair extends Resource {
|
|
|
190
228
|
* The calling peer sends a batch of SyncRecords; we merge them.
|
|
191
229
|
*/
|
|
192
230
|
export class FederationSync extends Resource {
|
|
231
|
+
// Public endpoint by design: spokes have no Harper credentials. Body signature
|
|
232
|
+
// (with timestamp + nonce, via verifyBodySignatureFresh) is the auth boundary.
|
|
233
|
+
// Same pattern as FederationPair.
|
|
234
|
+
allowCreate(_user, _record, _context) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
193
237
|
async post(data) {
|
|
194
238
|
const { instanceId, records, lamportClock, signature } = data || {};
|
|
195
239
|
if (!instanceId || !Array.isArray(records)) {
|
|
@@ -204,14 +248,20 @@ export class FederationSync extends Resource {
|
|
|
204
248
|
status: 403, headers: { "content-type": "application/json" },
|
|
205
249
|
});
|
|
206
250
|
}
|
|
207
|
-
// Verify request signature against peer's pinned public key
|
|
251
|
+
// Verify request signature against peer's pinned public key.
|
|
252
|
+
// Fresh verification with anti-replay — prevents captured signatures from
|
|
253
|
+
// being replayed across the timestamp window.
|
|
208
254
|
if (!signature) {
|
|
209
255
|
return new Response(JSON.stringify({ error: "signature required — sync requests must be signed" }), {
|
|
210
256
|
status: 401, headers: { "content-type": "application/json" },
|
|
211
257
|
});
|
|
212
258
|
}
|
|
213
|
-
|
|
214
|
-
|
|
259
|
+
const verifyResult = verifyBodySignatureFresh(data, peer.publicKey, {
|
|
260
|
+
windowMs: 30_000,
|
|
261
|
+
nonceStore: federationNonceStore,
|
|
262
|
+
});
|
|
263
|
+
if (!verifyResult.ok) {
|
|
264
|
+
return new Response(JSON.stringify({ error: `invalid signature — ${verifyResult.reason}` }), {
|
|
215
265
|
status: 401, headers: { "content-type": "application/json" },
|
|
216
266
|
});
|
|
217
267
|
}
|
|
@@ -313,3 +363,16 @@ export class FederationPeers extends Resource {
|
|
|
313
363
|
return { peers };
|
|
314
364
|
}
|
|
315
365
|
}
|
|
366
|
+
// ── Module initialisation ────────────────────────────────────────────────────
|
|
367
|
+
// Deferred: the cleanup sweep (PR-5) starts after the module graph resolves.
|
|
368
|
+
// In unit test environments (no Harper runtime), setTimeout never fires
|
|
369
|
+
// synchronously and import is safe. In the live Harper process, the sweep
|
|
370
|
+
// will start on the next event loop tick and runs only on hub instances.
|
|
371
|
+
if (typeof setTimeout !== "undefined") {
|
|
372
|
+
setTimeout(() => {
|
|
373
|
+
initFederationCleanup().catch((err) => {
|
|
374
|
+
// Swallow — in test/resource-env the Harper databases may not be bound.
|
|
375
|
+
// In production, initFederationCleanup handles its own error paths.
|
|
376
|
+
});
|
|
377
|
+
}, 0);
|
|
378
|
+
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -2,7 +2,7 @@ import { databases } from "@harperfast/harper";
|
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
3
|
import { isAdmin } from "./auth-middleware.js";
|
|
4
4
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
5
|
-
import {
|
|
5
|
+
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
6
6
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
7
7
|
export class Memory extends databases.flair.Memory {
|
|
8
8
|
/**
|
|
@@ -41,25 +41,17 @@ export class Memory extends databases.flair.Memory {
|
|
|
41
41
|
const agentIdCondition = allowedOwners.length === 1
|
|
42
42
|
? { attribute: "agentId", comparator: "equals", value: allowedOwners[0] }
|
|
43
43
|
: { conditions: allowedOwners.map(id => ({ attribute: "agentId", comparator: "equals", value: id })), operator: "or" };
|
|
44
|
-
// Harper passes `query` as a RequestTarget (extends URLSearchParams)
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
44
|
+
// Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
|
|
45
|
+
// conditions array. For URL-based GET /Memory?... calls, URL params are no
|
|
46
|
+
// longer translated to conditions here — callers should use
|
|
47
|
+
// POST /Memory/search_by_conditions with an explicit conditions array.
|
|
48
|
+
// For programmatic calls with a conditions array, we wrap with the agentId scope.
|
|
49
49
|
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
50
|
-
const existing = [];
|
|
51
50
|
if (Array.isArray(query.conditions) && query.conditions.length > 0) {
|
|
52
|
-
|
|
51
|
+
query.conditions = [agentIdCondition, ...query.conditions];
|
|
52
|
+
return withDetachedTxn(ctx, () => super.search(query));
|
|
53
53
|
}
|
|
54
|
-
|
|
55
|
-
for (const [k, v] of query.entries()) {
|
|
56
|
-
if (k === "agentId")
|
|
57
|
-
continue;
|
|
58
|
-
existing.push({ attribute: k, comparator: "equals", value: v });
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
query.conditions = [agentIdCondition, ...existing];
|
|
62
|
-
return withDetachedTxn(ctx, () => super.search(query));
|
|
54
|
+
// Fallback: no conditions array present — just scope and pass through
|
|
63
55
|
}
|
|
64
56
|
// Fallback: plain array or no query (internal calls)
|
|
65
57
|
const conditions = Array.isArray(query) && query.length > 0
|
|
@@ -114,9 +106,10 @@ export class Memory extends databases.flair.Memory {
|
|
|
114
106
|
const ttlHours = Number(process.env.FLAIR_EPHEMERAL_TTL_HOURS || 24);
|
|
115
107
|
content.expiresAt = new Date(Date.now() + ttlHours * 3600_000).toISOString();
|
|
116
108
|
}
|
|
117
|
-
// Content safety scan
|
|
118
|
-
|
|
119
|
-
|
|
109
|
+
// Content safety scan — covers content + summary (defense-in-depth for
|
|
110
|
+
// agent-set summaries, ops-i2jb).
|
|
111
|
+
if (content.content || content.summary) {
|
|
112
|
+
const safety = scanFields(content, ["content", "summary"]);
|
|
120
113
|
if (!safety.safe) {
|
|
121
114
|
if (isStrictMode()) {
|
|
122
115
|
return new Response(JSON.stringify({
|
|
@@ -163,9 +156,9 @@ export class Memory extends databases.flair.Memory {
|
|
|
163
156
|
// Set defaults that post() sets — put() is also used for new records via CLI
|
|
164
157
|
content.archived = content.archived ?? false;
|
|
165
158
|
content.createdAt = content.createdAt ?? now;
|
|
166
|
-
// Content safety scan on updated content
|
|
167
|
-
if (content.content) {
|
|
168
|
-
const safety =
|
|
159
|
+
// Content safety scan on updated content + summary (ops-i2jb).
|
|
160
|
+
if (content.content || content.summary) {
|
|
161
|
+
const safety = scanFields(content, ["content", "summary"]);
|
|
169
162
|
if (!safety.safe) {
|
|
170
163
|
if (isStrictMode()) {
|
|
171
164
|
return new Response(JSON.stringify({
|
|
@@ -177,7 +170,7 @@ export class Memory extends databases.flair.Memory {
|
|
|
177
170
|
content._safetyFlags = safety.flags;
|
|
178
171
|
}
|
|
179
172
|
else {
|
|
180
|
-
// Clear previous flags if
|
|
173
|
+
// Clear previous flags if both fields are now clean
|
|
181
174
|
content._safetyFlags = null;
|
|
182
175
|
}
|
|
183
176
|
}
|
|
@@ -82,6 +82,7 @@ export class BootstrapMemories extends Resource {
|
|
|
82
82
|
let tokenBudget = maxTokens;
|
|
83
83
|
let memoriesIncluded = 0;
|
|
84
84
|
let memoriesAvailable = 0;
|
|
85
|
+
let memoriesTruncated = 0;
|
|
85
86
|
// --- 1. Soul records (budgeted — prioritized by key importance) ---
|
|
86
87
|
// Soul is who you are, but we still need to respect token budgets.
|
|
87
88
|
// Workspace files (SOUL.md, AGENTS.md) can be massive — they're already
|
|
@@ -189,6 +190,9 @@ export class BootstrapMemories extends Resource {
|
|
|
189
190
|
tokenBudget -= cost;
|
|
190
191
|
memoriesIncluded++;
|
|
191
192
|
}
|
|
193
|
+
else {
|
|
194
|
+
memoriesTruncated++;
|
|
195
|
+
}
|
|
192
196
|
}
|
|
193
197
|
// --- 3. Recent memories (adaptive window) ---
|
|
194
198
|
// Start with 48h. If nothing found, widen to 7d, then 30d.
|
|
@@ -217,8 +221,10 @@ export class BootstrapMemories extends Resource {
|
|
|
217
221
|
for (const m of recent) {
|
|
218
222
|
const line = formatMemory(m);
|
|
219
223
|
const cost = estimateTokens(line);
|
|
220
|
-
if (recentSpent + cost > recentBudget)
|
|
224
|
+
if (recentSpent + cost > recentBudget) {
|
|
225
|
+
memoriesTruncated++;
|
|
221
226
|
continue;
|
|
227
|
+
}
|
|
222
228
|
sections.recent.push(line);
|
|
223
229
|
recentSpent += cost;
|
|
224
230
|
tokenBudget -= cost;
|
|
@@ -249,8 +255,10 @@ export class BootstrapMemories extends Resource {
|
|
|
249
255
|
for (const m of subjectMemories) {
|
|
250
256
|
const line = formatMemory(m);
|
|
251
257
|
const cost = estimateTokens(line);
|
|
252
|
-
if (predictedSpent + cost > predictedBudget)
|
|
258
|
+
if (predictedSpent + cost > predictedBudget) {
|
|
259
|
+
memoriesTruncated++;
|
|
253
260
|
continue;
|
|
261
|
+
}
|
|
254
262
|
sections.predicted.push(line);
|
|
255
263
|
predictedSpent += cost;
|
|
256
264
|
tokenBudget -= cost;
|
|
@@ -401,6 +409,7 @@ export class BootstrapMemories extends Resource {
|
|
|
401
409
|
memoryTokens,
|
|
402
410
|
memoriesIncluded,
|
|
403
411
|
memoriesAvailable,
|
|
412
|
+
memoriesTruncated,
|
|
404
413
|
};
|
|
405
414
|
}
|
|
406
415
|
}
|
|
@@ -117,28 +117,50 @@ server.http(async (request, nextLayer) => {
|
|
|
117
117
|
url.pathname === "/AgentCard" ||
|
|
118
118
|
url.pathname.startsWith("/A2AAdapter/") ||
|
|
119
119
|
url.pathname.startsWith("/AgentCard/") ||
|
|
120
|
-
//
|
|
121
|
-
|
|
120
|
+
// FederationSync uses Ed25519 body-signature auth with anti-replay, validated
|
|
121
|
+
// by the resource handler (allowCreate=true, same pattern as FederationPair).
|
|
122
122
|
url.pathname === "/FederationSync" ||
|
|
123
|
+
// FederationPair uses one-time PairingToken in the request body, validated
|
|
124
|
+
// by the resource itself (allowCreate=true on the Resource lets anonymous
|
|
125
|
+
// POST through Harper's role gate). Bearer can't be used here because
|
|
126
|
+
// Harper's auth layer claims any "Bearer X" Authorization header for itself.
|
|
127
|
+
url.pathname === "/FederationPair" ||
|
|
123
128
|
// OAuth 2.1 public endpoints (spec requires no pre-auth)
|
|
124
129
|
url.pathname === "/OAuthRegister" ||
|
|
125
130
|
url.pathname === "/OAuthAuthorize" ||
|
|
126
131
|
url.pathname === "/OAuthToken" ||
|
|
127
132
|
url.pathname === "/OAuthRevoke" ||
|
|
128
133
|
url.pathname === "/.well-known/oauth-authorization-server" ||
|
|
129
|
-
url.pathname === "/OAuthMetadata"
|
|
134
|
+
url.pathname === "/OAuthMetadata" ||
|
|
135
|
+
// ObservationCenter HTML shell is public — the page itself is just markup
|
|
136
|
+
// and inline JS, with no embedded data. The JS prompts for admin-pass and
|
|
137
|
+
// auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
|
|
138
|
+
// Without this allow-list entry, the HTML is 401-blocked on hosted Flair
|
|
139
|
+
// instances (rockit-local works only because authorizeLocal=true).
|
|
140
|
+
url.pathname === "/ObservationCenter")
|
|
130
141
|
return nextLayer(request);
|
|
142
|
+
// If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
|
|
143
|
+
// trust Harper's auth decision and pass through without requiring additional headers.
|
|
144
|
+
if (request.user?.role?.permission?.super_user === true) {
|
|
145
|
+
return nextLayer(request);
|
|
146
|
+
}
|
|
131
147
|
// Skip re-entry: if we already swapped auth to Basic, pass through
|
|
132
148
|
if (request._tpsAuthVerified)
|
|
133
149
|
return nextLayer(request);
|
|
134
150
|
const header = request.headers.get("authorization") || request.headers?.asObject?.authorization || "";
|
|
135
|
-
// ── Basic admin auth
|
|
136
|
-
// Allow Basic auth
|
|
137
|
-
//
|
|
151
|
+
// ── Basic admin / super_user auth ──────────────────────────────────────────
|
|
152
|
+
// Allow Basic auth for CLI operations (backup, etc.). Two paths:
|
|
153
|
+
// 1. HDB_ADMIN_PASSWORD env-var fast-path (user must be "admin" with exact pass)
|
|
154
|
+
// 2. Harper super_user check — any user with super_user:true permission accepted
|
|
155
|
+
// Checked BEFORE Ed25519 so admin tools can use simple auth.
|
|
138
156
|
if (header.startsWith("Basic ")) {
|
|
139
157
|
try {
|
|
140
158
|
const decoded = Buffer.from(header.slice(6), "base64").toString("utf-8");
|
|
141
|
-
const
|
|
159
|
+
const colonIdx = decoded.indexOf(":");
|
|
160
|
+
const user = colonIdx >= 0 ? decoded.slice(0, colonIdx) : decoded;
|
|
161
|
+
const pass = colonIdx >= 0 ? decoded.slice(colonIdx + 1) : "";
|
|
162
|
+
// Path 1: Env-var fast-path (back-compat). Only matches user==="admin"
|
|
163
|
+
// with exact HDB_ADMIN_PASSWORD. Non-match falls through to Path 2.
|
|
142
164
|
const adminPass = getAdminPass();
|
|
143
165
|
if (adminPass !== null && user === "admin" && pass === adminPass) {
|
|
144
166
|
// Mark as verified and set Harper user directly
|
|
@@ -150,14 +172,47 @@ server.http(async (request, nextLayer) => {
|
|
|
150
172
|
request.headers.set("x-tps-agent", "admin");
|
|
151
173
|
if (request.headers.asObject)
|
|
152
174
|
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
175
|
request.tpsAgent = "admin";
|
|
158
176
|
request.tpsAgentIsAdmin = true;
|
|
159
177
|
return nextLayer(request);
|
|
160
178
|
}
|
|
179
|
+
// Path 2: Harper super_user check — any user with super_user:true
|
|
180
|
+
let harperUser = null;
|
|
181
|
+
try {
|
|
182
|
+
harperUser = await server.getUser(user, pass, request);
|
|
183
|
+
}
|
|
184
|
+
catch { /* fall through — invalid creds, non-existent user, etc. */ }
|
|
185
|
+
if (harperUser?.role?.permission?.super_user === true) {
|
|
186
|
+
request._tpsAuthVerified = true;
|
|
187
|
+
request.user = harperUser;
|
|
188
|
+
request.headers.set("x-tps-agent", user);
|
|
189
|
+
if (request.headers.asObject)
|
|
190
|
+
request.headers.asObject["x-tps-agent"] = user;
|
|
191
|
+
request.tpsAgent = user;
|
|
192
|
+
request.tpsAgentIsAdmin = true;
|
|
193
|
+
return nextLayer(request);
|
|
194
|
+
}
|
|
195
|
+
// Path 3: flair_pair_initiator — restricted to /FederationPair only.
|
|
196
|
+
// Bootstrap credentials (pair-bootstrap-<id>) may only be used on this
|
|
197
|
+
// one endpoint. Any other path must fall through to 401.
|
|
198
|
+
if (url.pathname === "/FederationPair" && user.startsWith("pair-bootstrap-")) {
|
|
199
|
+
let pairUser = null;
|
|
200
|
+
try {
|
|
201
|
+
pairUser = await server.getUser(user, pass, request);
|
|
202
|
+
}
|
|
203
|
+
catch { /* fall through */ }
|
|
204
|
+
if (pairUser?.role?.role === "flair_pair_initiator" &&
|
|
205
|
+
pairUser?.active === true) {
|
|
206
|
+
request._tpsAuthVerified = true;
|
|
207
|
+
request.user = pairUser;
|
|
208
|
+
request.headers.set("x-tps-agent", user);
|
|
209
|
+
if (request.headers.asObject)
|
|
210
|
+
request.headers.asObject["x-tps-agent"] = user;
|
|
211
|
+
request.tpsAgent = user;
|
|
212
|
+
request.tpsAgentIsAdmin = false;
|
|
213
|
+
return nextLayer(request);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
161
216
|
}
|
|
162
217
|
catch { /* fall through to Ed25519 check */ }
|
|
163
218
|
return new Response(JSON.stringify({ error: "invalid_admin_credentials" }), { status: 401 });
|