From c84fa96238bdeb38a02e023d7b3b0ed1e0280bef Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Sat, 13 Dec 2025 05:20:26 +0900 Subject: [PATCH 1/4] fix(config): deep merge agent overrides between user and project configs Previously, when merging user config with project config, agent overrides were shallow merged at the agent name level. This caused user-level agent settings to be completely replaced by project-level settings instead of being merged together. For example: - User config: { agents: { oracle: { temperature: 0.5 } } } - Project config: { agents: { oracle: { model: "gpt-4" } } } - Before: { agents: { oracle: { model: "gpt-4" } } } (temperature lost!) - After: { agents: { oracle: { temperature: 0.5, model: "gpt-4" } } } This fix adds two helper functions: - mergeAgentOverrideConfig: deep merges individual agent configs including nested objects like 'tools' and 'permission' - mergeAgentOverrides: iterates all agent names from both configs and applies deep merge for each --- src/index.ts | 49 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index e3b4602..4fc780c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -82,6 +82,50 @@ function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null { return null; } +function mergeAgentOverrideConfig( + base: OhMyOpenCodeConfig["agents"][keyof OhMyOpenCodeConfig["agents"]], + override: OhMyOpenCodeConfig["agents"][keyof OhMyOpenCodeConfig["agents"]] +): OhMyOpenCodeConfig["agents"][keyof OhMyOpenCodeConfig["agents"]] { + if (!base) return override; + if (!override) return base; + + return { + ...base, + ...override, + tools: + override.tools !== undefined || base.tools !== undefined + ? { ...(base.tools ?? {}), ...(override.tools ?? {}) } + : undefined, + permission: + override.permission !== undefined || base.permission !== undefined + ? { ...(base.permission ?? {}), ...(override.permission ?? {}) } + : undefined, + }; +} + +function mergeAgentOverrides( + base: OhMyOpenCodeConfig["agents"], + override: OhMyOpenCodeConfig["agents"] +): OhMyOpenCodeConfig["agents"] { + if (!base && !override) return undefined; + if (!base) return override; + if (!override) return base; + + const allAgentNames = [ + ...new Set([...Object.keys(base), ...Object.keys(override)]), + ] as Array>; + + const result: OhMyOpenCodeConfig["agents"] = {}; + + for (const agentName of allAgentNames) { + const baseAgent = base[agentName]; + const overrideAgent = override[agentName]; + result[agentName] = mergeAgentOverrideConfig(baseAgent, overrideAgent); + } + + return result; +} + function mergeConfigs( base: OhMyOpenCodeConfig, override: OhMyOpenCodeConfig @@ -89,10 +133,7 @@ function mergeConfigs( return { ...base, ...override, - agents: - override.agents !== undefined - ? { ...(base.agents ?? {}), ...override.agents } - : base.agents, + agents: mergeAgentOverrides(base.agents, override.agents), disabled_agents: [ ...new Set([ ...(base.disabled_agents ?? []), From d943995abb444cb22b4ca2cbb7cbbe8025f9a7b5 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Sat, 13 Dec 2025 05:35:44 +0900 Subject: [PATCH 2/4] refactor(config): extract deepMerge utility for config merging - Create reusable deepMerge function in src/shared/deep-merge.ts - Replace manual nested merge logic with deepMerge calls - Also fix shallow merge bug in claude_code config - Reduce ~54 lines of duplicated merge code - Handle arbitrary nesting depth automatically --- src/agents/utils.ts | 12 ++------- src/index.ts | 53 +++------------------------------------- src/shared/deep-merge.ts | 44 +++++++++++++++++++++++++++++++++ src/shared/index.ts | 1 + 4 files changed, 50 insertions(+), 60 deletions(-) create mode 100644 src/shared/deep-merge.ts diff --git a/src/agents/utils.ts b/src/agents/utils.ts index f0d3519..200408e 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -5,6 +5,7 @@ import { librarianAgent } from "./librarian" import { exploreAgent } from "./explore" import { frontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer" import { documentWriterAgent } from "./document-writer" +import { deepMerge } from "../shared" const allBuiltinAgents: Record = { oracle: oracleAgent, @@ -18,16 +19,7 @@ function mergeAgentConfig( base: AgentConfig, override: AgentOverrideConfig ): AgentConfig { - return { - ...base, - ...override, - tools: override.tools !== undefined - ? { ...(base.tools ?? {}), ...override.tools } - : base.tools, - permission: override.permission !== undefined - ? { ...(base.permission ?? {}), ...override.permission } - : base.permission, - } + return deepMerge(base, override) as AgentConfig } export function createBuiltinAgents( diff --git a/src/index.ts b/src/index.ts index 4fc780c..fdbc8a7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -42,7 +42,7 @@ import { builtinTools, createCallOmoAgent, createBackgroundTools } from "./tools import { BackgroundManager } from "./features/background-agent"; import { createBuiltinMcps } from "./mcp"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; -import { log } from "./shared/logger"; +import { log, deepMerge } from "./shared"; import * as fs from "fs"; import * as path from "path"; import * as os from "os"; @@ -82,50 +82,6 @@ function loadConfigFromPath(configPath: string): OhMyOpenCodeConfig | null { return null; } -function mergeAgentOverrideConfig( - base: OhMyOpenCodeConfig["agents"][keyof OhMyOpenCodeConfig["agents"]], - override: OhMyOpenCodeConfig["agents"][keyof OhMyOpenCodeConfig["agents"]] -): OhMyOpenCodeConfig["agents"][keyof OhMyOpenCodeConfig["agents"]] { - if (!base) return override; - if (!override) return base; - - return { - ...base, - ...override, - tools: - override.tools !== undefined || base.tools !== undefined - ? { ...(base.tools ?? {}), ...(override.tools ?? {}) } - : undefined, - permission: - override.permission !== undefined || base.permission !== undefined - ? { ...(base.permission ?? {}), ...(override.permission ?? {}) } - : undefined, - }; -} - -function mergeAgentOverrides( - base: OhMyOpenCodeConfig["agents"], - override: OhMyOpenCodeConfig["agents"] -): OhMyOpenCodeConfig["agents"] { - if (!base && !override) return undefined; - if (!base) return override; - if (!override) return base; - - const allAgentNames = [ - ...new Set([...Object.keys(base), ...Object.keys(override)]), - ] as Array>; - - const result: OhMyOpenCodeConfig["agents"] = {}; - - for (const agentName of allAgentNames) { - const baseAgent = base[agentName]; - const overrideAgent = override[agentName]; - result[agentName] = mergeAgentOverrideConfig(baseAgent, overrideAgent); - } - - return result; -} - function mergeConfigs( base: OhMyOpenCodeConfig, override: OhMyOpenCodeConfig @@ -133,7 +89,7 @@ function mergeConfigs( return { ...base, ...override, - agents: mergeAgentOverrides(base.agents, override.agents), + agents: deepMerge(base.agents, override.agents), disabled_agents: [ ...new Set([ ...(base.disabled_agents ?? []), @@ -146,10 +102,7 @@ function mergeConfigs( ...(override.disabled_mcps ?? []), ]), ], - claude_code: - override.claude_code !== undefined || base.claude_code !== undefined - ? { ...(base.claude_code ?? {}), ...(override.claude_code ?? {}) } - : undefined, + claude_code: deepMerge(base.claude_code, override.claude_code), }; } diff --git a/src/shared/deep-merge.ts b/src/shared/deep-merge.ts new file mode 100644 index 0000000..cbf3003 --- /dev/null +++ b/src/shared/deep-merge.ts @@ -0,0 +1,44 @@ +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); +} + +/** + * Deep merges two objects, with override values taking precedence. + * - Objects are recursively merged + * - Arrays are replaced (not concatenated) + * - undefined values in override do not overwrite base values + * + * @example + * deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 }) + * // => { a: 1, b: { c: 10, d: 3 }, e: 5 } + */ +export function deepMerge>( + base: T | undefined, + override: T | undefined +): T | undefined { + if (!base && !override) return undefined; + if (!base) return override; + if (!override) return base; + + const result = { ...base } as Record; + + for (const key of Object.keys(override)) { + const baseValue = base[key]; + const overrideValue = override[key]; + + if (overrideValue === undefined) continue; + + if (isPlainObject(baseValue) && isPlainObject(overrideValue)) { + result[key] = deepMerge(baseValue, overrideValue); + } else { + result[key] = overrideValue; + } + } + + return result as T; +} diff --git a/src/shared/index.ts b/src/shared/index.ts index 7e87c2a..d96c880 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -7,3 +7,4 @@ export * from "./snake-case" export * from "./tool-name" export * from "./pattern-matcher" export * from "./hook-disabled" +export * from "./deep-merge" From c3e9404cdaaef03b33bd87a4d976c84d68d875c0 Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Sat, 13 Dec 2025 11:53:28 +0900 Subject: [PATCH 3/4] security(deep-merge): add prototype pollution defense and stack overflow protection - Filter dangerous keys: __proto__, constructor, prototype - Add MAX_DEPTH (50) limit to prevent stack overflow from malicious configs - Addresses critical security review feedback --- src/shared/deep-merge.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/shared/deep-merge.ts b/src/shared/deep-merge.ts index cbf3003..b3a1b46 100644 --- a/src/shared/deep-merge.ts +++ b/src/shared/deep-merge.ts @@ -1,3 +1,6 @@ +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const MAX_DEPTH = 50; + function isPlainObject(value: unknown): value is Record { return ( typeof value === "object" && @@ -19,22 +22,26 @@ function isPlainObject(value: unknown): value is Record { */ export function deepMerge>( base: T | undefined, - override: T | undefined + override: T | undefined, + depth = 0 ): T | undefined { if (!base && !override) return undefined; if (!base) return override; if (!override) return base; + if (depth > MAX_DEPTH) return override ?? base; const result = { ...base } as Record; for (const key of Object.keys(override)) { + if (DANGEROUS_KEYS.has(key)) continue; + const baseValue = base[key]; const overrideValue = override[key]; if (overrideValue === undefined) continue; if (isPlainObject(baseValue) && isPlainObject(overrideValue)) { - result[key] = deepMerge(baseValue, overrideValue); + result[key] = deepMerge(baseValue, overrideValue, depth + 1); } else { result[key] = overrideValue; } From f29478ad058dfae7633fc17f70ceaa7a5459d76e Mon Sep 17 00:00:00 2001 From: Junho Yeo Date: Sat, 13 Dec 2025 11:55:10 +0900 Subject: [PATCH 4/4] types(deep-merge): add function overloads for better type inference - Add overload: when both args are defined, return type is T (not T | undefined) - Remove 'as AgentConfig' cast, use 'as Partial' for proper overload matching - Eliminates need for type assertions at call sites --- src/agents/utils.ts | 2 +- src/shared/deep-merge.ts | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/agents/utils.ts b/src/agents/utils.ts index 200408e..a45962d 100644 --- a/src/agents/utils.ts +++ b/src/agents/utils.ts @@ -19,7 +19,7 @@ function mergeAgentConfig( base: AgentConfig, override: AgentOverrideConfig ): AgentConfig { - return deepMerge(base, override) as AgentConfig + return deepMerge(base, override as Partial) } export function createBuiltinAgents( diff --git a/src/shared/deep-merge.ts b/src/shared/deep-merge.ts index b3a1b46..1f7a79b 100644 --- a/src/shared/deep-merge.ts +++ b/src/shared/deep-merge.ts @@ -20,6 +20,8 @@ function isPlainObject(value: unknown): value is Record { * deepMerge({ a: 1, b: { c: 2, d: 3 } }, { b: { c: 10 }, e: 5 }) * // => { a: 1, b: { c: 10, d: 3 }, e: 5 } */ +export function deepMerge>(base: T, override: Partial, depth?: number): T; +export function deepMerge>(base: T | undefined, override: T | undefined, depth?: number): T | undefined; export function deepMerge>( base: T | undefined, override: T | undefined,