feat: "let BotFights answer for me" — server-side AI bot (poll mode), + Latest Bouts short-viewport fix
CI / check (push) Failing after 6m8s
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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) => {
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user