@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
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* launchd-repair.ts — the `doctor --fix` launchd repair (flair#1573 slice b).
|
|
3
|
+
*
|
|
4
|
+
* Slice (a) made the no-inline-secret plist a product capability (pass-file
|
|
5
|
+
* mode + the product launcher). This module is the DECISION half of the repair
|
|
6
|
+
* that uses it: given the current launchd observation and the on-disk plist,
|
|
7
|
+
* decide what `doctor --fix` may do — and, just as importantly, what it must
|
|
8
|
+
* refuse to do. The EXECUTION half (regenerate the plist, adopt a running
|
|
9
|
+
* process, load, verify) lives in src/cli.ts, which owns the real filesystem
|
|
10
|
+
* and launchctl; everything here is pure and unit-testable without either.
|
|
11
|
+
*
|
|
12
|
+
* The two load-bearing decisions, both from the adjudication (issue comment
|
|
13
|
+
* 5607172125):
|
|
14
|
+
*
|
|
15
|
+
* 1. CONFIG AUTHORITY (flair#914). The whole fix is gated on the instance's
|
|
16
|
+
* own harper-config.yaml being readable. ROOTPATH and the ports come from
|
|
17
|
+
* that file — never ~/.flair/config.yaml, never defaults — because a
|
|
18
|
+
* wrong ROOTPATH boots Harper against the wrong data directory, which is
|
|
19
|
+
* the data-adjacent disaster this issue exists to prevent. If the config
|
|
20
|
+
* cannot be read, there is no safe way to regenerate the plist, so the
|
|
21
|
+
* repair refuses rather than invent a ROOTPATH.
|
|
22
|
+
*
|
|
23
|
+
* 2. OWNERSHIP GUARD (mirror flair#966). A plist is only repaired when it is
|
|
24
|
+
* provably ours (ROOTPATH == dataDir), provably corrupt (not XML), or
|
|
25
|
+
* absent. A valid plist whose ROOTPATH names a DIFFERENT directory is a
|
|
26
|
+
* different instance and is refused. A valid plist with NO ROOTPATH at all
|
|
27
|
+
* cannot be attributed, so it is refused and the file is named — the
|
|
28
|
+
* operator decides. (No TTY confirm-adopt escape hatch exists; a
|
|
29
|
+
* confirm-adopt for the unattributable case is slice b3, if ever.)
|
|
30
|
+
*
|
|
31
|
+
* The state matrix the plan collapses to:
|
|
32
|
+
*
|
|
33
|
+
* - not-applicable (not macOS) -> no-op.
|
|
34
|
+
* - managed -> no-op ("already managed").
|
|
35
|
+
* - absent / corrupt / ours -> regenerate (pass-file mode).
|
|
36
|
+
* - foreign / unattributable -> refuse.
|
|
37
|
+
* - config unreadable -> refuse.
|
|
38
|
+
* - detached-and-running (ours) -> adopt (clean-stop -> regenerate -> load).
|
|
39
|
+
* - detached-and-running (foreign) -> refuse (ownership guard).
|
|
40
|
+
*/
|
|
41
|
+
import { resolve } from "node:path";
|
|
42
|
+
/**
|
|
43
|
+
* Classify the plist at `plistPath` against `dataDir`.
|
|
44
|
+
*
|
|
45
|
+
* The "corrupt" test is deliberately structural, not a full plist parse: a
|
|
46
|
+
* Flair plist is an XML document with a `<plist>` root and a `<dict>` body,
|
|
47
|
+
* and the reported corruption (a bare JSON array) has neither. A full parser
|
|
48
|
+
* would pull the whole EnvironmentVariables dict — including the admin
|
|
49
|
+
* password — into memory to answer a question about two tags, and the shape
|
|
50
|
+
* here is fixed because buildLaunchdPlist wrote it.
|
|
51
|
+
*/
|
|
52
|
+
export function classifyPlist(plistPath, dataDir, deps) {
|
|
53
|
+
if (!deps.exists(plistPath))
|
|
54
|
+
return "absent";
|
|
55
|
+
const raw = deps.read(plistPath);
|
|
56
|
+
if (raw === null)
|
|
57
|
+
return "corrupt";
|
|
58
|
+
if (!/<plist[\s>]/.test(raw) || !/<dict>/.test(raw))
|
|
59
|
+
return "corrupt";
|
|
60
|
+
const rootPath = deps.readRootPath(plistPath);
|
|
61
|
+
if (rootPath === null)
|
|
62
|
+
return "unattributable";
|
|
63
|
+
return resolve(rootPath) === resolve(dataDir) ? "ours" : "foreign";
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Decide what `doctor --fix` may do about launchd management.
|
|
67
|
+
*
|
|
68
|
+
* Pure: no filesystem, no launchctl. The executor in cli.ts turns a
|
|
69
|
+
* `regenerate` plan into a plist write + load + verify, an `adopt` plan into
|
|
70
|
+
* a clean-stop + regenerate + load + verify, and a `refuse` plan into a named
|
|
71
|
+
* refusal.
|
|
72
|
+
*/
|
|
73
|
+
export function planLaunchdRepair(input) {
|
|
74
|
+
const { observation, disposition, plistPath, directProcessRunning, configReadable } = input;
|
|
75
|
+
if (observation.state === "not-applicable") {
|
|
76
|
+
return { kind: "no-op", reason: "not-applicable", detail: observation.detail };
|
|
77
|
+
}
|
|
78
|
+
if (observation.state === "managed") {
|
|
79
|
+
return { kind: "no-op", reason: "already-managed", detail: observation.detail };
|
|
80
|
+
}
|
|
81
|
+
// Config authority (flair#914): no readable harper-config.yaml means no safe
|
|
82
|
+
// ROOTPATH/ports, so the repair cannot proceed without inventing them.
|
|
83
|
+
if (!configReadable) {
|
|
84
|
+
return {
|
|
85
|
+
kind: "refuse",
|
|
86
|
+
reason: "config-unreadable",
|
|
87
|
+
detail: "cannot repair launchd management: the instance's harper-config.yaml is missing or unreadable, " +
|
|
88
|
+
"so its ROOTPATH and ports cannot be established. Run 'flair init' to (re)create the instance.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
// Ownership guard (flair#966 mirror).
|
|
92
|
+
if (disposition === "foreign") {
|
|
93
|
+
return {
|
|
94
|
+
kind: "refuse",
|
|
95
|
+
reason: "foreign",
|
|
96
|
+
detail: `refusing to repair the launchd plist at ${plistPath}: it is registered to a different data ` +
|
|
97
|
+
"directory, so it belongs to a different Flair instance.",
|
|
98
|
+
plistPath,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
if (disposition === "unattributable") {
|
|
102
|
+
return {
|
|
103
|
+
kind: "refuse",
|
|
104
|
+
reason: "unattributable",
|
|
105
|
+
detail: `refusing to repair the launchd plist at ${plistPath}: it has no ROOTPATH, so it cannot be ` +
|
|
106
|
+
"proven to belong to this instance.",
|
|
107
|
+
plistPath,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
// Detached-and-running (flair#1573 slice b2): a direct (non-launchd) process
|
|
111
|
+
// is serving this instance. The plist is ours/absent/corrupt (the foreign and
|
|
112
|
+
// unattributable cases were refused above), so the direct process is THIS
|
|
113
|
+
// instance's and the adopt path clean-stops it before regenerating + loading.
|
|
114
|
+
// The plan states the bounce explicitly: adopt is the one repair that takes
|
|
115
|
+
// the live instance down and back up.
|
|
116
|
+
if (directProcessRunning) {
|
|
117
|
+
return {
|
|
118
|
+
kind: "adopt",
|
|
119
|
+
detail: "the instance is running but not under launchd (direct-spawned) — adopting it into launchd " +
|
|
120
|
+
"will clean-stop the live process (SIGTERM, wait for exit), regenerate the plist, and reload it. " +
|
|
121
|
+
"This bounces the live instance.",
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// Repairable: absent, corrupt, or ours, with no direct process in the way.
|
|
125
|
+
return {
|
|
126
|
+
kind: "regenerate",
|
|
127
|
+
detail: "regenerating the launchd plist for this instance",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
// ─── the executor's pure helpers (slice b2) ───────────────────────────────
|
|
131
|
+
/**
|
|
132
|
+
* Map a throw from the executor arm to a named result (flair#1573 slice b2,
|
|
133
|
+
* Kern's b1 defect). `doctor --fix` must never crash mid-report: every throw
|
|
134
|
+
* becomes a `failed` result, except an engine-backwards refusal (flair#1093),
|
|
135
|
+
* which is a refusal by nature and is surfaced as `refused` so the operator
|
|
136
|
+
* sees the actor/state/remedy rather than a generic failure.
|
|
137
|
+
*
|
|
138
|
+
* NOTE: the engine-backwards `refused` intentionally carries its remedy in the
|
|
139
|
+
* detail prose (the actor/state/remedy sentence buildRecoveryLines renders),
|
|
140
|
+
* NOT in a structured `remedy` field — a refusal is a verdict, not a failure,
|
|
141
|
+
* and the prose is what the operator reads.
|
|
142
|
+
*/
|
|
143
|
+
export function mapRepairThrow(err) {
|
|
144
|
+
const e = err;
|
|
145
|
+
if (e?.engineBackwards) {
|
|
146
|
+
return { kind: "refused", reason: "engine-backwards", detail: e.message ?? "engine is backwards" };
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
kind: "failed",
|
|
150
|
+
detail: e?.message ?? String(err),
|
|
151
|
+
remedy: ["flair doctor --fix"],
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Decide whether the adopt path may proceed to regenerate + load, given the
|
|
156
|
+
* liveness classification of the direct process and the post-stop health probe
|
|
157
|
+
* (flair#1573 slice b2). Pure — the SIGTERM + wait and the probe happen in the
|
|
158
|
+
* executor; this only maps their results to a verdict.
|
|
159
|
+
*
|
|
160
|
+
* - DISAGREEMENT / UNKNOWN -> failed (never stop a foreign/unattributable
|
|
161
|
+
* process — the liveness machine refused to verify identity).
|
|
162
|
+
* - post-stop health "ok" -> failed ("port still occupied" — the old
|
|
163
|
+
* process did not fully exit, so loading the new plist would collide).
|
|
164
|
+
* - post-stop health "unreachable" -> failed ("port not confirmed free" — a
|
|
165
|
+
* wedged daemon that ignored SIGTERM but stays BOUND to the port while no
|
|
166
|
+
* longer serving /Health would EADDRINUSE on load; "unreachable" is the
|
|
167
|
+
* probe's "cannot tell", so it must NOT proceed).
|
|
168
|
+
* - post-stop health "refused" -> proceed (ECONNREFUSED — nothing is
|
|
169
|
+
* listening, the port is provably free).
|
|
170
|
+
*/
|
|
171
|
+
export function decideAdoptStop(state, postStopHealth) {
|
|
172
|
+
switch (state.state) {
|
|
173
|
+
case "RUNNING":
|
|
174
|
+
case "WEDGED":
|
|
175
|
+
case "NOT_RUNNING":
|
|
176
|
+
break;
|
|
177
|
+
case "DISAGREEMENT":
|
|
178
|
+
case "UNKNOWN":
|
|
179
|
+
return {
|
|
180
|
+
kind: "failed",
|
|
181
|
+
detail: `refusing to adopt: ${state.detail}`,
|
|
182
|
+
remedy: ["flair stop", "flair doctor --fix"],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
// Proceed ONLY when the port is provably free (ECONNREFUSED). "ok" or
|
|
186
|
+
// "foreign" means something is still serving; "unreachable" means a wedged
|
|
187
|
+
// daemon may still be BOUND to the port (ignored SIGTERM) — both would
|
|
188
|
+
// EADDRINUSE on load.
|
|
189
|
+
if (postStopHealth.kind !== "refused") {
|
|
190
|
+
return {
|
|
191
|
+
kind: "failed",
|
|
192
|
+
detail: postStopHealth.kind === "ok" || postStopHealth.kind === "foreign"
|
|
193
|
+
? "port still occupied after stopping the direct process"
|
|
194
|
+
: "port not confirmed free after stopping the direct process (a wedged process may still hold it)",
|
|
195
|
+
remedy: ["flair stop", "flair doctor --fix"],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
return "proceed";
|
|
199
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ops-api-bind.ts — one decision for "is the Harper ops API bound to all
|
|
3
|
+
* interfaces?", shared by `flair doctor` and `flair status` (flair#670,
|
|
4
|
+
* unified in flair#852).
|
|
5
|
+
*
|
|
6
|
+
* The Harper ops API used to bind all interfaces unconditionally. `flair init`
|
|
7
|
+
* now defaults it to loopback + the domain socket, with an escape hatch
|
|
8
|
+
* (`--ops-bind` / `FLAIR_OPS_BIND`) for deployments that genuinely need remote
|
|
9
|
+
* ops access (flair#670). That narrowing is only useful if every health surface
|
|
10
|
+
* agrees on what the running install actually bound, and it is what doctor
|
|
11
|
+
* reads out of Harper's own config. The predicate is an allow-list: ONLY a
|
|
12
|
+
* loopback host narrows the bind. A bare numeric port, a wildcard host
|
|
13
|
+
* (`0.0.0.0:19925`, `[::]:19925`, …), an empty/unspecified host and an
|
|
14
|
+
* unparseable value are all reported as exposed — a `host:port` string is not
|
|
15
|
+
* evidence of narrowing unless the host is actually loopback.
|
|
16
|
+
*
|
|
17
|
+
* That allow-list is the flair#852 wildcard blind spot. The detector originally
|
|
18
|
+
* treated ANY `host:port` as narrowed, so `flair init --ops-bind 0.0.0.0`
|
|
19
|
+
* persisted `0.0.0.0:19925` and both `flair status` and `flair doctor` printed
|
|
20
|
+
* green while the ops API was reachable off-box.
|
|
21
|
+
*
|
|
22
|
+
* flair#852 was the two surfaces disagreeing. `flair doctor` flagged the bare
|
|
23
|
+
* port while `flair status` printed "✓ all checks passing" — a security-relevant
|
|
24
|
+
* exposure visible to one command and invisible to the other, so users shipped
|
|
25
|
+
* with the ops API reachable off-box. The fix is structural: the decision lives
|
|
26
|
+
* HERE, both commands call it, and status folds the finding into the same
|
|
27
|
+
* warning verdict that drives its green line.
|
|
28
|
+
*
|
|
29
|
+
* Pure: parsing Harper's config is the caller's job (doctor/status both read it
|
|
30
|
+
* with `readHarperConfig`); this module only decides.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Hosts that genuinely narrow the ops-API bind to loopback. This is an
|
|
34
|
+
* allow-list on purpose: ANYTHING not in it — a wildcard (`0.0.0.0`, `::`,
|
|
35
|
+
* `[::]`, `0:0:0:0:0:0:0:0`), an explicit routable host, an empty/unspecified
|
|
36
|
+
* host, or a value we cannot parse — is reported as exposed. The cost of a
|
|
37
|
+
* missed exposure (a user ships an ops API reachable off-box) is worse than the
|
|
38
|
+
* cost of warning about a bind we did not recognise.
|
|
39
|
+
*/
|
|
40
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
41
|
+
/**
|
|
42
|
+
* Best-effort host half of a persisted `operationsApi.network.port` value.
|
|
43
|
+
* Returns null when the value declares no host (a bare port) or the host is
|
|
44
|
+
* unparseable; the caller reports both as exposed.
|
|
45
|
+
*
|
|
46
|
+
* Handles the forms flair and Harper write:
|
|
47
|
+
* - `127.0.0.1:19925` → `127.0.0.1`
|
|
48
|
+
* - `[::1]:19925` → `::1` (bracketed IPv6, port stripped)
|
|
49
|
+
* - `[::]:19925` → `::`
|
|
50
|
+
* - `::1:19925` → `::1` (bare IPv6 with a trailing numeric port)
|
|
51
|
+
* - `::` → `::` (bare wildcard, nothing to strip)
|
|
52
|
+
* - `0:0:0:0:0:0:0:0` → `0:0:0:0:0:0:0` (still not loopback — flagged)
|
|
53
|
+
*
|
|
54
|
+
* Splits on the LAST colon so an IPv6 literal keeps its port, matching
|
|
55
|
+
* `harperPortValue`. A trailing all-digit segment is treated as the port; a
|
|
56
|
+
* bare IPv6 like `::` has no trailing port, so the whole value is the host.
|
|
57
|
+
*/
|
|
58
|
+
function parseBindHost(str) {
|
|
59
|
+
if (str.startsWith("[")) {
|
|
60
|
+
const close = str.indexOf("]");
|
|
61
|
+
if (close === -1)
|
|
62
|
+
return null; // malformed bracket form — unparseable
|
|
63
|
+
return str.slice(1, close);
|
|
64
|
+
}
|
|
65
|
+
const lastColon = str.lastIndexOf(":");
|
|
66
|
+
if (lastColon === -1)
|
|
67
|
+
return null; // bare port — no host to narrow on
|
|
68
|
+
const tail = str.slice(lastColon + 1);
|
|
69
|
+
return /^\d+$/.test(tail) ? str.slice(0, lastColon) : str;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Decide whether a persisted `operationsApi.network.port` value (read back from
|
|
73
|
+
* harper-config.yaml) indicates an ops-API bind reachable off-box.
|
|
74
|
+
*
|
|
75
|
+
* A bare port number/numeric string is Harper's all-interfaces default (the
|
|
76
|
+
* pre-flair#670 behavior, or an install that predates the fix and has not been
|
|
77
|
+
* re-`init`ed). A `host:port` string narrows the bind ONLY when the host is
|
|
78
|
+
* loopback — a wildcard (`0.0.0.0:19925`, `[::]:19925`, `::19925`) is still
|
|
79
|
+
* all-interfaces, and is flagged (flair#852). Empty/unspecified and unparseable
|
|
80
|
+
* values are flagged too.
|
|
81
|
+
*/
|
|
82
|
+
export function detectOpsApiAllInterfacesBind(portValue) {
|
|
83
|
+
if (portValue === undefined || portValue === null)
|
|
84
|
+
return { allInterfaces: false, boundHost: null };
|
|
85
|
+
const str = String(portValue).trim();
|
|
86
|
+
if (str === "")
|
|
87
|
+
return { allInterfaces: false, boundHost: null };
|
|
88
|
+
const host = parseBindHost(str);
|
|
89
|
+
if (host !== null && LOOPBACK_HOSTS.has(host.toLowerCase())) {
|
|
90
|
+
return { allInterfaces: false, boundHost: host };
|
|
91
|
+
}
|
|
92
|
+
return { allInterfaces: true, boundHost: null };
|
|
93
|
+
}
|
|
94
|
+
const OPS_API_BIND_REMEDY = "Single-host installs don't need this reachable off-box. Fix: flair init && flair restart " +
|
|
95
|
+
"(rebinds to loopback + domain socket; re-init reuses your existing admin password, so this is " +
|
|
96
|
+
"safe on a running install — pass --ops-bind for deliberate remote admin)";
|
|
97
|
+
/**
|
|
98
|
+
* The ops-API bind finding for a parsed harper-config, or null when the config
|
|
99
|
+
* declares no ops port (nothing to report). This is the single source of truth
|
|
100
|
+
* `flair doctor` and `flair status` both call, so they cannot disagree about
|
|
101
|
+
* the same instance.
|
|
102
|
+
*/
|
|
103
|
+
export function opsApiBindFinding(harperConfig) {
|
|
104
|
+
const portValue = harperConfig?.operationsApi?.network?.port;
|
|
105
|
+
if (portValue === undefined || portValue === null || String(portValue).trim() === "")
|
|
106
|
+
return null;
|
|
107
|
+
const { allInterfaces, boundHost } = detectOpsApiAllInterfacesBind(portValue);
|
|
108
|
+
return {
|
|
109
|
+
allInterfaces,
|
|
110
|
+
portValue,
|
|
111
|
+
boundHost,
|
|
112
|
+
message: `Ops API bound to all interfaces (${String(portValue)})`,
|
|
113
|
+
remedy: OPS_API_BIND_REMEDY,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* owned-pins.ts — flair#1485
|
|
3
|
+
*
|
|
4
|
+
* The single catalogue of files `flair init` / `flair hook install` write a
|
|
5
|
+
* pinned `@tpsdev-ai/flair-mcp@<version>` into. `flair upgrade` refreshes
|
|
6
|
+
* every entry that is already wired; `flair doctor` reads the same list to
|
|
7
|
+
* decide whether a pin is current.
|
|
8
|
+
*
|
|
9
|
+
* Two kinds:
|
|
10
|
+
* mcp-client — ALL_CLIENTS kind:"mcp" config paths
|
|
11
|
+
* session-start-hook — SUPPORTED_HARNESSES hook settings paths
|
|
12
|
+
*
|
|
13
|
+
* Adding a client to ALL_CLIENTS or a harness to SUPPORTED_HARNESSES adds
|
|
14
|
+
* it here automatically — no second list to forget on the next upgrade.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { ALL_CLIENTS, clientConfigPath, } from "../install/clients.js";
|
|
18
|
+
import { checkSessionStartHook, extractFlairMcpPin, isFlairHookCommand, readClientMcpBlock, } from "../doctor-client.js";
|
|
19
|
+
import { hookInstallHint, hookSettingsPath, repinSessionStartHook, SUPPORTED_HARNESSES, } from "../hook-install.js";
|
|
20
|
+
import { flairCliVersion, isResolvedVersion } from "./mcp-spec.js";
|
|
21
|
+
function withHome(homeDir, fn) {
|
|
22
|
+
const prev = process.env.HOME;
|
|
23
|
+
process.env.HOME = homeDir;
|
|
24
|
+
try {
|
|
25
|
+
return fn();
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
if (prev === undefined)
|
|
29
|
+
delete process.env.HOME;
|
|
30
|
+
else
|
|
31
|
+
process.env.HOME = prev;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function displayHomePath(homeDir, path) {
|
|
35
|
+
if (path.startsWith(homeDir))
|
|
36
|
+
return "~" + path.slice(homeDir.length);
|
|
37
|
+
return path;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Every file Flair pins. Order: MCP clients (registry order), then hooks
|
|
41
|
+
* (harness registry order). Upgrade and doctor both iterate this.
|
|
42
|
+
*/
|
|
43
|
+
export function listOwnedPinTargets(homeDir) {
|
|
44
|
+
return withHome(homeDir, () => {
|
|
45
|
+
const targets = [];
|
|
46
|
+
for (const client of ALL_CLIENTS) {
|
|
47
|
+
if (client.kind !== "mcp")
|
|
48
|
+
continue;
|
|
49
|
+
const path = clientConfigPath(client.id);
|
|
50
|
+
targets.push({
|
|
51
|
+
kind: "mcp-client",
|
|
52
|
+
id: client.id,
|
|
53
|
+
path,
|
|
54
|
+
displayPath: displayHomePath(homeDir, path),
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
for (const harness of SUPPORTED_HARNESSES) {
|
|
58
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
59
|
+
targets.push({
|
|
60
|
+
kind: "session-start-hook",
|
|
61
|
+
id: harness,
|
|
62
|
+
path,
|
|
63
|
+
displayPath: displayHomePath(homeDir, path),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return targets;
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
function readFileText(path) {
|
|
70
|
+
try {
|
|
71
|
+
if (!existsSync(path))
|
|
72
|
+
return null;
|
|
73
|
+
return readFileSync(path, "utf-8");
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export function readOwnedPin(target, homeDir) {
|
|
80
|
+
if (target.kind === "session-start-hook") {
|
|
81
|
+
const hook = checkSessionStartHook(homeDir, target.path);
|
|
82
|
+
const present = !!(hook.present && hook.command && isFlairHookCommand(hook.command));
|
|
83
|
+
return {
|
|
84
|
+
target,
|
|
85
|
+
present,
|
|
86
|
+
pin: present ? extractFlairMcpPin(hook.command ?? "") : null,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
const block = readClientMcpBlock(target.id, homeDir);
|
|
90
|
+
const text = readFileText(target.path) ?? "";
|
|
91
|
+
return {
|
|
92
|
+
target,
|
|
93
|
+
present: block.present,
|
|
94
|
+
pin: block.present ? extractFlairMcpPin(text) : null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
export function readOwnedPins(homeDir) {
|
|
98
|
+
return listOwnedPinTargets(homeDir).map((t) => readOwnedPin(t, homeDir));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Wired pins whose concrete version is not the installed CLI version.
|
|
102
|
+
* Unpinned / absent entries are not stale — they are a different check.
|
|
103
|
+
*/
|
|
104
|
+
export function staleOwnedPins(homeDir, expectedVersion = flairCliVersion()) {
|
|
105
|
+
if (!isResolvedVersion(expectedVersion))
|
|
106
|
+
return [];
|
|
107
|
+
return readOwnedPins(homeDir).filter((r) => r.present && r.pin !== null && r.pin !== expectedVersion);
|
|
108
|
+
}
|
|
109
|
+
export function staleSessionStartHookPins(homeDir, expectedVersion = flairCliVersion()) {
|
|
110
|
+
return staleOwnedPins(homeDir, expectedVersion).filter((r) => r.target.kind === "session-start-hook");
|
|
111
|
+
}
|
|
112
|
+
export function staleMcpClientPins(homeDir, expectedVersion = flairCliVersion()) {
|
|
113
|
+
return staleOwnedPins(homeDir, expectedVersion).filter((r) => r.target.kind === "mcp-client");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* What `flair upgrade` prints from a refresh result.
|
|
117
|
+
*
|
|
118
|
+
* A failed `client.wire` is `action: "skip"` + `ok: false` (same shape as a
|
|
119
|
+
* fail-closed hook re-pin). Filtering on `action !== "skip"` alone dropped
|
|
120
|
+
* that failure, left the pin stale, and printed nothing (Bugbot on #1485).
|
|
121
|
+
* Failures always surface. MCP no-ops/updates still print; hook no-ops stay quiet.
|
|
122
|
+
*/
|
|
123
|
+
export function ownedPinRefreshShouldReport(r) {
|
|
124
|
+
if (!r.ok)
|
|
125
|
+
return true;
|
|
126
|
+
if (r.target.kind === "mcp-client")
|
|
127
|
+
return r.action !== "skip";
|
|
128
|
+
return r.action === "update";
|
|
129
|
+
}
|
|
130
|
+
export function staleHookRemedy(readings) {
|
|
131
|
+
const harnesses = readings
|
|
132
|
+
.filter((r) => r.target.kind === "session-start-hook")
|
|
133
|
+
.map((r) => r.target.id);
|
|
134
|
+
if (harnesses.length === 0)
|
|
135
|
+
return "flair hook install";
|
|
136
|
+
return [...new Set(harnesses)].map((h) => hookInstallHint(h)).join(" ; ");
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Refresh every already-wired owned pin to the running CLI's spec.
|
|
140
|
+
*
|
|
141
|
+
* MCP client pins need `agentId` (the wire functions rewrite the env block).
|
|
142
|
+
* SessionStart hooks do not — `repinSessionStartHook` reads the agent from
|
|
143
|
+
* the existing command. A missing agentId therefore skips MCP only; it must
|
|
144
|
+
* not skip hooks (that was the early-return hole in the inline upgrade path).
|
|
145
|
+
*
|
|
146
|
+
* NEVER adds a hook or wires a new client.
|
|
147
|
+
*/
|
|
148
|
+
export function refreshOwnedPins(opts) {
|
|
149
|
+
const { homeDir } = opts;
|
|
150
|
+
const flairUrl = opts.flairUrl ?? "http://127.0.0.1:9926";
|
|
151
|
+
const agentId = opts.agentId ?? null;
|
|
152
|
+
const targets = listOwnedPinTargets(homeDir);
|
|
153
|
+
const results = [];
|
|
154
|
+
return withHome(homeDir, () => {
|
|
155
|
+
for (const target of targets) {
|
|
156
|
+
if (target.kind === "session-start-hook") {
|
|
157
|
+
const harness = target.id;
|
|
158
|
+
const repin = repinSessionStartHook(homeDir, harness);
|
|
159
|
+
results.push({
|
|
160
|
+
target,
|
|
161
|
+
action: repin.action,
|
|
162
|
+
ok: repin.ok,
|
|
163
|
+
message: repin.message,
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const block = readClientMcpBlock(target.id, homeDir);
|
|
168
|
+
if (!block.present) {
|
|
169
|
+
results.push({
|
|
170
|
+
target,
|
|
171
|
+
action: "skip",
|
|
172
|
+
ok: true,
|
|
173
|
+
message: `${target.id}: not wired — skip`,
|
|
174
|
+
});
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (!agentId) {
|
|
178
|
+
results.push({
|
|
179
|
+
target,
|
|
180
|
+
action: "skip",
|
|
181
|
+
ok: true,
|
|
182
|
+
message: `${target.id}: no agent id — skip MCP pin refresh`,
|
|
183
|
+
});
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const client = ALL_CLIENTS.find((c) => c.id === target.id);
|
|
187
|
+
if (!client) {
|
|
188
|
+
results.push({
|
|
189
|
+
target,
|
|
190
|
+
action: "skip",
|
|
191
|
+
ok: true,
|
|
192
|
+
message: `${target.id}: not in client registry — skip`,
|
|
193
|
+
});
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const env = {
|
|
197
|
+
FLAIR_AGENT_ID: agentId,
|
|
198
|
+
FLAIR_URL: flairUrl,
|
|
199
|
+
FLAIR_CLIENT: target.id,
|
|
200
|
+
};
|
|
201
|
+
const before = extractFlairMcpPin(readFileText(target.path) ?? "");
|
|
202
|
+
const wired = client.wire(env);
|
|
203
|
+
const after = extractFlairMcpPin(readFileText(target.path) ?? "");
|
|
204
|
+
// Failed write stays skip+ok:false (fail-closed, like hook re-pin).
|
|
205
|
+
// ownedPinRefreshShouldReport treats !ok as printable — do not recode
|
|
206
|
+
// this as a quiet skip.
|
|
207
|
+
const action = !wired.ok ? "skip"
|
|
208
|
+
: before !== after ? "update"
|
|
209
|
+
: "noop";
|
|
210
|
+
results.push({
|
|
211
|
+
target,
|
|
212
|
+
action,
|
|
213
|
+
ok: wired.ok,
|
|
214
|
+
message: wired.message,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
return results;
|
|
218
|
+
});
|
|
219
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stabilize-mqtt-network.ts — keep mqtt.network key order settled (flair#1586 / #1581).
|
|
3
|
+
*
|
|
4
|
+
* Harper's HARPER_SET_CONFIG persist (`applyRuntimeEnvVarConfig`) does
|
|
5
|
+
* `YAML.stringify` of the in-memory object, so map key order is insertion
|
|
6
|
+
* order. First repair on a default/populate yaml writes:
|
|
7
|
+
*
|
|
8
|
+
* mqtt.network: port, securePort, mtls
|
|
9
|
+
*
|
|
10
|
+
* A later direct spawn that omits SET_CONFIG (production `buildDirectSpawnEnv`)
|
|
11
|
+
* runs `cleanupRemovedEnvVar`. When SET_CONFIG first saw those ports as
|
|
12
|
+
* already-null it stored no originals, so cleanup DELETES `port` / `securePort`
|
|
13
|
+
* and the MQTT_* env vars re-add them after the surviving `mtls` key:
|
|
14
|
+
*
|
|
15
|
+
* mqtt.network: mtls, port, securePort
|
|
16
|
+
*
|
|
17
|
+
* Adopt SET_CONFIG then `setNestedValue`s in place and keeps that order.
|
|
18
|
+
* `#1581` requires harper-config.yaml to be byte-identical across
|
|
19
|
+
* `doctor --fix`, so the adopt persist fails even though every value matches.
|
|
20
|
+
*
|
|
21
|
+
* This helper rewrites only the `mqtt.network` scalar lines, in the file's
|
|
22
|
+
* own indent/quoting, to the first-repair order. Fail-closed: nested maps,
|
|
23
|
+
* comments inside the map, or a shape we cannot attribute are left untouched
|
|
24
|
+
* rather than dumping the whole document (a full dump would fail #1581 on
|
|
25
|
+
* its own).
|
|
26
|
+
*/
|
|
27
|
+
import { load as parseYaml } from "js-yaml";
|
|
28
|
+
const PREFERRED_MQTT_NETWORK_KEYS = ["port", "securePort", "mtls"];
|
|
29
|
+
export function stabilizeMqttNetworkKeyOrder(text) {
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = parseYaml(text);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return { text, changed: false };
|
|
36
|
+
}
|
|
37
|
+
const net = parsed && typeof parsed === "object"
|
|
38
|
+
? parsed.mqtt?.network
|
|
39
|
+
: undefined;
|
|
40
|
+
if (!net || typeof net !== "object" || Array.isArray(net)) {
|
|
41
|
+
return { text, changed: false };
|
|
42
|
+
}
|
|
43
|
+
const keys = Object.keys(net);
|
|
44
|
+
const preferred = PREFERRED_MQTT_NETWORK_KEYS.filter((k) => Object.prototype.hasOwnProperty.call(net, k));
|
|
45
|
+
const rest = keys.filter((k) => !preferred.includes(k));
|
|
46
|
+
const wanted = [...preferred, ...rest];
|
|
47
|
+
if (wanted.length === 0 || keys.every((k, i) => k === wanted[i])) {
|
|
48
|
+
return { text, changed: false };
|
|
49
|
+
}
|
|
50
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
51
|
+
const endsWithEol = text.endsWith("\n");
|
|
52
|
+
const lines = text.replace(/\r\n/g, "\n").replace(/\n$/, "").split("\n");
|
|
53
|
+
let mqttIdx = -1;
|
|
54
|
+
let mqttIndent = "";
|
|
55
|
+
for (let i = 0; i < lines.length; i++) {
|
|
56
|
+
const m = lines[i].match(/^([ \t]*)mqtt:\s*$/);
|
|
57
|
+
if (m) {
|
|
58
|
+
mqttIdx = i;
|
|
59
|
+
mqttIndent = m[1];
|
|
60
|
+
break;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (mqttIdx < 0)
|
|
64
|
+
return { text, changed: false };
|
|
65
|
+
let netIdx = -1;
|
|
66
|
+
let netIndent = "";
|
|
67
|
+
for (let i = mqttIdx + 1; i < lines.length; i++) {
|
|
68
|
+
const trimmed = lines[i].trim();
|
|
69
|
+
if (trimmed === "" || trimmed.startsWith("#"))
|
|
70
|
+
continue;
|
|
71
|
+
const indent = lines[i].match(/^[ \t]*/)?.[0] ?? "";
|
|
72
|
+
if (indent.length <= mqttIndent.length)
|
|
73
|
+
break;
|
|
74
|
+
const m = lines[i].match(/^([ \t]*)network:\s*$/);
|
|
75
|
+
if (m) {
|
|
76
|
+
netIdx = i;
|
|
77
|
+
netIndent = m[1];
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
if (netIdx < 0)
|
|
82
|
+
return { text, changed: false };
|
|
83
|
+
const items = [];
|
|
84
|
+
let bodyEnd = netIdx + 1;
|
|
85
|
+
for (let i = netIdx + 1; i < lines.length; i++) {
|
|
86
|
+
const trimmed = lines[i].trim();
|
|
87
|
+
if (trimmed === "")
|
|
88
|
+
return { text, changed: false };
|
|
89
|
+
if (trimmed.startsWith("#"))
|
|
90
|
+
return { text, changed: false };
|
|
91
|
+
const indent = lines[i].match(/^[ \t]*/)?.[0] ?? "";
|
|
92
|
+
if (indent.length <= netIndent.length) {
|
|
93
|
+
bodyEnd = i;
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
const keyMatch = lines[i].match(/^[ \t]+([^:#\s]+):\s*/);
|
|
97
|
+
if (!keyMatch)
|
|
98
|
+
return { text, changed: false };
|
|
99
|
+
const next = lines[i + 1];
|
|
100
|
+
if (next) {
|
|
101
|
+
const nextTrim = next.trim();
|
|
102
|
+
if (nextTrim !== "" && !nextTrim.startsWith("#")) {
|
|
103
|
+
const nextIndent = next.match(/^[ \t]*/)?.[0] ?? "";
|
|
104
|
+
if (nextIndent.length > indent.length)
|
|
105
|
+
return { text, changed: false };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
items.push({ key: keyMatch[1], line: lines[i] });
|
|
109
|
+
bodyEnd = i + 1;
|
|
110
|
+
}
|
|
111
|
+
if (items.length === 0)
|
|
112
|
+
return { text, changed: false };
|
|
113
|
+
const fileKeys = items.map((it) => it.key);
|
|
114
|
+
if (fileKeys.length !== wanted.length || wanted.some((k) => !fileKeys.includes(k))) {
|
|
115
|
+
return { text, changed: false };
|
|
116
|
+
}
|
|
117
|
+
if (fileKeys.every((k, i) => k === wanted[i]))
|
|
118
|
+
return { text, changed: false };
|
|
119
|
+
const byKey = new Map(items.map((it) => [it.key, it.line]));
|
|
120
|
+
const newBody = wanted.map((k) => byKey.get(k));
|
|
121
|
+
const newLines = [...lines.slice(0, netIdx + 1), ...newBody, ...lines.slice(bodyEnd)];
|
|
122
|
+
return { text: newLines.join(eol) + (endsWithEol ? eol : ""), changed: true };
|
|
123
|
+
}
|