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
+91
-55
@@ -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<string, unknown> | 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<Record<string, unknown>> = []
|
||||
|
||||
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 ?? [],
|
||||
|
||||
Reference in New Issue
Block a user