From 239d73afc44f2c3f4cc5095e47eeaee3473a1d0f Mon Sep 17 00:00:00 2001 From: t Date: Tue, 14 Jul 2026 12:56:10 -0700 Subject: [PATCH] fix(hosts): validate suppressed resolver names --- scripts/host-config-export.ts | 3 ++- scripts/host-config.ts | 18 +++++++++++++++--- test/host-config.test.ts | 29 ++++++++++++++++++++++++++++- 3 files changed, 45 insertions(+), 5 deletions(-) diff --git a/scripts/host-config-export.ts b/scripts/host-config-export.ts index bca436f26..5ef703624 100644 --- a/scripts/host-config-export.ts +++ b/scripts/host-config-export.ts @@ -15,6 +15,7 @@ import { ALL_HOST_CONFIGS, getHostConfig, ALL_HOST_NAMES } from '../hosts/index'; import { validateAllConfigs } from './host-config'; +import { RESOLVERS } from './resolvers'; import { execSync } from 'child_process'; const CLI_REGEX = /^[a-z][a-z0-9_-]*$/; @@ -82,7 +83,7 @@ switch (command) { } case 'validate': { - const errors = validateAllConfigs(ALL_HOST_CONFIGS); + const errors = validateAllConfigs(ALL_HOST_CONFIGS, new Set(Object.keys(RESOLVERS))); if (errors.length > 0) { for (const error of errors) { console.error(`ERROR: ${error}`); diff --git a/scripts/host-config.ts b/scripts/host-config.ts index 4421c4a79..c7a3ae9f3 100644 --- a/scripts/host-config.ts +++ b/scripts/host-config.ts @@ -117,7 +117,7 @@ const NAME_REGEX = /^[a-z][a-z0-9-]*$/; const PATH_REGEX = /^[a-zA-Z0-9_.\/${}~-]+$/; const CLI_REGEX = /^[a-z][a-z0-9_-]*$/; -export function validateHostConfig(config: HostConfig): string[] { +export function validateHostConfig(config: HostConfig, validResolverNames?: ReadonlySet): string[] { const errors: string[] = []; if (!NAME_REGEX.test(config.name)) { @@ -152,15 +152,27 @@ export function validateHostConfig(config: HostConfig): string[] { errors.push(`install.linkingStrategy must be 'real-dir-symlink' or 'symlink-generated'`); } + // Cross-check suppressedResolvers against the known resolver names (injected to avoid a + // circular import on the resolver registry). A typo would otherwise silently no-op: the + // generator short-circuits suppressed names before the "unknown placeholder" throw, so an + // unknown entry never surfaces at generation time either. + if (validResolverNames && config.suppressedResolvers) { + for (const name of config.suppressedResolvers) { + if (!validResolverNames.has(name)) { + errors.push(`suppressedResolvers entry '${name}' is not a known resolver`); + } + } + } + return errors; } -export function validateAllConfigs(configs: HostConfig[]): string[] { +export function validateAllConfigs(configs: HostConfig[], validResolverNames?: ReadonlySet): string[] { const errors: string[] = []; // Per-config validation for (const config of configs) { - const configErrors = validateHostConfig(config); + const configErrors = validateHostConfig(config, validResolverNames); errors.push(...configErrors.map(e => `[${config.name}] ${e}`)); } diff --git a/test/host-config.test.ts b/test/host-config.test.ts index 577057033..1c939d7be 100644 --- a/test/host-config.test.ts +++ b/test/host-config.test.ts @@ -24,8 +24,10 @@ import { openclaw, } from '../hosts/index'; import { HOST_PATHS } from '../scripts/resolvers/types'; +import { RESOLVERS } from '../scripts/resolvers'; const ROOT = path.resolve(import.meta.dir, '..'); +const RESOLVER_NAMES = new Set(Object.keys(RESOLVERS)); // ─── hosts/index.ts ───────────────────────────────────────── @@ -205,13 +207,32 @@ describe('validateHostConfig', () => { c.cliCommand = 'opencode;rm -rf /'; expect(validateHostConfig(c).some(e => e.includes('cliCommand'))).toBe(true); }); + + test('valid suppressedResolvers pass when resolver names provided', () => { + const c = makeValid(); + c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'REVIEW_ARMY']; + expect(validateHostConfig(c, RESOLVER_NAMES)).toEqual([]); + }); + + test('unknown suppressedResolvers entry is caught', () => { + const c = makeValid(); + c.suppressedResolvers = ['DESIGN_OUTSIDE_VOICES', 'NONEXISTENT_RESOLVER']; + const errors = validateHostConfig(c, RESOLVER_NAMES); + expect(errors.some(e => e.includes('NONEXISTENT_RESOLVER'))).toBe(true); + }); + + test('suppressedResolvers unchecked when resolver names omitted', () => { + const c = makeValid(); + c.suppressedResolvers = ['TYPO_RESOLVER']; + expect(validateHostConfig(c)).toEqual([]); + }); }); // ─── validateAllConfigs ───────────────────────────────────── describe('validateAllConfigs', () => { test('real configs all pass validation', () => { - const errors = validateAllConfigs(ALL_HOST_CONFIGS); + const errors = validateAllConfigs(ALL_HOST_CONFIGS, RESOLVER_NAMES); expect(errors).toEqual([]); }); @@ -233,6 +254,12 @@ describe('validateAllConfigs', () => { expect(errors.some(e => e.includes('Duplicate globalRoot'))).toBe(true); }); + test('unknown suppressedResolvers entry surfaces with host-name prefix', () => { + const bad = { ...codex, name: 'bad-host', hostSubdir: '.bad', globalRoot: '.bad/skills/gstack', suppressedResolvers: ['BOGUS_RESOLVER'] } as HostConfig; + const errors = validateAllConfigs([bad], RESOLVER_NAMES); + expect(errors.some(e => e.startsWith('[bad-host]') && e.includes('BOGUS_RESOLVER'))).toBe(true); + }); + test('per-config validation errors are prefixed with host name', () => { const bad = { ...codex, name: 'BAD', cliCommand: 'also bad' } as HostConfig; const errors = validateAllConfigs([bad]);