@tpsdev-ai/flair 0.22.0 → 0.23.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/dist/cli.js +612 -119
- package/dist/doctor-client.js +176 -0
- package/dist/install/clients.js +46 -11
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +80 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/migrations/runner.js +2 -3
- package/dist/resources/migrations/space.js +111 -17
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +2 -2
- package/schemas/memory.graphql +2 -2
package/dist/doctor-client.js
CHANGED
|
@@ -135,6 +135,37 @@ function scanCodexFlairBlock(raw) {
|
|
|
135
135
|
const flairUrl = urlMatch?.[1] || undefined;
|
|
136
136
|
return { present: !!agentId && !!flairUrl, agentId, flairUrl };
|
|
137
137
|
}
|
|
138
|
+
// ── check 2: FLAIR_URL to use when (re-)wiring a client (flair#727) ────────
|
|
139
|
+
/**
|
|
140
|
+
* Pick the FLAIR_URL to feed a wire() call when `doctor --fix` re-wires a
|
|
141
|
+
* client whose block was judged "not present" (readClientMcpBlock — missing
|
|
142
|
+
* FLAIR_AGENT_ID and/or FLAIR_URL). A pre-existing config can still carry a
|
|
143
|
+
* `flairUrl` fragment (e.g. `present:false` because FLAIR_AGENT_ID is empty,
|
|
144
|
+
* but FLAIR_URL scanned fine) — and that fragment can itself be malformed: a
|
|
145
|
+
* bare host with no scheme/port (`"127.0.0.1"`), left over from an older
|
|
146
|
+
* Flair version or a hand-edited config. Blindly reusing it perpetuates the
|
|
147
|
+
* corruption into the freshly suggested block (flair#727 — a real dogfood
|
|
148
|
+
* run printed exactly `FLAIR_URL = "127.0.0.1"`, unusable if pasted).
|
|
149
|
+
*
|
|
150
|
+
* Only trust `existingFlairUrl` when it parses as an absolute http(s) URL;
|
|
151
|
+
* otherwise fall back to `baseUrl` — the live, authoritative URL `doctor`
|
|
152
|
+
* already computed from the same port source as its "Config: ... (port:
|
|
153
|
+
* NNNNN)" line (resolveHttpPort / readPortFromConfig, with live-port
|
|
154
|
+
* discovery layered on top).
|
|
155
|
+
*/
|
|
156
|
+
export function resolveWireFlairUrl(existingFlairUrl, baseUrl) {
|
|
157
|
+
if (existingFlairUrl) {
|
|
158
|
+
try {
|
|
159
|
+
const parsed = new URL(existingFlairUrl);
|
|
160
|
+
if (parsed.protocol === "http:" || parsed.protocol === "https:")
|
|
161
|
+
return existingFlairUrl;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
// Not an absolute URL (e.g. a bare host like "127.0.0.1") — fall through.
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return baseUrl;
|
|
168
|
+
}
|
|
138
169
|
/**
|
|
139
170
|
* Pass when EITHER the project-scoped `${cwd}/CLAUDE.md` or the user-level
|
|
140
171
|
* `~/.claude/CLAUDE.md` contains the bootstrap marker — Claude Code loads
|
|
@@ -310,3 +341,148 @@ export function applyOrReportSessionStartHook(homeDir, agentId, skip) {
|
|
|
310
341
|
const fix = fixSessionStartHook(homeDir, agentId);
|
|
311
342
|
return { applied: fix.ok, ok: fix.ok, message: fix.message, hint: fix.ok ? undefined : hint };
|
|
312
343
|
}
|
|
344
|
+
// ── check 5: per-agent iteration for verified-read sections (flair#722) ────
|
|
345
|
+
//
|
|
346
|
+
// `doctor`'s Fleet presence and Migrations sections need a signed (Ed25519)
|
|
347
|
+
// request to reveal server-verified fields (flairVersion/harperVersion,
|
|
348
|
+
// migration state) — previously that meant passing --agent explicitly, even
|
|
349
|
+
// though doctor already enumerates every key in ~/.flair/keys (the "Keys
|
|
350
|
+
// found: N agent(s)" line above). A real dogfood run found the #720
|
|
351
|
+
// halted-migration warning visible via `flair status --agent local` but
|
|
352
|
+
// invisible in the default `doctor` run the same user ran minutes later.
|
|
353
|
+
//
|
|
354
|
+
// These two functions are the pure decision logic for iterating and
|
|
355
|
+
// rendering per agent — no fs, no network, no crypto — so they're
|
|
356
|
+
// unit-testable the same way as the rest of this module. The actual signed
|
|
357
|
+
// fetches (which reuse authFetch/checkAgentRegistered, private to cli.ts)
|
|
358
|
+
// stay in src/cli.ts and call these to decide who to iterate and how a given
|
|
359
|
+
// agent's registration-gate outcome should render.
|
|
360
|
+
/**
|
|
361
|
+
* Decide which agent ids the verified-read sections should iterate over.
|
|
362
|
+
* - `agentFlag` given -> exactly that one id (a plain filter — unchanged
|
|
363
|
+
* pre-#722 semantics: doctor still tries a single signed identity, it
|
|
364
|
+
* just doesn't widen to "every key"). Doesn't require the id to already
|
|
365
|
+
* have a key on disk; the registration gate reports "no local key" for
|
|
366
|
+
* that case rather than silently expanding the search.
|
|
367
|
+
* - no `agentFlag` -> every id in `keyAgentIds` (the ~/.flair/keys
|
|
368
|
+
* enumeration doctor's own "Keys found" check already did), sorted for
|
|
369
|
+
* deterministic, reproducible output across runs.
|
|
370
|
+
*/
|
|
371
|
+
export function planAgentIterations(keyAgentIds, agentFlag) {
|
|
372
|
+
if (agentFlag)
|
|
373
|
+
return [agentFlag];
|
|
374
|
+
return [...keyAgentIds].sort();
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Render decision for one agent's registration-gate outcome, ahead of a
|
|
378
|
+
* verified-read section (Fleet presence / Migrations). Returns null when the
|
|
379
|
+
* agent is registered — the caller should proceed with its actual signed
|
|
380
|
+
* read for that agent. Otherwise returns the finding to print for THAT
|
|
381
|
+
* agent's subsection; the caller must still move on to the next agent
|
|
382
|
+
* (failure isolation, flair#722) rather than aborting the whole section.
|
|
383
|
+
*/
|
|
384
|
+
export function describeAgentGateFinding(agentId, state, detail) {
|
|
385
|
+
switch (state) {
|
|
386
|
+
case "registered":
|
|
387
|
+
return null;
|
|
388
|
+
case "no-key":
|
|
389
|
+
return {
|
|
390
|
+
icon: "warn",
|
|
391
|
+
message: `no local key for '${agentId}' — skipping${detail ? ` (${detail})` : ""}`,
|
|
392
|
+
isIssue: false,
|
|
393
|
+
};
|
|
394
|
+
case "not-registered":
|
|
395
|
+
return {
|
|
396
|
+
icon: "error",
|
|
397
|
+
message: `agent '${agentId}' has a local key but is NOT registered on this Flair instance`,
|
|
398
|
+
// Two ways out, both actionable — register it if it should exist, or
|
|
399
|
+
// (flair#734) clean it up if it's a stale/leftover key. `flair keys
|
|
400
|
+
// prune` never touches a key that IS registered, so it's always a
|
|
401
|
+
// safe suggestion here even when the right fix is actually `agent add`.
|
|
402
|
+
fixHint: `flair agent add ${agentId} (if it should be registered) — or flair keys prune (if it's a stale/leftover key)`,
|
|
403
|
+
isIssue: true,
|
|
404
|
+
};
|
|
405
|
+
case "unreachable":
|
|
406
|
+
return {
|
|
407
|
+
icon: "warn",
|
|
408
|
+
message: `could not verify agent '${agentId}' registration${detail ? ` (${detail})` : ""}`,
|
|
409
|
+
isIssue: false,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
// ── check 6: `flair keys prune` classification (flair#734) ─────────────────
|
|
414
|
+
//
|
|
415
|
+
// Follow-up to #731's doctor agent-iteration, which made previously-invisible
|
|
416
|
+
// stale keys visible (each renders as a "not registered" gate finding, check
|
|
417
|
+
// 5 above) but shipped no command to act on it — every doctor run just
|
|
418
|
+
// re-reported the same noise. `flair keys prune` (src/cli.ts) walks the key
|
|
419
|
+
// dir and moves anything it can positively classify as prunable into
|
|
420
|
+
// `<keysDir>/.pruned/<date>/` — never deletes. The network-dependent half
|
|
421
|
+
// (is this agentId actually registered?) reuses checkAgentRegistered
|
|
422
|
+
// (src/cli.ts), the exact same signed GET /Agent/:id check 5's gate uses.
|
|
423
|
+
// This module only owns the PURE decision — given a file's seed-validity and
|
|
424
|
+
// (if checked) registration state, what class is it and why — plus two
|
|
425
|
+
// path/naming helpers pure enough to live here (no crypto, no network).
|
|
426
|
+
/** Name of the archive subdirectory prune moves prunable files into —
|
|
427
|
+
* `<keysDir>/.pruned/<date>/`. Also the one directory name the scanner
|
|
428
|
+
* itself must skip when walking the key dir (never re-classify prune's own
|
|
429
|
+
* archive as a candidate). */
|
|
430
|
+
export const PRUNED_DIR_NAME = ".pruned";
|
|
431
|
+
/** `YYYY-MM-DD`, UTC — the `<date>` component of the archive path. UTC (not
|
|
432
|
+
* local time) so a single prune run always lands in exactly one date
|
|
433
|
+
* bucket regardless of the host's timezone. */
|
|
434
|
+
export function pruneDateStamp(now = new Date()) {
|
|
435
|
+
return now.toISOString().slice(0, 10);
|
|
436
|
+
}
|
|
437
|
+
/**
|
|
438
|
+
* Pick a collision-free destination filename for a move into an archive
|
|
439
|
+
* directory that may already hold a file of the same name (e.g. two prune
|
|
440
|
+
* runs on the same UTC day). Preserves the original name whenever possible;
|
|
441
|
+
* on collision appends `.2`, `.3`, ... until free. Pure — the caller supplies
|
|
442
|
+
* the set of names already present (or about to be present, within the same
|
|
443
|
+
* run) at the destination; no fs access happens here.
|
|
444
|
+
*/
|
|
445
|
+
export function resolveCollisionSafeName(existingNames, filename) {
|
|
446
|
+
const existing = existingNames instanceof Set ? existingNames : new Set(existingNames);
|
|
447
|
+
if (!existing.has(filename))
|
|
448
|
+
return filename;
|
|
449
|
+
let n = 2;
|
|
450
|
+
while (existing.has(`${filename}.${n}`))
|
|
451
|
+
n++;
|
|
452
|
+
return `${filename}.${n}`;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Classify one `.key` file given whether its seed parsed (`seedValid`) and,
|
|
456
|
+
* if it did, the registration-gate result checkAgentRegistered (src/cli.ts)
|
|
457
|
+
* returned for it — the SAME check doctor's "not registered" finding above
|
|
458
|
+
* is built from. Pure — no fs/crypto/network; the caller (classifyKeysDir,
|
|
459
|
+
* src/cli.ts) does the actual file read, seed parse, and signed registration
|
|
460
|
+
* check, and only calls this to decide what the result means.
|
|
461
|
+
*
|
|
462
|
+
* `registration` is ignored (pass null) when `seedValid` is false — an
|
|
463
|
+
* unparseable seed can't be signed with, so it was never checked against the
|
|
464
|
+
* instance, regardless of what agentId its filename implies.
|
|
465
|
+
*
|
|
466
|
+
* Deliberately has no case for "unreachable": classifyKeysDir aborts the
|
|
467
|
+
* WHOLE run before classifying anything once the instance is confirmed
|
|
468
|
+
* unreachable (never classify offline) — this function is only ever called
|
|
469
|
+
* once that's already been ruled out.
|
|
470
|
+
*/
|
|
471
|
+
export function classifyKeyFile(agentId, seedValid, registration, baseUrl) {
|
|
472
|
+
if (!seedValid) {
|
|
473
|
+
return { class: "invalid", reason: "not a parseable Ed25519 private key seed" };
|
|
474
|
+
}
|
|
475
|
+
if (registration?.state === "registered") {
|
|
476
|
+
return { class: "keep", reason: `agent '${agentId}' is registered on ${baseUrl} — never pruned` };
|
|
477
|
+
}
|
|
478
|
+
// "not-registered", "no-key", or (defensively) no registration result at
|
|
479
|
+
// all — every one of those means we could not confirm this agent is
|
|
480
|
+
// registered, so it's prunable. "no-key" is not expected in practice here
|
|
481
|
+
// (the file we just parsed a valid seed FROM is itself the key
|
|
482
|
+
// checkAgentRegistered would sign with), but is handled the same way
|
|
483
|
+
// rather than left as an unclassified gap.
|
|
484
|
+
return {
|
|
485
|
+
class: "stale",
|
|
486
|
+
reason: `agent '${agentId}' is not registered on ${baseUrl}${registration?.detail ? ` (${registration.detail})` : ""}`,
|
|
487
|
+
};
|
|
488
|
+
}
|
package/dist/install/clients.js
CHANGED
|
@@ -110,15 +110,22 @@ function flairMcpEntry(env) {
|
|
|
110
110
|
return {
|
|
111
111
|
command: "npx",
|
|
112
112
|
args: ["-y", "@tpsdev-ai/flair-mcp"],
|
|
113
|
-
env: {
|
|
113
|
+
env: {
|
|
114
|
+
FLAIR_AGENT_ID: env.FLAIR_AGENT_ID,
|
|
115
|
+
FLAIR_URL: env.FLAIR_URL,
|
|
116
|
+
// flair#718 — only present when the caller set it; absent = omitted,
|
|
117
|
+
// not written as FLAIR_CLIENT: undefined.
|
|
118
|
+
...(env.FLAIR_CLIENT ? { FLAIR_CLIENT: env.FLAIR_CLIENT } : {}),
|
|
119
|
+
},
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
/** Pretty-printed JSON `mcpServers.flair` snippet for copy-paste fallbacks. */
|
|
117
123
|
function jsonSnippet(env) {
|
|
118
124
|
return JSON.stringify({ mcpServers: { flair: flairMcpEntry(env) } }, null, 2);
|
|
119
125
|
}
|
|
120
|
-
/** TOML `[mcp_servers.flair]` snippet (Codex format).
|
|
121
|
-
|
|
126
|
+
/** TOML `[mcp_servers.flair]` snippet (Codex format). Exported for tests
|
|
127
|
+
* (flair#727 — asserts the rendered template carries a full scheme+port URL). */
|
|
128
|
+
export function tomlSnippet(env) {
|
|
122
129
|
return [
|
|
123
130
|
`[mcp_servers.flair]`,
|
|
124
131
|
`command = "npx"`,
|
|
@@ -127,8 +134,32 @@ function tomlSnippet(env) {
|
|
|
127
134
|
`[mcp_servers.flair.env]`,
|
|
128
135
|
`FLAIR_AGENT_ID = "${env.FLAIR_AGENT_ID}"`,
|
|
129
136
|
`FLAIR_URL = "${env.FLAIR_URL}"`,
|
|
137
|
+
// flair#718 — only present when the caller set it (same rule as flairMcpEntry above).
|
|
138
|
+
...(env.FLAIR_CLIENT ? [`FLAIR_CLIENT = "${env.FLAIR_CLIENT}"`] : []),
|
|
130
139
|
].join("\n");
|
|
131
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* True when `raw` TOML content already has a `[mcp_servers.flair]` header —
|
|
143
|
+
* the same detection scanCodexFlairBlock (src/doctor-client.ts) uses to
|
|
144
|
+
* decide whether the block is present. Pure string scan; no TOML parser
|
|
145
|
+
* (see the comment on _wireCodex below for why).
|
|
146
|
+
*/
|
|
147
|
+
export function codexConfigHasFlairSection(raw) {
|
|
148
|
+
return /^\[mcp_servers\.flair\]\s*$/m.test(raw);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Pure merge: append the Flair TOML snippet to existing raw config.toml
|
|
152
|
+
* content. Callers MUST first confirm codexConfigHasFlairSection(raw) is
|
|
153
|
+
* false — appending a second `[mcp_servers.flair]` table would shadow/
|
|
154
|
+
* duplicate the first (TOML doesn't merge repeated table headers), so this
|
|
155
|
+
* function does not re-check; it just appends safely with a newline
|
|
156
|
+
* separator (mirrors fixClaudeMdBootstrap's separator logic in
|
|
157
|
+
* src/doctor-client.ts — never runs the new block into the prior line).
|
|
158
|
+
*/
|
|
159
|
+
export function appendCodexFlairBlock(raw, env) {
|
|
160
|
+
const separator = raw.length === 0 ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
|
|
161
|
+
return raw + separator + tomlSnippet(env) + "\n";
|
|
162
|
+
}
|
|
132
163
|
/**
|
|
133
164
|
* Merge the Flair MCP server into a JSON config file with an `mcpServers` map.
|
|
134
165
|
* Creates the file (and parent dir) if absent; preserves existing servers and
|
|
@@ -211,18 +242,22 @@ function _wireClaudeCode(env) {
|
|
|
211
242
|
}
|
|
212
243
|
function _wireCodex(env) {
|
|
213
244
|
// Codex uses TOML with a [mcp_servers.flair] table. We don't carry a TOML
|
|
214
|
-
// parser,
|
|
215
|
-
//
|
|
216
|
-
//
|
|
245
|
+
// parser, but appending a new top-level table at EOF is safe TOML when the
|
|
246
|
+
// exact header isn't already present (flair#727) — so an existing file only
|
|
247
|
+
// forces the manual-print fallback when it's genuinely unreadable/
|
|
248
|
+
// unwritable (permissions, I/O error), never merely "exists". A file that
|
|
249
|
+
// already has the section is reported already-wired, matching the JSON
|
|
250
|
+
// clients' idempotency (wireJsonMcp above).
|
|
217
251
|
const path = codexConfigPath();
|
|
218
252
|
const display = "~/.codex/config.toml";
|
|
219
253
|
try {
|
|
220
254
|
if (existsSync(path)) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
message: `Codex:
|
|
224
|
-
|
|
225
|
-
|
|
255
|
+
const raw = readFileSync(path, "utf-8");
|
|
256
|
+
if (codexConfigHasFlairSection(raw)) {
|
|
257
|
+
return { ok: true, message: `Codex: already wired in ${display}` };
|
|
258
|
+
}
|
|
259
|
+
writeFileSync(path, appendCodexFlairBlock(raw, env));
|
|
260
|
+
return { ok: true, message: `Codex: wired ${display} (restart Codex to pick it up)` };
|
|
226
261
|
}
|
|
227
262
|
mkdirSync(dirname(path), { recursive: true });
|
|
228
263
|
writeFileSync(path, tomlSnippet(env) + "\n");
|
package/dist/probe.js
CHANGED
|
@@ -64,27 +64,40 @@ export async function probeInstance(baseUrl, opts = {}) {
|
|
|
64
64
|
version: null,
|
|
65
65
|
versionMatch: null,
|
|
66
66
|
ok: false,
|
|
67
|
+
authFailureKind: null,
|
|
67
68
|
error: `instance did not answer ${base}/Health within ${timeoutMs}ms` +
|
|
68
69
|
(lastHealthError ? ` (last error: ${lastHealthError})` : ""),
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
if (!authedGet) {
|
|
72
|
-
return { healthy: true, authenticated: null, version: null, versionMatch: null, ok: true };
|
|
73
|
+
return { healthy: true, authenticated: null, version: null, versionMatch: null, ok: true, authFailureKind: null };
|
|
73
74
|
}
|
|
74
75
|
let version = null;
|
|
75
76
|
let authError;
|
|
77
|
+
let authFailureKind = null;
|
|
76
78
|
try {
|
|
77
79
|
const body = await authedGet(versionPath);
|
|
78
80
|
version = typeof body?.version === "string" ? body.version : null;
|
|
79
81
|
}
|
|
80
82
|
catch (err) {
|
|
81
83
|
authError = err?.message ?? String(err);
|
|
84
|
+
// flair#741: classify the failure so callers can tell "server responded,
|
|
85
|
+
// credentials rejected" (liveness proven) from "can't tell what state
|
|
86
|
+
// this instance is in". Duck-typed on `.status` — any authedGet
|
|
87
|
+
// implementation can opt in by throwing an error with a numeric status;
|
|
88
|
+
// api() (src/cli.ts) does via ApiHttpError. No recognizable status (a
|
|
89
|
+
// plain network error, or an authedGet that doesn't set one) stays
|
|
90
|
+
// conservative and lands in "server".
|
|
91
|
+
const status = typeof err?.status === "number" ? err.status : null;
|
|
92
|
+
authFailureKind = status === 401 || status === 403 ? "credentials" : "server";
|
|
82
93
|
}
|
|
83
94
|
const authenticated = authError === undefined;
|
|
84
95
|
let versionMatch = null;
|
|
85
96
|
if (authenticated && expectVersion !== undefined) {
|
|
86
97
|
versionMatch = version === expectVersion;
|
|
87
98
|
}
|
|
99
|
+
if (authenticated)
|
|
100
|
+
authFailureKind = null;
|
|
88
101
|
const ok = healthy && authenticated && versionMatch !== false;
|
|
89
102
|
let error;
|
|
90
103
|
if (!authenticated) {
|
|
@@ -93,5 +106,5 @@ export async function probeInstance(baseUrl, opts = {}) {
|
|
|
93
106
|
else if (versionMatch === false) {
|
|
94
107
|
error = `version mismatch: expected ${expectVersion}, instance reports ${version ?? "unknown"}`;
|
|
95
108
|
}
|
|
96
|
-
return { healthy, authenticated, version, versionMatch, ok, error };
|
|
109
|
+
return { healthy, authenticated, version, versionMatch, ok, error, authFailureKind };
|
|
97
110
|
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -1,29 +1,44 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
2
|
import { patchRecord, withDetachedTxn } from "./table-helpers.js";
|
|
3
|
-
import { isAdmin, resolveAgentAuth
|
|
3
|
+
import { isAdmin, resolveAgentAuth } from "./agent-auth.js";
|
|
4
4
|
import { localInstanceId } from "./instance-identity.js";
|
|
5
5
|
import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
6
6
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
7
7
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
8
8
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
9
|
-
import { resolveReadScope } from "./memory-read-scope.js";
|
|
10
9
|
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
11
|
-
import { buildProvenance } from "./
|
|
12
|
-
|
|
13
|
-
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
14
|
-
const NOT_FOUND = () => new Response(JSON.stringify({ error: "not found" }), { status: 404, headers: { "Content-Type": "application/json" } });
|
|
10
|
+
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
11
|
+
import { RECORD_TYPES } from "./record-types.js";
|
|
15
12
|
/**
|
|
16
|
-
* Owner ids a non-admin agent may READ (resolveAllowedOwners)
|
|
17
|
-
* read-scope
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
13
|
+
* Owner ids a non-admin agent may READ (resolveAllowedOwners) live in
|
|
14
|
+
* ./memory-read-scope.ts — still exported/used elsewhere (admin tooling).
|
|
15
|
+
* The full read-scope condition + private-exclusion predicate is now
|
|
16
|
+
* consumed through ./record-type-kit.ts's makeReadScope(), parameterized
|
|
17
|
+
* from RECORD_TYPES.Memory (record-types slice 2, flair#520) rather than a
|
|
18
|
+
* hand-typed "open-within-org" literal — the registry is now the single
|
|
19
|
+
* source of truth this class draws its read-scope mode from. makeReadScope
|
|
20
|
+
* delegates "open-within-org" to ./memory-read-scope.ts's resolveReadScope()
|
|
21
|
+
* UNCHANGED — the ONE centralized helper every cross-agent Memory read path
|
|
22
|
+
* (search()/get() here, SemanticSearch.ts, MemoryBootstrap.ts, auth-
|
|
23
|
+
* middleware.ts's by-id guard) resolves its scope through, so the scoping
|
|
24
|
+
* rule cannot drift per-path again (a SemanticSearch inline
|
|
25
|
+
* `visibility === "office"` OR-clause leaked office memories to any
|
|
26
|
+
* authenticated agent because the rule had scattered). See memory-read-
|
|
27
|
+
* scope.ts's doc for the migration invariant (no-visibility-field reads as
|
|
28
|
+
* "shared", never "private").
|
|
29
|
+
*
|
|
30
|
+
* Exported (not just a module-local const) solely so
|
|
31
|
+
* test/unit/record-types-registry.test.ts's drift tripwire can introspect
|
|
32
|
+
* the composed resolver's tagged `.mode`/`.ownerField` (see makeReadScope's
|
|
33
|
+
* doc in record-type-kit.ts) against RECORD_TYPES.Memory — not for any
|
|
34
|
+
* other runtime consumer.
|
|
26
35
|
*/
|
|
36
|
+
export const memoryReadScope = makeReadScope(RECORD_TYPES.Memory.readScope, RECORD_TYPES.Memory.ownerField);
|
|
37
|
+
const memoryByIdReadGate = makeByIdReadGate(memoryReadScope);
|
|
38
|
+
// See makeAuthGate's doc (record-type-kit.ts): must be wired as a genuine
|
|
39
|
+
// prototype method below, never a class-field assignment — Harper's
|
|
40
|
+
// relationship-traversal RBAC path reads allowRead off the prototype.
|
|
41
|
+
const memoryAuthGate = makeAuthGate();
|
|
27
42
|
/**
|
|
28
43
|
* ─── Server-side conservative-duplicate gate (memory-integrity fix) ──────────
|
|
29
44
|
*
|
|
@@ -335,14 +350,16 @@ function defaultVisibilityForDurability(durability) {
|
|
|
335
350
|
/**
|
|
336
351
|
* ─── Write-time provenance stamp (memory-provenance slice 1) ────────────────
|
|
337
352
|
*
|
|
338
|
-
* `buildProvenance` itself
|
|
353
|
+
* `buildProvenance` itself lives in ./provenance.ts, re-exported unmodified
|
|
354
|
+
* via ./record-type-kit.ts (imported above) for a single kit import surface —
|
|
339
355
|
* extracted so resources/Relationship.ts's write path can reuse the EXACT
|
|
340
356
|
* same `{v, verified, claimed?}` shape (the relationship-write-path spec's
|
|
341
357
|
* "reuse buildProvenance as-is" contract) instead of a hand-copied format
|
|
342
358
|
* that could drift. See that module for the full field-by-field rationale
|
|
343
359
|
* (verified.agentId from the auth verdict never the body, verified.timestamp
|
|
344
|
-
* = the server-computed createdAt, optional unverified claimed.model
|
|
345
|
-
*
|
|
360
|
+
* = the server-computed createdAt, optional unverified claimed.model /
|
|
361
|
+
* claimed.client passthroughs — the latter added by flair#718 authorship-
|
|
362
|
+
* provenance). Deliberately NOT implemented in this slice: a
|
|
346
363
|
* context-fingerprint field — bootstrap doesn't return the IDs a fingerprint
|
|
347
364
|
* would need, so it requires client cooperation that's out of scope here.
|
|
348
365
|
*/
|
|
@@ -394,13 +411,15 @@ export class Memory extends databases.flair.Memory {
|
|
|
394
411
|
* unverified, risks regressing owner writes/deletes on a P0 security fix
|
|
395
412
|
* that is scoped to the read leak — left as-is on purpose.
|
|
396
413
|
*/
|
|
397
|
-
allowRead() { return
|
|
414
|
+
allowRead() { return memoryAuthGate.call(this); }
|
|
398
415
|
/**
|
|
399
416
|
* Override get() to scope by-id reads the same way search() scopes
|
|
400
417
|
* collection reads (memory-soul-read-gate fix). Never distinguishes
|
|
401
418
|
* "doesn't exist" from "exists but not yours" — both return 404, never
|
|
402
419
|
* 403, so a denied caller can't use get() to enumerate other agents'
|
|
403
|
-
* memory ids.
|
|
420
|
+
* memory ids. Wired through record-type-kit.ts's makeByIdReadGate, scoped
|
|
421
|
+
* with Memory's own "open-within-org" read-scope resolver above — same
|
|
422
|
+
* dispatch shape Relationship.ts/WorkspaceState.ts's get() overrides use.
|
|
404
423
|
*/
|
|
405
424
|
async get(target) {
|
|
406
425
|
// Collection / query reads — the `GET /Memory/?<query>` form and the bare
|
|
@@ -412,31 +431,13 @@ export class Memory extends databases.flair.Memory {
|
|
|
412
431
|
// authenticated self-query would 404 (regression caught by the auth-
|
|
413
432
|
// middleware e2e "TPS-Ed25519 on GET /Memory/?agentId=X → 200"). A by-id
|
|
414
433
|
// get (RequestTarget with isCollection false, or a bare id) falls through.
|
|
434
|
+
// makeByIdReadGate re-applies this same guard internally (delegating to
|
|
435
|
+
// this.search via `.call(this, ...)`) — kept here too as documentation of
|
|
436
|
+
// the invariant at the call site, harmless no-op double-check.
|
|
415
437
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
416
438
|
return this.search(target);
|
|
417
439
|
}
|
|
418
|
-
|
|
419
|
-
const auth = await resolveAgentAuth(ctx);
|
|
420
|
-
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
421
|
-
// this is defense-in-depth if get() is ever reached directly.
|
|
422
|
-
if (auth.kind === "anonymous") {
|
|
423
|
-
return NOT_FOUND();
|
|
424
|
-
}
|
|
425
|
-
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
426
|
-
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
427
|
-
return super.get(target);
|
|
428
|
-
}
|
|
429
|
-
// Non-admin agent: only its own memories (any visibility), or a granted
|
|
430
|
-
// owner's SHARED memories — never that owner's private ones (Layer 1
|
|
431
|
-
// private-exclusion). Centralized in resolveReadScope() so this
|
|
432
|
-
// and search() below cannot drift.
|
|
433
|
-
const record = await super.get(target);
|
|
434
|
-
if (!record)
|
|
435
|
-
return NOT_FOUND();
|
|
436
|
-
const scope = await resolveReadScope(auth.agentId);
|
|
437
|
-
if (!scope.isAllowed(record))
|
|
438
|
-
return NOT_FOUND();
|
|
439
|
-
return record;
|
|
440
|
+
return memoryByIdReadGate.call(this, target, (t) => super.get(t));
|
|
440
441
|
}
|
|
441
442
|
/**
|
|
442
443
|
* Override search() to scope collection GETs by authenticated agent.
|
|
@@ -451,23 +452,24 @@ export class Memory extends databases.flair.Memory {
|
|
|
451
452
|
async search(query) {
|
|
452
453
|
// Access request context via Harper's Resource instance context.
|
|
453
454
|
const ctx = this.getContext?.();
|
|
454
|
-
const auth = await resolveAgentAuth(ctx);
|
|
455
455
|
// Anonymous HTTP must NOT read memories. (Previously `!authAgent` was treated
|
|
456
456
|
// as unfiltered — the anonymous-read leak once the gate stops rejecting.)
|
|
457
|
-
if (auth.kind === "anonymous") {
|
|
458
|
-
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
459
|
-
status: 401, headers: { "content-type": "application/json" },
|
|
460
|
-
});
|
|
461
|
-
}
|
|
462
457
|
// Trusted internal call (no request context) or admin agent — unfiltered.
|
|
463
|
-
|
|
458
|
+
// Non-admin agent: scoped below. Dispatch shape shared via
|
|
459
|
+
// record-type-kit.ts's resolveAuthGate — same three-way branch
|
|
460
|
+
// Relationship.ts/WorkspaceState.ts's search() use.
|
|
461
|
+
const gate = await resolveAuthGate(ctx, UNAUTH());
|
|
462
|
+
if (gate.kind === "denied")
|
|
463
|
+
return gate.response;
|
|
464
|
+
if (gate.kind === "unfiltered")
|
|
464
465
|
return super.search(query);
|
|
465
|
-
}
|
|
466
466
|
// Non-admin agent: scope to own (any visibility) + granted owners' SHARED
|
|
467
467
|
// memories only (Layer 1 private-exclusion). Centralized in
|
|
468
|
-
//
|
|
469
|
-
|
|
470
|
-
|
|
468
|
+
// memoryReadScope (record-type-kit.ts's makeReadScope(), parameterized
|
|
469
|
+
// from RECORD_TYPES.Memory — see this file's header — delegating
|
|
470
|
+
// "open-within-org" to memory-read-scope.ts's resolveReadScope()
|
|
471
|
+
// unchanged) so get() above and search() here cannot drift.
|
|
472
|
+
const scope = await memoryReadScope(gate.agentId);
|
|
471
473
|
const agentIdCondition = scope.condition;
|
|
472
474
|
// Harper passes `query` as a RequestTarget (extends URLSearchParams) or a
|
|
473
475
|
// conditions array. For URL-based GET /Memory?... calls, URL params are no
|
|
@@ -509,9 +511,14 @@ export class Memory extends databases.flair.Memory {
|
|
|
509
511
|
if (auth.kind === "anonymous") {
|
|
510
512
|
return UNAUTH();
|
|
511
513
|
}
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
514
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.Memory
|
|
515
|
+
// (record-types slice 2, flair#520) rather than a hand-typed literal.
|
|
516
|
+
// "validate-truthy" (see record-type-kit.ts's stampAttribution doc):
|
|
517
|
+
// reject a PRESENT, mismatched agentId; never stamp when absent (the
|
|
518
|
+
// caller is expected to have set it).
|
|
519
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.Memory.ownerField, RECORD_TYPES.Memory.attribution.post, "forbidden: cannot write memory owned by another agent");
|
|
520
|
+
if (attr.denied)
|
|
521
|
+
return attr.denied;
|
|
515
522
|
}
|
|
516
523
|
content.durability ||= "standard";
|
|
517
524
|
content.createdAt = new Date().toISOString();
|
|
@@ -605,6 +612,12 @@ export class Memory extends databases.flair.Memory {
|
|
|
605
612
|
// buildProvenance's doc above. Stamped last, right before persist, so it
|
|
606
613
|
// reflects the final resolved `content.createdAt`.
|
|
607
614
|
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
615
|
+
// flair#718 authorship-provenance: `claimedClient` is a WRITE-BODY-ONLY
|
|
616
|
+
// passthrough — buildProvenance above already folded it into
|
|
617
|
+
// `provenance.claimed.client` (sanitized/capped). Strip it from the row
|
|
618
|
+
// itself so it is NEVER persisted as a second, undeclared/unsanitized
|
|
619
|
+
// top-level field — authorship lives in the provenance JSON only.
|
|
620
|
+
delete content.claimedClient;
|
|
608
621
|
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
609
622
|
// 1) — see stampOriginatorInstanceId's doc above. No-op if already set
|
|
610
623
|
// (never fires for a genuine local write — no client sets this field).
|
|
@@ -652,9 +665,12 @@ export class Memory extends databases.flair.Memory {
|
|
|
652
665
|
if (auth.kind === "anonymous") {
|
|
653
666
|
return UNAUTH();
|
|
654
667
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
668
|
+
// No-forge attribution — mode/field drawn from RECORD_TYPES.Memory,
|
|
669
|
+
// same rule as post(). "validate-truthy" (see record-type-kit.ts's
|
|
670
|
+
// stampAttribution doc).
|
|
671
|
+
const attr = stampAttribution(auth, content, RECORD_TYPES.Memory.ownerField, RECORD_TYPES.Memory.attribution.put, "forbidden: cannot write memory owned by another agent");
|
|
672
|
+
if (attr.denied)
|
|
673
|
+
return attr.denied;
|
|
658
674
|
}
|
|
659
675
|
const now = new Date().toISOString();
|
|
660
676
|
content.updatedAt = now;
|
|
@@ -771,6 +787,10 @@ export class Memory extends databases.flair.Memory {
|
|
|
771
787
|
// always gets a freshly-stamped provenance reflecting the CURRENT
|
|
772
788
|
// authenticated actor performing this write.
|
|
773
789
|
content.provenance = buildProvenance(auth, content.createdAt, content);
|
|
790
|
+
// flair#718 authorship-provenance — see post()'s identical comment above:
|
|
791
|
+
// strip the write-body-only `claimedClient` passthrough now that it's
|
|
792
|
+
// folded into `provenance.claimed.client`. Never persisted as a row field.
|
|
793
|
+
delete content.claimedClient;
|
|
774
794
|
// Write-time originatorInstanceId stamp (federation-edge-hardening slice
|
|
775
795
|
// 1) — see stampOriginatorInstanceId's doc above post(). No-op if
|
|
776
796
|
// already set: an update/patch of an existing local record carries its
|