mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-01 22:48:16 +00:00
feat(config): add managed settings support for enterprise deployments (#6441)
Co-authored-by: Dax <mail@thdxr.com>
This commit is contained in:
committed by
GitHub
parent
75166a1961
commit
b5ffa997da
@@ -32,6 +32,21 @@ import { Event } from "../server/event"
|
|||||||
export namespace Config {
|
export namespace Config {
|
||||||
const log = Log.create({ service: "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
|
// Custom merge function that concatenates array fields instead of replacing them
|
||||||
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||||
const merged = mergeDeep(target, source)
|
const merged = mergeDeep(target, source)
|
||||||
@@ -148,8 +163,18 @@ export namespace Config {
|
|||||||
result.plugin.push(...(await loadPlugin(dir)))
|
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
|
// 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 ?? {}, {
|
result.agent = mergeDeep(result.agent ?? {}, {
|
||||||
[name]: {
|
[name]: {
|
||||||
...mode,
|
...mode,
|
||||||
|
|||||||
@@ -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 { Config } from "../../src/config/config"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Auth } from "../../src/auth"
|
import { Auth } from "../../src/auth"
|
||||||
@@ -6,6 +6,23 @@ import { tmpdir } from "../fixture/fixture"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import { pathToFileURL } from "url"
|
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 () => {
|
test("loads config with defaults when no files exist", async () => {
|
||||||
await using tmp = await tmpdir()
|
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 () => {
|
test("loads JSON config file", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(
|
await writeConfig(dir, {
|
||||||
path.join(dir, "opencode.json"),
|
$schema: "https://opencode.ai/config.json",
|
||||||
JSON.stringify({
|
model: "test/model",
|
||||||
$schema: "https://opencode.ai/config.json",
|
username: "testuser",
|
||||||
model: "test/model",
|
})
|
||||||
username: "testuser",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -68,21 +82,19 @@ test("loads JSONC config file", async () => {
|
|||||||
test("merges multiple config files with correct precedence", async () => {
|
test("merges multiple config files with correct precedence", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(
|
await writeConfig(
|
||||||
path.join(dir, "opencode.jsonc"),
|
dir,
|
||||||
JSON.stringify({
|
{
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
model: "base",
|
model: "base",
|
||||||
username: "base",
|
username: "base",
|
||||||
}),
|
},
|
||||||
)
|
"opencode.jsonc",
|
||||||
await Bun.write(
|
|
||||||
path.join(dir, "opencode.json"),
|
|
||||||
JSON.stringify({
|
|
||||||
$schema: "https://opencode.ai/config.json",
|
|
||||||
model: "override",
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
|
await writeConfig(dir, {
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
model: "override",
|
||||||
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -102,13 +114,10 @@ test("handles environment variable substitution", async () => {
|
|||||||
try {
|
try {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(
|
await writeConfig(dir, {
|
||||||
path.join(dir, "opencode.json"),
|
$schema: "https://opencode.ai/config.json",
|
||||||
JSON.stringify({
|
theme: "{env:TEST_VAR}",
|
||||||
$schema: "https://opencode.ai/config.json",
|
})
|
||||||
theme: "{env:TEST_VAR}",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -169,13 +178,10 @@ test("handles file inclusion substitution", async () => {
|
|||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(path.join(dir, "included.txt"), "test_theme")
|
await Bun.write(path.join(dir, "included.txt"), "test_theme")
|
||||||
await Bun.write(
|
await writeConfig(dir, {
|
||||||
path.join(dir, "opencode.json"),
|
$schema: "https://opencode.ai/config.json",
|
||||||
JSON.stringify({
|
theme: "{file:included.txt}",
|
||||||
$schema: "https://opencode.ai/config.json",
|
})
|
||||||
theme: "{file:included.txt}",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -190,13 +196,10 @@ test("handles file inclusion substitution", async () => {
|
|||||||
test("validates config schema and throws on invalid fields", async () => {
|
test("validates config schema and throws on invalid fields", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(
|
await writeConfig(dir, {
|
||||||
path.join(dir, "opencode.json"),
|
$schema: "https://opencode.ai/config.json",
|
||||||
JSON.stringify({
|
invalid_field: "should cause error",
|
||||||
$schema: "https://opencode.ai/config.json",
|
})
|
||||||
invalid_field: "should cause error",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -225,19 +228,16 @@ test("throws error for invalid JSON", async () => {
|
|||||||
test("handles agent configuration", async () => {
|
test("handles agent configuration", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(
|
await writeConfig(dir, {
|
||||||
path.join(dir, "opencode.json"),
|
$schema: "https://opencode.ai/config.json",
|
||||||
JSON.stringify({
|
agent: {
|
||||||
$schema: "https://opencode.ai/config.json",
|
test_agent: {
|
||||||
agent: {
|
model: "test/model",
|
||||||
test_agent: {
|
temperature: 0.7,
|
||||||
model: "test/model",
|
description: "test agent",
|
||||||
temperature: 0.7,
|
|
||||||
description: "test agent",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
@@ -258,19 +258,16 @@ test("handles agent configuration", async () => {
|
|||||||
test("handles command configuration", async () => {
|
test("handles command configuration", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(
|
await writeConfig(dir, {
|
||||||
path.join(dir, "opencode.json"),
|
$schema: "https://opencode.ai/config.json",
|
||||||
JSON.stringify({
|
command: {
|
||||||
$schema: "https://opencode.ai/config.json",
|
test_command: {
|
||||||
command: {
|
template: "test template",
|
||||||
test_command: {
|
description: "test command",
|
||||||
template: "test template",
|
agent: "test_agent",
|
||||||
description: "test command",
|
|
||||||
agent: "test_agent",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
)
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
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 () => {
|
test("migrates legacy edit tool to edit permission", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ const testHome = path.join(dir, "home")
|
|||||||
await fs.mkdir(testHome, { recursive: true })
|
await fs.mkdir(testHome, { recursive: true })
|
||||||
process.env["OPENCODE_TEST_HOME"] = testHome
|
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_DATA_HOME"] = path.join(dir, "share")
|
||||||
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
|
process.env["XDG_CACHE_HOME"] = path.join(dir, "cache")
|
||||||
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
|
process.env["XDG_CONFIG_HOME"] = path.join(dir, "config")
|
||||||
|
|||||||
Reference in New Issue
Block a user