@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.
- package/README.md +64 -64
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +659 -111
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +357 -7
- package/dist/hook-install.js +39 -9
- package/dist/lib/auth-resolve.js +85 -2
- package/dist/lib/launchd-management.js +328 -0
- package/dist/lib/mcp-enable.js +19 -0
- package/dist/resources/AdminInstance.js +20 -2
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/OAuth.js +41 -25
- package/dist/resources/auth-middleware.js +26 -0
- package/dist/resources/dcr-gate.js +194 -0
- package/dist/resources/in-process-api.js +5 -1
- package/dist/resources/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +89 -7
- package/dist/resources/mcp-tools.js +40 -0
- package/dist/resources/oauth-discovery.js +242 -0
- package/dist/resources/oauth-wellknown.js +111 -0
- package/dist/resources/rate-limit.js +400 -0
- package/docs/auth.md +122 -5
- package/docs/deploying-on-fabric.md +35 -2
- package/docs/deployment.md +1 -1
- package/docs/embedding-in-a-harper-app.md +6 -1
- package/docs/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +28 -4
- package/docs/quickstart.md +29 -4
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +37 -1
- package/package.json +1 -1
package/dist/doctor-client.js
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
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:
|
|
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:
|
|
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",
|
package/dist/hook-install.js
CHANGED
|
@@ -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 —
|
|
45
|
-
//
|
|
46
|
-
//
|
|
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
|
|
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 {
|
|
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
|
}
|
package/dist/lib/auth-resolve.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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";
|