@tpsdev-ai/flair 0.36.0 → 0.38.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 +95 -22
- package/dist/deploy.js +123 -6
- package/dist/lib/auth-resolve.js +19 -0
- package/dist/lib/mcp-enable.js +226 -25
- package/dist/lib/secret-envelope.js +76 -0
- package/dist/lib/secrets-push.js +128 -0
- package/dist/resources/Memory.js +35 -0
- package/dist/resources/memory-visibility.js +44 -0
- package/dist/version-check.js +45 -0
- package/docs/hosted-on-fabric.md +20 -0
- package/docs/upgrade.md +3 -3
- package/package.json +7 -4
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { keystore } from "./keystore.js";
|
|
|
13
13
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl, resolveDeployPublicUrl } from "./deploy.js";
|
|
14
14
|
import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, assertNoSecretKeysAdded, describePublicUrlFinding, planComponentEnv, readEnvValue, } from "./component-env.js";
|
|
15
15
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
16
|
-
import { checkVersion, formatVersionNudge, primeVersionCheckCache, FLAIR_PKG_NAME } from "./version-check.js";
|
|
16
|
+
import { checkVersion, formatVersionNudge, primeVersionCheckCache, probeInstanceVersion, FLAIR_PKG_NAME } from "./version-check.js";
|
|
17
17
|
import { readInstalledHarperVersion, fetchDeclaredHarperVersion, writeEngineVersionStamp, checkEngineVersionBackwards, UPGRADE_SNAPSHOT_ROOT, } from "./engine-version.js";
|
|
18
18
|
import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache } from "./version-handshake.js";
|
|
19
19
|
import { probeInstance } from "./probe.js";
|
|
@@ -4777,7 +4777,7 @@ mcp
|
|
|
4777
4777
|
.option("--secrets-path <path>", "Override the secrets staging file path")
|
|
4778
4778
|
.option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
|
|
4779
4779
|
.option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
|
|
4780
|
-
.option("--admin-pass <pass>", "Admin password for the
|
|
4780
|
+
.option("--admin-pass <pass>", "Admin password for the TARGET instance. Required explicitly for a remote target — FLAIR_ADMIN_PASS and ~/.flair/admin-pass are this machine's local credentials and are never sent to a remote instance")
|
|
4781
4781
|
.option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
|
|
4782
4782
|
.option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
|
|
4783
4783
|
.option("--json", "Print machine-readable JSON instead of a human summary")
|
|
@@ -4802,8 +4802,11 @@ mcp
|
|
|
4802
4802
|
// against someone else's instance (see resolveLocalAdminPass's doc comment).
|
|
4803
4803
|
const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
|
|
4804
4804
|
if (!dryRun && !adminPass) {
|
|
4805
|
-
console.error("Error: --admin-pass or
|
|
4806
|
-
"for identity mapping + set_configuration + restart)
|
|
4805
|
+
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for a REMOTE target " +
|
|
4806
|
+
"(the operations API on the target instance needs it for identity mapping + set_configuration + restart).\n" +
|
|
4807
|
+
" FLAIR_ADMIN_PASS and ~/.flair/admin-pass are deliberately NOT used here: they are THIS machine's " +
|
|
4808
|
+
"local admin credentials, and sending them to another instance is how a local secret ends up on someone " +
|
|
4809
|
+
"else's Harper. Pass the target's own admin password explicitly.");
|
|
4807
4810
|
process.exit(1);
|
|
4808
4811
|
}
|
|
4809
4812
|
let idpClientId = opts.idpClientId;
|
|
@@ -9954,14 +9957,17 @@ program
|
|
|
9954
9957
|
process.exit(1);
|
|
9955
9958
|
}
|
|
9956
9959
|
// flair#1047: refuse to boot if the store was written by a newer engine.
|
|
9957
|
-
|
|
9958
|
-
|
|
9959
|
-
|
|
9960
|
-
|
|
9961
|
-
|
|
9962
|
-
|
|
9963
|
-
|
|
9964
|
-
|
|
9960
|
+
// Same guard startFlairProcess runs, so restart/upgrade/snapshot cannot
|
|
9961
|
+
// reach a boot this command would refuse (flair#1093).
|
|
9962
|
+
try {
|
|
9963
|
+
guardEngineNotBackwards(dataDir);
|
|
9964
|
+
}
|
|
9965
|
+
catch (err) {
|
|
9966
|
+
if (!err?.engineBackwards)
|
|
9967
|
+
throw err;
|
|
9968
|
+
console.error(`❌ Cannot start Flair — the data directory was written by a newer Harper engine.\n`);
|
|
9969
|
+
console.error(err.message);
|
|
9970
|
+
process.exit(1);
|
|
9965
9971
|
}
|
|
9966
9972
|
const platform = process.platform;
|
|
9967
9973
|
if (platform === "darwin") {
|
|
@@ -10329,7 +10335,45 @@ async function stopFlairProcess(port, dataDir) {
|
|
|
10329
10335
|
* snapshot commands' restart leg brought the DEFAULT instance back up after
|
|
10330
10336
|
* operating on a `--data-dir` elsewhere.
|
|
10331
10337
|
*/
|
|
10338
|
+
/**
|
|
10339
|
+
* flair#1047's refusal, at the point every boot passes through (flair#1093).
|
|
10340
|
+
*
|
|
10341
|
+
* It used to live inline in the `start` command's action and nowhere else, so
|
|
10342
|
+
* `flair restart`, `flair upgrade` (which restarts by spawning the new CLI with
|
|
10343
|
+
* `restart`) and the snapshot paths all booted Harper without it. The guard
|
|
10344
|
+
* covered one of the doors, and not the one an ENGINE SWAP comes through — so
|
|
10345
|
+
* an upgrade across a storage-format boundary came back as a dead port and a
|
|
10346
|
+
* bare exit 1 instead of a refusal naming actor, state and remedy.
|
|
10347
|
+
*
|
|
10348
|
+
* These same two functions had already drifted once, on the spawn environment:
|
|
10349
|
+
* see the note above buildDirectSpawnEnv about `start` setting a host-qualified
|
|
10350
|
+
* OPERATIONSAPI_NETWORK_PORT while startFlairProcess set none, silently
|
|
10351
|
+
* re-widening the ops API on every restart. Same pair, same shape. This is why
|
|
10352
|
+
* the check is a single function called from both rather than a second copy.
|
|
10353
|
+
*
|
|
10354
|
+
* Throws rather than exiting: `start` wants its own framing and an exit code,
|
|
10355
|
+
* while restart/upgrade need the message to travel up as an error. Nothing here
|
|
10356
|
+
* decides how it is presented.
|
|
10357
|
+
*/
|
|
10358
|
+
function guardEngineNotBackwards(dataDir) {
|
|
10359
|
+
const runningHarperVersion = readInstalledHarperVersion(flairPackageDir());
|
|
10360
|
+
// No readable engine version means nothing to compare — the pre-stamp case
|
|
10361
|
+
// checkEngineVersionBackwards already treats as "not backwards". Refusing here
|
|
10362
|
+
// would brick every install written before the stamp existed.
|
|
10363
|
+
if (!runningHarperVersion)
|
|
10364
|
+
return;
|
|
10365
|
+
const backwardsError = checkEngineVersionBackwards(dataDir, runningHarperVersion);
|
|
10366
|
+
if (!backwardsError)
|
|
10367
|
+
return;
|
|
10368
|
+
const err = new Error(backwardsError);
|
|
10369
|
+
err.engineBackwards = true;
|
|
10370
|
+
throw err;
|
|
10371
|
+
}
|
|
10332
10372
|
async function startFlairProcess(port, dataDir) {
|
|
10373
|
+
// Before anything is spawned or launchd is touched: an older engine opening a
|
|
10374
|
+
// newer store fails at the storage layer with an error about compression
|
|
10375
|
+
// internals, minutes later and nowhere near the cause.
|
|
10376
|
+
guardEngineNotBackwards(dataDir);
|
|
10333
10377
|
if (process.platform === "darwin") {
|
|
10334
10378
|
// resolveLaunchdLabel (flair#693) finds whichever label this data dir
|
|
10335
10379
|
// is currently registered under before we attempt anything.
|
|
@@ -11310,17 +11354,46 @@ program
|
|
|
11310
11354
|
// since we don't have advisory data, only the version gap. A red gap
|
|
11311
11355
|
// counts as an issue (exit 1); a quieter yellow gap (one minor, or
|
|
11312
11356
|
// patch-only) is printed but doesn't fail doctor.
|
|
11313
|
-
|
|
11314
|
-
|
|
11315
|
-
|
|
11316
|
-
|
|
11317
|
-
|
|
11318
|
-
|
|
11319
|
-
|
|
11320
|
-
|
|
11357
|
+
// ── flair#1072: the currency claim must be about the INSTANCE ─────────────
|
|
11358
|
+
//
|
|
11359
|
+
// This check used to run `checkVersion(__pkgVersion)` — the version of the
|
|
11360
|
+
// CLI you happen to have installed — and print "flair <x> is current". When
|
|
11361
|
+
// FLAIR_URL or --url points at a deployed instance, every other line doctor
|
|
11362
|
+
// prints is genuinely remote, so that sentence reads as a statement about
|
|
11363
|
+
// the thing you are talking to. It was a statement about your laptop.
|
|
11364
|
+
//
|
|
11365
|
+
// Reported against an instance five minors behind, where doctor said
|
|
11366
|
+
// "current". Telling you that is doctor's entire job.
|
|
11367
|
+
//
|
|
11368
|
+
// UNKNOWN MUST NOT FALL BACK TO THE LOCAL NUMBER. An older instance may not
|
|
11369
|
+
// expose its version at all, and the tempting fix is to use the one already
|
|
11370
|
+
// in hand — which is precisely how this bug reads today. If the instance
|
|
11371
|
+
// version cannot be determined, say so and count it as an issue rather than
|
|
11372
|
+
// answering from the wrong machine.
|
|
11373
|
+
const instanceVersion = await probeInstanceVersion(baseUrl);
|
|
11374
|
+
const versionSubject = instanceVersion ?? null;
|
|
11375
|
+
if (versionSubject === null) {
|
|
11376
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `could not determine the version running at ${baseUrl} — not reporting currency. ` +
|
|
11377
|
+
`(The local CLI is ${__pkgVersion}; that is NOT the instance.)`)}`);
|
|
11378
|
+
issues++;
|
|
11321
11379
|
}
|
|
11322
|
-
else
|
|
11323
|
-
|
|
11380
|
+
else {
|
|
11381
|
+
const versionCheckResult = await checkVersion(versionSubject);
|
|
11382
|
+
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
11383
|
+
if (versionNudge) {
|
|
11384
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
11385
|
+
const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
|
|
11386
|
+
console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
|
|
11387
|
+
if (versionNudge.severity === "red")
|
|
11388
|
+
issues++;
|
|
11389
|
+
}
|
|
11390
|
+
else if (versionCheckResult.latest) {
|
|
11391
|
+
console.log(` ${render.icons.ok} instance at ${baseUrl} runs flair ${versionSubject} — current`);
|
|
11392
|
+
}
|
|
11393
|
+
if (versionSubject !== __pkgVersion) {
|
|
11394
|
+
console.log(` ${render.icons.warn} ${render.wrap(render.c.yellow, `local CLI is ${__pkgVersion}, instance is ${versionSubject} — they differ. ` +
|
|
11395
|
+
`Commands run through the CLI; the instance serves the data.`)}`);
|
|
11396
|
+
}
|
|
11324
11397
|
}
|
|
11325
11398
|
// Helper: try to reach Harper on a given port.
|
|
11326
11399
|
// Must return true ONLY when Harper's /Health endpoint returns 200 OK.
|
package/dist/deploy.js
CHANGED
|
@@ -6,8 +6,19 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { COMPONENT_ENV_FILENAME, PUBLIC_URL_KEY, isLoopbackUrl, planComponentEnv, publicUrlRemedy, } from "./component-env.js";
|
|
8
8
|
import { awaitOriginQuiescent, awaitReplicationConvergence, defaultConvergenceDeps, parseReplicationFailure, } from "./replication-convergence.js";
|
|
9
|
-
// Files that must be
|
|
10
|
-
//
|
|
9
|
+
// Files that must be PRESENT for a deploy root to be usable at all — a
|
|
10
|
+
// preflight sanity check, not the payload definition.
|
|
11
|
+
//
|
|
12
|
+
// This used to say "Mirrors the `files` array in package.json — keep in sync."
|
|
13
|
+
// It did not, and nothing compared them. The payload was whatever sat in the
|
|
14
|
+
// deploy root, which for a git checkout meant .git, models/, test/, packages/
|
|
15
|
+
// and any stray file: 96 MB against a 1.3 MB published tarball, verified on a
|
|
16
|
+
// production Fabric component. A rule asserted in a comment with no mechanism
|
|
17
|
+
// behind it will drift, and this one drifted invisibly for as long as it existed.
|
|
18
|
+
//
|
|
19
|
+
// The payload is now derived from `files` directly, at pack time, by
|
|
20
|
+
// publishedEntryNames() — so there is one source of truth and nothing left to
|
|
21
|
+
// keep in sync by hand.
|
|
11
22
|
export const REQUIRED_PACKAGE_FILES = [
|
|
12
23
|
"dist",
|
|
13
24
|
"schemas",
|
|
@@ -592,6 +603,73 @@ function isNodeModulesPath(root, path) {
|
|
|
592
603
|
const rel = relative(root, path);
|
|
593
604
|
return rel !== "" && rel.split(sep).includes("node_modules");
|
|
594
605
|
}
|
|
606
|
+
/**
|
|
607
|
+
* The top-level entries the payload may contain: whatever `files` declares, plus
|
|
608
|
+
* the three npm always-includes. Read from the deploy root's own package.json so
|
|
609
|
+
* there is ONE source of truth — a hardcoded copy here is how `files` and the
|
|
610
|
+
* payload drifted apart in the first place.
|
|
611
|
+
*/
|
|
612
|
+
export function publishedEntryNames(packageRoot) {
|
|
613
|
+
// `.env` is not in `files` and never ships to npm, but it MUST reach the
|
|
614
|
+
// component: config.yaml's `loadEnv` reads it, and shipping it is the whole
|
|
615
|
+
// point of flair#1005 and of stageDeployRoot itself. Filtering it out breaks
|
|
616
|
+
// FLAIR_PUBLIC_URL on every deploy — caught by the operator-value test rather
|
|
617
|
+
// than in production, which is the only reason this comment exists.
|
|
618
|
+
//
|
|
619
|
+
// What goes INSIDE that file is a separate concern with its own issue
|
|
620
|
+
// (flair#1011, HDB_ADMIN_PASSWORD must not be written into a deployed .env).
|
|
621
|
+
// This function decides what may ship, not what the file may contain.
|
|
622
|
+
const always = ["package.json", "README.md", "LICENSE", "LICENCE", COMPONENT_ENV_FILENAME];
|
|
623
|
+
let declared = [];
|
|
624
|
+
try {
|
|
625
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
626
|
+
if (Array.isArray(pkg.files))
|
|
627
|
+
declared = pkg.files;
|
|
628
|
+
}
|
|
629
|
+
catch {
|
|
630
|
+
/* handled by the caller's emptiness check below */
|
|
631
|
+
}
|
|
632
|
+
// `files` entries are npm patterns; the ones flair uses are plain top-level
|
|
633
|
+
// names, optionally trailing-slashed ("dist/"). Normalise to the entry name.
|
|
634
|
+
const names = declared
|
|
635
|
+
.map((f) => String(f).replace(/^\.\//, "").replace(/\/+$/, ""))
|
|
636
|
+
.filter((f) => f !== "" && !f.includes("*") && !f.startsWith("!"));
|
|
637
|
+
return new Set([...names, ...always]);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Whether `packageRoot` declares a usable `files` array.
|
|
641
|
+
*
|
|
642
|
+
* Separate from `publishedEntryNames` deliberately. The refusal below used to
|
|
643
|
+
* ask whether the resulting set was larger than the always-includes, i.e. it
|
|
644
|
+
* compared against a hardcoded count — so adding one always-include silently
|
|
645
|
+
* stopped it firing. A check keyed to the length of a list that grows is a check
|
|
646
|
+
* that disables itself the next time someone edits that list.
|
|
647
|
+
*/
|
|
648
|
+
export function hasDeclaredFiles(packageRoot) {
|
|
649
|
+
try {
|
|
650
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8"));
|
|
651
|
+
return Array.isArray(pkg.files) && pkg.files.length > 0;
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* True when `src` is a top-level entry the published package would not contain.
|
|
659
|
+
*
|
|
660
|
+
* Only TOP-LEVEL entries are filtered. Once a directory is admitted its whole
|
|
661
|
+
* subtree ships, which is what npm does and what makes the staged copy equal the
|
|
662
|
+
* published tarball rather than merely similar.
|
|
663
|
+
*/
|
|
664
|
+
function isUnpublishedEntry(root, src, allowed) {
|
|
665
|
+
const rel = relative(root, src);
|
|
666
|
+
if (rel === "")
|
|
667
|
+
return false;
|
|
668
|
+
const parts = rel.split(sep);
|
|
669
|
+
if (parts.length > 1)
|
|
670
|
+
return false;
|
|
671
|
+
return !allowed.has(parts[0]);
|
|
672
|
+
}
|
|
595
673
|
/**
|
|
596
674
|
* Resolve the value `FLAIR_PUBLIC_URL` should carry for this deploy, or null.
|
|
597
675
|
*
|
|
@@ -629,8 +707,40 @@ export function stageDeployRoot(packageRoot, publicUrl) {
|
|
|
629
707
|
const envPath = join(packageRoot, COMPONENT_ENV_FILENAME);
|
|
630
708
|
const existing = existsSync(envPath) ? readFileSync(envPath, "utf8") : null;
|
|
631
709
|
const plan = planComponentEnv(existing, publicUrl);
|
|
632
|
-
|
|
633
|
-
|
|
710
|
+
// ── Why this ALWAYS stages now ─────────────────────────────────────────────
|
|
711
|
+
//
|
|
712
|
+
// harper packs its CWD wholesale, so the payload is whatever sits in the
|
|
713
|
+
// deploy root. The comment above assumes that root is an npm-installed
|
|
714
|
+
// package, where the tree already IS the published file set. That assumption
|
|
715
|
+
// is true for the intended path and silently false for the one our own
|
|
716
|
+
// deploy procedure prescribes: a git checkout.
|
|
717
|
+
//
|
|
718
|
+
// Measured on the production Fabric origin 2026-08-03, deploying from a
|
|
719
|
+
// checkout at v0.36.0: the deployed component held 36 top-level entries —
|
|
720
|
+
// `.git`, `.env`, `models/`, `test/`, `packages/`, `src/`, and a scratch
|
|
721
|
+
// `pr-body.md` left in the clone that afternoon. Payload 96 MB against the
|
|
722
|
+
// published tarball's 1.3 MB, a factor of 74. `models/` alone was 80 MB.
|
|
723
|
+
//
|
|
724
|
+
// Two consequences, both bad. An operator deploying from a checkout ships
|
|
725
|
+
// `.git` (every secret ever committed and later removed) and any `.env`
|
|
726
|
+
// sitting in the tree, into a component that is then PERSISTED and REPLICATED
|
|
727
|
+
// across the cluster. And a 96 MB payload is what puts the deploy inside
|
|
728
|
+
// HarperFast/harper#2062's aborted-transaction window, where the pre-saved
|
|
729
|
+
// blob is destroyed at the source.
|
|
730
|
+
//
|
|
731
|
+
// So the filter is not an optimisation. Staging unconditionally, restricted to
|
|
732
|
+
// the entries `files` declares, makes the payload equal the published package
|
|
733
|
+
// BY CONSTRUCTION rather than by an operator happening to run from the right
|
|
734
|
+
// directory. For an npm-installed root the result is byte-identical to before,
|
|
735
|
+
// because such a tree contains nothing else.
|
|
736
|
+
const allowed = publishedEntryNames(packageRoot);
|
|
737
|
+
// An empty/unreadable `files` would filter the payload down to the
|
|
738
|
+
// always-includes and deploy a component with no dist/. Refuse instead: a
|
|
739
|
+
// deploy that silently ships almost nothing is worse than one that does not run.
|
|
740
|
+
if (!hasDeclaredFiles(packageRoot)) {
|
|
741
|
+
throw new Error(`Cannot determine the published file set for ${packageRoot}: package.json has no usable "files" array. ` +
|
|
742
|
+
`Refusing to deploy rather than shipping an unfiltered payload (which would include .git and any .env) ` +
|
|
743
|
+
`or an empty one. Deploy from an npm-installed @tpsdev-ai/flair, or pass --package-root at one.`);
|
|
634
744
|
}
|
|
635
745
|
const dir = mkdtempSync(join(tmpdir(), STAGING_PREFIX));
|
|
636
746
|
try {
|
|
@@ -638,12 +748,19 @@ export function stageDeployRoot(packageRoot, publicUrl) {
|
|
|
638
748
|
recursive: true,
|
|
639
749
|
dereference: false,
|
|
640
750
|
verbatimSymlinks: true,
|
|
641
|
-
filter: (src) => !isNodeModulesPath(packageRoot, src),
|
|
751
|
+
filter: (src) => !isNodeModulesPath(packageRoot, src) && !isUnpublishedEntry(packageRoot, src, allowed),
|
|
642
752
|
});
|
|
643
753
|
// 0600 even though the generated content is a public URL: an operator's own
|
|
644
754
|
// keys may have been merged through, and the file's permissions should not
|
|
645
755
|
// depend on what happens to be in it.
|
|
646
|
-
|
|
756
|
+
//
|
|
757
|
+
// `plan.text === null` means there is nothing to add — a loopback target, or
|
|
758
|
+
// an operator who already set the key. Staging still happened (the filter is
|
|
759
|
+
// the point now, not the `.env`), so any `.env` the root carried has been
|
|
760
|
+
// copied through unchanged and must be left alone.
|
|
761
|
+
if (plan.text !== null) {
|
|
762
|
+
writeFileSync(join(dir, COMPONENT_ENV_FILENAME), plan.text, { mode: 0o600 });
|
|
763
|
+
}
|
|
647
764
|
}
|
|
648
765
|
catch (err) {
|
|
649
766
|
rmSync(dir, { recursive: true, force: true });
|
package/dist/lib/auth-resolve.js
CHANGED
|
@@ -140,6 +140,25 @@ export function defaultKeysDir() {
|
|
|
140
140
|
* local admin secret against someone else's Harper instance. Remote callers
|
|
141
141
|
* keep requiring an explicit `--admin-pass`.
|
|
142
142
|
*
|
|
143
|
+
* ── Both legs, not just the file (flair#1085) ────────────────────────────────
|
|
144
|
+
* An operator reported this as a bug: `--help` and the error both promised
|
|
145
|
+
* `FLAIR_ADMIN_PASS` would work, and for a remote target it silently did not.
|
|
146
|
+
* The promise was wrong, not the guard — but the codebase disagreed with itself,
|
|
147
|
+
* which is why it read as a defect. One call site's comment described the goal as
|
|
148
|
+
* blocking only the *file* fallback, this doc described blocking both, and the
|
|
149
|
+
* user-facing strings described neither.
|
|
150
|
+
*
|
|
151
|
+
* The env leg is skipped **deliberately**, and the reasoning is worth keeping:
|
|
152
|
+
* an exported `FLAIR_ADMIN_PASS` is not evidence of intent toward THIS target. It
|
|
153
|
+
* persists across a shell session, so an operator who set it for their local
|
|
154
|
+
* instance and later types `--instance https://someone-elses-host` would send a
|
|
155
|
+
* local admin credential to a third party — silently, and with no way to notice.
|
|
156
|
+
* "The operator exported it" and "the operator meant it for this host" are
|
|
157
|
+
* different claims, and only the explicit flag asserts the second.
|
|
158
|
+
*
|
|
159
|
+
* If that trade is ever revisited, revisit it here rather than at a call site:
|
|
160
|
+
* the guard is one line and the reasoning is not obvious from it.
|
|
161
|
+
*
|
|
143
162
|
* Throws (via readAdminPassFileSecure) if the file exists but has unsafe
|
|
144
163
|
* permissions, so a misconfigured file surfaces as an actionable chmod error
|
|
145
164
|
* instead of a generic "admin pass required" message.
|
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;
|
|
@@ -355,8 +384,50 @@ export function provisionSecrets(instanceUrl, bundle, opts = {}) {
|
|
|
355
384
|
return { mechanism, path, varNames, instructions };
|
|
356
385
|
}
|
|
357
386
|
// ─── Identity mapping (Credential kind:idp) ─────────────────────────────────
|
|
387
|
+
/**
|
|
388
|
+
* The ops API is NOT the served origin, and its port is NOT derivable.
|
|
389
|
+
*
|
|
390
|
+
* flair#1072-adjacent, found while enabling MCP against a hosted instance: this
|
|
391
|
+
* function used a string target verbatim, so `flair mcp enable --instance
|
|
392
|
+
* https://flair.example.harperfabric.com` posted its ops calls to **port 443**,
|
|
393
|
+
* where the flair REST component owns `/` and answers `404 Not found`. Measured
|
|
394
|
+
* against a live Fabric instance, same request both ways:
|
|
395
|
+
*
|
|
396
|
+
* POST https://<host>/ -> HTTP 404 "Not found"
|
|
397
|
+
* POST https://<host>:9925/ -> HTTP 200 []
|
|
398
|
+
*
|
|
399
|
+
* The codebase elsewhere documents "ops port = HTTP port - 1", which derives 442
|
|
400
|
+
* for a 443-served instance. Also measured: 442 and 19925 are both dead on
|
|
401
|
+
* Fabric. **That convention does not hold, and no arithmetic on the served port
|
|
402
|
+
* can be trusted — an operator can put the ops API anywhere.**
|
|
403
|
+
*
|
|
404
|
+
* So: never derive silently. An explicit target wins; otherwise the conventional
|
|
405
|
+
* hosted ops port is *tried*, and a caller that cannot reach it is told to pass
|
|
406
|
+
* one rather than being handed a 404 about something else.
|
|
407
|
+
*/
|
|
408
|
+
export const HOSTED_OPS_PORT = 9925;
|
|
409
|
+
export function resolveOpsUrl(target, explicitOpsUrl) {
|
|
410
|
+
if (explicitOpsUrl)
|
|
411
|
+
return `${explicitOpsUrl.replace(/\/+$/, "")}/`;
|
|
412
|
+
if (typeof target === "number")
|
|
413
|
+
return `http://127.0.0.1:${target}/`;
|
|
414
|
+
// A string target is the SERVED origin. Its own port serves the REST surface,
|
|
415
|
+
// not the ops API, so reuse the host and apply the hosted ops port.
|
|
416
|
+
try {
|
|
417
|
+
const u = new URL(target.includes("://") ? target : `https://${target}`);
|
|
418
|
+
u.port = String(HOSTED_OPS_PORT);
|
|
419
|
+
u.pathname = "/";
|
|
420
|
+
u.search = "";
|
|
421
|
+
return u.toString();
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
// Unparseable — preserve the old behaviour rather than inventing a URL, and
|
|
425
|
+
// let the caller's error path name the remedy.
|
|
426
|
+
return `${target.replace(/\/+$/, "")}/`;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
358
429
|
function opsBaseUrl(opsPortOrUrl) {
|
|
359
|
-
return
|
|
430
|
+
return resolveOpsUrl(opsPortOrUrl);
|
|
360
431
|
}
|
|
361
432
|
function basicAuthHeader(adminUser, adminPass) {
|
|
362
433
|
return `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
@@ -388,7 +459,19 @@ export async function provisionIdpIdentityMapping(params, deps = {}) {
|
|
|
388
459
|
});
|
|
389
460
|
if (!findRes.ok) {
|
|
390
461
|
const text = await findRes.text().catch(() => "");
|
|
391
|
-
|
|
462
|
+
// A MISSING principal is not this branch. The ops API answers an empty
|
|
463
|
+
// search with 200 and [], and the code below creates the principal when the
|
|
464
|
+
// list is empty. Reaching here means the ops CALL failed, not that the
|
|
465
|
+
// identity is absent — and saying "failed to look up principal 'x'" sends
|
|
466
|
+
// the reader to look at principals, which is where an evening goes.
|
|
467
|
+
//
|
|
468
|
+
// 404 in particular almost always means the request reached the SERVED
|
|
469
|
+
// origin instead of the ops API: the flair REST component owns `/` there and
|
|
470
|
+
// answers 404. Say that, and name the flag that fixes it.
|
|
471
|
+
const hint = findRes.status === 404
|
|
472
|
+
? ` — a 404 here usually means ${opsUrl} is the served origin rather than the ops API (the REST component owns "/" and answers 404). The ops API is a DIFFERENT port (conventionally ${HOSTED_OPS_PORT} on hosted instances) and is not derivable from the served port. Pass --ops-url <url> to point at it explicitly.`
|
|
473
|
+
: "";
|
|
474
|
+
throw new Error(`Identity mapping: the ops API call to ${opsUrl} failed (HTTP ${findRes.status})${hint}${text ? `: ${text}` : ""}`);
|
|
392
475
|
}
|
|
393
476
|
const foundAgents = await findRes.json().catch(() => []);
|
|
394
477
|
let principalCreated = false;
|
|
@@ -551,12 +634,61 @@ export async function selfVerifyMcpMetadata(issuer, deps = {}) {
|
|
|
551
634
|
catch {
|
|
552
635
|
return { ok: false, detail: `${url} did not return JSON` };
|
|
553
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.
|
|
554
686
|
if (body?.issuer !== normalizedIssuer ||
|
|
555
|
-
typeof body?.
|
|
556
|
-
typeof body
|
|
687
|
+
typeof body?.token_endpoint !== "string" ||
|
|
688
|
+
(body?.registration_endpoint !== undefined && typeof body.registration_endpoint !== "string")) {
|
|
557
689
|
return {
|
|
558
690
|
ok: false,
|
|
559
|
-
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)}`,
|
|
560
692
|
};
|
|
561
693
|
}
|
|
562
694
|
// flair#1000: this path is now served by flair ITSELF when FLAIR_MCP_OAUTH is
|
|
@@ -630,29 +762,46 @@ export function buildClaudePasteBlock(resource) {
|
|
|
630
762
|
*/
|
|
631
763
|
export async function enableMcp(params, deps = {}) {
|
|
632
764
|
const steps = [];
|
|
765
|
+
// The step currently executing, so a throw is attributed to IT rather than to
|
|
766
|
+
// the last step that succeeded (flair#1087).
|
|
767
|
+
//
|
|
768
|
+
// `push` deliberately takes NO step name: it reads this variable. A name passed
|
|
769
|
+
// per-call would be the same string typed twice (once here, once at the push),
|
|
770
|
+
// and the two drifting apart is precisely the misattribution #1087 is about —
|
|
771
|
+
// a rule that only a comment or a source scan could enforce. Deriving it makes
|
|
772
|
+
// a wrong name unrepresentable instead of merely discouraged, so there is
|
|
773
|
+
// nothing left for a reviewer to check.
|
|
774
|
+
//
|
|
775
|
+
// Initialised to the first step rather than left undefined so a throw before
|
|
776
|
+
// any assignment cannot be attributed to an arbitrary fallback name.
|
|
777
|
+
let currentStep = "local-origin-check";
|
|
633
778
|
const dryRun = Boolean(params.dryRun);
|
|
634
|
-
const push = (
|
|
779
|
+
const push = (ok, detail) => steps.push({ step: currentStep, ok, detail });
|
|
635
780
|
// ── Local-origin refusal (scenario addendum, binding) ─────────────────────
|
|
781
|
+
currentStep = "local-origin-check";
|
|
636
782
|
const localCheck = checkLocalOriginRefusal(params.instance);
|
|
637
783
|
if (localCheck.refused) {
|
|
638
|
-
push(
|
|
784
|
+
push(false, localCheck.message);
|
|
639
785
|
return { ok: false, dryRun, refused: { message: localCheck.message }, steps, failedStep: "local-origin-check" };
|
|
640
786
|
}
|
|
641
|
-
push(
|
|
787
|
+
push(true, `${params.instance} is a public-shaped origin`);
|
|
642
788
|
const issuer = (params.issuer ?? params.instance).replace(/\/+$/, "");
|
|
643
789
|
const idpProvider = params.idpProvider ?? "github";
|
|
644
790
|
const principal = params.principal ?? "self";
|
|
645
791
|
const principalKind = params.principalKind ?? "human";
|
|
646
792
|
try {
|
|
647
793
|
// ── RS256 signing keypair ─────────────────────────────────────────────────
|
|
794
|
+
currentStep = "signing-key";
|
|
648
795
|
const keyResult = ensureSigningKeyFile(params.signingKeyFilePath, { generate: deps.generateRsaKeyPair });
|
|
649
|
-
push(
|
|
796
|
+
push(true, `signing key ${keyResult.reused ? "reused" : "generated"} at ${keyResult.path} (0600)`);
|
|
650
797
|
// ── @harperfast/oauth config block (CIMD-only; DCR explicitly disabled) ──
|
|
651
798
|
const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
|
|
799
|
+
currentStep = "config-block";
|
|
652
800
|
const configBlock = buildMcpOAuthConfigBlock({ idpProvider, cimdAllowedHosts });
|
|
653
|
-
push(
|
|
801
|
+
push(true, `built the @harperfast/oauth mcp config block (accessTokenTtl=${REQUIRED_ACCESS_TOKEN_TTL}, ` +
|
|
654
802
|
`dynamicClientRegistration.enabled=false, clientIdMetadataDocuments.allowedHosts=${JSON.stringify(cimdAllowedHosts)})`);
|
|
655
803
|
// ── IdP OAuth-app credential intake ───────────────────────────────────────
|
|
804
|
+
currentStep = "idp-credentials";
|
|
656
805
|
const callbackUrl = idpCallbackUrl(issuer, idpProvider);
|
|
657
806
|
if (!params.idpClientId || !params.idpClientSecret || !params.idpSubject) {
|
|
658
807
|
const missing = [
|
|
@@ -660,10 +809,10 @@ export async function enableMcp(params, deps = {}) {
|
|
|
660
809
|
!params.idpClientSecret && "--idp-client-secret",
|
|
661
810
|
!params.idpSubject && "--idp-subject",
|
|
662
811
|
].filter(Boolean).join(", ");
|
|
663
|
-
push(
|
|
812
|
+
push(false, `missing ${missing}. Create a ${idpProvider} OAuth app with callback URL ${callbackUrl}, then re-run with the credentials.`);
|
|
664
813
|
return { ok: false, dryRun, steps, failedStep: "idp-credentials", callbackUrl };
|
|
665
814
|
}
|
|
666
|
-
push(
|
|
815
|
+
push(true, `${idpProvider} OAuth app credentials present; callback URL: ${callbackUrl}`);
|
|
667
816
|
if (dryRun) {
|
|
668
817
|
// Dry-run stops here — everything above is pure/local generation; no
|
|
669
818
|
// remote mutation has happened, and nothing below this line would run
|
|
@@ -687,12 +836,48 @@ export async function enableMcp(params, deps = {}) {
|
|
|
687
836
|
idpClientId: params.idpClientId,
|
|
688
837
|
idpClientSecret: params.idpClientSecret,
|
|
689
838
|
});
|
|
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.
|
|
690
844
|
const secretsResult = provisionSecrets(params.instance, bundle, {
|
|
691
845
|
mechanism: params.secretsMechanism,
|
|
692
846
|
stagingPath: params.secretsStagingPath,
|
|
693
847
|
});
|
|
694
|
-
|
|
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
|
+
}
|
|
695
879
|
// ── Identity mapping (Credential kind:idp) ────────────────────────────────
|
|
880
|
+
currentStep = "identity-mapping";
|
|
696
881
|
const mapping = await provisionIdpIdentityMapping({
|
|
697
882
|
opsPortOrUrl: params.instance,
|
|
698
883
|
adminUser: params.adminUser,
|
|
@@ -702,7 +887,7 @@ export async function enableMcp(params, deps = {}) {
|
|
|
702
887
|
idpProvider,
|
|
703
888
|
idpSubject: params.idpSubject,
|
|
704
889
|
}, { fetchImpl: deps.fetchImpl, now: deps.now });
|
|
705
|
-
push(
|
|
890
|
+
push(true, `principal '${principal}' ${mapping.principalCreated ? "created" : "already existed"}; ` +
|
|
706
891
|
`Credential(kind:idp) ${mapping.credentialReused ? "reused" : "created"} (${mapping.credentialId})`);
|
|
707
892
|
// ── Gate: confirm the staged secrets are actually live before restarting ─
|
|
708
893
|
let confirmed = Boolean(params.confirmSecretsApplied);
|
|
@@ -710,16 +895,18 @@ export async function enableMcp(params, deps = {}) {
|
|
|
710
895
|
confirmed = await deps.confirmPrompt(`Have you applied the ${secretsResult.varNames.length} vars staged at ${secretsResult.path} to ${params.instance}'s environment?`);
|
|
711
896
|
}
|
|
712
897
|
if (!confirmed) {
|
|
713
|
-
push(
|
|
898
|
+
push(false, `not applied: pass --confirm-secrets-applied once the staged secrets are live on ${params.instance}, then re-run \`flair mcp enable\` (earlier steps are idempotent and will reuse what's already provisioned).`);
|
|
714
899
|
return { ok: false, dryRun, steps, failedStep: "apply-config-and-restart", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
|
|
715
900
|
}
|
|
716
901
|
// ── Apply config + restart ────────────────────────────────────────────────
|
|
902
|
+
currentStep = "apply-config-and-restart";
|
|
717
903
|
await applyRemoteConfigAndRestart({ opsPortOrUrl: params.instance, adminUser: params.adminUser, adminPass: params.adminPass, configBlock }, { fetchImpl: deps.fetchImpl });
|
|
718
|
-
push(
|
|
904
|
+
push(true, `set_configuration + restart succeeded against ${params.instance}`);
|
|
719
905
|
// ── Self-verify from the operator's machine, public origin, CIMD-inclusive
|
|
906
|
+
currentStep = "self-verify";
|
|
720
907
|
const verify = await selfVerifyMcpMetadata(issuer, { fetchImpl: deps.fetchImpl });
|
|
721
908
|
if (!verify.ok) {
|
|
722
|
-
push(
|
|
909
|
+
push(false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry the apply-config-and-restart step.`);
|
|
723
910
|
return {
|
|
724
911
|
ok: false,
|
|
725
912
|
dryRun,
|
|
@@ -729,7 +916,7 @@ export async function enableMcp(params, deps = {}) {
|
|
|
729
916
|
resource: `${issuer}/mcp`,
|
|
730
917
|
};
|
|
731
918
|
}
|
|
732
|
-
push(
|
|
919
|
+
push(true, verify.detail);
|
|
733
920
|
const resource = `${issuer}/mcp`;
|
|
734
921
|
return {
|
|
735
922
|
ok: true,
|
|
@@ -745,9 +932,23 @@ export async function enableMcp(params, deps = {}) {
|
|
|
745
932
|
};
|
|
746
933
|
}
|
|
747
934
|
catch (err) {
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
935
|
+
// flair#1087: blame the step that was RUNNING, never the last one that
|
|
936
|
+
// succeeded. This read steps[steps.length - 1] — the last COMPLETED step —
|
|
937
|
+
// so a throw inside identity-mapping was reported against
|
|
938
|
+
// secrets-provisioning, which had just succeeded. An operator saw:
|
|
939
|
+
//
|
|
940
|
+
// ✓ secrets-provisioning ...apply these 5 vars in Fabric Studio, then re-run
|
|
941
|
+
// ✗ secrets-provisioning unexpected error: Identity mapping: ...
|
|
942
|
+
//
|
|
943
|
+
// Two results for one step, and the ✓ instructs several minutes of manual
|
|
944
|
+
// work in a web UI that the ✗ makes pointless. Read in order, you do the
|
|
945
|
+
// work first.
|
|
946
|
+
// No `?? "signing-key"` fallback: currentStep is initialised to the first
|
|
947
|
+
// step, so there is no undefined case to invent a name for. A fallback here
|
|
948
|
+
// would attribute a throw to a step chosen for being a plausible default —
|
|
949
|
+
// the same misattribution this handler exists to prevent, one layer down.
|
|
950
|
+
push(false, `unexpected error: ${err?.message ?? err}`);
|
|
951
|
+
return { ok: false, dryRun, steps, failedStep: currentStep };
|
|
751
952
|
}
|
|
752
953
|
}
|
|
753
954
|
/**
|
|
@@ -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,128 @@
|
|
|
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), tier: PROCESS_ENV_TIER }, fetchImpl);
|
|
120
|
+
const err = String(r.json?.error ?? "").slice(0, 140);
|
|
121
|
+
results.push({ name, ok: r.ok, detail: r.ok ? undefined : `HTTP ${r.status}${err ? `: ${err}` : ""}` });
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
results.push({ name, ok: false, detail: String(err?.message ?? err).slice(0, 140) });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { allOk: results.every((x) => x.ok), results };
|
|
128
|
+
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -6,6 +6,7 @@ import { getEmbedding, getModelId } from "./embeddings-provider.js";
|
|
|
6
6
|
import { scanFields, isStrictMode } from "./content-safety.js";
|
|
7
7
|
import { invalidEntitiesResponse } from "./entity-vocab.js";
|
|
8
8
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
9
|
+
import { assertValidVisibility } from "./memory-visibility.js";
|
|
9
10
|
import { DEDUP_COSINE_THRESHOLD_DEFAULT, DEDUP_LEXICAL_THRESHOLD_DEFAULT, DEDUP_MIN_CONTENT_LENGTH, computeMatchConfidence, cosineSimilarity, isConservativeMatch, } from "./dedup.js";
|
|
10
11
|
import { buildProvenance, makeAuthGate, makeReadScope, makeByIdReadGate, makeScopedSearch, resolveAuthGate, stampAttribution, FORBIDDEN, UNAUTH, } from "./record-type-kit.js";
|
|
11
12
|
import { RECORD_TYPES } from "./record-types.js";
|
|
@@ -600,6 +601,23 @@ export class Memory extends databases.flair.Memory {
|
|
|
600
601
|
// existing record's visibility" concern here. Explicit visibility on the
|
|
601
602
|
// write ALWAYS overrides; only stamp the default when the caller left it
|
|
602
603
|
// unset. permanent|persistent → shared; standard|ephemeral|absent → private.
|
|
604
|
+
// ── flair#1009: refuse an unrecognised visibility BEFORE defaulting ──
|
|
605
|
+
// isPrivateVisibility() is an exact match on "private", so on the READ
|
|
606
|
+
// side every other value (a typo, a wrong case, a retired tier like
|
|
607
|
+
// "office") resolves to non-private and is readable by every agent on the
|
|
608
|
+
// instance. #1006 closed that at the CLI flag and the MCP tool argument;
|
|
609
|
+
// REST and the in-process API reach here without passing either.
|
|
610
|
+
//
|
|
611
|
+
// Refusing, rather than dropping the key: dropping it falls through to the
|
|
612
|
+
// durability-keyed default below, which for a permanent or persistent
|
|
613
|
+
// write is "shared" - the same widening, arrived at silently. A misspelled
|
|
614
|
+
// argument must not decide who can read a memory.
|
|
615
|
+
{
|
|
616
|
+
const visibilityError = assertValidVisibility(content.visibility);
|
|
617
|
+
if (visibilityError) {
|
|
618
|
+
return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
|
|
619
|
+
}
|
|
620
|
+
}
|
|
603
621
|
if (content.visibility === undefined || content.visibility === null) {
|
|
604
622
|
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
605
623
|
}
|
|
@@ -784,6 +802,23 @@ export class Memory extends databases.flair.Memory {
|
|
|
784
802
|
// Explicit visibility on the write ALWAYS overrides; only stamp the
|
|
785
803
|
// default when the caller left it unset AND this is a fresh record.
|
|
786
804
|
// permanent|persistent → shared; standard|ephemeral|absent → private.
|
|
805
|
+
// ── flair#1009: refuse an unrecognised visibility BEFORE defaulting ──
|
|
806
|
+
// isPrivateVisibility() is an exact match on "private", so on the READ
|
|
807
|
+
// side every other value (a typo, a wrong case, a retired tier like
|
|
808
|
+
// "office") resolves to non-private and is readable by every agent on the
|
|
809
|
+
// instance. #1006 closed that at the CLI flag and the MCP tool argument;
|
|
810
|
+
// REST and the in-process API reach here without passing either.
|
|
811
|
+
//
|
|
812
|
+
// Refusing, rather than dropping the key: dropping it falls through to the
|
|
813
|
+
// durability-keyed default below, which for a permanent or persistent
|
|
814
|
+
// write is "shared" - the same widening, arrived at silently. A misspelled
|
|
815
|
+
// argument must not decide who can read a memory.
|
|
816
|
+
{
|
|
817
|
+
const visibilityError = assertValidVisibility(content.visibility);
|
|
818
|
+
if (visibilityError) {
|
|
819
|
+
return new Response(JSON.stringify({ error: "invalid_visibility", message: visibilityError }), { status: 400, headers: { "content-type": "application/json" } });
|
|
820
|
+
}
|
|
821
|
+
}
|
|
787
822
|
if (!preExisting && (content.visibility === undefined || content.visibility === null)) {
|
|
788
823
|
content.visibility = defaultVisibilityForDurability(content.durability);
|
|
789
824
|
}
|
|
@@ -29,9 +29,53 @@
|
|
|
29
29
|
* missing/null/anything-other-than-'private' all count as non-private.
|
|
30
30
|
*/
|
|
31
31
|
export const PRIVATE_VISIBILITY = "private";
|
|
32
|
+
export const SHARED_VISIBILITY = "shared";
|
|
33
|
+
/** The only values a WRITER may supply. Deliberately not derived from the read
|
|
34
|
+
* predicate below — see the asymmetry note on assertValidVisibility. */
|
|
35
|
+
export const WRITABLE_VISIBILITIES = [PRIVATE_VISIBILITY, SHARED_VISIBILITY];
|
|
32
36
|
/** True only when visibility is the literal string "private". Null, undefined,
|
|
33
37
|
* "shared", or any other value are all non-private (see migration invariant
|
|
34
38
|
* above) — never invert this to an allowlist of "shared". */
|
|
35
39
|
export function isPrivateVisibility(visibility) {
|
|
36
40
|
return visibility === PRIVATE_VISIBILITY;
|
|
37
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Reject a visibility a writer supplied that is not one of the two valid values.
|
|
44
|
+
* Returns an error message, or null when the value is acceptable.
|
|
45
|
+
*
|
|
46
|
+
* ── Why this is NOT the inverse of isPrivateVisibility (flair#1009) ──────────
|
|
47
|
+
*
|
|
48
|
+
* The read predicate above must stay "is this exactly 'private'", because a row
|
|
49
|
+
* written before the field existed has no visibility and must keep reading as it
|
|
50
|
+
* always did. That is a MIGRATION rule about stored data, and it is correct.
|
|
51
|
+
*
|
|
52
|
+
* The consequence is that on the read side every unrecognised value — a typo, a
|
|
53
|
+
* wrong case, a retired tier — resolves to non-private and is readable by every
|
|
54
|
+
* agent on the instance. #1006 closed that at the two writer-intent boundaries
|
|
55
|
+
* (the CLI flag, the MCP tool argument); REST and the in-process API still
|
|
56
|
+
* accepted anything, so `PUT /Memory/<id> {"visibility":"prvate"}` wrote a
|
|
57
|
+
* memory the caller believed was owner-only and everyone could read.
|
|
58
|
+
*
|
|
59
|
+
* So the two directions need different rules, and conflating them breaks one or
|
|
60
|
+
* the other:
|
|
61
|
+
* - READING an unknown value must be permissive, or old rows break.
|
|
62
|
+
* - WRITING an unknown value must be refused, or a typo silently widens who
|
|
63
|
+
* can read a memory.
|
|
64
|
+
*
|
|
65
|
+
* Refusing is also the only safe option at write time. Silently dropping the key
|
|
66
|
+
* would fall back to the durability-keyed default, which for a permanent or
|
|
67
|
+
* persistent write is `shared` — the same wrong outcome, arrived at quietly.
|
|
68
|
+
*
|
|
69
|
+
* `undefined`/`null` are accepted: omitting the field is how a caller asks for
|
|
70
|
+
* the durability-keyed default, and that is a documented, intentional path.
|
|
71
|
+
*/
|
|
72
|
+
export function assertValidVisibility(visibility) {
|
|
73
|
+
if (visibility === undefined || visibility === null)
|
|
74
|
+
return null;
|
|
75
|
+
if (typeof visibility === "string" && WRITABLE_VISIBILITIES.includes(visibility)) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return (`visibility must be ${WRITABLE_VISIBILITIES.map((v) => `"${v}"`).join(" or ")} ` +
|
|
79
|
+
`(got: ${JSON.stringify(visibility)}). Omit it to use the durability-keyed default: ` +
|
|
80
|
+
`permanent/persistent -> shared, standard/ephemeral -> private.`);
|
|
81
|
+
}
|
package/dist/version-check.js
CHANGED
|
@@ -178,3 +178,48 @@ export function formatVersionNudge(result) {
|
|
|
178
178
|
`Upgrade: npm i -g ${FLAIR_PKG_NAME}@latest`;
|
|
179
179
|
return { severity: gap.severity, message };
|
|
180
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* The version an INSTANCE reports, or null when it cannot be determined.
|
|
183
|
+
*
|
|
184
|
+
* flair#1072. Every other line `doctor` prints about a remote target is
|
|
185
|
+
* genuinely remote; the currency claim was about the local CLI. This asks the
|
|
186
|
+
* instance instead.
|
|
187
|
+
*
|
|
188
|
+
* Returns null — never a fallback — when the instance is unreachable, answers
|
|
189
|
+
* without a version, or times out. The whole defect being fixed is a fallback
|
|
190
|
+
* to the number already in hand, and an older instance that does not expose its
|
|
191
|
+
* version is exactly the case where that fallback is most tempting and most
|
|
192
|
+
* wrong. A caller that gets null must say "unknown", not substitute its own
|
|
193
|
+
* version.
|
|
194
|
+
*
|
|
195
|
+
* Deliberately short-timeout and failure-swallowing: doctor runs against
|
|
196
|
+
* possibly-down instances by design, and "cannot determine" is a legitimate,
|
|
197
|
+
* reportable answer rather than an error to propagate.
|
|
198
|
+
*/
|
|
199
|
+
export async function probeInstanceVersion(baseUrl, timeoutMs = 5000, fetchImpl = fetch) {
|
|
200
|
+
const url = `${String(baseUrl).replace(/\/+$/, "")}/Health`;
|
|
201
|
+
const ctrl = new AbortController();
|
|
202
|
+
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
203
|
+
try {
|
|
204
|
+
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
205
|
+
if (!res.ok)
|
|
206
|
+
return null;
|
|
207
|
+
const body = await res.json();
|
|
208
|
+
if (!body || typeof body !== "object")
|
|
209
|
+
return null;
|
|
210
|
+
const v = body.version;
|
|
211
|
+
// "dev" and other non-semver markers are real answers from a real server,
|
|
212
|
+
// but they cannot be compared against a published version. Treat them as
|
|
213
|
+
// undeterminable rather than feeding them to a semver comparison — a
|
|
214
|
+
// Fabric peer mid-failed-deploy reports exactly this (harper#2061).
|
|
215
|
+
if (typeof v !== "string" || !/^\d+\.\d+\.\d+/.test(v))
|
|
216
|
+
return null;
|
|
217
|
+
return v;
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
finally {
|
|
223
|
+
clearTimeout(timer);
|
|
224
|
+
}
|
|
225
|
+
}
|
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/docs/upgrade.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
This page covers the mechanics of upgrading Flair — the general path, valid across
|
|
4
4
|
versions. For **what changed in a specific release** (behavior changes, new surfaces,
|
|
5
|
-
breaking changes), see [`CHANGELOG.md`](
|
|
5
|
+
breaking changes), see [`CHANGELOG.md`](https://github.com/tpsdev-ai/flair/blob/main/CHANGELOG.md) — each version has its own
|
|
6
6
|
`## [X.Y.Z]` section. Check the CHANGELOG entries between your current version and the
|
|
7
7
|
target version before upgrading anything you depend on in production.
|
|
8
8
|
|
|
@@ -370,7 +370,7 @@ flair restore ~/flair-backup-<date>.json
|
|
|
370
370
|
|
|
371
371
|
### Known issue — upgrading *from* an older version can still report a false rollback
|
|
372
372
|
|
|
373
|
-
The 0.25.1 fix (see [`CHANGELOG.md`](
|
|
373
|
+
The 0.25.1 fix (see [`CHANGELOG.md`](https://github.com/tpsdev-ai/flair/blob/main/CHANGELOG.md)) makes `flair upgrade` resolve a
|
|
374
374
|
credentials-only post-restart-verification failure to `healthy-unverified` instead of
|
|
375
375
|
rolling back. That fix is **forward-only**: it lives in the *new* CLI code, but an
|
|
376
376
|
upgrade's post-restart verification is run by the CLI that was already installed
|
|
@@ -526,7 +526,7 @@ you haven't personally tested.
|
|
|
526
526
|
|
|
527
527
|
## See also
|
|
528
528
|
|
|
529
|
-
- [`CHANGELOG.md`](
|
|
529
|
+
- [`CHANGELOG.md`](https://github.com/tpsdev-ai/flair/blob/main/CHANGELOG.md) — what actually changed, version by version.
|
|
530
530
|
- [`docs/releasing.md`](releasing.md) — how a release gets published in the first
|
|
531
531
|
place (staged npm publish with 2FA approval), if you're curious why a new version
|
|
532
532
|
shows up when it does.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.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",
|
|
@@ -52,8 +52,7 @@
|
|
|
52
52
|
"prepublishOnly": "npm run build && npm run build:cli",
|
|
53
53
|
"test": "bun test",
|
|
54
54
|
"test:e2e": "playwright test",
|
|
55
|
-
"release": "./scripts/release.sh"
|
|
56
|
-
"postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');for(const p of ['dist/cli-shim.cjs','dist/cli.js']){try{if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}}}catch(e){console.error('postinstall warn:',e.message)}\""
|
|
55
|
+
"release": "./scripts/release.sh"
|
|
57
56
|
},
|
|
58
57
|
"publishConfig": {
|
|
59
58
|
"access": "public"
|
|
@@ -73,7 +72,11 @@
|
|
|
73
72
|
"tweetnacl": "1.0.3"
|
|
74
73
|
},
|
|
75
74
|
"overrides": {
|
|
76
|
-
"react-native-fs": "npm:empty-npm-package@1.0.0"
|
|
75
|
+
"react-native-fs": "npm:empty-npm-package@1.0.0",
|
|
76
|
+
"brace-expansion": "^5.0.9",
|
|
77
|
+
"undici": "^8.9.0",
|
|
78
|
+
"fast-uri": "^4.1.2",
|
|
79
|
+
"hono": "^4.12.34"
|
|
77
80
|
},
|
|
78
81
|
"devDependencies": {
|
|
79
82
|
"@playwright/test": "1.59.1",
|