@tpsdev-ai/flair 0.23.0 → 0.24.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/cli.js +1278 -410
- package/dist/deploy.js +16 -1
- package/dist/hook-install.js +324 -0
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/resources/Memory.js +18 -0
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/package.json +3 -3
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* auth-resolve.ts — the ONE shared CLI credential resolver (flair#747).
|
|
3
|
+
*
|
|
4
|
+
* Before this file existed, every auth-requiring CLI surface hand-rolled its
|
|
5
|
+
* own version of "how do I authenticate this request against a Flair
|
|
6
|
+
* instance" — `api()`, `fetchHealthDetail()`, and `bootstrap` in src/cli.ts
|
|
7
|
+
* each carried a slightly different, independently-maintained chain. The
|
|
8
|
+
* worst gap: several of them never tried an Ed25519 agent key at all unless
|
|
9
|
+
* an agentId was ALREADY known by some OTHER means, so a machine with a
|
|
10
|
+
* perfectly good `~/.flair/keys/<agentId>.key` and nothing else (the normal
|
|
11
|
+
* shape for a headless/agent machine) silently fell through to "no
|
|
12
|
+
* credentials" on surfaces that a human operator's `~/.flair/admin-pass`
|
|
13
|
+
* machine sailed through without noticing. flair#741/#742 fixed exactly
|
|
14
|
+
* this for `flair upgrade`'s verification calls only (`verifyAuthedGet`,
|
|
15
|
+
* still exported from src/cli.ts as a thin wrapper over `api()` below).
|
|
16
|
+
* This module generalizes that fix into the floor every surface gets.
|
|
17
|
+
*
|
|
18
|
+
* ── Resolution order ─────────────────────────────────────────────────────
|
|
19
|
+
*
|
|
20
|
+
* 1. EXPLICIT — a caller-supplied credential: `explicitAdminPass` (e.g. a
|
|
21
|
+
* resolved `--admin-pass` flag) or `explicitKeyPath` (e.g. `--key`,
|
|
22
|
+
* paired with `agentId`). Always wins; this is the operator saying
|
|
23
|
+
* "use exactly this."
|
|
24
|
+
* 2. ENV — `FLAIR_TOKEN` (Bearer) or `FLAIR_ADMIN_PASS` / `HDB_ADMIN_
|
|
25
|
+
* PASSWORD` (Basic admin auth). Ambient but still an explicit
|
|
26
|
+
* operator/CI choice.
|
|
27
|
+
* 3. A PINNED agent identity — `agentId` was already known (an --agent
|
|
28
|
+
* flag, FLAIR_AGENT_ID env, or an id the caller extracted from its own
|
|
29
|
+
* request body/query string) — signed with THAT agent's key via the
|
|
30
|
+
* standard `resolveKeyPath` lookup. Deliberately ABOVE the admin-pass
|
|
31
|
+
* file: naming a specific agent identity is a deliberate choice that
|
|
32
|
+
* should win over the ambient convenience file (flair#634 precedent —
|
|
33
|
+
* preserved bit-for-bit; see test/unit/local-no-auth.test.ts).
|
|
34
|
+
* 4. The secure `~/.flair/admin-pass` file `flair init` writes (LOCAL
|
|
35
|
+
* TARGETS ONLY — never sent to a --target/FLAIR_URL/FLAIR_TARGET
|
|
36
|
+
* remote instance; see `resolveLocalAdminPass`).
|
|
37
|
+
* 5. THE FLOOR (flair#747, generalizing flair#742's upgrade-only
|
|
38
|
+
* fallback) — when NOTHING above resolved to anything sendable at
|
|
39
|
+
* all, sign the SAME request with every registered key under
|
|
40
|
+
* `~/.flair/keys` (sorted, deterministic order), first one the server
|
|
41
|
+
* accepts wins. This is the credential every headless/agent machine
|
|
42
|
+
* actually has, and is exactly the flair#741 incident's fix: an agent
|
|
43
|
+
* key existed the whole time, nothing tried it.
|
|
44
|
+
*
|
|
45
|
+
* Tier 5 ONLY ever engages when the primary attempt fails with
|
|
46
|
+
* `ApiHttpError.noCredentials` — i.e. truly nothing was sent (a bare 403
|
|
47
|
+
* with no Authorization header at all). A credential that WAS sent and got
|
|
48
|
+
* rejected (bad admin pass, unregistered FLAIR_AGENT_ID) is a different,
|
|
49
|
+
* more specific failure; guessing at unrelated keys on disk in that case
|
|
50
|
+
* would obscure the real error instead of explaining it (the flair#741 fix
|
|
51
|
+
* #3 lesson, applied here — see `sendJsonRequest`'s 403 branch).
|
|
52
|
+
*/
|
|
53
|
+
import { existsSync, readFileSync, readdirSync, statSync, } from "node:fs";
|
|
54
|
+
import { homedir } from "node:os";
|
|
55
|
+
import { join } from "node:path";
|
|
56
|
+
import { createPrivateKey, randomUUID, sign as nodeCryptoSign } from "node:crypto";
|
|
57
|
+
// ─── Failure classification ─────────────────────────────────────────────────
|
|
58
|
+
/**
|
|
59
|
+
* Thrown by `sendJsonRequest` for an HTTP-level failure (as opposed to a
|
|
60
|
+
* network error, which fetch() itself throws before a status code ever
|
|
61
|
+
* exists). Data-carrying, not just a message string, so callers downstream
|
|
62
|
+
* can make decisions without re-parsing text (flair#741):
|
|
63
|
+
* - `status`: the HTTP status code, so probeInstance (src/probe.ts) can
|
|
64
|
+
* classify ProbeResult.authFailureKind (401/403 = credential failure,
|
|
65
|
+
* duck-typed off this property).
|
|
66
|
+
* - `noCredentials`: true only for the specific bare-403-no-auth-header
|
|
67
|
+
* case — distinguishes "we had nothing to send" from "we sent
|
|
68
|
+
* something and it was rejected", which is exactly the signal
|
|
69
|
+
* `authedRequest` uses to decide whether tier 5 (the floor) applies.
|
|
70
|
+
*/
|
|
71
|
+
export class ApiHttpError extends Error {
|
|
72
|
+
status;
|
|
73
|
+
noCredentials;
|
|
74
|
+
constructor(status, message, noCredentials = false) {
|
|
75
|
+
super(message);
|
|
76
|
+
this.name = "ApiHttpError";
|
|
77
|
+
this.status = status;
|
|
78
|
+
this.noCredentials = noCredentials;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// ─── Admin-pass file handling ───────────────────────────────────────────────
|
|
82
|
+
/**
|
|
83
|
+
* Read a secret (admin password, Fabric password, ...) from a file, refusing
|
|
84
|
+
* if the file is world/group readable.
|
|
85
|
+
*
|
|
86
|
+
* Secret files (default ~/.flair/admin-pass; also used for
|
|
87
|
+
* --fabric-password-file) are short-lived values generated by `openssl
|
|
88
|
+
* rand` — mode 0600 keeps them out of reach of other local users + most
|
|
89
|
+
* backup tooling. A 0644 file silently leaks the secret to anyone with read
|
|
90
|
+
* access to the user's home (multi-user hosts, NFS, time-machine snapshots,
|
|
91
|
+
* etc.).
|
|
92
|
+
*
|
|
93
|
+
* Throws with an actionable error if the file isn't owner-only, otherwise
|
|
94
|
+
* returns the trimmed file content (values generated by `openssl rand
|
|
95
|
+
* -base64` conventionally end in a newline). `flagName` is only used to
|
|
96
|
+
* personalize the error text (e.g. "--admin-pass-file" vs
|
|
97
|
+
* "--fabric-password-file") — the check itself is identical either way.
|
|
98
|
+
*/
|
|
99
|
+
export function readSecretFileSecure(path, flagName) {
|
|
100
|
+
if (!existsSync(path)) {
|
|
101
|
+
throw new Error(`${flagName} path does not exist: ${path}`);
|
|
102
|
+
}
|
|
103
|
+
const st = statSync(path);
|
|
104
|
+
if (st.mode & 0o077) {
|
|
105
|
+
const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
|
|
106
|
+
throw new Error(`Refusing to read ${flagName} at ${path}: permissions ${modeOctal} are too open. ` +
|
|
107
|
+
`Run \`chmod 600 ${path}\` to restrict to owner-only.`);
|
|
108
|
+
}
|
|
109
|
+
const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
|
|
110
|
+
if (!content) {
|
|
111
|
+
throw new Error(`${flagName}: file is empty or contains only whitespace: ${path}`);
|
|
112
|
+
}
|
|
113
|
+
return content;
|
|
114
|
+
}
|
|
115
|
+
/** `readSecretFileSecure` specialized for --admin-pass-file (see that function for the shared check). */
|
|
116
|
+
export function readAdminPassFileSecure(path) {
|
|
117
|
+
return readSecretFileSecure(path, "--admin-pass-file");
|
|
118
|
+
}
|
|
119
|
+
export function defaultAdminPassPath() {
|
|
120
|
+
return join(homedir(), ".flair", "admin-pass");
|
|
121
|
+
}
|
|
122
|
+
export function defaultKeysDir() {
|
|
123
|
+
return join(homedir(), ".flair", "keys");
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
|
|
127
|
+
* `principal add`) without requiring `--admin-pass` on every call (#590).
|
|
128
|
+
* Also reused by `authedRequest`'s tier 4 (local-target file fallback,
|
|
129
|
+
* flair#634) — called there as `resolveLocalAdminPass(undefined,
|
|
130
|
+
* !isLocal)`, so only its file leg ever fires (the env leg is already
|
|
131
|
+
* handled by tier 2 first).
|
|
132
|
+
*
|
|
133
|
+
* Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
|
|
134
|
+
* env → the secure `~/.flair/admin-pass` file `flair init` already writes with
|
|
135
|
+
* mode 0600 (read via `readAdminPassFileSecure`, which enforces that mode).
|
|
136
|
+
*
|
|
137
|
+
* When `isRemoteTarget` is true, ONLY the explicit value is honored — the env
|
|
138
|
+
* and file legs are skipped entirely. This is the security-critical guard: a
|
|
139
|
+
* `--target`/`--ops-target` deploy must never silently reuse THIS machine's
|
|
140
|
+
* local admin secret against someone else's Harper instance. Remote callers
|
|
141
|
+
* keep requiring an explicit `--admin-pass`.
|
|
142
|
+
*
|
|
143
|
+
* Throws (via readAdminPassFileSecure) if the file exists but has unsafe
|
|
144
|
+
* permissions, so a misconfigured file surfaces as an actionable chmod error
|
|
145
|
+
* instead of a generic "admin pass required" message.
|
|
146
|
+
*/
|
|
147
|
+
export function resolveLocalAdminPass(explicit, isRemoteTarget = false, adminPassPath = defaultAdminPassPath()) {
|
|
148
|
+
if (explicit)
|
|
149
|
+
return explicit;
|
|
150
|
+
if (isRemoteTarget)
|
|
151
|
+
return undefined;
|
|
152
|
+
if (process.env.FLAIR_ADMIN_PASS)
|
|
153
|
+
return process.env.FLAIR_ADMIN_PASS;
|
|
154
|
+
if (!existsSync(adminPassPath))
|
|
155
|
+
return undefined;
|
|
156
|
+
return readAdminPassFileSecure(adminPassPath);
|
|
157
|
+
}
|
|
158
|
+
// ─── Ed25519 agent-key signing ──────────────────────────────────────────────
|
|
159
|
+
/** Find the agent's private key file from standard locations. */
|
|
160
|
+
export function resolveKeyPath(agentId) {
|
|
161
|
+
const candidates = [
|
|
162
|
+
process.env.FLAIR_KEY_DIR ? join(process.env.FLAIR_KEY_DIR, `${agentId}.key`) : null,
|
|
163
|
+
join(homedir(), ".flair", "keys", `${agentId}.key`),
|
|
164
|
+
join(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
|
|
165
|
+
].filter(Boolean);
|
|
166
|
+
return candidates.find((p) => existsSync(p)) ?? null;
|
|
167
|
+
}
|
|
168
|
+
/** Build a TPS-Ed25519 auth header from a raw 32-byte seed on disk. */
|
|
169
|
+
export function buildEd25519Auth(agentId, method, path, keyPath) {
|
|
170
|
+
const raw = readFileSync(keyPath);
|
|
171
|
+
const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
|
|
172
|
+
let privKey;
|
|
173
|
+
if (raw.length === 32) {
|
|
174
|
+
// Raw 32-byte seed
|
|
175
|
+
privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, raw]), format: "der", type: "pkcs8" });
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
// Try as base64-encoded PKCS8 DER (standard Flair key format)
|
|
179
|
+
const decoded = Buffer.from(raw.toString("utf-8").trim(), "base64");
|
|
180
|
+
if (decoded.length === 32) {
|
|
181
|
+
// Base64-encoded raw seed
|
|
182
|
+
privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, decoded]), format: "der", type: "pkcs8" });
|
|
183
|
+
}
|
|
184
|
+
else {
|
|
185
|
+
// Full PKCS8 DER or PEM
|
|
186
|
+
try {
|
|
187
|
+
privKey = createPrivateKey({ key: decoded, format: "der", type: "pkcs8" });
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
privKey = createPrivateKey(raw);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const ts = Date.now().toString();
|
|
195
|
+
const nonce = randomUUID();
|
|
196
|
+
const payload = `${agentId}:${ts}:${nonce}:${method}:${path}`;
|
|
197
|
+
const sig = nodeCryptoSign(null, Buffer.from(payload), privKey).toString("base64");
|
|
198
|
+
return `TPS-Ed25519 ${agentId}:${ts}:${nonce}:${sig}`;
|
|
199
|
+
}
|
|
200
|
+
/** Authenticated fetch against Flair using Ed25519. */
|
|
201
|
+
export async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
|
|
202
|
+
const auth = buildEd25519Auth(agentId, method, path, keyPath);
|
|
203
|
+
const headers = { Authorization: auth };
|
|
204
|
+
if (body !== undefined)
|
|
205
|
+
headers["Content-Type"] = "application/json";
|
|
206
|
+
return fetch(`${baseUrl}${path}`, {
|
|
207
|
+
method,
|
|
208
|
+
headers,
|
|
209
|
+
body: body !== undefined ? JSON.stringify(body) : undefined,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
export function isLocalBase(base) {
|
|
213
|
+
try {
|
|
214
|
+
const url = new URL(base);
|
|
215
|
+
return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return !base;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// ─── HTTP + response handling ───────────────────────────────────────────────
|
|
222
|
+
/**
|
|
223
|
+
* Perform the actual Flair REST call with an already-resolved Authorization
|
|
224
|
+
* header (or none) and classify the response. Shared by the primary
|
|
225
|
+
* (tiers 1-4) attempt and each tier-5 floor attempt below, so both paths
|
|
226
|
+
* agree on 204/empty-body handling, JSON parsing, and the no-credentials
|
|
227
|
+
* 403 hint text.
|
|
228
|
+
*/
|
|
229
|
+
export async function sendJsonRequest(baseUrl, method, path, body, authHeader, isLocal) {
|
|
230
|
+
const res = await fetch(`${baseUrl}${path}`, {
|
|
231
|
+
method,
|
|
232
|
+
headers: {
|
|
233
|
+
"content-type": "application/json",
|
|
234
|
+
...(authHeader ? { authorization: authHeader } : {}),
|
|
235
|
+
},
|
|
236
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
237
|
+
});
|
|
238
|
+
// Handle 204 No Content (e.g., PUT upsert returns empty body)
|
|
239
|
+
if (res.status === 204 || res.headers.get("content-length") === "0") {
|
|
240
|
+
if (!res.ok)
|
|
241
|
+
throw new ApiHttpError(res.status, `HTTP ${res.status}`);
|
|
242
|
+
return { ok: true };
|
|
243
|
+
}
|
|
244
|
+
const text = await res.text();
|
|
245
|
+
if (!res.ok) {
|
|
246
|
+
// 403 with no credentials sent at all is the flair#634 case: a gated
|
|
247
|
+
// resource rejected a credential-less call. Name the fix instead of
|
|
248
|
+
// surfacing the raw "forbidden" body — never a stack trace.
|
|
249
|
+
if (res.status === 403 && !authHeader) {
|
|
250
|
+
const hint = isLocal
|
|
251
|
+
? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key."
|
|
252
|
+
: "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
|
|
253
|
+
throw new ApiHttpError(403, `HTTP 403: no credentials sent. ${hint}`, /* noCredentials */ true);
|
|
254
|
+
}
|
|
255
|
+
throw new ApiHttpError(res.status, text || `HTTP ${res.status}`);
|
|
256
|
+
}
|
|
257
|
+
if (!text)
|
|
258
|
+
return { ok: true };
|
|
259
|
+
return JSON.parse(text);
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Tier 5, the floor (flair#747, generalizing flair#742's `verifyAuthedGet`
|
|
263
|
+
* GET-only fallback to any method/body): try every registered key in
|
|
264
|
+
* `keysDir` in sorted (deterministic) filename order, first one that
|
|
265
|
+
* authenticates wins. Returns `undefined` — never throws — when every key
|
|
266
|
+
* fails or none exist, so the caller can rethrow its ORIGINAL
|
|
267
|
+
* no-credentials error rather than a floor-specific one.
|
|
268
|
+
*/
|
|
269
|
+
export async function tryAgentKeyFloor(baseUrl, method, path, body, keysDir) {
|
|
270
|
+
let keyFiles = [];
|
|
271
|
+
try {
|
|
272
|
+
keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key")).sort();
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// No keys dir at all — nothing to fall back to.
|
|
276
|
+
}
|
|
277
|
+
const isLocal = isLocalBase(baseUrl);
|
|
278
|
+
for (const kf of keyFiles) {
|
|
279
|
+
const agentId = kf.replace(/\.key$/, "");
|
|
280
|
+
const keyPath = join(keysDir, kf);
|
|
281
|
+
try {
|
|
282
|
+
const authHeader = buildEd25519Auth(agentId, method, path, keyPath);
|
|
283
|
+
return await sendJsonRequest(baseUrl, method, path, body, authHeader, isLocal);
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
// This key didn't work (unregistered, bad signature, network blip on
|
|
287
|
+
// this one attempt) — try the next one.
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* The shared resolver: given a Flair REST call and whatever credential
|
|
294
|
+
* material the caller already knows about, resolve auth across all 5 tiers
|
|
295
|
+
* (see module header) and perform the request. Throws `ApiHttpError` on
|
|
296
|
+
* any non-2xx response (after the tier-5 floor has also been exhausted).
|
|
297
|
+
*/
|
|
298
|
+
export async function authedRequest(method, path, body, opts) {
|
|
299
|
+
const isLocal = opts.isLocal ?? isLocalBase(opts.baseUrl);
|
|
300
|
+
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
301
|
+
let authHeader;
|
|
302
|
+
// Tier 1: explicit — caller-resolved flag material always wins.
|
|
303
|
+
if (opts.explicitAdminPass) {
|
|
304
|
+
authHeader = `Basic ${Buffer.from(`admin:${opts.explicitAdminPass}`).toString("base64")}`;
|
|
305
|
+
}
|
|
306
|
+
else if (opts.explicitKeyPath && opts.agentId) {
|
|
307
|
+
try {
|
|
308
|
+
authHeader = buildEd25519Auth(opts.agentId, method, path, opts.explicitKeyPath);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
312
|
+
console.error(`Warning: Ed25519 auth failed for agent '${opts.agentId}': ${message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Tier 2: env — FLAIR_TOKEN (Bearer), else FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD (Basic).
|
|
316
|
+
if (!authHeader) {
|
|
317
|
+
if (process.env.FLAIR_TOKEN) {
|
|
318
|
+
authHeader = `Bearer ${process.env.FLAIR_TOKEN}`;
|
|
319
|
+
}
|
|
320
|
+
else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
|
|
321
|
+
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
322
|
+
authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Tier 3: a PINNED agent identity — sign specifically as this agent via
|
|
326
|
+
// the standard key lookup (skipped if tier 1 already signed as this
|
|
327
|
+
// agent via an explicit key path).
|
|
328
|
+
if (!authHeader && opts.agentId) {
|
|
329
|
+
const keyPath = resolveKeyPath(opts.agentId);
|
|
330
|
+
if (keyPath) {
|
|
331
|
+
try {
|
|
332
|
+
authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
336
|
+
console.error(`Warning: Ed25519 auth failed for agent '${opts.agentId}': ${message}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// Tier 4: LOCAL TARGETS ONLY — the secure ~/.flair/admin-pass file.
|
|
341
|
+
if (!authHeader) {
|
|
342
|
+
try {
|
|
343
|
+
const filePass = resolveLocalAdminPass(undefined, !isLocal);
|
|
344
|
+
if (filePass) {
|
|
345
|
+
authHeader = `Basic ${Buffer.from(`admin:${filePass}`).toString("base64")}`;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
catch (err) {
|
|
349
|
+
// File exists but has unsafe permissions — warn (never the secret
|
|
350
|
+
// itself) and fall through to no-auth / the floor.
|
|
351
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
352
|
+
console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
return await sendJsonRequest(opts.baseUrl, method, path, body, authHeader, isLocal);
|
|
357
|
+
}
|
|
358
|
+
catch (err) {
|
|
359
|
+
// Tier 5 — the floor. Only engages when NOTHING above resolved to
|
|
360
|
+
// anything sendable (see module header for why a rejected credential
|
|
361
|
+
// does not retry here).
|
|
362
|
+
if (err instanceof ApiHttpError && err.noCredentials) {
|
|
363
|
+
const floorResult = await tryAgentKeyFloor(opts.baseUrl, method, path, body, keysDir);
|
|
364
|
+
if (floorResult !== undefined)
|
|
365
|
+
return floorResult;
|
|
366
|
+
}
|
|
367
|
+
throw err;
|
|
368
|
+
}
|
|
369
|
+
}
|