diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index adf733e322..2f1cba8a05 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -32,6 +32,21 @@ import { Event } from "../server/event" export namespace Config { const log = Log.create({ service: "config" }) + // Managed settings directory for enterprise deployments (highest priority, admin-controlled) + // These settings override all user and project settings + function getManagedConfigDir(): string { + switch (process.platform) { + case "darwin": + return "/Library/Application Support/opencode" + case "win32": + return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode") + default: + return "/etc/opencode" + } + } + + const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir() + // Custom merge function that concatenates array fields instead of replacing them function mergeConfigConcatArrays(target: Info, source: Info): Info { const merged = mergeDeep(target, source) @@ -148,8 +163,18 @@ export namespace Config { result.plugin.push(...(await loadPlugin(dir))) } + // Load managed config files last (highest priority) - enterprise admin-controlled + // Kept separate from directories array to avoid write operations when installing plugins + // which would fail on system directories requiring elevated permissions + // This way it only loads config file and not skills/plugins/commands + if (existsSync(managedConfigDir)) { + for (const file of ["opencode.jsonc", "opencode.json"]) { + result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file))) + } + } + // Migrate deprecated mode field to agent field - for (const [name, mode] of Object.entries(result.mode)) { + for (const [name, mode] of Object.entries(result.mode ?? {})) { result.agent = mergeDeep(result.agent ?? {}, { [name]: { ...mode, diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index decd18446c..1752e22e01 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,4 +1,4 @@ -import { test, expect, describe, mock } from "bun:test" +import { test, expect, describe, mock, afterEach } from "bun:test" import { Config } from "../../src/config/config" import { Instance } from "../../src/project/instance" import { Auth } from "../../src/auth" @@ -6,6 +6,23 @@ import { tmpdir } from "../fixture/fixture" import path from "path" import fs from "fs/promises" import { pathToFileURL } from "url" +import { Global } from "../../src/global" + +// Get managed config directory from environment (set in preload.ts) +const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! + +afterEach(async () => { + await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) +}) + +async function writeManagedSettings(settings: object, filename = "opencode.json") { + await fs.mkdir(managedConfigDir, { recursive: true }) + await Bun.write(path.join(managedConfigDir, filename), JSON.stringify(settings)) +} + +async function writeConfig(dir: string, config: object, name = "opencode.json") { + await Bun.write(path.join(dir, name), JSON.stringify(config)) +} test("loads config with defaults when no files exist", async () => { await using tmp = await tmpdir() @@ -21,14 +38,11 @@ test("loads config with defaults when no files exist", async () => { test("loads JSON config file", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - model: "test/model", - username: "testuser", - }), - ) + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "test/model", + username: "testuser", + }) }, }) await Instance.provide({ @@ -68,21 +82,19 @@ test("loads JSONC config file", async () => { test("merges multiple config files with correct precedence", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.jsonc"), - JSON.stringify({ + await writeConfig( + dir, + { $schema: "https://opencode.ai/config.json", model: "base", username: "base", - }), - ) - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - model: "override", - }), + }, + "opencode.jsonc", ) + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "override", + }) }, }) await Instance.provide({ @@ -102,13 +114,10 @@ test("handles environment variable substitution", async () => { try { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - theme: "{env:TEST_VAR}", - }), - ) + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + theme: "{env:TEST_VAR}", + }) }, }) await Instance.provide({ @@ -169,13 +178,10 @@ test("handles file inclusion substitution", async () => { await using tmp = await tmpdir({ init: async (dir) => { await Bun.write(path.join(dir, "included.txt"), "test_theme") - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - theme: "{file:included.txt}", - }), - ) + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + theme: "{file:included.txt}", + }) }, }) await Instance.provide({ @@ -190,13 +196,10 @@ test("handles file inclusion substitution", async () => { test("validates config schema and throws on invalid fields", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - invalid_field: "should cause error", - }), - ) + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + invalid_field: "should cause error", + }) }, }) await Instance.provide({ @@ -225,19 +228,16 @@ test("throws error for invalid JSON", async () => { test("handles agent configuration", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - agent: { - test_agent: { - model: "test/model", - temperature: 0.7, - description: "test agent", - }, + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { + model: "test/model", + temperature: 0.7, + description: "test agent", }, - }), - ) + }, + }) }, }) await Instance.provide({ @@ -258,19 +258,16 @@ test("handles agent configuration", async () => { test("handles command configuration", async () => { await using tmp = await tmpdir({ init: async (dir) => { - await Bun.write( - path.join(dir, "opencode.json"), - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - command: { - test_command: { - template: "test template", - description: "test command", - agent: "test_agent", - }, + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + command: { + test_command: { + template: "test template", + description: "test command", + agent: "test_agent", }, - }), - ) + }, + }) }, }) await Instance.provide({ @@ -894,6 +891,86 @@ test("migrates legacy write tool to edit permission", async () => { }) }) +// Managed settings tests +// Note: preload.ts sets OPENCODE_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses + +test("managed settings override user settings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "user/model", + share: "auto", + username: "testuser", + }) + }, + }) + + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + model: "managed/model", + share: "disabled", + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("managed/model") + expect(config.share).toBe("disabled") + expect(config.username).toBe("testuser") + }, + }) +}) + +test("managed settings override project settings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + autoupdate: true, + disabled_providers: [], + theme: "dark", + }) + }, + }) + + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + disabled_providers: ["openai"], + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.autoupdate).toBe(false) + expect(config.disabled_providers).toEqual(["openai"]) + expect(config.theme).toBe("dark") + }, + }) +}) + +test("missing managed settings file is not an error", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "user/model", + }) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.model).toBe("user/model") + }, + }) +}) + test("migrates legacy edit tool to edit permission", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 1cb7778623..c1b03ea821 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -17,6 +17,10 @@ const testHome = path.join(dir, "home") await fs.mkdir(testHome, { recursive: true }) process.env["OPENCODE_TEST_HOME"] = testHome +// Set test managed config directory to isolate tests from system managed settings +const testManagedConfigDir = path.join(dir, "managed") +process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir + process.env["XDG_DATA_HOME"] = path.join(dir, "share") process.env["XDG_CACHE_HOME"] = path.join(dir, "cache") process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")