@tpsdev-ai/flair 0.50.0 → 0.51.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 +15 -4
- package/dist/build-info.json +3 -3
- package/dist/cli.js +188 -67
- package/dist/doctor-client.js +3 -3
- package/dist/install/clients.js +28 -17
- package/dist/lib/doctor-run.js +481 -0
- package/dist/lib/launchd-management.js +7 -26
- package/dist/resources/Federation.js +42 -20
- package/dist/resources/RecordUsage.js +13 -6
- package/dist/resources/SemanticSearch.js +8 -1
- package/dist/resources/federation-classify.js +90 -0
- package/dist/resources/health.js +9 -10
- package/dist/resources/mcp-tools.js +9 -6
- package/dist/resources/search-readiness.js +33 -10
- package/dist/resources/semantic-retrieval-core.js +39 -20
- package/dist/resources/usage-ids.js +63 -0
- package/docs/federation.md +11 -0
- package/docs/supply-chain-policy.md +1 -1
- package/docs/upgrade.md +17 -1
- package/package.json +2 -2
- package/schemas/federation.graphql +1 -1
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* doctor-run.ts — the enumerable install-health runner (flair#1439).
|
|
3
|
+
*
|
|
4
|
+
* `flair doctor` and `flair upgrade` used to keep two definitions of
|
|
5
|
+
* "healthy". Upgrade's success line (`renderVerifiedSummary`) could only
|
|
6
|
+
* qualify a ✅ by the facts in its signature — version plus launchd — so
|
|
7
|
+
* every other doctor check was invisible and rendered green. The previous
|
|
8
|
+
* fix special-cased one known-unhealthy form (launchd detach). That is a
|
|
9
|
+
* blacklist: the next unmeasured fact (a Codex SessionStart hook 0.49.0
|
|
10
|
+
* never wrote) printed the same unqualified success marker.
|
|
11
|
+
*
|
|
12
|
+
* This module is the single entry point that runs the named install-health
|
|
13
|
+
* checks. Adding a check to `DOCTOR_CHECK_IDS` automatically widens what
|
|
14
|
+
* upgrade claims. Success is asserted POSITIVELY: every catalog id must
|
|
15
|
+
* have been executed and none may have failed. An `unrun` member is never
|
|
16
|
+
* treated as a pass — if a reachable state cannot produce "unrun ⇒ no ✅",
|
|
17
|
+
* the enumeration is decorative and we have rebuilt the same guard.
|
|
18
|
+
*
|
|
19
|
+
* Skip (N/A) is not unrun. Linux has no launchd; a machine without Codex
|
|
20
|
+
* does not owe a Codex hook. Those checks execute and return `skip`.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
23
|
+
import { checkClaudeMdBootstrap, checkSessionStartHook, effectiveFlairUrl, hookCommandIsSilenced, isFlairHookCommand, partitionKeyIds, planAgentIterations, readClientMcpBlock, } from "../doctor-client.js";
|
|
24
|
+
import { hookInstallHint, hookSettingsPath, installHook, SUPPORTED_HARNESSES, } from "../hook-install.js";
|
|
25
|
+
import { isDetached, renderDetachedWarning, } from "./launchd-management.js";
|
|
26
|
+
/**
|
|
27
|
+
* The catalog. This is the contract upgrade asserts against.
|
|
28
|
+
*
|
|
29
|
+
* A new doctor install-health check is added HERE (id + runner). An id
|
|
30
|
+
* without a runner is emitted as `unrun` and blocks the success marker —
|
|
31
|
+
* that is the point, not a fallback.
|
|
32
|
+
*
|
|
33
|
+
* Flint's six named client-integration checks plus launchd (the previous
|
|
34
|
+
* special-case, now a catalog member rather than a side channel).
|
|
35
|
+
*/
|
|
36
|
+
export const DOCTOR_CHECK_IDS = [
|
|
37
|
+
"mcp-block",
|
|
38
|
+
"flair-url",
|
|
39
|
+
"claude-md",
|
|
40
|
+
"session-start-hook",
|
|
41
|
+
"verified-read",
|
|
42
|
+
"keys-prune",
|
|
43
|
+
"launchd-management",
|
|
44
|
+
];
|
|
45
|
+
const MCP_CLIENT_IDS = ["claude-code", "codex", "gemini", "cursor", "antigravity"];
|
|
46
|
+
function result(id, label, status, extra = {}) {
|
|
47
|
+
return { id, label, status, ...extra };
|
|
48
|
+
}
|
|
49
|
+
function runMcpBlock(ctx) {
|
|
50
|
+
const id = "mcp-block";
|
|
51
|
+
const label = "MCP server block";
|
|
52
|
+
const mcp = ctx.detectedClientIds.filter((c) => MCP_CLIENT_IDS.includes(c));
|
|
53
|
+
if (mcp.length === 0) {
|
|
54
|
+
return result(id, label, "skip", { detail: "no MCP client detected" });
|
|
55
|
+
}
|
|
56
|
+
const missing = [];
|
|
57
|
+
for (const clientId of mcp) {
|
|
58
|
+
const block = readClientMcpBlock(clientId, ctx.homeDir);
|
|
59
|
+
if (!block.present)
|
|
60
|
+
missing.push(`${clientId} (${block.configPath})`);
|
|
61
|
+
}
|
|
62
|
+
if (missing.length > 0) {
|
|
63
|
+
return result(id, label, "fail", {
|
|
64
|
+
detail: `no Flair MCP server configured: ${missing.join(", ")}`,
|
|
65
|
+
remedy: "flair doctor --fix",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
return result(id, label, "pass", { detail: `configured for ${mcp.join(", ")}` });
|
|
69
|
+
}
|
|
70
|
+
function runFlairUrl(ctx) {
|
|
71
|
+
const id = "flair-url";
|
|
72
|
+
const label = "FLAIR_URL";
|
|
73
|
+
const mcp = ctx.detectedClientIds.filter((c) => MCP_CLIENT_IDS.includes(c));
|
|
74
|
+
if (mcp.length === 0) {
|
|
75
|
+
return result(id, label, "skip", { detail: "no MCP client detected" });
|
|
76
|
+
}
|
|
77
|
+
// Presence of a working URL (explicit or client-defaulted) is the check.
|
|
78
|
+
// Unreachable is a doctor warn, not an install-health failure — the
|
|
79
|
+
// instance probe already covered liveness on the upgrade path.
|
|
80
|
+
const present = mcp.filter((clientId) => readClientMcpBlock(clientId, ctx.homeDir).present);
|
|
81
|
+
if (present.length === 0) {
|
|
82
|
+
return result(id, label, "skip", { detail: "no MCP block present to take a URL from" });
|
|
83
|
+
}
|
|
84
|
+
const urls = present.map((clientId) => {
|
|
85
|
+
const block = readClientMcpBlock(clientId, ctx.homeDir);
|
|
86
|
+
const eff = effectiveFlairUrl(block);
|
|
87
|
+
return `${clientId}=${eff.url}${eff.defaulted ? " (client default)" : ""}`;
|
|
88
|
+
});
|
|
89
|
+
return result(id, label, "pass", { detail: urls.join(", ") });
|
|
90
|
+
}
|
|
91
|
+
function runClaudeMd(ctx) {
|
|
92
|
+
const id = "claude-md";
|
|
93
|
+
const label = "CLAUDE.md bootstrap";
|
|
94
|
+
if (!ctx.detectedClientIds.includes("claude-code")) {
|
|
95
|
+
return result(id, label, "skip", { detail: "Claude Code not detected" });
|
|
96
|
+
}
|
|
97
|
+
const check = checkClaudeMdBootstrap(ctx.cwd, ctx.homeDir);
|
|
98
|
+
if (!check.present) {
|
|
99
|
+
return result(id, label, "fail", {
|
|
100
|
+
detail: "bootstrap instruction not found",
|
|
101
|
+
remedy: "flair doctor --fix",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
return result(id, label, "pass", { detail: check.path ?? undefined });
|
|
105
|
+
}
|
|
106
|
+
function runSessionStartHook(ctx) {
|
|
107
|
+
const id = "session-start-hook";
|
|
108
|
+
const label = "SessionStart hook";
|
|
109
|
+
const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h));
|
|
110
|
+
if (harnesses.length === 0) {
|
|
111
|
+
return result(id, label, "skip", { detail: "no hook-capable client detected" });
|
|
112
|
+
}
|
|
113
|
+
const missing = [];
|
|
114
|
+
const loud = [];
|
|
115
|
+
for (const harness of harnesses) {
|
|
116
|
+
const path = hookSettingsPath(ctx.homeDir, harness);
|
|
117
|
+
const hook = checkSessionStartHook(ctx.homeDir, path);
|
|
118
|
+
if (!hook.present) {
|
|
119
|
+
missing.push(harness);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (hook.command && isFlairHookCommand(hook.command) && !hookCommandIsSilenced(hook.command)) {
|
|
123
|
+
loud.push(harness);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (missing.length > 0) {
|
|
127
|
+
const harness = missing[0];
|
|
128
|
+
const path = hookSettingsPath(ctx.homeDir, harness);
|
|
129
|
+
const detail = missing.length === 1
|
|
130
|
+
? `SessionStart hook (${harness}): not found in ${path}`
|
|
131
|
+
: `SessionStart hook: not found for ${missing.join(", ")} (${missing.map((h) => hookSettingsPath(ctx.homeDir, h)).join("; ")})`;
|
|
132
|
+
return result(id, label, "fail", {
|
|
133
|
+
detail,
|
|
134
|
+
remedy: missing.map((h) => hookInstallHint(h)).join(" ; "),
|
|
135
|
+
missingHarnesses: missing,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
if (loud.length > 0) {
|
|
139
|
+
const harness = loud[0];
|
|
140
|
+
return result(id, label, "fail", {
|
|
141
|
+
detail: `SessionStart hook (${harness}): a failure would print an error on every session`,
|
|
142
|
+
remedy: hookInstallHint(harness),
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
return result(id, label, "pass", {
|
|
146
|
+
detail: `wired for ${harnesses.join(", ")}`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function runVerifiedRead(ctx) {
|
|
150
|
+
const id = "verified-read";
|
|
151
|
+
const label = "per-agent verified-read";
|
|
152
|
+
if (ctx.keyAgentIds === undefined) {
|
|
153
|
+
return result(id, label, "skip", { detail: "no keys enumeration in context" });
|
|
154
|
+
}
|
|
155
|
+
const plan = planAgentIterations(ctx.keyAgentIds, ctx.agentFlag);
|
|
156
|
+
if (plan.length === 0) {
|
|
157
|
+
return result(id, label, "skip", { detail: "no agents to iterate" });
|
|
158
|
+
}
|
|
159
|
+
return result(id, label, "pass", { detail: `iterate ${plan.join(", ")}` });
|
|
160
|
+
}
|
|
161
|
+
function runKeysPrune(ctx) {
|
|
162
|
+
const id = "keys-prune";
|
|
163
|
+
const label = "keys prune classification";
|
|
164
|
+
if (!ctx.keysDir) {
|
|
165
|
+
return result(id, label, "skip", { detail: "no keys dir in context" });
|
|
166
|
+
}
|
|
167
|
+
if (!existsSync(ctx.keysDir)) {
|
|
168
|
+
return result(id, label, "skip", { detail: "keys dir absent" });
|
|
169
|
+
}
|
|
170
|
+
const ids = readdirSync(ctx.keysDir)
|
|
171
|
+
.filter((f) => f.endsWith(".key"))
|
|
172
|
+
.map((f) => f.replace(/\.key$/, ""));
|
|
173
|
+
const { agentKeyIds, nodeKeyIds } = partitionKeyIds(ids, ctx.keysDir);
|
|
174
|
+
return result(id, label, "pass", {
|
|
175
|
+
detail: `classified ${agentKeyIds.length} agent / ${nodeKeyIds.length} node key(s)`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
function runLaunchdManagement(ctx) {
|
|
179
|
+
const id = "launchd-management";
|
|
180
|
+
const label = "launchd management";
|
|
181
|
+
if (!ctx.launchd) {
|
|
182
|
+
return result(id, label, "skip", { detail: "launchd not observed" });
|
|
183
|
+
}
|
|
184
|
+
const m = ctx.launchd;
|
|
185
|
+
if (isDetached(m)) {
|
|
186
|
+
return result(id, label, "fail", {
|
|
187
|
+
detail: m.detail,
|
|
188
|
+
remedy: m.remedy?.join(" && "),
|
|
189
|
+
launchd: m,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
if (m.state === "not-applicable" || m.state === "no-service") {
|
|
193
|
+
return result(id, label, "skip", { detail: m.detail, launchd: m });
|
|
194
|
+
}
|
|
195
|
+
return result(id, label, "pass", { detail: m.detail, launchd: m });
|
|
196
|
+
}
|
|
197
|
+
/** Default implementations. An id in DOCTOR_CHECK_IDS missing from this
|
|
198
|
+
* list is emitted as `unrun` — that is a reachable incomplete state. */
|
|
199
|
+
export const DOCTOR_CHECKS = [
|
|
200
|
+
{ id: "mcp-block", label: "MCP server block", run: runMcpBlock },
|
|
201
|
+
{ id: "flair-url", label: "FLAIR_URL", run: runFlairUrl },
|
|
202
|
+
{ id: "claude-md", label: "CLAUDE.md bootstrap", run: runClaudeMd },
|
|
203
|
+
{ id: "session-start-hook", label: "SessionStart hook", run: runSessionStartHook },
|
|
204
|
+
{ id: "verified-read", label: "per-agent verified-read", run: runVerifiedRead },
|
|
205
|
+
{ id: "keys-prune", label: "keys prune classification", run: runKeysPrune },
|
|
206
|
+
{ id: "launchd-management", label: "launchd management", run: runLaunchdManagement },
|
|
207
|
+
];
|
|
208
|
+
/**
|
|
209
|
+
* Run every catalog id. Missing implementations and thrown runners become
|
|
210
|
+
* `unrun`, never `pass`.
|
|
211
|
+
*/
|
|
212
|
+
export function runDoctorChecks(ctx, opts = {}) {
|
|
213
|
+
const catalogIds = opts.catalogIds ?? DOCTOR_CHECK_IDS;
|
|
214
|
+
const defs = new Map((opts.checks ?? DOCTOR_CHECKS).map((c) => [c.id, c]));
|
|
215
|
+
const results = [];
|
|
216
|
+
for (const id of catalogIds) {
|
|
217
|
+
const stub = opts.stubs?.[id];
|
|
218
|
+
const def = defs.get(id);
|
|
219
|
+
if (stub) {
|
|
220
|
+
try {
|
|
221
|
+
results.push(normalizeResult(id, def?.label ?? id, stub(ctx)));
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
results.push(unrunResult(id, def?.label ?? id, "check threw before returning"));
|
|
225
|
+
}
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (!def) {
|
|
229
|
+
results.push(unrunResult(id, id, "check was not executed — no runner registered"));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
results.push(normalizeResult(id, def.label, def.run(ctx)));
|
|
234
|
+
}
|
|
235
|
+
catch {
|
|
236
|
+
results.push(unrunResult(id, def.label, "check threw before returning"));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return summarizeDoctorRunResults(results, catalogIds);
|
|
240
|
+
}
|
|
241
|
+
function normalizeResult(id, label, r) {
|
|
242
|
+
return { ...r, id, label: r.label || label };
|
|
243
|
+
}
|
|
244
|
+
function unrunResult(id, label, detail) {
|
|
245
|
+
return { id, label, status: "unrun", detail };
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Positive assertion over an already-collected result list. Any catalog id
|
|
249
|
+
* absent from `results` is filled in as `unrun` so a partial list cannot
|
|
250
|
+
* look complete.
|
|
251
|
+
*/
|
|
252
|
+
export function summarizeDoctorRunResults(results, catalogIds = DOCTOR_CHECK_IDS) {
|
|
253
|
+
const byId = new Map(results.map((r) => [r.id, r]));
|
|
254
|
+
const complete = catalogIds.map((id) => byId.get(id) ?? unrunResult(id, id, "check was not executed"));
|
|
255
|
+
const incomplete = complete.some((r) => r.status === "unrun");
|
|
256
|
+
const failed = complete.some((r) => r.status === "fail");
|
|
257
|
+
return {
|
|
258
|
+
results: complete,
|
|
259
|
+
healthy: catalogIds.length > 0 && !incomplete && !failed,
|
|
260
|
+
incomplete,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* The final status line of a successful `flair upgrade`.
|
|
265
|
+
*
|
|
266
|
+
* Success is asserted from the enumerated doctor run — never from "facts I
|
|
267
|
+
* gathered, minus known-bad cases." The unqualified `✅ verified: healthy`
|
|
268
|
+
* marker is emitted only when `run.healthy` is true. An unrun member, a
|
|
269
|
+
* failed member, or an empty catalog all withhold it.
|
|
270
|
+
*/
|
|
271
|
+
export function renderVerifiedSummary(version, run, opts) {
|
|
272
|
+
// `authenticated` is a probe fact, not a doctor-check special case. The
|
|
273
|
+
// healthy-unverified upgrade branch could not read /HealthDetail; claiming
|
|
274
|
+
// "authenticated" there contradicts the next line. Default stays true so
|
|
275
|
+
// the ok path is unchanged.
|
|
276
|
+
const authed = opts?.authenticated !== false;
|
|
277
|
+
const facts = `healthy${authed ? ", authenticated" : ""}${version ? `, running ${version}` : ""}`;
|
|
278
|
+
if (run.healthy) {
|
|
279
|
+
return { degraded: false, lines: [`✅ verified: ${facts}`] };
|
|
280
|
+
}
|
|
281
|
+
const lines = [];
|
|
282
|
+
const launchdFail = run.results.find((r) => r.id === "launchd-management" && r.status === "fail");
|
|
283
|
+
if (launchdFail?.launchd && isDetached(launchdFail.launchd)) {
|
|
284
|
+
lines.push(...renderDetachedWarning(launchdFail.launchd, `upgrade landed (${facts}) but the instance is NOT running under launchd.`));
|
|
285
|
+
}
|
|
286
|
+
else if (run.incomplete) {
|
|
287
|
+
const unrun = run.results.filter((r) => r.status === "unrun");
|
|
288
|
+
lines.push(`⚠️ upgrade landed (${facts}) but install checks are incomplete — ${unrun.length} check(s) were not run.`);
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
const failed = run.results.filter((r) => r.status === "fail");
|
|
292
|
+
lines.push(`⚠️ upgrade landed (${facts}) but doctor found ${failed.length} issue${failed.length === 1 ? "" : "s"}.`);
|
|
293
|
+
}
|
|
294
|
+
for (const r of run.results) {
|
|
295
|
+
if (r.status === "unrun") {
|
|
296
|
+
lines.push(` UNRUN: ${r.label} — not yet checked (cannot treat as passing)`);
|
|
297
|
+
}
|
|
298
|
+
else if (r.status === "fail" && r.id !== "launchd-management") {
|
|
299
|
+
lines.push(` ✗ ${r.detail ?? r.label}`);
|
|
300
|
+
if (r.remedy)
|
|
301
|
+
lines.push(` Fix: ${r.remedy}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return { degraded: true, lines };
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Whether upgrade may write a SessionStart hook. Installing something that
|
|
308
|
+
* executes at every session start is consent-bearing — the flag is the
|
|
309
|
+
* documented non-interactive consent; a TTY prompt is the interactive one.
|
|
310
|
+
* Silence (no flag, no TTY) never writes.
|
|
311
|
+
*/
|
|
312
|
+
export function resolveHookInstallConsent(opts) {
|
|
313
|
+
if (opts.installHooksFlag)
|
|
314
|
+
return "install";
|
|
315
|
+
if (opts.interactive && opts.promptAccepted === undefined)
|
|
316
|
+
return "prompt";
|
|
317
|
+
if (opts.interactive && opts.promptAccepted)
|
|
318
|
+
return "install";
|
|
319
|
+
if (opts.interactive)
|
|
320
|
+
return "skip-declined";
|
|
321
|
+
return "skip-noninteractive";
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Interactive upgrade prompt for missing SessionStart hooks.
|
|
325
|
+
*
|
|
326
|
+
* Names EVERY missing harness (and its path) before asking. A yes after
|
|
327
|
+
* this prompt is consent to write each named hook — not an unnamed extra.
|
|
328
|
+
* Codex-only rationale is attached only when Codex is in the set.
|
|
329
|
+
*/
|
|
330
|
+
export function missingHookPromptLines(missing, homeDir) {
|
|
331
|
+
const named = missing.length > 0 ? [...missing] : ["codex"];
|
|
332
|
+
const preamble = [];
|
|
333
|
+
for (const h of named) {
|
|
334
|
+
preamble.push(`SessionStart hook (${h}) is not installed at ${hookSettingsPath(homeDir, h)}.`);
|
|
335
|
+
}
|
|
336
|
+
if (named.includes("codex")) {
|
|
337
|
+
preamble.push("Without the Codex hook, memory never bootstraps (no CLAUDE.md alternative on Codex).");
|
|
338
|
+
}
|
|
339
|
+
if (named.length > 1) {
|
|
340
|
+
preamble.push(`A yes installs the hook for ${named.join(" and ")} — each executes at session start.`);
|
|
341
|
+
}
|
|
342
|
+
const question = named.length === 1
|
|
343
|
+
? " Install the flair-session-start hook now? [y/N] "
|
|
344
|
+
: ` Install the flair-session-start hook for ${named.join(" and ")} now? [y/N] `;
|
|
345
|
+
return { preamble, question };
|
|
346
|
+
}
|
|
347
|
+
/** Plain-language lines when upgrade will not write the hook. */
|
|
348
|
+
export function missingHookWithoutConsentLines(harness, path) {
|
|
349
|
+
return [
|
|
350
|
+
`SessionStart hook (${harness}) is not installed at ${path}.`,
|
|
351
|
+
"This hook runs at every session start — a consent-bearing write, so upgrade will not install it unprompted.",
|
|
352
|
+
`Install with: flair upgrade --install-hooks`,
|
|
353
|
+
` or: ${hookInstallHint(harness)}`,
|
|
354
|
+
];
|
|
355
|
+
}
|
|
356
|
+
/** True when the SessionStart-hook check failed because a hook file is missing. */
|
|
357
|
+
export function sessionStartHookMissing(run) {
|
|
358
|
+
const hook = run.results.find((r) => r.id === "session-start-hook");
|
|
359
|
+
return hook?.status === "fail" && (hook.missingHarnesses?.length ?? 0) > 0;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Inputs for a consented `installHook` after upgrade. Prefers the missing
|
|
363
|
+
* harness's own MCP block, then env, then the provided fallbacks.
|
|
364
|
+
*/
|
|
365
|
+
export function resolveUpgradeHookInstall(homeDir, harness, fallbacks) {
|
|
366
|
+
const clientId = harness === "codex" ? "codex" : "claude-code";
|
|
367
|
+
const block = readClientMcpBlock(clientId, homeDir);
|
|
368
|
+
const agentId = (typeof process.env.FLAIR_AGENT_ID === "string" && process.env.FLAIR_AGENT_ID) ||
|
|
369
|
+
block.agentId ||
|
|
370
|
+
fallbacks.agentId;
|
|
371
|
+
const flairUrl = (typeof process.env.FLAIR_URL === "string" && process.env.FLAIR_URL) ||
|
|
372
|
+
block.flairUrl ||
|
|
373
|
+
fallbacks.flairUrl ||
|
|
374
|
+
(fallbacks.port ? `http://127.0.0.1:${fallbacks.port}` : "http://127.0.0.1:9926");
|
|
375
|
+
if (!agentId) {
|
|
376
|
+
return { error: "no agent id known — pass --agent or set FLAIR_AGENT_ID so the hook can be wired" };
|
|
377
|
+
}
|
|
378
|
+
return { agentId, flairUrl };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* How many catalog members block doctor / withhold upgrade's ✅.
|
|
382
|
+
* `fail` and `unrun` both count — an unrun member is never a pass.
|
|
383
|
+
*/
|
|
384
|
+
export function catalogBlockingCount(run) {
|
|
385
|
+
return run.results.filter((r) => r.status === "fail" || r.status === "unrun").length;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Found/fixed delta for `flair doctor --fix`. `found` is the pre-fix
|
|
389
|
+
* catalog blocking count; `fixed` is how many of those cleared after.
|
|
390
|
+
*/
|
|
391
|
+
export function catalogIssueDelta(before, after) {
|
|
392
|
+
const found = catalogBlockingCount(before);
|
|
393
|
+
const remaining = catalogBlockingCount(after);
|
|
394
|
+
return { found, fixed: Math.max(0, found - remaining) };
|
|
395
|
+
}
|
|
396
|
+
/** Compact catalog lines for doctor's Install health section. */
|
|
397
|
+
export function renderCatalogDoctorLines(run) {
|
|
398
|
+
return run.results.map((r) => {
|
|
399
|
+
const icon = r.status === "pass" || r.status === "skip" ? "ok" : r.status === "unrun" ? "warn" : "error";
|
|
400
|
+
const word = r.status === "skip" ? "n/a" : r.status;
|
|
401
|
+
const detail = r.detail ? ` — ${r.detail}` : "";
|
|
402
|
+
return { icon, line: `${r.label}: ${word}${detail}` };
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Consent → write composition for missing SessionStart hooks after a
|
|
407
|
+
* catalog run. Silence (no flag, not interactive) never creates a hook
|
|
408
|
+
* file. `--install-hooks` or an accepted prompt writes every named
|
|
409
|
+
* missing harness via `installHook` (or `writeHook`).
|
|
410
|
+
*
|
|
411
|
+
* This is the path `flair upgrade` actually uses. Testing
|
|
412
|
+
* `resolveHookInstallConsent` alone would stay green if the caller
|
|
413
|
+
* always wrote anyway.
|
|
414
|
+
*/
|
|
415
|
+
export function applyUpgradeHookConsent(opts) {
|
|
416
|
+
const homeDir = opts.homeDir;
|
|
417
|
+
const empty = {
|
|
418
|
+
run: opts.run,
|
|
419
|
+
consent: "skip-declined",
|
|
420
|
+
missing: [],
|
|
421
|
+
written: [],
|
|
422
|
+
writes: [],
|
|
423
|
+
messages: [],
|
|
424
|
+
};
|
|
425
|
+
if (!sessionStartHookMissing(opts.run)) {
|
|
426
|
+
return { ...empty, consent: "install" };
|
|
427
|
+
}
|
|
428
|
+
const missing = opts.run.results.find((r) => r.id === "session-start-hook")?.missingHarnesses ?? [];
|
|
429
|
+
const consent = resolveHookInstallConsent({
|
|
430
|
+
installHooksFlag: opts.installHooksFlag,
|
|
431
|
+
interactive: opts.interactive,
|
|
432
|
+
promptAccepted: opts.promptAccepted,
|
|
433
|
+
});
|
|
434
|
+
const prompt = missingHookPromptLines(missing, homeDir);
|
|
435
|
+
if (consent === "prompt") {
|
|
436
|
+
return { ...empty, consent, missing, prompt };
|
|
437
|
+
}
|
|
438
|
+
if (consent === "install") {
|
|
439
|
+
const write = opts.writeHook ?? installHook;
|
|
440
|
+
const writes = [];
|
|
441
|
+
const written = [];
|
|
442
|
+
const messages = [];
|
|
443
|
+
for (const harness of missing) {
|
|
444
|
+
const inputs = resolveUpgradeHookInstall(homeDir, harness, {
|
|
445
|
+
port: opts.port,
|
|
446
|
+
agentId: opts.agentId,
|
|
447
|
+
flairUrl: opts.flairUrl,
|
|
448
|
+
});
|
|
449
|
+
if ("error" in inputs) {
|
|
450
|
+
messages.push(inputs.error);
|
|
451
|
+
writes.push({ harness, ok: false, message: inputs.error });
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const installed = write({
|
|
455
|
+
homeDir,
|
|
456
|
+
harness,
|
|
457
|
+
agentId: inputs.agentId,
|
|
458
|
+
flairUrl: inputs.flairUrl,
|
|
459
|
+
});
|
|
460
|
+
writes.push({ harness, ok: installed.ok, message: installed.message });
|
|
461
|
+
messages.push(installed.message);
|
|
462
|
+
if (installed.ok)
|
|
463
|
+
written.push(harness);
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
run: runDoctorChecks(opts.ctx),
|
|
467
|
+
consent,
|
|
468
|
+
missing,
|
|
469
|
+
written,
|
|
470
|
+
writes,
|
|
471
|
+
messages,
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
const messages = [];
|
|
475
|
+
if (consent === "skip-noninteractive") {
|
|
476
|
+
for (const harness of missing) {
|
|
477
|
+
messages.push(...missingHookWithoutConsentLines(harness, hookSettingsPath(homeDir, harness)));
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return { ...empty, consent, missing, messages };
|
|
481
|
+
}
|
|
@@ -300,29 +300,10 @@ export function renderDetachedWarning(m, headline) {
|
|
|
300
300
|
lines.push(` Fix: ${m.remedy.join(" && ")}`);
|
|
301
301
|
return lines;
|
|
302
302
|
}
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
* fourth, unmeasured fact (the instance had been dropped out of its process
|
|
311
|
-
* manager) was the one that mattered. Deciding it here rather than inline in
|
|
312
|
-
* the command means the decision is testable without performing an upgrade,
|
|
313
|
-
* which on the darwin path nothing else can do: no CI lane runs it.
|
|
314
|
-
*
|
|
315
|
-
* The verified facts are still reported in the degraded case. The upgrade DID
|
|
316
|
-
* land, and hiding that would swap one misleading summary for another; what
|
|
317
|
-
* changes is the marker and the sentence around it.
|
|
318
|
-
*/
|
|
319
|
-
export function renderVerifiedSummary(version, m) {
|
|
320
|
-
const facts = `healthy, authenticated${version ? `, running ${version}` : ""}`;
|
|
321
|
-
if (!isDetached(m)) {
|
|
322
|
-
return { degraded: false, lines: [`✅ verified: ${facts}`] };
|
|
323
|
-
}
|
|
324
|
-
return {
|
|
325
|
-
degraded: true,
|
|
326
|
-
lines: renderDetachedWarning(m, `upgrade landed (${facts}) but the instance is NOT running under launchd.`),
|
|
327
|
-
};
|
|
328
|
-
}
|
|
303
|
+
// renderVerifiedSummary used to live here and qualify ✅ from version +
|
|
304
|
+
// LaunchdManagement alone (flair#1022). That signature was the ceiling on
|
|
305
|
+
// what it could notice — a blacklist of one known-unhealthy form. The
|
|
306
|
+
// enumerated doctor runner in doctor-run.ts is now the source of the
|
|
307
|
+
// success marker (flair#1439). renderDetachedWarning stays: it is the
|
|
308
|
+
// wording for a detached launchd check, used by the runner and by
|
|
309
|
+
// `flair restart`.
|
|
@@ -5,8 +5,8 @@ import { allowAdmin } from "./agent-auth.js";
|
|
|
5
5
|
import { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodySignatureFresh, generateNonce, } from "./federation-crypto.js";
|
|
6
6
|
import { initFederationCleanup } from "./federation-cleanup.js";
|
|
7
7
|
import { createPersistentNonceStore, initNonceStoreCleanup } from "./federation-nonce-store.js";
|
|
8
|
-
import { classifyRecord } from "./federation-classify.js";
|
|
9
|
-
export { classifyRecord } from "./federation-classify.js";
|
|
8
|
+
import { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, } from "./federation-classify.js";
|
|
9
|
+
export { classifyRecord, reconstructRecordVerifyBody, checkPrincipalEntitlement, recordSignatureVersion, PRINCIPAL_OWNING_TABLES, FEDERATION_TABLE_POLICY, FEDERATION_SYNC_TABLES, } from "./federation-classify.js";
|
|
10
10
|
// Module-level nonce store for federation anti-replay.
|
|
11
11
|
// Shared across FederationPair + FederationSync — nonces are globally unique
|
|
12
12
|
// (generated by signBodyFresh per request with 128-bit random nonces).
|
|
@@ -37,6 +37,17 @@ export { canonicalize, signBody, verifyBodySignature, signBodyFresh, verifyBodyS
|
|
|
37
37
|
function requireRecordSignatures() {
|
|
38
38
|
return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_SIGNATURES ?? "").toLowerCase() === "true";
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* Phase 3 of flair#1416 — skip leftover v:1 records on principal-owning
|
|
42
|
+
* tables that lack principalId. Default OFF. Flip only once every paired
|
|
43
|
+
* peer is emitting v:2 (check SyncLog for unsigned / v:1 Memory). Same
|
|
44
|
+
* operator-decision pattern as requireRecordSignatures() — never
|
|
45
|
+
* auto-flipped. v:2 Memory is already mandatory-principal regardless of
|
|
46
|
+
* this flag (see checkPrincipalEntitlement).
|
|
47
|
+
*/
|
|
48
|
+
function requireRecordPrincipal() {
|
|
49
|
+
return (process.env.FLAIR_FEDERATION_REQUIRE_RECORD_PRINCIPAL ?? "").toLowerCase() === "true";
|
|
50
|
+
}
|
|
40
51
|
// ─── Conflict resolution ─────────────────────────────────────────────────────
|
|
41
52
|
/**
|
|
42
53
|
* Field-level Last-Write-Wins merge.
|
|
@@ -359,7 +370,10 @@ export class FederationSync extends Resource {
|
|
|
359
370
|
skipped++;
|
|
360
371
|
skippedReasons[reason] = (skippedReasons[reason] ?? 0) + 1;
|
|
361
372
|
}
|
|
362
|
-
// Table name → Harper database table mapping
|
|
373
|
+
// Table name → Harper database table mapping.
|
|
374
|
+
// Typed against FEDERATION_TABLE_POLICY so adding a federated table
|
|
375
|
+
// without deciding principalOwning is a type error, not a silent
|
|
376
|
+
// default (flair#1416 — refuse by whitelist, never by field presence).
|
|
363
377
|
const tableMap = {
|
|
364
378
|
Memory: databases.flair.Memory,
|
|
365
379
|
Soul: databases.flair.Soul,
|
|
@@ -369,7 +383,9 @@ export class FederationSync extends Resource {
|
|
|
369
383
|
const knownTables = new Set(Object.keys(tableMap));
|
|
370
384
|
for (const record of records) {
|
|
371
385
|
try {
|
|
372
|
-
const table =
|
|
386
|
+
const table = (record.table in tableMap)
|
|
387
|
+
? tableMap[record.table]
|
|
388
|
+
: undefined;
|
|
373
389
|
const local = table ? await table.get(record.id) : null;
|
|
374
390
|
const decision = classifyRecord(record, peer.role, instanceId, local, knownTables);
|
|
375
391
|
if (decision.action === "skip") {
|
|
@@ -403,22 +419,15 @@ export class FederationSync extends Resource {
|
|
|
403
419
|
recordSkip("unknown_originator_key");
|
|
404
420
|
continue;
|
|
405
421
|
}
|
|
406
|
-
// CONTRACT —
|
|
407
|
-
//
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
//
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
table: record.table,
|
|
416
|
-
id: record.id,
|
|
417
|
-
data: record.data,
|
|
418
|
-
updatedAt: record.updatedAt,
|
|
419
|
-
originatorInstanceId: originator,
|
|
420
|
-
signature: record.signature,
|
|
421
|
-
}, originatorPublicKey);
|
|
422
|
+
// CONTRACT — reconstruct from the record (v defaults to 1 when
|
|
423
|
+
// absent — v is NOT on the wire today). Must match
|
|
424
|
+
// reconstructRecordVerifyBody / src/cli.ts's signing payload.
|
|
425
|
+
// A hardcoded { v: 1, table, id, data, updatedAt,
|
|
426
|
+
// originatorInstanceId } field set can only ever verify one
|
|
427
|
+
// shape; building from the record is what makes v:2 (principalId
|
|
428
|
+
// in the signed body) verifiable without breaking existing
|
|
429
|
+
// records. See flair#1416.
|
|
430
|
+
const signatureValid = verifyBodySignature(reconstructRecordVerifyBody(record, originator), originatorPublicKey);
|
|
422
431
|
if (!signatureValid) {
|
|
423
432
|
recordSkip("invalid_signature");
|
|
424
433
|
continue;
|
|
@@ -431,6 +440,19 @@ export class FederationSync extends Resource {
|
|
|
431
440
|
recordSkip("missing_signature");
|
|
432
441
|
continue;
|
|
433
442
|
}
|
|
443
|
+
// ── Per-record principal entitlement (flair#1416 / slice 3a) ──
|
|
444
|
+
// After signature verification, before table.put. Scoped by the
|
|
445
|
+
// explicit PRINCIPAL_OWNING_TABLES set (Memory), never by whether
|
|
446
|
+
// principalId happens to be present — absent Memory principalId
|
|
447
|
+
// is a skip, not an accept. Soul/Agent/Relationship are not in
|
|
448
|
+
// the set and are not consulted. No Agent.get.
|
|
449
|
+
const principalSkip = checkPrincipalEntitlement(record, {
|
|
450
|
+
enforceV1Principal: requireRecordPrincipal(),
|
|
451
|
+
});
|
|
452
|
+
if (principalSkip) {
|
|
453
|
+
recordSkip(principalSkip);
|
|
454
|
+
continue;
|
|
455
|
+
}
|
|
434
456
|
const mergedData = mergeRecord(local, record);
|
|
435
457
|
mergedData._originatorInstanceId = decision.originator;
|
|
436
458
|
mergedData._syncedFrom = instanceId;
|
|
@@ -97,6 +97,7 @@ import { Resource } from "harper";
|
|
|
97
97
|
import { resolveAgentAuth } from "./agent-auth.js";
|
|
98
98
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
99
99
|
import { recordUsageContribution, MAX_USAGE_IDS_PER_CALL } from "./usage-recording.js";
|
|
100
|
+
import { resolveRecordUsageIds } from "./usage-ids.js";
|
|
100
101
|
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
101
102
|
const BAD_REQUEST = (msg) => new Response(JSON.stringify({ error: msg }), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
102
103
|
// flair#744 slice A: sourced from the shared module (./usage-recording.ts)
|
|
@@ -159,14 +160,20 @@ export class RecordUsage extends Resource {
|
|
|
159
160
|
const rl = checkRateLimit(agentId, "usage");
|
|
160
161
|
if (!rl.allowed)
|
|
161
162
|
return rateLimitResponse(rl.retryAfterMs, "usage");
|
|
162
|
-
|
|
163
|
-
|
|
163
|
+
// flair#1410: MERGE memoryId + memoryIds (union, then dedupe). The
|
|
164
|
+
// previous `data?.memoryIds ?? [data?.memoryId]` preferred the plural
|
|
165
|
+
// and silently dropped the singular — quiet data loss. Unioning HERE
|
|
166
|
+
// means a client that POSTs both fields straight through (without
|
|
167
|
+
// flattening first) still credits both. Native `/mcp` also unions
|
|
168
|
+
// before calling this; the endpoint is the guarantee, not the client.
|
|
169
|
+
const resolved = resolveRecordUsageIds(data, MAX_IDS_PER_CALL);
|
|
170
|
+
if (!resolved.ok) {
|
|
171
|
+
if (resolved.error === "cap") {
|
|
172
|
+
return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
|
|
173
|
+
}
|
|
164
174
|
return BAD_REQUEST("memoryIds must be a non-empty array of memory id strings");
|
|
165
175
|
}
|
|
166
|
-
|
|
167
|
-
return BAD_REQUEST(`memoryIds exceeds the per-call limit of ${MAX_IDS_PER_CALL}`);
|
|
168
|
-
}
|
|
169
|
-
const memoryIds = [...new Set(rawIds)]; // dedupe within THIS call too
|
|
176
|
+
const memoryIds = resolved.ids;
|
|
170
177
|
const attribution = sanitizeAttribution(data?.attribution);
|
|
171
178
|
const now = new Date().toISOString();
|
|
172
179
|
for (const memoryId of memoryIds) {
|