@tpsdev-ai/flair 0.52.0 → 0.54.1
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 +4 -1
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1914 -15328
- package/dist/commands/agent.js +453 -0
- package/dist/commands/attention.js +121 -0
- package/dist/commands/backup.js +115 -0
- package/dist/commands/bootstrap.js +91 -0
- package/dist/commands/bridge.js +608 -0
- package/dist/commands/deploy.js +180 -0
- package/dist/commands/doctor.js +1654 -0
- package/dist/commands/export.js +110 -0
- package/dist/commands/federation.js +1575 -0
- package/dist/commands/fleet.js +73 -0
- package/dist/commands/grant.js +109 -0
- package/dist/commands/hook.js +193 -0
- package/dist/commands/idp.js +193 -0
- package/dist/commands/import.js +134 -0
- package/dist/commands/init.js +1203 -0
- package/dist/commands/inspect.js +45 -0
- package/dist/commands/keys.js +187 -0
- package/dist/commands/mcp.js +707 -0
- package/dist/commands/memory.js +501 -0
- package/dist/commands/migrate-harness-memory.js +270 -0
- package/dist/commands/orgevent.js +138 -0
- package/dist/commands/presence.js +76 -0
- package/dist/commands/principal.js +338 -0
- package/dist/commands/quality.js +1164 -0
- package/dist/commands/reembed.js +296 -0
- package/dist/commands/relationship.js +76 -0
- package/dist/commands/rem.js +1048 -0
- package/dist/commands/restore.js +130 -0
- package/dist/commands/search.js +244 -0
- package/dist/commands/service.js +315 -0
- package/dist/commands/session.js +184 -0
- package/dist/commands/soul.js +155 -0
- package/dist/commands/status.js +914 -0
- package/dist/commands/test.js +93 -0
- package/dist/commands/uninstall.js +143 -0
- package/dist/commands/upgrade.js +1592 -0
- package/dist/commands/workspace.js +114 -0
- package/dist/deploy.js +24 -0
- package/dist/fabric-npm-install.js +87 -0
- package/dist/federation-verify.js +498 -0
- package/dist/fleet-verify.js +144 -21
- package/dist/install/clients.js +167 -0
- package/dist/lib/auth-resolve.js +76 -1
- package/dist/lib/daemon-liveness.js +131 -2
- package/dist/lib/doctor-config-path.js +61 -0
- package/dist/lib/doctor-federation-driver.js +189 -0
- package/dist/lib/doctor-run.js +40 -0
- package/dist/lib/entity-vocab-cli.js +3 -3
- package/dist/lib/federation-pair-identity.js +47 -0
- package/dist/lib/launchd-repair.js +199 -0
- package/dist/lib/ops-api-bind.js +115 -0
- package/dist/lib/owned-pins.js +219 -0
- package/dist/lib/stabilize-mqtt-network.js +123 -0
- package/dist/lib/uninstall-purge.js +218 -0
- package/dist/rem/restore.js +8 -10
- package/dist/resources/AgentReadPosition.js +74 -0
- package/dist/resources/Federation.js +8 -2
- package/dist/resources/Memory.js +4 -3
- package/dist/resources/MemoryBootstrap.js +41 -25
- package/dist/resources/MemoryCandidate.js +5 -6
- package/dist/resources/OrgEventCatchup.js +126 -47
- package/dist/resources/agent-read-position-lib.js +83 -0
- package/dist/resources/agent-read-position.js +120 -0
- package/dist/resources/embeddings-boot.js +32 -0
- package/dist/resources/federation-peer-liveness.js +73 -0
- package/dist/resources/health.js +68 -19
- package/dist/resources/mcp-tools.js +43 -279
- package/dist/resources/memory-visibility.js +3 -3
- package/dist/resources/migration-boot.js +59 -18
- package/dist/resources/migrations/embedding-stamp.js +20 -1
- package/dist/resources/migrations/recheck.js +43 -0
- package/dist/resources/migrations/runner.js +6 -1
- package/dist/resources/migrations/stamp-outstanding.js +171 -0
- package/dist/resources/migrations/visibility-backfill.js +2 -2
- package/dist/resources/org-event-catchup-lib.js +47 -0
- package/dist/resources/record-owner-guard.js +1 -0
- package/dist/stamp-migration-verify.js +163 -0
- package/dist/stamp-outstanding.js +144 -0
- package/docs/api-reference.md +4 -2
- package/docs/deploying-on-fabric.md +11 -10
- package/docs/deployment.md +3 -1
- package/docs/federation.md +19 -0
- package/docs/hosted-on-fabric.md +3 -3
- package/docs/quickstart.md +2 -1
- package/docs/releasing.md +15 -7
- package/docs/spoke-bringup.md +10 -5
- package/docs/standalone-local.md +3 -1
- package/docs/upgrade.md +25 -6
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/LICENSE +19 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/README.md +22 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.d.ts +70 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/dist/index.js +665 -0
- package/node_modules/@tpsdev-ai/flair-tool-descriptors/package.json +46 -0
- package/package.json +9 -4
- package/schemas/agent.graphql +15 -0
- package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
package/dist/fleet-verify.js
CHANGED
|
@@ -38,40 +38,128 @@
|
|
|
38
38
|
* authenticated version check, same Fabric admin Basic-auth credentials as
|
|
39
39
|
* the origin — a Fabric cluster shares one admin realm). A peer with no
|
|
40
40
|
* usable endpoint is reported "unverifiable" — never silently dropped, never
|
|
41
|
-
* printed green
|
|
42
|
-
*
|
|
41
|
+
* printed green, and does not fail the run (flair#988). A reachable peer
|
|
42
|
+
* verified on the wrong version still fails as diverged. See classifyNode()'s
|
|
43
|
+
* "method" field: "direct" (we actually hit it) vs "none" (we could not
|
|
44
|
+
* attempt a check at all, and say why).
|
|
43
45
|
*/
|
|
44
46
|
import { probeInstance } from "./probe.js";
|
|
45
47
|
import * as render from "./render.js";
|
|
46
48
|
// ─── Exit codes (documented in `flair fleet verify --help`) ─────────────────
|
|
47
|
-
|
|
49
|
+
//
|
|
50
|
+
// Three operator-facing states (flair#988). UNVERIFIABLE (couldn't check)
|
|
51
|
+
// must never share an exit with DIVERGED (probed, verified wrong):
|
|
52
|
+
//
|
|
53
|
+
// 1. All probed nodes OK, none diverged → 0 "converged."
|
|
54
|
+
// Unverifiable peers are listed + warned; they do NOT fail the run.
|
|
55
|
+
// 2. Any reachable node DIVERGED (wrong version) → non-zero
|
|
56
|
+
// "NOT converged — <node> diverged."
|
|
57
|
+
// 3. Some peers UNVERIFIABLE, none diverged → 0 + WARNING
|
|
58
|
+
// (same exit as (1); a distinct warning exit would also be fine as
|
|
59
|
+
// long as it is not (2)'s code).
|
|
60
|
+
//
|
|
61
|
+
// Exit 3 remains for a reachable peer we *attempted* to probe (unreachable
|
|
62
|
+
// / auth-failed). That is a failed check, not "no endpoint on file."
|
|
63
|
+
/** All probed nodes verified. Unverifiable peers warn but do not fail (flair#988). */
|
|
48
64
|
export const FLEET_EXIT_OK = 0;
|
|
49
65
|
/** Origin failed — unreachable, unauthenticated, or running the wrong version. Worst case: the cluster's entrypoint itself is broken. */
|
|
50
66
|
export const FLEET_EXIT_ORIGIN_FAILED = 1;
|
|
51
|
-
/** Origin is fine, but at least one reachable peer is running a DIFFERENT version — a mixed-version fleet (the #638 scenario). */
|
|
67
|
+
/** Origin is fine, but at least one reachable peer is running a DIFFERENT version — a mixed-version fleet (the #638 scenario). This is DIVERGED. */
|
|
52
68
|
export const FLEET_EXIT_PEER_SKEW = 2;
|
|
53
|
-
/** Origin is fine, no version skew among reachable peers, but
|
|
69
|
+
/** Origin is fine, no version skew among reachable peers, but a reachable peer we attempted to probe was unreachable or rejected auth. */
|
|
54
70
|
export const FLEET_EXIT_PEER_UNREACHABLE = 3;
|
|
55
71
|
export const FLEET_EXIT_DESCRIPTIONS = {
|
|
56
|
-
[FLEET_EXIT_OK]: "all nodes verified
|
|
72
|
+
[FLEET_EXIT_OK]: "all probed nodes verified (unverifiable peers are listed as a warning and do not fail)",
|
|
57
73
|
[FLEET_EXIT_ORIGIN_FAILED]: "origin failed (unreachable, unauthenticated, or wrong version)",
|
|
58
|
-
[FLEET_EXIT_PEER_SKEW]: "origin OK, but a reachable peer
|
|
59
|
-
[FLEET_EXIT_PEER_UNREACHABLE]: "origin OK, no skew among reachable peers, but a peer
|
|
74
|
+
[FLEET_EXIT_PEER_SKEW]: "origin OK, but a reachable peer diverged (wrong version)",
|
|
75
|
+
[FLEET_EXIT_PEER_UNREACHABLE]: "origin OK, no skew among reachable peers, but a reachable peer was unreachable or rejected auth",
|
|
60
76
|
};
|
|
77
|
+
function isDivergedStatus(status) {
|
|
78
|
+
return status === "skew";
|
|
79
|
+
}
|
|
80
|
+
function isCheckFailedStatus(status) {
|
|
81
|
+
return status === "unreachable" || status === "auth-failed";
|
|
82
|
+
}
|
|
83
|
+
function formatUnverifiableWarning(unverifiableCount, verifiableCount) {
|
|
84
|
+
return (`${unverifiableCount} peer(s) unverifiable — could not check (no endpoint); ` +
|
|
85
|
+
`converged among the ${verifiableCount} verifiable node(s).`);
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Separate "couldn't check" from "verified wrong" (flair#988).
|
|
89
|
+
* Single source of truth for exit code + operator sentences. Unverifiable
|
|
90
|
+
* peers never fail the run; a reachable skew still does.
|
|
91
|
+
*/
|
|
92
|
+
export function describeFleetSweep(origin, peers) {
|
|
93
|
+
const unverifiable = peers.filter((p) => p.status === "unverifiable");
|
|
94
|
+
const verifiableOk = [origin, ...peers].filter((p) => p.status === "ok");
|
|
95
|
+
const divergedNodes = [origin, ...peers].filter((p) => isDivergedStatus(p.status));
|
|
96
|
+
const warning = unverifiable.length > 0
|
|
97
|
+
? formatUnverifiableWarning(unverifiable.length, verifiableOk.length)
|
|
98
|
+
: null;
|
|
99
|
+
if (origin.status !== "ok" && !isDivergedStatus(origin.status)) {
|
|
100
|
+
return {
|
|
101
|
+
kind: "check-failed",
|
|
102
|
+
exitCode: FLEET_EXIT_ORIGIN_FAILED,
|
|
103
|
+
diverged: false,
|
|
104
|
+
divergedNodeId: null,
|
|
105
|
+
unverifiableCount: unverifiable.length,
|
|
106
|
+
verifiableCount: verifiableOk.length,
|
|
107
|
+
summary: `origin ${origin.status} — could not complete check.`,
|
|
108
|
+
warning,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
if (divergedNodes.length > 0) {
|
|
112
|
+
const node = divergedNodes[0];
|
|
113
|
+
return {
|
|
114
|
+
kind: "diverged",
|
|
115
|
+
exitCode: origin.status !== "ok" ? FLEET_EXIT_ORIGIN_FAILED : FLEET_EXIT_PEER_SKEW,
|
|
116
|
+
diverged: true,
|
|
117
|
+
divergedNodeId: node.id,
|
|
118
|
+
unverifiableCount: unverifiable.length,
|
|
119
|
+
verifiableCount: verifiableOk.length,
|
|
120
|
+
summary: `NOT converged — ${node.id} diverged.`,
|
|
121
|
+
warning,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const checkFailedPeer = peers.find((p) => isCheckFailedStatus(p.status));
|
|
125
|
+
if (checkFailedPeer) {
|
|
126
|
+
return {
|
|
127
|
+
kind: "check-failed",
|
|
128
|
+
exitCode: FLEET_EXIT_PEER_UNREACHABLE,
|
|
129
|
+
diverged: false,
|
|
130
|
+
divergedNodeId: null,
|
|
131
|
+
unverifiableCount: unverifiable.length,
|
|
132
|
+
verifiableCount: verifiableOk.length,
|
|
133
|
+
summary: `peer ${checkFailedPeer.id} ${checkFailedPeer.status} — could not complete check.`,
|
|
134
|
+
warning,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
kind: "converged",
|
|
139
|
+
exitCode: FLEET_EXIT_OK,
|
|
140
|
+
diverged: false,
|
|
141
|
+
divergedNodeId: null,
|
|
142
|
+
unverifiableCount: unverifiable.length,
|
|
143
|
+
verifiableCount: verifiableOk.length,
|
|
144
|
+
summary: "converged.",
|
|
145
|
+
warning,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
61
148
|
/**
|
|
62
|
-
* Priority
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* genuinely don't know that node's state).
|
|
149
|
+
* Priority: origin failure, then reachable skew (DIVERGED), then a reachable
|
|
150
|
+
* peer we attempted and could not complete (unreachable / auth-failed).
|
|
151
|
+
* Unverifiable peers (no endpoint, never paired) do not fail the run.
|
|
66
152
|
*/
|
|
67
153
|
export function decideFleetExitCode(origin, peers) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
154
|
+
return describeFleetSweep(origin, peers).exitCode;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Whether `flair deploy` / `flair upgrade --target` should abort after the
|
|
158
|
+
* sweep. Unverifiable-only is not a failure — that was the #988 bug: every
|
|
159
|
+
* non-OK used to print "deploy is NOT fully converged."
|
|
160
|
+
*/
|
|
161
|
+
export function fleetSweepShouldAbort(verdict) {
|
|
162
|
+
return verdict.exitCode !== FLEET_EXIT_OK;
|
|
75
163
|
}
|
|
76
164
|
// ─── Pure decision logic: ProbeResult → FleetNodeResult ─────────────────────
|
|
77
165
|
/**
|
|
@@ -241,8 +329,17 @@ export async function sweepFleet(opts, injected) {
|
|
|
241
329
|
detail: `GET /FederationPeers on the origin failed: ${peerEnumerationError} — fleet coverage unknown, cannot confirm every peer was even considered`,
|
|
242
330
|
});
|
|
243
331
|
}
|
|
244
|
-
const
|
|
245
|
-
return {
|
|
332
|
+
const verdict = describeFleetSweep(origin, peers);
|
|
333
|
+
return {
|
|
334
|
+
target: opts.target,
|
|
335
|
+
expectVersion,
|
|
336
|
+
expectVersionSource,
|
|
337
|
+
origin,
|
|
338
|
+
peers,
|
|
339
|
+
peerEnumerationError,
|
|
340
|
+
exitCode: verdict.exitCode,
|
|
341
|
+
verdict,
|
|
342
|
+
};
|
|
246
343
|
}
|
|
247
344
|
// ─── Rendering ───────────────────────────────────────────────────────────────
|
|
248
345
|
const STATUS_COLOR = {
|
|
@@ -287,5 +384,31 @@ export function renderFleetSweepTable(result) {
|
|
|
287
384
|
: "no version baseline available (origin unreachable/unauthenticated and no --expect-version given) — skew could not be checked";
|
|
288
385
|
lines.push("");
|
|
289
386
|
lines.push(render.wrap(render.c.dim, expectLine));
|
|
387
|
+
const verdictLines = renderFleetSweepVerdict(result.verdict);
|
|
388
|
+
if (verdictLines) {
|
|
389
|
+
lines.push("");
|
|
390
|
+
lines.push(verdictLines);
|
|
391
|
+
}
|
|
392
|
+
return lines.join("\n");
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Operator-facing verdict after the per-node table (flair#988).
|
|
396
|
+
* Unverifiable-only → warning, never "NOT converged."
|
|
397
|
+
* A reachable skew → "NOT converged — <node> diverged."
|
|
398
|
+
*/
|
|
399
|
+
export function renderFleetSweepVerdict(verdict) {
|
|
400
|
+
const lines = [];
|
|
401
|
+
if (verdict.warning) {
|
|
402
|
+
lines.push(`${render.icons.warn} ${render.wrap(render.c.yellow, `WARNING ${verdict.warning}`)}`);
|
|
403
|
+
}
|
|
404
|
+
if (verdict.kind === "diverged") {
|
|
405
|
+
lines.push(`${render.icons.error} ${render.wrap(render.c.red, verdict.summary)}`);
|
|
406
|
+
}
|
|
407
|
+
else if (verdict.kind === "check-failed") {
|
|
408
|
+
lines.push(`${render.icons.error} ${render.wrap(render.c.red, verdict.summary)}`);
|
|
409
|
+
}
|
|
410
|
+
else if (!verdict.warning) {
|
|
411
|
+
lines.push(`${render.icons.ok} ${render.wrap(render.c.green, verdict.summary)}`);
|
|
412
|
+
}
|
|
290
413
|
return lines.join("\n");
|
|
291
414
|
}
|
package/dist/install/clients.js
CHANGED
|
@@ -171,6 +171,26 @@ export function appendCodexFlairBlock(raw, env) {
|
|
|
171
171
|
const separator = raw.length === 0 ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
|
|
172
172
|
return raw + separator + tomlSnippet(env) + "\n";
|
|
173
173
|
}
|
|
174
|
+
/**
|
|
175
|
+
* Inverse of appendCodexFlairBlock / replaceCodexFlairBlock: drop the
|
|
176
|
+
* `[mcp_servers.flair]` table and its `[mcp_servers.flair.*]` subtables,
|
|
177
|
+
* leaving every other table untouched. Pure — callers write the result.
|
|
178
|
+
*/
|
|
179
|
+
export function removeCodexFlairBlock(raw) {
|
|
180
|
+
const header = "[mcp_servers.flair]";
|
|
181
|
+
const idx = raw.indexOf(header);
|
|
182
|
+
if (idx === -1)
|
|
183
|
+
return raw;
|
|
184
|
+
const before = raw.slice(0, idx);
|
|
185
|
+
const after = raw.slice(idx);
|
|
186
|
+
const nextHeader = after.slice(header.length).search(/\n\[(?!mcp_servers\.flair\.)/);
|
|
187
|
+
const rest = nextHeader === -1 ? "" : after.slice(header.length + nextHeader);
|
|
188
|
+
const restBody = rest.replace(/^\n+/, "");
|
|
189
|
+
const joined = before.replace(/\s+$/, "") + (restBody ? "\n\n" + restBody : "");
|
|
190
|
+
if (joined.trim().length === 0)
|
|
191
|
+
return "";
|
|
192
|
+
return joined.replace(/^\n+/, "").replace(/\s+$/, "\n");
|
|
193
|
+
}
|
|
174
194
|
/**
|
|
175
195
|
* flair#1135: does the existing `[mcp_servers.flair]` TOML section carry the
|
|
176
196
|
* CURRENT pinned mcpServerSpec()? Pure string scan — no TOML parser needed
|
|
@@ -657,6 +677,125 @@ function _wireCursor(env) {
|
|
|
657
677
|
function _wireAntigravity(env) {
|
|
658
678
|
return wireJsonMcp(antigravityConfigPath(), "Antigravity", env, "wiring unverified against a real agy — restart Antigravity and confirm the flair tools appear");
|
|
659
679
|
}
|
|
680
|
+
function displayUnderHome(configPath) {
|
|
681
|
+
const home = resolveHome();
|
|
682
|
+
return configPath.startsWith(home) ? "~" + configPath.slice(home.length) : configPath;
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Remove `mcpServers.flair` from a JSON MCP config. Preserves sibling servers
|
|
686
|
+
* and every other top-level key. A no-op when the file or the flair entry is
|
|
687
|
+
* absent. Refuses to write when the file is not a JSON object.
|
|
688
|
+
*/
|
|
689
|
+
function unwireJsonMcp(configPath, label) {
|
|
690
|
+
const display = displayUnderHome(configPath);
|
|
691
|
+
if (!existsSync(configPath)) {
|
|
692
|
+
return { ok: true, removed: false, message: `${label}: no config at ${display}` };
|
|
693
|
+
}
|
|
694
|
+
try {
|
|
695
|
+
const raw = readFileSync(configPath, "utf-8").trim();
|
|
696
|
+
if (!raw) {
|
|
697
|
+
return { ok: true, removed: false, message: `${label}: no Flair MCP entry in ${display}` };
|
|
698
|
+
}
|
|
699
|
+
const config = JSON.parse(raw);
|
|
700
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
701
|
+
return { ok: false, removed: false, message: `${label}: refusing to modify a non-object config at ${display}` };
|
|
702
|
+
}
|
|
703
|
+
const servers = config.mcpServers;
|
|
704
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers) || !("flair" in servers)) {
|
|
705
|
+
return { ok: true, removed: false, message: `${label}: no Flair MCP entry in ${display}` };
|
|
706
|
+
}
|
|
707
|
+
delete servers.flair;
|
|
708
|
+
if (Object.keys(servers).length === 0) {
|
|
709
|
+
delete config.mcpServers;
|
|
710
|
+
}
|
|
711
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
712
|
+
return { ok: true, removed: true, message: `${label}: unwired ${display}` };
|
|
713
|
+
}
|
|
714
|
+
catch (err) {
|
|
715
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
716
|
+
return { ok: false, removed: false, message: `${label}: could not unwire ${display}: ${reason}` };
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
function _unwireClaudeCode() {
|
|
720
|
+
return unwireJsonMcp(join(resolveHome(), ".claude.json"), "Claude Code");
|
|
721
|
+
}
|
|
722
|
+
function _unwireCodex() {
|
|
723
|
+
const path = codexConfigPath();
|
|
724
|
+
const display = "~/.codex/config.toml";
|
|
725
|
+
if (!existsSync(path)) {
|
|
726
|
+
return { ok: true, removed: false, message: `Codex: no config at ${display}` };
|
|
727
|
+
}
|
|
728
|
+
try {
|
|
729
|
+
const raw = readFileSync(path, "utf-8");
|
|
730
|
+
if (!codexConfigHasFlairSection(raw)) {
|
|
731
|
+
return { ok: true, removed: false, message: `Codex: no Flair MCP entry in ${display}` };
|
|
732
|
+
}
|
|
733
|
+
writeFileSync(path, removeCodexFlairBlock(raw));
|
|
734
|
+
return { ok: true, removed: true, message: `Codex: unwired ${display}` };
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
738
|
+
return { ok: false, removed: false, message: `Codex: could not unwire ${display}: ${reason}` };
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
function _unwireGemini() {
|
|
742
|
+
return unwireJsonMcp(geminiConfigPath(), "Gemini");
|
|
743
|
+
}
|
|
744
|
+
function _unwireCursor() {
|
|
745
|
+
return unwireJsonMcp(cursorConfigPath(), "Cursor");
|
|
746
|
+
}
|
|
747
|
+
function _unwireAntigravity() {
|
|
748
|
+
return unwireJsonMcp(antigravityConfigPath(), "Antigravity");
|
|
749
|
+
}
|
|
750
|
+
function _unwirePi() {
|
|
751
|
+
const path = piSettingsPath();
|
|
752
|
+
const display = displayUnderHome(path);
|
|
753
|
+
if (!existsSync(path)) {
|
|
754
|
+
return { ok: true, removed: false, message: `pi: no config at ${display}` };
|
|
755
|
+
}
|
|
756
|
+
try {
|
|
757
|
+
const raw = readFileSync(path, "utf-8").trim();
|
|
758
|
+
if (!raw) {
|
|
759
|
+
return { ok: true, removed: false, message: `pi: no Flair extension in ${display}` };
|
|
760
|
+
}
|
|
761
|
+
const config = JSON.parse(raw);
|
|
762
|
+
if (!config || typeof config !== "object" || Array.isArray(config)) {
|
|
763
|
+
return { ok: false, removed: false, message: `pi: refusing to modify a non-object config at ${display}` };
|
|
764
|
+
}
|
|
765
|
+
let changed = false;
|
|
766
|
+
if (Array.isArray(config.packages)) {
|
|
767
|
+
const kept = config.packages.filter((entry) => {
|
|
768
|
+
const source = piPackageEntrySource(entry);
|
|
769
|
+
return !(source && isPiFlairNpmSource(source));
|
|
770
|
+
});
|
|
771
|
+
if (kept.length !== config.packages.length) {
|
|
772
|
+
config.packages = kept;
|
|
773
|
+
changed = true;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (Array.isArray(config.extensions)) {
|
|
777
|
+
const kept = config.extensions.filter((entry) => {
|
|
778
|
+
if (typeof entry === "string" && (isPiFlairNpmSource(entry) || isPiFlairExtensionPath(entry))) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
return true;
|
|
782
|
+
});
|
|
783
|
+
if (kept.length !== config.extensions.length) {
|
|
784
|
+
config.extensions = kept;
|
|
785
|
+
changed = true;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
if (!changed) {
|
|
789
|
+
return { ok: true, removed: false, message: `pi: no Flair extension in ${display}` };
|
|
790
|
+
}
|
|
791
|
+
writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
|
|
792
|
+
return { ok: true, removed: true, message: `pi: unwired ${display}` };
|
|
793
|
+
}
|
|
794
|
+
catch (err) {
|
|
795
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
796
|
+
return { ok: false, removed: false, message: `pi: could not unwire ${display}: ${reason}` };
|
|
797
|
+
}
|
|
798
|
+
}
|
|
660
799
|
// ---- Exported detection & wiring array ------------------------------------------
|
|
661
800
|
export const ALL_CLIENTS = [
|
|
662
801
|
{
|
|
@@ -665,6 +804,7 @@ export const ALL_CLIENTS = [
|
|
|
665
804
|
bin: "claude",
|
|
666
805
|
kind: "mcp",
|
|
667
806
|
wire: _wireClaudeCode,
|
|
807
|
+
unwire: _unwireClaudeCode,
|
|
668
808
|
},
|
|
669
809
|
{
|
|
670
810
|
id: "codex",
|
|
@@ -672,6 +812,7 @@ export const ALL_CLIENTS = [
|
|
|
672
812
|
bin: "codex",
|
|
673
813
|
kind: "mcp",
|
|
674
814
|
wire: _wireCodex,
|
|
815
|
+
unwire: _unwireCodex,
|
|
675
816
|
},
|
|
676
817
|
{
|
|
677
818
|
id: "gemini",
|
|
@@ -679,6 +820,7 @@ export const ALL_CLIENTS = [
|
|
|
679
820
|
bin: "gemini",
|
|
680
821
|
kind: "mcp",
|
|
681
822
|
wire: _wireGemini,
|
|
823
|
+
unwire: _unwireGemini,
|
|
682
824
|
},
|
|
683
825
|
{
|
|
684
826
|
id: "cursor",
|
|
@@ -686,6 +828,7 @@ export const ALL_CLIENTS = [
|
|
|
686
828
|
bin: "cursor",
|
|
687
829
|
kind: "mcp",
|
|
688
830
|
wire: _wireCursor,
|
|
831
|
+
unwire: _unwireCursor,
|
|
689
832
|
},
|
|
690
833
|
{
|
|
691
834
|
id: "antigravity",
|
|
@@ -694,6 +837,7 @@ export const ALL_CLIENTS = [
|
|
|
694
837
|
bin: "agy",
|
|
695
838
|
kind: "mcp",
|
|
696
839
|
wire: _wireAntigravity,
|
|
840
|
+
unwire: _unwireAntigravity,
|
|
697
841
|
},
|
|
698
842
|
{
|
|
699
843
|
id: "pi",
|
|
@@ -708,6 +852,7 @@ export const ALL_CLIENTS = [
|
|
|
708
852
|
// a pi whose wiring is worth checking/fixing. Pure fs check, both legs.
|
|
709
853
|
detect: () => detectBin("pi") || existsSync(piSettingsPath()),
|
|
710
854
|
wire: _wirePi,
|
|
855
|
+
unwire: _unwirePi,
|
|
711
856
|
},
|
|
712
857
|
];
|
|
713
858
|
/**
|
|
@@ -800,3 +945,25 @@ export function wireAntigravity(env) {
|
|
|
800
945
|
export function wirePi(env) {
|
|
801
946
|
return _wirePi(env);
|
|
802
947
|
}
|
|
948
|
+
export function unwireClaudeCode() {
|
|
949
|
+
return _unwireClaudeCode();
|
|
950
|
+
}
|
|
951
|
+
export function unwireCodex() {
|
|
952
|
+
return _unwireCodex();
|
|
953
|
+
}
|
|
954
|
+
export function unwireGemini() {
|
|
955
|
+
return _unwireGemini();
|
|
956
|
+
}
|
|
957
|
+
export function unwireCursor() {
|
|
958
|
+
return _unwireCursor();
|
|
959
|
+
}
|
|
960
|
+
export function unwireAntigravity() {
|
|
961
|
+
return _unwireAntigravity();
|
|
962
|
+
}
|
|
963
|
+
export function unwirePi() {
|
|
964
|
+
return _unwirePi();
|
|
965
|
+
}
|
|
966
|
+
/** Unwire every registry client. A missing or already-clean config is a no-op. */
|
|
967
|
+
export function unwireAllClients() {
|
|
968
|
+
return ALL_CLIENTS.map((client) => client.unwire());
|
|
969
|
+
}
|
package/dist/lib/auth-resolve.js
CHANGED
|
@@ -21,9 +21,13 @@
|
|
|
21
21
|
* resolved `--admin-pass` flag) or `explicitKeyPath` (e.g. `--key`,
|
|
22
22
|
* paired with `agentId`). Always wins; this is the operator saying
|
|
23
23
|
* "use exactly this."
|
|
24
|
+
* 1.5 A FLAG-PINNED agent (flair#1500) — `agentIdSource === "flag"`
|
|
25
|
+
* (`--agent X`). Signs as X BEFORE the env tier, and hard-errors if X
|
|
26
|
+
* has no key. An explicitly named identity is never quietly replaced.
|
|
24
27
|
* 2. ENV — `FLAIR_TOKEN` (Bearer) or `FLAIR_ADMIN_PASS` / `HDB_ADMIN_
|
|
25
28
|
* PASSWORD` (Basic admin auth). Ambient but still an explicit
|
|
26
|
-
* operator/CI choice.
|
|
29
|
+
* operator/CI choice. (An ENV-pinned identity — `FLAIR_AGENT_ID` — stays
|
|
30
|
+
* BELOW this tier; see the never-silent notice below.)
|
|
27
31
|
* 3. A PINNED agent identity — `agentId` was already known (an --agent
|
|
28
32
|
* flag, FLAIR_AGENT_ID env, or an id the caller extracted from its own
|
|
29
33
|
* request body/query string) — signed with THAT agent's key via the
|
|
@@ -49,6 +53,14 @@
|
|
|
49
53
|
* more specific failure; guessing at unrelated keys on disk in that case
|
|
50
54
|
* would obscure the real error instead of explaining it (the flair#741 fix
|
|
51
55
|
* #3 lesson, applied here — see `sendJsonRequest`'s 403 branch).
|
|
56
|
+
*
|
|
57
|
+
* ── Named identity vs ambient credential: never a silent substitution ────
|
|
58
|
+
* Precedence above is deliberately unchanged (flair#1504/#1507), but a caller
|
|
59
|
+
* that NAMED an identity (`--agent` or `FLAIR_AGENT_ID`) and got a different,
|
|
60
|
+
* usually more-privileged, credential is always told so on stderr — see
|
|
61
|
+
* `describeIdentityOverride`/`emitIdentityOverrideNotice` below. A named
|
|
62
|
+
* identity is never quietly upgraded to admin, and a two-explicit-flags
|
|
63
|
+
* conflict (`--admin-pass` + `--agent`) is never quiet either.
|
|
52
64
|
*/
|
|
53
65
|
import { existsSync, readFileSync, readdirSync, statSync, } from "node:fs";
|
|
54
66
|
import { homedir } from "node:os";
|
|
@@ -419,6 +431,44 @@ export async function tryAgentKeyFloor(baseUrl, method, path, body, keysDir) {
|
|
|
419
431
|
}
|
|
420
432
|
return undefined;
|
|
421
433
|
}
|
|
434
|
+
/**
|
|
435
|
+
* One actionable line for a named identity that was not the signer, or `null`
|
|
436
|
+
* when the named identity itself signed (the normal, expected case). Pure and
|
|
437
|
+
* exported so the matrix is unit-testable without spawning the CLI.
|
|
438
|
+
*/
|
|
439
|
+
export function describeIdentityOverride(input) {
|
|
440
|
+
const { agentId, namedSource, credentialSource, adminPassEnvVar } = input;
|
|
441
|
+
const named = namedSource === "flag" ? `--agent '${agentId}'` : `FLAIR_AGENT_ID='${agentId}'`;
|
|
442
|
+
switch (credentialSource) {
|
|
443
|
+
case "flag-agent":
|
|
444
|
+
case "pinned-agent":
|
|
445
|
+
case "explicit-key":
|
|
446
|
+
// The named identity signed — nothing was overridden.
|
|
447
|
+
return null;
|
|
448
|
+
case "explicit-admin":
|
|
449
|
+
return `Warning: ${named} names an identity, but --admin-pass was also given; signing as admin. Drop --admin-pass to sign as '${agentId}'.`;
|
|
450
|
+
case "env-token":
|
|
451
|
+
return `Warning: ${named} names an identity, but FLAIR_TOKEN is set; signing with that bearer token instead. Unset FLAIR_TOKEN to sign as '${agentId}'.`;
|
|
452
|
+
case "env-admin":
|
|
453
|
+
return `Warning: ${named} names an identity, but an ambient admin password (${adminPassEnvVar ?? "FLAIR_ADMIN_PASS"}) is set; signing as admin. Unset it to sign as '${agentId}'.`;
|
|
454
|
+
case "local-admin-file":
|
|
455
|
+
return `Warning: ${named} names an identity, but no key was found for it and the local ~/.flair/admin-pass file is present; signing as admin. Add a key for '${agentId}' to sign as it.`;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// De-duplicate in one process (a command may issue several requests) so an
|
|
459
|
+
// operator sees the notice once, not once per call. Tests reset it explicitly.
|
|
460
|
+
const emittedIdentityOverrides = new Set();
|
|
461
|
+
/** Write an override notice to stderr once per process. Injectable for tests. */
|
|
462
|
+
export function emitIdentityOverrideNotice(message, write = (s) => { process.stderr.write(s); }) {
|
|
463
|
+
if (emittedIdentityOverrides.has(message))
|
|
464
|
+
return;
|
|
465
|
+
emittedIdentityOverrides.add(message);
|
|
466
|
+
write(message + "\n");
|
|
467
|
+
}
|
|
468
|
+
/** Test hook: forget notices already emitted in this process. */
|
|
469
|
+
export function resetIdentityOverrideNotices() {
|
|
470
|
+
emittedIdentityOverrides.clear();
|
|
471
|
+
}
|
|
422
472
|
/**
|
|
423
473
|
* The shared resolver: given a Flair REST call and whatever credential
|
|
424
474
|
* material the caller already knows about, resolve auth across all 5 tiers
|
|
@@ -432,13 +482,18 @@ export async function authedRequest(method, path, body, opts) {
|
|
|
432
482
|
// (flair#1345 — these three sites used to hardcode the literal `admin`).
|
|
433
483
|
const adminUser = resolveAdminUser(opts.adminUser);
|
|
434
484
|
let authHeader;
|
|
485
|
+
// Which tier supplied authHeader — drives the never-silent override notice
|
|
486
|
+
// below (see describeIdentityOverride).
|
|
487
|
+
let credSource;
|
|
435
488
|
// Tier 1: explicit — caller-resolved flag material always wins.
|
|
436
489
|
if (opts.explicitAdminPass) {
|
|
437
490
|
authHeader = `Basic ${Buffer.from(`${adminUser}:${opts.explicitAdminPass}`).toString("base64")}`;
|
|
491
|
+
credSource = "explicit-admin";
|
|
438
492
|
}
|
|
439
493
|
else if (opts.explicitKeyPath && opts.agentId) {
|
|
440
494
|
try {
|
|
441
495
|
authHeader = buildEd25519Auth(opts.agentId, method, path, opts.explicitKeyPath);
|
|
496
|
+
credSource = "explicit-key";
|
|
442
497
|
}
|
|
443
498
|
catch (err) {
|
|
444
499
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -459,15 +514,18 @@ export async function authedRequest(method, path, body, opts) {
|
|
|
459
514
|
`or the local admin-pass file.`);
|
|
460
515
|
}
|
|
461
516
|
authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
|
|
517
|
+
credSource = "flag-agent";
|
|
462
518
|
}
|
|
463
519
|
// Tier 2: env — FLAIR_TOKEN (Bearer), else FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD (Basic).
|
|
464
520
|
if (!authHeader) {
|
|
465
521
|
if (process.env.FLAIR_TOKEN) {
|
|
466
522
|
authHeader = `Bearer ${process.env.FLAIR_TOKEN}`;
|
|
523
|
+
credSource = "env-token";
|
|
467
524
|
}
|
|
468
525
|
else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
|
|
469
526
|
const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
|
|
470
527
|
authHeader = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
|
|
528
|
+
credSource = "env-admin";
|
|
471
529
|
}
|
|
472
530
|
}
|
|
473
531
|
// Tier 3: a PINNED agent identity — sign specifically as this agent via
|
|
@@ -478,6 +536,7 @@ export async function authedRequest(method, path, body, opts) {
|
|
|
478
536
|
if (keyPath) {
|
|
479
537
|
try {
|
|
480
538
|
authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
|
|
539
|
+
credSource = "pinned-agent";
|
|
481
540
|
}
|
|
482
541
|
catch (err) {
|
|
483
542
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -491,6 +550,7 @@ export async function authedRequest(method, path, body, opts) {
|
|
|
491
550
|
const filePass = resolveLocalAdminPass(undefined, !isLocal);
|
|
492
551
|
if (filePass) {
|
|
493
552
|
authHeader = `Basic ${Buffer.from(`${adminUser}:${filePass}`).toString("base64")}`;
|
|
553
|
+
credSource = "local-admin-file";
|
|
494
554
|
}
|
|
495
555
|
}
|
|
496
556
|
catch (err) {
|
|
@@ -500,6 +560,21 @@ export async function authedRequest(method, path, body, opts) {
|
|
|
500
560
|
console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
|
|
501
561
|
}
|
|
502
562
|
}
|
|
563
|
+
// Never let a named identity (--agent flag or FLAIR_AGENT_ID env) be
|
|
564
|
+
// silently outranked by a different credential. Precedence is unchanged
|
|
565
|
+
// (see the section above describeIdentityOverride); only the silence is.
|
|
566
|
+
if (opts.agentId && (opts.agentIdSource === "flag" || opts.agentIdSource === "env") && credSource) {
|
|
567
|
+
const notice = describeIdentityOverride({
|
|
568
|
+
agentId: opts.agentId,
|
|
569
|
+
namedSource: opts.agentIdSource,
|
|
570
|
+
credentialSource: credSource,
|
|
571
|
+
adminPassEnvVar: process.env.FLAIR_ADMIN_PASS
|
|
572
|
+
? "FLAIR_ADMIN_PASS"
|
|
573
|
+
: process.env.HDB_ADMIN_PASSWORD ? "HDB_ADMIN_PASSWORD" : undefined,
|
|
574
|
+
});
|
|
575
|
+
if (notice)
|
|
576
|
+
emitIdentityOverrideNotice(notice);
|
|
577
|
+
}
|
|
503
578
|
try {
|
|
504
579
|
return await sendJsonRequest(opts.baseUrl, method, path, body, authHeader, isLocal);
|
|
505
580
|
}
|