Просмотр исходного кода

fix: config precedence now correctly allows local config to override remote (#7141)

Matt Silverlock 1 месяц назад
Родитель
Сommit
4ba0b22b04

+ 29 - 10
packages/opencode/src/config/config.ts

@@ -37,14 +37,40 @@ export namespace Config {
 
   export const state = Instance.state(async () => {
     const auth = await Auth.all()
-    let result = await global()
 
-    // Override with custom config if provided
+    // Load remote/well-known config first as the base layer (lowest precedence)
+    // This allows organizations to provide default configs that users can override
+    let result: Info = {}
+    for (const [key, value] of Object.entries(auth)) {
+      if (value.type === "wellknown") {
+        process.env[value.key] = value.token
+        log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
+        const response = await fetch(`${key}/.well-known/opencode`)
+        if (!response.ok) {
+          throw new Error(`failed to fetch remote config from ${key}: ${response.status}`)
+        }
+        const wellknown = (await response.json()) as any
+        const remoteConfig = wellknown.config ?? {}
+        // Add $schema to prevent load() from trying to write back to a non-existent file
+        if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
+        result = mergeConfigConcatArrays(
+          result,
+          await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
+        )
+        log.debug("loaded remote config from well-known", { url: key })
+      }
+    }
+
+    // Global user config overrides remote config
+    result = mergeConfigConcatArrays(result, await global())
+
+    // Custom config path overrides global
     if (Flag.OPENCODE_CONFIG) {
       result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
       log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
     }
 
+    // Project config has highest precedence (overrides global and remote)
     for (const file of ["opencode.jsonc", "opencode.json"]) {
       const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
       for (const resolved of found.toReversed()) {
@@ -52,19 +78,12 @@ export namespace Config {
       }
     }
 
+    // Inline config content has highest precedence
     if (Flag.OPENCODE_CONFIG_CONTENT) {
       result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
       log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
     }
 
-    for (const [key, value] of Object.entries(auth)) {
-      if (value.type === "wellknown") {
-        process.env[value.key] = value.token
-        const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
-        result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
-      }
-    }
-
     result.agent = result.agent || {}
     result.mode = result.mode || {}
     result.plugin = result.plugin || []

+ 233 - 1
packages/opencode/test/config/config.test.ts

@@ -1,6 +1,7 @@
-import { test, expect } from "bun:test"
+import { test, expect, mock, afterEach } from "bun:test"
 import { Config } from "../../src/config/config"
 import { Instance } from "../../src/project/instance"
+import { Auth } from "../../src/auth"
 import { tmpdir } from "../fixture/fixture"
 import path from "path"
 import fs from "fs/promises"
@@ -913,3 +914,234 @@ test("permission config preserves key order", async () => {
     },
   })
 })
+
+// MCP config merging tests
+
+test("project config can override MCP server enabled status", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      // Simulates a base config (like from remote .well-known) with disabled MCP
+      await Bun.write(
+        path.join(dir, "opencode.jsonc"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          mcp: {
+            jira: {
+              type: "remote",
+              url: "https://jira.example.com/mcp",
+              enabled: false,
+            },
+            wiki: {
+              type: "remote",
+              url: "https://wiki.example.com/mcp",
+              enabled: false,
+            },
+          },
+        }),
+      )
+      // Project config enables just jira
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          mcp: {
+            jira: {
+              type: "remote",
+              url: "https://jira.example.com/mcp",
+              enabled: true,
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      // jira should be enabled (overridden by project config)
+      expect(config.mcp?.jira).toEqual({
+        type: "remote",
+        url: "https://jira.example.com/mcp",
+        enabled: true,
+      })
+      // wiki should still be disabled (not overridden)
+      expect(config.mcp?.wiki).toEqual({
+        type: "remote",
+        url: "https://wiki.example.com/mcp",
+        enabled: false,
+      })
+    },
+  })
+})
+
+test("MCP config deep merges preserving base config properties", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      // Base config with full MCP definition
+      await Bun.write(
+        path.join(dir, "opencode.jsonc"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          mcp: {
+            myserver: {
+              type: "remote",
+              url: "https://myserver.example.com/mcp",
+              enabled: false,
+              headers: {
+                "X-Custom-Header": "value",
+              },
+            },
+          },
+        }),
+      )
+      // Override just enables it, should preserve other properties
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          mcp: {
+            myserver: {
+              type: "remote",
+              url: "https://myserver.example.com/mcp",
+              enabled: true,
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.mcp?.myserver).toEqual({
+        type: "remote",
+        url: "https://myserver.example.com/mcp",
+        enabled: true,
+        headers: {
+          "X-Custom-Header": "value",
+        },
+      })
+    },
+  })
+})
+
+test("local .opencode config can override MCP from project config", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      // Project config with disabled MCP
+      await Bun.write(
+        path.join(dir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          mcp: {
+            docs: {
+              type: "remote",
+              url: "https://docs.example.com/mcp",
+              enabled: false,
+            },
+          },
+        }),
+      )
+      // Local .opencode directory config enables it
+      const opencodeDir = path.join(dir, ".opencode")
+      await fs.mkdir(opencodeDir, { recursive: true })
+      await Bun.write(
+        path.join(opencodeDir, "opencode.json"),
+        JSON.stringify({
+          $schema: "https://opencode.ai/config.json",
+          mcp: {
+            docs: {
+              type: "remote",
+              url: "https://docs.example.com/mcp",
+              enabled: true,
+            },
+          },
+        }),
+      )
+    },
+  })
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      expect(config.mcp?.docs?.enabled).toBe(true)
+    },
+  })
+})
+
+test("project config overrides remote well-known config", async () => {
+  const originalFetch = globalThis.fetch
+  let fetchedUrl: string | undefined
+  const mockFetch = mock((url: string | URL | Request) => {
+    const urlStr = url.toString()
+    if (urlStr.includes(".well-known/opencode")) {
+      fetchedUrl = urlStr
+      return Promise.resolve(
+        new Response(
+          JSON.stringify({
+            config: {
+              mcp: {
+                jira: {
+                  type: "remote",
+                  url: "https://jira.example.com/mcp",
+                  enabled: false,
+                },
+              },
+            },
+          }),
+          { status: 200 },
+        ),
+      )
+    }
+    return originalFetch(url)
+  })
+  globalThis.fetch = mockFetch as unknown as typeof fetch
+
+  const originalAuthAll = Auth.all
+  Auth.all = mock(() =>
+    Promise.resolve({
+      "https://example.com": {
+        type: "wellknown" as const,
+        key: "TEST_TOKEN",
+        token: "test-token",
+      },
+    }),
+  )
+
+  try {
+    await using tmp = await tmpdir({
+      git: true,
+      init: async (dir) => {
+        // Project config enables jira (overriding remote default)
+        await Bun.write(
+          path.join(dir, "opencode.json"),
+          JSON.stringify({
+            $schema: "https://opencode.ai/config.json",
+            mcp: {
+              jira: {
+                type: "remote",
+                url: "https://jira.example.com/mcp",
+                enabled: true,
+              },
+            },
+          }),
+        )
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const config = await Config.get()
+        // Verify fetch was called for wellknown config
+        expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
+        // Project config (enabled: true) should override remote (enabled: false)
+        expect(config.mcp?.jira?.enabled).toBe(true)
+      },
+    })
+  } finally {
+    globalThis.fetch = originalFetch
+    Auth.all = originalAuthAll
+  }
+})

+ 59 - 6
packages/web/src/content/docs/config.mdx

@@ -32,21 +32,74 @@ different order of precedence.
 Configuration files are **merged together**, not replaced.
 :::
 
-Configuration files are merged together, not replaced. Settings from the following config locations are combined. Where later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
+Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
 
 For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
 
 ---
 
+### Precedence order
+
+Config sources are loaded in this order (later sources override earlier ones):
+
+1. **Remote config** (from `.well-known/opencode`) - organizational defaults
+2. **Global config** (`~/.config/opencode/opencode.json`) - user preferences
+3. **Custom config** (`OPENCODE_CONFIG` env var) - custom overrides
+4. **Project config** (`opencode.json` in project) - project-specific settings
+5. **`.opencode` directories** - agents, commands, plugins
+6. **Inline config** (`OPENCODE_CONFIG_CONTENT` env var) - runtime overrides
+
+This means project configs can override global defaults, and global configs can override remote organizational defaults.
+
+---
+
+### Remote
+
+Organizations can provide default configuration via the `.well-known/opencode` endpoint. This is fetched automatically when you authenticate with a provider that supports it.
+
+Remote config is loaded first, serving as the base layer. All other config sources (global, project) can override these defaults.
+
+For example, if your organization provides MCP servers that are disabled by default:
+
+```json title="Remote config from .well-known/opencode"
+{
+  "mcp": {
+    "jira": {
+      "type": "remote",
+      "url": "https://jira.example.com/mcp",
+      "enabled": false
+    }
+  }
+}
+```
+
+You can enable specific servers in your local config:
+
+```json title="opencode.json"
+{
+  "mcp": {
+    "jira": {
+      "type": "remote",
+      "url": "https://jira.example.com/mcp",
+      "enabled": true
+    }
+  }
+}
+```
+
+---
+
 ### Global
 
-Place your global OpenCode config in `~/.config/opencode/opencode.json`. You'll want to use the global config for things like themes, providers, or keybinds.
+Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
+
+Global config overrides remote organizational defaults.
 
 ---
 
 ### Per project
 
-You can also add a `opencode.json` in your project. Settings from this config are merged with and can override the global config. This is useful for configuring providers or modes specific to your project.
+Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
 
 :::tip
 Place project specific config in the root of your project.
@@ -60,20 +113,20 @@ This is also safe to be checked into Git and uses the same schema as the global
 
 ### Custom path
 
-You can also specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
+Specify a custom config file path using the `OPENCODE_CONFIG` environment variable.
 
 ```bash
 export OPENCODE_CONFIG=/path/to/my/custom-config.json
 opencode run "Hello world"
 ```
 
-Settings from this config are merged with and **can override** the global and project configs.
+Custom config is loaded between global and project configs in the precedence order.
 
 ---
 
 ### Custom directory
 
-You can specify a custom config directory using the `OPENCODE_CONFIG_DIR`
+Specify a custom config directory using the `OPENCODE_CONFIG_DIR`
 environment variable. This directory will be searched for agents, commands,
 modes, and plugins just like the standard `.opencode` directory, and should
 follow the same structure.

+ 23 - 0
packages/web/src/content/docs/mcp-servers.mdx

@@ -44,6 +44,29 @@ You can also disable a server by setting `enabled` to `false`. This is useful if
 
 ---
 
+### Overriding remote defaults
+
+Organizations can provide default MCP servers via their `.well-known/opencode` endpoint. These servers may be disabled by default, allowing users to opt-in to the ones they need.
+
+To enable a specific server from your organization's remote config, add it to your local config with `enabled: true`:
+
+```json title="opencode.json"
+{
+  "$schema": "https://opencode.ai/config.json",
+  "mcp": {
+    "jira": {
+      "type": "remote",
+      "url": "https://jira.example.com/mcp",
+      "enabled": true
+    }
+  }
+}
+```
+
+Your local config values override the remote defaults. See [config precedence](/docs/config#precedence-order) for more details.
+
+---
+
 ## Local
 
 Add local MCP servers using `type` to `"local"` within the MCP object.