feat(opencode): load supported v2 config in v1 (#45421)

This commit is contained in:
James Long
2026-08-27 16:03:36 -04:00
committed by GitHub
parent 05ea5073be
commit 03afae5b95
66 changed files with 1948 additions and 7 deletions
+26 -6
View File
@@ -33,6 +33,7 @@ import { ConfigParse } from "./parse"
import { ConfigPaths } from "./paths"
import { ConfigPlugin } from "./plugin"
import { ConfigVariable } from "./variable"
import { ConfigV2Compat } from "./v2-compat"
import { Npm } from "@opencode-ai/core/npm"
import { withTransientReadRetry } from "@/util/effect-http-client"
@@ -184,6 +185,19 @@ const layer = Layer.effect(
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
const decodeConfig = Effect.fnUntraced(function* (input: unknown, source: string) {
const result = ConfigV2Compat.lower(normalizeLoadedConfig(input), source)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration compatibility diagnostic", {
source,
path: diagnostic.path,
kind: diagnostic.kind,
action: diagnostic.message,
}),
)
return ConfigParse.schema(ConfigV1.Info, result.value, source)
})
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
url: string,
headers: Record<string, string> | undefined,
@@ -224,7 +238,7 @@ const layer = Layer.effect(
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed), source)
const data = yield* decodeConfig(parsed, source)
if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
@@ -625,8 +639,13 @@ const layer = Layer.effect(
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
const existing = yield* loadFile(file)
const text = yield* readConfigFile(file)
const original = text ? ConfigParse.jsonc(text, file) : writable(existing)
yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.writeFileString(
file,
JSON.stringify(mergeDeep(isRecord(original) ? original : writable(existing), writable(config)), null, 2),
)
.pipe(Effect.orDie)
})
@@ -642,15 +661,16 @@ const layer = Layer.effect(
let next: Info
let changed: boolean
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), patch)
const existing = ConfigParse.jsonc(before, file)
ConfigParse.schema(ConfigV1.Info, ConfigV2Compat.lower(normalizeLoadedConfig(existing), file).value, file)
const merged = mergeDeep(isRecord(existing) ? existing : {}, patch)
const serialized = JSON.stringify(merged, null, 2)
next = yield* decodeConfig(merged, file)
changed = serialized !== before
if (changed) yield* fs.writeFileString(file, serialized).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, patch)
next = ConfigParse.schema(ConfigV1.Info, ConfigParse.jsonc(updated, file), file)
next = yield* decodeConfig(ConfigParse.jsonc(updated, file), file)
changed = updated !== before
if (changed) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
+449
View File
@@ -0,0 +1,449 @@
export * as ConfigV2Compat from "./v2-compat"
import { isDeepStrictEqual } from "node:util"
import { Option, Schema } from "effect"
import { NonNegativeInt, PositiveInt } from "@opencode-ai/core/schema"
import { ConfigAttachmentV1 } from "@opencode-ai/core/v1/config/attachment"
import { ConfigLSPV1 } from "@opencode-ai/core/v1/config/lsp"
import { InvalidError } from "@opencode-ai/core/v1/config/error"
export interface Diagnostic {
readonly kind: "invalid" | "unsupported" | "conflict"
readonly path: readonly string[]
readonly message: string
}
export interface Result {
readonly value: unknown
readonly diagnostics: readonly Diagnostic[]
}
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const Record = Schema.Record(Schema.String, Schema.Unknown)
const Timeout = Schema.Struct({
startup: Schema.optional(PositiveInt),
catalog: Schema.optional(PositiveInt),
execution: Schema.optional(PositiveInt),
})
const OAuth = Schema.Struct({
client_id: Schema.optional(Schema.String),
client_secret: Schema.optional(Schema.String),
scope: Schema.optional(Schema.String),
callback_port: Schema.optional(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
redirect_uri: Schema.optional(Schema.String),
})
const Server = Schema.Union([
Schema.Struct({
type: Schema.Literal("local"),
command: Schema.Array(Schema.String),
cwd: Schema.optional(Schema.String),
environment: Schema.optional(Schema.Record(Schema.String, Schema.String)),
disabled: Schema.optional(Schema.Boolean),
codemode: Schema.optional(Schema.Boolean),
timeout: Schema.optional(Timeout),
}),
Schema.Struct({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
oauth: Schema.optional(Schema.Union([OAuth, Schema.Literal(false)])),
disabled: Schema.optional(Schema.Boolean),
codemode: Schema.optional(Schema.Boolean),
timeout: Schema.optional(Timeout),
}),
])
const Selection = Schema.Union([
Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/)),
Schema.Struct({
providerID: Schema.String.check(Schema.isPattern(/^[^/#]+$/)),
model: Schema.String.check(Schema.isPattern(/^[^#]+$/)),
variant: Schema.optional(Schema.String.check(Schema.isPattern(/^[^#]+$/))),
}),
])
const Agent = Schema.Struct({
model: Schema.optional(Selection),
request: Schema.optional(
Schema.Struct({
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
body: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
}),
),
system: Schema.optional(Schema.String),
description: Schema.optional(Schema.String),
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
hidden: Schema.optional(Schema.Boolean),
color: Schema.optional(Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))),
steps: Schema.optional(PositiveInt),
disabled: Schema.optional(Schema.Boolean),
})
const Command = Schema.Struct({
template: Schema.String,
description: Schema.optional(Schema.String),
agent: Schema.optional(Schema.String),
model: Schema.optional(Selection),
subtask: Schema.optional(Schema.Boolean),
})
const decodeRecord = Schema.decodeUnknownOption(Record, decodeOptions)
const decodeLspEntry = Schema.decodeUnknownOption(ConfigLSPV1.Entry, decodeOptions)
const builtinServers = new Set<string>(ConfigLSPV1.builtinServerIds)
export function lower(input: unknown, source = "configuration"): Result {
const parsed = decodeRecord(input)
if (Option.isNone(parsed)) return { value: input, diagnostics: [] }
const permissions = [
...(Object.hasOwn(parsed.value, "permissions") ? [["permissions"]] : []),
...["agents", "agent", "mode"].flatMap((key) => {
const agents = decodeRecord(parsed.value[key])
if (Option.isNone(agents)) return []
return Object.entries(agents.value).flatMap(([name, value]) => {
const agent = decodeRecord(value)
return Option.isSome(agent) && Object.hasOwn(agent.value, "permissions") ? [[key, name, "permissions"]] : []
})
}),
]
if (permissions.length)
throw new InvalidError({
path: source,
issues: permissions.map((path) => ({
path,
message: 'V2 permissions are not supported by OpenCode V1. Use V1 "permission" rules or run opencode2.',
})),
})
const result: Record<string, unknown> = { ...parsed.value }
const diagnostics: Diagnostic[] = []
for (const key of ["plugins", "providers", "websearch", "warming"])
if (Object.hasOwn(parsed.value, key)) unsupported([key], diagnostics)
normalizeSettings(parsed.value, result, diagnostics)
normalizeModel(parsed.value, result, diagnostics)
normalizeSkills(parsed.value, result, diagnostics)
normalizeCompaction(parsed.value, result, diagnostics)
normalizeExperimental(parsed.value, result, diagnostics)
normalizeAgents(parsed.value, result, diagnostics)
normalizeCommands(parsed.value, result, diagnostics)
normalizeMcp(parsed.value, result, diagnostics)
normalizeLsp(parsed.value, result, diagnostics)
return { value: result, diagnostics }
}
function normalizeSettings(input: Record<string, unknown>, result: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (Object.hasOwn(input, "snapshots")) {
const value = decodeValue(Schema.Boolean, input.snapshots, ["snapshots"], diagnostics)
if (value !== undefined) preferLegacy(result, "snapshot", value, ["snapshots"], diagnostics)
}
if (Object.hasOwn(input, "media")) {
const value = decodeValue(ConfigAttachmentV1.Info, input.media, ["media"], diagnostics)
if (value !== undefined) preferLegacy(result, "attachment", value, ["media"], diagnostics)
}
}
function normalizeModel(input: Record<string, unknown>, result: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!Object.hasOwn(input, "model")) return
const selection = Schema.decodeUnknownOption(Selection, decodeOptions)(input.model)
if (Option.isNone(selection)) return
const value = lowerSelection(selection.value)
result.model = value.model
if (value.variant !== undefined) unsupported(["model", "variant"], diagnostics)
}
function normalizeSkills(input: Record<string, unknown>, result: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!Array.isArray(input.skills)) return
const skills = decodeValue(Schema.Array(Schema.String), input.skills, ["skills"], diagnostics)
if (skills === undefined) return
result.skills = {
paths: skills.filter((value) => !/^https?:\/\//i.test(value)),
urls: skills.filter((value) => /^https?:\/\//i.test(value)),
}
}
function normalizeCompaction(
input: Record<string, unknown>,
result: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
const compaction = decodeRecord(input.compaction)
if (Option.isNone(compaction)) return
const value = { ...compaction.value }
if (Object.hasOwn(value, "keep")) {
const keep = decodeValue(Record, value.keep, ["compaction", "keep"], diagnostics)
if (keep !== undefined && Object.hasOwn(keep, "tokens")) {
const tokens = decodeValue(NonNegativeInt, keep.tokens, ["compaction", "keep", "tokens"], diagnostics)
if (tokens !== undefined)
preferLegacy(value, "preserve_recent_tokens", tokens, ["compaction", "keep", "tokens"], diagnostics)
}
}
if (Object.hasOwn(value, "buffer")) {
const buffer = decodeValue(NonNegativeInt, value.buffer, ["compaction", "buffer"], diagnostics)
if (buffer !== undefined) preferLegacy(value, "reserved", buffer, ["compaction", "buffer"], diagnostics)
}
result.compaction = value
}
function normalizeExperimental(
input: Record<string, unknown>,
result: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
const experimental = decodeRecord(input.experimental)
if (Option.isNone(experimental)) return
if (Object.hasOwn(experimental.value, "portable_shell_scanner"))
unsupported(["experimental", "portable_shell_scanner"], diagnostics)
if (!Object.hasOwn(experimental.value, "subagent_depth")) return
const depth = decodeValue(
NonNegativeInt,
experimental.value.subagent_depth,
["experimental", "subagent_depth"],
diagnostics,
)
if (depth !== undefined)
preferLegacy(result, "subagent_depth", depth, ["experimental", "subagent_depth"], diagnostics)
}
function normalizeAgents(input: Record<string, unknown>, result: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!Object.hasOwn(input, "agents")) return
const agents = decodeValue(Record, input.agents, ["agents"], diagnostics)
if (agents === undefined) return
const legacy = decodeRecord(result.agent)
const merged: Record<string, unknown> = Option.isSome(legacy) ? { ...legacy.value } : {}
for (const [name, value] of Object.entries(agents)) {
const path = ["agents", name]
if (Object.hasOwn(merged, name)) {
if (!isDeepStrictEqual(merged[name], value)) conflict(path, diagnostics)
continue
}
const parsed = decodeValue(Agent, value, path, diagnostics)
if (parsed === undefined) continue
if (parsed.request?.headers !== undefined) unsupported([...path, "request", "headers"], diagnostics)
setOwn(merged, name, lowerAgent(parsed))
}
if (Object.hasOwn(result, "agent") && Option.isNone(legacy)) return
if (Object.keys(merged).length > 0 || Option.isSome(legacy)) result.agent = merged
}
function normalizeCommands(input: Record<string, unknown>, result: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!Object.hasOwn(input, "commands")) return
const commands = decodeValue(Record, input.commands, ["commands"], diagnostics)
if (commands === undefined) return
const legacy = decodeRecord(result.command)
if (Object.hasOwn(result, "command") && Option.isNone(legacy)) return
const merged: Record<string, unknown> = Option.isSome(legacy) ? { ...legacy.value } : {}
for (const [name, value] of Object.entries(commands)) {
const path = ["commands", name]
const parsed = decodeValue(Command, value, path, diagnostics)
if (parsed === undefined) continue
preferLegacy(merged, name, lowerCommand(parsed), path, diagnostics)
}
if (Object.keys(merged).length > 0 || Option.isSome(legacy)) result.command = merged
}
function normalizeMcp(input: Record<string, unknown>, result: Record<string, unknown>, diagnostics: Diagnostic[]) {
const mcp = decodeRecord(input.mcp)
if (Option.isNone(mcp)) return
const servers: Record<string, unknown> = {}
const nested = decodeRecord(mcp.value.servers)
const envelope = Option.isSome(nested) && !isDirectServer(nested.value)
const timeoutRecord = decodeRecord(mcp.value.timeout)
const timeout = Schema.decodeUnknownOption(Timeout, decodeOptions)(mcp.value.timeout)
const globalTimeout =
Option.isSome(timeout) &&
Option.isSome(timeoutRecord) &&
!isDirectServer(timeoutRecord.value) &&
(Object.keys(timeoutRecord.value).length === 0 ||
["startup", "catalog", "execution"].some((key) => Object.hasOwn(timeoutRecord.value, key)))
for (const [name, value] of Object.entries(mcp.value)) {
if (name === "servers" && envelope) continue
if (name === "timeout" && globalTimeout) continue
const path = ["mcp", name]
const record = decodeRecord(value)
const oauth = Option.isSome(record) ? decodeRecord(record.value.oauth) : Option.none()
const native =
Option.isSome(record) &&
(Object.hasOwn(record.value, "disabled") ||
Object.hasOwn(record.value, "codemode") ||
typeof record.value.timeout === "object" ||
(Option.isSome(oauth) &&
["client_id", "client_secret", "callback_port", "redirect_uri"].some((key) =>
Object.hasOwn(oauth.value, key),
)))
// Keep invalid flat entries for the final V1 decoder rather than sanitizing them.
setOwn(servers, name, native ? (normalizeServer(value, path, diagnostics) ?? value) : value)
}
if (envelope && Option.isSome(nested)) {
for (const [name, value] of Object.entries(nested.value)) {
const path = ["mcp", "servers", name]
if (Object.hasOwn(servers, name)) {
if (!isDeepStrictEqual(servers[name], value)) conflict(path, diagnostics)
continue
}
const record = decodeRecord(value)
if (
Option.isSome(record) &&
typeof record.value.enabled === "boolean" &&
!Object.hasOwn(record.value, "disabled")
) {
setOwn(servers, name, value)
continue
}
const server = normalizeServer(value, path, diagnostics)
if (server !== undefined) setOwn(servers, name, server)
}
}
result.mcp = servers
if (!globalTimeout || Option.isNone(timeout)) return
const value = lowerTimeout(timeout.value)
if (value === undefined) {
if (Object.keys(timeout.value).length) unsupported(["mcp", "timeout"], diagnostics)
return
}
const existing = decodeRecord(result.experimental)
if (Object.hasOwn(result, "experimental") && Option.isNone(existing)) return
const experimental = Option.isSome(existing) ? { ...existing.value } : {}
preferLegacy(experimental, "mcp_timeout", value, ["mcp", "timeout"], diagnostics)
result.experimental = experimental
}
function isDirectServer(value: Record<string, unknown>) {
// Object-valued entries can be servers literally named "type" or "enabled".
return ["type", "enabled"].some(
(key) =>
Object.hasOwn(value, key) && (value[key] === null || typeof value[key] !== "object" || Array.isArray(value[key])),
)
}
function normalizeServer(input: unknown, path: string[], diagnostics: Diagnostic[]) {
const server = decodeValue(Server, input, path, diagnostics)
if (server === undefined) return
if (server.codemode !== undefined) unsupported([...path, "codemode"], diagnostics)
if (server.timeout && lowerTimeout(server.timeout) === undefined && Object.keys(server.timeout).length)
unsupported([...path, "timeout"], diagnostics)
const raw = decodeRecord(input)
if (Option.isNone(raw) || !Object.hasOwn(raw.value, "enabled")) return lowerServer(server)
if (server.disabled !== undefined && raw.value.enabled === server.disabled)
conflict([...path, "disabled"], diagnostics)
return { ...lowerServer(server), enabled: raw.value.enabled }
}
function normalizeLsp(input: Record<string, unknown>, result: Record<string, unknown>, diagnostics: Diagnostic[]) {
const lsp = decodeRecord(input.lsp)
if (Option.isNone(lsp)) return
result.lsp = Object.fromEntries(
Object.entries(lsp.value).filter(([name, value]) => {
if (builtinServers.has(name)) return true
const entry = decodeLspEntry(value)
if (Option.isNone(entry)) return true
if (entry.value.disabled === true) return true
if ("extensions" in entry.value && entry.value.extensions !== undefined) return true
unsupported(["lsp", name], diagnostics)
return false
}),
)
}
function lowerSelection(input: Schema.Schema.Type<typeof Selection>) {
if (typeof input !== "string") {
return {
model: `${input.providerID}/${input.model}`,
...(input.variant !== undefined ? { variant: input.variant } : {}),
}
}
const index = input.indexOf("#")
if (index === -1) return { model: input }
return { model: input.slice(0, index), variant: input.slice(index + 1) }
}
function lowerTimeout(input: Schema.Schema.Type<typeof Timeout>) {
if (input.startup !== undefined) return undefined
if (input.catalog === undefined || input.execution === undefined) return undefined
if (input.catalog !== input.execution) return undefined
return input.catalog
}
function lowerServer(input: Schema.Schema.Type<typeof Server>) {
const result: Record<string, unknown> = {
...input,
enabled: input.disabled !== true,
}
delete result.disabled
delete result.codemode
delete result.timeout
if (input.timeout) {
const timeout = lowerTimeout(input.timeout)
if (timeout !== undefined) result.timeout = timeout
}
if (input.type === "remote" && input.oauth && typeof input.oauth === "object") {
const oauth: Record<string, unknown> = {}
if (input.oauth.client_id !== undefined) oauth.clientId = input.oauth.client_id
if (input.oauth.client_secret !== undefined) oauth.clientSecret = input.oauth.client_secret
if (input.oauth.scope !== undefined) oauth.scope = input.oauth.scope
if (input.oauth.callback_port !== undefined) oauth.callbackPort = input.oauth.callback_port
if (input.oauth.redirect_uri !== undefined) oauth.redirectUri = input.oauth.redirect_uri
result.oauth = oauth
}
return result
}
function lowerAgent(input: Schema.Schema.Type<typeof Agent>) {
const result: Record<string, unknown> = {}
for (const key of ["description", "mode", "hidden", "color", "steps"] as const) {
if (input[key] !== undefined) result[key] = input[key]
}
if (input.system !== undefined) result.prompt = input.system
if (input.disabled !== undefined) result.disable = input.disabled
if (input.model !== undefined) Object.assign(result, lowerSelection(input.model))
if (input.request?.body !== undefined) result.options = input.request.body
return result
}
function lowerCommand(input: Schema.Schema.Type<typeof Command>) {
return { ...input, ...(input.model !== undefined ? lowerSelection(input.model) : {}) }
}
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
schema: S,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
const decoded = Schema.decodeUnknownOption(schema, decodeOptions)(value)
if (Option.isSome(decoded)) return decoded.value
diagnostics.push({ kind: "invalid", path, message: "Native setting could not be lowered because it is malformed" })
return undefined
}
function preferLegacy(
target: Record<string, unknown>,
key: string,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
if (Object.hasOwn(target, key)) {
if (!isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
return
}
setOwn(target, key, value)
}
function setOwn(target: Record<string, unknown>, key: string, value: unknown) {
Object.defineProperty(target, key, { value, enumerable: true, configurable: true, writable: true })
}
function unsupported(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "unsupported", path, message: "Omitted native setting that cannot be represented in V1" })
}
function conflict(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "conflict", path, message: "Retained legacy value over native value" })
}
+188 -1
View File
@@ -2,12 +2,14 @@ import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { Cause, Effect, Exit, Layer, Option } from "effect"
import { Cause, Effect, Exit, Layer, Logger, Option } from "effect"
import { NamedError } from "@opencode-ai/core/util/error"
import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
import { Config } from "@/config/config"
import { ConfigManaged } from "@/config/managed"
import { ConfigParse } from "../../src/config/parse"
import { ConfigV2Compat } from "../../src/config/v2-compat"
import { snapshot } from "./snapshot"
import { Npm } from "@opencode-ai/core/npm"
import { InstanceRef } from "../../src/effect/instance-ref"
@@ -397,6 +399,191 @@ it.effect("updates global config and omits empty shell key in jsonc", () =>
),
)
it.effect("logs global update diagnostics once without exposing values", () =>
withGlobalConfig(
{
config: {
providers: { example: { settings: { apiKey: "keep-me" } } },
},
},
({ dir }) =>
Effect.gen(function* () {
const messages: unknown[] = []
yield* Config.use.updateGlobal({ username: "updated" }).pipe(
Effect.provide(
Logger.layer([
Logger.make<unknown, void>((options) => {
messages.push(options.message)
}),
]),
),
)
expect(JSON.stringify(messages)).not.toContain("keep-me")
expect(
messages.filter((item) => Array.isArray(item) && item[0] === "configuration compatibility diagnostic"),
).toEqual([
[
"configuration compatibility diagnostic",
expect.objectContaining({
source: path.join(dir, "opencode.json"),
kind: "unsupported",
path: ["providers"],
}),
],
])
}),
),
)
const updateFixtures = path.join(import.meta.dir, "fixtures/v2-compat")
const globalInputs = [...new Bun.Glob("update-global/*-input.{json,jsonc}").scanSync({ cwd: updateFixtures })].sort()
const projectInputs = [...new Bun.Glob("update-project/*-input.json").scanSync({ cwd: updateFixtures })].sort()
if (!globalInputs.length || !projectInputs.length) throw new Error("Missing config update fixtures")
for (const input of globalInputs) {
const extension = path.extname(input)
const name = input.slice(0, -`-input${extension}`.length)
const prefix = path.join(updateFixtures, name)
it.live(`fixture ${name}`, () =>
withGlobalConfig({}, ({ dir }) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const file = path.join(dir, `opencode${extension}`)
yield* fs.writeFileString(file, yield* fs.readFileString(path.join(updateFixtures, input)))
const patch = ConfigParse.schema(ConfigV1.Info, yield* fs.readJson(`${prefix}-patch.json`), input)
const updated = yield* Config.use.updateGlobal(patch)
const written = yield* fs.readFileString(file)
yield* Effect.promise(() => snapshot(`${prefix}-output${extension}`, written))
yield* Effect.promise(() => snapshot(`${prefix}-normalized.json`, JSON.stringify(updated.info, null, 2) + "\n"))
}),
),
)
}
for (const input of projectInputs) {
const name = input.slice(0, -"-input.json".length)
const prefix = path.join(updateFixtures, name)
it.instance(`fixture ${name}`, () =>
Effect.gen(function* () {
const instance = yield* TestInstance
const fs = yield* FSUtil.Service
const file = path.join(instance.directory, "config.json")
yield* fs.writeFileString(file, yield* fs.readFileString(path.join(updateFixtures, input)))
const patch = ConfigParse.schema(ConfigV1.Info, yield* fs.readJson(`${prefix}-patch.json`), input)
yield* Config.use.update(patch)
const written = yield* fs.readFileString(file)
const normalized = ConfigParse.schema(
ConfigV1.Info,
ConfigV2Compat.lower(ConfigParse.jsonc(written, file)).value,
file,
)
yield* Effect.promise(() => snapshot(`${prefix}-output.json`, written))
yield* Effect.promise(() => snapshot(`${prefix}-normalized.json`, JSON.stringify(normalized, null, 2) + "\n"))
}),
)
}
for (const name of ["opencode.json", "opencode.jsonc"]) {
it.live(`rejects updating ${name} with native permissions without writing it`, () =>
withGlobalConfig(
{ name, config: { permissions: [{ action: "read", resource: "secret-resource", effect: "deny" }] } },
({ dir }) =>
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const file = path.join(dir, name)
const before = yield* fs.readFileString(file)
const exit = yield* Effect.exit(Config.use.updateGlobal({ username: "changed" }))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const error = Cause.squash(exit.cause)
expect(error).toMatchObject({ data: { path: file, issues: [{ path: ["permissions"] }] } })
expect(JSON.stringify(error)).not.toContain("secret-resource")
}
expect(yield* fs.readFileString(file)).toBe(before)
}),
),
)
}
it.instance("rejects a project update with native agent permissions without writing it", () =>
Effect.gen(function* () {
const instance = yield* TestInstance
const fs = yield* FSUtil.Service
const file = path.join(instance.directory, "config.json")
const before = JSON.stringify({ agents: { reviewer: { permissions: [] } } })
yield* fs.writeFileString(file, before)
const exit = yield* Effect.exit(Config.use.update({ username: "changed" }))
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Cause.squash(exit.cause)).toMatchObject({
data: { path: file, issues: [{ path: ["agents", "reviewer", "permissions"] }] },
})
expect(yield* fs.readFileString(file)).toBe(before)
}),
)
it.effect("native project MCP servers override inherited V1 disabled state", () =>
withConfigTree(
{
global: {
mcp: {
shared: { type: "local", command: ["global-mcp"], enabled: false },
},
},
project: {
mcp: {
servers: {
shared: { type: "local", command: ["project-mcp"] },
},
},
},
},
Effect.gen(function* () {
expect((yield* Config.use.get()).mcp?.shared).toMatchObject({
type: "local",
command: ["project-mcp"],
enabled: true,
})
}),
),
)
it.effect("rejects native project permissions even with inherited V1 rules", () =>
withConfigTree(
{
global: {
permission: { read: "deny", bash: "ask" },
agent: { reviewer: { permission: { edit: "deny" } } },
},
project: {
permissions: [{ action: "read", resource: "*", effect: "allow" }],
agents: {
reviewer: {
system: "Review carefully",
permissions: [{ action: "edit", resource: "*", effect: "allow" }],
},
},
},
},
Effect.gen(function* () {
const exit = yield* Effect.exit(Config.use.get())
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit))
expect(Cause.squash(exit.cause)).toMatchObject({
data: {
path: expect.stringContaining("project/opencode.json"),
issues: [
{ path: ["permissions"], message: expect.stringContaining('Use V1 "permission" rules or run opencode2') },
{ path: ["agents", "reviewer", "permissions"], message: expect.stringContaining("not supported") },
],
},
})
}),
),
)
it.instance(
"loads formatter boolean config",
Effect.gen(function* () {
@@ -0,0 +1,40 @@
# Config Transformation Fixtures
Each directory is an operation, with a flat list of files grouped by case-name prefixes. Add a case without adding another
test body; the runners discover `*-input.*` files in sorted order.
## Operations
| Directory | Inputs | Expected outputs | Operation |
| ----------------- | ---------------------------------------------------- | ---------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| `read/` | `<name>-input.jsonc` | `<name>-output.json` | Parse JSONC, lower supported V2 fields, and decode the V1 schema. |
| `update-global/` | `<name>-input.json` or `.jsonc`, `<name>-patch.json` | `<name>-output.json` or `.jsonc`, `<name>-normalized.json` | Write an isolated global file and invoke the real `Config.updateGlobal` service. |
| `update-project/` | `<name>-input.json`, `<name>-patch.json` | `<name>-output.json`, `<name>-normalized.json` | Write an isolated project `config.json` and invoke the real `Config.update` service. |
Read outputs capture the complete decoded document, including V1 schema defaults, but not environment-dependent runtime
defaults such as the OS username. The read runner also checks that lowering did not mutate its input.
For updates, `<name>-output.*` is the exact text written by the service, including comments, formatting, and the presence or
absence of a final newline. `<name>-normalized.json` records the V1 config returned by `updateGlobal`, or the decoded saved file
for project updates (which return no config). Native V2 data can remain in the saved file even when it is absent or has a
different shape in the in-memory V1 output.
These are checked-in expectations, not output generated during ordinary test runs. Missing expectations fail the test.
Focused tests separately cover invalid inputs, diagnostics, secret redaction, logging, and cross-source behavior.
## Run
From `packages/opencode`:
```sh
bun test test/config/v2-compat.test.ts test/config/config.test.ts --timeout 30000
```
To intentionally regenerate expected outputs:
```sh
UPDATE_CONFIG_FIXTURES=1 bun test test/config/v2-compat.test.ts test/config/config.test.ts --timeout 30000
```
Review every changed `*-output.*` and `*-normalized.json` before accepting it. Do not run a formatter on update outputs; their
exact formatting is part of the snapshot. Inputs are authored by hand and are never rewritten by the fixture runner.
@@ -0,0 +1,17 @@
{
"permission": { "bash": "deny", "*": "ask", "edit": "deny" },
"agent": {
"reviewer": { "prompt": "Legacy prompt", "permission": { "bash": "deny", "edit": "deny" } }
},
"agents": {
"reviewer": {
"system": "Native prompt"
},
"native": {}
},
"command": { "review": { "template": "Legacy review" } },
"commands": {
"review": { "template": "Native review" },
"modern": { "template": "Native command" }
}
}
@@ -0,0 +1,29 @@
{
"permission": {
"bash": "deny",
"*": "ask",
"edit": "deny"
},
"agent": {
"reviewer": {
"prompt": "Legacy prompt",
"permission": {
"bash": "deny",
"edit": "deny"
},
"options": {}
},
"native": {
"options": {},
"permission": {}
}
},
"command": {
"review": {
"template": "Legacy review"
},
"modern": {
"template": "Native command"
}
}
}
@@ -0,0 +1,15 @@
{
"agents": {
"reviewer": {
"model": { "providerID": "anthropic", "model": "claude-sonnet", "variant": "thinking" },
"system": "Review carefully.",
"description": "Reviews changes",
"mode": "subagent",
"hidden": true,
"color": "#123abc",
"steps": 4,
"disabled": true
},
"quick": { "model": "openai/gpt-4.1#fast", "disabled": false }
}
}
@@ -0,0 +1,24 @@
{
"agent": {
"reviewer": {
"description": "Reviews changes",
"mode": "subagent",
"hidden": true,
"color": "#123abc",
"steps": 4,
"prompt": "Review carefully.",
"disable": true,
"model": "anthropic/claude-sonnet",
"variant": "thinking",
"options": {},
"permission": {}
},
"quick": {
"disable": false,
"model": "openai/gpt-4.1",
"variant": "fast",
"options": {},
"permission": {}
}
}
}
@@ -0,0 +1,12 @@
{
"commands": {
"review": {
"template": "Review $ARGUMENTS",
"description": "Review code",
"agent": "reviewer",
"model": { "providerID": "anthropic", "model": "claude-sonnet", "variant": "thinking" },
"subtask": true
},
"quick": { "template": "Quick review", "model": "openai/gpt-4.1#fast" }
}
}
@@ -0,0 +1,17 @@
{
"command": {
"review": {
"template": "Review $ARGUMENTS",
"description": "Review code",
"agent": "reviewer",
"model": "anthropic/claude-sonnet",
"subtask": true,
"variant": "thinking"
},
"quick": {
"template": "Quick review",
"model": "openai/gpt-4.1",
"variant": "fast"
}
}
}
@@ -0,0 +1,8 @@
{
"plugins": [{ "package": "@example/native-plugin" }],
"providers": { "native": { "models": { "example": { "name": "Native model" } } } },
"policies": [{ "action": "provider.use", "effect": "deny", "resource": "openai" }],
"websearch": "native-only",
"warming": true,
"experimental": { "portable_shell_scanner": true }
}
@@ -0,0 +1,3 @@
{
"experimental": {}
}
@@ -0,0 +1,8 @@
{
"lsp": {
"typescript": { "command": ["typescript-language-server", "--stdio"] },
"compatible": { "command": ["custom-lsp"], "extensions": [".custom"] },
"incompatible": { "command": ["incompatible-lsp"] },
"disabled": { "disabled": true }
}
}
@@ -0,0 +1,21 @@
{
"lsp": {
"typescript": {
"command": [
"typescript-language-server",
"--stdio"
]
},
"compatible": {
"command": [
"custom-lsp"
],
"extensions": [
".custom"
]
},
"disabled": {
"disabled": true
}
}
}
@@ -0,0 +1,15 @@
{
"mcp": {
"legacy-disabled": { "enabled": false },
"legacy-enabled": { "enabled": true },
"existing": { "type": "local", "command": ["existing-mcp"], "enabled": true },
"flat-disabled": { "type": "local", "command": ["legacy"], "enabled": false, "codemode": false },
"flat-enabled": { "type": "local", "command": ["legacy"], "enabled": true, "disabled": true },
"servers": {
"type": { "type": "local", "command": ["type-mcp"] },
"enabled": { "type": "remote", "url": "https://example.com/mcp" },
"explicit-enabled": { "type": "local", "command": ["enabled-mcp"], "disabled": false },
"explicit-disabled": { "type": "local", "command": ["disabled-mcp"], "disabled": true }
}
}
}
@@ -0,0 +1,57 @@
{
"mcp": {
"legacy-disabled": {
"enabled": false
},
"legacy-enabled": {
"enabled": true
},
"existing": {
"type": "local",
"command": [
"existing-mcp"
],
"enabled": true
},
"flat-disabled": {
"type": "local",
"command": [
"legacy"
],
"enabled": false
},
"flat-enabled": {
"type": "local",
"command": [
"legacy"
],
"enabled": true
},
"type": {
"type": "local",
"command": [
"type-mcp"
],
"enabled": true
},
"enabled": {
"type": "remote",
"url": "https://example.com/mcp",
"enabled": true
},
"explicit-enabled": {
"type": "local",
"command": [
"enabled-mcp"
],
"enabled": true
},
"explicit-disabled": {
"type": "local",
"command": [
"disabled-mcp"
],
"enabled": false
}
}
}
@@ -0,0 +1,11 @@
{
// Flat V1 entries win when an enveloped V2 server has the same name.
"mcp": {
"legacy": { "type": "local", "command": ["legacy-mcp"], "enabled": false },
"shared": { "type": "remote", "url": "https://legacy.example.com/mcp" },
"servers": {
"shared": { "type": "remote", "url": "https://native.example.com/mcp", "disabled": false },
"native": { "type": "local", "command": ["native-mcp"], "disabled": true },
},
},
}
@@ -0,0 +1,22 @@
{
"mcp": {
"legacy": {
"type": "local",
"command": [
"legacy-mcp"
],
"enabled": false
},
"shared": {
"type": "remote",
"url": "https://legacy.example.com/mcp"
},
"native": {
"type": "local",
"command": [
"native-mcp"
],
"enabled": false
}
}
}
@@ -0,0 +1,19 @@
{
"mcp": {
"servers": {
"authenticated": {
"type": "remote",
"url": "https://oauth.example.com/mcp",
"headers": { "Authorization": "Bearer token" },
"oauth": {
"client_id": "client",
"client_secret": "secret",
"scope": "read write",
"callback_port": 19877,
"redirect_uri": "http://127.0.0.1:19877/callback"
}
},
"anonymous": { "type": "remote", "url": "https://anonymous.example.com/mcp", "oauth": false }
}
}
}
@@ -0,0 +1,25 @@
{
"mcp": {
"authenticated": {
"type": "remote",
"url": "https://oauth.example.com/mcp",
"headers": {
"Authorization": "Bearer token"
},
"oauth": {
"clientId": "client",
"clientSecret": "secret",
"scope": "read write",
"callbackPort": 19877,
"redirectUri": "http://127.0.0.1:19877/callback"
},
"enabled": true
},
"anonymous": {
"type": "remote",
"url": "https://anonymous.example.com/mcp",
"oauth": false,
"enabled": true
}
}
}
@@ -0,0 +1,3 @@
{
"mcp": { "timeout": { "startup": 1000, "catalog": 2000 } }
}
@@ -0,0 +1,3 @@
{
"mcp": {}
}
@@ -0,0 +1,7 @@
{
// An object-valued type must not hide a flat enabled-only server.
"mcp": {
"servers": { "type": {}, "enabled": false },
"timeout": { "enabled": true }
}
}
@@ -0,0 +1,10 @@
{
"mcp": {
"servers": {
"enabled": false
},
"timeout": {
"enabled": true
}
}
}
@@ -0,0 +1,6 @@
{
"mcp": {
"servers": { "type": "local", "command": ["server-named-servers"] },
"timeout": { "type": "remote", "url": "https://timeout.example.com/mcp" }
}
}
@@ -0,0 +1,14 @@
{
"mcp": {
"servers": {
"type": "local",
"command": [
"server-named-servers"
]
},
"timeout": {
"type": "remote",
"url": "https://timeout.example.com/mcp"
}
}
}
@@ -0,0 +1,15 @@
{
"mcp": {
"timeout": { "catalog": 8000, "execution": 8000 },
"servers": {
"safe": { "type": "local", "command": ["safe-mcp"], "timeout": { "catalog": 3000, "execution": 3000 } },
"unsafe": { "type": "local", "command": ["unsafe-mcp"], "timeout": { "catalog": 2000, "execution": 4000 } },
"partial": { "type": "local", "command": ["partial-mcp"], "timeout": { "execution": 5000 } },
"startup": {
"type": "local",
"command": ["startup-mcp"],
"timeout": { "startup": 1000, "catalog": 3000, "execution": 3000 }
}
}
}
}
@@ -0,0 +1,36 @@
{
"mcp": {
"safe": {
"type": "local",
"command": [
"safe-mcp"
],
"enabled": true,
"timeout": 3000
},
"unsafe": {
"type": "local",
"command": [
"unsafe-mcp"
],
"enabled": true
},
"partial": {
"type": "local",
"command": [
"partial-mcp"
],
"enabled": true
},
"startup": {
"type": "local",
"command": [
"startup-mcp"
],
"enabled": true
}
},
"experimental": {
"mcp_timeout": 8000
}
}
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"model": { "providerID": "anthropic", "model": "claude-sonnet", "variant": "fast" }
}
@@ -0,0 +1,4 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "anthropic/claude-sonnet"
}
@@ -0,0 +1,4 @@
{
"model": "anthropic/claude-sonnet",
"permission": "deny"
}
@@ -0,0 +1,6 @@
{
"model": "anthropic/claude-sonnet",
"permission": {
"*": "deny"
}
}
@@ -0,0 +1,3 @@
{
"model": "anthropic/claude-sonnet#fast"
}
@@ -0,0 +1,3 @@
{
"model": "anthropic/claude-sonnet"
}
@@ -0,0 +1,12 @@
{
"snapshots": false,
"media": {
"image": { "auto_resize": false, "max_width": 1920, "max_height": 1080, "max_base64_bytes": 4096 }
},
"compaction": { "auto": false, "keep": { "tokens": 12000 }, "buffer": 2048 },
"experimental": {
"subagent_depth": 3,
"policies": [{ "effect": "deny", "action": "provider.use", "resource": "openai" }],
"batch_tool": true
}
}
@@ -0,0 +1,27 @@
{
"compaction": {
"auto": false,
"preserve_recent_tokens": 12000,
"reserved": 2048
},
"experimental": {
"policies": [
{
"effect": "deny",
"action": "provider.use",
"resource": "openai"
}
],
"batch_tool": true
},
"snapshot": false,
"attachment": {
"image": {
"auto_resize": false,
"max_width": 1920,
"max_height": 1080,
"max_base64_bytes": 4096
}
},
"subagent_depth": 3
}
@@ -0,0 +1,15 @@
{
"snapshot": false,
"snapshots": true,
"attachment": { "image": { "max_width": 640 } },
"media": { "image": { "max_width": 1920 } },
"subagent_depth": 1,
"experimental": { "subagent_depth": 3, "mcp_timeout": 4000 },
"compaction": {
"preserve_recent_tokens": 100,
"keep": { "tokens": 200 },
"reserved": 300,
"buffer": 400
},
"mcp": { "timeout": { "catalog": 8000, "execution": 8000 } }
}
@@ -0,0 +1,17 @@
{
"snapshot": false,
"attachment": {
"image": {
"max_width": 640
}
},
"subagent_depth": 1,
"experimental": {
"mcp_timeout": 4000
},
"compaction": {
"preserve_recent_tokens": 100,
"reserved": 300
},
"mcp": {}
}
@@ -0,0 +1,3 @@
{
"skills": ["./skills", "https://example.com/skills", "/opt/skills", "http://localhost:8080/skills"]
}
@@ -0,0 +1,12 @@
{
"skills": {
"paths": [
"./skills",
"/opt/skills"
],
"urls": [
"https://example.com/skills",
"http://localhost:8080/skills"
]
}
}
@@ -0,0 +1,7 @@
{
"$schema": "https://opencode.ai/config.json",
// Empty shell in a global update removes the setting.
"shell": "bash",
"model": { "providerID": "example", "model": "demo" },
"snapshots": false
}
@@ -0,0 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"model": "example/demo",
"snapshot": false
}
@@ -0,0 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
"model": { "providerID": "example", "model": "demo" },
"snapshots": false
}
@@ -0,0 +1,3 @@
{
"shell": ""
}
@@ -0,0 +1,18 @@
{
"$schema": "https://opencode.ai/config.json",
"username": "before",
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": ["modern-mcp"],
"disabled": true
}
}
},
"providers": {
"example": {
"settings": { "apiKey": "fixture-secret" }
}
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/config.json",
"username": "after",
"mcp": {
"modern": {
"type": "local",
"command": [
"modern-mcp"
],
"enabled": false
}
}
}
@@ -0,0 +1,22 @@
{
"$schema": "https://opencode.ai/config.json",
"username": "after",
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": [
"modern-mcp"
],
"disabled": true
}
}
},
"providers": {
"example": {
"settings": {
"apiKey": "fixture-secret"
}
}
}
}
@@ -0,0 +1,3 @@
{
"username": "after"
}
@@ -0,0 +1,19 @@
{
"$schema": "https://opencode.ai/config.json",
// The V1 update must keep comments and native settings.
"username": "before",
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": ["modern-mcp"],
"disabled": true,
},
},
},
"providers": {
"example": {
"settings": { "apiKey": "fixture-secret" },
},
},
}
@@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/config.json",
"username": "after",
"mcp": {
"modern": {
"type": "local",
"command": [
"modern-mcp"
],
"enabled": false
}
}
}
@@ -0,0 +1,19 @@
{
"$schema": "https://opencode.ai/config.json",
// The V1 update must keep comments and native settings.
"username": "after",
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": ["modern-mcp"],
"disabled": true,
},
},
},
"providers": {
"example": {
"settings": { "apiKey": "fixture-secret" },
},
},
}
@@ -0,0 +1,3 @@
{
"username": "after"
}
@@ -0,0 +1,16 @@
{
"$schema": "https://opencode.ai/config.json",
"snapshots": true,
"agents": {
"reviewer": { "disabled": false }
},
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": ["modern-mcp"],
"disabled": false
}
}
}
}
@@ -0,0 +1,19 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"modern": {
"enabled": false
}
},
"snapshot": false,
"permission": {
"read": "deny"
},
"agent": {
"reviewer": {
"disable": true,
"options": {},
"permission": {}
}
}
}
@@ -0,0 +1,34 @@
{
"$schema": "https://opencode.ai/config.json",
"snapshots": true,
"agents": {
"reviewer": {
"disabled": false
}
},
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": [
"modern-mcp"
],
"disabled": false
}
},
"modern": {
"enabled": false
}
},
"snapshot": false,
"permission": {
"read": "deny"
},
"agent": {
"reviewer": {
"disable": true,
"options": {},
"permission": {}
}
}
}
@@ -0,0 +1,6 @@
{
"snapshot": false,
"permission": { "read": "deny" },
"agent": { "reviewer": { "disable": true } },
"mcp": { "modern": { "enabled": false } }
}
@@ -0,0 +1,18 @@
{
"$schema": "https://opencode.ai/config.json",
"username": "before",
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": ["modern-mcp"],
"disabled": true
}
}
},
"providers": {
"example": {
"settings": { "apiKey": "fixture-secret" }
}
}
}
@@ -0,0 +1,13 @@
{
"$schema": "https://opencode.ai/config.json",
"username": "after",
"mcp": {
"modern": {
"type": "local",
"command": [
"modern-mcp"
],
"enabled": false
}
}
}
@@ -0,0 +1,22 @@
{
"$schema": "https://opencode.ai/config.json",
"username": "after",
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": [
"modern-mcp"
],
"disabled": true
}
}
},
"providers": {
"example": {
"settings": {
"apiKey": "fixture-secret"
}
}
}
}
@@ -0,0 +1,3 @@
{
"username": "after"
}
@@ -0,0 +1,16 @@
{
"$schema": "https://opencode.ai/config.json",
"snapshots": true,
"agents": {
"reviewer": { "disabled": false }
},
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": ["modern-mcp"],
"disabled": false
}
}
}
}
@@ -0,0 +1,17 @@
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"modern": {
"enabled": false
}
},
"snapshot": false,
"agent": {
"reviewer": {
"disable": true,
"options": {},
"permission": {}
}
},
"shell": ""
}
@@ -0,0 +1,32 @@
{
"$schema": "https://opencode.ai/config.json",
"snapshots": true,
"agents": {
"reviewer": {
"disabled": false
}
},
"mcp": {
"servers": {
"modern": {
"type": "local",
"command": [
"modern-mcp"
],
"disabled": false
}
},
"modern": {
"enabled": false
}
},
"snapshot": false,
"agent": {
"reviewer": {
"disable": true,
"options": {},
"permission": {}
}
},
"shell": ""
}
@@ -0,0 +1,6 @@
{
"snapshot": false,
"agent": { "reviewer": { "disable": true } },
"mcp": { "modern": { "enabled": false } },
"shell": ""
}
@@ -0,0 +1,6 @@
import { expect } from "bun:test"
export async function snapshot(file: string, actual: string) {
if (process.env.UPDATE_CONFIG_FIXTURES === "1") await Bun.write(file, actual)
expect(actual).toBe(await Bun.file(file).text())
}
@@ -0,0 +1,400 @@
import { describe, expect, test } from "bun:test"
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { httpClient } from "@opencode-ai/core/effect/app-node-platform"
import { FSUtil } from "@opencode-ai/core/fs-util"
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
import { Npm } from "@opencode-ai/core/npm"
import { Effect, Layer, Logger } from "effect"
import { HttpClient } from "effect/unstable/http"
import path from "path"
import { Account } from "../../src/account/account"
import { Auth } from "../../src/auth"
import { Config } from "../../src/config/config"
import { ConfigParse } from "../../src/config/parse"
import { ConfigV2Compat } from "../../src/config/v2-compat"
import { Env } from "../../src/env"
import { AccountTest } from "../fake/account"
import { AuthTest } from "../fake/auth"
import { NpmTest } from "../fake/npm"
import { TestInstance } from "../fixture/fixture"
import { testEffect } from "../lib/effect"
import { snapshot } from "./snapshot"
const source = "test:v2-compat"
const lower = (input: unknown) => ConfigParse.schema(ConfigV1.Info, ConfigV2Compat.lower(input, source).value, source)
const it = testEffect(
LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [
[Auth.node, AuthTest.empty],
[Account.node, AccountTest.empty],
[Npm.node, NpmTest.noop],
[
httpClient,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`)),
),
],
]),
)
describe("V2 compatibility read fixtures", () => {
const directory = path.join(import.meta.dir, "fixtures/v2-compat/read")
const cases = Array.from(new Bun.Glob("*-input.jsonc").scanSync(directory))
.map((file) => file.slice(0, -"-input.jsonc".length))
.sort()
if (!cases.length) throw new Error("No V2 compatibility read fixtures found")
cases.forEach((name) => {
test(name, async () => {
const source = path.join(directory, `${name}-input.jsonc`)
const input = ConfigParse.jsonc(await Bun.file(source).text(), source)
const original = structuredClone(input)
const result = ConfigV2Compat.lower(input, source)
expect(input).toEqual(original)
const config = ConfigParse.schema(ConfigV1.Info, result.value, source)
await snapshot(path.join(directory, `${name}-output.json`), JSON.stringify(config, null, 2) + "\n")
})
})
})
describe("ConfigV2Compat.lower", () => {
test("returns structured invalid diagnostics while retaining supported siblings", () => {
const result = ConfigV2Compat.lower({
mcp: {
servers: {
broken: { type: "local", command: "not-an-array" },
working: { type: "local", command: ["working-mcp"] },
},
},
agents: { broken: { steps: "many" } },
commands: { broken: { template: 42 } },
})
expect(result.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "invalid", path: ["mcp", "servers", "broken"] }),
expect.objectContaining({ kind: "invalid", path: ["agents", "broken"] }),
expect.objectContaining({ kind: "invalid", path: ["commands", "broken"] }),
]),
)
expect(ConfigParse.schema(ConfigV1.Info, result.value, source).mcp).toEqual({
working: { type: "local", command: ["working-mcp"], enabled: true },
})
})
test("reports unsupported settings and lossy conversions without their values", () => {
const secret = "do-not-log-credentials"
const result = ConfigV2Compat.lower({
model: { providerID: "example", model: "model", variant: "high" },
plugins: [{ package: "native-plugin", options: { token: secret } }],
providers: { example: { settings: { apiKey: secret } } },
websearch: secret,
warming: true,
experimental: { portable_shell_scanner: true },
agents: { reviewer: { request: { headers: { Authorization: secret } } } },
mcp: {
servers: {
remote: {
type: "remote",
url: `https://example.com/?token=${secret}`,
oauth: { client_secret: secret },
codemode: false,
timeout: { execution: 60000 },
},
},
},
lsp: { custom: { command: ["custom-lsp"] } },
})
expect(result.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "unsupported", path: ["model", "variant"] }),
expect.objectContaining({ kind: "unsupported", path: ["plugins"] }),
expect.objectContaining({ kind: "unsupported", path: ["providers"] }),
expect.objectContaining({ kind: "unsupported", path: ["websearch"] }),
expect.objectContaining({ kind: "unsupported", path: ["warming"] }),
expect.objectContaining({ kind: "unsupported", path: ["experimental", "portable_shell_scanner"] }),
expect.objectContaining({ kind: "unsupported", path: ["agents", "reviewer", "request", "headers"] }),
expect.objectContaining({ kind: "unsupported", path: ["mcp", "servers", "remote", "codemode"] }),
expect.objectContaining({ kind: "unsupported", path: ["mcp", "servers", "remote", "timeout"] }),
expect.objectContaining({ kind: "unsupported", path: ["lsp", "custom"] }),
]),
)
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("reports conflicting forms while retaining the V1 value", () => {
const result = ConfigV2Compat.lower({
snapshot: false,
snapshots: true,
command: { review: { template: "Legacy review" } },
commands: { review: { template: "Native review" } },
mcp: {
shared: { type: "local", command: ["legacy"] },
servers: { shared: { type: "local", command: ["native"] } },
},
})
const config = ConfigParse.schema(ConfigV1.Info, result.value, source)
expect(config.snapshot).toBe(false)
expect(config.command?.review.template).toBe("Legacy review")
expect(config.mcp?.shared).toEqual({ type: "local", command: ["legacy"] })
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toHaveLength(3)
})
test("does not diagnose ordinary V1 configuration or reject invalid V1 roots early", () => {
expect(ConfigV2Compat.lower({ snapshot: false, mcp: { existing: { enabled: false } } }).diagnostics).toEqual([])
expect(ConfigV2Compat.lower({ snapshot: false, snapshots: false }).diagnostics).toEqual([])
const result = ConfigV2Compat.lower(null)
expect(result.value).toBeNull()
expect(() => ConfigParse.schema(ConfigV1.Info, result.value, source)).toThrow()
expect(() => lower({ snapshot: "invalid", snapshots: true })).toThrow()
})
test("keeps malformed MCP servers named servers and timeout for V1 validation", () => {
expect(() => lower({ mcp: { servers: { type: "local", command: "invalid" } } })).toThrow()
expect(() => lower({ mcp: { timeout: { type: "remote", url: 42 } } })).toThrow()
expect(() => lower({ mcp: { servers: { type: "bogus" } } })).toThrow()
expect(() => lower({ mcp: { servers: { type: 42 } } })).toThrow()
expect(() => lower({ mcp: { servers: { enabled: "false" } } })).toThrow()
})
test("rejects invalid V1 enablement when flat MCP entries include V2 fields", () => {
expect(() =>
lower({ mcp: { invalid: { type: "local", command: ["legacy"], enabled: "false", codemode: false } } }),
).toThrow("ConfigInvalidError")
})
test("does not repair malformed V1 containers or shadowed entries with V2 values", () => {
const cases = [
{ agent: null, agents: { reviewer: { system: "Native prompt" } } },
{ command: [], commands: { review: { template: "Native command" } } },
{ attachment: false, media: { image: { auto_resize: true } } },
{ experimental: null, mcp: { timeout: { catalog: 3000, execution: 3000 } } },
{ agent: { reviewer: 42 }, agents: { reviewer: { system: "Native prompt" } } },
{ command: { review: 42 }, commands: { review: { template: "Native command" } } },
{ mcp: { shared: 42, servers: { shared: { type: "local", command: ["native"] } } } },
]
cases.forEach((input) => expect(() => lower(input)).toThrow("ConfigInvalidError"))
})
test("keeps secrets out of invalid and conflict diagnostics", () => {
const secret = "secret-never-in-diagnostics"
const result = ConfigV2Compat.lower({
commands: { malformed: { template: { token: secret } } },
mcp: {
shared: { type: "remote", url: "https://example.com", headers: { Authorization: secret } },
servers: {
shared: { type: "remote", url: "https://example.com", headers: { Authorization: `${secret}-changed` } },
malformed: { type: "remote", url: `https://example.com?token=${secret}`, disabled: secret },
},
},
})
expect(result.diagnostics).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: "conflict", path: ["mcp", "servers", "shared"] }),
expect.objectContaining({ kind: "invalid", path: ["commands", "malformed"] }),
expect.objectContaining({ kind: "invalid", path: ["mcp", "servers", "malformed"] }),
]),
)
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("rejects any native permission field, including empty and malformed values", () => {
const cases = [
[{ action: "shell", resource: "*", effect: "deny" }],
[
{ action: "read", resource: "*", effect: "allow" },
{ action: "*", resource: "*", effect: "deny" },
{ action: "read", resource: "public", effect: "allow" },
],
[],
null,
"secret-permission-value",
[{ action: "read", resource: "secret-permission-value", effect: "invalid" }],
]
cases.forEach((permissions) => {
expect(() => lower({ username: "keep-me", permissions })).toThrow("ConfigInvalidError")
expect(() => lower({ permission: "deny", permissions })).toThrow("ConfigInvalidError")
})
})
test("does not repair invalid legacy permissions with native rules", () => {
expect(() => lower({ permission: { read: "invalid" }, permissions: [] })).toThrow("ConfigInvalidError")
})
test("rejects native agent permissions before decoding or applying V1 precedence", () => {
const cases = [
{ agents: { reviewer: { system: "Review carefully", permissions: [{ action: "read" }] } } },
{ agents: { reviewer: { disabled: true, permissions: [] } } },
{ agent: { reviewer: { permission: "deny" } }, agents: { reviewer: { permissions: [] } } },
{ agents: { reviewer: { steps: "invalid", permissions: [] } } },
{ agent: { reviewer: { permissions: [] } } },
{ mode: { reviewer: { permissions: [] } } },
]
cases.forEach((input) => expect(() => lower(input)).toThrow("ConfigInvalidError"))
})
test("continues to support V1 permission rules", () => {
const config = lower({
permission: { bash: "deny", "*": "ask", edit: "allow" },
agent: { reviewer: { permission: { edit: "deny" } } },
})
expect(config.permission).toEqual({ bash: "deny", "*": "ask", edit: "allow" })
expect(Object.keys(config.permission ?? {})).toEqual(["bash", "*", "edit"])
expect(config.agent?.reviewer?.permission).toEqual({ edit: "deny" })
})
test("does not sanitize malformed V1 fields before schema validation", () => {
expect(() => lower({ model: 42 })).toThrow()
expect(() => lower({ snapshot: "enabled" })).toThrow()
expect(() => lower({ mcp: { broken: { type: "local", command: "not-an-array" } } })).toThrow()
expect(() => lower({ experimental: { mcp_timeout: -1 } })).toThrow()
})
test("does not mutate the input or nested configuration objects", () => {
const input = {
model: { providerID: "anthropic", model: "claude-sonnet", variant: "fast" },
snapshots: true,
skills: ["./skills", "https://example.com/skills"],
mcp: {
existing: { type: "local", command: ["existing-mcp"], enabled: false },
servers: {
native: {
type: "remote",
url: "https://example.com/mcp",
disabled: true,
oauth: { client_id: "client" },
timeout: { execution: 3000 },
},
},
},
agents: { reviewer: { model: "anthropic/claude-sonnet#thinking", disabled: true } },
experimental: { subagent_depth: 2 },
}
const original = structuredClone(input)
lower(input)
expect(input).toEqual(original)
})
})
describe("V2 configuration loading", () => {
it.instance("logs compatibility diagnostics without writing the lowered projection", () =>
Effect.gen(function* () {
const instance = yield* TestInstance
const fs = yield* FSUtil.Service
const file = path.join(instance.directory, "opencode.jsonc")
const text =
'{\n // Retain this comment\n "$schema": "https://opencode.ai/config.json",\n "plugins": ["native-only"]\n}\n'
yield* fs.writeWithDirs(file, text)
const messages: unknown[] = []
const config = yield* Config.use.get().pipe(
Effect.provide(
Logger.layer([
Logger.make<unknown, void>((options) => {
messages.push(options.message)
}),
]),
),
)
expect(config.plugin).toEqual([])
expect(messages).toContainEqual([
"configuration compatibility diagnostic",
expect.objectContaining({ source: file, kind: "unsupported", path: ["plugins"] }),
])
expect(yield* fs.readFileString(file)).toBe(text)
}),
)
it.instance("loads native V2 configuration through the V1 Config service", () =>
Effect.gen(function* () {
const instance = yield* TestInstance
const fs = yield* FSUtil.Service
yield* fs.writeWithDirs(
path.join(instance.directory, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: { providerID: "anthropic", model: "claude-sonnet", variant: "fast" },
snapshots: false,
skills: ["./skills", "https://example.com/skills"],
mcp: {
timeout: { catalog: 9000, execution: 9000 },
servers: {
native: { type: "remote", url: "https://native.example.com/mcp", disabled: false },
},
},
agents: {
reviewer: {
model: "anthropic/claude-sonnet#thinking",
system: "Review carefully.",
},
},
commands: {
review: {
template: "Review $ARGUMENTS",
model: { providerID: "anthropic", model: "claude-sonnet", variant: "thinking" },
},
},
experimental: { subagent_depth: 2 },
}),
)
const config = yield* Config.use.get()
expect(config.model).toBe("anthropic/claude-sonnet")
expect(config.snapshot).toBe(false)
expect(config.skills).toEqual({ paths: ["./skills"], urls: ["https://example.com/skills"] })
expect(config.mcp?.native).toEqual({ type: "remote", url: "https://native.example.com/mcp", enabled: true })
expect(config.experimental?.mcp_timeout).toBe(9000)
expect(config.permission).toBeUndefined()
expect(config.agent?.reviewer).toMatchObject({
model: "anthropic/claude-sonnet",
variant: "thinking",
prompt: "Review carefully.",
permission: {},
})
expect(config.command?.review).toMatchObject({
template: "Review $ARGUMENTS",
model: "anthropic/claude-sonnet",
variant: "thinking",
})
expect(config.subagent_depth).toBe(2)
}),
)
it.instance("keeps legacy TUI normalization when loading a mixed V1 and V2 document", () =>
Effect.gen(function* () {
const instance = yield* TestInstance
const fs = yield* FSUtil.Service
yield* fs.writeWithDirs(
path.join(instance.directory, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
model: { providerID: "openai", model: "gpt-4.1" },
theme: "legacy",
keybinds: { leader: "ctrl+x" },
tui: { scroll_speed: 4 },
mcp: {
legacy: { enabled: false },
servers: { native: { type: "local", command: ["native-mcp"] } },
},
}),
)
const config = yield* Config.use.get()
expect(config.model).toBe("openai/gpt-4.1")
expect(config.mcp?.legacy).toEqual({ enabled: false })
expect(config.mcp?.native).toEqual({ type: "local", command: ["native-mcp"], enabled: true })
expect(config).not.toHaveProperty("theme")
expect(config).not.toHaveProperty("keybinds")
expect(config).not.toHaveProperty("tui")
}),
)
})