@tpsdev-ai/flair 0.37.0 → 0.39.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/lib/mcp-enable.js +120 -8
- package/dist/lib/secret-envelope.js +76 -0
- package/dist/lib/secrets-push.js +145 -0
- package/docs/hosted-on-fabric.md +20 -0
- package/package.json +1 -1
package/dist/lib/mcp-enable.js
CHANGED
|
@@ -101,10 +101,14 @@
|
|
|
101
101
|
* `dist/lib/mcp/wellKnown.js`'s `buildAuthorizationServerMetadata`
|
|
102
102
|
* (lines 129-166), which advertises `registration_endpoint`/
|
|
103
103
|
* `token_endpoint` unconditionally (NOTE: `registration_endpoint` is
|
|
104
|
-
* advertised even though DCR is disabled —
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
104
|
+
* advertised even though DCR is disabled — CORRECTED 2026-08-05: that was
|
|
105
|
+
* true of the version this was written against and is FALSE of the
|
|
106
|
+
* installed one. wellKnown.js:142 now reads
|
|
107
|
+
* `...(dcrEnabled(mcpConfig) ? { registration_endpoint: … } : {})`, so the
|
|
108
|
+
* field is OMITTED whenever DCR is off — which is every instance `enable`
|
|
109
|
+
* configures. selfVerifyMcpMetadata required it and therefore failed on a
|
|
110
|
+
* correctly enabled surface; see the note at that check. A verified fact
|
|
111
|
+
* carries the date it was verified, and this one expired.) and
|
|
108
112
|
* `client_id_metadata_document_supported: true` whenever
|
|
109
113
|
* `clientIdMetadataDocuments.enabled !== false` (wellKnown.js:164 — true
|
|
110
114
|
* by default, which is what our config relies on), and
|
|
@@ -135,6 +139,7 @@
|
|
|
135
139
|
* in `cimd.js`, so listing an extra host is not a meaningful risk
|
|
136
140
|
* expansion.
|
|
137
141
|
*/
|
|
142
|
+
import { probeSecretsCapability, pushSecrets, PROCESS_ENV_TIER } from "./secrets-push.js";
|
|
138
143
|
import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from "node:fs";
|
|
139
144
|
import { homedir } from "node:os";
|
|
140
145
|
import { join, dirname } from "node:path";
|
|
@@ -216,6 +221,30 @@ export function isFabricOrigin(url) {
|
|
|
216
221
|
* installed 5.1.17 SDK). Anything else defaults to `env-file` — the
|
|
217
222
|
* documented, universally-supported fallback. Always overridable.
|
|
218
223
|
*/
|
|
224
|
+
/**
|
|
225
|
+
* ── The hostname no longer selects the mechanism (flair#1094) ───────────────
|
|
226
|
+
*
|
|
227
|
+
* This used to be `isFabricOrigin(url) ? "fabric-env-secrets" : "env-file"`, and
|
|
228
|
+
* that was wrong in BOTH directions on the day it was replaced:
|
|
229
|
+
*
|
|
230
|
+
* - `tps.dtrt.harperfabric.com` runs Harper 5.1.26 and has no secrets
|
|
231
|
+
* operations at all — measured; `set_secret` answers "Operation 'set_secret'
|
|
232
|
+
* not found", identical to an invented operation — and was selected for the
|
|
233
|
+
* automated mechanism purely because of its name.
|
|
234
|
+
* - a self-hosted Harper 5.2 with the Pro env-secrets component is fully
|
|
235
|
+
* capable and was sent down the manual Studio path for not matching.
|
|
236
|
+
*
|
|
237
|
+
* A hostname is not a capability, and neither is a version — the write
|
|
238
|
+
* operations and the Pro decryptor that makes a `processEnv` secret reach the
|
|
239
|
+
* process ship separately. `probeSecretsCapability` asks the target instead, and
|
|
240
|
+
* the answer decides at provisioning time.
|
|
241
|
+
*
|
|
242
|
+
* What remains here is the STAGING FILE's flavour of instructions, which is
|
|
243
|
+
* genuinely about where the operator will paste if we end up falling back.
|
|
244
|
+
* Fabric operators paste into Studio; everyone else edits a unit file. That is a
|
|
245
|
+
* UI fact about a human, not a claim about the server, so a hostname is a
|
|
246
|
+
* reasonable signal for it and a wrong guess costs only slightly-off prose.
|
|
247
|
+
*/
|
|
219
248
|
export function selectSecretsMechanism(instanceUrl, override) {
|
|
220
249
|
if (override)
|
|
221
250
|
return override;
|
|
@@ -605,12 +634,61 @@ export async function selfVerifyMcpMetadata(issuer, deps = {}) {
|
|
|
605
634
|
catch {
|
|
606
635
|
return { ok: false, detail: `${url} did not return JSON` };
|
|
607
636
|
}
|
|
637
|
+
// ── The flair's-own-server check runs BEFORE the shape check (flair#1094) ──
|
|
638
|
+
//
|
|
639
|
+
// It used to run after, and that made the DEFAULT flag-off case misreport.
|
|
640
|
+
// flair's own document omits `registration_endpoint` unless DCR is enabled,
|
|
641
|
+
// which it is not by default — so the shape check fired first and returned
|
|
642
|
+
// "the metadata shape is unexpected", which is true, useless, and points at
|
|
643
|
+
// shapes when the cause is an unset environment variable.
|
|
644
|
+
//
|
|
645
|
+
// `token_endpoint` is present in that document either way, so testing the
|
|
646
|
+
// discriminator first names the real cause in EVERY flag-off case rather than
|
|
647
|
+
// only when DCR happens to be on. Found by writing the test that pins this
|
|
648
|
+
// relationship, not by reading the code.
|
|
649
|
+
if (typeof body?.token_endpoint === "string" && body.token_endpoint === `${normalizedIssuer}/OAuthToken`) {
|
|
650
|
+
return {
|
|
651
|
+
ok: false,
|
|
652
|
+
issuer: body?.issuer,
|
|
653
|
+
registrationEndpoint: body?.registration_endpoint,
|
|
654
|
+
tokenEndpoint: body.token_endpoint,
|
|
655
|
+
detail: `${url} answered with flair's OWN OAuth 2.1 authorization server, not the MCP one ` +
|
|
656
|
+
`(token_endpoint=${body.token_endpoint}) — the /mcp surface is NOT enabled on that instance. ` +
|
|
657
|
+
`Is FLAIR_MCP_OAUTH actually set on the restarted instance, and is the '@harperfast/oauth' ` +
|
|
658
|
+
`component declared in its config.yaml?`,
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
// ── registration_endpoint is OPTIONAL and must not be required (Kern, #1101) ─
|
|
662
|
+
//
|
|
663
|
+
// Requiring it made self-verify fail on a CORRECTLY enabled instance — the
|
|
664
|
+
// exact configuration `enable` itself creates.
|
|
665
|
+
//
|
|
666
|
+
// RFC 8414 marks the field optional, and BOTH authorization servers in this
|
|
667
|
+
// system omit it when DCR is off:
|
|
668
|
+
// - flair's own AS: resources/oauth-discovery.ts, conditional spread on
|
|
669
|
+
// dcrEnabled(), default off.
|
|
670
|
+
// - the MCP plugin: @harperfast/oauth/dist/lib/mcp/wellKnown.js:142,
|
|
671
|
+
// `...(dcrEnabled(mcpConfig) ? { registration_endpoint: … } : {})`.
|
|
672
|
+
//
|
|
673
|
+
// And `enable` writes `dynamicClientRegistration: { enabled: false }` by
|
|
674
|
+
// design — DCR is unsupported on this surface (#756). So the plugin omits the
|
|
675
|
+
// field on every instance this command configures, and self-verify then
|
|
676
|
+
// reported "the metadata shape is unexpected" on a working MCP surface,
|
|
677
|
+
// sending the operator to debug metadata fields instead.
|
|
678
|
+
//
|
|
679
|
+
// The module header above still claims the plugin advertises it
|
|
680
|
+
// "unconditionally". That was true of the version it was written against and
|
|
681
|
+
// is false of the installed one — corrected there too. A verified fact carries
|
|
682
|
+
// the date it was verified, and this one expired.
|
|
683
|
+
//
|
|
684
|
+
// Required: issuer and token_endpoint, both always present in both servers.
|
|
685
|
+
// registration_endpoint is validated only when it appears.
|
|
608
686
|
if (body?.issuer !== normalizedIssuer ||
|
|
609
|
-
typeof body?.
|
|
610
|
-
typeof body
|
|
687
|
+
typeof body?.token_endpoint !== "string" ||
|
|
688
|
+
(body?.registration_endpoint !== undefined && typeof body.registration_endpoint !== "string")) {
|
|
611
689
|
return {
|
|
612
690
|
ok: false,
|
|
613
|
-
detail: `${url} responded but the metadata shape is unexpected (issuer/
|
|
691
|
+
detail: `${url} responded but the metadata shape is unexpected (issuer/token_endpoint) — got issuer=${JSON.stringify(body?.issuer)}`,
|
|
614
692
|
};
|
|
615
693
|
}
|
|
616
694
|
// flair#1000: this path is now served by flair ITSELF when FLAIR_MCP_OAUTH is
|
|
@@ -759,11 +837,45 @@ export async function enableMcp(params, deps = {}) {
|
|
|
759
837
|
idpClientSecret: params.idpClientSecret,
|
|
760
838
|
});
|
|
761
839
|
currentStep = "secrets-provisioning";
|
|
840
|
+
// Stage first, unconditionally. If the push works the file is a no-op the
|
|
841
|
+
// operator never opens; if anything about the push is uncertain they still
|
|
842
|
+
// have the thing that always works, without a re-run. Staging costs a 0600
|
|
843
|
+
// write; not staging costs an operator stranded mid-enable.
|
|
762
844
|
const secretsResult = provisionSecrets(params.instance, bundle, {
|
|
763
845
|
mechanism: params.secretsMechanism,
|
|
764
846
|
stagingPath: params.secretsStagingPath,
|
|
765
847
|
});
|
|
766
|
-
|
|
848
|
+
// Ask the TARGET whether it can take these, rather than inferring from its
|
|
849
|
+
// hostname or its version (flair#1094 — see selectSecretsMechanism's note).
|
|
850
|
+
// An explicit --secrets-mechanism is an operator override and is honoured
|
|
851
|
+
// without a probe: they have said what they want.
|
|
852
|
+
let secretsPushed = false;
|
|
853
|
+
if (!params.secretsMechanism) {
|
|
854
|
+
const cap = await probeSecretsCapability(resolveOpsUrl(params.instance), basicAuthHeader(params.adminUser, params.adminPass), { fetchImpl: deps.fetchImpl });
|
|
855
|
+
if (cap.available && cap.publicKeyPem) {
|
|
856
|
+
const pushResult = await pushSecrets(resolveOpsUrl(params.instance), basicAuthHeader(params.adminUser, params.adminPass), bundle, cap.publicKeyPem, { fetchImpl: deps.fetchImpl });
|
|
857
|
+
secretsPushed = pushResult.allOk;
|
|
858
|
+
if (secretsPushed) {
|
|
859
|
+
push(true, `${secretsResult.varNames.length} vars pushed to the target as enc:v1 env-secrets (tier ${PROCESS_ENV_TIER}); ` +
|
|
860
|
+
`values were sealed locally and never sent in plaintext. Staged copy at ${secretsResult.path} (0600) is unused. ` +
|
|
861
|
+
`Self-verify below is what proves they were DECRYPTED into the process — a target that stores them without an ` +
|
|
862
|
+
`active env-secrets decryptor will fail there, not here.`);
|
|
863
|
+
}
|
|
864
|
+
else {
|
|
865
|
+
const failed = pushResult.results.filter((r) => !r.ok).map((r) => `${r.name} (${r.detail})`).join("; ");
|
|
866
|
+
push(true, `push attempted and did not complete for: ${failed}. Falling back to the staged file at ${secretsResult.path} (0600). ` +
|
|
867
|
+
`${secretsResult.instructions}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
else {
|
|
871
|
+
push(true, `mechanism: ${secretsResult.mechanism}; ${secretsResult.varNames.length} vars staged at ${secretsResult.path} (0600). ` +
|
|
872
|
+
`${cap.reason}. ${secretsResult.instructions}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
else {
|
|
876
|
+
push(true, `mechanism: ${secretsResult.mechanism} (explicit --secrets-mechanism, no capability probe); ` +
|
|
877
|
+
`${secretsResult.varNames.length} vars staged at ${secretsResult.path} (0600). ${secretsResult.instructions}`);
|
|
878
|
+
}
|
|
767
879
|
// ── Identity mapping (Credential kind:idp) ────────────────────────────────
|
|
768
880
|
currentStep = "identity-mapping";
|
|
769
881
|
const mapping = await provisionIdpIdentityMapping({
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side `enc:v1:` secret envelopes — the wire format Harper's env-secrets
|
|
3
|
+
* feature reads (`hdb_secret` store and encrypted `.env` entries).
|
|
4
|
+
*
|
|
5
|
+
* ── Why this is reimplemented rather than imported ──────────────────────────
|
|
6
|
+
* `harper@5.2.0` ships this as `utility/secretEnvelope.ts`, deliberately free of
|
|
7
|
+
* Harper imports and described in its own header as "safe to publish". We could
|
|
8
|
+
* import it — except that `flair mcp enable` builds the envelope for a REMOTE
|
|
9
|
+
* target whose Harper version is discovered at runtime and is routinely NEWER
|
|
10
|
+
* than the one flair bundles. Importing would tie the format we can produce to
|
|
11
|
+
* the engine we happen to ship, which is exactly backwards: the local engine has
|
|
12
|
+
* nothing to do with what the remote instance can read.
|
|
13
|
+
*
|
|
14
|
+
* So it is reimplemented, and the compatibility claim is TESTED rather than
|
|
15
|
+
* asserted — test/unit/secret-envelope.test.ts runs Harper's own
|
|
16
|
+
* `decryptEnvelope` (vendored verbatim into the test as a reference oracle)
|
|
17
|
+
* against envelopes this module produces. A round-trip against ourselves would
|
|
18
|
+
* only prove self-consistency, which is worth nothing here: the reader is
|
|
19
|
+
* someone else's code.
|
|
20
|
+
*
|
|
21
|
+
* ── The format, from harper@5.2.0 utility/secretEnvelope.ts ─────────────────
|
|
22
|
+
* Hybrid: AES-256-GCM encrypts the value, RSA-OAEP(SHA-256) wraps the AES key.
|
|
23
|
+
*
|
|
24
|
+
* envelope = base64url(JSON.stringify({ kid, k, iv, ct, tag }))
|
|
25
|
+
* kid = sha256(DER SPKI of the public key), hex
|
|
26
|
+
* k = base64(RSA-OAEP(aesKey)) iv = base64(12 random bytes)
|
|
27
|
+
* ct = base64(ciphertext) tag = base64(GCM auth tag)
|
|
28
|
+
*
|
|
29
|
+
* The `enc:v1:` marker is NOT part of the body — it is added by callers, the
|
|
30
|
+
* same split Harper uses (the marker lives in `utility/envFile.ts`).
|
|
31
|
+
*
|
|
32
|
+
* Nothing here reads or writes a secret to disk, and no value is logged. The
|
|
33
|
+
* plaintext exists only as an argument.
|
|
34
|
+
*/
|
|
35
|
+
import { createCipheriv, createHash, createPublicKey, publicEncrypt, randomBytes, constants } from "node:crypto";
|
|
36
|
+
/** Marker prefixed to an envelope body. Harper keys the encrypted-value path on this. */
|
|
37
|
+
export const ENV_ENCRYPTED_PREFIX = "enc:v1:";
|
|
38
|
+
/**
|
|
39
|
+
* SHA-256 (hex) of the DER SPKI public key — the stable key id used as `kid`.
|
|
40
|
+
*
|
|
41
|
+
* The server derives `kid` from the sealed body and trusts only that one, never
|
|
42
|
+
* a separate client-supplied field, so getting this wrong surfaces as a refusal
|
|
43
|
+
* to decrypt rather than as a silently-wrong secret.
|
|
44
|
+
*/
|
|
45
|
+
export function fingerprintOf(publicKeyPem) {
|
|
46
|
+
const der = createPublicKey(publicKeyPem).export({ type: "spki", format: "der" });
|
|
47
|
+
return createHash("sha256").update(der).digest("hex");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Seal `plaintext` for the holder of `publicKeyPem`. Returns the envelope BODY,
|
|
51
|
+
* without the `enc:v1:` marker.
|
|
52
|
+
*
|
|
53
|
+
* Randomised per call (fresh AES key and IV), so two calls on the same input
|
|
54
|
+
* differ — which is why the tests assert decryptability by the reference
|
|
55
|
+
* implementation rather than comparing against a fixed string.
|
|
56
|
+
*/
|
|
57
|
+
export function encryptEnvelope(plaintext, publicKeyPem, kid) {
|
|
58
|
+
const aesKey = randomBytes(32);
|
|
59
|
+
const iv = randomBytes(12);
|
|
60
|
+
const cipher = createCipheriv("aes-256-gcm", aesKey, iv);
|
|
61
|
+
const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
|
62
|
+
const tag = cipher.getAuthTag();
|
|
63
|
+
const k = publicEncrypt({ key: publicKeyPem, padding: constants.RSA_PKCS1_OAEP_PADDING, oaepHash: "sha256" }, aesKey);
|
|
64
|
+
const envelope = {
|
|
65
|
+
kid: kid ?? fingerprintOf(publicKeyPem),
|
|
66
|
+
k: k.toString("base64"),
|
|
67
|
+
iv: iv.toString("base64"),
|
|
68
|
+
ct: ct.toString("base64"),
|
|
69
|
+
tag: tag.toString("base64"),
|
|
70
|
+
};
|
|
71
|
+
return Buffer.from(JSON.stringify(envelope)).toString("base64url");
|
|
72
|
+
}
|
|
73
|
+
/** Seal and prefix — what a caller sends as `set_secret`'s `envelope` field. */
|
|
74
|
+
export function sealSecret(plaintext, publicKeyPem) {
|
|
75
|
+
return ENV_ENCRYPTED_PREFIX + encryptEnvelope(plaintext, publicKeyPem);
|
|
76
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pushing `mcp enable`'s staged secrets to the target over the ops API, when the
|
|
3
|
+
* target can actually take them (flair#1094).
|
|
4
|
+
*
|
|
5
|
+
* ── What replaced what ──────────────────────────────────────────────────────
|
|
6
|
+
* The mechanism used to be chosen by HOSTNAME: `selectSecretsMechanism` returned
|
|
7
|
+
* `fabric-env-secrets` for anything ending `.harperfabric.com` and `env-file`
|
|
8
|
+
* otherwise. That is wrong in both directions and was wrong in both directions
|
|
9
|
+
* on the day it was replaced:
|
|
10
|
+
*
|
|
11
|
+
* - tps.dtrt.harperfabric.com runs Harper 5.1.26 and has NO secrets
|
|
12
|
+
* operations — measured, `set_secret` answers "Operation 'set_secret' not
|
|
13
|
+
* found", identical to an operation that does not exist at all — and was
|
|
14
|
+
* selected for the automated mechanism because of its name.
|
|
15
|
+
* - a self-hosted Harper 5.2 with the Pro env-secrets component is fully
|
|
16
|
+
* capable and was sent down the manual Fabric Studio path because its
|
|
17
|
+
* hostname did not match.
|
|
18
|
+
*
|
|
19
|
+
* A hostname is not a capability. Nor is a version: the write operations and the
|
|
20
|
+
* decryptor that makes a `processEnv` secret reach the process ship separately
|
|
21
|
+
* (core vs Pro). So this asks the target directly.
|
|
22
|
+
*
|
|
23
|
+
* ── What this probe does and does not establish ─────────────────────────────
|
|
24
|
+
* It establishes that the secrets OPERATIONS exist, by asking for the public key
|
|
25
|
+
* we would need anyway. It does NOT establish that the Pro decryptor is active,
|
|
26
|
+
* and no read-only call can — a secret only proves it was decrypted by being
|
|
27
|
+
* present in the process.
|
|
28
|
+
*
|
|
29
|
+
* That check already exists downstream, though NOT for the reason first claimed
|
|
30
|
+
* here. The well-known endpoint does not stop answering when the flag is off —
|
|
31
|
+
* flair serves its OWN OAuth 2.1 discovery document in that case
|
|
32
|
+
* (resources/oauth-discovery.ts). What distinguishes them is a DISCRIMINATOR:
|
|
33
|
+
* flair's document advertises `<issuer>/OAuthToken`, the plugin's advertises
|
|
34
|
+
* `<issuer>/oauth/mcp/token`, and self-verify tests for the former by name.
|
|
35
|
+
*
|
|
36
|
+
* So a push that lands in `hdb_secret` and is never decrypted shows up as a
|
|
37
|
+
* self-verify failure naming FLAIR_MCP_OAUTH — because of a comparison, not an
|
|
38
|
+
* absence. That relationship spans two files and is pinned by a test in
|
|
39
|
+
* test/unit/secrets-push.test.ts; without it, changing either side silently
|
|
40
|
+
* disables the only thing standing between "stored" and "working".
|
|
41
|
+
*
|
|
42
|
+
* ── Failure direction ───────────────────────────────────────────────────────
|
|
43
|
+
* Every uncertain outcome falls back to the staged-file flow. That path works
|
|
44
|
+
* today, on every target, and costs the operator a paste. Pushing a secret at an
|
|
45
|
+
* endpoint that may not exist costs a silent flag-OFF boot, which is the exact
|
|
46
|
+
* failure this automation is meant to remove.
|
|
47
|
+
*/
|
|
48
|
+
import { sealSecret } from "./secret-envelope.js";
|
|
49
|
+
/** `set_secret`'s delivery tier. `processEnv` is global and cannot be scoped —
|
|
50
|
+
* which is what `FLAIR_MCP_OAUTH` and the signing key PEM need, since both are
|
|
51
|
+
* read from `process.env` and never from YAML. */
|
|
52
|
+
export const PROCESS_ENV_TIER = "processEnv";
|
|
53
|
+
async function opsCall(opsUrl, authHeader, body, fetchImpl) {
|
|
54
|
+
const res = await fetchImpl(opsUrl, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "Content-Type": "application/json", Authorization: authHeader },
|
|
57
|
+
body: JSON.stringify(body),
|
|
58
|
+
});
|
|
59
|
+
const text = await res.text().catch(() => "");
|
|
60
|
+
let json = null;
|
|
61
|
+
try {
|
|
62
|
+
json = JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
catch { /* non-JSON body — keep the text */ }
|
|
65
|
+
return { ok: res.ok, status: res.status, json, text };
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Ask the target whether it can take pushed secrets, by requesting the key we
|
|
69
|
+
* would encrypt to. Never throws: an unreachable or unparseable target is a
|
|
70
|
+
* fall-back-and-say-why, not a crash mid-enable.
|
|
71
|
+
*/
|
|
72
|
+
export async function probeSecretsCapability(opsUrl, authHeader, deps = {}) {
|
|
73
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
74
|
+
let r;
|
|
75
|
+
try {
|
|
76
|
+
r = await opsCall(opsUrl, authHeader, { operation: "get_secrets_public_key" }, fetchImpl);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
return { available: false, reason: `could not reach the ops API to ask (${err?.message ?? err}) — using the staged-file flow` };
|
|
80
|
+
}
|
|
81
|
+
// "Operation '<name>' not found" is how Harper answers an operation it does
|
|
82
|
+
// not have, and it is byte-identical to the answer for an invented one. That
|
|
83
|
+
// is the signal for a target older than the env-secrets feature.
|
|
84
|
+
const errText = String(r.json?.error ?? r.text ?? "");
|
|
85
|
+
if (/not found/i.test(errText) && /get_secrets_public_key/.test(errText)) {
|
|
86
|
+
return { available: false, reason: "the target's Harper has no env-secrets operations (needs 5.2 or newer) — using the staged-file flow" };
|
|
87
|
+
}
|
|
88
|
+
if (!r.ok) {
|
|
89
|
+
return { available: false, reason: `the target refused the capability probe (HTTP ${r.status}${errText ? `: ${errText.slice(0, 120)}` : ""}) — using the staged-file flow` };
|
|
90
|
+
}
|
|
91
|
+
const pem = extractPublicKeyPem(r.json);
|
|
92
|
+
if (!pem) {
|
|
93
|
+
// Answered, but not with something we can encrypt to. Undeterminable is
|
|
94
|
+
// treated exactly like unavailable — see the failure-direction note above.
|
|
95
|
+
return { available: false, reason: "the target answered the probe without a usable public key — using the staged-file flow" };
|
|
96
|
+
}
|
|
97
|
+
return { available: true, reason: "target supports env-secrets; pushing over the ops API", publicKeyPem: pem };
|
|
98
|
+
}
|
|
99
|
+
/** Pull the PEM out of whatever shape the operation returns, without guessing
|
|
100
|
+
* at a value that is not obviously a key. */
|
|
101
|
+
function extractPublicKeyPem(json) {
|
|
102
|
+
const candidates = [json, json?.public_key, json?.publicKey, json?.key, json?.pem, json?.data?.public_key];
|
|
103
|
+
for (const c of candidates) {
|
|
104
|
+
if (typeof c === "string" && c.includes("BEGIN PUBLIC KEY"))
|
|
105
|
+
return c;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Seal and set each var. Values are encrypted client-side, so plaintext never
|
|
111
|
+
* appears in a request body — and never in this module's return value either:
|
|
112
|
+
* results carry NAMES and outcomes only.
|
|
113
|
+
*/
|
|
114
|
+
export async function pushSecrets(opsUrl, authHeader, vars, publicKeyPem, deps = {}) {
|
|
115
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
116
|
+
const results = [];
|
|
117
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
118
|
+
try {
|
|
119
|
+
const r = await opsCall(opsUrl, authHeader, { operation: "set_secret", name, envelope: sealSecret(value, publicKeyPem), processEnv: true }, fetchImpl);
|
|
120
|
+
const err = String(r.json?.error ?? "").slice(0, 140);
|
|
121
|
+
if (!r.ok) {
|
|
122
|
+
results.push({ name, ok: false, detail: `HTTP ${r.status}${err ? `: ${err}` : ""}` });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
// Read back one pushed row to verify processEnv actually materialised.
|
|
126
|
+
// A 200 from set_secret is not proof — core silently ignores unknown
|
|
127
|
+
// params, so the row can be accepted and still land inert.
|
|
128
|
+
const verify = await opsCall(opsUrl, authHeader, { operation: "search_by_value", database: "system", table: "hdb_secret", search_attribute: "name", search_value: name, get_attributes: ["name", "processEnv"] }, fetchImpl);
|
|
129
|
+
if (!verify.ok) {
|
|
130
|
+
results.push({ name, ok: false, detail: `push returned 200 but verify failed HTTP ${verify.status}` });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const row = Array.isArray(verify.json) ? verify.json[0] : verify.json?.[0];
|
|
134
|
+
if (!row || row.processEnv !== true) {
|
|
135
|
+
results.push({ name, ok: false, detail: `push returned 200 but read-back shows processEnv is ${row?.processEnv ?? "missing"}, not true — secret is inert` });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
results.push({ name, ok: true });
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
results.push({ name, ok: false, detail: String(err?.message ?? err).slice(0, 140) });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { allOk: results.every((x) => x.ok), results };
|
|
145
|
+
}
|
package/docs/hosted-on-fabric.md
CHANGED
|
@@ -59,6 +59,26 @@ On Fabric, configuration goes through the component's environment, not a local `
|
|
|
59
59
|
|
|
60
60
|
On Fabric / managed deploys, environment variables are provisioned through Harper's Fabric secrets mechanism (encrypted at rest with `enc:v1:` storage format).
|
|
61
61
|
|
|
62
|
+
### How `flair mcp enable` delivers its secrets
|
|
63
|
+
|
|
64
|
+
`flair mcp enable` needs five variables live in the target's process before it restarts — including `FLAIR_MCP_OAUTH` and the RS256 signing key, both read from `process.env` only and therefore impossible to deliver via `set_configuration`.
|
|
65
|
+
|
|
66
|
+
It asks the target what it can do, rather than assuming from the hostname or the version:
|
|
67
|
+
|
|
68
|
+
| The target… | What happens |
|
|
69
|
+
|-------------|--------------|
|
|
70
|
+
| supports Harper's env-secrets operations | the five vars are **sealed locally** and pushed over the ops API. No manual step, no re-run. |
|
|
71
|
+
| does not have them (Harper older than 5.2) | the vars are staged to a `0600` file and you apply them yourself, then re-run with `--confirm-secrets-applied` |
|
|
72
|
+
| is unreachable, refuses the probe, or answers unusably | same staged-file fallback, and the output says **which** of those happened |
|
|
73
|
+
|
|
74
|
+
Values are encrypted **before leaving your machine** — AES-256-GCM on the value, RSA-OAEP(SHA-256) wrapping the key, addressed to a public key fetched from the target. Plaintext never appears in a request body, and the command's output carries variable *names* only.
|
|
75
|
+
|
|
76
|
+
The staging file is written in every case, so a fallback never strands you mid-run. When the push succeeds it simply goes unused.
|
|
77
|
+
|
|
78
|
+
> **What the probe does not promise.** It establishes that the target accepts secrets, not that it will *decrypt* them — no read-only call can, since a secret only proves it was decrypted by being present in the process. The **self-verify** step at the end is what proves that. Note the endpoint does *not* go quiet when the flag is off — flair serves its own OAuth 2.1 discovery document instead, and self-verify tells them apart by the advertised `token_endpoint` (`/OAuthToken` is flair's own; the MCP one is `/oauth/mcp/token`). So a secret that is stored and never decrypted fails at self-verify with a message naming `FLAIR_MCP_OAUTH`, rather than reporting success.
|
|
79
|
+
|
|
80
|
+
`--secrets-mechanism <fabric-env-secrets|env-file>` remains an explicit override and skips the probe entirely.
|
|
81
|
+
|
|
62
82
|
---
|
|
63
83
|
|
|
64
84
|
## Agent authentication
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.39.0",
|
|
4
4
|
"packageManager": "bun@1.3.10",
|
|
5
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
6
6
|
"type": "module",
|