@tpsdev-ai/flair-client 0.49.0 → 0.50.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/dist/auth.d.ts CHANGED
@@ -7,7 +7,55 @@
7
7
  import { type KeyObject } from "node:crypto";
8
8
  /** Resolve an Ed25519 private key from a file (base64 PKCS8 DER or raw 32-byte seed). */
9
9
  export declare function loadPrivateKey(path: string): KeyObject;
10
+ /** Injectable homes so tests can diverge `$HOME` / `os.homedir()` / passwd home. */
11
+ export interface HomeSources {
12
+ homedir: string;
13
+ envHome?: string;
14
+ userHomedir?: string;
15
+ }
16
+ /**
17
+ * Homes to probe, computed at CALL TIME (flair#1271).
18
+ *
19
+ * MCP hosts (npx under Cursor / Grok Bot / Claude Code) often have a different
20
+ * env than the shell that ran `flair agent add`. `os.homedir()` is the
21
+ * documented resolver — called here, not cached at module load — and we also
22
+ * keep `$HOME` and `os.userInfo().homedir` when they differ so a sanitized
23
+ * MCP `HOME` still finds `~/.flair/keys/<id>.key` on the real account home.
24
+ *
25
+ * Empty / relative values are dropped: `path.resolve("")` is cwd, which is
26
+ * exactly the footgun this function exists to refuse.
27
+ *
28
+ * `sources` is for tests: a fixture that puts the key only on `userHomedir`
29
+ * while `homedir`/`envHome` point at a sandbox must FAIL if the passwd-home
30
+ * probe is dropped.
31
+ */
32
+ export declare function callTimeHomes(sources?: HomeSources): string[];
33
+ /** Expand a leading `~/` against `home`. Unexpanded `~` with no home is left intact. */
34
+ export declare function expandHomePrefix(p: string, home: string): string;
35
+ /** Candidate key files for `agentId`, in probe order. Deduplicated. */
36
+ export declare function keyPathCandidates(agentId: string, keyPath?: string, homes?: string[]): string[];
37
+ /** How the request that failed was authenticated. */
38
+ export type KeyAuthMethod = "ed25519" | "basic" | "none";
39
+ /** Snapshot of a key-file lookup — attached to 401/403 so the error can name paths. */
40
+ export interface KeyLookupState {
41
+ agentId: string;
42
+ /** `os.homedir()` at lookup time (may be empty if it was unusable). */
43
+ home: string;
44
+ candidates: {
45
+ path: string;
46
+ exists: boolean;
47
+ }[];
48
+ resolvedPath: string | null;
49
+ /** True when this request carried an Ed25519 Authorization header. */
50
+ signed: boolean;
51
+ /** What was actually sent. Basic-auth 401s must not push FLAIR_KEY_PATH. */
52
+ authMethod?: KeyAuthMethod;
53
+ }
54
+ /** Inspect standard key locations. Does not cache — call at request time (flair#1271). */
55
+ export declare function inspectKeyLookup(agentId: string, keyPath?: string, homes?: string[]): Omit<KeyLookupState, "signed" | "authMethod">;
10
56
  /** Find the agent's private key file from standard locations. */
11
- export declare function resolveKeyPath(agentId: string, keyPath?: string): string | null;
57
+ export declare function resolveKeyPath(agentId: string, keyPath?: string, homes?: string[]): string | null;
58
+ /** Actor + state + remedy for a 401/403 (flair#1271). */
59
+ export declare function formatKeyLookup(state: KeyLookupState): string;
12
60
  /** Build an Authorization header for a Flair request. */
13
61
  export declare function signRequest(agentId: string, privateKey: KeyObject, method: string, path: string): string;
package/dist/auth.js CHANGED
@@ -6,8 +6,8 @@
6
6
  */
7
7
  import { randomUUID, sign as ed25519Sign, createPrivateKey } from "node:crypto";
8
8
  import { readFileSync, existsSync } from "node:fs";
9
- import { resolve } from "node:path";
10
- import { homedir } from "node:os";
9
+ import { homedir, userInfo } from "node:os";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
11
  import { readEnvOrUnset } from "./env-guard.js";
12
12
  const PKCS8_ED25519_PREFIX = Buffer.from("302e020100300506032b657004220420", "hex");
13
13
  /** Resolve an Ed25519 private key from a file (base64 PKCS8 DER or raw 32-byte seed). */
@@ -20,22 +20,136 @@ export function loadPrivateKey(path) {
20
20
  : decoded;
21
21
  return createPrivateKey({ key: der, format: "der", type: "pkcs8" });
22
22
  }
23
- /** Find the agent's private key file from standard locations. */
24
- export function resolveKeyPath(agentId, keyPath) {
23
+ /**
24
+ * Homes to probe, computed at CALL TIME (flair#1271).
25
+ *
26
+ * MCP hosts (npx under Cursor / Grok Bot / Claude Code) often have a different
27
+ * env than the shell that ran `flair agent add`. `os.homedir()` is the
28
+ * documented resolver — called here, not cached at module load — and we also
29
+ * keep `$HOME` and `os.userInfo().homedir` when they differ so a sanitized
30
+ * MCP `HOME` still finds `~/.flair/keys/<id>.key` on the real account home.
31
+ *
32
+ * Empty / relative values are dropped: `path.resolve("")` is cwd, which is
33
+ * exactly the footgun this function exists to refuse.
34
+ *
35
+ * `sources` is for tests: a fixture that puts the key only on `userHomedir`
36
+ * while `homedir`/`envHome` point at a sandbox must FAIL if the passwd-home
37
+ * probe is dropped.
38
+ */
39
+ export function callTimeHomes(sources) {
40
+ const homes = [];
41
+ const push = (h) => {
42
+ if (h && isAbsolute(h) && !homes.includes(h))
43
+ homes.push(h);
44
+ };
45
+ if (sources) {
46
+ push(sources.homedir);
47
+ push(sources.envHome);
48
+ push(sources.userHomedir);
49
+ return homes;
50
+ }
51
+ push(homedir());
52
+ push(process.env.HOME);
53
+ try {
54
+ push(userInfo().homedir);
55
+ }
56
+ catch {
57
+ // No passwd entry (some containers). Skip — do not fall back to cwd.
58
+ }
59
+ return homes;
60
+ }
61
+ /** Expand a leading `~/` against `home`. Unexpanded `~` with no home is left intact. */
62
+ export function expandHomePrefix(p, home) {
63
+ if (p === "~")
64
+ return home || p;
65
+ if ((p.startsWith("~/") || p.startsWith("~\\")) && home) {
66
+ return join(home, p.slice(2));
67
+ }
68
+ return p;
69
+ }
70
+ /**
71
+ * Make `p` absolute without turning an unexpanded `~` into a cwd-relative path.
72
+ * Operator-supplied relative paths (no `~`) still resolve against cwd.
73
+ */
74
+ function toAbsoluteKeyPath(p) {
75
+ if (!p)
76
+ return null;
77
+ if (isAbsolute(p))
78
+ return p;
79
+ if (p.startsWith("~"))
80
+ return null;
81
+ return resolve(p);
82
+ }
83
+ /** Candidate key files for `agentId`, in probe order. Deduplicated. */
84
+ export function keyPathCandidates(agentId, keyPath, homes = callTimeHomes()) {
85
+ const primaryHome = homes[0] ?? "";
25
86
  if (keyPath) {
26
- const resolved = resolve(keyPath.replace(/^~/, homedir()));
27
- return existsSync(resolved) ? resolved : null;
87
+ const abs = toAbsoluteKeyPath(expandHomePrefix(keyPath, primaryHome));
88
+ return abs ? [abs] : [];
28
89
  }
90
+ const out = [];
29
91
  // flair#1254: an unsubstituted `${FLAIR_KEY_DIR}` literal reads as unset, so
30
92
  // key resolution falls through to the standard locations below instead of
31
93
  // probing a directory literally named "${FLAIR_KEY_DIR}".
32
94
  const keyDir = readEnvOrUnset("FLAIR_KEY_DIR");
33
- const candidates = [
34
- keyDir ? resolve(keyDir, `${agentId}.key`) : null,
35
- resolve(homedir(), ".flair", "keys", `${agentId}.key`),
36
- resolve(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
37
- ].filter(Boolean);
38
- return candidates.find(existsSync) ?? null;
95
+ if (keyDir) {
96
+ const absDir = toAbsoluteKeyPath(expandHomePrefix(keyDir, primaryHome));
97
+ if (absDir)
98
+ out.push(join(absDir, `${agentId}.key`));
99
+ }
100
+ for (const home of homes) {
101
+ out.push(join(home, ".flair", "keys", `${agentId}.key`));
102
+ out.push(join(home, ".tps", "secrets", "flair", `${agentId}-priv.key`));
103
+ }
104
+ return [...new Set(out)];
105
+ }
106
+ function authMethodOf(state) {
107
+ if (state.authMethod)
108
+ return state.authMethod;
109
+ return state.signed ? "ed25519" : "none";
110
+ }
111
+ /** Inspect standard key locations. Does not cache — call at request time (flair#1271). */
112
+ export function inspectKeyLookup(agentId, keyPath, homes) {
113
+ const resolvedHomes = homes ?? callTimeHomes();
114
+ const candidates = keyPathCandidates(agentId, keyPath, resolvedHomes).map((path) => ({
115
+ path,
116
+ exists: existsSync(path),
117
+ }));
118
+ return {
119
+ agentId,
120
+ home: resolvedHomes[0] ?? "",
121
+ candidates,
122
+ resolvedPath: candidates.find((c) => c.exists)?.path ?? null,
123
+ };
124
+ }
125
+ /** Find the agent's private key file from standard locations. */
126
+ export function resolveKeyPath(agentId, keyPath, homes) {
127
+ return inspectKeyLookup(agentId, keyPath, homes).resolvedPath;
128
+ }
129
+ /** Actor + state + remedy for a 401/403 (flair#1271). */
130
+ export function formatKeyLookup(state) {
131
+ const actor = state.agentId
132
+ ? `agent '${state.agentId}'`
133
+ : "this agent (FLAIR_AGENT_ID unset)";
134
+ const method = authMethodOf(state);
135
+ if (method === "basic") {
136
+ return (`${actor} sent this request with Basic admin credentials (FLAIR_ADMIN_USER / FLAIR_ADMIN_PASSWORD), not an Ed25519 key.\n` +
137
+ "The server rejected those admin credentials. Check FLAIR_ADMIN_USER and FLAIR_ADMIN_PASSWORD — this 401 is not a missing agent key.");
138
+ }
139
+ const stateLine = method === "ed25519"
140
+ ? `${actor} signed with ${state.resolvedPath}.`
141
+ : `${actor} sent this request without a signing key.`;
142
+ const homeLine = `os.homedir() at lookup: ${state.home || "(empty — refused to fall back to cwd)"}`;
143
+ const looked = state.candidates.length === 0
144
+ ? "Looked for a key at: (no candidate paths — home could not be resolved)."
145
+ : [
146
+ "Looked for a key at:",
147
+ ...state.candidates.map((c) => ` ${c.path} (${c.exists ? "found" : "missing"})`),
148
+ ].join("\n");
149
+ const remedy = method === "ed25519"
150
+ ? "The server rejected the signature. Confirm the agent is registered (`flair agent add <id>`) and this key matches the Agent record. A daemon restart can also produce this — check `flair status`."
151
+ : "If `flair agent add` wrote the key, this process's home may differ from that shell, or the file appeared after an earlier lookup. Retry the tool (misses are no longer cached). Still missing? Set FLAIR_KEY_PATH to the absolute path of the .key file.";
152
+ return `${stateLine}\n${homeLine}\n${looked}\n${remedy}`;
39
153
  }
40
154
  /** Build an Authorization header for a Flair request. */
41
155
  export function signRequest(agentId, privateKey, method, path) {
package/dist/client.d.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * const results = await flair.memory.search('that thing')
8
8
  * const ctx = await flair.bootstrap({ maxTokens: 4000 })
9
9
  */
10
+ import { type KeyLookupState } from "./auth.js";
10
11
  import type { FlairClientConfig, Memory, MemoryType, Durability, Visibility, SoulEntry, SearchResult, BootstrapResult, Relationship } from "./types.js";
11
12
  export declare class FlairClient {
12
13
  readonly url: string;
@@ -19,9 +20,10 @@ export declare class FlairClient {
19
20
  * MemoryApi reads this to (optionally) stamp memory write payloads. */
20
21
  readonly claimedClient: string | undefined;
21
22
  private privateKey;
22
- private keyResolved;
23
23
  private keyPath;
24
24
  private rawPrivateKey;
25
+ /** Last file-key lookup — attached to 401/403 so the error can name paths (flair#1271). */
26
+ private lastKeyLookup;
25
27
  private timeoutMs;
26
28
  private basicAuth;
27
29
  constructor(config: FlairClientConfig);
@@ -76,6 +78,11 @@ declare class MemoryApi {
76
78
  /** Cosine-similarity threshold hint for the server's dedup gate.
77
79
  * Default (server-side): 0.95 */
78
80
  dedupThreshold?: number;
81
+ /** Citation-on-write (flair#1147 / #744). IDs of memories that informed
82
+ * this write. Forwarded only when the caller supplies a non-empty array;
83
+ * the server credits each id through the same usage ledger as
84
+ * POST /RecordUsage and strips the field before persisting. */
85
+ usedMemoryIds?: string[];
79
86
  }): Promise<Memory>;
80
87
  /**
81
88
  * Update an existing memory by id. Dedup-BYPASSED (this IS the intentional
@@ -99,6 +106,7 @@ declare class MemoryApi {
99
106
  */
100
107
  update(id: string, content: string, opts?: {
101
108
  preserveHistory?: boolean;
109
+ usedMemoryIds?: string[];
102
110
  }): Promise<Memory>;
103
111
  /** Search memories by meaning. Optionally filter to facts valid at a specific point in time. */
104
112
  search(query: string, opts?: {
@@ -234,6 +242,10 @@ export declare class FlairError extends Error {
234
242
  readonly path: string;
235
243
  readonly status: number;
236
244
  readonly body: string;
237
- constructor(method: string, path: string, status: number, body: string);
245
+ /** Key-file lookup at the request that failed — named on 401/403 (flair#1271). */
246
+ readonly keyLookup?: KeyLookupState | undefined;
247
+ constructor(method: string, path: string, status: number, body: string,
248
+ /** Key-file lookup at the request that failed — named on 401/403 (flair#1271). */
249
+ keyLookup?: KeyLookupState | undefined);
238
250
  }
239
251
  export {};
package/dist/client.js CHANGED
@@ -8,7 +8,7 @@
8
8
  * const ctx = await flair.bootstrap({ maxTokens: 4000 })
9
9
  */
10
10
  import { createHash, createPrivateKey } from "node:crypto";
11
- import { loadPrivateKey, resolveKeyPath, signRequest } from "./auth.js";
11
+ import { inspectKeyLookup, loadPrivateKey, signRequest } from "./auth.js";
12
12
  import { readEnvOrUnset } from "./env-guard.js";
13
13
  const DEFAULT_URL = "http://localhost:19926";
14
14
  const DEFAULT_TIMEOUT = 30_000;
@@ -23,9 +23,10 @@ export class FlairClient {
23
23
  * MemoryApi reads this to (optionally) stamp memory write payloads. */
24
24
  claimedClient;
25
25
  privateKey = null;
26
- keyResolved = false;
27
26
  keyPath;
28
27
  rawPrivateKey;
28
+ /** Last file-key lookup — attached to 401/403 so the error can name paths (flair#1271). */
29
+ lastKeyLookup;
29
30
  timeoutMs;
30
31
  basicAuth = null;
31
32
  constructor(config) {
@@ -53,9 +54,12 @@ export class FlairClient {
53
54
  this.soul = new SoulApi(this);
54
55
  }
55
56
  resolveKey() {
56
- if (this.keyResolved)
57
+ // Cache a FOUND key only. A miss must be retried on the next request —
58
+ // flair#1271: `flair agent add` can write ~/.flair/keys/<id>.key after
59
+ // this client was constructed (or after an earlier probe), and a cached
60
+ // null left flair-mcp 401ing until FLAIR_KEY_PATH was set by hand.
61
+ if (this.privateKey)
57
62
  return this.privateKey;
58
- this.keyResolved = true;
59
63
  // In-memory key takes priority over file-based resolution.
60
64
  if (this.rawPrivateKey) {
61
65
  if (typeof this.rawPrivateKey === "string") {
@@ -64,14 +68,29 @@ export class FlairClient {
64
68
  else {
65
69
  this.privateKey = this.rawPrivateKey;
66
70
  }
71
+ this.lastKeyLookup = {
72
+ agentId: this.agentId,
73
+ home: "",
74
+ candidates: [],
75
+ resolvedPath: "(in-memory)",
76
+ signed: true,
77
+ authMethod: "ed25519",
78
+ };
67
79
  return this.privateKey;
68
80
  }
69
- const path = resolveKeyPath(this.agentId, this.keyPath);
70
- if (path) {
81
+ // inspectKeyLookup calls os.homedir() at this moment — not a module-load
82
+ // snapshot, not cwd (flair#1271).
83
+ const lookup = inspectKeyLookup(this.agentId, this.keyPath);
84
+ if (lookup.resolvedPath) {
71
85
  // Key file exists — failure to parse is a hard error.
72
86
  // Silent fallback to unauthenticated would be a security risk.
73
- this.privateKey = loadPrivateKey(path);
87
+ this.privateKey = loadPrivateKey(lookup.resolvedPath);
74
88
  }
89
+ this.lastKeyLookup = {
90
+ ...lookup,
91
+ signed: this.privateKey != null,
92
+ authMethod: this.privateKey ? "ed25519" : "none",
93
+ };
75
94
  return this.privateKey;
76
95
  }
77
96
  /** Make an authenticated request to Flair. */
@@ -83,6 +102,16 @@ export class FlairClient {
83
102
  }
84
103
  else if (this.basicAuth) {
85
104
  headers["Authorization"] = this.basicAuth;
105
+ // Basic-only snapshot — do not spread a prior inspectKeyLookup result.
106
+ // A 401 here is about admin credentials, not key-file paths (review on #1390).
107
+ this.lastKeyLookup = {
108
+ agentId: this.agentId,
109
+ home: "",
110
+ candidates: [],
111
+ resolvedPath: null,
112
+ signed: false,
113
+ authMethod: "basic",
114
+ };
86
115
  }
87
116
  const res = await fetch(`${this.url}${path}`, {
88
117
  method,
@@ -92,7 +121,7 @@ export class FlairClient {
92
121
  });
93
122
  if (!res.ok) {
94
123
  const text = await res.text().catch(() => "");
95
- throw new FlairError(method, path, res.status, text.slice(0, 500));
124
+ throw new FlairError(method, path, res.status, text.slice(0, 500), this.lastKeyLookup);
96
125
  }
97
126
  const text = await res.text();
98
127
  return text ? JSON.parse(text) : {};
@@ -154,6 +183,11 @@ class MemoryApi {
154
183
  record.dedup = opts.dedup;
155
184
  if (opts.dedupThreshold !== undefined)
156
185
  record.dedupThreshold = opts.dedupThreshold;
186
+ // flair#1147: citation-on-write passthrough — only when supplied, so an
187
+ // omitted list is byte-identical to a pre-#1147 write.
188
+ if (Array.isArray(opts.usedMemoryIds) && opts.usedMemoryIds.length > 0) {
189
+ record.usedMemoryIds = opts.usedMemoryIds;
190
+ }
157
191
  // flair#718 authorship-provenance: forward this process's claimed client
158
192
  // label (config.claimedClient / FLAIR_CLIENT env) only when set — the
159
193
  // server folds it into provenance.claimed.client and strips it from the
@@ -222,6 +256,9 @@ class MemoryApi {
222
256
  // flair#718 authorship-provenance — see write()'s identical comment above.
223
257
  if (this.client.claimedClient)
224
258
  record.claimedClient = this.client.claimedClient;
259
+ if (Array.isArray(opts.usedMemoryIds) && opts.usedMemoryIds.length > 0) {
260
+ record.usedMemoryIds = opts.usedMemoryIds;
261
+ }
225
262
  // The Memory schema does not expose a working HTTP POST route (see
226
263
  // resources/Memory.ts) — Memory.post() is only reachable in-process
227
264
  // (resources/mcp-tools.ts). So the supersede-link write goes through
@@ -242,6 +279,9 @@ class MemoryApi {
242
279
  // flair#718 authorship-provenance — see write()'s identical comment above.
243
280
  if (this.client.claimedClient)
244
281
  merged.claimedClient = this.client.claimedClient;
282
+ if (Array.isArray(opts.usedMemoryIds) && opts.usedMemoryIds.length > 0) {
283
+ merged.usedMemoryIds = opts.usedMemoryIds;
284
+ }
245
285
  const response = await this.client.request("PUT", `/Memory/${id}`, merged);
246
286
  return { ...merged, id, ...(response ?? {}) };
247
287
  }
@@ -470,12 +510,16 @@ export class FlairError extends Error {
470
510
  path;
471
511
  status;
472
512
  body;
473
- constructor(method, path, status, body) {
513
+ keyLookup;
514
+ constructor(method, path, status, body,
515
+ /** Key-file lookup at the request that failed — named on 401/403 (flair#1271). */
516
+ keyLookup) {
474
517
  super(`Flair ${method} ${path} → ${status}: ${body}`);
475
518
  this.method = method;
476
519
  this.path = path;
477
520
  this.status = status;
478
521
  this.body = body;
522
+ this.keyLookup = keyLookup;
479
523
  this.name = "FlairError";
480
524
  }
481
525
  }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,4 @@
1
1
  export { FlairClient, FlairError, canonicalRelationshipId } from "./client.js";
2
- export { loadPrivateKey, resolveKeyPath, signRequest } from "./auth.js";
2
+ export { loadPrivateKey, resolveKeyPath, signRequest, inspectKeyLookup, formatKeyLookup, keyPathCandidates, callTimeHomes, expandHomePrefix, } from "./auth.js";
3
+ export type { KeyLookupState, KeyAuthMethod, HomeSources } from "./auth.js";
3
4
  export type { FlairClientConfig, Memory, MemoryType, Durability, Visibility, SoulEntry, SearchResult, BootstrapResult, Relationship, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,2 +1,2 @@
1
1
  export { FlairClient, FlairError, canonicalRelationshipId } from "./client.js";
2
- export { loadPrivateKey, resolveKeyPath, signRequest } from "./auth.js";
2
+ export { loadPrivateKey, resolveKeyPath, signRequest, inspectKeyLookup, formatKeyLookup, keyPathCandidates, callTimeHomes, expandHomePrefix, } from "./auth.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair-client",
3
- "version": "0.49.0",
3
+ "version": "0.50.0",
4
4
  "description": "Lightweight client for Flair — identity, memory, and soul for AI agents. Zero heavy dependencies.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",