Skip to content

Commit 4ba0b22

Browse files
authored
fix: config precedence now correctly allows local config to override remote (#7141)
1 parent 662d2b2 commit 4ba0b22

File tree

4 files changed

+344
-17
lines changed

4 files changed

+344
-17
lines changed

packages/opencode/src/config/config.ts

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -37,34 +37,53 @@ export namespace Config {
3737

3838
export const state = Instance.state(async () => {
3939
const auth = await Auth.all()
40-
let result = await global()
4140

42-
// Override with custom config if provided
41+
// Load remote/well-known config first as the base layer (lowest precedence)
42+
// This allows organizations to provide default configs that users can override
43+
let result: Info = {}
44+
for (const [key, value] of Object.entries(auth)) {
45+
if (value.type === "wellknown") {
46+
process.env[value.key] = value.token
47+
log.debug("fetching remote config", { url: `${key}/.well-known/opencode` })
48+
const response = await fetch(`${key}/.well-known/opencode`)
49+
if (!response.ok) {
50+
throw new Error(`failed to fetch remote config from ${key}: ${response.status}`)
51+
}
52+
const wellknown = (await response.json()) as any
53+
const remoteConfig = wellknown.config ?? {}
54+
// Add $schema to prevent load() from trying to write back to a non-existent file
55+
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
56+
result = mergeConfigConcatArrays(
57+
result,
58+
await load(JSON.stringify(remoteConfig), `${key}/.well-known/opencode`),
59+
)
60+
log.debug("loaded remote config from well-known", { url: key })
61+
}
62+
}
63+
64+
// Global user config overrides remote config
65+
result = mergeConfigConcatArrays(result, await global())
66+
67+
// Custom config path overrides global
4368
if (Flag.OPENCODE_CONFIG) {
4469
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
4570
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
4671
}
4772

73+
// Project config has highest precedence (overrides global and remote)
4874
for (const file of ["opencode.jsonc", "opencode.json"]) {
4975
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
5076
for (const resolved of found.toReversed()) {
5177
result = mergeConfigConcatArrays(result, await loadFile(resolved))
5278
}
5379
}
5480

81+
// Inline config content has highest precedence
5582
if (Flag.OPENCODE_CONFIG_CONTENT) {
5683
result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
5784
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
5885
}
5986

60-
for (const [key, value] of Object.entries(auth)) {
61-
if (value.type === "wellknown") {
62-
process.env[value.key] = value.token
63-
const wellknown = (await fetch(`${key}/.well-known/opencode`).then((x) => x.json())) as any
64-
result = mergeConfigConcatArrays(result, await load(JSON.stringify(wellknown.config ?? {}), process.cwd()))
65-
}
66-
}
67-
6887
result.agent = result.agent || {}
6988
result.mode = result.mode || {}
7089
result.plugin = result.plugin || []

packages/opencode/test/config/config.test.ts

Lines changed: 233 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { test, expect } from "bun:test"
1+
import { test, expect, mock, afterEach } from "bun:test"
22
import { Config } from "../../src/config/config"
33
import { Instance } from "../../src/project/instance"
4+
import { Auth } from "../../src/auth"
45
import { tmpdir } from "../fixture/fixture"
56
import path from "path"
67
import fs from "fs/promises"
@@ -913,3 +914,234 @@ test("permission config preserves key order", async () => {
913914
},
914915
})
915916
})
917+
918+
// MCP config merging tests
919+
920+
test("project config can override MCP server enabled status", async () => {
921+
await using tmp = await tmpdir({
922+
init: async (dir) => {
923+
// Simulates a base config (like from remote .well-known) with disabled MCP
924+
await Bun.write(
925+
path.join(dir, "opencode.jsonc"),
926+
JSON.stringify({
927+
$schema: "https://opencode.ai/config.json",
928+
mcp: {
929+
jira: {
930+
type: "remote",
931+
url: "https://jira.example.com/mcp",
932+
enabled: false,
933+
},
934+
wiki: {
935+
type: "remote",
936+
url: "https://wiki.example.com/mcp",
937+
enabled: false,
938+
},
939+
},
940+
}),
941+
)
942+
// Project config enables just jira
943+
await Bun.write(
944+
path.join(dir, "opencode.json"),
945+
JSON.stringify({
946+
$schema: "https://opencode.ai/config.json",
947+
mcp: {
948+
jira: {
949+
type: "remote",
950+
url: "https://jira.example.com/mcp",
951+
enabled: true,
952+
},
953+
},
954+
}),
955+
)
956+
},
957+
})
958+
await Instance.provide({
959+
directory: tmp.path,
960+
fn: async () => {
961+
const config = await Config.get()
962+
// jira should be enabled (overridden by project config)
963+
expect(config.mcp?.jira).toEqual({
964+
type: "remote",
965+
url: "https://jira.example.com/mcp",
966+
enabled: true,
967+
})
968+
// wiki should still be disabled (not overridden)
969+
expect(config.mcp?.wiki).toEqual({
970+
type: "remote",
971+
url: "https://wiki.example.com/mcp",
972+
enabled: false,
973+
})
974+
},
975+
})
976+
})
977+
978+
test("MCP config deep merges preserving base config properties", async () => {
979+
await using tmp = await tmpdir({
980+
init: async (dir) => {
981+
// Base config with full MCP definition
982+
await Bun.write(
983+
path.join(dir, "opencode.jsonc"),
984+
JSON.stringify({
985+
$schema: "https://opencode.ai/config.json",
986+
mcp: {
987+
myserver: {
988+
type: "remote",
989+
url: "https://myserver.example.com/mcp",
990+
enabled: false,
991+
headers: {
992+
"X-Custom-Header": "value",
993+
},
994+
},
995+
},
996+
}),
997+
)
998+
// Override just enables it, should preserve other properties
999+
await Bun.write(
1000+
path.join(dir, "opencode.json"),
1001+
JSON.stringify({
1002+
$schema: "https://opencode.ai/config.json",
1003+
mcp: {
1004+
myserver: {
1005+
type: "remote",
1006+
url: "https://myserver.example.com/mcp",
1007+
enabled: true,
1008+
},
1009+
},
1010+
}),
1011+
)
1012+
},
1013+
})
1014+
await Instance.provide({
1015+
directory: tmp.path,
1016+
fn: async () => {
1017+
const config = await Config.get()
1018+
expect(config.mcp?.myserver).toEqual({
1019+
type: "remote",
1020+
url: "https://myserver.example.com/mcp",
1021+
enabled: true,
1022+
headers: {
1023+
"X-Custom-Header": "value",
1024+
},
1025+
})
1026+
},
1027+
})
1028+
})
1029+
1030+
test("local .opencode config can override MCP from project config", async () => {
1031+
await using tmp = await tmpdir({
1032+
init: async (dir) => {
1033+
// Project config with disabled MCP
1034+
await Bun.write(
1035+
path.join(dir, "opencode.json"),
1036+
JSON.stringify({
1037+
$schema: "https://opencode.ai/config.json",
1038+
mcp: {
1039+
docs: {
1040+
type: "remote",
1041+
url: "https://docs.example.com/mcp",
1042+
enabled: false,
1043+
},
1044+
},
1045+
}),
1046+
)
1047+
// Local .opencode directory config enables it
1048+
const opencodeDir = path.join(dir, ".opencode")
1049+
await fs.mkdir(opencodeDir, { recursive: true })
1050+
await Bun.write(
1051+
path.join(opencodeDir, "opencode.json"),
1052+
JSON.stringify({
1053+
$schema: "https://opencode.ai/config.json",
1054+
mcp: {
1055+
docs: {
1056+
type: "remote",
1057+
url: "https://docs.example.com/mcp",
1058+
enabled: true,
1059+
},
1060+
},
1061+
}),
1062+
)
1063+
},
1064+
})
1065+
await Instance.provide({
1066+
directory: tmp.path,
1067+
fn: async () => {
1068+
const config = await Config.get()
1069+
expect(config.mcp?.docs?.enabled).toBe(true)
1070+
},
1071+
})
1072+
})
1073+
1074+
test("project config overrides remote well-known config", async () => {
1075+
const originalFetch = globalThis.fetch
1076+
let fetchedUrl: string | undefined
1077+
const mockFetch = mock((url: string | URL | Request) => {
1078+
const urlStr = url.toString()
1079+
if (urlStr.includes(".well-known/opencode")) {
1080+
fetchedUrl = urlStr
1081+
return Promise.resolve(
1082+
new Response(
1083+
JSON.stringify({
1084+
config: {
1085+
mcp: {
1086+
jira: {
1087+
type: "remote",
1088+
url: "https://jira.example.com/mcp",
1089+
enabled: false,
1090+
},
1091+
},
1092+
},
1093+
}),
1094+
{ status: 200 },
1095+
),
1096+
)
1097+
}
1098+
return originalFetch(url)
1099+
})
1100+
globalThis.fetch = mockFetch as unknown as typeof fetch
1101+
1102+
const originalAuthAll = Auth.all
1103+
Auth.all = mock(() =>
1104+
Promise.resolve({
1105+
"https://example.com": {
1106+
type: "wellknown" as const,
1107+
key: "TEST_TOKEN",
1108+
token: "test-token",
1109+
},
1110+
}),
1111+
)
1112+
1113+
try {
1114+
await using tmp = await tmpdir({
1115+
git: true,
1116+
init: async (dir) => {
1117+
// Project config enables jira (overriding remote default)
1118+
await Bun.write(
1119+
path.join(dir, "opencode.json"),
1120+
JSON.stringify({
1121+
$schema: "https://opencode.ai/config.json",
1122+
mcp: {
1123+
jira: {
1124+
type: "remote",
1125+
url: "https://jira.example.com/mcp",
1126+
enabled: true,
1127+
},
1128+
},
1129+
}),
1130+
)
1131+
},
1132+
})
1133+
await Instance.provide({
1134+
directory: tmp.path,
1135+
fn: async () => {
1136+
const config = await Config.get()
1137+
// Verify fetch was called for wellknown config
1138+
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
1139+
// Project config (enabled: true) should override remote (enabled: false)
1140+
expect(config.mcp?.jira?.enabled).toBe(true)
1141+
},
1142+
})
1143+
} finally {
1144+
globalThis.fetch = originalFetch
1145+
Auth.all = originalAuthAll
1146+
}
1147+
})

0 commit comments

Comments
 (0)