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

1722 lines
47 KiB
TypeScript

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"
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()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBeDefined()
},
})
})
test("loads JSON config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "test/model",
username: "testuser",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
// This is a comment
"$schema": "https://opencode.ai/config.json",
"model": "test/model",
"username": "testuser"
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("test/model")
expect(config.username).toBe("testuser")
},
})
})
test("merges multiple config files with correct precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(
dir,
{
$schema: "https://opencode.ai/config.json",
model: "base",
username: "base",
},
"opencode.jsonc",
)
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "override",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("override")
expect(config.username).toBe("base")
},
})
})
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test_theme"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{env:TEST_VAR}",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["TEST_VAR"] = originalEnv
} else {
delete process.env["TEST_VAR"]
}
}
})
test("preserves env variables when adding $schema to config", async () => {
const originalEnv = process.env["PRESERVE_VAR"]
process.env["PRESERVE_VAR"] = "secret_value"
try {
await using tmp = await tmpdir({
init: async (dir) => {
// Config without $schema - should trigger auto-add
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
theme: "{env:PRESERVE_VAR}",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Bun.file(path.join(tmp.path, "opencode.json")).text()
expect(content).toContain("{env:PRESERVE_VAR}")
expect(content).not.toContain("secret_value")
expect(content).toContain("$schema")
},
})
} finally {
if (originalEnv !== undefined) {
process.env["PRESERVE_VAR"] = originalEnv
} else {
delete process.env["PRESERVE_VAR"]
}
}
})
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 writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
theme: "{file:included.txt}",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.theme).toBe("test_theme")
},
})
})
test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
invalid_field: "should cause error",
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Strict schema should throw an error for invalid fields
await expect(Config.get()).rejects.toThrow()
},
})
})
test("throws error for invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), "{ invalid json }")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await expect(Config.get()).rejects.toThrow()
},
})
})
test("handles agent configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "test/model",
temperature: 0.7,
description: "test agent",
},
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test_agent"]).toEqual(
expect.objectContaining({
model: "test/model",
temperature: 0.7,
description: "test agent",
}),
)
},
})
})
test("treats agent variant as model-scoped setting (not provider option)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
agent: {
test_agent: {
model: "openai/gpt-5.2",
variant: "xhigh",
max_tokens: 123,
},
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const agent = config.agent?.["test_agent"]
expect(agent?.variant).toBe("xhigh")
expect(agent?.options).toMatchObject({
max_tokens: 123,
})
expect(agent?.options).not.toHaveProperty("variant")
},
})
})
test("handles command configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
command: {
test_command: {
template: "test template",
description: "test command",
agent: "test_agent",
},
},
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.command?.["test_command"]).toEqual({
template: "test template",
description: "test command",
agent: "test_agent",
})
},
})
})
test("migrates autoshare to share field", 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",
autoshare: true,
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.share).toBe("auto")
expect(config.autoshare).toBe(true)
},
})
})
test("migrates mode field to agent field", 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",
mode: {
test_mode: {
model: "test/model",
temperature: 0.5,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test_mode"]).toEqual({
model: "test/model",
temperature: 0.5,
mode: "primary",
options: {},
permission: {},
})
},
})
})
test("loads config from .opencode directory", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "test.md"),
`---
model: test/model
---
Test agent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]).toEqual(
expect.objectContaining({
name: "test",
model: "test/model",
prompt: "Test agent prompt",
}),
)
},
})
})
test("loads agents from .opencode/agents (plural)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentsDir = path.join(opencodeDir, "agents")
await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true })
await Bun.write(
path.join(agentsDir, "helper.md"),
`---
model: test/model
mode: subagent
---
Helper agent prompt`,
)
await Bun.write(
path.join(agentsDir, "nested", "child.md"),
`---
model: test/model
mode: subagent
---
Nested agent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
model: "test/model",
mode: "subagent",
prompt: "Helper agent prompt",
})
expect(config.agent?.["nested/child"]).toMatchObject({
name: "nested/child",
model: "test/model",
mode: "subagent",
prompt: "Nested agent prompt",
})
},
})
})
test("loads commands from .opencode/command (singular)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const commandDir = path.join(opencodeDir, "command")
await fs.mkdir(path.join(commandDir, "nested"), { recursive: true })
await Bun.write(
path.join(commandDir, "hello.md"),
`---
description: Test command
---
Hello from singular command`,
)
await Bun.write(
path.join(commandDir, "nested", "child.md"),
`---
description: Nested command
---
Nested command template`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.command?.["hello"]).toEqual({
description: "Test command",
template: "Hello from singular command",
})
expect(config.command?.["nested/child"]).toEqual({
description: "Nested command",
template: "Nested command template",
})
},
})
})
test("loads commands from .opencode/commands (plural)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const commandsDir = path.join(opencodeDir, "commands")
await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true })
await Bun.write(
path.join(commandsDir, "hello.md"),
`---
description: Test command
---
Hello from plural commands`,
)
await Bun.write(
path.join(commandsDir, "nested", "child.md"),
`---
description: Nested command
---
Nested command template`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.command?.["hello"]).toEqual({
description: "Test command",
template: "Hello from plural commands",
})
expect(config.command?.["nested/child"]).toEqual({
description: "Nested command",
template: "Nested command template",
})
},
})
})
test("updates config and writes to file", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const newConfig = { model: "updated/model" }
await Config.update(newConfig as any)
const writtenConfig = JSON.parse(await Bun.file(path.join(tmp.path, "config.json")).text())
expect(writtenConfig.model).toBe("updated/model")
},
})
})
test("gets config directories", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Config.directories()
expect(dirs.length).toBeGreaterThanOrEqual(1)
},
})
})
test("resolves scoped npm plugins in config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const pluginDir = path.join(dir, "node_modules", "@scope", "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Bun.write(
path.join(dir, "package.json"),
JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2),
)
await Bun.write(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@scope/plugin",
version: "1.0.0",
type: "module",
main: "./index.js",
},
null,
2,
),
)
await Bun.write(path.join(pluginDir, "index.js"), "export default {}\n")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
const pluginEntries = config.plugin ?? []
const baseUrl = pathToFileURL(path.join(tmp.path, "opencode.json")).href
const expected = import.meta.resolve("@scope/plugin", baseUrl)
expect(pluginEntries.includes(expected)).toBe(true)
const scopedEntry = pluginEntries.find((entry) => entry === expected)
expect(scopedEntry).toBeDefined()
expect(scopedEntry?.includes("/node_modules/@scope/plugin/")).toBe(true)
},
})
})
test("merges plugin arrays from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Create a nested project structure with local .opencode config
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
// Global config with plugins
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["global-plugin-1", "global-plugin-2"],
}),
)
// Local .opencode config with different plugins
await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["local-plugin-1"],
}),
)
},
})
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const plugins = config.plugin ?? []
// Should contain both global and local plugins
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
expect(plugins.some((p) => p.includes("global-plugin-2"))).toBe(true)
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
// Should have all 3 plugins (not replaced, but merged)
const pluginNames = plugins.filter((p) => p.includes("global-plugin") || p.includes("local-plugin"))
expect(pluginNames.length).toBeGreaterThanOrEqual(3)
},
})
})
test("does not error when only custom agent is a subagent", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
const agentDir = path.join(opencodeDir, "agent")
await fs.mkdir(agentDir, { recursive: true })
await Bun.write(
path.join(agentDir, "helper.md"),
`---
model: test/model
mode: subagent
---
Helper subagent prompt`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["helper"]).toMatchObject({
name: "helper",
model: "test/model",
mode: "subagent",
prompt: "Helper subagent prompt",
})
},
})
})
test("merges instructions arrays from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["global-instructions.md", "shared-rules.md"],
}),
)
await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["local-instructions.md"],
}),
)
},
})
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const instructions = config.instructions ?? []
expect(instructions).toContain("global-instructions.md")
expect(instructions).toContain("shared-rules.md")
expect(instructions).toContain("local-instructions.md")
expect(instructions.length).toBe(3)
},
})
})
test("deduplicates duplicate instructions from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["duplicate.md", "global-only.md"],
}),
)
await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["duplicate.md", "local-only.md"],
}),
)
},
})
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const instructions = config.instructions ?? []
expect(instructions).toContain("global-only.md")
expect(instructions).toContain("local-only.md")
expect(instructions).toContain("duplicate.md")
const duplicates = instructions.filter((i) => i === "duplicate.md")
expect(duplicates.length).toBe(1)
expect(instructions.length).toBe(3)
},
})
})
test("deduplicates duplicate plugins from global and local configs", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Create a nested project structure with local .opencode config
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
// Global config with plugins
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["duplicate-plugin", "global-plugin-1"],
}),
)
// Local .opencode config with some overlapping plugins
await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["duplicate-plugin", "local-plugin-1"],
}),
)
},
})
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const plugins = config.plugin ?? []
// Should contain all unique plugins
expect(plugins.some((p) => p.includes("global-plugin-1"))).toBe(true)
expect(plugins.some((p) => p.includes("local-plugin-1"))).toBe(true)
expect(plugins.some((p) => p.includes("duplicate-plugin"))).toBe(true)
// Should deduplicate the duplicate plugin
const duplicatePlugins = plugins.filter((p) => p.includes("duplicate-plugin"))
expect(duplicatePlugins.length).toBe(1)
// Should have exactly 3 unique plugins
const pluginNames = plugins.filter(
(p) => p.includes("global-plugin") || p.includes("local-plugin") || p.includes("duplicate-plugin"),
)
expect(pluginNames.length).toBe(3)
},
})
})
// Legacy tools migration tests
test("migrates legacy tools config to permissions - allow", 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: {
tools: {
bash: true,
read: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
read: "allow",
})
},
})
})
test("migrates legacy tools config to permissions - deny", 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: {
tools: {
bash: false,
webfetch: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "deny",
webfetch: "deny",
})
},
})
})
test("migrates legacy write tool to edit permission", 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: {
tools: {
write: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
},
})
})
// 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) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
tools: {
edit: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
},
})
})
test("migrates legacy patch tool to edit permission", 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: {
tools: {
patch: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "allow",
})
},
})
})
test("migrates legacy multiedit tool to edit permission", 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: {
tools: {
multiedit: false,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
edit: "deny",
})
},
})
})
test("migrates mixed legacy tools config", 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: {
tools: {
bash: true,
write: true,
read: false,
webfetch: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
bash: "allow",
edit: "allow",
read: "deny",
webfetch: "allow",
})
},
})
})
test("merges legacy tools with existing permission config", 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: {
permission: {
glob: "allow",
},
tools: {
bash: true,
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["test"]?.permission).toEqual({
glob: "allow",
bash: "allow",
})
},
})
})
test("permission config preserves key order", 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",
permission: {
"*": "deny",
edit: "ask",
write: "ask",
external_directory: "ask",
read: "allow",
todowrite: "allow",
todoread: "allow",
"thoughts_*": "allow",
"reasoning_model_*": "allow",
"tools_*": "allow",
"pr_comments_*": "allow",
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(Object.keys(config.permission!)).toEqual([
"*",
"edit",
"write",
"external_directory",
"read",
"todowrite",
"todoread",
"thoughts_*",
"reasoning_model_*",
"tools_*",
"pr_comments_*",
])
},
})
})
// MCP config merging tests
test("project config can override MCP server enabled status", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Simulates a base config (like from remote .well-known) with disabled MCP
await Bun.write(
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: false,
},
wiki: {
type: "remote",
url: "https://wiki.example.com/mcp",
enabled: false,
},
},
}),
)
// Project config enables just jira
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// jira should be enabled (overridden by project config)
expect(config.mcp?.jira).toEqual({
type: "remote",
url: "https://jira.example.com/mcp",
enabled: true,
})
// wiki should still be disabled (not overridden)
expect(config.mcp?.wiki).toEqual({
type: "remote",
url: "https://wiki.example.com/mcp",
enabled: false,
})
},
})
})
test("MCP config deep merges preserving base config properties", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Base config with full MCP definition
await Bun.write(
path.join(dir, "opencode.jsonc"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
myserver: {
type: "remote",
url: "https://myserver.example.com/mcp",
enabled: false,
headers: {
"X-Custom-Header": "value",
},
},
},
}),
)
// Override just enables it, should preserve other properties
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
myserver: {
type: "remote",
url: "https://myserver.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.mcp?.myserver).toEqual({
type: "remote",
url: "https://myserver.example.com/mcp",
enabled: true,
headers: {
"X-Custom-Header": "value",
},
})
},
})
})
test("local .opencode config can override MCP from project config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
// Project config with disabled MCP
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
docs: {
type: "remote",
url: "https://docs.example.com/mcp",
enabled: false,
},
},
}),
)
// Local .opencode directory config enables it
const opencodeDir = path.join(dir, ".opencode")
await fs.mkdir(opencodeDir, { recursive: true })
await Bun.write(
path.join(opencodeDir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
docs: {
type: "remote",
url: "https://docs.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.mcp?.docs?.enabled).toBe(true)
},
})
})
test("project config overrides remote well-known config", async () => {
const originalFetch = globalThis.fetch
let fetchedUrl: string | undefined
const mockFetch = mock((url: string | URL | Request) => {
const urlStr = url.toString()
if (urlStr.includes(".well-known/opencode")) {
fetchedUrl = urlStr
return Promise.resolve(
new Response(
JSON.stringify({
config: {
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: false,
},
},
},
}),
{ status: 200 },
),
)
}
return originalFetch(url)
})
globalThis.fetch = mockFetch as unknown as typeof fetch
const originalAuthAll = Auth.all
Auth.all = mock(() =>
Promise.resolve({
"https://example.com": {
type: "wellknown" as const,
key: "TEST_TOKEN",
token: "test-token",
},
}),
)
try {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
// Project config enables jira (overriding remote default)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
jira: {
type: "remote",
url: "https://jira.example.com/mcp",
enabled: true,
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// Verify fetch was called for wellknown config
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
// Project config (enabled: true) should override remote (enabled: false)
expect(config.mcp?.jira?.enabled).toBe(true)
},
})
} finally {
globalThis.fetch = originalFetch
Auth.all = originalAuthAll
}
})
describe("getPluginName", () => {
test("extracts name from file:// URL", () => {
expect(Config.getPluginName("file:///path/to/plugin/foo.js")).toBe("foo")
expect(Config.getPluginName("file:///path/to/plugin/bar.ts")).toBe("bar")
expect(Config.getPluginName("file:///some/path/my-plugin.js")).toBe("my-plugin")
})
test("extracts name from npm package with version", () => {
expect(Config.getPluginName("oh-my-opencode@2.4.3")).toBe("oh-my-opencode")
expect(Config.getPluginName("some-plugin@1.0.0")).toBe("some-plugin")
expect(Config.getPluginName("plugin@latest")).toBe("plugin")
})
test("extracts name from scoped npm package", () => {
expect(Config.getPluginName("@scope/pkg@1.0.0")).toBe("@scope/pkg")
expect(Config.getPluginName("@opencode/plugin@2.0.0")).toBe("@opencode/plugin")
})
test("returns full string for package without version", () => {
expect(Config.getPluginName("some-plugin")).toBe("some-plugin")
expect(Config.getPluginName("@scope/pkg")).toBe("@scope/pkg")
})
})
describe("deduplicatePlugins", () => {
test("removes duplicates keeping higher priority (later entries)", () => {
const plugins = ["global-plugin@1.0.0", "shared-plugin@1.0.0", "local-plugin@2.0.0", "shared-plugin@2.0.0"]
const result = Config.deduplicatePlugins(plugins)
expect(result).toContain("global-plugin@1.0.0")
expect(result).toContain("local-plugin@2.0.0")
expect(result).toContain("shared-plugin@2.0.0")
expect(result).not.toContain("shared-plugin@1.0.0")
expect(result.length).toBe(3)
})
test("prefers local file over npm package with same name", () => {
const plugins = ["oh-my-opencode@2.4.3", "file:///project/.opencode/plugin/oh-my-opencode.js"]
const result = Config.deduplicatePlugins(plugins)
expect(result.length).toBe(1)
expect(result[0]).toBe("file:///project/.opencode/plugin/oh-my-opencode.js")
})
test("preserves order of remaining plugins", () => {
const plugins = ["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"]
const result = Config.deduplicatePlugins(plugins)
expect(result).toEqual(["a-plugin@1.0.0", "b-plugin@1.0.0", "c-plugin@1.0.0"])
})
test("local plugin directory overrides global opencode.json plugin", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const projectDir = path.join(dir, "project")
const opencodeDir = path.join(projectDir, ".opencode")
const pluginDir = path.join(opencodeDir, "plugin")
await fs.mkdir(pluginDir, { recursive: true })
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
plugin: ["my-plugin@1.0.0"],
}),
)
await Bun.write(path.join(pluginDir, "my-plugin.js"), "export default {}")
},
})
await Instance.provide({
directory: path.join(tmp.path, "project"),
fn: async () => {
const config = await Config.get()
const plugins = config.plugin ?? []
const myPlugins = plugins.filter((p) => Config.getPluginName(p) === "my-plugin")
expect(myPlugins.length).toBe(1)
expect(myPlugins[0].startsWith("file://")).toBe(true)
},
})
})
})
describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => {
test("skips project config files when flag is set", async () => {
const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
try {
await using tmp = await tmpdir({
init: async (dir) => {
// Create a project config that would normally be loaded
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: "project/model",
username: "project-user",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// Project config should NOT be loaded - model should be default, not "project/model"
expect(config.model).not.toBe("project/model")
expect(config.username).not.toBe("project-user")
},
})
} finally {
if (originalEnv === undefined) {
delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
} else {
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
}
}
})
test("skips project .opencode/ directories when flag is set", async () => {
const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
try {
await using tmp = await tmpdir({
init: async (dir) => {
// Create a .opencode directory with a command
const opencodeDir = path.join(dir, ".opencode", "command")
await fs.mkdir(opencodeDir, { recursive: true })
await Bun.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const directories = await Config.directories()
// Project .opencode should NOT be in directories list
const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path))
expect(hasProjectOpencode).toBe(false)
},
})
} finally {
if (originalEnv === undefined) {
delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
} else {
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
}
}
})
test("still loads global config when flag is set", async () => {
const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
try {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Should still get default config (from global or defaults)
const config = await Config.get()
expect(config).toBeDefined()
expect(config.username).toBeDefined()
},
})
} finally {
if (originalEnv === undefined) {
delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
} else {
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv
}
}
})
test("skips relative instructions with warning when flag is set but no config dir", async () => {
const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
try {
// Ensure no config dir is set
delete process.env["OPENCODE_CONFIG_DIR"]
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
await using tmp = await tmpdir({
init: async (dir) => {
// Create a config with relative instruction path
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
instructions: ["./CUSTOM.md"],
}),
)
// Create the instruction file (should be skipped)
await Bun.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
// The relative instruction should be skipped without error
// We're mainly verifying this doesn't throw and the config loads
const config = await Config.get()
expect(config).toBeDefined()
// The instruction should have been skipped (warning logged)
// We can't easily test the warning was logged, but we verify
// the relative path didn't cause an error
},
})
} finally {
if (originalDisable === undefined) {
delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
} else {
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
}
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
}
})
test("OPENCODE_CONFIG_DIR still works when flag is set", async () => {
const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
try {
await using configDirTmp = await tmpdir({
init: async (dir) => {
// Create config in the custom config dir
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: "configdir/model",
}),
)
},
})
await using projectTmp = await tmpdir({
init: async (dir) => {
// Create config in project (should be ignored)
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: "project/model",
}),
)
},
})
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true"
process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const config = await Config.get()
// Should load from OPENCODE_CONFIG_DIR, not project
expect(config.model).toBe("configdir/model")
},
})
} finally {
if (originalDisable === undefined) {
delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"]
} else {
process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable
}
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
}
})
})