@tpsdev-ai/flair 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +247 -61
- package/dist/resources/Agent.js +20 -7
- package/dist/resources/AgentCard.js +6 -0
- package/dist/resources/AgentSeed.js +7 -1
- package/dist/resources/Credential.js +24 -13
- package/dist/resources/IngestEvents.js +7 -0
- package/dist/resources/Instance.js +13 -0
- package/dist/resources/Integration.js +64 -0
- package/dist/resources/Memory.js +53 -8
- package/dist/resources/MemoryBootstrap.js +8 -0
- package/dist/resources/MemoryConsolidate.js +8 -39
- package/dist/resources/MemoryFeed.js +8 -0
- package/dist/resources/MemoryGrant.js +79 -0
- package/dist/resources/MemoryMaintenance.js +1 -1
- package/dist/resources/MemoryReflect.js +7 -1
- package/dist/resources/MemoryReindex.js +7 -1
- package/dist/resources/OAuthClient.js +15 -0
- package/dist/resources/ObsAgentSnapshot.js +13 -0
- package/dist/resources/ObsEventFeed.js +13 -0
- package/dist/resources/ObsOffice.js +19 -0
- package/dist/resources/OrgEvent.js +37 -21
- package/dist/resources/OrgEventCatchup.js +8 -0
- package/dist/resources/OrgEventMaintenance.js +7 -0
- package/dist/resources/PairingToken.js +14 -0
- package/dist/resources/Peer.js +15 -0
- package/dist/resources/Presence.js +30 -0
- package/dist/resources/Relationship.js +13 -7
- package/dist/resources/SemanticSearch.js +29 -42
- package/dist/resources/Soul.js +18 -9
- package/dist/resources/SoulFeed.js +7 -0
- package/dist/resources/WorkspaceLatest.js +7 -0
- package/dist/resources/WorkspaceState.js +38 -28
- package/dist/resources/XAA.js +8 -0
- package/dist/resources/agent-auth.js +209 -0
- package/dist/resources/auth-middleware.js +83 -55
- package/dist/resources/memory-consolidate-lib.js +72 -0
- package/dist/resources/scoring.js +52 -0
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -510,35 +510,11 @@ export async function seedAgentViaOpsApi(opsPortOrUrl, agentId, pubKeyB64url, ad
|
|
|
510
510
|
throw new Error(`Operations API insert failed (${res.status}): ${text}`);
|
|
511
511
|
}
|
|
512
512
|
}
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
operation: "insert",
|
|
519
|
-
database: "flair",
|
|
520
|
-
table: "Agent",
|
|
521
|
-
records: [{ id: agentId, name: agentId, publicKey: pubKeyB64url, createdAt: new Date().toISOString() }],
|
|
522
|
-
};
|
|
523
|
-
// Only send Authorization header if adminPass is provided.
|
|
524
|
-
// Matches the auth pattern in api() which respects authorizeLocal=true.
|
|
525
|
-
const headers = { "Content-Type": "application/json" };
|
|
526
|
-
if (adminPass) {
|
|
527
|
-
headers.Authorization = `Basic ${Buffer.from(`admin:${adminPass}`).toString("base64")}`;
|
|
528
|
-
}
|
|
529
|
-
const res = await fetch(`${baseUrl}/Agent`, {
|
|
530
|
-
method: "POST",
|
|
531
|
-
headers,
|
|
532
|
-
body: JSON.stringify(body),
|
|
533
|
-
signal: AbortSignal.timeout(10_000),
|
|
534
|
-
});
|
|
535
|
-
if (!res.ok) {
|
|
536
|
-
const text = await res.text().catch(() => "");
|
|
537
|
-
if (res.status === 409 || text.includes("duplicate") || text.includes("already exists"))
|
|
538
|
-
return;
|
|
539
|
-
throw new Error(`REST API insert failed (${res.status}): ${text}`);
|
|
540
|
-
}
|
|
541
|
-
}
|
|
513
|
+
// NOTE: agent records are seeded exclusively via the Harper operations API
|
|
514
|
+
// (seedAgentViaOpsApi above). A former seedAgentViaRestApi() helper POSTed the
|
|
515
|
+
// ops-insert body to the REST root, which Harper 405s as a collection POST to
|
|
516
|
+
// /Agent (the Agent table resource has no POST handler). It was removed in the
|
|
517
|
+
// #499 fix; do not reintroduce a REST-root insert path.
|
|
542
518
|
// ─── FederationInstance seed via ops API ──────────────────────────────────────
|
|
543
519
|
//
|
|
544
520
|
// Remote init writes FederationInstance through the ops API (Basic auth with
|
|
@@ -736,25 +712,30 @@ export async function provisionFabric(target, opsTarget, clusterAdminUser, clust
|
|
|
736
712
|
// FederationPair resource handler. The role carries no table permissions itself
|
|
737
713
|
// — the resource's own allowCreate bypass handles route-level access once the
|
|
738
714
|
// request gets through the auth gate.
|
|
739
|
-
/**
|
|
715
|
+
/**
|
|
716
|
+
* Canonical permission spec for flair_pair_initiator.
|
|
717
|
+
*
|
|
718
|
+
* The role intentionally carries NO table permissions — its only job is to exist
|
|
719
|
+
* so bootstrap credentials can pass Harper platform auth before reaching the
|
|
720
|
+
* FederationPair resource handler (the resource's own allowCreate bypass handles
|
|
721
|
+
* route-level access). A bare role with both flags false is valid, grants nothing,
|
|
722
|
+
* and is exactly that intent.
|
|
723
|
+
*
|
|
724
|
+
* This previously carried an all-false `flair.tables` block, but Harper's add_role
|
|
725
|
+
* REJECTED the whole spec with a 400 (verified live against a spawned Harper):
|
|
726
|
+
* - `cluster_user` is not a recognized top-level key — Harper reads unknown keys
|
|
727
|
+
* as database names ("database 'cluster_user' does not exist");
|
|
728
|
+
* - the table names were the logical shorthand, not the real @table names
|
|
729
|
+
* ("Table 'flair.Workspace' does not exist" — it's WorkspaceState; "Event" is
|
|
730
|
+
* OrgEvent; "OAuth" is OAuthClient);
|
|
731
|
+
* - each grant omitted the required `attribute_permissions` array.
|
|
732
|
+
* The all-false block granted nothing anyway, so dropping it loses no capability
|
|
733
|
+
* and unbreaks fresh hub provisioning (where add_role runs and the 400 aborted
|
|
734
|
+
* `flair init --remote`). Only top-level booleans Harper recognizes remain.
|
|
735
|
+
*/
|
|
740
736
|
const PAIR_INITIATOR_PERMISSION = {
|
|
741
737
|
super_user: false,
|
|
742
|
-
cluster_user: false,
|
|
743
738
|
structure_user: false,
|
|
744
|
-
flair: {
|
|
745
|
-
tables: {
|
|
746
|
-
Memory: { read: false, insert: false, update: false, delete: false },
|
|
747
|
-
Soul: { read: false, insert: false, update: false, delete: false },
|
|
748
|
-
Agent: { read: false, insert: false, update: false, delete: false },
|
|
749
|
-
Workspace: { read: false, insert: false, update: false, delete: false },
|
|
750
|
-
Event: { read: false, insert: false, update: false, delete: false },
|
|
751
|
-
OAuth: { read: false, insert: false, update: false, delete: false },
|
|
752
|
-
Instance: { read: false, insert: false, update: false, delete: false },
|
|
753
|
-
Peer: { read: false, insert: false, update: false, delete: false },
|
|
754
|
-
PairingToken: { read: false, insert: false, update: false, delete: false },
|
|
755
|
-
SyncLog: { read: false, insert: false, update: false, delete: false },
|
|
756
|
-
},
|
|
757
|
-
},
|
|
758
739
|
};
|
|
759
740
|
/**
|
|
760
741
|
* Idempotently ensures the `flair_pair_initiator` role exists on the Harper
|
|
@@ -805,6 +786,174 @@ export async function ensureFlairPairInitiatorRole(opsUrl, adminUser, adminPass)
|
|
|
805
786
|
}, adminUser, adminPass);
|
|
806
787
|
console.log(`Role '${ROLE_NAME}' updated ✓`);
|
|
807
788
|
}
|
|
789
|
+
// ─── flair_agent role ────────────────────────────────────────────────────────
|
|
790
|
+
//
|
|
791
|
+
// The auth reshape replaces the global gate's "verified agent borrows admin
|
|
792
|
+
// super_user" elevation with a real, least-privilege Harper role. After an
|
|
793
|
+
// agent's Ed25519 signature verifies, resources resolve the request to the
|
|
794
|
+
// shared `flair_agent`-roled user instead of admin — so agents get exactly the
|
|
795
|
+
// table CRUD below and nothing more. Critically: with no `super_user` and no
|
|
796
|
+
// operations grants, /sql and /graphql become NATIVELY 403 for agents (the
|
|
797
|
+
// raw-query block the gate hand-rolled is now enforced by Harper itself).
|
|
798
|
+
//
|
|
799
|
+
// Row-level ownership (an agent touches only its OWN memories/soul/events) is
|
|
800
|
+
// NOT expressible in Harper's role model — it stays in each resource's allow*,
|
|
801
|
+
// keyed on the Ed25519-verified agentId. So these per-table grants are the
|
|
802
|
+
// coarse CRUD envelope; allow* is the ownership boundary inside it.
|
|
803
|
+
//
|
|
804
|
+
// VALIDATION GATES (must confirm against a live Harper before this role goes
|
|
805
|
+
// live — flagged for Sherlock + the PR, not assumed):
|
|
806
|
+
// 1. HNSW/vector search (SemanticSearch over Memory) works with table `read`
|
|
807
|
+
// alone — the old elevation comment claimed admin perms were needed for
|
|
808
|
+
// "HNSW-capable" access; confirm `read` suffices or widen precisely.
|
|
809
|
+
// 2. Role table keys must EXACTLY match the @table names (Memory, OrgEvent,
|
|
810
|
+
// WorkspaceState, OAuthClient — NOT the logical Memory/Event/Workspace/OAuth
|
|
811
|
+
// shorthand the flair_pair_initiator spec used, which was harmless only
|
|
812
|
+
// because every grant there is false).
|
|
813
|
+
// 3. Obs* writes: if the presence-emitter writes ObsAgentSnapshot AS the agent
|
|
814
|
+
// it needs insert/update — currently read-only here; confirm the writer's
|
|
815
|
+
// identity (system vs agent) and widen only if it's the agent.
|
|
816
|
+
// Harper 5.0.21 add_role requires an `attribute_permissions` array on EVERY table
|
|
817
|
+
// grant (empty = no attribute-level restriction, so the table-level CRUD applies);
|
|
818
|
+
// omitting it makes add_role reject the whole spec ("Missing 'attribute_permissions'
|
|
819
|
+
// array"). Validated live against a spawned Harper. This helper guarantees the
|
|
820
|
+
// array is never forgotten. Also: `cluster_user` is NOT a valid top-level key —
|
|
821
|
+
// Harper reads unrecognized top-level keys as database names ("database
|
|
822
|
+
// 'cluster_user' does not exist"); only super_user / structure_user are recognized.
|
|
823
|
+
const grant = (read, insert, update, del) => ({ read, insert, update, delete: del, attribute_permissions: [] });
|
|
824
|
+
/** Canonical permission spec for flair_agent (least-privilege; real @table names). */
|
|
825
|
+
const FLAIR_AGENT_PERMISSION = {
|
|
826
|
+
super_user: false,
|
|
827
|
+
structure_user: false,
|
|
828
|
+
flair: {
|
|
829
|
+
tables: {
|
|
830
|
+
// Core agent-owned data — CRUD envelope; ownership enforced in allow*.
|
|
831
|
+
Memory: grant(true, true, true, true),
|
|
832
|
+
MemoryCandidate: grant(true, true, true, true),
|
|
833
|
+
MemoryGrant: grant(true, true, true, true),
|
|
834
|
+
Soul: grant(true, true, true, false),
|
|
835
|
+
OrgEvent: grant(true, true, true, true),
|
|
836
|
+
WorkspaceState: grant(true, true, true, true),
|
|
837
|
+
Relationship: grant(true, true, true, true),
|
|
838
|
+
Integration: grant(true, true, true, true),
|
|
839
|
+
Credential: grant(true, true, true, true),
|
|
840
|
+
Presence: grant(true, true, true, false),
|
|
841
|
+
// Agent: read for discovery, update own card; creation/removal is admin.
|
|
842
|
+
Agent: grant(true, false, true, false),
|
|
843
|
+
// Read-only reference data.
|
|
844
|
+
Instance: grant(true, false, false, false),
|
|
845
|
+
// Observatory read-models — public reads; writes are system-driven (gate 3).
|
|
846
|
+
ObsOffice: grant(true, false, false, false),
|
|
847
|
+
ObsAgentSnapshot: grant(true, false, false, false),
|
|
848
|
+
ObsEventFeed: grant(true, false, false, false),
|
|
849
|
+
// Federation / OAuth / IdP / internal — system + admin only; agents get none.
|
|
850
|
+
Peer: grant(false, false, false, false),
|
|
851
|
+
PairingToken: grant(false, false, false, false),
|
|
852
|
+
SyncLog: grant(false, false, false, false),
|
|
853
|
+
OAuthClient: grant(false, false, false, false),
|
|
854
|
+
OAuthToken: grant(false, false, false, false),
|
|
855
|
+
OAuthAuthCode: grant(false, false, false, false),
|
|
856
|
+
IdpConfig: grant(false, false, false, false),
|
|
857
|
+
IdJagReplay: grant(false, false, false, false),
|
|
858
|
+
},
|
|
859
|
+
},
|
|
860
|
+
};
|
|
861
|
+
/**
|
|
862
|
+
* Idempotently ensures the `flair_agent` role exists on the Harper instance at
|
|
863
|
+
* `opsUrl` with the canonical least-privilege spec. Same list/add/alter shape as
|
|
864
|
+
* ensureFlairPairInitiatorRole.
|
|
865
|
+
*
|
|
866
|
+
* - absent → `add_role`
|
|
867
|
+
* - exists with different permissions → `alter_role`
|
|
868
|
+
* - already matches → no-op
|
|
869
|
+
*/
|
|
870
|
+
export async function ensureFlairAgentRole(opsUrl, adminUser, adminPass) {
|
|
871
|
+
const ROLE_NAME = "flair_agent";
|
|
872
|
+
let roles = [];
|
|
873
|
+
try {
|
|
874
|
+
const result = await callOpsApi(opsUrl, { operation: "list_roles" }, adminUser, adminPass);
|
|
875
|
+
roles = Array.isArray(result) ? result : [];
|
|
876
|
+
}
|
|
877
|
+
catch (err) {
|
|
878
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
879
|
+
throw new Error(`ensureFlairAgentRole: list_roles failed: ${msg}`);
|
|
880
|
+
}
|
|
881
|
+
const existing = roles.find((r) => r.role === ROLE_NAME || r.name === ROLE_NAME);
|
|
882
|
+
if (!existing) {
|
|
883
|
+
console.log(`Creating role '${ROLE_NAME}'...`);
|
|
884
|
+
await callOpsApi(opsUrl, {
|
|
885
|
+
operation: "add_role",
|
|
886
|
+
role: ROLE_NAME,
|
|
887
|
+
permission: FLAIR_AGENT_PERMISSION,
|
|
888
|
+
}, adminUser, adminPass);
|
|
889
|
+
console.log(`Role '${ROLE_NAME}' created ✓`);
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
892
|
+
const existingPerm = existing.permission ?? existing.role?.permission;
|
|
893
|
+
const canonicalStr = JSON.stringify(FLAIR_AGENT_PERMISSION);
|
|
894
|
+
const existingStr = JSON.stringify(existingPerm);
|
|
895
|
+
if (existingStr === canonicalStr) {
|
|
896
|
+
console.log(`Role '${ROLE_NAME}' already exists with correct permissions — skipping`);
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
console.log(`Role '${ROLE_NAME}' exists but permissions differ — updating...`);
|
|
900
|
+
await callOpsApi(opsUrl, {
|
|
901
|
+
operation: "alter_role",
|
|
902
|
+
role: ROLE_NAME,
|
|
903
|
+
permission: FLAIR_AGENT_PERMISSION,
|
|
904
|
+
}, adminUser, adminPass);
|
|
905
|
+
console.log(`Role '${ROLE_NAME}' updated ✓`);
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Shared Harper user that verified Ed25519 agents are resolved to.
|
|
909
|
+
* MUST match FLAIR_AGENT_USERNAME in resources/agent-auth.ts (the gate side).
|
|
910
|
+
* Not imported from there because cli.ts is standalone and that module pulls in
|
|
911
|
+
* Harper's native bindings — kept as a cross-referenced literal instead.
|
|
912
|
+
*/
|
|
913
|
+
export const FLAIR_AGENT_USERNAME = "flair-agent";
|
|
914
|
+
/**
|
|
915
|
+
* Idempotently ensures the shared `flair-agent` Harper user exists with the
|
|
916
|
+
* `flair_agent` role. Verified Ed25519 agents are resolved to THIS user
|
|
917
|
+
* (`getUser("flair-agent", null)` — no password check, identity already proven
|
|
918
|
+
* cryptographically), replacing the old `getUser("admin")` super_user elevation.
|
|
919
|
+
*
|
|
920
|
+
* The password is random and never used for authentication: the agent path
|
|
921
|
+
* resolves the user without it, and the auth gate's Basic path only accepts
|
|
922
|
+
* super_user / pair-bootstrap (a flair_agent Basic login is rejected there), so
|
|
923
|
+
* a Basic login as this user can't reach anything. Random + unused = safe.
|
|
924
|
+
*
|
|
925
|
+
* Row-level ownership stays in each resource's allow* (keyed on the verified
|
|
926
|
+
* agentId); this shared user only carries the flair_agent role's table grants.
|
|
927
|
+
*/
|
|
928
|
+
export async function ensureFlairAgentUser(opsUrl, adminUser, adminPass) {
|
|
929
|
+
const unusedPassword = randomBytes(32).toString("base64url");
|
|
930
|
+
try {
|
|
931
|
+
await callOpsApi(opsUrl, {
|
|
932
|
+
operation: "add_user",
|
|
933
|
+
username: FLAIR_AGENT_USERNAME,
|
|
934
|
+
password: unusedPassword,
|
|
935
|
+
role: "flair_agent",
|
|
936
|
+
active: true,
|
|
937
|
+
}, adminUser, adminPass);
|
|
938
|
+
console.log(`User '${FLAIR_AGENT_USERNAME}' created ✓`);
|
|
939
|
+
}
|
|
940
|
+
catch (err) {
|
|
941
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
942
|
+
if (msg.includes("already exists") || msg.includes("duplicate")) {
|
|
943
|
+
// Idempotent: ensure the role is correct without churning the password.
|
|
944
|
+
await callOpsApi(opsUrl, {
|
|
945
|
+
operation: "alter_user",
|
|
946
|
+
username: FLAIR_AGENT_USERNAME,
|
|
947
|
+
role: "flair_agent",
|
|
948
|
+
active: true,
|
|
949
|
+
}, adminUser, adminPass);
|
|
950
|
+
console.log(`User '${FLAIR_AGENT_USERNAME}' already exists — role ensured ✓`);
|
|
951
|
+
}
|
|
952
|
+
else {
|
|
953
|
+
throw err;
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
}
|
|
808
957
|
// ─── Upgrade presence probes ──────────────────────────────────────────────────
|
|
809
958
|
//
|
|
810
959
|
// `flair upgrade` previously called `npm list -g <pkg>` to detect the installed
|
|
@@ -1126,6 +1275,16 @@ program
|
|
|
1126
1275
|
if (opts.remote) {
|
|
1127
1276
|
await ensureFlairPairInitiatorRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
1128
1277
|
}
|
|
1278
|
+
// Every flair instance has agents, so provision the least-privilege
|
|
1279
|
+
// flair_agent role (idempotent, harmless until a user is assigned to it).
|
|
1280
|
+
await ensureFlairAgentRole(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
1281
|
+
// THE FLIP (auth-rbac): provision the shared least-privilege flair-agent user.
|
|
1282
|
+
// This ACTIVATES the gate's per-agent de-elevation (verified non-admin agents
|
|
1283
|
+
// resolve to flair-agent instead of admin super_user). Safe now: #487 gave
|
|
1284
|
+
// every agent-facing resource its own allow* + resolveAgentAuth, so they no
|
|
1285
|
+
// longer rely on the admin super_user bypass. The gate also falls back to
|
|
1286
|
+
// admin if this user is ever absent, so de-elevation degrades gracefully.
|
|
1287
|
+
await ensureFlairAgentUser(opsUrl, DEFAULT_ADMIN_USER, flairAdminPass);
|
|
1129
1288
|
}
|
|
1130
1289
|
else {
|
|
1131
1290
|
// ── Existing behavior: --admin-pass required for already-running Flair ──
|
|
@@ -1797,8 +1956,21 @@ program
|
|
|
1797
1956
|
writeConfig(httpPort);
|
|
1798
1957
|
}
|
|
1799
1958
|
else {
|
|
1800
|
-
// Flair already initialized — resolve admin pass from env
|
|
1959
|
+
// Flair already initialized — resolve admin pass from env, falling back to
|
|
1960
|
+
// the persisted ~/.flair/admin-pass written at first install. Agent
|
|
1961
|
+
// seeding now goes through the ops API (#499), which always requires Basic
|
|
1962
|
+
// admin auth (it does NOT honor authorizeLocal), so a re-run of
|
|
1963
|
+
// `flair install --agent <new-id>` against an already-initialized local
|
|
1964
|
+
// Flair needs a real pass even when no env var is set.
|
|
1801
1965
|
adminPass = process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
1966
|
+
if (!adminPass) {
|
|
1967
|
+
try {
|
|
1968
|
+
const persisted = join(homedir(), ".flair", "admin-pass");
|
|
1969
|
+
if (existsSync(persisted))
|
|
1970
|
+
adminPass = readFileSync(persisted, "utf-8").trim();
|
|
1971
|
+
}
|
|
1972
|
+
catch { /* fall through with empty pass */ }
|
|
1973
|
+
}
|
|
1802
1974
|
}
|
|
1803
1975
|
const httpUrl = `http://127.0.0.1:${httpPort}`;
|
|
1804
1976
|
// ── Step 2: Detect MCP clients ──
|
|
@@ -1857,17 +2029,16 @@ program
|
|
|
1857
2029
|
writeFileSync(pubPath, Buffer.from(kp.publicKey));
|
|
1858
2030
|
const pubKeyB64url = b64url(kp.publicKey);
|
|
1859
2031
|
console.log(`Keypair written: ${privPath} ✓`);
|
|
1860
|
-
// Seed agent
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
}
|
|
2032
|
+
// Seed agent via the Harper operations API. The Agent table is a REST
|
|
2033
|
+
// resource without a POST handler, so the insert body must go to the ops
|
|
2034
|
+
// API (POST <opsUrl>/ with {operation:"insert",...}), NOT the REST root
|
|
2035
|
+
// (which 405s the body as a collection POST to /Agent). This is the same
|
|
2036
|
+
// path `flair agent add` uses. (#499)
|
|
2037
|
+
const seedOpsTarget = opts.opsTarget ?? opsPort;
|
|
2038
|
+
console.log(opts.opsTarget
|
|
2039
|
+
? `Seeding agent '${agentId}' via operations API (--ops-target)...`
|
|
2040
|
+
: `Seeding agent '${agentId}' via operations API...`);
|
|
2041
|
+
await seedAgentViaOpsApi(seedOpsTarget, agentId, pubKeyB64url, adminUser, adminPass);
|
|
1871
2042
|
console.log(`Agent '${agentId}' registered ✓`);
|
|
1872
2043
|
}
|
|
1873
2044
|
// ── Step 4: Wire MCP clients ──
|
|
@@ -2026,10 +2197,16 @@ agent
|
|
|
2026
2197
|
if (adminPass) {
|
|
2027
2198
|
const opsPort = resolveOpsPort(opts);
|
|
2028
2199
|
const auth = Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64");
|
|
2200
|
+
// List every Agent without null-scanning the primary key. A
|
|
2201
|
+
// `starts_with ""` on `id` makes Harper search the index for nulls, which
|
|
2202
|
+
// the bundled Harper (5.0.21) rejects with "id is not indexed for nulls".
|
|
2203
|
+
// Use `createdAt > 1970-01-01` as the total "select all" predicate: every
|
|
2204
|
+
// Agent row has a non-null createdAt (schema: createdAt: String!), and its
|
|
2205
|
+
// index is built — same pattern as the `flair reembed` Memory scan. (#500)
|
|
2029
2206
|
const res = await fetch(`http://127.0.0.1:${opsPort}/`, {
|
|
2030
2207
|
method: "POST",
|
|
2031
2208
|
headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
|
|
2032
|
-
body: JSON.stringify({ operation: "
|
|
2209
|
+
body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table: "Agent", operator: "and", conditions: [{ search_attribute: "createdAt", search_type: "greater_than", search_value: "1970-01-01" }], get_attributes: ["id", "name", "createdAt"] }),
|
|
2033
2210
|
});
|
|
2034
2211
|
if (!res.ok) {
|
|
2035
2212
|
const text = await res.text().catch(() => "");
|
|
@@ -4015,7 +4192,7 @@ rem
|
|
|
4015
4192
|
console.log(result.prompt ?? "(no prompt returned)");
|
|
4016
4193
|
console.log("--- End Prompt ---\n");
|
|
4017
4194
|
console.log("Feed the prompt above to your LLM, then write insights back with:");
|
|
4018
|
-
console.log(" flair memory add --agent <id> --content <insight> --durability persistent");
|
|
4195
|
+
console.log(" flair memory add --agent <id> --content <insight> --durability persistent --derived-from <source-ids>");
|
|
4019
4196
|
}
|
|
4020
4197
|
catch (err) {
|
|
4021
4198
|
console.error(`Error: ${err.message}`);
|
|
@@ -6827,6 +7004,7 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
6827
7004
|
.option("--durability <d>", "standard").option("--tags <csv>")
|
|
6828
7005
|
.option("--summary <text>", "agent-set multi-sentence dense compression (3-tier chain: subject → summary → content; ops-wkoh)")
|
|
6829
7006
|
.option("--subject <text>", "one-line title / entity this memory is about")
|
|
7007
|
+
.option("--derived-from <csv>", "Comma-separated source Memory IDs this memory was distilled/reflected from (sets Memory.derivedFrom; used by the `rem rapid` reflection loop)")
|
|
6830
7008
|
.action(async (contentArg, opts) => {
|
|
6831
7009
|
const content = contentArg ?? opts.content;
|
|
6832
7010
|
if (!content) {
|
|
@@ -6843,6 +7021,9 @@ memory.command("add [content]").requiredOption("--agent <id>")
|
|
|
6843
7021
|
body.summary = opts.summary;
|
|
6844
7022
|
if (opts.subject)
|
|
6845
7023
|
body.subject = opts.subject;
|
|
7024
|
+
if (opts.derivedFrom) {
|
|
7025
|
+
body.derivedFrom = String(opts.derivedFrom).split(",").map((x) => x.trim()).filter(Boolean);
|
|
7026
|
+
}
|
|
6846
7027
|
const out = await api("PUT", `/Memory/${memId}`, body);
|
|
6847
7028
|
console.log(JSON.stringify(out, null, 2));
|
|
6848
7029
|
});
|
|
@@ -7466,12 +7647,17 @@ soul.command("set")
|
|
|
7466
7647
|
.option("--durability <d>", "permanent")
|
|
7467
7648
|
.option("--json", "Emit raw JSON response (also: pipe + FLAIR_OUTPUT=json)")
|
|
7468
7649
|
.action(async (opts) => {
|
|
7469
|
-
|
|
7470
|
-
|
|
7650
|
+
// PUT /Soul/{agentId:key} (upsert by id), matching flair-client's soul.set().
|
|
7651
|
+
// The Soul table resource has no POST handler, so a collection POST /Soul
|
|
7652
|
+
// 405s; the record must be written by its primary key. (#498)
|
|
7653
|
+
const id = `${opts.agent}:${opts.key}`;
|
|
7654
|
+
const out = await api("PUT", `/Soul/${encodeURIComponent(id)}`, {
|
|
7655
|
+
id,
|
|
7471
7656
|
agentId: opts.agent,
|
|
7472
7657
|
key: opts.key,
|
|
7473
7658
|
value: opts.value,
|
|
7474
7659
|
durability: opts.durability,
|
|
7660
|
+
createdAt: new Date().toISOString(),
|
|
7475
7661
|
});
|
|
7476
7662
|
const mode = render.resolveOutputMode(opts);
|
|
7477
7663
|
if (mode === "json") {
|
package/dist/resources/Agent.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth, allowVerified, allowAdmin } from "./agent-auth.js";
|
|
2
3
|
/**
|
|
3
4
|
* Agent resource — serves as the Principal table in 1.0.
|
|
4
5
|
*
|
|
@@ -16,6 +17,14 @@ import { databases } from "@harperfast/harper";
|
|
|
16
17
|
* - subjects: soul-level subject interests
|
|
17
18
|
*/
|
|
18
19
|
export class Agent extends databases.flair.Agent {
|
|
20
|
+
// Self-authorize now that the global gate is non-rejecting. Verified agents read
|
|
21
|
+
// the principal table for discovery; an agent updates only its OWN record (put
|
|
22
|
+
// handler enforces ownership). Creating/deleting principals is admin-only
|
|
23
|
+
// (flair_agent grant: insert=false, delete=false). Anonymous denied throughout.
|
|
24
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
25
|
+
allowCreate() { return allowAdmin(this.getContext?.()); }
|
|
26
|
+
allowUpdate() { return allowVerified(this.getContext?.()); }
|
|
27
|
+
allowDelete() { return allowAdmin(this.getContext?.()); }
|
|
19
28
|
async post(content, context) {
|
|
20
29
|
const now = new Date().toISOString();
|
|
21
30
|
// Backward compat: set type for legacy code
|
|
@@ -34,14 +43,18 @@ export class Agent extends databases.flair.Agent {
|
|
|
34
43
|
return super.post(content, context);
|
|
35
44
|
}
|
|
36
45
|
async put(content) {
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
46
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
47
|
+
// Anonymous denied (defense-in-depth alongside allowUpdate; the old check read
|
|
48
|
+
// tpsAgent and treated a missing agent as trusted, so anonymous slipped through).
|
|
49
|
+
if (auth.kind === "anonymous") {
|
|
50
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
51
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
// Only admin principals can modify OTHER principals; an agent updates its own.
|
|
55
|
+
if (auth.kind === "agent" && !auth.isAdmin) {
|
|
43
56
|
const existing = await super.get();
|
|
44
|
-
if (existing && existing.id !==
|
|
57
|
+
if (existing && existing.id !== auth.agentId) {
|
|
45
58
|
return new Response(JSON.stringify({ error: "only admin principals can modify other principals" }), {
|
|
46
59
|
status: 403, headers: { "content-type": "application/json" },
|
|
47
60
|
});
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
2
|
import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
|
|
3
3
|
export class AgentCard extends Resource {
|
|
4
|
+
// Public discovery metadata (per A2A spec, agent cards are intentionally
|
|
5
|
+
// public). Self-gates as public so it survives the global gate's removal — and
|
|
6
|
+
// its get() already returns only field-allowlisted public-safe data.
|
|
7
|
+
allowRead() {
|
|
8
|
+
return true;
|
|
9
|
+
}
|
|
4
10
|
async get(pathInfo) {
|
|
5
11
|
const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
|
|
6
12
|
this.getId?.() ??
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* Auth: admin only.
|
|
18
18
|
*/
|
|
19
19
|
import { Resource, databases } from "@harperfast/harper";
|
|
20
|
-
import { isAdmin } from "./auth
|
|
20
|
+
import { isAdmin, allowAdmin } from "./agent-auth.js";
|
|
21
21
|
const DEFAULT_SOUL_KEYS = (agentId, displayName, role, now) => ({
|
|
22
22
|
name: displayName,
|
|
23
23
|
role,
|
|
@@ -32,6 +32,12 @@ const DEFAULT_MEMORIES = (agentId, now) => [
|
|
|
32
32
|
},
|
|
33
33
|
];
|
|
34
34
|
export class AgentSeed extends Resource {
|
|
35
|
+
// Admin-only: permit verified ADMIN agents (Basic-admin is super_user and
|
|
36
|
+
// bypasses allow*); non-admin agents denied. Real authorization now that the
|
|
37
|
+
// gate no longer elevates agents to admin.
|
|
38
|
+
async allowCreate() {
|
|
39
|
+
return allowAdmin(this.getContext?.());
|
|
40
|
+
}
|
|
35
41
|
async post(data) {
|
|
36
42
|
const actorId = this.request?.tpsAgent;
|
|
37
43
|
if (!actorId || !(await isAdmin(actorId))) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
2
3
|
import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
3
4
|
/**
|
|
4
5
|
* Credential resource — authentication surfaces for Principals.
|
|
@@ -13,15 +14,21 @@ import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
|
|
|
13
14
|
*/
|
|
14
15
|
export class Credential extends databases.flair.Credential {
|
|
15
16
|
async search(query) {
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if (
|
|
17
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
18
|
+
// Anonymous HTTP must NOT read credentials. (Previously `!authAgent` was
|
|
19
|
+
// treated as trusted/unfiltered — which leaked every credential to an
|
|
20
|
+
// unauthenticated caller once the gate stopped rejecting.)
|
|
21
|
+
if (auth.kind === "anonymous") {
|
|
22
|
+
return new Response(JSON.stringify({ error: "authentication required" }), {
|
|
23
|
+
status: 401, headers: { "content-type": "application/json" },
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
// Trusted internal call or admin agent → unfiltered.
|
|
27
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
21
28
|
return super.search(query);
|
|
22
29
|
}
|
|
23
|
-
// Non-admin: scope to own credentials
|
|
24
|
-
const condition = { attribute: "principalId", comparator: "equals", value:
|
|
30
|
+
// Non-admin agent: scope to own credentials.
|
|
31
|
+
const condition = { attribute: "principalId", comparator: "equals", value: auth.agentId };
|
|
25
32
|
if (!query?.conditions) {
|
|
26
33
|
return super.search({ conditions: [condition], ...(query || {}) });
|
|
27
34
|
}
|
|
@@ -35,12 +42,16 @@ export class Credential extends databases.flair.Credential {
|
|
|
35
42
|
const result = await super.get();
|
|
36
43
|
if (!result)
|
|
37
44
|
return result;
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
45
|
+
const auth = await resolveAgentAuth(this.getContext?.());
|
|
46
|
+
// Anonymous HTTP must NOT read a credential (previously it fell through the
|
|
47
|
+
// ownership check and returned the record sans tokenHash — a metadata leak).
|
|
48
|
+
if (auth.kind === "anonymous") {
|
|
49
|
+
return new Response(JSON.stringify({ error: "forbidden" }), {
|
|
50
|
+
status: 403, headers: { "content-type": "application/json" },
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
// Non-admin agent can only see their own credentials. (Internal + admin pass.)
|
|
54
|
+
if (auth.kind === "agent" && !auth.isAdmin && result.principalId !== auth.agentId) {
|
|
44
55
|
return new Response(JSON.stringify({ error: "forbidden" }), {
|
|
45
56
|
status: 403, headers: { "content-type": "application/json" },
|
|
46
57
|
});
|
|
@@ -55,6 +55,13 @@ function verifyEd25519Signature(publicKeyHex, authHeader, officeId) {
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
export class IngestEvents extends Resource {
|
|
58
|
+
// Public POST by design: the handler self-verifies the OFFICE's Ed25519
|
|
59
|
+
// signature against ObsOffice.publicKey (not an agent identity), so it can't use
|
|
60
|
+
// agent auth. allowCreate=true lets the request reach post() where that
|
|
61
|
+
// verification happens — same self-gating pattern as FederationPair/FederationSync.
|
|
62
|
+
allowCreate() {
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
58
65
|
async post(body, context) {
|
|
59
66
|
const request = this.request;
|
|
60
67
|
const authHeader = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { allowVerified, allowAdmin } from "./agent-auth.js";
|
|
3
|
+
/**
|
|
4
|
+
* Instance is read-only reference data for agents (flair_agent grant: read only).
|
|
5
|
+
* Verified agents + admin may read; writes are admin/internal only. Self-authorizes
|
|
6
|
+
* now that the global gate is non-rejecting (anonymous denied).
|
|
7
|
+
*/
|
|
8
|
+
export class Instance extends databases.flair.Instance {
|
|
9
|
+
allowRead() { return allowVerified(this.getContext?.()); }
|
|
10
|
+
allowCreate() { return allowAdmin(this.getContext?.()); }
|
|
11
|
+
allowUpdate() { return allowAdmin(this.getContext?.()); }
|
|
12
|
+
allowDelete() { return allowAdmin(this.getContext?.()); }
|
|
13
|
+
}
|
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
import { databases } from "@harperfast/harper";
|
|
2
|
+
import { resolveAgentAuth } from "./agent-auth.js";
|
|
3
|
+
const FORBIDDEN = (msg) => new Response(JSON.stringify({ error: msg }), { status: 403, headers: { "Content-Type": "application/json" } });
|
|
4
|
+
const UNAUTH = () => new Response(JSON.stringify({ error: "authentication required" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
5
|
+
/**
|
|
6
|
+
* Integration records are agent-owned. Auth: the non-rejecting gate annotates the
|
|
7
|
+
* request; this resource self-enforces (resolveAgentAuth → internal/agent/anonymous).
|
|
8
|
+
* Anonymous HTTP is denied on every path; non-admin agents are scoped to their own
|
|
9
|
+
* agentId. Mirrors the WorkspaceState pattern.
|
|
10
|
+
*/
|
|
2
11
|
export class Integration extends databases.flair.Integration {
|
|
12
|
+
_auth() {
|
|
13
|
+
return resolveAgentAuth(this.getContext?.());
|
|
14
|
+
}
|
|
15
|
+
async search(query) {
|
|
16
|
+
const auth = await this._auth();
|
|
17
|
+
if (auth.kind === "anonymous")
|
|
18
|
+
return UNAUTH();
|
|
19
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
20
|
+
return super.search(query);
|
|
21
|
+
}
|
|
22
|
+
const agentIdCondition = { attribute: "agentId", comparator: "equals", value: auth.agentId };
|
|
23
|
+
if (query && typeof query === "object" && !Array.isArray(query)) {
|
|
24
|
+
const existing = query.conditions ?? [];
|
|
25
|
+
query.conditions = Array.isArray(existing) ? [agentIdCondition, ...existing] : [agentIdCondition, existing];
|
|
26
|
+
return super.search(query);
|
|
27
|
+
}
|
|
28
|
+
const conditions = Array.isArray(query) && query.length > 0 ? [agentIdCondition, ...query] : [agentIdCondition];
|
|
29
|
+
return super.search(conditions);
|
|
30
|
+
}
|
|
3
31
|
async post(content, context) {
|
|
32
|
+
const auth = await this._auth();
|
|
33
|
+
if (auth.kind === "anonymous")
|
|
34
|
+
return UNAUTH();
|
|
35
|
+
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
36
|
+
return FORBIDDEN("forbidden: cannot write integration for another agent");
|
|
37
|
+
}
|
|
4
38
|
// S31-A: API never accepts plaintext credentials.
|
|
5
39
|
if (typeof content?.credential === "string" || typeof content?.token === "string") {
|
|
6
40
|
return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
|
|
@@ -10,4 +44,34 @@ export class Integration extends databases.flair.Integration {
|
|
|
10
44
|
}
|
|
11
45
|
return super.post(content, context);
|
|
12
46
|
}
|
|
47
|
+
async put(content, context) {
|
|
48
|
+
const auth = await this._auth();
|
|
49
|
+
if (auth.kind === "anonymous")
|
|
50
|
+
return UNAUTH();
|
|
51
|
+
if (auth.kind === "agent" && !auth.isAdmin && content?.agentId && content.agentId !== auth.agentId) {
|
|
52
|
+
return FORBIDDEN("forbidden: cannot write integration for another agent");
|
|
53
|
+
}
|
|
54
|
+
if (typeof content?.credential === "string" || typeof content?.token === "string") {
|
|
55
|
+
return new Response(JSON.stringify({ error: "plaintext_credentials_forbidden" }), {
|
|
56
|
+
status: 400,
|
|
57
|
+
headers: { "Content-Type": "application/json" },
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return super.put(content, context);
|
|
61
|
+
}
|
|
62
|
+
async delete(id) {
|
|
63
|
+
const auth = await this._auth();
|
|
64
|
+
if (auth.kind === "anonymous")
|
|
65
|
+
return UNAUTH();
|
|
66
|
+
if (auth.kind === "internal" || (auth.kind === "agent" && auth.isAdmin)) {
|
|
67
|
+
return super.delete(id);
|
|
68
|
+
}
|
|
69
|
+
const record = await this.get(id);
|
|
70
|
+
if (!record)
|
|
71
|
+
return super.delete(id);
|
|
72
|
+
if (record.agentId !== auth.agentId) {
|
|
73
|
+
return FORBIDDEN("forbidden: cannot delete integration for another agent");
|
|
74
|
+
}
|
|
75
|
+
return super.delete(id);
|
|
76
|
+
}
|
|
13
77
|
}
|