@tpsdev-ai/flair 0.32.0 → 0.34.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.
@@ -7,15 +7,19 @@
7
7
  // but no CLAUDE.md line; or no SessionStart hook) that silently no-op, with
8
8
  // no way to tell "is Flair working for my agent?" short of an incident.
9
9
  //
10
- // This module is pure filesystem logic (no network, no crypto) so it's fast
11
- // and fully unit-testable in isolation — mirrors test/unit/client-wiring.test.ts's
10
+ // This module is filesystem logic (no network, no crypto) so it's fast and
11
+ // fully unit-testable in isolation — mirrors test/unit/client-wiring.test.ts's
12
12
  // technique of overriding process.env.HOME to a temp dir. The two
13
13
  // network-dependent checks (reachability + agent registration) live in
14
- // src/cli.ts alongside authFetch/resolveKeyPath, which they reuse.
14
+ // src/cli.ts alongside authFetch/resolveKeyPath, which they reuse. The one
15
+ // exception to "filesystem only" is probeSessionStartHookCommand (flair#1007),
16
+ // which spawns a bounded subprocess — it takes an injectable runner so every
17
+ // caller in the test suite stays hermetic.
15
18
  //
16
19
  // Every read here is try/catch-wrapped: a missing or malformed config file is
17
20
  // "not present", never a thrown error — doctor must never crash or hang on a
18
21
  // broken client config.
22
+ import { spawnSync } from "node:child_process";
19
23
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
24
  import { dirname, join } from "node:path";
21
25
  import { clientConfigPath } from "./install/clients.js";
@@ -26,6 +30,129 @@ const CLAUDE_MD_BOOTSTRAP_LINE = "At the start of every session, run mcp__flair_
26
30
  // The exact substring identifying a Flair SessionStart hook command (see
27
31
  // docs/mcp-clients.md "Auto-recall on session start").
28
32
  export const SESSION_START_HOOK_MARKER = "flair-session-start";
33
+ // ── the canonical SessionStart hook command (flair#1007) ───────────────────
34
+ //
35
+ // ONE builder, three call sites: `flair doctor --fix` (fixSessionStartHook),
36
+ // `flair init`'s copy-paste hint (sessionStartHookHint), and `flair hook
37
+ // install` (src/hook-install.ts's buildHookCommand, which delegates here).
38
+ // Before #1007 each of those carried its own literal copy of the string, so a
39
+ // change to the invocation's failure behaviour had to be made in three places
40
+ // and could be tested in none of them.
41
+ //
42
+ // WHY THE INVOCATION IS WRAPPED
43
+ // -----------------------------
44
+ // The hook runs `npx -y @tpsdev-ai/flair-mcp flair-session-start`: it resolves
45
+ // a package binary through whatever Node runtime the user's shell happens to
46
+ // expose. Under a Node version manager, globally installed packages are
47
+ // per-runtime-version, so a routine and entirely unrelated runtime upgrade
48
+ // orphans that binary. The command then stops resolving and the harness
49
+ // reports a hook error on EVERY session, indefinitely, in wording that names
50
+ // neither Flair nor a remedy. It also outlives Flair itself: uninstalling the
51
+ // CLI does not remove the hook, so the error survives the tool it exists to
52
+ // serve (flair#1007).
53
+ //
54
+ // packages/flair-mcp/src/session-start-hook.ts already guarantees
55
+ // no-op-on-any-failure — but that guarantee lives INSIDE the binary which, in
56
+ // this failure, never runs. The guard is behind the door it is meant to guard.
57
+ // The only layer that still exists when the binary does not is the command
58
+ // string itself, so that is where the silence has to be enforced.
59
+ //
60
+ // WHY `sh -c '...'` RATHER THAN A BARE FRAGMENT
61
+ // ---------------------------------------------
62
+ // Claude Code runs a `type: "command"` hook as `/bin/sh -c "<command>"` — it
63
+ // spawns with `shell: true` and never consults $SHELL (verified against the
64
+ // 2.1.220 bundle; the settings schema's claim that `"shell": "bash"` uses your
65
+ // $SHELL is not what the code does on POSIX). So a bare POSIX fragment would
66
+ // in fact be enough for the one harness we support today.
67
+ //
68
+ // It is still wrapped, because this string is not private to that harness:
69
+ // SUPPORTED_HARNESSES (src/hook-install.ts) is a registry meant to grow, and
70
+ // docs/mcp-clients.md publishes this exact command for hand-wiring. Measured
71
+ // across sh, bash, zsh, dash, ksh, fish and tcsh:
72
+ // - the wrapped form is uniform — stdout passed through byte-for-byte on
73
+ // success; empty stdout, empty stderr and exit 0 on every failure;
74
+ // - a bare `out=$(...) && ... || true` fragment is a SYNTAX ERROR in fish and
75
+ // csh/tcsh — silent on some shells, LOUDER than before on others, which is
76
+ // not a fix;
77
+ // - the unwrapped pre-#1007 command is already broken outright in csh/tcsh
78
+ // ("FLAIR_AGENT_ID=me: Command not found."), which the wrapper fixes.
79
+ // The cost is one extra process at session start; the benefit is that the
80
+ // silence is a property of the string rather than of who runs it.
81
+ //
82
+ // WHY STDOUT IS CAPTURED, NOT JUST STDERR DISCARDED
83
+ // -------------------------------------------------
84
+ // Exit code alone is not the whole surface. In the same bundle, a SessionStart
85
+ // hook's stdout is consumed two ways EVEN AT EXIT 0:
86
+ // - stdout that does not start with `{` is injected into the model's context
87
+ // verbatim, as a `<hook> hook success: <stdout>` meta message;
88
+ // - stdout that starts with `{` but fails to parse/validate is reported as a
89
+ // hook error — at exit 0, presented as exit 1.
90
+ // So a failing resolver that happens to print on stdout would either become
91
+ // silently injected context or produce the exact error class this issue is
92
+ // about, no matter what the exit code says. Capturing stdout and emitting it
93
+ // only on success closes both; discarding stderr alone would close neither.
94
+ /**
95
+ * Values interpolated into the hook command are constrained by a strict
96
+ * allow-list rather than escaped. The command is a single-quoted `sh -c`
97
+ * argument and correct single-quote escaping is NOT uniform across the shells
98
+ * above (fish and csh disagree with POSIX), so "cannot contain a quote" is
99
+ * enforced by shape instead of handled by quoting. This is also strictly
100
+ * safer than the pre-#1007 command, where an agent id containing a space or a
101
+ * `;` was a command injection into the user's settings file.
102
+ */
103
+ const HOOK_VALUE_SAFE_RE = /^[A-Za-z0-9._:/-]+$/;
104
+ export function isHookCommandValueSafe(value) {
105
+ return typeof value === "string" && HOOK_VALUE_SAFE_RE.test(value);
106
+ }
107
+ /**
108
+ * Build the exact `command` string to register as a SessionStart hook.
109
+ * Throws (rather than emitting a quoted approximation) when a value cannot be
110
+ * represented safely — see HOOK_VALUE_SAFE_RE.
111
+ */
112
+ export function buildSessionStartHookCommand(agentId, flairUrl) {
113
+ if (!isHookCommandValueSafe(agentId)) {
114
+ throw new Error(`agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
115
+ }
116
+ if (flairUrl != null && flairUrl !== "" && !isHookCommandValueSafe(flairUrl)) {
117
+ throw new Error(`Flair URL '${flairUrl}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`);
118
+ }
119
+ const env = flairUrl ? `FLAIR_AGENT_ID=${agentId} FLAIR_URL=${flairUrl}` : `FLAIR_AGENT_ID=${agentId}`;
120
+ const invocation = `${env} npx -y @tpsdev-ai/flair-mcp ${SESSION_START_HOOK_MARKER}`;
121
+ return `sh -c 'out=$(${invocation} 2>/dev/null) && printf %s "$out" || true'`;
122
+ }
123
+ /**
124
+ * Does this command absorb a failure instead of surfacing it? Checked as two
125
+ * independent PROPERTIES (stderr discarded, non-zero exit absorbed) rather
126
+ * than by string equality with what we currently emit, so a hand-rolled
127
+ * command that genuinely achieves both is not nagged about.
128
+ */
129
+ export function hookCommandIsSilenced(command) {
130
+ if (typeof command !== "string")
131
+ return false;
132
+ const discardsStderr = command.includes("2>/dev/null") || command.includes("2>&-");
133
+ const absorbsFailure = /\|\|\s*(?:true|:)(?:\s|'|$)/.test(command) || /;\s*(?:true|:)\s*'?\s*$/.test(command);
134
+ return discardsStderr && absorbsFailure;
135
+ }
136
+ /**
137
+ * The EXACT unwrapped shape Flair wrote before #1007. Recognising it precisely
138
+ * (not "anything containing the marker") is what lets `flair doctor --fix`
139
+ * upgrade a hook we know we authored while never rewriting one a user placed
140
+ * or edited themselves.
141
+ */
142
+ const LEGACY_SESSION_START_HOOK_RE = /^FLAIR_AGENT_ID=([^\s'"]+)(?: FLAIR_URL=([^\s'"]+))? npx -y @tpsdev-ai\/flair-mcp flair-session-start$/;
143
+ export function parseLegacySessionStartHookCommand(command) {
144
+ if (typeof command !== "string")
145
+ return null;
146
+ const m = command.trim().match(LEGACY_SESSION_START_HOOK_RE);
147
+ if (!m)
148
+ return null;
149
+ return { agentId: m[1], flairUrl: m[2] };
150
+ }
151
+ /** Does this command invoke the Flair adapter at all (pinned or not)? Only
152
+ * such a command is ever probed or rewritten. */
153
+ export function isFlairHookCommand(command) {
154
+ return typeof command === "string" && command.includes("@tpsdev-ai/flair-mcp") && command.includes(SESSION_START_HOOK_MARKER);
155
+ }
29
156
  // ── shared helpers ──────────────────────────────────────────────────────────
30
157
  /**
31
158
  * Run `fn` with process.env.HOME temporarily pointed at `homeDir`, then
@@ -228,7 +355,7 @@ export function checkSessionStartHook(homeDir) {
228
355
  continue;
229
356
  for (const hook of hooks) {
230
357
  if (typeof hook?.command === "string" && hook.command.includes(SESSION_START_HOOK_MARKER)) {
231
- return { present: true, path };
358
+ return { present: true, path, command: hook.command };
232
359
  }
233
360
  }
234
361
  }
@@ -255,6 +382,13 @@ export function fixSessionStartHook(homeDir, agentId) {
255
382
  message: "no agent id known — pass --agent <id> (or set FLAIR_AGENT_ID) so doctor knows which agent to wire the hook to",
256
383
  };
257
384
  }
385
+ if (!isHookCommandValueSafe(agentId)) {
386
+ return {
387
+ ok: false,
388
+ path,
389
+ message: `agent id '${agentId}' contains characters that cannot be safely written into a shell hook command (allowed: letters, digits, . _ : / -)`,
390
+ };
391
+ }
258
392
  try {
259
393
  let config = {};
260
394
  const raw = readTextFile(path);
@@ -271,7 +405,7 @@ export function fixSessionStartHook(homeDir, agentId) {
271
405
  hooks: [
272
406
  {
273
407
  type: "command",
274
- command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
408
+ command: buildSessionStartHookCommand(agentId),
275
409
  },
276
410
  ],
277
411
  });
@@ -284,6 +418,172 @@ export function fixSessionStartHook(homeDir, agentId) {
284
418
  return { ok: false, path, message: `could not write ${path}: ${reason}` };
285
419
  }
286
420
  }
421
+ /** Default probe budget. Generous: a cold `npx` may have to reach the registry
422
+ * before it can answer, and a slow answer must never be reported as a broken
423
+ * hook — that is what the "unknown" verdict is for. */
424
+ export const HOOK_PROBE_TIMEOUT_MS = 20_000;
425
+ /** Package-manager chatter that is never the reason a hook failed. The harness
426
+ * itself reports the FIRST stderr line, which on a modern npm is one of these
427
+ * — so doctor deliberately does better than repeating the symptom back. */
428
+ const NOISE_LINE_RE = /^(?:npm|yarn|pnpm|bun)\s+(?:notice|warn|info|http|verb)\b/i;
429
+ /** The most informative single line of a failed probe's output. */
430
+ export function evidenceLine(s, max = 200) {
431
+ const lines = (s || "").split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
432
+ const line = lines.find((l) => !NOISE_LINE_RE.test(l)) ?? lines[0] ?? "";
433
+ return line.length > max ? `${line.slice(0, max - 1)}…` : line;
434
+ }
435
+ const defaultProbeRunner = (command, timeoutMs) => {
436
+ // `/bin/sh -c` is not a guess: it is exactly how Claude Code runs a
437
+ // `type: "command"` hook (spawn with `shell: true`, $SHELL never consulted).
438
+ // Probing through any other shell would answer a question the user never
439
+ // asked.
440
+ const res = spawnSync("/bin/sh", ["-c", command], {
441
+ input: "",
442
+ encoding: "utf-8",
443
+ timeout: timeoutMs,
444
+ env: {
445
+ ...process.env,
446
+ // Tell a #1007-or-later adapter to answer without side effects.
447
+ FLAIR_HOOK_PROBE: "1",
448
+ // Bound an OLDER adapter, which will do a real bootstrap + presence
449
+ // heartbeat because it has never heard of FLAIR_HOOK_PROBE.
450
+ FLAIR_HOOK_TIMEOUT_MS: "1500",
451
+ FLAIR_PRESENCE_TIMEOUT_MS: "500",
452
+ },
453
+ });
454
+ const timedOut = res.signal === "SIGTERM" && res.status === null;
455
+ return {
456
+ exitCode: res.status,
457
+ stdout: res.stdout ?? "",
458
+ stderr: res.stderr ?? "",
459
+ timedOut,
460
+ spawnError: res.error && !timedOut ? res.error.message : null,
461
+ };
462
+ };
463
+ /**
464
+ * Run a registered hook command once, bounded, with probe-mode env set.
465
+ * Only ever called for commands isFlairHookCommand() recognises — doctor does
466
+ * not execute a stranger's hook to find out what it does.
467
+ */
468
+ export function probeSessionStartHookCommand(command, opts = {}) {
469
+ const timeoutMs = opts.timeoutMs ?? HOOK_PROBE_TIMEOUT_MS;
470
+ const runner = opts.runner ?? defaultProbeRunner;
471
+ try {
472
+ return runner(command, timeoutMs);
473
+ }
474
+ catch (err) {
475
+ const reason = err instanceof Error ? err.message : String(err);
476
+ return { exitCode: null, stdout: "", stderr: "", timedOut: false, spawnError: reason };
477
+ }
478
+ }
479
+ /** Pure: probe outcome -> verdict. See the section doc for why "exit 0, no
480
+ * output" is a definite failure rather than an ambiguous one. */
481
+ export function classifyHookProbe(outcome) {
482
+ if (outcome.timedOut) {
483
+ return { execution: "unknown", detail: "the command did not answer in time" };
484
+ }
485
+ if (outcome.spawnError) {
486
+ return { execution: "unknown", detail: `could not run the command (${outcome.spawnError})` };
487
+ }
488
+ if (outcome.exitCode !== 0) {
489
+ const evidence = evidenceLine(outcome.stderr) || evidenceLine(outcome.stdout);
490
+ return {
491
+ execution: "broken",
492
+ detail: evidence ? `exited ${outcome.exitCode}: ${evidence}` : `exited ${outcome.exitCode}`,
493
+ };
494
+ }
495
+ if (!outcome.stdout.trim()) {
496
+ return {
497
+ execution: "broken",
498
+ detail: "the command produced no output — the flair-session-start binary never ran",
499
+ };
500
+ }
501
+ return { execution: "runs" };
502
+ }
503
+ /**
504
+ * Full report for the registered hook: presence (as before), plus whether its
505
+ * command still executes and whether it would fail quietly if it stopped.
506
+ * `probe` is injectable and defaults to the real bounded spawn; pass a stub
507
+ * (or null via `{ probe: false }`) to keep a caller hermetic.
508
+ */
509
+ export function inspectSessionStartHook(homeDir, opts = {}) {
510
+ const found = checkSessionStartHook(homeDir);
511
+ if (!found.present || !found.command) {
512
+ return { path: found.path, present: false, ours: false, silenced: false, upgradable: false, execution: null };
513
+ }
514
+ const command = found.command;
515
+ const ours = isFlairHookCommand(command);
516
+ const silenced = hookCommandIsSilenced(command);
517
+ const upgradable = parseLegacySessionStartHookCommand(command) !== null;
518
+ if (!ours || opts.probe === false) {
519
+ return { path: found.path, present: true, command, ours, silenced, upgradable, execution: null };
520
+ }
521
+ const outcome = probeSessionStartHookCommand(command, {
522
+ timeoutMs: opts.timeoutMs,
523
+ runner: opts.probe || undefined,
524
+ });
525
+ const verdict = classifyHookProbe(outcome);
526
+ return { path: found.path, present: true, command, ours, silenced, upgradable, execution: verdict.execution, detail: verdict.detail };
527
+ }
528
+ /**
529
+ * Rewrite an existing Flair-authored hook command to the current canonical
530
+ * form, in place, preserving the agent id and URL the entry already carries —
531
+ * so this never needs --agent and never re-points a hook at a different agent.
532
+ *
533
+ * Deliberately narrow: it refuses unless the registered command is the EXACT
534
+ * unwrapped string Flair used to write. A hook the user hand-wrote or pinned
535
+ * is theirs; doctor reports on it and leaves it alone. And it never REMOVES a
536
+ * hook — an unresolvable command is an environment that changed, not a
537
+ * decision to un-wire ambient memory, and `flair hook uninstall` is the
538
+ * command for that when the user does decide.
539
+ */
540
+ export function upgradeSessionStartHookCommand(homeDir) {
541
+ const path = join(homeDir, ".claude", "settings.json");
542
+ try {
543
+ const raw = readTextFile(path);
544
+ if (!raw || !raw.trim())
545
+ return { ok: false, path, changed: false, message: `no ${path} to update` };
546
+ const config = JSON.parse(raw);
547
+ const groups = config?.hooks?.SessionStart;
548
+ if (!Array.isArray(groups))
549
+ return { ok: false, path, changed: false, message: `no SessionStart hooks in ${path}` };
550
+ for (const group of groups) {
551
+ const hooks = group?.hooks;
552
+ if (!Array.isArray(hooks))
553
+ continue;
554
+ for (const hook of hooks) {
555
+ if (typeof hook?.command !== "string" || !hook.command.includes(SESSION_START_HOOK_MARKER))
556
+ continue;
557
+ // Already absorbing its own failures — nothing to repair, whether we
558
+ // wrote it or the user did. Checked BEFORE the legacy match so a second
559
+ // run is a clean no-op rather than "that isn't the command we wrote".
560
+ if (hookCommandIsSilenced(hook.command)) {
561
+ return { ok: true, path, changed: false, message: `SessionStart hook in ${path} is already current` };
562
+ }
563
+ const legacy = parseLegacySessionStartHookCommand(hook.command);
564
+ if (!legacy) {
565
+ return {
566
+ ok: false,
567
+ path,
568
+ changed: false,
569
+ message: `the SessionStart hook in ${path} is not the command Flair wrote — leaving it untouched`,
570
+ };
571
+ }
572
+ const next = buildSessionStartHookCommand(legacy.agentId, legacy.flairUrl);
573
+ if (next === hook.command)
574
+ return { ok: true, path, changed: false, message: `SessionStart hook in ${path} is already current` };
575
+ hook.command = next;
576
+ writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
577
+ return { ok: true, path, changed: true, message: `rewrote the SessionStart hook in ${path} so a failure to resolve stays silent` };
578
+ }
579
+ }
580
+ return { ok: false, path, changed: false, message: `no Flair SessionStart hook found in ${path}` };
581
+ }
582
+ catch (err) {
583
+ const reason = err instanceof Error ? err.message : String(err);
584
+ return { ok: false, path, changed: false, message: `could not update ${path}: ${reason}` };
585
+ }
586
+ }
287
587
  function indentLines(s) {
288
588
  return s
289
589
  .split("\n")
@@ -315,7 +615,9 @@ function sessionStartHookHint(agentId, path) {
315
615
  hooks: [
316
616
  {
317
617
  type: "command",
318
- command: `FLAIR_AGENT_ID=${agentId} npx -y @tpsdev-ai/flair-mcp flair-session-start`,
618
+ command: isHookCommandValueSafe(agentId)
619
+ ? buildSessionStartHookCommand(agentId)
620
+ : buildSessionStartHookCommand("me"),
319
621
  },
320
622
  ],
321
623
  },
@@ -420,6 +722,27 @@ export function fixCommandAgentHint(keyAgentIds) {
420
722
  return "";
421
723
  return ` --agent ${[...keyAgentIds].sort()[0]}`;
422
724
  }
725
+ /**
726
+ * The remedy line to print under doctor's "Embeddings: not verified" warning,
727
+ * or null when no remedy applies (flair#1023 requirement 4).
728
+ *
729
+ * doctor used to print "Pass --agent <id> ..." for EVERY skip reason. That
730
+ * sentence only fixes the two cases where no agent identity was resolved. It
731
+ * cannot fix a key that will not decode — following it produces the identical
732
+ * error — and it cannot fix an HTTP failure from the probe itself. Printing a
733
+ * remedy that provably will not change the outcome spends the operator's time
734
+ * and is worse than printing nothing, so those cases now print nothing.
735
+ */
736
+ export function embeddingsSkipRemedy(reason) {
737
+ switch (reason) {
738
+ case "no-agent":
739
+ case "no-key":
740
+ return "Pass --agent <id> (or set FLAIR_AGENT_ID) so doctor can run a real semantic round-trip.";
741
+ case "key-load":
742
+ case "probe-failed":
743
+ return null;
744
+ }
745
+ }
423
746
  /**
424
747
  * Render decision for one agent's registration-gate outcome, ahead of a
425
748
  * verified-read section (Fleet presence / Migrations). Returns null when the
@@ -428,10 +751,37 @@ export function fixCommandAgentHint(keyAgentIds) {
428
751
  * agent's subsection; the caller must still move on to the next agent
429
752
  * (failure isolation, flair#722) rather than aborting the whole section.
430
753
  */
431
- export function describeAgentGateFinding(agentId, state, detail) {
754
+ export function describeAgentGateFinding(agentId, state, detail, reachability) {
755
+ // Self-inconsistency guard. "unreachable" is a legitimate state — a bare
756
+ // 500, a timeout — but it must never be ASSERTED after this run has already
757
+ // watched the instance answer. When it is, report the honest thing: the
758
+ // check failed, and we know it was not connectivity.
759
+ if (state === "unreachable" && reachability?.instanceReachable === true) {
760
+ // Strip the caller's own "instance unreachable:" prefix before quoting the
761
+ // detail — that prefix IS the claim being disclaimed, and leaving it in
762
+ // would reassert it in the same sentence that denies it.
763
+ const raw = detail?.replace(/^instance unreachable:\s*/i, "").trim();
764
+ return {
765
+ icon: "warn",
766
+ message: `could not verify agent '${agentId}' registration — the instance responded to this run, ` +
767
+ `so this is not a connectivity problem${raw ? ` (${raw})` : ""}`,
768
+ isIssue: false,
769
+ };
770
+ }
432
771
  switch (state) {
433
772
  case "registered":
434
773
  return null;
774
+ case "key-unreadable":
775
+ return {
776
+ icon: "warn",
777
+ // Names the file and the operation. No cause is invented and no fix
778
+ // hint is offered: the failure classes that land here (a corrupt key,
779
+ // a file that is not an agent key at all) have different remedies and
780
+ // we cannot tell them apart from the bytes. A remedy we cannot stand
781
+ // behind is the flair#1023 defect, not a fix for it.
782
+ message: `could not verify agent '${agentId}' registration — ${detail ?? "its signing key could not be loaded"}`,
783
+ isIssue: false,
784
+ };
435
785
  case "no-key":
436
786
  return {
437
787
  icon: "warn",
@@ -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";