@tpsdev-ai/flair 0.10.0 → 0.11.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/README.md +3 -3
- package/dist/cli.js +204 -48
- package/dist/resources/AgentCard.js +5 -14
- package/dist/resources/Presence.js +270 -0
- package/dist/resources/agentcard-fields.js +40 -0
- package/dist/resources/auth-middleware.js +32 -26
- package/dist/resources/health.js +2 -2
- package/package.json +11 -2
- package/schemas/schema.graphql +10 -1
package/README.md
CHANGED
|
@@ -17,7 +17,7 @@ Flair is the **identity + memory substrate for AI agents**. Cryptographic per-ag
|
|
|
17
17
|
│ Codex CLI ─┼─[ flair-mcp ]─┐ │
|
|
18
18
|
│ Gemini CLI ─┤ │ │
|
|
19
19
|
│ Continue.dev ─┤ │ ┌──────────────────────┐ │
|
|
20
|
-
│ Goose ─┘ ├─▶ │ Flair (
|
|
20
|
+
│ Goose ─┘ ├─▶ │ Flair (self-hosted) │ │
|
|
21
21
|
│ LangGraph ─[ langgraph-flair ]──│ Ed25519 / HNSW / │ │
|
|
22
22
|
│ OpenClaw ─[ openclaw-flair ]──│ Soul + Memory │ │
|
|
23
23
|
│ n8n ─[ n8n-nodes-flair ]──└──────────┬───────────┘ │
|
|
@@ -146,7 +146,7 @@ Not all memories are equal:
|
|
|
146
146
|
Memories can be time-bounded with `validFrom` and `validTo` fields. Expired memories are excluded from search and bootstrap automatically — no manual cleanup.
|
|
147
147
|
|
|
148
148
|
### Relationship Graph
|
|
149
|
-
Entity-to-entity triples with temporal bounds. Model structured knowledge — "
|
|
149
|
+
Entity-to-entity triples with temporal bounds. Model structured knowledge — "Alice works-with Bob since 2024-01-01" — queryable alongside semantic memory.
|
|
150
150
|
|
|
151
151
|
### Real-Time Feeds
|
|
152
152
|
Subscribe to memory or soul changes via WebSocket/SSE. Useful for dashboards, cross-agent sync, or audit trails.
|
|
@@ -392,7 +392,7 @@ Good for teams with multiple machines or always-on agents.
|
|
|
392
392
|
|
|
393
393
|
### Harper Fabric
|
|
394
394
|
|
|
395
|
-
Deploy Flair on [Harper Fabric](https://www.harperdb.io/) for managed hosting with multi-region replication and failover. Federation
|
|
395
|
+
Deploy Flair on [Harper Fabric](https://www.harperdb.io/) for managed hosting with multi-region replication and failover. Federation runs against Harper Fabric hubs (e.g. `flair.your-org.harperfabric.com`) — pair your local instance to sync memories across nodes.
|
|
396
396
|
|
|
397
397
|
## Security
|
|
398
398
|
|
package/dist/cli.js
CHANGED
|
@@ -3215,28 +3215,45 @@ federation
|
|
|
3215
3215
|
}
|
|
3216
3216
|
const result = await res.json();
|
|
3217
3217
|
console.log(`✅ Paired with hub: ${result.instance?.id ?? hubUrl}`);
|
|
3218
|
-
// Record the hub as our peer
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
3224
|
-
|
|
3225
|
-
|
|
3226
|
-
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
publicKey: result.instance?.publicKey ?? "",
|
|
3231
|
-
role: "hub", endpoint: hubUrl, status: "paired",
|
|
3232
|
-
pairedAt: new Date().toISOString(),
|
|
3233
|
-
createdAt: new Date().toISOString(),
|
|
3234
|
-
updatedAt: new Date().toISOString(),
|
|
3235
|
-
}],
|
|
3236
|
-
}),
|
|
3237
|
-
signal: AbortSignal.timeout(10_000),
|
|
3238
|
-
});
|
|
3218
|
+
// Record the hub as our local peer. This is REQUIRED, not optional:
|
|
3219
|
+
// `flair federation sync` reads the Peer table to find the hub, so
|
|
3220
|
+
// without this record sync reports "No hub peer configured" and silently
|
|
3221
|
+
// never runs. Previously this was gated on `if (adminPass)` and the write
|
|
3222
|
+
// result was never checked — pairing with only an agent key (or a failed
|
|
3223
|
+
// upsert) left no peer behind a misleadingly green "✅ Paired".
|
|
3224
|
+
const adminPass = opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? process.env.HDB_ADMIN_PASSWORD ?? "";
|
|
3225
|
+
if (!adminPass) {
|
|
3226
|
+
console.error("Error: paired on the hub, but the local hub-peer record needs admin auth to write — " +
|
|
3227
|
+
"pass --admin-pass, or set FLAIR_ADMIN_PASS / HDB_ADMIN_PASSWORD, then re-run pair. " +
|
|
3228
|
+
"Without it, 'flair federation sync' will report 'No hub peer configured'.");
|
|
3229
|
+
process.exit(1);
|
|
3239
3230
|
}
|
|
3231
|
+
const auth = `Basic ${Buffer.from(`${DEFAULT_ADMIN_USER}:${adminPass}`).toString("base64")}`;
|
|
3232
|
+
const opsEndpoint = resolveEffectiveOpsUrl(opts) ?? `http://127.0.0.1:${resolveOpsPort(opts)}`;
|
|
3233
|
+
const peerRes = await fetch(`${opsEndpoint}/`, {
|
|
3234
|
+
method: "POST",
|
|
3235
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
3236
|
+
body: JSON.stringify({
|
|
3237
|
+
operation: "upsert", database: "flair", table: "Peer",
|
|
3238
|
+
records: [{
|
|
3239
|
+
id: result.instance?.id ?? "hub",
|
|
3240
|
+
publicKey: result.instance?.publicKey ?? "",
|
|
3241
|
+
role: "hub", endpoint: hubUrl, status: "paired",
|
|
3242
|
+
pairedAt: new Date().toISOString(),
|
|
3243
|
+
createdAt: new Date().toISOString(),
|
|
3244
|
+
updatedAt: new Date().toISOString(),
|
|
3245
|
+
}],
|
|
3246
|
+
}),
|
|
3247
|
+
signal: AbortSignal.timeout(10_000),
|
|
3248
|
+
});
|
|
3249
|
+
if (!peerRes.ok) {
|
|
3250
|
+
const text = await peerRes.text().catch(() => "");
|
|
3251
|
+
console.error(`Error: paired with the hub but failed to write the local hub-peer record ` +
|
|
3252
|
+
`(${peerRes.status} ${text.slice(0, 200)}). Ops endpoint: ${opsEndpoint}. ` +
|
|
3253
|
+
`'flair federation sync' will not find the hub until this succeeds — check --admin-pass and the ops port.`);
|
|
3254
|
+
process.exit(1);
|
|
3255
|
+
}
|
|
3256
|
+
console.log(`✅ Recorded hub as local peer: ${result.instance?.id ?? "hub"} → ${hubUrl}`);
|
|
3240
3257
|
}
|
|
3241
3258
|
catch (err) {
|
|
3242
3259
|
console.error(`Error: ${err.message}`);
|
|
@@ -3373,25 +3390,63 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3373
3390
|
const hubUrl = hub.endpoint ?? hub.id;
|
|
3374
3391
|
// ── Batching constants ──────────────────────────────────────────────
|
|
3375
3392
|
// 2MB JSON budget (server cap is 10MB; 2MB leaves headroom for headers
|
|
3376
|
-
// and signature metadata) +
|
|
3393
|
+
// and signature metadata) + 50 records max per batch. The hub merge itself
|
|
3394
|
+
// is fast (~1.7s/50 records, per its SyncLog), but the Fabric ingress was
|
|
3395
|
+
// observed to intermittently stall on larger POSTs — a 50-record batch hung
|
|
3396
|
+
// ~2 min while the same records split into 2×25 went through immediately.
|
|
3397
|
+
// 50 keeps batches in the reliable range, and sendBatch's adaptive split
|
|
3398
|
+
// recovers if a stretch still stalls.
|
|
3377
3399
|
const BUDGET_BYTES = 2_000_000;
|
|
3378
|
-
const BUDGET_RECORDS =
|
|
3400
|
+
const BUDGET_RECORDS = 50;
|
|
3379
3401
|
// ── sendBatch helper ────────────────────────────────────────────────
|
|
3380
3402
|
// Secret key is lazy-loaded: only needed when there are records to send.
|
|
3381
3403
|
// Loading earlier would cause a spurious error when SQL queries fail
|
|
3382
3404
|
// (e.g. 401) before we know we have records.
|
|
3383
3405
|
let secretKey;
|
|
3406
|
+
// Statuses the Fabric ingress returns when a batch POST didn't complete in
|
|
3407
|
+
// time (408) or was too large (413), plus the transient gateway 5xx family.
|
|
3408
|
+
// Splitting the batch and retrying smaller chunks lets the sync converge
|
|
3409
|
+
// instead of aborting the whole run.
|
|
3410
|
+
const TIMEOUT_STATUSES = new Set([408, 413, 502, 503, 504]);
|
|
3411
|
+
// Per-batch wall-clock cap. Without it a stalled connection to the Fabric
|
|
3412
|
+
// ingress hangs the whole sync until the *gateway's* timeout fires (~2 min
|
|
3413
|
+
// observed), which is what stranded the re-pair. A 45s cap is generous —
|
|
3414
|
+
// a healthy 50-record batch merges in <2s — so a trip means a real stall,
|
|
3415
|
+
// and we split-and-retry rather than wait it out.
|
|
3416
|
+
const BATCH_TIMEOUT_MS = 45_000;
|
|
3384
3417
|
async function sendBatch(batch) {
|
|
3385
3418
|
if (!secretKey)
|
|
3386
3419
|
secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
3387
3420
|
const syncBody = { instanceId: instance.id, records: batch, lamportClock: Date.now() };
|
|
3388
3421
|
const signedSyncBody = signBodyFresh(syncBody, secretKey);
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3422
|
+
// Halve and retry down to a single record. Covers both an explicit
|
|
3423
|
+
// timeout status AND a client-side abort (stalled socket). The hub
|
|
3424
|
+
// merges idempotently (put-by-id), so retried records are safe.
|
|
3425
|
+
const splittable = (status) => batch.length > 1 && (status === null || TIMEOUT_STATUSES.has(status));
|
|
3426
|
+
const split = async () => {
|
|
3427
|
+
const mid = Math.floor(batch.length / 2);
|
|
3428
|
+
const left = await sendBatch(batch.slice(0, mid));
|
|
3429
|
+
const right = await sendBatch(batch.slice(mid));
|
|
3430
|
+
return { merged: left.merged + right.merged, skipped: left.skipped + right.skipped };
|
|
3431
|
+
};
|
|
3432
|
+
let syncRes;
|
|
3433
|
+
try {
|
|
3434
|
+
syncRes = await fetch(`${hubUrl}/FederationSync`, {
|
|
3435
|
+
method: "POST",
|
|
3436
|
+
headers: { "Content-Type": "application/json" },
|
|
3437
|
+
body: JSON.stringify(signedSyncBody),
|
|
3438
|
+
signal: AbortSignal.timeout(BATCH_TIMEOUT_MS),
|
|
3439
|
+
});
|
|
3440
|
+
}
|
|
3441
|
+
catch (err) {
|
|
3442
|
+
// Timeout/abort or network drop — no status. Split if we can.
|
|
3443
|
+
if (splittable(null))
|
|
3444
|
+
return await split();
|
|
3445
|
+
throw new Error(`Sync batch (${batch.length} record${batch.length === 1 ? "" : "s"}) failed: ${err?.message ?? err}`);
|
|
3446
|
+
}
|
|
3394
3447
|
if (!syncRes.ok) {
|
|
3448
|
+
if (splittable(syncRes.status))
|
|
3449
|
+
return await split();
|
|
3395
3450
|
const text = await syncRes.text().catch(() => "");
|
|
3396
3451
|
throw new Error(`Sync batch failed: ${syncRes.status} ${text}`);
|
|
3397
3452
|
}
|
|
@@ -3399,24 +3454,35 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3399
3454
|
}
|
|
3400
3455
|
let totalBatches = 0;
|
|
3401
3456
|
for (const table of tables) {
|
|
3402
|
-
let
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3457
|
+
let rows = [];
|
|
3458
|
+
for (const query of [
|
|
3459
|
+
{ search_attribute: "updatedAt", search_type: "greater_than", search_value: since },
|
|
3460
|
+
// Rows with null updatedAt (legacy direct-insert rows) use createdAt.
|
|
3461
|
+
// COALESCE(updatedAt, createdAt) > since → pick up null-updatedAt rows
|
|
3462
|
+
// whose createdAt > since. Filtered in JS below.
|
|
3463
|
+
{ search_attribute: "updatedAt", search_type: "equals", search_value: null },
|
|
3464
|
+
]) {
|
|
3465
|
+
let res;
|
|
3466
|
+
try {
|
|
3467
|
+
res = await fetch(`${opsEndpoint}/`, {
|
|
3468
|
+
method: "POST",
|
|
3469
|
+
headers: { "Content-Type": "application/json", Authorization: auth },
|
|
3470
|
+
body: JSON.stringify({ operation: "search_by_conditions", schema: "flair", table, operator: "and", conditions: [query], get_attributes: ["*"] }),
|
|
3471
|
+
signal: AbortSignal.timeout(15_000),
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
catch (err) {
|
|
3475
|
+
return { pushed: totalMerged, skipped: totalSkipped, error: err instanceof Error ? err : new Error(String(err)) };
|
|
3476
|
+
}
|
|
3477
|
+
if (!res.ok) {
|
|
3478
|
+
const text = await res.text().catch(() => "");
|
|
3479
|
+
return { pushed: totalMerged, skipped: totalSkipped, error: new Error(`SQL query failed (${res.status}): ${text}`) };
|
|
3480
|
+
}
|
|
3481
|
+
const batch = await res.json();
|
|
3482
|
+
// For null-updatedAt rows, use createdAt as the effective timestamp.
|
|
3483
|
+
// Skip rows created before the last sync cursor.
|
|
3484
|
+
rows = rows.concat(batch.filter((r) => r.updatedAt !== null || r.createdAt > since));
|
|
3417
3485
|
}
|
|
3418
|
-
// Stream-collect records into batches
|
|
3419
|
-
const rows = await res.json();
|
|
3420
3486
|
if (rows.length === 0)
|
|
3421
3487
|
continue;
|
|
3422
3488
|
let batch = [];
|
|
@@ -3473,6 +3539,31 @@ export async function runFederationSyncOnce(opts) {
|
|
|
3473
3539
|
console.warn(`⚠️ Local hub.lastSyncAt advance error: ${advErr?.message ?? advErr}. Next poll will re-send memories.`);
|
|
3474
3540
|
}
|
|
3475
3541
|
if (totalBatches === 0) {
|
|
3542
|
+
// ops-c7t9: no-change syncs must still ping the hub so it updates the
|
|
3543
|
+
// spoke's lastSyncAt (liveness). Without this, idle-but-alive spokes
|
|
3544
|
+
// look indistinguishable from dead ones on the hub dashboard.
|
|
3545
|
+
try {
|
|
3546
|
+
if (!secretKey)
|
|
3547
|
+
secretKey = await loadInstanceSecretKey(instance.id, opts);
|
|
3548
|
+
const pingBody = signBodyFresh({
|
|
3549
|
+
instanceId: instance.id,
|
|
3550
|
+
records: [],
|
|
3551
|
+
lamportClock: Date.now(),
|
|
3552
|
+
}, secretKey);
|
|
3553
|
+
const pingRes = await fetch(`${hubUrl}/FederationSync`, {
|
|
3554
|
+
method: "POST",
|
|
3555
|
+
headers: { "Content-Type": "application/json" },
|
|
3556
|
+
body: JSON.stringify(pingBody),
|
|
3557
|
+
signal: AbortSignal.timeout(10_000),
|
|
3558
|
+
});
|
|
3559
|
+
if (!pingRes.ok) {
|
|
3560
|
+
const txt = await pingRes.text().catch(() => "");
|
|
3561
|
+
console.warn(`⚠️ Liveness ping to hub failed (${pingRes.status}): ${txt.slice(0, 200)}. Hub won't update spoke liveness.`);
|
|
3562
|
+
}
|
|
3563
|
+
}
|
|
3564
|
+
catch (pingErr) {
|
|
3565
|
+
console.warn(`⚠️ Liveness ping error: ${pingErr?.message ?? pingErr}. Hub won't update spoke liveness.`);
|
|
3566
|
+
}
|
|
3476
3567
|
console.log("No changes since last sync.");
|
|
3477
3568
|
return { pushed: 0, skipped: 0 };
|
|
3478
3569
|
}
|
|
@@ -4693,7 +4784,7 @@ function oauthDetailLines(o) {
|
|
|
4693
4784
|
// discoverLocalFlairPort when the configured URL is unreachable, to detect
|
|
4694
4785
|
// config-vs-daemon port drift (ops-mbdi). Order is ad-hoc — first hit wins.
|
|
4695
4786
|
//
|
|
4696
|
-
// 9926: original default (long-running
|
|
4787
|
+
// 9926: original default (long-running early installs predate the bump)
|
|
4697
4788
|
// 19926: current default (DEFAULT_PORT)
|
|
4698
4789
|
// 19925: ops-anvil VM secondary
|
|
4699
4790
|
const LOCAL_FLAIR_PROBE_PORTS = [9926, 19926, 19925];
|
|
@@ -6969,7 +7060,7 @@ memory.command("list")
|
|
|
6969
7060
|
});
|
|
6970
7061
|
// ─── flair memory hygiene ────────────────────────────────────────────────────
|
|
6971
7062
|
// Detect + remove junk memory rows that accumulate over time. Surfaced from
|
|
6972
|
-
// the 2026-05-07 manual cleanup (ops-ucyy):
|
|
7063
|
+
// the 2026-05-07 manual cleanup (ops-ucyy): an instance had 627 records, ~250 of
|
|
6973
7064
|
// them were noise — `*-compact-*` ID fragments from an old pipeline, pangram
|
|
6974
7065
|
// test content ("the quick brown fox..." / "Flair 251 test ..."), and
|
|
6975
7066
|
// near-empty rows (<25 chars). We did the cleanup ad-hoc with raw curl + jq;
|
|
@@ -8760,9 +8851,74 @@ program
|
|
|
8760
8851
|
process.exit(1);
|
|
8761
8852
|
}
|
|
8762
8853
|
});
|
|
8854
|
+
// ─── flair presence ─────────────────────────────────────────────────────────
|
|
8855
|
+
const VALID_PRESENCE_ACTIVITIES = ["coding", "reviewing", "planning", "idle"];
|
|
8856
|
+
const MAX_TASK_LENGTH = 120;
|
|
8857
|
+
const presence = program.command("presence").description("Manage agent presence (The Office Space)");
|
|
8858
|
+
presence
|
|
8859
|
+
.command("set")
|
|
8860
|
+
.description("Set your agent's current activity and task (POST /Presence)")
|
|
8861
|
+
.option("--activity <activity>", `Activity type (${VALID_PRESENCE_ACTIVITIES.join("|")})`)
|
|
8862
|
+
.option("--task <text>", "Short description of what you're working on")
|
|
8863
|
+
.option("--agent <id>", "Agent ID (env: FLAIR_AGENT_ID)")
|
|
8864
|
+
.option("--port <port>", "Harper HTTP port")
|
|
8865
|
+
.option("--target <url>", "Remote Flair URL (env: FLAIR_TARGET)")
|
|
8866
|
+
.action(async (opts) => {
|
|
8867
|
+
const agentId = resolveAgentIdOrEnv(opts);
|
|
8868
|
+
if (!agentId) {
|
|
8869
|
+
console.error("Error: agent ID required. Pass --agent <id> or set FLAIR_AGENT_ID environment variable.");
|
|
8870
|
+
process.exit(1);
|
|
8871
|
+
}
|
|
8872
|
+
// Validate activity
|
|
8873
|
+
if (!opts.activity) {
|
|
8874
|
+
console.error("Error: --activity is required.");
|
|
8875
|
+
process.exit(1);
|
|
8876
|
+
}
|
|
8877
|
+
const activity = opts.activity;
|
|
8878
|
+
if (!VALID_PRESENCE_ACTIVITIES.includes(activity)) {
|
|
8879
|
+
console.error(`Error: invalid activity '${activity}'. Must be one of: ${VALID_PRESENCE_ACTIVITIES.join(", ")}`);
|
|
8880
|
+
process.exit(1);
|
|
8881
|
+
}
|
|
8882
|
+
// Validate task length
|
|
8883
|
+
const task = opts.task ?? undefined;
|
|
8884
|
+
if (task && task.length > MAX_TASK_LENGTH) {
|
|
8885
|
+
console.error(`Error: --task exceeds ${MAX_TASK_LENGTH} character limit (got ${task.length}).`);
|
|
8886
|
+
process.exit(1);
|
|
8887
|
+
}
|
|
8888
|
+
// Resolve key path
|
|
8889
|
+
const keyPath = resolveKeyPath(agentId);
|
|
8890
|
+
if (!keyPath) {
|
|
8891
|
+
console.error(`Error: private key not found for agent '${agentId}'. Check ~/.flair/keys/ or set FLAIR_KEY_DIR.`);
|
|
8892
|
+
process.exit(1);
|
|
8893
|
+
}
|
|
8894
|
+
// Build auth + POST
|
|
8895
|
+
const baseUrl = resolveBaseUrl(opts).replace(/\/$/, "");
|
|
8896
|
+
const auth = buildEd25519Auth(agentId, "POST", "/Presence", keyPath);
|
|
8897
|
+
const body = { activity };
|
|
8898
|
+
if (task)
|
|
8899
|
+
body.currentTask = task;
|
|
8900
|
+
const res = await fetch(`${baseUrl}/Presence`, {
|
|
8901
|
+
method: "POST",
|
|
8902
|
+
headers: {
|
|
8903
|
+
"Content-Type": "application/json",
|
|
8904
|
+
Authorization: auth,
|
|
8905
|
+
},
|
|
8906
|
+
body: JSON.stringify(body),
|
|
8907
|
+
});
|
|
8908
|
+
if (!res.ok) {
|
|
8909
|
+
const text = await res.text().catch(() => "");
|
|
8910
|
+
console.error(`Error: POST /Presence failed (${res.status}): ${text}`);
|
|
8911
|
+
process.exit(1);
|
|
8912
|
+
}
|
|
8913
|
+
const data = await res.json().catch(() => null);
|
|
8914
|
+
console.log(`✓ Presence updated for '${agentId}': activity=${activity}${task ? `, task="${task}"` : ""}`);
|
|
8915
|
+
if (data?.presenceStatus) {
|
|
8916
|
+
console.log(` Status: ${data.presenceStatus}`);
|
|
8917
|
+
}
|
|
8918
|
+
});
|
|
8763
8919
|
// Run CLI only when this is the entry point (not when imported for testing)
|
|
8764
8920
|
if (import.meta.main) {
|
|
8765
8921
|
await program.parseAsync();
|
|
8766
8922
|
}
|
|
8767
8923
|
// ─── Exported for testing ─────────────────────────────────────────────────────
|
|
8768
|
-
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
|
|
8924
|
+
export { resolveKeyPath, buildEd25519Auth, readPortFromConfig, resolveHttpPort, resolveOpsPort, resolveTarget, resolveOpsTarget, resolveEffectiveOpsUrl, resolveOpsUrlFromTarget, signRequestBody, b64, b64url, program, api, VALID_PRESENCE_ACTIVITIES, MAX_TASK_LENGTH, isLocalBase, isLikelyRealSecret, shouldShowInlineSecretWarning, parseTokenFromFile, };
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { Resource, databases } from "@harperfast/harper";
|
|
2
|
-
|
|
3
|
-
return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
|
|
4
|
-
}
|
|
5
|
-
function readSoulContent(entry) {
|
|
6
|
-
return String(entry.content ?? entry.value ?? "").trim();
|
|
7
|
-
}
|
|
2
|
+
import { selectPublicDescription, selectPublicSkills } from "./agentcard-fields.js";
|
|
8
3
|
export class AgentCard extends Resource {
|
|
9
4
|
async get(pathInfo) {
|
|
10
5
|
const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
|
|
@@ -25,19 +20,15 @@ export class AgentCard extends Resource {
|
|
|
25
20
|
if (row?.agentId === agentId)
|
|
26
21
|
souls.push(row);
|
|
27
22
|
}
|
|
28
|
-
const descriptionEntry = souls.find((s) => readSoulKind(s) === "description" && readSoulContent(s)) ??
|
|
29
|
-
souls.find((s) => readSoulContent(s));
|
|
30
|
-
const skills = souls
|
|
31
|
-
.filter((s) => readSoulKind(s) === "capability")
|
|
32
|
-
.map((s) => readSoulContent(s))
|
|
33
|
-
.filter(Boolean);
|
|
34
23
|
return {
|
|
35
24
|
name: String(agent.name ?? agent.id ?? agentId),
|
|
36
|
-
description
|
|
25
|
+
// Only an explicit kind="description" soul publishes — no private-soul
|
|
26
|
+
// fallback (ops-vz6j). See agentcard-fields.ts for the security rationale.
|
|
27
|
+
description: selectPublicDescription(souls),
|
|
37
28
|
url: String(agent.url ?? ""),
|
|
38
29
|
version: String(agent.version ?? "1.0.0"),
|
|
39
30
|
capabilities: agent.capabilities && typeof agent.capabilities === "object" ? agent.capabilities : {},
|
|
40
|
-
skills,
|
|
31
|
+
skills: selectPublicSkills(souls),
|
|
41
32
|
defaultInputModes: ["text"],
|
|
42
33
|
defaultOutputModes: ["text"],
|
|
43
34
|
};
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* POST /Presence — agent heartbeat writes its own presence.
|
|
3
|
+
* GET /Presence — public-safe presence roster for The Office Space.
|
|
4
|
+
*
|
|
5
|
+
* Extends the auto-generated Presence table resource (from schema.graphql).
|
|
6
|
+
* Overrides get() for public-safe roster and post() for Ed25519-authed
|
|
7
|
+
* heartbeat writes.
|
|
8
|
+
*
|
|
9
|
+
* Auth:
|
|
10
|
+
* GET — public (returns only allowlisted fields; safe for public renderer).
|
|
11
|
+
* POST — Ed25519 agent credential (TPS-Ed25519 header). Agent writes only its
|
|
12
|
+
* own record; cross-agent writes are rejected (403).
|
|
13
|
+
*
|
|
14
|
+
* Security (Sherlock):
|
|
15
|
+
* - Write: per-agent Ed25519 auth. Cross-agent → 403.
|
|
16
|
+
* - Read: field-allowlisted to public-safe set. No secrets, no admin data.
|
|
17
|
+
* - currentTask is agent-authored free text → cap length, escape on render.
|
|
18
|
+
*/
|
|
19
|
+
import { databases } from "@harperfast/harper";
|
|
20
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
21
|
+
const WINDOW_MS = 30_000;
|
|
22
|
+
const CURRENT_TASK_MAX_LENGTH = 200;
|
|
23
|
+
const VALID_ACTIVITIES = new Set(["coding", "reviewing", "planning", "idle"]);
|
|
24
|
+
function idleThresholdMs() {
|
|
25
|
+
const env = process.env.PRESENCE_IDLE_THRESHOLD_MS;
|
|
26
|
+
return env ? Number(env) || 90_000 : 90_000;
|
|
27
|
+
}
|
|
28
|
+
function offlineThresholdMs() {
|
|
29
|
+
const env = process.env.PRESENCE_OFFLINE_THRESHOLD_MS;
|
|
30
|
+
return env ? Number(env) || 600_000 : 600_000;
|
|
31
|
+
}
|
|
32
|
+
// ─── Nonce replay protection ──────────────────────────────────────────────────
|
|
33
|
+
const nonceSeen = new Map();
|
|
34
|
+
function pruneNonces() {
|
|
35
|
+
const now = Date.now();
|
|
36
|
+
for (const [k, ts] of nonceSeen.entries()) {
|
|
37
|
+
if (now - ts > WINDOW_MS)
|
|
38
|
+
nonceSeen.delete(k);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ─── Crypto helpers ───────────────────────────────────────────────────────────
|
|
42
|
+
function b64ToArrayBuffer(b64) {
|
|
43
|
+
const std = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
44
|
+
const bin = atob(std);
|
|
45
|
+
const buf = new ArrayBuffer(bin.length);
|
|
46
|
+
const view = new Uint8Array(buf);
|
|
47
|
+
for (let i = 0; i < bin.length; i++)
|
|
48
|
+
view[i] = bin.charCodeAt(i);
|
|
49
|
+
return buf;
|
|
50
|
+
}
|
|
51
|
+
const keyCache = new Map();
|
|
52
|
+
async function importEd25519Key(publicKeyStr) {
|
|
53
|
+
if (keyCache.has(publicKeyStr))
|
|
54
|
+
return keyCache.get(publicKeyStr);
|
|
55
|
+
let raw;
|
|
56
|
+
if (/^[0-9a-f]{64}$/i.test(publicKeyStr)) {
|
|
57
|
+
const bytes = new Uint8Array(32);
|
|
58
|
+
for (let i = 0; i < 32; i++)
|
|
59
|
+
bytes[i] = parseInt(publicKeyStr.slice(i * 2, i * 2 + 2), 16);
|
|
60
|
+
raw = bytes.buffer;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
raw = b64ToArrayBuffer(publicKeyStr);
|
|
64
|
+
}
|
|
65
|
+
const key = await crypto.subtle.importKey("raw", raw, { name: "Ed25519" }, false, ["verify"]);
|
|
66
|
+
keyCache.set(publicKeyStr, key);
|
|
67
|
+
return key;
|
|
68
|
+
}
|
|
69
|
+
// ─── Status derivation (pure — exported for unit testing) ─────────────────────
|
|
70
|
+
export function derivePresenceStatus(now, lastHeartbeatAt, idleMs, offlineMs) {
|
|
71
|
+
const idle = idleMs ?? idleThresholdMs();
|
|
72
|
+
const offline = offlineMs ?? offlineThresholdMs();
|
|
73
|
+
if (lastHeartbeatAt == null || !Number.isFinite(lastHeartbeatAt))
|
|
74
|
+
return "offline";
|
|
75
|
+
const elapsed = now - lastHeartbeatAt;
|
|
76
|
+
if (elapsed < 0)
|
|
77
|
+
return "active";
|
|
78
|
+
if (elapsed < idle)
|
|
79
|
+
return "active";
|
|
80
|
+
if (elapsed < offline)
|
|
81
|
+
return "idle";
|
|
82
|
+
return "offline";
|
|
83
|
+
}
|
|
84
|
+
// ─── Public-safe field allowlist ──────────────────────────────────────────────
|
|
85
|
+
const ROSTER_ALLOWLIST = new Set([
|
|
86
|
+
"id",
|
|
87
|
+
"displayName",
|
|
88
|
+
"role",
|
|
89
|
+
"runtime",
|
|
90
|
+
"activity",
|
|
91
|
+
"presenceStatus",
|
|
92
|
+
"currentTask",
|
|
93
|
+
"lastHeartbeatAt",
|
|
94
|
+
]);
|
|
95
|
+
function sanitizeCurrentTask(task) {
|
|
96
|
+
if (typeof task !== "string")
|
|
97
|
+
return null;
|
|
98
|
+
const trimmed = task.trim();
|
|
99
|
+
if (trimmed.length === 0)
|
|
100
|
+
return null;
|
|
101
|
+
return trimmed.slice(0, CURRENT_TASK_MAX_LENGTH);
|
|
102
|
+
}
|
|
103
|
+
function pickAllowlisted(record) {
|
|
104
|
+
const out = {};
|
|
105
|
+
for (const key of Object.keys(record)) {
|
|
106
|
+
if (ROSTER_ALLOWLIST.has(key))
|
|
107
|
+
out[key] = record[key];
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
// ─── Resource ─────────────────────────────────────────────────────────────────
|
|
112
|
+
/**
|
|
113
|
+
* Extends the auto-generated Presence table resource (from schema.graphql) so
|
|
114
|
+
* Harper resolves the /Presence path without conflict. Overrides get() for the
|
|
115
|
+
* public-safe roster view and post() for Ed25519-authed heartbeat writes.
|
|
116
|
+
*/
|
|
117
|
+
export class Presence extends databases.flair.Presence {
|
|
118
|
+
/** Bypass Harper's role gate for GET (public-safe data only). */
|
|
119
|
+
allowRead() {
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
/** Bypass Harper's role gate for POST (Ed25519 auth handled internally). */
|
|
123
|
+
allowCreate() {
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* GET /Presence — public-safe presence roster.
|
|
128
|
+
*
|
|
129
|
+
* Joins Presence records with Agent metadata and derives presenceStatus.
|
|
130
|
+
* Only allowlisted fields are returned (no secrets, no admin data).
|
|
131
|
+
*/
|
|
132
|
+
async get() {
|
|
133
|
+
const now = Date.now();
|
|
134
|
+
const idleThreshold = idleThresholdMs();
|
|
135
|
+
const offlineThreshold = offlineThresholdMs();
|
|
136
|
+
const results = [];
|
|
137
|
+
try {
|
|
138
|
+
const presenceRows = databases.flair.Presence.search();
|
|
139
|
+
for await (const row of presenceRows) {
|
|
140
|
+
const agentId = row?.agentId;
|
|
141
|
+
if (!agentId)
|
|
142
|
+
continue;
|
|
143
|
+
let agent = null;
|
|
144
|
+
try {
|
|
145
|
+
agent = await databases.flair.Agent.get(agentId);
|
|
146
|
+
}
|
|
147
|
+
catch { /* agent may not exist or be deactivated */ }
|
|
148
|
+
const entry = {
|
|
149
|
+
id: agentId,
|
|
150
|
+
displayName: agent?.displayName ?? agent?.name ?? agentId,
|
|
151
|
+
role: agent?.role ?? "agent",
|
|
152
|
+
runtime: agent?.runtime ?? null,
|
|
153
|
+
activity: row?.activity ?? "idle",
|
|
154
|
+
presenceStatus: derivePresenceStatus(now, typeof row?.lastHeartbeatAt === "number"
|
|
155
|
+
? row.lastHeartbeatAt
|
|
156
|
+
: Number(row?.lastHeartbeatAt ?? 0), idleThreshold, offlineThreshold),
|
|
157
|
+
currentTask: sanitizeCurrentTask(row?.currentTask),
|
|
158
|
+
lastHeartbeatAt: typeof row?.lastHeartbeatAt === "number"
|
|
159
|
+
? row.lastHeartbeatAt
|
|
160
|
+
: Number(row?.lastHeartbeatAt ?? 0),
|
|
161
|
+
};
|
|
162
|
+
results.push(pickAllowlisted(entry));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return new Response(JSON.stringify({ error: "presence_query_failed", detail: err?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
167
|
+
}
|
|
168
|
+
return results;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* POST /Presence — agent heartbeat.
|
|
172
|
+
*
|
|
173
|
+
* Auth: TPS-Ed25519 header required. agentId from signature, NOT from body.
|
|
174
|
+
* An agent may update ONLY its own presence; cross-agent writes → 403.
|
|
175
|
+
*
|
|
176
|
+
* Bypasses Harper's default table post handler — writes via databases.flair.Presence
|
|
177
|
+
* directly so we control auth flow end-to-end.
|
|
178
|
+
*/
|
|
179
|
+
async post(content, context) {
|
|
180
|
+
const ctx = this.getContext?.();
|
|
181
|
+
const request = ctx?.request ?? ctx;
|
|
182
|
+
// ── Parse Ed25519 auth header ────────────────────────────────────────────
|
|
183
|
+
const authHeader = request?.headers?.get?.("authorization") ??
|
|
184
|
+
request?.headers?.asObject?.authorization ??
|
|
185
|
+
"";
|
|
186
|
+
// If the middleware already verified and set tpsAgent, trust it
|
|
187
|
+
const middlewareAgent = request?.tpsAgent;
|
|
188
|
+
let agentId;
|
|
189
|
+
if (middlewareAgent) {
|
|
190
|
+
agentId = middlewareAgent;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
const m = authHeader.match(/^TPS-Ed25519\s+([^:]+):(\d+):([^:]+):(.+)$/);
|
|
194
|
+
if (!m) {
|
|
195
|
+
return new Response(JSON.stringify({ error: "Ed25519 agent auth required for heartbeat" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
196
|
+
}
|
|
197
|
+
const [, headerAgentId, tsRaw, nonce, sigB64] = m;
|
|
198
|
+
const ts = Number(tsRaw);
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > WINDOW_MS) {
|
|
201
|
+
return new Response(JSON.stringify({ error: "timestamp_out_of_window" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
202
|
+
}
|
|
203
|
+
pruneNonces();
|
|
204
|
+
const nonceKey = `${headerAgentId}:${nonce}`;
|
|
205
|
+
if (nonceSeen.has(nonceKey)) {
|
|
206
|
+
return new Response(JSON.stringify({ error: "nonce_replay_detected" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
207
|
+
}
|
|
208
|
+
const agent = await databases.flair.Agent.get(headerAgentId).catch(() => null);
|
|
209
|
+
if (!agent) {
|
|
210
|
+
return new Response(JSON.stringify({ error: "unknown_agent" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
// request.url is just the path portion (e.g. "/Presence")
|
|
214
|
+
const pathname = (request.url ?? "/Presence").split("?")[0];
|
|
215
|
+
const payload = `${headerAgentId}:${tsRaw}:${nonce}:POST:${pathname}`;
|
|
216
|
+
const key = await importEd25519Key(agent.publicKey);
|
|
217
|
+
const sigBuf = b64ToArrayBuffer(sigB64);
|
|
218
|
+
const payloadBuf = new TextEncoder().encode(payload);
|
|
219
|
+
const ok = await crypto.subtle.verify({ name: "Ed25519" }, key, sigBuf, payloadBuf);
|
|
220
|
+
if (!ok) {
|
|
221
|
+
return new Response(JSON.stringify({ error: "invalid_signature" }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
return new Response(JSON.stringify({ error: "signature_verification_failed", detail: e?.message }), { status: 401, headers: { "Content-Type": "application/json" } });
|
|
226
|
+
}
|
|
227
|
+
nonceSeen.set(nonceKey, ts);
|
|
228
|
+
agentId = headerAgentId;
|
|
229
|
+
}
|
|
230
|
+
// ── Validate body ────────────────────────────────────────────────────────
|
|
231
|
+
const { currentTask, activity } = content || {};
|
|
232
|
+
if (activity !== undefined && !VALID_ACTIVITIES.has(activity)) {
|
|
233
|
+
return new Response(JSON.stringify({
|
|
234
|
+
error: "invalid_activity",
|
|
235
|
+
detail: `activity must be one of: ${[...VALID_ACTIVITIES].join(", ")}`,
|
|
236
|
+
}), { status: 400, headers: { "Content-Type": "application/json" } });
|
|
237
|
+
}
|
|
238
|
+
// ── Write presence ───────────────────────────────────────────────────────
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
try {
|
|
241
|
+
let existing = null;
|
|
242
|
+
try {
|
|
243
|
+
existing = await databases.flair.Presence.get(agentId);
|
|
244
|
+
}
|
|
245
|
+
catch { /* first heartbeat */ }
|
|
246
|
+
const record = {
|
|
247
|
+
agentId,
|
|
248
|
+
lastHeartbeatAt: now,
|
|
249
|
+
currentTask: sanitizeCurrentTask(currentTask),
|
|
250
|
+
activity: activity ?? (existing?.activity ?? "idle"),
|
|
251
|
+
};
|
|
252
|
+
if (existing) {
|
|
253
|
+
const merged = { ...existing, ...record };
|
|
254
|
+
await databases.flair.Presence.put(merged);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
await databases.flair.Presence.put(record);
|
|
258
|
+
}
|
|
259
|
+
return {
|
|
260
|
+
ok: true,
|
|
261
|
+
agentId,
|
|
262
|
+
lastHeartbeatAt: now,
|
|
263
|
+
presenceStatus: "active",
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
return new Response(JSON.stringify({ error: "presence_write_failed", detail: e?.message }), { status: 500, headers: { "Content-Type": "application/json" } });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Pure selection logic for what an agent's public AgentCard exposes.
|
|
2
|
+
//
|
|
3
|
+
// SECURITY: `GET /AgentCard/{id}` is intentionally UNAUTHENTICATED (A2A spec —
|
|
4
|
+
// agent cards are public discovery metadata, and the auth-middleware allow-list
|
|
5
|
+
// lets it bypass auth). Everything these helpers return is therefore world-
|
|
6
|
+
// readable. They must publish ONLY explicitly-kinded souls — never an implicit
|
|
7
|
+
// fallback that could surface a private soul (an operator's internal note,
|
|
8
|
+
// prompt fragment, or credential reminder) on the public endpoint.
|
|
9
|
+
//
|
|
10
|
+
// Kept free of any @harperfast/harper import so the real logic is unit-testable
|
|
11
|
+
// without spinning up Harper (avoids the simulator-pattern that let the
|
|
12
|
+
// description-fallback leak ship untested — ops-vz6j).
|
|
13
|
+
export function readSoulKind(entry) {
|
|
14
|
+
return String(entry.kind ?? entry.key ?? "").trim().toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
export function readSoulContent(entry) {
|
|
17
|
+
return String(entry.content ?? entry.value ?? "").trim();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The public description is ONLY an explicit `kind="description"` soul.
|
|
21
|
+
*
|
|
22
|
+
* There is deliberately no fallback to "the first soul with any content": that
|
|
23
|
+
* was the ops-vz6j leak — an agent with no description soul would publish an
|
|
24
|
+
* arbitrary private soul on the unauthenticated card. Absent an explicit
|
|
25
|
+
* description, publish nothing.
|
|
26
|
+
*/
|
|
27
|
+
export function selectPublicDescription(souls) {
|
|
28
|
+
const entry = souls.find((s) => readSoulKind(s) === "description" && readSoulContent(s));
|
|
29
|
+
return entry ? readSoulContent(entry) : "";
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Public skills are ONLY `kind="capability"` souls — an explicit, operator-
|
|
33
|
+
* intended allow-list. Any other soul kind is never published.
|
|
34
|
+
*/
|
|
35
|
+
export function selectPublicSkills(souls) {
|
|
36
|
+
return souls
|
|
37
|
+
.filter((s) => readSoulKind(s) === "capability")
|
|
38
|
+
.map((s) => readSoulContent(s))
|
|
39
|
+
.filter(Boolean);
|
|
40
|
+
}
|
|
@@ -143,7 +143,11 @@ server.http(async (request, nextLayer) => {
|
|
|
143
143
|
// auths every API call (/Agent, /SemanticSearch, /FederationPeers, etc).
|
|
144
144
|
// Without this allow-list entry, the HTML is 401-blocked on hosted Flair
|
|
145
145
|
// instances (rockit-local works only because authorizeLocal=true).
|
|
146
|
-
url.pathname === "/ObservationCenter"
|
|
146
|
+
url.pathname === "/ObservationCenter" ||
|
|
147
|
+
// Presence roster is public-safe (field-allowlisted); GET serves the
|
|
148
|
+
// Office Space renderer without auth. POST handles Ed25519 auth internally
|
|
149
|
+
// (allowCreate=true on the Resource).
|
|
150
|
+
url.pathname === "/Presence")
|
|
147
151
|
return nextLayer(request);
|
|
148
152
|
// If Harper has already authorized this request (e.g. authorizeLocal=true on localhost),
|
|
149
153
|
// trust Harper's auth decision and pass through without requiring additional headers.
|
|
@@ -272,32 +276,34 @@ server.http(async (request, nextLayer) => {
|
|
|
272
276
|
request.tpsAgent = agentId;
|
|
273
277
|
request._tpsAuthVerified = true;
|
|
274
278
|
request.tpsAgentIsAdmin = await isAdmin(agentId);
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
279
|
+
// Grant Harper-level permissions for the cryptographically-verified agent by
|
|
280
|
+
// setting request.user directly to the admin super_user.
|
|
281
|
+
//
|
|
282
|
+
// Prior approach swapped the Authorization header to "Basic admin:<pass>" so
|
|
283
|
+
// Harper's auth pipeline would re-authenticate with full (HNSW-capable)
|
|
284
|
+
// permissions. That silently broke under Harper 5.0.9: its authentication
|
|
285
|
+
// layer (security/auth.ts `authentication()`) reads the Authorization header
|
|
286
|
+
// and resolves request.user BEFORE invoking this middleware via nextHandler.
|
|
287
|
+
// A TPS-Ed25519 header matches no Basic/Bearer strategy, so Harper sets
|
|
288
|
+
// request.user = null and never re-reads the header — our post-hoc swap was
|
|
289
|
+
// ignored, the request ran unauthenticated, and Harper surfaced it downstream
|
|
290
|
+
// as a generic "Login failed" 401. (Broke at the 2026-05-27 daemon restart,
|
|
291
|
+
// which loaded 5.0.9 fresh; the prior long-running process held an older
|
|
292
|
+
// in-memory Harper where the late header read still worked.)
|
|
293
|
+
//
|
|
294
|
+
// Setting request.user directly is the supported extension path and is honored
|
|
295
|
+
// by all downstream resources, including HNSW vector search. getUser(admin,
|
|
296
|
+
// null) looks up the admin record WITHOUT password validation — safe here
|
|
297
|
+
// because the Ed25519 signature verified above already proves agent identity
|
|
298
|
+
// cryptographically; no Basic credential is presented. This preserves the
|
|
299
|
+
// prior privilege model (verified agents act with admin perms; per-agent data
|
|
300
|
+
// isolation is still enforced downstream via the x-tps-agent header below).
|
|
301
|
+
try {
|
|
302
|
+
request.user = await server.getUser("admin", null, request);
|
|
294
303
|
}
|
|
295
|
-
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
request.user = await server.getUser("admin", null, request);
|
|
299
|
-
}
|
|
300
|
-
catch { }
|
|
304
|
+
catch {
|
|
305
|
+
// Admin record unavailable — request proceeds as the verified tpsAgent
|
|
306
|
+
// without elevated perms; resource-level scoping still applies.
|
|
301
307
|
}
|
|
302
308
|
// Propagate authenticated agent to downstream resources via header.
|
|
303
309
|
// Resources can read this to enforce agent-level scoping.
|
package/dist/resources/health.js
CHANGED
|
@@ -23,10 +23,10 @@ const exists = async (path) => {
|
|
|
23
23
|
* which is what makes /Health work for callers outside `authorizeLocal`'s
|
|
24
24
|
* localhost-bypass. Without this, Harper's intrinsic Basic-auth gate fires
|
|
25
25
|
* BEFORE our HTTP middleware can apply the /Health bypass list in
|
|
26
|
-
* auth-middleware.ts, so remote callers (e.g., from
|
|
26
|
+
* auth-middleware.ts, so remote callers (e.g., from a remote host hitting a Fabric-
|
|
27
27
|
* hosted Flair) get a 401 even though the Resource handler is intentionally
|
|
28
28
|
* unauthenticated. Symptom matched: Fabric returned 401 + WWW-Authenticate:
|
|
29
|
-
* Basic on /Health while
|
|
29
|
+
* Basic on /Health while a localhost caller returned 200 — the difference was
|
|
30
30
|
* Harper's localhost-auto-auth, not anything in our code.
|
|
31
31
|
*
|
|
32
32
|
* Same pattern as `FederationPair.allowCreate(){ return true }` (PR #299):
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
"node": ">=22"
|
|
56
56
|
},
|
|
57
57
|
"dependencies": {
|
|
58
|
-
"@harperfast/harper": "5.0.
|
|
58
|
+
"@harperfast/harper": "5.0.21",
|
|
59
59
|
"@types/js-yaml": "^4.0.9",
|
|
60
60
|
"commander": "14.0.3",
|
|
61
61
|
"harper-fabric-embeddings": "0.2.3",
|
|
@@ -64,6 +64,15 @@
|
|
|
64
64
|
"tar": "^7.5.13",
|
|
65
65
|
"tweetnacl": "1.0.3"
|
|
66
66
|
},
|
|
67
|
+
"optionalDependencies": {
|
|
68
|
+
"@node-llama-cpp/linux-arm64": "3.18.1",
|
|
69
|
+
"@node-llama-cpp/linux-armv7l": "3.18.1",
|
|
70
|
+
"@node-llama-cpp/linux-x64": "3.18.1",
|
|
71
|
+
"@node-llama-cpp/mac-arm64-metal": "3.18.1",
|
|
72
|
+
"@node-llama-cpp/mac-x64": "3.18.1",
|
|
73
|
+
"@node-llama-cpp/win-arm64": "3.18.1",
|
|
74
|
+
"@node-llama-cpp/win-x64": "3.18.1"
|
|
75
|
+
},
|
|
67
76
|
"devDependencies": {
|
|
68
77
|
"@playwright/test": "^1.59.1",
|
|
69
78
|
"@types/node": "24.11.0",
|
package/schemas/schema.graphql
CHANGED
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
|
|
2
|
+
# ─── Presence table ───────────────────────────────────────────────────────────
|
|
3
|
+
|
|
4
|
+
type Presence @table(database: "flair") @export {
|
|
5
|
+
agentId: ID @primaryKey
|
|
6
|
+
lastHeartbeatAt: BigInt # unix ms timestamp
|
|
7
|
+
currentTask: String # short human string, e.g. "Reviewing flair#467"
|
|
8
|
+
activity: String @indexed # "coding" | "reviewing" | "planning" | "idle"
|
|
9
|
+
}
|
|
10
|
+
|
|
2
11
|
# ─── Observatory tables ───────────────────────────────────────────────────────
|
|
3
12
|
|
|
4
13
|
type ObsOffice @table(database: "flair") @export {
|
|
5
|
-
id: ID @primaryKey # e.g. "
|
|
14
|
+
id: ID @primaryKey # e.g. "office1"
|
|
6
15
|
name: String!
|
|
7
16
|
publicKey: String! # Ed25519 hex public key for auth verification
|
|
8
17
|
status: String @indexed # "online" | "offline" | "degraded"
|