Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
ebdb91a
feat(ledger): replicate comment bodies on gossip ingest via Options.C…
SaifRehman Jul 9, 2026
b8f2e28
feat(relay,witness): archive and serve comment bodies
SaifRehman Jul 9, 2026
5d3d043
feat(boss): fetch comment bodies from author or relay on local miss
SaifRehman Jul 9, 2026
d22fcd8
fix(comments): review hardening — ctx-bounded fetch, strict CID valid…
SaifRehman Jul 10, 2026
93c437a
fix(boss): enforce trust gate on /api/execute(+async) + validate requ…
SaifRehman Jul 10, 2026
cbee341
feat(sdk): add peers.comment() for self-signed feedback submission
SaifRehman Jul 10, 2026
4cd8322
fix(desktop): persist chat sessions + live relay-status indicator
SaifRehman Jul 11, 2026
39ab7a4
fix(desktop): drop per-rating verified/unverified badge
SaifRehman Jul 11, 2026
576a70d
feat(desktop): show copyable rater peer ID on each rating/comment row
SaifRehman Jul 11, 2026
460dfae
fix(desktop): remove Verified raters summary from peer card
SaifRehman Jul 11, 2026
b5082fd
fix(network): keep the relay connection alive (protect + reconnect loop)
SaifRehman Jul 11, 2026
45dfada
style(desktop): remove green glow/pulse from Status page
SaifRehman Jul 11, 2026
134d9e4
style(desktop): trim Status banner to just agents-online
SaifRehman Jul 12, 2026
753e115
fix(desktop): set app name to AgentFM (was 'Electron' in dev)
SaifRehman Jul 12, 2026
c9a1b00
fix(desktop): brand dev Electron bundle as AgentFM (Dock name)
SaifRehman Jul 12, 2026
341d599
built binary for desktop app
SaifRehman Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions RELEASE_NOTES_v1.3.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# AgentFM v1.3.0 — Verifiable Agent Mesh

AgentFM is a peer-to-peer compute mesh for running containerized AI agents on idle hardware. **v1.3.0 makes the mesh trustworthy:** it adds a tamper-evident reputation system so dishonest agents get caught and ejected — with no blockchain, tokens, or staking anywhere in it.

This is the biggest release so far (276 commits since 1.2.0). Here's what's in it.

## Trust you can actually verify

Every rating and comment an agent earns now lives in a **signed, append-only ledger** — one per peer, built on a Merkle log. Nothing can be edited or deleted after the fact, and anyone can independently check a peer's history with cryptographic inclusion proofs. No central server, no chain.

- **Signed feedback.** Ratings and comments are Ed25519-signed and gossiped across the mesh on a dedicated feedback topic. Replays, forged signatures, and out-of-range scores are rejected.
- **Witnesses catch liars.** Peers can act as witnesses that co-sign each other's ledger heads. If an agent shows a different history to different peers ("equivocation"), the witnesses detect the fork and the offender is **permanently pinned to the lowest score, -1.0**.
- **Reputation that means something.** Scoring uses an EigenTrust-style model: your vote weighs as much as your own reputation, recent feedback counts more than old, and agents that drop below the threshold are auto-ejected (with hysteresis so they don't flap in and out).

## Know what you're running

- Agents now advertise their **container image digest and capability** in telemetry.
- At dispatch, the Boss checks that image against a curated **trusted-agents registry** in one of three modes — `off`, `warn`, or `strict`. Strict refuses anything unrecognized; a mismatch costs the agent reputation.
- Known equivocators are always refused, regardless of mode.

## A new desktop app

A full **desktop app (the "Boss")**, built with Electron and React, ships alongside the CLI. Discover agents on a live mesh radar, dispatch tasks and watch the output stream in, browse the artifacts they produce, leave signed ratings, and inspect any peer's reputation ledger — all without touching the terminal. The interactive TUI is still there if you prefer it.

## API, SDK, and web viewer

- New reputation endpoints on the Boss HTTP gateway: read a peer's reputation, its full log, an inclusion proof for any entry, and submit signed comments.
- The **Python SDK** gains a `client.reputation` namespace (`get` / `log` / `proof` / `comment`).
- A built-in **web viewer** shows any peer's reputation history at `/ui/peer/{id}`, auto-refreshing.

## One binary, every role

The standalone relay binary is **gone** — it's folded into the main binary. Every role now runs from the same `agentfm` executable:

```sh
agentfm # Boss: dispatch tasks + HTTP gateway
agentfm -mode worker # host agents in Podman sandboxes
agentfm -mode relay # public lighthouse / circuit relay
agentfm --witness # co-sign the reputation ledger
```

## Security hardening

Fixes from an internal audit, landed before release:

- Rogue witnesses can no longer forge equivocation alerts against innocent peers.
- Witness extension checks now require a valid consistency proof, closing a fork-at-mismatched-size hole.
- Post-signature validation rejects NaN / infinite / out-of-range scores.
- All protocol framing has hard size caps (witness frames, comment bodies, fetch batches, image cache).

## Install

```sh
curl -fsSL https://api.agentfm.net/install.sh | bash
```

Or grab a binary for your platform from the release assets and `chmod +x` it. They're static, with no dependencies:

| OS | Architectures |
|----|----------------|
| macOS | `darwin_arm64` (Apple Silicon), `darwin_amd64` (Intel) |
| Linux | `amd64`, `arm64`, `arm` (v7), `386`, `riscv64` |
| Windows | `amd64`, `arm64` |
| FreeBSD | `amd64` |

Verify your download against `checksums.txt` (SHA-256).

## Coming later

A few pieces are intentionally deferred: salt-challenge image probes and the real Sigstore Rekor client (v1.3.1); golden-prompt probes, an LLM-judge grader, delegated comments, and an optional TEE attestation tier (v1.4).

## On purpose: no blockchain

AgentFM has no blockchain, no token, no staking, and no on-chain governance, and it never will. Trust here comes from signed logs and witnesses — not consensus or coins.

---

Full technical changelog: [`CHANGELOG.md`](./CHANGELOG.md)
15 changes: 14 additions & 1 deletion agentfm-desktop/electron/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ const ALLOWED_SETTINGS_KEYS = new Set([
'activeProjectId',
])

// Chat sessions persist under a per-project key (chat:sessions:<projectId>),
// so they can't live in the static allowlist. Permit exactly that shape with
// a conservative project-id suffix (matches the ids minted in projectStore).
const CHAT_SESSIONS_KEY = /^chat:sessions:[A-Za-z0-9_-]{1,64}$/

// isAllowedSettingsKey gates every renderer settings:set. A key passes when it
// is a dot-free member of the static allowlist OR a well-formed per-project
// chat-sessions key. Exported for unit testing.
export function isAllowedSettingsKey(key: unknown): key is string {
if (typeof key !== 'string' || key.includes('.')) return false
return ALLOWED_SETTINGS_KEYS.has(key) || CHAT_SESSIONS_KEY.test(key)
}

const SWARM_KEY_HEX = /^[0-9a-fA-F]{64}$/

// validateProjects checks the security-relevant fields of each stored project
Expand Down Expand Up @@ -78,7 +91,7 @@ export function registerIPC(backend: BackendManager): void {
// Persistent settings
ipcMain.handle('settings:get', (_event, key: string) => settingsStore.get(key as never))
ipcMain.handle('settings:set', (_event, key: string, value: unknown) => {
if (typeof key !== 'string' || key.includes('.') || !ALLOWED_SETTINGS_KEYS.has(key)) {
if (!isAllowedSettingsKey(key)) {
throw new Error(`refused settings key: ${JSON.stringify(key)}`)
}
let safeValue = value
Expand Down
6 changes: 6 additions & 0 deletions agentfm-desktop/electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { BackendManager } from './backend-manager'
import { registerIPC, isSafeExternalUrl } from './ipc'
import { settingsStore } from './store'

// Set before app 'ready' so the macOS menu bar, app switcher, and dock use
// "AgentFM" instead of the default "Electron" in dev. The packaged app gets
// this from electron-builder.yml (productName), but dev runs the raw bundle.
app.setName('AgentFM')

let backend: BackendManager | null = null

function resolveAppIcon(): Electron.NativeImage | undefined {
Expand All @@ -30,6 +35,7 @@ function createWindow(): void {
minWidth: 900,
minHeight: 600,
show: false,
title: 'AgentFM',
icon: appIcon,
autoHideMenuBar: true,
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
Expand Down
1 change: 1 addition & 0 deletions agentfm-desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"license": "MIT",
"main": "out/main/index.js",
"scripts": {
"predev": "node scripts/brand-dev-bundle.mjs",
"dev": "electron-vite dev",
"build": "electron-vite build",
"start": "electron-vite preview",
Expand Down
28 changes: 28 additions & 0 deletions agentfm-desktop/scripts/brand-dev-bundle.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Renames the dev Electron.app bundle to "AgentFM" so the macOS Dock and menu
// show the product name during `npm run dev`. The Dock reads CFBundleName from
// the running .app bundle, which app.setName() cannot override in dev — only
// this can. No-op on non-macOS or when the bundle is absent (CI / fresh clone
// before electron is installed). Packaged builds get their name from
// electron-builder.yml (productName) and never hit this path.
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'

if (process.platform !== 'darwin') process.exit(0)

const plist = 'node_modules/electron/dist/Electron.app/Contents/Info.plist'
if (!existsSync(plist)) process.exit(0)

function setKey(key, value) {
try {
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plist])
} catch {
try {
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Add :${key} string ${value}`, plist])
} catch {
// best-effort dev convenience; never fail the build over it
}
}
}

setKey('CFBundleName', 'AgentFM')
setKey('CFBundleDisplayName', 'AgentFM')
34 changes: 21 additions & 13 deletions agentfm-desktop/src/components/peer/EntryRow.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { Copy } from 'lucide-react';
import { toast } from 'sonner';
import type { PeerEntry } from '../../types/api';
import { compactAge } from '../../lib/peer';
import { compactAge, shortenPeerID } from '../../lib/peer';
import { CommentBody } from './CommentBody';
import { StarRow } from '../primitives/StarRow';
import { starsFromScore } from '../../lib/stars';
Expand All @@ -11,8 +13,6 @@ interface Props {

export function EntryRow({ entry, peerId }: Props) {
const isComment = entry.kind === 'Comment';
const unverified = entry.rater_status === 'unverified';
const hasMeta = unverified || !!entry.context;

return (
<div>
Expand Down Expand Up @@ -44,16 +44,24 @@ export function EntryRow({ entry, peerId }: Props) {
) : null}
</div>
<div className="min-w-0">
{hasMeta && (
<div className="flex gap-1.5 items-baseline">
{unverified && (
<span className="text-2xs bg-white/[0.06] text-text-2 px-1.5 py-0.5 rounded-full">
unverified
</span>
)}
{entry.context && <span className="text-text-2 text-xs">{entry.context}</span>}
</div>
)}
<div className="flex gap-1.5 items-baseline">
{entry.rater_peer_id && (
<button
onClick={() =>
navigator.clipboard
.writeText(entry.rater_peer_id)
.then(() => toast.success('Rater peer ID copied'))
.catch(() => toast.error('Copy failed'))
}
title={`Copy rater peer ID ${entry.rater_peer_id}`}
className="group/rid inline-flex items-center gap-1 font-mono text-2xs text-text-2 hover:text-text-0 transition-colors shrink-0 cursor-pointer"
>
{shortenPeerID(entry.rater_peer_id, 3, 3)}
<Copy size={10} className="opacity-0 group-hover/rid:opacity-70 transition-opacity" />
</button>
)}
{entry.context && <span className="text-text-2 text-xs">{entry.context}</span>}
</div>
{isComment && entry.text_cid && <CommentBody peerId={peerId} cid={entry.text_cid} />}
</div>
<div className="text-2xs text-text-2 text-right font-mono tabular-nums pt-0.5">
Expand Down
7 changes: 0 additions & 7 deletions agentfm-desktop/src/components/peer/SummaryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,6 @@ export function SummaryCard({ data }: { data: PeerSummary }) {
<Field label="Total entries">
<span className="font-mono text-xs text-text-0 tabular-nums">{data.entries_count}</span>
</Field>
<Field label="Verified raters">
<span className="inline-flex items-center gap-2 font-mono text-xs text-text-1 tabular-nums">
<span>{data.rater_summary?.verified_raters_count ?? 0} verified</span>
<span className="w-px h-3 bg-border-1" aria-hidden="true" />
<span>{data.rater_summary?.unverified_raters_count ?? 0} unverified</span>
</span>
</Field>
</Card>
);
}
Expand Down
10 changes: 9 additions & 1 deletion agentfm-desktop/src/lib/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,15 @@ export const qk = {
}

export function useAbout() {
return useQuery({ queryKey: qk.about(), queryFn: api.about, staleTime: 5000 })
// Poll: relay connectedness, ledger tree size, and uptime are all live.
// Without an interval the toolbar would latch onto the boot-time snapshot
// (relay not yet dialed) and show "Connecting to relay…" forever.
return useQuery({
queryKey: qk.about(),
queryFn: api.about,
staleTime: 5000,
refetchInterval: 5000,
})
}

export function useWorkers(includeOffline = false) {
Expand Down
5 changes: 1 addition & 4 deletions agentfm-desktop/src/routes/Status.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ function StatCard({ label, icon: Icon, value, valueTone, sub }: StatCardProps) {
ok: COLORS.ok, accent: COLORS.accent, bad: COLORS.bad,
}[valueTone]
return (
<Card live>
<Card>
<div className="flex items-center justify-between mb-2">
<div className="text-2xs font-medium text-text-2">{label}</div>
<Avatar size="sm">
Expand Down Expand Up @@ -72,7 +72,6 @@ export default function Status() {
{/* Health banner */}
<motion.div {...staggerItem(0)}>
<Card
live={healthy}
className={`mt-4 mb-2 flex items-center gap-3 ${
healthy ? '' : 'border-bad/40'
}`}
Expand All @@ -88,8 +87,6 @@ export default function Status() {
</div>
<div className="flex items-center gap-3 text-xs text-text-2 mt-0.5 font-mono tabular-nums">
<span>{onlineWorkers} agent{onlineWorkers === 1 ? '' : 's'} online</span>
<span>{isPrivate ? 'private swarm' : 'public mesh'}</span>
<span>up {formatUptime(uptime)}</span>
</div>
</div>
</Card>
Expand Down
25 changes: 24 additions & 1 deletion agentfm-desktop/tests/unit/ipcGuards.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ vi.mock('electron', () => ({
}))
vi.mock('../../electron/store', () => ({ settingsStore: { get: vi.fn(), set: vi.fn(), delete: vi.fn() } }))

import { isSafeExternalUrl } from '../../electron/ipc'
import { isSafeExternalUrl, isAllowedSettingsKey } from '../../electron/ipc'

describe('isSafeExternalUrl', () => {
it('allows web and mail links', () => {
Expand All @@ -26,3 +26,26 @@ describe('isSafeExternalUrl', () => {
expect(isSafeExternalUrl('not a url')).toBe(false)
})
})

describe('isAllowedSettingsKey', () => {
it('allows the static settings keys', () => {
expect(isAllowedSettingsKey('theme')).toBe(true)
expect(isAllowedSettingsKey('projects')).toBe(true)
expect(isAllowedSettingsKey('activeProjectId')).toBe(true)
})

it('allows per-project chat-session keys', () => {
expect(isAllowedSettingsKey('chat:sessions:prj_default')).toBe(true)
expect(isAllowedSettingsKey('chat:sessions:prj_private_ag')).toBe(true)
expect(isAllowedSettingsKey('chat:sessions:prj_h4af90vy')).toBe(true)
})

it('refuses malformed or unlisted keys', () => {
expect(isAllowedSettingsKey('apiPort.x')).toBe(false)
expect(isAllowedSettingsKey('chat:sessions:')).toBe(false)
expect(isAllowedSettingsKey('chat:sessions:bad id!')).toBe(false)
expect(isAllowedSettingsKey('chat:sessions:a.b')).toBe(false)
expect(isAllowedSettingsKey('arbitrary')).toBe(false)
expect(isAllowedSettingsKey(42 as unknown)).toBe(false)
})
})
17 changes: 11 additions & 6 deletions agentfm-go/cmd/agentfm/bossbootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,15 @@ func bossOptionsFromFlags(
return opts, cleanup
}

cstore, err := comments.Open(defaultCommentsRoot())
if err != nil {
slog.Warn("boss bootstrap: comments store open failed; P4-3 disabled",
slog.Any(obs.FieldErr, err))
}

l, err := ledger.NewWithOptions(dbPath, priv, node.PubSub, ledger.Options{
Host: node.Host,
Host: node.Host,
Comments: cstore,
})
if err != nil {
slog.Warn("boss bootstrap: ledger open failed; v1.3 endpoints will 503",
Expand Down Expand Up @@ -203,11 +210,9 @@ func bossOptionsFromFlags(
}

// --- comments store + submission handler --------------------------
cstore, err := comments.Open(defaultCommentsRoot())
if err != nil {
slog.Warn("boss bootstrap: comments store open failed; P4-3 disabled",
slog.Any(obs.FieldErr, err))
} else {
// cstore was opened before the ledger so Options.Comments could be
// wired; nil here means the open failed and P4-3 stays disabled.
if cstore != nil {
cserver := comments.NewServer(node.Host, cstore)
cserver.Start()
cleanups = append(cleanups, cserver.Stop)
Expand Down
19 changes: 16 additions & 3 deletions agentfm-go/cmd/agentfm/relay.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"syscall"

"agentfm/internal/ledger"
"agentfm/internal/ledger/comments"
"agentfm/internal/metrics"
"agentfm/internal/network"
"agentfm/internal/obs"
Expand All @@ -21,8 +22,9 @@ import (
// infinite reservation limits, a Kademlia DHT in server mode, an actively
// drained telemetry subscription (so it keeps routing gossip), and a full
// archive ledger. The archive persists every signed Rating / Comment /
// EquivocationAlert and serves head-fetch / ledger-fetch, so a fresh boss
// can catch up against this relay even when every other boss is offline.
// EquivocationAlert, serves head-fetch / ledger-fetch, and replicates +
// serves comment bodies (comment-fetch), so a fresh boss can catch up
// against this relay even when every other boss is offline.
//
// This is the single relay path — the dedicated relay binary was folded in
// here so `agentfm -mode relay` is the only relay, dev or production.
Expand Down Expand Up @@ -73,11 +75,22 @@ func runRelayMode(ctx context.Context, netCfg network.Config, promListen string)
pterm.Fatal.Printfln("relay identity load failed: %v", err)
}

var cstore *comments.Store
if cs, err := comments.Open(defaultCommentsRoot()); err != nil {
slog.Warn("relay: comments store open failed; comment bodies will not be archived",
slog.Any(obs.FieldErr, err))
} else {
cstore = cs
cserver := comments.NewServer(node.Host, cstore)
cserver.Start()
defer cserver.Stop()
}

dbPath := defaultRelayLedgerPath()
if err := os.MkdirAll(filepath.Dir(dbPath), 0o700); err != nil {
slog.Warn("relay: could not create ledger dir; running connectivity-only",
slog.String("path", filepath.Dir(dbPath)), slog.Any(obs.FieldErr, err))
} else if arch, err := ledger.NewWithOptions(dbPath, priv, node.PubSub, ledger.Options{Host: node.Host}); err != nil {
} else if arch, err := ledger.NewWithOptions(dbPath, priv, node.PubSub, ledger.Options{Host: node.Host, Comments: cstore}); err != nil {
slog.Warn("relay: archive ledger failed to open; running connectivity-only",
slog.Any(obs.FieldErr, err))
} else {
Expand Down
Loading
Loading