66 lines
2.6 KiB
TypeScript
66 lines
2.6 KiB
TypeScript
// 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)
|
||
|
|
}
|