Skip to content

Commit b5ffa99

Browse files
Mishkunthdxr
andauthored
feat(config): add managed settings support for enterprise deployments (anomalyco#6441)
Co-authored-by: Dax <mail@thdxr.com>
1 parent 75166a1 commit b5ffa99

File tree

3 files changed

+172
-66
lines changed

3 files changed

+172
-66
lines changed

packages/opencode/src/config/config.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,21 @@ import { Event } from "../server/event"
3232
export namespace Config {
3333
const log = Log.create({ service: "config" })
3434

35+
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
36+
// These settings override all user and project settings
37+
function getManagedConfigDir(): string {
38+
switch (process.platform) {
39+
case "darwin":
40+
return "/Library/Application Support/opencode"
41+
case "win32":
42+
return path.join(process.env.ProgramData || "C:\\ProgramData", "opencode")
43+
default:
44+
return "/etc/opencode"
45+
}
46+
}
47+
48+
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
49+
3550
// Custom merge function that concatenates array fields instead of replacing them
3651
function mergeConfigConcatArrays(target: Info, source: Info): Info {
3752
const merged = mergeDeep(target, source)
@@ -148,8 +163,18 @@ export namespace Config {
148163
result.plugin.push(...(await loadPlugin(dir)))
149164
}
150165

166+
// Load managed config files last (highest priority) - enterprise admin-controlled
167+
// Kept separate from directories array to avoid write operations when installing plugins
168+
// which would fail on system directories requiring elevated permissions
169+
// This way it only loads config file and not skills/plugins/commands
170+
if (existsSync(managedConfigDir)) {
171+
for (const file of ["opencode.jsonc", "opencode.json"]) {
172+
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedConfigDir, file)))
173+
}
174+
}
175+
151176
// Migrate deprecated mode field to agent field
152-
for (const [name, mode] of Object.entries(result.mode)) {
177+
for (const [name, mode] of Object.entries(result.mode ?? {})) {
153178
result.agent = mergeDeep(result.agent ?? {}, {
154179
[name]: {
155180
...mode,

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

Lines changed: 142 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,28 @@
1-
import { test, expect, describe, mock } from "bun:test"
1+
import { test, expect, describe, mock, afterEach } from "bun:test"
22
import { Config } from "../../src/config/config"
33
import { Instance } from "../../src/project/instance"
44
import { Auth } from "../../src/auth"
55
import { tmpdir } from "../fixture/fixture"
66
import path from "path"
77
import fs from "fs/promises"
88
import { pathToFileURL } from "url"
9+
import { Global } from "../../src/global"
10+
11+
// Get managed config directory from environment (set in preload.ts)
12+
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
13+
14+
afterEach(async () => {
15+
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
16+
})
17+
18+
async function writeManagedSettings(settings: object, filename = "opencode.json") {
19+
await fs.mkdir(managedConfigDir, { recursive: true })
20+
await Bun.write(path.join(managedConfigDir, filename), JSON.stringify(settings))
21+
}
22+
23+
async function writeConfig(dir: string, config: object, name = "opencode.json") {
24+
await Bun.write(path.join(dir, name), JSON.stringify(config))
25+
}
926

1027
test("loads config with defaults when no files exist", async () => {
1128
await using tmp = await tmpdir()
@@ -21,14 +38,11 @@ test("loads config with defaults when no files exist", async () => {
2138
test("loads JSON config file", async () => {
2239
await using tmp = await tmpdir({
2340
init: async (dir) => {
24-
await Bun.write(
25-
path.join(dir, "opencode.json"),
26-
JSON.stringify({
27-
$schema: "https://opencode.ai/config.json",
28-
model: "test/model",
29-
username: "testuser",
30-
}),
31-
)
41+
await writeConfig(dir, {
42+
$schema: "https://opencode.ai/config.json",
43+
model: "test/model",
44+
username: "testuser",
45+
})
3246
},
3347
})
3448
await Instance.provide({
@@ -68,21 +82,19 @@ test("loads JSONC config file", async () => {
6882
test("merges multiple config files with correct precedence", async () => {
6983
await using tmp = await tmpdir({
7084
init: async (dir) => {
71-
await Bun.write(
72-
path.join(dir, "opencode.jsonc"),
73-
JSON.stringify({
85+
await writeConfig(
86+
dir,
87+
{
7488
$schema: "https://opencode.ai/config.json",
7589
model: "base",
7690
username: "base",
77-
}),
78-
)
79-
await Bun.write(
80-
path.join(dir, "opencode.json"),
81-
JSON.stringify({
82-
$schema: "https://opencode.ai/config.json",
83-
model: "override",
84-
}),
91+
},
92+
"opencode.jsonc",
8593
)
94+
await writeConfig(dir, {
95+
$schema: "https://opencode.ai/config.json",
96+
model: "override",
97+
})
8698
},
8799
})
88100
await Instance.provide({
@@ -102,13 +114,10 @@ test("handles environment variable substitution", async () => {
102114
try {
103115
await using tmp = await tmpdir({
104116
init: async (dir) => {
105-
await Bun.write(
106-
path.join(dir, "opencode.json"),
107-
JSON.stringify({
108-
$schema: "https://opencode.ai/config.json",
109-
theme: "{env:TEST_VAR}",
110-
}),
111-
)
117+
await writeConfig(dir, {
118+
$schema: "https://opencode.ai/config.json",
119+
theme: "{env:TEST_VAR}",
120+
})
112121
},
113122
})
114123
await Instance.provide({
@@ -169,13 +178,10 @@ test("handles file inclusion substitution", async () => {
169178
await using tmp = await tmpdir({
170179
init: async (dir) => {
171180
await Bun.write(path.join(dir, "included.txt"), "test_theme")
172-
await Bun.write(
173-
path.join(dir, "opencode.json"),
174-
JSON.stringify({
175-
$schema: "https://opencode.ai/config.json",
176-
theme: "{file:included.txt}",
177-
}),
178-
)
181+
await writeConfig(dir, {
182+
$schema: "https://opencode.ai/config.json",
183+
theme: "{file:included.txt}",
184+
})
179185
},
180186
})
181187
await Instance.provide({
@@ -190,13 +196,10 @@ test("handles file inclusion substitution", async () => {
190196
test("validates config schema and throws on invalid fields", async () => {
191197
await using tmp = await tmpdir({
192198
init: async (dir) => {
193-
await Bun.write(
194-
path.join(dir, "opencode.json"),
195-
JSON.stringify({
196-
$schema: "https://opencode.ai/config.json",
197-
invalid_field: "should cause error",
198-
}),
199-
)
199+
await writeConfig(dir, {
200+
$schema: "https://opencode.ai/config.json",
201+
invalid_field: "should cause error",
202+
})
200203
},
201204
})
202205
await Instance.provide({
@@ -225,19 +228,16 @@ test("throws error for invalid JSON", async () => {
225228
test("handles agent configuration", async () => {
226229
await using tmp = await tmpdir({
227230
init: async (dir) => {
228-
await Bun.write(
229-
path.join(dir, "opencode.json"),
230-
JSON.stringify({
231-
$schema: "https://opencode.ai/config.json",
232-
agent: {
233-
test_agent: {
234-
model: "test/model",
235-
temperature: 0.7,
236-
description: "test agent",
237-
},
231+
await writeConfig(dir, {
232+
$schema: "https://opencode.ai/config.json",
233+
agent: {
234+
test_agent: {
235+
model: "test/model",
236+
temperature: 0.7,
237+
description: "test agent",
238238
},
239-
}),
240-
)
239+
},
240+
})
241241
},
242242
})
243243
await Instance.provide({
@@ -258,19 +258,16 @@ test("handles agent configuration", async () => {
258258
test("handles command configuration", async () => {
259259
await using tmp = await tmpdir({
260260
init: async (dir) => {
261-
await Bun.write(
262-
path.join(dir, "opencode.json"),
263-
JSON.stringify({
264-
$schema: "https://opencode.ai/config.json",
265-
command: {
266-
test_command: {
267-
template: "test template",
268-
description: "test command",
269-
agent: "test_agent",
270-
},
261+
await writeConfig(dir, {
262+
$schema: "https://opencode.ai/config.json",
263+
command: {
264+
test_command: {
265+
template: "test template",
266+
description: "test command",
267+
agent: "test_agent",
271268
},
272-
}),
273-
)
269+
},
270+
})
274271
},
275272
})
276273
await Instance.provide({
@@ -894,6 +891,86 @@ test("migrates legacy write tool to edit permission", async () => {
894891
})
895892
})
896893

894+
// Managed settings tests
895+
// Note: preload.ts sets OPENCODE_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses
896+
897+
test("managed settings override user settings", async () => {
898+
await using tmp = await tmpdir({
899+
init: async (dir) => {
900+
await writeConfig(dir, {
901+
$schema: "https://opencode.ai/config.json",
902+
model: "user/model",
903+
share: "auto",
904+
username: "testuser",
905+
})
906+
},
907+
})
908+
909+
await writeManagedSettings({
910+
$schema: "https://opencode.ai/config.json",
911+
model: "managed/model",
912+
share: "disabled",
913+
})
914+
915+
await Instance.provide({
916+
directory: tmp.path,
917+
fn: async () => {
918+
const config = await Config.get()
919+
expect(config.model).toBe("managed/model")
920+
expect(config.share).toBe("disabled")
921+
expect(config.username).toBe("testuser")
922+
},
923+
})
924+
})
925+
926+
test("managed settings override project settings", async () => {
927+
await using tmp = await tmpdir({
928+
init: async (dir) => {
929+
await writeConfig(dir, {
930+
$schema: "https://opencode.ai/config.json",
931+
autoupdate: true,
932+
disabled_providers: [],
933+
theme: "dark",
934+
})
935+
},
936+
})
937+
938+
await writeManagedSettings({
939+
$schema: "https://opencode.ai/config.json",
940+
autoupdate: false,
941+
disabled_providers: ["openai"],
942+
})
943+
944+
await Instance.provide({
945+
directory: tmp.path,
946+
fn: async () => {
947+
const config = await Config.get()
948+
expect(config.autoupdate).toBe(false)
949+
expect(config.disabled_providers).toEqual(["openai"])
950+
expect(config.theme).toBe("dark")
951+
},
952+
})
953+
})
954+
955+
test("missing managed settings file is not an error", async () => {
956+
await using tmp = await tmpdir({
957+
init: async (dir) => {
958+
await writeConfig(dir, {
959+
$schema: "https://opencode.ai/config.json",
960+
model: "user/model",
961+
})
962+
},
963+
})
964+
965+
await Instance.provide({
966+
directory: tmp.path,
967+
fn: async () => {
968+
const config = await Config.get()
969+
expect(config.model).toBe("user/model")
970+
},
971+
})
972+
})
973+
897974
test("migrates legacy edit tool to edit permission", async () => {
898975
await using tmp = await tmpdir({
899976
init: async (dir) => {

packages/opencode/test/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ const testHome = path.join(dir, "home")
1717
await fs.mkdir(testHome, { recursive: true })
1818
process.env["OPENCODE_TEST_HOME"] = testHome
1919

20+
// Set test managed config directory to isolate tests from system managed settings
21+
const testManagedConfigDir = path.join(dir, "managed")
22+
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
23+
2024
process.env["XDG_DATA_HOME"] = path.join(dir, "share")
2125
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
2226
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")

0 commit comments

Comments
 (0)