From 01c8a2a9271cdb250cf65004054646915b19d524 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 4 May 2026 19:11:49 +0900 Subject: [PATCH 1/4] feat(shared): add findProjectOpencodePluginConfigFiles walker Walks up the directory tree from a start directory looking for .opencode/oh-my-openagent.json[c] (or legacy basename) files. Returns detected paths in nearest-first order, optionally stopping at a caller-provided directory. Reuses detectPluginConfigFile so canonical/legacy basename detection, caching, and JSONC vs JSON precedence stay consistent with existing config loading. Foundation for #417 hierarchical config discovery. --- src/shared/project-discovery-dirs.test.ts | 92 ++++++++++++++++++++++- src/shared/project-discovery-dirs.ts | 34 +++++++++ 2 files changed, 125 insertions(+), 1 deletion(-) diff --git a/src/shared/project-discovery-dirs.test.ts b/src/shared/project-discovery-dirs.test.ts index d2904bc72..b2aab0c1b 100644 --- a/src/shared/project-discovery-dirs.test.ts +++ b/src/shared/project-discovery-dirs.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test" -import { mkdirSync, realpathSync, rmSync } from "node:fs" +import { mkdirSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" @@ -121,4 +121,94 @@ describe("project-discovery-dirs", () => { expect(directories).toEqual([canonicalPath(join(projectDir, ".opencode", "skills"))]) }) + it("#given nested .opencode plugin config files #when finding plugin config files #then returns nearest-first canonical paths", async () => { + // given + const grandparentDir = join(TEST_DIR, "grandparent") + const parentDir = join(grandparentDir, "parent") + const projectDir = join(parentDir, "project") + mkdirSync(join(grandparentDir, ".opencode"), { recursive: true }) + mkdirSync(join(parentDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + writeFileSync(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc"), "{}") + writeFileSync(join(parentDir, ".opencode", "oh-my-openagent.jsonc"), "{}") + writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}") + + const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser") + clearPluginConfigFileDetectionCache() + const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs") + + // when + const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR) + + // then + expect(paths).toEqual([ + canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")), + canonicalPath(join(parentDir, ".opencode", "oh-my-openagent.jsonc")), + canonicalPath(join(grandparentDir, ".opencode", "oh-my-openagent.jsonc")), + ]) + }) + + it("#given a stop directory #when finding plugin config files #then walking halts at the stop boundary inclusive", async () => { + // given + const stopDir = join(TEST_DIR, "stop") + const childDir = join(stopDir, "child") + mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true }) + mkdirSync(join(stopDir, ".opencode"), { recursive: true }) + mkdirSync(join(childDir, ".opencode"), { recursive: true }) + writeFileSync(join(TEST_DIR, ".opencode", "oh-my-openagent.jsonc"), "{}") + writeFileSync(join(stopDir, ".opencode", "oh-my-openagent.jsonc"), "{}") + writeFileSync(join(childDir, ".opencode", "oh-my-openagent.jsonc"), "{}") + + const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser") + clearPluginConfigFileDetectionCache() + const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs") + + // when + const paths = findProjectOpencodePluginConfigFiles(childDir, stopDir) + + // then + expect(paths).toEqual([ + canonicalPath(join(childDir, ".opencode", "oh-my-openagent.jsonc")), + canonicalPath(join(stopDir, ".opencode", "oh-my-openagent.jsonc")), + ]) + }) + + it("#given a legacy basename in an ancestor #when finding plugin config files #then detection picks up the legacy path", async () => { + // given + const projectDir = join(TEST_DIR, "project") + mkdirSync(join(TEST_DIR, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + writeFileSync(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc"), "{}") + writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}") + + const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser") + clearPluginConfigFileDetectionCache() + const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs") + + // when + const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR) + + // then + expect(paths).toEqual([ + canonicalPath(join(projectDir, ".opencode", "oh-my-openagent.jsonc")), + canonicalPath(join(TEST_DIR, ".opencode", "oh-my-opencode.jsonc")), + ]) + }) + + it("#given no .opencode directories along the walk #when finding plugin config files #then returns an empty list", async () => { + // given + const projectDir = join(TEST_DIR, "project", "deep") + mkdirSync(projectDir, { recursive: true }) + + const { clearPluginConfigFileDetectionCache } = await import("./jsonc-parser") + clearPluginConfigFileDetectionCache() + const { findProjectOpencodePluginConfigFiles } = await import("./project-discovery-dirs") + + // when + const paths = findProjectOpencodePluginConfigFiles(projectDir, TEST_DIR) + + // then + expect(paths).toEqual([]) + }) + }) diff --git a/src/shared/project-discovery-dirs.ts b/src/shared/project-discovery-dirs.ts index 5e243df5a..ee53f5486 100644 --- a/src/shared/project-discovery-dirs.ts +++ b/src/shared/project-discovery-dirs.ts @@ -2,6 +2,8 @@ import { execFileSync } from "node:child_process" import { existsSync, realpathSync } from "node:fs" import { dirname, join, resolve } from "node:path" +import { detectPluginConfigFile } from "./jsonc-parser" + const worktreePathCache = new Map() function normalizePath(path: string): string { @@ -114,3 +116,35 @@ export function findProjectOpencodeCommandDirs(startDirectory: string, stopDirec stopDirectory ?? detectWorktreePath(startDirectory), ) } + +export function findProjectOpencodePluginConfigFiles( + startDirectory: string, + stopDirectory?: string, +): string[] { + const paths: string[] = [] + const seen = new Set() + let currentDirectory = normalizePath(startDirectory) + const resolvedStopDirectory = stopDirectory ? normalizePath(stopDirectory) : undefined + + while (true) { + const opencodeDirectory = join(currentDirectory, ".opencode") + if (existsSync(opencodeDirectory)) { + const detected = detectPluginConfigFile(opencodeDirectory) + if (detected.format !== "none" && !seen.has(detected.path)) { + seen.add(detected.path) + paths.push(detected.path) + } + } + + if (resolvedStopDirectory === currentDirectory) { + return paths + } + + const parentDirectory = dirname(currentDirectory) + if (parentDirectory === currentDirectory) { + return paths + } + + currentDirectory = normalizePath(parentDirectory) + } +} From cc1d9cf030ff90c345b8e972e73e992005598171 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 4 May 2026 19:25:07 +0900 Subject: [PATCH 2/4] feat(config): walk up directory tree to merge ancestor plugin configs Closes #417. The user config (`~/.config/opencode/oh-my-openagent.json[c]`) is no longer the only level above the project. `loadPluginConfig` now walks from the working directory up to `$HOME` (inclusive), collecting every `.opencode/oh-my-openagent.json[c]` it finds along the way. Configs closer to the working directory override configs farther up, allowing per-tree setups like: ~/work/.opencode/oh-my-openagent.json # work credentials ~/dev/.opencode/oh-my-openagent.json # personal credentials ~/.config/opencode/oh-my-openagent.json # global fallback This subsumes the previous single project-config load: the project's own config is just the closest hit of the walk. `agent_definitions` relative paths resolve against each ancestor's own `.opencode/` base, `git_master` overrides accumulate across the walk (closer wins), and legacy basenames are migrated wherever they appear. `mcp_env_allowlist` is intentionally NOT extensible from walked ancestors. It remains user-only as a security boundary so a malicious or untrusted parent directory cannot extend the env var allowlist used during ${VAR} expansion in `.mcp.json` files. The existing test that pins this behaviour is extended to cover the multi-ancestor case. `os.homedir()` caches in Bun, so the stop directory is resolved by reading `process.env.HOME` directly. Production behaviour is unchanged because the OS sets HOME at startup; tests can now redirect the walk boundary by setting HOME to a temp directory before each call. --- src/plugin-config.test.ts | 198 +++++++++++++++++++++++++++++++++++++- src/plugin-config.ts | 146 +++++++++++++++++----------- 2 files changed, 288 insertions(+), 56 deletions(-) diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index c8bac63ba..fbe4d8599 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -560,7 +560,6 @@ describe("loadPluginConfig", () => { git_env_prefix: "GIT_MASTER=1", }) }) - describe("team_mode.tmux_visualization", () => { it("#given canonical user config enables team_mode and legacy config also exists #when loadPluginConfig runs #then tmux_visualization remains false", async () => { // given @@ -639,4 +638,201 @@ describe("loadPluginConfig", () => { expect(config.team_mode).toBeUndefined() }) }) + + it("should merge configs from ancestor directories with closer winning", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-")) + const userConfigDir = join(rootDir, "user-config") + const homeDir = join(rootDir, "home") + const workDir = join(homeDir, "work") + const projectDir = join(workDir, "project") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(homeDir, ".opencode"), { recursive: true }) + mkdirSync(join(workDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync( + join(userConfigDir, "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "user/model" } } }) + ) + writeFileSync( + join(homeDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "home/model" } } }) + ) + writeFileSync( + join(workDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "work/model" } } }) + ) + writeFileSync( + join(projectDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "project/model" } } }) + ) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then + expect(config.agents?.oracle?.model).toBe("project/model") + }) + + it("should layer ancestor configs so each contributes fields not overridden by closer ones", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-layer-")) + const userConfigDir = join(rootDir, "user-config") + const homeDir = join(rootDir, "home") + const workDir = join(homeDir, "work") + const projectDir = join(workDir, "project") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(homeDir, ".opencode"), { recursive: true }) + mkdirSync(join(workDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}") + writeFileSync( + join(homeDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "home/oracle" } } }) + ) + writeFileSync( + join(workDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { hephaestus: { model: "work/hephaestus" } } }) + ) + writeFileSync( + join(projectDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { sisyphus: { model: "project/sisyphus" } } }) + ) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then - each level contributes a non-conflicting field + expect(config.agents?.oracle?.model).toBe("home/oracle") + expect(config.agents?.hephaestus?.model).toBe("work/hephaestus") + expect(config.agents?.sisyphus?.model).toBe("project/sisyphus") + }) + + it("should preserve mcp_env_allowlist as user-only when ancestors set their own allowlists", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-allowlist-")) + const userConfigDir = join(rootDir, "user-config") + const homeDir = join(rootDir, "home") + const workDir = join(homeDir, "work") + const projectDir = join(workDir, "project") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(homeDir, ".opencode"), { recursive: true }) + mkdirSync(join(workDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync( + join(userConfigDir, "oh-my-openagent.jsonc"), + JSON.stringify({ mcp_env_allowlist: ["USER_ONLY_TOKEN"] }) + ) + writeFileSync( + join(homeDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ mcp_env_allowlist: ["HOME_TOKEN"] }) + ) + writeFileSync( + join(workDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ mcp_env_allowlist: ["WORK_TOKEN"] }) + ) + writeFileSync( + join(projectDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ mcp_env_allowlist: ["PROJECT_TOKEN"] }) + ) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then - only the canonical user config can extend the allowlist + expect(config.mcp_env_allowlist).toEqual(["USER_ONLY_TOKEN"]) + }) + + it("should stop walking at $HOME and ignore configs above it", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-stop-")) + const userConfigDir = join(rootDir, "user-config") + const aboveHomeDir = join(rootDir, "above-home") + const homeDir = join(aboveHomeDir, "home") + const projectDir = join(homeDir, "project") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(aboveHomeDir, ".opencode"), { recursive: true }) + mkdirSync(join(homeDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}") + writeFileSync( + join(aboveHomeDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "above-home/leak" } } }) + ) + writeFileSync( + join(homeDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { hephaestus: { model: "home/wins" } } }) + ) + writeFileSync(join(projectDir, ".opencode", "oh-my-openagent.jsonc"), "{}") + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then - $HOME's config applies, but the directory above it does NOT + expect(config.agents?.hephaestus?.model).toBe("home/wins") + expect(config.agents?.oracle).toBeUndefined() + }) + + it("should migrate legacy basenames found in ancestor directories", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-legacy-")) + const userConfigDir = join(rootDir, "user-config") + const homeDir = join(rootDir, "home") + const workDir = join(homeDir, "work") + const projectDir = join(workDir, "project") + const ancestorLegacyPath = join(workDir, ".opencode", "oh-my-opencode.jsonc") + const ancestorCanonicalPath = join(workDir, ".opencode", "oh-my-openagent.jsonc") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(homeDir, ".opencode"), { recursive: true }) + mkdirSync(join(workDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}") + writeFileSync( + ancestorLegacyPath, + JSON.stringify({ agents: { oracle: { model: "ancestor-legacy/model" } } }) + ) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then + expect(existsSync(ancestorLegacyPath)).toBe(false) + expect(existsSync(ancestorCanonicalPath)).toBe(true) + expect(config.agents?.oracle?.model).toBe("ancestor-legacy/model") + }) }) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index 008c7cd09..f745f9181 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -1,4 +1,5 @@ import * as fs from "fs"; +import { homedir } from "node:os"; import * as path from "path"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; import { @@ -8,12 +9,41 @@ import { addConfigLoadError, parseJsonc, detectPluginConfigFile, + findProjectOpencodePluginConfigFiles, migrateConfigFile, resolveAgentDefinitionPaths, } from "./shared"; import { migrateLegacyConfigFile } from "./shared/migrate-legacy-config-file"; import { CONFIG_BASENAME, LEGACY_CONFIG_BASENAME } from "./shared/plugin-identity"; +function resolveHomeDirectory(): string { + // Read env vars directly to bypass os.homedir() caching. Bun caches the + // first os.homedir() result, which means tests that set process.env.HOME + // after import never see the new value. Production behaviour is preserved + // because HOME (or USERPROFILE on Windows) is set by the OS at startup. + return process.env.HOME ?? process.env.USERPROFILE ?? homedir() +} + +function migrateLegacyAndResolveCanonicalPath(detectedPath: string): string { + if (!path.basename(detectedPath).startsWith(LEGACY_CONFIG_BASENAME)) { + return detectedPath + } + + const migrated = migrateLegacyConfigFile(detectedPath) + const canonicalPath = path.join( + path.dirname(detectedPath), + `${CONFIG_BASENAME}${path.extname(detectedPath)}`, + ) + + // Only switch to canonical path if migration succeeded OR canonical file already exists + if (migrated || fs.existsSync(canonicalPath)) { + return canonicalPath + } + + // Otherwise keep loading from the legacy path that was detected + return detectedPath +} + function loadExplicitGitMasterOverrides(configPath: string): Record | undefined { try { if (!fs.existsSync(configPath)) { @@ -214,47 +244,35 @@ export function loadPluginConfig( } // Auto-copy legacy config file to canonical name if needed - if (userDetected.format !== "none" && path.basename(userDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) { - const migrated = migrateLegacyConfigFile(userDetected.path); - const canonicalPath = path.join( - path.dirname(userDetected.path), - `${CONFIG_BASENAME}${path.extname(userDetected.path)}` - ); - // Only switch to canonical path if migration succeeded OR canonical file already exists - if (migrated || fs.existsSync(canonicalPath)) { - userConfigPath = canonicalPath; + if (userDetected.format !== "none") { + userConfigPath = migrateLegacyAndResolveCanonicalPath(userConfigPath) + } + + // Walk up from directory to $HOME for ancestor configs (closest first) + // This subsumes the previous single project-config load: the closest hit is + // the project's own .opencode/oh-my-openagent.json[c], and walking continues + // up so configs in ~/work/ (etc.) apply to all subprojects. + const ancestorConfigPaths = findProjectOpencodePluginConfigFiles( + directory, + resolveHomeDirectory(), + ) + log("Walked ancestor plugin configs", { + paths: ancestorConfigPaths, + count: ancestorConfigPaths.length, + }) + + // Migrate any legacy basenames among ancestors and warn on dual-config presence + const canonicalAncestorPaths = ancestorConfigPaths.map((ancestorPath) => { + const opencodeDir = path.dirname(ancestorPath) + const ancestorDetected = detectPluginConfigFile(opencodeDir) + if (ancestorDetected.legacyPath) { + log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { + canonicalPath: ancestorDetected.path, + legacyPath: ancestorDetected.legacyPath, + }) } - // Otherwise keep loading from the legacy path that was detected - } - - // Project-level config path - prefer .jsonc over .json - const projectBasePath = path.join(directory, ".opencode"); - const projectDetected = detectPluginConfigFile(projectBasePath); - let projectConfigPath = - projectDetected.format !== "none" - ? projectDetected.path - : path.join(projectBasePath, `${CONFIG_BASENAME}.json`); - - if (projectDetected.legacyPath) { - log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { - canonicalPath: projectDetected.path, - legacyPath: projectDetected.legacyPath, - }); - } - - // Auto-copy legacy project config file to canonical name if needed - if (projectDetected.format !== "none" && path.basename(projectDetected.path).startsWith(LEGACY_CONFIG_BASENAME)) { - const projectMigrated = migrateLegacyConfigFile(projectDetected.path); - const canonicalProjectPath = path.join( - path.dirname(projectDetected.path), - `${CONFIG_BASENAME}${path.extname(projectDetected.path)}` - ); - // Only switch to canonical path if migration succeeded OR canonical file already exists - if (projectMigrated || fs.existsSync(canonicalProjectPath)) { - projectConfigPath = canonicalProjectPath; - } - // Otherwise keep loading from the legacy path that was detected - } + return migrateLegacyAndResolveCanonicalPath(ancestorPath) + }) // Load user config first (base). Parse empty config through Zod to apply field defaults. const userConfig = loadConfigFromPath(userConfigPath, ctx) @@ -271,34 +289,52 @@ export function loadPluginConfig( let config: OhMyOpenCodeConfig = userConfig ?? OhMyOpenCodeConfigSchema.parse({}); - // Override with project config + // Merge ancestor configs from farthest to nearest, so closer overrides farther. + // Walker returns nearest-first; reverse for merge order. const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master - const projectConfig = loadConfigFromPath(projectConfigPath, ctx); - const projectGitMasterOverrides = loadExplicitGitMasterOverrides(projectConfigPath) + const ancestorGitMasterOverrides: Array> = [] - if (projectConfig?.agent_definitions) { - projectConfig.agent_definitions = resolveAgentDefinitionPaths( - projectConfig.agent_definitions, - projectBasePath, - directory - ) + for (const ancestorPath of canonicalAncestorPaths.slice().reverse()) { + const ancestorConfig = loadConfigFromPath(ancestorPath, ctx) + const ancestorOverrides = loadExplicitGitMasterOverrides(ancestorPath) + + if (ancestorConfig?.agent_definitions) { + // Resolve relative paths against this ancestor's own .opencode/ base. + const ancestorBasePath = path.dirname(ancestorPath) + const ancestorDir = path.dirname(ancestorBasePath) + ancestorConfig.agent_definitions = resolveAgentDefinitionPaths( + ancestorConfig.agent_definitions, + ancestorBasePath, + ancestorDir, + ) + } + + if (ancestorConfig) { + config = mergeConfigs(config, ancestorConfig) + } + + if (ancestorOverrides) { + ancestorGitMasterOverrides.push(ancestorOverrides) + } } - if (projectConfig) { - config = mergeConfigs(config, projectConfig); - } - - if (userGitMasterOverrides || projectGitMasterOverrides) { + if (userGitMasterOverrides || ancestorGitMasterOverrides.length > 0) { config = { ...config, git_master: { ...defaultGitMaster, ...(userGitMasterOverrides ?? {}), - ...(projectGitMasterOverrides ?? {}), + // Ancestors are pushed far-to-near; Object.assign with an empty seed + // applies each in order so the nearest (last) wins. + ...Object.assign({}, ...ancestorGitMasterOverrides), }, } } + // Security: mcp_env_allowlist remains user-only across the entire walk. + // This prevents clone-and-load attacks where a malicious project (or any + // walked ancestor) could extend the env var allowlist used during ${VAR} + // expansion in .mcp.json files. See commit 316d2504 for context. config = { ...config, mcp_env_allowlist: userConfig?.mcp_env_allowlist ?? [], From eb1e104742e3d51fbb3eb2078d1852dac0a12d7a Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 4 May 2026 19:36:23 +0900 Subject: [PATCH 3/4] refactor(config): tighten walk-up config discovery from oracle review Stop the walk at the start directory when it sits outside $HOME so the walker never falls through to filesystem root. Without this guard a project at /tmp/x or /opt/projects/foo would surface unrelated configs in /tmp, /opt, or / itself. Also clean up reviewer-flagged friction: - Rename ancestor path/override variables to *NearestFirst / *FarthestFirst so the merge order is self-documenting and the mid-flight `.slice().reverse()` is no longer surprising. - Rename `migrateLegacyAndResolveCanonicalPath` to `resolveConfigPathAfterLegacyMigration` to reflect that the helper returns the path to load, which may still be the legacy path when migration could not run. - Replace `Object.assign({}, ...overrides)` with a named accumulator loop so the closer-wins ordering is obvious from the code instead of relying on a comment. Tests added: - start directory outside $HOME does not walk above itself - multi-ancestor git_master merge order (closer wins, distant fields still flow through) - agent_definitions in an ancestor resolves against that ancestor's own .opencode/ base path, not the start directory's --- src/plugin-config.test.ts | 135 +++++++++++++++++++++++++++++++++++++- src/plugin-config.ts | 66 ++++++++++--------- 2 files changed, 169 insertions(+), 32 deletions(-) diff --git a/src/plugin-config.test.ts b/src/plugin-config.test.ts index fbe4d8599..ee2dfa8c4 100644 --- a/src/plugin-config.test.ts +++ b/src/plugin-config.test.ts @@ -1,5 +1,5 @@ -import { afterEach, describe, expect, it, mock } from "bun:test"; -import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" import { mergeConfigs, parseConfigPartially } from "./plugin-config"; @@ -801,6 +801,137 @@ describe("loadPluginConfig", () => { expect(config.agents?.oracle).toBeUndefined() }) + it("should not walk above the start directory when start is outside $HOME", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-outside-")) + const userConfigDir = join(rootDir, "user-config") + const homeDir = join(rootDir, "home") + const outsideHomeRoot = join(rootDir, "outside-home") + const projectDir = join(outsideHomeRoot, "proj") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(homeDir, { recursive: true }) + mkdirSync(join(outsideHomeRoot, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}") + writeFileSync( + join(outsideHomeRoot, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { oracle: { model: "outside-home/leak" } } }) + ) + writeFileSync( + join(projectDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agents: { hephaestus: { model: "project/wins" } } }) + ) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then - project loads, but the parent above it (outside $HOME) is not walked into + expect(config.agents?.hephaestus?.model).toBe("project/wins") + expect(config.agents?.oracle).toBeUndefined() + }) + + it("should merge git_master overrides across ancestors with closer winning", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-git-master-")) + const userConfigDir = join(rootDir, "user-config") + const homeDir = join(rootDir, "home") + const workDir = join(homeDir, "work") + const projectDir = join(workDir, "project") + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(homeDir, ".opencode"), { recursive: true }) + mkdirSync(join(workDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}") + writeFileSync( + join(homeDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ + git_master: { + commit_footer: false, + include_co_authored_by: false, + git_env_prefix: "HOME=1", + }, + }) + ) + writeFileSync( + join(workDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ + git_master: { + include_co_authored_by: true, + }, + }) + ) + writeFileSync( + join(projectDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ + git_master: { + commit_footer: true, + }, + }) + ) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then project's commit_footer wins, work's include_co_authored_by wins, + // home's git_env_prefix is preserved since nobody else set it + expect(config.git_master).toEqual({ + commit_footer: true, + include_co_authored_by: true, + git_env_prefix: "HOME=1", + }) + }) + + it("should resolve agent_definitions relative to each ancestor's own .opencode directory", async () => { + // given + const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-agent-defs-")) + const userConfigDir = join(rootDir, "user-config") + const homeDir = join(rootDir, "home") + const workDir = join(homeDir, "work") + const projectDir = join(workDir, "project") + const workDefRelativePath = "./work-agent.md" + const projectDefRelativePath = "./project-agent.md" + + tempDirs.push(rootDir) + mkdirSync(userConfigDir, { recursive: true }) + mkdirSync(join(workDir, ".opencode"), { recursive: true }) + mkdirSync(join(projectDir, ".opencode"), { recursive: true }) + + writeFileSync(join(userConfigDir, "oh-my-openagent.jsonc"), "{}") + writeFileSync( + join(workDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agent_definitions: [workDefRelativePath] }) + ) + writeFileSync( + join(projectDir, ".opencode", "oh-my-openagent.jsonc"), + JSON.stringify({ agent_definitions: [projectDefRelativePath] }) + ) + + process.env.OPENCODE_CONFIG_DIR = userConfigDir + process.env.HOME = homeDir + + // when + const { loadPluginConfig } = await importFreshPluginConfigModule() + const config = loadPluginConfig(projectDir, {}) + + // then each ancestor's relative path resolves against its own .opencode/ + expect(config.agent_definitions).toContain(join(realpathSync(workDir), ".opencode", "work-agent.md")) + expect(config.agent_definitions).toContain(join(realpathSync(projectDir), ".opencode", "project-agent.md")) + }) + it("should migrate legacy basenames found in ancestor directories", async () => { // given const rootDir = mkdtempSync(join(tmpdir(), "omo-plugin-config-walk-legacy-")) diff --git a/src/plugin-config.ts b/src/plugin-config.ts index f745f9181..cedb3ac2d 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -4,6 +4,7 @@ import * as path from "path"; import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"; import { log, + containsPath, deepMerge, getOpenCodeConfigDir, addConfigLoadError, @@ -24,7 +25,7 @@ function resolveHomeDirectory(): string { return process.env.HOME ?? process.env.USERPROFILE ?? homedir() } -function migrateLegacyAndResolveCanonicalPath(detectedPath: string): string { +function resolveConfigPathAfterLegacyMigration(detectedPath: string): string { if (!path.basename(detectedPath).startsWith(LEGACY_CONFIG_BASENAME)) { return detectedPath } @@ -245,34 +246,38 @@ export function loadPluginConfig( // Auto-copy legacy config file to canonical name if needed if (userDetected.format !== "none") { - userConfigPath = migrateLegacyAndResolveCanonicalPath(userConfigPath) + userConfigPath = resolveConfigPathAfterLegacyMigration(userConfigPath) } - // Walk up from directory to $HOME for ancestor configs (closest first) - // This subsumes the previous single project-config load: the closest hit is - // the project's own .opencode/oh-my-openagent.json[c], and walking continues - // up so configs in ~/work/ (etc.) apply to all subprojects. - const ancestorConfigPaths = findProjectOpencodePluginConfigFiles( + // Pin the walk to $HOME only when the start directory is inside it. Outside + // $HOME the walker would otherwise reach FS root and surface unrelated configs + // in /tmp, /opt, etc. + const homeDirectory = resolveHomeDirectory() + const stopDirectory = containsPath(homeDirectory, directory) ? homeDirectory : directory + const ancestorConfigPathsNearestFirst = findProjectOpencodePluginConfigFiles( directory, - resolveHomeDirectory(), + stopDirectory, ) log("Walked ancestor plugin configs", { - paths: ancestorConfigPaths, - count: ancestorConfigPaths.length, + paths: ancestorConfigPathsNearestFirst, + count: ancestorConfigPathsNearestFirst.length, + stopDirectory, }) // Migrate any legacy basenames among ancestors and warn on dual-config presence - const canonicalAncestorPaths = ancestorConfigPaths.map((ancestorPath) => { - const opencodeDir = path.dirname(ancestorPath) - const ancestorDetected = detectPluginConfigFile(opencodeDir) - if (ancestorDetected.legacyPath) { - log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { - canonicalPath: ancestorDetected.path, - legacyPath: ancestorDetected.legacyPath, - }) - } - return migrateLegacyAndResolveCanonicalPath(ancestorPath) - }) + const canonicalAncestorPathsNearestFirst = ancestorConfigPathsNearestFirst.map( + (ancestorPath) => { + const opencodeDir = path.dirname(ancestorPath) + const ancestorDetected = detectPluginConfigFile(opencodeDir) + if (ancestorDetected.legacyPath) { + log("Canonical plugin config detected alongside legacy config. Remove the legacy file to avoid confusion.", { + canonicalPath: ancestorDetected.path, + legacyPath: ancestorDetected.legacyPath, + }) + } + return resolveConfigPathAfterLegacyMigration(ancestorPath) + }, + ) // Load user config first (base). Parse empty config through Zod to apply field defaults. const userConfig = loadConfigFromPath(userConfigPath, ctx) @@ -289,12 +294,11 @@ export function loadPluginConfig( let config: OhMyOpenCodeConfig = userConfig ?? OhMyOpenCodeConfigSchema.parse({}); - // Merge ancestor configs from farthest to nearest, so closer overrides farther. - // Walker returns nearest-first; reverse for merge order. + const canonicalAncestorPathsFarthestFirst = [...canonicalAncestorPathsNearestFirst].reverse() const defaultGitMaster = OhMyOpenCodeConfigSchema.parse({}).git_master - const ancestorGitMasterOverrides: Array> = [] + const ancestorGitMasterOverridesFarthestFirst: Array> = [] - for (const ancestorPath of canonicalAncestorPaths.slice().reverse()) { + for (const ancestorPath of canonicalAncestorPathsFarthestFirst) { const ancestorConfig = loadConfigFromPath(ancestorPath, ctx) const ancestorOverrides = loadExplicitGitMasterOverrides(ancestorPath) @@ -314,19 +318,21 @@ export function loadPluginConfig( } if (ancestorOverrides) { - ancestorGitMasterOverrides.push(ancestorOverrides) + ancestorGitMasterOverridesFarthestFirst.push(ancestorOverrides) } } - if (userGitMasterOverrides || ancestorGitMasterOverrides.length > 0) { + if (userGitMasterOverrides || ancestorGitMasterOverridesFarthestFirst.length > 0) { + const mergedAncestorGitMaster: Record = {} + for (const override of ancestorGitMasterOverridesFarthestFirst) { + Object.assign(mergedAncestorGitMaster, override) + } config = { ...config, git_master: { ...defaultGitMaster, ...(userGitMasterOverrides ?? {}), - // Ancestors are pushed far-to-near; Object.assign with an empty seed - // applies each in order so the nearest (last) wins. - ...Object.assign({}, ...ancestorGitMasterOverrides), + ...mergedAncestorGitMaster, }, } } From 13c70cff121865fcc22cd26e2e13fb623273bce8 Mon Sep 17 00:00:00 2001 From: Matan Kushner Date: Mon, 4 May 2026 19:41:12 +0900 Subject: [PATCH 4/4] docs(config): document hierarchical config discovery Update the README quick-overview bullet and the dedicated File Locations section in docs/reference/configuration.md to describe the walk-up behaviour added in #417: configs under `.opencode/` are discovered by walking from the working directory up to $HOME, with closer configs winning. Includes a hierarchical example (`~/.config/opencode/` global, `~/work/.opencode/` work overrides, repo-specific overrides under that) and a security note explaining why `mcp_env_allowlist` remains extensible only from the canonical user config. --- README.md | 2 +- docs/reference/configuration.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ce12f818b..c3fbc3441 100644 --- a/README.md +++ b/README.md @@ -336,7 +336,7 @@ Opinionated defaults, adjustable if you insist. See [Configuration Documentation](docs/reference/configuration.md). **Quick Overview:** -- **Config Locations**: The compatibility layer recognizes both `oh-my-openagent.json[c]` and legacy `oh-my-opencode.json[c]` plugin config files. Existing installs still commonly use the legacy basename. +- **Config Locations**: User config plus walked `.opencode/oh-my-openagent.json[c]` configs up to `$HOME`; closest wins. Legacy `oh-my-opencode.json[c]` still works. - **JSONC Support**: Comments and trailing commas supported - **Agents**: Override models, temperatures, prompts, and permissions for any agent - **Built-in Skills**: `playwright` (browser automation), `git-master` (atomic commits) diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index cfd5e1b18..7a6004a49 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -43,9 +43,9 @@ Complete reference for Oh My OpenCode plugin configuration. During the rename tr ### File Locations -User config is loaded first, then project config overrides it. In each directory, the compatibility layer recognizes both the renamed and legacy basenames. +User config loads first. Project configs are discovered by walking from the working directory up to `$HOME`; closer configs win. If the working directory is outside `$HOME`, only that directory is checked. -1. Project config: `.opencode/oh-my-openagent.json[c]` or `.opencode/oh-my-opencode.json[c]` +1. Walked configs: `.opencode/oh-my-openagent.json[c]` or legacy `.opencode/oh-my-opencode.json[c]` 2. User config (`.jsonc` preferred over `.json`): | Platform | Path candidates | @@ -53,6 +53,8 @@ User config is loaded first, then project config overrides it. In each directory | macOS/Linux | `~/.config/opencode/oh-my-openagent.json[c]`, `~/.config/opencode/oh-my-opencode.json[c]` | | Windows | `%APPDATA%\opencode\oh-my-openagent.json[c]`, `%APPDATA%\opencode\oh-my-opencode.json[c]` | +**Security note:** `mcp_env_allowlist` is user-only. Walked configs cannot extend it. + **Rename compatibility:** The published package and CLI binary remain `oh-my-opencode`. OpenCode plugin registration prefers `oh-my-openagent`, while legacy `oh-my-opencode` entries and config basenames still load during the transition. Config detection checks `oh-my-opencode` before `oh-my-openagent`, so if both plugin config basenames exist in the same directory, the legacy `oh-my-opencode.*` file currently wins. JSONC supports `// line comments`, `/* block comments */`, and trailing commas.