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