@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.
- package/dist/bridges/runtime/allow-list.js +198 -0
- package/dist/bridges/runtime/import-runner.js +7 -3
- package/dist/bridges/runtime/load-bridge.js +126 -0
- package/dist/bridges/runtime/load-plugin.js +127 -0
- package/dist/bridges/runtime/roundtrip.js +154 -0
- package/dist/cli.js +392 -61
- package/package.json +1 -1
- package/dist/bridges/runtime/load-descriptor.js +0 -46
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
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
|
|
2375
|
-
for (const
|
|
2439
|
+
const findings = [];
|
|
2440
|
+
for (const { name, probe } of packages) {
|
|
2376
2441
|
try {
|
|
2377
|
-
const res = await fetch(`https://registry.npmjs.org/${
|
|
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
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
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
|
-
|
|
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${
|
|
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
|
-
|
|
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 {
|
|
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
|
|
3557
|
+
let loaded;
|
|
3485
3558
|
try {
|
|
3486
|
-
|
|
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${
|
|
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${
|
|
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
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
|
|
3548
|
-
|
|
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 {
|
|
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
|
|
3689
|
+
let loaded;
|
|
3592
3690
|
try {
|
|
3593
|
-
|
|
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${
|
|
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${
|
|
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
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
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>
|
|
3685
|
-
.description("
|
|
3686
|
-
.
|
|
3687
|
-
.
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
process.
|
|
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.
|
|
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
|
-
}
|