@tpsdev-ai/flair 0.6.1 → 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/cli.js +230 -35
- 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
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -3544,7 +3544,7 @@ bridge
|
|
|
3544
3544
|
const cwd = opts.cwd ?? srcArg ?? process.cwd();
|
|
3545
3545
|
const { discover } = await import("./bridges/discover.js");
|
|
3546
3546
|
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3547
|
-
const {
|
|
3547
|
+
const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
|
|
3548
3548
|
const { runImport } = await import("./bridges/runtime/import-runner.js");
|
|
3549
3549
|
const { makeContext } = await import("./bridges/runtime/context.js");
|
|
3550
3550
|
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
@@ -3554,9 +3554,9 @@ bridge
|
|
|
3554
3554
|
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3555
3555
|
process.exit(1);
|
|
3556
3556
|
}
|
|
3557
|
-
let
|
|
3557
|
+
let loaded;
|
|
3558
3558
|
try {
|
|
3559
|
-
|
|
3559
|
+
loaded = await loadBridge(target);
|
|
3560
3560
|
}
|
|
3561
3561
|
catch (err) {
|
|
3562
3562
|
printBridgeError(err);
|
|
@@ -3591,10 +3591,10 @@ bridge
|
|
|
3591
3591
|
if (ev.type === "done") {
|
|
3592
3592
|
const noun = (n) => `${n} ${n === 1 ? "memory" : "memories"}`;
|
|
3593
3593
|
if (opts.dryRun) {
|
|
3594
|
-
console.log(`\n${
|
|
3594
|
+
console.log(`\n${target.name}: would import ${noun(ev.total)}. Re-run without --dry-run to write to Flair.`);
|
|
3595
3595
|
}
|
|
3596
3596
|
else {
|
|
3597
|
-
console.log(`\n${
|
|
3597
|
+
console.log(`\n${target.name}: imported ${ev.imported}/${ev.total} memories${ev.skipped > 0 ? ` (${ev.skipped} skipped)` : ""}.`);
|
|
3598
3598
|
}
|
|
3599
3599
|
return;
|
|
3600
3600
|
}
|
|
@@ -3611,15 +3611,40 @@ bridge
|
|
|
3611
3611
|
}
|
|
3612
3612
|
};
|
|
3613
3613
|
try {
|
|
3614
|
-
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
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
|
+
}
|
|
3623
3648
|
}
|
|
3624
3649
|
catch (err) {
|
|
3625
3650
|
if (err instanceof BridgeRuntimeError) {
|
|
@@ -3651,7 +3676,7 @@ bridge
|
|
|
3651
3676
|
const cwd = opts.cwd ?? dst;
|
|
3652
3677
|
const { discover } = await import("./bridges/discover.js");
|
|
3653
3678
|
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3654
|
-
const {
|
|
3679
|
+
const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
|
|
3655
3680
|
const { runExport } = await import("./bridges/runtime/export-runner.js");
|
|
3656
3681
|
const { makeContext } = await import("./bridges/runtime/context.js");
|
|
3657
3682
|
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
@@ -3661,18 +3686,22 @@ bridge
|
|
|
3661
3686
|
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3662
3687
|
process.exit(1);
|
|
3663
3688
|
}
|
|
3664
|
-
let
|
|
3689
|
+
let loaded;
|
|
3665
3690
|
try {
|
|
3666
|
-
|
|
3691
|
+
loaded = await loadBridge(target);
|
|
3667
3692
|
}
|
|
3668
3693
|
catch (err) {
|
|
3669
3694
|
printBridgeError(err);
|
|
3670
3695
|
process.exit(1);
|
|
3671
3696
|
}
|
|
3672
|
-
if (!descriptor.export) {
|
|
3697
|
+
if (loaded.kind === "yaml" && !loaded.descriptor.export) {
|
|
3673
3698
|
console.error(`Bridge "${name}" has no export block — cannot export through it.`);
|
|
3674
3699
|
process.exit(1);
|
|
3675
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
|
+
}
|
|
3676
3705
|
const baseUrl = opts.url ?? `http://127.0.0.1:${resolveHttpPort(opts)}`;
|
|
3677
3706
|
const ctx = makeContext({ bridge: name });
|
|
3678
3707
|
// Memory fetcher — paginates GET /Memory?agentId=... applying any
|
|
@@ -3710,10 +3739,10 @@ bridge
|
|
|
3710
3739
|
const onProgress = (ev) => {
|
|
3711
3740
|
if (ev.type === "done") {
|
|
3712
3741
|
if (opts.dryRun) {
|
|
3713
|
-
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.`);
|
|
3714
3743
|
}
|
|
3715
3744
|
else {
|
|
3716
|
-
console.log(`\n${
|
|
3745
|
+
console.log(`\n${target.name}: exported ${ev.exported} memor${ev.exported === 1 ? "y" : "ies"} from ${ev.total} total.`);
|
|
3717
3746
|
}
|
|
3718
3747
|
return;
|
|
3719
3748
|
}
|
|
@@ -3733,15 +3762,28 @@ bridge
|
|
|
3733
3762
|
process.stdout.write(`\r filtering memory ${ev.ordinal}...`.padEnd(60));
|
|
3734
3763
|
};
|
|
3735
3764
|
try {
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
|
|
3743
|
-
|
|
3744
|
-
|
|
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
|
+
}
|
|
3745
3787
|
}
|
|
3746
3788
|
catch (err) {
|
|
3747
3789
|
if (err instanceof BridgeRuntimeError) {
|
|
@@ -3762,7 +3804,7 @@ bridge
|
|
|
3762
3804
|
const cwd = opts.cwd ?? process.cwd();
|
|
3763
3805
|
const { discover } = await import("./bridges/discover.js");
|
|
3764
3806
|
const { builtinDiscoveryRecords } = await import("./bridges/builtins/index.js");
|
|
3765
|
-
const {
|
|
3807
|
+
const { loadBridge } = await import("./bridges/runtime/load-bridge.js");
|
|
3766
3808
|
const { runRoundTrip } = await import("./bridges/runtime/roundtrip.js");
|
|
3767
3809
|
const { BridgeRuntimeError } = await import("./bridges/types.js");
|
|
3768
3810
|
const found = await discover({ builtins: builtinDiscoveryRecords() });
|
|
@@ -3771,25 +3813,30 @@ bridge
|
|
|
3771
3813
|
console.error(`No bridge named "${name}" — run \`flair bridge list\` to see installed bridges.`);
|
|
3772
3814
|
process.exit(1);
|
|
3773
3815
|
}
|
|
3774
|
-
let
|
|
3816
|
+
let loaded;
|
|
3775
3817
|
try {
|
|
3776
|
-
|
|
3818
|
+
loaded = await loadBridge(target);
|
|
3777
3819
|
}
|
|
3778
3820
|
catch (err) {
|
|
3779
3821
|
printBridgeError(err);
|
|
3780
3822
|
process.exit(1);
|
|
3781
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
|
+
}
|
|
3782
3829
|
try {
|
|
3783
|
-
const result = await runRoundTrip({ descriptor, cwd, fixturePath: opts.fixture });
|
|
3830
|
+
const result = await runRoundTrip({ descriptor: loaded.descriptor, cwd, fixturePath: opts.fixture });
|
|
3784
3831
|
if (opts.json) {
|
|
3785
3832
|
console.log(JSON.stringify(result, null, 2));
|
|
3786
3833
|
process.exit(result.passed ? 0 : 1);
|
|
3787
3834
|
}
|
|
3788
3835
|
if (result.passed) {
|
|
3789
|
-
console.log(`✅ ${
|
|
3836
|
+
console.log(`✅ ${target.name} round-trip passed (${result.expectedCount} record${result.expectedCount === 1 ? "" : "s"}).`);
|
|
3790
3837
|
process.exit(0);
|
|
3791
3838
|
}
|
|
3792
|
-
console.log(`❌ ${
|
|
3839
|
+
console.log(`❌ ${target.name} round-trip failed.`);
|
|
3793
3840
|
console.log(` expected ${result.expectedCount} records, got ${result.actualCount} back.`);
|
|
3794
3841
|
if (result.missingInPass2.length > 0) {
|
|
3795
3842
|
console.log(` missing from re-import (${result.missingInPass2.length}):`);
|
|
@@ -3825,11 +3872,92 @@ bridge
|
|
|
3825
3872
|
process.exit(1);
|
|
3826
3873
|
}
|
|
3827
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
|
+
}
|
|
3947
|
+
});
|
|
3828
3948
|
function printBridgeError(err) {
|
|
3829
3949
|
// Pretty-print BridgeRuntimeError as the structured shape from §10 of the
|
|
3830
3950
|
// spec, plus a one-line human summary so the operator gets both.
|
|
3831
3951
|
const detail = err?.detail;
|
|
3832
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
|
+
}
|
|
3833
3961
|
console.error(`Bridge error: ${detail.hint ?? err.message}`);
|
|
3834
3962
|
console.error(JSON.stringify(detail, null, 2));
|
|
3835
3963
|
}
|
|
@@ -3837,6 +3965,73 @@ function printBridgeError(err) {
|
|
|
3837
3965
|
console.error(`Bridge error: ${err.message ?? String(err)}`);
|
|
3838
3966
|
}
|
|
3839
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
|
+
}
|
|
3840
4035
|
// ─── flair backup ────────────────────────────────────────────────────────────
|
|
3841
4036
|
program
|
|
3842
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
|
-
}
|