Skip to content
Open
85 changes: 84 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,58 @@ export namespace Config {
return {}
}

// Custom merge function that concatenates array fields instead of replacing them
/**
* Load managed-settings.json and managed-settings.d/*.json drop-in fragments.
* Following the systemd convention: managed-settings.json is the base, then
* all *.json files in managed-settings.d/ are sorted alphabetically and merged on top.
* Later files override earlier ones for scalar values; arrays are concatenated and
* de-duplicated; objects are deep-merged. Hidden files starting with . are ignored.
*/
async function loadManagedSettings(dir: string): Promise<Info> {
const baseFile = path.join(dir, "managed-settings.json")
const dropinDir = path.join(dir, "managed-settings.d")

let result: Info = {}

try {
const text = await fsNode.readFile(baseFile, "utf-8")
result = parseConfig(text, baseFile)
log.info("loaded managed settings", { path: baseFile })
} catch (err: any) {
if (err.code !== "ENOENT") {
log.warn("failed to parse managed-settings.json", { path: baseFile, error: String(err) })
}
}

let entries: string[] = []
try {
entries = await fsNode.readdir(dropinDir)
} catch (err: any) {
if (err.code !== "ENOENT" && err.code !== "ENOTDIR") {
log.warn("failed to read managed-settings.d", { path: dropinDir, error: String(err) })
}
return result
}

const fragments = entries.filter((name) => name.endsWith(".json") && !name.startsWith(".")).sort()

for (const name of fragments) {
const fragPath = path.join(dropinDir, name)
try {
const text = await fsNode.readFile(fragPath, "utf-8")
const parsed = parseConfig(text, fragPath)
result = mergeConfigConcatArrays(result, parsed)
log.info("loaded managed settings fragment", { path: fragPath })
} catch (err: any) {
if (err.code !== "ENOENT") {
log.warn("failed to parse managed settings fragment", { path: fragPath, error: String(err) })
}
}
}

return result
}

function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.instructions && source.instructions) {
Expand All @@ -136,6 +187,29 @@ export namespace Config {
return merged
}

export const REPLACE_KEYS = [
"agent",
"command",
"experimental",
"formatter",
"instructions",
"lsp",
"mcp",
"mode",
"provider",
"skills",
] as const

function applyAuthoritative(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
for (const key of REPLACE_KEYS) {
if (key in source) {
;(merged as Record<string, unknown>)[key] = (source as Record<string, unknown>)[key]
}
}
return merged
}

export type InstallInput = {
waitTick?: (input: { dir: string; attempt: number; delay: number; waited: number }) => void | Promise<void>
}
Expand Down Expand Up @@ -1541,6 +1615,15 @@ export namespace Config {
result.permission = mergeDeep(perms, result.permission ?? {})
}

// managed-settings.json is the authoritative source — applied after all user,
// project, env, and legacy processing so it cannot be overridden by any of them.
// Only macOS MDM preferences (above) take higher priority.
const managedSettings = yield* Effect.promise(() => loadManagedSettings(managedDir))
if (Object.keys(managedSettings).length > 0) {
result = applyAuthoritative(result, managedSettings)
log.info("applied managed settings (authoritative override)", { source: managedDir })
}

if (!result.username) result.username = os.userInfo().username

if (result.autoshare === true && !result.share) {
Expand Down
255 changes: 254 additions & 1 deletion packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ afterEach(async () => {
await clear(true)
})

async function writeManagedSettings(settings: object, filename = "opencode.json") {
async function writeManagedSettings(settings: object, filename = "managed-settings.json") {
await fs.mkdir(managedConfigDir, { recursive: true })
await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings))
}
Expand Down Expand Up @@ -1424,6 +1424,259 @@ test("managed settings override project settings", async () => {
})
})

// Demonstrates the gap using pure mergeDeep logic — no caching involved.
// In the unpatched code, OPENCODE_PERMISSION is applied as:
// result.permission = mergeDeep(result.permission, JSON.parse(OPENCODE_PERMISSION))
// Since envvar is the second argument to mergeDeep, it overwrites the managed deny.
test("mergeDeep semantics allow OPENCODE_PERMISSION to overwrite managed deny", async () => {
const { mergeDeep } = await import("remeda")

const managed = { bash: { "echo *": "deny" } }
const envvar = { bash: { "echo *": "allow" } }

// This is exactly what line 1501 of config.ts does
const result = mergeDeep(managed, envvar) as Record<string, Record<string, string>>

// On unpatched code this is "allow" — the managed deny is lost
expect(result.bash["echo *"]).toBe("allow")
})

test("managed instructions should replace user instructions, not union", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
instructions: ["/etc/opencode/managed-instructions.md"],
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
instructions: ["./user-instructions.md"],
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.instructions).toEqual(["/etc/opencode/managed-instructions.md"])
},
})
})

test("managed agents should replace user agents, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
agent: {
build: { permission: { bash: { "echo *": "deny" } } },
},
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
agent: {
escape: { permission: { bash: "allow" } },
},
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.agent?.escape).toBeUndefined()
},
})
})

test("managed mcp should replace user mcp, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
mcp: {
safe: { type: "local", command: ["safe"] },
},
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
mcp: {
evil: { type: "local", command: ["evil"] },
},
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.mcp?.evil).toBeUndefined()
},
})
})

test("managed commands should replace user commands, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
command: {
safe: { template: "do something safe" },
},
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
command: {
evil: { template: "do something bad" },
},
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.command?.evil).toBeUndefined()
},
})
})

test("managed providers should replace user providers, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
provider: {
"google-vertex": { models: { "gemini-2.5-pro": {} } },
},
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: { models: {} },
},
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.provider?.anthropic).toBeUndefined()
},
})
})

test("managed formatters should replace user formatters, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
formatter: { safe: { command: ["safe-fmt"] } },
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
formatter: { evil: { command: ["evil-fmt"] } },
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const fmts = config.formatter as Record<string, unknown>
expect(fmts.evil).toBeUndefined()
},
})
})

test("managed lsp should replace user lsp, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
lsp: { safe: { command: ["safe-lsp"], extensions: [".ts"] } },
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
lsp: { evil: { command: ["evil-lsp"], extensions: [".txt"] } },
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
const lsps = config.lsp as Record<string, unknown>
expect(lsps.evil).toBeUndefined()
},
})
})

test("managed experimental should replace user experimental, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
experimental: { disable_paste_summary: true },
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
experimental: { batch_tool: true, openTelemetry: true },
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.experimental?.batch_tool).toBeUndefined()
expect(config.experimental?.openTelemetry).toBeUndefined()
},
})
})

test("managed skills should replace user skills, not additively merge", async () => {
await writeManagedSettings({
$schema: "https://opencode.ai/config.json",
skills: { paths: ["/etc/opencode/skills/"] },
})

await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
skills: { paths: ["./user-skills/"], urls: ["https://evil.com/skills/"] },
})
},
})

await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.skills?.paths).toEqual(["/etc/opencode/skills/"])
expect(config.skills?.urls).toBeUndefined()
},
})
})

test("missing managed settings file is not an error", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
Expand Down
Loading
Loading