@tpsdev-ai/flair 0.44.13 → 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 +410 -21
- 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/safe-snapshot-extract.js +20 -2
- package/dist/lib/scheduler-platform.js +210 -1
- package/dist/rem/scheduler.js +113 -13
- package/dist/rem/snapshot.js +14 -13
- package/dist/resources/Memory.js +32 -1
- package/dist/resources/MemoryBootstrap.js +88 -51
- package/dist/resources/MemoryFeed.js +56 -1
- package/dist/resources/MemoryMaintenance.js +8 -2
- package/dist/resources/abstention.js +12 -9
- package/dist/resources/auth-middleware.js +11 -3
- package/dist/resources/bm25.js +7 -3
- package/dist/resources/mcp-oauth-flag.js +20 -0
- package/dist/resources/mcp-oauth.js +6 -1
- package/dist/resources/memory-visibility.js +48 -0
- package/dist/resources/semantic-retrieval-core.js +99 -19
- 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/docs/quickstart-fabric.md +42 -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}`);
|
|
@@ -3743,6 +3928,7 @@ agent
|
|
|
3743
3928
|
.option("--name <name>", "Display name (defaults to id)")
|
|
3744
3929
|
.option("--port <port>", "Harper HTTP port")
|
|
3745
3930
|
.option("--admin-pass <pass>", "Admin password for registration")
|
|
3931
|
+
.option("--admin-pass-file <path>", "Read the admin password from a file (chmod 600 enforced). Preferred over inline --admin-pass — keeps the secret out of ps and shell history; works for remote targets too (an explicit flag is operator intent).")
|
|
3746
3932
|
.option("--keys-dir <dir>", "Directory for Ed25519 keys")
|
|
3747
3933
|
.option("--ops-port <port>", "Harper operations API port")
|
|
3748
3934
|
.option("--target <url>", "Remote Flair REST URL; derives the ops API URL (port-1) to seed the Agent there (env: FLAIR_TARGET)")
|
|
@@ -3759,6 +3945,22 @@ agent
|
|
|
3759
3945
|
// `flair import`: explicit --ops-target > derive from --target > localhost.
|
|
3760
3946
|
const seedOpsTarget = resolveEffectiveOpsUrl({ target: opts.target, opsTarget: opts.opsTarget }) ?? opsPort;
|
|
3761
3947
|
const isRemoteTarget = typeof seedOpsTarget === "string";
|
|
3948
|
+
// flair#1259 — --admin-pass-file resolves into the same explicit slot the
|
|
3949
|
+
// inline flag uses (same shape as `flair federation sync`), read in-process
|
|
3950
|
+
// via readAdminPassFileSecure so the secret never appears in ps or shell
|
|
3951
|
+
// history. This does NOT weaken the #1085 remote guard below: an explicit
|
|
3952
|
+
// flag naming a file IS operator intent toward this target, exactly like an
|
|
3953
|
+
// explicit inline --admin-pass — what the guard blocks is the AMBIENT
|
|
3954
|
+
// env/local-file fallbacks silently traveling to a third-party host.
|
|
3955
|
+
if (!opts.adminPass && opts.adminPassFile) {
|
|
3956
|
+
try {
|
|
3957
|
+
opts.adminPass = readAdminPassFileSecure(opts.adminPassFile);
|
|
3958
|
+
}
|
|
3959
|
+
catch (err) {
|
|
3960
|
+
console.error(`Error reading --admin-pass-file ${opts.adminPassFile}: ${err.message}`);
|
|
3961
|
+
process.exit(1);
|
|
3962
|
+
}
|
|
3963
|
+
}
|
|
3762
3964
|
// #590 — local convenience fallback: FLAIR_ADMIN_PASS env, then the secure
|
|
3763
3965
|
// ~/.flair/admin-pass file `flair init` already writes (mode 0600). Never
|
|
3764
3966
|
// applied for a remote target — see resolveLocalAdminPass.
|
|
@@ -3772,12 +3974,13 @@ agent
|
|
|
3772
3974
|
}
|
|
3773
3975
|
if (!adminPass) {
|
|
3774
3976
|
if (isRemoteTarget) {
|
|
3775
|
-
console.error("Error: --admin-pass is required for agent add when targeting
|
|
3776
|
-
"(--target/--ops-target) — the local ~/.flair/admin-pass
|
|
3977
|
+
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add when targeting " +
|
|
3978
|
+
"a remote instance (--target/--ops-target) — the local ~/.flair/admin-pass and FLAIR_ADMIN_PASS " +
|
|
3979
|
+
"fallbacks are never used for remote targets. Prefer --admin-pass-file: it keeps the secret out of ps.");
|
|
3777
3980
|
}
|
|
3778
3981
|
else {
|
|
3779
|
-
console.error("Error: --admin-pass is required for agent add (needed to insert
|
|
3780
|
-
"Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
|
|
3982
|
+
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for agent add (needed to insert " +
|
|
3983
|
+
"into Agent table). Set FLAIR_ADMIN_PASS, or make sure ~/.flair/admin-pass exists (created by `flair init`).");
|
|
3781
3984
|
}
|
|
3782
3985
|
process.exit(1);
|
|
3783
3986
|
}
|
|
@@ -4341,6 +4544,7 @@ hook
|
|
|
4341
4544
|
.option("--agent <id>", "Agent ID to wire (else FLAIR_AGENT_ID, else the agent already wired for the claude-code MCP client)")
|
|
4342
4545
|
.option("--agent-id <id>", "Alias for --agent")
|
|
4343
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)")
|
|
4344
4548
|
.action((opts) => {
|
|
4345
4549
|
const harness = requireSupportedHarness(opts.harness);
|
|
4346
4550
|
const home = homedir();
|
|
@@ -4351,6 +4555,18 @@ hook
|
|
|
4351
4555
|
}
|
|
4352
4556
|
const flairUrl = resolveHookFlairUrl(opts, home);
|
|
4353
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
|
+
}
|
|
4354
4570
|
const result = installHook({ homeDir: home, harness, agentId, flairUrl, dryRun });
|
|
4355
4571
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook install")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4356
4572
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
@@ -4370,10 +4586,23 @@ hook
|
|
|
4370
4586
|
.description("Remove the Flair SessionStart hook entry — only ours, everything else in the file is left untouched")
|
|
4371
4587
|
.option("--harness <name>", `Target harness (${SUPPORTED_HARNESSES.join(", ")})`, "claude-code")
|
|
4372
4588
|
.option("--dry-run", "Print the exact JSON delta without writing")
|
|
4589
|
+
.option("--continuity", "Remove the continuity capture hooks instead (PostToolUse + Stop — flair#1257)")
|
|
4373
4590
|
.action((opts) => {
|
|
4374
4591
|
const harness = requireSupportedHarness(opts.harness);
|
|
4375
4592
|
const home = homedir();
|
|
4376
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
|
+
}
|
|
4377
4606
|
const result = uninstallHook({ homeDir: home, harness, dryRun });
|
|
4378
4607
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook uninstall")}${dryRun ? render.wrap(render.c.dim, " (dry run)") : ""}\n`);
|
|
4379
4608
|
console.log(` ${result.ok ? render.icons.ok : render.icons.error} ${result.message}`);
|
|
@@ -4396,6 +4625,22 @@ hook
|
|
|
4396
4625
|
const harness = requireSupportedHarness(opts.harness);
|
|
4397
4626
|
const home = homedir();
|
|
4398
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
|
+
};
|
|
4399
4644
|
console.log(`\n${render.wrap(render.c.bold, "🪝 flair hook status")}\n`);
|
|
4400
4645
|
console.log(` ${render.wrap(render.c.dim, "Harness:")} ${status.harness}`);
|
|
4401
4646
|
console.log(` ${render.wrap(render.c.dim, "Config:")} ${status.path}`);
|
|
@@ -4407,6 +4652,7 @@ hook
|
|
|
4407
4652
|
if (!status.wired) {
|
|
4408
4653
|
console.log(` ${render.icons.error} not wired`);
|
|
4409
4654
|
console.log(` ${render.wrap(render.c.dim, "Fix:")} flair hook install`);
|
|
4655
|
+
renderContinuity();
|
|
4410
4656
|
console.log("");
|
|
4411
4657
|
process.exit(1);
|
|
4412
4658
|
}
|
|
@@ -4421,6 +4667,7 @@ hook
|
|
|
4421
4667
|
else {
|
|
4422
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`);
|
|
4423
4669
|
}
|
|
4670
|
+
renderContinuity();
|
|
4424
4671
|
console.log("");
|
|
4425
4672
|
});
|
|
4426
4673
|
// ─── flair mcp ───────────────────────────────────────────────────────────────
|
|
@@ -6415,6 +6662,11 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6415
6662
|
return await syncRes.json();
|
|
6416
6663
|
}
|
|
6417
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;
|
|
6418
6670
|
for (const table of tables) {
|
|
6419
6671
|
let rows = [];
|
|
6420
6672
|
for (const query of [
|
|
@@ -6448,9 +6700,11 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6448
6700
|
// filter only applies there; on the other 3 tables `row.visibility`
|
|
6449
6701
|
// is always undefined, which isFederationPrivateVisibility() treats
|
|
6450
6702
|
// as non-private (included) — a no-op for them.
|
|
6451
|
-
|
|
6452
|
-
|
|
6453
|
-
|
|
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);
|
|
6454
6708
|
}
|
|
6455
6709
|
if (rows.length === 0)
|
|
6456
6710
|
continue;
|
|
@@ -6565,7 +6819,13 @@ export async function runFederationSyncOnce(opts) {
|
|
|
6565
6819
|
catch (pingErr) {
|
|
6566
6820
|
console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
|
|
6567
6821
|
}
|
|
6568
|
-
|
|
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.");
|
|
6569
6829
|
return { pushed: 0, skipped: 0 };
|
|
6570
6830
|
}
|
|
6571
6831
|
console.log(`✅ Synced ${totalMerged} records (${totalSkipped} skipped) across ${totalBatches} batches`);
|
|
@@ -12285,6 +12545,60 @@ program
|
|
|
12285
12545
|
}
|
|
12286
12546
|
}
|
|
12287
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
|
+
}
|
|
12288
12602
|
// 5. Stale PID file (skip if already reported in port check)
|
|
12289
12603
|
const dataDir = defaultDataDir();
|
|
12290
12604
|
const pidFile = join(dataDir, "hdb.pid");
|
|
@@ -12598,6 +12912,73 @@ program
|
|
|
12598
12912
|
}
|
|
12599
12913
|
issues++;
|
|
12600
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
|
+
}
|
|
12601
12982
|
}
|
|
12602
12983
|
}
|
|
12603
12984
|
// 7a. Resolve which agent identities the two verified-read sections below
|
|
@@ -13891,18 +14272,26 @@ sessionSnapshot
|
|
|
13891
14272
|
console.error(` Pass --target <new-path> or remove the existing dir.`);
|
|
13892
14273
|
process.exit(1);
|
|
13893
14274
|
}
|
|
14275
|
+
// flair#903 — fail CLOSED on a tampered archive. node-tar's defaults below
|
|
14276
|
+
// do contain malicious entries (leading "/" stripped, ".." entries
|
|
14277
|
+
// dropped, no writing through a symlink — verified on the pinned tar
|
|
14278
|
+
// against all four vectors), but they discard those entries SILENTLY: the
|
|
14279
|
+
// restore printed the target dir as a plain success minus the parts it
|
|
14280
|
+
// never mentioned. Validation runs BEFORE the target directory is even
|
|
14281
|
+
// created — a tampered snapshot aborts the whole restore, names the
|
|
14282
|
+
// offending entry, and writes nothing (same posture as the data-dir
|
|
14283
|
+
// restore's extractSnapshotSafely). The default extract flags stay as
|
|
14284
|
+
// containment defense-in-depth — deliberately NOT preservePaths; if that
|
|
14285
|
+
// flag is ever added here, this call MUST move to extractSnapshotSafely.
|
|
14286
|
+
// See src/lib/safe-snapshot-extract.ts.
|
|
14287
|
+
try {
|
|
14288
|
+
await validateSnapshotArchive({ file: snapshotPath, targetDir });
|
|
14289
|
+
}
|
|
14290
|
+
catch (err) {
|
|
14291
|
+
console.error(`Error: ${err.message}`);
|
|
14292
|
+
process.exit(1);
|
|
14293
|
+
}
|
|
13894
14294
|
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
13895
|
-
// Deliberately NOT preservePaths, and deliberately NOT routed through
|
|
13896
|
-
// extractSnapshotSafely. --snapshot is an operator-supplied path, so
|
|
13897
|
-
// provenance here is no more controlled than the data-dir restore's — the
|
|
13898
|
-
// difference is the flag, not the trust. node-tar's defaults keep their
|
|
13899
|
-
// own containment: leading "/" stripped from entry paths, ".." entries
|
|
13900
|
-
// dropped, and no writing through a symlink (including one created
|
|
13901
|
-
// earlier in the same archive). Verified against the pinned tar (7.5.20)
|
|
13902
|
-
// on all four cases, each contained, with a benign control entry landing
|
|
13903
|
-
// to prove the archives parsed. Add `preservePaths` here and that
|
|
13904
|
-
// containment disappears — this call would then need extractSnapshotSafely,
|
|
13905
|
-
// exactly as the data-dir restore does. See src/lib/safe-snapshot-extract.ts.
|
|
13906
14295
|
await tarExtract({ file: snapshotPath, cwd: targetDir });
|
|
13907
14296
|
console.log(targetDir);
|
|
13908
14297
|
console.error(` extracted to: ${targetDir}`);
|