@tpsdev-ai/flair 0.45.0 → 0.46.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/config.yaml +35 -2
- package/dist/cli.js +369 -6
- package/dist/doctor-client.js +266 -0
- package/dist/federation/scheduler.js +114 -9
- package/dist/hook-install.js +150 -1
- package/dist/lib/mcp-enable.js +59 -19
- package/dist/lib/scheduler-platform.js +210 -1
- package/dist/rem/scheduler.js +113 -13
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/src/lib/scheduler-platform.js +210 -1
- package/dist/src/rem/scheduler.js +113 -13
- package/docs/notes/mcp-oauth-model2.md +10 -3
- package/package.json +1 -1
- package/templates/bin/flair-federation-sync.sh.tmpl +8 -1
- package/templates/bin/flair-rem-nightly.sh.tmpl +8 -1
package/config.yaml
CHANGED
|
@@ -16,9 +16,42 @@ authentication:
|
|
|
16
16
|
clientId: ${OAUTH_GITHUB_CLIENT_ID}
|
|
17
17
|
clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET}
|
|
18
18
|
mcp:
|
|
19
|
-
|
|
19
|
+
# WHOLE-TOKEN env reference — the same FLAIR_MCP_OAUTH flag flair's
|
|
20
|
+
# in-process /mcp route gates on (resources/mcp-oauth-flag.ts). The on/off
|
|
21
|
+
# choice lives in the instance ENVIRONMENT, not in this packed file, so a
|
|
22
|
+
# re-packed deploy can no longer revert an operator's enablement
|
|
23
|
+
# (flair#1152). Requires @harperfast/oauth >= 2.5.0 (resolved-version
|
|
24
|
+
# assertion + behavioral gate: test/integration/mcp-oauth-boot-safety.test.ts).
|
|
25
|
+
#
|
|
26
|
+
# ASYMMETRY (load-bearing, measured on oauth 2.5.0): the two readers of
|
|
27
|
+
# this flag accept DIFFERENT vocabularies. The component's
|
|
28
|
+
# coerceConfigBoolean accepts ONLY "true"/"false" and DELETES any other
|
|
29
|
+
# string (unresolved placeholder, "1", "yes", garbage) so its disabled
|
|
30
|
+
# default applies. flair's mcpOAuthEnabled() accepts 1/true/yes/on.
|
|
31
|
+
#
|
|
32
|
+
# Failure modes (oauth 2.5.0):
|
|
33
|
+
# unset -> placeholder deleted -> both sides OFF, clean boot
|
|
34
|
+
# true -> BOTH sides ON — the one working enable value
|
|
35
|
+
# (`flair mcp enable` stages exactly this)
|
|
36
|
+
# false -> both sides OFF
|
|
37
|
+
# 1 / yes / on -> flair /mcp handler ON, component AS OFF ->
|
|
38
|
+
# fail-closed broken-on: every /mcp request 401s,
|
|
39
|
+
# no AS is advertised. Use "true" instead.
|
|
40
|
+
# garbage (maybe) -> deleted -> component OFF; flair strict -> OFF.
|
|
41
|
+
# Inert: no /mcp handler, no data path (flair's own
|
|
42
|
+
# discovery documents still serve, by design).
|
|
43
|
+
# On oauth <2.5.0 there is NO normalization: an unresolved placeholder is
|
|
44
|
+
# a truthy string (fail-open) — the version assertion exists for that.
|
|
45
|
+
# If the component's `enabled` vocabulary changes, or it ever drives
|
|
46
|
+
# flair's handler registration directly, re-derive this table first.
|
|
47
|
+
enabled: ${FLAIR_MCP_OAUTH}
|
|
20
48
|
issuer: ${FLAIR_MCP_ISSUER}
|
|
21
|
-
resource:
|
|
49
|
+
# No `resource:` key ON PURPOSE (flair#1180): when absent, the component
|
|
50
|
+
# derives `<issuer>/mcp` at request time (resolveResource) — identical to
|
|
51
|
+
# flair's in-process derivation. A composite like ${FLAIR_MCP_ISSUER}/mcp
|
|
52
|
+
# NEVER interpolates (env expansion is whole-token-only) and fails every
|
|
53
|
+
# connect with invalid_target. Escape hatch: an operator needing a
|
|
54
|
+
# non-standard resource sets an explicit LITERAL absolute URL here.
|
|
22
55
|
accessTokenTtl: 900
|
|
23
56
|
dynamicClientRegistration:
|
|
24
57
|
enabled: false
|
package/dist/cli.js
CHANGED
|
@@ -23,8 +23,8 @@ import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemi
|
|
|
23
23
|
import { flairCliVersion, clearFlairCliVersionCache, mcpServerSpec, unpinnedSpecWarning, FLAIR_MCP_PACKAGE } from "./lib/mcp-spec.js";
|
|
24
24
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
25
25
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
26
|
-
import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
27
|
-
import { installHook, uninstallHook, hookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
26
|
+
import { readClientMcpBlock, checkClaudeMdBootstrap, detectWiredFlairMcp, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, fixCommandAgentHint, isNodeKeyId, partitionKeyIds, resolveFixAgentId, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, checkContinuityCaptureHooks, fixContinuityCaptureHooks, } from "./doctor-client.js";
|
|
27
|
+
import { installHook, uninstallHook, hookStatus, installContinuityHooks, uninstallContinuityHooks, continuityHookStatus, isSupportedHarness, SUPPORTED_HARNESSES, } from "./hook-install.js";
|
|
28
28
|
import { readSecretFileSecure, readAdminPassFileSecure, defaultAdminPassPath, defaultKeysDir, resolveLocalAdminPass, resolveKeyPath, buildEd25519Auth, authFetch, KeyLoadError, isLocalBase, authedRequest, } from "./lib/auth-resolve.js";
|
|
29
29
|
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
30
30
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
@@ -1604,6 +1604,164 @@ export async function verifySemanticSearch(baseUrl, agentIdOpt, keysDir) {
|
|
|
1604
1604
|
}
|
|
1605
1605
|
}
|
|
1606
1606
|
}
|
|
1607
|
+
/**
|
|
1608
|
+
* Positive control for the Harper audit log (flair#970): verify that audit
|
|
1609
|
+
* ACTUALLY records by writing and then reading the audit trail back — never by
|
|
1610
|
+
* trusting the `audit: true` flag `describe_table` reports.
|
|
1611
|
+
*
|
|
1612
|
+
* Why a positive control: records applied via cluster base-copy/resync are
|
|
1613
|
+
* committed with audit explicitly disabled (harper Table.ts isCopyApply, filed
|
|
1614
|
+
* upstream as harper#2212), so a node can report audit enabled, answer
|
|
1615
|
+
* read_audit_log with HTTP 200, and still hold zero entries. Before this
|
|
1616
|
+
* check, nothing in flair declared, read, or verified audit — the canonical
|
|
1617
|
+
* check that cannot fire.
|
|
1618
|
+
*
|
|
1619
|
+
* Probe (modeled on verifySemanticSearch above, same skipped/degraded/ok
|
|
1620
|
+
* discipline):
|
|
1621
|
+
* 1. PUT an ephemeral probe row (id `flair-doctor-audit-probe-<uuid>`,
|
|
1622
|
+
* short inert marker content — ephemeral durability so a failed cleanup
|
|
1623
|
+
* self-prunes; the DELETE in `finally` is best-effort).
|
|
1624
|
+
* 2. PATCH it — verified live on harper@5.2.0: PATCH /Memory/<id> returns
|
|
1625
|
+
* 204 and generates an audit entry with operation "patch" (PUT generates
|
|
1626
|
+
* "upsert", DELETE "delete").
|
|
1627
|
+
* 3. `read_audit_log` (search_type hash_value, the probe id) over the ops
|
|
1628
|
+
* API with Basic admin auth — the operations API only exists on its own
|
|
1629
|
+
* port/socket, so the agent's Ed25519 header cannot authenticate it.
|
|
1630
|
+
* 4. Assert BOTH write entries are present. The probe's own DELETE lands
|
|
1631
|
+
* AFTER the read, so it is never required (audit is append-only; the
|
|
1632
|
+
* probe's entries persisting after the row is gone is by design).
|
|
1633
|
+
*
|
|
1634
|
+
* All assertions are BOOLEAN (entry counts only). Audit entries carry full
|
|
1635
|
+
* record images (`records: [value]`), so no entry content is ever copied into
|
|
1636
|
+
* a result detail — the detail strings are fixed text plus counts/statuses.
|
|
1637
|
+
*/
|
|
1638
|
+
export async function verifyAuditLog(baseUrl, agentIdOpt, keysDir, opsUrl, adminUser, adminPass) {
|
|
1639
|
+
// Resolve an agent + key to sign the probe writes with — identical
|
|
1640
|
+
// resolution to verifySemanticSearch so the two probes agree on identity.
|
|
1641
|
+
let agentId = agentIdOpt || process.env.FLAIR_AGENT_ID || undefined;
|
|
1642
|
+
if (!agentId) {
|
|
1643
|
+
try {
|
|
1644
|
+
const keyFiles = readdirSync(keysDir).filter((f) => f.endsWith(".key"));
|
|
1645
|
+
const agentKeyFile = keyFiles.find((f) => !isNodeKeyId(f.replace(/\.key$/, ""), keysDir));
|
|
1646
|
+
if (agentKeyFile)
|
|
1647
|
+
agentId = agentKeyFile.replace(/\.key$/, "");
|
|
1648
|
+
}
|
|
1649
|
+
catch { /* keysDir missing */ }
|
|
1650
|
+
}
|
|
1651
|
+
if (!agentId) {
|
|
1652
|
+
return { state: "skipped", reason: "no-agent", detail: "no agent id or key found" };
|
|
1653
|
+
}
|
|
1654
|
+
let keyPath = resolveKeyPath(agentId);
|
|
1655
|
+
if (!keyPath) {
|
|
1656
|
+
const candidate = join(keysDir, `${agentId}.key`);
|
|
1657
|
+
if (existsSync(candidate))
|
|
1658
|
+
keyPath = candidate;
|
|
1659
|
+
}
|
|
1660
|
+
if (!keyPath) {
|
|
1661
|
+
return { state: "skipped", reason: "no-key", detail: `no private key for agent '${agentId}'` };
|
|
1662
|
+
}
|
|
1663
|
+
if (!adminUser || !adminPass) {
|
|
1664
|
+
return {
|
|
1665
|
+
state: "skipped",
|
|
1666
|
+
reason: "no-admin-credentials",
|
|
1667
|
+
detail: "no admin credentials for the ops API (read_audit_log requires them)",
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
const id = `flair-doctor-audit-probe-${randomUUID()}`;
|
|
1671
|
+
const path = `/Memory/${id}`;
|
|
1672
|
+
let stored = false;
|
|
1673
|
+
try {
|
|
1674
|
+
// Write 1: PUT the probe row. Ephemeral durability — TTL is the cleanup
|
|
1675
|
+
// backstop if the finally-DELETE fails.
|
|
1676
|
+
const putRes = await authFetch(baseUrl, agentId, keyPath, "PUT", path, {
|
|
1677
|
+
id,
|
|
1678
|
+
agentId,
|
|
1679
|
+
content: `flair doctor audit probe (inert marker, safe to ignore) [${id}]`,
|
|
1680
|
+
durability: "ephemeral",
|
|
1681
|
+
createdAt: new Date().toISOString(),
|
|
1682
|
+
});
|
|
1683
|
+
if (!putRes.ok && putRes.status !== 204) {
|
|
1684
|
+
return { state: "skipped", reason: "probe-failed", detail: `could not write probe row: HTTP ${putRes.status}` };
|
|
1685
|
+
}
|
|
1686
|
+
stored = true;
|
|
1687
|
+
// Write 2: PATCH — a second, distinct audit-visible write (live-verified
|
|
1688
|
+
// to produce its own entry on harper@5.2.0; see doc comment).
|
|
1689
|
+
const patchRes = await authFetch(baseUrl, agentId, keyPath, "PATCH", path, {
|
|
1690
|
+
content: `flair doctor audit probe (inert marker, second write) [${id}]`,
|
|
1691
|
+
});
|
|
1692
|
+
if (!patchRes.ok && patchRes.status !== 204) {
|
|
1693
|
+
return { state: "skipped", reason: "probe-failed", detail: `could not apply second probe write: HTTP ${patchRes.status}` };
|
|
1694
|
+
}
|
|
1695
|
+
// Read the audit trail back over the ops API.
|
|
1696
|
+
let auditRes;
|
|
1697
|
+
try {
|
|
1698
|
+
auditRes = await fetch(`${opsUrl.replace(/\/+$/, "")}/`, {
|
|
1699
|
+
method: "POST",
|
|
1700
|
+
headers: {
|
|
1701
|
+
"Content-Type": "application/json",
|
|
1702
|
+
Authorization: `Basic ${Buffer.from(`${adminUser}:${adminPass}`).toString("base64")}`,
|
|
1703
|
+
},
|
|
1704
|
+
body: JSON.stringify({
|
|
1705
|
+
operation: "read_audit_log",
|
|
1706
|
+
database: "flair",
|
|
1707
|
+
table: "Memory",
|
|
1708
|
+
search_type: "hash_value",
|
|
1709
|
+
search_values: [id],
|
|
1710
|
+
}),
|
|
1711
|
+
signal: AbortSignal.timeout(10_000),
|
|
1712
|
+
});
|
|
1713
|
+
}
|
|
1714
|
+
catch (err) {
|
|
1715
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1716
|
+
return { state: "skipped", reason: "probe-failed", detail: `ops API unreachable at ${opsUrl} (${message.slice(0, 80)})` };
|
|
1717
|
+
}
|
|
1718
|
+
if (auditRes.status === 400) {
|
|
1719
|
+
// harper rejects read_audit_log with HTTP 400 ("To use this operation
|
|
1720
|
+
// audit log must be enabled in harperdb-config.yaml") when
|
|
1721
|
+
// logging.auditLog is off — live-verified on harper@5.2.0.
|
|
1722
|
+
return { state: "degraded", cause: "disabled", detail: "read_audit_log rejected the probe: audit logging is not enabled on this instance" };
|
|
1723
|
+
}
|
|
1724
|
+
if (!auditRes.ok) {
|
|
1725
|
+
// 401/403/404/5xx tell us nothing about whether audit records — that is
|
|
1726
|
+
// "could not verify", never "verified" and never "broken".
|
|
1727
|
+
return { state: "skipped", reason: "probe-failed", detail: `read_audit_log failed: HTTP ${auditRes.status}` };
|
|
1728
|
+
}
|
|
1729
|
+
const body = (await auditRes.json().catch(() => null));
|
|
1730
|
+
// BOOLEAN classification only: count the probe's write entries. Audit
|
|
1731
|
+
// entries carry full record images — none of that content may reach the
|
|
1732
|
+
// result (and via it, doctor/init output).
|
|
1733
|
+
const entries = body && Array.isArray(body[id]) ? body[id] : [];
|
|
1734
|
+
const writeEntries = entries.filter((e) => e && typeof e === "object" && e.operation !== "delete");
|
|
1735
|
+
if (writeEntries.length >= 2) {
|
|
1736
|
+
return { state: "ok" };
|
|
1737
|
+
}
|
|
1738
|
+
// Enabled-but-empty (or partial) is the critical row: we put data in and
|
|
1739
|
+
// could not see it come back — the pipeline is broken, not "empty". The
|
|
1740
|
+
// pre-fix world silently passed this as ok.
|
|
1741
|
+
return {
|
|
1742
|
+
state: "degraded",
|
|
1743
|
+
cause: "not-recording",
|
|
1744
|
+
detail: `probe made 2 writes; read_audit_log returned ${writeEntries.length} write ${writeEntries.length === 1 ? "entry" : "entries"}`,
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
catch (err) {
|
|
1748
|
+
if (err instanceof KeyLoadError) {
|
|
1749
|
+
return { state: "skipped", reason: "key-load", detail: err.message };
|
|
1750
|
+
}
|
|
1751
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1752
|
+
return { state: "skipped", reason: "probe-failed", detail: `probe error: ${message.slice(0, 100)}` };
|
|
1753
|
+
}
|
|
1754
|
+
finally {
|
|
1755
|
+
// Best-effort cleanup — ephemeral TTL is the backstop if this fails. The
|
|
1756
|
+
// DELETE itself appends one more audit entry AFTER the read, by design.
|
|
1757
|
+
if (stored) {
|
|
1758
|
+
try {
|
|
1759
|
+
await authFetch(baseUrl, agentId, keyPath, "DELETE", path);
|
|
1760
|
+
}
|
|
1761
|
+
catch { /* leave the ephemeral row; it'll age out */ }
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1607
1765
|
// ─── Doctor: client-integration network checks (flair#588) ────────────────────
|
|
1608
1766
|
//
|
|
1609
1767
|
// The pure filesystem checks (MCP block parsing, CLAUDE.md, SessionStart hook)
|
|
@@ -3355,6 +3513,33 @@ program
|
|
|
3355
3513
|
else {
|
|
3356
3514
|
console.log(`${render.icons.warn} Semantic search not verified ${render.wrap(render.c.dim, `(${embedCheck.detail})`)}`);
|
|
3357
3515
|
}
|
|
3516
|
+
// Verify the audit log ACTUALLY records (flair#970) — a positive
|
|
3517
|
+
// control, not a flag read: `describe_table` reports `audit: true` on
|
|
3518
|
+
// nodes whose audit trail is empty (base-copy elision, harper#2212).
|
|
3519
|
+
// Same surface as the semantic-search check above.
|
|
3520
|
+
console.log("Verifying audit log...");
|
|
3521
|
+
const auditCheck = await verifyAuditLog(httpUrl, agentId, keysDir, `http://127.0.0.1:${opsPort}`, adminUser, adminPass);
|
|
3522
|
+
if (auditCheck.state === "ok") {
|
|
3523
|
+
// Present tense ONLY: the probe proves current recording, never
|
|
3524
|
+
// historical completeness — see AuditVerifyResult's doc comment.
|
|
3525
|
+
console.log(`Audit log: recording (verified now) ✓ ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
|
|
3526
|
+
}
|
|
3527
|
+
else if (auditCheck.state === "degraded") {
|
|
3528
|
+
if (auditCheck.cause === "disabled") {
|
|
3529
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log DISABLED")} — ${auditCheck.detail}.`);
|
|
3530
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
|
|
3531
|
+
}
|
|
3532
|
+
else {
|
|
3533
|
+
console.log(`\n${render.icons.error} ${render.wrap(render.c.red, "Audit log NOT RECORDING")} — ${auditCheck.detail}.`);
|
|
3534
|
+
console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
|
|
3535
|
+
console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
|
|
3536
|
+
console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
else {
|
|
3540
|
+
// An unrun check must not look like a pass.
|
|
3541
|
+
console.log(`${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditCheck.detail})`);
|
|
3542
|
+
}
|
|
3358
3543
|
// Output — admin password printed once, never written to disk
|
|
3359
3544
|
console.log("\n✅ Flair initialized successfully");
|
|
3360
3545
|
console.log(` Agent ID: ${agentId}`);
|
|
@@ -4359,6 +4544,7 @@ hook
|
|
|
4359
4544
|
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
|
|
4360
4545
|
.option("--agent-id <id>", "Alias for --agent")
|
|
4361
4546
|
.option("--url <url>", "Flair URL to wire (else FLAIR_TARGET/FLAIR_URL, else the existing claude-code MCP wiring, else the local default)")
|
|
4547
|
+
.option("--continuity", "Wire the continuity capture hooks instead (PostToolUse + Stop — flair#1257; installing them IS the opt-in)")
|
|
4362
4548
|
.action((opts) => {
|
|
4363
4549
|
const harness = requireSupportedHarness(opts.harness);
|
|
4364
4550
|
const home = homedir();
|
|
@@ -4369,6 +4555,18 @@ hook
|
|
|
4369
4555
|
}
|
|
4370
4556
|
const flairUrl = resolveHookFlairUrl(opts, home);
|
|
4371
4557
|
const dryRun = !!opts.dryRun;
|
|
4558
|
+
if (opts.continuity) {
|
|
4559
|
+
const result = installContinuityHooks({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4560
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4561
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
4562
|
+
if (result.backupPath) {
|
|
4563
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
4564
|
+
}
|
|
4565
|
+
console.log("");
|
|
4566
|
+
if (!result.ok)
|
|
4567
|
+
process.exit(1);
|
|
4568
|
+
return;
|
|
4569
|
+
}
|
|
4372
4570
|
const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4373
4571
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4374
4572
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
@@ -4388,10 +4586,23 @@ hook
|
|
|
4388
4586
|
.description("Remove the Flair SessionStart hook entry — only ours, everything else in the file is left untouched")
|
|
4389
4587
|
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
4390
4588
|
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
4589
|
+
.option("--continuity", "Remove the continuity capture hooks instead (PostToolUse + Stop — flair#1257)")
|
|
4391
4590
|
.action((opts) => {
|
|
4392
4591
|
const harness = requireSupportedHarness(opts.harness);
|
|
4393
4592
|
const home = homedir();
|
|
4394
4593
|
const dryRun = !!opts.dryRun;
|
|
4594
|
+
if (opts.continuity) {
|
|
4595
|
+
const result = uninstallContinuityHooks({ homeDir: home, harness, dryRun });
|
|
4596
|
+
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall --continuity")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4597
|
+
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
4598
|
+
if (result.backupPath) {
|
|
4599
|
+
console.log(` ${render.wrap(render.c.dim, `backup: ${result.backupPath}`)}`);
|
|
4600
|
+
}
|
|
4601
|
+
console.log("");
|
|
4602
|
+
if (!result.ok)
|
|
4603
|
+
process.exit(1);
|
|
4604
|
+
return;
|
|
4605
|
+
}
|
|
4395
4606
|
const result = uninstallHook({ homeDir: home, harness, dryRun });
|
|
4396
4607
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4397
4608
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
@@ -4414,6 +4625,22 @@ hook
|
|
|
4414
4625
|
const harness = requireSupportedHarness(opts.harness);
|
|
4415
4626
|
const home = homedir();
|
|
4416
4627
|
const status = hookStatus(home, harness);
|
|
4628
|
+
// Continuity pair (flair#1257) — reported alongside the SessionStart
|
|
4629
|
+
// status in every branch below. "absent" is NOT a failure: installing the
|
|
4630
|
+
// pair is the opt-in, so absence renders as "not enabled".
|
|
4631
|
+
const renderContinuity = () => {
|
|
4632
|
+
const cont = continuityHookStatus(home, harness);
|
|
4633
|
+
if (cont.state === "installed") {
|
|
4634
|
+
console.log(` ${render.icons.ok} continuity capture: PostToolUse + Stop wired`);
|
|
4635
|
+
}
|
|
4636
|
+
else if (cont.state === "absent") {
|
|
4637
|
+
console.log(` ${render.icons.info} continuity capture: not enabled ${render.wrap(render.c.dim, "(opt-in: flair hook install --continuity)")}`);
|
|
4638
|
+
}
|
|
4639
|
+
else {
|
|
4640
|
+
const missing = !cont.postToolUse.present ? "PostToolUse missing" : !cont.stop.present ? "Stop missing" : "stale form";
|
|
4641
|
+
console.log(` ${render.icons.warn} continuity capture: ${cont.state} (${missing}) ${render.wrap(render.c.dim, "— re-run: flair hook install --continuity")}`);
|
|
4642
|
+
}
|
|
4643
|
+
};
|
|
4417
4644
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
|
|
4418
4645
|
console.log(` ${render.wrap(render.c.dim, "Harness:")} ${status.harness}`);
|
|
4419
4646
|
console.log(` ${render.wrap(render.c.dim, "Config:")} ${status.path}`);
|
|
@@ -4425,6 +4652,7 @@ hook
|
|
|
4425
4652
|
if (!status.wired) {
|
|
4426
4653
|
console.log(` ${render.icons.error} not wired`);
|
|
4427
4654
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
|
|
4655
|
+
renderContinuity();
|
|
4428
4656
|
console.log("");
|
|
4429
4657
|
process.exit(1);
|
|
4430
4658
|
}
|
|
@@ -4439,6 +4667,7 @@ hook
|
|
|
4439
4667
|
else {
|
|
4440
4668
|
console.log(` ${render.icons.warn} ${render.wrap(render.c.dim, "On failure:")} prints an error on every session — run \`flair hook install\` to adopt the silent form`);
|
|
4441
4669
|
}
|
|
4670
|
+
renderContinuity();
|
|
4442
4671
|
console.log("");
|
|
4443
4672
|
});
|
|
4444
4673
|
// ─── flair mcp ───────────────────────────────────────────────────────────────
|
|
@@ -6433,6 +6662,11 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6433
6662
|
return await syncRes.json();
|
|
6434
6663
|
}
|
|
6435
6664
|
let totalBatches = 0;
|
|
6665
|
+
// Memory rows that passed the since-cursor filter but were excluded as
|
|
6666
|
+
// private. Used only so the quiet path can distinguish "nothing since
|
|
6667
|
+
// the cursor" from "found rows, all withheld" (flair#1232). Does not
|
|
6668
|
+
// change what gets pushed — private still never leaves the instance.
|
|
6669
|
+
let privateHeldBack = 0;
|
|
6436
6670
|
for (const table of tables) {
|
|
6437
6671
|
let rows = [];
|
|
6438
6672
|
for (const query of [
|
|
@@ -6466,9 +6700,11 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6466
6700
|
// filter only applies there; on the other 3 tables `row.visibility`
|
|
6467
6701
|
// is always undefined, which isFederationPrivateVisibility() treats
|
|
6468
6702
|
// as non-private (included) — a no-op for them.
|
|
6469
|
-
|
|
6470
|
-
|
|
6471
|
-
|
|
6703
|
+
const sinceCursor = batch.filter((r) => r.updatedAt !== null || r.createdAt > since);
|
|
6704
|
+
const federable = sinceCursor.filter((r) => table !== "Memory" || !isFederationPrivateVisibility(r.visibility));
|
|
6705
|
+
if (table === "Memory")
|
|
6706
|
+
privateHeldBack += sinceCursor.length - federable.length;
|
|
6707
|
+
rows = rows.concat(federable);
|
|
6472
6708
|
}
|
|
6473
6709
|
if (rows.length === 0)
|
|
6474
6710
|
continue;
|
|
@@ -6583,7 +6819,13 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6583
6819
|
catch (pingErr) {
|
|
6584
6820
|
console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
|
|
6585
6821
|
}
|
|
6586
|
-
|
|
6822
|
+
// flair#1232: "No changes" is true only when nothing was found since
|
|
6823
|
+
// the cursor. If rows were found and every one was withheld as private,
|
|
6824
|
+
// say so — count and reason only, never content. A zero withheld count
|
|
6825
|
+
// must not invent a private-withheld story.
|
|
6826
|
+
console.log(privateHeldBack > 0
|
|
6827
|
+
? `No federable changes since last sync (${privateHeldBack} row${privateHeldBack === 1 ? "" : "s"} held back: private visibility).`
|
|
6828
|
+
: "No changes since last sync.");
|
|
6587
6829
|
return { pushed: 0, skipped: 0 };
|
|
6588
6830
|
}
|
|
6589
6831
|
console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
|
|
@@ -12303,6 +12545,60 @@ program
|
|
|
12303
12545
|
}
|
|
12304
12546
|
}
|
|
12305
12547
|
}
|
|
12548
|
+
// 4b. Audit-log positive control (flair#970) — REAL write→read_audit_log
|
|
12549
|
+
// round-trip, only if Harper is responding. `describe_table` reporting
|
|
12550
|
+
// `audit: true` proves nothing: a node that joined or resynced via
|
|
12551
|
+
// cluster base copy holds zero audit history while reporting audit
|
|
12552
|
+
// enabled and answering read_audit_log with clean empty (harper#2212).
|
|
12553
|
+
// So doctor writes probe rows and asserts their audit entries come back —
|
|
12554
|
+
// never trusts the flag. Same ok/degraded/skipped discipline as the
|
|
12555
|
+
// embeddings check above: skipped is rendered UNVERIFIED, never as a pass.
|
|
12556
|
+
if (harperResponding) {
|
|
12557
|
+
// read_audit_log only exists on the ops API (its own port), which the
|
|
12558
|
+
// agent's Ed25519 header cannot authenticate — resolve the local admin
|
|
12559
|
+
// credential (env or ~/.flair/admin-pass; never prompts). A file with
|
|
12560
|
+
// unsafe permissions throws — that is "could not probe", not "broken".
|
|
12561
|
+
let auditAdminPass;
|
|
12562
|
+
let auditCredIssue = null;
|
|
12563
|
+
try {
|
|
12564
|
+
auditAdminPass = resolveLocalAdminPass(undefined);
|
|
12565
|
+
}
|
|
12566
|
+
catch (err) {
|
|
12567
|
+
auditCredIssue = err instanceof Error ? err.message : String(err);
|
|
12568
|
+
}
|
|
12569
|
+
const auditStatus = auditCredIssue
|
|
12570
|
+
? { state: "skipped", reason: "no-admin-credentials", detail: auditCredIssue }
|
|
12571
|
+
: await verifyAuditLog(baseUrl, opts.agent, defaultKeysDir(), `http://127.0.0.1:${resolveOpsPort(opts)}`, DEFAULT_ADMIN_USER, auditAdminPass);
|
|
12572
|
+
switch (auditStatus.state) {
|
|
12573
|
+
case "ok":
|
|
12574
|
+
// Present-tense claim ONLY (see AuditVerifyResult): the probe
|
|
12575
|
+
// proves the log records writes NOW — never that history is
|
|
12576
|
+
// complete. Overclaiming here would rebuild the false trust
|
|
12577
|
+
// anchor this check exists to kill, one layer up.
|
|
12578
|
+
console.log(` ${render.icons.ok} Audit log: recording (verified now) ${render.wrap(render.c.dim, "(verifies current recording, not history — a resynced node's audit has a hard start boundary at its copy time)")}`);
|
|
12579
|
+
break;
|
|
12580
|
+
case "degraded":
|
|
12581
|
+
if (auditStatus.cause === "disabled") {
|
|
12582
|
+
console.log(` ${render.icons.error} Audit log DISABLED ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
|
|
12583
|
+
console.log(` ${render.wrap(render.c.dim, "Fix: enable logging.auditLog in the ROOT harperdb-config.yaml (the Harper instance config, NOT flair's component config.yaml), then restart Harper.")}`);
|
|
12584
|
+
}
|
|
12585
|
+
else {
|
|
12586
|
+
console.log(` ${render.icons.error} Audit log NOT RECORDING ${render.wrap(render.c.dim, `— ${auditStatus.detail}`)}`);
|
|
12587
|
+
console.log(` ${render.wrap(render.c.red, "Audit reports as enabled, but fresh writes produced no audit entries — do not treat the audit log as a record of what happened.")}`);
|
|
12588
|
+
console.log(` ${render.wrap(render.c.dim, "On a node that joined or resynced via cluster base copy, audit history has a hard start boundary at copy time (harper#2212) — \"no history\" does not mean \"nothing happened\".")}`);
|
|
12589
|
+
console.log(` ${render.wrap(render.c.dim, "Check logging.auditLog in the ROOT harperdb-config.yaml (not flair's component config.yaml), then restart Harper.")}`);
|
|
12590
|
+
}
|
|
12591
|
+
issues++;
|
|
12592
|
+
break;
|
|
12593
|
+
case "skipped":
|
|
12594
|
+
// An unrun check must not look like a pass — UNVERIFIED, visually
|
|
12595
|
+
// distinct from ok, but not a hard issue (mirrors the embeddings
|
|
12596
|
+
// skip: the operator may simply have no agent or no local admin
|
|
12597
|
+
// credential on this box).
|
|
12598
|
+
console.log(` ${render.icons.warn} Audit log: UNVERIFIED (could not probe — ${auditStatus.detail})`);
|
|
12599
|
+
break;
|
|
12600
|
+
}
|
|
12601
|
+
}
|
|
12306
12602
|
// 5. Stale PID file (skip if already reported in port check)
|
|
12307
12603
|
const dataDir = defaultDataDir();
|
|
12308
12604
|
const pidFile = join(dataDir, "hdb.pid");
|
|
@@ -12616,6 +12912,73 @@ program
|
|
|
12616
12912
|
}
|
|
12617
12913
|
issues++;
|
|
12618
12914
|
}
|
|
12915
|
+
// flair#1257 slice 2 — continuity capture pair (the check-5 twin of
|
|
12916
|
+
// the SessionStart check above: installed / absent / stale-form).
|
|
12917
|
+
// Continuity is OPT-IN — installing the PostToolUse+Stop pair IS the
|
|
12918
|
+
// opt-in — so "absent" renders as informational "not enabled": NEVER
|
|
12919
|
+
// a pass (an unrun check must not look green) and never counted as an
|
|
12920
|
+
// issue. A partial/stale install IS an issue and is --fix-able;
|
|
12921
|
+
// --fix also offers first-time enablement (the y/N prompt is the
|
|
12922
|
+
// consent; non-TTY --fix is itself the consent signal, matching every
|
|
12923
|
+
// other doctor fix).
|
|
12924
|
+
const continuity = checkContinuityCaptureHooks(homedir());
|
|
12925
|
+
if (continuity.state === "installed") {
|
|
12926
|
+
console.log(` ${render.icons.ok} Continuity capture hooks: PostToolUse + Stop wired in ${render.wrap(render.c.dim, continuity.path)}`);
|
|
12927
|
+
}
|
|
12928
|
+
else if (continuity.state === "absent") {
|
|
12929
|
+
console.log(` ${render.icons.info} Continuity capture hooks: not enabled ${render.wrap(render.c.dim, "(opt-in — auto-journal working state into the ephemeral memory tier; enable: flair hook install --continuity)")}`);
|
|
12930
|
+
if (autoFix) {
|
|
12931
|
+
if (dryRun) {
|
|
12932
|
+
console.log(` ${render.wrap(render.c.dim, "Would wire the continuity capture hooks (PostToolUse + Stop) in")} ${continuity.path}`);
|
|
12933
|
+
}
|
|
12934
|
+
else {
|
|
12935
|
+
const proceed = await confirmFix(` Enable continuity capture (PostToolUse + Stop hooks in ${continuity.path})? [y/N] `);
|
|
12936
|
+
if (!proceed) {
|
|
12937
|
+
console.log(` Skipped.`);
|
|
12938
|
+
}
|
|
12939
|
+
else {
|
|
12940
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
12941
|
+
const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId);
|
|
12942
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
12943
|
+
if (fixRes.ok && fixRes.changed)
|
|
12944
|
+
fixed++;
|
|
12945
|
+
}
|
|
12946
|
+
}
|
|
12947
|
+
}
|
|
12948
|
+
}
|
|
12949
|
+
else {
|
|
12950
|
+
const continuityDetail = continuity.state === "partial"
|
|
12951
|
+
? (!continuity.postToolUse.present ? "the PostToolUse entry is missing" : "the Stop entry is missing")
|
|
12952
|
+
: "an entry is not the current form (unsilenced, hand-altered, or a drifted PostToolUse matcher)";
|
|
12953
|
+
console.log(` ${render.icons.warn} Continuity capture hooks: ${continuity.state} — ${continuityDetail}`);
|
|
12954
|
+
if (autoFix) {
|
|
12955
|
+
if (dryRun) {
|
|
12956
|
+
console.log(` ${render.wrap(render.c.dim, "Would rewrite the continuity capture hooks in")} ${continuity.path}`);
|
|
12957
|
+
}
|
|
12958
|
+
else {
|
|
12959
|
+
const proceed = await confirmFix(` Rewrite the continuity capture hooks in ${continuity.path} to the current form? [y/N] `);
|
|
12960
|
+
if (!proceed) {
|
|
12961
|
+
console.log(` Skipped.`);
|
|
12962
|
+
}
|
|
12963
|
+
else {
|
|
12964
|
+
const fixAgentId = claudeCodeAgentId || opts.agent || process.env.FLAIR_AGENT_ID;
|
|
12965
|
+
// Preserve the FLAIR_URL an existing entry already carries —
|
|
12966
|
+
// a repair must never silently re-point the hooks at a
|
|
12967
|
+
// different instance.
|
|
12968
|
+
const existingCommand = continuity.postToolUse.command || continuity.stop.command || "";
|
|
12969
|
+
const existingUrl = existingCommand.match(/FLAIR_URL=(\S+)/)?.[1];
|
|
12970
|
+
const fixRes = fixContinuityCaptureHooks(homedir(), fixAgentId, existingUrl);
|
|
12971
|
+
console.log(` ${fixRes.ok ? render.icons.ok : render.icons.warn} ${fixRes.message}`);
|
|
12972
|
+
if (fixRes.ok && fixRes.changed)
|
|
12973
|
+
fixed++;
|
|
12974
|
+
}
|
|
12975
|
+
}
|
|
12976
|
+
}
|
|
12977
|
+
else {
|
|
12978
|
+
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair doctor --fix ${render.wrap(render.c.dim, "(rewrites both entries to the current form — same agent, same instance)")}`);
|
|
12979
|
+
}
|
|
12980
|
+
issues++;
|
|
12981
|
+
}
|
|
12619
12982
|
}
|
|
12620
12983
|
}
|
|
12621
12984
|
// 7a. Resolve which agent identities the two verified-read sections below
|