@tpsdev-ai/flair 0.23.0 → 0.24.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/cli.js CHANGED
@@ -3,11 +3,11 @@ import { Command } from "commander";
3
3
  import nacl from "tweetnacl";
4
4
  import { load as parseYaml } from "js-yaml";
5
5
  import * as render from "./render.js";
6
- import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, } from "node:fs";
6
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync, renameSync, cpSync, rmSync, mkdtempSync, readdirSync, statSync, lstatSync, realpathSync, unlinkSync, chownSync, } from "node:fs";
7
7
  import { homedir, tmpdir } from "node:os";
8
8
  import { join, resolve, sep, dirname } from "node:path";
9
- import { spawn } from "node:child_process";
10
- import { createHash, createPrivateKey, sign as nodeCryptoSign, randomUUID, randomBytes } from "node:crypto";
9
+ import { spawn, execFileSync } from "node:child_process";
10
+ import { createHash, randomUUID, randomBytes } from "node:crypto";
11
11
  import { create as tarCreate, extract as tarExtract, list as tarList } from "tar";
12
12
  import { keystore } from "./keystore.js";
13
13
  import { deploy as deployToFabric, validateOptions as validateDeployOptions, buildTargetUrl as buildDeployUrl } from "./deploy.js";
@@ -18,8 +18,11 @@ import { probeInstance } from "./probe.js";
18
18
  import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
19
19
  import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
20
20
  import { detectClients, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
21
- import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
21
+ import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
22
+ import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
22
23
  import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, describeAgentGateFinding, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
24
+ import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
25
+ import { readSecretFileSecure, readAdminPassFileSecure, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
23
26
  // Federation crypto helpers — inlined to avoid cross-boundary imports from
24
27
  // src/ into resources/, which don't survive npm packaging (see also
25
28
  // resources/federation-crypto.ts; the two must stay in sync).
@@ -125,82 +128,117 @@ const DEFAULT_OPS_PORT = 19925;
125
128
  const DEFAULT_ADMIN_USER = "admin";
126
129
  const STARTUP_TIMEOUT_MS = 60_000;
127
130
  const HEALTH_POLL_INTERVAL_MS = 500;
131
+ // flair#670 — single-host default for the Harper ops API bind address.
132
+ // Loopback-only (+ the domain socket, always provisioned) shrinks the
133
+ // network-exposure surface: single-host installs don't need :9925 reachable
134
+ // from any interface but the box itself, so an accidentally-exposed port
135
+ // (misconfigured firewall/container networking) can't be reached remotely.
136
+ const DEFAULT_OPS_BIND_HOST = "127.0.0.1";
137
+ // readSecretFileSecure / readAdminPassFileSecure / defaultAdminPassPath /
138
+ // resolveLocalAdminPass / defaultKeysDir now live in src/lib/auth-resolve.ts
139
+ // (flair#747) — imported above, re-exported at the bottom of this file so
140
+ // the public CLI module surface is unchanged.
141
+ function defaultDataDir() {
142
+ return join(homedir(), ".flair", "data");
143
+ }
144
+ // ─── launchd label (flair#693) ─────────────────────────────────────────────
145
+ // A bare "ai.tpsdev.flair" label is global to the current macOS user's
146
+ // launchd session. A second Flair instance on one host (dev + prod, a
147
+ // second user, the Harper-app embedded-component shape) used to collide on
148
+ // that SAME label — `flair start`/`stop`/`restart` from either instance
149
+ // could silently unload/replace the OTHER instance's daemon (see
150
+ // test/unit/upgrade-data-snapshot.test.ts's header for the exact hazard
151
+ // this caused, pre-fix).
152
+ //
153
+ // The label now incorporates instance identity: a short deterministic hash
154
+ // of the RESOLVED data dir. Same data dir -> same label every run
155
+ // (idempotent init/start/stop); different data dirs -> different labels,
156
+ // so two instances can never collide. Every code path that touches the
157
+ // label goes through the helpers below — no scattered label string
158
+ // literals — and resolveLaunchdLabel()/migrateLegacyLaunchdLabel() handle
159
+ // finding + cleanly migrating a pre-flair#693 install off the bare legacy
160
+ // label so it is never orphaned.
161
+ const LEGACY_LAUNCHD_LABEL = "ai.tpsdev.flair";
162
+ function defaultLaunchAgentsDir() {
163
+ return join(homedir(), "Library", "LaunchAgents");
164
+ }
165
+ /** Instance-scoped launchd label for `dataDir`: ai.tpsdev.flair.<8-hex-char sha256 of the resolved data dir>. */
166
+ function launchdLabel(dataDir) {
167
+ const hash = createHash("sha256").update(resolve(dataDir), "utf8").digest("hex").slice(0, 8);
168
+ return `${LEGACY_LAUNCHD_LABEL}.${hash}`;
169
+ }
170
+ function launchdPlistPath(label, launchAgentsDir = defaultLaunchAgentsDir()) {
171
+ return join(launchAgentsDir, `${label}.plist`);
172
+ }
128
173
  /**
129
- * Read a secret (admin password, Fabric password, ...) from a file, refusing
130
- * if the file is world/group readable.
131
- *
132
- * Secret files (default ~/.flair/admin-pass; also used for --fabric-password-file)
133
- * are short-lived values generated by `openssl rand` mode 0600 keeps them
134
- * out of reach of other local users + most backup tooling. A 0644 file
135
- * silently leaks the secret to anyone with read access to the user's home
136
- * (multi-user hosts, NFS, time-machine snapshots, etc.).
137
- *
138
- * Throws with an actionable error if the file isn't owner-only, otherwise
139
- * returns the trimmed file content (values generated by `openssl rand
140
- * -base64` conventionally end in a newline). `flagName` is only used to
141
- * personalize the error text (e.g. "--admin-pass-file" vs
142
- * "--fabric-password-file") — the check itself is identical either way.
174
+ * Which launchd label an existing installation for `dataDir` is actually
175
+ * registered under right now. Prefers the new instance-scoped label if its
176
+ * plist is present; falls back to the pre-flair#693 bare
177
+ * LEGACY_LAUNCHD_LABEL if only THAT plist is present (so start/stop/
178
+ * uninstall against an old install still find and manage it instead of
179
+ * silently no-op'ing and orphaning it); otherwise returns the new label
180
+ * with nothing registered yet (e.g. before the first `init`).
181
+ * `launchAgentsDir` is injectable so tests can point this at a temp dir
182
+ * instead of the real ~/Library/LaunchAgents.
183
+ */
184
+ function resolveLaunchdLabel(dataDir, launchAgentsDir = defaultLaunchAgentsDir()) {
185
+ const newLabel = launchdLabel(dataDir);
186
+ const newPlistPath = launchdPlistPath(newLabel, launchAgentsDir);
187
+ if (existsSync(newPlistPath))
188
+ return { label: newLabel, plistPath: newPlistPath, isLegacy: false };
189
+ const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, launchAgentsDir);
190
+ if (existsSync(legacyPlistPath))
191
+ return { label: LEGACY_LAUNCHD_LABEL, plistPath: legacyPlistPath, isLegacy: true };
192
+ return { label: newLabel, plistPath: newPlistPath, isLegacy: false };
193
+ }
194
+ /**
195
+ * Migrate a pre-flair#693 legacy-labeled launchd service to the new
196
+ * instance-scoped label for `dataDir`: unload the legacy service FIRST,
197
+ * then rewrite its plist content under the new label/path (same content,
198
+ * only the <Label> value and filename change) and remove the legacy plist
199
+ * file. There is never a moment where both the legacy and new labels are
200
+ * registered for the same data dir. No-op (migrated: false) if there's
201
+ * nothing legacy to migrate, or the new label is already registered.
143
202
  */
144
- function readSecretFileSecure(path, flagName) {
145
- if (!existsSync(path)) {
146
- throw new Error(`${flagName} path does not exist: ${path}`);
203
+ function migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir = defaultLaunchAgentsDir()) {
204
+ const resolved = resolveLaunchdLabel(dataDir, launchAgentsDir);
205
+ if (!resolved.isLegacy) {
206
+ return { migrated: false, label: resolved.label, plistPath: resolved.plistPath };
147
207
  }
148
- const st = statSync(path);
149
- if (st.mode & 0o077) {
150
- const modeOctal = (st.mode & 0o777).toString(8).padStart(3, "0");
151
- throw new Error(`Refusing to read ${flagName} at ${path}: permissions ${modeOctal} are too open. ` +
152
- `Run \`chmod 600 ${path}\` to restrict to owner-only.`);
208
+ const newLabel = launchdLabel(dataDir);
209
+ const newPlistPath = launchdPlistPath(newLabel, launchAgentsDir);
210
+ try {
211
+ runLaunchctl(`launchctl unload "${resolved.plistPath}"`);
153
212
  }
154
- const content = readFileSync(path, "utf-8").replace(/\s+$/, "");
155
- if (!content) {
156
- throw new Error(`${flagName}: file is empty or contains only whitespace: ${path}`);
213
+ catch { /* best effort */ }
214
+ const legacyContent = readFileSync(resolved.plistPath, "utf-8");
215
+ const newContent = legacyContent.replace(`<key>Label</key><string>${LEGACY_LAUNCHD_LABEL}</string>`, `<key>Label</key><string>${newLabel}</string>`);
216
+ writeFileSync(newPlistPath, newContent);
217
+ try {
218
+ unlinkSync(resolved.plistPath);
157
219
  }
158
- return content;
159
- }
160
- /** `readSecretFileSecure` specialized for --admin-pass-file (see that function for the shared check). */
161
- export function readAdminPassFileSecure(path) {
162
- return readSecretFileSecure(path, "--admin-pass-file");
163
- }
164
- function defaultAdminPassPath() {
165
- return join(homedir(), ".flair", "admin-pass");
220
+ catch { /* best effort */ }
221
+ return { migrated: true, label: newLabel, plistPath: newPlistPath };
166
222
  }
167
223
  /**
168
- * Resolve an admin password for LOCAL-only CLI convenience (`agent add`,
169
- * `principal add`) without requiring `--admin-pass` on every call (#590).
170
- * Also reused by `api()`'s local-target auth fallback (flair#634) called
171
- * there as `resolveLocalAdminPass(undefined, !isLocal)`, so only its file leg
172
- * ever fires (the env leg is already handled by `api()` itself first).
173
- *
174
- * Resolution order: explicit value (the `--admin-pass` flag) → `FLAIR_ADMIN_PASS`
175
- * env the secure `~/.flair/admin-pass` file `flair init` already writes with
176
- * mode 0600 (read via `readAdminPassFileSecure`, which enforces that mode).
177
- *
178
- * When `isRemoteTarget` is true, ONLY the explicit value is honored — the env
179
- * and file legs are skipped entirely. This is the security-critical guard: a
180
- * `--target`/`--ops-target` deploy must never silently reuse THIS machine's
181
- * local admin secret against someone else's Harper instance. Remote callers
182
- * keep requiring an explicit `--admin-pass`.
183
- *
184
- * Throws (via readAdminPassFileSecure) if the file exists but has unsafe
185
- * permissions, so a misconfigured file surfaces as an actionable chmod error
186
- * instead of a generic "admin pass required" message.
224
+ * Load + start `dataDir`'s launchd service, migrating off a pre-flair#693
225
+ * legacy registration FIRST if one is found (migrateLegacyLaunchdLabel
226
+ * above). Call order is load-bearing unload legacy -> load new -> start
227
+ * new, never a window with both registered and pinned by
228
+ * test/unit/launchd-label.test.ts. `load` failure is tolerated (e.g.
229
+ * "already loaded" is a common, harmless nonzero exit); `start` failure
230
+ * propagates so callers can fall back to a direct (non-launchd) start.
231
+ * Shared by the `start` command and startFlairProcess() (used by restart/
232
+ * upgrade/snapshot) so this sequence is expressed in exactly one place.
187
233
  */
188
- function resolveLocalAdminPass(explicit, isRemoteTarget = false, adminPassPath = defaultAdminPassPath()) {
189
- if (explicit)
190
- return explicit;
191
- if (isRemoteTarget)
192
- return undefined;
193
- if (process.env.FLAIR_ADMIN_PASS)
194
- return process.env.FLAIR_ADMIN_PASS;
195
- if (!existsSync(adminPassPath))
196
- return undefined;
197
- return readAdminPassFileSecure(adminPassPath);
198
- }
199
- function defaultKeysDir() {
200
- return join(homedir(), ".flair", "keys");
201
- }
202
- function defaultDataDir() {
203
- return join(homedir(), ".flair", "data");
234
+ function ensureLaunchdServiceLoaded(dataDir, runLaunchctl, launchAgentsDir = defaultLaunchAgentsDir()) {
235
+ const migration = migrateLegacyLaunchdLabel(dataDir, runLaunchctl, launchAgentsDir);
236
+ try {
237
+ runLaunchctl(`launchctl load "${migration.plistPath}"`);
238
+ }
239
+ catch { /* already loaded, etc. — best effort */ }
240
+ runLaunchctl(`launchctl start ${migration.label}`);
241
+ return migration;
204
242
  }
205
243
  function configPath() {
206
244
  // Check both .yaml and .yml extensions
@@ -282,6 +320,294 @@ function resolveOpsPort(opts) {
282
320
  // Default: httpPort - 1
283
321
  return resolveHttpPort(opts) - 1;
284
322
  }
323
+ // Ops API bind-host resolution (flair#670): --ops-bind flag > FLAIR_OPS_BIND
324
+ // env > loopback default. This is the escape hatch — deployments that
325
+ // genuinely need remote ops access (multi-host / Fabric) pass an explicit
326
+ // wider address (e.g. `--ops-bind 0.0.0.0`) to opt back in; everything else
327
+ // gets the loopback-only single-host default. FLAIR_OPS_BIND (not just the
328
+ // flag) matters for `flair start`'s non-launchd fallback spawn, which has no
329
+ // --ops-bind flag of its own — see the comment at its OPERATIONSAPI_NETWORK_PORT
330
+ // assignment for why it re-resolves this on every start instead of trusting
331
+ // the persisted config alone.
332
+ function resolveOpsBindHost(opts) {
333
+ if (opts.opsBind !== undefined && opts.opsBind !== null && String(opts.opsBind).trim() !== "") {
334
+ return String(opts.opsBind).trim();
335
+ }
336
+ const envBind = process.env.FLAIR_OPS_BIND;
337
+ if (envBind && envBind.trim() !== "")
338
+ return envBind.trim();
339
+ return DEFAULT_OPS_BIND_HOST;
340
+ }
341
+ /**
342
+ * Build the `operationsApi` block for Harper's HARPER_SET_CONFIG (flair#670).
343
+ *
344
+ * `network.port` uses Harper's "host:port" string form — Harper's server
345
+ * bootstrap (@harperfast/harper dist/server/threads/threadServer.js,
346
+ * listenOnPorts/listenOnPortsBun) splits a config port value on its last
347
+ * `:` into an explicit bind host + port when present, and falls back to
348
+ * binding all interfaces (0.0.0.0 / ::) when given a bare number. A colon-free
349
+ * numeric port is exactly the pre-#670 behavior (all-interfaces); prefixing
350
+ * it with a host is the only config-level way to narrow the bind.
351
+ *
352
+ * `domainSocket` lives under `network` per Harper's own config schema
353
+ * (@harperfast/harper/config-root.schema.json → properties.operationsApi
354
+ * .properties.network.properties.domainSocket, and
355
+ * dist/validation/configValidator.js's `operationsApi.network.domainSocket`
356
+ * Joi path) — nested here, not as a sibling of `network`.
357
+ */
358
+ export function buildOperationsApiConfig(opsPort, opsSocket, opsBindHost) {
359
+ return {
360
+ network: { port: `${opsBindHost}:${opsPort}`, cors: true, domainSocket: opsSocket },
361
+ };
362
+ }
363
+ /**
364
+ * Decide whether a persisted `operationsApi.network.port` value (read back
365
+ * from harper-config.yaml) indicates an all-interfaces ops-API bind
366
+ * (flair#670 doctor finding — report-only, no --fix: rebinding a live
367
+ * production instance is a restart-worthy change, not something `doctor`
368
+ * should do silently on an unrelated command).
369
+ *
370
+ * A bare port number/numeric string is Harper's all-interfaces default (the
371
+ * pre-#670 behavior, or an install that predates it and hasn't been
372
+ * re-`init`ed). A "host:port" string means something upstream — a `flair
373
+ * init` since #670, or manual config — already narrowed the bind.
374
+ */
375
+ export function detectOpsApiAllInterfacesBind(portValue) {
376
+ if (portValue === undefined || portValue === null)
377
+ return { allInterfaces: false, boundHost: null };
378
+ const str = String(portValue).trim();
379
+ if (str === "")
380
+ return { allInterfaces: false, boundHost: null };
381
+ const lastColon = str.lastIndexOf(":");
382
+ if (lastColon > 0) {
383
+ return { allInterfaces: false, boundHost: str.slice(0, lastColon).replace(/[[\]]/g, "") };
384
+ }
385
+ return { allInterfaces: true, boundHost: null };
386
+ }
387
+ // ─── Ops-socket permission posture (flair#763) ─────────────────────────────────
388
+ //
389
+ // The ops API domain socket (dataDir/operations-server) is protected by a
390
+ // two-layer posture, with the socket's IMMEDIATE PARENT DIRECTORY as the
391
+ // primary, load-bearing gate (resolved from the socket path — never a
392
+ // hardcoded ~/.flair, so custom --data-dir installs get the same gate):
393
+ //
394
+ // FLAIR_SOCKET_GROUP unset → parent dir 0700, socket 0600 (owner-only).
395
+ // FLAIR_SOCKET_GROUP set → parent dir 0750 (owner+group traverse — else
396
+ // the group grant is unreachable behind the dir
397
+ // gate), socket 0660 + chgrp to that group.
398
+ //
399
+ // The two layers move in lockstep BOTH directions: a later UNSET returns the
400
+ // dir to 0700 and the socket to 0600. The directory gate is race-free
401
+ // (checked on every connect(2) traversal), umask-independent (explicit
402
+ // chmod), and cross-platform (VFS-level, unlike socket-file permission
403
+ // enforcement on connect(2) which varies across BSD lineage); the socket-file
404
+ // mode is defense-in-depth within it. Split from #670 (network bind shipped
405
+ // in #762); same local-admin-surface axis as #654 (authorizeLocal off).
406
+ /** Group names allowed for FLAIR_SOCKET_GROUP — validated BEFORE existence
407
+ * resolution (Sherlock #763): rejects path-traversal / whitespace / any
408
+ * weird-but-"valid" name before it reaches getgrnam/chgrp. */
409
+ export const SOCKET_GROUP_NAME_RE = /^[a-zA-Z_][a-zA-Z0-9._-]*$/;
410
+ export function isValidSocketGroupName(name) {
411
+ return SOCKET_GROUP_NAME_RE.test(name);
412
+ }
413
+ /** System groups broad enough that opting the ops socket into them silently
414
+ * grants access to a wide set of accounts (macOS `staff` = every human user
415
+ * on the box). Not a gate — a warning (Sherlock #763). */
416
+ const BROAD_SYSTEM_GROUPS = new Set(["staff", "wheel", "users", "admin", "everyone", "adm"]);
417
+ /** The posture (parent-dir + socket file modes) for a given FLAIR_SOCKET_GROUP
418
+ * value. Pure — the single source of truth for the two lockstep states. */
419
+ export function resolveSocketPosture(group) {
420
+ const g = (group ?? "").trim();
421
+ if (g.length > 0)
422
+ return { dirMode: 0o750, socketMode: 0o660, group: g };
423
+ return { dirMode: 0o700, socketMode: 0o600, group: null };
424
+ }
425
+ const NODE_SOCKET_FS = {
426
+ chmodSync,
427
+ statSync: (p) => {
428
+ const s = statSync(p);
429
+ return { mode: s.mode, uid: s.uid, gid: s.gid };
430
+ },
431
+ existsSync,
432
+ chownSync,
433
+ };
434
+ /** Resolve a group NAME to its numeric gid, cross-platform, or null if the
435
+ * group does not exist. Uses execFileSync (arg vector — no shell) and the
436
+ * name is regex-validated by the caller before it gets here. */
437
+ function resolveGroupGid(name) {
438
+ try {
439
+ if (process.platform === "darwin") {
440
+ // dscl reads Directory Services (macOS groups don't live in /etc/group).
441
+ const out = execFileSync("dscl", [".", "-read", `/Groups/${name}`, "PrimaryGroupID"], {
442
+ encoding: "utf-8",
443
+ stdio: ["ignore", "pipe", "ignore"],
444
+ });
445
+ const m = out.match(/PrimaryGroupID:\s*(\d+)/);
446
+ return m ? Number(m[1]) : null;
447
+ }
448
+ // Linux / other POSIX: getent resolves NSS (files, LDAP, …).
449
+ const out = execFileSync("getent", ["group", name], {
450
+ encoding: "utf-8",
451
+ stdio: ["ignore", "pipe", "ignore"],
452
+ });
453
+ const parts = out.trim().split(":");
454
+ return parts.length >= 3 && parts[2] !== "" ? Number(parts[2]) : null;
455
+ }
456
+ catch {
457
+ return null; // command failed / group not found → treated as missing
458
+ }
459
+ }
460
+ /**
461
+ * Apply the ops-socket permission posture (flair#763). Idempotent: safe to
462
+ * call before Harper spawns (dir gate only — socket not present yet) and again
463
+ * after the socket appears (socket mode + optional chgrp). fs and resolveGid
464
+ * are injectable for unit tests (temp dirs / mocked group resolution).
465
+ *
466
+ * Fail-closed: an invalid OR missing FLAIR_SOCKET_GROUP is a hard error — NEVER
467
+ * a silent fallback to 0600. The group name is regex-validated BEFORE
468
+ * existence resolution.
469
+ */
470
+ export function applyOpsSocketPosture(opts) {
471
+ const fs = opts.fs ?? NODE_SOCKET_FS;
472
+ const resolveGid = opts.resolveGid ?? resolveGroupGid;
473
+ const posture = resolveSocketPosture(opts.group);
474
+ let gid = null;
475
+ let broadGroup = false;
476
+ if (posture.group !== null) {
477
+ if (!isValidSocketGroupName(posture.group)) {
478
+ throw new Error(`Invalid FLAIR_SOCKET_GROUP '${posture.group}': group names must match ${SOCKET_GROUP_NAME_RE.source}.`);
479
+ }
480
+ gid = resolveGid(posture.group);
481
+ if (gid === null) {
482
+ throw new Error(`FLAIR_SOCKET_GROUP '${posture.group}' does not exist on this system. ` +
483
+ `Create the group first, or unset FLAIR_SOCKET_GROUP to use the owner-only default (dir 0700 / socket 0600).`);
484
+ }
485
+ broadGroup = BROAD_SYSTEM_GROUPS.has(posture.group);
486
+ }
487
+ const parentDir = dirname(opts.socketPath);
488
+ // Directory gate — the race-free primary control. Applied whether or not the
489
+ // socket exists yet, so it is in place BEFORE Harper creates the socket.
490
+ fs.chmodSync(parentDir, posture.dirMode);
491
+ // Socket file: defense-in-depth mode + optional chgrp — only once it exists.
492
+ let socketApplied = false;
493
+ if (fs.existsSync(opts.socketPath)) {
494
+ fs.chmodSync(opts.socketPath, posture.socketMode);
495
+ if (gid !== null) {
496
+ const st = fs.statSync(opts.socketPath);
497
+ try {
498
+ // uid unchanged (we own it); only the group moves.
499
+ fs.chownSync(opts.socketPath, st.uid, gid);
500
+ }
501
+ catch (err) {
502
+ throw new Error(`Failed to chgrp the ops socket to group '${posture.group}' (gid ${gid}): ${err?.code ?? err?.message ?? err}. ` +
503
+ `chgrp requires membership in the target group — join '${posture.group}' or pick a different FLAIR_SOCKET_GROUP.`);
504
+ }
505
+ }
506
+ socketApplied = true;
507
+ }
508
+ return {
509
+ dirMode: posture.dirMode,
510
+ socketMode: posture.socketMode,
511
+ group: posture.group,
512
+ gid,
513
+ dirApplied: true,
514
+ socketApplied,
515
+ broadGroup,
516
+ };
517
+ }
518
+ /**
519
+ * Resolve + apply the ops-socket posture for a data dir, with the right
520
+ * fatal-vs-warn handling. A broken FLAIR_SOCKET_GROUP opt-in is a hard error
521
+ * (fail closed, no silent fallback); a failure of the owner-only default is
522
+ * non-fatal defense-in-depth (warn — the dir gate remains the load-bearing
523
+ * control). Returns the applied posture, or null on a non-fatal default-path
524
+ * failure. The socket path is derived from dataDir so a --data-dir install is
525
+ * gated at its own root, never a hardcoded ~/.flair.
526
+ */
527
+ function readyOpsSocketPosture(dataDir) {
528
+ const socketPath = join(dataDir, "operations-server");
529
+ const group = process.env.FLAIR_SOCKET_GROUP;
530
+ const optedIn = !!(group && group.trim().length > 0);
531
+ try {
532
+ const res = applyOpsSocketPosture({ socketPath, group });
533
+ if (res.broadGroup) {
534
+ console.error(`warning: FLAIR_SOCKET_GROUP='${res.group}' is a broad system group — every member gets ops-socket access. Prefer a dedicated group.`);
535
+ }
536
+ return res;
537
+ }
538
+ catch (err) {
539
+ if (optedIn)
540
+ throw err; // opt-in misconfiguration — fail closed
541
+ console.error(`warning: could not tighten ops-socket permissions (${err?.message ?? err}); ` +
542
+ `the ${dataDir} directory gate remains the primary control.`);
543
+ return null;
544
+ }
545
+ }
546
+ /**
547
+ * The `flair doctor` detection matrix for the ops-socket posture (flair#763,
548
+ * Sherlock's exact six rows). Report-only — never auto-remediated (changing a
549
+ * live socket's mode needs a restart). Pure: takes the parent-dir mode, the
550
+ * socket mode, and whether FLAIR_SOCKET_GROUP is set (the opt-in signal).
551
+ */
552
+ export function classifyOpsSocketPosture(dirMode, socketMode, groupOptIn) {
553
+ const dm = dirMode & 0o777;
554
+ const sm = socketMode & 0o777;
555
+ const dirWorld = (dm & 0o007) !== 0;
556
+ const sockWorld = (sm & 0o007) !== 0;
557
+ const dirGroup = (dm & 0o070) !== 0;
558
+ const sockGroup = (sm & 0o070) !== 0;
559
+ if (groupOptIn) {
560
+ // Deliberate multi-user posture (dir 0750 / socket 0660). Clean as long as
561
+ // no WORLD access leaked onto either — a world bit means a regressed mode.
562
+ if (!dirWorld && !sockWorld) {
563
+ return {
564
+ flagged: false,
565
+ row: "deliberate-group-clean",
566
+ reason: "deliberate FLAIR_SOCKET_GROUP posture (dir 0750 / socket 0660) — no world access",
567
+ };
568
+ }
569
+ return {
570
+ flagged: true,
571
+ row: "group-opt-in-world-open",
572
+ reason: "FLAIR_SOCKET_GROUP is set but the parent directory or socket is world-accessible",
573
+ };
574
+ }
575
+ // No opt-in. World access is the worst and takes precedence.
576
+ if (dirWorld && sockWorld) {
577
+ return {
578
+ flagged: true,
579
+ row: "both-open",
580
+ reason: "both the socket's parent directory and the socket itself are group/world-accessible",
581
+ };
582
+ }
583
+ if (dirWorld) {
584
+ return {
585
+ flagged: true,
586
+ row: "root-open",
587
+ reason: "the socket's parent directory is group/world-traversable — the primary access gate is breached",
588
+ };
589
+ }
590
+ if (sockWorld) {
591
+ return {
592
+ flagged: true,
593
+ row: "socket-open",
594
+ reason: "the ops socket is group/world-accessible",
595
+ };
596
+ }
597
+ // No world bits, but group bits present without the opt-in.
598
+ if (dirGroup || sockGroup) {
599
+ return {
600
+ flagged: true,
601
+ row: "group-mode-without-opt-in",
602
+ reason: "the socket carries group permissions but FLAIR_SOCKET_GROUP is not set (unintended group access)",
603
+ };
604
+ }
605
+ return {
606
+ flagged: false,
607
+ row: "default-clean",
608
+ reason: "owner-only posture (dir 0700 / socket 0600)",
609
+ };
610
+ }
285
611
  // ─── Target resolution (remote Flair instance) ─────────────────────────────────
286
612
  // --target <url> (or FLAIR_TARGET env) points all CLI operations at a remote
287
613
  // Flair instance instead of localhost. This enables bootstrapping and
@@ -373,225 +699,51 @@ function b64(bytes) {
373
699
  function b64url(bytes) {
374
700
  return Buffer.from(bytes).toString("base64url");
375
701
  }
376
- function isLocalBase(base) {
377
- try {
378
- const url = new URL(base);
379
- return url.hostname === "127.0.0.1" || url.hostname === "localhost" || url.hostname === "::1";
380
- }
381
- catch {
382
- return !base;
383
- }
384
- }
702
+ // isLocalBase / ApiHttpError / resolveKeyPath / buildEd25519Auth / authFetch
703
+ // now live in src/lib/auth-resolve.ts (flair#747) — imported above.
385
704
  /**
386
- * Thrown by api() for an HTTP-level failure (as opposed to a network error,
387
- * which fetch() itself throws before api() ever sees a status code).
388
- * Data-carrying, not just a message string, so callers downstream can make
389
- * decisions without re-parsing text (flair#741):
390
- * - `status`: the HTTP status code, so probeInstance (src/probe.ts) can
391
- * classify ProbeResult.authFailureKind (401/403 = credential failure,
392
- * duck-typed off this property see probe.ts's authedGet doc).
393
- * - `noCredentials`: true only for the specific bare-403-no-auth-header
394
- * case (api()'s own hint-carrying branch below) distinguishes "we had
395
- * nothing to send" from "we sent something and it was rejected" so
396
- * verifyAuthedGet (below) knows exactly when its agent-key fallback
397
- * applies (fix #2) versus when a real credential was tried and failed
398
- * (where guessing at other keys would be misleading, not helpful).
705
+ * api() resolves the request's Harper HTTP/REST auth via the shared
706
+ * `authedRequest` (src/lib/auth-resolve.ts see that module's header for
707
+ * the full 5-tier resolution order, including tier 5, the Ed25519 agent-key
708
+ * FLOOR this used to lack entirely). The only thing api() itself still owns
709
+ * is Harper-CLI-convention agentId extraction (FLAIR_AGENT_ID env, or an
710
+ * agentId embedded in the request body/query string) request-shape
711
+ * knowledge that belongs here, not in the generic resolver.
712
+ *
713
+ * NOTE: this function is for the Harper HTTP/REST API only. The Harper
714
+ * operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
715
+ * ALSO honors authorizeLocal: a header-less loopback request to the ops port
716
+ * is auto-authorized as super_user (verified by live probe flair#610). Those
717
+ * helpers nonetheless send Basic admin auth UNCONDITIONALLY, so they never
718
+ * depend on that ambient elevation and behave identically against a remote or
719
+ * hardened instance. Hardening the ops-API loopback posture itself (bind scope
720
+ * / disabling authorizeLocal there) is tracked separately in flair#654 and is
721
+ * out of scope for this HTTP/REST auth path.
399
722
  */
400
- class ApiHttpError extends Error {
401
- status;
402
- noCredentials;
403
- constructor(status, message, noCredentials = false) {
404
- super(message);
405
- this.name = "ApiHttpError";
406
- this.status = status;
407
- this.noCredentials = noCredentials;
408
- }
409
- }
410
723
  async function api(method, path, body, options) {
411
724
  // Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
412
725
  // When baseUrl is provided (--target), use it directly.
413
726
  const savedPort = readPortFromConfig();
414
727
  const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
415
728
  const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
416
- const isLocal = isLocalBase(base);
417
- // Auth resolution order (flair#634local targets used to send NO auth at
418
- // all here and ride Harper's authorizeLocal forged super_user; #632 gated
419
- // FederationInstance/FederationPeers behind allowAdmin, so credential-less
420
- // local calls to those now 403 instead of silently passing):
421
- // 1. FLAIR_TOKEN env → Bearer token (backward compat)
422
- // 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth. Applies to
423
- // BOTH local and remote targets — an explicit env var always wins, local
424
- // included, so a caller that sets it never depends on authorizeLocal.
425
- // 3. FLAIR_AGENT_ID env + key file Ed25519 signature (standard)
426
- // 4. LOCAL TARGETS ONLY: the secure ~/.flair/admin-pass file `flair init`
427
- // writes (#593) → Basic admin auth, via the same resolveLocalAdminPass
428
- // convenience `agent add`/`principal add` already use (#590). Guarded to
429
- // isLocal so a --target/FLAIR_URL request aimed elsewhere never rides
430
- // this machine's local admin secret.
431
- // 5. No auth (remote will 401/403; local now also gets a real 403 from
432
- // #632-gated resources instead of the old forged-admin passthrough)
433
- //
434
- // NOTE: this function is for the Harper HTTP/REST API only. The Harper
435
- // operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
436
- // ALSO honors authorizeLocal: a header-less loopback request to the ops port
437
- // is auto-authorized as super_user (verified by live probe — flair#610). Those
438
- // helpers nonetheless send Basic admin auth UNCONDITIONALLY, so they never
439
- // depend on that ambient elevation and behave identically against a remote or
440
- // hardened instance. Hardening the ops-API loopback posture itself (bind scope
441
- // / disabling authorizeLocal there) is tracked separately in flair#654 and is
442
- // out of scope for this HTTP/REST auth path.
443
- let authHeader;
444
- const token = process.env.FLAIR_TOKEN;
445
- if (token) {
446
- authHeader = `Bearer ${token}`;
447
- }
448
- else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
449
- // Admin Basic auth — used by federation, backup, and other admin CLI commands
450
- const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
451
- authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
452
- }
453
- else {
454
- // Extract agentId from body (POST/PUT) or URL query params (GET)
455
- let agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
456
- if (!agentId && path.includes("agentId=")) {
457
- const match = path.match(/agentId=([^&]+)/);
458
- if (match)
459
- agentId = decodeURIComponent(match[1]);
460
- }
461
- if (agentId) {
462
- const keyPath = resolveKeyPath(agentId);
463
- if (keyPath) {
464
- try {
465
- // Sign the path without query params — auth middleware verifies the clean path
466
- // Auth middleware verifies the full request path including query params
467
- authHeader = buildEd25519Auth(agentId, method, path, keyPath);
468
- }
469
- catch (err) {
470
- // Key exists but auth build failed — warn and continue without auth
471
- const message = err instanceof Error ? err.message : String(err);
472
- console.error(`Warning: Ed25519 auth failed for agent '${agentId}': ${message}`);
473
- }
474
- }
475
- }
476
- // Local-only fallback (flair#634): no explicit env, no usable agent key.
477
- // FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD are already ruled out by this point
478
- // (handled above), so resolveLocalAdminPass's env leg is a no-op here and
479
- // this only ever resolves the ~/.flair/admin-pass file — isRemoteTarget
480
- // is `!isLocal` so it's skipped entirely for --target/FLAIR_URL requests.
481
- if (!authHeader) {
482
- try {
483
- const filePass = resolveLocalAdminPass(undefined, !isLocal);
484
- if (filePass) {
485
- authHeader = `Basic ${Buffer.from(`admin:${filePass}`).toString("base64")}`;
486
- }
487
- }
488
- catch (err) {
489
- // File exists but has unsafe permissions — warn (never the secret
490
- // itself) and fall through to no-auth.
491
- const message = err instanceof Error ? err.message : String(err);
492
- console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
493
- }
494
- }
495
- }
496
- const res = await fetch(`${base}${path}`, {
497
- method,
498
- headers: {
499
- "content-type": "application/json",
500
- ...(authHeader ? { authorization: authHeader } : {}),
501
- },
502
- body: body ? JSON.stringify(body) : undefined,
503
- });
504
- // Handle 204 No Content (e.g., PUT upsert returns empty body)
505
- if (res.status === 204 || res.headers.get("content-length") === "0") {
506
- if (!res.ok)
507
- throw new ApiHttpError(res.status, `HTTP ${res.status}`);
508
- return { ok: true };
509
- }
510
- const text = await res.text();
511
- if (!res.ok) {
512
- // 403 with no credentials sent at all is the flair#634 case: a gated
513
- // resource (e.g. #632's FederationInstance/FederationPeers) rejected a
514
- // credential-less call. Name the fix instead of surfacing the raw
515
- // "forbidden" body — never a stack trace.
516
- if (res.status === 403 && !authHeader) {
517
- const hint = isLocal
518
- ? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass."
519
- : "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
520
- throw new ApiHttpError(403, `HTTP 403: no credentials sent. ${hint}`, /* noCredentials */ true);
521
- }
522
- throw new ApiHttpError(res.status, text || `HTTP ${res.status}`);
523
- }
524
- if (!text)
525
- return { ok: true };
526
- return JSON.parse(text);
527
- }
528
- /** Find the agent's private key file from standard locations. */
529
- function resolveKeyPath(agentId) {
530
- const candidates = [
531
- process.env.FLAIR_KEY_DIR ? join(process.env.FLAIR_KEY_DIR, `${agentId}.key`) : null,
532
- join(homedir(), ".flair", "keys", `${agentId}.key`),
533
- join(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
534
- ].filter(Boolean);
535
- return candidates.find((p) => existsSync(p)) ?? null;
536
- }
537
- /** Build a TPS-Ed25519 auth header from a raw 32-byte seed on disk. */
538
- function buildEd25519Auth(agentId, method, path, keyPath) {
539
- const raw = readFileSync(keyPath);
540
- const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
541
- let privKey;
542
- if (raw.length === 32) {
543
- // Raw 32-byte seed
544
- privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, raw]), format: "der", type: "pkcs8" });
545
- }
546
- else {
547
- // Try as base64-encoded PKCS8 DER (standard Flair key format)
548
- const decoded = Buffer.from(raw.toString("utf-8").trim(), "base64");
549
- if (decoded.length === 32) {
550
- // Base64-encoded raw seed
551
- privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, decoded]), format: "der", type: "pkcs8" });
552
- }
553
- else {
554
- // Full PKCS8 DER or PEM
555
- try {
556
- privKey = createPrivateKey({ key: decoded, format: "der", type: "pkcs8" });
557
- }
558
- catch {
559
- privKey = createPrivateKey(raw);
560
- }
561
- }
562
- }
563
- const ts = Date.now().toString();
564
- const nonce = randomUUID();
565
- const payload = `${agentId}:${ts}:${nonce}:${method}:${path}`;
566
- const sig = nodeCryptoSign(null, Buffer.from(payload), privKey).toString("base64");
567
- return `TPS-Ed25519 ${agentId}:${ts}:${nonce}:${sig}`;
568
- }
569
- /** Authenticated fetch against Flair using Ed25519. */
570
- async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
571
- const auth = buildEd25519Auth(agentId, method, path, keyPath);
572
- const headers = { Authorization: auth };
573
- if (body !== undefined)
574
- headers["Content-Type"] = "application/json";
575
- return fetch(`${baseUrl}${path}`, {
576
- method,
577
- headers,
578
- body: body !== undefined ? JSON.stringify(body) : undefined,
579
- });
729
+ // Extract agentId from FLAIR_AGENT_ID env, or the body (POST/PUT) / URL
730
+ // query params (GET)Harper-CLI-request-shape knowledge, not a generic
731
+ // auth concern, so it stays here rather than in authedRequest.
732
+ let agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
733
+ if (!agentId && path.includes("agentId=")) {
734
+ const match = path.match(/agentId=([^&]+)/);
735
+ if (match)
736
+ agentId = decodeURIComponent(match[1]);
737
+ }
738
+ return authedRequest(method, path, body, { baseUrl: base, agentId, keysDir: options?.keysDir });
580
739
  }
581
740
  /**
582
741
  * The authedGet `flair upgrade` verification (flair#635/#741) hands to
583
- * probeInstance(): api()'s existing local-credential chain (FLAIR_TOKEN >
584
- * FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD > FLAIR_AGENT_ID+key > ~/.flair/admin-
585
- * pass file), with an Ed25519 agent-key fallback layered on top (flair#741
586
- * fix #2).
587
- *
588
- * The gap this closes: api()'s agent-key leg only fires when an agentId is
589
- * ALREADY known (FLAIR_AGENT_ID env, or an agentId in the request body/query
590
- * string) — none of which a bare `flair upgrade` ever sets. So on a machine
591
- * with an agent key under ~/.flair/keys but no admin password anywhere,
592
- * api() falls straight through to its no-credentials 403, even though
593
- * `flair doctor`'s verified-read gate would happily use that same key. This
594
- * wrapper is what actually reuses that path for verification.
742
+ * probeInstance() now a one-line delegation to api(), since api() itself
743
+ * carries the tier-5 agent-key floor this wrapper used to implement
744
+ * standalone (flair#747 generalized flair#742's fix from "upgrade
745
+ * verification only" into api()'s own resolution chain, so every api()
746
+ * caller gets it, not just this one).
595
747
  *
596
748
  * Auth requirement of the verification target itself: /HealthDetail
597
749
  * (probeInstance's default versionPath) is NOT admin-gated — its
@@ -599,52 +751,9 @@ async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
599
751
  * auth.ts), which permits ANY registered agent, not just admins. So a
600
752
  * signed request from any registered agent's key is sufficient; there's no
601
753
  * need to special-case a different, more-public endpoint.
602
- *
603
- * Only engages the fallback when api() reports NOTHING was available to
604
- * send (`ApiHttpError.noCredentials`) — a wrong/rejected credential (bad
605
- * admin-pass, unregistered FLAIR_AGENT_ID) is a different, more specific
606
- * problem than "nothing to try"; guessing at unrelated keys on disk in that
607
- * case would obscure the real error instead of explaining it.
608
- *
609
- * Selection rule when multiple keys exist in `keysDir` (mirrors doctor's
610
- * planAgentIterations sort — doctor-client.ts): try them in sorted
611
- * (deterministic) filename order, first one that authenticates wins. A
612
- * liveness/verified-read check only needs ONE working identity, unlike
613
- * doctor's per-agent registration report which needs to enumerate all of
614
- * them.
615
754
  */
616
755
  export async function verifyAuthedGet(baseUrl, path, keysDir) {
617
- try {
618
- return await api("GET", path, undefined, { baseUrl });
619
- }
620
- catch (err) {
621
- if (!(err instanceof ApiHttpError) || !err.noCredentials)
622
- throw err;
623
- let keyFiles = [];
624
- try {
625
- keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key")).sort();
626
- }
627
- catch {
628
- // No keys dir at all — nothing to fall back to.
629
- }
630
- for (const kf of keyFiles) {
631
- const agentId = kf.replace(/\.key$/, "");
632
- const keyPath = join(keysDir, kf);
633
- try {
634
- const res = await authFetch(baseUrl, agentId, keyPath, "GET", path);
635
- if (res.ok)
636
- return await res.json();
637
- }
638
- catch {
639
- // This key didn't work (unregistered, bad signature, network blip
640
- // on this one attempt) — try the next one.
641
- }
642
- }
643
- // No local key authenticated either — surface api()'s original,
644
- // actionable "no credentials sent" hint rather than a fallback-specific
645
- // message; it already names both remedies (FLAIR_ADMIN_PASS / `flair init`).
646
- throw err;
647
- }
756
+ return api("GET", path, undefined, { baseUrl, keysDir });
648
757
  }
649
758
  /**
650
759
  * flair#741 fix #3: does this ProbeResult's failure mean "the server
@@ -1772,6 +1881,7 @@ program
1772
1881
  .option("--agent <id>", "Alias for --agent-id")
1773
1882
  .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1774
1883
  .option("--ops-port <port>", "Harper operations API port")
1884
+ .option("--ops-bind <addr>", "Harper ops API bind address (env: FLAIR_OPS_BIND; default: 127.0.0.1 loopback-only for single-host — pass e.g. 0.0.0.0 for multi-host/Fabric remote admin)")
1775
1885
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
1776
1886
  .option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
1777
1887
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
@@ -1971,6 +2081,7 @@ program
1971
2081
  // ── Local init (full one-command setup) ──
1972
2082
  const httpPort = resolveHttpPort(opts);
1973
2083
  const opsPort = resolveOpsPort(opts);
2084
+ const opsBindHost = resolveOpsBindHost(opts);
1974
2085
  const keysDir = opts.keysDir ?? defaultKeysDir();
1975
2086
  const dataDir = opts.dataDir ?? defaultDataDir();
1976
2087
  // Resolve MCP client selection (union of init's auto-wire + the multi-client
@@ -2063,6 +2174,13 @@ program
2063
2174
  // the fresh-start path) since the launchd plist step needs it too, even
2064
2175
  // when Harper was already running and the fresh-spawn branch was skipped.
2065
2176
  const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
2177
+ // flair#763: put the ops-socket directory gate in place BEFORE Harper
2178
+ // spawns, so the socket is never reachable during the create→chmod window
2179
+ // (the dir gate is the race-free primary control). This also validates
2180
+ // FLAIR_SOCKET_GROUP early — a bad group fails fast, before a full boot.
2181
+ // The socket doesn't exist yet, so only the parent-dir mode is applied here.
2182
+ mkdirSync(dataDir, { recursive: true });
2183
+ readyOpsSocketPosture(dataDir);
2066
2184
  if (!opts.skipStart) {
2067
2185
  // Check if already running
2068
2186
  try {
@@ -2089,10 +2207,13 @@ program
2089
2207
  // request is no longer auto-authorized as super_user. Every ops-API
2090
2208
  // seed call below (seedAgentViaOpsApi et al.) already passes a real
2091
2209
  // adminPass via Basic auth, so this does not change local-init behavior.
2210
+ // operationsApi (flair#670): loopback-only by default (buildOperationsApiConfig
2211
+ // — see its doc comment for the "host:port" bind mechanism and the
2212
+ // domainSocket schema path), escape hatch via --ops-bind/FLAIR_OPS_BIND.
2092
2213
  const harperSetConfig = JSON.stringify({
2093
2214
  rootPath: dataDir,
2094
2215
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
2095
- operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
2216
+ operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
2096
2217
  mqtt: { network: { port: null }, webSocket: false },
2097
2218
  localStudio: { enabled: false },
2098
2219
  authentication: { authorizeLocal: false, enableSessions: true },
@@ -2173,22 +2294,46 @@ program
2173
2294
  console.log("Waiting for Harper health check...");
2174
2295
  await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
2175
2296
  console.log("Harper is healthy ✓");
2297
+ // flair#763: the socket now exists — apply its file mode (+ chgrp for the
2298
+ // FLAIR_SOCKET_GROUP opt-in). The dir gate above is re-asserted idempotently.
2299
+ readyOpsSocketPosture(dataDir);
2176
2300
  // Register launchd service on macOS so Harper survives reboots
2177
2301
  // and `flair restart` / `flair stop` work via launchctl.
2178
2302
  if (process.platform === "darwin") {
2179
2303
  const harperBinPath = harperBin();
2180
2304
  if (harperBinPath) {
2181
- const label = "ai.tpsdev.flair";
2182
- const plistDir = join(homedir(), "Library", "LaunchAgents");
2305
+ const label = launchdLabel(dataDir);
2306
+ const plistDir = defaultLaunchAgentsDir();
2183
2307
  mkdirSync(plistDir, { recursive: true });
2184
- const plistPath = join(plistDir, `${label}.plist`);
2308
+ const plistPath = launchdPlistPath(label, plistDir);
2309
+ // flair#693 migration: a pre-flair#693 install registered under
2310
+ // the bare LEGACY_LAUNCHD_LABEL. init always writes fresh plist
2311
+ // content below (it has the current ports/creds in hand), so
2312
+ // migration here is just "clean up the old registration" —
2313
+ // unload + remove it BEFORE writing the new one, so re-running
2314
+ // init never leaves two services behind for this data dir.
2315
+ const legacyPlistPath = launchdPlistPath(LEGACY_LAUNCHD_LABEL, plistDir);
2316
+ if (existsSync(legacyPlistPath)) {
2317
+ try {
2318
+ const { execSync } = await import("node:child_process");
2319
+ execSync(`launchctl unload "${legacyPlistPath}"`, { stdio: "pipe" });
2320
+ }
2321
+ catch { /* best effort */ }
2322
+ try {
2323
+ unlinkSync(legacyPlistPath);
2324
+ }
2325
+ catch { /* best effort */ }
2326
+ console.log(`Migrated off legacy launchd label (${LEGACY_LAUNCHD_LABEL}) ✓`);
2327
+ }
2185
2328
  const opsSocket = join(dataDir, "operations-server");
2186
2329
  // authorizeLocal: false (flair#654) — same posture as the initial spawn
2187
2330
  // above; the launchd-managed process must not diverge from it.
2331
+ // operationsApi (flair#670): same buildOperationsApiConfig posture as the
2332
+ // initial spawn above — the launchd-managed process must not diverge.
2188
2333
  const setConfig = JSON.stringify({
2189
2334
  rootPath: dataDir,
2190
2335
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
2191
- operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
2336
+ operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
2192
2337
  mqtt: { network: { port: null }, webSocket: false },
2193
2338
  localStudio: { enabled: false },
2194
2339
  authentication: { authorizeLocal: false, enableSessions: true },
@@ -3133,6 +3278,121 @@ keys
3133
3278
  }
3134
3279
  console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
3135
3280
  });
3281
+ // ─── flair hook ──────────────────────────────────────────────────────────────
3282
+ // Ambient memory via harness SessionStart hooks (flair#745, design record
3283
+ // #719 — the "Paved-paths" round). `flair doctor --fix`/`flair init` already
3284
+ // wire the same SessionStart hook as a side effect of a bigger flow; this is
3285
+ // the standalone, symmetric command family (install/uninstall/status) an
3286
+ // operator or a headless/scheduled setup script can run on its own. All the
3287
+ // mutation logic (fail-closed on malformed settings.json, idempotent merge,
3288
+ // dry-run delta, symmetric removal) lives in src/hook-install.ts — this
3289
+ // section is pure CLI plumbing: option parsing, default resolution, and
3290
+ // rendering the pure functions' results.
3291
+ function resolveHookAgentId(opts, homeDir) {
3292
+ return (opts.agent ||
3293
+ opts.agentId ||
3294
+ process.env.FLAIR_AGENT_ID ||
3295
+ readClientMcpBlock("claude-code", homeDir).agentId ||
3296
+ undefined);
3297
+ }
3298
+ function resolveHookFlairUrl(opts, homeDir) {
3299
+ return (opts.url ||
3300
+ process.env.FLAIR_TARGET ||
3301
+ process.env.FLAIR_URL ||
3302
+ readClientMcpBlock("claude-code", homeDir).flairUrl ||
3303
+ resolveBaseUrl({}));
3304
+ }
3305
+ function requireSupportedHarness(raw) {
3306
+ const name = raw || "claude-code";
3307
+ if (!isSupportedHarness(name)) {
3308
+ console.error(`Unknown harness '${name}'. Supported: ${SUPPORTED_HARNESSES.join(", ")}`);
3309
+ process.exit(1);
3310
+ }
3311
+ return name;
3312
+ }
3313
+ const hook = program.command("hook").description("Manage ambient-memory harness SessionStart hooks (flair#745)");
3314
+ hook
3315
+ .command("install")
3316
+ .description("Wire the Flair SessionStart hook into the harness config so memory loads automatically at session start")
3317
+ .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
3318
+ .option("--dry-run", "Print the exact JSON delta without writing")
3319
+ .option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
3320
+ .option("--agent-id <id>", "Alias for --agent")
3321
+ .option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
3322
+ .action((opts) => {
3323
+ const harness = requireSupportedHarness(opts.harness);
3324
+ const home = homedir();
3325
+ const agentId = resolveHookAgentId(opts, home);
3326
+ if (!agentId) {
3327
+ console.error("No agent id known — pass --agent <id>, set FLAIR_AGENT_ID, or run `flair init` / `flair agent add` first.");
3328
+ process.exit(1);
3329
+ }
3330
+ const flairUrl = resolveHookFlairUrl(opts, home);
3331
+ const dryRun = !!opts.dryRun;
3332
+ const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
3333
+ console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
3334
+ console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
3335
+ if (result.backupPath) {
3336
+ console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
3337
+ }
3338
+ if (result.delta) {
3339
+ console.log(`\n ${render.wrap(render.c.dim, `${dryRun ? "would apply" : "applied"} (${result.delta.action}):`)}`);
3340
+ console.log(render.asJSON(result.delta));
3341
+ }
3342
+ console.log("");
3343
+ if (!result.ok)
3344
+ process.exit(1);
3345
+ });
3346
+ hook
3347
+ .command("uninstall")
3348
+ .description("Remove the Flair SessionStart hook entry — only ours, everything else in the file is left untouched")
3349
+ .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
3350
+ .option("--dry-run", "Print the exact JSON delta without writing")
3351
+ .action((opts) => {
3352
+ const harness = requireSupportedHarness(opts.harness);
3353
+ const home = homedir();
3354
+ const dryRun = !!opts.dryRun;
3355
+ const result = uninstallHook({ homeDir: home, harness, dryRun });
3356
+ console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
3357
+ console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
3358
+ if (result.backupPath) {
3359
+ console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
3360
+ }
3361
+ if (result.delta) {
3362
+ console.log(`\n ${render.wrap(render.c.dim, `${dryRun ? "would apply" : "applied"} (${result.delta.action}):`)}`);
3363
+ console.log(render.asJSON(result.delta));
3364
+ }
3365
+ console.log("");
3366
+ if (!result.ok)
3367
+ process.exit(1);
3368
+ });
3369
+ hook
3370
+ .command("status")
3371
+ .description("Show whether the SessionStart hook is wired, its shape, and which Flair instance it targets")
3372
+ .option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
3373
+ .action((opts) => {
3374
+ const harness = requireSupportedHarness(opts.harness);
3375
+ const home = homedir();
3376
+ const status = hookStatus(home, harness);
3377
+ console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
3378
+ console.log(` ${render.wrap(render.c.dim, "Harness:")} ${status.harness}`);
3379
+ console.log(` ${render.wrap(render.c.dim, "Config:")} ${status.path}`);
3380
+ if (status.parseError) {
3381
+ console.log(` ${render.icons.error} ${status.parseError}`);
3382
+ console.log("");
3383
+ process.exit(1);
3384
+ }
3385
+ if (!status.wired) {
3386
+ console.log(` ${render.icons.error} not wired`);
3387
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
3388
+ console.log("");
3389
+ process.exit(1);
3390
+ }
3391
+ console.log(` ${status.correctShape ? render.icons.ok : render.icons.warn} wired${status.correctShape ? "" : " (unexpected shape — was it hand-edited?)"}`);
3392
+ console.log(` ${render.wrap(render.c.dim, "Agent:")} ${status.agentId ?? render.wrap(render.c.dim, "(unknown — could not parse command)")}`);
3393
+ console.log(` ${render.wrap(render.c.dim, "Flair URL:")} ${status.flairUrl ?? render.wrap(render.c.dim, "(unknown — could not parse command)")}`);
3394
+ console.log("");
3395
+ });
3136
3396
  // ─── flair mcp ───────────────────────────────────────────────────────────────
3137
3397
  // Headless agent-auth to a Harper MCP `/mcp` endpoint: RFC 7523
3138
3398
  // client_credentials + private_key_jwt, using the agent's EXISTING Ed25519
@@ -3235,6 +3495,574 @@ mcp
3235
3495
  process.exit(1);
3236
3496
  }
3237
3497
  });
3498
+ /** Default manifest path: `~/.flair/mcp-clients.json`, sibling to
3499
+ * admin-pass/config.yaml — independent of --keys-dir (a custom keys dir
3500
+ * doesn't imply a custom manifest location, and vice versa). */
3501
+ export function defaultMcpClientManifestPath() {
3502
+ return join(homedir(), ".flair", "mcp-clients.json");
3503
+ }
3504
+ /** Read the manifest; a missing file is "no clients granted yet", not an
3505
+ * error. Malformed JSON is also treated as empty (defensive — a corrupt
3506
+ * manifest must never crash `list`), never partially parsed. */
3507
+ export function readMcpClientManifest(manifestPath) {
3508
+ if (!existsSync(manifestPath))
3509
+ return [];
3510
+ try {
3511
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf-8"));
3512
+ return Array.isArray(parsed) ? parsed : [];
3513
+ }
3514
+ catch {
3515
+ return [];
3516
+ }
3517
+ }
3518
+ /** Write the manifest, 0600 — it names every granted machine client's
3519
+ * key-file path, which is operationally sensitive even though it carries
3520
+ * no key material itself. */
3521
+ function writeMcpClientManifest(manifestPath, entries) {
3522
+ mkdirSync(dirname(manifestPath), { recursive: true });
3523
+ writeFileSync(manifestPath, JSON.stringify(entries, null, 2) + "\n", { mode: 0o600 });
3524
+ chmodSync(manifestPath, 0o600);
3525
+ }
3526
+ /** Machine-readable "ready-to-paste" MCP config block for `grant`'s output.
3527
+ * Pure — no I/O — so it's independently unit-testable. Mirrors the
3528
+ * `mcpServers` top-level key src/install/clients.ts's jsonSnippet already
3529
+ * established as this codebase's paste-target convention, pointed at the
3530
+ * Model-2 OAuth `/mcp` HTTP surface (not that stdio-bridge path).
3531
+ *
3532
+ * The Authorization header CANNOT be a static, working value: a
3533
+ * client_credentials access token is short-lived (default 300s TTL,
3534
+ * server-side — see token.js's DEFAULT_CLIENT_CREDENTIALS_TTL) and this
3535
+ * grant issues no refresh token by design. Printing a real-looking-but-
3536
+ * dead token would be actively misleading, so the placeholder says exactly
3537
+ * what to run instead — never a fabricated "it just works" static header.
3538
+ */
3539
+ export function buildMcpGrantConfig(params) {
3540
+ return {
3541
+ mcpServers: {
3542
+ [params.name]: {
3543
+ type: "http",
3544
+ url: params.resource,
3545
+ headers: {
3546
+ Authorization: `Bearer <mint before each session: ` +
3547
+ `flair mcp token --agent-id ${params.name} --json — copy .access_token here; ` +
3548
+ `expires in minutes, not a long-lived credential>`,
3549
+ },
3550
+ },
3551
+ },
3552
+ note: `Key material lives at ${params.keyFile} (0600, never printed). ` +
3553
+ `The Bearer token above is a placeholder — mint a fresh one with ` +
3554
+ `\`flair mcp token --agent-id ${params.name}\` at connection time.`,
3555
+ };
3556
+ }
3557
+ export class McpClientNameExistsError extends Error {
3558
+ constructor(name) {
3559
+ super(`Machine client '${name}' already exists — use \`flair mcp revoke ${name}\` first or pick a different name.`);
3560
+ this.name = "McpClientNameExistsError";
3561
+ }
3562
+ }
3563
+ export class McpClientAgentIdCollisionError extends Error {
3564
+ constructor(name) {
3565
+ super(`An agent named '${name}' already exists in Flair but is not an mcp-granted machine client ` +
3566
+ `— pick a different name (or \`flair agent remove ${name}\` first if that's intentional).`);
3567
+ this.name = "McpClientAgentIdCollisionError";
3568
+ }
3569
+ }
3570
+ export class McpClientNotFoundError extends Error {
3571
+ constructor(name) {
3572
+ super(`No granted machine client named '${name}' (see \`flair mcp list\`).`);
3573
+ this.name = "McpClientNotFoundError";
3574
+ }
3575
+ }
3576
+ const MCP_CLIENT_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
3577
+ /**
3578
+ * Core, testable `flair mcp grant` orchestration — no process.exit, no
3579
+ * console output, so it's directly unit-testable with a mocked fetch and a
3580
+ * temp dir (same split as classifyKeysDir/applyKeyPrune above). Throws
3581
+ * typed errors; the CLI action below catches and formats them.
3582
+ */
3583
+ export async function grantMcpClient(params, deps = {}) {
3584
+ const { name, keysDir, manifestPath, issuer, opsPortOrUrl, adminUser, adminPass } = params;
3585
+ const fetchImpl = deps.fetchImpl ?? fetch;
3586
+ const now = deps.now ?? (() => new Date().toISOString());
3587
+ const generateKeyPair = deps.generateKeyPair ?? (() => nacl.sign.keyPair());
3588
+ if (!MCP_CLIENT_NAME_PATTERN.test(name)) {
3589
+ throw new Error(`Invalid machine client name '${name}' — must be 1-64 chars, start alphanumeric, ` +
3590
+ `and contain only letters, digits, '_', '-' (it becomes an Agent id, a key filename, and a URL path segment).`);
3591
+ }
3592
+ const manifest = readMcpClientManifest(manifestPath);
3593
+ if (manifest.some((e) => e.name === name)) {
3594
+ throw new McpClientNameExistsError(name);
3595
+ }
3596
+ // Defense-in-depth: refuse to silently reuse/clobber an unrelated
3597
+ // pre-existing Agent id (e.g. a human-run principal sharing this name).
3598
+ const opsUrl = typeof opsPortOrUrl === "number" ? `http://127.0.0.1:${opsPortOrUrl}/` : `${opsPortOrUrl.replace(/\/$/, "")}/`;
3599
+ const authHeader = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
3600
+ const existingRes = await fetchImpl(opsUrl, {
3601
+ method: "POST",
3602
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
3603
+ body: JSON.stringify({
3604
+ operation: "search_by_value",
3605
+ database: "flair",
3606
+ table: "Agent",
3607
+ search_attribute: "id",
3608
+ search_value: name,
3609
+ get_attributes: ["id"],
3610
+ }),
3611
+ });
3612
+ if (existingRes.ok) {
3613
+ const existing = await existingRes.json().catch(() => []);
3614
+ if (Array.isArray(existing) && existing.length > 0) {
3615
+ throw new McpClientAgentIdCollisionError(name);
3616
+ }
3617
+ }
3618
+ mkdirSync(keysDir, { recursive: true });
3619
+ const privPath = privKeyPath(name, keysDir);
3620
+ const pubPath = pubKeyPath(name, keysDir);
3621
+ const kp = generateKeyPair();
3622
+ const seed = kp.secretKey.slice(0, 32);
3623
+ const pubKeyB64url = b64url(kp.publicKey);
3624
+ writeFileSync(privPath, Buffer.from(seed), { mode: 0o600 });
3625
+ chmodSync(privPath, 0o600);
3626
+ writeFileSync(pubPath, Buffer.from(kp.publicKey));
3627
+ const nowIso = now();
3628
+ const insertRes = await fetchImpl(opsUrl, {
3629
+ method: "POST",
3630
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
3631
+ body: JSON.stringify({
3632
+ operation: "insert",
3633
+ database: "flair",
3634
+ table: "Agent",
3635
+ records: [{
3636
+ id: name,
3637
+ name,
3638
+ type: "agent",
3639
+ kind: "agent",
3640
+ status: "active",
3641
+ displayName: name,
3642
+ admin: false,
3643
+ defaultTrustTier: "unverified",
3644
+ runtime: "headless",
3645
+ publicKey: pubKeyB64url,
3646
+ createdAt: nowIso,
3647
+ updatedAt: nowIso,
3648
+ }],
3649
+ }),
3650
+ });
3651
+ if (!insertRes.ok) {
3652
+ // Roll back the key files we just wrote — nothing left behind locally
3653
+ // on a failed grant.
3654
+ for (const p of [privPath, pubPath]) {
3655
+ try {
3656
+ const { unlinkSync } = await import("node:fs");
3657
+ unlinkSync(p);
3658
+ }
3659
+ catch { /* best effort */ }
3660
+ }
3661
+ const text = await insertRes.text().catch(() => "");
3662
+ throw new Error(`Failed to create Agent '${name}' via operations API (${insertRes.status}): ${text}`);
3663
+ }
3664
+ const clientId = `${issuer.replace(/\/+$/, "")}/MCPClientMetadata/${name}`;
3665
+ const entry = {
3666
+ name,
3667
+ agentId: name,
3668
+ clientId,
3669
+ keyFile: privPath,
3670
+ pubKeyFile: pubPath,
3671
+ issuer,
3672
+ createdAt: nowIso,
3673
+ status: "active",
3674
+ };
3675
+ writeMcpClientManifest(manifestPath, [...manifest, entry]);
3676
+ const resource = `${issuer.replace(/\/+$/, "")}/mcp`;
3677
+ const config = buildMcpGrantConfig({ name, resource, keyFile: privPath });
3678
+ return { entry, config };
3679
+ }
3680
+ /**
3681
+ * Core, testable `flair mcp revoke` orchestration. SERVER-SIDE ack is
3682
+ * mandatory before any local mutation: the Agent record backing this
3683
+ * client's CIMD identity is DELETEd via the admin-authenticated ops API
3684
+ * first; only a 2xx response ("ack") triggers local key-file deletion and
3685
+ * manifest cleanup. A network error or non-2xx leaves everything local
3686
+ * untouched and throws — the caller (CLI action) reports a clear failure
3687
+ * and exits non-zero. This mirrors `agent remove`'s existing
3688
+ * delete-then-cleanup ordering.
3689
+ */
3690
+ export async function revokeMcpClient(params, deps = {}) {
3691
+ const { name, manifestPath, opsPortOrUrl, adminUser, adminPass, keepKeys } = params;
3692
+ const fetchImpl = deps.fetchImpl ?? fetch;
3693
+ const manifest = readMcpClientManifest(manifestPath);
3694
+ const entry = manifest.find((e) => e.name === name);
3695
+ if (!entry) {
3696
+ throw new McpClientNotFoundError(name);
3697
+ }
3698
+ const opsUrl = typeof opsPortOrUrl === "number" ? `http://127.0.0.1:${opsPortOrUrl}/` : `${opsPortOrUrl.replace(/\/$/, "")}/`;
3699
+ const authHeader = `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`;
3700
+ let delRes;
3701
+ try {
3702
+ delRes = await fetchImpl(opsUrl, {
3703
+ method: "POST",
3704
+ headers: { "Content-Type": "application/json", Authorization: authHeader },
3705
+ body: JSON.stringify({ operation: "delete", database: "flair", table: "Agent", ids: [entry.agentId] }),
3706
+ });
3707
+ }
3708
+ catch (err) {
3709
+ throw new Error(`Server-side revoke failed: could not reach the operations API to delete Agent '${entry.agentId}' ` +
3710
+ `(${err?.message ?? err}). Nothing was deleted locally — retry once the instance is reachable.`);
3711
+ }
3712
+ if (!delRes.ok) {
3713
+ const text = await delRes.text().catch(() => "");
3714
+ throw new Error(`Server-side revoke failed (HTTP ${delRes.status}) deleting Agent '${entry.agentId}': ${text}. ` +
3715
+ `Nothing was deleted locally.`);
3716
+ }
3717
+ // Server ack received — now safe to clean up locally.
3718
+ if (!keepKeys) {
3719
+ const { unlinkSync } = await import("node:fs");
3720
+ for (const p of [entry.keyFile, entry.pubKeyFile]) {
3721
+ if (p && existsSync(p)) {
3722
+ try {
3723
+ unlinkSync(p);
3724
+ }
3725
+ catch { /* best effort */ }
3726
+ }
3727
+ }
3728
+ }
3729
+ writeMcpClientManifest(manifestPath, manifest.filter((e) => e.name !== name));
3730
+ return entry;
3731
+ }
3732
+ mcp
3733
+ .command("grant <name>")
3734
+ .description("Provision a named, individually-revocable machine client for the /mcp OAuth surface " +
3735
+ "(flair Agent + Ed25519 keypair; the existing CIMD path — see `flair mcp token` — makes it usable).")
3736
+ .option("--issuer <url>", "Public origin for the CIMD client_id (defaults to FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
3737
+ .option("--keys-dir <dir>", "Directory to write the new key pair into (else FLAIR_KEY_DIR, ~/.flair/keys)")
3738
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
3739
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
3740
+ .option("--port <port>", "Harper HTTP port")
3741
+ .option("--ops-port <port>", "Harper operations API port")
3742
+ .option("--json", "Print machine-readable JSON instead of a human summary")
3743
+ .action(async (name, opts) => {
3744
+ const issuer = opts.issuer ?? defaultMcpIssuer();
3745
+ if (!issuer) {
3746
+ console.error("Error: --issuer is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL) — the CIMD client_id " +
3747
+ "must be a stable, publicly-resolvable URL.");
3748
+ process.exit(1);
3749
+ }
3750
+ // Workflow gate: proof `flair mcp enable` has actually run against this
3751
+ // instance — a live probe of the OAuth metadata endpoint (flair#756;
3752
+ // replaces the old DCR-gate-token presence check, which a CIMD-only
3753
+ // instance legitimately can't satisfy).
3754
+ const gate = await selfVerifyMcpMetadata(issuer);
3755
+ if (!gate.ok) {
3756
+ console.error(`Error: the /mcp OAuth surface isn't answering at ${issuer} (${gate.detail}). Run \`flair mcp enable\` first.`);
3757
+ process.exit(1);
3758
+ }
3759
+ const adminPass = resolveLocalAdminPass(opts.adminPass);
3760
+ if (!adminPass) {
3761
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for `flair mcp grant` (needed to insert into the Agent table). " +
3762
+ "Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
3763
+ process.exit(1);
3764
+ }
3765
+ const keysDir = opts.keysDir ?? defaultKeysDir();
3766
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
3767
+ const opsPort = resolveOpsPort(opts);
3768
+ try {
3769
+ const { entry, config } = await grantMcpClient({
3770
+ name,
3771
+ keysDir,
3772
+ manifestPath,
3773
+ issuer,
3774
+ opsPortOrUrl: opsPort,
3775
+ adminUser: DEFAULT_ADMIN_USER,
3776
+ adminPass,
3777
+ });
3778
+ if (opts.json) {
3779
+ console.log(render.asJSON({ entry, config }));
3780
+ return;
3781
+ }
3782
+ console.log(`\n${render.wrap(render.c.bold, `✅ Machine client '${name}' granted`)}\n`);
3783
+ console.log(render.kv("client_id", entry.clientId));
3784
+ console.log(render.kv("key file", render.wrap(render.c.dim, entry.keyFile)));
3785
+ console.log(render.kv("issuer", entry.issuer));
3786
+ console.log(`\n${render.wrap(render.c.dim, "Ready-to-paste MCP config (references the key file, never inline key material):")}\n`);
3787
+ console.log(render.asJSON(config));
3788
+ console.log("");
3789
+ }
3790
+ catch (err) {
3791
+ console.error(`Error: ${err.message}`);
3792
+ process.exit(1);
3793
+ }
3794
+ });
3795
+ mcp
3796
+ .command("revoke <name>")
3797
+ .description("Server-side revoke a granted machine client (deletes its backing Agent record), then clean up locally.")
3798
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
3799
+ .option("--admin-pass <pass>", "Admin password (or set FLAIR_ADMIN_PASS)")
3800
+ .option("--issuer <url>", "Public origin of the /mcp OAuth surface — used only for the enable-gate probe (defaults to FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
3801
+ .option("--ops-port <port>", "Harper operations API port")
3802
+ .option("--port <port>", "Harper HTTP port")
3803
+ .option("--keep-keys", "Do not delete local key files after a successful server-side revoke")
3804
+ .action(async (name, opts) => {
3805
+ const issuer = opts.issuer ?? defaultMcpIssuer();
3806
+ if (!issuer) {
3807
+ console.error("Error: --issuer is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL) to verify the /mcp OAuth surface before revoking.");
3808
+ process.exit(1);
3809
+ }
3810
+ // Workflow gate — see the matching comment on `grant` above.
3811
+ const gate = await selfVerifyMcpMetadata(issuer);
3812
+ if (!gate.ok) {
3813
+ console.error(`Error: the /mcp OAuth surface isn't answering at ${issuer} (${gate.detail}). Run \`flair mcp enable\` first.`);
3814
+ process.exit(1);
3815
+ }
3816
+ const adminPass = resolveLocalAdminPass(opts.adminPass);
3817
+ if (!adminPass) {
3818
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required for `flair mcp revoke`.");
3819
+ process.exit(1);
3820
+ }
3821
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
3822
+ const opsPort = resolveOpsPort(opts);
3823
+ try {
3824
+ await revokeMcpClient({
3825
+ name,
3826
+ manifestPath,
3827
+ opsPortOrUrl: opsPort,
3828
+ adminUser: DEFAULT_ADMIN_USER,
3829
+ adminPass,
3830
+ keepKeys: !!opts.keepKeys,
3831
+ });
3832
+ console.log(`${render.icons.ok} Machine client '${name}' revoked (server-side Agent record deleted${opts.keepKeys ? "; local keys kept" : " and local keys removed"}).`);
3833
+ }
3834
+ catch (err) {
3835
+ console.error(`${render.icons.error} ${err.message}`);
3836
+ process.exit(1);
3837
+ }
3838
+ });
3839
+ mcp
3840
+ .command("list")
3841
+ .description("List granted machine clients (name, client_id, status, created).")
3842
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
3843
+ .option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
3844
+ .action((opts) => {
3845
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
3846
+ const entries = readMcpClientManifest(manifestPath);
3847
+ const mode = render.resolveOutputMode(opts);
3848
+ if (mode === "json") {
3849
+ console.log(render.asJSON(entries));
3850
+ return;
3851
+ }
3852
+ if (entries.length === 0) {
3853
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "no machine clients granted (see `flair mcp grant <name>`)")}`);
3854
+ return;
3855
+ }
3856
+ console.log(`${render.wrap(render.c.bold, String(entries.length))} machine client(s)\n`);
3857
+ const cols = [
3858
+ { label: "name", key: "name", format: (v) => render.wrap(render.c.bold, String(v ?? "—")) },
3859
+ { label: "client_id", key: "clientId", format: (v) => render.wrap(render.c.dim, String(v ?? "—")) },
3860
+ { label: "status", key: "status", format: (v) => (v === "active" ? render.wrap(render.c.green, String(v)) : String(v ?? "—")) },
3861
+ { label: "created", key: "createdAt", format: (v) => render.relativeTime(v) },
3862
+ ];
3863
+ console.log(render.table(cols, entries));
3864
+ });
3865
+ // ─── flair mcp enable / disable / status ────────────────────────────────────
3866
+ // flair#719 — the last piece of the paved-paths command family. Automates
3867
+ // docs/notes/mcp-oauth-model2.md's 8-step operator checklist into one
3868
+ // command, per the design record + K&S verdicts on #719's thread (see
3869
+ // src/lib/mcp-enable.ts's module header for the full binding design record,
3870
+ // including the scenario addendum: `enable` targets the HOSTED shape only —
3871
+ // it runs on the OPERATOR's machine, against a REMOTE instance, and refuses
3872
+ // honestly against a local-origin instance rather than walking eight steps
3873
+ // toward a connector that can never connect).
3874
+ /** Simple y/N confirmation over readline — TTY-only, mirrors the existing
3875
+ * restore-confirmation pattern (`flair snapshot restore`) above. */
3876
+ async function confirmYesNo(question) {
3877
+ if (!process.stdin.isTTY)
3878
+ return false;
3879
+ const { createInterface } = await import("node:readline");
3880
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3881
+ const answer = await new Promise((res) => rl.question(`${question} [y/N] `, (a) => { rl.close(); res(a); }));
3882
+ return /^y(es)?$/i.test(answer.trim());
3883
+ }
3884
+ /** Plain-text readline prompt (tests never exercise this — CLI-only). Used
3885
+ * for --idp-client-id/--idp-client-secret/--idp-subject when a flag is
3886
+ * omitted and stdin is a TTY. */
3887
+ async function promptText(question) {
3888
+ const { createInterface } = await import("node:readline");
3889
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
3890
+ const answer = await new Promise((res) => rl.question(question, (a) => { rl.close(); res(a); }));
3891
+ return answer.trim();
3892
+ }
3893
+ function printEnableSteps(result) {
3894
+ console.log(`\n${render.wrap(render.c.bold, "flair mcp enable")}${result.dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
3895
+ for (const s of result.steps) {
3896
+ console.log(` ${s.ok ? render.icons.ok : render.icons.error} ${render.wrap(render.c.dim, s.step)}`);
3897
+ console.log(` ${s.detail}`);
3898
+ }
3899
+ console.log("");
3900
+ }
3901
+ mcp
3902
+ .command("enable")
3903
+ .description("One-command hosted-shape enablement of the OAuth /mcp surface for claude.ai — automates the " +
3904
+ "docs/notes/mcp-oauth-model2.md checklist. Targets a REMOTE instance with a public HTTPS origin; " +
3905
+ "refuses honestly against a local-origin instance.")
3906
+ .option("--instance <url>", "Remote flair instance to enable against (else FLAIR_URL)")
3907
+ .option("--issuer <url>", "Public origin claude.ai will use (else --instance)")
3908
+ .option("--idp-provider <name>", "Upstream IdP provider", "github")
3909
+ .option("--idp-client-id <id>", "IdP OAuth app client id (else prompted interactively)")
3910
+ .option("--idp-client-secret <secret>", "IdP OAuth app client secret (else prompted interactively — prefer the prompt; an inline flag leaks to shell history)")
3911
+ .option("--idp-subject <value>", "Your expected `sub`/login at the IdP (GitHub: your username; else prompted interactively)")
3912
+ .option("--principal <id>", "Principal (Agent) to map your IdP identity to — personal-shape default", "self")
3913
+ .option("--principal-kind <human|agent>", "Kind for a newly-created principal", "human")
3914
+ .option("--secrets-mechanism <fabric-env-secrets|env-file>", "Override the shape-aware secrets mechanism (else auto-detected from --instance)")
3915
+ .option("--secrets-path <path>", "Override the secrets staging file path")
3916
+ .option("--cimd-allowed-hosts <hosts>", "Comma-separated clientIdMetadataDocuments.allowedHosts override (else claude.ai,claude.com)")
3917
+ .option("--signing-key-file <path>", "RS256 signing key PEM file (else ~/.flair/mcp-signing-key.pem)")
3918
+ .option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
3919
+ .option("--confirm-secrets-applied", "Confirm the staged secrets are already live on the target instance's environment (skips the interactive confirm)")
3920
+ .option("--dry-run", "Generate keys/tokens/config and validate inputs; skip every remote call")
3921
+ .option("--json", "Print machine-readable JSON instead of a human summary")
3922
+ .action(async (opts) => {
3923
+ const instance = opts.instance ?? process.env.FLAIR_URL;
3924
+ if (!instance) {
3925
+ console.error("Error: --instance is required (or set FLAIR_URL) — `flair mcp enable` targets a specific remote instance.");
3926
+ process.exit(1);
3927
+ }
3928
+ // Local-origin refusal short-circuits before we ask for anything else —
3929
+ // never walk the operator through IdP app creation for a connector that
3930
+ // can never connect.
3931
+ const localCheck = checkLocalOriginRefusal(instance);
3932
+ if (localCheck.refused) {
3933
+ console.error(`${render.icons.error} ${localCheck.message}`);
3934
+ process.exit(1);
3935
+ }
3936
+ const dryRun = Boolean(opts.dryRun);
3937
+ // --instance is ALWAYS remote for this command (local is refused above)
3938
+ // — isRemoteTarget=true so a missing --admin-pass/FLAIR_ADMIN_PASS never
3939
+ // silently falls back to THIS machine's local ~/.flair/admin-pass file
3940
+ // against someone else's instance (see resolveLocalAdminPass's doc comment).
3941
+ const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
3942
+ if (!dryRun && !adminPass) {
3943
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required (the operations API on the target instance needs it " +
3944
+ "for identity mapping + set_configuration + restart).");
3945
+ process.exit(1);
3946
+ }
3947
+ let idpClientId = opts.idpClientId;
3948
+ let idpClientSecret = opts.idpClientSecret;
3949
+ let idpSubject = opts.idpSubject;
3950
+ if (!dryRun && process.stdin.isTTY) {
3951
+ if (!idpClientId)
3952
+ idpClientId = await promptText(`${opts.idpProvider} OAuth app client id: `);
3953
+ if (!idpClientSecret)
3954
+ idpClientSecret = await promptText(`${opts.idpProvider} OAuth app client secret: `);
3955
+ if (!idpSubject)
3956
+ idpSubject = await promptText(`Your expected ${opts.idpProvider} login/sub: `);
3957
+ }
3958
+ const secretsMechanism = opts.secretsMechanism;
3959
+ if (secretsMechanism && secretsMechanism !== "fabric-env-secrets" && secretsMechanism !== "env-file") {
3960
+ console.error(`Error: --secrets-mechanism must be "fabric-env-secrets" or "env-file", got "${secretsMechanism}"`);
3961
+ process.exit(1);
3962
+ }
3963
+ const cimdAllowedHosts = opts.cimdAllowedHosts
3964
+ ? String(opts.cimdAllowedHosts).split(",").map((h) => h.trim()).filter(Boolean)
3965
+ : undefined;
3966
+ const result = await enableMcp({
3967
+ instance,
3968
+ issuer: opts.issuer,
3969
+ idpProvider: opts.idpProvider,
3970
+ idpClientId,
3971
+ idpClientSecret,
3972
+ idpSubject,
3973
+ principal: opts.principal,
3974
+ principalKind: opts.principalKind,
3975
+ adminUser: DEFAULT_ADMIN_USER,
3976
+ adminPass,
3977
+ signingKeyFilePath: opts.signingKeyFile,
3978
+ secretsMechanism,
3979
+ secretsStagingPath: opts.secretsPath,
3980
+ cimdAllowedHosts,
3981
+ dryRun,
3982
+ confirmSecretsApplied: Boolean(opts.confirmSecretsApplied),
3983
+ }, { confirmPrompt: dryRun ? undefined : confirmYesNo });
3984
+ if (opts.json) {
3985
+ console.log(render.asJSON(result));
3986
+ if (!result.ok)
3987
+ process.exit(1);
3988
+ return;
3989
+ }
3990
+ printEnableSteps(result);
3991
+ if (result.refused) {
3992
+ process.exit(1);
3993
+ }
3994
+ if (!result.ok) {
3995
+ console.error(`${render.icons.error} enable failed at step "${result.failedStep}" — see detail above for the exact fix, then re-run \`flair mcp enable\` (earlier steps are idempotent and will be reused).`);
3996
+ process.exit(1);
3997
+ }
3998
+ if (result.dryRun) {
3999
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "dry-run: no remote calls were made.")}`);
4000
+ return;
4001
+ }
4002
+ console.log(`${render.icons.ok} ${render.wrap(render.c.bold, "claude.ai can now connect.")}\n`);
4003
+ console.log(result.pasteBlock ?? "");
4004
+ console.log("");
4005
+ });
4006
+ mcp
4007
+ .command("disable")
4008
+ .description("Flag off + restart = byte-identical boot (Model-2 contract) — removes the /mcp OAuth surface.")
4009
+ .option("--instance <url>", "Remote flair instance to disable against (else FLAIR_URL)")
4010
+ .option("--admin-pass <pass>", "Admin password for the target instance (or FLAIR_ADMIN_PASS)")
4011
+ .option("--confirm-flag-off", "Confirm FLAIR_MCP_OAUTH is already unset on the target instance's environment (skips the interactive confirm)")
4012
+ .option("--json", "Print machine-readable JSON instead of a human summary")
4013
+ .action(async (opts) => {
4014
+ const instance = opts.instance ?? process.env.FLAIR_URL;
4015
+ if (!instance) {
4016
+ console.error("Error: --instance is required (or set FLAIR_URL).");
4017
+ process.exit(1);
4018
+ }
4019
+ // --instance is always remote for this command — see the matching
4020
+ // comment in `mcp enable` above.
4021
+ const adminPass = resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
4022
+ if (!adminPass) {
4023
+ console.error("Error: --admin-pass or FLAIR_ADMIN_PASS required.");
4024
+ process.exit(1);
4025
+ }
4026
+ const result = await disableMcp({ instance, adminUser: DEFAULT_ADMIN_USER, adminPass, confirmFlagOff: Boolean(opts.confirmFlagOff) }, { confirmPrompt: confirmYesNo });
4027
+ if (opts.json) {
4028
+ console.log(render.asJSON(result));
4029
+ if (!result.ok)
4030
+ process.exit(1);
4031
+ return;
4032
+ }
4033
+ console.log(`${result.ok ? render.icons.ok : render.icons.error} ${result.detail}`);
4034
+ if (!result.ok)
4035
+ process.exit(1);
4036
+ });
4037
+ mcp
4038
+ .command("status")
4039
+ .description("Surface the /mcp OAuth surface's live state: enabled? CIMD advertised? granted machine-client count.")
4040
+ .option("--instance <url>", "Remote flair instance to check (else FLAIR_URL)")
4041
+ .option("--manifest <path>", "Path to the local machine-client manifest (else ~/.flair/mcp-clients.json)")
4042
+ .option("--json", "Print machine-readable JSON instead of a human summary")
4043
+ .action(async (opts) => {
4044
+ const instance = opts.instance ?? process.env.FLAIR_URL;
4045
+ if (!instance) {
4046
+ console.error("Error: --instance is required (or set FLAIR_URL).");
4047
+ process.exit(1);
4048
+ }
4049
+ const manifestPath = opts.manifest ?? defaultMcpClientManifestPath();
4050
+ const result = await mcpStatus({ instance }, { countMachineClients: () => readMcpClientManifest(manifestPath).length });
4051
+ if (opts.json) {
4052
+ console.log(render.asJSON(result));
4053
+ return;
4054
+ }
4055
+ console.log(`\n${render.wrap(render.c.bold, "flair mcp status")}\n`);
4056
+ console.log(render.kv("instance", result.instance));
4057
+ console.log(render.kv("enabled", result.enabled ? render.wrap(render.c.green, "yes") : render.wrap(render.c.yellow, "no")));
4058
+ console.log(render.kv("metadata", result.detail));
4059
+ // flair#756: CIMD is the only supported client-registration path — this
4060
+ // reflects clientIdMetadataDocuments config presence on the target
4061
+ // instance, the only signal `status` can see without admin credentials.
4062
+ console.log(render.kv("CIMD", result.cimdSupported ? render.wrap(render.c.green, "advertised") : render.wrap(render.c.yellow, "not advertised")));
4063
+ console.log(render.kv("machine clients", String(result.machineClientCount ?? 0)));
4064
+ console.log("");
4065
+ });
3238
4066
  // ─── flair principal ─────────────────────────────────────────────────────────
3239
4067
  // 1.0 identity management. The Principal model extends Agent — this is the
3240
4068
  // preferred CLI surface for managing identities going forward.
@@ -5924,36 +6752,24 @@ async function fetchHealthDetail(opts) {
5924
6752
  }
5925
6753
  catch { /* unreachable */ }
5926
6754
  if (healthy) {
5927
- const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
5928
- if (agentId) {
5929
- const keyPath = resolveKeyPath(agentId);
5930
- if (keyPath) {
5931
- try {
5932
- const authHeader = buildEd25519Auth(agentId, "GET", "/HealthDetail", keyPath);
5933
- const res = await fetch(`${baseUrl}/HealthDetail`, {
5934
- headers: { Authorization: authHeader },
5935
- signal: AbortSignal.timeout(5000),
5936
- });
5937
- if (res.ok)
5938
- healthData = await res.json().catch(() => null);
5939
- }
5940
- catch { /* fall through */ }
5941
- }
6755
+ // flair#747: /HealthDetail is a verified-read (any registered agent, not
6756
+ // just admins — see verifyAuthedGet's doc). Previously this tried the
6757
+ // --agent/FLAIR_AGENT_ID key FIRST and admin-pass env only as a manual
6758
+ // second attempt, with no admin-pass-FILE leg and no floor at all when
6759
+ // no --agent was given — exactly the flair#741 gap, on the `status`
6760
+ // command family specifically. Now routed through the shared resolver:
6761
+ // env admin-pass > pinned agent key (if --agent/FLAIR_AGENT_ID given) >
6762
+ // ~/.flair/admin-pass file > the Ed25519 floor (any registered key) when
6763
+ // nothing else resolved.
6764
+ try {
6765
+ healthData = await authedRequest("GET", "/HealthDetail", undefined, {
6766
+ baseUrl,
6767
+ agentId: opts.agent || process.env.FLAIR_AGENT_ID,
6768
+ });
5942
6769
  }
5943
- if (!healthData) {
5944
- const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
5945
- if (adminPass) {
5946
- try {
5947
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5948
- const res = await fetch(`${baseUrl}/HealthDetail`, {
5949
- headers: { Authorization: auth },
5950
- signal: AbortSignal.timeout(5000),
5951
- });
5952
- if (res.ok)
5953
- healthData = await res.json().catch(() => null);
5954
- }
5955
- catch { /* fall through */ }
5956
- }
6770
+ catch {
6771
+ // No credential tier resolved a verified read — healthData stays
6772
+ // null; callers already render an "unauthenticated" / limited view.
5957
6773
  }
5958
6774
  }
5959
6775
  return { healthy, baseUrl, healthData };
@@ -7620,9 +8436,10 @@ program
7620
8436
  const port = resolveHttpPort(opts);
7621
8437
  const platform = process.platform;
7622
8438
  if (platform === "darwin") {
7623
- // macOS: try launchd first
7624
- const label = "ai.tpsdev.flair";
7625
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
8439
+ // macOS: try launchd first. resolveLaunchdLabel (flair#693) finds
8440
+ // whichever label this data dir is actually registered under —
8441
+ // the new instance-scoped one, or a pre-flair#693 legacy install.
8442
+ const { plistPath } = resolveLaunchdLabel(defaultDataDir());
7626
8443
  if (existsSync(plistPath)) {
7627
8444
  try {
7628
8445
  const { execSync } = await import("node:child_process");
@@ -7677,17 +8494,19 @@ program
7677
8494
  }
7678
8495
  const platform = process.platform;
7679
8496
  if (platform === "darwin") {
7680
- const label = "ai.tpsdev.flair";
7681
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
8497
+ // resolveLaunchdLabel (flair#693) finds whichever label this data
8498
+ // dir is currently registered under (new instance-scoped, or a
8499
+ // pre-flair#693 legacy install) so the existsSync gate below is
8500
+ // accurate before we attempt anything.
8501
+ const { plistPath } = resolveLaunchdLabel(dataDir);
7682
8502
  if (existsSync(plistPath)) {
7683
8503
  try {
7684
8504
  const { execSync } = await import("node:child_process");
7685
- try {
7686
- execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
7687
- }
7688
- catch { }
7689
- execSync(`launchctl start ${label}`, { stdio: "pipe" });
8505
+ const { label, migrated } = ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
8506
+ if (migrated)
8507
+ console.log(`Migrated launchd service off the legacy label (${LEGACY_LAUNCHD_LABEL}) → ${label} ✓`);
7690
8508
  await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
8509
+ readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
7691
8510
  console.log("✅ Flair started (launchd)");
7692
8511
  return;
7693
8512
  }
@@ -7704,6 +8523,14 @@ program
7704
8523
  }
7705
8524
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
7706
8525
  const opsPort = resolveOpsPort(opts);
8526
+ // flair#670: this fallback path (no launchd plist) sets no HARPER_SET_CONFIG,
8527
+ // so OPERATIONSAPI_NETWORK_PORT applies unfiltered on top of whatever init
8528
+ // persisted to harper-config.yaml. A bare port number here would silently
8529
+ // strip a loopback (or escape-hatch) bind host back to all-interfaces on
8530
+ // every plain `flair start`. Re-resolving the same host:port form init uses
8531
+ // keeps this re-assertion consistent instead of regressing it — the
8532
+ // escape hatch on this path is FLAIR_OPS_BIND (no --ops-bind flag on `start`).
8533
+ const opsBindHost = resolveOpsBindHost({});
7707
8534
  const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
7708
8535
  const env = {
7709
8536
  ...process.env,
@@ -7713,7 +8540,7 @@ program
7713
8540
  DEFAULTS_MODE: "dev",
7714
8541
  HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
7715
8542
  HTTP_PORT: String(port),
7716
- OPERATIONSAPI_NETWORK_PORT: String(opsPort),
8543
+ OPERATIONSAPI_NETWORK_PORT: `${opsBindHost}:${opsPort}`,
7717
8544
  LOCAL_STUDIO: "false",
7718
8545
  };
7719
8546
  // Only set HDB_ADMIN_PASSWORD if we have a real value — empty string
@@ -7729,6 +8556,7 @@ program
7729
8556
  proc.unref();
7730
8557
  try {
7731
8558
  await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
8559
+ readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture on the freshly-created socket
7732
8560
  console.log(`✅ Flair started on port ${port}`);
7733
8561
  }
7734
8562
  catch {
@@ -7751,8 +8579,12 @@ program
7751
8579
  */
7752
8580
  async function stopFlairProcess(port) {
7753
8581
  if (process.platform === "darwin") {
7754
- const label = "ai.tpsdev.flair";
7755
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
8582
+ const dataDir = defaultDataDir();
8583
+ // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8584
+ // is currently registered under (new instance-scoped, or a
8585
+ // pre-flair#693 legacy install) — stop only needs to operate on
8586
+ // whichever exists, no migration.
8587
+ const { label, plistPath } = resolveLaunchdLabel(dataDir);
7756
8588
  if (existsSync(plistPath)) {
7757
8589
  try {
7758
8590
  const { execSync } = await import("node:child_process");
@@ -7765,7 +8597,7 @@ async function stopFlairProcess(port) {
7765
8597
  // immediately restart can verify exit. Without this, waitForHealth
7766
8598
  // can race against the still-shutting-down old process and return
7767
8599
  // success before KeepAlive brings the new one up.
7768
- const oldPid = readHarperPid(defaultDataDir());
8600
+ const oldPid = readHarperPid(dataDir);
7769
8601
  try {
7770
8602
  execSync(`launchctl stop ${label}`, { stdio: "pipe" });
7771
8603
  }
@@ -7803,21 +8635,17 @@ async function stopFlairProcess(port) {
7803
8635
  * Counterpart to `stopFlairProcess`; see that function's doc comment.
7804
8636
  */
7805
8637
  async function startFlairProcess(port) {
8638
+ const dataDir = defaultDataDir();
7806
8639
  if (process.platform === "darwin") {
7807
- const label = "ai.tpsdev.flair";
7808
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
8640
+ // resolveLaunchdLabel (flair#693) finds whichever label this data dir
8641
+ // is currently registered under before we attempt anything.
8642
+ const { plistPath } = resolveLaunchdLabel(dataDir);
7809
8643
  if (existsSync(plistPath)) {
7810
8644
  try {
7811
8645
  const { execSync } = await import("node:child_process");
7812
- try {
7813
- execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
7814
- }
7815
- catch { }
7816
- try {
7817
- execSync(`launchctl start ${label}`, { stdio: "pipe" });
7818
- }
7819
- catch { }
8646
+ ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
7820
8647
  await waitForHealth(port, DEFAULT_ADMIN_USER, process.env.HDB_ADMIN_PASSWORD ?? "", STARTUP_TIMEOUT_MS);
8648
+ readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
7821
8649
  return;
7822
8650
  }
7823
8651
  catch (err) {
@@ -7830,7 +8658,6 @@ async function startFlairProcess(port) {
7830
8658
  if (!bin) {
7831
8659
  throw new Error("Harper binary not found. Run 'flair init' first.");
7832
8660
  }
7833
- const dataDir = defaultDataDir();
7834
8661
  // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
7835
8662
  // Without this, `flair init --admin-pass X` (which only exports HDB_*
7836
8663
  // to the initial Harper spawn) followed by `flair restart` would silently
@@ -7857,6 +8684,7 @@ async function startFlairProcess(port) {
7857
8684
  });
7858
8685
  proc.unref();
7859
8686
  await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
8687
+ readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
7860
8688
  }
7861
8689
  /**
7862
8690
  * The ONE restart mechanism for a local Flair install. Shared by `flair
@@ -7897,20 +8725,28 @@ program
7897
8725
  .action(async (opts) => {
7898
8726
  const platform = process.platform;
7899
8727
  const port = readPortFromConfig() ?? DEFAULT_PORT;
7900
- // Stop first: remove launchd service on macOS, then kill by port on all platforms
8728
+ // Stop first: remove launchd service(s) on macOS, then kill by port on
8729
+ // all platforms. Removes BOTH the new instance-scoped plist and a
8730
+ // pre-flair#693 legacy plist if present — uninstall's job is to purge
8731
+ // everything for this data dir, so it doesn't rely on resolveLaunchdLabel's
8732
+ // "prefer new" pick alone (which would skip a stray legacy leftover).
7901
8733
  if (platform === "darwin") {
7902
- const label = "ai.tpsdev.flair";
7903
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
7904
- if (existsSync(plistPath)) {
7905
- try {
7906
- const { execSync } = await import("node:child_process");
7907
- execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
8734
+ const dataDir = defaultDataDir();
8735
+ const candidatePlists = [launchdPlistPath(launchdLabel(dataDir)), launchdPlistPath(LEGACY_LAUNCHD_LABEL)];
8736
+ let removedAny = false;
8737
+ for (const plistPath of candidatePlists) {
8738
+ if (existsSync(plistPath)) {
8739
+ try {
8740
+ const { execSync } = await import("node:child_process");
8741
+ execSync(`launchctl unload "${plistPath}"`, { stdio: "pipe" });
8742
+ }
8743
+ catch { /* best effort */ }
8744
+ unlinkSync(plistPath);
8745
+ removedAny = true;
7908
8746
  }
7909
- catch { /* best effort */ }
7910
- const { unlinkSync } = await import("node:fs");
7911
- unlinkSync(plistPath);
7912
- console.log("✅ Launchd service removed");
7913
8747
  }
8748
+ if (removedAny)
8749
+ console.log("✅ Launchd service removed");
7914
8750
  }
7915
8751
  // Kill any process still on the port (covers direct-start, no-service, or failed unload)
7916
8752
  try {
@@ -8774,6 +9610,43 @@ program
8774
9610
  else {
8775
9611
  console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
8776
9612
  }
9613
+ // 3b. Ops API bind (flair#670) — report-only finding, never auto-fixed.
9614
+ // Rebinding the ops API requires a Harper restart to take effect, so
9615
+ // `doctor --fix` deliberately does not touch it here; the fix is
9616
+ // `flair init` (re-run) or a manual harper-config.yaml edit + restart.
9617
+ try {
9618
+ const harperConfigPath = join(defaultDataDir(), "harper-config.yaml");
9619
+ if (existsSync(harperConfigPath)) {
9620
+ const harperConfig = parseYaml(readFileSync(harperConfigPath, "utf-8")) || {};
9621
+ const opsPortValue = harperConfig?.operationsApi?.network?.port;
9622
+ const bind = detectOpsApiAllInterfacesBind(opsPortValue);
9623
+ if (bind.allInterfaces) {
9624
+ console.log(` ${render.icons.error} Ops API bound to ${render.wrap(render.c.bold, "all interfaces")} (${render.wrap(render.c.dim, String(opsPortValue))})`);
9625
+ console.log(` ${render.wrap(render.c.dim, "Single-host installs don't need this reachable off-box. Fix:")} flair init ${render.wrap(render.c.dim, "(rebinds to loopback + domain socket on next start; pass --ops-bind for deliberate remote admin)")}`);
9626
+ issues++;
9627
+ }
9628
+ }
9629
+ }
9630
+ catch { /* best-effort — don't fail doctor over a malformed harper-config.yaml */ }
9631
+ // 3c. Ops-socket permission posture (flair#763) — report-only, never
9632
+ // auto-fixed. Re-tightening a live socket needs a restart, so the remedy is
9633
+ // `flair init`/restart (which re-applies the posture), not a `doctor --fix`.
9634
+ // Only assessed when the socket exists (Harper has booted at least once).
9635
+ try {
9636
+ const socketPath = join(defaultDataDir(), "operations-server");
9637
+ if (existsSync(socketPath)) {
9638
+ const dirMode = statSync(dirname(socketPath)).mode;
9639
+ const socketMode = statSync(socketPath).mode;
9640
+ const groupOptIn = !!(process.env.FLAIR_SOCKET_GROUP && process.env.FLAIR_SOCKET_GROUP.trim().length > 0);
9641
+ const verdict = classifyOpsSocketPosture(dirMode, socketMode, groupOptIn);
9642
+ if (verdict.flagged) {
9643
+ console.log(` ${render.icons.error} Ops socket permissions: ${verdict.reason}`);
9644
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} flair init ${render.wrap(render.c.dim, "(re-applies the 0700 dir / 0600 socket posture on next start; set FLAIR_SOCKET_GROUP for deliberate multi-user access)")}`);
9645
+ issues++;
9646
+ }
9647
+ }
9648
+ }
9649
+ catch { /* best-effort — a stat failure shouldn't fail doctor */ }
8777
9650
  // 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
8778
9651
  //
8779
9652
  // The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
@@ -9988,21 +10861,14 @@ program
9988
10861
  const baseUrl = resolveBaseUrl(opts);
9989
10862
  const mode = render.resolveOutputMode(opts);
9990
10863
  try {
9991
- const headers = { "content-type": "application/json" };
9992
- const keyPath = opts.key || resolveKeyPath(agentId);
9993
- if (keyPath) {
9994
- headers["authorization"] = buildEd25519Auth(agentId, "POST", "/BootstrapMemories", keyPath);
9995
- }
9996
- const res = await fetch(`${baseUrl}/BootstrapMemories`, {
9997
- method: "POST",
9998
- headers,
9999
- body: JSON.stringify({ agentId, maxTokens: parseInt(opts.maxTokens, 10) }),
10000
- });
10001
- if (!res.ok) {
10002
- const body = await res.text();
10003
- throw new Error(`${res.status}: ${body}`);
10004
- }
10005
- const result = (await res.json());
10864
+ // flair#747: routed through the shared resolver — --key is an
10865
+ // explicit tier-1 override (as before), now additionally backstopped
10866
+ // by env admin-pass / ~/.flair/admin-pass / the Ed25519 floor if
10867
+ // neither --key nor the agent's own resolveKeyPath(agentId) lookup
10868
+ // finds a usable key. Previously this only ever tried Ed25519 (no
10869
+ // admin fallback at all) and sent NO Authorization header when no key
10870
+ // was found, relying on Harper's local passthrough.
10871
+ const result = (await authedRequest("POST", "/BootstrapMemories", { agentId, maxTokens: parseInt(opts.maxTokens, 10) }, { baseUrl, agentId, explicitKeyPath: opts.key }));
10006
10872
  if (mode === "json") {
10007
10873
  // Agent-first: emit the full server response, augmented with the cap
10008
10874
  // that was requested. Includes context, sections, tokenEstimate, etc.
@@ -11874,4 +12740,6 @@ if (import.meta.main) {
11874
12740
  await runCli();
11875
12741
  }
11876
12742
  // ─── Exported for testing ─────────────────────────────────────────────────────
11877
- export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, };
12743
+ export { runCli, resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveOpsBindHost, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, MAX_WORKSPACE_FIELD_LENGTH, MAX_ORGEVENT_SUMMARY_LENGTH, MAX_ORGEVENT_DETAIL_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, resolveLocalAdminPass, readAdminPassFileSecure,
12744
+ // launchd label (flair#693)
12745
+ LEGACY_LAUNCHD_LABEL, launchdLabel, launchdPlistPath, resolveLaunchdLabel, migrateLegacyLaunchdLabel, ensureLaunchdServiceLoaded, };