Compare commits
2 Commits
b1d76c569c
...
d503e1fc1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d503e1fc1c | ||
|
|
8578dbc817 |
@ -17,9 +17,14 @@ COPY tsconfig.json ./
|
||||
# IIFE format (not ESM) so it loads as a classic <script> and self-assigns window.AgentViewPay.
|
||||
RUN bun build src/web/pay.ts --target=browser --format=iife --minify --outfile dist-web/pay.js
|
||||
|
||||
# Writable data dir for the persistent spend ledger (mounted as a volume in compose.yml).
|
||||
# Owned by the unprivileged 'bun' user so the process can write budgets/audit across restarts.
|
||||
RUN mkdir -p /app/data && chown -R bun:bun /app/data
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=8402
|
||||
ENV SPEND_LEDGER_PATH=/app/data/spend-ledger.json
|
||||
|
||||
# Drop to the image's unprivileged 'bun' user.
|
||||
USER bun
|
||||
|
||||
@ -19,6 +19,10 @@ services:
|
||||
environment:
|
||||
HOST: 0.0.0.0
|
||||
PORT: 8402
|
||||
# Persist the spend ledger (budgets + audit) across restarts/redeploys. Without this a
|
||||
# redeploy would reset every agent's budget — silently defeating the leash.
|
||||
volumes:
|
||||
- agentview-data:/app/data
|
||||
networks: [coolify]
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
@ -31,6 +35,9 @@ services:
|
||||
- traefik.http.routers.agentview.tls.certresolver=letsencrypt
|
||||
- traefik.http.services.agentview.loadbalancer.server.port=8402
|
||||
|
||||
volumes:
|
||||
agentview-data:
|
||||
|
||||
networks:
|
||||
coolify:
|
||||
external: true
|
||||
|
||||
@ -6,6 +6,8 @@
|
||||
* so an operator can see exactly what an agent did and why.
|
||||
*/
|
||||
import type { SpendPolicy, SpendState } from './policy.ts';
|
||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname } from 'node:path';
|
||||
|
||||
export interface AuditEntry {
|
||||
at: number; // unix seconds
|
||||
@ -34,13 +36,51 @@ export interface SpendLedger {
|
||||
audit(agentId: string): AuditEntry[];
|
||||
}
|
||||
|
||||
export function createSpendLedger(): SpendLedger {
|
||||
// Policy <-> JSON (bigint fields serialize as strings so budgets survive a restart).
|
||||
type StoredPolicy = Omit<SpendPolicy, 'budgetTotalAtomic' | 'perPaymentMaxAtomic' | 'windowMaxAtomic'> & {
|
||||
budgetTotalAtomic: string;
|
||||
perPaymentMaxAtomic: string;
|
||||
windowMaxAtomic: string;
|
||||
};
|
||||
const toStored = (p: SpendPolicy): StoredPolicy => ({ ...p, budgetTotalAtomic: p.budgetTotalAtomic.toString(), perPaymentMaxAtomic: p.perPaymentMaxAtomic.toString(), windowMaxAtomic: p.windowMaxAtomic.toString() });
|
||||
const fromStored = (p: StoredPolicy): SpendPolicy => ({ ...p, budgetTotalAtomic: BigInt(p.budgetTotalAtomic), perPaymentMaxAtomic: BigInt(p.perPaymentMaxAtomic), windowMaxAtomic: BigInt(p.windowMaxAtomic) });
|
||||
|
||||
/**
|
||||
* @param persistPath Optional file to persist the ledger to. Without it the ledger is in-memory
|
||||
* only — a restart resets all spend state, which DEFEATS the budget. With it, budgets and audit
|
||||
* survive restarts/redeploys (mount the file's dir as a volume). Reference persistence (whole
|
||||
* state written on each mutation); a real deployment uses a transactional DB.
|
||||
*/
|
||||
export function createSpendLedger(persistPath?: string): SpendLedger {
|
||||
const agents = new Map<string, AgentRecord>();
|
||||
|
||||
if (persistPath && existsSync(persistPath)) {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(persistPath, 'utf8')) as { agents: Record<string, { policy: StoredPolicy; entries: AuditEntry[] }> };
|
||||
for (const [id, rec] of Object.entries(raw.agents ?? {})) {
|
||||
agents.set(id, { policy: fromStored(rec.policy), entries: rec.entries ?? [] });
|
||||
}
|
||||
} catch {
|
||||
/* corrupt/unreadable ledger file — start empty rather than crash */
|
||||
}
|
||||
}
|
||||
|
||||
const save = (): void => {
|
||||
if (!persistPath) return;
|
||||
try {
|
||||
mkdirSync(dirname(persistPath), { recursive: true });
|
||||
const out = { agents: Object.fromEntries([...agents].map(([id, r]) => [id, { policy: toStored(r.policy), entries: r.entries }])) };
|
||||
writeFileSync(persistPath, JSON.stringify(out));
|
||||
} catch {
|
||||
/* never let a persistence failure break enforcement */
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
setPolicy(policy) {
|
||||
const existing = agents.get(policy.agentId);
|
||||
agents.set(policy.agentId, { policy, entries: existing?.entries ?? [] });
|
||||
save();
|
||||
},
|
||||
getPolicy(agentId) {
|
||||
return agents.get(agentId)?.policy;
|
||||
@ -68,6 +108,7 @@ export function createSpendLedger(): SpendLedger {
|
||||
const rec = agents.get(agentId);
|
||||
if (!rec) return;
|
||||
rec.entries.push(entry);
|
||||
save();
|
||||
},
|
||||
audit(agentId) {
|
||||
return agents.get(agentId)?.entries ?? [];
|
||||
|
||||
@ -50,9 +50,10 @@ const x402Deps: X402Deps | null = config.x402
|
||||
? { config: config.x402, facilitator: createFacilitator(config.x402), replay, ...(receiptSigner ? { receiptSigner } : {}) }
|
||||
: null;
|
||||
|
||||
// Safety layer: per-agent spend policies + audit ledger. The gateway signs with a spending key
|
||||
// (the demo buyer key here); the policy is enforced before any payment.
|
||||
const spendLedger = createSpendLedger();
|
||||
// Safety layer: per-agent spend policies + audit ledger. Persisted when SPEND_LEDGER_PATH is set
|
||||
// (else in-memory only — a restart would reset budgets, which defeats them). The gateway signs
|
||||
// with a spending key; the policy is enforced before any payment.
|
||||
const spendLedger = createSpendLedger((process.env.SPEND_LEDGER_PATH ?? '').trim() || undefined);
|
||||
|
||||
// Cooldowns for the public scripted safety demo (each run does real testnet settlements, so it
|
||||
// must be un-drainable). The per-IP one is UX only and keys on X-Forwarded-For, which is
|
||||
|
||||
@ -47,6 +47,30 @@ test('blocks over the total budget', () => {
|
||||
expect(d.reason).toBe('over_total_budget');
|
||||
});
|
||||
|
||||
test('ledger persistence: budget + spend survive a restart (reload from the same file)', () => {
|
||||
const path = `/tmp/av-ledger-test-${process.pid}.json`;
|
||||
try {
|
||||
// "boot 1": set a policy, record an allowed spend, then drop the ledger (simulated restart).
|
||||
const l1 = createSpendLedger(path);
|
||||
l1.setPolicy(policy);
|
||||
const now = 2_000_000;
|
||||
l1.record('a', { at: now, recipient: RCV, amountAtomic: '10000', network: 'eip155:84532', asset: '', allowed: true, reason: 'ok', transaction: '0xtx' });
|
||||
// "boot 2": a fresh ledger loading the same file must reconstruct the policy AND the spend.
|
||||
const l2 = createSpendLedger(path);
|
||||
const p = l2.getPolicy('a');
|
||||
expect(p?.budgetTotalAtomic).toBe(20000n); // bigint round-tripped through JSON
|
||||
const s = l2.state('a', now + 10);
|
||||
expect(s.spentTotalAtomic).toBe(10000n); // spend survived the restart — budget is NOT reset
|
||||
expect(l2.audit('a').length).toBe(1);
|
||||
} finally {
|
||||
try {
|
||||
require('node:fs').unlinkSync(path);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('ledger: allowed payments consume budget; denied ones do not; window respected', () => {
|
||||
const l = createSpendLedger();
|
||||
l.setPolicy(policy);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user