summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorMikhail Levchenko <[email protected]>2026-01-29 23:56:25 +0100
committerGitHub <[email protected]>2026-01-29 22:56:25 +0000
commitb5ffa997dad94f87e68d557631de174ea09f6b77 (patch)
treed001c0c335605effe9121ebb23405861702a8273
parent75166a1961f1963927b838c729bcd17f8acd0858 (diff)
downloadopencode-b5ffa997dad94f87e68d557631de174ea09f6b77.tar.gz
opencode-b5ffa997dad94f87e68d557631de174ea09f6b77.zip
feat(config): add managed settings support for enterprise deployments (#6441)
Co-authored-by: Dax <[email protected]>
-rw-r--r--packages/opencode/src/config/config.ts27
-rw-r--r--packages/opencode/test/config/config.test.ts207
-rw-r--r--packages/opencode/test/preload.ts4
3 files changed, 172 insertions, 66 deletions
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index adf733e32..2f1cba8a0 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 decd18446..1752e22e0 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 1cb777862..c1b03ea82 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")