@tpsdev-ai/flair 0.30.0 → 0.31.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +194 -377
- package/dist/cli.js +1434 -288
- package/dist/deploy.js +212 -24
- package/dist/fabric-upgrade.js +16 -1
- package/dist/federation/scheduler.js +500 -0
- package/dist/install/clients.js +111 -53
- package/dist/lib/mcp-spec.js +128 -0
- package/dist/lib/safe-snapshot-extract.js +231 -0
- package/dist/lib/scheduler-platform.js +128 -0
- package/dist/lib/xml-escape.js +54 -0
- package/dist/rem/scheduler.js +35 -87
- package/dist/rem/snapshot.js +13 -0
- package/dist/replication-convergence.js +505 -0
- package/dist/resources/AdminPrincipals.js +10 -1
- package/dist/resources/Agent.js +105 -11
- package/dist/resources/AgentSeed.js +10 -2
- package/dist/resources/MemoryBootstrap.js +7 -8
- package/dist/resources/MemoryUsage.js +18 -0
- package/dist/resources/Presence.js +8 -1
- package/dist/resources/SemanticSearch.js +17 -45
- package/dist/resources/abstention.js +1 -1
- package/dist/resources/agent-admin.js +149 -0
- package/dist/resources/agent-auth.js +98 -5
- package/dist/resources/auth-middleware.js +92 -19
- package/dist/resources/embeddings-boot.js +10 -12
- package/dist/resources/embeddings-provider.js +10 -7
- package/dist/resources/health.js +24 -19
- package/dist/resources/in-process.js +234 -0
- package/dist/resources/mcp-handler.js +14 -4
- package/dist/resources/mcp-tools.js +23 -17
- package/dist/resources/migration-boot.js +80 -10
- package/dist/resources/migrations/data-dir.js +205 -0
- package/dist/resources/migrations/progress.js +33 -0
- package/dist/resources/migrations/runner.js +29 -2
- package/dist/resources/migrations/state.js +13 -2
- package/dist/resources/models-dir.js +18 -9
- package/dist/resources/presence-internal.js +6 -1
- package/dist/resources/record-owner-guard.js +149 -0
- package/dist/resources/semantic-retrieval-core.js +5 -4
- package/dist/src/lib/scheduler-platform.js +128 -0
- package/dist/src/lib/xml-escape.js +54 -0
- package/dist/src/rem/scheduler.js +35 -87
- package/docs/deploying-on-fabric.md +267 -0
- package/docs/deployment.md +5 -0
- package/docs/embedding-in-a-harper-app.md +303 -0
- package/docs/federation.md +61 -4
- package/docs/integrations.md +3 -0
- package/docs/mcp-clients.md +16 -7
- package/docs/quickstart.md +80 -54
- package/docs/releasing.md +72 -38
- package/docs/supply-chain-policy.md +36 -0
- package/docs/troubleshooting.md +24 -0
- package/docs/upgrade.md +99 -4
- package/package.json +1 -11
- package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
- package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
- package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
- package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
- package/dist/resources/rerank-provider.js +0 -569
- package/docs/rerank-provisioning.md +0 -101
package/dist/install/clients.js
CHANGED
|
@@ -14,10 +14,10 @@
|
|
|
14
14
|
// All paths are resolved cross-platform (Linux included) via standard
|
|
15
15
|
// per-client locations under $HOME / $XDG_CONFIG_HOME.
|
|
16
16
|
// ---- Detection helpers ----------------------------------------------------------
|
|
17
|
-
import { spawnSync } from "node:child_process";
|
|
18
17
|
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
18
|
import { homedir } from "node:os";
|
|
20
19
|
import { dirname, join } from "node:path";
|
|
20
|
+
import { mcpServerSpec } from "../lib/mcp-spec.js";
|
|
21
21
|
/**
|
|
22
22
|
* Resolve the user's home dir. Prefer the live HOME/USERPROFILE env over
|
|
23
23
|
* os.homedir(), which caches the value at process start and so ignores a
|
|
@@ -59,57 +59,61 @@ function binInPath(name) {
|
|
|
59
59
|
return false;
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
function cursorDetect() {
|
|
62
|
+
/**
|
|
63
|
+
* A client is INSTALLED when its executable is on PATH. One rule for all four,
|
|
64
|
+
* evaluated with filesystem calls only — detection never starts a subprocess.
|
|
65
|
+
*
|
|
66
|
+
* Claude Code, Codex and Gemini used to fall back to `npm list -g <pkg>` when
|
|
67
|
+
* the binary was absent. That fallback was removed (flair#906 follow-up); it
|
|
68
|
+
* was wrong in three separate directions and bought nothing:
|
|
69
|
+
*
|
|
70
|
+
* 1. UNBOUNDED AND SLOW ON AN INTERACTIVE PATH. `npm list -g <pkg>` walks the
|
|
71
|
+
* whole global tree, measured at ~0.8 s per call on a warm developer
|
|
72
|
+
* machine, with no `timeout` set. `flair init` calls detectClients(), so a
|
|
73
|
+
* user with none of these clients paid up to three of those walks — seconds
|
|
74
|
+
* of silent stall on first run — and on a loaded CI runner the same three
|
|
75
|
+
* calls blew past a 5 s test timeout.
|
|
76
|
+
*
|
|
77
|
+
* 2. FALSE POSITIVES. `npm list -g <pkg>` exits 0 when the package appears
|
|
78
|
+
* ANYWHERE in the global tree, including as a transitive dependency of an
|
|
79
|
+
* unrelated global package. Gemini was probed with `@google/generative-ai`
|
|
80
|
+
* — a library, not the CLI — so any globally installed tool that depended
|
|
81
|
+
* on it made Flair report Gemini "detected" and write ~/.gemini/settings.json
|
|
82
|
+
* for a CLI that was not on the machine.
|
|
83
|
+
*
|
|
84
|
+
* 3. FALSE NEGATIVES. It assumes npm's default global prefix, so it reports
|
|
85
|
+
* "not installed" for mise / fnm / nvm / volta users whose prefix lives
|
|
86
|
+
* elsewhere — the same defect already fixed for `flair upgrade`'s presence
|
|
87
|
+
* probes (see "Upgrade presence probes" in src/cli.ts).
|
|
88
|
+
*
|
|
89
|
+
* Nothing is lost by dropping it: an `npm install -g` links the package's bin
|
|
90
|
+
* into the prefix's bin directory, which is on PATH by construction (it is where
|
|
91
|
+
* `npm` itself is found from). A client whose binary is NOT on PATH is a client
|
|
92
|
+
* the user cannot launch, and wiring an MCP config for it is at best a no-op.
|
|
93
|
+
* `flair init --client <name>` still wires a client explicitly, bypassing
|
|
94
|
+
* detection entirely, so an exotic install is never locked out.
|
|
95
|
+
*/
|
|
96
|
+
function detectBin(bin) {
|
|
100
97
|
try {
|
|
101
|
-
return binInPath(
|
|
98
|
+
return binInPath(bin);
|
|
102
99
|
}
|
|
103
100
|
catch (_e) {
|
|
104
101
|
return false;
|
|
105
102
|
}
|
|
106
103
|
}
|
|
107
104
|
// ---- Shared config shapes -------------------------------------------------------
|
|
108
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* The standard MCP stdio server entry every client (except Codex TOML) uses.
|
|
107
|
+
*
|
|
108
|
+
* The spec comes from mcpServerSpec() and is therefore PINNED — this used to
|
|
109
|
+
* hardcode the bare `@tpsdev-ai/flair-mcp`, so Gemini, Cursor and the Claude
|
|
110
|
+
* Code array fallback were all wired unpinned while only the inline Claude
|
|
111
|
+
* Code path in cli.ts got the pin it was documented to get (flair#907).
|
|
112
|
+
*/
|
|
109
113
|
function flairMcpEntry(env) {
|
|
110
114
|
return {
|
|
111
115
|
command: "npx",
|
|
112
|
-
args: ["-y",
|
|
116
|
+
args: ["-y", mcpServerSpec()],
|
|
113
117
|
env: {
|
|
114
118
|
FLAIR_AGENT_ID: env.FLAIR_AGENT_ID,
|
|
115
119
|
FLAIR_URL: env.FLAIR_URL,
|
|
@@ -124,12 +128,13 @@ function jsonSnippet(env) {
|
|
|
124
128
|
return JSON.stringify({ mcpServers: { flair: flairMcpEntry(env) } }, null, 2);
|
|
125
129
|
}
|
|
126
130
|
/** TOML `[mcp_servers.flair]` snippet (Codex format). Exported for tests
|
|
127
|
-
* (flair#727 — asserts the rendered template carries a full scheme+port URL).
|
|
131
|
+
* (flair#727 — asserts the rendered template carries a full scheme+port URL).
|
|
132
|
+
* Pinned via mcpServerSpec() — see flairMcpEntry above for why (flair#907). */
|
|
128
133
|
export function tomlSnippet(env) {
|
|
129
134
|
return [
|
|
130
135
|
`[mcp_servers.flair]`,
|
|
131
136
|
`command = "npx"`,
|
|
132
|
-
`args = ["-y", "
|
|
137
|
+
`args = ["-y", "${mcpServerSpec()}"]`,
|
|
133
138
|
``,
|
|
134
139
|
`[mcp_servers.flair.env]`,
|
|
135
140
|
`FLAIR_AGENT_ID = "${env.FLAIR_AGENT_ID}"`,
|
|
@@ -283,36 +288,89 @@ export const ALL_CLIENTS = [
|
|
|
283
288
|
{
|
|
284
289
|
id: "claude-code",
|
|
285
290
|
label: "Claude Code",
|
|
291
|
+
bin: "claude",
|
|
286
292
|
wire: _wireClaudeCode,
|
|
287
293
|
},
|
|
288
294
|
{
|
|
289
295
|
id: "codex",
|
|
290
296
|
label: "Codex",
|
|
297
|
+
bin: "codex",
|
|
291
298
|
wire: _wireCodex,
|
|
292
299
|
},
|
|
293
300
|
{
|
|
294
301
|
id: "gemini",
|
|
295
302
|
label: "Gemini",
|
|
303
|
+
bin: "gemini",
|
|
296
304
|
wire: _wireGemini,
|
|
297
305
|
},
|
|
298
306
|
{
|
|
299
307
|
id: "cursor",
|
|
300
308
|
label: "Cursor",
|
|
309
|
+
bin: "cursor",
|
|
301
310
|
wire: _wireCursor,
|
|
302
311
|
},
|
|
303
312
|
];
|
|
313
|
+
/**
|
|
314
|
+
* The summary `flair init` prints LAST.
|
|
315
|
+
*
|
|
316
|
+
* Every fact here was already available mid-run — `wiringResults` has always
|
|
317
|
+
* distinguished "wired ~/.claude.json" from "snippet printed (no
|
|
318
|
+
* ~/.claude.json)". What was missing is a place where the difference is still
|
|
319
|
+
* on screen once the command finishes: a not-wired client showed up only as a
|
|
320
|
+
* snippet in the middle of a wall of output, *after* a success line, so
|
|
321
|
+
* `--client all` read as having done all of it (flair#906). Pure so it can be
|
|
322
|
+
* tested without running init.
|
|
323
|
+
*/
|
|
324
|
+
export function renderWiringSummary(results, opts = {}) {
|
|
325
|
+
const label = (id) => opts.labels?.get(id) ?? id;
|
|
326
|
+
const skipped = opts.skippedUndetected ?? [];
|
|
327
|
+
const wired = results.filter(r => r.wired);
|
|
328
|
+
const notWired = results.filter(r => !r.wired);
|
|
329
|
+
if (results.length === 0 && skipped.length === 0)
|
|
330
|
+
return [];
|
|
331
|
+
const lines = [{ level: "heading", text: "MCP clients" }];
|
|
332
|
+
if (wired.length > 0) {
|
|
333
|
+
lines.push({ level: "ok", text: `Wired: ${wired.map(r => label(r.client)).join(", ")}` });
|
|
334
|
+
}
|
|
335
|
+
// One line per NOT-wired client, naming the client and why — a count alone
|
|
336
|
+
// ("1 client failed") sends the user back to scrollback to find which.
|
|
337
|
+
for (const r of notWired) {
|
|
338
|
+
lines.push({ level: "error", text: `NOT wired: ${label(r.client)} — ${r.message}` });
|
|
339
|
+
}
|
|
340
|
+
if (skipped.length > 0) {
|
|
341
|
+
lines.push({ level: "muted", text: `Not installed, skipped: ${skipped.join(", ")}` });
|
|
342
|
+
}
|
|
343
|
+
if (wired.length === 0 && notWired.length === 0) {
|
|
344
|
+
lines.push({
|
|
345
|
+
level: "warn",
|
|
346
|
+
text: opts.rewireHint
|
|
347
|
+
? `No MCP client was wired. Install a client, then re-run: ${opts.rewireHint}`
|
|
348
|
+
: "No MCP client was wired.",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
if (notWired.length > 0) {
|
|
352
|
+
lines.push({
|
|
353
|
+
level: "warn",
|
|
354
|
+
text: `${notWired.length} client(s) need manual wiring — see the config printed above, or docs/mcp-clients.md`,
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
if (opts.unpinned) {
|
|
358
|
+
lines.push({
|
|
359
|
+
level: "warn",
|
|
360
|
+
text: "MCP server wired UNPINNED (Flair could not read its own version) — see the warning above.",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
return lines;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Detect every known client. One rule (`bin` on PATH) applied uniformly, so a
|
|
367
|
+
* client added to ALL_CLIENTS is detected by declaring its executable — there is
|
|
368
|
+
* no per-client branch here to forget to extend.
|
|
369
|
+
*/
|
|
304
370
|
export function detectClients() {
|
|
305
371
|
return ALL_CLIENTS.map((client) => ({
|
|
306
372
|
...client,
|
|
307
|
-
detected: client.
|
|
308
|
-
? claudeCodeDetect()
|
|
309
|
-
: client.id === "codex"
|
|
310
|
-
? codexDetect()
|
|
311
|
-
: client.id === "gemini"
|
|
312
|
-
? geminiDetect()
|
|
313
|
-
: client.id === "cursor"
|
|
314
|
-
? cursorDetect()
|
|
315
|
-
: false,
|
|
373
|
+
detected: detectBin(client.bin),
|
|
316
374
|
}));
|
|
317
375
|
}
|
|
318
376
|
export function wireClaudeCode(env) {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// ─── The `@tpsdev-ai/flair-mcp` spec written into client MCP configs ────────
|
|
2
|
+
//
|
|
3
|
+
// ONE definition of "which flair-mcp does a wired client run", shared by every
|
|
4
|
+
// writer. It used to live in src/cli.ts and be applied at exactly ONE of the
|
|
5
|
+
// five places that write a spec — src/install/clients.ts hardcoded the bare
|
|
6
|
+
// unpinned string in `flairMcpEntry()` (Claude Code fallback / Gemini / Cursor)
|
|
7
|
+
// and `tomlSnippet()` (Codex), so every client except the inline Claude Code
|
|
8
|
+
// branch was wired unpinned no matter what the CLI's own version resolved to
|
|
9
|
+
// (flair#907). A pin that lives next to one call site is a pin the next call
|
|
10
|
+
// site forgets; it lives here now so a writer has to go out of its way to be
|
|
11
|
+
// unpinned.
|
|
12
|
+
import { readFileSync } from "node:fs";
|
|
13
|
+
import { dirname, join } from "node:path";
|
|
14
|
+
/** The npm package a wired client runs via `npx`. */
|
|
15
|
+
export const FLAIR_MCP_PACKAGE = "@tpsdev-ai/flair-mcp";
|
|
16
|
+
/** The CLI package whose version is the correct pin (lockstep with flair-mcp). */
|
|
17
|
+
const FLAIR_PACKAGE = "@tpsdev-ai/flair";
|
|
18
|
+
/** What every version-resolution path reports when it cannot read package.json. */
|
|
19
|
+
export const UNKNOWN_VERSION = "unknown";
|
|
20
|
+
/**
|
|
21
|
+
* Walk up from `startDir` looking for `@tpsdev-ai/flair`'s own package.json.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately a NAMED search rather than a fixed number of `..` hops: this
|
|
24
|
+
* module is compiled to `dist/lib/mcp-spec.js` while its previous home
|
|
25
|
+
* (src/cli.ts) compiled to `dist/cli.js`, so a hardcoded depth is one
|
|
26
|
+
* refactor away from silently resolving "unknown" — and "unknown" is not a
|
|
27
|
+
* loud failure, it is a downgraded security property (see mcpServerSpec).
|
|
28
|
+
* Checking `name` also means a stray package.json in an intermediate
|
|
29
|
+
* directory can't be mistaken for ours.
|
|
30
|
+
*
|
|
31
|
+
* Exported for tests, which need to exercise the not-found path without
|
|
32
|
+
* corrupting a real install.
|
|
33
|
+
*/
|
|
34
|
+
export function resolveFlairCliVersion(startDir) {
|
|
35
|
+
let dir = startDir;
|
|
36
|
+
// 8 levels is far more than any real layout needs (dist/lib → package root
|
|
37
|
+
// is 2) while still terminating on a pathological symlink loop.
|
|
38
|
+
for (let i = 0; i < 8; i++) {
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(join(dir, "package.json"), "utf-8"));
|
|
41
|
+
if (pkg?.name === FLAIR_PACKAGE && typeof pkg.version === "string" && pkg.version) {
|
|
42
|
+
return pkg.version;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// No package.json here, or unreadable/malformed — keep walking.
|
|
47
|
+
}
|
|
48
|
+
const parent = dirname(dir);
|
|
49
|
+
if (parent === dir)
|
|
50
|
+
break; // filesystem root
|
|
51
|
+
dir = parent;
|
|
52
|
+
}
|
|
53
|
+
return UNKNOWN_VERSION;
|
|
54
|
+
}
|
|
55
|
+
let cachedVersion;
|
|
56
|
+
/**
|
|
57
|
+
* This CLI's own version — the single source for `--version`, the CLI↔server
|
|
58
|
+
* handshake, `upgrade --check`, and the MCP pin. Cached: the answer cannot
|
|
59
|
+
* change within a process, and several commands ask repeatedly.
|
|
60
|
+
*/
|
|
61
|
+
export function flairCliVersion() {
|
|
62
|
+
if (cachedVersion === undefined) {
|
|
63
|
+
cachedVersion = resolveFlairCliVersion(import.meta.dirname ?? __dirname);
|
|
64
|
+
}
|
|
65
|
+
return cachedVersion;
|
|
66
|
+
}
|
|
67
|
+
/** True when `version` is usable as a pin. */
|
|
68
|
+
export function isResolvedVersion(version) {
|
|
69
|
+
return !!version && version !== UNKNOWN_VERSION;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The `@tpsdev-ai/flair-mcp` spec written into a client's MCP config.
|
|
73
|
+
*
|
|
74
|
+
* PINNED, deliberately. A bare `npx -y @tpsdev-ai/flair-mcp` re-resolves to
|
|
75
|
+
* whatever is currently published on EVERY agent session — so a single bad
|
|
76
|
+
* publish (stolen credentials, a malicious commit that clears review, or a
|
|
77
|
+
* compromised dependency of the MCP package) reaches every wired user
|
|
78
|
+
* silently, with no lockfile and no review step in the path. The postmark-mcp
|
|
79
|
+
* incident was exactly this shape: a legitimate publish by the legitimate
|
|
80
|
+
* owner, propagating for 16 days before anyone noticed. Worse, a yank does
|
|
81
|
+
* not help — unpinned clients keep resolving latest.
|
|
82
|
+
*
|
|
83
|
+
* Our publish side is already hardened (OIDC staged publish, human 2FA at the
|
|
84
|
+
* release gate), but that defends against credential theft, not against a bad
|
|
85
|
+
* version being published legitimately. The consumer side is where that gap
|
|
86
|
+
* closes, and pinning is what closes it: a wired client keeps running the
|
|
87
|
+
* exact version that was current when it was wired, and moving forward
|
|
88
|
+
* becomes a deliberate act.
|
|
89
|
+
*
|
|
90
|
+
* flair and flair-mcp ship in version lockstep from this monorepo, so the
|
|
91
|
+
* running CLI's own version is the correct pin. `flair init` re-run rewires
|
|
92
|
+
* to the then-current version.
|
|
93
|
+
*
|
|
94
|
+
* Falls back to the unpinned spec when the version can't be read — the same
|
|
95
|
+
* condition under which `--version` reports "unknown", i.e. a broken install.
|
|
96
|
+
* That fallback is NOT silent: callers that write a config must surface
|
|
97
|
+
* unpinnedSpecWarning() (flair#907). Quietly substituting a weaker guarantee
|
|
98
|
+
* for a documented one is how a user ends up believing they are pinned.
|
|
99
|
+
*/
|
|
100
|
+
export function mcpServerSpec(version = flairCliVersion()) {
|
|
101
|
+
return isResolvedVersion(version)
|
|
102
|
+
? `${FLAIR_MCP_PACKAGE}@${version}`
|
|
103
|
+
: FLAIR_MCP_PACKAGE;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The warning a config-writing command MUST print when the spec it is about
|
|
107
|
+
* to write cannot be pinned. Returns null in the normal case, so a caller is
|
|
108
|
+
* `const w = unpinnedSpecWarning(); if (w) ...`.
|
|
109
|
+
*
|
|
110
|
+
* Warn rather than refuse: an unresolvable version means a damaged install,
|
|
111
|
+
* and hard-failing `init` there turns "MCP works, but unpinned" into "the
|
|
112
|
+
* user has nothing" — while leaving them no better able to diagnose it. The
|
|
113
|
+
* wiring still has value; the false belief in a pin is what has to go. So the
|
|
114
|
+
* consequence is named explicitly, and the caller is expected to repeat this
|
|
115
|
+
* where the user will still see it once the command finishes.
|
|
116
|
+
*/
|
|
117
|
+
export function unpinnedSpecWarning(version = flairCliVersion()) {
|
|
118
|
+
if (isResolvedVersion(version))
|
|
119
|
+
return null;
|
|
120
|
+
return [
|
|
121
|
+
`Could not read Flair's own version, so ${FLAIR_MCP_PACKAGE} is being wired UNPINNED.`,
|
|
122
|
+
`Consequence: every agent session re-resolves it to the latest published version,`,
|
|
123
|
+
`so a future publish reaches this machine with no review step. Normally the spec is`,
|
|
124
|
+
`pinned to the CLI version that wired it.`,
|
|
125
|
+
`Fix: reinstall the CLI (npm i -g ${FLAIR_PACKAGE}) and re-run this command, or edit`,
|
|
126
|
+
`the client config by hand to read "${FLAIR_MCP_PACKAGE}@<version>".`,
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// safe-snapshot-extract.ts — path-escape-safe extraction for physical
|
|
2
|
+
// snapshot tarballs.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// ---------------
|
|
6
|
+
// `createDataSnapshot` archives with `preservePaths: true`, and it needs to:
|
|
7
|
+
// WITHOUT it, node-tar strips the leading "/" off any absolute symlink
|
|
8
|
+
// TARGET, so an in-bounds symlink pointing at an absolute path under the
|
|
9
|
+
// data dir comes back as a nonsense RELATIVE path — silently broken.
|
|
10
|
+
//
|
|
11
|
+
// The restore side used to mirror that flag, reasoning that the archive
|
|
12
|
+
// "only contains what createDataSnapshot's filter chose to include". That
|
|
13
|
+
// reasoning does not hold: restore accepts a tarball the CLI did not create
|
|
14
|
+
// — copied from another machine, downloaded, or handed over during a
|
|
15
|
+
// migration. `preservePaths: true` disables BOTH of node-tar's containment
|
|
16
|
+
// behaviours (it stops stripping a leading "/" from entry PATHS, and stops
|
|
17
|
+
// rejecting ".." segments), so a hostile archive could write anywhere the
|
|
18
|
+
// invoking user can write.
|
|
19
|
+
//
|
|
20
|
+
// The flag is load-bearing for symlink TARGET text and useless-to-harmful
|
|
21
|
+
// for entry PATHS, and node-tar exposes only the one knob for both. So we
|
|
22
|
+
// keep `preservePaths: true` and do the containment check ourselves —
|
|
23
|
+
// strictly, and BEFORE anything is written to disk.
|
|
24
|
+
//
|
|
25
|
+
// Posture is FAIL CLOSED: a snapshot with even one out-of-bounds entry is
|
|
26
|
+
// refused whole, rather than extracted-minus-the-bad-parts. A snapshot that
|
|
27
|
+
// wants to escape is not a snapshot we can vouch for the rest of.
|
|
28
|
+
import { existsSync, realpathSync } from "node:fs";
|
|
29
|
+
import { dirname, isAbsolute, resolve, sep } from "node:path";
|
|
30
|
+
import { extract as tarExtract, list as tarList } from "tar";
|
|
31
|
+
/** Thrown when a snapshot entry would resolve outside the target directory. */
|
|
32
|
+
export class SnapshotPathEscapeError extends Error {
|
|
33
|
+
entryPath;
|
|
34
|
+
reason;
|
|
35
|
+
constructor(message, entryPath, reason) {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = "SnapshotPathEscapeError";
|
|
38
|
+
this.entryPath = entryPath;
|
|
39
|
+
this.reason = reason;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* True when `candidate` is the directory itself or lives underneath it.
|
|
44
|
+
* Compares resolved absolute paths; the `+ sep` guard stops "/a/bc" from
|
|
45
|
+
* counting as inside "/a/b".
|
|
46
|
+
*/
|
|
47
|
+
function isWithin(candidate, root) {
|
|
48
|
+
return candidate === root || candidate.startsWith(root + sep);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Absolute in the sense that matters for tar containment. `isAbsolute` alone
|
|
52
|
+
* is platform-dependent, and an archive is attacker-controlled data that need
|
|
53
|
+
* not match the host it is being extracted on — so a Windows-shaped drive or
|
|
54
|
+
* UNC path is treated as absolute even on POSIX.
|
|
55
|
+
*/
|
|
56
|
+
function looksAbsolute(p) {
|
|
57
|
+
return isAbsolute(p) || /^[a-zA-Z]:[\\/]/.test(p) || p.startsWith("/") || p.startsWith("\\\\");
|
|
58
|
+
}
|
|
59
|
+
/** A ".." component anywhere in the path, under either separator. */
|
|
60
|
+
function hasDotDotSegment(p) {
|
|
61
|
+
return p.split(/[\\/]+/).includes("..");
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Lexical containment check for a single archive entry. Pure — no filesystem
|
|
65
|
+
* access — so it can run over the whole archive listing before a single byte
|
|
66
|
+
* is written.
|
|
67
|
+
*
|
|
68
|
+
* `linkpath` is the entry's link target, when it has one.
|
|
69
|
+
*/
|
|
70
|
+
export function checkSnapshotEntry(entryPath, type, linkpath, resolvedTargetDir) {
|
|
71
|
+
if (looksAbsolute(entryPath)) {
|
|
72
|
+
return { ok: false, reason: `entry has an absolute path ("${entryPath}"), which would write outside the target directory` };
|
|
73
|
+
}
|
|
74
|
+
if (hasDotDotSegment(entryPath)) {
|
|
75
|
+
return { ok: false, reason: `entry path contains a ".." segment ("${entryPath}"), which would traverse outside the target directory` };
|
|
76
|
+
}
|
|
77
|
+
const dest = resolve(resolvedTargetDir, entryPath);
|
|
78
|
+
if (!isWithin(dest, resolvedTargetDir)) {
|
|
79
|
+
return { ok: false, reason: `entry resolves to "${dest}", outside the target directory` };
|
|
80
|
+
}
|
|
81
|
+
if (linkpath) {
|
|
82
|
+
if (type === "Link") {
|
|
83
|
+
// Hardlink: node-tar resolves linkpath relative to cwd, and a hardlink
|
|
84
|
+
// to a file outside the target would expose that file's contents
|
|
85
|
+
// through the restored tree.
|
|
86
|
+
if (looksAbsolute(linkpath) || hasDotDotSegment(linkpath)) {
|
|
87
|
+
return { ok: false, reason: `hard link "${entryPath}" points to "${linkpath}", outside the target directory` };
|
|
88
|
+
}
|
|
89
|
+
const linkDest = resolve(resolvedTargetDir, linkpath);
|
|
90
|
+
if (!isWithin(linkDest, resolvedTargetDir)) {
|
|
91
|
+
return { ok: false, reason: `hard link "${entryPath}" resolves to "${linkDest}", outside the target directory` };
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (type === "SymbolicLink") {
|
|
95
|
+
// Symlink targets are written verbatim (that is what preservePaths
|
|
96
|
+
// buys us), so an absolute target is legitimate — but only if it lands
|
|
97
|
+
// inside the target directory. This mirrors createDataSnapshot's own
|
|
98
|
+
// filter, which already refuses to archive a symlink pointing outside
|
|
99
|
+
// the data dir; such an entry cannot have come from a snapshot this
|
|
100
|
+
// CLI produced for this target directory.
|
|
101
|
+
const linkDest = looksAbsolute(linkpath)
|
|
102
|
+
? resolve(linkpath)
|
|
103
|
+
: resolve(dirname(resolve(resolvedTargetDir, entryPath)), linkpath);
|
|
104
|
+
if (!isWithin(linkDest, resolvedTargetDir)) {
|
|
105
|
+
return { ok: false, reason: `symlink "${entryPath}" points to "${linkDest}", outside the target directory` };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Resolve a path's existing ancestor chain through symlinks. Used to catch an
|
|
113
|
+
* entry that would be written THROUGH a symlink an earlier entry in the same
|
|
114
|
+
* archive created — the lexical check cannot see that, because the path has
|
|
115
|
+
* no ".." and is not absolute.
|
|
116
|
+
*/
|
|
117
|
+
function realpathOfNearestExistingAncestor(p) {
|
|
118
|
+
let cur = p;
|
|
119
|
+
// Walk up until something exists; the freshly-created target dir always does.
|
|
120
|
+
for (let i = 0; i < 256; i++) {
|
|
121
|
+
if (existsSync(cur))
|
|
122
|
+
return realpathSync(cur);
|
|
123
|
+
const parent = dirname(cur);
|
|
124
|
+
if (parent === cur)
|
|
125
|
+
return cur;
|
|
126
|
+
cur = parent;
|
|
127
|
+
}
|
|
128
|
+
return cur;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the target directory for containment comparisons. realpath when it
|
|
132
|
+
* already exists — on macOS the temp dir itself sits behind a symlink
|
|
133
|
+
* (/tmp -> /private/tmp), so a purely lexical resolve would misclassify
|
|
134
|
+
* genuinely in-bounds paths — falling back to a lexical resolve when it does
|
|
135
|
+
* not (validation runs before the directory is recreated).
|
|
136
|
+
*/
|
|
137
|
+
function resolveTargetDir(p) {
|
|
138
|
+
const lexical = resolve(p);
|
|
139
|
+
try {
|
|
140
|
+
return realpathSync(lexical);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return lexical;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Read the archive's listing and throw if any entry would land outside
|
|
148
|
+
* `targetDir`. Writes NOTHING — call this before any destructive step so a
|
|
149
|
+
* hostile snapshot cannot cost the operator their data directory on the way
|
|
150
|
+
* to being refused.
|
|
151
|
+
*/
|
|
152
|
+
export async function validateSnapshotArchive(opts) {
|
|
153
|
+
const resolvedTargetDir = resolveTargetDir(opts.targetDir);
|
|
154
|
+
const violations = [];
|
|
155
|
+
await tarList({
|
|
156
|
+
file: opts.file,
|
|
157
|
+
onReadEntry: (entry) => {
|
|
158
|
+
const verdict = checkSnapshotEntry(String(entry.path ?? ""), entry.type, entry.linkpath ? String(entry.linkpath) : undefined, resolvedTargetDir);
|
|
159
|
+
// `verdict.ok === false` rather than `!verdict.ok`: an explicit
|
|
160
|
+
// comparison against the literal discriminant is what narrows the union
|
|
161
|
+
// to its failure member, so `reason` is known to exist here. The union
|
|
162
|
+
// is deliberately shaped so a caller cannot read a reason off a success.
|
|
163
|
+
if (verdict.ok === false) {
|
|
164
|
+
violations.push({ entryPath: String(entry.path ?? ""), reason: verdict.reason });
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
if (violations.length > 0) {
|
|
169
|
+
const shown = violations.slice(0, 5).map((v) => ` - ${v.reason}`).join("\n");
|
|
170
|
+
const more = violations.length > 5 ? `\n ...and ${violations.length - 5} more` : "";
|
|
171
|
+
throw new SnapshotPathEscapeError(`refusing to restore: this snapshot contains ${violations.length} ` +
|
|
172
|
+
`entr${violations.length === 1 ? "y that would write" : "ies that would write"} outside ${resolvedTargetDir}.\n` +
|
|
173
|
+
`${shown}${more}\n` +
|
|
174
|
+
`Nothing was extracted and the target directory was left as-is.\n` +
|
|
175
|
+
`A snapshot taken by "flair snapshot create" never contains such entries — this archive was ` +
|
|
176
|
+
`either produced by something else or modified after it was taken. Restore from a snapshot you ` +
|
|
177
|
+
`trust, or inspect this one with "tar -tzvf <snapshot>" before using it.`, violations[0].entryPath, violations[0].reason);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Extract a snapshot tarball into `targetDir`, refusing any archive that
|
|
182
|
+
* would write outside it.
|
|
183
|
+
*
|
|
184
|
+
* Two passes on purpose:
|
|
185
|
+
* 1. `validateSnapshotArchive` — no writes at all, so a hostile archive is
|
|
186
|
+
* refused before it can leave a partial mess behind.
|
|
187
|
+
* 2. Extract, with a filesystem-aware guard that re-checks each entry
|
|
188
|
+
* against symlinks created earlier in the same archive — an escape the
|
|
189
|
+
* lexical pass cannot see, because such a path is neither absolute nor
|
|
190
|
+
* contains "..".
|
|
191
|
+
*
|
|
192
|
+
* `preservePaths: true` is retained for symlink-target fidelity; see this
|
|
193
|
+
* file's header.
|
|
194
|
+
*/
|
|
195
|
+
export async function extractSnapshotSafely(opts) {
|
|
196
|
+
await validateSnapshotArchive(opts);
|
|
197
|
+
const resolvedTargetDir = resolveTargetDir(opts.targetDir);
|
|
198
|
+
// ── Pass 2: extract, guarding against write-through-symlink ─────────────
|
|
199
|
+
const runtimeViolations = [];
|
|
200
|
+
await tarExtract({
|
|
201
|
+
file: opts.file,
|
|
202
|
+
cwd: resolvedTargetDir,
|
|
203
|
+
// Load-bearing: keeps absolute symlink TARGET text verbatim. Entry-path
|
|
204
|
+
// containment is our job, done above and re-checked here.
|
|
205
|
+
preservePaths: true,
|
|
206
|
+
filter: (entryPath) => {
|
|
207
|
+
const dest = resolve(resolvedTargetDir, entryPath);
|
|
208
|
+
// Resolve `dest` itself, NOT dirname(dest): a real snapshot's first
|
|
209
|
+
// entry is "./" (createDataSnapshot archives the fileList ["."]), whose
|
|
210
|
+
// dest IS the target directory — taking its parent would resolve to the
|
|
211
|
+
// directory ABOVE the target and reject every legitimate snapshot.
|
|
212
|
+
// Walking up from dest gives the target dir itself for that entry, and
|
|
213
|
+
// still catches a symlinked ancestor for everything deeper.
|
|
214
|
+
const anchor = realpathOfNearestExistingAncestor(dest);
|
|
215
|
+
if (!isWithin(anchor, resolvedTargetDir)) {
|
|
216
|
+
runtimeViolations.push({
|
|
217
|
+
entryPath,
|
|
218
|
+
reason: `entry "${entryPath}" would be written through a symlink to "${anchor}", outside the target directory`,
|
|
219
|
+
});
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return true;
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
if (runtimeViolations.length > 0) {
|
|
226
|
+
const v = runtimeViolations[0];
|
|
227
|
+
throw new SnapshotPathEscapeError(`refusing to complete this restore: ${v.reason}.\n` +
|
|
228
|
+
`The target directory ${resolvedTargetDir} is now partially written and must not be used — ` +
|
|
229
|
+
`delete it and restore from a snapshot you trust.`, v.entryPath, v.reason);
|
|
230
|
+
}
|
|
231
|
+
}
|