feat: "let BotFights answer for me" — server-side AI bot (poll mode), + Latest Bouts short-viewport fix
CI / check (push) Failing after 6m8s

New feature, requested live during demo prep: an operator can paste their
own Anthropic or OpenAI API key and have the server itself answer fight
challenges for their poll-mode bot, instead of running an external script.

Storage (server/src/engine/ai-bot-config.ts): one 0600 JSON file per bot
under this app's own data volume — deliberately mirrors Archipelago's own
node-level pattern for the identical class of secret (system.settings.set
"claude_api_key" in core/archipelago/src/api/rpc/system/handlers.rs): never
returns the raw key on GET, only whether one is configured and which
provider. This is a human operator opting in for their own bot via the
app's own UI — a different trust boundary from the unified prompt's "never
ask an AI agent for its API key" rule (BOTFIGHTS.md), not a violation of it.

Execution (server/src/engine/orchestrator.ts): purely additive hook inside
the existing isPollingBot branch of getBotResponse(). waitForPollResponse()
(unchanged) registers the pending challenge synchronously before returning;
right after, answerWithAiIfConfigured() fires a fire-and-forget async LLM
call that races to call submitPollResponse() — the exact function an
external poller already calls — before that promise's own timeout. No AI
config = instant no-op. LLM error/timeout = the existing timeout path
handles it identically to a human forgetting to poll. No new failure mode,
no change to scoring/round timing for any other bot.

Adapter (server/src/engine/llm-adapter.ts): both providers, one call shape
each (Anthropic /v1/messages + x-api-key, OpenAI /v1/chat/completions +
Bearer), same competitive system prompt already documented in BOTFIGHTS.md
for operator-run bots.

API (server/src/routes/bots.ts): POST/GET/DELETE /api/bots/ai-config,
authenticated via the bot's own Authorization: Bot <id>:<secret> (same as
/api/fights/poll). Registered BEFORE GET /:name — same route-shadowing bug
class already found once in fights.ts's /poll route (09-05); a bare /:name
registered first would have swallowed /ai-config as a bot-name lookup.
Covered by a new test (ai-config.test.ts) that asserts the real contract
shape, not just a 200, specifically to catch that regression.

UI (frontend/src/pages/JoinBoutPage.vue): collapsible section in the
bot-setup step, poll mode only (webhook mode already assumes the operator
runs their own infra) — provider picker, password-type key input, links to
get a key from either provider, key cleared from page state immediately
after saving.

Explicitly deferred (not built here): "use this node's key" as an
alternative to pasting your own — the node already has one configured for
AIUI (found live, /opt/archipelago/claude-api-proxy.py), but wiring a
cross-container secret share from the archy orchestrator into this
container needs a manifest change and another catalog signing cycle, which
this session isn't improvising under demo time pressure.

Also: HomePage.vue "Latest Bouts" section hidden on short viewports
([@media(max-height:700px)]:hidden) — the hero layout is a vertically-
centered flex column with overflow-hidden and no scroll by design, so on a
short viewport (embedded node dashboard iframes, small kiosk screens) this
last/least-essential section was what silently clipped, reported live as
"it looks cut off on node screens often".

Fixed a regression-test violation this batch would have introduced
(BUG-F2: no silent .catch(() => {})) in the DocsPage.vue proxy-URL-resolve
fix from the previous commit — both catches now log a warning instead of
swallowing silently.

Verified: full server typecheck clean; orchestrator.test.ts (23) +
poll-responses.test.ts (10) unchanged and passing — the new hook doesn't
alter existing poll-mode behavior; new ai-config.test.ts (7) passing,
including the route-shadowing regression check; regression.test.ts (43,
including the newly-fixed BUG-F2) passing. lifecycle.test.ts/scoring.test.ts
perf-timing failures are pre-existing, documented, unrelated flakiness
under this shared machine's CPU load (see archy's 09-01 deferred-items.md
item 2) — not caused by this change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-07-31 08:06:02 -04:00
co-authored by Claude Fable 5
parent 2512265113
commit ca5b63468e
8 changed files with 549 additions and 6 deletions
+133
View File
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { Hono } from 'hono'
// Mock DB: authenticateBot looks up a bot by id via db.select(...).from(...).where(...).limit(...)
const mockBotRow = {
id: 'bot_test123',
name: 'testbot',
secretHash: 'aa'.repeat(32), // placeholder; overridden per-test via crypto mock below
webhookUrl: 'http://poll.local/',
}
vi.mock('../db/index.js', () => ({
db: {
select: vi.fn().mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([mockBotRow]),
}),
}),
}),
},
schema: {
bots: { id: 'id', name: 'name', publicKey: 'publicKey', eloRating: 'eloRating', wins: 'wins', losses: 'losses', winStreak: 'winStreak', tier: 'tier', avatarSeed: 'avatarSeed', archetype: 'archetype', botType: 'botType', hasWallet: 'hasWallet', zapsReceived: 'zapsReceived', isActive: 'isActive', webhookUrl: 'webhookUrl', secretHash: 'secretHash', bestStreak: 'bestStreak', satsWagered: 'satsWagered' },
walletConnections: { id: 'id', botId: 'botId' },
},
}))
vi.mock('../engine/scoring.js', () => ({
TIER_NAMES: ['Baby', 'Bronze', 'Silver', 'Gold', 'Platinum', 'Diamond', 'Legend'],
TIER_COLORS: ['#999', '#cd7f32', '#c0c0c0', '#ffd700', '#e5e4e2', '#b9f2ff', '#ff6b6b'],
}))
vi.mock('../engine/achievements.js', () => ({ computeAchievements: vi.fn().mockReturnValue([]) }))
vi.mock('../engine/orchestrator.js', () => ({ isAllowedWebhookUrl: vi.fn().mockReturnValue(true) }))
vi.mock('../engine/webhook-test.js', () => ({ testWebhook: vi.fn().mockResolvedValue({ success: true }) }))
vi.mock('../middleware/rate-limit.js', () => ({ rateLimit: () => async (_c: any, next: any) => next() }))
// Mock ai-bot-config storage so this test never touches the real filesystem —
// route-wiring correctness is what's under test here, not file I/O (that
// module is simple, direct fs calls with its own low surface area).
const store = new Map<string, { provider: string; apiKey: string }>()
vi.mock('../engine/ai-bot-config.js', () => ({
setAiBotConfig: vi.fn((botId: string, config: { provider: string; apiKey: string }) => { store.set(botId, config) }),
getAiBotConfig: vi.fn((botId: string) => store.get(botId) ?? null),
deleteAiBotConfig: vi.fn((botId: string) => { store.delete(botId) }),
hasAiBotConfig: vi.fn((botId: string) => store.has(botId)),
}))
// Real bot-auth verification is a SHA-256 hash comparison against secretHash —
// use a real matching secret so authenticateBot() actually succeeds.
import { createHash } from 'crypto'
const REAL_SECRET = 'test-bot-secret-1234567890'
mockBotRow.secretHash = createHash('sha256').update(REAL_SECRET).digest('hex')
const { botsRouter } = await import('./bots.js')
const app = new Hono()
app.route('/api/bots', botsRouter)
const AUTH = { Authorization: `Bot ${mockBotRow.id}:${REAL_SECRET}` }
beforeEach(() => { store.clear() })
describe('bots ai-config routes', () => {
it('POST /api/bots/ai-config sets config and never echoes the key back', async () => {
const res = await app.request('/api/bots/ai-config', {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
})
expect(res.status).toBe(200)
const body = await res.json() as Record<string, unknown>
expect(body).toEqual({ configured: true, provider: 'anthropic' })
expect(JSON.stringify(body)).not.toContain('sk-ant-fake-key-value')
})
it('POST rejects an unknown provider', async () => {
const res = await app.request('/api/bots/ai-config', {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'not-a-real-provider', apiKey: 'sk-fake-key-value' }),
})
expect(res.status).toBe(400)
})
it('POST rejects a too-short apiKey', async () => {
const res = await app.request('/api/bots/ai-config', {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'openai', apiKey: 'short' }),
})
expect(res.status).toBe(400)
})
it('GET /api/bots/ai-config returns configured status without the key — and is NOT shadowed by GET /:name', async () => {
await app.request('/api/bots/ai-config', {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'openai', apiKey: 'sk-openai-fake-key-value' }),
})
const res = await app.request('/api/bots/ai-config', { headers: AUTH })
expect(res.status).toBe(200)
const body = await res.json() as Record<string, unknown>
// A shadowed route (GET /:name matching "ai-config" as a bot name) would
// return a completely different shape from GET /:name's handler (bot
// profile fields like eloRating/wins/losses, or a 404 from the mocked
// single-row lookup returning the wrong shape) — assert the REAL
// ai-config contract explicitly.
expect(body).toEqual({ configured: true, provider: 'openai' })
})
it('GET /api/bots/ai-config with no config set returns configured: false', async () => {
const res = await app.request('/api/bots/ai-config', { headers: AUTH })
expect(res.status).toBe(200)
expect(await res.json()).toEqual({ configured: false, provider: null })
})
it('DELETE /api/bots/ai-config removes the config', async () => {
await app.request('/api/bots/ai-config', {
method: 'POST',
headers: { ...AUTH, 'Content-Type': 'application/json' },
body: JSON.stringify({ provider: 'anthropic', apiKey: 'sk-ant-fake-key-value' }),
})
const del = await app.request('/api/bots/ai-config', { method: 'DELETE', headers: AUTH })
expect(del.status).toBe(200)
const check = await app.request('/api/bots/ai-config', { headers: AUTH })
expect(await check.json()).toEqual({ configured: false, provider: null })
})
it('rejects requests with no bot auth', async () => {
const res = await app.request('/api/bots/ai-config', { method: 'POST', body: '{}' })
expect(res.status).toBe(401)
})
})
+56
View File
@@ -9,6 +9,8 @@ import { isAllowedWebhookUrl } from '../engine/orchestrator.js'
import { testWebhook } from '../engine/webhook-test.js'
import { rateLimit } from '../middleware/rate-limit.js'
import { botNameSchema, httpUrlSchema } from '../lib/validators.js'
import { authenticateBot } from '../middleware/bot-auth.js'
import { setAiBotConfig, getAiBotConfig, deleteAiBotConfig, type LlmProvider } from '../engine/ai-bot-config.js'
export const botsRouter = new Hono()
@@ -138,6 +140,59 @@ botsRouter.get('/', async (c) => {
})))
})
// --- "Let BotFights answer for me" — operator-supplied LLM key, poll-mode bots only ---
// Auth matches /api/fights/poll[/respond]: Authorization: Bot <bot_id>:<secret>
// or query params — this is the bot's own credential, not a nostr session,
// consistent with every other bot-scoped endpoint in this file.
//
// MUST be registered before GET /:name below — same-segment-count route
// collisions resolve in registration order in this framework (Hono), not by
// specificity; a bare /:name registered first would shadow /ai-config and
// treat "ai-config" as a bot name lookup instead. (This exact bug class was
// found and fixed once already in fights.ts's /poll route — see 09-05.)
const AI_PROVIDERS: LlmProvider[] = ['anthropic', 'openai']
botsRouter.post('/ai-config', rateLimit(60_000, 10), async (c) => {
const botOrRes = await authenticateBot(c)
if (botOrRes instanceof Response) return botOrRes
const bot = botOrRes
const body = await c.req.json().catch(() => ({})) as { provider?: string; apiKey?: string }
const provider = body.provider
const apiKey = body.apiKey?.trim()
if (!provider || !AI_PROVIDERS.includes(provider as LlmProvider)) {
return c.json({ error: `provider must be one of: ${AI_PROVIDERS.join(', ')}` }, 400)
}
if (!apiKey || apiKey.length < 8 || apiKey.length > 512) {
return c.json({ error: 'apiKey is required (8-512 chars).' }, 400)
}
setAiBotConfig(bot.botId, { provider: provider as LlmProvider, apiKey })
return c.json({ configured: true, provider })
})
// Never returns the key itself — only whether one is set and which provider,
// same contract as the node's own system.settings.get "claude_api_key_set".
botsRouter.get('/ai-config', async (c) => {
const botOrRes = await authenticateBot(c)
if (botOrRes instanceof Response) return botOrRes
const bot = botOrRes
const config = getAiBotConfig(bot.botId)
return c.json({ configured: !!config, provider: config?.provider ?? null })
})
botsRouter.delete('/ai-config', async (c) => {
const botOrRes = await authenticateBot(c)
if (botOrRes instanceof Response) return botOrRes
const bot = botOrRes
deleteAiBotConfig(bot.botId)
return c.json({ configured: false })
})
// Get single bot profile
botsRouter.get('/:name', async (c) => {
const name = c.req.param('name')
@@ -558,3 +613,4 @@ botsRouter.post('/:name/test-challenge', async (c) => {
})