Files
botfights/server/src/engine/ai-bot-config.ts
T

66 lines
2.6 KiB
TypeScript
Raw Normal View History

// 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)
}