@tpsdev-ai/flair 0.44.3 → 0.44.4
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 +125 -41
- package/dist/lib/signing-identity.js +97 -0
- package/dist/resources/Memory.js +9 -0
- package/dist/resources/MemoryBootstrap.js +67 -1
- package/dist/resources/mcp-tools.js +54 -15
- package/dist/resources/record-type-kit.js +32 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,7 @@ import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMc
|
|
|
26
26
|
import { readClientMcpBlock, checkClaudeMdBootstrap, inspectSessionStartHook, upgradeSessionStartHookCommand, fixClaudeMdBootstrap, fixSessionStartHook, applyOrReportClaudeMdBootstrap, applyOrReportSessionStartHook, resolveWireFlairUrl, planAgentIterations, inferSoleAgentId, fixCommandAgentHint, describeAgentGateFinding, embeddingsSkipRemedy, classifyKeyFile, resolveCollisionSafeName, pruneDateStamp, PRUNED_DIR_NAME, } from "./doctor-client.js";
|
|
27
27
|
import { installHook, uninstallHook, hookStatus, 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
|
+
import { resolveSigningIdentity, emitSigningIdentityDebug, } from "./lib/signing-identity.js";
|
|
29
30
|
import { validateSnapshotArchive, extractSnapshotSafely } from "./lib/safe-snapshot-extract.js";
|
|
30
31
|
import { escapeXml, unescapeXml } from "./lib/xml-escape.js";
|
|
31
32
|
import { assessLaunchdManagement, diagnoseLaunchdPlistPaths, isDetached, pickInstancePid, renderDetachedWarning, renderVerifiedSummary, LAUNCHCTL_QUERY_TIMEOUT_MS, } from "./lib/launchd-management.js";
|
|
@@ -638,10 +639,47 @@ function resolveBaseUrl(opts) {
|
|
|
638
639
|
|| process.env.FLAIR_URL
|
|
639
640
|
|| `http://127.0.0.1:${resolveHttpPort(opts)}`);
|
|
640
641
|
}
|
|
641
|
-
// Resolve agent id from --agent flag or FLAIR_AGENT_ID env.
|
|
642
|
-
//
|
|
642
|
+
// Resolve agent id from --agent flag or FLAIR_AGENT_ID env (flag > env).
|
|
643
|
+
// The low-level helper with no debug line — used where only the flag/env pair
|
|
644
|
+
// is wanted (e.g. the upgrade path that then applies its own key-dir floor).
|
|
645
|
+
// Delegates to the canonical core so the flag>env precedence lives in exactly
|
|
646
|
+
// one place (flair#1183). Returns null if neither is set; caller decides
|
|
647
|
+
// whether that's fatal.
|
|
643
648
|
function resolveAgentIdOrEnv(opts) {
|
|
644
|
-
return opts.
|
|
649
|
+
return resolveSigningIdentity(opts).agentId;
|
|
650
|
+
}
|
|
651
|
+
// ── The ONE signing-identity seam every user-facing command family calls ──────
|
|
652
|
+
//
|
|
653
|
+
// Precedence (flair#1183): --agent flag > FLAIR_AGENT_ID env > config profile.
|
|
654
|
+
//
|
|
655
|
+
// This seam pins the top two tiers — the flag and the env — and resolves the
|
|
656
|
+
// signer ONCE, at the command boundary, threading the result down to
|
|
657
|
+
// api()/authedRequest as an AUTHORITATIVE value. That is the whole fix: a lower
|
|
658
|
+
// layer must never re-derive the signer from the environment behind the
|
|
659
|
+
// command's back (api() used to, signing as FLAIR_AGENT_ID even when --agent
|
|
660
|
+
// named someone else).
|
|
661
|
+
//
|
|
662
|
+
// The third tier — the "config profile" — is the machine's ambient credential
|
|
663
|
+
// (the ~/.flair/admin-pass file and the Ed25519 agent-key FLOOR), applied BELOW
|
|
664
|
+
// this by authedRequest (src/lib/auth-resolve.ts tiers 4-5) when this seam
|
|
665
|
+
// resolves nothing. It is deliberately NOT a name lookup here: making a
|
|
666
|
+
// forgotten --agent silently resolve to some configured identity is the exact
|
|
667
|
+
// silent-substitution this issue is about, and it would also turn every
|
|
668
|
+
// "identity required" command into one that guesses. So when neither flag nor
|
|
669
|
+
// env is set this returns null, and the caller either demands an explicit
|
|
670
|
+
// identity (agent-scoped commands) or lets authedRequest apply the ambient
|
|
671
|
+
// config-profile credential. Emits the FLAIR_DEBUG line naming the resolved
|
|
672
|
+
// agentId + which source won, so operator, CLI, and server cannot silently
|
|
673
|
+
// disagree about who's calling.
|
|
674
|
+
function resolveSigningIdentityFor(opts, command) {
|
|
675
|
+
const resolved = resolveSigningIdentity(opts);
|
|
676
|
+
emitSigningIdentityDebug(resolved, command);
|
|
677
|
+
return resolved;
|
|
678
|
+
}
|
|
679
|
+
// Same seam, returning just the agentId (or null) for the common call site that
|
|
680
|
+
// only needs the id. Still emits the debug line via resolveSigningIdentityFor.
|
|
681
|
+
function resolveSigningAgentId(opts, command) {
|
|
682
|
+
return resolveSigningIdentityFor(opts, command).agentId;
|
|
645
683
|
}
|
|
646
684
|
// Ops port resolution: --ops-port flag > FLAIR_OPS_PORT env > config opsPort > httpPort - 1
|
|
647
685
|
//
|
|
@@ -1336,10 +1374,23 @@ function b64url(bytes) {
|
|
|
1336
1374
|
* api() resolves the request's Harper HTTP/REST auth via the shared
|
|
1337
1375
|
* `authedRequest` (src/lib/auth-resolve.ts — see that module's header for
|
|
1338
1376
|
* the full 5-tier resolution order, including tier 5, the Ed25519 agent-key
|
|
1339
|
-
* FLOOR this used to lack entirely).
|
|
1340
|
-
*
|
|
1341
|
-
*
|
|
1342
|
-
*
|
|
1377
|
+
* FLOOR this used to lack entirely).
|
|
1378
|
+
*
|
|
1379
|
+
* SIGNING IDENTITY (flair#1183): a caller that has already resolved the signer
|
|
1380
|
+
* — every user-facing command does, via `resolveSigningAgentId` (flag > env >
|
|
1381
|
+
* config profile) — passes it as `options.agentId`, and that value is
|
|
1382
|
+
* AUTHORITATIVE. api() does NOT re-derive it from the environment in that case.
|
|
1383
|
+
* That re-derivation was the bug: api() used to compute the signer as
|
|
1384
|
+
* `FLAIR_AGENT_ID env > body.agentId`, so a command run with `--agent X` while
|
|
1385
|
+
* `FLAIR_AGENT_ID=Y` was exported signed as Y — inverting the documented
|
|
1386
|
+
* precedence and silently disagreeing with the record/query, which both named X.
|
|
1387
|
+
*
|
|
1388
|
+
* Only when the caller passes NO agentId (the `"agentId" in options` check
|
|
1389
|
+
* distinguishes an omitted key from an explicit `null`) does api() fall back to
|
|
1390
|
+
* the legacy request-shape extraction — FLAIR_AGENT_ID env, then an agentId
|
|
1391
|
+
* embedded in the body/query string. That path serves the admin/federation
|
|
1392
|
+
* callers that resolve no identity of their own and lean on admin-pass / the
|
|
1393
|
+
* floor, so their behavior is unchanged.
|
|
1343
1394
|
*
|
|
1344
1395
|
* NOTE: this function is for the Harper HTTP/REST API only. The Harper
|
|
1345
1396
|
* operations API (used by seedAgentViaOpsApi / seedFederationInstanceViaOpsApi)
|
|
@@ -1355,14 +1406,24 @@ async function api(method, path, body, options) {
|
|
|
1355
1406
|
// Resolve port via the canonical path (flair#1129): options.baseUrl > FLAIR_URL > resolveHttpPort.
|
|
1356
1407
|
// api() callers mean the default install, so resolveHttpPort({}) with no --data-dir is correct.
|
|
1357
1408
|
const base = options?.baseUrl ?? (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`);
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1409
|
+
let agentId;
|
|
1410
|
+
if (options && "agentId" in options) {
|
|
1411
|
+
// The caller resolved the signing identity (flag > env > config profile)
|
|
1412
|
+
// and it is AUTHORITATIVE — never override it with FLAIR_AGENT_ID here
|
|
1413
|
+
// (that inversion is flair#1183). An explicit null means "no identity
|
|
1414
|
+
// resolved"; honor it and let authedRequest fall to admin-pass / the floor.
|
|
1415
|
+
agentId = options.agentId ?? undefined;
|
|
1416
|
+
}
|
|
1417
|
+
else {
|
|
1418
|
+
// Legacy request-shape extraction for callers that resolve no identity of
|
|
1419
|
+
// their own (admin/federation ops): FLAIR_AGENT_ID env, then the body
|
|
1420
|
+
// (POST/PUT) / URL query params (GET).
|
|
1421
|
+
agentId = process.env.FLAIR_AGENT_ID || (body && typeof body === "object" ? body.agentId : undefined);
|
|
1422
|
+
if (!agentId && path.includes("agentId=")) {
|
|
1423
|
+
const match = path.match(/agentId=([^&]+)/);
|
|
1424
|
+
if (match)
|
|
1425
|
+
agentId = decodeURIComponent(match[1]);
|
|
1426
|
+
}
|
|
1366
1427
|
}
|
|
1367
1428
|
return authedRequest(method, path, body, { baseUrl: base, agentId, keysDir: options?.keysDir });
|
|
1368
1429
|
}
|
|
@@ -7949,7 +8010,12 @@ export async function discoverLocalFlairPort(originalUrl) {
|
|
|
7949
8010
|
}
|
|
7950
8011
|
return null;
|
|
7951
8012
|
}
|
|
7952
|
-
|
|
8013
|
+
// flair#1183: `signingAgentIdOverride` lets a calling command that already
|
|
8014
|
+
// resolved a signing identity via the canonical seam (resolveSigningAgentId)
|
|
8015
|
+
// pass it in, so this verified read signs as the SAME agent the rest of the
|
|
8016
|
+
// command does. Undefined = resolve locally via the legacy flag>env pair (all
|
|
8017
|
+
// other callers, unchanged).
|
|
8018
|
+
async function fetchHealthDetail(opts, signingAgentIdOverride) {
|
|
7953
8019
|
const port = resolveHttpPort(opts);
|
|
7954
8020
|
// --target takes precedence, then --url, then FLAIR_TARGET, then FLAIR_URL, then localhost
|
|
7955
8021
|
const baseUrl = opts.target || opts.url || process.env.FLAIR_TARGET || (process.env.FLAIR_URL ?? `http://127.0.0.1:${port}`);
|
|
@@ -7982,7 +8048,9 @@ async function fetchHealthDetail(opts) {
|
|
|
7982
8048
|
try {
|
|
7983
8049
|
healthData = await authedRequest("GET", "/HealthDetail", undefined, {
|
|
7984
8050
|
baseUrl,
|
|
7985
|
-
agentId:
|
|
8051
|
+
agentId: signingAgentIdOverride !== undefined
|
|
8052
|
+
? (signingAgentIdOverride ?? undefined)
|
|
8053
|
+
: (opts.agent || process.env.FLAIR_AGENT_ID),
|
|
7986
8054
|
});
|
|
7987
8055
|
}
|
|
7988
8056
|
catch {
|
|
@@ -8001,7 +8069,8 @@ const statusCmd = program
|
|
|
8001
8069
|
.option("--json", "Output as JSON")
|
|
8002
8070
|
.option("--agent <id>", "Agent ID for authenticated detail (or set FLAIR_AGENT_ID)")
|
|
8003
8071
|
.action(async (opts) => {
|
|
8004
|
-
const
|
|
8072
|
+
const statusAgentId = resolveSigningAgentId(opts, "status");
|
|
8073
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, statusAgentId);
|
|
8005
8074
|
// When unreachable on a localhost URL, probe candidate ports to detect
|
|
8006
8075
|
// config-vs-daemon port drift. Surface the actually-listening
|
|
8007
8076
|
// port with a fix recipe — better UX than just "unreachable."
|
|
@@ -12861,7 +12930,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
12861
12930
|
let all;
|
|
12862
12931
|
try {
|
|
12863
12932
|
const q = new URLSearchParams({ agentId }).toString();
|
|
12864
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl });
|
|
12933
|
+
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
12865
12934
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
12866
12935
|
}
|
|
12867
12936
|
catch (err) {
|
|
@@ -12888,7 +12957,7 @@ async function fetchRecallSpotCheckData(agentId, baseUrl, opts = {}) {
|
|
|
12888
12957
|
const id = String(m.id);
|
|
12889
12958
|
const cue = deriveRecallCue(m);
|
|
12890
12959
|
const body = { agentId, q: cue, limit: k };
|
|
12891
|
-
const res = await api("POST", "/SemanticSearch", body, { baseUrl });
|
|
12960
|
+
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
12892
12961
|
const results = Array.isArray(res) ? res : (res?.results ?? []);
|
|
12893
12962
|
sampledIds.push(id);
|
|
12894
12963
|
perQueryResultIds.push(results.map((r) => String(r.id)));
|
|
@@ -13103,7 +13172,7 @@ async function fetchPreviousQualitySnapshot(agentId, baseUrl, subject) {
|
|
|
13103
13172
|
let all;
|
|
13104
13173
|
try {
|
|
13105
13174
|
const q = new URLSearchParams({ agentId }).toString();
|
|
13106
|
-
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl });
|
|
13175
|
+
const raw = await api("GET", `/Memory?${q}`, undefined, { baseUrl, agentId });
|
|
13107
13176
|
all = Array.isArray(raw) ? raw : (raw?.results ?? raw?.items ?? []);
|
|
13108
13177
|
}
|
|
13109
13178
|
catch {
|
|
@@ -13146,7 +13215,7 @@ async function storeQualitySnapshot(agentId, baseUrl, subject, snapshot) {
|
|
|
13146
13215
|
type: "quality-snapshot",
|
|
13147
13216
|
createdAt: new Date().toISOString(),
|
|
13148
13217
|
};
|
|
13149
|
-
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl });
|
|
13218
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { baseUrl, agentId });
|
|
13150
13219
|
if (out?.error)
|
|
13151
13220
|
throw new Error(String(out.error));
|
|
13152
13221
|
return memId;
|
|
@@ -13162,8 +13231,8 @@ program
|
|
|
13162
13231
|
.option("--agent <id>", "Scope per-agent metrics to one agent id (or set FLAIR_AGENT_ID); default = all agents")
|
|
13163
13232
|
.option("--emit", "Slice 2: snapshot this report, diff it against the previous quality-snapshot memory, and emit OrgEvents (quality.threshold_crossed / quality.regression) for any crossings/regressions found. Requires an agent identity (--agent or FLAIR_AGENT_ID) — the opt-in write boundary; without this flag `flair quality` remains fully read-only")
|
|
13164
13233
|
.action(async (opts) => {
|
|
13165
|
-
const
|
|
13166
|
-
const
|
|
13234
|
+
const agentId = resolveSigningAgentId(opts, "quality");
|
|
13235
|
+
const { healthy, baseUrl, healthData } = await fetchHealthDetail(opts, agentId);
|
|
13167
13236
|
if (opts.emit && !agentId) {
|
|
13168
13237
|
console.error("Error: --emit requires an agent identity. Pass --agent <id> or set FLAIR_AGENT_ID.");
|
|
13169
13238
|
process.exit(1);
|
|
@@ -13549,6 +13618,7 @@ memory.command("add [content]")
|
|
|
13549
13618
|
console.error("error: content required (positional arg or --content)");
|
|
13550
13619
|
process.exit(1);
|
|
13551
13620
|
}
|
|
13621
|
+
const agentId = resolveSigningAgentId(opts, "memory add") ?? opts.agent;
|
|
13552
13622
|
const memId = `${opts.agent}-${Date.now()}`;
|
|
13553
13623
|
const body = {
|
|
13554
13624
|
id: memId, agentId: opts.agent, content, durability: opts.durability || "standard",
|
|
@@ -13577,7 +13647,7 @@ memory.command("add [content]")
|
|
|
13577
13647
|
if (opts.derivedFrom) {
|
|
13578
13648
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
13579
13649
|
}
|
|
13580
|
-
const out = await api("PUT", `/Memory/${memId}`, body);
|
|
13650
|
+
const out = await api("PUT", `/Memory/${memId}`, body, { agentId });
|
|
13581
13651
|
console.log(JSON.stringify(out, null, 2));
|
|
13582
13652
|
});
|
|
13583
13653
|
// ─── flair memory write-task-summary ────────────────────────────────────────
|
|
@@ -13632,6 +13702,7 @@ memory.command("write-task-summary")
|
|
|
13632
13702
|
lines.push(opts.summary);
|
|
13633
13703
|
}
|
|
13634
13704
|
const content = lines.join("\n");
|
|
13705
|
+
const agentId = resolveSigningAgentId(opts, "memory write-task-summary") ?? opts.agent;
|
|
13635
13706
|
const memId = `${opts.agent}-task-${opts.beads}-${Date.now()}`;
|
|
13636
13707
|
const body = {
|
|
13637
13708
|
id: memId,
|
|
@@ -13648,7 +13719,7 @@ memory.command("write-task-summary")
|
|
|
13648
13719
|
if (opts.derivedFrom) {
|
|
13649
13720
|
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
13650
13721
|
}
|
|
13651
|
-
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body);
|
|
13722
|
+
const out = await api("PUT", `/Memory/${encodeURIComponent(memId)}`, body, { agentId });
|
|
13652
13723
|
if (out?.error) {
|
|
13653
13724
|
console.error(`Error writing task summary: ${out.error}`);
|
|
13654
13725
|
process.exit(1);
|
|
@@ -13667,7 +13738,7 @@ memory.command("search [query]")
|
|
|
13667
13738
|
.option("--url <url>", "Flair base URL (overrides --port)")
|
|
13668
13739
|
.option("--port <port>", "Harper HTTP port")
|
|
13669
13740
|
.action(async (queryArg, opts) => {
|
|
13670
|
-
const agentId =
|
|
13741
|
+
const agentId = resolveSigningAgentId(opts, "memory search");
|
|
13671
13742
|
if (!agentId) {
|
|
13672
13743
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
13673
13744
|
process.exit(2);
|
|
@@ -13681,7 +13752,7 @@ memory.command("search [query]")
|
|
|
13681
13752
|
if (opts.tag)
|
|
13682
13753
|
body.tag = opts.tag;
|
|
13683
13754
|
const baseUrl = resolveBaseUrl(opts);
|
|
13684
|
-
const res = await api("POST", "/SemanticSearch", body, { baseUrl });
|
|
13755
|
+
const res = await api("POST", "/SemanticSearch", body, { baseUrl, agentId });
|
|
13685
13756
|
console.log(JSON.stringify(res, null, 2));
|
|
13686
13757
|
});
|
|
13687
13758
|
memory.command("list")
|
|
@@ -13692,13 +13763,13 @@ memory.command("list")
|
|
|
13692
13763
|
.option("--limit <n>", "Max rows when using --hash-fallback", "50")
|
|
13693
13764
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
13694
13765
|
.action(async (opts) => {
|
|
13695
|
-
const agentId =
|
|
13766
|
+
const agentId = resolveSigningAgentId(opts, "memory list");
|
|
13696
13767
|
if (!agentId) {
|
|
13697
13768
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
13698
13769
|
process.exit(2);
|
|
13699
13770
|
}
|
|
13700
13771
|
const q = new URLSearchParams({ agentId, ...(opts.tag ? { tag: opts.tag } : {}) }).toString();
|
|
13701
|
-
const raw = await api("GET", `/Memory?${q}
|
|
13772
|
+
const raw = await api("GET", `/Memory?${q}`, undefined, { agentId });
|
|
13702
13773
|
const mode = render.resolveOutputMode(opts);
|
|
13703
13774
|
// hashFallback flag changes the lens: instead of all memories, show
|
|
13704
13775
|
// only those that need re-embedding. Keep that surface separate.
|
|
@@ -14030,7 +14101,7 @@ program
|
|
|
14030
14101
|
.option("--json", "Output raw JSON array")
|
|
14031
14102
|
.action(async (query, opts) => {
|
|
14032
14103
|
try {
|
|
14033
|
-
const agentId =
|
|
14104
|
+
const agentId = resolveSigningAgentId(opts, "search");
|
|
14034
14105
|
if (!agentId) {
|
|
14035
14106
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
14036
14107
|
process.exit(2);
|
|
@@ -14190,7 +14261,7 @@ program
|
|
|
14190
14261
|
.option("--key <path>", "Ed25519 private key path")
|
|
14191
14262
|
.option("--json", "Emit JSON {context, tokenEstimate, memoriesIncluded, ...} (also: pipe + FLAIR_OUTPUT=json)")
|
|
14192
14263
|
.action(async (opts) => {
|
|
14193
|
-
const agentId =
|
|
14264
|
+
const agentId = resolveSigningAgentId(opts, "bootstrap");
|
|
14194
14265
|
if (!agentId) {
|
|
14195
14266
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
14196
14267
|
process.exit(2);
|
|
@@ -14279,6 +14350,7 @@ relationship.command("add")
|
|
|
14279
14350
|
.option("--valid-to <iso>", "ISO timestamp this relationship ended (leave unset for an active relationship)")
|
|
14280
14351
|
.option("--source <text>", "Where this was learned from (a memory ID, conversation, etc.)")
|
|
14281
14352
|
.action(async (opts) => {
|
|
14353
|
+
const agentId = resolveSigningAgentId(opts, "relationship add") ?? opts.agent;
|
|
14282
14354
|
const id = canonicalRelationshipId(opts.agent, opts.subject, opts.predicate, opts.object);
|
|
14283
14355
|
const body = {
|
|
14284
14356
|
id,
|
|
@@ -14295,7 +14367,7 @@ relationship.command("add")
|
|
|
14295
14367
|
body.validTo = opts.validTo;
|
|
14296
14368
|
if (opts.source)
|
|
14297
14369
|
body.source = opts.source;
|
|
14298
|
-
const out = await api("PUT", `/Relationship/${id}`, body);
|
|
14370
|
+
const out = await api("PUT", `/Relationship/${id}`, body, { agentId });
|
|
14299
14371
|
console.log(JSON.stringify(out, null, 2));
|
|
14300
14372
|
});
|
|
14301
14373
|
const soul = program.command("soul").description("Manage agent soul entries");
|
|
@@ -14310,6 +14382,13 @@ soul.command("set")
|
|
|
14310
14382
|
// PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
|
|
14311
14383
|
// The Soul table resource has no POST handler, so a collection POST /Soul
|
|
14312
14384
|
// 405s; the record must be written by its primary key. (#498)
|
|
14385
|
+
//
|
|
14386
|
+
// flair#1183: resolve the SIGNING identity through the canonical seam and
|
|
14387
|
+
// thread it to api(). --agent is required, so the flag always wins the
|
|
14388
|
+
// precedence — but before this, api() re-derived the signer as
|
|
14389
|
+
// FLAIR_AGENT_ID-first, so `soul set --agent X` with FLAIR_AGENT_ID=Y set
|
|
14390
|
+
// wrote a record owned by X while signing as Y (the soul family's stale rung).
|
|
14391
|
+
const agentId = resolveSigningAgentId(opts, "soul set") ?? opts.agent;
|
|
14313
14392
|
const id = `${opts.agent}:${opts.key}`;
|
|
14314
14393
|
const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
|
|
14315
14394
|
id,
|
|
@@ -14318,7 +14397,7 @@ soul.command("set")
|
|
|
14318
14397
|
value: opts.value,
|
|
14319
14398
|
durability: opts.durability,
|
|
14320
14399
|
createdAt: new Date().toISOString(),
|
|
14321
|
-
});
|
|
14400
|
+
}, { agentId });
|
|
14322
14401
|
const mode = render.resolveOutputMode(opts);
|
|
14323
14402
|
if (mode === "json") {
|
|
14324
14403
|
console.log(render.asJSON(out));
|
|
@@ -14334,9 +14413,14 @@ soul.command("set")
|
|
|
14334
14413
|
soul.command("get")
|
|
14335
14414
|
.description("Fetch a single soul entry by id (agent:key)")
|
|
14336
14415
|
.argument("<id>")
|
|
14416
|
+
.option("--agent <id>", "Agent ID to sign the read as (or set FLAIR_AGENT_ID); falls back to the config-profile agent")
|
|
14337
14417
|
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
14338
14418
|
.action(async (id, opts) => {
|
|
14339
|
-
|
|
14419
|
+
// flair#1183: /Soul reads are verified (any registered agent). Resolve the
|
|
14420
|
+
// signer through the canonical seam so soul get honors the SAME precedence
|
|
14421
|
+
// as every other family; a null result lets api() fall to admin-pass/floor.
|
|
14422
|
+
const agentId = resolveSigningAgentId(opts, "soul get");
|
|
14423
|
+
const out = await api("GET", `/Soul/${id}`, undefined, { agentId });
|
|
14340
14424
|
const mode = render.resolveOutputMode(opts);
|
|
14341
14425
|
if (mode === "json") {
|
|
14342
14426
|
console.log(render.asJSON(out));
|
|
@@ -14368,12 +14452,12 @@ soul.command("list")
|
|
|
14368
14452
|
.option("--agent <id>", "Agent ID (or set FLAIR_AGENT_ID env)")
|
|
14369
14453
|
.option("--json", "Emit raw JSON array (also: pipe + FLAIR_OUTPUT=json)")
|
|
14370
14454
|
.action(async (opts) => {
|
|
14371
|
-
const agentId =
|
|
14455
|
+
const agentId = resolveSigningAgentId(opts, "soul list");
|
|
14372
14456
|
if (!agentId) {
|
|
14373
14457
|
console.error(`${render.icons.error} --agent <id> required (or set FLAIR_AGENT_ID)`);
|
|
14374
14458
|
process.exit(2);
|
|
14375
14459
|
}
|
|
14376
|
-
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}
|
|
14460
|
+
const out = await api("GET", `/Soul?agentId=${encodeURIComponent(agentId)}`, undefined, { agentId });
|
|
14377
14461
|
const mode = render.resolveOutputMode(opts);
|
|
14378
14462
|
if (mode === "json") {
|
|
14379
14463
|
console.log(render.asJSON(out));
|
|
@@ -15735,7 +15819,7 @@ presence
|
|
|
15735
15819
|
.option("--port <port>", "Harper HTTP port")
|
|
15736
15820
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
15737
15821
|
.action(async (opts) => {
|
|
15738
|
-
const agentId =
|
|
15822
|
+
const agentId = resolveSigningAgentId(opts, "presence set");
|
|
15739
15823
|
if (!agentId) {
|
|
15740
15824
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
15741
15825
|
process.exit(1);
|
|
@@ -15820,7 +15904,7 @@ workspace
|
|
|
15820
15904
|
.option("--port <port>", "Harper HTTP port")
|
|
15821
15905
|
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
15822
15906
|
.action(async (opts) => {
|
|
15823
|
-
const agentId =
|
|
15907
|
+
const agentId = resolveSigningAgentId(opts, "workspace set");
|
|
15824
15908
|
if (!agentId) {
|
|
15825
15909
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
15826
15910
|
process.exit(1);
|
|
@@ -15962,7 +16046,7 @@ program
|
|
|
15962
16046
|
.option("--port <port>", "Harper HTTP port")
|
|
15963
16047
|
.option("--target-url <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
15964
16048
|
.action(async (opts) => {
|
|
15965
|
-
const agentId =
|
|
16049
|
+
const agentId = resolveSigningAgentId(opts, "orgevent");
|
|
15966
16050
|
if (!agentId) {
|
|
15967
16051
|
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
15968
16052
|
process.exit(1);
|
|
@@ -16028,7 +16112,7 @@ program
|
|
|
16028
16112
|
.option("--json", "Output raw JSON")
|
|
16029
16113
|
.action(async (entity, opts) => {
|
|
16030
16114
|
try {
|
|
16031
|
-
const agentId =
|
|
16115
|
+
const agentId = resolveSigningAgentId(opts, "attention");
|
|
16032
16116
|
if (!agentId) {
|
|
16033
16117
|
console.error("error: --agent <id> required (or set FLAIR_AGENT_ID)");
|
|
16034
16118
|
process.exit(2);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* signing-identity.ts — the ONE canonical signing-identity resolver (flair#1183).
|
|
3
|
+
*
|
|
4
|
+
* Before this file existed, every CLI command family answered "which agent am
|
|
5
|
+
* I signing this request AS?" with its own chain, and they disagreed:
|
|
6
|
+
*
|
|
7
|
+
* - `search`/`bootstrap`/`status`/`presence`/`workspace` resolved
|
|
8
|
+
* `--agent flag > FLAIR_AGENT_ID env` (via `resolveAgentIdOrEnv`), then
|
|
9
|
+
* hand-signed with that id.
|
|
10
|
+
* - Everything routed through `api()` (memory search/list, soul list, and
|
|
11
|
+
* the writes) RE-derived the signer inside `api()` as
|
|
12
|
+
* `FLAIR_AGENT_ID env > body.agentId`. That inverts the precedence: a
|
|
13
|
+
* command the operator ran with `--agent X` while `FLAIR_AGENT_ID=Y` was
|
|
14
|
+
* exported in the shell SIGNED as Y, even though the record it wrote and
|
|
15
|
+
* the query it filtered both named X. Against a remote target where Y is
|
|
16
|
+
* not registered, the server answered `unknown_agent` — three parties
|
|
17
|
+
* (operator, CLI, server) silently disagreeing about who was calling.
|
|
18
|
+
* - The `soul` family had NO env/flag resolution of its own at all: it put
|
|
19
|
+
* `--agent` into the request body and leaned entirely on `api()`'s
|
|
20
|
+
* env-first extraction, so even the `FLAIR_KEY_DIR` workaround that forced
|
|
21
|
+
* the identity on the other commands could not steer it. That was the
|
|
22
|
+
* worst rung — the "stale rung" the issue calls out.
|
|
23
|
+
*
|
|
24
|
+
* This module makes the precedence ONE thing, documented and testable:
|
|
25
|
+
*
|
|
26
|
+
* ── Resolution order ──────────────────────────────────────────────────────
|
|
27
|
+
* 1. FLAG — an explicit `--agent <id>`. The operator naming an identity on
|
|
28
|
+
* THIS invocation always wins.
|
|
29
|
+
* 2. ENV — `FLAIR_AGENT_ID`. Ambient but still a deliberate operator/CI
|
|
30
|
+
* choice for the session.
|
|
31
|
+
* 3. CONFIG — the "config profile": the machine's ambient signing credential
|
|
32
|
+
* (the `~/.flair/admin-pass` file and the Ed25519 agent-key floor
|
|
33
|
+
* under `~/.flair/keys`). This tier is applied DOWNSTREAM by
|
|
34
|
+
* `authedRequest` (src/lib/auth-resolve.ts, tiers 4-5), not by a
|
|
35
|
+
* name lookup here — a forgotten `--agent` must never silently
|
|
36
|
+
* resolve to some other configured identity. `resolveSigningIdentity`
|
|
37
|
+
* models this tier via its `configProfileAgentId` parameter so the
|
|
38
|
+
* full documented precedence is expressible and unit-testable; the
|
|
39
|
+
* CLI leaves it unset and delegates the tier to `authedRequest`.
|
|
40
|
+
* 4. NONE — nothing resolved by flag/env; the caller decides whether that is
|
|
41
|
+
* fatal (agent-scoped commands demand an explicit identity) or
|
|
42
|
+
* whether to let `authedRequest` apply the tier-3 ambient credential.
|
|
43
|
+
*
|
|
44
|
+
* The signer is resolved ONCE, at the command boundary, and threaded down to
|
|
45
|
+
* `api()`/`authedRequest` as an authoritative value — `api()` no longer
|
|
46
|
+
* re-derives it from the environment behind the caller's back.
|
|
47
|
+
*/
|
|
48
|
+
/**
|
|
49
|
+
* The canonical resolver. Pure: every input is a parameter, so precedence is
|
|
50
|
+
* testable in isolation with no filesystem or process state.
|
|
51
|
+
*
|
|
52
|
+
* `configProfileAgentId` is the tier-3 value (the machine's wired agent id);
|
|
53
|
+
* pass `undefined`/`null` when there is no config profile, or when the caller
|
|
54
|
+
* deliberately does not consult one. `env` is injectable for tests; it
|
|
55
|
+
* defaults to `process.env`.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveSigningIdentity(opts, configProfileAgentId, env = process.env) {
|
|
58
|
+
if (opts.agent)
|
|
59
|
+
return { agentId: opts.agent, source: "flag" };
|
|
60
|
+
const envId = env.FLAIR_AGENT_ID;
|
|
61
|
+
if (envId)
|
|
62
|
+
return { agentId: envId, source: "env" };
|
|
63
|
+
if (configProfileAgentId)
|
|
64
|
+
return { agentId: configProfileAgentId, source: "config" };
|
|
65
|
+
return { agentId: null, source: "none" };
|
|
66
|
+
}
|
|
67
|
+
/** Human label for a source, used in the debug line and error text. */
|
|
68
|
+
export function describeSigningIdentitySource(source) {
|
|
69
|
+
switch (source) {
|
|
70
|
+
case "flag": return "--agent flag";
|
|
71
|
+
case "env": return "FLAIR_AGENT_ID env";
|
|
72
|
+
case "config": return "config profile";
|
|
73
|
+
case "none": return "no --agent flag, FLAIR_AGENT_ID env, or config-profile agent";
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The one-line diagnostic. Names the resolved agentId AND which tier won, so
|
|
78
|
+
* an operator can see — without guessing — who the CLI is about to sign as.
|
|
79
|
+
* Pure string builder; the gate + write live in `emitSigningIdentityDebug`.
|
|
80
|
+
*/
|
|
81
|
+
export function formatSigningIdentityDebug(resolved, command) {
|
|
82
|
+
const where = command ? ` for '${command}'` : "";
|
|
83
|
+
if (resolved.agentId === null) {
|
|
84
|
+
return `[flair] signing identity${where}: <none> — ${describeSigningIdentitySource("none")} resolved`;
|
|
85
|
+
}
|
|
86
|
+
return `[flair] signing identity${where}: ${resolved.agentId} (source: ${describeSigningIdentitySource(resolved.source)})`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Emit the debug line to stderr, gated behind `FLAIR_DEBUG` (any non-empty
|
|
90
|
+
* value). stderr, never stdout, so `--json` output stays clean and pipeable.
|
|
91
|
+
* `env` and `write` are injectable for tests.
|
|
92
|
+
*/
|
|
93
|
+
export function emitSigningIdentityDebug(resolved, command, env = process.env, write = (s) => { process.stderr.write(s); }) {
|
|
94
|
+
if (!env.FLAIR_DEBUG)
|
|
95
|
+
return;
|
|
96
|
+
write(formatSigningIdentityDebug(resolved, command) + "\n");
|
|
97
|
+
}
|
package/dist/resources/Memory.js
CHANGED
|
@@ -23,6 +23,15 @@ import { recordCitations } from "./usage-recording.js";
|
|
|
23
23
|
function wantsTrust(target, opts) {
|
|
24
24
|
if (opts?.includeTrust === true)
|
|
25
25
|
return true;
|
|
26
|
+
// flair#1181 — the in-process STATIC by-id read (resources/mcp-tools.ts
|
|
27
|
+
// memory_get) folds includeTrust into the RequestTarget as a plain property,
|
|
28
|
+
// because Harper's static `Cls.get(target, context)` has no opts slot (arg 2
|
|
29
|
+
// is the context, not opts). This is the in-process analog of the two shapes
|
|
30
|
+
// below; a real RequestTarget from the HTTP path never carries a plain
|
|
31
|
+
// `includeTrust` property (it lives in the query string, read via `.get`),
|
|
32
|
+
// so this is purely additive and does not affect the HTTP path.
|
|
33
|
+
if (target?.includeTrust === true)
|
|
34
|
+
return true;
|
|
26
35
|
const raw = target?.get?.("includeTrust") ??
|
|
27
36
|
target?.searchParams?.get?.("includeTrust") ??
|
|
28
37
|
undefined;
|
|
@@ -68,7 +68,13 @@ import { bestSemanticSimilarity, evaluateAbstention } from "./abstention.js";
|
|
|
68
68
|
* row's `entities`.
|
|
69
69
|
*
|
|
70
70
|
* Response:
|
|
71
|
-
* { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable
|
|
71
|
+
* { context, sections, tokenEstimate, memoriesIncluded, memoriesAvailable,
|
|
72
|
+
* agentId, scope, soul, memories, predicted[, currentTaskHint] }
|
|
73
|
+
* The self-describing keys (flair#1182 part 1) — `agentId` (resolved caller),
|
|
74
|
+
* `scope` (read model applied to the caller), `soul`/`memories`/`predicted`
|
|
75
|
+
* (the caller's OWN records as structured containers), and `currentTaskHint`
|
|
76
|
+
* (present only when currentTask is absent/blank) — are ALWAYS emitted so a
|
|
77
|
+
* client can tell an empty instance from one that doesn't support them.
|
|
72
78
|
*/
|
|
73
79
|
// Collision surfacing (flair#681) tunables.
|
|
74
80
|
const COLLISION_WINDOW_DAYS = 7;
|
|
@@ -187,6 +193,27 @@ export class BootstrapMemories extends Resource {
|
|
|
187
193
|
let memoriesIncluded = 0;
|
|
188
194
|
let memoriesAvailable = 0;
|
|
189
195
|
let memoriesTruncated = 0;
|
|
196
|
+
// flair#1182 (part 1) — self-describing bootstrap. These structured
|
|
197
|
+
// container keys are ALWAYS emitted on the response (empty `{}`/`[]` when
|
|
198
|
+
// the caller has nothing), so a client can tell an *empty* instance from
|
|
199
|
+
// one that doesn't support these keys at all — and can read the caller's
|
|
200
|
+
// own soul/memories as structured data instead of parsing the `context`
|
|
201
|
+
// markdown string. Scoped to the CALLER'S OWN records only
|
|
202
|
+
// (permanent/recent/relevant/predicted are all agentId==self reads);
|
|
203
|
+
// teammate findings stay in `context`/`sections.teammate` and are never
|
|
204
|
+
// duplicated here, so these containers carry no other agent's data.
|
|
205
|
+
const soulMap = {};
|
|
206
|
+
const includedOwnMemories = [];
|
|
207
|
+
const includedPredicted = [];
|
|
208
|
+
const leanMemory = (m, section) => ({
|
|
209
|
+
id: m.id,
|
|
210
|
+
content: m.content,
|
|
211
|
+
durability: m.durability ?? null,
|
|
212
|
+
createdAt: m.createdAt ?? null,
|
|
213
|
+
agentId: m.agentId ?? agentId,
|
|
214
|
+
subject: m.subject ?? null,
|
|
215
|
+
section,
|
|
216
|
+
});
|
|
190
217
|
// --- 1. Soul records (budgeted — prioritized by key importance) ---
|
|
191
218
|
// Soul is who you are, but we still need to respect token budgets.
|
|
192
219
|
// Workspace files (SOUL.md, AGENTS.md) can be massive — they're already
|
|
@@ -210,6 +237,9 @@ export class BootstrapMemories extends Resource {
|
|
|
210
237
|
skillAssignments.push(record);
|
|
211
238
|
continue;
|
|
212
239
|
}
|
|
240
|
+
// flair#1182 — the raw soul container (key→value), independent of the
|
|
241
|
+
// token-budgeted/priority-truncated `sections.soul` lines built below.
|
|
242
|
+
soulMap[record.key] = record.value;
|
|
213
243
|
const line = `**${record.key}:** ${record.value}`;
|
|
214
244
|
const tokens = estimateTokens(line);
|
|
215
245
|
const priority = SOUL_KEY_PRIORITY[record.key] ?? 50;
|
|
@@ -424,6 +454,7 @@ export class BootstrapMemories extends Resource {
|
|
|
424
454
|
const cost = estimateTokens(line);
|
|
425
455
|
if (cost <= tokenBudget) {
|
|
426
456
|
sections.permanent.push(line);
|
|
457
|
+
includedOwnMemories.push(leanMemory(m, "permanent"));
|
|
427
458
|
if (includeTrust)
|
|
428
459
|
includedTrustMemories.push(m);
|
|
429
460
|
tokenBudget -= cost;
|
|
@@ -498,6 +529,7 @@ export class BootstrapMemories extends Resource {
|
|
|
498
529
|
continue;
|
|
499
530
|
}
|
|
500
531
|
sections.recent.push(line);
|
|
532
|
+
includedOwnMemories.push(leanMemory(m, "recent"));
|
|
501
533
|
if (includeTrust)
|
|
502
534
|
includedTrustMemories.push(m);
|
|
503
535
|
recentSpent += cost;
|
|
@@ -540,6 +572,7 @@ export class BootstrapMemories extends Resource {
|
|
|
540
572
|
continue;
|
|
541
573
|
}
|
|
542
574
|
sections.predicted.push(line);
|
|
575
|
+
includedPredicted.push(leanMemory(m, "predicted"));
|
|
543
576
|
if (includeTrust)
|
|
544
577
|
includedTrustMemories.push(m);
|
|
545
578
|
predictedSpent += cost;
|
|
@@ -716,6 +749,9 @@ export class BootstrapMemories extends Resource {
|
|
|
716
749
|
}
|
|
717
750
|
else {
|
|
718
751
|
sections.relevant.push(line);
|
|
752
|
+
// flair#1182 — own task-relevant records join the `memories`
|
|
753
|
+
// container; teammate (`_source`) records stay in `context` only.
|
|
754
|
+
includedOwnMemories.push(leanMemory(m, "relevant"));
|
|
719
755
|
}
|
|
720
756
|
if (includeTrust)
|
|
721
757
|
includedTrustMemories.push(m);
|
|
@@ -944,8 +980,38 @@ export class BootstrapMemories extends Resource {
|
|
|
944
980
|
// decision reads only the confidence number — never a principal — against
|
|
945
981
|
// the single GLOBAL threshold.
|
|
946
982
|
const abstention = abstain ? evaluateAbstention(taskBestSimilarity) : undefined;
|
|
983
|
+
// flair#1182 (part 1) — resolved identity + read-scope descriptor. Reveals
|
|
984
|
+
// ONLY the caller's own resolved identity/scope (who the server decided the
|
|
985
|
+
// caller is, and the read model applied to them) — never another agent's
|
|
986
|
+
// data. Would have made the #1181 read-gate bug a one-call diagnosis.
|
|
987
|
+
const scopeInfo = {
|
|
988
|
+
agentId,
|
|
989
|
+
isAdmin: callerIsAdmin,
|
|
990
|
+
// The read model resolveReadScope(agentId) enforces for this caller: the
|
|
991
|
+
// caller's own records (any visibility) plus every other in-org agent's
|
|
992
|
+
// non-private record. Keep in sync with resources/memory-read-scope.ts.
|
|
993
|
+
reads: "own-and-org-non-private",
|
|
994
|
+
};
|
|
995
|
+
// flair#1182 (part 1) — currentTask is what turns on task-relevant
|
|
996
|
+
// retrieval, teammate findings and collision surfacing. When it's absent or
|
|
997
|
+
// blank, say so in the response so a caller learns the knob exists (present
|
|
998
|
+
// ONLY when absent/blank — a provided task needs no hint).
|
|
999
|
+
const taskProvided = typeof currentTask === "string" && currentTask.trim().length > 0;
|
|
1000
|
+
const currentTaskHint = taskProvided
|
|
1001
|
+
? undefined
|
|
1002
|
+
: "No currentTask was provided. Pass currentTask (a short description of what you're working on) to enable task-relevant memory retrieval, teammate findings, and collision surfacing.";
|
|
947
1003
|
return {
|
|
948
1004
|
context,
|
|
1005
|
+
// flair#1182 (part 1) — always-present self-describing keys: who the
|
|
1006
|
+
// server resolved the caller as, the read model applied, and the caller's
|
|
1007
|
+
// own soul/memories/predicted as structured containers (empty `{}`/`[]`,
|
|
1008
|
+
// never absent, so "empty" is distinguishable from "unsupported").
|
|
1009
|
+
agentId,
|
|
1010
|
+
scope: scopeInfo,
|
|
1011
|
+
soul: soulMap,
|
|
1012
|
+
memories: includedOwnMemories,
|
|
1013
|
+
predicted: includedPredicted,
|
|
1014
|
+
...(currentTaskHint ? { currentTaskHint } : {}),
|
|
949
1015
|
...(trust ? { trust } : {}),
|
|
950
1016
|
...(abstention ? { abstention } : {}),
|
|
951
1017
|
sections: {
|
|
@@ -213,11 +213,15 @@ async function memoryStore(agent, args) {
|
|
|
213
213
|
*/
|
|
214
214
|
async function memoryUpdate(agent, args) {
|
|
215
215
|
const Cls = await handler("Memory");
|
|
216
|
-
const h = new Cls(undefined, delegationContext(agent));
|
|
217
216
|
const id = args?.id;
|
|
218
217
|
const content = args?.content;
|
|
219
218
|
const preserveHistory = args?.preserveHistory === true;
|
|
220
|
-
|
|
219
|
+
// flair#1181 — the existing-record fetch is a by-id READ and must use the
|
|
220
|
+
// STATIC `Cls.get(id, context)` form (see memoryGet). The instance
|
|
221
|
+
// `new Cls(undefined, ctx).get(id)` returned `undefined` for the caller's own
|
|
222
|
+
// record (getProperty on an unloaded instance), so memory_update 404'd
|
|
223
|
+
// ("memory not found") on the connector path before it ever reached a write.
|
|
224
|
+
const existing = await Cls.get(id, delegationContext(agent));
|
|
221
225
|
if (!existing) {
|
|
222
226
|
return { error: "memory not found", status: 404 };
|
|
223
227
|
}
|
|
@@ -241,8 +245,7 @@ async function memoryUpdate(agent, args) {
|
|
|
241
245
|
// version's provenance records which client authored this update.
|
|
242
246
|
if (agent.clientId)
|
|
243
247
|
record.claimedClient = agent.clientId;
|
|
244
|
-
// A create needs a COLLECTION-bound instance (see resources/in-process.ts)
|
|
245
|
-
// `h` above is the by-id handle the get()/put() branches use.
|
|
248
|
+
// A create needs a COLLECTION-bound instance (see resources/in-process.ts).
|
|
246
249
|
const coll = await collectionResource(Cls, delegationContext(agent));
|
|
247
250
|
return unwrap(await coll.post(record));
|
|
248
251
|
}
|
|
@@ -252,21 +255,51 @@ async function memoryUpdate(agent, args) {
|
|
|
252
255
|
// flair#718 authorship-provenance — see memoryStore's comment above.
|
|
253
256
|
if (agent.clientId)
|
|
254
257
|
merged.claimedClient = agent.clientId;
|
|
255
|
-
|
|
258
|
+
// flair#1181 — the default merge write must ALSO use the STATIC transactional
|
|
259
|
+
// path, `Cls.put(merged, context)`, NOT an instance `new Cls(undefined, ctx).put(merged)`.
|
|
260
|
+
// An unloaded instance has no primary key, so its put()/save() throws
|
|
261
|
+
// "Invalid primary key type: undefined" (proven by the memory_update
|
|
262
|
+
// round-trip integration test). The pre-#1181 code never hit this because the
|
|
263
|
+
// broken by-id read above 404'd first — migrating the read to static exposed
|
|
264
|
+
// the same unloaded-instance defect on the write. The static form loads the
|
|
265
|
+
// row by `merged.id` and threads the context, then dispatches through
|
|
266
|
+
// Memory.put()'s own ownership gate — no scope change, same as the read.
|
|
267
|
+
return unwrap(await Cls.put(merged, delegationContext(agent)));
|
|
256
268
|
}
|
|
257
269
|
async function memoryGet(agent, args) {
|
|
258
270
|
const Cls = await handler("Memory");
|
|
259
|
-
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
271
|
+
// flair#1181 — by-id reads MUST use the STATIC `Cls.get(id, context)` form,
|
|
272
|
+
// never an instance `new Cls(undefined, ctx).get(id)`. Harper routes an
|
|
273
|
+
// instance `.get(<string>)` on an UNLOADED record (tables leave
|
|
274
|
+
// `loadAsInstance` at its `undefined` default) to `getProperty()` — a field
|
|
275
|
+
// accessor that returns `undefined` — so the read never loads the row and
|
|
276
|
+
// `makeByIdReadGate`'s `!record` branch 404s the caller's OWN record (one
|
|
277
|
+
// call after a successful store). The static form is the same transactional
|
|
278
|
+
// path the Ed25519 REST route takes: it loads the row, hands the override a
|
|
279
|
+
// `RequestTarget` (never a bare string), and still dispatches through
|
|
280
|
+
// Memory.get() → makeByIdReadGate → resolveReadScope, so the scope model is
|
|
281
|
+
// unchanged (own + org-non-private only). See resources/in-process.ts:223.
|
|
282
|
+
//
|
|
283
|
+
// flair#744 slice 1 — opt-in inline trust block. The instance call passed
|
|
284
|
+
// `includeTrust` as a 2nd positional opts arg to get(); the static form has
|
|
285
|
+
// no opts slot (arg 2 is the context), so fold it into the RequestTarget as
|
|
286
|
+
// a plain `{ id, includeTrust }` property — Memory.get()'s wantsTrust() reads
|
|
287
|
+
// it there (the in-process shape alongside the HTTP query-param shape).
|
|
288
|
+
const target = args?.includeTrust === true ? { id: args?.id, includeTrust: true } : args?.id;
|
|
289
|
+
return unwrap(await Cls.get(target, delegationContext(agent)));
|
|
265
290
|
}
|
|
266
291
|
async function memoryDelete(agent, args) {
|
|
267
292
|
const Cls = await handler("Memory");
|
|
268
|
-
|
|
269
|
-
|
|
293
|
+
// flair#1181 — STATIC `Cls.delete(id, context)`, not an instance
|
|
294
|
+
// `new Cls(undefined, ctx).delete(id)`. Memory.delete()'s override loads the
|
|
295
|
+
// row via `super.get(id)` to enforce the permanent-memory admin guard; on an
|
|
296
|
+
// unloaded instance that `super.get(<string>)` hit the same `getProperty()`
|
|
297
|
+
// dead end (`undefined`), so the guard's `record.durability === "permanent"`
|
|
298
|
+
// check was SILENTLY SKIPPED and the delete fell through to an unguarded
|
|
299
|
+
// `super.delete(id)`. The static form loads the row first (RequestTarget,
|
|
300
|
+
// not a bare string), so the override sees the real record and the
|
|
301
|
+
// ownership/durability guard runs as intended.
|
|
302
|
+
return unwrap(await Cls.delete(args?.id, delegationContext(agent)));
|
|
270
303
|
}
|
|
271
304
|
async function bootstrap(agent, args) {
|
|
272
305
|
const Cls = await handler("BootstrapMemories");
|
|
@@ -315,8 +348,14 @@ async function soulSet(agent, args) {
|
|
|
315
348
|
}
|
|
316
349
|
async function soulGet(agent, args) {
|
|
317
350
|
const Cls = await handler("Soul");
|
|
318
|
-
|
|
319
|
-
|
|
351
|
+
// flair#1181 — STATIC by-id read (see memoryGet). Soul has no get() override
|
|
352
|
+
// and no read-scope gate; its ids are `${agentId}:${key}`, built here from
|
|
353
|
+
// the RESOLVED agent, so a caller can only ever address its OWN soul — the
|
|
354
|
+
// static migration does not change that. The instance `h.get(<string>)`
|
|
355
|
+
// returned `undefined` (getProperty on an unloaded record), which is why
|
|
356
|
+
// soul_get came back empty on the connector path even though the entries
|
|
357
|
+
// exist and load fine via the Ed25519 static route.
|
|
358
|
+
return unwrap(await Cls.get(`${agent.agentId}:${args?.key}`, delegationContext(agent)));
|
|
320
359
|
}
|
|
321
360
|
async function workspaceSet(agent, args) {
|
|
322
361
|
const Cls = await handler("WorkspaceState");
|
|
@@ -184,6 +184,10 @@ export function makeReadScope(mode, ownerField = "agentId") {
|
|
|
184
184
|
* where it was.
|
|
185
185
|
*/
|
|
186
186
|
export function makeByIdReadGate(readScope) {
|
|
187
|
+
// flair#1181 — the owner field this gate's read-scope keys on, used ONLY to
|
|
188
|
+
// annotate the diagnostic logs below. Tagged onto the resolver by
|
|
189
|
+
// makeReadScope(); "agentId" for every table wired through this kit today.
|
|
190
|
+
const ownerField = readScope?.ownerField ?? "agentId";
|
|
187
191
|
return async function byIdReadGate(target, superGet) {
|
|
188
192
|
// Collection / query reads arrive as a RequestTarget with
|
|
189
193
|
// `isCollection === true`, and are governed by search() (same owner
|
|
@@ -191,23 +195,48 @@ export function makeByIdReadGate(readScope) {
|
|
|
191
195
|
if (!target || (typeof target === "object" && target.isCollection)) {
|
|
192
196
|
return this.search(target);
|
|
193
197
|
}
|
|
198
|
+
// flair#1181 — a single debug breadcrumb per DENY/ABSENT outcome so the
|
|
199
|
+
// next 404-on-your-own-record is a one-log diagnosis instead of a re-derive
|
|
200
|
+
// from Anthropic request ids. The three outcomes below are otherwise
|
|
201
|
+
// indistinguishable to the caller BY DESIGN — the client ALWAYS receives
|
|
202
|
+
// NOT_FOUND (404-never-403, so a denied caller can't enumerate other
|
|
203
|
+
// agents' ids). This logging is server-side only and changes no response.
|
|
204
|
+
const table = this?.constructor?.name;
|
|
205
|
+
const targetId = typeof target === "string" ? target : target?.id;
|
|
194
206
|
const ctx = this.getContext?.();
|
|
195
207
|
const auth = await resolveAgentAuth(ctx);
|
|
196
208
|
// Anonymous by-id read is already blocked at the allowRead() gate (403);
|
|
197
209
|
// this is defense-in-depth if get() is ever reached directly.
|
|
198
|
-
if (auth.kind === "anonymous")
|
|
210
|
+
if (auth.kind === "anonymous") {
|
|
211
|
+
console.debug("makeByIdReadGate by-id read → NOT_FOUND", {
|
|
212
|
+
table, targetId, resolvedAgentId: undefined, recordLoaded: false, recordOwner: undefined, branch: "anonymous",
|
|
213
|
+
});
|
|
199
214
|
return NOT_FOUND();
|
|
215
|
+
}
|
|
200
216
|
// Trusted internal call or admin agent — unfiltered, unchanged behavior.
|
|
201
217
|
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
202
218
|
return superGet(target);
|
|
203
219
|
}
|
|
204
220
|
// Non-admin agent: scoped per the table's own read-scope model.
|
|
205
221
|
const record = await superGet(target);
|
|
206
|
-
if (!record)
|
|
222
|
+
if (!record) {
|
|
223
|
+
// Row did not load: either it genuinely does not exist, OR — the #1181
|
|
224
|
+
// failure — a by-id read reached here on an unloaded instance and
|
|
225
|
+
// getProperty()'d to undefined. `recordLoaded: false` is the tell.
|
|
226
|
+
console.debug("makeByIdReadGate by-id read → NOT_FOUND", {
|
|
227
|
+
table, targetId, resolvedAgentId: auth.agentId, recordLoaded: false, recordOwner: undefined, branch: "absent-or-failed-load",
|
|
228
|
+
});
|
|
207
229
|
return NOT_FOUND();
|
|
230
|
+
}
|
|
208
231
|
const scope = await readScope(auth.agentId);
|
|
209
|
-
if (!scope.isAllowed(record))
|
|
232
|
+
if (!scope.isAllowed(record)) {
|
|
233
|
+
// Row loaded and is owned by someone this caller may not read — a genuine
|
|
234
|
+
// ownership denial, distinct from the absent/failed-load branch above.
|
|
235
|
+
console.debug("makeByIdReadGate by-id read → NOT_FOUND", {
|
|
236
|
+
table, targetId, resolvedAgentId: auth.agentId, recordLoaded: true, recordOwner: record?.[ownerField], branch: "denied",
|
|
237
|
+
});
|
|
210
238
|
return NOT_FOUND();
|
|
239
|
+
}
|
|
211
240
|
return record;
|
|
212
241
|
};
|
|
213
242
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.44.
|
|
3
|
+
"version": "0.44.4",
|
|
4
4
|
"packageManager": "bun@1.3.10",
|
|
5
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
6
6
|
"type": "module",
|