@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.
- package/README.md +10 -7
- package/SECURITY.md +24 -2
- package/config.yaml +32 -5
- package/dist/cli.js +1811 -221
- package/dist/deploy.js +3 -4
- package/dist/fleet-presence.js +98 -0
- package/dist/fleet-verify.js +291 -0
- package/dist/mcp-client-assertion.js +364 -0
- package/dist/probe.js +97 -0
- package/dist/rem/runner.js +82 -12
- package/dist/resources/AttentionQuery.js +356 -0
- package/dist/resources/Credential.js +11 -1
- package/dist/resources/Federation.js +23 -0
- package/dist/resources/MCPClientMetadata.js +88 -0
- package/dist/resources/Memory.js +52 -53
- package/dist/resources/MemoryBootstrap.js +422 -76
- package/dist/resources/MemoryReflect.js +105 -49
- package/dist/resources/MemoryUsage.js +104 -0
- package/dist/resources/OAuth.js +8 -1
- package/dist/resources/OrgEvent.js +11 -0
- package/dist/resources/Presence.js +218 -19
- package/dist/resources/RecordUsage.js +230 -0
- package/dist/resources/Relationship.js +98 -25
- package/dist/resources/SemanticSearch.js +85 -317
- package/dist/resources/SkillScan.js +15 -0
- package/dist/resources/WorkspaceState.js +11 -0
- package/dist/resources/agent-auth.js +60 -2
- package/dist/resources/auth-middleware.js +18 -9
- package/dist/resources/bm25.js +12 -6
- package/dist/resources/collision-lib.js +157 -0
- package/dist/resources/embeddings-boot.js +149 -0
- package/dist/resources/embeddings-provider.js +364 -106
- package/dist/resources/entity-vocab.js +139 -0
- package/dist/resources/health.js +97 -6
- package/dist/resources/mcp-client-metadata-fields.js +112 -0
- package/dist/resources/mcp-handler.js +1 -1
- package/dist/resources/mcp-tools.js +88 -10
- package/dist/resources/memory-reflect-lib.js +289 -0
- package/dist/resources/migration-boot.js +143 -0
- package/dist/resources/migrations/dir-safety.js +71 -0
- package/dist/resources/migrations/embedding-stamp.js +178 -0
- package/dist/resources/migrations/envelope.js +43 -0
- package/dist/resources/migrations/export.js +38 -0
- package/dist/resources/migrations/ledger.js +55 -0
- package/dist/resources/migrations/lock.js +154 -0
- package/dist/resources/migrations/progress.js +31 -0
- package/dist/resources/migrations/registry.js +36 -0
- package/dist/resources/migrations/risk-policy.js +23 -0
- package/dist/resources/migrations/runner.js +455 -0
- package/dist/resources/migrations/snapshot.js +127 -0
- package/dist/resources/migrations/source-fields.js +93 -0
- package/dist/resources/migrations/space.js +167 -0
- package/dist/resources/migrations/state.js +64 -0
- package/dist/resources/migrations/status.js +39 -0
- package/dist/resources/migrations/synthetic-test-migration.js +93 -0
- package/dist/resources/migrations/types.js +9 -0
- package/dist/resources/presence-internal.js +29 -0
- package/dist/resources/provenance.js +50 -0
- package/dist/resources/rate-limiter.js +8 -1
- package/dist/resources/scoring.js +124 -5
- package/dist/resources/semantic-retrieval-core.js +317 -0
- package/dist/version-handshake.js +122 -0
- package/package.json +4 -5
- package/schemas/event.graphql +5 -0
- package/schemas/memory.graphql +66 -0
- package/schemas/schema.graphql +5 -43
- package/schemas/workspace.graphql +3 -0
- package/dist/resources/IngestEvents.js +0 -162
- package/dist/resources/IssueTokens.js +0 -19
- package/dist/resources/ObsAgentSnapshot.js +0 -13
- package/dist/resources/ObsEventFeed.js +0 -13
- package/dist/resources/ObsOffice.js +0 -19
- package/dist/resources/ObservationCenter.js +0 -23
- 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
|
+
}
|
package/dist/rem/runner.js
CHANGED
|
@@ -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.
|
|
9
|
-
* 5.
|
|
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
|
|
14
|
-
* - Step 3 (maintenance)
|
|
15
|
-
* the audit row.
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
23
|
-
* `
|
|
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`
|
|
217
|
-
//
|
|
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 };
|