Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 5 additions & 2 deletions .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
[
"@s0nderlabs/anima",
"@s0nderlabs/anima-core",
"@s0nderlabs/anima-harness",
"@s0nderlabs/anima-gateway",
"@s0nderlabs/anima-plugin-comms",
"@s0nderlabs/anima-plugin-onchain",
"@s0nderlabs/anima-plugin-system"
"@s0nderlabs/anima-plugin-system",
"@s0nderlabs/anima-plugin-telegram",
"@s0nderlabs/anima-plugin-webapp",
"@s0nderlabs/anima-relay"
]
],
"linked": [],
Expand Down
70 changes: 63 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,24 +9,80 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# Four legible, parallel jobs. Each is individually requireable as a branch-
# protection check (gate PRs before merge). Together they close the gaps the
# old single `check` job left open: forge ran ONLY at tag-publish time, apps/web
# was never built/typechecked/tested anywhere, and the cross-surface integration
# seam (relay+tunnel+gateway, EIP-191 parity, SSE format, dispatch/approval
# floors) had no automated coverage. None of these require live 0G or funds.
jobs:
check:
# Lint + typecheck + the full unit suite (packages/*/src). The day-to-day gate.
unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5

- uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install deps
run: bun install --frozen-lockfile

- name: Lint
run: bun run lint

- name: Typecheck
run: bun run typecheck

- name: Test
- name: Unit tests
run: bun run test

# Solidity contract tests. Moved from tag-time (release.yml) to PR-time so a
# contract regression blocks the PR instead of aborting a release mid-publish.
# contracts/lib is gitignored, so the pinned deps are fetched fresh, matching
# what release.yml does.
forge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: foundry-rs/foundry-toolchain@v1
- name: Install Forge libs
run: |
mkdir -p contracts/lib
git clone --depth 1 --branch v1.16.0 \
https://github.com/foundry-rs/forge-std contracts/lib/forge-std
git clone --depth 1 --branch v5.6.1 \
https://github.com/OpenZeppelin/openzeppelin-contracts contracts/lib/openzeppelin-contracts
- name: Forge tests
run: forge test

# The web-app gateway surface (apps/web). Previously ungated everywhere: not
# typechecked (root tsc -b skips it), not built, not tested. A `next build`
# failure, a route-export violation, or a broken auth route now blocks the PR.
web:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install deps
run: bun install --frozen-lockfile
- name: Web typecheck
run: bun run web:typecheck
- name: Web unit tests (SIWE auth, ownerOf gate, slug binding, SSE parser)
run: bun test ./apps/web
- name: Web build (next build — catches route-export + build-time type errors)
run: bun run web:build

# Committed cross-package integration + contract tests (test/integration).
# Wires the REAL relay + tunnel + gateway + EIP-191 verification with a stub
# brain (no 0G); plus EIP-191 web<->gateway digest parity, the SSE wire-format
# golden, and the dispatch/permission/approval/hard-floor harness drive.
integration:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install deps
run: bun install --frozen-lockfile
- name: Integration tests
run: bun run test:integration
67 changes: 64 additions & 3 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,19 @@ jobs:
- name: Forge tests
run: forge test

# Defense-in-depth before the irreversible publish: the web surface and the
# cross-package integration seam are gated at PR-time, but re-run them here
# so a tag can never publish an artifact that fails them. Same commands as
# ci.yml; deterministic, no live 0G.
- name: Web typecheck + tests + build
run: |
bun run web:typecheck
bun test ./apps/web
bun run web:build

- name: Integration tests (relay+tunnel+gateway, EIP-191 parity, SSE, harness)
run: bun run test:integration

# bun publish does NOT honor `~/.npmrc` reliably on CI runners (verified
# silently broken on v0.16.3 manual recovery: workflow reported success
# but every publish step output `error: missing authentication`, masked
Expand Down Expand Up @@ -152,6 +165,20 @@ jobs:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bun publish --access=public

# plugin-webapp must publish BEFORE gateway (gateway deps it via workspace:*).
- name: Publish @s0nderlabs/anima-plugin-webapp
working-directory: packages/plugin-webapp
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bun publish --access=public

# relay is standalone infra (no internal deps); order is flexible.
- name: Publish @s0nderlabs/anima-relay
working-directory: packages/relay
env:
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
run: bun publish --access=public

- name: Publish @s0nderlabs/anima-gateway
working-directory: packages/gateway
env:
Expand Down Expand Up @@ -264,8 +291,11 @@ jobs:
echo " ✓ $bin → $(command -v "$bin")"
done
# Verify version of every installed @s0nderlabs/anima-* package.
# 6 packages total: cli + gateway + core + 3 plugins (telegram lives under deps).
for pkg in anima anima-gateway anima-core anima-plugin-comms anima-plugin-onchain anima-plugin-system anima-plugin-telegram; do
# cli + gateway + core + 4 plugins (telegram + webapp live under gateway deps).
# anima-relay is standalone infra (not a cli/gateway dep) so it is NOT
# installed transitively here — it is published + verified by the
# auto-discovered PACKAGES list above, not this global-install loop.
for pkg in anima anima-gateway anima-core anima-plugin-comms anima-plugin-onchain anima-plugin-system anima-plugin-telegram anima-plugin-webapp; do
PKG_JSON="$HOME/.bun/install/global/node_modules/@s0nderlabs/${pkg}/package.json"
if [ ! -f "$PKG_JSON" ]; then
echo "::error::@s0nderlabs/${pkg} not installed (missing $PKG_JSON)"
Expand All @@ -278,7 +308,38 @@ jobs:
fi
echo " ✓ @s0nderlabs/${pkg}@${INSTALLED_VERSION}"
done
echo "Smoke test passed: 7 packages installed + bins resolve at ${PKG_VERSION}"
# BOOT PROBE: actually EXECUTE the installed binaries, not just check
# presence. Every package ships RAW TypeScript (main -> ./src/index.ts),
# so a broken transitive import, a missing runtime dep, or a top-level
# throw passes every presence/version check above yet bricks every
# fresh install + npm-bootstrap sandbox + `bun add -g` upgrade. Running
# the bin loads the real import graph and surfaces that class here.
echo "Boot-probing installed binaries..."
if ! anima help 2>&1 | grep -q 'Commands:'; then
echo "::error::anima CLI failed to boot (\`anima help\` did not print 'Commands:')"
anima help 2>&1 | head -30
exit 1
fi
echo " ✓ anima help booted (CLI + transitive gateway/core/plugins import graph)"
# Relay is the load-bearing binary for the web gateway and is otherwise
# never executed at release time. Install it standalone, start it on an
# OS-assigned port, confirm it binds, then stop it.
for attempt in 1 2 3; do
rm -rf "$HOME/.bun/install/cache/@s0nderlabs"
if bun add -g "@s0nderlabs/anima-relay@${PKG_VERSION}"; then break; fi
[ "$attempt" -lt 3 ] && { echo "::warning::relay install attempt $attempt failed; retrying"; sleep 15; } || { echo "::error::anima-relay install failed at ${PKG_VERSION}"; exit 1; }
done
RELAY_PORT=0 anima-relay > /tmp/anima-relay-boot.log 2>&1 &
RELAY_PID=$!
for _ in $(seq 1 20); do grep -q 'listening' /tmp/anima-relay-boot.log && break; sleep 0.5; done
kill "$RELAY_PID" 2>/dev/null || true
if ! grep -q 'listening' /tmp/anima-relay-boot.log; then
echo "::error::anima-relay failed to boot + bind a port"
cat /tmp/anima-relay-boot.log
exit 1
fi
echo " ✓ anima-relay booted + bound a port"
echo "Smoke test passed: packages installed, bins resolve AND boot at ${PKG_VERSION}"

- name: Create GitHub release
env:
Expand Down
15 changes: 15 additions & 0 deletions apps/web/.env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,18 @@ SESSION_SECRET="replace-with-strong-random-32-plus-char-secret"
# Add this origin + prod origin to the project allowlist at cloud.reown.com
# or Reown will pop an "origin not on allowlist" modal.
NEXT_PUBLIC_WC_PROJECT_ID="974ed7663d88e07086104fa9a73b2d87"

# --- Web-app gateway (reach your agent from any browser) ---
# Public HTTP base of the dumb reverse-tunnel relay the agent daemon dials out
# to. The browser reaches the agent at <ANIMA_WEBAPP_RELAY_URL>/a/<slug>/...
# Run the relay with: bun packages/relay/bin/relay.ts (or deploy it anywhere).
ANIMA_WEBAPP_RELAY_URL="http://localhost:8787"
# Per-agent SECRET routing slug map (tokenId -> slug). Only the verified owner is
# handed the slug (server-side ownerOf gate in /api/agent/[tokenId]/connection).
# Example: ANIMA_WEBAPP_SLUGS='{"16":"slug-abc123","17":"slug-xyz789"}'
ANIMA_WEBAPP_SLUGS=""
# Fallback slug for single-agent deployments. Honored ONLY for the token id in
# ANIMA_WEBAPP_DEFAULT_TOKEN_ID below — otherwise a verified owner of any
# unrelated iNFT would be handed this slug and reach the wrong agent.
ANIMA_WEBAPP_DEFAULT_SLUG=""
ANIMA_WEBAPP_DEFAULT_TOKEN_ID=""
88 changes: 88 additions & 0 deletions apps/web/app/api/agent/[tokenId]/connection/resolve-slug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
// Tests for resolveSlug — the slug-binding logic of the connection route. The
// documented cross-agent leak: a verified owner of token A must NOT be handed
// token B's default slug. The single-agent fallback honors ANIMA_WEBAPP_DEFAULT_SLUG
// ONLY when ANIMA_WEBAPP_DEFAULT_TOKEN_ID equals the requested token. This test
// asserts that binding without any chain or session (resolveSlug is pure over
// process.env).

import { test, expect, mock, beforeEach } from 'bun:test'

// The route imports @/lib/siwe/authorize -> ./session -> 'server-only'.
mock.module('server-only', () => ({}))

const { resolveSlug } = await import('./slug')

// Snapshot + restore the three env vars resolveSlug reads.
const ENV_KEYS = [
'ANIMA_WEBAPP_SLUGS',
'ANIMA_WEBAPP_DEFAULT_SLUG',
'ANIMA_WEBAPP_DEFAULT_TOKEN_ID',
] as const

beforeEach(() => {
for (const k of ENV_KEYS) delete process.env[k]
})

test('per-agent slug map returns the slug bound to that exact token', () => {
process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '16': 'slug-sixteen', '17': 'slug-seventeen' })
expect(resolveSlug('16')).toBe('slug-sixteen')
expect(resolveSlug('17')).toBe('slug-seventeen')
})

test('per-agent map does NOT leak another token\'s slug for an unmapped token', () => {
process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '16': 'slug-sixteen' })
// Token 99 is not in the map and there is no default -> no slug.
expect(resolveSlug('99')).toBeNull()
})

test('CROSS-AGENT LEAK GUARD: default slug is honored ONLY for its own token', () => {
// The owner of token 17 is authenticated (the ownerOf gate already passed for
// 17), but the single-agent default belongs to token 16. resolveSlug must
// refuse to hand token 16's slug to a request for token 17.
process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'secret-slug-for-16'
process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16'
expect(resolveSlug('16')).toBe('secret-slug-for-16') // own token: allowed
expect(resolveSlug('17')).toBeNull() // different token: blocked (no leak)
expect(resolveSlug('1')).toBeNull()
expect(resolveSlug('160')).toBeNull() // not a prefix/substring match either
})

test('per-agent map takes precedence over the single-agent default', () => {
process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '17': 'mapped-17' })
process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'default-16'
process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16'
expect(resolveSlug('17')).toBe('mapped-17')
// 16 is not in the map, falls through to default which matches 16.
expect(resolveSlug('16')).toBe('default-16')
})

test('malformed ANIMA_WEBAPP_SLUGS JSON falls through to default (no crash)', () => {
process.env.ANIMA_WEBAPP_SLUGS = '{ not valid json'
process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'default-16'
process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16'
expect(() => resolveSlug('16')).not.toThrow()
expect(resolveSlug('16')).toBe('default-16')
expect(resolveSlug('17')).toBeNull()
})

test('default slug requires BOTH default vars set', () => {
process.env.ANIMA_WEBAPP_DEFAULT_SLUG = 'orphan-slug'
// No DEFAULT_TOKEN_ID -> binding cannot be established -> null.
expect(resolveSlug('16')).toBeNull()

delete process.env.ANIMA_WEBAPP_DEFAULT_SLUG
process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID = '16'
// No DEFAULT_SLUG -> null.
expect(resolveSlug('16')).toBeNull()
})

test('no env configured at all -> null (agent-not-web-enabled)', () => {
expect(resolveSlug('16')).toBeNull()
})

test('empty-string slug in map is falsy -> treated as not present', () => {
// map['16'] === '' is falsy under the `if (map[tokenId])` guard, so it falls
// through. With no default, that is null. Documents current behavior.
process.env.ANIMA_WEBAPP_SLUGS = JSON.stringify({ '16': '' })
expect(resolveSlug('16')).toBeNull()
})
43 changes: 43 additions & 0 deletions apps/web/app/api/agent/[tokenId]/connection/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// GET /api/agent/[tokenId]/connection
//
// The server-side ownerOf gate + the web-gateway connection handshake. Only the
// verified owner of tokenId learns how to reach the agent: the relay's public
// base URL + the agent's SECRET routing slug. The browser then talks to the
// relay directly (reach-from-anywhere); writes are additionally protected by
// the operator's EIP-191 signature at the daemon, and SSE reads are gated by
// the secrecy of this slug (never the public tokenId).

import { authorizeAgent } from '@/lib/siwe/authorize'
import type { NextRequest } from 'next/server'
import { resolveSlug } from './slug'

export const runtime = 'nodejs'

export async function GET(_req: NextRequest, ctx: { params: Promise<{ tokenId: string }> }) {
const { tokenId: raw } = await ctx.params
let tokenId: bigint
try {
tokenId = BigInt(raw)
} catch {
return Response.json({ error: 'bad-token-id' }, { status: 400 })
}

// SERVER-SIDE OWNERSHIP GATE — runs before any connection info is revealed.
const auth = await authorizeAgent(tokenId)
if (!auth) return Response.json({ error: 'unauthorized' }, { status: 401 })

const relayUrl = process.env.ANIMA_WEBAPP_RELAY_URL
if (!relayUrl) {
return Response.json({ error: 'web-gateway-not-configured' }, { status: 503 })
}
const slug = resolveSlug(raw)
if (!slug) {
return Response.json({ error: 'agent-not-web-enabled' }, { status: 503 })
}

return Response.json({
relayUrl: relayUrl.replace(/\/$/, ''),
agentId: slug,
operator: auth.operator,
})
}
24 changes: 24 additions & 0 deletions apps/web/app/api/agent/[tokenId]/connection/slug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Slug resolution for the web-gateway connection handshake. Lives OUTSIDE
// route.ts because Next.js route files may only export route handlers
// (GET/POST/…) + reserved config — exporting a helper from a route module fails
// `next build` ("not a valid Route export field"). Kept testable in isolation.

export function resolveSlug(tokenId: string): string | null {
// Per-agent slug map, e.g. ANIMA_WEBAPP_SLUGS={"16":"slug-abc","17":"slug-xyz"}
const raw = process.env.ANIMA_WEBAPP_SLUGS
if (raw) {
try {
const map = JSON.parse(raw) as Record<string, string>
if (map[tokenId]) return map[tokenId]
} catch {
/* fall through to default */
}
}
// Single-agent fallback: the default slug is honored ONLY for its own token.
// Without this binding, a verified owner of ANY unrelated iNFT would pass the
// ownerOf gate for their token and be handed a DIFFERENT agent's slug.
const defaultSlug = process.env.ANIMA_WEBAPP_DEFAULT_SLUG
const defaultToken = process.env.ANIMA_WEBAPP_DEFAULT_TOKEN_ID
if (defaultSlug && defaultToken && defaultToken === tokenId) return defaultSlug
return null
}
2 changes: 1 addition & 1 deletion apps/web/app/console/[tokenId]/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export default function ActivityTab() {
if (!ctx.agentEOA) {
return (
<div className="grid gap-3 pt-6">
<span className="kicker">ACTIVITY · WAITING ON SUBNAME</span>
<span className="kicker">Activity · Waiting on Subname</span>
<p className="max-w-[44ch] text-[15.5px] leading-[1.65] text-[var(--color-ink-2)]">
We could not resolve this agent’s wallet address from the SANN registry. Decrypt needs
that address. Register a subname via the CLI and reload.
Expand Down
13 changes: 13 additions & 0 deletions apps/web/app/console/[tokenId]/chat/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
'use client'

import { useAgentContext } from '@/components/console/agent-context'
import { ChatPanel } from '@/components/console/chat/ChatPanel'

// Live chat with the agent over the web gateway. Unlike the activity/memory
// tabs, this does NOT require a keystore unlock — chat talks to the live daemon
// (which holds the agent key) via wallet-signed messages, decrypting nothing
// locally. Auth is the SIWE session + the per-message EIP-191 signature.
export default function ChatTab() {
const ctx = useAgentContext()
return <ChatPanel tokenId={ctx.tokenId} />
}
Loading