@tpsdev-ai/flair 0.6.0 → 0.6.2

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.
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Trust allow-list for npm code-plugin bridges.
3
+ *
4
+ * A Shape B bridge is arbitrary JavaScript fetched from npm. The spec §7
5
+ * requires explicit operator opt-in on first use: `flair bridge allow <name>`
6
+ * records the approval; subsequent invocations see the bridge on the
7
+ * allow-list and skip the prompt. `flair bridge revoke <name>` removes it.
8
+ *
9
+ * Approvals are pinned to a concrete package location and its package.json
10
+ * content digest. A name-only approval is not enough: a malicious package
11
+ * squatting on an allowed short-name in a different `node_modules` tree
12
+ * (e.g. the user approved `mem0` in ProjectA, then cd'd into ProjectB which
13
+ * ships a planted `node_modules/flair-bridge-mem0`) would otherwise load
14
+ * under the existing trust record. Each entry therefore captures:
15
+ *
16
+ * - packageDir: canonical (realpath) directory where the approved
17
+ * package lives on disk at allow-time.
18
+ * - packageJsonSha256: hex sha256 of the package's package.json content.
19
+ * - version: the package's self-reported version (display only;
20
+ * the sha is what enforces identity).
21
+ *
22
+ * At load-time, `verifyAllow(discovered)` re-checks all three. A change in
23
+ * any triggers a BridgeRuntimeError pointing the operator back at
24
+ * `flair bridge allow` — either the move is intentional (re-approve) or
25
+ * it is a squat (refuse).
26
+ *
27
+ * Design notes:
28
+ * - YAML (Shape A) + built-in bridges skip this gate entirely. They don't
29
+ * execute arbitrary JS.
30
+ * - The store is a simple JSON file at ~/.flair/bridges-allowed.json.
31
+ * No separate DB, no Harper dependency — the trust decision should
32
+ * still work when Flair's own service is down.
33
+ * - Migration: older entries from 0.6.0/0.6.1 carry only {name, allowedAt}.
34
+ * `verifyAllow` treats those as invalid and forces re-approval on next
35
+ * load. Acceptable cost — no known external consumers pre-1.0.
36
+ */
37
+ import { promises as fsp } from "node:fs";
38
+ import { existsSync } from "node:fs";
39
+ import { join, dirname } from "node:path";
40
+ import { homedir } from "node:os";
41
+ import { createHash } from "node:crypto";
42
+ function resolvePath(opts) {
43
+ return opts?.path ?? join(homedir(), ".flair", "bridges-allowed.json");
44
+ }
45
+ function hasEntryFields(e) {
46
+ return (typeof e?.name === "string" &&
47
+ typeof e?.allowedAt === "string" &&
48
+ typeof e?.packageDir === "string" &&
49
+ typeof e?.packageJsonSha256 === "string");
50
+ }
51
+ async function read(path) {
52
+ if (!existsSync(path))
53
+ return { allowed: [] };
54
+ try {
55
+ const raw = await fsp.readFile(path, "utf-8");
56
+ const parsed = JSON.parse(raw);
57
+ if (!parsed || !Array.isArray(parsed.allowed))
58
+ return { allowed: [] };
59
+ // Keep structurally valid entries only. Legacy name-only rows are
60
+ // dropped here so `verifyAllow` reports not-allowed (forcing re-approve).
61
+ return {
62
+ allowed: parsed.allowed.filter(hasEntryFields).map((e) => ({
63
+ name: e.name,
64
+ allowedAt: e.allowedAt,
65
+ packageDir: e.packageDir,
66
+ packageJsonSha256: e.packageJsonSha256,
67
+ ...(typeof e.version === "string" ? { version: e.version } : {}),
68
+ })),
69
+ };
70
+ }
71
+ catch {
72
+ return { allowed: [] };
73
+ }
74
+ }
75
+ async function write(path, data) {
76
+ await fsp.mkdir(dirname(path), { recursive: true });
77
+ const tmp = `${path}.tmp`;
78
+ await fsp.writeFile(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
79
+ await fsp.rename(tmp, path);
80
+ }
81
+ /**
82
+ * Canonicalize a filesystem path. Resolves symlinks so node_modules hoisting
83
+ * and pnpm-style content-addressed stores still hash-match reliably.
84
+ */
85
+ async function canonical(path) {
86
+ try {
87
+ return await fsp.realpath(path);
88
+ }
89
+ catch {
90
+ return path;
91
+ }
92
+ }
93
+ /**
94
+ * Hash a package's package.json file. The sha changes whenever the package
95
+ * contents "change enough to matter" — version bumps, name changes, and
96
+ * realistically most substantive updates touch package.json. It is NOT a
97
+ * hash of every file in the package; that would be slower and more fragile
98
+ * without closing the meaningful attack paths (since a squatter needs the
99
+ * right package.json anyway to survive discovery).
100
+ */
101
+ export async function digestPackage(packageDir) {
102
+ const canonicalDir = await canonical(packageDir);
103
+ const pkgJsonPath = join(canonicalDir, "package.json");
104
+ const raw = await fsp.readFile(pkgJsonPath, "utf-8");
105
+ const sha = createHash("sha256").update(raw).digest("hex");
106
+ let version;
107
+ try {
108
+ const parsed = JSON.parse(raw);
109
+ if (typeof parsed?.version === "string")
110
+ version = parsed.version;
111
+ }
112
+ catch {
113
+ // A malformed package.json is itself a refusal signal — but leave that
114
+ // to the import path; here we just decline to record a version.
115
+ }
116
+ return { canonicalDir, sha256: sha, version };
117
+ }
118
+ /**
119
+ * Check if a name appears in the allow-list. Useful for UI listings; NOT a
120
+ * trust decision — use `verifyAllow` for load-time security checks.
121
+ */
122
+ export async function isAllowed(name, opts) {
123
+ const data = await read(resolvePath(opts));
124
+ return data.allowed.some((e) => e.name === name);
125
+ }
126
+ /**
127
+ * Load-time trust check. Verifies the discovered package still matches the
128
+ * approval record by canonical path AND package.json sha. Anything else is
129
+ * a refusal — the operator must re-run `flair bridge allow <name>`.
130
+ */
131
+ export async function verifyAllow(discovered, opts) {
132
+ const data = await read(resolvePath(opts));
133
+ const entry = data.allowed.find((e) => e.name === discovered.name);
134
+ if (!entry)
135
+ return { ok: false, reason: "not-allowed" };
136
+ // Guard: if we ever loaded a partial record, refuse. Belt-and-braces;
137
+ // read() already filters these, but if someone hand-edits the file we
138
+ // don't want to silently bypass the check.
139
+ if (!entry.packageDir || !entry.packageJsonSha256) {
140
+ return { ok: false, reason: "entry-incomplete", entry };
141
+ }
142
+ let observed;
143
+ try {
144
+ observed = await digestPackage(discovered.path);
145
+ }
146
+ catch {
147
+ return { ok: false, reason: "package-missing", entry };
148
+ }
149
+ if (observed.canonicalDir !== entry.packageDir) {
150
+ return { ok: false, reason: "path-mismatch", entry, observedPath: observed.canonicalDir };
151
+ }
152
+ if (observed.sha256 !== entry.packageJsonSha256) {
153
+ return { ok: false, reason: "digest-mismatch", entry, observedDigest: observed.sha256 };
154
+ }
155
+ return { ok: true, entry };
156
+ }
157
+ /**
158
+ * Approve a package for code-plugin execution. `packageDir` must be the
159
+ * directory holding the package.json — typically discovered via
160
+ * `flair bridge list` and passed through by the CLI layer.
161
+ */
162
+ export async function allow(name, packageDir, opts) {
163
+ const listPath = resolvePath(opts);
164
+ const data = await read(listPath);
165
+ const { canonicalDir, sha256, version } = await digestPackage(packageDir);
166
+ const now = new Date().toISOString();
167
+ const existing = data.allowed.find((e) => e.name === name);
168
+ if (existing && existing.packageDir === canonicalDir && existing.packageJsonSha256 === sha256) {
169
+ return { alreadyAllowed: true, updated: false, entry: existing };
170
+ }
171
+ const entry = {
172
+ name,
173
+ allowedAt: now,
174
+ packageDir: canonicalDir,
175
+ packageJsonSha256: sha256,
176
+ ...(version ? { version } : {}),
177
+ };
178
+ data.allowed = data.allowed.filter((e) => e.name !== name);
179
+ data.allowed.push(entry);
180
+ data.allowed.sort((a, b) => a.name.localeCompare(b.name));
181
+ await write(listPath, data);
182
+ return { alreadyAllowed: false, updated: !!existing, entry };
183
+ }
184
+ export async function revoke(name, opts) {
185
+ const path = resolvePath(opts);
186
+ const data = await read(path);
187
+ const before = data.allowed.length;
188
+ data.allowed = data.allowed.filter((e) => e.name !== name);
189
+ if (data.allowed.length === before) {
190
+ return { wasAllowed: false };
191
+ }
192
+ await write(path, data);
193
+ return { wasAllowed: true };
194
+ }
195
+ export async function list(opts) {
196
+ const data = await read(resolvePath(opts));
197
+ return data.allowed;
198
+ }
@@ -26,7 +26,11 @@ export async function runImport(opts) {
26
26
  let total = 0;
27
27
  let imported = 0;
28
28
  let skipped = 0;
29
- for await (const m of importFromYaml(opts.descriptor, { cwd: opts.cwd, ctx: opts.ctx })) {
29
+ const iterable = opts.source
30
+ ?? (opts.descriptor
31
+ ? importFromYaml(opts.descriptor, { cwd: opts.cwd, ctx: opts.ctx })
32
+ : (() => { throw new BridgeRuntimeError({ bridge: opts.bridgeName, op: "import", field: "(source)", expected: "descriptor or source", got: "neither", hint: "runImport requires exactly one of opts.descriptor (YAML) or opts.source (AsyncIterable)" }); })());
33
+ for await (const m of iterable) {
30
34
  total++;
31
35
  const resolvedAgent = m.agentId ?? opts.agentId;
32
36
  if (!resolvedAgent) {
@@ -35,7 +39,7 @@ export async function runImport(opts) {
35
39
  // the operator should know whether this is a descriptor bug or a
36
40
  // missing flag.
37
41
  throw new BridgeRuntimeError({
38
- bridge: opts.descriptor.name,
42
+ bridge: opts.bridgeName,
39
43
  op: "import",
40
44
  field: "agentId",
41
45
  expected: "set on record OR provided via --agent",
@@ -86,7 +90,7 @@ export async function runImport(opts) {
86
90
  catch (err) {
87
91
  // Wrap PUT errors so they read consistently with other BridgeRuntimeErrors.
88
92
  throw new BridgeRuntimeError({
89
- bridge: opts.descriptor.name,
93
+ bridge: opts.bridgeName,
90
94
  op: "import",
91
95
  record: total,
92
96
  field: "(write)",
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Unified loader for all bridge sources (replaces load-descriptor.ts).
3
+ *
4
+ * Returns a `LoadedBridge` discriminated union. Runners dispatch on
5
+ * `.kind`:
6
+ * - "yaml": use the existing YAML runtime (applyMap, parseRecords, etc.)
7
+ * - "code": call the plugin's bridge.import() / bridge.export() directly
8
+ *
9
+ * For code-plugin sources (npm packages), the allow-list gate runs first:
10
+ * if not allowed, throw a BridgeRuntimeError pointing the operator at
11
+ * `flair bridge allow <name>`.
12
+ */
13
+ import { BridgeRuntimeError } from "../types.js";
14
+ import { loadYamlDescriptor } from "./yaml-loader.js";
15
+ import { BUILTIN_BY_NAME } from "../builtins/index.js";
16
+ import { loadCodePlugin } from "./load-plugin.js";
17
+ import { verifyAllow } from "./allow-list.js";
18
+ export async function loadBridge(discovered, opts = {}) {
19
+ switch (discovered.source) {
20
+ case "builtin": {
21
+ const builtin = BUILTIN_BY_NAME.get(discovered.name);
22
+ if (!builtin) {
23
+ throw new BridgeRuntimeError({
24
+ bridge: discovered.name,
25
+ op: "import",
26
+ field: "(builtin)",
27
+ expected: "registered built-in name",
28
+ got: discovered.name,
29
+ hint: `built-in "${discovered.name}" not in the registry — likely code drift between discover.ts and builtins/index.ts`,
30
+ });
31
+ }
32
+ return { kind: "yaml", descriptor: builtin.descriptor, source: discovered };
33
+ }
34
+ case "project-yaml":
35
+ case "user-yaml": {
36
+ const descriptor = await loadYamlDescriptor(discovered.path);
37
+ return { kind: "yaml", descriptor, source: discovered };
38
+ }
39
+ case "npm-package": {
40
+ if (!opts.skipAllowCheck) {
41
+ const verdict = await verifyAllow(discovered, { path: opts.allowListPath });
42
+ if (!verdict.ok) {
43
+ throw new BridgeRuntimeError({
44
+ bridge: discovered.name,
45
+ op: "import",
46
+ path: discovered.path,
47
+ field: "(trust)",
48
+ expected: verdict.reason === "not-allowed" ? "allow-listed code plugin" : "approved package at recorded location/digest",
49
+ got: verdict.reason,
50
+ hint: trustHint(discovered.name, verdict),
51
+ context: trustContext(discovered, verdict),
52
+ });
53
+ }
54
+ }
55
+ const plugin = await loadCodePlugin(discovered, { importer: opts.importer });
56
+ return { kind: "code", plugin, source: discovered };
57
+ }
58
+ }
59
+ }
60
+ function trustContext(discovered, verdict) {
61
+ switch (verdict.reason) {
62
+ case "not-allowed":
63
+ return { name: discovered.name };
64
+ case "path-mismatch":
65
+ return {
66
+ name: discovered.name,
67
+ approvedPath: verdict.entry.packageDir,
68
+ observedPath: verdict.observedPath,
69
+ approvedVersion: verdict.entry.version ?? "(unknown)",
70
+ approvedAt: verdict.entry.allowedAt,
71
+ };
72
+ case "digest-mismatch":
73
+ return {
74
+ name: discovered.name,
75
+ packagePath: verdict.entry.packageDir,
76
+ approvedVersion: verdict.entry.version ?? "(unknown)",
77
+ approvedDigest: verdict.entry.packageJsonSha256,
78
+ observedDigest: verdict.observedDigest,
79
+ approvedAt: verdict.entry.allowedAt,
80
+ };
81
+ case "entry-incomplete":
82
+ return { name: discovered.name };
83
+ case "package-missing":
84
+ return {
85
+ name: discovered.name,
86
+ approvedPath: verdict.entry?.packageDir ?? "(unknown)",
87
+ discoveredPath: discovered.path,
88
+ };
89
+ }
90
+ }
91
+ function trustHint(name, verdict) {
92
+ const reapprove = `flair bridge allow ${name}`;
93
+ switch (verdict.reason) {
94
+ case "not-allowed":
95
+ return `npm code plugins run arbitrary JavaScript. First-use approval required. Run: ${reapprove}`;
96
+ case "path-mismatch":
97
+ return `approved package lives at ${verdict.entry.packageDir}, but a different package with the same name was discovered at ${verdict.observedPath}. This is how local squatting attacks present. If the new location is intentional, re-run: ${reapprove}`;
98
+ case "digest-mismatch":
99
+ return `package.json contents changed since approval (recorded sha ${verdict.entry.packageJsonSha256.slice(0, 12)}…, observed ${verdict.observedDigest.slice(0, 12)}…). Re-run if the update is intentional: ${reapprove}`;
100
+ case "entry-incomplete":
101
+ return `allow-list entry for "${name}" is missing a location/digest (likely from a pre-fix Flair version). Re-run: ${reapprove}`;
102
+ case "package-missing":
103
+ return `package at ${verdict.entry?.packageDir ?? "(unknown)"} could not be read; allow-list verification cannot proceed`;
104
+ }
105
+ }
106
+ /**
107
+ * Kept as a shim for code paths that still call the old name. Remove once
108
+ * all call sites move to loadBridge directly.
109
+ *
110
+ * Returns the YAML descriptor when the source produces one; throws for
111
+ * code plugins (they don't have a YamlBridgeDescriptor representation).
112
+ */
113
+ export async function loadDescriptor(discovered) {
114
+ const loaded = await loadBridge(discovered);
115
+ if (loaded.kind === "yaml")
116
+ return loaded.descriptor;
117
+ throw new BridgeRuntimeError({
118
+ bridge: discovered.name,
119
+ op: "import",
120
+ path: discovered.path,
121
+ field: "(kind)",
122
+ expected: "yaml descriptor",
123
+ got: "code plugin",
124
+ hint: "this code path expected a YAML descriptor; the bridge is a code plugin — caller should dispatch on loadBridge().kind instead",
125
+ });
126
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Dynamic loader for Shape B code-plugin bridges.
3
+ *
4
+ * Given a discovered `npm-package` source, dynamic-import the package,
5
+ * validate that it exports a `MemoryBridge`, and return the loaded module.
6
+ * The import is done via `createRequire` relative to the package root so
7
+ * scoped packages and deep nested deps resolve correctly.
8
+ *
9
+ * Validation is deliberately minimal: `name`, `version`, `kind`, and at
10
+ * least one of `import` / `export` must be present and look right. The
11
+ * spec §6 contract is "duck-type at runtime, strict error messages."
12
+ */
13
+ import { pathToFileURL } from "node:url";
14
+ import { BridgeRuntimeError } from "../types.js";
15
+ const DEFAULT_IMPORTER = (spec) => import(spec);
16
+ export async function loadCodePlugin(discovered, opts = {}) {
17
+ if (discovered.source !== "npm-package") {
18
+ throw new BridgeRuntimeError({
19
+ bridge: discovered.name,
20
+ op: "import",
21
+ field: "source",
22
+ expected: "npm-package",
23
+ got: discovered.source,
24
+ hint: "loadCodePlugin only handles Shape B (npm code plugin) bridges; YAML descriptors go through the YAML loader",
25
+ });
26
+ }
27
+ const importer = opts.importer ?? DEFAULT_IMPORTER;
28
+ // Package root path — the entry point is resolved by Node's package.json
29
+ // "main"/"exports" fields. Use a file:// URL so Node resolves it as a
30
+ // filesystem path rather than a bare specifier.
31
+ const spec = pathToFileURL(discovered.path + "/").href;
32
+ let mod;
33
+ try {
34
+ mod = await importer(spec);
35
+ }
36
+ catch (err) {
37
+ throw new BridgeRuntimeError({
38
+ bridge: discovered.name,
39
+ op: "import",
40
+ path: discovered.path,
41
+ field: "(import)",
42
+ expected: "importable npm package",
43
+ got: err?.code ?? "import error",
44
+ hint: `could not dynamic-import ${spec}: ${err?.message ?? err}`,
45
+ });
46
+ }
47
+ const candidate = pickBridgeExport(mod);
48
+ if (!candidate) {
49
+ throw new BridgeRuntimeError({
50
+ bridge: discovered.name,
51
+ op: "import",
52
+ path: discovered.path,
53
+ field: "exports",
54
+ expected: "named `bridge` export or default export implementing MemoryBridge",
55
+ got: typeof mod === "object" && mod !== null ? `exports=${Object.keys(mod).join(",") || "(empty)"}` : typeof mod,
56
+ hint: `flair-bridge-<name> packages must export \`bridge\` (or default-export) a MemoryBridge. See specs/FLAIR-BRIDGES.md §6`,
57
+ });
58
+ }
59
+ // Validate the shape. Keep this minimal — we trust the plugin author
60
+ // to build a working MemoryBridge; we just need enough to route calls.
61
+ validateBridge(discovered, candidate);
62
+ return candidate;
63
+ }
64
+ function pickBridgeExport(mod) {
65
+ if (!mod || typeof mod !== "object")
66
+ return null;
67
+ const m = mod;
68
+ if (isBridgeLike(m.bridge))
69
+ return m.bridge;
70
+ if (isBridgeLike(m.default))
71
+ return m.default;
72
+ if (isBridgeLike(m))
73
+ return m;
74
+ return null;
75
+ }
76
+ function isBridgeLike(x) {
77
+ if (!x || typeof x !== "object")
78
+ return false;
79
+ const o = x;
80
+ return typeof o.name === "string" && (typeof o.import === "function" || typeof o.export === "function");
81
+ }
82
+ function validateBridge(discovered, b) {
83
+ if (typeof b.name !== "string" || !b.name) {
84
+ throw new BridgeRuntimeError({
85
+ bridge: discovered.name,
86
+ op: "import",
87
+ path: discovered.path,
88
+ field: "name",
89
+ expected: "non-empty string",
90
+ got: JSON.stringify(b.name),
91
+ hint: "MemoryBridge.name must be a non-empty string matching the npm package's flair-bridge-<name>",
92
+ });
93
+ }
94
+ if (b.name !== discovered.name) {
95
+ throw new BridgeRuntimeError({
96
+ bridge: discovered.name,
97
+ op: "import",
98
+ path: discovered.path,
99
+ field: "name",
100
+ expected: `"${discovered.name}" (from package name flair-bridge-${discovered.name})`,
101
+ got: `"${b.name}"`,
102
+ hint: "MemoryBridge.name must match the npm package's public name suffix — mismatch would surprise discovery and allow-list lookups",
103
+ });
104
+ }
105
+ if (b.kind !== "file" && b.kind !== "api") {
106
+ throw new BridgeRuntimeError({
107
+ bridge: discovered.name,
108
+ op: "import",
109
+ path: discovered.path,
110
+ field: "kind",
111
+ expected: `"file" | "api"`,
112
+ got: JSON.stringify(b.kind),
113
+ hint: "MemoryBridge.kind must be 'file' or 'api'",
114
+ });
115
+ }
116
+ if (typeof b.import !== "function" && typeof b.export !== "function") {
117
+ throw new BridgeRuntimeError({
118
+ bridge: discovered.name,
119
+ op: "import",
120
+ path: discovered.path,
121
+ field: "(methods)",
122
+ expected: "at least one of `import` or `export`",
123
+ got: "neither",
124
+ hint: "MemoryBridge needs at least one of import/export implemented. See spec §6",
125
+ });
126
+ }
127
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Round-trip test harness for bridges.
3
+ *
4
+ * A bridge passes the round-trip test iff:
5
+ * 1. Parse the fixture (or descriptor's first import.source.path) → pass1 BridgeMemory[]
6
+ * 2. Apply export.targets[0]: when-filter → map → write to tmp in the
7
+ * target's format
8
+ * 3. Re-import the tmp file using import.sources[0] → pass2 BridgeMemory[]
9
+ * 4. pass1 and pass2 must agree on the round-trip-stable fields from the
10
+ * spec (§8): content, subject, tags, durability.
11
+ *
12
+ * This exercises the full mapper + predicate + writer + parser chain —
13
+ * if a bridge author's descriptor has mapping bugs, the round-trip
14
+ * surfaces them before memories ever reach Flair.
15
+ *
16
+ * Implementation note: does NOT talk to Flair. Fixture-to-fixture only.
17
+ * Lives here so slice-3c code plugins can reuse the same harness.
18
+ */
19
+ import { promises as fsp } from "node:fs";
20
+ import { join, isAbsolute } from "node:path";
21
+ import { tmpdir } from "node:os";
22
+ import { randomUUID } from "node:crypto";
23
+ import { BridgeRuntimeError } from "../types.js";
24
+ import { parseRecords } from "./formats.js";
25
+ import { applyMap } from "./mapper.js";
26
+ import { evaluatePredicate } from "./predicate.js";
27
+ import { writeRecords } from "./writers.js";
28
+ /** Fields the spec (§8) requires to survive round-trip. */
29
+ export const ROUND_TRIP_STABLE_FIELDS = ["content", "subject", "tags", "durability"];
30
+ export async function runRoundTrip(opts) {
31
+ const { descriptor, cwd } = opts;
32
+ if (!descriptor.import || descriptor.import.sources.length === 0) {
33
+ throw new BridgeRuntimeError({
34
+ bridge: descriptor.name,
35
+ op: "test",
36
+ field: "import",
37
+ expected: "descriptor with at least one import source",
38
+ got: "missing or empty",
39
+ hint: "round-trip needs an import block to start from. Add `import.sources:` to the descriptor.",
40
+ });
41
+ }
42
+ if (!descriptor.export || descriptor.export.targets.length === 0) {
43
+ throw new BridgeRuntimeError({
44
+ bridge: descriptor.name,
45
+ op: "test",
46
+ field: "export",
47
+ expected: "descriptor with at least one export target",
48
+ got: "missing or empty",
49
+ hint: "round-trip needs an export block to round-trip through. Add `export.targets:` to the descriptor.",
50
+ });
51
+ }
52
+ const importSource = descriptor.import.sources[0];
53
+ const exportTarget = descriptor.export.targets[0];
54
+ const resolvedFixture = resolvePath(cwd, opts.fixturePath ?? importSource.path);
55
+ const tmpDir = await fsp.mkdtemp(join(tmpdir(), `flair-bridge-test-${descriptor.name}-`));
56
+ const tmpPath = join(tmpDir, `roundtrip.${suffixForFormat(exportTarget.format)}`);
57
+ // Pass 1: fixture → BridgeMemory[]
58
+ const pass1 = [];
59
+ for await (const { record } of parseRecords(descriptor.name, resolvedFixture, importSource.format)) {
60
+ const mapped = applyMap(importSource.map, record);
61
+ pass1.push(mapped);
62
+ }
63
+ // Filter pass1 by the export target's when: (records filtered out wouldn't make it to the target).
64
+ const expected = [];
65
+ for (const m of pass1) {
66
+ if (!exportTarget.when || exportTarget.when.trim() === "") {
67
+ expected.push(m);
68
+ continue;
69
+ }
70
+ const result = evaluatePredicate(exportTarget.when, m);
71
+ if (result === "match" || result === "unparsable")
72
+ expected.push(m);
73
+ }
74
+ // Export phase: apply export.map, write to tmp
75
+ const shaped = [];
76
+ for (const m of expected) {
77
+ const out = applyMap(exportTarget.map, m);
78
+ if (Object.keys(out).length === 0)
79
+ continue;
80
+ shaped.push(out);
81
+ }
82
+ await writeRecords(descriptor.name, tmpPath, exportTarget.format, shaped);
83
+ // Pass 2: re-import the tmp using import.sources[0]'s map + format
84
+ // (round-trip requires the bridge's own import map applied to what export wrote).
85
+ const pass2 = [];
86
+ for await (const { record } of parseRecords(descriptor.name, tmpPath, importSource.format)) {
87
+ const mapped = applyMap(importSource.map, record);
88
+ pass2.push(mapped);
89
+ }
90
+ // Match pass1[expected] with pass2 by key (foreignId if present, else content)
91
+ // and diff the stable fields.
92
+ const keyOf = (m) => m.foreignId ?? (m.content ? `content:${m.content}` : "");
93
+ const pass2ByKey = new Map();
94
+ for (const m of pass2)
95
+ pass2ByKey.set(keyOf(m), m);
96
+ const mismatches = [];
97
+ const missingInPass2 = [];
98
+ for (let i = 0; i < expected.length; i++) {
99
+ const a = expected[i];
100
+ const key = keyOf(a);
101
+ const b = pass2ByKey.get(key);
102
+ if (!b) {
103
+ missingInPass2.push({ ordinal: i + 1, key });
104
+ continue;
105
+ }
106
+ for (const field of ROUND_TRIP_STABLE_FIELDS) {
107
+ if (!deepEqualField(a[field], b[field])) {
108
+ mismatches.push({ ordinal: i + 1, key, field, expected: a[field], got: b[field] });
109
+ }
110
+ }
111
+ // Matched this pass2 entry; remove so leftover tracking works
112
+ pass2ByKey.delete(key);
113
+ }
114
+ const unexpectedInPass2 = Array.from(pass2ByKey.keys()).map((key) => ({ key }));
115
+ const passed = mismatches.length === 0 && missingInPass2.length === 0 && unexpectedInPass2.length === 0;
116
+ return {
117
+ passed,
118
+ expectedCount: expected.length,
119
+ actualCount: pass2.length,
120
+ mismatches,
121
+ missingInPass2,
122
+ unexpectedInPass2,
123
+ tmpExportPath: tmpPath,
124
+ };
125
+ }
126
+ function deepEqualField(a, b) {
127
+ // tags is the only array-valued stable field today; deep-equal shallowly
128
+ // (order matters per our mapping; if a bridge re-orders tags, that's a
129
+ // round-trip regression worth catching).
130
+ if (Array.isArray(a) && Array.isArray(b)) {
131
+ if (a.length !== b.length)
132
+ return false;
133
+ for (let i = 0; i < a.length; i++)
134
+ if (a[i] !== b[i])
135
+ return false;
136
+ return true;
137
+ }
138
+ // string / number / boolean / undefined: strict equality
139
+ return a === b;
140
+ }
141
+ function suffixForFormat(format) {
142
+ switch (format) {
143
+ case "jsonl": return "jsonl";
144
+ case "json": return "json";
145
+ case "yaml": return "yaml";
146
+ case "markdown-frontmatter": return "md";
147
+ default: return "out";
148
+ }
149
+ }
150
+ function resolvePath(cwd, p) {
151
+ return isAbsolute(p) ? p : join(cwd, p);
152
+ }
153
+ // Keep randomUUID imported; future slice may use it for named tmp files.
154
+ void randomUUID;
package/dist/cli.js CHANGED
@@ -324,6 +324,51 @@ async function seedAgentViaOpsApi(opsPort, agentId, pubKeyB64url, adminUser, adm
324
324
  throw new Error(`Operations API insert failed (${res.status}): ${text}`);
325
325
  }
326
326
  }
327
+ // ─── Upgrade presence probes ──────────────────────────────────────────────────
328
+ //
329
+ // `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
330
+ // version. That assumed the default npm global prefix and failed (silently,
331
+ // reporting "not installed") for mise / fnm / nvm / volta users whose prefix
332
+ // lives elsewhere — including for the running flair binary itself, which is
333
+ // clearly installed somewhere. These probes locate the package regardless of
334
+ // install path.
335
+ export function probeBinVersion(execFileSync, bin) {
336
+ // Run the binary's --version via argv (no shell). PATH resolution still
337
+ // happens (so we find the binary wherever npm/mise/fnm installed it),
338
+ // but there's no shell-string to inject into. CodeQL-safe and simpler.
339
+ try {
340
+ const out = execFileSync(bin, ["--version"], {
341
+ encoding: "utf-8",
342
+ timeout: 5000,
343
+ stdio: ["ignore", "pipe", "ignore"],
344
+ }).trim();
345
+ if (!out)
346
+ return null;
347
+ // Accept either "0.6.0" on its own or a line containing a semver.
348
+ const m = out.match(/\b(\d+\.\d+\.\d+(?:[\d.a-z.-]*)?)\b/);
349
+ return m ? m[1] : null;
350
+ }
351
+ catch {
352
+ return null;
353
+ }
354
+ }
355
+ export function probeLibVersion(pkgName) {
356
+ // Resolve the package's package.json from the running flair's module graph.
357
+ // If the lib is installed anywhere Node can see (including bundled as a
358
+ // dep of flair itself, sibling global install, or linked workspace), this
359
+ // finds it. If it's truly missing, require.resolve throws → null.
360
+ try {
361
+ const { createRequire } = require("node:module");
362
+ const req = createRequire(import.meta.url);
363
+ const pkgJsonPath = req.resolve(`${pkgName}/package.json`);
364
+ const { readFileSync } = require("node:fs");
365
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
366
+ return typeof pkg.version === "string" ? pkg.version : null;
367
+ }
368
+ catch {
369
+ return null;
370
+ }
371
+ }
327
372
  export function templateSoul(choice) {
328
373
  const templates = {
329
374
  "1": [
@@ -2363,52 +2408,80 @@ program
2363
2408
  .option("--check", "Only check for updates, don't install")
2364
2409
  .option("--restart", "Restart Flair after upgrade")
2365
2410
  .action(async (opts) => {
2366
- const { execSync } = await import("node:child_process");
2411
+ const { execSync, execFileSync } = await import("node:child_process");
2367
2412
  const checkOnly = opts.check ?? false;
2368
2413
  console.log("Checking for updates...\n");
2414
+ // Per-package install probes. `npm list -g` assumed the default global
2415
+ // prefix and silently mis-reported "not installed" for anyone using
2416
+ // mise / fnm / nvm / volta / non-default-prefix npm — including the
2417
+ // running flair binary itself, which was obviously installed. Each
2418
+ // entry now has a locator that works regardless of install path:
2419
+ //
2420
+ // - For packages with a bin: shell out to the bin with --version
2421
+ // (same PATH lookup that got them invokable in the first place).
2422
+ // - For library packages: require.resolve the package.json from the
2423
+ // running flair's module graph (works whether it's a sibling
2424
+ // global install or a bundled dep).
2369
2425
  const packages = [
2370
- "@tpsdev-ai/flair",
2371
- "@tpsdev-ai/flair-client",
2372
- "@tpsdev-ai/flair-mcp",
2426
+ {
2427
+ name: "@tpsdev-ai/flair",
2428
+ probe: () => probeBinVersion(execFileSync, "flair"),
2429
+ },
2430
+ {
2431
+ name: "@tpsdev-ai/flair-client",
2432
+ probe: () => probeLibVersion("@tpsdev-ai/flair-client"),
2433
+ },
2434
+ {
2435
+ name: "@tpsdev-ai/flair-mcp",
2436
+ probe: () => probeBinVersion(execFileSync, "flair-mcp"),
2437
+ },
2373
2438
  ];
2374
- const upgrades = [];
2375
- for (const pkg of packages) {
2439
+ const findings = [];
2440
+ for (const { name, probe } of packages) {
2376
2441
  try {
2377
- const res = await fetch(`https://registry.npmjs.org/${pkg}/latest`, { signal: AbortSignal.timeout(5000) });
2442
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(5000) });
2378
2443
  if (!res.ok)
2379
2444
  continue;
2380
2445
  const data = await res.json();
2381
2446
  const latest = data.version ?? "unknown";
2382
- let installed = "not installed";
2383
- try {
2384
- const out = execSync(`npm list -g ${pkg} --depth=0 2>/dev/null || true`, { encoding: "utf-8" }).trim();
2385
- const match = out.match(/@(\d+\.\d+[\d.a-z-]*)/);
2386
- installed = match ? match[1] : "not installed";
2387
- }
2388
- catch { /* best effort */ }
2389
- const upToDate = installed === latest;
2390
- const icon = upToDate ? "✅" : "⬆️";
2391
- console.log(` ${icon} ${pkg}: ${installed} → ${latest}${upToDate ? " (current)" : ""}`);
2392
- if (!upToDate && installed !== "not installed") {
2393
- upgrades.push({ pkg, installed, latest });
2394
- }
2447
+ const installed = probe();
2448
+ const status = installed === null ? "missing" : installed === latest ? "current" : "outdated";
2449
+ findings.push({ name, installed, latest, status });
2450
+ const icon = status === "current" ? "✅" : status === "outdated" ? "⬆️" : "❔";
2451
+ const installedLabel = installed ?? "not detected";
2452
+ const suffix = status === "current" ? " (current)" : status === "missing" ? " (run: npm install -g)" : "";
2453
+ console.log(` ${icon} ${name}: ${installedLabel} ${latest}${suffix}`);
2395
2454
  }
2396
2455
  catch { /* skip unavailable packages */ }
2397
2456
  }
2398
- if (upgrades.length === 0) {
2457
+ const outdated = findings.filter((f) => f.status === "outdated");
2458
+ const missing = findings.filter((f) => f.status === "missing");
2459
+ const upgrades = outdated.map(({ name, installed, latest }) => ({ pkg: name, installed: installed ?? "unknown", latest }));
2460
+ if (outdated.length === 0 && missing.length === 0) {
2399
2461
  console.log("\n✅ Everything is up to date.");
2400
2462
  return;
2401
2463
  }
2464
+ if (missing.length > 0 && outdated.length === 0) {
2465
+ console.log(`\n❔ ${missing.length} package${missing.length > 1 ? "s" : ""} not detected — all detected packages are up to date.`);
2466
+ console.log(` Install missing: npm install -g ${missing.map((f) => f.name).join(" ")}`);
2467
+ return;
2468
+ }
2402
2469
  if (checkOnly) {
2403
- console.log(`\n${upgrades.length} update${upgrades.length > 1 ? "s" : ""} available. Run: flair upgrade`);
2470
+ console.log(`\n${outdated.length} update${outdated.length > 1 ? "s" : ""} available. Run: flair upgrade`);
2471
+ if (missing.length > 0) {
2472
+ console.log(`${missing.length} package${missing.length > 1 ? "s" : ""} not detected${missing.length > 0 ? ": " + missing.map((f) => f.name).join(", ") : ""}.`);
2473
+ }
2404
2474
  return;
2405
2475
  }
2406
- // Perform upgrade
2476
+ // Perform upgrade. `latest` comes from the npm registry's HTTP
2477
+ // response, so CodeQL (correctly) treats it as untrusted input.
2478
+ // Use execFileSync with argv — the spec `<name>@<version>` becomes a
2479
+ // single argument to npm, no shell to inject into.
2407
2480
  console.log(`\nUpgrading ${upgrades.length} package${upgrades.length > 1 ? "s" : ""}...\n`);
2408
2481
  for (const { pkg, latest } of upgrades) {
2409
2482
  try {
2410
2483
  console.log(` Installing ${pkg}@${latest}...`);
2411
- execSync(`npm install -g ${pkg}@${latest}`, { stdio: "pipe" });
2484
+ execFileSync("npm", ["install", "-g", `${pkg}@${latest}`], { stdio: "pipe" });
2412
2485
  console.log(` ✅ ${pkg}@${latest} installed`);
2413
2486
  }
2414
2487
  catch (err) {
@@ -3471,7 +3544,7 @@ bridge
3471
3544
  const cwd = opts.cwd ?? srcArg ?? process.cwd();
3472
3545
  const { discover } = await import("./bridges/discover.js");
3473
3546
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3474
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3547
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3475
3548
  const { runImport } = await import("./bridges/runtime/import-runner.js");
3476
3549
  const { makeContext } = await import("./bridges/runtime/context.js");
3477
3550
  const { BridgeRuntimeError } = await import("./bridges/types.js");
@@ -3481,9 +3554,9 @@ bridge
3481
3554
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3482
3555
  process.exit(1);
3483
3556
  }
3484
- let descriptor;
3557
+ let loaded;
3485
3558
  try {
3486
- descriptor = await loadDescriptor(target);
3559
+ loaded = await loadBridge(target);
3487
3560
  }
3488
3561
  catch (err) {
3489
3562
  printBridgeError(err);
@@ -3518,10 +3591,10 @@ bridge
3518
3591
  if (ev.type === "done") {
3519
3592
  const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
3520
3593
  if (opts.dryRun) {
3521
- console.log(`\n${descriptor.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3594
+ console.log(`\n${target.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
3522
3595
  }
3523
3596
  else {
3524
- console.log(`\n${descriptor.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3597
+ console.log(`\n${target.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
3525
3598
  }
3526
3599
  return;
3527
3600
  }
@@ -3538,15 +3611,40 @@ bridge
3538
3611
  }
3539
3612
  };
3540
3613
  try {
3541
- await runImport({
3542
- descriptor,
3543
- cwd,
3544
- agentId,
3545
- dryRun: !!opts.dryRun,
3546
- putMemory,
3547
- onProgress,
3548
- ctx,
3549
- });
3614
+ if (loaded.kind === "yaml") {
3615
+ await runImport({
3616
+ bridgeName: target.name,
3617
+ descriptor: loaded.descriptor,
3618
+ cwd,
3619
+ agentId,
3620
+ dryRun: !!opts.dryRun,
3621
+ putMemory,
3622
+ onProgress,
3623
+ ctx,
3624
+ });
3625
+ }
3626
+ else {
3627
+ // Code plugin: invoke bridge.import(opts, ctx) directly; the plugin
3628
+ // returns an AsyncIterable of BridgeMemory that runImport processes.
3629
+ if (!loaded.plugin.import) {
3630
+ console.error(`Bridge "${name}" is a code plugin without an import() function — can only export through it.`);
3631
+ process.exit(1);
3632
+ }
3633
+ // Code-plugin options: pass through all --X flags as a single object.
3634
+ // The plugin's declared `options` descriptor validates what it actually cares about.
3635
+ const pluginOpts = { ...opts };
3636
+ const source = loaded.plugin.import(pluginOpts, ctx);
3637
+ await runImport({
3638
+ bridgeName: target.name,
3639
+ source,
3640
+ cwd,
3641
+ agentId,
3642
+ dryRun: !!opts.dryRun,
3643
+ putMemory,
3644
+ onProgress,
3645
+ ctx,
3646
+ });
3647
+ }
3550
3648
  }
3551
3649
  catch (err) {
3552
3650
  if (err instanceof BridgeRuntimeError) {
@@ -3578,7 +3676,7 @@ bridge
3578
3676
  const cwd = opts.cwd ?? dst;
3579
3677
  const { discover } = await import("./bridges/discover.js");
3580
3678
  const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3581
- const { loadDescriptor } = await import("./bridges/runtime/load-descriptor.js");
3679
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3582
3680
  const { runExport } = await import("./bridges/runtime/export-runner.js");
3583
3681
  const { makeContext } = await import("./bridges/runtime/context.js");
3584
3682
  const { BridgeRuntimeError } = await import("./bridges/types.js");
@@ -3588,18 +3686,22 @@ bridge
3588
3686
  console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3589
3687
  process.exit(1);
3590
3688
  }
3591
- let descriptor;
3689
+ let loaded;
3592
3690
  try {
3593
- descriptor = await loadDescriptor(target);
3691
+ loaded = await loadBridge(target);
3594
3692
  }
3595
3693
  catch (err) {
3596
3694
  printBridgeError(err);
3597
3695
  process.exit(1);
3598
3696
  }
3599
- if (!descriptor.export) {
3697
+ if (loaded.kind === "yaml" && !loaded.descriptor.export) {
3600
3698
  console.error(`Bridge "${name}" has no export block — cannot export through it.`);
3601
3699
  process.exit(1);
3602
3700
  }
3701
+ if (loaded.kind === "code" && !loaded.plugin.export) {
3702
+ console.error(`Bridge "${name}" is a code plugin without an export() function — can only import through it.`);
3703
+ process.exit(1);
3704
+ }
3603
3705
  const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
3604
3706
  const ctx = makeContext({ bridge: name });
3605
3707
  // Memory fetcher — paginates GET /Memory?agentId=... applying any
@@ -3637,10 +3739,10 @@ bridge
3637
3739
  const onProgress = (ev) => {
3638
3740
  if (ev.type === "done") {
3639
3741
  if (opts.dryRun) {
3640
- console.log(`\n${descriptor.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
3742
+ console.log(`\n${target.name}: would export ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total. Re-run without --dry-run to write.`);
3641
3743
  }
3642
3744
  else {
3643
- console.log(`\n${descriptor.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3745
+ console.log(`\n${target.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
3644
3746
  }
3645
3747
  return;
3646
3748
  }
@@ -3660,15 +3762,28 @@ bridge
3660
3762
  process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
3661
3763
  };
3662
3764
  try {
3663
- await runExport({
3664
- descriptor,
3665
- cwd,
3666
- fetchMemories,
3667
- filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3668
- dryRun: !!opts.dryRun,
3669
- ctx,
3670
- onProgress,
3671
- });
3765
+ if (loaded.kind === "yaml") {
3766
+ await runExport({
3767
+ descriptor: loaded.descriptor,
3768
+ cwd,
3769
+ fetchMemories,
3770
+ filters: { agentId, subject: opts.subject, source: opts.source, since: opts.since },
3771
+ dryRun: !!opts.dryRun,
3772
+ ctx,
3773
+ onProgress,
3774
+ });
3775
+ }
3776
+ else {
3777
+ // Code plugin export: invoke plugin.export(memoryStream, opts, ctx) directly.
3778
+ // Plugin writes to its target however it likes (HTTP, file, etc.).
3779
+ if (opts.dryRun) {
3780
+ console.log(`${target.name}: dry-run not supported for code-plugin exports; aborting before invoking plugin.export().`);
3781
+ process.exit(2);
3782
+ }
3783
+ const pluginOpts = { ...opts };
3784
+ await loaded.plugin.export(fetchMemories({ agentId, subject: opts.subject, source: opts.source, since: opts.since }), pluginOpts, ctx);
3785
+ console.log(`${target.name}: code-plugin export completed. Record count not reported by the plugin.`);
3786
+ }
3672
3787
  }
3673
3788
  catch (err) {
3674
3789
  if (err instanceof BridgeRuntimeError) {
@@ -3679,21 +3794,170 @@ bridge
3679
3794
  process.exit(1);
3680
3795
  }
3681
3796
  });
3682
- // `test` still stubbed — round-trip harness lands in slice 3b.
3683
3797
  bridge
3684
- .command("test <name> [args...]")
3685
- .description("test a bridge not yet implemented (slice 3b of FLAIR-BRIDGES)")
3686
- .allowUnknownOption()
3687
- .action(() => {
3688
- console.error(`\`flair bridge test\` is not yet implemented landing in slice 3b of FLAIR-BRIDGES.`);
3689
- console.error(`Slice 3a ships export (Shape A YAML); the round-trip test harness pairs with it.`);
3690
- process.exit(2);
3798
+ .command("test <name>")
3799
+ .description("Round-trip a bridge through its fixture: import export re-import → diff. Pass iff the stable fields (content/subject/tags/durability) match.")
3800
+ .option("--fixture <path>", "Override the import source path (defaults to descriptor's import.sources[0].path)")
3801
+ .option("--cwd <dir>", "Filesystem root the descriptor's relative paths resolve against (default: cwd)")
3802
+ .option("--json", "Emit the full RoundTripResult as JSON on stdout")
3803
+ .action(async (name, opts) => {
3804
+ const cwd = opts.cwd ?? process.cwd();
3805
+ const { discover } = await import("./bridges/discover.js");
3806
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3807
+ const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
3808
+ const { runRoundTrip } = await import("./bridges/runtime/roundtrip.js");
3809
+ const { BridgeRuntimeError } = await import("./bridges/types.js");
3810
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3811
+ const target = found.find((b) => b.name === name);
3812
+ if (!target) {
3813
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3814
+ process.exit(1);
3815
+ }
3816
+ let loaded;
3817
+ try {
3818
+ loaded = await loadBridge(target);
3819
+ }
3820
+ catch (err) {
3821
+ printBridgeError(err);
3822
+ process.exit(1);
3823
+ }
3824
+ if (loaded.kind === "code") {
3825
+ console.error(`Bridge "${name}" is a code plugin — round-trip testing for code plugins lands in slice 3d (requires mocked fetch transport).`);
3826
+ console.error(`For now, code plugins should ship their own tests alongside the npm package.`);
3827
+ process.exit(2);
3828
+ }
3829
+ try {
3830
+ const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
3831
+ if (opts.json) {
3832
+ console.log(JSON.stringify(result, null, 2));
3833
+ process.exit(result.passed ? 0 : 1);
3834
+ }
3835
+ if (result.passed) {
3836
+ console.log(`✅ ${target.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
3837
+ process.exit(0);
3838
+ }
3839
+ console.log(`❌ ${target.name} round-trip failed.`);
3840
+ console.log(` expected ${result.expectedCount} records, got ${result.actualCount} back.`);
3841
+ if (result.missingInPass2.length > 0) {
3842
+ console.log(` missing from re-import (${result.missingInPass2.length}):`);
3843
+ for (const m of result.missingInPass2.slice(0, 5))
3844
+ console.log(` - ${m.key}`);
3845
+ if (result.missingInPass2.length > 5)
3846
+ console.log(` ... ${result.missingInPass2.length - 5} more`);
3847
+ }
3848
+ if (result.unexpectedInPass2.length > 0) {
3849
+ console.log(` unexpected extras in re-import (${result.unexpectedInPass2.length}):`);
3850
+ for (const m of result.unexpectedInPass2.slice(0, 5))
3851
+ console.log(` - ${m.key}`);
3852
+ if (result.unexpectedInPass2.length > 5)
3853
+ console.log(` ... ${result.unexpectedInPass2.length - 5} more`);
3854
+ }
3855
+ if (result.mismatches.length > 0) {
3856
+ console.log(` field mismatches (${result.mismatches.length}):`);
3857
+ for (const m of result.mismatches.slice(0, 10)) {
3858
+ console.log(` - record ${m.ordinal} (${m.key}) field ${m.field}: expected ${JSON.stringify(m.expected)}, got ${JSON.stringify(m.got)}`);
3859
+ }
3860
+ if (result.mismatches.length > 10)
3861
+ console.log(` ... ${result.mismatches.length - 10} more`);
3862
+ }
3863
+ console.log(`\n Intermediate export at: ${result.tmpExportPath}`);
3864
+ process.exit(1);
3865
+ }
3866
+ catch (err) {
3867
+ if (err instanceof BridgeRuntimeError) {
3868
+ printBridgeError(err);
3869
+ process.exit(1);
3870
+ }
3871
+ console.error(`Bridge test failed: ${err?.message ?? err}`);
3872
+ process.exit(1);
3873
+ }
3874
+ });
3875
+ bridge
3876
+ .command("allow <name>")
3877
+ .description("Approve an npm code-plugin bridge for execution. Approval is pinned to the package's location and package.json contents — a malicious package squatting on the same name in a different node_modules tree will be refused at load-time.")
3878
+ .action(async (name) => {
3879
+ const { discover } = await import("./bridges/discover.js");
3880
+ const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
3881
+ const { allow } = await import("./bridges/runtime/allow-list.js");
3882
+ const found = await discover({ builtins: builtinDiscoveryRecords() });
3883
+ const target = found.find((b) => b.name === name);
3884
+ if (!target) {
3885
+ console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
3886
+ process.exit(1);
3887
+ }
3888
+ if (target.source !== "npm-package") {
3889
+ console.error(`"${name}" is a ${target.source} bridge; only npm code plugins require allow-list approval.`);
3890
+ console.error(`YAML and built-in bridges run via the descriptor runtime and don't execute arbitrary JS.`);
3891
+ process.exit(1);
3892
+ }
3893
+ try {
3894
+ const result = await allow(name, target.path);
3895
+ if (result.alreadyAllowed) {
3896
+ console.log(`${name} was already allowed at ${result.entry.packageDir} — no change.`);
3897
+ return;
3898
+ }
3899
+ const verb = result.updated ? "re-approved" : "allowed";
3900
+ console.log(`✓ ${name} ${verb}.`);
3901
+ console.log(` location: ${result.entry.packageDir}`);
3902
+ console.log(` version: ${result.entry.version ?? "(not declared)"}`);
3903
+ console.log(` digest: ${result.entry.packageJsonSha256.slice(0, 16)}…`);
3904
+ console.log(` If the package later moves or its package.json content changes, execution is refused until you re-run this command.`);
3905
+ console.log(` Revoke anytime with: flair bridge revoke ${name}`);
3906
+ }
3907
+ catch (err) {
3908
+ console.error(`Failed to approve "${name}": ${err?.message ?? err}`);
3909
+ process.exit(1);
3910
+ }
3911
+ });
3912
+ bridge
3913
+ .command("revoke <name>")
3914
+ .description("Revoke approval for an npm code-plugin bridge (future invocations will require `flair bridge allow <name>` again)")
3915
+ .action(async (name) => {
3916
+ const { revoke } = await import("./bridges/runtime/allow-list.js");
3917
+ const result = await revoke(name);
3918
+ if (!result.wasAllowed) {
3919
+ console.log(`${name} was not on the allow-list — no change.`);
3920
+ return;
3921
+ }
3922
+ console.log(`✓ ${name} revoked. Future invocations require \`flair bridge allow ${name}\` again.`);
3923
+ });
3924
+ bridge
3925
+ .command("allow-list")
3926
+ .description("Show the allow-listed code-plugin bridges")
3927
+ .option("--json", "Emit raw JSON")
3928
+ .action(async (opts) => {
3929
+ const { list: listAllowed } = await import("./bridges/runtime/allow-list.js");
3930
+ const entries = await listAllowed();
3931
+ if (opts.json) {
3932
+ console.log(JSON.stringify(entries, null, 2));
3933
+ return;
3934
+ }
3935
+ if (entries.length === 0) {
3936
+ console.log("No code-plugin bridges are allow-listed yet.");
3937
+ console.log("Allow one with: flair bridge allow <name>");
3938
+ return;
3939
+ }
3940
+ const nameW = Math.max(4, ...entries.map((e) => e.name.length));
3941
+ const verW = Math.max(7, ...entries.map((e) => (e.version ?? "—").length));
3942
+ console.log(` ${"name".padEnd(nameW)} ${"version".padEnd(verW)} allowed-at location / digest`);
3943
+ for (const e of entries) {
3944
+ console.log(` ${e.name.padEnd(nameW)} ${(e.version ?? "—").padEnd(verW)} ${e.allowedAt} ${e.packageDir}`);
3945
+ console.log(` ${" ".repeat(nameW)} ${" ".repeat(verW)} ${" ".repeat(24)} sha256:${e.packageJsonSha256.slice(0, 16)}…`);
3946
+ }
3691
3947
  });
3692
3948
  function printBridgeError(err) {
3693
3949
  // Pretty-print BridgeRuntimeError as the structured shape from §10 of the
3694
3950
  // spec, plus a one-line human summary so the operator gets both.
3695
3951
  const detail = err?.detail;
3696
3952
  if (detail && typeof detail === "object") {
3953
+ // Trust-check failures get a dedicated, operator-facing rendering.
3954
+ // Dumping the full spec-§10 JSON is useful when an operator is
3955
+ // debugging a broken YAML descriptor; for trust errors it buries the
3956
+ // one thing that matters — the command to re-approve.
3957
+ if (detail.field === "(trust)") {
3958
+ printTrustError(detail);
3959
+ return;
3960
+ }
3697
3961
  console.error(`Bridge error: ${detail.hint ?? err.message}`);
3698
3962
  console.error(JSON.stringify(detail, null, 2));
3699
3963
  }
@@ -3701,6 +3965,73 @@ function printBridgeError(err) {
3701
3965
  console.error(`Bridge error: ${err.message ?? String(err)}`);
3702
3966
  }
3703
3967
  }
3968
+ function printTrustError(detail) {
3969
+ const name = detail.bridge ?? "(unknown)";
3970
+ const ctx = detail.context ?? {};
3971
+ const reapprove = ` flair bridge allow ${name}`;
3972
+ const bar = "─".repeat(60);
3973
+ const header = (title) => {
3974
+ console.error("");
3975
+ console.error(`⚠ ${title} — ${name}`);
3976
+ console.error(bar);
3977
+ };
3978
+ const footer = (label) => {
3979
+ console.error("");
3980
+ console.error(`${label}:`);
3981
+ console.error(reapprove);
3982
+ console.error("");
3983
+ };
3984
+ switch (detail.got) {
3985
+ case "not-allowed":
3986
+ header("Approval required");
3987
+ console.error("This bridge is an npm code plugin — it runs arbitrary JavaScript.");
3988
+ console.error("First-use approval is required before Flair will execute it.");
3989
+ footer("Approve it with");
3990
+ return;
3991
+ case "path-mismatch":
3992
+ header("Trust check failed: package location changed");
3993
+ console.error("A different package with the same name was discovered. This is how");
3994
+ console.error("local squatting attacks present — a planted `node_modules/flair-bridge-*`");
3995
+ console.error("in an unrelated project tree.");
3996
+ console.error("");
3997
+ console.error(` approved: ${ctx.approvedPath ?? "(unknown)"}`);
3998
+ console.error(` version ${ctx.approvedVersion ?? "?"} at ${ctx.approvedAt ?? "?"}`);
3999
+ console.error(` now: ${ctx.observedPath ?? "(unknown)"}`);
4000
+ footer("If the new location is intentional, re-approve");
4001
+ return;
4002
+ case "digest-mismatch":
4003
+ header("Trust check failed: package contents changed");
4004
+ console.error("The package.json at the approved location has changed since you");
4005
+ console.error("approved this bridge. This fires on every upgrade — it's a trust");
4006
+ console.error("event, not an error. If the update is intentional, re-approve.");
4007
+ console.error("");
4008
+ console.error(` location: ${ctx.packagePath ?? "(unknown)"}`);
4009
+ console.error(` approved version: ${ctx.approvedVersion ?? "?"} (at ${ctx.approvedAt ?? "?"})`);
4010
+ console.error(` approved digest: sha256:${(ctx.approvedDigest ?? "").slice(0, 16)}…`);
4011
+ console.error(` observed digest: sha256:${(ctx.observedDigest ?? "").slice(0, 16)}…`);
4012
+ footer("Re-approve");
4013
+ return;
4014
+ case "entry-incomplete":
4015
+ header("Trust check failed: approval record is incomplete");
4016
+ console.error("The allow-list entry for this bridge is missing a location or digest.");
4017
+ console.error("This usually means the record was created by a pre-fix Flair version");
4018
+ console.error("(0.6.0 / 0.6.1) that only stored the name. Re-approve to upgrade.");
4019
+ footer("Re-approve");
4020
+ return;
4021
+ case "package-missing":
4022
+ header("Trust check failed: approved package missing on disk");
4023
+ console.error("The package location recorded at allow-time is no longer readable.");
4024
+ console.error("");
4025
+ console.error(` approved at: ${ctx.approvedPath ?? "(unknown)"}`);
4026
+ console.error(` discovered: ${ctx.discoveredPath ?? "(unknown)"}`);
4027
+ footer("Reinstall the package, then re-approve");
4028
+ return;
4029
+ default:
4030
+ // Unknown trust sub-reason — fall back to the raw structured print.
4031
+ console.error(`Bridge error (trust): ${detail.hint ?? detail.got ?? "unknown"}`);
4032
+ console.error(JSON.stringify(detail, null, 2));
4033
+ }
4034
+ }
3704
4035
  // ─── flair backup ────────────────────────────────────────────────────────────
3705
4036
  program
3706
4037
  .command("backup")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -1,46 +0,0 @@
1
- /**
2
- * Unified descriptor loader.
3
- *
4
- * Given a discovered bridge, resolve it into a `YamlBridgeDescriptor`
5
- * that the runtime executor can drive — regardless of whether the
6
- * descriptor came from the in-tree built-in registry, a project-local
7
- * YAML, a user-scoped YAML, or an npm package.
8
- *
9
- * Code-plugin bridges (Shape B, npm packages) are not supported in
10
- * slice 2 — calling `loadDescriptor` on one returns a structured error
11
- * pointing at slice 3.
12
- */
13
- import { BridgeRuntimeError } from "../types.js";
14
- import { loadYamlDescriptor } from "./yaml-loader.js";
15
- import { BUILTIN_BY_NAME } from "../builtins/index.js";
16
- export async function loadDescriptor(discovered) {
17
- switch (discovered.source) {
18
- case "builtin": {
19
- const builtin = BUILTIN_BY_NAME.get(discovered.name);
20
- if (!builtin) {
21
- throw new BridgeRuntimeError({
22
- bridge: discovered.name,
23
- op: "import",
24
- field: "(builtin)",
25
- expected: `registered built-in name`,
26
- got: discovered.name,
27
- hint: `discovery surfaced built-in "${discovered.name}" but the registry has no entry — likely a code drift between discover.ts and builtins/index.ts`,
28
- });
29
- }
30
- return builtin.descriptor;
31
- }
32
- case "project-yaml":
33
- case "user-yaml":
34
- return await loadYamlDescriptor(discovered.path);
35
- case "npm-package":
36
- throw new BridgeRuntimeError({
37
- bridge: discovered.name,
38
- op: "import",
39
- path: discovered.path,
40
- field: "(kind)",
41
- expected: "yaml file or built-in",
42
- got: "npm code plugin",
43
- hint: "code-plugin bridge runtime ships in slice 3 of FLAIR-BRIDGES (alongside the `flair bridge allow` trust prompt). Use a YAML descriptor in the meantime, or wait for slice 3.",
44
- });
45
- }
46
- }