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,65 @@
|
||||
// Per-bot "let BotFights answer for me" configuration — an operator-supplied
|
||||
// LLM API key (Anthropic or OpenAI) stored locally so the server itself can
|
||||
// answer fight challenges for a poll-mode bot, instead of the operator
|
||||
// running their own external bot script.
|
||||
//
|
||||
// Storage pattern deliberately mirrors Archipelago's own node-level pattern
|
||||
// for the exact same class of secret (system.settings.set "claude_api_key"
|
||||
// in core/archipelago/src/api/rpc/system/handlers.rs): a single 0600 file
|
||||
// per secret, under this app's own data volume, GET never returns the raw
|
||||
// value — only whether one is configured and which provider.
|
||||
//
|
||||
// This is a human operator opting in via the app's own UI for their own
|
||||
// bot — never something an AI agent following the unified prompt is asked
|
||||
// for (see BOTFIGHTS.md "What playing never requires... your model-provider
|
||||
// API keys"). Different trust boundary entirely: a person configuring their
|
||||
// own node-local bot, not a third party asking an autonomous agent for
|
||||
// credentials mid-conversation.
|
||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, chmodSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const configDir = join(__dirname, '..', '..', 'data', 'ai-keys')
|
||||
|
||||
export type LlmProvider = 'anthropic' | 'openai'
|
||||
|
||||
export interface AiBotConfig {
|
||||
provider: LlmProvider
|
||||
apiKey: string
|
||||
}
|
||||
|
||||
function configPath(botId: string): string {
|
||||
// botId is always a nanoid from this app's own registration flow (never
|
||||
// user-supplied path input), but guard against traversal regardless.
|
||||
if (botId.includes('/') || botId.includes('..')) {
|
||||
throw new Error('Invalid bot ID')
|
||||
}
|
||||
return join(configDir, `${botId}.json`)
|
||||
}
|
||||
|
||||
export function setAiBotConfig(botId: string, config: AiBotConfig): void {
|
||||
if (!existsSync(configDir)) mkdirSync(configDir, { recursive: true })
|
||||
const path = configPath(botId)
|
||||
writeFileSync(path, JSON.stringify(config), { mode: 0o600 })
|
||||
chmodSync(path, 0o600) // belt-and-suspenders: writeFileSync's mode is subject to umask
|
||||
}
|
||||
|
||||
export function getAiBotConfig(botId: string): AiBotConfig | null {
|
||||
const path = configPath(botId)
|
||||
if (!existsSync(path)) return null
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, 'utf-8')) as AiBotConfig
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function hasAiBotConfig(botId: string): boolean {
|
||||
return existsSync(configPath(botId))
|
||||
}
|
||||
|
||||
export function deleteAiBotConfig(botId: string): void {
|
||||
const path = configPath(botId)
|
||||
if (existsSync(path)) unlinkSync(path)
|
||||
}
|
||||
Reference in New Issue
Block a user