@tpsdev-ai/flair 0.22.1 → 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";
22
- import { readClientMcpBlock, checkClaudeMdBootstrap, checkSessionStartHook, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, } from "./doctor-client.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";
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,185 +699,77 @@ 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.
704
+ /**
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.
722
+ */
385
723
  async function api(method, path, body, options) {
386
724
  // Resolve port: FLAIR_URL env > ~/.flair/config.yaml > default 9926
387
725
  // When baseUrl is provided (--target), use it directly.
388
726
  const savedPort = readPortFromConfig();
389
727
  const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
390
728
  const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
391
- const isLocal = isLocalBase(base);
392
- // Auth resolution order (flair#634local targets used to send NO auth at
393
- // all here and ride Harper's authorizeLocal forged super_user; #632 gated
394
- // FederationInstance/FederationPeers behind allowAdmin, so credential-less
395
- // local calls to those now 403 instead of silently passing):
396
- // 1. FLAIR_TOKEN env → Bearer token (backward compat)
397
- // 2. FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD env → Basic admin auth. Applies to
398
- // BOTH local and remote targets — an explicit env var always wins, local
399
- // included, so a caller that sets it never depends on authorizeLocal.
400
- // 3. FLAIR_AGENT_ID env + key file Ed25519 signature (standard)
401
- // 4. LOCAL TARGETS ONLY: the secure ~/.flair/admin-pass file `flair init`
402
- // writes (#593) → Basic admin auth, via the same resolveLocalAdminPass
403
- // convenience `agent add`/`principal add` already use (#590). Guarded to
404
- // isLocal so a --target/FLAIR_URL request aimed elsewhere never rides
405
- // this machine's local admin secret.
406
- // 5. No auth (remote will 401/403; local now also gets a real 403 from
407
- // #632-gated resources instead of the old forged-admin passthrough)
408
- //
409
- // NOTE: this function is for the Harper HTTP/REST API only. The Harper
410
- // operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
411
- // ALSO honors authorizeLocal: a header-less loopback request to the ops port
412
- // is auto-authorized as super_user (verified by live probe — flair#610). Those
413
- // helpers nonetheless send Basic admin auth UNCONDITIONALLY, so they never
414
- // depend on that ambient elevation and behave identically against a remote or
415
- // hardened instance. Hardening the ops-API loopback posture itself (bind scope
416
- // / disabling authorizeLocal there) is tracked separately in flair#654 and is
417
- // out of scope for this HTTP/REST auth path.
418
- let authHeader;
419
- const token = process.env.FLAIR_TOKEN;
420
- if (token) {
421
- authHeader = `Bearer ${token}`;
422
- }
423
- else if (process.env.FLAIR_ADMIN_PASS || process.env.HDB_ADMIN_PASSWORD) {
424
- // Admin Basic auth used by federation, backup, and other admin CLI commands
425
- const adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD;
426
- authHeader = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
427
- }
428
- else {
429
- // Extract agentId from body (POST/PUT) or URL query params (GET)
430
- let agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
431
- if (!agentId && path.includes("agentId=")) {
432
- const match = path.match(/agentId=([^&]+)/);
433
- if (match)
434
- agentId = decodeURIComponent(match[1]);
435
- }
436
- if (agentId) {
437
- const keyPath = resolveKeyPath(agentId);
438
- if (keyPath) {
439
- try {
440
- // Sign the path without query params — auth middleware verifies the clean path
441
- // Auth middleware verifies the full request path including query params
442
- authHeader = buildEd25519Auth(agentId, method, path, keyPath);
443
- }
444
- catch (err) {
445
- // Key exists but auth build failed — warn and continue without auth
446
- const message = err instanceof Error ? err.message : String(err);
447
- console.error(`Warning: Ed25519 auth failed for agent '${agentId}': ${message}`);
448
- }
449
- }
450
- }
451
- // Local-only fallback (flair#634): no explicit env, no usable agent key.
452
- // FLAIR_ADMIN_PASS/HDB_ADMIN_PASSWORD are already ruled out by this point
453
- // (handled above), so resolveLocalAdminPass's env leg is a no-op here and
454
- // this only ever resolves the ~/.flair/admin-pass file — isRemoteTarget
455
- // is `!isLocal` so it's skipped entirely for --target/FLAIR_URL requests.
456
- if (!authHeader) {
457
- try {
458
- const filePass = resolveLocalAdminPass(undefined, !isLocal);
459
- if (filePass) {
460
- authHeader = `Basic ${Buffer.from(`admin:${filePass}`).toString("base64")}`;
461
- }
462
- }
463
- catch (err) {
464
- // File exists but has unsafe permissions — warn (never the secret
465
- // itself) and fall through to no-auth.
466
- const message = err instanceof Error ? err.message : String(err);
467
- console.error(`Warning: ~/.flair/admin-pass unusable: ${message}`);
468
- }
469
- }
470
- }
471
- const res = await fetch(`${base}${path}`, {
472
- method,
473
- headers: {
474
- "content-type": "application/json",
475
- ...(authHeader ? { authorization: authHeader } : {}),
476
- },
477
- body: body ? JSON.stringify(body) : undefined,
478
- });
479
- // Handle 204 No Content (e.g., PUT upsert returns empty body)
480
- if (res.status === 204 || res.headers.get("content-length") === "0") {
481
- if (!res.ok)
482
- throw new Error(`HTTP ${res.status}`);
483
- return { ok: true };
484
- }
485
- const text = await res.text();
486
- if (!res.ok) {
487
- // 403 with no credentials sent at all is the flair#634 case: a gated
488
- // resource (e.g. #632's FederationInstance/FederationPeers) rejected a
489
- // credential-less call. Name the fix instead of surfacing the raw
490
- // "forbidden" body — never a stack trace.
491
- if (res.status === 403 && !authHeader) {
492
- const hint = isLocal
493
- ? "Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass."
494
- : "Set FLAIR_ADMIN_PASS (remote targets have no local admin-pass fallback).";
495
- throw new Error(`HTTP 403: no credentials sent. ${hint}`);
496
- }
497
- throw new Error(text || `HTTP ${res.status}`);
498
- }
499
- if (!text)
500
- return { ok: true };
501
- return JSON.parse(text);
502
- }
503
- /** Find the agent's private key file from standard locations. */
504
- function resolveKeyPath(agentId) {
505
- const candidates = [
506
- process.env.FLAIR_KEY_DIR ? join(process.env.FLAIR_KEY_DIR, `${agentId}.key`) : null,
507
- join(homedir(), ".flair", "keys", `${agentId}.key`),
508
- join(homedir(), ".tps", "secrets", "flair", `${agentId}-priv.key`),
509
- ].filter(Boolean);
510
- return candidates.find((p) => existsSync(p)) ?? null;
511
- }
512
- /** Build a TPS-Ed25519 auth header from a raw 32-byte seed on disk. */
513
- function buildEd25519Auth(agentId, method, path, keyPath) {
514
- const raw = readFileSync(keyPath);
515
- const pkcs8Header = Buffer.from("302e020100300506032b657004220420", "hex");
516
- let privKey;
517
- if (raw.length === 32) {
518
- // Raw 32-byte seed
519
- privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, raw]), format: "der", type: "pkcs8" });
520
- }
521
- else {
522
- // Try as base64-encoded PKCS8 DER (standard Flair key format)
523
- const decoded = Buffer.from(raw.toString("utf-8").trim(), "base64");
524
- if (decoded.length === 32) {
525
- // Base64-encoded raw seed
526
- privKey = createPrivateKey({ key: Buffer.concat([pkcs8Header, decoded]), format: "der", type: "pkcs8" });
527
- }
528
- else {
529
- // Full PKCS8 DER or PEM
530
- try {
531
- privKey = createPrivateKey({ key: decoded, format: "der", type: "pkcs8" });
532
- }
533
- catch {
534
- privKey = createPrivateKey(raw);
535
- }
536
- }
537
- }
538
- const ts = Date.now().toString();
539
- const nonce = randomUUID();
540
- const payload = `${agentId}:${ts}:${nonce}:${method}:${path}`;
541
- const sig = nodeCryptoSign(null, Buffer.from(payload), privKey).toString("base64");
542
- return `TPS-Ed25519 ${agentId}:${ts}:${nonce}:${sig}`;
543
- }
544
- /** Authenticated fetch against Flair using Ed25519. */
545
- async function authFetch(baseUrl, agentId, keyPath, method, path, body) {
546
- const auth = buildEd25519Auth(agentId, method, path, keyPath);
547
- const headers = { Authorization: auth };
548
- if (body !== undefined)
549
- headers["Content-Type"] = "application/json";
550
- return fetch(`${baseUrl}${path}`, {
551
- method,
552
- headers,
553
- body: body !== undefined ? JSON.stringify(body) : undefined,
554
- });
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 });
739
+ }
740
+ /**
741
+ * The authedGet `flair upgrade` verification (flair#635/#741) hands to
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).
747
+ *
748
+ * Auth requirement of the verification target itself: /HealthDetail
749
+ * (probeInstance's default versionPath) is NOT admin-gated its
750
+ * `allowRead()` is `allowVerified()` (resources/health.ts, resources/agent-
751
+ * auth.ts), which permits ANY registered agent, not just admins. So a
752
+ * signed request from any registered agent's key is sufficient; there's no
753
+ * need to special-case a different, more-public endpoint.
754
+ */
755
+ export async function verifyAuthedGet(baseUrl, path, keysDir) {
756
+ return api("GET", path, undefined, { baseUrl, keysDir });
757
+ }
758
+ /**
759
+ * flair#741 fix #3: does this ProbeResult's failure mean "the server
760
+ * responded but rejected the verifier's credentials" — proof of liveness,
761
+ * NOT a data-integrity risk — as opposed to a genuine down/unreachable/5xx
762
+ * failure where the instance's real state can't be determined? Pure.
763
+ *
764
+ * This is the single predicate behind three call sites in the `upgrade`
765
+ * command: whether the pre-upgrade credential pre-flight aborts (fix #1),
766
+ * and whether the post-restart / post-rollback verification failure
767
+ * messages claim "instance state UNKNOWN do not assume data integrity"
768
+ * (they must NOT, for this case that's the flair#741 incident itself) or
769
+ * explain the real, much less scary situation instead.
770
+ */
771
+ export function isCredentialOnlyFailure(result) {
772
+ return result.healthy === true && result.authFailureKind === "credentials";
555
773
  }
556
774
  async function waitForHealth(httpPort, adminUser, adminPass, timeoutMs) {
557
775
  const url = `http://127.0.0.1:${httpPort}/Health`;
@@ -1663,6 +1881,7 @@ program
1663
1881
  .option("--agent <id>", "Alias for --agent-id")
1664
1882
  .option("--port <port>", "Harper HTTP port", String(DEFAULT_PORT))
1665
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)")
1666
1885
  .option("--admin-pass <pass>", "Admin password (generated if omitted)")
1667
1886
  .option("--admin-pass-file <path>", "Read admin password from file (chmod 600 recommended)")
1668
1887
  .option("--keys-dir <dir>", "Directory for Ed25519 keys")
@@ -1862,6 +2081,7 @@ program
1862
2081
  // ── Local init (full one-command setup) ──
1863
2082
  const httpPort = resolveHttpPort(opts);
1864
2083
  const opsPort = resolveOpsPort(opts);
2084
+ const opsBindHost = resolveOpsBindHost(opts);
1865
2085
  const keysDir = opts.keysDir ?? defaultKeysDir();
1866
2086
  const dataDir = opts.dataDir ?? defaultDataDir();
1867
2087
  // Resolve MCP client selection (union of init's auto-wire + the multi-client
@@ -1954,6 +2174,13 @@ program
1954
2174
  // the fresh-start path) since the launchd plist step needs it too, even
1955
2175
  // when Harper was already running and the fresh-spawn branch was skipped.
1956
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);
1957
2184
  if (!opts.skipStart) {
1958
2185
  // Check if already running
1959
2186
  try {
@@ -1980,10 +2207,13 @@ program
1980
2207
  // request is no longer auto-authorized as super_user. Every ops-API
1981
2208
  // seed call below (seedAgentViaOpsApi et al.) already passes a real
1982
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.
1983
2213
  const harperSetConfig = JSON.stringify({
1984
2214
  rootPath: dataDir,
1985
2215
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
1986
- operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
2216
+ operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
1987
2217
  mqtt: { network: { port: null }, webSocket: false },
1988
2218
  localStudio: { enabled: false },
1989
2219
  authentication: { authorizeLocal: false, enableSessions: true },
@@ -2064,22 +2294,46 @@ program
2064
2294
  console.log("Waiting for Harper health check...");
2065
2295
  await waitForHealth(httpPort, adminUser, adminPass, STARTUP_TIMEOUT_MS);
2066
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);
2067
2300
  // Register launchd service on macOS so Harper survives reboots
2068
2301
  // and `flair restart` / `flair stop` work via launchctl.
2069
2302
  if (process.platform === "darwin") {
2070
2303
  const harperBinPath = harperBin();
2071
2304
  if (harperBinPath) {
2072
- const label = "ai.tpsdev.flair";
2073
- const plistDir = join(homedir(), "Library", "LaunchAgents");
2305
+ const label = launchdLabel(dataDir);
2306
+ const plistDir = defaultLaunchAgentsDir();
2074
2307
  mkdirSync(plistDir, { recursive: true });
2075
- 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
+ }
2076
2328
  const opsSocket = join(dataDir, "operations-server");
2077
2329
  // authorizeLocal: false (flair#654) — same posture as the initial spawn
2078
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.
2079
2333
  const setConfig = JSON.stringify({
2080
2334
  rootPath: dataDir,
2081
2335
  http: { port: httpPort, cors: true, corsAccessList: [`http://127.0.0.1:${httpPort}`, `http://localhost:${httpPort}`] },
2082
- operationsApi: { network: { port: opsPort, cors: true }, domainSocket: opsSocket },
2336
+ operationsApi: buildOperationsApiConfig(opsPort, opsSocket, opsBindHost),
2083
2337
  mqtt: { network: { port: null }, webSocket: false },
2084
2338
  localStudio: { enabled: false },
2085
2339
  authentication: { authorizeLocal: false, enableSessions: true },
@@ -2281,7 +2535,12 @@ program
2281
2535
  type: "stdio",
2282
2536
  command: "npx",
2283
2537
  args: ["-y", "@tpsdev-ai/flair-mcp"],
2284
- env: mcpEnv,
2538
+ // flair#718 authorship-provenance: each client's wired env block
2539
+ // gets its OWN FLAIR_CLIENT label (never the shared mcpEnv
2540
+ // object directly — that would stamp the same label into every
2541
+ // client's config) so writes from THIS client's proxy stamp
2542
+ // provenance.claimed.client = "claude-code".
2543
+ env: { ...mcpEnv, FLAIR_CLIENT: "claude-code" },
2285
2544
  };
2286
2545
  try {
2287
2546
  if (existsSync(claudeJsonPath)) {
@@ -2337,15 +2596,18 @@ program
2337
2596
  }
2338
2597
  else {
2339
2598
  let result;
2599
+ // flair#718 authorship-provenance — see the claude-code branch's
2600
+ // identical comment above: FLAIR_CLIENT is per-client, so it's
2601
+ // added at each call site rather than baked into the shared mcpEnv.
2340
2602
  switch (clientId) {
2341
2603
  case "codex":
2342
- result = wireCodex(mcpEnv);
2604
+ result = wireCodex({ ...mcpEnv, FLAIR_CLIENT: "codex" });
2343
2605
  break;
2344
2606
  case "gemini":
2345
- result = wireGemini(mcpEnv);
2607
+ result = wireGemini({ ...mcpEnv, FLAIR_CLIENT: "gemini" });
2346
2608
  break;
2347
2609
  case "cursor":
2348
- result = wireCursor(mcpEnv);
2610
+ result = wireCursor({ ...mcpEnv, FLAIR_CLIENT: "cursor" });
2349
2611
  break;
2350
2612
  default: result = { ok: false, message: `Unknown client: ${clientId}` };
2351
2613
  }
@@ -2858,107 +3120,948 @@ agent
2858
3120
  }
2859
3121
  console.log(`\n✅ Agent '${id}' removed successfully`);
2860
3122
  });
2861
- // ─── flair mcp ───────────────────────────────────────────────────────────────
2862
- // Headless agent-auth to a Harper MCP `/mcp` endpoint: RFC 7523
2863
- // client_credentials + private_key_jwt, using the agent's EXISTING Ed25519
2864
- // identity key (no new key material, no browser, no human). The plugin side
2865
- // (HarperFast/oauth, parent issue #159) shipped the full chain in the
2866
- // published 2.2.0 release: assertion verification (#160/PR #165), CIMD-first
2867
- // client resolution (#161/#167), and the client_credentials token-endpoint
2868
- // grant + issuance rate limiting (#170/#171, closing #162/#163). `flair mcp
2869
- // token` signs the assertion and, by default, requests a real access token
2870
- // against the token endpoint; see src/mcp-client-assertion.ts.
2871
- const mcp = program.command("mcp").description("MCP client-credentials agent-auth (RFC 7523 private_key_jwt)");
3123
+ /** Best-effort seed-validity check for a `.key` file: does it parse via any
3124
+ * of the formats loadEd25519PrivateKeyFromFile (src/mcp-client-assertion.ts
3125
+ * — the same loader `flair mcp token` uses) accepts? Never throws — used
3126
+ * only to decide "invalid" vs. "worth a registration check", not to
3127
+ * actually sign anything. */
3128
+ function isValidPrivateKeySeedFile(keyPath) {
3129
+ try {
3130
+ loadEd25519PrivateKeyFromFile(keyPath);
3131
+ return true;
3132
+ }
3133
+ catch {
3134
+ return false;
3135
+ }
3136
+ }
3137
+ /**
3138
+ * Classify every entry in `keysDir` for `flair keys prune`. Pure READ —
3139
+ * never writes or moves anything; see applyKeyPrune below for the actual
3140
+ * move. Directories (including keysDir's own `.pruned` archive, PRUNED_DIR_NAME)
3141
+ * and files not ending in `.key` are "ignored" without any network call.
3142
+ * `.key` files with an unparseable seed are "invalid" without a network call
3143
+ * either — only a `.key` file that DOES parse triggers a signed
3144
+ * `GET /Agent/:id` against `baseUrl` (checkAgentRegistered above, the exact
3145
+ * same check doctor's registration gate uses).
3146
+ *
3147
+ * If that check EVER reports "unreachable" — the instance couldn't be
3148
+ * confirmed up for that key — the WHOLE run aborts immediately
3149
+ * (`aborted: true`, `entries: []`, short-circuiting the loop): never
3150
+ * classify anything, prunable or not, while registration state can't be
3151
+ * verified. A missing keysDir is treated as "nothing to classify" (fresh
3152
+ * install), not an error — matches `flair doctor`'s own "Keys directory
3153
+ * missing" being a separate, non-fatal finding.
3154
+ */
3155
+ export async function classifyKeysDir(keysDir, baseUrl) {
3156
+ if (!existsSync(keysDir))
3157
+ return { aborted: false, entries: [] };
3158
+ const dirents = readdirSync(keysDir, { withFileTypes: true });
3159
+ const entries = [];
3160
+ const candidates = [];
3161
+ for (const d of dirents) {
3162
+ if (d.isDirectory()) {
3163
+ entries.push({
3164
+ name: d.name,
3165
+ class: "ignored",
3166
+ reason: d.name === PRUNED_DIR_NAME ? "prune archive directory" : "directory (not a key file)",
3167
+ });
3168
+ continue;
3169
+ }
3170
+ if (!d.name.endsWith(".key")) {
3171
+ entries.push({ name: d.name, class: "ignored", reason: "not a .key file" });
3172
+ continue;
3173
+ }
3174
+ candidates.push({ name: d.name, agentId: d.name.slice(0, -".key".length) });
3175
+ }
3176
+ for (const c of candidates) {
3177
+ const keyPath = join(keysDir, c.name);
3178
+ if (!isValidPrivateKeySeedFile(keyPath)) {
3179
+ const decision = classifyKeyFile(c.agentId, false, null, baseUrl);
3180
+ entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
3181
+ continue;
3182
+ }
3183
+ const reg = await checkAgentRegistered(baseUrl, c.agentId, keysDir);
3184
+ if (reg.state === "unreachable") {
3185
+ return {
3186
+ aborted: true,
3187
+ abortReason: `could not reach ${baseUrl} to verify agent '${c.agentId}' is registered` +
3188
+ `${reg.detail ? ` (${reg.detail})` : ""} — aborting; nothing was classified or moved. ` +
3189
+ `Pass --instance <url> to target a different instance.`,
3190
+ entries: [],
3191
+ };
3192
+ }
3193
+ const decision = classifyKeyFile(c.agentId, true, { state: reg.state, detail: reg.detail }, baseUrl);
3194
+ entries.push({ name: c.name, class: decision.class, reason: decision.reason, agentId: c.agentId });
3195
+ }
3196
+ return { aborted: false, entries };
3197
+ }
3198
+ /**
3199
+ * Move every "stale" or "invalid" entry from `keysDir` into
3200
+ * `<keysDir>/.pruned/<dateStamp>/`, creating the archive dir as needed —
3201
+ * MOVE, never delete, so a bad classification is always recoverable. Only
3202
+ * ever called with entries classifyKeysDir already decided are prunable; a
3203
+ * "keep" or "ignored" entry passed in here is simply skipped (defense in
3204
+ * depth — a registered agent's key must never move, even if a caller bug
3205
+ * fed it in). Collisions (same filename already archived from an earlier
3206
+ * prune run today) get a numeric suffix (resolveCollisionSafeName) rather
3207
+ * than silently overwriting the earlier archive.
3208
+ */
3209
+ export function applyKeyPrune(keysDir, entries, dateStamp) {
3210
+ const prunable = entries.filter((e) => e.class === "stale" || e.class === "invalid");
3211
+ if (prunable.length === 0)
3212
+ return [];
3213
+ const destDir = join(keysDir, PRUNED_DIR_NAME, dateStamp);
3214
+ mkdirSync(destDir, { recursive: true });
3215
+ const existing = new Set(readdirSync(destDir));
3216
+ const moved = [];
3217
+ for (const e of prunable) {
3218
+ const destName = resolveCollisionSafeName(existing, e.name);
3219
+ existing.add(destName);
3220
+ const from = join(keysDir, e.name);
3221
+ const to = join(destDir, destName);
3222
+ renameSync(from, to);
3223
+ moved.push({ name: e.name, movedTo: to });
3224
+ }
3225
+ return moved;
3226
+ }
3227
+ const keys = program.command("keys").description("Manage Ed25519 key files in the key directory");
3228
+ keys
3229
+ .command("prune")
3230
+ .description("Move stale/unregistered/invalid keys to <keysDir>/.pruned/<date>/ — dry-run by default")
3231
+ .option("--apply", "Actually move prunable keys (default: dry-run, prints what would move and why)")
3232
+ .option("--keys-dir <dir>", "Directory to scan for key files (else FLAIR_KEY_DIR, ~/.flair/keys)")
3233
+ .option("--instance <url>", "Flair instance to check registration against (else FLAIR_TARGET/FLAIR_URL/config)")
3234
+ .option("--port <port>", "Harper HTTP port (used when --instance/FLAIR_URL/FLAIR_TARGET are not set)")
3235
+ .action(async (opts) => {
3236
+ const keysDir = opts.keysDir ?? process.env.FLAIR_KEY_DIR ?? defaultKeysDir();
3237
+ const baseUrl = resolveBaseUrl({ target: opts.instance, port: opts.port });
3238
+ const apply = !!opts.apply;
3239
+ console.log(`\n${render.wrap(render.c.bold, "🔑 Flair Keys Prune")}${apply ? "" : render.wrap(render.c.dim, " (dry run)")}\n`);
3240
+ console.log(` Keys directory: ${render.wrap(render.c.dim, keysDir)}`);
3241
+ console.log(` Instance: ${render.wrap(render.c.dim, baseUrl)}\n`);
3242
+ const result = await classifyKeysDir(keysDir, baseUrl);
3243
+ if (result.aborted) {
3244
+ console.error(` ${render.icons.error} ${result.abortReason}`);
3245
+ console.log("");
3246
+ process.exit(1);
3247
+ }
3248
+ const stale = result.entries.filter((e) => e.class === "stale");
3249
+ const invalid = result.entries.filter((e) => e.class === "invalid");
3250
+ const kept = result.entries.filter((e) => e.class === "keep");
3251
+ const ignored = result.entries.filter((e) => e.class === "ignored");
3252
+ const prunable = [...stale, ...invalid];
3253
+ if (stale.length + invalid.length + kept.length === 0) {
3254
+ console.log(` ${render.icons.ok} No key files found in ${render.wrap(render.c.dim, keysDir)} — nothing to prune.`);
3255
+ console.log("");
3256
+ return;
3257
+ }
3258
+ for (const e of prunable) {
3259
+ const icon = e.class === "invalid" ? render.icons.error : render.icons.warn;
3260
+ console.log(` ${icon} ${render.wrap(render.c.bold, e.name)} — ${e.class}: ${e.reason}`);
3261
+ }
3262
+ for (const e of kept) {
3263
+ console.log(` ${render.icons.ok} ${e.name} — registered, keeping`);
3264
+ }
3265
+ if (!apply) {
3266
+ console.log("");
3267
+ console.log(` ${render.wrap(render.c.dim, `${prunable.length} prunable (${stale.length} stale, ${invalid.length} invalid), ${kept.length} kept, ${ignored.length} ignored`)}`);
3268
+ if (prunable.length > 0) {
3269
+ console.log(` ${render.wrap(render.c.dim, "Run with --apply to move prunable keys to")} ${join(keysDir, PRUNED_DIR_NAME, pruneDateStamp())}`);
3270
+ }
3271
+ console.log("");
3272
+ return;
3273
+ }
3274
+ const moved = applyKeyPrune(keysDir, result.entries, pruneDateStamp());
3275
+ console.log("");
3276
+ for (const m of moved) {
3277
+ console.log(` ${render.icons.ok} moved ${m.name} -> ${m.movedTo}`);
3278
+ }
3279
+ console.log(`\n ${render.wrap(render.c.bold, String(moved.length))} moved, ${kept.length} kept, ${ignored.length} ignored\n`);
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
+ });
3396
+ // ─── flair mcp ───────────────────────────────────────────────────────────────
3397
+ // Headless agent-auth to a Harper MCP `/mcp` endpoint: RFC 7523
3398
+ // client_credentials + private_key_jwt, using the agent's EXISTING Ed25519
3399
+ // identity key (no new key material, no browser, no human). The plugin side
3400
+ // (HarperFast/oauth, parent issue #159) shipped the full chain in the
3401
+ // published 2.2.0 release: assertion verification (#160/PR #165), CIMD-first
3402
+ // client resolution (#161/#167), and the client_credentials token-endpoint
3403
+ // grant + issuance rate limiting (#170/#171, closing #162/#163). `flair mcp
3404
+ // token` signs the assertion and, by default, requests a real access token
3405
+ // against the token endpoint; see src/mcp-client-assertion.ts.
3406
+ const mcp = program.command("mcp").description("MCP client-credentials agent-auth (RFC 7523 private_key_jwt)");
3407
+ mcp
3408
+ .command("token")
3409
+ .description("Build + sign an RFC 7523 client_assertion and request an MCP client_credentials " +
3410
+ "access token. Caches the minted token (in-process) and reuses it until " +
3411
+ "near-expiry — use --force-refresh to mint unconditionally.")
3412
+ .requiredOption("--agent-id <id>", "Agent id — becomes the client_id (iss/sub claims)")
3413
+ .option("--client-id <url>", "Client ID Metadata Document URL for this agent (defaults to this instance's " +
3414
+ "MCPClientMetadata URL, derived from FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
3415
+ .option("--token-endpoint <url>", "Token-endpoint URL — becomes the `aud` claim (defaults to this instance's " +
3416
+ "own oauth token endpoint, same env vars)")
3417
+ .option("--resource <url>", "RFC 8707 resource indicator for the token request (defaults to this instance's canonical /mcp URI)")
3418
+ .option("--keys-dir <dir>", "Directory to look for <agentId>.key (else FLAIR_KEY_DIR, ~/.flair/keys, ~/.tps/secrets/flair)")
3419
+ .option("--expires-in <seconds>", `Assertion exp - iat window, seconds (default + hard cap: ${MAX_ASSERTION_LIFETIME_SECONDS})`)
3420
+ .option("--dry-run", "Sign the assertion and print what would be sent, but do not call the token endpoint")
3421
+ .option("--force-refresh", "Mint a fresh token even if a cached, not-near-expiry one exists")
3422
+ .option("--json", "Print machine-readable JSON instead of a human summary")
3423
+ .action(async (opts) => {
3424
+ const agentId = opts.agentId;
3425
+ const keyPath = resolveAgentKeyPath(agentId, opts.keysDir);
3426
+ if (!keyPath) {
3427
+ console.error(`Error: no private key found for agent '${agentId}'. Checked --keys-dir, FLAIR_KEY_DIR, ` +
3428
+ `~/.flair/keys, and ~/.tps/secrets/flair.`);
3429
+ process.exit(1);
3430
+ }
3431
+ const clientId = opts.clientId ?? defaultMcpClientId(agentId);
3432
+ if (!clientId) {
3433
+ console.error("Error: --client-id is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive it from " +
3434
+ "this instance's MCPClientMetadata URL).");
3435
+ process.exit(1);
3436
+ }
3437
+ const tokenEndpoint = opts.tokenEndpoint ?? defaultMcpTokenEndpoint();
3438
+ if (!tokenEndpoint) {
3439
+ console.error("Error: --token-endpoint is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive " +
3440
+ "this instance's own oauth token endpoint).");
3441
+ process.exit(1);
3442
+ }
3443
+ const resource = opts.resource ?? defaultMcpResource();
3444
+ const expiresIn = opts.expiresIn ? Number(opts.expiresIn) : undefined;
3445
+ let privateKey;
3446
+ try {
3447
+ privateKey = loadEd25519PrivateKeyFromFile(keyPath);
3448
+ }
3449
+ catch (err) {
3450
+ console.error(`Error: failed to load private key at ${keyPath}: ${err?.message ?? err}`);
3451
+ process.exit(1);
3452
+ }
3453
+ if (opts.dryRun) {
3454
+ const { assertion, claims } = signClientAssertion({
3455
+ clientId,
3456
+ tokenEndpoint,
3457
+ privateKey,
3458
+ expiresInSeconds: expiresIn,
3459
+ });
3460
+ const form = buildTokenRequestForm({ clientId, assertion, resource });
3461
+ if (opts.json) {
3462
+ console.log(JSON.stringify({ assertion, claims, wouldSendForm: form, tokenEndpoint }, null, 2));
3463
+ return;
3464
+ }
3465
+ console.log(`client_assertion (RFC 7523, EdDSA):\n\n${assertion}\n`);
3466
+ console.log(`claims: iss=sub=${claims.iss} aud=${claims.aud} exp-iat=${claims.exp - claims.iat}s jti=${claims.jti}`);
3467
+ console.log(`\n--dry-run: NOT sent. This assertion is the client_assertion value for:\n` +
3468
+ ` POST ${tokenEndpoint}\n ${JSON.stringify(form, null, 2).split("\n").join("\n ")}`);
3469
+ return;
3470
+ }
3471
+ try {
3472
+ const token = await getMcpAccessToken({
3473
+ clientId,
3474
+ tokenEndpoint,
3475
+ privateKey,
3476
+ resource,
3477
+ expiresInSeconds: expiresIn,
3478
+ forceRefresh: Boolean(opts.forceRefresh),
3479
+ });
3480
+ if (opts.json) {
3481
+ console.log(JSON.stringify(token, null, 2));
3482
+ return;
3483
+ }
3484
+ console.log(`access_token minted (${token.tokenType}, expires_in=${token.expiresIn}s):\n\n${token.accessToken}`);
3485
+ if (token.scope)
3486
+ console.log(`\nscope: ${token.scope}`);
3487
+ }
3488
+ catch (err) {
3489
+ if (err instanceof McpTokenRequestError) {
3490
+ console.error(`Error: token request failed (HTTP ${err.status}${err.error ? ` ${err.error}` : ""}): ${err.message}`);
3491
+ }
3492
+ else {
3493
+ console.error(`Error: token request failed: ${err?.message ?? err}`);
3494
+ }
3495
+ process.exit(1);
3496
+ }
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
+ }
2872
3901
  mcp
2873
- .command("token")
2874
- .description("Build + sign an RFC 7523 client_assertion and request an MCP client_credentials " +
2875
- "access token. Caches the minted token (in-process) and reuses it until " +
2876
- "near-expiry use --force-refresh to mint unconditionally.")
2877
- .requiredOption("--agent-id <id>", "Agent id becomes the client_id (iss/sub claims)")
2878
- .option("--client-id <url>", "Client ID Metadata Document URL for this agent (defaults to this instance's " +
2879
- "MCPClientMetadata URL, derived from FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL)")
2880
- .option("--token-endpoint <url>", "Token-endpoint URL becomes the `aud` claim (defaults to this instance's " +
2881
- "own oauth token endpoint, same env vars)")
2882
- .option("--resource <url>", "RFC 8707 resource indicator for the token request (defaults to this instance's canonical /mcp URI)")
2883
- .option("--keys-dir <dir>", "Directory to look for <agentId>.key (else FLAIR_KEY_DIR, ~/.flair/keys, ~/.tps/secrets/flair)")
2884
- .option("--expires-in <seconds>", `Assertion exp - iat window, seconds (default + hard cap: ${MAX_ASSERTION_LIFETIME_SECONDS})`)
2885
- .option("--dry-run", "Sign the assertion and print what would be sent, but do not call the token endpoint")
2886
- .option("--force-refresh", "Mint a fresh token even if a cached, not-near-expiry one exists")
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")
2887
3921
  .option("--json", "Print machine-readable JSON instead of a human summary")
2888
3922
  .action(async (opts) => {
2889
- const agentId = opts.agentId;
2890
- const keyPath = resolveAgentKeyPath(agentId, opts.keysDir);
2891
- if (!keyPath) {
2892
- console.error(`Error: no private key found for agent '${agentId}'. Checked --keys-dir, FLAIR_KEY_DIR, ` +
2893
- `~/.flair/keys, and ~/.tps/secrets/flair.`);
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.");
2894
3926
  process.exit(1);
2895
3927
  }
2896
- const clientId = opts.clientId ?? defaultMcpClientId(agentId);
2897
- if (!clientId) {
2898
- console.error("Error: --client-id is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive it from " +
2899
- "this instance's MCPClientMetadata URL).");
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}`);
2900
3934
  process.exit(1);
2901
3935
  }
2902
- const tokenEndpoint = opts.tokenEndpoint ?? defaultMcpTokenEndpoint();
2903
- if (!tokenEndpoint) {
2904
- console.error("Error: --token-endpoint is required (or set FLAIR_MCP_ISSUER/FLAIR_PUBLIC_URL to derive " +
2905
- "this instance's own oauth token endpoint).");
2906
- process.exit(1);
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;
2907
3989
  }
2908
- const resource = opts.resource ?? defaultMcpResource();
2909
- const expiresIn = opts.expiresIn ? Number(opts.expiresIn) : undefined;
2910
- let privateKey;
2911
- try {
2912
- privateKey = loadEd25519PrivateKeyFromFile(keyPath);
3990
+ printEnableSteps(result);
3991
+ if (result.refused) {
3992
+ process.exit(1);
2913
3993
  }
2914
- catch (err) {
2915
- console.error(`Error: failed to load private key at ${keyPath}: ${err?.message ?? err}`);
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).`);
2916
3996
  process.exit(1);
2917
3997
  }
2918
- if (opts.dryRun) {
2919
- const { assertion, claims } = signClientAssertion({
2920
- clientId,
2921
- tokenEndpoint,
2922
- privateKey,
2923
- expiresInSeconds: expiresIn,
2924
- });
2925
- const form = buildTokenRequestForm({ clientId, assertion, resource });
2926
- if (opts.json) {
2927
- console.log(JSON.stringify({ assertion, claims, wouldSendForm: form, tokenEndpoint }, null, 2));
2928
- return;
2929
- }
2930
- console.log(`client_assertion (RFC 7523, EdDSA):\n\n${assertion}\n`);
2931
- console.log(`claims: iss=sub=${claims.iss} aud=${claims.aud} exp-iat=${claims.exp - claims.iat}s jti=${claims.jti}`);
2932
- console.log(`\n--dry-run: NOT sent. This assertion is the client_assertion value for:\n` +
2933
- ` POST ${tokenEndpoint}\n ${JSON.stringify(form, null, 2).split("\n").join("\n ")}`);
3998
+ if (result.dryRun) {
3999
+ console.log(`${render.icons.info} ${render.wrap(render.c.dim, "dry-run: no remote calls were made.")}`);
2934
4000
  return;
2935
4001
  }
2936
- try {
2937
- const token = await getMcpAccessToken({
2938
- clientId,
2939
- tokenEndpoint,
2940
- privateKey,
2941
- resource,
2942
- expiresInSeconds: expiresIn,
2943
- forceRefresh: Boolean(opts.forceRefresh),
2944
- });
2945
- if (opts.json) {
2946
- console.log(JSON.stringify(token, null, 2));
2947
- return;
2948
- }
2949
- console.log(`access_token minted (${token.tokenType}, expires_in=${token.expiresIn}s):\n\n${token.accessToken}`);
2950
- if (token.scope)
2951
- console.log(`\nscope: ${token.scope}`);
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);
2952
4018
  }
2953
- catch (err) {
2954
- if (err instanceof McpTokenRequestError) {
2955
- console.error(`Error: token request failed (HTTP ${err.status}${err.error ? ` ${err.error}` : ""}): ${err.message}`);
2956
- }
2957
- else {
2958
- console.error(`Error: token request failed: ${err?.message ?? err}`);
2959
- }
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).");
2960
4047
  process.exit(1);
2961
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("");
2962
4065
  });
2963
4066
  // ─── flair principal ─────────────────────────────────────────────────────────
2964
4067
  // 1.0 identity management. The Principal model extends Agent — this is the
@@ -5649,36 +6752,24 @@ async function fetchHealthDetail(opts) {
5649
6752
  }
5650
6753
  catch { /* unreachable */ }
5651
6754
  if (healthy) {
5652
- const agentId = opts.agent || process.env.FLAIR_AGENT_ID;
5653
- if (agentId) {
5654
- const keyPath = resolveKeyPath(agentId);
5655
- if (keyPath) {
5656
- try {
5657
- const authHeader = buildEd25519Auth(agentId, "GET", "/HealthDetail", keyPath);
5658
- const res = await fetch(`${baseUrl}/HealthDetail`, {
5659
- headers: { Authorization: authHeader },
5660
- signal: AbortSignal.timeout(5000),
5661
- });
5662
- if (res.ok)
5663
- healthData = await res.json().catch(() => null);
5664
- }
5665
- catch { /* fall through */ }
5666
- }
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
+ });
5667
6769
  }
5668
- if (!healthData) {
5669
- const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS;
5670
- if (adminPass) {
5671
- try {
5672
- const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
5673
- const res = await fetch(`${baseUrl}/HealthDetail`, {
5674
- headers: { Authorization: auth },
5675
- signal: AbortSignal.timeout(5000),
5676
- });
5677
- if (res.ok)
5678
- healthData = await res.json().catch(() => null);
5679
- }
5680
- catch { /* fall through */ }
5681
- }
6770
+ catch {
6771
+ // No credential tier resolved a verified read — healthData stays
6772
+ // null; callers already render an "unauthenticated" / limited view.
5682
6773
  }
5683
6774
  }
5684
6775
  return { healthy, baseUrl, healthData };
@@ -7017,6 +8108,66 @@ program
7017
8108
  // `opts` — safe to call this early.
7018
8109
  const { restart: shouldRestart, verify: shouldVerify, deprecatedRestartFlagUsed } = resolveUpgradeRestartVerify(opts);
7019
8110
  const upgradePort = resolveHttpPort({});
8111
+ // Hoisted so the pre-flight check (below) and the post-restart/rollback
8112
+ // verification steps (further down) all target the same URL — upgrade
8113
+ // never restarts Flair onto a different port.
8114
+ const baseUrl = `http://127.0.0.1:${upgradePort}`;
8115
+ // ── Credential pre-flight (flair#741 fix #1) ────────────────────────────
8116
+ // Post-restart verification (below) needs to authenticate against the
8117
+ // running instance. If it can't do that RIGHT NOW, against the CURRENT,
8118
+ // pre-upgrade instance, every upgrade on this machine is structurally
8119
+ // doomed before a single package is touched: post-restart verify fails
8120
+ // for the exact same credential reason, the rollback fires, and the
8121
+ // rollback's own re-verify fails identically — producing "ROLLBACK ALSO
8122
+ // FAILED VERIFICATION / state UNKNOWN" for an instance that was healthy
8123
+ // the entire time. That is exactly the flair#741 incident report (a
8124
+ // real 0.22.0→0.22.1 upgrade, healthy Flair, no ~/.flair/admin-pass, no
8125
+ // FLAIR_ADMIN_PASS). Catch it here, before any mutation, with a message
8126
+ // that says plainly: nothing was touched.
8127
+ //
8128
+ // Runs the SAME verification call (probeInstance + the agent-key-aware
8129
+ // verifyAuthedGet, fix #2) that post-restart verification uses below —
8130
+ // just against the pre-upgrade instance, with no expectVersion (there's
8131
+ // no target version to compare against yet; the question here is purely
8132
+ // "does an authenticated read work at all").
8133
+ //
8134
+ // Gated on --verify (shouldVerify): this check exists ONLY to keep
8135
+ // post-restart verification honest. A user who already opted out of
8136
+ // that verification with --no-verify has no use for a pre-flight that
8137
+ // protects it, and blocking their upgrade on a check they didn't ask
8138
+ // for would be a new, surprising failure mode of its own.
8139
+ //
8140
+ // Deliberately does NOT abort when the pre-flight instance is merely
8141
+ // UNREACHABLE (down/timeout) rather than reachable-but-unauthenticated.
8142
+ // `flair upgrade` may be the user's way of FIXING a down instance (bad
8143
+ // code on disk that a newer version resolves) — today's behavior
8144
+ // (pre-flair#741, no pre-flight at all) already lets that proceed, and
8145
+ // a new hard block here would take away a legitimate recovery path for
8146
+ // a failure mode this issue was never about. Only the specific
8147
+ // "server responded, credentials didn't work" case is structurally
8148
+ // doomed in a way a fresh install/restart can't fix on its own — so
8149
+ // only that case aborts. (If a down instance turns out to ALSO lack
8150
+ // credentials, that surfaces the normal way: post-restart verification
8151
+ // fails and rolls back, same as any other post-restart failure.)
8152
+ if (shouldVerify) {
8153
+ const preflight = await probeInstance(baseUrl, {
8154
+ // A short, bounded budget — this instance is presumed already
8155
+ // running (upgrade's normal case); doctor's probePort convention
8156
+ // (probeFlairReachable's doc comment) uses the same ~3s ballpark
8157
+ // for "is anything there at all" checks.
8158
+ timeoutMs: 3000,
8159
+ pollIntervalMs: 300,
8160
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
8161
+ });
8162
+ if (isCredentialOnlyFailure(preflight)) {
8163
+ console.error(`❌ pre-flight check failed: ${preflight.error}`);
8164
+ console.error(" Nothing has been touched — no packages were installed, no restart happened.");
8165
+ console.error(" The current instance is up and responded; the verifier just has no way to authenticate against it.");
8166
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key — then re-run flair upgrade.");
8167
+ console.error(" (--no-verify skips this check too, but post-restart verification would then fail the exact same way.)");
8168
+ process.exit(1);
8169
+ }
8170
+ }
7020
8171
  // ── Pre-upgrade data snapshot (flair#637, opt-in as of the 2026-07-08 rewire) ──
7021
8172
  // Only an @tpsdev-ai/flair package swap touches the code that reads/
7022
8173
  // writes ~/.flair/data — an flair-mcp-only or openclaw-plugin-only
@@ -7170,7 +8321,7 @@ program
7170
8321
  }
7171
8322
  console.log("\nRestarting Flair...");
7172
8323
  const port = upgradePort;
7173
- const baseUrl = `http://127.0.0.1:${port}`;
8324
+ // baseUrl was hoisted above (pre-flight, fix #1) — same URL, no redeclaration.
7174
8325
  try {
7175
8326
  await restartFlair(port);
7176
8327
  }
@@ -7185,13 +8336,16 @@ program
7185
8336
  return;
7186
8337
  }
7187
8338
  console.log("\nVerifying...");
7188
- // The authenticated leg dogfoods api()'s local-credential resolution
7189
- // (flair#640: env > agent key > ~/.flair/admin-pass file) — probeInstance
7190
- // itself never resolves credentials, it just calls whatever's handed to it.
8339
+ // The authenticated leg reuses verifyAuthedGet (flair#741 fix #2): api()'s
8340
+ // local-credential resolution (flair#640: env > agent key when an agentId
8341
+ // is already known > ~/.flair/admin-pass file), PLUS an Ed25519 agent-key
8342
+ // fallback when none of that resolves anything — see verifyAuthedGet's
8343
+ // doc comment. probeInstance itself never resolves credentials, it just
8344
+ // calls whatever's handed to it.
7191
8345
  const verify = await probeInstance(baseUrl, {
7192
8346
  expectVersion: expectedFlairVersion ?? undefined,
7193
8347
  timeoutMs: STARTUP_TIMEOUT_MS,
7194
- authedGet: (path) => api("GET", path, undefined, { baseUrl }),
8348
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
7195
8349
  });
7196
8350
  const verdict = decideAfterVerify(verify, previousFlairVersion);
7197
8351
  if (verdict.kind === "ok") {
@@ -7199,6 +8353,17 @@ program
7199
8353
  return;
7200
8354
  }
7201
8355
  console.error(`❌ post-restart verification failed: ${verdict.reason}`);
8356
+ if (isCredentialOnlyFailure(verify)) {
8357
+ // flair#741 fix #3: a responding server that rejects the verifier's
8358
+ // credentials proves the instance is UP — it is not evidence the
8359
+ // upgrade itself broke anything. Say so explicitly instead of leaving
8360
+ // the reader to infer it from the raw error text. Rollback still
8361
+ // proceeds below (a credential check that worked pre-upgrade — see
8362
+ // the pre-flight above — and stopped working mid-run is an edge case
8363
+ // ambiguous enough that "prefer the known-good version" is still the
8364
+ // safer default), but the reason for it is now honest.
8365
+ console.error(" The instance is up and responded — the verifier could not authenticate. This is not a sign the upgrade broke anything.");
8366
+ }
7202
8367
  if (verdict.kind === "cannot-rollback") {
7203
8368
  console.error(" Cannot roll back automatically: the previously-installed @tpsdev-ai/flair version is unknown.");
7204
8369
  console.error(" Check the instance now: flair doctor");
@@ -7224,7 +8389,7 @@ program
7224
8389
  const rollbackVerify = await probeInstance(baseUrl, {
7225
8390
  expectVersion: verdict.toVersion,
7226
8391
  timeoutMs: STARTUP_TIMEOUT_MS,
7227
- authedGet: (path) => api("GET", path, undefined, { baseUrl }),
8392
+ authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
7228
8393
  });
7229
8394
  const rollbackVerdict = decideAfterRollbackVerify(rollbackVerify);
7230
8395
  if (rollbackVerdict.kind === "rolled-back") {
@@ -7233,7 +8398,22 @@ program
7233
8398
  process.exit(1);
7234
8399
  }
7235
8400
  console.error(`❌❌ ROLLBACK ALSO FAILED VERIFICATION: ${rollbackVerdict.reason}`);
7236
- console.error(" Instance state is UNKNOWNdo not assume data integrity.");
8401
+ // flair#741 fix #3: this is the exact incident report a 403 from a
8402
+ // responding, healthy server (credentials-only failure) was printed as
8403
+ // "state UNKNOWN — do not assume data integrity" for BOTH the upgrade
8404
+ // verify AND the rollback re-verify, because the same missing-auth-
8405
+ // material condition rejects both. Reserve the UNKNOWN/do-not-assume
8406
+ // text for failures where the instance's real state genuinely can't be
8407
+ // determined (connection refused, timeout, 5xx) — a credential-only
8408
+ // failure here means the rollback likely landed fine and the checker
8409
+ // simply can't prove it.
8410
+ if (isCredentialOnlyFailure(rollbackVerify)) {
8411
+ console.error(" The instance is up and responding — the verifier could not authenticate (credentials, not the rollback, are the problem).");
8412
+ console.error(" Set FLAIR_ADMIN_PASS, or run `flair init` to provision ~/.flair/admin-pass or an agent key, then check: flair doctor");
8413
+ }
8414
+ else {
8415
+ console.error(" Instance state is UNKNOWN — do not assume data integrity.");
8416
+ }
7237
8417
  // This double-failure isn't auto-recoverable yet (flair#637) — but if a
7238
8418
  // pre-upgrade snapshot landed, point at the CONCRETE path instead of
7239
8419
  // just the issue number, so recovery doesn't start with a GitHub search.
@@ -7256,9 +8436,10 @@ program
7256
8436
  const port = resolveHttpPort(opts);
7257
8437
  const platform = process.platform;
7258
8438
  if (platform === "darwin") {
7259
- // macOS: try launchd first
7260
- const label = "ai.tpsdev.flair";
7261
- 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());
7262
8443
  if (existsSync(plistPath)) {
7263
8444
  try {
7264
8445
  const { execSync } = await import("node:child_process");
@@ -7313,17 +8494,19 @@ program
7313
8494
  }
7314
8495
  const platform = process.platform;
7315
8496
  if (platform === "darwin") {
7316
- const label = "ai.tpsdev.flair";
7317
- 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);
7318
8502
  if (existsSync(plistPath)) {
7319
8503
  try {
7320
8504
  const { execSync } = await import("node:child_process");
7321
- try {
7322
- execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
7323
- }
7324
- catch { }
7325
- 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} ✓`);
7326
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
7327
8510
  console.log("✅ Flair started (launchd)");
7328
8511
  return;
7329
8512
  }
@@ -7340,6 +8523,14 @@ program
7340
8523
  }
7341
8524
  const adminPass = process.env.HDB_ADMIN_PASSWORD || process.env.FLAIR_ADMIN_PASS || "";
7342
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({});
7343
8534
  const modelsDir = process.env.FLAIR_MODELS_DIR ?? join(dataDir, "models");
7344
8535
  const env = {
7345
8536
  ...process.env,
@@ -7349,7 +8540,7 @@ program
7349
8540
  DEFAULTS_MODE: "dev",
7350
8541
  HDB_ADMIN_USERNAME: DEFAULT_ADMIN_USER,
7351
8542
  HTTP_PORT: String(port),
7352
- OPERATIONSAPI_NETWORK_PORT: String(opsPort),
8543
+ OPERATIONSAPI_NETWORK_PORT: `${opsBindHost}:${opsPort}`,
7353
8544
  LOCAL_STUDIO: "false",
7354
8545
  };
7355
8546
  // Only set HDB_ADMIN_PASSWORD if we have a real value — empty string
@@ -7365,6 +8556,7 @@ program
7365
8556
  proc.unref();
7366
8557
  try {
7367
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
7368
8560
  console.log(`✅ Flair started on port ${port}`);
7369
8561
  }
7370
8562
  catch {
@@ -7387,8 +8579,12 @@ program
7387
8579
  */
7388
8580
  async function stopFlairProcess(port) {
7389
8581
  if (process.platform === "darwin") {
7390
- const label = "ai.tpsdev.flair";
7391
- 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);
7392
8588
  if (existsSync(plistPath)) {
7393
8589
  try {
7394
8590
  const { execSync } = await import("node:child_process");
@@ -7401,7 +8597,7 @@ async function stopFlairProcess(port) {
7401
8597
  // immediately restart can verify exit. Without this, waitForHealth
7402
8598
  // can race against the still-shutting-down old process and return
7403
8599
  // success before KeepAlive brings the new one up.
7404
- const oldPid = readHarperPid(defaultDataDir());
8600
+ const oldPid = readHarperPid(dataDir);
7405
8601
  try {
7406
8602
  execSync(`launchctl stop ${label}`, { stdio: "pipe" });
7407
8603
  }
@@ -7439,21 +8635,17 @@ async function stopFlairProcess(port) {
7439
8635
  * Counterpart to `stopFlairProcess`; see that function's doc comment.
7440
8636
  */
7441
8637
  async function startFlairProcess(port) {
8638
+ const dataDir = defaultDataDir();
7442
8639
  if (process.platform === "darwin") {
7443
- const label = "ai.tpsdev.flair";
7444
- 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);
7445
8643
  if (existsSync(plistPath)) {
7446
8644
  try {
7447
8645
  const { execSync } = await import("node:child_process");
7448
- try {
7449
- execSync(`launchctl load "${plistPath}"`, { stdio: "pipe" });
7450
- }
7451
- catch { }
7452
- try {
7453
- execSync(`launchctl start ${label}`, { stdio: "pipe" });
7454
- }
7455
- catch { }
8646
+ ensureLaunchdServiceLoaded(dataDir, (cmd) => execSync(cmd, { stdio: "pipe" }));
7456
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
7457
8649
  return;
7458
8650
  }
7459
8651
  catch (err) {
@@ -7466,7 +8658,6 @@ async function startFlairProcess(port) {
7466
8658
  if (!bin) {
7467
8659
  throw new Error("Harper binary not found. Run 'flair init' first.");
7468
8660
  }
7469
- const dataDir = defaultDataDir();
7470
8661
  // Match `flair start`: accept either HDB_ADMIN_PASSWORD or FLAIR_ADMIN_PASS.
7471
8662
  // Without this, `flair init --admin-pass X` (which only exports HDB_*
7472
8663
  // to the initial Harper spawn) followed by `flair restart` would silently
@@ -7493,6 +8684,7 @@ async function startFlairProcess(port) {
7493
8684
  });
7494
8685
  proc.unref();
7495
8686
  await waitForHealth(port, DEFAULT_ADMIN_USER, adminPass, STARTUP_TIMEOUT_MS);
8687
+ readyOpsSocketPosture(dataDir); // flair#763: re-assert socket posture across restart/upgrade
7496
8688
  }
7497
8689
  /**
7498
8690
  * The ONE restart mechanism for a local Flair install. Shared by `flair
@@ -7533,20 +8725,28 @@ program
7533
8725
  .action(async (opts) => {
7534
8726
  const platform = process.platform;
7535
8727
  const port = readPortFromConfig() ?? DEFAULT_PORT;
7536
- // 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).
7537
8733
  if (platform === "darwin") {
7538
- const label = "ai.tpsdev.flair";
7539
- const plistPath = join(homedir(), "Library", "LaunchAgents", `${label}.plist`);
7540
- if (existsSync(plistPath)) {
7541
- try {
7542
- const { execSync } = await import("node:child_process");
7543
- 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;
7544
8746
  }
7545
- catch { /* best effort */ }
7546
- const { unlinkSync } = await import("node:fs");
7547
- unlinkSync(plistPath);
7548
- console.log("✅ Launchd service removed");
7549
8747
  }
8748
+ if (removedAny)
8749
+ console.log("✅ Launchd service removed");
7550
8750
  }
7551
8751
  // Kill any process still on the port (covers direct-start, no-service, or failed unload)
7552
8752
  try {
@@ -8191,6 +9391,7 @@ program
8191
9391
  let issues = 0;
8192
9392
  let fixed = 0; // issues that --fix successfully resolved during this run (flair#721)
8193
9393
  let harperResponding = false;
9394
+ let keyAgentIds = []; // populated by step 2 (Keys directory) below; feeds the flair#722 per-agent iteration
8194
9395
  console.log(`\n${render.wrap(render.c.bold, "🩺 Flair Doctor")}\n`);
8195
9396
  // 0. Version check (flair#587) — offline-tolerant + cached, independent
8196
9397
  // of Harper being up. A gap of ≥2 minor versions (or any major) is
@@ -8386,6 +9587,7 @@ program
8386
9587
  if (existsSync(keysDir)) {
8387
9588
  const keyFiles = (await import("node:fs")).readdirSync(keysDir).filter((f) => f.endsWith(".key"));
8388
9589
  if (keyFiles.length > 0) {
9590
+ keyAgentIds = keyFiles.map((f) => f.replace(/\.key$/, ""));
8389
9591
  console.log(` ${render.icons.ok} Keys found: ${render.wrap(render.c.bold, String(keyFiles.length))} agent(s) in ${render.wrap(render.c.dim, keysDir)}`);
8390
9592
  }
8391
9593
  else {
@@ -8408,6 +9610,43 @@ program
8408
9610
  else {
8409
9611
  console.log(` ${render.icons.warn} No config file at ${render.wrap(render.c.dim, cfgPath)} — using defaults`);
8410
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 */ }
8411
9650
  // 4. Embeddings check — REAL semantic round-trip (only if Harper is responding).
8412
9651
  //
8413
9652
  // The dead-simple `{ q: "test" }` probe used to pass even when embeddings were
@@ -8537,7 +9776,7 @@ program
8537
9776
  console.log(` ${render.icons.warn} Cannot auto-wire ${client.label}: no agent id known — pass --agent <id>`);
8538
9777
  }
8539
9778
  else {
8540
- const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: block.flairUrl || baseUrl };
9779
+ const wireEnv = { FLAIR_AGENT_ID: fixAgentId, FLAIR_URL: resolveWireFlairUrl(block.flairUrl, baseUrl) };
8541
9780
  const wireResult = client.id === "claude-code" ? wireClaudeCode(wireEnv) :
8542
9781
  client.id === "codex" ? wireCodex(wireEnv) :
8543
9782
  client.id === "gemini" ? wireGemini(wireEnv) :
@@ -8637,6 +9876,54 @@ program
8637
9876
  }
8638
9877
  }
8639
9878
  }
9879
+ // 7a. Resolve which agent identities the two verified-read sections below
9880
+ // (Fleet presence, Migrations) iterate (flair#722). Previously both
9881
+ // sections required --agent explicitly; doctor already enumerates every
9882
+ // key in ~/.flair/keys (step 2 above), so by default it now runs the
9883
+ // signed read AS EACH of those agents instead of hiding behind a flag —
9884
+ // a real dogfood run found the #720 halted-migration warning visible via
9885
+ // `flair status --agent local` but invisible in the default `doctor` run
9886
+ // the same user ran minutes later. --agent <id> narrows this to exactly
9887
+ // that one identity (planAgentIterations — same pre-#722 semantics: a
9888
+ // single signed identity, just no longer widened to "every key").
9889
+ //
9890
+ // The registration gate (checkAgentRegistered — same signed GET
9891
+ // /Agent/:id used by the Client integration section above) is resolved
9892
+ // ONCE here per agent and shared by both sections, so a bad/unregistered
9893
+ // key doesn't cost two network round-trips, and its "found" count isn't
9894
+ // double-counted by each section re-discovering the same finding
9895
+ // (flair#721 found/fixed/remaining summary — these are found-only, no
9896
+ // --fix action exists for a bad local key). A gate failure for one agent
9897
+ // never aborts the others — that's the failure isolation flair#722 asks
9898
+ // for; describeAgentGateFinding (src/doctor-client.ts) is pure decision
9899
+ // logic so it's unit-tested without a real Harper.
9900
+ const verifiedReadAgentIds = harperResponding
9901
+ ? planAgentIterations(keyAgentIds, opts.agent || process.env.FLAIR_AGENT_ID)
9902
+ : [];
9903
+ const agentGates = [];
9904
+ for (const id of verifiedReadAgentIds) {
9905
+ const reg = await checkAgentRegistered(baseUrl, id, defaultKeysDir());
9906
+ agentGates.push({ id, state: reg.state, detail: reg.detail });
9907
+ const finding = describeAgentGateFinding(id, reg.state, reg.detail);
9908
+ if (finding?.isIssue)
9909
+ issues++;
9910
+ }
9911
+ // Shared renderer for one agent's registration-gate outcome — prints the
9912
+ // "Agent: <id>" subsection header, and if the gate isn't clean, the
9913
+ // finding (never re-counted here; already counted once above) and
9914
+ // returns false so the caller skips its own verified fetch for this
9915
+ // agent and moves on to the next (failure isolation).
9916
+ function renderAgentGateHeader(gate) {
9917
+ console.log(` ${render.wrap(render.c.dim, `Agent: ${gate.id}`)}`);
9918
+ const finding = describeAgentGateFinding(gate.id, gate.state, gate.detail);
9919
+ if (!finding)
9920
+ return true;
9921
+ const icon = finding.icon === "error" ? render.icons.error : render.icons.warn;
9922
+ console.log(` ${icon} ${finding.message}`);
9923
+ if (finding.fixHint)
9924
+ console.log(` ${render.wrap(render.c.dim, "Fix:")} ${finding.fixHint}`);
9925
+ return false;
9926
+ }
8640
9927
  // 8. Fleet presence (flair#639) — known instances via /Presence heartbeats.
8641
9928
  //
8642
9929
  // "Instance" here means each AGENT's heartbeat row — Presence is keyed by
@@ -8657,69 +9944,82 @@ program
8657
9944
  // `doctor` unless those agents also heartbeat straight to the hub. Not
8658
9945
  // fixed here — flair#639's fix list is version-stamping + a doctor
8659
9946
  // listing, not widening federation sync scope.
8660
- if (harperResponding) {
8661
- console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
9947
+ //
9948
+ // flair#722: iterated per agent (agentGates above) instead of a single
9949
+ // --agent-gated read. flairVersion/harperVersion are gated to verified
9950
+ // readers on the server (resources/Presence.ts, same boundary as
9951
+ // currentTask), so each agent subsection signs its own GET — a working
9952
+ // key reveals versions for that subsection; roster IDENTITY is public
9953
+ // either way. Zero local keys (and no --agent) falls back to exactly the
9954
+ // pre-#722 single unauthenticated read (hidden versions, "Pass --agent"
9955
+ // hint) — there's no agent to sign as, but remote agents may still have
9956
+ // heartbeated onto this instance and identities are worth showing.
9957
+ async function fetchAndRenderFleetPresence(headers, canSign, indent) {
8662
9958
  try {
8663
- // flairVersion/harperVersion are gated to verified readers on the
8664
- // server (resources/Presence.ts, same boundary as currentTask) — sign
8665
- // the GET when we have an agent + key so the fields aren't silently
8666
- // nulled out from under us.
8667
- const fleetAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
8668
- const fleetKeyPath = fleetAgentId ? join(defaultKeysDir(), `${fleetAgentId}.key`) : undefined;
8669
- const canSign = !!(fleetAgentId && fleetKeyPath && existsSync(fleetKeyPath));
8670
- const headers = canSign
8671
- ? { Authorization: buildEd25519Auth(fleetAgentId, "GET", "/Presence", fleetKeyPath) }
8672
- : {};
8673
9959
  const presRes = await fetch(`${baseUrl}/Presence`, { headers, signal: AbortSignal.timeout(5000) });
8674
9960
  if (!presRes.ok) {
8675
- console.log(` ${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
9961
+ console.log(`${indent}${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
9962
+ return;
8676
9963
  }
8677
- else {
8678
- const roster = (await presRes.json());
8679
- if (!Array.isArray(roster) || roster.length === 0) {
8680
- console.log(` ${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
8681
- }
8682
- else {
8683
- const rows = sortOldestVersionFirst(markStale(roster));
8684
- for (const row of rows) {
8685
- const lastSeen = typeof row.lastHeartbeatAt === "number"
8686
- ? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
8687
- : "—";
8688
- const versionLabel = !canSign
8689
- ? render.wrap(render.c.dim, "hidden")
8690
- : row.flairVersion
8691
- ? `v${row.flairVersion}`
8692
- : render.wrap(render.c.dim, "no version reported");
8693
- const staleNote = row.stale && row.newestVersion
8694
- ? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
8695
- : "";
8696
- const icon = row.stale ? render.icons.warn : render.icons.ok;
8697
- const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
8698
- // Natural-presence: same staleness principle as the version
8699
- // column — a live activity is shown as current, a decayed one as
8700
- // "last-known". `activityFresh === false` (server verdict) plus a
8701
- // known lastActivity → "(was: X)"; a fresh, non-idle activity →
8702
- // "(X)". Skip entirely when there's nothing informative to say
8703
- // (no signal, or idle) so the line stays quiet for the common case.
8704
- const lastActivity = row.lastActivity ?? row.activity;
8705
- const activityNote = row.activityFresh === false
8706
- ? (lastActivity && lastActivity !== "idle"
8707
- ? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
8708
- : "")
8709
- : (row.activity && row.activity !== "idle"
8710
- ? " " + render.wrap(render.c.dim, `(${row.activity})`)
8711
- : "");
8712
- console.log(` ${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
8713
- }
8714
- if (!canSign) {
8715
- console.log(` ${render.wrap(render.c.dim, "Pass --agent <id> (with a matching key in ~/.flair/keys) to reveal versions — flairVersion/harperVersion require a verified signature, same as currentTask.")}`);
8716
- }
8717
- console.log(` ${render.wrap(render.c.dim, "Staleness above is fleet-relative (newest version seen among these instances) — comparing against the latest PUBLISHED flair is the version check at the top of this report, not this section.")}`);
8718
- }
9964
+ const roster = (await presRes.json());
9965
+ if (!Array.isArray(roster) || roster.length === 0) {
9966
+ console.log(`${indent}${render.icons.info} No known instances yet — no /Presence heartbeats recorded on this instance`);
9967
+ return;
8719
9968
  }
9969
+ const rows = sortOldestVersionFirst(markStale(roster));
9970
+ for (const row of rows) {
9971
+ const lastSeen = typeof row.lastHeartbeatAt === "number"
9972
+ ? render.relativeTime(new Date(row.lastHeartbeatAt).toISOString())
9973
+ : "—";
9974
+ const versionLabel = !canSign
9975
+ ? render.wrap(render.c.dim, "hidden")
9976
+ : row.flairVersion
9977
+ ? `v${row.flairVersion}`
9978
+ : render.wrap(render.c.dim, "no version reported");
9979
+ const staleNote = row.stale && row.newestVersion
9980
+ ? " " + render.wrap(render.c.yellow, `(stale — fleet newest is v${row.newestVersion})`)
9981
+ : "";
9982
+ const icon = row.stale ? render.icons.warn : render.icons.ok;
9983
+ const statusSuffix = row.presenceStatus ? ` (${row.presenceStatus})` : "";
9984
+ // Natural-presence: same staleness principle as the version
9985
+ // column — a live activity is shown as current, a decayed one as
9986
+ // "last-known". `activityFresh === false` (server verdict) plus a
9987
+ // known lastActivity → "(was: X)"; a fresh, non-idle activity →
9988
+ // "(X)". Skip entirely when there's nothing informative to say
9989
+ // (no signal, or idle) so the line stays quiet for the common case.
9990
+ const lastActivity = row.lastActivity ?? row.activity;
9991
+ const activityNote = row.activityFresh === false
9992
+ ? (lastActivity && lastActivity !== "idle"
9993
+ ? " " + render.wrap(render.c.dim, `(was: ${lastActivity})`)
9994
+ : "")
9995
+ : (row.activity && row.activity !== "idle"
9996
+ ? " " + render.wrap(render.c.dim, `(${row.activity})`)
9997
+ : "");
9998
+ console.log(`${indent}${icon} ${row.id} — ${versionLabel} — last seen ${lastSeen}${statusSuffix}${activityNote}${staleNote}`);
9999
+ }
10000
+ if (!canSign) {
10001
+ console.log(`${indent} ${render.wrap(render.c.dim, "Pass --agent <id> (with a matching key in ~/.flair/keys) to reveal versions — flairVersion/harperVersion require a verified signature, same as currentTask.")}`);
10002
+ }
10003
+ console.log(`${indent} ${render.wrap(render.c.dim, "Staleness above is fleet-relative (newest version seen among these instances) — comparing against the latest PUBLISHED flair is the version check at the top of this report, not this section.")}`);
8720
10004
  }
8721
10005
  catch (err) {
8722
- console.log(` ${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
10006
+ console.log(`${indent}${render.icons.warn} Fleet presence check failed: ${err?.message ?? err}`);
10007
+ }
10008
+ }
10009
+ if (harperResponding) {
10010
+ console.log(`\n ${render.wrap(render.c.bold, "Fleet presence")}`);
10011
+ if (agentGates.length === 0) {
10012
+ await fetchAndRenderFleetPresence({}, false, " ");
10013
+ }
10014
+ else {
10015
+ for (const gate of agentGates) {
10016
+ const registered = renderAgentGateHeader(gate);
10017
+ if (!registered)
10018
+ continue;
10019
+ const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
10020
+ const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/Presence", keyPath) };
10021
+ await fetchAndRenderFleetPresence(headers, true, " ");
10022
+ }
8723
10023
  }
8724
10024
  }
8725
10025
  // 9. Migration state (flair#695) — pending/in-progress/blocked + last
@@ -8729,52 +10029,70 @@ program
8729
10029
  // 1a above (a halted migration retries automatically on the next boot —
8730
10030
  // there's no separate "run the migration now" fix; the fix for
8731
10031
  // "blocked" is whatever the halt reason names, e.g. freeing disk).
8732
- if (harperResponding) {
8733
- console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
10032
+ //
10033
+ // flair#722: iterated per agent (agentGates above), same as Fleet
10034
+ // presence — each subsection's finding is found-only (no per-agent
10035
+ // --fix here beyond the existing restart-on-halt story). Gate FINDINGS
10036
+ // are rendered in full under Fleet presence only (the first
10037
+ // verified-read section); re-printing the identical per-agent finding
10038
+ // here doubled the noise on real multi-key machines (a 27-key dogfood
10039
+ // box printed 15 not-registered findings twice each), so this section
10040
+ // iterates only the gate-passed agents and rolls the rest into one
10041
+ // aggregate skip line. The issue COUNT is unaffected either way — gate
10042
+ // findings are counted exactly once, at gate-resolution time (step 7a).
10043
+ async function fetchAndRenderMigrations(headers, indent) {
8734
10044
  try {
8735
- const migAgentId = opts.agent || process.env.FLAIR_AGENT_ID;
8736
- const migKeyPath = migAgentId ? join(defaultKeysDir(), `${migAgentId}.key`) : undefined;
8737
- const migCanSign = !!(migAgentId && migKeyPath && existsSync(migKeyPath));
8738
- if (!migCanSign) {
8739
- console.log(` ${render.icons.info} Pass --agent <id> (with a matching key in ~/.flair/keys) to see migration state — requires a verified read, same as Fleet presence above.`);
10045
+ const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers, signal: AbortSignal.timeout(5000) });
10046
+ if (!migRes.ok) {
10047
+ console.log(`${indent}${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
10048
+ return;
8740
10049
  }
8741
- else {
8742
- const migHeaders = { Authorization: buildEd25519Auth(migAgentId, "GET", "/HealthDetail", migKeyPath) };
8743
- const migRes = await fetch(`${baseUrl}/HealthDetail`, { headers: migHeaders, signal: AbortSignal.timeout(5000) });
8744
- if (!migRes.ok) {
8745
- console.log(` ${render.icons.warn} Could not fetch migration state (HTTP ${migRes.status})`);
10050
+ const detail = (await migRes.json());
10051
+ const migBlock = detail?.migrations;
10052
+ if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
10053
+ console.log(`${indent}${render.icons.info} No migrations registered on this instance`);
10054
+ return;
10055
+ }
10056
+ if (migBlock.cyclePhase === "pre-hash") {
10057
+ console.log(`${indent}${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
10058
+ }
10059
+ for (const m of migBlock.migrations) {
10060
+ if (m.state === "completed") {
10061
+ console.log(`${indent}${render.icons.ok} ${m.id}: completed`);
10062
+ }
10063
+ else if (m.state === "halted" || m.state === "failed") {
10064
+ console.log(`${indent}${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
10065
+ issues++;
10066
+ }
10067
+ else if (m.state === "running") {
10068
+ console.log(`${indent}${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
8746
10069
  }
8747
10070
  else {
8748
- const detail = (await migRes.json());
8749
- const migBlock = detail?.migrations;
8750
- if (!migBlock || !Array.isArray(migBlock.migrations) || migBlock.migrations.length === 0) {
8751
- console.log(` ${render.icons.info} No migrations registered on this instance`);
8752
- }
8753
- else {
8754
- if (migBlock.cyclePhase === "pre-hash") {
8755
- console.log(` ${render.icons.info} Pre-flight integrity check in progress — migrations deferred until it completes`);
8756
- }
8757
- for (const m of migBlock.migrations) {
8758
- if (m.state === "completed") {
8759
- console.log(` ${render.icons.ok} ${m.id}: completed`);
8760
- }
8761
- else if (m.state === "halted" || m.state === "failed") {
8762
- console.log(` ${render.icons.error} ${m.id}: ${m.state}${m.reason ? ` — ${m.reason}` : ""}`);
8763
- issues++;
8764
- }
8765
- else if (m.state === "running") {
8766
- console.log(` ${render.icons.info} ${m.id}: in progress (${m.rowsDone} done, ${m.rowsRemaining} remaining)`);
8767
- }
8768
- else {
8769
- console.log(` ${render.icons.info} ${m.id}: ${m.state}`);
8770
- }
8771
- }
8772
- }
10071
+ console.log(`${indent}${render.icons.info} ${m.id}: ${m.state}`);
8773
10072
  }
8774
10073
  }
8775
10074
  }
8776
10075
  catch (err) {
8777
- console.log(` ${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
10076
+ console.log(`${indent}${render.icons.warn} Migration state check failed: ${err?.message ?? err}`);
10077
+ }
10078
+ }
10079
+ if (harperResponding) {
10080
+ console.log(`\n ${render.wrap(render.c.bold, "Migrations")}`);
10081
+ if (agentGates.length === 0) {
10082
+ console.log(` ${render.icons.info} Pass --agent <id> (with a matching key in ~/.flair/keys) to see migration state — requires a verified read, same as Fleet presence above.`);
10083
+ }
10084
+ else {
10085
+ const passedGates = agentGates.filter((g) => describeAgentGateFinding(g.id, g.state, g.detail) === null);
10086
+ for (const gate of passedGates) {
10087
+ renderAgentGateHeader(gate);
10088
+ const keyPath = resolveKeyPath(gate.id) ?? join(defaultKeysDir(), `${gate.id}.key`);
10089
+ const headers = { Authorization: buildEd25519Auth(gate.id, "GET", "/HealthDetail", keyPath) };
10090
+ await fetchAndRenderMigrations(headers, " ");
10091
+ }
10092
+ const skipped = agentGates.length - passedGates.length;
10093
+ if (skipped > 0) {
10094
+ console.log(` ${render.icons.info} ${skipped} agent(s) skipped — registration-gate findings reported under Fleet presence above`);
10095
+ }
8778
10096
  }
8779
10097
  }
8780
10098
  // Summary — see summarizeDoctorRun above (flair#721): distinguishes
@@ -9543,21 +10861,14 @@ program
9543
10861
  const baseUrl = resolveBaseUrl(opts);
9544
10862
  const mode = render.resolveOutputMode(opts);
9545
10863
  try {
9546
- const headers = { "content-type": "application/json" };
9547
- const keyPath = opts.key || resolveKeyPath(agentId);
9548
- if (keyPath) {
9549
- headers["authorization"] = buildEd25519Auth(agentId, "POST", "/BootstrapMemories", keyPath);
9550
- }
9551
- const res = await fetch(`${baseUrl}/BootstrapMemories`, {
9552
- method: "POST",
9553
- headers,
9554
- body: JSON.stringify({ agentId, maxTokens: parseInt(opts.maxTokens, 10) }),
9555
- });
9556
- if (!res.ok) {
9557
- const body = await res.text();
9558
- throw new Error(`${res.status}: ${body}`);
9559
- }
9560
- 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 }));
9561
10872
  if (mode === "json") {
9562
10873
  // Agent-first: emit the full server response, augmented with the cap
9563
10874
  // that was requested. Includes context, sections, tokenEstimate, etc.
@@ -11429,4 +12740,6 @@ if (import.meta.main) {
11429
12740
  await runCli();
11430
12741
  }
11431
12742
  // ─── Exported for testing ─────────────────────────────────────────────────────
11432
- 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, };