@tpsdev-ai/flair 0.33.0 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,239 @@
1
+ // engine-version.ts — Harper engine version tracking (flair#1047)
2
+ //
3
+ // Two concerns, one module:
4
+ //
5
+ // 1. STORE STAMP — flair records the engine version that last wrote the data
6
+ // directory. At boot, if the store was written by a NEWER engine than the
7
+ // one running, flair refuses to start and says so: which version wrote the
8
+ // store, which is running now, and what to do about it.
9
+ //
10
+ // 2. VERSION READ — read the Harper version installed alongside this flair
11
+ // package, and (when a target flair version is known) the Harper version
12
+ // that target declares. Used by the upgrade path to decide whether the
13
+ // engine version is changing and a pre-upgrade snapshot is therefore
14
+ // mandatory.
15
+ //
16
+ // The stamp is a single-line file in the data directory. It must survive the
17
+ // data directory being moved and must not require a Harper query to read — if
18
+ // the engine cannot boot, we still need to read it.
19
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join, resolve } from "node:path";
21
+ import { homedir } from "node:os";
22
+ /** Filename of the engine-version stamp inside the data directory. */
23
+ export const ENGINE_VERSION_STAMP = "engine-version.txt";
24
+ /** Root directory for pre-upgrade snapshots (~/.flair/upgrade-snapshots). */
25
+ export const UPGRADE_SNAPSHOT_ROOT = resolve(homedir(), ".flair", "upgrade-snapshots");
26
+ /** Read the Harper version installed alongside this flair package. */
27
+ export function readInstalledHarperVersion(packageRoot) {
28
+ for (const name of ["harper", "@harperfast/harper"]) {
29
+ const pkgPath = join(packageRoot, "node_modules", ...name.split("/"), "package.json");
30
+ if (!existsSync(pkgPath))
31
+ continue;
32
+ try {
33
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
34
+ if (pkg.version)
35
+ return pkg.version;
36
+ }
37
+ catch {
38
+ continue;
39
+ }
40
+ }
41
+ return null;
42
+ }
43
+ /**
44
+ * Fetch the Harper version that a given @tpsdev-ai/flair version declares as
45
+ * a dependency. Returns null when the lookup fails (network, unparseable, etc.)
46
+ * — callers treat null as "cannot determine, assume it might change."
47
+ */
48
+ export async function fetchDeclaredHarperVersion(flairVersion) {
49
+ try {
50
+ const res = await fetch(`https://registry.npmjs.org/@tpsdev-ai/flair/${flairVersion}`, { signal: AbortSignal.timeout(5000) });
51
+ if (!res.ok)
52
+ return null;
53
+ const data = await res.json();
54
+ return data.dependencies?.harper ?? data.dependencies?.["@harperfast/harper"] ?? null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ // ─── Store stamp ─────────────────────────────────────────────────────────────
61
+ /** Write the engine version stamp into the data directory. */
62
+ export function writeEngineVersionStamp(dataDir, version) {
63
+ writeFileSync(join(dataDir, ENGINE_VERSION_STAMP), `${version}\n`, "utf-8");
64
+ }
65
+ /** Read the engine version stamp from the data directory, or null if absent. */
66
+ export function readEngineVersionStamp(dataDir) {
67
+ const stampPath = join(dataDir, ENGINE_VERSION_STAMP);
68
+ if (!existsSync(stampPath))
69
+ return null;
70
+ try {
71
+ return readFileSync(stampPath, "utf-8").trim() || null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ /**
78
+ * Check whether the running engine is OLDER than the engine that last wrote
79
+ * the store. Returns null when the check passes (no stamp, or stamp ≤ running),
80
+ * or an error message when the store is newer.
81
+ *
82
+ * The error must be actionable: actor, state, remedy.
83
+ */
84
+ export function checkEngineVersionBackwards(dataDir, runningVersion) {
85
+ const stamp = readEngineVersionStamp(dataDir);
86
+ if (!stamp)
87
+ return null; // no stamp — nothing to compare (pre-stamp install)
88
+ const parsed = compareVersions(stamp, runningVersion);
89
+ if (parsed === null) {
90
+ // Genuinely unparseable — cannot determine ordering. Refuse with a
91
+ // message that does NOT claim one is newer than the other.
92
+ return [
93
+ `This Flair install is running Harper ${runningVersion}, but the data directory at`,
94
+ ` ${dataDir}`,
95
+ `was last written by Harper ${stamp}.`,
96
+ ``,
97
+ `The engine version stamp could not be compared to the running version.`,
98
+ `An older Harper cannot safely read a store written by a newer one.`,
99
+ ``,
100
+ ...buildRecoveryLines(),
101
+ ].join("\n");
102
+ }
103
+ if (parsed > 0) {
104
+ // stamp > running — backwards boot, refuse.
105
+ return [
106
+ `This Flair install is running Harper ${runningVersion}, but the data directory at`,
107
+ ` ${dataDir}`,
108
+ `was last written by Harper ${stamp} — a newer engine version.`,
109
+ ``,
110
+ `An older Harper cannot safely read a store written by a newer one.`,
111
+ `The data may appear intact but can be silently unreadable.`,
112
+ ``,
113
+ ...buildRecoveryLines(),
114
+ ].join("\n");
115
+ }
116
+ return null; // running >= stamp — allowed
117
+ }
118
+ // ─── Version comparison (flair#1047) ─────────────────────────────────────────
119
+ /**
120
+ * Compare two semver-like version strings.
121
+ * Returns negative when a < b, positive when a > b, zero when equal,
122
+ * or null when either version is genuinely unparseable (not N.N.N at all).
123
+ *
124
+ * Pre-release ordering follows semver: a version WITH a pre-release tag is
125
+ * LOWER than the same core without one (5.2.0-rc1 < 5.2.0). When both have
126
+ * pre-releases, identifiers are compared dot by dot — numeric parts
127
+ * numerically, the rest as strings.
128
+ */
129
+ function compareVersions(a, b) {
130
+ const pa = parseVersion(a);
131
+ const pb = parseVersion(b);
132
+ if (!pa || !pb)
133
+ return null;
134
+ // Compare major.minor.patch (and any additional numeric components) numerically.
135
+ const coreLen = Math.max(pa.core.length, pb.core.length);
136
+ for (let i = 0; i < coreLen; i++) {
137
+ const ac = pa.core[i] ?? 0;
138
+ const bc = pb.core[i] ?? 0;
139
+ if (ac !== bc)
140
+ return ac - bc;
141
+ }
142
+ // Cores are equal — compare pre-release tags.
143
+ if (pa.pre === null && pb.pre === null)
144
+ return 0;
145
+ if (pa.pre === null)
146
+ return 1; // a has no pre-release → a > b
147
+ if (pb.pre === null)
148
+ return -1; // b has no pre-release → a < b
149
+ // Both have pre-releases — compare identifiers dot by dot.
150
+ const len = Math.max(pa.pre.length, pb.pre.length);
151
+ for (let i = 0; i < len; i++) {
152
+ const ai = pa.pre[i];
153
+ const bi = pb.pre[i];
154
+ if (ai === undefined)
155
+ return -1; // fewer identifiers → lower
156
+ if (bi === undefined)
157
+ return 1;
158
+ const an = Number(ai);
159
+ const bn = Number(bi);
160
+ const aIsNum = !isNaN(an);
161
+ const bIsNum = !isNaN(bn);
162
+ if (aIsNum && bIsNum) {
163
+ if (an !== bn)
164
+ return an - bn;
165
+ }
166
+ else if (aIsNum) {
167
+ return -1; // numeric < string
168
+ }
169
+ else if (bIsNum) {
170
+ return 1;
171
+ }
172
+ else {
173
+ if (ai !== bi)
174
+ return ai < bi ? -1 : 1;
175
+ }
176
+ }
177
+ return 0;
178
+ }
179
+ function parseVersion(v) {
180
+ // Split off pre-release: everything after the first hyphen.
181
+ const hyphenIdx = v.indexOf("-");
182
+ const coreStr = hyphenIdx === -1 ? v : v.slice(0, hyphenIdx);
183
+ const preStr = hyphenIdx === -1 ? null : v.slice(hyphenIdx + 1);
184
+ const coreParts = coreStr.split(".");
185
+ if (coreParts.length < 3)
186
+ return null; // not at least N.N.N
187
+ const core = coreParts.map(Number);
188
+ if (core.some(isNaN))
189
+ return null; // non-numeric core component
190
+ const pre = preStr ? preStr.split(".") : null;
191
+ return { core, pre };
192
+ }
193
+ /**
194
+ * Build the recovery lines for a backwards-boot refusal message.
195
+ * Inspects the snapshot directory so the operator gets a runnable command
196
+ * (or a clear "nothing to restore" message) instead of a literal placeholder.
197
+ */
198
+ export function buildRecoveryLines(snapshotDir) {
199
+ const effectiveSnapshotDir = snapshotDir ?? UPGRADE_SNAPSHOT_ROOT;
200
+ const snapshots = readSnapshotFiles(effectiveSnapshotDir);
201
+ if (snapshots.length === 0) {
202
+ return [
203
+ `To recover:`,
204
+ ` No pre-upgrade snapshot was found.`,
205
+ ` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`,
206
+ ` 2. Or restore from a flair backup export (if you have one).`,
207
+ ``,
208
+ `This check only helps from the release that ships it onward — it cannot`,
209
+ `rescue a downgrade to a build that predates the stamp.`,
210
+ ];
211
+ }
212
+ const newest = snapshots[0];
213
+ const lines = [`To recover:`];
214
+ if (snapshots.length > 1) {
215
+ lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the newest pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`, ``, ` (To see all snapshots: flair snapshot list)`);
216
+ }
217
+ else {
218
+ lines.push(` 1. Reinstall the newer version: npm install -g @tpsdev-ai/flair@latest`, ` 2. Or restore from the pre-upgrade snapshot:`, ` flair snapshot restore ${newest.path}`);
219
+ }
220
+ lines.push(``, `This check only helps from the release that ships it onward — it cannot`, `rescue a downgrade to a build that predates the stamp.`);
221
+ return lines;
222
+ }
223
+ /** Read snapshot files (.tar.gz), newest first. Lexical sort = chronological, because the
224
+ filename carries an ISO 8601 timestamp (see upgradeSnapshotFileName). If that format ever
225
+ changes, this sort must be revisited. */
226
+ function readSnapshotFiles(dir) {
227
+ if (!existsSync(dir))
228
+ return [];
229
+ try {
230
+ return readdirSync(dir)
231
+ .filter((f) => f.startsWith("flair-data-") && f.endsWith(".tar.gz"))
232
+ .sort() // alphabetical = chronological (flair-data-<timestamp>.tar.gz)
233
+ .reverse() // newest first
234
+ .map((f) => ({ name: f, path: join(dir, f) }));
235
+ }
236
+ catch {
237
+ return [];
238
+ }
239
+ }
@@ -41,14 +41,20 @@
41
41
  // FlairClient's plain global `fetch`, no rejectUnauthorized/NODE_TLS_*
42
42
  // bypass anywhere — test/unit/hook-install.test.ts asserts that
43
43
  // statically).
44
- // 5. Silent-fast degradation — also owned by session-start-hook.ts (hard
45
- // timeout, no-op-on-any-failure); this module only writes the pointer
46
- // to it.
44
+ // 5. Silent-fast degradation — SPLIT, deliberately, since flair#1007.
45
+ // session-start-hook.ts owns it once the binary is running (hard
46
+ // timeout, no-op-on-any-failure). It cannot own the case where the
47
+ // binary never runs at all — an orphaned global install after a Node
48
+ // runtime change — because in that case its guard is behind the door it
49
+ // is meant to guard. That half is owned by the command string this
50
+ // module writes, which is built by doctor-client.ts's
51
+ // buildSessionStartHookCommand (see its section doc for the shell
52
+ // analysis and why the wrapper is `sh -c`, not a bare fragment).
47
53
  // 6. Size-budgeted payload — also owned by session-start-hook.ts, which
48
54
  // reuses bootstrap's own maxTokens machinery.
49
55
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
50
56
  import { dirname, join } from "node:path";
51
- import { SESSION_START_HOOK_MARKER } from "./doctor-client.js";
57
+ import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, hookCommandIsSilenced, isHookCommandValueSafe, } from "./doctor-client.js";
52
58
  // ── harness registry ────────────────────────────────────────────────────────
53
59
  /** v1 supports exactly one harness. The flag/type exist so a second harness
54
60
  * is an additive registry entry, not a rewrite (Kern's #719 verdict: "a
@@ -77,9 +83,16 @@ export function hookBackupPath(settingsPath) {
77
83
  /** The exact `command` string written into the SessionStart hook entry.
78
84
  * Always carries both FLAIR_AGENT_ID and FLAIR_URL (see module doc above)
79
85
  * and always contains SESSION_START_HOOK_MARKER verbatim, so doctor's
80
- * existing checkSessionStartHook recognizes it unchanged. */
86
+ * existing checkSessionStartHook recognizes it unchanged.
87
+ *
88
+ * Since flair#1007 this is a thin wrapper over doctor-client.ts's
89
+ * buildSessionStartHookCommand — ONE builder for every path that writes this
90
+ * string (`flair hook install`, `flair doctor --fix`, `flair init`'s hint),
91
+ * so the invocation's failure behaviour is defined and tested in one place
92
+ * instead of drifting across three literals. Throws when agentId/flairUrl
93
+ * cannot be represented safely; installHook() checks first and reports. */
81
94
  export function buildHookCommand(agentId, flairUrl) {
82
- return `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl} npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
95
+ return buildSessionStartHookCommand(agentId, flairUrl);
83
96
  }
84
97
  /** Best-effort recovery of the agentId/flairUrl a previously-wired hook
85
98
  * command carries — used by `flair hook status`. Pure string scan, never
@@ -194,6 +207,19 @@ export function installHook(opts) {
194
207
  const { homeDir, harness, agentId, flairUrl } = opts;
195
208
  const dryRun = !!opts.dryRun;
196
209
  const path = hookSettingsPath(homeDir, harness);
210
+ // The command is a single-quoted shell argument (flair#1007) and quoting
211
+ // rules are not uniform across the shells a harness might use, so unsafe
212
+ // values are REFUSED rather than escaped — checked before anything is
213
+ // backed up or written, so a bad input never half-mutates the file.
214
+ for (const [label, value] of [["agent id", agentId], ["Flair URL", flairUrl]]) {
215
+ if (!isHookCommandValueSafe(value)) {
216
+ return {
217
+ ok: false, path, harness, dryRun,
218
+ message: `${label} '${value}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -) — refusing to write it`,
219
+ backupPath: null, delta: null,
220
+ };
221
+ }
222
+ }
197
223
  if (dryRun) {
198
224
  const read = readSettingsFile(path);
199
225
  if (read.parseError) {
@@ -309,16 +335,20 @@ export function hookStatus(homeDir, harness) {
309
335
  const path = hookSettingsPath(homeDir, harness);
310
336
  const read = readSettingsFile(path);
311
337
  if (read.parseError) {
312
- return { harness, path, wired: false, correctShape: false, parseError: read.parseError };
338
+ return { harness, path, wired: false, correctShape: false, silenced: false, parseError: read.parseError };
313
339
  }
314
340
  const config = read.parsed ?? {};
315
341
  const existing = findHookEntry(config);
316
342
  if (!existing) {
317
- return { harness, path, wired: false, correctShape: false, parseError: null };
343
+ return { harness, path, wired: false, correctShape: false, silenced: false, parseError: null };
318
344
  }
319
345
  const hookEntry = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex];
320
346
  const command = typeof hookEntry?.command === "string" ? hookEntry.command : "";
321
347
  const correctShape = hookEntry?.type === "command" && command.includes(`npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`);
322
348
  const env = parseHookCommandEnv(command);
323
- return { harness, path, wired: true, correctShape, agentId: env.agentId, flairUrl: env.flairUrl, command, parseError: null };
349
+ return {
350
+ harness, path, wired: true, correctShape,
351
+ silenced: hookCommandIsSilenced(command),
352
+ agentId: env.agentId, flairUrl: env.flairUrl, command, parseError: null,
353
+ };
324
354
  }
@@ -197,9 +197,92 @@ export function buildEd25519Auth(agentId, method, path, keyPath) {
197
197
  const sig = nodeCryptoSign(null, Buffer.from(payload), privKey).toString("base64");
198
198
  return `TPS-Ed25519 ${agentId}:${ts}:${nonce}:${sig}`;
199
199
  }
200
- /** Authenticated fetch against Flair using Ed25519. */
200
+ /**
201
+ * Classify a throw from `buildEd25519Auth`.
202
+ *
203
+ * Keyed on the STRUCTURED `code` property rather than on message text,
204
+ * because the message is not stable across crypto backends. Probed directly
205
+ * on the same 60-byte malformed input:
206
+ *
207
+ * Node (OpenSSL) ERR_OSSL_UNSUPPORTED error:1E08010C:DECODER routines::unsupported
208
+ * ERR_OSSL_ASN1_WRONG_TAG error:068000A8:asn1 encoding routines::wrong tag
209
+ * Bun (BoringSSL) ERR_OSSL_NO_START_LINE error:0900006e:PEM routines:...:NO_START_LINE
210
+ * ERR_OSSL_WRONG_TAG error:0c0000be:ASN.1 encoding routines:...:WRONG_TAG
211
+ *
212
+ * The first line is the error in flair#1023, and matching it alone would have
213
+ * left the tests (bun) and the shipped CLI (node) classifying differently.
214
+ * So the rule is the `ERR_OSSL_` family as a whole — sound here because
215
+ * buildEd25519Auth does no I/O beyond reading the file: any crypto-backend
216
+ * error it raises is about the key, never about the instance. Message text is
217
+ * a secondary signal only, for a backend that reports no code we recognise.
218
+ */
219
+ export function classifyKeyLoadFailure(err) {
220
+ const code = typeof err?.code === "string" ? err.code : "";
221
+ if (code === "ENOENT")
222
+ return "not-found";
223
+ if (code === "EACCES" || code === "EPERM" || code === "EISDIR")
224
+ return "unreadable";
225
+ if (code.startsWith("ERR_OSSL"))
226
+ return "decode";
227
+ const message = err instanceof Error ? err.message : String(err ?? "");
228
+ // `asn\.?1` because the two backends punctuate it differently:
229
+ // "asn1 encoding routines" (OpenSSL) vs "ASN.1 encoding routines" (BoringSSL).
230
+ if (/DECODER routines|asn\.?1 encoding routines|PEM routines/i.test(message))
231
+ return "decode";
232
+ return "unknown";
233
+ }
234
+ /** A signing key could not be loaded — distinct from the instance being down. */
235
+ export class KeyLoadError extends Error {
236
+ /** The file we tried to read. Known at the point of failure; used to be discarded. */
237
+ keyPath;
238
+ kind;
239
+ /** The underlying error's message, preserved verbatim for the operator. */
240
+ underlying;
241
+ constructor(keyPath, kind, underlying) {
242
+ super(describeKeyLoadFailure(keyPath, kind, underlying));
243
+ this.name = "KeyLoadError";
244
+ this.keyPath = keyPath;
245
+ this.kind = kind;
246
+ this.underlying = underlying;
247
+ }
248
+ static from(keyPath, err) {
249
+ const underlying = err instanceof Error ? err.message : String(err ?? "");
250
+ return new KeyLoadError(keyPath, classifyKeyLoadFailure(err), underlying);
251
+ }
252
+ }
253
+ /**
254
+ * One line an operator can act on: what we were doing, which file, and — only
255
+ * when it has actually been established — why. The "unknown" arm deliberately
256
+ * states no cause and shows the raw error instead (flair#1023 requirement 1).
257
+ */
258
+ export function describeKeyLoadFailure(keyPath, kind, underlying) {
259
+ switch (kind) {
260
+ case "not-found":
261
+ return `signing key ${keyPath} does not exist`;
262
+ case "unreadable":
263
+ return `signing key ${keyPath} could not be read (${underlying})`;
264
+ case "decode":
265
+ return `signing key ${keyPath} could not be parsed as an Ed25519 private key (${underlying})`;
266
+ case "unknown":
267
+ // No cause is asserted — we genuinely do not know one.
268
+ return `signing key ${keyPath} could not be loaded: ${underlying}`;
269
+ }
270
+ }
271
+ /**
272
+ * Authenticated fetch against Flair using Ed25519.
273
+ *
274
+ * Throws {@link KeyLoadError} if the key could not be loaded — a failure that
275
+ * provably happened BEFORE any byte hit the network, so no caller need guess
276
+ * whether the instance is reachable. Anything else thrown here is transport.
277
+ */
201
278
  export async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
202
- const auth = buildEd25519Auth(agentId, method, path, keyPath);
279
+ let auth;
280
+ try {
281
+ auth = buildEd25519Auth(agentId, method, path, keyPath);
282
+ }
283
+ catch (err) {
284
+ throw KeyLoadError.from(keyPath, err);
285
+ }
203
286
  const headers = { Authorization: auth };
204
287
  if (body !== undefined)
205
288
  headers["Content-Type"] = "application/json";