Browse Source

fix(config): reject custom-only prompt fields on built-ins

Fail config validation when built-in agents or aliases set prompt/orchestratorPrompt in top-level agents or presets. This makes the schema match runtime behavior and prevents silent no-op configuration.
Alvin Unreal 3 months ago
parent
commit
2a3b29c063
3 changed files with 158 additions and 36 deletions
  1. 58 0
      src/agents/index.test.ts
  2. 20 0
      src/config/loader.test.ts
  3. 80 36
      src/config/schema.ts

+ 58 - 0
src/agents/index.test.ts

@@ -4,6 +4,7 @@ import {
   AgentOverrideConfigSchema,
   DEFAULT_DISABLED_AGENTS,
   DEFAULT_MODELS,
+  PluginConfigSchema,
   SUBAGENT_NAMES,
 } from '../config';
 import {
@@ -584,6 +585,63 @@ describe('AgentOverrideConfigSchema options validation', () => {
   });
 });
 
+describe('PluginConfigSchema custom-agent-only prompt fields', () => {
+  test('rejects prompt on built-in top-level agent overrides', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        oracle: {
+          model: 'openai/gpt-5.4',
+          prompt: 'ignored built-in prompt override',
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects orchestratorPrompt on built-in top-level agent overrides', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        explorer: {
+          model: 'openai/gpt-5.4-mini',
+          orchestratorPrompt: '@explorer\n- Role: should be invalid here',
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
+
+  test('rejects custom-only prompt fields on built-in preset agents', () => {
+    const result = PluginConfigSchema.safeParse({
+      presets: {
+        openai: {
+          oracle: {
+            model: 'openai/gpt-5.4',
+            prompt: 'ignored preset built-in prompt override',
+          },
+        },
+      },
+    });
+
+    expect(result.success).toBe(false);
+  });
+
+  test('allows prompt fields on custom agents', () => {
+    const result = PluginConfigSchema.safeParse({
+      agents: {
+        janitor: {
+          model: 'openai/gpt-5.4-mini',
+          prompt: 'You are Janitor.',
+          orchestratorPrompt: '@janitor\n- Role: Cleanup specialist',
+        },
+      },
+    });
+
+    expect(result.success).toBe(true);
+  });
+});
+
 describe('disabled_agents', () => {
   test('disabled agents are not created', () => {
     const config: PluginConfig = {

+ 20 - 0
src/config/loader.test.ts

@@ -169,6 +169,26 @@ describe('loadPluginConfig', () => {
     expect(loadPluginConfig(projectDir)).toEqual({});
   });
 
+  test('rejects custom-only prompt fields on built-in agents in config files', () => {
+    const projectDir = path.join(tempDir, 'project');
+    const projectConfigDir = path.join(projectDir, '.opencode');
+    fs.mkdirSync(projectConfigDir, { recursive: true });
+
+    fs.writeFileSync(
+      path.join(projectConfigDir, 'oh-my-opencode-slim.json'),
+      JSON.stringify({
+        agents: {
+          oracle: {
+            model: 'openai/gpt-5.4',
+            prompt: 'This should be rejected for built-in agents.',
+          },
+        },
+      }),
+    );
+
+    expect(loadPluginConfig(projectDir)).toEqual({});
+  });
+
   test('respects OPENCODE_CONFIG_DIR for user config location', () => {
     const customDir = fs.mkdtempSync(
       path.join(os.tmpdir(), 'omc-opencode-config-'),

+ 80 - 36
src/config/schema.ts

@@ -1,4 +1,5 @@
 import { z } from 'zod';
+import { AGENT_ALIASES, ALL_AGENT_NAMES } from './constants';
 import { CouncilConfigSchema } from './council-schema';
 
 const FALLBACK_AGENT_NAMES = [
@@ -230,42 +231,85 @@ export const FailoverConfigSchema = z.object({
 
 export type FailoverConfig = z.infer<typeof FailoverConfigSchema>;
 
-// Main plugin config
-export const PluginConfigSchema = z.object({
-  preset: z.string().optional(),
-  setDefaultAgent: z.boolean().optional(),
-  scoringEngineVersion: z.enum(['v1', 'v2-shadow', 'v2']).optional(),
-  balanceProviderUsage: z.boolean().optional(),
-  showStartupToast: z
-    .boolean()
-    .optional()
-    .describe(
-      'Show the startup activation toast when OpenCode starts. Defaults to true.',
-    ),
-  manualPlan: ManualPlanSchema.optional(),
-  presets: z.record(z.string(), PresetSchema).optional(),
-  agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
-  disabled_agents: z
-    .array(z.string())
-    .optional()
-    .describe(
-      'Agent names to disable completely. ' +
-        'Disabled agents are not instantiated and cannot be delegated to. ' +
-        'Orchestrator and council internal agents (councillor) cannot be disabled. ' +
-        "By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable.",
-    ),
-  disabled_mcps: z.array(z.string()).optional(),
-  // Multiplexer config (new unified config - preferred)
-  multiplexer: MultiplexerConfigSchema.optional(),
-  // Legacy tmux config (for backward compatibility)
-  // When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
-  tmux: TmuxConfigSchema.optional(),
-  websearch: WebsearchConfigSchema.optional(),
-  interview: InterviewConfigSchema.optional(),
-  todoContinuation: TodoContinuationConfigSchema.optional(),
-  fallback: FailoverConfigSchema.optional(),
-  council: CouncilConfigSchema.optional(),
-});
+function validateCustomOnlyPromptFields(
+  overrides: Record<string, z.infer<typeof AgentOverrideConfigSchema>>,
+  ctx: z.RefinementCtx,
+  pathPrefix: Array<string | number>,
+): void {
+  for (const [name, override] of Object.entries(overrides)) {
+    const isBuiltInOrAlias =
+      (ALL_AGENT_NAMES as readonly string[]).includes(name) ||
+      AGENT_ALIASES[name] !== undefined;
+
+    if (!isBuiltInOrAlias) {
+      continue;
+    }
+
+    if (override.prompt !== undefined) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: [...pathPrefix, name, 'prompt'],
+        message: 'prompt is only supported for custom agents',
+      });
+    }
+
+    if (override.orchestratorPrompt !== undefined) {
+      ctx.addIssue({
+        code: z.ZodIssueCode.custom,
+        path: [...pathPrefix, name, 'orchestratorPrompt'],
+        message: 'orchestratorPrompt is only supported for custom agents',
+      });
+    }
+  }
+}
+
+export const PluginConfigSchema = z
+  .object({
+    preset: z.string().optional(),
+    setDefaultAgent: z.boolean().optional(),
+    scoringEngineVersion: z.enum(['v1', 'v2-shadow', 'v2']).optional(),
+    balanceProviderUsage: z.boolean().optional(),
+    showStartupToast: z
+      .boolean()
+      .optional()
+      .describe(
+        'Show the startup activation toast when OpenCode starts. Defaults to true.',
+      ),
+    manualPlan: ManualPlanSchema.optional(),
+    presets: z.record(z.string(), PresetSchema).optional(),
+    agents: z.record(z.string(), AgentOverrideConfigSchema).optional(),
+    disabled_agents: z
+      .array(z.string())
+      .optional()
+      .describe(
+        'Agent names to disable completely. ' +
+          'Disabled agents are not instantiated and cannot be delegated to. ' +
+          'Orchestrator and council internal agents (councillor) cannot be disabled. ' +
+          "By default, 'observer' is disabled. Remove it from this list and configure a vision-capable model to enable.",
+      ),
+    disabled_mcps: z.array(z.string()).optional(),
+    // Multiplexer config (new unified config - preferred)
+    multiplexer: MultiplexerConfigSchema.optional(),
+    // Legacy tmux config (for backward compatibility)
+    // When tmux.enabled is true, it's equivalent to multiplexer.type = 'tmux'
+    tmux: TmuxConfigSchema.optional(),
+    websearch: WebsearchConfigSchema.optional(),
+    interview: InterviewConfigSchema.optional(),
+    todoContinuation: TodoContinuationConfigSchema.optional(),
+    fallback: FailoverConfigSchema.optional(),
+    council: CouncilConfigSchema.optional(),
+  })
+  .superRefine((value, ctx) => {
+    if (value.agents) {
+      validateCustomOnlyPromptFields(value.agents, ctx, ['agents']);
+    }
+
+    if (value.presets) {
+      for (const [presetName, preset] of Object.entries(value.presets)) {
+        validateCustomOnlyPromptFields(preset, ctx, ['presets', presetName]);
+      }
+    }
+  });
 
 export type PluginConfig = z.infer<typeof PluginConfigSchema>;