Files
hermes-agent/tests-js/scripts/mock-provider-config.ts
hermes-seaeye[bot] aae6c2044e fmt(js): npm run fix on merge (#123390)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-09-26 03:39:27 +00:00

143 lines
5.4 KiB
TypeScript

import fs from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import yaml from 'js-yaml'
import { z } from 'zod'
const section = z.object({}).passthrough()
const configSchema = z.object({
model: section.optional(),
providers: section.optional(),
custom_providers: z.array(section).optional(),
auxiliary: z.object({ title_generation: section.optional() }).passthrough().optional(),
approvals: section.optional(),
display: section.optional(),
}).passthrough()
//: Entry name this writer owns in ``custom_providers``; reconfiguring replaces
//: it rather than stacking duplicates.
const MOCK_PROVIDER_NAME = 'Mock'
export function validateMockUrl(value: string): string {
const url = new URL(value)
if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1' || !url.port
|| url.username || url.password || url.search || url.hash || url.pathname !== '/') {
throw new Error('Mock URL must be a credential-free http://127.0.0.1:PORT origin')
}
return url.origin
}
/** Merge only test-owned settings. Do not replace the update feed or plugins. */
export function writeMockProviderConfig(
hermesHome: string,
mockUrl: string,
extraDisplayConfig?: string,
extraConfig?: string,
modelContextLength?: number,
): void {
const url = validateMockUrl(mockUrl)
fs.mkdirSync(hermesHome, { recursive: true })
const configPath = path.join(hermesHome, 'config.yaml')
const config = configSchema.parse(fs.existsSync(configPath) ? yaml.load(fs.readFileSync(configPath, 'utf8')) ?? {} : {})
const extra = configSchema.parse(extraConfig ? yaml.load(extraConfig) ?? {} : {})
const display = section.parse(extraDisplayConfig ? yaml.load(extraDisplayConfig) ?? {} : {})
const merged = {
...config,
// The endpoint must live in config.yaml: runtime_provider's bare-`custom`
// trust path reads the CONFIG base_url, and v2026.8.31 says so outright --
// "OPENAI_BASE_URL env var is no longer consulted -- config.yaml is the
// single source of truth for endpoint URLs". Without it `custom` resolves to
// no endpoint and the app boots on its onboarding overlay ("No usable
// credentials found for custom"), which is what every +desktop smoke leg
// hit. The `custom_providers` entry names the same endpoint for the
// named/`custom:<name>` form; the .env OPENAI_BASE_URL/OPENAI_API_KEY pair
// (writeEnvFile) stays for the trees that resolve the endpoint from the
// environment. `providers:` stays untouched (a provider named 'mock' only
// resolved on trees that had registered such a profile, so older refs died
// with "Unknown provider 'mock'").
model: { ...config.model, default: 'mock-model', provider: 'custom', base_url: `${url}/v1`, context_length: modelContextLength ?? 64000 },
providers: { ...config.providers },
custom_providers: [
...(config.custom_providers ?? []).filter(entry => entry?.name !== MOCK_PROVIDER_NAME),
{ name: MOCK_PROVIDER_NAME, base_url: `${url}/v1`, key_env: 'OPENAI_API_KEY' },
],
auxiliary: { ...config.auxiliary, title_generation: { ...config.auxiliary?.title_generation, enabled: false } },
approvals: { ...config.approvals, mode: 'off' },
...extra,
}
if (extraDisplayConfig) {
merged.display = { ...config.display, ...extra.display, ...display }
}
fs.writeFileSync(configPath, yaml.dump(merged), 'utf8')
}
/** Keep journey-owned entries, replacing only the inert test keys. */
export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key', mockUrl?: string): void {
if (!/^[\w-]+$/.test(apiKey)) {
throw new Error('Mock key must be an inert single-line test value')
}
const envPath = path.join(hermesHome, '.env')
const prior = fs.existsSync(envPath) ? fs.readFileSync(envPath, 'utf8') : ''
const reps = new Map<string, string>([['MOCK_API_KEY', `MOCK_API_KEY=${apiKey}`]])
if (mockUrl) {
reps.set('OPENAI_BASE_URL', `OPENAI_BASE_URL=${mockUrl}/v1`)
reps.set('OPENAI_API_KEY', `OPENAI_API_KEY=${apiKey}`)
}
// Rewrite each key IN PLACE and append only the ones that are missing. Filtering
// the keys out and re-appending them at the end moved a journey's own entries on
// every call: same keys, same values, different bytes -- which the user-state
// verifier reported as "0 deleted, 1 modified ... no key differs". Writing the
// same pair twice is byte-identical now.
const seen = new Set<string>()
const kept: string[] = []
for (const line of prior.split(/\r?\n/)) {
const key = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line)?.[1]
if (!key || !reps.has(key)) {
kept.push(line)
continue
}
if (!seen.has(key)) {
kept.push(reps.get(key)!)
seen.add(key)
}
}
// Drop the prior file's trailing blank lines BEFORE appending: trimEnd() runs
// after the appended keys, so it cannot reach a blank line that they now follow.
while (kept.length > 0 && kept[kept.length - 1].trim() === '') {
kept.pop()
}
for (const [key, line] of reps) {
if (!seen.has(key)) {
kept.push(line)
}
}
fs.writeFileSync(envPath, `${kept.join('\n').trimEnd()}\n`, { mode: 0o600 })
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
const [home, url] = process.argv.slice(2)
if (!home || !url || !path.isAbsolute(home)) {
throw new Error('usage: node mock-provider-config.ts ABSOLUTE_HERMES_HOME MOCK_URL')
}
writeMockProviderConfig(home, url)
writeEnvFile(home, 'e2e-mock-key', url)
}