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.
This commit is contained in:
committed by
YeonGyu-Kim
parent
01c8a2a927
commit
cc1d9cf030
+197
-1
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user