@tpsdev-ai/flair 0.51.2 → 0.53.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 +10 -5
- package/dist/build-info.json +3 -3
- package/dist/cli.js +1037 -566
- package/dist/doctor-client.js +35 -0
- package/dist/hook-install.js +74 -0
- package/dist/install/global-bin-path.js +14 -0
- package/dist/lib/auth-resolve.js +15 -0
- package/dist/lib/doctor-run.js +28 -15
- package/dist/lib/launchd-repair.js +198 -0
- package/dist/lib/stabilize-mqtt-network.js +123 -0
- package/dist/lib/upgrade-exec-path.js +257 -0
- package/dist/lib/upgrade-plain-tree.js +558 -0
- package/dist/rem/promote-policy.js +204 -0
- package/dist/rem/restore.js +55 -15
- package/dist/rem/runner.js +203 -20
- package/dist/resources/AdminMemory.js +2 -1
- package/dist/resources/AgentSeed.js +26 -10
- package/dist/resources/Asset.js +203 -0
- package/dist/resources/AutoPromoteCandidates.js +2 -4
- package/dist/resources/Credential.js +14 -0
- package/dist/resources/Federation.js +80 -0
- package/dist/resources/Integration.js +12 -0
- package/dist/resources/Memory.js +158 -60
- package/dist/resources/MemoryBootstrap.js +63 -20
- package/dist/resources/MemoryCandidate.js +12 -0
- package/dist/resources/MemoryConsolidate.js +2 -1
- package/dist/resources/MemoryDedupStats.js +17 -2
- package/dist/resources/MemoryFeed.js +30 -0
- package/dist/resources/MemoryGrant.js +14 -0
- package/dist/resources/MemoryReflect.js +75 -17
- package/dist/resources/Message.js +190 -0
- package/dist/resources/OrgEvent.js +12 -0
- package/dist/resources/PromoteMemoryCandidate.js +76 -0
- package/dist/resources/RecordUsage.js +1 -1
- package/dist/resources/Relationship.js +12 -0
- package/dist/resources/SemanticSearch.js +45 -13
- package/dist/resources/Soul.js +54 -18
- package/dist/resources/WorkspaceState.js +12 -0
- package/dist/resources/auth-middleware.js +17 -44
- package/dist/resources/authority-field-guard.js +37 -0
- package/dist/resources/bm25-index-service.js +1 -1
- package/dist/resources/bm25-index.js +50 -11
- package/dist/resources/embedding-space-guard.js +238 -0
- package/dist/resources/embeddings-provider.js +32 -5
- package/dist/resources/federation-classify.js +23 -1
- package/dist/resources/health.js +11 -2
- package/dist/resources/hit-tracking.js +244 -0
- package/dist/resources/mcp-tools.js +272 -7
- package/dist/resources/memory-reflect-lib.js +111 -0
- package/dist/resources/migrations/embedding-stamp.js +22 -4
- package/dist/resources/owner-field-guard.js +62 -0
- package/dist/resources/promotion-stamp.js +29 -0
- package/dist/resources/record-owner-guard.js +71 -5
- package/dist/resources/record-types.js +30 -7
- package/dist/resources/relay-lib.js +205 -0
- package/dist/resources/relay-ops.js +294 -0
- package/dist/resources/skill-write.js +120 -0
- package/dist/resources/soul-adk-guard.js +68 -0
- package/dist/resources/soul-write-policy.js +63 -0
- package/dist/resources/table-helpers.js +2 -0
- package/dist/resources/usage-recording.js +3 -3
- package/dist/src/rem/promote-policy.js +204 -0
- package/docs/api-reference.md +374 -0
- package/docs/auth.md +52 -0
- package/docs/federation.md +4 -0
- package/docs/integrations.md +6 -6
- package/docs/mcp-clients.md +16 -1
- package/docs/releasing.md +11 -8
- package/docs/rem.md +20 -2
- package/docs/upgrade.md +47 -2
- package/package.json +6 -5
- package/schemas/memory.graphql +51 -2
- package/schemas/message.graphql +74 -0
- package/templates/launchd/start-flair-with-admin-pass.sh +73 -0
package/dist/doctor-client.js
CHANGED
|
@@ -839,6 +839,41 @@ export function detectWiredFlairMcp(homeDir) {
|
|
|
839
839
|
}
|
|
840
840
|
return { wired, pinnedVersion };
|
|
841
841
|
}
|
|
842
|
+
/** The `@tpsdev-ai/flair-mcp` version pinned in a client's MCP config, or null
|
|
843
|
+
* when the client is unwired or wired unpinned. */
|
|
844
|
+
export function readClientMcpPin(clientId, homeDir) {
|
|
845
|
+
const configPath = withHome(homeDir, () => clientConfigPath(clientId));
|
|
846
|
+
return extractFlairMcpPin(readTextFile(configPath) ?? "");
|
|
847
|
+
}
|
|
848
|
+
/**
|
|
849
|
+
* Compare a harness's SessionStart hook pin against its MCP client pin.
|
|
850
|
+
*
|
|
851
|
+
* `flair init`/`flair upgrade`/`flair hook install` keep both on the same
|
|
852
|
+
* @version. A skew means an upgrade moved the client block forward but left
|
|
853
|
+
* the hook behind (flair#1516) — so every session silently launches the OLD
|
|
854
|
+
* adapter while the client block advertises the new one. `flair doctor` used
|
|
855
|
+
* to report such a hook as wired "and still runs" without ever comparing the
|
|
856
|
+
* two pins.
|
|
857
|
+
*
|
|
858
|
+
* Only a concrete-vs-concrete difference is a skew: an unpinned hook (pre-
|
|
859
|
+
* #1143) or a missing pin on either side is "nothing to compare", never a
|
|
860
|
+
* false skew.
|
|
861
|
+
*/
|
|
862
|
+
export function checkSessionStartHookPinSkew(homeDir, harness) {
|
|
863
|
+
const hookPath = harness === "codex"
|
|
864
|
+
? join(homeDir, ".codex", "hooks.json")
|
|
865
|
+
: join(homeDir, ".claude", "settings.json");
|
|
866
|
+
const hook = checkSessionStartHook(homeDir, hookPath);
|
|
867
|
+
const hookWired = hook.present && isFlairHookCommand(hook.command ?? "");
|
|
868
|
+
const hookPin = hookWired ? extractFlairMcpPin(hook.command ?? "") : null;
|
|
869
|
+
const clientPin = readClientMcpPin(harness, homeDir);
|
|
870
|
+
return {
|
|
871
|
+
hookWired,
|
|
872
|
+
hookPin,
|
|
873
|
+
clientPin,
|
|
874
|
+
skewed: !!hookPin && !!clientPin && hookPin !== clientPin,
|
|
875
|
+
};
|
|
876
|
+
}
|
|
842
877
|
/**
|
|
843
878
|
* Merge-safe insert of a Flair SessionStart hook group into the harness
|
|
844
879
|
* settings file (default ~/.claude/settings.json) — creates the file/array
|
package/dist/hook-install.js
CHANGED
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
56
56
|
import { dirname, join } from "node:path";
|
|
57
57
|
import { SESSION_START_HOOK_MARKER, buildSessionStartHookCommand, buildContinuityCaptureHookCommand, checkContinuityCaptureHooks, computeContinuityHookInstall, computeContinuityHookRemoval, hookCommandIsSilenced, isHookCommandValueSafe, isSessionStartHookInvocation, readClientMcpBlock, } from "./doctor-client.js";
|
|
58
|
+
import { mcpServerSpec } from "./lib/mcp-spec.js";
|
|
58
59
|
// ── harness registry ────────────────────────────────────────────────────────
|
|
59
60
|
/** SessionStart hook harnesses. A second harness is an additive registry
|
|
60
61
|
* entry, not a rewrite (Kern's #719 verdict: "a switch statement... is
|
|
@@ -352,6 +353,79 @@ export function installHook(opts) {
|
|
|
352
353
|
backupPath, delta,
|
|
353
354
|
};
|
|
354
355
|
}
|
|
356
|
+
/**
|
|
357
|
+
* Re-pin an ALREADY-WIRED Flair SessionStart hook to the current
|
|
358
|
+
* mcpServerSpec(), preserving the agent id and Flair URL the entry already
|
|
359
|
+
* carries.
|
|
360
|
+
*
|
|
361
|
+
* This is `flair upgrade`'s hook counterpart to refreshing the MCP client
|
|
362
|
+
* pins (flair#1516). `flair upgrade` re-pins every wired client's MCP block
|
|
363
|
+
* to the new @version but used to leave the SessionStart hook command on the
|
|
364
|
+
* OLD one — so a user who upgraded by the documented path kept launching the
|
|
365
|
+
* previous adapter on every session, silently, while `flair doctor` reported
|
|
366
|
+
* the hook "still runs" without noticing the skew.
|
|
367
|
+
*
|
|
368
|
+
* NEVER adds a hook — a home with no Flair hook is a clean `skip`, not an
|
|
369
|
+
* `add`: wiring a hook is `flair init` / `flair hook install`, an opt-in the
|
|
370
|
+
* upgrade path must not make on the user's behalf. Only rewrites the exact
|
|
371
|
+
* canonical `npx -y -p …` invocation Flair itself writes (pinned or the
|
|
372
|
+
* pre-#1143 unpinned form); a hand-edited command is the user's and is left
|
|
373
|
+
* untouched. Fail-closed on a malformed settings file and backs up before any
|
|
374
|
+
* real write — the same Sherlock conditions installHook implements. Idempotent:
|
|
375
|
+
* a second call is a `noop`.
|
|
376
|
+
*/
|
|
377
|
+
export function repinSessionStartHook(homeDir, harness) {
|
|
378
|
+
const path = hookSettingsPath(homeDir, harness);
|
|
379
|
+
const skip = (ok, message) => ({ ok, path, harness, action: "skip", message, backupPath: null });
|
|
380
|
+
const read = readSettingsFile(path);
|
|
381
|
+
if (read.parseError) {
|
|
382
|
+
return skip(false, `${read.parseError} — refusing to re-pin a file we can't safely parse; left untouched`);
|
|
383
|
+
}
|
|
384
|
+
const config = read.parsed ?? {};
|
|
385
|
+
const existing = findHookEntry(config);
|
|
386
|
+
if (!existing) {
|
|
387
|
+
return skip(true, `no Flair SessionStart hook in ${path} — nothing to re-pin`);
|
|
388
|
+
}
|
|
389
|
+
const current = config.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex]?.command ?? "";
|
|
390
|
+
// Only re-pin the canonical invocation Flair writes. A legacy (pre-#1143,
|
|
391
|
+
// no `-p`) or hand-edited command is NOT version-bumped here — `flair
|
|
392
|
+
// doctor`/`flair hook install` own the legacy → current rewrite, with their
|
|
393
|
+
// own consent.
|
|
394
|
+
if (!isSessionStartHookInvocation(current)) {
|
|
395
|
+
return skip(true, `SessionStart hook in ${path} is not the canonical form Flair writes — left untouched`);
|
|
396
|
+
}
|
|
397
|
+
const env = parseHookCommandEnv(current);
|
|
398
|
+
if (!env.agentId) {
|
|
399
|
+
return skip(true, `could not read the agent id from the SessionStart hook in ${path} — left untouched`);
|
|
400
|
+
}
|
|
401
|
+
let next;
|
|
402
|
+
try {
|
|
403
|
+
next = buildSessionStartHookCommand(env.agentId, env.flairUrl);
|
|
404
|
+
}
|
|
405
|
+
catch (err) {
|
|
406
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
407
|
+
return skip(false, `could not rebuild the SessionStart hook command for ${path}: ${reason}`);
|
|
408
|
+
}
|
|
409
|
+
if (next === current) {
|
|
410
|
+
return { ok: true, path, harness, action: "noop", message: `SessionStart hook in ${path} already pinned to ${mcpServerSpec()}`, backupPath: null };
|
|
411
|
+
}
|
|
412
|
+
let backupPath = null;
|
|
413
|
+
try {
|
|
414
|
+
backupPath = takeBackup(path);
|
|
415
|
+
}
|
|
416
|
+
catch (err) {
|
|
417
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
418
|
+
return skip(false, `could not back up ${path} before re-pinning it: ${reason} — refusing to touch it`);
|
|
419
|
+
}
|
|
420
|
+
const newConfig = deepClone(config);
|
|
421
|
+
newConfig.hooks.SessionStart[existing.groupIndex].hooks[existing.hookIndex] = { type: "command", command: next };
|
|
422
|
+
writeFileSync(path, JSON.stringify(newConfig, null, 2) + "\n");
|
|
423
|
+
return {
|
|
424
|
+
ok: true, path, harness, action: "update",
|
|
425
|
+
message: `re-pinned the SessionStart hook in ${path} to ${mcpServerSpec()}`,
|
|
426
|
+
backupPath,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
355
429
|
/** Symmetric removal — deletes ONLY our hook entry (found the same way
|
|
356
430
|
* install finds it: SESSION_START_HOOK_MARKER substring match), never
|
|
357
431
|
* touches unrelated hooks/keys. A no-op (ok:true, action "noop") when
|
|
@@ -145,6 +145,20 @@ export function prefixFromPackageDir(packageDir, platform = process.platform) {
|
|
|
145
145
|
const kept = segments.slice(0, Math.max(1, segments.length - ups));
|
|
146
146
|
return kept.join(win32 ? "\\" : "/") || (win32 ? packageDir : "/");
|
|
147
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* Inverse of {@link prefixFromPackageDir}: where a global
|
|
150
|
+
* `@tpsdev-ai/flair` install lives under `prefix`.
|
|
151
|
+
*
|
|
152
|
+
* String-based (not path.join) so the win32 shape stays faithful even in
|
|
153
|
+
* tests running on posix hosts.
|
|
154
|
+
*/
|
|
155
|
+
export function npmGlobalFlairPackageDir(prefix, platform = process.platform) {
|
|
156
|
+
const win32 = platform === "win32";
|
|
157
|
+
const clean = stripTrailingSeps(prefix.trim(), win32);
|
|
158
|
+
return win32
|
|
159
|
+
? [clean, "node_modules", "@tpsdev-ai", "flair"].join("\\")
|
|
160
|
+
: [clean, "lib", "node_modules", "@tpsdev-ai", "flair"].join("/");
|
|
161
|
+
}
|
|
148
162
|
function defaultBinDirHasFlair(binDir, platform) {
|
|
149
163
|
const names = platform === "win32" ? ["flair.cmd", "flair"] : ["flair"];
|
|
150
164
|
return names.some((n) => existsSync(join(binDir, n)));
|
package/dist/lib/auth-resolve.js
CHANGED
|
@@ -445,6 +445,21 @@ export async function authedRequest(method, path, body, opts) {
|
|
|
445
445
|
console.error(`Warning: Ed25519 auth failed for agent '${opts.agentId}': ${message}`);
|
|
446
446
|
}
|
|
447
447
|
}
|
|
448
|
+
// Tier 1.5: flag-pinned agent (flair#1500) — an --agent flag identity signs
|
|
449
|
+
// as THAT agent BEFORE env admin. A flag-pinned agent with no key on disk is
|
|
450
|
+
// a hard error (never a warning, never a silent substitution to env admin or
|
|
451
|
+
// the local admin-pass file): the operator named a specific identity and the
|
|
452
|
+
// CLI must not quietly sign as someone else.
|
|
453
|
+
if (!authHeader && opts.agentIdSource === "flag" && opts.agentId) {
|
|
454
|
+
const keyPath = resolveKeyPath(opts.agentId);
|
|
455
|
+
if (!keyPath) {
|
|
456
|
+
throw new Error(`--agent '${opts.agentId}' has no signing key. Expected a key at ` +
|
|
457
|
+
`${join(defaultKeysDir(), `${opts.agentId}.key`)} (or set FLAIR_KEY_DIR ` +
|
|
458
|
+
`to a directory containing it). Refusing to fall back to FLAIR_ADMIN_PASS ` +
|
|
459
|
+
`or the local admin-pass file.`);
|
|
460
|
+
}
|
|
461
|
+
authHeader = buildEd25519Auth(opts.agentId, method, path, keyPath);
|
|
462
|
+
}
|
|
448
463
|
// Tier 2: env — FLAIR_TOKEN (Bearer), else FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD (Basic).
|
|
449
464
|
if (!authHeader) {
|
|
450
465
|
if (process.env.FLAIR_TOKEN) {
|
package/dist/lib/doctor-run.js
CHANGED
|
@@ -53,19 +53,24 @@ function runMcpBlock(ctx) {
|
|
|
53
53
|
if (mcp.length === 0) {
|
|
54
54
|
return result(id, label, "skip", { detail: "no MCP client detected" });
|
|
55
55
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
56
|
+
// flair#989: DETECTION is not OPT-IN. A client whose binary or config merely
|
|
57
|
+
// exists on the box — e.g. Codex is installed but the user ran `flair init
|
|
58
|
+
// --client claude-code` — is not an install FAILURE for lacking a Flair
|
|
59
|
+
// block; it was never opted into. The MCP block IS the opt-in signal, so the
|
|
60
|
+
// install-health subject is the WIRED clients only. An un-wired but detected
|
|
61
|
+
// client is surfaced as info by `flair doctor`, never counted as a failure
|
|
62
|
+
// here (which used to inflate the ✗ count with clients the user never chose).
|
|
63
|
+
const wired = mcp.filter((clientId) => readClientMcpBlock(clientId, ctx.homeDir).present);
|
|
64
|
+
if (wired.length === 0) {
|
|
65
|
+
// Detected clients exist, but Flair is wired to none of them. Not a
|
|
66
|
+
// per-client failure (nothing was opted in) — a skip that names the
|
|
67
|
+
// detected clients, so a zero-wiring run neither invents a failure the
|
|
68
|
+
// user didn't cause nor masquerades as a verified-healthy wiring.
|
|
69
|
+
return result(id, label, "skip", {
|
|
70
|
+
detail: `no Flair MCP server wired into any detected client (${mcp.join(", ")}) — wire one with: flair init --client <id>`,
|
|
66
71
|
});
|
|
67
72
|
}
|
|
68
|
-
return result(id, label, "pass", { detail: `configured for ${
|
|
73
|
+
return result(id, label, "pass", { detail: `configured for ${wired.join(", ")}` });
|
|
69
74
|
}
|
|
70
75
|
function runFlairUrl(ctx) {
|
|
71
76
|
const id = "flair-url";
|
|
@@ -91,8 +96,11 @@ function runFlairUrl(ctx) {
|
|
|
91
96
|
function runClaudeMd(ctx) {
|
|
92
97
|
const id = "claude-md";
|
|
93
98
|
const label = "CLAUDE.md bootstrap";
|
|
94
|
-
|
|
95
|
-
|
|
99
|
+
// flair#989: only relevant once Claude Code is actually WIRED. A Claude Code
|
|
100
|
+
// binary merely present on the box (never `flair init`-ed) does not owe a
|
|
101
|
+
// CLAUDE.md bootstrap line — same opt-in rule as runMcpBlock/runSessionStartHook.
|
|
102
|
+
if (!ctx.detectedClientIds.includes("claude-code") || !readClientMcpBlock("claude-code", ctx.homeDir).present) {
|
|
103
|
+
return result(id, label, "skip", { detail: "Claude Code not wired" });
|
|
96
104
|
}
|
|
97
105
|
const check = checkClaudeMdBootstrap(ctx.cwd, ctx.homeDir);
|
|
98
106
|
if (!check.present) {
|
|
@@ -106,9 +114,14 @@ function runClaudeMd(ctx) {
|
|
|
106
114
|
function runSessionStartHook(ctx) {
|
|
107
115
|
const id = "session-start-hook";
|
|
108
116
|
const label = "SessionStart hook";
|
|
109
|
-
|
|
117
|
+
// flair#989: only a harness the user actually WIRED (its MCP block is
|
|
118
|
+
// present) owes a SessionStart hook. A merely-detected harness — Codex on
|
|
119
|
+
// PATH that was never `flair init`-ed — is not an install failure for
|
|
120
|
+
// lacking a hook it was never asked to have. This mirrors runMcpBlock's
|
|
121
|
+
// opt-in rule so a detected-but-unwired client can't fail either check.
|
|
122
|
+
const harnesses = SUPPORTED_HARNESSES.filter((h) => ctx.detectedClientIds.includes(h) && readClientMcpBlock(h, ctx.homeDir).present);
|
|
110
123
|
if (harnesses.length === 0) {
|
|
111
|
-
return result(id, label, "skip", { detail: "no hook-capable client detected" });
|
|
124
|
+
return result(id, label, "skip", { detail: "no wired hook-capable client detected" });
|
|
112
125
|
}
|
|
113
126
|
const missing = [];
|
|
114
127
|
const loud = [];
|
|
@@ -0,0 +1,198 @@
|
|
|
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" means
|
|
186
|
+
// something is still serving; "unreachable" means a wedged daemon may still
|
|
187
|
+
// be BOUND to the port (ignored SIGTERM) — both would EADDRINUSE on load.
|
|
188
|
+
if (postStopHealth.kind !== "refused") {
|
|
189
|
+
return {
|
|
190
|
+
kind: "failed",
|
|
191
|
+
detail: postStopHealth.kind === "ok"
|
|
192
|
+
? "port still occupied after stopping the direct process"
|
|
193
|
+
: "port not confirmed free after stopping the direct process (a wedged process may still hold it)",
|
|
194
|
+
remedy: ["flair stop", "flair doctor --fix"],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
return "proceed";
|
|
198
|
+
}
|
|
@@ -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
|
+
}
|