@tpsdev-ai/flair 0.52.0 → 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.
@@ -1,6 +1,6 @@
1
1
  {
2
- "version": "0.52.0",
3
- "commit": "f5fbdfda5d1065256f70821721c53fb6ec93a84c",
4
- "builtAt": "2026-09-09T16:23:44.353Z",
2
+ "version": "0.53.0",
3
+ "commit": "3a238b4a3ce3fce8cf64fcb83bd3203096ebae3a",
4
+ "builtAt": "2026-09-10T17:58:16.549Z",
5
5
  "builder": "tsc"
6
6
  }
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import { load as parseYaml } from "js-yaml";
5
5
  import * as render from "./render.js";
6
6
  import { existsSync, mkdirSync, writeFileSync, readFileSync, openSync, closeSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, constants as fsConstants, } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
- import { join, resolve, sep, dirname } from "node:path";
8
+ import { join, resolve, sep, dirname, basename } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
10
  import { spawn, execFileSync, spawnSync, execSync } from "node:child_process";
11
11
  import { createHash, randomUUID, randomBytes } from "node:crypto";
@@ -33,7 +33,9 @@ import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snaps
33
33
  import { entityFormatHint, parseEntitiesCsv } from "./lib/entity-vocab-cli.js";
34
34
  import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
35
35
  import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
36
- import { applyUpgradeHookConsent, catalogIssueDelta, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
36
+ import { classifyPlist, planLaunchdRepair, mapRepairThrow, decideAdoptStop, } from "./lib/launchd-repair.js";
37
+ import { stabilizeMqttNetworkKeyOrder } from "./lib/stabilize-mqtt-network.js";
38
+ import { applyUpgradeHookConsent, catalogIssueDelta, DOCTOR_CHECK_IDS, renderCatalogDoctorLines, renderVerifiedSummary, runDoctorChecks, } from "./lib/doctor-run.js";
37
39
  import { classifyDaemonState, verifyIdentity, parseProcStatStartTime, procStartTimeToEpochMs, parsePsLstart, parseSidecarJson, } from "./lib/daemon-liveness.js";
38
40
  // Value-only static import so `--interval`'s advertised default cannot drift
39
41
  // from the one the scheduler actually validates against. The module itself is
@@ -221,21 +223,45 @@ function launchdPlistPath(label, launchAgentsDir = defaultLaunchAgentsDir()) {
221
223
  */
222
224
  export function buildLaunchdPlist(opts) {
223
225
  const e = escapeXml;
224
- return `<?xml version="1.0" encoding="UTF-8"?>
225
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
226
- <plist version="1.0">
227
- <dict>
228
- <key>Label</key><string>${e(opts.label)}</string>
229
- <key>ProgramArguments</key>
230
- <array>
226
+ const passFile = opts.passFile;
227
+ // ProgramArguments: inline mode execs node directly; pass-file mode execs
228
+ // the launcher, which reads the secret from a 0600 file and then execs node
229
+ // itself (so launchd still tracks Harper as the job).
230
+ const programArguments = passFile
231
+ ? `<array>
232
+ <string>${e(passFile.launcher)}</string>
233
+ <string>${e(passFile.adminPassFile)}</string>
234
+ <string>${e(opts.execPath)}</string>
235
+ <string>${e(opts.harperBinPath)}</string>
236
+ </array>`
237
+ : `<array>
231
238
  <string>${e(opts.execPath)}</string>
232
239
  <string>${e(opts.harperBinPath)}</string>
233
240
  <string>run</string>
234
241
  <string>.</string>
235
- </array>
236
- <key>WorkingDirectory</key><string>${e(opts.workingDirectory)}</string>
237
- <key>EnvironmentVariables</key>
238
- <dict>
242
+ </array>`;
243
+ // EnvironmentVariables: pass-file mode drops HDB_ADMIN_PASSWORD (the secret
244
+ // is read from the file by the launcher) and adds HOME + PATH, which the
245
+ // launcher needs under launchd's minimal env to start Harper non-interactively.
246
+ const environmentVariables = passFile
247
+ ? `<dict>
248
+ <key>ROOTPATH</key><string>${e(opts.dataDir)}</string>
249
+ <key>FLAIR_MODELS_DIR</key><string>${e(opts.modelsDir)}</string>
250
+ <key>HARPER_SET_CONFIG</key><string>${e(opts.setConfig)}</string>
251
+ <key>DEFAULTS_MODE</key><string>dev</string>
252
+ <key>HDB_ADMIN_USERNAME</key><string>${e(opts.adminUser)}</string>
253
+ <key>THREADS_COUNT</key><string>1</string>
254
+ <key>NODE_HOSTNAME</key><string>localhost</string>
255
+ <key>HTTP_PORT</key><string>${e(String(opts.httpPort))}</string>
256
+ <key>OPERATIONSAPI_NETWORK_PORT</key><string>${e(opts.opsNetworkPort)}</string>
257
+ <key>LOCAL_STUDIO</key><string>false</string>
258
+ <key>MQTT_NETWORK_PORT</key><string>null</string>
259
+ <key>MQTT_NETWORK_SECUREPORT</key><string>null</string>
260
+ <key>MQTT_WEBSOCKET</key><string>false</string>
261
+ <key>HOME</key><string>${e(passFile.home)}</string>
262
+ <key>PATH</key><string>${e(passFile.path)}</string>
263
+ </dict>`
264
+ : `<dict>
239
265
  <key>ROOTPATH</key><string>${e(opts.dataDir)}</string>
240
266
  <key>FLAIR_MODELS_DIR</key><string>${e(opts.modelsDir)}</string>
241
267
  <key>HARPER_SET_CONFIG</key><string>${e(opts.setConfig)}</string>
@@ -247,7 +273,20 @@ export function buildLaunchdPlist(opts) {
247
273
  <key>HTTP_PORT</key><string>${e(String(opts.httpPort))}</string>
248
274
  <key>OPERATIONSAPI_NETWORK_PORT</key><string>${e(opts.opsNetworkPort)}</string>
249
275
  <key>LOCAL_STUDIO</key><string>false</string>
250
- </dict>
276
+ <key>MQTT_NETWORK_PORT</key><string>null</string>
277
+ <key>MQTT_NETWORK_SECUREPORT</key><string>null</string>
278
+ <key>MQTT_WEBSOCKET</key><string>false</string>
279
+ </dict>`;
280
+ return `<?xml version="1.0" encoding="UTF-8"?>
281
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
282
+ <plist version="1.0">
283
+ <dict>
284
+ <key>Label</key><string>${e(opts.label)}</string>
285
+ <key>ProgramArguments</key>
286
+ ${programArguments}
287
+ <key>WorkingDirectory</key><string>${e(opts.workingDirectory)}</string>
288
+ <key>EnvironmentVariables</key>
289
+ ${environmentVariables}
251
290
  <key>RunAtLoad</key><true/>
252
291
  <key>KeepAlive</key><true/>
253
292
  <key>StandardOutPath</key><string>${e(join(opts.dataDir, "log", "launchd-stdout.log"))}</string>
@@ -255,6 +294,46 @@ export function buildLaunchdPlist(opts) {
255
294
  </dict>
256
295
  </plist>`;
257
296
  }
297
+ /**
298
+ * Absolute path to the product-owned launchd launcher (flair#1573 slice a).
299
+ * Shipped in the package under templates/launchd/; the plist's
300
+ * ProgramArguments point at it in pass-file mode. `packageRoot` is injectable
301
+ * so tests can point this at a fixture tree instead of the real package dir.
302
+ */
303
+ export function launchdLauncherPath(packageRoot = flairPackageDir()) {
304
+ return join(packageRoot, "templates", "launchd", "start-flair-with-admin-pass.sh");
305
+ }
306
+ /**
307
+ * Write `content` to `path` atomically: write to a temp file in the SAME
308
+ * directory, then rename over the target. Same-fs rename is atomic on POSIX,
309
+ * so a reader never observes a half-written file. `mode` is applied to the
310
+ * temp file from creation — pass 0o600 when `content` holds a secret, so the
311
+ * secret is never briefly world-readable on disk (flair#1573 slice a).
312
+ */
313
+ export function writeFileAtomic(path, content, mode) {
314
+ const dir = dirname(path);
315
+ mkdirSync(dir, { recursive: true });
316
+ const tmpPath = join(dir, `.${basename(path)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`);
317
+ try {
318
+ writeFileSync(tmpPath, content, { mode });
319
+ renameSync(tmpPath, path);
320
+ }
321
+ catch (err) {
322
+ try {
323
+ unlinkSync(tmpPath);
324
+ }
325
+ catch { /* best effort */ }
326
+ throw err;
327
+ }
328
+ }
329
+ /**
330
+ * Write the admin password to `path` with mode 0600 from creation (flair#1573
331
+ * slice a). The secret is written to a temp file in the same dir (0600) and
332
+ * renamed into place, so it is never briefly world-readable.
333
+ */
334
+ export function writeAdminPassFile(path, content) {
335
+ writeFileAtomic(path, content, 0o600);
336
+ }
258
337
  /**
259
338
  * Which launchd label an existing installation for `dataDir` is actually
260
339
  * registered under right now. Prefers the new instance-scoped label if its
@@ -854,6 +933,26 @@ export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
854
933
  network: { port: opsNetworkPortValue(opsBindHost, opsPort), cors: true, domainSocket: opsSocket },
855
934
  };
856
935
  }
936
+ /**
937
+ * Harper config that fully disables the MQTT broker (flair#1586).
938
+ *
939
+ * Flair does not use MQTT. Harper's mqtt component (server/mqtt.ts
940
+ * `handleApplication`) binds a TCP listener on `mqtt.network.port` (1883) and
941
+ * a TLS listener on `mqtt.network.securePort` (8883) whenever EITHER is truthy
942
+ * (`if (port || securePort)`), plus a WebSocket upgrade path when
943
+ * `mqtt.webSocket` is true. Nulling only `network.port` leaves
944
+ * `network.securePort` at its 8883 default, so the TLS listener still binds.
945
+ * Fully disabling MQTT requires nulling BOTH ports and turning off the
946
+ * WebSocket path.
947
+ *
948
+ * Note: config-root.schema.json still documents a flat `mqtt.port` /
949
+ * `mqtt.securePort`, but the runtime reads the nested `mqtt.network.*` form
950
+ * (see static/defaultConfig.yaml) — the flat keys are stale.
951
+ */
952
+ const MQTT_DISABLED_CONFIG = {
953
+ network: { port: null, securePort: null },
954
+ webSocket: false,
955
+ };
857
956
  /**
858
957
  * Build the flair-owned environment overrides for a DIRECT (non-launchd)
859
958
  * Harper spawn (flair#863) — shared by `flair start`'s fallback path and
@@ -868,6 +967,15 @@ export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
868
967
  * the ops API to all interfaces on every restart/upgrade, and persisted it.
869
968
  * One builder means the next spawn site cannot reintroduce that gap.
870
969
  *
970
+ * MQTT (flair#1586): the direct-spawn path re-asserts the mqtt disable via the
971
+ * individual MQTT_* env vars (the same channel as OPERATIONSAPI_NETWORK_PORT /
972
+ * HTTP_PORT) rather than HARPER_SET_CONFIG, so it cannot reintroduce the
973
+ * SET_CONFIG drift/restore gap. "null" casts to a null port (Harper's
974
+ * castConfigValue), which passes config validation (portConstraints
975
+ * `.empty(null)`) and is falsy, so Harper's mqtt component (server/mqtt.ts
976
+ * `if (port || securePort)`) binds neither the TCP (1883) nor TLS (8883)
977
+ * listener, and MQTT_WEBSOCKET=false turns off the WebSocket upgrade path.
978
+ *
871
979
  * Deliberately omits HDB_ADMIN_PASSWORD when no password is in hand: an empty
872
980
  * string would strip Harper's auth on an existing install.
873
981
  */
@@ -881,6 +989,14 @@ export function buildDirectSpawnEnv(opts) {
881
989
  HDB_ADMIN_USERNAME: opts.adminUser,
882
990
  HTTP_PORT: String(opts.httpPort),
883
991
  OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opts.opsBindHost, opts.opsPort),
992
+ // flair#1586: fully disable the MQTT broker (Flair does not use it). "null"
993
+ // casts to a null port (Harper's castConfigValue), which passes config
994
+ // validation (portConstraints `.empty(null)`) and is falsy, so Harper binds
995
+ // neither the TCP (1883) nor TLS (8883) listener; MQTT_WEBSOCKET=false turns
996
+ // off the WebSocket upgrade path.
997
+ MQTT_NETWORK_PORT: "null",
998
+ MQTT_NETWORK_SECUREPORT: "null",
999
+ MQTT_WEBSOCKET: "false",
884
1000
  LOCAL_STUDIO: "false",
885
1001
  // flair#905 / lrf5: Harper's forceDowngradePrompt reads CONFIRM_DOWNGRADE
886
1002
  // from the environment (via the `prompt` npm package's assignCmdEnvVariables
@@ -3429,7 +3545,7 @@ program
3429
3545
  rootPath: dataDir,
3430
3546
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
3431
3547
  operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
3432
- mqtt: { network: { port: null }, webSocket: false },
3548
+ mqtt: MQTT_DISABLED_CONFIG,
3433
3549
  localStudio: { enabled: false },
3434
3550
  authentication: { authorizeLocal: false, enableSessions: true },
3435
3551
  });
@@ -3451,6 +3567,12 @@ program
3451
3567
  // all interfaces. See opsNetworkPortValue's doc comment.
3452
3568
  OPERATIONSAPI_NETWORK_PORT: opsNetworkPortValue(opsBindHost, opsPort),
3453
3569
  LOCAL_STUDIO: "false",
3570
+ // flair#1586: same MQTT_* re-assert as buildDirectSpawnEnv / the
3571
+ // launchd plist, so init cannot restore Harper's 1883/8883 defaults
3572
+ // on a later boot that omits HARPER_SET_CONFIG.
3573
+ MQTT_NETWORK_PORT: "null",
3574
+ MQTT_NETWORK_SECUREPORT: "null",
3575
+ MQTT_WEBSOCKET: "false",
3454
3576
  };
3455
3577
  // models (flair#504 Phase 1): the embedding backend registers itself
3456
3578
  // in-process at boot (resources/embeddings-boot.ts, loaded by
@@ -3554,7 +3676,7 @@ program
3554
3676
  rootPath: dataDir,
3555
3677
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
3556
3678
  operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
3557
- mqtt: { network: { port: null }, webSocket: false },
3679
+ mqtt: MQTT_DISABLED_CONFIG,
3558
3680
  localStudio: { enabled: false },
3559
3681
  authentication: { authorizeLocal: false, enableSessions: true },
3560
3682
  });
@@ -11787,6 +11909,253 @@ function observeLaunchdManagement(dataDir, port) {
11787
11909
  list: realLaunchctlLister,
11788
11910
  });
11789
11911
  }
11912
+ /**
11913
+ * Build the launchd plist for a `doctor --fix` repair (flair#1573 slice b).
11914
+ *
11915
+ * Deliberately DIVERGES from the `flair init` plist in one way that matters:
11916
+ * it always uses the pass-file (secret-free) mode, so the regenerated plist
11917
+ * never embeds HDB_ADMIN_PASSWORD inline — the exact regression this issue
11918
+ * exists to prevent. The ports and ROOTPATH come from the instance's own
11919
+ * harper-config.yaml (config authority, flair#914), never ~/.flair/config.yaml
11920
+ * or defaults, so the repair cannot re-bootstrap Harper against a different
11921
+ * directory or port.
11922
+ *
11923
+ * `config` is the parsed harper-config.yaml, already gated readable by the
11924
+ * caller. Throws when the Harper binary cannot be resolved — a plist pointing
11925
+ * at a missing binary is the stale-plist failure this repair must not write.
11926
+ */
11927
+ function buildRepairPlist(dataDir, config) {
11928
+ // The DEFAULT_PORT / httpPort-1 fallbacks below are practically unreachable:
11929
+ // Harper writes harper-config.yaml ports on every boot (flair#914), so a
11930
+ // readable config always carries them. They exist only as a last resort.
11931
+ const httpPort = harperPortValue(config?.http?.port) ?? DEFAULT_PORT;
11932
+ const opsPortRaw = config?.operationsApi?.network?.port;
11933
+ const opsPort = harperPortValue(opsPortRaw) ?? (httpPort - 1);
11934
+ const opsBind = detectOpsApiAllInterfacesBind(opsPortRaw);
11935
+ const opsBindHost = opsBind.boundHost ?? "127.0.0.1";
11936
+ const opsSocket = join(dataDir, "operations-server");
11937
+ // Preserve the config's exact ops-port form (host-qualified or bare) so the
11938
+ // regenerated plist neither re-narrows nor re-widens the bind — the
11939
+ // no-re-bootstrap guarantee is mechanical, not best-effort.
11940
+ const opsNetworkPort = typeof opsPortRaw === "string" && opsPortRaw.trim() !== ""
11941
+ ? opsPortRaw.trim()
11942
+ : opsNetworkPortValue(opsBindHost, opsPort);
11943
+ const setConfig = JSON.stringify({
11944
+ rootPath: dataDir,
11945
+ http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
11946
+ operationsApi: { network: { port: opsNetworkPort, cors: true, domainSocket: opsSocket } },
11947
+ mqtt: MQTT_DISABLED_CONFIG,
11948
+ localStudio: { enabled: false },
11949
+ authentication: { authorizeLocal: false, enableSessions: true },
11950
+ });
11951
+ const harperBinPath = harperBin();
11952
+ if (!harperBinPath)
11953
+ throw new Error(harperBinNotFoundMessage(harperSearchRoots()));
11954
+ const label = launchdLabel(dataDir);
11955
+ const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
11956
+ return buildLaunchdPlist({
11957
+ label,
11958
+ execPath: process.execPath,
11959
+ harperBinPath,
11960
+ workingDirectory: flairPackageDir(),
11961
+ dataDir,
11962
+ modelsDir,
11963
+ setConfig,
11964
+ adminUser: DEFAULT_ADMIN_USER,
11965
+ adminPass: "", // ignored in pass-file mode
11966
+ httpPort,
11967
+ opsNetworkPort,
11968
+ passFile: {
11969
+ launcher: launchdLauncherPath(),
11970
+ adminPassFile: defaultAdminPassPath(),
11971
+ home: homedir(),
11972
+ path: process.env.PATH ?? "/usr/bin:/bin:/usr/sbin:/sbin",
11973
+ },
11974
+ });
11975
+ }
11976
+ /**
11977
+ * Compute the launchd repair plan for `dataDir` (flair#1573 slice b) WITHOUT
11978
+ * executing it — the detect + classify + decide half. The doctor command uses
11979
+ * this for dry-run / non-`--fix` reporting; `repairLaunchdManagement` (below)
11980
+ * reuses it and then executes a `regenerate` or `adopt` plan.
11981
+ */
11982
+ function planLaunchdRepairFor(dataDir, port) {
11983
+ // Config authority gate (flair#914): the whole fix is gated on the
11984
+ // instance's own harper-config.yaml being readable.
11985
+ const config = harperConfigPath(dataDir) ? readHarperConfig(dataDir) : null;
11986
+ const configReadable = config !== null;
11987
+ // Observe the current state.
11988
+ const observation = observeLaunchdManagement(dataDir, port);
11989
+ // Classify the plist disposition (ownership guard's first question).
11990
+ const { plistPath, isLegacy } = resolveLaunchdLabel(dataDir);
11991
+ const disposition = classifyPlist(plistPath, dataDir, {
11992
+ exists: existsSync,
11993
+ read: (p) => { try {
11994
+ return readFileSync(p, "utf-8");
11995
+ }
11996
+ catch {
11997
+ return null;
11998
+ } },
11999
+ readRootPath: readPlistRootPath,
12000
+ });
12001
+ // Is a direct (non-launchd) process serving this instance right now?
12002
+ const instancePid = resolveInstanceServingPid(dataDir, port);
12003
+ const directProcessRunning = instancePid !== null && observation.state !== "managed";
12004
+ const plan = planLaunchdRepair({ observation, disposition, plistPath, directProcessRunning, configReadable });
12005
+ return { plan, plistPath, isLegacy, config };
12006
+ }
12007
+ /**
12008
+ * Repair launchd management for `dataDir` (flair#1573 slice b) — the
12009
+ * `doctor --fix` launchd repair for a MISSING, CORRUPT, or DETACHED plist.
12010
+ *
12011
+ * detect -> (adopt: clean-stop the direct process) -> regenerate (pass-file
12012
+ * mode) -> load -> verify. The DECISION (state matrix + ownership guard +
12013
+ * config authority) lives in planLaunchdRepair (src/lib/launchd-repair.ts);
12014
+ * this is the EXECUTION, and it is the only place that touches the real
12015
+ * filesystem and launchctl.
12016
+ *
12017
+ * Reuses the existing primitives rather than re-inventing them:
12018
+ * - observeLaunchdManagement / assessLaunchdManagement is the fail-loud
12019
+ * verifier (launchctl PID AND that PID is the serving process).
12020
+ * - ensureLaunchdServiceLoaded is the unload -> load -> start.
12021
+ * - the five-state liveness machine (gatherDaemonEvidence +
12022
+ * classifyDaemonState, flair#1454) attributes and clean-stops the direct
12023
+ * process on the adopt path — never a foreign/recycled pid, never kill -9.
12024
+ *
12025
+ * The adopt path (flair#1573 slice b2) BOUNCES the live instance: it
12026
+ * clean-stops the direct-spawned process (SIGTERM + wait for exit), confirms
12027
+ * the port is free, then regenerates + loads. A foreign/unattributable direct
12028
+ * process is refused by the liveness machine (DISAGREEMENT/UNKNOWN), never
12029
+ * signalled.
12030
+ *
12031
+ * Never reports success on a direct-start fallback: the final verify is
12032
+ * assessLaunchdManagement, and anything short of `managed` is a `failed` result
12033
+ * with the detached detail + remedy, never a silent pass. The whole executor
12034
+ * arm is wrapped in try/catch (Kern's b1 defect): a throw becomes a named
12035
+ * `failed` result (or an engine-backwards `refused`), never a crash mid-report.
12036
+ */
12037
+ async function repairLaunchdManagement(dataDir, port) {
12038
+ const { plan, plistPath, isLegacy, config } = planLaunchdRepairFor(dataDir, port);
12039
+ switch (plan.kind) {
12040
+ case "no-op":
12041
+ return { kind: "no-op", reason: plan.reason, detail: plan.detail };
12042
+ case "refuse":
12043
+ return { kind: "refused", reason: plan.reason, detail: plan.detail, plistPath: plan.plistPath };
12044
+ case "adopt":
12045
+ case "regenerate": {
12046
+ try {
12047
+ // Guard FIRST (flair#1093): the repair is a boot path, and an older
12048
+ // engine opening a newer store fails at the storage layer minutes
12049
+ // later — same refusal as startFlairProcess. On the adopt arm this
12050
+ // must run BEFORE the stop: it is a pure read whose inputs don't
12051
+ // change during the repair, so guard-first refuses WITHOUT bouncing
12052
+ // the live instance (guard-after-stop would SIGTERM the instance and
12053
+ // then refuse, leaving it down with nothing to restart it).
12054
+ guardEngineNotBackwards(dataDir);
12055
+ // Adopt (flair#1573 slice b2): clean-stop the direct process first, so
12056
+ // the regenerate + load below does not collide on the port.
12057
+ if (plan.kind === "adopt") {
12058
+ const stop = await stopDirectProcessForAdopt(port, dataDir);
12059
+ if (stop)
12060
+ return stop; // a named failed result
12061
+ }
12062
+ const { execSync } = await import("node:child_process");
12063
+ // Regenerate the plist (pass-file mode) and write it atomically.
12064
+ // No secret is embedded, so 0644 is correct here.
12065
+ const plist = buildRepairPlist(dataDir, config);
12066
+ const newPlistPath = launchdPlistPath(launchdLabel(dataDir));
12067
+ writeFileAtomic(newPlistPath, plist, 0o644);
12068
+ // flair#1586 / #1581: a SET_CONFIG-less detach (MQTT_* via
12069
+ // buildDirectSpawnEnv) can persist mqtt.network as mtls, port,
12070
+ // securePort when Harper stored no originals for already-null ports.
12071
+ // Adopt SET_CONFIG updates those keys in place and would otherwise
12072
+ // leave harper-config.yaml not byte-identical to the first-repair
12073
+ // file (port, securePort, mtls). Reorder only those scalar lines
12074
+ // before launchd loads so the next persist matches the settled file.
12075
+ const cfgPath = harperConfigPath(dataDir);
12076
+ if (cfgPath) {
12077
+ const raw = readFileSync(cfgPath, "utf-8");
12078
+ const { text, changed } = stabilizeMqttNetworkKeyOrder(raw);
12079
+ if (changed)
12080
+ writeFileAtomic(cfgPath, text, 0o644);
12081
+ }
12082
+ // If the resolved plist was a pre-flair#693 legacy label, unload and
12083
+ // remove it so it is not orphaned beside the regenerated one.
12084
+ if (isLegacy && plistPath !== newPlistPath) {
12085
+ try {
12086
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
12087
+ }
12088
+ catch { /* best effort */ }
12089
+ try {
12090
+ unlinkSync(plistPath);
12091
+ }
12092
+ catch { /* best effort */ }
12093
+ }
12094
+ // Load (unload -> load -> start).
12095
+ ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
12096
+ // Verify (fail-loud).
12097
+ const after = observeLaunchdManagement(dataDir, port);
12098
+ if (after.state !== "managed") {
12099
+ return { kind: "failed", detail: after.detail, remedy: after.remedy };
12100
+ }
12101
+ const detail = plan.kind === "adopt"
12102
+ ? `adopted the direct-spawned instance into launchd (bounced the live instance): ${after.detail}`
12103
+ : after.detail;
12104
+ return { kind: "repaired", detail };
12105
+ }
12106
+ catch (err) {
12107
+ return mapRepairThrow(err);
12108
+ }
12109
+ }
12110
+ }
12111
+ }
12112
+ /**
12113
+ * Clean-stop the direct (non-launchd) process serving `dataDir`+`port` for the
12114
+ * adopt path (flair#1573 slice b2). Returns a `failed` result when the process
12115
+ * cannot be attributed (never stop a foreign process) or the port is still
12116
+ * occupied after the stop; returns null when the port is free and the caller
12117
+ * should proceed to regenerate + load.
12118
+ *
12119
+ * Reuses the five-state liveness machine (flair#1454): identity is verified
12120
+ * (pidfile + sidecar + start time) before any signal, so a DISAGREEMENT/UNKNOWN
12121
+ * verdict refuses rather than gambling on a recycled pid. The stop is SIGTERM +
12122
+ * wait for exit — never kill -9.
12123
+ */
12124
+ async function stopDirectProcessForAdopt(port, dataDir) {
12125
+ const evidence = await gatherDaemonEvidence(port, dataDir);
12126
+ const state = classifyDaemonState(evidence, { port, dataDir });
12127
+ // SIGTERM + wait for exit for a verified live pid (RUNNING or WEDGED — a
12128
+ // wedged daemon is recovery, not a recycled-pid gamble).
12129
+ if (state.state === "RUNNING" || state.state === "WEDGED") {
12130
+ try {
12131
+ process.kill(state.pid, "SIGTERM");
12132
+ }
12133
+ catch { /* already gone */ }
12134
+ try {
12135
+ await waitForProcessExit(state.pid, STARTUP_TIMEOUT_MS);
12136
+ }
12137
+ catch { /* best-effort — the port check below surfaces the real problem */ }
12138
+ }
12139
+ const postStopHealth = await probeHealth(port);
12140
+ const decision = decideAdoptStop(state, postStopHealth);
12141
+ if (decision !== "proceed")
12142
+ return decision;
12143
+ // Belt-and-suspenders: lsof confirms no TCP listener remains before the
12144
+ // caller loads the plist. probeHealth "refused" (ECONNREFUSED) already means
12145
+ // nothing is listening, but a port that is BOUND yet refuses connections
12146
+ // (backlog-full, or a non-HTTP listener) would still EADDRINUSE on load —
12147
+ // this catches that rare case the HTTP probe cannot see.
12148
+ const { execSync } = await import("node:child_process");
12149
+ const listeners = listeningPidsOnPort(port, (cmd) => execSync(cmd, { encoding: "utf-8" }));
12150
+ if (listeners.length > 0) {
12151
+ return {
12152
+ kind: "failed",
12153
+ detail: `port still occupied after stopping the direct process (listener pid ${listeners.join(", ")})`,
12154
+ remedy: ["flair stop", "flair doctor --fix"],
12155
+ };
12156
+ }
12157
+ return null;
12158
+ }
11790
12159
  /**
11791
12160
  * Stop the local Flair (Harper) process — launchd `stop` on darwin when a
11792
12161
  * plist is present (falling back on failure), otherwise a manual SIGTERM by
@@ -13538,16 +13907,22 @@ program
13538
13907
  // catalog upgrade asserts. Adding a check to DOCTOR_CHECK_IDS widens
13539
13908
  // both. Extra doctor UX (pi, --fix, execution probe, continuity,
13540
13909
  // agent registration) stays below and does not redefine those checks.
13910
+ //
13911
+ // flair#1573 slice b — launchd management is diagnosed + repaired by its
13912
+ // own section below (planLaunchdRepairFor / repairLaunchdManagement), not
13913
+ // by the install-health catalog. The catalog's launchd check stays for
13914
+ // `upgrade` (flair#1022), but doctor would otherwise double-count the same
13915
+ // drift (catalog "detached" fail + repair "regenerate"/"adopt"/"refuse").
13916
+ const doctorCatalogIds = DOCTOR_CHECK_IDS.filter((id) => id !== "launchd-management");
13541
13917
  const doctorCtx = {
13542
13918
  homeDir: homedir(),
13543
13919
  cwd: process.cwd(),
13544
13920
  detectedClientIds: detectedClients.map((c) => c.id),
13545
- launchd: observeLaunchdManagement(defaultDataDir(), effectivePort),
13546
13921
  keysDir,
13547
13922
  keyAgentIds,
13548
13923
  agentFlag: typeof opts.agent === "string" ? opts.agent : undefined,
13549
13924
  };
13550
- const catalogBefore = runDoctorChecks(doctorCtx);
13925
+ const catalogBefore = runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds });
13551
13926
  if (detectedClients.length === 0) {
13552
13927
  console.log(` ${render.icons.info} No MCP client detected — skipping client-integration checks`);
13553
13928
  }
@@ -14016,7 +14391,7 @@ program
14016
14391
  // Catalog is the install-health verdict — count fail/unrun here, not
14017
14392
  // via a second issues++ on MCP / CLAUDE.md / SessionStart hook above.
14018
14393
  // --fix that cleared a catalog member shows up in the found→fixed delta.
14019
- const catalogAfter = autoFix ? runDoctorChecks(doctorCtx) : catalogBefore;
14394
+ const catalogAfter = autoFix ? runDoctorChecks(doctorCtx, { catalogIds: doctorCatalogIds }) : catalogBefore;
14020
14395
  const catalogDelta = catalogIssueDelta(catalogBefore, catalogAfter);
14021
14396
  issues += catalogDelta.found;
14022
14397
  if (autoFix)
@@ -14025,6 +14400,74 @@ program
14025
14400
  for (const row of renderCatalogDoctorLines(catalogAfter)) {
14026
14401
  console.log(` ${render.icons[row.icon]} ${row.line}`);
14027
14402
  }
14403
+ // 7b. Launchd management repair (flair#1573 slice b) — `doctor --fix`
14404
+ // repairs a MISSING, CORRUPT, or DETACHED launchd plist. This is a
14405
+ // distinct concern from the install-health catalog above (which
14406
+ // `upgrade` also asserts), so it owns its own reporting + counting
14407
+ // rather than double-counting the catalog's launchd check. The
14408
+ // DECISION is pure (planLaunchdRepairFor -> planLaunchdRepair); the
14409
+ // EXECUTION (adopt: clean-stop -> regenerate pass-file plist -> load ->
14410
+ // verify) is repairLaunchdManagement, which is the only place that
14411
+ // touches the real filesystem and launchctl.
14412
+ console.log(`\n ${render.wrap(render.c.bold, "Launchd management")}`);
14413
+ if (autoFix && !dryRun) {
14414
+ // Execute the repair directly; it re-derives the plan internally and
14415
+ // verifies via assessLaunchdManagement (fail-loud, never a silent pass).
14416
+ const repairResult = await repairLaunchdManagement(defaultDataDir(), effectivePort);
14417
+ switch (repairResult.kind) {
14418
+ case "no-op":
14419
+ console.log(` ${render.icons.ok} ${repairResult.detail}`);
14420
+ break;
14421
+ case "refused":
14422
+ issues++;
14423
+ console.log(` ${render.icons.error} ${repairResult.detail}`);
14424
+ break;
14425
+ case "repaired":
14426
+ fixed++;
14427
+ console.log(` ${render.icons.ok} ${repairResult.detail}`);
14428
+ break;
14429
+ case "failed":
14430
+ issues++;
14431
+ console.log(` ${render.icons.error} ${repairResult.detail}`);
14432
+ if (repairResult.remedy)
14433
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${repairResult.remedy.join(" && ")}`);
14434
+ break;
14435
+ }
14436
+ }
14437
+ else {
14438
+ // Report only (no --fix, or --fix --dry-run): compute the plan, touch
14439
+ // nothing. A regenerate plan is drift; a refuse plan is a named refusal.
14440
+ const repairPlan = planLaunchdRepairFor(defaultDataDir(), effectivePort);
14441
+ switch (repairPlan.plan.kind) {
14442
+ case "no-op":
14443
+ console.log(` ${render.icons.ok} ${repairPlan.plan.detail}`);
14444
+ break;
14445
+ case "refuse":
14446
+ issues++;
14447
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
14448
+ break;
14449
+ case "regenerate":
14450
+ issues++;
14451
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
14452
+ if (dryRun) {
14453
+ console.log(` ${render.wrap(render.c.dim, "Would regenerate")} the launchd plist (pass-file mode) and load it`);
14454
+ }
14455
+ else {
14456
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(regenerates the plist in pass-file mode, loads it, and verifies)")}`);
14457
+ }
14458
+ break;
14459
+ case "adopt":
14460
+ issues++;
14461
+ console.log(` ${render.icons.error} ${repairPlan.plan.detail}`);
14462
+ if (dryRun) {
14463
+ console.log(` ${render.wrap(render.c.dim, "Would adopt")} the direct-spawned instance into launchd (clean-stop, regenerate, load — bounces the live instance)`);
14464
+ }
14465
+ else {
14466
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(clean-stops the direct process, regenerates the plist, loads it, and verifies — bounces the live instance)")}`);
14467
+ }
14468
+ break;
14469
+ }
14470
+ }
14028
14471
  // 7a. Resolve which agent identities the two verified-read sections below
14029
14472
  // (Fleet presence, Migrations) iterate (flair#722). Previously both
14030
14473
  // sections required --agent explicitly; doctor already enumerates every
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "packageManager": "bun@1.3.10",
5
5
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
6
6
  "type": "module",
@@ -0,0 +1,73 @@
1
+ #!/bin/sh
2
+ # start-flair-with-admin-pass.sh — product-owned launchd launcher (flair#1573).
3
+ #
4
+ # Reads the Flair admin password from a 0600 file and execs Harper
5
+ # NON-INTERACTIVELY. The secret never appears in the launchd plist: it enters
6
+ # the process environment here, from the file, at start time. This is the
7
+ # product shape of the no-inline-secret pattern — the plist's ProgramArguments
8
+ # point at this launcher instead of embedding HDB_ADMIN_PASSWORD.
9
+ #
10
+ # Usage: start-flair-with-admin-pass.sh <admin-pass-file> <node> <harper-bin>
11
+ #
12
+ # admin-pass-file path to the 0600 file holding the admin password
13
+ # node the node binary to exec
14
+ # harper-bin Harper's entrypoint (harper.js)
15
+ #
16
+ # HDB_ADMIN_USERNAME is already in the launchd environment (it is not a
17
+ # secret); only the password is read from the file here. HOME, PATH and the
18
+ # Harper config (HARPER_SET_CONFIG / ROOTPATH / FLAIR_MODELS_DIR) are also
19
+ # supplied by the plist's EnvironmentVariables, so Harper boots without ever
20
+ # hitting its interactive readline prompt under launchd's minimal env.
21
+
22
+ set -eu
23
+
24
+ ADMIN_PASS_FILE="$1"
25
+ NODE="$2"
26
+ HARPER_BIN="$3"
27
+
28
+ if [ ! -f "$ADMIN_PASS_FILE" ]; then
29
+ echo "start-flair-with-admin-pass: admin-pass file not found: $ADMIN_PASS_FILE" >&2
30
+ exit 1
31
+ fi
32
+
33
+ if [ ! -r "$ADMIN_PASS_FILE" ]; then
34
+ echo "start-flair-with-admin-pass: admin-pass file not readable: $ADMIN_PASS_FILE" >&2
35
+ exit 1
36
+ fi
37
+
38
+ # Re-verify owner-only (0600) at READ time, not just at `flair init` write
39
+ # time. A file that drifted to 0644 after init (umask change, backup tool,
40
+ # tar restore) would leak the secret to any reader on the host. Mirrors
41
+ # readSecretFileSecure (src/lib/auth-resolve.ts), which refuses any group/other
42
+ # permission bit. `stat -f %Lp` is macOS (the launchd host); `stat -c %a` is
43
+ # Linux (the unit-test host). The two syntaxes are mutually exclusive, so
44
+ # branch on the OS rather than chaining with `||` — on Linux `stat -f %Lp`
45
+ # prints filesystem info to stdout *and* exits non-zero, which would pollute
46
+ # the captured mode. Fail CLOSED: an unreadable mode (empty) refuses rather
47
+ # than proceeding on a guess.
48
+ case "$(uname -s)" in
49
+ Darwin) MODE="$(stat -f %Lp "$ADMIN_PASS_FILE" 2>/dev/null)" ;;
50
+ *) MODE="$(stat -c %a "$ADMIN_PASS_FILE" 2>/dev/null)" ;;
51
+ esac
52
+ case "$MODE" in
53
+ *00) : ;;
54
+ *)
55
+ echo "start-flair-with-admin-pass: admin-pass file permissions '${MODE:-unknown}' are too open (expected 600): $ADMIN_PASS_FILE" >&2
56
+ exit 1
57
+ ;;
58
+ esac
59
+
60
+ # Read the secret. Command substitution strips a trailing newline, which is
61
+ # what `flair init` writes (base64url + "\n"); the value itself is preserved
62
+ # verbatim by the double quotes.
63
+ ADMIN_PASS="$(cat "$ADMIN_PASS_FILE")"
64
+ if [ -z "$ADMIN_PASS" ]; then
65
+ echo "start-flair-with-admin-pass: admin-pass file is empty: $ADMIN_PASS_FILE" >&2
66
+ exit 1
67
+ fi
68
+
69
+ export HDB_ADMIN_PASSWORD="$ADMIN_PASS"
70
+
71
+ # exec (not spawn) so launchd tracks Harper itself — the job's PID is Harper's
72
+ # PID, and KeepAlive restarts the real service rather than a dead launcher.
73
+ exec "$NODE" "$HARPER_BIN" run .