@tpsdev-ai/flair 0.21.0 → 0.22.1

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.
Files changed (74) hide show
  1. package/README.md +10 -7
  2. package/SECURITY.md +24 -2
  3. package/config.yaml +32 -5
  4. package/dist/cli.js +1811 -221
  5. package/dist/deploy.js +3 -4
  6. package/dist/fleet-presence.js +98 -0
  7. package/dist/fleet-verify.js +291 -0
  8. package/dist/mcp-client-assertion.js +364 -0
  9. package/dist/probe.js +97 -0
  10. package/dist/rem/runner.js +82 -12
  11. package/dist/resources/AttentionQuery.js +356 -0
  12. package/dist/resources/Credential.js +11 -1
  13. package/dist/resources/Federation.js +23 -0
  14. package/dist/resources/MCPClientMetadata.js +88 -0
  15. package/dist/resources/Memory.js +52 -53
  16. package/dist/resources/MemoryBootstrap.js +422 -76
  17. package/dist/resources/MemoryReflect.js +105 -49
  18. package/dist/resources/MemoryUsage.js +104 -0
  19. package/dist/resources/OAuth.js +8 -1
  20. package/dist/resources/OrgEvent.js +11 -0
  21. package/dist/resources/Presence.js +218 -19
  22. package/dist/resources/RecordUsage.js +230 -0
  23. package/dist/resources/Relationship.js +98 -25
  24. package/dist/resources/SemanticSearch.js +85 -317
  25. package/dist/resources/SkillScan.js +15 -0
  26. package/dist/resources/WorkspaceState.js +11 -0
  27. package/dist/resources/agent-auth.js +60 -2
  28. package/dist/resources/auth-middleware.js +18 -9
  29. package/dist/resources/bm25.js +12 -6
  30. package/dist/resources/collision-lib.js +157 -0
  31. package/dist/resources/embeddings-boot.js +149 -0
  32. package/dist/resources/embeddings-provider.js +364 -106
  33. package/dist/resources/entity-vocab.js +139 -0
  34. package/dist/resources/health.js +97 -6
  35. package/dist/resources/mcp-client-metadata-fields.js +112 -0
  36. package/dist/resources/mcp-handler.js +1 -1
  37. package/dist/resources/mcp-tools.js +88 -10
  38. package/dist/resources/memory-reflect-lib.js +289 -0
  39. package/dist/resources/migration-boot.js +143 -0
  40. package/dist/resources/migrations/dir-safety.js +71 -0
  41. package/dist/resources/migrations/embedding-stamp.js +178 -0
  42. package/dist/resources/migrations/envelope.js +43 -0
  43. package/dist/resources/migrations/export.js +38 -0
  44. package/dist/resources/migrations/ledger.js +55 -0
  45. package/dist/resources/migrations/lock.js +154 -0
  46. package/dist/resources/migrations/progress.js +31 -0
  47. package/dist/resources/migrations/registry.js +36 -0
  48. package/dist/resources/migrations/risk-policy.js +23 -0
  49. package/dist/resources/migrations/runner.js +455 -0
  50. package/dist/resources/migrations/snapshot.js +127 -0
  51. package/dist/resources/migrations/source-fields.js +93 -0
  52. package/dist/resources/migrations/space.js +167 -0
  53. package/dist/resources/migrations/state.js +64 -0
  54. package/dist/resources/migrations/status.js +39 -0
  55. package/dist/resources/migrations/synthetic-test-migration.js +93 -0
  56. package/dist/resources/migrations/types.js +9 -0
  57. package/dist/resources/presence-internal.js +29 -0
  58. package/dist/resources/provenance.js +50 -0
  59. package/dist/resources/rate-limiter.js +8 -1
  60. package/dist/resources/scoring.js +124 -5
  61. package/dist/resources/semantic-retrieval-core.js +317 -0
  62. package/dist/version-handshake.js +122 -0
  63. package/package.json +4 -5
  64. package/schemas/event.graphql +5 -0
  65. package/schemas/memory.graphql +66 -0
  66. package/schemas/schema.graphql +5 -43
  67. package/schemas/workspace.graphql +3 -0
  68. package/dist/resources/IngestEvents.js +0 -162
  69. package/dist/resources/IssueTokens.js +0 -19
  70. package/dist/resources/ObsAgentSnapshot.js +0 -13
  71. package/dist/resources/ObsEventFeed.js +0 -13
  72. package/dist/resources/ObsOffice.js +0 -19
  73. package/dist/resources/ObservationCenter.js +0 -23
  74. package/ui/observation-center.html +0 -385
@@ -0,0 +1,364 @@
1
+ /**
2
+ * RFC 7523 client_assertion signing — the Flair/consumer half of headless
3
+ * agent-auth to a Harper MCP `/mcp` endpoint.
4
+ *
5
+ * Builds + signs the `client_assertion` JWT a Flair agent presents to an
6
+ * OAuth token endpoint for the `client_credentials` grant with
7
+ * `private_key_jwt` client authentication (RFC 7523 §2.2), using the agent's
8
+ * EXISTING Ed25519 identity key — no new key material, no browser, no human.
9
+ *
10
+ * The exact claim shape here is deliberately pinned to what
11
+ * HarperFast/oauth PR #165 (`src/lib/mcp/clientAssertion.ts`,
12
+ * merged @ commit d48c3b2) verifies:
13
+ * - header: `alg` exactly `EdDSA`; `typ` "JWT" when present.
14
+ * - payload: `iss` = `sub` = `client_id`; `aud` = the token endpoint
15
+ * (exact string match); `exp` required, ≤ 60s out; `iat` required;
16
+ * `jti` required (replay guard, enforced server-side via
17
+ * `assertionJtiStore.ts`).
18
+ * See test/unit/mcp-client-assertion.test.ts for a mirror of that
19
+ * verification, run against assertions this module produces.
20
+ *
21
+ * ── oauth#161/#162/#163, shipped in @harperfast/oauth@2.2.0 ─────────────────
22
+ * The token-endpoint grant that CONSUMES this assertion
23
+ * (`grant_type=client_credentials`) shipped as HarperFast/oauth PR #170
24
+ * (closes issues #161/#162) in the 2.2.0 release; rate limiting (#171,
25
+ * closes #163) shipped in the same release. Confirmed against the published
26
+ * package's source (`node_modules/@harperfast/oauth/dist/lib/mcp/token.js`,
27
+ * `.../cimd.js`), not guessed:
28
+ * - `client_assertion_type` = `urn:ietf:params:oauth:client-assertion-
29
+ * type:jwt-bearer`; `client_assertion` = the signed JWT; `client_id`
30
+ * required in the form body and MUST equal the assertion's `iss`/`sub`
31
+ * — already true here by construction (`buildTokenRequestForm`'s
32
+ * `clientId` param is always the same value callers pass as
33
+ * `signClientAssertion`'s `clientId`, which becomes both `iss` and
34
+ * `sub`).
35
+ * - RFC 8707 `resource` parameter: accepted, exact-match, fail-closed,
36
+ * defaulting to the configured canonical resource — `buildTokenRequestForm`
37
+ * already carries an optional `resource` pass-through (see below), and
38
+ * `defaultMcpResource()` supplies the configured canonical default
39
+ * (mirrors `resources/mcp-oauth-flag.ts`'s `mcpResource()` — the same
40
+ * `<issuer>/mcp` value the AS will exact-match against).
41
+ * - Issued token `sub` = `client_id`; access-token TTL default 300s
42
+ * (`mcp.clientCredentials.accessTokenTtl`); no `refresh_token` — agents
43
+ * re-mint on 401/near-expiry instead (see `getMcpAccessToken` below).
44
+ * - Issuance is rate-limited per verified client_id
45
+ * (`mcp.clientCredentials.rateLimit`, default 30/min) — debited AFTER
46
+ * assertion verification, so a forged assertion can never drain a real
47
+ * client's bucket. Over-limit is `429 {"error":"slow_down"}` with a
48
+ * `Retry-After` header (seconds). `requestMcpAccessToken` below honors it.
49
+ * `requestMcpAccessToken` performs the real POST; `getMcpAccessToken` wraps
50
+ * it with the two consumer requirements the 2.2.0 rate limiter adds (pinned
51
+ * in flair#663's tracking-issue thread): token caching (reuse until
52
+ * near-expiry, mint sparingly) and jittered Retry-After backoff on 429.
53
+ * See docs/notes/mcp-agent-auth-consumer.md.
54
+ */
55
+ import { createPrivateKey, createPublicKey, randomUUID, sign } from "node:crypto";
56
+ import { existsSync, readFileSync } from "node:fs";
57
+ import { homedir } from "node:os";
58
+ import { join } from "node:path";
59
+ // ─── RFC 7523 constants ─────────────────────────────────────────────────────
60
+ /** RFC 7523 §2.2 value for `client_assertion_type`. */
61
+ export const CLIENT_ASSERTION_TYPE_JWT_BEARER = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
62
+ /**
63
+ * Maximum assertion lifetime (`exp - iat`), in seconds. Pinned to 60 to
64
+ * match HarperFast/oauth PR #165's `DEFAULT_MAX_EXPIRES_IN_SECONDS` — an
65
+ * assertion signed with a longer window would be rejected server-side.
66
+ */
67
+ export const MAX_ASSERTION_LIFETIME_SECONDS = 60;
68
+ // ─── Key loading ────────────────────────────────────────────────────────────
69
+ /**
70
+ * Locate an agent's private key file. Search order mirrors `resolveKeyPath`
71
+ * in src/cli.ts (the existing TPS-Ed25519 REST-auth path) exactly, with one
72
+ * addition: an explicit `keysDirOverride` (this module's `--keys-dir` CLI
73
+ * flag) takes priority over everything, including `FLAIR_KEY_DIR`.
74
+ */
75
+ export function resolveAgentKeyPath(agentId, keysDirOverride) {
76
+ const candidates = [
77
+ keysDirOverride ? join(keysDirOverride, `${agentId}.key`) : null,
78
+ process.env.FLAIR_KEY_DIR ? join(process.env.FLAIR_KEY_DIR, `${agentId}.key`) : null,
79
+ join(homedir(), ".flair", "keys", `${agentId}.key`),
80
+ join(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
81
+ ].filter((p) => Boolean(p));
82
+ return candidates.find((p) => existsSync(p)) ?? null;
83
+ }
84
+ /** Fixed ASN.1 prefix that turns a raw 32-byte Ed25519 seed into a PKCS8 DER key. */
85
+ const ED25519_PKCS8_HEADER = Buffer.from("302e020100300506032b657004220420", "hex");
86
+ /**
87
+ * Load an Ed25519 private key from disk. Accepts every format already in use
88
+ * across this repo's agent keys (mirrors `buildEd25519Auth` in src/cli.ts):
89
+ * - raw 32-byte binary seed
90
+ * - base64-encoded 32-byte seed
91
+ * - base64-encoded full PKCS8 DER (the standard `~/.tps/secrets/flair/
92
+ * <agentId>-priv.key` format written by flair-client.mjs et al.)
93
+ * - raw PEM/DER bytes as a last-resort fallback
94
+ */
95
+ export function loadEd25519PrivateKeyFromFile(filePath) {
96
+ const raw = readFileSync(filePath);
97
+ if (raw.length === 32) {
98
+ return createPrivateKey({
99
+ key: Buffer.concat([ED25519_PKCS8_HEADER, raw]),
100
+ format: "der",
101
+ type: "pkcs8",
102
+ });
103
+ }
104
+ const decoded = Buffer.from(raw.toString("utf-8").trim(), "base64");
105
+ if (decoded.length === 32) {
106
+ return createPrivateKey({
107
+ key: Buffer.concat([ED25519_PKCS8_HEADER, decoded]),
108
+ format: "der",
109
+ type: "pkcs8",
110
+ });
111
+ }
112
+ try {
113
+ return createPrivateKey({ key: decoded, format: "der", type: "pkcs8" });
114
+ }
115
+ catch {
116
+ return createPrivateKey(raw);
117
+ }
118
+ }
119
+ /** Derive the public JWK (OKP/Ed25519) for a loaded private key. */
120
+ export function publicJwkFromPrivateKey(privateKey) {
121
+ const pub = createPublicKey(privateKey);
122
+ const jwk = pub.export({ format: "jwk" });
123
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || typeof jwk.x !== "string" || !jwk.x) {
124
+ throw new Error("private key is not an Ed25519 key");
125
+ }
126
+ return { kty: "OKP", crv: "Ed25519", x: jwk.x };
127
+ }
128
+ function clampExpiresIn(requested) {
129
+ const n = requested ?? MAX_ASSERTION_LIFETIME_SECONDS;
130
+ if (!Number.isFinite(n) || n <= 0)
131
+ return MAX_ASSERTION_LIFETIME_SECONDS;
132
+ return Math.min(n, MAX_ASSERTION_LIFETIME_SECONDS);
133
+ }
134
+ function base64urlJson(obj) {
135
+ return Buffer.from(JSON.stringify(obj), "utf8").toString("base64url");
136
+ }
137
+ /**
138
+ * Build + sign an RFC 7523 client_assertion JWT for the client_credentials
139
+ * grant. Pure given a KeyObject — callers load the key separately (see
140
+ * loadEd25519PrivateKeyFromFile) so this stays independently testable with
141
+ * an ephemeral test keypair.
142
+ */
143
+ export function signClientAssertion(params) {
144
+ const { clientId, tokenEndpoint, privateKey } = params;
145
+ if (!clientId)
146
+ throw new Error("clientId is required");
147
+ if (!tokenEndpoint)
148
+ throw new Error("tokenEndpoint is required");
149
+ const expiresIn = clampExpiresIn(params.expiresInSeconds);
150
+ const iat = Math.floor(params.nowSeconds ?? Date.now() / 1000);
151
+ const exp = iat + expiresIn;
152
+ const jti = params.jti ?? randomUUID();
153
+ // header.typ is optional per RFC 7515 §4.1.9, but #165 accepts it when
154
+ // present (case-insensitively) — include it for maximum interop.
155
+ const header = { alg: "EdDSA", typ: "JWT" };
156
+ const claims = { iss: clientId, sub: clientId, aud: tokenEndpoint, exp, iat, jti };
157
+ const headerB64 = base64urlJson(header);
158
+ const payloadB64 = base64urlJson(claims);
159
+ const signingInput = `${headerB64}.${payloadB64}`;
160
+ // Ed25519 (EdDSA) takes no digest algorithm — pass null, per RFC 8032 and
161
+ // matching #165's own verify call (`verifySignature(null, ...)`).
162
+ const signature = sign(null, Buffer.from(signingInput), privateKey);
163
+ const assertion = `${signingInput}.${signature.toString("base64url")}`;
164
+ return { assertion, claims };
165
+ }
166
+ /**
167
+ * The form-body this assertion would be sent in, per oauth#162's scope (RFC
168
+ * 7523 `client_credentials` grant + RFC 8707 `resource`). Pure/testable;
169
+ * NOT sent anywhere by this module — see requestMcpAccessToken. Callers are
170
+ * responsible for passing the SAME `clientId` used to sign the assertion
171
+ * (`signClientAssertion`'s `iss`/`sub`) — every call site in this repo
172
+ * (`flair mcp token` in src/cli.ts) already does this from a single shared
173
+ * local.
174
+ */
175
+ export function buildTokenRequestForm(params) {
176
+ const form = {
177
+ grant_type: "client_credentials",
178
+ client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER,
179
+ client_assertion: params.assertion,
180
+ client_id: params.clientId,
181
+ };
182
+ if (params.resource)
183
+ form.resource = params.resource;
184
+ return form;
185
+ }
186
+ export class McpTokenRequestError extends Error {
187
+ status;
188
+ error;
189
+ constructor(message, status, error) {
190
+ super(message);
191
+ this.status = status;
192
+ this.error = error;
193
+ this.name = "McpTokenRequestError";
194
+ }
195
+ }
196
+ const DEFAULT_MAX_429_RETRIES = 3;
197
+ // Ceiling on a single backoff sleep: a misconfigured or hostile server
198
+ // returning an absurd Retry-After must not stall the caller indefinitely
199
+ // (mirrors the plugin's own MAX_RETRY_AFTER_SECONDS defense-in-depth
200
+ // posture at the source of the header, node_modules/@harperfast/oauth's
201
+ // rateLimit.ts — this is the client-side counterpart of that cap).
202
+ const MAX_BACKOFF_MS = 60_000;
203
+ // Fallback backoff (exponential, doubling per attempt) when the server 429s
204
+ // without a Retry-After header at all — should not happen against the
205
+ // plugin (it always sets one, see mcp-client-assertion.ts's module header),
206
+ // but a client MUST NOT hammer even a non-conformant server.
207
+ const FALLBACK_BASE_MS = 1_000;
208
+ /**
209
+ * POST the `client_credentials` grant to the MCP token endpoint (oauth#162,
210
+ * shipped in @harperfast/oauth@2.2.0's PR #170). On `429 slow_down`
211
+ * (issuance rate limit, #171/#163), honors the `Retry-After` header with
212
+ * FULL JITTER (sleep a random duration in `[0, Retry-After]`, per the
213
+ * standard token-bucket backoff pattern — never hammer, and never retry in
214
+ * lockstep with other rate-limited callers), retrying up to `maxRetries`
215
+ * times before giving up.
216
+ *
217
+ * Throws `McpTokenRequestError` on any non-2xx response (including the
218
+ * final 429). Never retries non-429 errors — those are the AS telling us
219
+ * the request itself is wrong (bad assertion, unresolvable client, wrong
220
+ * resource), and retrying identically would just repeat the same rejection
221
+ * (worse, it would burn a fresh `jti` for no reason on a client-side bug).
222
+ */
223
+ export async function requestMcpAccessToken(form, tokenEndpoint, opts = {}) {
224
+ const fetchImpl = opts.fetchImpl ?? fetch;
225
+ const sleep = opts.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
226
+ const random = opts.random ?? Math.random;
227
+ const maxRetries = opts.maxRetries ?? DEFAULT_MAX_429_RETRIES;
228
+ const now = opts.now ?? Date.now;
229
+ const body = new URLSearchParams();
230
+ for (const [key, value] of Object.entries(form)) {
231
+ if (value !== undefined)
232
+ body.set(key, String(value));
233
+ }
234
+ for (let attempt = 0;; attempt++) {
235
+ const mintedAtMs = now();
236
+ const res = await fetchImpl(tokenEndpoint, {
237
+ method: "POST",
238
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
239
+ body: body.toString(),
240
+ });
241
+ if (res.status === 429 && attempt < maxRetries) {
242
+ // `res.headers.get()` returns null (not undefined) when absent, and
243
+ // `Number(null) === 0` — a bare `Number(...)` here would silently read
244
+ // a MISSING header as "retry immediately", not "no header present".
245
+ // Distinguish explicitly so absence falls through to the exponential
246
+ // fallback below instead.
247
+ const retryAfterHeader = res.headers.get("retry-after");
248
+ const retryAfterRaw = retryAfterHeader === null ? NaN : Number(retryAfterHeader);
249
+ const baseMs = Number.isFinite(retryAfterRaw) && retryAfterRaw >= 0
250
+ ? retryAfterRaw * 1000
251
+ : FALLBACK_BASE_MS * 2 ** attempt;
252
+ const jitteredMs = Math.min(MAX_BACKOFF_MS, Math.round(random() * baseMs));
253
+ // Drain the body so the connection can be reused; the error payload
254
+ // isn't needed for a retry.
255
+ await res.body?.cancel?.().catch(() => { });
256
+ await sleep(jitteredMs);
257
+ continue;
258
+ }
259
+ let payload;
260
+ try {
261
+ payload = await res.json();
262
+ }
263
+ catch {
264
+ throw new McpTokenRequestError(`MCP token endpoint returned a non-JSON response (HTTP ${res.status})`, res.status);
265
+ }
266
+ if (!res.ok) {
267
+ const errorCode = typeof payload?.error === "string" ? payload.error : "error";
268
+ const description = typeof payload?.error_description === "string" ? `: ${payload.error_description}` : "";
269
+ throw new McpTokenRequestError(`MCP token request failed (HTTP ${res.status} ${errorCode})${description}`, res.status, errorCode);
270
+ }
271
+ if (typeof payload?.access_token !== "string" || !payload.access_token) {
272
+ throw new McpTokenRequestError("MCP token endpoint response is missing access_token", res.status);
273
+ }
274
+ const expiresIn = Number.isFinite(payload.expires_in) ? Number(payload.expires_in) : 300;
275
+ return {
276
+ accessToken: payload.access_token,
277
+ tokenType: typeof payload.token_type === "string" ? payload.token_type : "Bearer",
278
+ expiresIn,
279
+ scope: typeof payload.scope === "string" ? payload.scope : undefined,
280
+ mintedAtMs,
281
+ expiresAtMs: mintedAtMs + expiresIn * 1000,
282
+ };
283
+ }
284
+ }
285
+ // ─── Token caching (consumer requirement — flair#663 tracking-issue thread) ─
286
+ //
287
+ // The 2.2.0 rate limiter (mcp.clientCredentials.rateLimit, default 30/min)
288
+ // is debited PER MINT, not per use — so a caller that re-signs+re-requests
289
+ // on every tool call would burn a real agent's quota for no reason. Cache
290
+ // the minted token per (clientId, tokenEndpoint, resource) and reuse it
291
+ // until it's within `refreshMarginMs` of expiry.
292
+ const mcpTokenCache = new Map();
293
+ function mcpTokenCacheKey(clientId, tokenEndpoint, resource) {
294
+ return `${clientId}${tokenEndpoint}${resource ?? ""}`;
295
+ }
296
+ /** Drop all cached tokens (tests only, or a caller that wants a hard reset — e.g. after rotating the agent's key). */
297
+ export function clearMcpAccessTokenCache() {
298
+ mcpTokenCache.clear();
299
+ }
300
+ const DEFAULT_REFRESH_MARGIN_MS = 30_000;
301
+ /**
302
+ * Mint sparingly: reuse a cached, not-near-expiry token for the same
303
+ * (clientId, tokenEndpoint, resource) rather than signing a fresh assertion
304
+ * and re-minting on every call. This is the mandatory consumer-side
305
+ * counterpart to the 2.2.0 rate limiter (pinned in flair#663's tracking
306
+ * comment: "mint sparingly and reuse until expiry... per-request minting
307
+ * would burn the bucket for no reason").
308
+ */
309
+ export async function getMcpAccessToken(params) {
310
+ const now = (params.now ?? Date.now)();
311
+ const key = mcpTokenCacheKey(params.clientId, params.tokenEndpoint, params.resource);
312
+ const cached = mcpTokenCache.get(key);
313
+ const refreshMarginMs = params.refreshMarginMs ?? DEFAULT_REFRESH_MARGIN_MS;
314
+ if (!params.forceRefresh && cached && cached.expiresAtMs - now > refreshMarginMs) {
315
+ return cached;
316
+ }
317
+ const { assertion } = signClientAssertion({
318
+ clientId: params.clientId,
319
+ tokenEndpoint: params.tokenEndpoint,
320
+ privateKey: params.privateKey,
321
+ expiresInSeconds: params.expiresInSeconds,
322
+ });
323
+ const form = buildTokenRequestForm({ clientId: params.clientId, assertion, resource: params.resource });
324
+ const token = await requestMcpAccessToken(form, params.tokenEndpoint, {
325
+ fetchImpl: params.fetchImpl,
326
+ sleep: params.sleep,
327
+ random: params.random,
328
+ maxRetries: params.maxRetries,
329
+ now: params.now,
330
+ });
331
+ mcpTokenCache.set(key, token);
332
+ return token;
333
+ }
334
+ // ─── Convenience defaults (this Flair instance's own /mcp + oauth surface) ──
335
+ //
336
+ // Mirrors resources/mcp-oauth-flag.ts's mcpIssuer()/mcpResource() env-driven
337
+ // defaults. Duplicated here (rather than imported) because src/ (this CLI's
338
+ // build root, tsconfig.cli.json) and resources/ (tsconfig.json) are separate
339
+ // TypeScript compilation roots with no existing cross-import in this repo —
340
+ // keep both in sync if either changes. These are only DEFAULTS: every value
341
+ // is overridable via CLI flags, since an agent may authenticate to a
342
+ // DIFFERENT Harper MCP server than the one running this CLI (identity is
343
+ // portable — the whole point of the design, see
344
+ // ~/ops/FLAIR-AGENT-AUTH-CONSUMER-SPEC.md "Wiring / usage").
345
+ /** `FLAIR_MCP_ISSUER`, falling back to `FLAIR_PUBLIC_URL`. */
346
+ export function defaultMcpIssuer() {
347
+ const raw = (process.env.FLAIR_MCP_ISSUER ?? process.env.FLAIR_PUBLIC_URL ?? "").trim();
348
+ return raw || undefined;
349
+ }
350
+ /** `${issuer}/mcp` — the RFC 8707 resource this instance's /mcp binds to. */
351
+ export function defaultMcpResource() {
352
+ const iss = defaultMcpIssuer();
353
+ return iss ? `${iss.replace(/\/+$/, "")}/mcp` : undefined;
354
+ }
355
+ /** `${issuer}/oauth/mcp/token` — matches HarperFast/oauth's wellKnown.ts discovery doc. */
356
+ export function defaultMcpTokenEndpoint() {
357
+ const iss = defaultMcpIssuer();
358
+ return iss ? `${iss.replace(/\/+$/, "")}/oauth/mcp/token` : undefined;
359
+ }
360
+ /** `${issuer}/MCPClientMetadata/{agentId}` — matches resources/MCPClientMetadata.ts. */
361
+ export function defaultMcpClientId(agentId) {
362
+ const iss = defaultMcpIssuer();
363
+ return iss ? `${iss.replace(/\/+$/, "")}/MCPClientMetadata/${agentId}` : undefined;
364
+ }
package/dist/probe.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * probe.ts — post-restart instance verification.
3
+ *
4
+ * Shared by `flair upgrade`'s local post-restart verification (flair#635)
5
+ * and the planned Fabric fleet verify sweep (flair#636, peer-at-a-time
6
+ * rolling restart gate). `probeInstance()` answers three questions about a
7
+ * running Flair instance:
8
+ *
9
+ * 1. Is it reachable at all? (GET /Health — public, no auth, polled until
10
+ * it answers or the time budget runs out.)
11
+ * 2. Does an authenticated request round-trip successfully? Credential
12
+ * resolution is ENTIRELY the caller's responsibility via the injected
13
+ * `authedGet` — probeInstance never resolves credentials itself. Local
14
+ * `flair upgrade` dogfoods `api()`'s local-credential resolution
15
+ * (flair#640: FLAIR_TOKEN > FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD env >
16
+ * agent Ed25519 key > ~/.flair/admin-pass file); a future Fabric fleet
17
+ * check will instead build Fabric admin Basic auth per peer. Same
18
+ * shape, different credentials — that's the reuse.
19
+ * 3. Does the reported running version match what was just installed?
20
+ * Read from the authenticated GET /HealthDetail response's `version`
21
+ * field (resources/health.ts resolves it from the RUNNING process's
22
+ * own package.json — it only changes once the process actually
23
+ * restarts onto new code, so it can't be spoofed by a package.json
24
+ * that changed on disk but hasn't been loaded yet).
25
+ *
26
+ * Never throws — every failure mode is expressed structurally in
27
+ * ProbeResult so callers can render (or automate a rollback decision from)
28
+ * a clear reason instead of catching an exception.
29
+ */
30
+ export const DEFAULT_PROBE_TIMEOUT_MS = 60_000;
31
+ export const DEFAULT_PROBE_POLL_INTERVAL_MS = 500;
32
+ export const DEFAULT_PROBE_VERSION_PATH = "/HealthDetail";
33
+ /**
34
+ * Poll `${baseUrl}/Health` until it answers 2xx or `timeoutMs` elapses, then
35
+ * (if `authedGet` is given) make ONE authenticated GET against
36
+ * `versionPath` to confirm auth works and read the running version.
37
+ */
38
+ export async function probeInstance(baseUrl, opts = {}) {
39
+ const { expectVersion, timeoutMs = DEFAULT_PROBE_TIMEOUT_MS, pollIntervalMs = DEFAULT_PROBE_POLL_INTERVAL_MS, fetchImpl = fetch, authedGet, versionPath = DEFAULT_PROBE_VERSION_PATH, } = opts;
40
+ const base = baseUrl.replace(/\/+$/, "");
41
+ const deadline = Date.now() + timeoutMs;
42
+ let healthy = false;
43
+ let lastHealthError;
44
+ for (;;) {
45
+ try {
46
+ const res = await fetchImpl(`${base}/Health`, { signal: AbortSignal.timeout(Math.min(5000, timeoutMs)) });
47
+ if (res.ok) {
48
+ healthy = true;
49
+ break;
50
+ }
51
+ lastHealthError = `HTTP ${res.status}`;
52
+ }
53
+ catch (err) {
54
+ lastHealthError = err?.message ?? String(err);
55
+ }
56
+ if (Date.now() >= deadline)
57
+ break;
58
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
59
+ }
60
+ if (!healthy) {
61
+ return {
62
+ healthy: false,
63
+ authenticated: null,
64
+ version: null,
65
+ versionMatch: null,
66
+ ok: false,
67
+ error: `instance did not answer ${base}/Health within ${timeoutMs}ms` +
68
+ (lastHealthError ? ` (last error: ${lastHealthError})` : ""),
69
+ };
70
+ }
71
+ if (!authedGet) {
72
+ return { healthy: true, authenticated: null, version: null, versionMatch: null, ok: true };
73
+ }
74
+ let version = null;
75
+ let authError;
76
+ try {
77
+ const body = await authedGet(versionPath);
78
+ version = typeof body?.version === "string" ? body.version : null;
79
+ }
80
+ catch (err) {
81
+ authError = err?.message ?? String(err);
82
+ }
83
+ const authenticated = authError === undefined;
84
+ let versionMatch = null;
85
+ if (authenticated && expectVersion !== undefined) {
86
+ versionMatch = version === expectVersion;
87
+ }
88
+ const ok = healthy && authenticated && versionMatch !== false;
89
+ let error;
90
+ if (!authenticated) {
91
+ error = `authenticated request to ${base}${versionPath} failed: ${authError}`;
92
+ }
93
+ else if (versionMatch === false) {
94
+ error = `version mismatch: expected ${expectVersion}, instance reports ${version ?? "unknown"}`;
95
+ }
96
+ return { healthy, authenticated, version, versionMatch, ok, error };
97
+ }
@@ -5,22 +5,32 @@
5
5
  * 1. Pre-flight: check pause sentinel / FLAIR_REM_PAUSE env. Exit clean if paused.
6
6
  * 2. Snapshot agent state (memory + soul) to ~/.flair/snapshots/<agent>/.
7
7
  * 3. Maintenance — delegate to /MemoryMaintenance (same code path `flair rem light` uses).
8
- * 4. (slice-2 next) trust-tier filter on input memories.
9
- * 5. (slice-2 next) distillation — call /ReflectMemories, persist candidates.
8
+ * 4. Trust-tier filter on input memories — permanently deferred (see below).
9
+ * 5. Distillation — call /ReflectMemories with execute:true, persist staged
10
+ * candidate ids to the audit row.
10
11
  * 6. Append a row to ~/.flair/logs/rem-nightly.jsonl.
11
12
  *
12
13
  * Status today:
13
- * - Steps 1, 2, 6 shipped in slice-1 PR-1 (#414).
14
- * - Step 3 (maintenance) ships in this PR — fills `archived`/`expired` in
15
- * the audit row.
16
- * - Steps 4, 5 are slice-2 follow-ups; require an in-process distillation
17
- * LLM path (today `/ReflectMemories` returns a prompt for human/agent
18
- * consumption, not server-side candidate generation).
14
+ * - Steps 1, 2 shipped in slice-1 PR-1 (#414).
15
+ * - Step 3 (maintenance) shipped in a prior slice-2 PR — fills
16
+ * `archived`/`expired` in the audit row.
17
+ * - Step 4: per specs/FLAIR-NIGHTLY-REM-SLICE-2-DISTILLATION.md § 3B,
18
+ * "Deferred from parent § 4 step 4" trust tiers aren't derivable yet
19
+ * (that's the emergent-trust arc). The input filter stays as today (own
20
+ * agent, non-archived, non-permanent, scope window); the safety net for
21
+ * un-tiered input is structural — candidates are staged, never
22
+ * auto-promoted.
23
+ * - Step 5 (distillation) ships in this PR: calls `/ReflectMemories` with
24
+ * `execute: true` after maintenance succeeds. `dryRun` skips the call
25
+ * entirely (staging rows + spending model tokens are side effects).
26
+ * - Step 6 shipped in slice-1 PR-1.
19
27
  *
20
28
  * The audit row's `slice` field tells readers which steps populated which
21
29
  * counts: `slice: "1"` rows have `archived`/`expired` undefined; `slice:
22
- * "2-maintenance"` rows populate them; future slice-2 rows will populate
23
- * `consolidated` and `candidates`.
30
+ * "2-maintenance"` rows populate them but distillation didn't run this cycle
31
+ * (dry-run skip); `slice: "2"` rows had distillation attempted — check
32
+ * `candidates` for staged ids on success, `errors` for a `distillation:`
33
+ * entry on failure (maintenance results still stand either way).
24
34
  *
25
35
  * Pure dependency injection so the runner is unit-testable without Harper.
26
36
  * The CLI wires the real `apiCall` + `pkgVersion`; tests pass stubs.
@@ -64,6 +74,29 @@ function asArray(raw) {
64
74
  }
65
75
  return [];
66
76
  }
77
+ /**
78
+ * Best-effort extraction of a readable message out of a thrown API error.
79
+ * `apiCall` implementations (see `api()` in src/cli.ts) throw
80
+ * `Error(responseBodyText)` for non-2xx HTTP responses — /ReflectMemories's
81
+ * 503 (no backend configured) and 502 (distillation_failed) bodies are JSON
82
+ * `{ error: string, detail?: string }`. Unwraps that shape into a single
83
+ * string so distinct failure modes read distinctly in the audit log instead
84
+ * of as a raw JSON blob; falls back to the raw input for network errors or
85
+ * any other shape.
86
+ */
87
+ function describeApiError(err) {
88
+ const message = typeof err === "string" ? err : err?.message ?? String(err);
89
+ try {
90
+ const parsed = JSON.parse(message);
91
+ if (parsed && typeof parsed === "object" && typeof parsed.error === "string") {
92
+ return parsed.detail ? `${parsed.error}: ${parsed.detail}` : parsed.error;
93
+ }
94
+ }
95
+ catch {
96
+ // Not a JSON error body — network error, plain string, etc.
97
+ }
98
+ return message;
99
+ }
67
100
  /**
68
101
  * Counts pending memory candidates for the agent.
69
102
  *
@@ -199,6 +232,42 @@ export async function runNightlyCycle(opts) {
199
232
  appendLogRow(logPath, row);
200
233
  return { status: "failed", logRow: row, snapshotPath };
201
234
  }
235
+ // Step 5: distillation — call /ReflectMemories with execute:true, now that
236
+ // maintenance has succeeded. Per spec § 3B, dryRun skips the call entirely:
237
+ // staging MemoryCandidate rows and spending model tokens are side effects,
238
+ // the same way dryRun skips the snapshot write.
239
+ // When the call IS attempted (success or failure), the audit row's `slice`
240
+ // flips to "2" — "2-maintenance" is reserved for the dry-run skip case.
241
+ let candidates;
242
+ if (!opts.dryRun) {
243
+ sliceLabel = "2";
244
+ try {
245
+ const reflectRaw = await opts.apiCall("POST", "/ReflectMemories", {
246
+ agentId: opts.agentId,
247
+ execute: true,
248
+ });
249
+ const obj = (reflectRaw && typeof reflectRaw === "object") ? reflectRaw : {};
250
+ if (obj.error) {
251
+ // Defensive: a 200 response shouldn't carry { error }, since
252
+ // MemoryReflect signals failure via HTTP status (503/502) — apiCall
253
+ // implementations throw for those. Handled the same way regardless.
254
+ errors.push(`distillation: ${describeApiError(obj.error)}`);
255
+ }
256
+ else {
257
+ const staged = asArray(obj.candidates);
258
+ candidates = staged
259
+ .map((c) => (c && typeof c === "object" ? c.id : c))
260
+ .filter((id) => typeof id === "string");
261
+ }
262
+ }
263
+ catch (err) {
264
+ // Distillation failure is recorded, not fatal — maintenance already
265
+ // succeeded and the cycle's guaranteed steps are done (spec § 3B item
266
+ // 3). Zero partial candidates is guaranteed server-side (all-or-
267
+ // nothing staging in /ReflectMemories).
268
+ errors.push(`distillation: ${describeApiError(err?.message ?? err)}`);
269
+ }
270
+ }
202
271
  // Step 6: log
203
272
  const row = {
204
273
  ...baseRow,
@@ -211,10 +280,11 @@ export async function runNightlyCycle(opts) {
211
280
  pendingCandidates,
212
281
  archived,
213
282
  expired,
283
+ candidates,
214
284
  durationMs: Date.now() - startedMs,
215
285
  errors,
216
- // `consolidated` and `candidates` populate when slice-2 PR-4 (distillation)
217
- // lands; today they remain undefined so the row is honest about what ran.
286
+ // `consolidated` remains undefined this runner has no consolidation
287
+ // step; it's reserved for a future slice that adds one.
218
288
  };
219
289
  appendLogRow(logPath, row);
220
290
  return { status: row.status, logRow: row, snapshotPath };