@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.
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"commit": "
|
|
4
|
-
"builtAt": "2026-09-
|
|
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 {
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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.
|
|
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 .
|