@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 +1836 -523
- package/dist/deploy.js +16 -1
- package/dist/doctor-client.js +176 -0
- package/dist/hook-install.js +324 -0
- package/dist/install/clients.js +46 -11
- package/dist/lib/auth-resolve.js +369 -0
- package/dist/lib/mcp-enable.js +787 -0
- package/dist/probe.js +15 -2
- package/dist/resources/Memory.js +98 -60
- package/dist/resources/OrgEvent.js +37 -26
- package/dist/resources/Presence.js +4 -4
- package/dist/resources/Relationship.js +52 -53
- package/dist/resources/Soul.js +16 -8
- package/dist/resources/WorkspaceState.js +58 -54
- package/dist/resources/agent-auth.js +4 -5
- package/dist/resources/auth-middleware.js +4 -4
- package/dist/resources/ed25519-auth.js +37 -0
- package/dist/resources/embeddings-boot.js +38 -1
- package/dist/resources/mcp-handler.js +29 -5
- package/dist/resources/mcp-tools.js +50 -3
- package/dist/resources/provenance.js +60 -8
- package/dist/resources/record-type-kit.js +253 -0
- package/dist/resources/record-types.js +363 -0
- package/package.json +3 -3
- package/schemas/memory.graphql +2 -2
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,
|
|
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 {
|
|
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
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
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
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
|
|
392
|
-
//
|
|
393
|
-
//
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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:
|
|
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 =
|
|
2073
|
-
const plistDir =
|
|
2305
|
+
const label = launchdLabel(dataDir);
|
|
2306
|
+
const plistDir = defaultLaunchAgentsDir();
|
|
2074
2307
|
mkdirSync(plistDir, { recursive: true });
|
|
2075
|
-
const plistPath =
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
2869
|
-
|
|
2870
|
-
|
|
2871
|
-
|
|
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("
|
|
2874
|
-
.description("
|
|
2875
|
-
"
|
|
2876
|
-
"
|
|
2877
|
-
.
|
|
2878
|
-
.option("--
|
|
2879
|
-
"
|
|
2880
|
-
.option("--
|
|
2881
|
-
"
|
|
2882
|
-
.option("--
|
|
2883
|
-
.option("--
|
|
2884
|
-
.option("--
|
|
2885
|
-
.option("--
|
|
2886
|
-
.option("--
|
|
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
|
|
2890
|
-
|
|
2891
|
-
|
|
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
|
-
|
|
2897
|
-
|
|
2898
|
-
|
|
2899
|
-
|
|
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
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
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
|
-
|
|
2909
|
-
|
|
2910
|
-
|
|
2911
|
-
try {
|
|
2912
|
-
privateKey = loadEd25519PrivateKeyFromFile(keyPath);
|
|
3990
|
+
printEnableSteps(result);
|
|
3991
|
+
if (result.refused) {
|
|
3992
|
+
process.exit(1);
|
|
2913
3993
|
}
|
|
2914
|
-
|
|
2915
|
-
console.error(
|
|
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 (
|
|
2919
|
-
|
|
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
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
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
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
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
|
-
|
|
5653
|
-
|
|
5654
|
-
|
|
5655
|
-
|
|
5656
|
-
|
|
5657
|
-
|
|
5658
|
-
|
|
5659
|
-
|
|
5660
|
-
|
|
5661
|
-
|
|
5662
|
-
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
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
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
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
|
-
|
|
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
|
|
7189
|
-
// (flair#640: env > agent key
|
|
7190
|
-
//
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
7261
|
-
|
|
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
|
-
|
|
7317
|
-
|
|
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
|
-
|
|
7322
|
-
|
|
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:
|
|
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
|
|
7391
|
-
|
|
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(
|
|
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
|
-
|
|
7444
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
7539
|
-
const
|
|
7540
|
-
|
|
7541
|
-
|
|
7542
|
-
|
|
7543
|
-
|
|
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
|
|
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
|
-
|
|
8661
|
-
|
|
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(
|
|
9961
|
+
console.log(`${indent}${render.icons.warn} Could not fetch presence roster (HTTP ${presRes.status})`);
|
|
9962
|
+
return;
|
|
8676
9963
|
}
|
|
8677
|
-
|
|
8678
|
-
|
|
8679
|
-
|
|
8680
|
-
|
|
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(
|
|
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
|
-
|
|
8733
|
-
|
|
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
|
|
8736
|
-
|
|
8737
|
-
|
|
8738
|
-
|
|
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
|
-
|
|
8742
|
-
|
|
8743
|
-
|
|
8744
|
-
|
|
8745
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
9547
|
-
|
|
9548
|
-
|
|
9549
|
-
|
|
9550
|
-
|
|
9551
|
-
|
|
9552
|
-
|
|
9553
|
-
|
|
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, };
|