@tpsdev-ai/flair 0.20.0 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -4
- package/SECURITY.md +28 -18
- package/dist/cli.js +482 -32
- package/dist/deploy.js +280 -18
- package/dist/doctor-client.js +312 -0
- package/dist/install/clients.js +18 -0
- package/dist/rem/restore.js +1 -1
- package/dist/resources/Admin.js +1 -1
- package/dist/resources/AdminConnectors.js +1 -1
- package/dist/resources/AdminDashboard.js +1 -1
- package/dist/resources/AdminIdp.js +1 -1
- package/dist/resources/AdminInstance.js +1 -1
- package/dist/resources/AdminMemory.js +2 -2
- package/dist/resources/AdminPrincipals.js +1 -1
- package/dist/resources/Agent.js +14 -0
- package/dist/resources/AgentCard.js +1 -1
- package/dist/resources/AgentSeed.js +1 -1
- package/dist/resources/Federation.js +98 -2
- package/dist/resources/IngestEvents.js +1 -1
- package/dist/resources/Integration.js +1 -1
- package/dist/resources/Memory.js +123 -17
- package/dist/resources/MemoryBootstrap.js +46 -36
- package/dist/resources/MemoryGrant.js +1 -1
- package/dist/resources/OAuth.js +61 -4
- package/dist/resources/OrgEventCatchup.js +1 -1
- package/dist/resources/Presence.js +55 -3
- package/dist/resources/Relationship.js +10 -1
- package/dist/resources/SemanticSearch.js +14 -14
- package/dist/resources/Soul.js +14 -0
- package/dist/resources/WorkspaceLatest.js +1 -1
- package/dist/resources/WorkspaceState.js +1 -1
- package/dist/resources/agent-auth.js +1 -1
- package/dist/resources/agentcard-fields.js +2 -2
- package/dist/resources/auth-middleware.js +24 -5
- package/dist/resources/bm25-filter.js +1 -1
- package/dist/resources/bm25.js +1 -1
- package/dist/resources/dedup.js +2 -2
- package/dist/resources/ed25519-auth.js +2 -2
- package/dist/resources/embeddings-provider.js +1 -1
- package/dist/resources/federation-nonce-store.js +195 -0
- package/dist/resources/instance-identity.js +53 -0
- package/dist/resources/memory-bootstrap-lib.js +1 -1
- package/dist/resources/memory-read-scope.js +58 -71
- package/dist/resources/memory-visibility.js +37 -0
- package/dist/version-check.js +167 -0
- package/package.json +2 -2
- package/schemas/agent.graphql +7 -0
- package/schemas/federation.graphql +12 -0
- package/schemas/memory.graphql +16 -0
package/dist/cli.js
CHANGED
|
@@ -12,7 +12,9 @@ import { create as tarCreate, extract as tarExtract, list as tarList } from "tar
|
|
|
12
12
|
import { keystore } from "./keystore.js";
|
|
13
13
|
import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
|
|
14
14
|
import { fabricUpgrade } from "./fabric-upgrade.js";
|
|
15
|
-
import {
|
|
15
|
+
import { checkVersion, formatVersionNudge } from "./version-check.js";
|
|
16
|
+
import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
17
|
+
import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, } from "./doctor-client.js";
|
|
16
18
|
// Federation crypto helpers — inlined to avoid cross-boundary imports from
|
|
17
19
|
// src/ into resources/, which don't survive npm packaging (see also
|
|
18
20
|
// resources/federation-crypto.ts; the two must stay in sync).
|
|
@@ -35,6 +37,39 @@ function signBody(body, secretKey) {
|
|
|
35
37
|
const sig = nacl.sign.detached(message, secretKey);
|
|
36
38
|
return Buffer.from(sig).toString("base64url");
|
|
37
39
|
}
|
|
40
|
+
// Per-record principalId (federation-edge-hardening slice 3a) — INFORMATIONAL
|
|
41
|
+
// only; the receiver (resources/Federation.ts) never treats it as verified
|
|
42
|
+
// identity or uses it in any auth decision. Sourced from the write-time
|
|
43
|
+
// provenance stamp (memory-provenance slice 1, Memory.ts's buildProvenance)
|
|
44
|
+
// when present. `provenance` is persisted as a JSON STRING (not an object),
|
|
45
|
+
// so it must be parsed — a raw `row.provenance?.verified?.agentId` would
|
|
46
|
+
// silently always be undefined. Soul/Agent/Relationship rows never carry a
|
|
47
|
+
// provenance stamp today, so this is a no-op for them.
|
|
48
|
+
function principalIdFromRow(row) {
|
|
49
|
+
if (typeof row?.provenance !== "string" || row.provenance.length === 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
try {
|
|
52
|
+
return JSON.parse(row.provenance)?.verified?.agentId ?? undefined;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Federation push private-visibility filter — inlined for the SAME reason as
|
|
59
|
+
// the crypto helpers above (see comment there; also resources/memory-
|
|
60
|
+
// visibility.ts, the canonical definition; the two must stay in sync).
|
|
61
|
+
//
|
|
62
|
+
// federation-edge-hardening slice 2 (the office-visibility read leak: one rule, one place): the
|
|
63
|
+
// push side of federation sync (runFederationSyncOnce below) must exclude
|
|
64
|
+
// `private` Memory rows from what gets sent to peers, using the EXACT same
|
|
65
|
+
// "not private" semantics as resources/memory-read-scope.ts's resolveReadScope()
|
|
66
|
+
// — a record with NO visibility field (legacy, pre-dates the field) is NOT
|
|
67
|
+
// private and must keep syncing exactly as before. Only `visibility ===
|
|
68
|
+
// "private"` is excluded; null/undefined/"shared"/anything else is included.
|
|
69
|
+
const FEDERATION_PRIVATE_VISIBILITY = "private";
|
|
70
|
+
function isFederationPrivateVisibility(visibility) {
|
|
71
|
+
return visibility === FEDERATION_PRIVATE_VISIBILITY;
|
|
72
|
+
}
|
|
38
73
|
// ─── Secret detection helpers ────────────────────────
|
|
39
74
|
/**
|
|
40
75
|
* Check if a value looks like a real secret/password/token.
|
|
@@ -113,6 +148,38 @@ export function readAdminPassFileSecure(path) {
|
|
|
113
148
|
}
|
|
114
149
|
return content;
|
|
115
150
|
}
|
|
151
|
+
function defaultAdminPassPath() {
|
|
152
|
+
return join(homedir(), ".flair", "admin-pass");
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
|
|
156
|
+
* `principal add`) without requiring `--admin-pass` on every call (#590).
|
|
157
|
+
*
|
|
158
|
+
* Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
|
|
159
|
+
* env → the secure `~/.flair/admin-pass` file `flair init` already writes with
|
|
160
|
+
* mode 0600 (read via `readAdminPassFileSecure`, which enforces that mode).
|
|
161
|
+
*
|
|
162
|
+
* When `isRemoteTarget` is true, ONLY the explicit value is honored — the env
|
|
163
|
+
* and file legs are skipped entirely. This is the security-critical guard: a
|
|
164
|
+
* `--target`/`--ops-target` deploy must never silently reuse THIS machine's
|
|
165
|
+
* local admin secret against someone else's Harper instance. Remote callers
|
|
166
|
+
* keep requiring an explicit `--admin-pass`.
|
|
167
|
+
*
|
|
168
|
+
* Throws (via readAdminPassFileSecure) if the file exists but has unsafe
|
|
169
|
+
* permissions, so a misconfigured file surfaces as an actionable chmod error
|
|
170
|
+
* instead of a generic "admin pass required" message.
|
|
171
|
+
*/
|
|
172
|
+
function resolveLocalAdminPass(explicit, isRemoteTarget = false, adminPassPath = defaultAdminPassPath()) {
|
|
173
|
+
if (explicit)
|
|
174
|
+
return explicit;
|
|
175
|
+
if (isRemoteTarget)
|
|
176
|
+
return undefined;
|
|
177
|
+
if (process.env.FLAIR_ADMIN_PASS)
|
|
178
|
+
return process.env.FLAIR_ADMIN_PASS;
|
|
179
|
+
if (!existsSync(adminPassPath))
|
|
180
|
+
return undefined;
|
|
181
|
+
return readAdminPassFileSecure(adminPassPath);
|
|
182
|
+
}
|
|
116
183
|
function defaultKeysDir() {
|
|
117
184
|
return join(homedir(), ".flair", "keys");
|
|
118
185
|
}
|
|
@@ -558,6 +625,90 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
558
625
|
}
|
|
559
626
|
}
|
|
560
627
|
}
|
|
628
|
+
// ─── Doctor: client-integration network checks (flair#588) ────────────────────
|
|
629
|
+
//
|
|
630
|
+
// The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
|
|
631
|
+
// live in src/doctor-client.ts. These two are network-dependent and live here
|
|
632
|
+
// because they reuse authFetch/resolveKeyPath, which are private to this file.
|
|
633
|
+
/**
|
|
634
|
+
* Quick, offline-tolerant reachability probe for a Flair instance's HTTP
|
|
635
|
+
* endpoint — GETs /Health with a short timeout. Never hangs, never throws:
|
|
636
|
+
* any failure (timeout, DNS, connection refused, bad URL) is "unreachable".
|
|
637
|
+
* Mirrors the doctor action's own probePort helper (3000ms AbortSignal.timeout
|
|
638
|
+
* style), but takes a full URL since client configs point at arbitrary hosts.
|
|
639
|
+
*/
|
|
640
|
+
export async function probeFlairReachable(url, timeoutMs = 2000) {
|
|
641
|
+
try {
|
|
642
|
+
const res = await fetch(`${url.replace(/\/+$/, "")}/Health`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
643
|
+
return res.status > 0;
|
|
644
|
+
}
|
|
645
|
+
catch {
|
|
646
|
+
return false;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
/**
|
|
650
|
+
* Is `agentId` actually registered on the Flair instance at `baseUrl`? Signs
|
|
651
|
+
* GET /Agent/:id with the agent's own key (same pattern as the `flair init`
|
|
652
|
+
* verification at line ~2043 and `flair agent rotate` at line ~2596) —
|
|
653
|
+
* reuses authFetch/resolveKeyPath rather than duplicating the signing logic.
|
|
654
|
+
*
|
|
655
|
+
* 200 -> "registered"
|
|
656
|
+
* 401/403 carrying the server's "unknown_agent" signal -> "not-registered"
|
|
657
|
+
* (see below — this is the actual live behavior for a missing agent, NOT
|
|
658
|
+
* 404)
|
|
659
|
+
* any other status, or a network error/timeout -> "unreachable" (could not
|
|
660
|
+
* verify one way or the other — e.g. a bare 401/403/500 doesn't tell us
|
|
661
|
+
* whether the agent exists, so we don't claim NOT registered on those)
|
|
662
|
+
* no local key found for agentId (checked resolveKeyPath, then keysDir) -> "no-key"
|
|
663
|
+
* (can't sign the request at all — distinct from "unreachable" so the
|
|
664
|
+
* caller can print an accurate reason)
|
|
665
|
+
*
|
|
666
|
+
* Why not 404: an unregistered agent never actually reaches the /Agent/:id
|
|
667
|
+
* resource handler (which is where a 404 would come from) — Flair's own
|
|
668
|
+
* signed-auth middleware (resources/auth-middleware.ts) rejects the request
|
|
669
|
+
* first, once it can't find an Agent record matching the signing identity.
|
|
670
|
+
* On current main that's an explicit `401 {"error":"unknown_agent"}` — Live-
|
|
671
|
+
* verified 2026-07-07 against a local Flair instance with a resolvable-but-
|
|
672
|
+
* unregistered signing key: `401 Unauthorized`, body `{"error":"unknown_agent"}`.
|
|
673
|
+
* Some server versions/paths may instead surface Harper's native
|
|
674
|
+
* AccessViolation as a 403 for the same condition, so both codes are checked
|
|
675
|
+
* — but ONLY when the response also carries the unknown-agent marker; a bare
|
|
676
|
+
* 401/403 without it (e.g. a real AccessViolation for an agent that exists
|
|
677
|
+
* but fails a resource-level authorization check) stays "unreachable", since
|
|
678
|
+
* the server can't always distinguish "agent doesn't exist" from "signing key
|
|
679
|
+
* doesn't match a known agent" and we don't want to falsely claim
|
|
680
|
+
* not-registered on that ambiguity. We only make the not-registered call
|
|
681
|
+
* because we ALREADY have a local signing key that resolved for this
|
|
682
|
+
* agentId (checked above) — so this isn't a client-side key problem, and the
|
|
683
|
+
* server naming the agent unknown is a reliable, actionable signal.
|
|
684
|
+
*/
|
|
685
|
+
export async function checkAgentRegistered(baseUrl, agentId, keysDir) {
|
|
686
|
+
let keyPath = resolveKeyPath(agentId);
|
|
687
|
+
if (!keyPath) {
|
|
688
|
+
const candidate = join(keysDir, `${agentId}.key`);
|
|
689
|
+
if (existsSync(candidate))
|
|
690
|
+
keyPath = candidate;
|
|
691
|
+
}
|
|
692
|
+
if (!keyPath) {
|
|
693
|
+
return { state: "no-key", detail: `no local key for agent '${agentId}' to sign the check` };
|
|
694
|
+
}
|
|
695
|
+
try {
|
|
696
|
+
const res = await authFetch(baseUrl, agentId, keyPath, "GET", `/Agent/${agentId}`);
|
|
697
|
+
if (res.ok)
|
|
698
|
+
return { state: "registered" };
|
|
699
|
+
if (res.status === 404)
|
|
700
|
+
return { state: "not-registered" };
|
|
701
|
+
const text = await res.text().catch(() => "");
|
|
702
|
+
if ((res.status === 401 || res.status === 403) && /unknown_agent/i.test(text)) {
|
|
703
|
+
return { state: "not-registered", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
|
|
704
|
+
}
|
|
705
|
+
return { state: "unreachable", detail: `HTTP ${res.status} ${text.slice(0, 80)}` };
|
|
706
|
+
}
|
|
707
|
+
catch (err) {
|
|
708
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
709
|
+
return { state: "unreachable", detail: `instance unreachable: ${message.slice(0, 100)}` };
|
|
710
|
+
}
|
|
711
|
+
}
|
|
561
712
|
// Blocks until the given PID is gone (ESRCH from signal 0), or timeout.
|
|
562
713
|
// Used during restart to confirm the old Harper process actually exited before
|
|
563
714
|
// we start polling /Health — otherwise the still-shutting-down old process can
|
|
@@ -689,7 +840,7 @@ export async function seedFederationInstanceViaOpsApi(opsPortOrUrl, instanceId,
|
|
|
689
840
|
throw new Error(`Federation Instance insert via ops API failed (${res.status}): ${text}`);
|
|
690
841
|
}
|
|
691
842
|
}
|
|
692
|
-
// ─── Provision Flair on Harper Fabric
|
|
843
|
+
// ─── Provision Flair on Harper Fabric ──────────────────────────────────────
|
|
693
844
|
//
|
|
694
845
|
// Atomic provisioning for a fresh Harper Fabric cluster: builds a deploy
|
|
695
846
|
// tarball with .env baked in, deploys via ops API, waits for restart, and
|
|
@@ -782,7 +933,7 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
|
|
|
782
933
|
await waitForFlairRestart(target);
|
|
783
934
|
console.log("Flair is running ✓");
|
|
784
935
|
// 3. Provision Harper super_user
|
|
785
|
-
// Since
|
|
936
|
+
// Since custom-admin-username support is merged, the username doesn't have to be "admin".
|
|
786
937
|
// We can use the cluster-admin user directly if it's already a super_user.
|
|
787
938
|
// Check if cluster admin is already a super_user first:
|
|
788
939
|
let clusterAdminIsSuperUser = false;
|
|
@@ -1161,7 +1312,7 @@ export function probeOpenclawPluginVersion(extensionName) {
|
|
|
1161
1312
|
/**
|
|
1162
1313
|
* Whether a package's status line should be printed in the default `flair
|
|
1163
1314
|
* upgrade` listing. Suppresses optional-because-openclaw-is-absent lines
|
|
1164
|
-
*
|
|
1315
|
+
* — pure noise on machines without openclaw — unless `--all`
|
|
1165
1316
|
* (showAll) is set. All other statuses always print.
|
|
1166
1317
|
*/
|
|
1167
1318
|
export function shouldPrintUpgradeLine(status, showAll) {
|
|
@@ -1367,6 +1518,8 @@ program
|
|
|
1367
1518
|
.option("--client <client>", "MCP client(s) to wire: claude-code, codex, gemini, cursor, all, or none")
|
|
1368
1519
|
.option("--no-mcp", "Skip MCP client wiring (instance + agent only)")
|
|
1369
1520
|
.option("--skip-smoke", "Skip the MCP smoke test")
|
|
1521
|
+
.option("--skip-claude-md", "Skip appending the Flair bootstrap line to CLAUDE.md (claude-code only)")
|
|
1522
|
+
.option("--skip-hook", "Skip installing the flair-session-start SessionStart hook (claude-code only)")
|
|
1370
1523
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
1371
1524
|
.option("--remote", "When used with --target, init as hub for remote federation")
|
|
1372
1525
|
.option("--ops-target <url>", "Explicit ops API URL (env: FLAIR_OPS_TARGET; bypasses port derivation)")
|
|
@@ -1906,8 +2059,6 @@ program
|
|
|
1906
2059
|
console.log(`\n Soul prompts skipped (${reason}). Add entries with:`);
|
|
1907
2060
|
console.log(` flair soul set --agent ${agentId} --key role --value "..."`);
|
|
1908
2061
|
}
|
|
1909
|
-
console.log(`\n Claude Code: Add to your CLAUDE.md:`);
|
|
1910
|
-
console.log(` At the start of every session, run mcp__flair__bootstrap before responding.`);
|
|
1911
2062
|
// ── MCP client wiring ────────────────────────────────────────────────
|
|
1912
2063
|
// The full one-command front door: detect installed MCP clients and wire
|
|
1913
2064
|
// each to the zero-install `npx -y @tpsdev-ai/flair-mcp` server. Claude
|
|
@@ -1976,6 +2127,30 @@ program
|
|
|
1976
2127
|
console.log(` { "mcpServers": { "flair": ${JSON.stringify(flairMcpConfig)} } }`);
|
|
1977
2128
|
wiringResults.push({ client: "claude-code", message: "snippet printed" });
|
|
1978
2129
|
}
|
|
2130
|
+
// ── CLAUDE.md bootstrap line (flair#597) ──────────────────────────
|
|
2131
|
+
// The MCP block alone isn't a working setup — Claude Code also needs
|
|
2132
|
+
// the bootstrap instruction in CLAUDE.md, or it never calls
|
|
2133
|
+
// mcp__flair__bootstrap and memory silently does nothing. Applied
|
|
2134
|
+
// automatically here (same "just do it" shape as the MCP block
|
|
2135
|
+
// above); --skip-claude-md opts out and prints the exact line to
|
|
2136
|
+
// add by hand instead.
|
|
2137
|
+
const claudeMdResult = applyOrReportClaudeMdBootstrap(process.cwd(), homedir(), !!opts.skipClaudeMd);
|
|
2138
|
+
console.log(` ${claudeMdResult.ok ? "✓" : "•"} ${claudeMdResult.message}`);
|
|
2139
|
+
if (claudeMdResult.hint) {
|
|
2140
|
+
for (const line of claudeMdResult.hint.split("\n"))
|
|
2141
|
+
console.log(` ${line}`);
|
|
2142
|
+
}
|
|
2143
|
+
// ── SessionStart hook (flair#597) ─────────────────────────────────
|
|
2144
|
+
// Auto-recall on session start needs this hook wired into
|
|
2145
|
+
// ~/.claude/settings.json — without it, mcp__flair__bootstrap only
|
|
2146
|
+
// ever runs if the agent remembers to call it itself.
|
|
2147
|
+
// --skip-hook opts out and prints the exact JSON to add by hand.
|
|
2148
|
+
const hookResult = applyOrReportSessionStartHook(homedir(), agentId, !!opts.skipHook);
|
|
2149
|
+
console.log(` ${hookResult.ok ? "✓" : "•"} ${hookResult.message}`);
|
|
2150
|
+
if (hookResult.hint) {
|
|
2151
|
+
for (const line of hookResult.hint.split("\n"))
|
|
2152
|
+
console.log(` ${line}`);
|
|
2153
|
+
}
|
|
1979
2154
|
}
|
|
1980
2155
|
else {
|
|
1981
2156
|
let result;
|
|
@@ -2116,7 +2291,6 @@ agent
|
|
|
2116
2291
|
const httpPort = resolveHttpPort(opts);
|
|
2117
2292
|
const opsPort = resolveOpsPort(opts);
|
|
2118
2293
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
2119
|
-
const adminPass = opts.adminPass;
|
|
2120
2294
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
2121
2295
|
const name = opts.name ?? id;
|
|
2122
2296
|
// Where to seed the Agent record. Default is localhost (opsPort). When
|
|
@@ -2124,8 +2298,27 @@ agent
|
|
|
2124
2298
|
// (#514 — agent add could only ever hit localhost ops). Precedence matches
|
|
2125
2299
|
// `flair import`: explicit --ops-target > derive from --target > localhost.
|
|
2126
2300
|
const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
|
|
2301
|
+
const isRemoteTarget = typeof seedOpsTarget === "string";
|
|
2302
|
+
// #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
|
|
2303
|
+
// ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
|
|
2304
|
+
// applied for a remote target — see resolveLocalAdminPass.
|
|
2305
|
+
let adminPass;
|
|
2306
|
+
try {
|
|
2307
|
+
adminPass = resolveLocalAdminPass(opts.adminPass, isRemoteTarget);
|
|
2308
|
+
}
|
|
2309
|
+
catch (err) {
|
|
2310
|
+
console.error(`Error: ${err.message}`);
|
|
2311
|
+
process.exit(1);
|
|
2312
|
+
}
|
|
2127
2313
|
if (!adminPass) {
|
|
2128
|
-
|
|
2314
|
+
if (isRemoteTarget) {
|
|
2315
|
+
console.error("Error: --admin-pass is required for agent add when targeting a remote instance " +
|
|
2316
|
+
"(--target/--ops-target) — the local ~/.flair/admin-pass fallback is not used for remote targets.");
|
|
2317
|
+
}
|
|
2318
|
+
else {
|
|
2319
|
+
console.error("Error: --admin-pass is required for agent add (needed to insert into Agent table). " +
|
|
2320
|
+
"Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
|
|
2321
|
+
}
|
|
2129
2322
|
process.exit(1);
|
|
2130
2323
|
}
|
|
2131
2324
|
mkdirSync(keysDir, { recursive: true });
|
|
@@ -2501,15 +2694,26 @@ principal
|
|
|
2501
2694
|
.action(async (id, opts) => {
|
|
2502
2695
|
const opsPort = resolveOpsPort(opts);
|
|
2503
2696
|
const keysDir = opts.keysDir ?? defaultKeysDir();
|
|
2504
|
-
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS;
|
|
2505
2697
|
const adminUser = DEFAULT_ADMIN_USER;
|
|
2506
2698
|
const kind = opts.kind ?? "agent";
|
|
2507
2699
|
const name = opts.name ?? id;
|
|
2508
2700
|
const isAdmin = opts.admin ?? false;
|
|
2509
2701
|
const trustTier = opts.trust ?? (isAdmin ? "endorsed" : "unverified");
|
|
2510
2702
|
const runtime = opts.runtime;
|
|
2703
|
+
// #590 — same local-only fallback as `agent add`: FLAIR_ADMIN_PASS env, then
|
|
2704
|
+
// the secure ~/.flair/admin-pass file (mode 0600). `principal add` has no
|
|
2705
|
+
// --target/--ops-target (always localhost), so the fallback always applies.
|
|
2706
|
+
let adminPass;
|
|
2707
|
+
try {
|
|
2708
|
+
adminPass = resolveLocalAdminPass(opts.adminPass);
|
|
2709
|
+
}
|
|
2710
|
+
catch (err) {
|
|
2711
|
+
console.error(`Error: ${err.message}`);
|
|
2712
|
+
process.exit(1);
|
|
2713
|
+
}
|
|
2511
2714
|
if (!adminPass) {
|
|
2512
|
-
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required"
|
|
2715
|
+
console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required (or ensure ~/.flair/admin-pass exists, " +
|
|
2716
|
+
"created by `flair init`)");
|
|
2513
2717
|
process.exit(1);
|
|
2514
2718
|
}
|
|
2515
2719
|
// Generate Ed25519 keypair (agents always get one; humans get one for instance-attestation)
|
|
@@ -3662,15 +3866,54 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3662
3866
|
}
|
|
3663
3867
|
const batch = await res.json();
|
|
3664
3868
|
// For null-updatedAt rows, use createdAt as the effective timestamp.
|
|
3665
|
-
// Skip rows created before the last sync cursor.
|
|
3666
|
-
|
|
3869
|
+
// Skip rows created before the last sync cursor. Only Memory carries
|
|
3870
|
+
// a `visibility` field (Soul/Agent/Relationship don't — see
|
|
3871
|
+
// schemas/memory.graphql vs agent.graphql), so the private-exclusion
|
|
3872
|
+
// filter only applies there; on the other 3 tables `row.visibility`
|
|
3873
|
+
// is always undefined, which isFederationPrivateVisibility() treats
|
|
3874
|
+
// as non-private (included) — a no-op for them.
|
|
3875
|
+
rows = rows.concat(batch
|
|
3876
|
+
.filter((r) => r.updatedAt !== null || r.createdAt > since)
|
|
3877
|
+
.filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility)));
|
|
3667
3878
|
}
|
|
3668
3879
|
if (rows.length === 0)
|
|
3669
3880
|
continue;
|
|
3881
|
+
// Records are signed (below) before they're batched, so the secret key
|
|
3882
|
+
// is needed here rather than only inside sendBatch. Still deferred
|
|
3883
|
+
// until we know THIS table has rows to send — preserves the "don't
|
|
3884
|
+
// load the key on a no-op run" property the original lazy load had.
|
|
3885
|
+
if (!secretKey)
|
|
3886
|
+
secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
3670
3887
|
let batch = [];
|
|
3671
3888
|
let batchBytes = 0;
|
|
3672
3889
|
for (const row of rows) {
|
|
3673
|
-
const
|
|
3890
|
+
const updatedAt = row.updatedAt ?? row.createdAt;
|
|
3891
|
+
const originatorInstanceId = instance.id;
|
|
3892
|
+
// Per-record signature (federation-edge-hardening slice 3a): signed by
|
|
3893
|
+
// THIS instance — the originator — over a versioned canonical form, so
|
|
3894
|
+
// a receiver (including a hub relaying this record onward to other
|
|
3895
|
+
// spokes) can verify authorship independent of who forwarded the
|
|
3896
|
+
// batch. Closes the hub-relay forgery hole — see
|
|
3897
|
+
// resources/Federation.ts FederationSync.post's verification gate.
|
|
3898
|
+
//
|
|
3899
|
+
// CONTRACT — must match Federation.ts's verification payload
|
|
3900
|
+
// byte-for-byte: keys { v, table, id, data, updatedAt,
|
|
3901
|
+
// originatorInstanceId }. canonicalize() sorts keys, so field ORDER
|
|
3902
|
+
// doesn't matter, but the field SET and values do. `v: 1` versions the
|
|
3903
|
+
// canonical form itself: bump it on BOTH sides together if the signed
|
|
3904
|
+
// field set ever changes, so an old signature fails closed instead of
|
|
3905
|
+
// silently mis-verifying under a new form.
|
|
3906
|
+
//
|
|
3907
|
+
// Additive/backward-compatible: pre-3a receivers don't read
|
|
3908
|
+
// `signature`/`principalId` at all and merge exactly as before.
|
|
3909
|
+
const signature = signBody({ v: 1, table, id: row.id, data: row, updatedAt, originatorInstanceId }, secretKey);
|
|
3910
|
+
const sr = { table, id: row.id, data: row, updatedAt, originatorInstanceId, signature };
|
|
3911
|
+
// Informational only (see principalIdFromRow) — never verified by the
|
|
3912
|
+
// receiver as proof of authorship. Omitted entirely when the row
|
|
3913
|
+
// carries no write-time provenance stamp.
|
|
3914
|
+
const principalId = principalIdFromRow(row);
|
|
3915
|
+
if (principalId)
|
|
3916
|
+
sr.principalId = principalId;
|
|
3674
3917
|
const srBytes = JSON.stringify(sr).length;
|
|
3675
3918
|
if (batch.length >= BUDGET_RECORDS || (batch.length > 0 && batchBytes + srBytes > BUDGET_BYTES)) {
|
|
3676
3919
|
const result = await sendBatch(batch);
|
|
@@ -3721,7 +3964,7 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3721
3964
|
console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
|
|
3722
3965
|
}
|
|
3723
3966
|
if (totalBatches === 0) {
|
|
3724
|
-
//
|
|
3967
|
+
// No-change syncs must still ping the hub so it updates the
|
|
3725
3968
|
// spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
|
|
3726
3969
|
// look indistinguishable from dead ones on the hub dashboard.
|
|
3727
3970
|
try {
|
|
@@ -4964,7 +5207,7 @@ function oauthDetailLines(o) {
|
|
|
4964
5207
|
}
|
|
4965
5208
|
// Common localhost ports a running Flair daemon might be on. Used by
|
|
4966
5209
|
// discoverLocalFlairPort when the configured URL is unreachable, to detect
|
|
4967
|
-
// config-vs-daemon port drift
|
|
5210
|
+
// config-vs-daemon port drift. Order is ad-hoc — first hit wins.
|
|
4968
5211
|
//
|
|
4969
5212
|
// 9926: original default (long-running early installs predate the bump)
|
|
4970
5213
|
// 19926: current default (DEFAULT_PORT)
|
|
@@ -5081,16 +5324,23 @@ const statusCmd = program
|
|
|
5081
5324
|
.action(async (opts) => {
|
|
5082
5325
|
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts);
|
|
5083
5326
|
// When unreachable on a localhost URL, probe candidate ports to detect
|
|
5084
|
-
// config-vs-daemon port drift
|
|
5327
|
+
// config-vs-daemon port drift. Surface the actually-listening
|
|
5085
5328
|
// port with a fix recipe — better UX than just "unreachable."
|
|
5086
5329
|
let discoveredPort = null;
|
|
5087
5330
|
if (!healthy && isLocalhostUrl(baseUrl)) {
|
|
5088
5331
|
discoveredPort = await discoverLocalFlairPort(baseUrl);
|
|
5089
5332
|
}
|
|
5333
|
+
// Version-behind check (flair#587) — offline-tolerant + cached, so this
|
|
5334
|
+
// never adds meaningful latency or fails `status` when the registry is
|
|
5335
|
+
// unreachable. Independent of Harper health; runs either way.
|
|
5336
|
+
const versionCheckResult = await checkVersion(__pkgVersion);
|
|
5337
|
+
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
5090
5338
|
if (opts.json) {
|
|
5091
5339
|
const out = { healthy, url: baseUrl, flairVersion: __pkgVersion, ...healthData };
|
|
5092
5340
|
if (discoveredPort != null)
|
|
5093
5341
|
out.discoveredPort = discoveredPort;
|
|
5342
|
+
if (versionCheckResult.latest)
|
|
5343
|
+
out.latestVersion = versionCheckResult.latest;
|
|
5094
5344
|
console.log(JSON.stringify(out, null, 2));
|
|
5095
5345
|
if (!healthy)
|
|
5096
5346
|
process.exit(1);
|
|
@@ -5110,6 +5360,10 @@ const statusCmd = program
|
|
|
5110
5360
|
else {
|
|
5111
5361
|
console.log(`\n Run: flair start or flair doctor`);
|
|
5112
5362
|
}
|
|
5363
|
+
if (versionNudge) {
|
|
5364
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
5365
|
+
console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
|
|
5366
|
+
}
|
|
5113
5367
|
process.exit(1);
|
|
5114
5368
|
}
|
|
5115
5369
|
const uptimeSec = healthData?.uptimeSeconds;
|
|
@@ -5162,6 +5416,10 @@ const statusCmd = program
|
|
|
5162
5416
|
const metaParts = [pidPart, uptimePart].filter(Boolean).join(render.wrap(render.c.dim, " · "));
|
|
5163
5417
|
console.log(`${versionStr} ${render.wrap(render.c.dim, "—")} ${runStatus}${metaParts ? ` ${metaParts}` : ""}`);
|
|
5164
5418
|
console.log(render.kv("URL", baseUrl));
|
|
5419
|
+
if (versionNudge) {
|
|
5420
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
5421
|
+
console.log(`\n ${render.wrap(color, "⚠")} ${render.wrap(color, versionNudge.message)}`);
|
|
5422
|
+
}
|
|
5165
5423
|
if (scopedWarnings.length > 0) {
|
|
5166
5424
|
console.log(`\n${render.wrap(render.c.bold, "Warnings")} ${render.wrap(render.c.dim, `(${scopedWarnings.length})`)}`);
|
|
5167
5425
|
for (const w of scopedWarnings) {
|
|
@@ -5884,7 +6142,7 @@ program
|
|
|
5884
6142
|
kind: "bin",
|
|
5885
6143
|
// Older flair-mcp installs (e.g. 0.10.0) either aren't on PATH or
|
|
5886
6144
|
// don't support `--version`, so the bin probe returns null even when
|
|
5887
|
-
// the package IS globally installed
|
|
6145
|
+
// the package IS globally installed. Fall back to the lib
|
|
5888
6146
|
// probe, which require.resolves the package.json from a sibling global
|
|
5889
6147
|
// install regardless of PATH or --version support. kind stays "bin" so
|
|
5890
6148
|
// it remains npm-upgradeable (npm install -g), not the openclaw path.
|
|
@@ -5927,7 +6185,7 @@ program
|
|
|
5927
6185
|
}
|
|
5928
6186
|
findings.push({ name, installed, latest, status, kind });
|
|
5929
6187
|
// Suppress the line for openclaw plugins that are optional-because-
|
|
5930
|
-
// openclaw-is-absent
|
|
6188
|
+
// openclaw-is-absent: on machines without openclaw the
|
|
5931
6189
|
// "○ … not installed (openclaw not detected) → … (install via …)"
|
|
5932
6190
|
// line is pure noise. Still print it when openclaw IS installed
|
|
5933
6191
|
// (current/outdated) or under --all.
|
|
@@ -5946,7 +6204,7 @@ program
|
|
|
5946
6204
|
}
|
|
5947
6205
|
catch { /* skip unavailable packages */ }
|
|
5948
6206
|
}
|
|
5949
|
-
// Scope footer
|
|
6207
|
+
// Scope footer: make explicit what `flair upgrade` does and
|
|
5950
6208
|
// doesn't cover, so "were the others checked?" has a one-line answer.
|
|
5951
6209
|
console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
|
|
5952
6210
|
const outdated = findings.filter((f) => f.status === "outdated");
|
|
@@ -6648,9 +6906,17 @@ program
|
|
|
6648
6906
|
.option("--no-restart", "Do not restart the component after deploy (default: restart=true)")
|
|
6649
6907
|
.option("--dry-run", "Resolve package, validate args, skip the deploy call")
|
|
6650
6908
|
.option("--package-root <dir>", "Override package root (mainly for testing)")
|
|
6909
|
+
.option("--deployment-timeout <ms>", "Milliseconds harper waits for cluster-wide peer replication (env: FABRIC_DEPLOYMENT_TIMEOUT; default: 600000 — harper's own 120s default is too short for Fabric)")
|
|
6910
|
+
.option("--install-timeout <ms>", "Milliseconds harper waits for package install (env: FABRIC_INSTALL_TIMEOUT; default: 600000)")
|
|
6911
|
+
.option("--no-verify", "Skip post-deploy served-API verification (default: verify — on by design, so the CLI can't report success on an empty/broken deploy)")
|
|
6912
|
+
.option("--verify-timeout <ms>", "Milliseconds to wait for the served API to settle after harper's post-deploy restart before verifying (default: 300000)")
|
|
6913
|
+
.option("--verify-resource <name>", "Resource to verify is serving after deploy (repeatable; default: derived from the deployed package's dist/resources)", (val, prev) => [...prev, val], [])
|
|
6914
|
+
.option("--deploy-retries <n>", "Retry the full harper deploy this many times on a detected flaky peer-replication failure ONLY — a normal deploy failure (auth, bad package, ...) never retries (default: 2; 0 disables)", "2")
|
|
6915
|
+
.option("--ignore-replication-errors", "If peer replication is still failing once retries are exhausted, treat it as a non-fatal warning and succeed with an origin-only deploy (the peer catches up via federation sync or a later deploy)")
|
|
6651
6916
|
.action(async (opts) => {
|
|
6652
6917
|
const green = (s) => `\x1b[32m${s}\x1b[0m`;
|
|
6653
6918
|
const red = (s) => `\x1b[31m${s}\x1b[0m`;
|
|
6919
|
+
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
|
6654
6920
|
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
|
6655
6921
|
const deployOpts = {
|
|
6656
6922
|
fabricOrg: opts.fabricOrg ?? process.env.FABRIC_ORG,
|
|
@@ -6665,6 +6931,14 @@ program
|
|
|
6665
6931
|
restart: opts.restart !== false,
|
|
6666
6932
|
dryRun: opts.dryRun ?? false,
|
|
6667
6933
|
packageRoot: opts.packageRoot,
|
|
6934
|
+
deploymentTimeoutMs: Number(opts.deploymentTimeout ?? process.env.FABRIC_DEPLOYMENT_TIMEOUT ?? 600_000),
|
|
6935
|
+
installTimeoutMs: Number(opts.installTimeout ?? process.env.FABRIC_INSTALL_TIMEOUT ?? 600_000),
|
|
6936
|
+
verify: opts.verify !== false,
|
|
6937
|
+
verifyResources: opts.verifyResource?.length ? opts.verifyResource : undefined,
|
|
6938
|
+
verifyTimeoutMs: Number(opts.verifyTimeout ?? 300_000),
|
|
6939
|
+
deployRetries: Number(opts.deployRetries ?? 2),
|
|
6940
|
+
ignoreReplicationErrors: opts.ignoreReplicationErrors ?? false,
|
|
6941
|
+
onProgress: (msg) => console.log(dim(` ${msg}`)),
|
|
6668
6942
|
};
|
|
6669
6943
|
const errors = validateDeployOptions(deployOpts);
|
|
6670
6944
|
if (errors.length) {
|
|
@@ -6689,7 +6963,12 @@ program
|
|
|
6689
6963
|
console.log(dim(` package root: ${result.packageRoot}`));
|
|
6690
6964
|
return;
|
|
6691
6965
|
}
|
|
6692
|
-
|
|
6966
|
+
if (result.replicationWarning) {
|
|
6967
|
+
console.log(`\n${yellow("⚠")} Flair ${result.version} deployed to the ORIGIN NODE ONLY — peer replication did not complete (see warning above). The peer will catch up via federation sync or a later deploy.`);
|
|
6968
|
+
}
|
|
6969
|
+
else {
|
|
6970
|
+
console.log(`\n${green("✓")} Flair ${result.version} deployed${deployOpts.verify ? " and verified serving" : ""}`);
|
|
6971
|
+
}
|
|
6693
6972
|
console.log(`\n URL: ${result.url}`);
|
|
6694
6973
|
console.log(` Project: ${result.project}`);
|
|
6695
6974
|
console.log(`\nNext steps:`);
|
|
@@ -6703,6 +6982,15 @@ program
|
|
|
6703
6982
|
if (hint?.includes("401") || hint?.includes("unauthoriz")) {
|
|
6704
6983
|
console.error(dim(" hint: check Fabric Studio → Cluster Settings → Admin for the admin password"));
|
|
6705
6984
|
}
|
|
6985
|
+
if (hint?.includes("component is not serving")) {
|
|
6986
|
+
console.error(dim(" hint: harper reported success but the served API disagrees — check the Fabric Studio component logs for the real deploy error, then retry"));
|
|
6987
|
+
}
|
|
6988
|
+
if (hint?.includes("did not settle")) {
|
|
6989
|
+
console.error(dim(" hint: Harper may still be restarting — check Fabric Studio, or retry with a longer --verify-timeout"));
|
|
6990
|
+
}
|
|
6991
|
+
if (hint?.includes("peer replication failed after")) {
|
|
6992
|
+
console.error(dim(" hint: pass --ignore-replication-errors to accept an origin-only deploy, or re-run once the peer link recovers"));
|
|
6993
|
+
}
|
|
6706
6994
|
process.exit(1);
|
|
6707
6995
|
}
|
|
6708
6996
|
});
|
|
@@ -6726,6 +7014,24 @@ program
|
|
|
6726
7014
|
let issues = 0;
|
|
6727
7015
|
let harperResponding = false;
|
|
6728
7016
|
console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
|
|
7017
|
+
// 0. Version check (flair#587) — offline-tolerant + cached, independent
|
|
7018
|
+
// of Harper being up. A gap of ≥2 minor versions (or any major) is
|
|
7019
|
+
// treated as loud/red — heuristic for "likely missed a security fix"
|
|
7020
|
+
// since we don't have advisory data, only the version gap. A red gap
|
|
7021
|
+
// counts as an issue (exit 1); a quieter yellow gap (one minor, or
|
|
7022
|
+
// patch-only) is printed but doesn't fail doctor.
|
|
7023
|
+
const versionCheckResult = await checkVersion(__pkgVersion);
|
|
7024
|
+
const versionNudge = formatVersionNudge(versionCheckResult);
|
|
7025
|
+
if (versionNudge) {
|
|
7026
|
+
const color = versionNudge.severity === "red" ? render.c.red : render.c.yellow;
|
|
7027
|
+
const icon = versionNudge.severity === "red" ? render.wrap(render.c.red, "✗") : render.icons.warn;
|
|
7028
|
+
console.log(` ${icon} ${render.wrap(color, versionNudge.message)}`);
|
|
7029
|
+
if (versionNudge.severity === "red")
|
|
7030
|
+
issues++;
|
|
7031
|
+
}
|
|
7032
|
+
else if (versionCheckResult.latest) {
|
|
7033
|
+
console.log(` ${render.icons.ok} flair ${__pkgVersion} is current`);
|
|
7034
|
+
}
|
|
6729
7035
|
// Helper: try to reach Harper on a given port
|
|
6730
7036
|
async function probePort(p) {
|
|
6731
7037
|
try {
|
|
@@ -6952,6 +7258,150 @@ program
|
|
|
6952
7258
|
issues++;
|
|
6953
7259
|
}
|
|
6954
7260
|
}
|
|
7261
|
+
// 7. Client integration (flair#588) — the first 6 checks diagnose the
|
|
7262
|
+
// SERVER side. This diagnoses whether Flair is actually wired to a real
|
|
7263
|
+
// MCP client (Claude Code, Codex, Gemini, Cursor): the MCP block present
|
|
7264
|
+
// + reachable + the configured agent genuinely registered (every detected
|
|
7265
|
+
// client), plus CLAUDE.md + the SessionStart hook (Claude Code only,
|
|
7266
|
+
// since only Claude Code has those mechanisms). Reuses detectClients()
|
|
7267
|
+
// rather than reimplementing client detection.
|
|
7268
|
+
console.log(`\n ${render.wrap(render.c.bold, "Client integration")}`);
|
|
7269
|
+
// Prompt y/N before a content-editing fix, but only when interactive —
|
|
7270
|
+
// in a non-TTY context (CI, scripts) --fix itself is the consent signal,
|
|
7271
|
+
// matching how doctor's other --fix branches already behave unprompted.
|
|
7272
|
+
// Mirrors the confirm pattern at `flair fabric upgrade` (~line 6258).
|
|
7273
|
+
async function confirmFix(question) {
|
|
7274
|
+
if (!process.stdin.isTTY)
|
|
7275
|
+
return true;
|
|
7276
|
+
const { createInterface } = await import("node:readline");
|
|
7277
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
7278
|
+
const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
|
|
7279
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
7280
|
+
}
|
|
7281
|
+
const detectedClients = detectClients().filter((c) => c.detected);
|
|
7282
|
+
if (detectedClients.length === 0) {
|
|
7283
|
+
console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
|
|
7284
|
+
}
|
|
7285
|
+
else {
|
|
7286
|
+
let claudeCodeAgentId;
|
|
7287
|
+
let anyKnownAgentId;
|
|
7288
|
+
for (const client of detectedClients) {
|
|
7289
|
+
const block = readClientMcpBlock(client.id, homedir());
|
|
7290
|
+
if (client.id === "claude-code" && block.agentId)
|
|
7291
|
+
claudeCodeAgentId = block.agentId;
|
|
7292
|
+
if (block.agentId)
|
|
7293
|
+
anyKnownAgentId = anyKnownAgentId ?? block.agentId;
|
|
7294
|
+
if (!block.present) {
|
|
7295
|
+
console.log(` ${render.icons.error} ${client.label}: no Flair MCP server configured in ${render.wrap(render.c.dim, block.configPath)}`);
|
|
7296
|
+
if (autoFix) {
|
|
7297
|
+
if (dryRun) {
|
|
7298
|
+
console.log(` ${render.wrap(render.c.dim, "Would wire")} ${client.label} (writes ${block.configPath})`);
|
|
7299
|
+
}
|
|
7300
|
+
else {
|
|
7301
|
+
const proceed = await confirmFix(` Wire ${client.label} now? [y/N] `);
|
|
7302
|
+
if (!proceed) {
|
|
7303
|
+
console.log(` Skipped.`);
|
|
7304
|
+
}
|
|
7305
|
+
else {
|
|
7306
|
+
const fixAgentId = opts.agent || process.env.FLAIR_AGENT_ID || anyKnownAgentId;
|
|
7307
|
+
if (!fixAgentId) {
|
|
7308
|
+
console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
|
|
7309
|
+
}
|
|
7310
|
+
else {
|
|
7311
|
+
const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: block.flairUrl || baseUrl };
|
|
7312
|
+
const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
|
|
7313
|
+
client.id === "codex" ? wireCodex(wireEnv) :
|
|
7314
|
+
client.id === "gemini" ? wireGemini(wireEnv) :
|
|
7315
|
+
wireCursor(wireEnv);
|
|
7316
|
+
console.log(` ${wireResult.ok ? render.icons.ok : render.icons.warn} ${wireResult.message}`);
|
|
7317
|
+
}
|
|
7318
|
+
}
|
|
7319
|
+
}
|
|
7320
|
+
}
|
|
7321
|
+
else {
|
|
7322
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, `(wires ${client.label} automatically)`)}`);
|
|
7323
|
+
}
|
|
7324
|
+
issues++;
|
|
7325
|
+
continue;
|
|
7326
|
+
}
|
|
7327
|
+
console.log(` ${render.icons.ok} ${client.label}: MCP server configured (${render.wrap(render.c.dim, block.configPath)})`);
|
|
7328
|
+
const reachable = await probeFlairReachable(block.flairUrl);
|
|
7329
|
+
if (!reachable) {
|
|
7330
|
+
console.log(` ${render.icons.warn} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} not reachable — cannot verify agent registration`);
|
|
7331
|
+
continue;
|
|
7332
|
+
}
|
|
7333
|
+
console.log(` ${render.icons.ok} FLAIR_URL ${render.wrap(render.c.dim, block.flairUrl)} reachable`);
|
|
7334
|
+
const reg = await checkAgentRegistered(block.flairUrl, block.agentId, defaultKeysDir());
|
|
7335
|
+
if (reg.state === "registered") {
|
|
7336
|
+
console.log(` ${render.icons.ok} agent '${block.agentId}' registered`);
|
|
7337
|
+
}
|
|
7338
|
+
else if (reg.state === "not-registered") {
|
|
7339
|
+
console.log(` ${render.icons.error} agent '${block.agentId}' is NOT registered on this Flair instance`);
|
|
7340
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair agent add ${block.agentId}`);
|
|
7341
|
+
issues++;
|
|
7342
|
+
}
|
|
7343
|
+
else {
|
|
7344
|
+
console.log(` ${render.icons.warn} could not verify agent registration ${render.wrap(render.c.dim, `(${reg.detail})`)}`);
|
|
7345
|
+
}
|
|
7346
|
+
}
|
|
7347
|
+
// Claude-Code-specific: CLAUDE.md + SessionStart hook. Only Claude Code
|
|
7348
|
+
// has these mechanisms, so only run them when claude-code was detected.
|
|
7349
|
+
if (detectedClients.some((c) => c.id === "claude-code")) {
|
|
7350
|
+
const claudeMd = checkClaudeMdBootstrap(process.cwd(), homedir());
|
|
7351
|
+
if (claudeMd.present) {
|
|
7352
|
+
console.log(` ${render.icons.ok} CLAUDE.md: bootstrap instruction present (${render.wrap(render.c.dim, claudeMd.path)})`);
|
|
7353
|
+
}
|
|
7354
|
+
else {
|
|
7355
|
+
console.log(` ${render.icons.error} CLAUDE.md: bootstrap instruction not found (checked ${render.wrap(render.c.dim, join(process.cwd(), "CLAUDE.md"))} and ${render.wrap(render.c.dim, join(homedir(), ".claude", "CLAUDE.md"))})`);
|
|
7356
|
+
if (autoFix) {
|
|
7357
|
+
if (dryRun) {
|
|
7358
|
+
console.log(` ${render.wrap(render.c.dim, "Would append bootstrap instruction to")} ${join(process.cwd(), "CLAUDE.md")}`);
|
|
7359
|
+
}
|
|
7360
|
+
else {
|
|
7361
|
+
const proceed = await confirmFix(` Add the Flair bootstrap line to ./CLAUDE.md? [y/N] `);
|
|
7362
|
+
if (!proceed) {
|
|
7363
|
+
console.log(` Skipped.`);
|
|
7364
|
+
}
|
|
7365
|
+
else {
|
|
7366
|
+
const fixRes = fixClaudeMdBootstrap(process.cwd());
|
|
7367
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
7368
|
+
}
|
|
7369
|
+
}
|
|
7370
|
+
}
|
|
7371
|
+
else {
|
|
7372
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the mcp__flair__bootstrap line to ./CLAUDE.md)")}`);
|
|
7373
|
+
}
|
|
7374
|
+
issues++;
|
|
7375
|
+
}
|
|
7376
|
+
const hook = checkSessionStartHook(homedir());
|
|
7377
|
+
if (hook.present) {
|
|
7378
|
+
console.log(` ${render.icons.ok} SessionStart hook: flair-session-start wired in ${render.wrap(render.c.dim, hook.path)}`);
|
|
7379
|
+
}
|
|
7380
|
+
else {
|
|
7381
|
+
console.log(` ${render.icons.error} SessionStart hook: not found in ${render.wrap(render.c.dim, hook.path)}`);
|
|
7382
|
+
if (autoFix) {
|
|
7383
|
+
if (dryRun) {
|
|
7384
|
+
console.log(` ${render.wrap(render.c.dim, "Would add SessionStart hook to")} ${hook.path}`);
|
|
7385
|
+
}
|
|
7386
|
+
else {
|
|
7387
|
+
const proceed = await confirmFix(` Add the flair-session-start SessionStart hook to ${hook.path}? [y/N] `);
|
|
7388
|
+
if (!proceed) {
|
|
7389
|
+
console.log(` Skipped.`);
|
|
7390
|
+
}
|
|
7391
|
+
else {
|
|
7392
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
7393
|
+
const fixRes = fixSessionStartHook(homedir(), fixAgentId);
|
|
7394
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
7395
|
+
}
|
|
7396
|
+
}
|
|
7397
|
+
}
|
|
7398
|
+
else {
|
|
7399
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(adds the flair-session-start SessionStart hook)")}`);
|
|
7400
|
+
}
|
|
7401
|
+
issues++;
|
|
7402
|
+
}
|
|
7403
|
+
}
|
|
7404
|
+
}
|
|
6955
7405
|
// Summary
|
|
6956
7406
|
console.log("");
|
|
6957
7407
|
if (issues === 0) {
|
|
@@ -6965,7 +7415,7 @@ program
|
|
|
6965
7415
|
process.exit(1);
|
|
6966
7416
|
});
|
|
6967
7417
|
// ─── flair session snapshot ──────────────────────────────────────────────────
|
|
6968
|
-
// Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B
|
|
7418
|
+
// Slice 2 of FLAIR-AGENT-CONTEXT-TIERS-B. Snapshot a
|
|
6969
7419
|
// session jsonl + label metadata into a tar.gz under ~/.flair/snapshots/<agent>/sessions/.
|
|
6970
7420
|
//
|
|
6971
7421
|
// Three subcommands: create | list | restore. Mirrors FLAIR-NIGHTLY-REM's
|
|
@@ -7120,10 +7570,10 @@ const memory = program.command("memory").description("Manage agent memories");
|
|
|
7120
7570
|
memory.command("add [content]").requiredOption("--agent <id>")
|
|
7121
7571
|
.option("--content <text>", "memory content (alias for positional arg)")
|
|
7122
7572
|
.option("--durability <d>", "standard").option("--tags <csv>")
|
|
7123
|
-
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content
|
|
7573
|
+
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content)")
|
|
7124
7574
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
7125
7575
|
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
7126
|
-
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to
|
|
7576
|
+
.option("--visibility <value>", "Writer-controlled sharing intent (sets Memory.visibility): 'private' (owner-only, never visible to any other agent) or 'shared' (visible to owner + every other agent on this instance — open within the org, not gated by a MemoryGrant). Omit to use the server's durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private (flair#509)")
|
|
7127
7577
|
.action(async (contentArg, opts) => {
|
|
7128
7578
|
const content = contentArg ?? opts.content;
|
|
7129
7579
|
if (!content) {
|
|
@@ -7149,7 +7599,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
7149
7599
|
console.log(JSON.stringify(out, null, 2));
|
|
7150
7600
|
});
|
|
7151
7601
|
// ─── flair memory write-task-summary ────────────────────────────────────────
|
|
7152
|
-
// Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B
|
|
7602
|
+
// Slice 1 of FLAIR-AGENT-CONTEXT-TIERS-B. Standalone
|
|
7153
7603
|
// helper that any agent harness (or a manual operator) can invoke at task
|
|
7154
7604
|
// close to capture a structured task summary as a persistent Memory row
|
|
7155
7605
|
// before resetting the session.
|
|
@@ -7166,7 +7616,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
7166
7616
|
memory.command("write-task-summary")
|
|
7167
7617
|
.description("Capture a structured task summary as a persistent Memory row (used by session-reset harness; standalone-callable by operators)")
|
|
7168
7618
|
.requiredOption("--agent <id>", "Agent the summary belongs to")
|
|
7169
|
-
.requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about
|
|
7619
|
+
.requiredOption("--beads <ops-id>", "Bead/PR/task identifier this summary is about")
|
|
7170
7620
|
.requiredOption("--outcome <s>", "Outcome of the task: merged | rejected | abandoned")
|
|
7171
7621
|
.option("--summary <text>", "Multi-sentence dense compression (populates Memory.summary; will be the agent's read-time view)")
|
|
7172
7622
|
.option("--files-touched <csv>", "Comma-separated list of files touched during the task (becomes part of content)")
|
|
@@ -7362,7 +7812,7 @@ memory.command("list")
|
|
|
7362
7812
|
});
|
|
7363
7813
|
// ─── flair memory hygiene ────────────────────────────────────────────────────
|
|
7364
7814
|
// Detect + remove junk memory rows that accumulate over time. Surfaced from
|
|
7365
|
-
//
|
|
7815
|
+
// a 2026-05-07 manual cleanup: an instance had 627 records, ~250 of
|
|
7366
7816
|
// them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
|
|
7367
7817
|
// test content ("the quick brown fox..." / "Flair 251 test ..."), and
|
|
7368
7818
|
// near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
|
|
@@ -7377,8 +7827,8 @@ memory.command("list")
|
|
|
7377
7827
|
// Default is all three, dry-run. Flip --apply to actually delete. Always
|
|
7378
7828
|
// requires admin pass to read across agent scopes (uses ops API).
|
|
7379
7829
|
//
|
|
7380
|
-
// Federation note: this only deletes on the local instance.
|
|
7381
|
-
//
|
|
7830
|
+
// Federation note: this only deletes on the local instance. Federation
|
|
7831
|
+
// distributed-delete via tombstones is the systemic answer for
|
|
7382
7832
|
// fan-out — until that lands, run `flair memory hygiene` on each peer.
|
|
7383
7833
|
// Exported for unit testing — keeps the predicate logic separable from
|
|
7384
7834
|
// the CLI plumbing, ops-API fetching, and confirmation flow.
|
|
@@ -7498,7 +7948,7 @@ memory.command("hygiene")
|
|
|
7498
7948
|
console.log(`\n\n✅ Deleted ${deleted} rows.`);
|
|
7499
7949
|
console.log("");
|
|
7500
7950
|
console.log("Note: this is a local-instance delete. Federated peers will keep their copies until");
|
|
7501
|
-
console.log("
|
|
7951
|
+
console.log("tombstone-based distributed delete lands. Until then, run `flair memory");
|
|
7502
7952
|
console.log("hygiene --apply` on each peer to fan out.");
|
|
7503
7953
|
});
|
|
7504
7954
|
// ─── flair search (top-level shortcut) ───────────────────────────────────────
|
|
@@ -9236,7 +9686,7 @@ presence
|
|
|
9236
9686
|
});
|
|
9237
9687
|
// ─── flair workspace ─────────────────────────────────────────────────────────
|
|
9238
9688
|
//
|
|
9239
|
-
// Coordination write surface (
|
|
9689
|
+
// Coordination write surface (Kris #510). `workspace set` writes the
|
|
9240
9690
|
// agent's OWN WorkspaceState via a signed POST /WorkspaceState. Identity comes
|
|
9241
9691
|
// from the Ed25519 signature — the body carries NO agentId, so an agent can only
|
|
9242
9692
|
// write as itself (the WorkspaceState.post() handler overwrites agentId from the
|
|
@@ -9306,7 +9756,7 @@ workspace
|
|
|
9306
9756
|
});
|
|
9307
9757
|
// ─── flair orgevent ──────────────────────────────────────────────────────────
|
|
9308
9758
|
//
|
|
9309
|
-
// Coordination write surface (
|
|
9759
|
+
// Coordination write surface (Kris #510). `orgevent` publishes an
|
|
9310
9760
|
// OrgEvent ATTRIBUTED to the authenticated agent via a signed POST /OrgEvent.
|
|
9311
9761
|
// The event's authorId comes from the Ed25519 signature — the body carries NO
|
|
9312
9762
|
// authorId, so an agent CANNOT forge another agent's events (the OrgEvent.post()
|
|
@@ -9389,4 +9839,4 @@ if (import.meta.main) {
|
|
|
9389
9839
|
await runCli();
|
|
9390
9840
|
}
|
|
9391
9841
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
9392
|
-
export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
|
|
9842
|
+
export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, };
|