@tpsdev-ai/flair 0.30.0 → 0.31.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.
Files changed (49) hide show
  1. package/README.md +194 -377
  2. package/dist/cli.js +1355 -281
  3. package/dist/deploy.js +212 -24
  4. package/dist/fabric-upgrade.js +16 -1
  5. package/dist/federation/scheduler.js +500 -0
  6. package/dist/install/clients.js +111 -53
  7. package/dist/lib/mcp-spec.js +128 -0
  8. package/dist/lib/safe-snapshot-extract.js +231 -0
  9. package/dist/lib/scheduler-platform.js +128 -0
  10. package/dist/lib/xml-escape.js +54 -0
  11. package/dist/rem/scheduler.js +35 -87
  12. package/dist/rem/snapshot.js +13 -0
  13. package/dist/replication-convergence.js +505 -0
  14. package/dist/resources/MemoryBootstrap.js +7 -8
  15. package/dist/resources/SemanticSearch.js +17 -45
  16. package/dist/resources/abstention.js +1 -1
  17. package/dist/resources/embeddings-boot.js +10 -12
  18. package/dist/resources/embeddings-provider.js +10 -7
  19. package/dist/resources/health.js +24 -19
  20. package/dist/resources/in-process.js +225 -0
  21. package/dist/resources/mcp-tools.js +23 -17
  22. package/dist/resources/migration-boot.js +80 -10
  23. package/dist/resources/migrations/data-dir.js +205 -0
  24. package/dist/resources/migrations/progress.js +33 -0
  25. package/dist/resources/migrations/runner.js +29 -2
  26. package/dist/resources/migrations/state.js +13 -2
  27. package/dist/resources/models-dir.js +18 -9
  28. package/dist/resources/semantic-retrieval-core.js +5 -4
  29. package/dist/src/lib/scheduler-platform.js +128 -0
  30. package/dist/src/lib/xml-escape.js +54 -0
  31. package/dist/src/rem/scheduler.js +35 -87
  32. package/docs/deploying-on-fabric.md +267 -0
  33. package/docs/deployment.md +5 -0
  34. package/docs/embedding-in-a-harper-app.md +299 -0
  35. package/docs/federation.md +61 -4
  36. package/docs/integrations.md +3 -0
  37. package/docs/mcp-clients.md +16 -7
  38. package/docs/quickstart.md +80 -54
  39. package/docs/releasing.md +72 -38
  40. package/docs/supply-chain-policy.md +36 -0
  41. package/docs/troubleshooting.md +24 -0
  42. package/docs/upgrade.md +98 -3
  43. package/package.json +1 -11
  44. package/templates/bin/flair-federation-sync.sh.tmpl +28 -0
  45. package/templates/launchd/dev.flair.federation.sync.plist.tmpl +47 -0
  46. package/templates/systemd/flair-federation-sync.service.tmpl +21 -0
  47. package/templates/systemd/flair-federation-sync.timer.tmpl +16 -0
  48. package/dist/resources/rerank-provider.js +0 -569
  49. package/docs/rerank-provisioning.md +0 -101
@@ -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
- function claudeCodeDetect() {
63
- try {
64
- const result = spawnSync("npm", ["list", "-g", "@anthropic-ai/claude-code"], {
65
- stdio: ["ignore", "ignore", "ignore"],
66
- });
67
- return result.status === 0;
68
- }
69
- catch (_e) {
70
- return false;
71
- }
72
- }
73
- function codexDetect() {
74
- try {
75
- if (binInPath("codex"))
76
- return true;
77
- const npmResult = spawnSync("npm", ["list", "-g", "@openai/codex"], {
78
- stdio: ["ignore", "ignore", "ignore"],
79
- });
80
- return npmResult.status === 0;
81
- }
82
- catch (_e) {
83
- return false;
84
- }
85
- }
86
- function geminiDetect() {
87
- try {
88
- if (binInPath("gemini"))
89
- return true;
90
- const npmResult = spawnSync("npm", ["list", "-g", "@google/generative-ai"], {
91
- stdio: ["ignore", "ignore", "ignore"],
92
- });
93
- return npmResult.status === 0;
94
- }
95
- catch (_e) {
96
- return false;
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("cursor");
98
+ return binInPath(bin);
102
99
  }
103
100
  catch (_e) {
104
101
  return false;
105
102
  }
106
103
  }
107
104
  // ---- Shared config shapes -------------------------------------------------------
108
- /** The standard MCP stdio server entry every client (except Codex TOML) uses. */
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", "@tpsdev-ai/flair-mcp"],
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", "@tpsdev-ai/flair-mcp"]`,
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.id === "claude-code"
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
+ }