From f5e874786ab6f5248452c8315cf5b4f5806d9d1f Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Wed, 18 Mar 2026 23:56:59 +0800 Subject: [PATCH 1/3] feat: blockchain-inspired World Ledger for agent activity tracking Append-only event ledger with hash chain and world signatures: - WorldLedger class: genesis block, hash chain (SHA-256), Ed25519-signed entries - Events: world.genesis, world.join, world.leave, world.evict, world.action - State derived from event replay (getAgentSummaries) - Persisted as JSON Lines (.jsonl), survives server restarts - Chain integrity verification (verify()) - HTTP endpoints: /world/ledger (with filtering), /world/agents - Auto-integrated into world-server.ts onMessage handlers - 13 new tests covering chain integrity, persistence, tampering, filtering Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/agent-world-sdk/src/index.ts | 5 + packages/agent-world-sdk/src/types.ts | 41 ++++ packages/agent-world-sdk/src/world-ledger.ts | 203 ++++++++++++++++++ packages/agent-world-sdk/src/world-server.ts | 38 ++++ test/world-ledger.test.mjs | 211 +++++++++++++++++++ 5 files changed, 498 insertions(+) create mode 100644 packages/agent-world-sdk/src/world-ledger.ts create mode 100644 test/world-ledger.test.mjs diff --git a/packages/agent-world-sdk/src/index.ts b/packages/agent-world-sdk/src/index.ts index 588bdf7..6be6fa3 100644 --- a/packages/agent-world-sdk/src/index.ts +++ b/packages/agent-world-sdk/src/index.ts @@ -29,6 +29,7 @@ export { } from "./bootstrap.js"; export { registerPeerRoutes } from "./peer-protocol.js"; export { createWorldServer } from "./world-server.js"; +export { WorldLedger } from "./world-ledger.js"; export type { Endpoint, PeerRecord, @@ -45,4 +46,8 @@ export type { WorldServer, KeyRotationRequest, KeyRotationIdentity, + LedgerEntry, + LedgerEvent, + AgentSummary, + LedgerQueryOpts, } from "./types.js"; diff --git a/packages/agent-world-sdk/src/types.ts b/packages/agent-world-sdk/src/types.ts index 95ca855..cbe195b 100644 --- a/packages/agent-world-sdk/src/types.ts +++ b/packages/agent-world-sdk/src/types.ts @@ -142,9 +142,50 @@ export interface WorldServer { /** Underlying Fastify instance — register additional routes here */ fastify: import("fastify").FastifyInstance identity: Identity + /** Append-only event ledger for agent activity */ + ledger: import("./world-ledger.js").WorldLedger stop(): Promise } +// ── World Ledger (append-only event log) ─────────────────────────────────────── + +export type LedgerEvent = + | "world.genesis" + | "world.join" + | "world.leave" + | "world.evict" + | "world.action" + +export interface LedgerEntry { + seq: number + prevHash: string + timestamp: number + event: LedgerEvent + agentId: string + alias?: string + data?: Record + hash: string + worldSig: string +} + +export interface AgentSummary { + agentId: string + alias: string + firstSeen: number + lastSeen: number + actions: number + joins: number + online: boolean +} + +export interface LedgerQueryOpts { + agentId?: string + event?: LedgerEvent | LedgerEvent[] + since?: number + until?: number + limit?: number +} + // ── Key rotation (AgentWorld v0.2 §6.10/§10.4) ──────────────────────────────── export interface KeyRotationIdentity { diff --git a/packages/agent-world-sdk/src/world-ledger.ts b/packages/agent-world-sdk/src/world-ledger.ts new file mode 100644 index 0000000..5aba503 --- /dev/null +++ b/packages/agent-world-sdk/src/world-ledger.ts @@ -0,0 +1,203 @@ +import fs from "fs" +import path from "path" +import crypto from "node:crypto" +import { signWithDomainSeparator, verifyWithDomainSeparator, DOMAIN_SEPARATORS } from "./crypto.js" +import type { Identity } from "./types.js" +import type { LedgerEntry, LedgerEvent, AgentSummary, LedgerQueryOpts } from "./types.js" + +const ZERO_HASH = "0".repeat(64) +const LEDGER_DOMAIN = `AgentWorld-Ledger-${DOMAIN_SEPARATORS.MESSAGE.split("-").slice(-1)[0].replace("\0", "")}` +const LEDGER_SEPARATOR = `AgentWorld-Ledger-${DOMAIN_SEPARATORS.MESSAGE.split("-")[2]}` + +/** + * Append-only event ledger for World Agent activity. + * + * Blockchain-inspired design: + * - Each entry references the previous entry's hash (hash chain) + * - Entries are signed by the world's identity (tamper-evident) + * - State is derived from replaying the event log + * - Persisted as JSON Lines (.jsonl) — one entry per line + */ +export class WorldLedger { + private entries: LedgerEntry[] = [] + private filePath: string + private identity: Identity + private worldId: string + private writeStream: fs.WriteStream | null = null + + constructor(dataDir: string, worldId: string, identity: Identity) { + this.filePath = path.join(dataDir, "world-ledger.jsonl") + this.identity = identity + this.worldId = worldId + this.load() + } + + private load(): void { + if (!fs.existsSync(this.filePath)) { + this.writeGenesis() + return + } + + const lines = fs.readFileSync(this.filePath, "utf8").trim().split("\n").filter(Boolean) + for (const line of lines) { + try { + this.entries.push(JSON.parse(line) as LedgerEntry) + } catch { + // skip malformed lines + } + } + + if (this.entries.length === 0) { + this.writeGenesis() + } + } + + private writeGenesis(): void { + const entry = this.buildEntry("world.genesis", this.identity.agentId, undefined, { + worldId: this.worldId, + }) + this.entries.push(entry) + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }) + fs.writeFileSync(this.filePath, JSON.stringify(entry) + "\n") + } + + private lastHash(): string { + if (this.entries.length === 0) return ZERO_HASH + return this.entries[this.entries.length - 1].hash + } + + private buildEntry( + event: LedgerEvent, + agentId: string, + alias?: string, + data?: Record + ): LedgerEntry { + const seq = this.entries.length + const prevHash = this.lastHash() + const timestamp = Date.now() + + const core = { seq, prevHash, timestamp, event, agentId, ...(alias ? { alias } : {}), ...(data ? { data } : {}) } + const hash = crypto.createHash("sha256").update(JSON.stringify(core)).digest("hex") + + const sigPayload = { ...core, hash } + const worldSig = signWithDomainSeparator(LEDGER_SEPARATOR, sigPayload, this.identity.secretKey) + + return { ...core, hash, worldSig } + } + + append(event: LedgerEvent, agentId: string, alias?: string, data?: Record): LedgerEntry { + const entry = this.buildEntry(event, agentId, alias, data) + this.entries.push(entry) + fs.appendFileSync(this.filePath, JSON.stringify(entry) + "\n") + return entry + } + + getEntries(opts?: LedgerQueryOpts): LedgerEntry[] { + let result = this.entries + + if (opts?.agentId) { + result = result.filter(e => e.agentId === opts.agentId) + } + if (opts?.event) { + const events = Array.isArray(opts.event) ? opts.event : [opts.event] + result = result.filter(e => events.includes(e.event)) + } + if (opts?.since) { + result = result.filter(e => e.timestamp >= opts.since!) + } + if (opts?.until) { + result = result.filter(e => e.timestamp <= opts.until!) + } + if (opts?.limit) { + result = result.slice(-opts.limit) + } + return result + } + + /** + * Derive agent summaries from the event log. + * Returns a map of agentId → summary with first/last seen, total actions, and online status. + */ + getAgentSummaries(): AgentSummary[] { + const map = new Map() + + for (const entry of this.entries) { + if (entry.event === "world.genesis") continue + const id = entry.agentId + let summary = map.get(id) + if (!summary) { + summary = { agentId: id, alias: "", firstSeen: entry.timestamp, lastSeen: entry.timestamp, actions: 0, joins: 0, online: false } + map.set(id, summary) + } + + if (entry.alias) summary.alias = entry.alias + summary.lastSeen = entry.timestamp + + switch (entry.event) { + case "world.join": + summary.joins++ + summary.online = true + break + case "world.action": + summary.actions++ + break + case "world.leave": + case "world.evict": + summary.online = false + break + } + } + + return [...map.values()].sort((a, b) => b.lastSeen - a.lastSeen) + } + + /** + * Verify the entire chain's integrity: hash chain + world signatures. + * Returns { ok, errors } where errors lists any broken entries. + */ + verify(): { ok: boolean; errors: Array<{ seq: number; error: string }> } { + const errors: Array<{ seq: number; error: string }> = [] + + for (let i = 0; i < this.entries.length; i++) { + const entry = this.entries[i] + + // Verify prevHash chain + const expectedPrev = i === 0 ? ZERO_HASH : this.entries[i - 1].hash + if (entry.prevHash !== expectedPrev) { + errors.push({ seq: entry.seq, error: `prevHash mismatch: expected ${expectedPrev.slice(0, 8)}..., got ${entry.prevHash.slice(0, 8)}...` }) + } + + // Verify self-hash + const { hash, worldSig, ...core } = entry + const expectedHash = crypto.createHash("sha256").update(JSON.stringify(core)).digest("hex") + if (hash !== expectedHash) { + errors.push({ seq: entry.seq, error: "hash mismatch" }) + } + + // Verify world signature + const sigPayload = { ...core, hash } + const valid = verifyWithDomainSeparator(LEDGER_SEPARATOR, this.identity.pubB64, sigPayload, worldSig) + if (!valid) { + errors.push({ seq: entry.seq, error: "invalid worldSig" }) + } + } + + return { ok: errors.length === 0, errors } + } + + get length(): number { + return this.entries.length + } + + get head(): LedgerEntry | undefined { + return this.entries[this.entries.length - 1] + } +} diff --git a/packages/agent-world-sdk/src/world-server.ts b/packages/agent-world-sdk/src/world-server.ts index 52f8a44..d83bc47 100644 --- a/packages/agent-world-sdk/src/world-server.ts +++ b/packages/agent-world-sdk/src/world-server.ts @@ -10,11 +10,13 @@ import { DOMAIN_SEPARATORS, signWithDomainSeparator, } from "./crypto.js"; +import { WorldLedger } from "./world-ledger.js"; import type { WorldConfig, WorldHooks, WorldServer, WorldManifest, + LedgerQueryOpts, } from "./types.js"; const DEFAULT_BOOTSTRAP_URL = @@ -92,6 +94,12 @@ export async function createWorldServer( // Track agents currently in world for idle eviction const agentLastSeen = new Map(); + // Append-only event ledger — blockchain-inspired agent activity log + const ledger = new WorldLedger(dataDir, worldId, identity); + console.log( + `[world] Ledger loaded — ${ledger.length} entries, head=${ledger.head?.hash.slice(0, 8) ?? "none"}` + ); + const fastify = Fastify({ logger: false }); // Register peer protocol routes @@ -127,6 +135,7 @@ export async function createWorldServer( } agentLastSeen.set(agentId, Date.now()); const result = await hooks.onJoin(agentId, data); + ledger.append("world.join", agentId, (data["alias"] ?? data["name"]) as string | undefined); sendReply({ ok: true, worldId, @@ -146,6 +155,7 @@ export async function createWorldServer( agentLastSeen.delete(agentId); if (wasPresent) { await hooks.onLeave(agentId); + ledger.append("world.leave", agentId); console.log( `[world] ${agentId.slice(0, 8)} left — ${ agentLastSeen.size @@ -163,6 +173,7 @@ export async function createWorldServer( } agentLastSeen.set(agentId, Date.now()); const { ok, state } = await hooks.onAction(agentId, data); + ledger.append("world.action", agentId, undefined, { action: data["action"] as string | undefined }); sendReply({ ok, state }); return; } @@ -173,6 +184,31 @@ export async function createWorldServer( }, }); + // World ledger HTTP endpoints + fastify.get("/world/ledger", async (req) => { + const query = req.query as Record; + const opts: LedgerQueryOpts = {}; + if (query.agent_id) opts.agentId = query.agent_id; + if (query.event) opts.event = query.event.split(",") as LedgerQueryOpts["event"]; + if (query.since) opts.since = parseInt(query.since); + if (query.limit) opts.limit = parseInt(query.limit); + return { + ok: true, + worldId, + chainHead: ledger.head?.hash ?? null, + total: ledger.length, + entries: ledger.getEntries(opts), + }; + }); + + fastify.get("/world/agents", async () => { + return { + ok: true, + worldId, + agents: ledger.getAgentSummaries(), + }; + }); + // Allow caller to register additional routes before listen if (setupRoutes) await setupRoutes(fastify); @@ -248,6 +284,7 @@ export async function createWorldServer( if (ts < cutoff) { agentLastSeen.delete(id); await hooks.onLeave(id).catch(() => {}); + ledger.append("world.evict", id, undefined, { reason: "idle" }); console.log(`[world] ${id.slice(0, 8)} evicted (idle)`); } } @@ -282,6 +319,7 @@ export async function createWorldServer( return { fastify, identity, + ledger, async stop() { clearInterval(broadcastTimer); clearInterval(evictionTimer); diff --git a/test/world-ledger.test.mjs b/test/world-ledger.test.mjs new file mode 100644 index 0000000..e86742e --- /dev/null +++ b/test/world-ledger.test.mjs @@ -0,0 +1,211 @@ +import { describe, it, beforeEach, afterEach } from "node:test" +import assert from "node:assert/strict" +import fs from "fs" +import path from "path" +import os from "os" +import { WorldLedger } from "../packages/agent-world-sdk/dist/world-ledger.js" +import { loadOrCreateIdentity } from "../packages/agent-world-sdk/dist/identity.js" + +let tmpDir +let identity + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "ledger-test-")) + identity = loadOrCreateIdentity(tmpDir, "test-identity") +}) + +afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +describe("WorldLedger", () => { + it("creates genesis entry on first init", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger.length, 1) + const entries = ledger.getEntries() + assert.equal(entries[0].event, "world.genesis") + assert.equal(entries[0].seq, 0) + assert.equal(entries[0].prevHash, "0".repeat(64)) + assert.equal(entries[0].agentId, identity.agentId) + assert.ok(entries[0].data?.worldId, "genesis should contain worldId") + assert.ok(entries[0].hash) + assert.ok(entries[0].worldSig) + }) + + it("appends join/action/leave events with hash chain", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const agentId = "aw:sha256:aabbccdd" + + const joinEntry = ledger.append("world.join", agentId, "TestBot") + assert.equal(joinEntry.seq, 1) + assert.equal(joinEntry.event, "world.join") + assert.equal(joinEntry.agentId, agentId) + assert.equal(joinEntry.alias, "TestBot") + assert.equal(joinEntry.prevHash, ledger.getEntries()[0].hash) + + const actionEntry = ledger.append("world.action", agentId, undefined, { action: "move" }) + assert.equal(actionEntry.seq, 2) + assert.equal(actionEntry.prevHash, joinEntry.hash) + assert.deepEqual(actionEntry.data, { action: "move" }) + + const leaveEntry = ledger.append("world.leave", agentId) + assert.equal(leaveEntry.seq, 3) + assert.equal(leaveEntry.prevHash, actionEntry.hash) + + assert.equal(ledger.length, 4) + }) + + it("persists to disk and reloads on new instance", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:agent1", "Alpha") + ledger1.append("world.action", "aw:sha256:agent1", undefined, { action: "attack" }) + assert.equal(ledger1.length, 3) + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger2.length, 3) + const entries = ledger2.getEntries() + assert.equal(entries[0].event, "world.genesis") + assert.equal(entries[1].event, "world.join") + assert.equal(entries[1].alias, "Alpha") + assert.equal(entries[2].event, "world.action") + }) + + it("verify() passes on valid chain", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Bot1") + ledger.append("world.action", "aw:sha256:a1") + ledger.append("world.leave", "aw:sha256:a1") + + const result = ledger.verify() + assert.equal(result.ok, true) + assert.equal(result.errors.length, 0) + }) + + it("verify() detects tampered entry on reload", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:a1", "Bot1") + + // Tamper with the file: change the alias in the second line + const filePath = path.join(tmpDir, "world-ledger.jsonl") + const lines = fs.readFileSync(filePath, "utf8").trim().split("\n") + const entry = JSON.parse(lines[1]) + entry.alias = "TAMPERED" + lines[1] = JSON.stringify(entry) + fs.writeFileSync(filePath, lines.join("\n") + "\n") + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + const result = ledger2.verify() + assert.equal(result.ok, false) + assert.ok(result.errors.length > 0) + }) + + it("getAgentSummaries() derives correct state from events", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + const a2 = "aw:sha256:agent2" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.join", a2, "Beta") + ledger.append("world.action", a1, undefined, { action: "move" }) + ledger.append("world.action", a1, undefined, { action: "attack" }) + ledger.append("world.action", a2, undefined, { action: "defend" }) + ledger.append("world.leave", a2) + + const summaries = ledger.getAgentSummaries() + assert.equal(summaries.length, 2) + + const alpha = summaries.find(s => s.agentId === a1) + assert.ok(alpha) + assert.equal(alpha.alias, "Alpha") + assert.equal(alpha.joins, 1) + assert.equal(alpha.actions, 2) + assert.equal(alpha.online, true) + + const beta = summaries.find(s => s.agentId === a2) + assert.ok(beta) + assert.equal(beta.alias, "Beta") + assert.equal(beta.joins, 1) + assert.equal(beta.actions, 1) + assert.equal(beta.online, false) + }) + + it("getAgentSummaries() tracks re-joins", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.leave", a1) + ledger.append("world.join", a1, "Alpha v2") + + const summaries = ledger.getAgentSummaries() + const alpha = summaries.find(s => s.agentId === a1) + assert.equal(alpha.joins, 2) + assert.equal(alpha.online, true) + assert.equal(alpha.alias, "Alpha v2") + }) + + it("getEntries() supports filtering by agentId", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Alpha") + ledger.append("world.join", "aw:sha256:a2", "Beta") + ledger.append("world.action", "aw:sha256:a1") + + const filtered = ledger.getEntries({ agentId: "aw:sha256:a1" }) + assert.equal(filtered.length, 2) + assert.ok(filtered.every(e => e.agentId === "aw:sha256:a1")) + }) + + it("getEntries() supports filtering by event type", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1") + ledger.append("world.action", "aw:sha256:a1") + ledger.append("world.leave", "aw:sha256:a1") + + const joins = ledger.getEntries({ event: "world.join" }) + assert.equal(joins.length, 1) + + const multi = ledger.getEntries({ event: ["world.join", "world.leave"] }) + assert.equal(multi.length, 2) + }) + + it("getEntries() supports limit (returns last N)", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + for (let i = 0; i < 10; i++) { + ledger.append("world.action", "aw:sha256:a1") + } + const last3 = ledger.getEntries({ limit: 3 }) + assert.equal(last3.length, 3) + assert.equal(last3[0].seq, 8) + }) + + it("head returns the last entry", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const entry = ledger.append("world.join", "aw:sha256:a1", "Alpha") + assert.equal(ledger.head?.hash, entry.hash) + }) + + it("evict event is recorded properly", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1", "Alpha") + ledger.append("world.evict", "aw:sha256:a1", undefined, { reason: "idle" }) + + const summaries = ledger.getAgentSummaries() + const alpha = summaries.find(s => s.agentId === "aw:sha256:a1") + assert.equal(alpha.online, false) + + const evicts = ledger.getEntries({ event: "world.evict" }) + assert.equal(evicts.length, 1) + assert.deepEqual(evicts[0].data, { reason: "idle" }) + }) + + it("each entry hash is unique", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + ledger.append("world.join", "aw:sha256:a1") + ledger.append("world.join", "aw:sha256:a2") + ledger.append("world.action", "aw:sha256:a1") + + const hashes = ledger.getEntries().map(e => e.hash) + const uniqueHashes = new Set(hashes) + assert.equal(uniqueHashes.size, hashes.length) + }) +}) From 78184225c78016c77e87b0ca455f1b1644d26c6c Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 19 Mar 2026 00:08:11 +0800 Subject: [PATCH 2/3] fix: address Codex review findings on World Ledger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [High] verify() now detects corrupted/dropped lines via corruptedLines counter and seq gap detection — truncated ledger no longer silently passes verification - [Medium] getAgentSummaries(liveAgentIds?) accepts optional live session set; /world/agents passes agentLastSeen keys so online status reflects reality after restart - [Low] /world/ledger endpoint now parses the 'until' query parameter - 2 new tests: corrupted line detection, liveAgentIds online status Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- packages/agent-world-sdk/src/world-ledger.ts | 35 ++++++++++++++-- packages/agent-world-sdk/src/world-server.ts | 3 +- test/world-ledger.test.mjs | 44 ++++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/packages/agent-world-sdk/src/world-ledger.ts b/packages/agent-world-sdk/src/world-ledger.ts index 5aba503..3be61bc 100644 --- a/packages/agent-world-sdk/src/world-ledger.ts +++ b/packages/agent-world-sdk/src/world-ledger.ts @@ -23,7 +23,8 @@ export class WorldLedger { private filePath: string private identity: Identity private worldId: string - private writeStream: fs.WriteStream | null = null + /** Number of raw lines that failed to parse on load (0 = clean) */ + public corruptedLines = 0 constructor(dataDir: string, worldId: string, identity: Identity) { this.filePath = path.join(dataDir, "world-ledger.jsonl") @@ -39,13 +40,19 @@ export class WorldLedger { } const lines = fs.readFileSync(this.filePath, "utf8").trim().split("\n").filter(Boolean) + let corrupted = 0 for (const line of lines) { try { this.entries.push(JSON.parse(line) as LedgerEntry) } catch { - // skip malformed lines + corrupted++ } } + this.corruptedLines = corrupted + + if (corrupted > 0) { + console.warn(`[ledger] WARNING: ${corrupted} corrupted line(s) detected in ${this.filePath}`) + } if (this.entries.length === 0) { this.writeGenesis() @@ -116,9 +123,12 @@ export class WorldLedger { /** * Derive agent summaries from the event log. - * Returns a map of agentId → summary with first/last seen, total actions, and online status. + * + * @param liveAgentIds Optional set of agent IDs currently in the live session. + * When provided, `online` is true only if the agent is in this set. + * When omitted, `online` is derived from the event log (may be stale after restart). */ - getAgentSummaries(): AgentSummary[] { + getAgentSummaries(liveAgentIds?: Set): AgentSummary[] { const map = new Map b.lastSeen - a.lastSeen) } @@ -166,9 +183,19 @@ export class WorldLedger { verify(): { ok: boolean; errors: Array<{ seq: number; error: string }> } { const errors: Array<{ seq: number; error: string }> = [] + // Detect corrupted/dropped lines from load + if (this.corruptedLines > 0) { + errors.push({ seq: -1, error: `${this.corruptedLines} corrupted line(s) dropped during load — possible data loss` }) + } + for (let i = 0; i < this.entries.length; i++) { const entry = this.entries[i] + // Detect seq gaps (entries dropped from middle of chain) + if (entry.seq !== i) { + errors.push({ seq: entry.seq, error: `seq gap: expected ${i}, got ${entry.seq}` }) + } + // Verify prevHash chain const expectedPrev = i === 0 ? ZERO_HASH : this.entries[i - 1].hash if (entry.prevHash !== expectedPrev) { diff --git a/packages/agent-world-sdk/src/world-server.ts b/packages/agent-world-sdk/src/world-server.ts index d83bc47..1e75bd6 100644 --- a/packages/agent-world-sdk/src/world-server.ts +++ b/packages/agent-world-sdk/src/world-server.ts @@ -191,6 +191,7 @@ export async function createWorldServer( if (query.agent_id) opts.agentId = query.agent_id; if (query.event) opts.event = query.event.split(",") as LedgerQueryOpts["event"]; if (query.since) opts.since = parseInt(query.since); + if (query.until) opts.until = parseInt(query.until); if (query.limit) opts.limit = parseInt(query.limit); return { ok: true, @@ -205,7 +206,7 @@ export async function createWorldServer( return { ok: true, worldId, - agents: ledger.getAgentSummaries(), + agents: ledger.getAgentSummaries(new Set(agentLastSeen.keys())), }; }); diff --git a/test/world-ledger.test.mjs b/test/world-ledger.test.mjs index e86742e..fe15d21 100644 --- a/test/world-ledger.test.mjs +++ b/test/world-ledger.test.mjs @@ -208,4 +208,48 @@ describe("WorldLedger", () => { const uniqueHashes = new Set(hashes) assert.equal(uniqueHashes.size, hashes.length) }) + + it("verify() detects corrupted/truncated lines on load", () => { + const ledger1 = new WorldLedger(tmpDir, "test-world", identity) + ledger1.append("world.join", "aw:sha256:a1", "Bot1") + assert.equal(ledger1.length, 2) + + // Append a corrupted line to the file + const filePath = path.join(tmpDir, "world-ledger.jsonl") + fs.appendFileSync(filePath, '{"broken":true, invalid json\n') + + const ledger2 = new WorldLedger(tmpDir, "test-world", identity) + assert.equal(ledger2.corruptedLines, 1) + assert.equal(ledger2.length, 2) // corrupted line dropped + + const result = ledger2.verify() + assert.equal(result.ok, false) + assert.ok(result.errors.some(e => e.error.includes("corrupted"))) + }) + + it("getAgentSummaries() uses liveAgentIds to determine online status", () => { + const ledger = new WorldLedger(tmpDir, "test-world", identity) + const a1 = "aw:sha256:agent1" + const a2 = "aw:sha256:agent2" + + ledger.append("world.join", a1, "Alpha") + ledger.append("world.join", a2, "Beta") + + // Without liveAgentIds — both online from log + const all = ledger.getAgentSummaries() + assert.equal(all.find(s => s.agentId === a1).online, true) + assert.equal(all.find(s => s.agentId === a2).online, true) + + // With liveAgentIds — only a1 is actually online + const live = new Set([a1]) + const filtered = ledger.getAgentSummaries(live) + assert.equal(filtered.find(s => s.agentId === a1).online, true) + assert.equal(filtered.find(s => s.agentId === a2).online, false) + + // After restart — empty live set + const empty = new Set() + const restarted = ledger.getAgentSummaries(empty) + assert.equal(restarted.find(s => s.agentId === a1).online, false) + assert.equal(restarted.find(s => s.agentId === a2).online, false) + }) }) From 3cf9ba66a67a5474e47a274a7e0b3d39579fefad Mon Sep 17 00:00:00 2001 From: Yilin Jing Date: Thu, 19 Mar 2026 13:04:53 +0800 Subject: [PATCH 3/3] fix: use major.minor protocol version in HTTP signatures instead of full semver DAP plugin (src/identity.ts, src/peer-server.ts) was using the raw package.json version (e.g. '0.4.3') as PROTOCOL_VERSION in HTTP signatures and domain separators. This causes signature verification failures between nodes on different patch versions (0.4.3 vs 0.4.4). Now extracts major.minor ('0.4') matching the SDK's approach, so signatures remain compatible across patch releases. Co-authored-by: factory-droid[bot] <138933559+factory-droid[bot]@users.noreply.github.com> --- src/identity.ts | 6 +++++- src/peer-server.ts | 3 ++- test/key-rotation.test.mjs | 3 ++- test/request-signing.test.mjs | 3 ++- test/response-signing.test.mjs | 3 ++- 5 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/identity.ts b/src/identity.ts index dd55d0e..39ae91d 100644 --- a/src/identity.ts +++ b/src/identity.ts @@ -12,8 +12,12 @@ import * as path from "path" import * as os from "os" import { Identity, AwRequestHeaders, AwResponseHeaders } from "./types" +// Protocol version for HTTP signatures and domain separators. +// Uses major.minor from package.json — only changes on breaking protocol updates. +// This MUST match the SDK's PROTOCOL_VERSION to allow cross-node signature verification. // eslint-disable-next-line @typescript-eslint/no-var-requires -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion: string = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") // ── did:key mapping ───────────────────────────────────────────────────────── diff --git a/src/peer-server.ts b/src/peer-server.ts index 08f6561..093a556 100644 --- a/src/peer-server.ts +++ b/src/peer-server.ts @@ -12,7 +12,8 @@ import Fastify, { FastifyInstance } from "fastify" import { P2PMessage, Identity, Endpoint } from "./types" import { verifySignature, agentIdFromPublicKey, verifyHttpRequestHeaders, signHttpResponse as signHttpResponseFn } from "./identity" // eslint-disable-next-line @typescript-eslint/no-var-requires -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion: string = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") import { tofuVerifyAndCache, tofuReplaceKey, getPeersForExchange, upsertDiscoveredPeer, removePeer, getPeer } from "./peer-db" const MAX_MESSAGE_AGE_MS = 5 * 60 * 1000 // 5 minutes diff --git a/test/key-rotation.test.mjs b/test/key-rotation.test.mjs index 2a65ad7..6639c29 100644 --- a/test/key-rotation.test.mjs +++ b/test/key-rotation.test.mjs @@ -8,7 +8,8 @@ const nacl = (await import("tweetnacl")).default import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const { startPeerServer, stopPeerServer } = await import("../dist/peer-server.js") const { initDb } = await import("../dist/peer-db.js") diff --git a/test/request-signing.test.mjs b/test/request-signing.test.mjs index f4278a8..9196a92 100644 --- a/test/request-signing.test.mjs +++ b/test/request-signing.test.mjs @@ -17,7 +17,8 @@ import crypto from "node:crypto" import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default diff --git a/test/response-signing.test.mjs b/test/response-signing.test.mjs index 56d819e..bc36f07 100644 --- a/test/response-signing.test.mjs +++ b/test/response-signing.test.mjs @@ -14,7 +14,8 @@ import crypto from "node:crypto" import { createRequire } from "node:module" const require = createRequire(import.meta.url) -const { version: PROTOCOL_VERSION } = require("../package.json") +const pkgVersion = require("../package.json").version +const PROTOCOL_VERSION = pkgVersion.split(".").slice(0, 2).join(".") const nacl = (await import("tweetnacl")).default