Compare commits

..

4 Commits

Author SHA1 Message Date
Dax 5cf97f1b96 feat(core): normalize mixed config formats (#40919) 2026-08-06 17:11:13 -04:00
Kit Langton 65c6a71903 fix(tui): simplify MCP status rows (#40916) 2026-08-06 16:50:58 -04:00
Kit Langton a779027303 fix(tui): compact single-line prompts (#40924) 2026-08-06 16:46:24 -04:00
Dax 20fa444f31 fix(config): omit unset optional values (#40918) 2026-08-06 13:06:03 -07:00
37 changed files with 4099 additions and 5103 deletions
+109 -131
View File
@@ -1693,44 +1693,43 @@ export type AgentInfo = {
export type ConfigEntry =
| {
type: "document"
path?: string | null
path?: string
info: {
$schema?: string | null
shell?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
default_agent?: string | null
autoupdate?: boolean | "notify" | null
share?: "manual" | "auto" | "disabled" | null
enterprise?: { url?: string | null } | null
username?: string | null
permissions?: PermissionRuleset | null
$schema?: string
shell?: string
model?: string | { providerID: string; model: string; variant?: string }
default_agent?: string
autoupdate?: boolean | "notify"
share?: "manual" | "auto" | "disabled"
enterprise?: { url?: string }
username?: string
permissions?: PermissionRuleset
agents?: {
[x: string]: {
model?: string | { providerID: string; model: string; variant?: string | null } | null
request?: { headers?: { [x: string]: string } | null; body?: { [x: string]: JsonValue } | null } | null
system?: string | null
description?: string | null
mode?: "subagent" | "primary" | "all" | null
hidden?: boolean | null
color?: string | null
steps?: number | null
disabled?: boolean | null
permissions?: PermissionRuleset | null
model?: string | { providerID: string; model: string; variant?: string }
request?: { headers?: { [x: string]: string }; body?: { [x: string]: JsonValue } }
system?: string
description?: string
mode?: "subagent" | "primary" | "all"
hidden?: boolean
color?: string
steps?: number
disabled?: boolean
permissions?: PermissionRuleset
}
} | null
snapshots?: boolean | null
watcher?: { ignore?: Array<string> | null } | null
}
snapshots?: boolean
watcher?: { ignore?: Array<string> }
formatter?:
| boolean
| {
[x: string]: {
disabled?: boolean | null
command?: Array<string> | null
environment?: { [x: string]: string } | null
extensions?: Array<string> | null
disabled?: boolean
command?: Array<string>
environment?: { [x: string]: string }
extensions?: Array<string>
}
}
| null
lsp?:
| boolean
| {
@@ -1738,125 +1737,117 @@ export type ConfigEntry =
| { disabled: true }
| {
command: Array<string>
extensions?: Array<string> | null
disabled?: boolean | null
env?: { [x: string]: string } | null
initialization?: { [x: string]: JsonValue } | null
extensions?: Array<string>
disabled?: boolean
env?: { [x: string]: string }
initialization?: { [x: string]: JsonValue }
}
}
| null
media?: {
image?: {
auto_resize?: boolean | null
max_width?: number | null
max_height?: number | null
max_base64_bytes?: number | null
} | null
} | null
tool_output?: { max_lines?: number | null; max_bytes?: number | null } | null
image?: { auto_resize?: boolean; max_width?: number; max_height?: number; max_base64_bytes?: number }
}
tool_output?: { max_lines?: number; max_bytes?: number }
mcp?: {
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
timeout?: { startup?: number; catalog?: number; execution?: number }
servers?: {
[x: string]:
| {
type: "local"
command: Array<string>
cwd?: string | null
environment?: { [x: string]: string } | null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
cwd?: string
environment?: { [x: string]: string }
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
}
| {
type: "remote"
url: string
headers?: { [x: string]: string } | null
headers?: { [x: string]: string }
oauth?:
| {
client_id?: string | null
client_secret?: string | null
scope?: string | null
callback_port?: number | null
redirect_uri?: string | null
client_id?: string
client_secret?: string
scope?: string
callback_port?: number
redirect_uri?: string
}
| false
| null
disabled?: boolean | null
codemode?: boolean | null
timeout?: { startup?: number | null; catalog?: number | null; execution?: number | null } | null
disabled?: boolean
codemode?: boolean
timeout?: { startup?: number; catalog?: number; execution?: number }
}
} | null
} | null
compaction?: { auto?: boolean | null; keep?: { tokens?: number | null } | null; buffer?: number | null } | null
skills?: Array<string> | null
}
}
compaction?: { auto?: boolean; keep?: { tokens?: number }; buffer?: number }
skills?: Array<string>
commands?: {
[x: string]: {
template: string
description?: string | null
agent?: string | null
model?: string | { providerID: string; model: string; variant?: string | null } | null
subtask?: boolean | null
description?: string
agent?: string
model?: string | { providerID: string; model: string; variant?: string }
subtask?: boolean
}
} | null
instructions?: Array<string> | null
}
instructions?: Array<string>
references?: {
[x: string]:
| string
| { repository: string; branch?: string | null; description?: string | null; hidden?: boolean | null }
| { path: string; description?: string | null; hidden?: boolean | null }
} | null
websearch?: { provider: string } | null
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } | null }> | null
warming?: boolean | { prompt?: string | null; interval?: string | null; duration?: string | null } | null
| { repository: string; branch?: string; description?: string; hidden?: boolean }
| { path: string; description?: string; hidden?: boolean }
}
websearch?: { provider: string }
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
[x: string]: {
name?: string | null
env?: Array<string> | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
name?: string
env?: Array<string>
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
models?: {
[x: string]: {
modelID?: string | null
family?: string | null
name?: string | null
compatibility?: ModelCompatibility | null
package?: string | null
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
capabilities?: ModelCapabilities | null
modelID?: string
family?: string
name?: string
compatibility?: ModelCompatibility
package?: string
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
capabilities?: ModelCapabilities
variants?: Array<{
id: string
settings?: { [x: string]: JsonValue } | null
headers?: { [x: string]: string } | null
body?: { [x: string]: JsonValue } | null
}> | null
settings?: { [x: string]: JsonValue }
headers?: { [x: string]: string }
body?: { [x: string]: JsonValue }
}>
cost?:
| {
tier?: { type: "context"; size: number } | null
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
}
| Array<{
tier?: { type: "context"; size: number } | null
tier?: { type: "context"; size: number }
input: MoneyUSDPerMillionTokens
output: MoneyUSDPerMillionTokens
cache?: { read?: MoneyUSDPerMillionTokens | null; write?: MoneyUSDPerMillionTokens | null } | null
cache?: { read?: MoneyUSDPerMillionTokens; write?: MoneyUSDPerMillionTokens }
}>
| null
disabled?: boolean | null
limit?: { context?: number | null; input?: number | null; output?: number | null } | null
disabled?: boolean
limit?: { context?: number; input?: number; output?: number }
}
} | null
}
}
} | null
}
experimental?: {
subagent_depth?: number | null
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }> | null
} | null
subagent_depth?: number
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
}
}
| { type: "directory"; path: string }
@@ -3242,41 +3233,28 @@ export type McpAddInput = {
| {
readonly type: "local"
readonly command: ReadonlyArray<string>
readonly cwd?: string | undefined
readonly environment?: { readonly [x: string]: string } | undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
readonly cwd?: string
readonly environment?: { readonly [x: string]: string }
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
}
| {
readonly type: "remote"
readonly url: string
readonly headers?: { readonly [x: string]: string } | undefined
readonly headers?: { readonly [x: string]: string }
readonly oauth?:
| {
readonly client_id?: string | undefined
readonly client_secret?: string | undefined
readonly scope?: string | undefined
readonly callback_port?: number | undefined
readonly redirect_uri?: string | undefined
readonly client_id?: string
readonly client_secret?: string
readonly scope?: string
readonly callback_port?: number
readonly redirect_uri?: string
}
| false
| undefined
readonly disabled?: boolean | undefined
readonly codemode?: boolean | undefined
readonly timeout?:
| {
readonly startup?: number | undefined
readonly catalog?: number | undefined
readonly execution?: number | undefined
}
| undefined
readonly disabled?: boolean
readonly codemode?: boolean
readonly timeout?: { readonly startup?: number; readonly catalog?: number; readonly execution?: number }
}
}["config"]
}
+34 -16
View File
@@ -24,8 +24,7 @@ import { Global } from "@opencode-ai/util/global"
import { Location } from "./location"
import { AbsolutePath } from "./schema"
import { ConfigVariable } from "./config/variable"
import { ConfigV1 } from "./v1/config/config"
import { ConfigMigrateV1 } from "./v1/config/migrate"
import { ConfigNormalize } from "./config/normalize"
import { WellKnown } from "./wellknown"
export function latest<K extends keyof Info>(entries: readonly Entry[], key: K): Info[K] | undefined {
@@ -93,24 +92,43 @@ export const layer = (options?: Options) => Layer.effect(
const reloadLock = Semaphore.makeUnsafe(1)
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const decodeV1Info = Schema.decodeUnknownOption(ConfigV1.Info, decodeOptions)
const parseInfo = (text: string) => {
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
const errors: ParseError[] = []
const input: unknown = parse(text, errors, { allowTrailingComma: true })
if (errors.length) return
return Option.getOrUndefined(
ConfigMigrateV1.isV1(input)
? decodeV1Info(input).pipe(Option.map(ConfigMigrateV1.migrate), Option.flatMap(decodeInfo))
: decodeInfo(input),
if (errors.length) {
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected malformed JSON or JSONC document",
})
return
}
const result = ConfigNormalize.normalize(input)
yield* Effect.forEach(result.diagnostics, (diagnostic) =>
Effect.logWarning("configuration normalization diagnostic", {
source,
path: diagnostic.path[0] === "$" ? "$" : `$.${diagnostic.path.join(".")}`,
kind: diagnostic.kind,
action: diagnostic.message,
}),
)
}
if (result.type === "rejected") return
const info = Option.getOrUndefined(decodeInfo(result.encoded))
if (info) return info
yield* Effect.logWarning("configuration normalization diagnostic", {
source,
path: "$",
kind: "invalid",
action: "rejected canonical configuration after final validation",
})
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (!text) return
if (text === undefined) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = parseInfo(substituted)
const info = yield* parseInfo(substituted, filepath)
if (!info) return
return new Document({ type: "document", path: filepath, info })
})
@@ -141,7 +159,7 @@ export const layer = (options?: Options) => Layer.effect(
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.map(parseInfo),
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
@@ -218,14 +236,14 @@ export const layer = (options?: Options) => Layer.effect(
Effect.orDie,
)
: []
const content = options?.content
const content = options?.content !== undefined
? yield* ConfigVariable.substitute({
type: "virtual",
source: "OPENCODE_CONFIG_CONTENT",
dir: location.directory,
text: options.content,
}).pipe(
Effect.map(parseInfo),
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.orDie,
)
+796
View File
@@ -0,0 +1,796 @@
export * as ConfigNormalize from "./normalize"
import { isDeepStrictEqual } from "node:util"
import { Option, Schema } from "effect"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { ConfigCommand } from "@opencode-ai/schema/config/command"
import { ConfigCompaction } from "@opencode-ai/schema/config/compaction"
import { ConfigFormatter } from "@opencode-ai/schema/config/formatter"
import { ConfigLSP } from "@opencode-ai/schema/config/lsp"
import { ConfigMedia } from "@opencode-ai/schema/config/media"
import { ConfigMCP } from "@opencode-ai/schema/config/mcp"
import { ConfigPlugin } from "@opencode-ai/schema/config/plugin"
import { ConfigPolicy } from "@opencode-ai/schema/config/policy"
import { ConfigProvider } from "@opencode-ai/schema/config/provider"
import { ConfigReference } from "@opencode-ai/schema/config/reference"
import { ConfigExperimental } from "@opencode-ai/schema/config/experimental"
import { Permission } from "@opencode-ai/schema/permission"
import { ConfigAgentV1 } from "../v1/config/agent"
import { ConfigAttachmentV1 } from "../v1/config/attachment"
import { ConfigCommandV1 } from "../v1/config/command"
import { ConfigMCPV1 } from "../v1/config/mcp"
import { ConfigPermissionV1 } from "../v1/config/permission"
import { ConfigPluginV1 } from "../v1/config/plugin"
import { ConfigProviderV1 } from "../v1/config/provider"
import { ConfigMigrateV1 } from "../v1/config/migrate"
import { PositiveInt } from "../schema"
export interface Diagnostic {
readonly kind: "conflict" | "invalid" | "unsupported"
readonly path: readonly string[]
readonly message: string
}
export type Result =
| {
readonly type: "normalized"
readonly encoded: Readonly<Record<string, unknown>>
readonly diagnostics: readonly Diagnostic[]
}
| { readonly type: "rejected"; readonly diagnostics: readonly Diagnostic[] }
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const unsupportedTopLevel = ["logLevel", "server", "small_model", "subagent_depth", "layout"] as const
const unsupportedExperimental = [
"disable_paste_summary",
"batch_tool",
"openTelemetry",
"primary_tools",
"continue_loop_on_deny",
] as const
const unsupportedProvider = ["id", "whitelist", "blacklist"] as const
const unsupportedModel = ["release_date", "attachment", "reasoning", "temperature", "experimental"] as const
export function normalize(input: unknown): Result {
if (!isRecord(input))
return {
type: "rejected",
diagnostics: [
{ kind: "invalid", path: ["$"], message: "rejected configuration because its root is not an object" },
],
}
const diagnostics: Diagnostic[] = []
const encoded: Record<string, unknown> = {}
unsupportedTopLevel.forEach((key) => unsupportedIfPresent(input, key, [key], diagnostics))
const legacySnapshots = own(input, "snapshot")
? decodeEncoded(Schema.Boolean, input.snapshot, ["snapshot"], diagnostics)
: undefined
const legacyShare = own(input, "autoshare")
? decodeValue(Schema.Boolean, input.autoshare, ["autoshare"], diagnostics) === true
? "auto"
: undefined
: undefined
const legacyMedia = own(input, "attachment")
? decodeValue(ConfigAttachmentV1.Info, input.attachment, ["attachment"], diagnostics)
: undefined
if (legacyMedia !== undefined) {
const migrated = ConfigMigrateV1.migrate({ attachment: legacyMedia }).media
if (migrated !== undefined) encoded.media = canonical(ConfigMedia.Info, migrated)
}
if (legacySnapshots !== undefined) encoded.snapshots = legacySnapshots
if (legacyShare !== undefined) encoded.share = legacyShare
const legacyReferences = decodeEncodedMap(input.reference, ConfigReference.Entry, ["reference"], diagnostics)
const nativeReferences = decodeEncodedMap(input.references, ConfigReference.Entry, ["references"], diagnostics)
mergeMap(
encoded,
"references",
legacyReferences,
nativeReferences,
isRecord(input.reference) || isRecord(input.references),
diagnostics,
)
const legacyCommands = decodeMap(input.command, ConfigCommandV1.Info, ["command"], diagnostics)
diagnoseSelectionMap(input.command, ["command"], diagnostics)
const migratedCommands = mapValues(legacyCommands, (value) => {
const migrated = ConfigMigrateV1.commands({ value })?.value
return migrated === undefined ? undefined : canonical(ConfigCommand.Info, migrated)
})
const nativeCommands = decodeEncodedMap(input.commands, ConfigCommand.Info, ["commands"], diagnostics)
mergeMap(
encoded,
"commands",
migratedCommands,
nativeCommands,
isRecord(input.command) || isRecord(input.commands),
diagnostics,
)
const legacyAgents = mapValues(decodeMap(input.agent, ConfigAgentV1.Info, ["agent"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent(value)),
)
const modeAgents = mapValues(decodeMap(input.mode, ConfigAgentV1.Info, ["mode"], diagnostics), (value) =>
canonical(ConfigAgent.Info, ConfigMigrateV1.migrateAgent({ ...value, mode: "primary" })),
)
const migratedAgents = mergeMaps(legacyAgents, modeAgents, ["agents"], diagnostics)
const nativeAgents = decodeEncodedMap(input.agents, ConfigAgent.Info, ["agents"], diagnostics)
diagnoseAgentUnsupported(input.agent, ["agent"], diagnostics)
diagnoseAgentUnsupported(input.mode, ["mode"], diagnostics)
mergeMap(
encoded,
"agents",
migratedAgents,
nativeAgents,
isRecord(input.agent) || isRecord(input.mode) || isRecord(input.agents),
diagnostics,
)
const legacyProviders = migrateProviders(input.provider, diagnostics)
const nativeProviders = decodeEncodedMap(input.providers, ConfigProvider.Info, ["providers"], diagnostics)
mergeMap(
encoded,
"providers",
legacyProviders,
nativeProviders,
isRecord(input.provider) || isRecord(input.providers),
diagnostics,
)
const toolRules = migrateTools(input.tools, diagnostics)
const permissionRules = migratePermissions(input.permission, diagnostics)
const nativePermissions = decodeEncodedList(input.permissions, Permission.Rule, ["permissions"], diagnostics)
const permissions = [...toolRules, ...permissionRules, ...nativePermissions]
if (permissions.length || Array.isArray(input.permissions)) encoded.permissions = permissions
const legacyPlugins = decodeList(input.plugin, ConfigPluginV1.Spec, ["plugin"], diagnostics).map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
)
const nativePlugins = decodeEncodedList(input.plugins, ConfigPlugin.Plugin, ["plugins"], diagnostics)
if (legacyPlugins.length || nativePlugins.length || Array.isArray(input.plugin) || Array.isArray(input.plugins))
encoded.plugins = [...legacyPlugins, ...nativePlugins]
normalizeSkills(input, encoded, diagnostics)
normalizeMcp(input, encoded, diagnostics)
normalizeCompaction(input, encoded, diagnostics)
normalizeExperimental(input, encoded, diagnostics)
normalizeWatcher(input, encoded, diagnostics)
normalizeFormatter(input, encoded, diagnostics)
normalizeLsp(input, encoded, diagnostics)
const nativeAtomic = {
$schema: Info.fields.$schema,
shell: Info.fields.shell,
model: Info.fields.model,
default_agent: Info.fields.default_agent,
autoupdate: Info.fields.autoupdate,
share: Info.fields.share,
enterprise: Info.fields.enterprise,
username: Info.fields.username,
snapshots: Info.fields.snapshots,
media: Info.fields.media,
tool_output: Info.fields.tool_output,
websearch: Info.fields.websearch,
warming: Info.fields.warming,
}
Object.entries(nativeAtomic).forEach(([key, schema]) => {
if (!own(input, key)) return
const value = decodeEncoded(schema, input[key], [key], diagnostics)
if (value === undefined) return
overlay(encoded, key, value, [key], diagnostics)
})
const instructions = decodeEncodedList(input.instructions, Schema.String, ["instructions"], diagnostics)
if (instructions.length || Array.isArray(input.instructions)) encoded.instructions = instructions
return { type: "normalized", encoded, diagnostics }
}
function normalizeSkills(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "skills")) return
if (Array.isArray(input.skills)) {
encoded.skills = decodeEncodedList(input.skills, Schema.String, ["skills"], diagnostics)
return
}
if (!isRecord(input.skills)) {
invalid(["skills"], diagnostics)
return
}
encoded.skills = [
...decodeEncodedList(input.skills.paths, Schema.String, ["skills", "paths"], diagnostics),
...decodeEncodedList(input.skills.urls, Schema.String, ["skills", "urls"], diagnostics),
]
}
function normalizeMcp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
const legacyServers: Record<string, unknown> = {}
const nativeServers: Record<string, unknown> = {}
const timeout: Record<string, unknown> = {}
if (isRecord(input.experimental) && own(input.experimental, "mcp_timeout")) {
const value = decodeEncoded(
PositiveInt,
input.experimental.mcp_timeout,
["experimental", "mcp_timeout"],
diagnostics,
)
if (value !== undefined) {
timeout.catalog = value
timeout.execution = value
}
}
if (own(input, "mcp")) {
if (!isRecord(input.mcp)) invalid(["mcp"], diagnostics)
if (isRecord(input.mcp)) {
Object.entries(input.mcp).forEach(([name, value]) => {
const path = ["mcp", name]
if (isEnabledOnlyMcp(value)) {
diagnostics.push({ kind: "unsupported", path, message: "omitted enabled-only legacy MCP entry" })
return
}
if (name === "servers" && !isDirectLegacyMcp(value)) {
Object.entries(decodeEncodedMap(value, ConfigMCP.Server, path, diagnostics)).forEach(([key, server]) =>
setOwn(nativeServers, key, server),
)
return
}
if (name === "timeout" && !isDirectLegacyMcp(value)) {
normalizeMcpTimeout(value, timeout, path, diagnostics)
return
}
const server = decodeValue(ConfigMCPV1.Info, value, path, diagnostics)
if (server !== undefined)
setOwn(legacyServers, name, canonical(ConfigMCP.Server, ConfigMigrateV1.migrateMcp(server)))
})
}
}
const servers = mergeMaps(legacyServers, nativeServers, ["mcp", "servers"], diagnostics)
if (!Object.keys(servers).length && !Object.keys(timeout).length) {
if (isRecord(input.mcp) && !Object.keys(input.mcp).length) encoded.mcp = {}
return
}
encoded.mcp = {
...(Object.keys(timeout).length ? { timeout } : {}),
...(Object.keys(servers).length ? { servers } : {}),
}
}
function normalizeMcpTimeout(
value: unknown,
timeout: Record<string, unknown>,
path: string[],
diagnostics: Diagnostic[],
) {
if (!isRecord(value)) {
invalid(path, diagnostics)
return
}
const recognized = ["startup", "catalog", "execution"].filter((key) => own(value, key))
if (Object.keys(value).length && !recognized.length) {
invalid(path, diagnostics)
return
}
recognized.forEach((key) => {
const leaf = decodeEncoded(
ConfigMCP.Timeout.fields[key as keyof typeof ConfigMCP.Timeout.fields],
value[key],
[...path, key],
diagnostics,
)
if (leaf === undefined) return
overlay(timeout, key, leaf, [...path, key], diagnostics)
})
}
function normalizeCompaction(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
if (!own(input, "compaction")) return
if (!isRecord(input.compaction)) {
invalid(["compaction"], diagnostics)
return
}
unsupportedIfPresent(input.compaction, "tail_turns", ["compaction", "tail_turns"], diagnostics)
unsupportedIfPresent(input.compaction, "prune", ["compaction", "prune"], diagnostics)
const result: Record<string, unknown> = {}
if (own(input.compaction, "auto")) {
const value = decodeEncoded(
ConfigCompaction.Info.fields.auto,
input.compaction.auto,
["compaction", "auto"],
diagnostics,
)
if (value !== undefined) result.auto = value
}
const legacyTokens = own(input.compaction, "preserve_recent_tokens")
? decodeEncoded(
ConfigCompaction.Keep.fields.tokens,
input.compaction.preserve_recent_tokens,
["compaction", "preserve_recent_tokens"],
diagnostics,
)
: undefined
const nativeKeep = isRecord(input.compaction.keep) ? input.compaction.keep : undefined
if (own(input.compaction, "keep") && !nativeKeep) invalid(["compaction", "keep"], diagnostics)
const nativeTokens =
nativeKeep && own(nativeKeep, "tokens")
? decodeEncoded(
ConfigCompaction.Keep.fields.tokens,
nativeKeep.tokens,
["compaction", "keep", "tokens"],
diagnostics,
)
: undefined
const tokens = prefer(legacyTokens, nativeTokens, ["compaction", "keep", "tokens"], diagnostics)
if (tokens !== undefined) result.keep = { tokens }
const legacyBuffer = own(input.compaction, "reserved")
? decodeEncoded(
ConfigCompaction.Info.fields.buffer,
input.compaction.reserved,
["compaction", "reserved"],
diagnostics,
)
: undefined
const nativeBuffer = own(input.compaction, "buffer")
? decodeEncoded(ConfigCompaction.Info.fields.buffer, input.compaction.buffer, ["compaction", "buffer"], diagnostics)
: undefined
const buffer = prefer(legacyBuffer, nativeBuffer, ["compaction", "buffer"], diagnostics)
if (buffer !== undefined) result.buffer = buffer
if (Object.keys(result).length || !Object.keys(input.compaction).length) encoded.compaction = result
}
function normalizeExperimental(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
const result: Record<string, unknown> = {}
const generated: unknown[] = []
const enabled = decodeProviderList(input, "enabled_providers", diagnostics)
if (enabled.present && (!enabled.nonEmpty || enabled.values.length)) {
generated.push({ action: "provider.use", resource: "*", effect: "deny" })
generated.push(
...enabled.values.map((resource) => ({
action: "provider.use",
resource: ConfigMigrateV1.providerID(resource),
effect: "allow",
})),
)
}
const disabled = decodeProviderList(input, "disabled_providers", diagnostics)
generated.push(
...disabled.values.map((resource) => ({
action: "provider.use",
resource: ConfigMigrateV1.providerID(resource),
effect: "deny",
})),
)
const native: unknown[] = []
if (own(input, "experimental")) {
if (!isRecord(input.experimental)) invalid(["experimental"], diagnostics)
if (isRecord(input.experimental)) {
const experimental = input.experimental
unsupportedExperimental.forEach((key) =>
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
)
if (own(experimental, "subagent_depth")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.subagent_depth,
experimental.subagent_depth,
["experimental", "subagent_depth"],
diagnostics,
)
if (value !== undefined) result.subagent_depth = value
}
native.push(
...decodeEncodedList(experimental.policies, ConfigPolicy.Info, ["experimental", "policies"], diagnostics),
)
}
}
if (generated.length || native.length || (isRecord(input.experimental) && Array.isArray(input.experimental.policies)))
result.policies = [...generated, ...native]
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
encoded.experimental = result
}
function normalizeWatcher(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "watcher")) return
if (!isRecord(input.watcher)) {
invalid(["watcher"], diagnostics)
return
}
const ignore = decodeEncodedList(input.watcher.ignore, Schema.String, ["watcher", "ignore"], diagnostics)
encoded.watcher = ignore.length || Array.isArray(input.watcher.ignore) ? { ignore } : {}
}
function normalizeFormatter(
input: Record<string, unknown>,
encoded: Record<string, unknown>,
diagnostics: Diagnostic[],
) {
if (!own(input, "formatter")) return
if (typeof input.formatter === "boolean") {
const value = decodeEncoded(ConfigFormatter.Info, input.formatter, ["formatter"], diagnostics)
if (value !== undefined) encoded.formatter = value
return
}
const entries = decodeEncodedMap(input.formatter, ConfigFormatter.Entry, ["formatter"], diagnostics)
if (isRecord(input.formatter) && (!Object.keys(input.formatter).length || Object.keys(entries).length))
encoded.formatter = entries
}
function normalizeLsp(input: Record<string, unknown>, encoded: Record<string, unknown>, diagnostics: Diagnostic[]) {
if (!own(input, "lsp")) return
if (typeof input.lsp === "boolean") {
const value = decodeEncoded(ConfigLSP.Info, input.lsp, ["lsp"], diagnostics)
if (value !== undefined) encoded.lsp = value
return
}
const entries = decodeEncodedMap(input.lsp, ConfigLSP.Entry, ["lsp"], diagnostics)
if (isRecord(input.lsp) && (!Object.keys(input.lsp).length || Object.keys(entries).length)) encoded.lsp = entries
}
function migrateTools(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return []
if (!isRecord(value)) {
invalid(["tools"], diagnostics)
return []
}
return Object.entries(value).flatMap(([action, raw]) => {
const enabled = decodeValue(Schema.Boolean, raw, ["tools", action], diagnostics)
if (enabled === undefined) return []
return [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect: enabled ? "allow" : "deny" }]
})
}
function migratePermissions(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return []
if (typeof value === "string") {
const effect = decodeValue(ConfigPermissionV1.Action, value, ["permission"], diagnostics)
return effect === undefined ? [] : [{ action: "*", resource: "*", effect }]
}
if (!isRecord(value)) {
invalid(["permission"], diagnostics)
return []
}
return Object.entries(value).flatMap(([action, raw]) => {
if (typeof raw === "string") {
const effect = decodeValue(ConfigPermissionV1.Action, raw, ["permission", action], diagnostics)
return effect === undefined ? [] : [{ action: ConfigMigrateV1.normalizeAction(action), resource: "*", effect }]
}
if (!isRecord(raw)) {
invalid(["permission", action], diagnostics)
return []
}
return Object.entries(raw).flatMap(([resource, effect], index) => {
const decoded = decodeValue(ConfigPermissionV1.Action, effect, ["permission", action, String(index)], diagnostics)
return decoded === undefined
? []
: [{ action: ConfigMigrateV1.normalizeAction(action), resource, effect: decoded }]
})
})
}
function migrateProviders(value: unknown, diagnostics: Diagnostic[]) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(["provider"], diagnostics)
return {}
}
const candidates = Object.entries(value).flatMap(([name, raw]) => {
const path = ["provider", name]
diagnoseProviderUnsupported(raw, path, diagnostics)
if (invalidProviderOverlays(raw, path, diagnostics)) return []
const provider = decodeValue(ConfigProviderV1.Info, raw, path, diagnostics)
if (provider === undefined) return []
const destination = ConfigMigrateV1.providerID(name)
return [
{
name,
destination,
provider: canonical(ConfigProvider.Info, ConfigMigrateV1.migrateProvider(name, provider)),
},
]
})
const current = new Set(candidates.filter((item) => item.name === item.destination).map((item) => item.destination))
const result: Record<string, unknown> = {}
candidates.forEach((item) => {
if (item.name !== item.destination && current.has(item.destination)) return
setOwn(result, item.destination, item.provider)
})
return result
}
function invalidProviderOverlays(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value) || !isRecord(value.options)) return false
const headersInvalid =
own(value.options, "headers") &&
(!isPlainRecord(value.options.headers) ||
Object.values(value.options.headers).some((item) => typeof item !== "string"))
const bodyInvalid = own(value.options, "body") && !isPlainRecord(value.options.body)
if (headersInvalid) invalid([...path, "options", "headers"], diagnostics)
if (bodyInvalid) invalid([...path, "options", "body"], diagnostics)
return headersInvalid || bodyInvalid
}
function diagnoseProviderUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
unsupportedProvider.forEach((key) => unsupportedIfPresent(value, key, [...path, key], diagnostics))
if (!isRecord(value.models)) return
Object.entries(value.models).forEach(([name, model]) => {
if (!isRecord(model)) return
unsupportedModel.forEach((key) => unsupportedIfPresent(model, key, [...path, "models", name, key], diagnostics))
if (own(model, "status") && model.status !== "deprecated")
unsupportedIfPresent(model, "status", [...path, "models", name, "status"], diagnostics)
if (own(model, "interleaved") && typeof model.interleaved === "boolean")
unsupportedIfPresent(model, "interleaved", [...path, "models", name, "interleaved"], diagnostics)
})
}
function diagnoseAgentUnsupported(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
Object.entries(value).forEach(([name, agent]) => {
if (!isRecord(agent)) return
unsupportedIfPresent(agent, "name", [...path, name, "name"], diagnostics)
diagnoseSelection(agent, [...path, name], diagnostics)
})
}
function diagnoseSelectionMap(value: unknown, path: string[], diagnostics: Diagnostic[]) {
if (!isRecord(value)) return
Object.entries(value).forEach(([name, entry]) => {
if (isRecord(entry)) diagnoseSelection(entry, [...path, name], diagnostics)
})
}
function diagnoseSelection(value: Record<string, unknown>, path: string[], diagnostics: Diagnostic[]) {
const modelValid = typeof value.model === "string" && /^[^/#]+\/[^#]+$/.test(value.model)
if (own(value, "model") && typeof value.model === "string" && !modelValid)
diagnostics.push({
kind: "unsupported",
path: [...path, "model"],
message: "omitted unsupported legacy model reference",
})
if (
own(value, "variant") &&
typeof value.variant === "string" &&
(!modelValid || value.variant.length === 0 || value.variant.includes("#"))
)
diagnostics.push({
kind: "unsupported",
path: [...path, "variant"],
message: "omitted unsupported legacy model variant",
})
}
function decodeProviderList(
input: Record<string, unknown>,
key: "enabled_providers" | "disabled_providers",
diagnostics: Diagnostic[],
) {
if (!own(input, key)) return { present: false, nonEmpty: false, values: [] as string[] }
if (!Array.isArray(input[key])) {
invalid([key], diagnostics)
return { present: true, nonEmpty: true, values: [] as string[] }
}
return {
present: true,
nonEmpty: input[key].length > 0,
values: decodeList(input[key], Schema.String, [key], diagnostics),
}
}
function decodeEncodedMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return {}
if (!isRecord(value)) {
invalid(path, diagnostics)
return {}
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeEncoded(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
)
}
function decodeMap<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return {} as Record<string, S["Type"]>
if (!isRecord(value)) {
invalid(path, diagnostics)
return {} as Record<string, S["Type"]>
}
return Object.fromEntries(
Object.entries(value).flatMap(([name, raw]) => {
const decoded = decodeValue(schema, raw, [...path, name], diagnostics)
return decoded === undefined ? [] : [[name, decoded]]
}),
) as Record<string, S["Type"]>
}
function decodeEncodedList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Encoded"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Encoded"][]
}
return value.flatMap((item, index) => {
const decoded = decodeEncoded(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeList<S extends Schema.Codec<unknown, unknown, never, never>>(
value: unknown,
schema: S,
path: string[],
diagnostics: Diagnostic[],
) {
if (value === undefined) return [] as S["Type"][]
if (!Array.isArray(value)) {
invalid(path, diagnostics)
return [] as S["Type"][]
}
return value.flatMap((item, index) => {
const decoded = decodeValue(schema, item, [...path, String(index)], diagnostics)
return decoded === undefined ? [] : [decoded]
})
}
function decodeValue<S extends Schema.Codec<unknown, unknown, never, never>>(
schema: S,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
const decoded = Schema.decodeUnknownOption(schema, options)(value)
if (Option.isSome(decoded)) return decoded.value
invalid(path, diagnostics)
return undefined
}
function decodeEncoded<S extends Schema.Codec<unknown, unknown, never, never>>(
schema: S,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
const decoded = Schema.decodeUnknownOption(schema, options)(value)
if (Option.isNone(decoded)) {
invalid(path, diagnostics)
return undefined
}
const encoded = Schema.encodeUnknownOption(schema, options)(decoded.value)
if (Option.isSome(encoded)) return plain(encoded.value)
invalid(path, diagnostics)
return undefined
}
function canonical<S extends Schema.Codec<unknown, unknown, never, never>>(schema: S, value: unknown) {
return plain(
Option.getOrThrow(
Schema.decodeUnknownOption(
schema,
options,
)(plain(value)).pipe(Option.flatMap((decoded) => Schema.encodeUnknownOption(schema, options)(decoded))),
),
)
}
function plain(value: unknown): unknown {
if (Array.isArray(value)) return value.map(plain)
if (!isRecord(value)) return value
return Object.fromEntries(
Object.entries(value).flatMap(([key, item]) => (item === undefined ? [] : [[key, plain(item)]])),
)
}
function mergeMap(
target: Record<string, unknown>,
key: string,
legacy: Readonly<Record<string, unknown>>,
native: Readonly<Record<string, unknown>>,
present: boolean,
diagnostics: Diagnostic[],
) {
const merged = mergeMaps(legacy, native, [key], diagnostics)
if (present) target[key] = merged
}
function mergeMaps(
legacy: Readonly<Record<string, unknown>>,
native: Readonly<Record<string, unknown>>,
path: string[],
diagnostics: Diagnostic[],
) {
const result = Object.fromEntries(Object.entries(legacy))
Object.entries(native).forEach(([name, value]) => {
if (own(result, name) && !isDeepStrictEqual(result[name], value)) conflict([...path, name], diagnostics)
setOwn(result, name, value)
})
return result
}
function mapValues<A>(input: Readonly<Record<string, A>>, map: (value: A) => unknown) {
return Object.fromEntries(
Object.entries(input).flatMap(([key, value]) => {
const mapped = map(value)
return mapped === undefined ? [] : [[key, mapped]]
}),
)
}
function overlay(
target: Record<string, unknown>,
key: string,
value: unknown,
path: string[],
diagnostics: Diagnostic[],
) {
if (own(target, key) && !isDeepStrictEqual(target[key], value)) conflict(path, diagnostics)
target[key] = value
}
function prefer(legacy: unknown, native: unknown, path: string[], diagnostics: Diagnostic[]) {
if (native === undefined) return legacy
if (legacy !== undefined && !isDeepStrictEqual(legacy, native)) conflict(path, diagnostics)
return native
}
function unsupportedIfPresent(value: Record<string, unknown>, key: string, path: string[], diagnostics: Diagnostic[]) {
if (!own(value, key)) return
diagnostics.push({ kind: "unsupported", path, message: "omitted unsupported legacy setting" })
}
function invalid(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "invalid", path, message: "skipped malformed recognized value" })
}
function conflict(path: string[], diagnostics: Diagnostic[]) {
diagnostics.push({ kind: "conflict", path, message: "retained native value over legacy value" })
}
function isDirectLegacyMcp(value: unknown) {
return isRecord(value) && (value.type === "local" || value.type === "remote")
}
function isEnabledOnlyMcp(value: unknown) {
return isRecord(value) && !own(value, "type") && typeof value.enabled === "boolean"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
if (!isRecord(value)) return false
const prototype = Object.getPrototypeOf(value)
return prototype === Object.prototype || prototype === null
}
function own(value: Record<string, unknown>, key: string) {
return Object.prototype.hasOwnProperty.call(value, key)
}
function setOwn(value: Record<string, unknown>, key: string, item: unknown) {
Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true })
}
-1
View File
@@ -42,6 +42,5 @@ export const migrations = (
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260806200000_import_next_credentials"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -1,89 +0,0 @@
import path from "node:path"
import { existsSync } from "node:fs"
import { sql } from "drizzle-orm"
import { Effect, Option, Schema } from "effect"
import { Credential } from "@opencode-ai/schema/credential"
import { Global } from "@opencode-ai/util/global"
import type { DatabaseMigration } from "../migration"
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
const decodeValue = Schema.decodeUnknownOption(Credential.Value)
export default {
id: "20260806200000_import_next_credentials",
up(tx) {
return importNextCredentials(tx, path.join(Global.Path.data, "opencode-next.db"))
},
} satisfies DatabaseMigration.Migration
/**
* The next channel stored credentials in its own `opencode-next.db` before the
* channel databases were consolidated into `opencode.db`. The legacy import
* only reads V1 `auth.json`, so a credential that existed only in the previous
* channel database was silently dropped. Copy those rows over, keeping any
* credential the target database already has for the same integration.
*/
export function importNextCredentials(tx: Parameters<DatabaseMigration.Migration["up"]>[0], sourcePath: string) {
return Effect.gen(function* () {
if (!existsSync(sourcePath)) return
for (const row of yield* readSourceCredentials(sourcePath)) {
const integrationID = typeof row.integration_id === "string" && row.integration_id.length ? row.integration_id : undefined
if (!integrationID) continue
if (typeof row.value !== "string") continue
const json = Option.getOrUndefined(decodeJson(row.value))
if (json === undefined || Option.isNone(decodeValue(json))) continue
if (yield* tx.get(sql`SELECT id FROM credential WHERE integration_id = ${integrationID}`)) continue
const now = Date.now()
yield* tx.run(sql`
INSERT OR IGNORE INTO credential (
id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated
) VALUES (
${typeof row.id === "string" && row.id.length ? row.id : Credential.ID.create()},
${integrationID},
${typeof row.label === "string" && row.label.length ? row.label : "default"},
${row.value},
${typeof row.connector_id === "string" ? row.connector_id : null},
${typeof row.method_id === "string" ? row.method_id : null},
${typeof row.active === "number" ? row.active : null},
${typeof row.time_created === "number" ? row.time_created : now},
${typeof row.time_updated === "number" ? row.time_updated : now}
)
`)
}
})
}
type SourceRow = Record<string, unknown>
// An unreadable or incompatible source database skips the import instead of
// failing the migration and blocking startup; the source is never modified.
function readSourceCredentials(sourcePath: string) {
return Effect.scoped(
Effect.gen(function* () {
const sqlite = yield* Effect.promise(() => import("bun:sqlite"))
const source = yield* Effect.acquireRelease(
Effect.try({
try: () => new sqlite.Database(sourcePath, { readonly: true, strict: true }),
catch: (error) => error,
}),
(database) => Effect.sync(() => database.close()),
)
return yield* Effect.try({
try: () => {
const table = source
.query("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'credential'")
.get()
if (!table) return [] as SourceRow[]
return source.query<SourceRow, []>("SELECT * FROM credential").all()
},
catch: (error) => error,
})
}),
).pipe(
Effect.catch((error) =>
Effect.logWarning("Skipped incompatible opencode-next.db credentials", { path: sourcePath, error }).pipe(
Effect.as([] as SourceRow[]),
),
),
)
}
+70 -91
View File
@@ -1,5 +1,8 @@
export * as ConfigMigrateV1 from "./migrate"
import { Info } from "@opencode-ai/schema/config"
import { ConfigAgent } from "@opencode-ai/schema/config/agent"
import { Schema } from "effect"
import { ConfigV1 } from "./config"
import { ConfigAgentV1 } from "./agent"
import { ConfigCommandV1 } from "./command"
@@ -10,80 +13,52 @@ import { ConfigProviderOptionsV1 } from "./provider-options"
import { Provider } from "../../provider"
import { Model } from "../../model"
const keys = new Set([
"logLevel",
"server",
"command",
"reference",
"snapshot",
"plugin",
"autoshare",
"disabled_providers",
"enabled_providers",
"small_model",
"mode",
"agent",
"provider",
"permission",
"tools",
"attachment",
"layout",
])
export function isV1(input: unknown) {
if (typeof input !== "object" || input === null || Array.isArray(input)) return false
const record = input as Record<string, unknown>
if (Object.keys(record).some((key) => keys.has(key))) return true
// `mcp` exists in both versions, so presence alone is ambiguous: v1 lists servers directly under
// `mcp`, while v2 nests them under `mcp.servers`. Only the v1 shape (a server entry with `type`)
// counts, so a bare `mcp`-only file still migrates instead of silently parsing to zero servers.
const mcp = record.mcp
return (
typeof mcp === "object" &&
mcp !== null &&
!Array.isArray(mcp) &&
!("servers" in mcp) &&
Object.values(mcp).some((server) => typeof server === "object" && server !== null && "type" in server)
)
}
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeOptions)
const encodeInfo = Schema.encodeSync(Info)
const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions)
const encodeAgent = Schema.encodeSync(ConfigAgent.Info)
export function migrate(info: typeof ConfigV1.Info.Type) {
return {
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
return encodeInfo(
decodeInfo(
JSON.stringify({
$schema: info.$schema,
shell: info.shell,
model: modelSelection(info.model),
default_agent: info.default_agent,
autoupdate: info.autoupdate,
share: info.share ?? (info.autoshare ? "auto" : undefined),
enterprise: info.enterprise,
username: info.username,
permissions: permissions(info.permission, info.tools),
agents: agents(info),
snapshots: info.snapshot,
watcher: info.watcher,
formatter: info.formatter,
lsp: info.lsp,
media: info.attachment,
tool_output: info.tool_output,
mcp: mcp(info),
compaction: info.compaction && {
auto: info.compaction.auto,
prune: info.compaction.prune,
keep: {
tokens: info.compaction.preserve_recent_tokens,
},
buffer: info.compaction.reserved,
},
skills: info.skills && [...(info.skills.paths ?? []), ...(info.skills.urls ?? [])],
commands: commands(info.command),
instructions: info.instructions,
references: info.references ?? info.reference,
experimental: experimental(info),
plugins: info.plugin?.map((plugin) =>
typeof plugin === "string" ? plugin : { package: plugin[0], options: plugin[1] },
),
providers: providers(info.provider),
}),
),
providers: providers(info.provider),
}
)
}
function experimental(info: typeof ConfigV1.Info.Type) {
@@ -132,7 +107,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly<Record<str
}
// Map v1 permission/tool keys onto their renamed v2 tool actions so migrated rules keep matching.
function normalizeAction(action: string) {
export function normalizeAction(action: string) {
if (action === "write" || action === "patch") return "edit"
if (action === "task") return "subagent"
if (action === "bash") return "shell"
@@ -154,21 +129,25 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
...(info.temperature === undefined ? {} : { temperature: info.temperature }),
...(info.top_p === undefined ? {} : { top_p: info.top_p }),
}
return {
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}
return encodeAgent(
decodeAgent(
JSON.stringify({
model: modelSelection(info.model, info.variant),
request: Object.keys(body).length ? { body } : undefined,
system: info.prompt,
description: info.description,
mode: info.mode,
hidden: info.hidden,
color: info.color === undefined ? undefined : info.color.startsWith("#") ? info.color : "#aaaaaa",
steps: info.steps,
disabled: info.disable,
permissions: permissions(info.permission),
}),
),
)
}
function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
export function commands(info?: Readonly<Record<string, ConfigCommandV1.Info>>) {
if (!info) return undefined
return Object.fromEntries(
Object.entries(info).map(([id, command]) => [
@@ -205,7 +184,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers }
}
function migrateMcp(info: ConfigMCPV1.Info) {
export function migrateMcp(info: ConfigMCPV1.Info) {
const disabled = info.enabled === undefined ? undefined : !info.enabled
if (info.type === "local")
return {
@@ -244,7 +223,7 @@ function providers(info?: Readonly<Record<string, ConfigProviderV1.Info>>) {
)
}
function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
export function migrateProvider(sourceID: string, info: ConfigProviderV1.Info) {
if (sourceID === "azure-cognitive-services") return migrateAzureCognitiveServicesProvider(info)
if (sourceID === "google-vertex-anthropic") return migrateGoogleVertexAnthropicProvider(info)
return migrateStandardProvider(info)
@@ -256,7 +235,7 @@ function migrateStandardProvider(info: ConfigProviderV1.Info) {
name: info.name,
env: info.env,
package: info.npm ? Provider.aisdk(info.npm) : undefined,
settings: info.api ? { ...options.settings, baseURL: info.api } : options.settings,
settings: info.api ? { ...options.settings, baseURL: info.api } : info.options ? options.settings : undefined,
headers: info.options && options.headers,
body: info.options && options.body,
models:
@@ -300,8 +279,8 @@ function migrateGoogleVertexAnthropicProvider(info: ConfigProviderV1.Info) {
}
}
// Rename these only in files detected as V1 by a field that exists only in the old config format.
function providerID(input: string) {
// Rename these only while migrating unambiguous V1 fields.
export function providerID(input: string) {
if (input === "azure-cognitive-services") return "azure"
if (input === "google-vertex-anthropic") return "google-vertex"
return input
+94 -25
View File
@@ -1,7 +1,7 @@
import path from "path"
import fs from "fs/promises"
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, PubSub, Schema, Stream } from "effect"
import { Effect, Fiber, Layer, Logger, PubSub, Schema, Stream } from "effect"
import { FastCheck } from "effect/testing"
import { Config } from "@opencode-ai/core/config"
import { AgentsDirectory, Directory, Document, Event, Info } from "@opencode-ai/schema/config"
@@ -307,7 +307,7 @@ describe("Config", () => {
}),
)
it.live("loads authenticated wellknown config at highest priority", () =>
it.live("loads authenticated wellknown config below project config", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
@@ -370,7 +370,7 @@ describe("Config", () => {
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
expect(Config.latest(yield* config.entries(), "shell")).toBe("secret")
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
const updated = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
@@ -378,7 +378,7 @@ describe("Config", () => {
key = "next"
yield* bus.publish(Integration.Event.ConnectionUpdated, { integrationID })
expect(yield* Fiber.join(updated)).toHaveLength(1)
expect(Config.latest(yield* config.entries(), "shell")).toBe("next")
expect(Config.latest(yield* config.entries(), "shell")).toBe("project")
}).pipe(
Effect.provide(testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode)),
)
@@ -387,27 +387,96 @@ describe("Config", () => {
),
)
it.effect("detects v1 configuration from any v1-only top-level key", () =>
Effect.sync(() => {
expect(ConfigMigrateV1.isV1({ snapshot: false })).toBe(true)
expect(ConfigMigrateV1.isV1({ snapshot: false, agents: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ reference: {} })).toBe(true)
expect(ConfigMigrateV1.isV1({ shell: "/bin/zsh", model: "anthropic/claude" })).toBe(false)
expect(ConfigMigrateV1.isV1({ references: {} })).toBe(false)
}),
)
it.live("logs redacted source-aware diagnostics for every config source", () => {
const output: Array<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration normalization diagnostic") return
const details = entry.message[1]
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
})
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const malformed = path.join(tmp.path, "malformed.json")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(path.join(global, "opencode.json"), "null")
await fs.writeFile(path.join(project, "opencode.json"), "")
await fs.writeFile(malformed, '{ "credential": "file-secret"')
})
const integrationID = Integration.ID.make("https://invalid.example.com")
const entry: WellKnown.Entry = {
origin: "https://invalid.example.com",
integrationID,
manifest: { auth: { command: ["login"], env: "TOKEN" } },
}
const credentialNode = makeGlobalNode({
service: Credential.Service,
layer: Layer.succeed(
Credential.Service,
Credential.Service.of({
all: () => Effect.die("unused Credential.all"),
list: () =>
Effect.succeed([
new Credential.Info({
id: Credential.ID.create(),
integrationID,
label: "default",
value: Credential.Key.make({ type: "key", key: "wellknown-secret" }),
}),
]),
get: () => Effect.die("unused Credential.get"),
create: () => Effect.die("unused Credential.create"),
update: () => Effect.die("unused Credential.update"),
remove: () => Effect.die("unused Credential.remove"),
}),
),
deps: [],
})
const wellknownNode = makeGlobalNode({
service: WellKnown.Service,
layer: Layer.succeed(
WellKnown.Service,
WellKnown.Service.of({
entries: () => Effect.succeed([entry]),
snapshot: () => [entry],
refresh: () => Effect.succeed(false),
add: () => Effect.die("unused Wellknown.add"),
remove: () => Effect.die("unused Wellknown.remove"),
// Exercise the loader boundary against a malformed implementation response.
resolve: () => Effect.succeed([null as unknown as WellKnown.Config]),
}),
),
deps: [],
})
it.effect("detects a bare v1-shaped mcp block while leaving v2 mcp config alone", () =>
Effect.sync(() => {
// V1 lists servers directly under `mcp`, so a file with only `$schema` + `mcp` still migrates.
expect(ConfigMigrateV1.isV1({ mcp: { context7: { type: "local", command: ["npx"] } } })).toBe(true)
expect(ConfigMigrateV1.isV1({ $schema: "x", mcp: { executor: { type: "remote", url: "https://x" } } })).toBe(true)
// Current config nests under `mcp.servers`, so it must not be misdetected and re-migrated.
expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false)
}),
)
yield* Config.Service.use((config) => config.entries()).pipe(
Effect.provide(
testLayer(project, global, project, undefined, undefined, credentialNode, wellknownNode, {
file: malformed,
content: "",
}),
),
)
expect(output.map((item) => `${item.source}:${item.path}:${item.kind}`).toSorted()).toEqual(
[
`${path.join(global, "opencode.json")}:$:invalid`,
`${path.join(project, "opencode.json")}:$:invalid`,
`${malformed}:$:invalid`,
"https://invalid.example.com:$:invalid",
"OPENCODE_CONFIG_CONTENT:$:invalid",
].toSorted(),
)
expect(JSON.stringify(output)).not.toContain("secret")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.provide(Logger.layer([logger])))
})
it.effect("migrates arbitrary v1 configuration into valid v2 configuration", () =>
Effect.sync(() => {
@@ -516,12 +585,12 @@ describe("Config", () => {
})
expect(migrated.providers?.["azure-cognitive-services"]).toBeUndefined()
expect(migrated.providers?.["google-vertex"]).toMatchObject({
package: undefined,
settings: { project: "test-project", location: "us-central1" },
models: {
"claude-sonnet": { package: Provider.aisdk("@ai-sdk/google-vertex/anthropic") },
},
})
expect(migrated.providers?.["google-vertex"]).not.toHaveProperty("package")
expect(migrated.providers?.["google-vertex-anthropic"]).toBeUndefined()
}),
)
@@ -0,0 +1,489 @@
import { describe, expect, test } from "bun:test"
import { Duration, Schema } from "effect"
import { FastCheck } from "effect/testing"
import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
import { Info } from "@opencode-ai/schema/config"
const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
function normalized(input: unknown) {
const result = ConfigNormalize.normalize(input)
expect(result.type).toBe("normalized")
if (result.type !== "normalized") throw new Error("expected normalized config")
return result
}
function decoded(input: unknown) {
return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
}
function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
const result = structuredClone(input)
if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
const mcp = result.mcp as Record<string, unknown>
const originallyEmpty = !Object.keys(mcp).length
for (const key of ["servers", "timeout"]) {
if (
typeof mcp[key] === "object" &&
mcp[key] !== null &&
!Array.isArray(mcp[key]) &&
!Object.keys(mcp[key]).length
)
delete mcp[key]
}
if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
}
if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
const compaction = result.compaction as Record<string, unknown>
const originallyEmpty = !Object.keys(compaction).length
if (
typeof compaction.keep === "object" &&
compaction.keep !== null &&
!Array.isArray(compaction.keep) &&
!Object.keys(compaction.keep).length
)
delete compaction.keep
if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
}
return result
}
describe("ConfigNormalize", () => {
test("rejects every non-object root with one root diagnostic", () => {
for (const input of [null, [], "config", true, 1]) {
expect(ConfigNormalize.normalize(input)).toEqual({
type: "rejected",
diagnostics: [
{
kind: "invalid",
path: ["$"],
message: "rejected configuration because its root is not an object",
},
],
})
}
})
test("keeps unrelated native fields when a legacy field is present", () => {
const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
expect(result.snapshots).toBe(false)
expect(result.agents?.reviewer?.system).toBe("Use V2")
})
test("canonicalizes transformed native values through decode then encode", () => {
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
const info = Schema.decodeUnknownSync(Info)(result.encoded)
if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
})
test("preserves arbitrary JSON-round-tripped native configuration", () => {
FastCheck.assert(
FastCheck.property(Schema.toArbitrary(Info), (info) => {
const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
const result = normalized(source)
expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
)
}),
{ numRuns: 100 },
)
})
test("merges named maps by entry and gives valid native entries precedence", () => {
const result = normalized({
reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
references: { native: { path: "../native" }, duplicate: { path: "../new" } },
command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
commands: { native: { template: "native" }, duplicate: { template: "new" } },
})
expect(result.encoded.references).toEqual({
legacy: { path: "../legacy" },
native: { path: "../native" },
duplicate: { path: "../new" },
})
expect(result.encoded.commands).toEqual({
legacy: { template: "legacy" },
native: { template: "native" },
duplicate: { template: "new" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["references", "duplicate"],
["commands", "duplicate"],
])
})
test("does not report canonical-equal duplicates as conflicts", () => {
const result = normalized({
snapshot: false,
snapshots: false,
reference: { docs: { path: "../docs" } },
references: { docs: { path: "../docs" } },
agent: { reviewer: { prompt: "same" } },
agents: { reviewer: { system: "same" } },
provider: { custom: { name: "same" } },
providers: { custom: { name: "same" } },
compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
})
test("uses agent then mode then native agent precedence", () => {
const result = normalized({
agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
})
expect(result.encoded.agents).toEqual({
reviewer: { system: "native" },
agentOnly: { system: "agent-only" },
modeOnly: { system: "mode-only", mode: "primary" },
nativeOnly: { system: "native-only" },
})
expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
["agents", "reviewer"],
["agents", "reviewer"],
])
expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
})
test("recovers malformed named entries and retains a valid legacy collision", () => {
const result = normalized({
command: { fallback: { template: "legacy" } },
commands: {
fallback: { template: 1 },
valid: { template: "native" },
invalid: { template: false },
},
providers: {
valid: { name: "Valid" },
invalid: { env: [1] },
},
})
expect(result.encoded.commands).toEqual({ fallback: { template: "legacy" }, valid: { template: "native" } })
expect(result.encoded.providers).toEqual({ valid: { name: "Valid" } })
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["commands", "fallback"],
["commands", "invalid"],
["providers", "invalid"],
])
})
test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => {
const result = normalized({
provider: {
"azure-cognitive-services": { models: { deployment: {} } },
azure: { env: [1] },
},
})
expect(result.encoded.providers).toHaveProperty("azure.models.deployment")
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toContainEqual([
"provider",
"azure",
])
})
test("preserves permission source order and appends native rules", () => {
expect(
normalized({
tools: { bash: true, write: false },
permission: { read: "allow", custom: { first: "deny", second: "ask" }, task: "allow" },
permissions: [{ action: "native", resource: "*", effect: "deny" }],
}).encoded.permissions,
).toEqual([
{ action: "shell", resource: "*", effect: "allow" },
{ action: "edit", resource: "*", effect: "deny" },
{ action: "read", resource: "*", effect: "allow" },
{ action: "custom", resource: "first", effect: "deny" },
{ action: "custom", resource: "second", effect: "ask" },
{ action: "subagent", resource: "*", effect: "allow" },
{ action: "native", resource: "*", effect: "deny" },
])
})
test("redacts permission resource keys from invalid diagnostics", () => {
const result = normalized({
permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },
})
expect(result.diagnostics).toEqual([
{
kind: "invalid",
path: ["permission", "bash", "0"],
message: "skipped malformed recognized value",
},
])
expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
})
test("recovers list items for skills, plugins, instructions, and permissions", () => {
const result = normalized({
skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
plugin: ["legacy", ["tuple", {}], [1, {}]],
plugins: ["native", { package: "object" }, { package: 1 }],
instructions: ["one", 2, "three"],
permissions: [
{ action: "read", resource: "*", effect: "allow" },
{ action: "read", resource: "*", effect: "invalid" },
],
})
expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
expect(result.encoded.plugins).toEqual([
"legacy",
{ package: "tuple", options: {} },
"native",
{ package: "object" },
])
expect(result.encoded.instructions).toEqual(["one", "three"])
expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
})
test("omits malformed collection roots instead of synthesizing empty values", () => {
const result = normalized({
commands: [],
providers: "invalid",
references: false,
agents: 1,
plugins: {},
permissions: {},
instructions: {},
})
expect(result.encoded).toEqual({})
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["references"],
["commands"],
["agents"],
["providers"],
["permissions"],
["plugins"],
["instructions"],
])
})
test("omits all-invalid formatter and LSP maps while preserving explicit empty maps", () => {
const invalid = normalized({
formatter: { prettier: { command: [1] } },
lsp: { typescript: { command: [1] } },
})
expect(invalid.encoded).not.toHaveProperty("formatter")
expect(invalid.encoded).not.toHaveProperty("lsp")
expect(invalid.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["formatter", "prettier"],
["lsp", "typescript"],
])
expect(normalized({ formatter: {}, lsp: {} }).encoded).toMatchObject({ formatter: {}, lsp: {} })
})
test("combines legacy and native MCP servers and merges timeout leaves", () => {
const result = normalized({
experimental: { mcp_timeout: 5000 },
mcp: {
legacy: { type: "local", command: ["legacy"] },
duplicate: { type: "remote", url: "https://legacy.example.com" },
servers: {
native: { type: "local", command: ["native"] },
duplicate: { type: "remote", url: "https://native.example.com" },
invalid: { type: "local", command: [1] },
},
timeout: { startup: 1000, catalog: 6000 },
},
})
expect(result.encoded.mcp).toEqual({
timeout: { catalog: 6000, execution: 5000, startup: 1000 },
servers: {
legacy: { type: "local", command: ["legacy"], disabled: undefined, timeout: undefined },
duplicate: { type: "remote", url: "https://native.example.com" },
native: { type: "local", command: ["native"] },
},
})
expect(
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.servers.duplicate"),
).toBe(true)
expect(
result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.timeout.catalog"),
).toBe(true)
expect(
result.diagnostics.some((item) => item.kind === "invalid" && item.path.join(".") === "mcp.servers.invalid"),
).toBe(true)
})
test("uses raw MCP discriminators for reserved server names", () => {
const result = normalized({
mcp: {
servers: { type: "local", command: ["reserved-servers"] },
timeout: { type: "remote", url: "https://reserved.example.com" },
},
})
expect((result.encoded.mcp as { servers: Record<string, unknown> }).servers).toEqual({
servers: { type: "local", command: ["reserved-servers"], disabled: undefined, timeout: undefined },
timeout: { type: "remote", url: "https://reserved.example.com", disabled: undefined, timeout: undefined },
})
const enabledOnly = normalized({ mcp: { servers: { enabled: true }, timeout: { enabled: false } } })
expect(enabledOnly.encoded.mcp).toBeUndefined()
expect(enabledOnly.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["unsupported", ["mcp", "servers"]],
["unsupported", ["mcp", "timeout"]],
])
})
test("merges bounded compaction leaves and omits unsupported leaves", () => {
const result = normalized({
compaction: {
auto: false,
preserve_recent_tokens: 1000,
keep: { tokens: 2000 },
reserved: 3000,
buffer: 4000,
tail_turns: 2,
prune: true,
},
})
expect(result.encoded.compaction).toEqual({ auto: false, keep: { tokens: 2000 }, buffer: 4000 })
expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
["unsupported", ["compaction", "tail_turns"]],
["unsupported", ["compaction", "prune"]],
["conflict", ["compaction", "keep", "tokens"]],
["conflict", ["compaction", "buffer"]],
])
})
test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
expect(normalized({ enabled_providers: [] }).encoded.experimental).toEqual({
policies: [{ action: "provider.use", resource: "*", effect: "deny" }],
})
expect(normalized({ enabled_providers: [1, "anthropic", false] }).encoded.experimental).toEqual({
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
],
})
expect(normalized({ enabled_providers: [1, false] }).encoded.experimental).toBeUndefined()
expect(normalized({ enabled_providers: "anthropic" }).encoded.experimental).toBeUndefined()
})
test("appends native policies after migrated provider policies", () => {
expect(
normalized({
enabled_providers: ["anthropic"],
disabled_providers: ["openai"],
experimental: {
subagent_depth: 0,
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
},
}).encoded.experimental,
).toEqual({
subagent_depth: 0,
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
{ action: "provider.use", resource: "openai", effect: "deny" },
{ action: "provider.use", resource: "custom", effect: "allow" },
],
})
})
test("reports unsupported legacy settings without including their values", () => {
const secret = "do-not-log-this-value"
const result = normalized({
logLevel: "DEBUG",
small_model: secret,
agent: { reviewer: { name: secret, prompt: "review" } },
provider: {
custom: {
id: secret,
whitelist: ["model"],
models: {
model: {
release_date: secret,
status: "active",
interleaved: true,
},
},
},
},
experimental: { openTelemetry: true },
})
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["logLevel"],
["small_model"],
["agent", "reviewer", "name"],
["provider", "custom", "id"],
["provider", "custom", "whitelist"],
["provider", "custom", "models", "model", "release_date"],
["provider", "custom", "models", "model", "status"],
["provider", "custom", "models", "model", "interleaved"],
["experimental", "openTelemetry"],
])
expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
})
test("diagnoses unsupported legacy model selections without dropping their entries", () => {
const result = normalized({
command: {
invalidModel: { template: "one", model: "invalid" },
invalidVariant: { template: "two", model: "anthropic/model", variant: "bad#variant" },
missingModel: { template: "three", variant: "high" },
},
agent: { invalid: { prompt: "agent", model: "invalid", variant: "" } },
})
expect(Object.keys(result.encoded.commands as Record<string, unknown>)).toEqual([
"invalidModel",
"invalidVariant",
"missingModel",
])
expect(Object.keys(result.encoded.agents as Record<string, unknown>)).toEqual(["invalid"])
expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
["command", "invalidModel", "model"],
["command", "invalidVariant", "variant"],
["command", "missingModel", "variant"],
["agent", "invalid", "model"],
["agent", "invalid", "variant"],
])
})
test("invalid legacy provider overlays skip only that provider", () => {
const result = normalized({
provider: {
headers: { options: { headers: { valid: "yes", invalid: 1 } } },
body: { options: { body: "not-an-object" } },
valid: { options: { headers: { valid: "yes" }, body: { trace: true } } },
},
})
expect(result.encoded.providers).toEqual({
valid: { settings: {}, headers: { valid: "yes" }, body: { trace: true } },
})
expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
["provider", "headers", "options", "headers"],
["provider", "body", "options", "body"],
])
})
test("preserves explicit false, zero, empty list, and empty map presence", () => {
const result = normalized({
snapshot: false,
autoshare: false,
references: {},
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
expect(result.encoded).toMatchObject({
snapshots: false,
references: {},
commands: {},
agents: {},
providers: {},
plugins: [],
instructions: [],
experimental: { subagent_depth: 0 },
})
expect(result.encoded.share).toBeUndefined()
})
})
@@ -12,7 +12,6 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { importLegacyCredentials } from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import { importNextCredentials } from "@opencode-ai/core/database/migration/20260806200000_import_next_credentials"
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
Effect.runPromise(
@@ -165,75 +164,6 @@ describe("DatabaseMigration", () => {
expect(await Bun.file(source).text()).toBe(content)
})
test("imports previous channel database credentials without replacing existing integrations", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "opencode-next.db")
const { Database: Sqlite } = await import("bun:sqlite")
const sourceDb = new Sqlite(source, { strict: true })
sourceDb.run(`
CREATE TABLE credential (
id text PRIMARY KEY, integration_id text, label text NOT NULL, value text NOT NULL,
connector_id text, method_id text, active integer, time_created integer NOT NULL, time_updated integer NOT NULL
)
`)
const insert = sourceDb.prepare(
"INSERT INTO credential (id, integration_id, label, value, connector_id, method_id, active, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
)
insert.run("cred_next_opencode", "opencode", "anomaly", JSON.stringify({ type: "key", key: "zen-key" }), null, "console", null, 100, 200)
insert.run(
"cred_next_anthropic",
"anthropic",
"default",
JSON.stringify({ type: "oauth", methodID: "oauth", refresh: "next-refresh", access: "next-access", expires: 456 }),
null,
null,
null,
100,
200,
)
insert.run("cred_next_invalid", "invalid", "default", "not-json", null, null, null, 100, 200)
sourceDb.close()
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const now = Date.now()
yield* db.run(sql`
INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
`)
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A second run is a no-op because the integration now has a credential.
yield* db.transaction((tx) => importNextCredentials(tx, source))
// A missing source database is a no-op.
yield* db.transaction((tx) => importNextCredentials(tx, path.join(tmp.path, "missing.db")))
expect(
yield* db.all(sql`SELECT id, integration_id, label, value, method_id, time_created FROM credential ORDER BY integration_id`),
).toEqual([
{
id: "existing",
integration_id: "anthropic",
label: "Existing",
value: JSON.stringify({ type: "key", key: "current-key" }),
method_id: null,
time_created: now,
},
{
id: "cred_next_opencode",
integration_id: "opencode",
label: "anomaly",
value: JSON.stringify({ type: "key", key: "zen-key" }),
method_id: "console",
time_created: 100,
},
])
}),
)
})
test("rolls back a failed migration without recording it", async () => {
await run(
Effect.gen(function* () {
File diff suppressed because it is too large Load Diff
+30 -30
View File
@@ -3,7 +3,7 @@ export * as Config from "./config.js"
import { Schema } from "effect"
import { ephemeral, inventory } from "./event.js"
import { Permission } from "./permission.js"
import { AbsolutePath } from "./schema.js"
import { AbsolutePath, optional } from "./schema.js"
import { ConfigAgent } from "./config/agent.js"
import { ConfigMedia } from "./config/media.js"
import { ConfigCompaction } from "./config/compaction.js"
@@ -22,94 +22,94 @@ import { ConfigWatcher } from "./config/watcher.js"
import { ConfigWarming } from "./config/warming.js"
export class Info extends Schema.Class<Info>("Config.Info")({
$schema: Schema.optional(Schema.String).annotate({
$schema: optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
shell: Schema.String.pipe(Schema.optional).annotate({
shell: Schema.String.pipe(optional).annotate({
description: "Default shell to use for terminal and shell tool execution",
}),
model: ConfigModel.Selection.pipe(Schema.optional).annotate({
model: ConfigModel.Selection.pipe(optional).annotate({
description: "Default model to use when no session or agent model is selected",
}),
default_agent: Schema.String.pipe(Schema.optional).annotate({
default_agent: Schema.String.pipe(optional).annotate({
description: "Default primary agent to use when no session agent is selected",
}),
autoupdate: Schema.Union([Schema.Boolean, Schema.Literal("notify")])
.pipe(Schema.optional)
.pipe(optional)
.annotate({
description: "Automatically update or notify when a new version is available",
}),
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(Schema.optional).annotate({
share: Schema.Literals(["manual", "auto", "disabled"]).pipe(optional).annotate({
description: "Control whether sessions may be shared manually, automatically, or not at all",
}),
enterprise: Schema.Struct({
url: Schema.String.pipe(Schema.optional),
url: Schema.String.pipe(optional),
})
.pipe(Schema.optional)
.pipe(optional)
.annotate({
description: "Enterprise sharing service configuration",
}),
username: Schema.String.pipe(Schema.optional).annotate({
username: Schema.String.pipe(optional).annotate({
description: "Username displayed in conversations and used for telemetry identity",
}),
permissions: Permission.Ruleset.pipe(Schema.optional).annotate({
permissions: Permission.Ruleset.pipe(optional).annotate({
description: "Ordered tool permission rules applied to agent tool use",
}),
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(Schema.optional).annotate({
agents: Schema.Record(Schema.String, ConfigAgent.Info).pipe(optional).annotate({
description: "Named built-in agent overrides and custom agent definitions",
}),
snapshots: Schema.Boolean.pipe(Schema.optional).annotate({
snapshots: Schema.Boolean.pipe(optional).annotate({
description: "Enable snapshots used for undo and revert behavior",
}),
watcher: ConfigWatcher.Info.pipe(Schema.optional).annotate({
watcher: ConfigWatcher.Info.pipe(optional).annotate({
description: "Filesystem watcher configuration",
}),
formatter: ConfigFormatter.Info.pipe(Schema.optional).annotate({
formatter: ConfigFormatter.Info.pipe(optional).annotate({
description: "Enable built-in formatters or configure formatter overrides",
}),
lsp: ConfigLSP.Info.pipe(Schema.optional).annotate({
lsp: ConfigLSP.Info.pipe(optional).annotate({
description: "Enable built-in language servers or configure server overrides",
}),
media: ConfigMedia.Info.pipe(Schema.optional).annotate({
media: ConfigMedia.Info.pipe(optional).annotate({
description: "Media processing configuration",
}),
tool_output: ConfigToolOutput.Info.pipe(Schema.optional).annotate({
tool_output: ConfigToolOutput.Info.pipe(optional).annotate({
description: "Tool output truncation thresholds",
}),
mcp: ConfigMCP.Info.pipe(Schema.optional).annotate({
mcp: ConfigMCP.Info.pipe(optional).annotate({
description: "MCP server configuration",
}),
compaction: ConfigCompaction.Info.pipe(Schema.optional).annotate({
compaction: ConfigCompaction.Info.pipe(optional).annotate({
description: "Conversation compaction behavior",
}),
skills: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
skills: Schema.String.pipe(Schema.Array, optional).annotate({
description: "Additional paths or URLs to discover skills from",
}),
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(Schema.optional).annotate({
commands: Schema.Record(Schema.String, ConfigCommand.Info).pipe(optional).annotate({
description: "Named slash command definitions",
}),
instructions: Schema.String.pipe(Schema.Array, Schema.optional).annotate({
instructions: Schema.String.pipe(Schema.Array, optional).annotate({
description: "Additional paths or URLs supplying ambient instructions",
}),
references: ConfigReference.Info.pipe(Schema.optional).annotate({
references: ConfigReference.Info.pipe(optional).annotate({
description: "Named local directories or Git repositories available as external context",
}),
websearch: ConfigWebSearch.Info.pipe(Schema.optional).annotate({
websearch: ConfigWebSearch.Info.pipe(optional).annotate({
description: "Web search provider selection",
}),
plugins: ConfigPlugin.Plugins.pipe(Schema.optional).annotate({
plugins: ConfigPlugin.Plugins.pipe(optional).annotate({
description: "Ordered plugin enablement directives and external package declarations",
}),
warming: ConfigWarming.Warming.pipe(Schema.optional).annotate({
warming: ConfigWarming.Warming.pipe(optional).annotate({
description: "Keep recently active sessions warm with transient model requests (default: false)",
}),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(Schema.optional),
experimental: ConfigExperimental.Info.pipe(Schema.optional),
providers: Schema.Record(Schema.String, ConfigProvider.Info).pipe(optional),
experimental: ConfigExperimental.Info.pipe(optional),
}) {}
export class Document extends Schema.Class<Document>("Config.Document")({
type: Schema.Literal("document"),
path: Schema.String.pipe(Schema.optional),
path: Schema.String.pipe(optional),
info: Info,
}) {}
+11 -11
View File
@@ -2,21 +2,21 @@ export * as ConfigAgent from "./agent.js"
import { Schema } from "effect"
import { Permission } from "../permission.js"
import { PositiveInt } from "../schema.js"
import { optional, PositiveInt } from "../schema.js"
import { ConfigModel } from "./model.js"
import { ConfigProvider } from "./provider.js"
export const Color = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
export class Info extends Schema.Class<Info>("Config.Agent")({
model: ConfigModel.Selection.pipe(Schema.optional),
request: ConfigProvider.Request.pipe(Schema.optional),
system: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
color: Color.pipe(Schema.optional),
steps: PositiveInt.pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(optional),
request: ConfigProvider.Request.pipe(optional),
system: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
mode: Schema.Literals(["subagent", "primary", "all"]).pipe(optional),
hidden: Schema.Boolean.pipe(optional),
color: Color.pipe(optional),
steps: PositiveInt.pipe(optional),
disabled: Schema.Boolean.pipe(optional),
permissions: Permission.Ruleset.pipe(optional),
}) {}
+5 -4
View File
@@ -1,12 +1,13 @@
export * as ConfigCommand from "./command.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
import { ConfigModel } from "./model.js"
export class Info extends Schema.Class<Info>("Config.Command")({
template: Schema.String,
description: Schema.String.pipe(Schema.optional),
agent: Schema.String.pipe(Schema.optional),
model: ConfigModel.Selection.pipe(Schema.optional),
subtask: Schema.Boolean.pipe(Schema.optional),
description: Schema.String.pipe(optional),
agent: Schema.String.pipe(optional),
model: ConfigModel.Selection.pipe(optional),
subtask: Schema.Boolean.pipe(optional),
}) {}
+5 -5
View File
@@ -1,14 +1,14 @@
export * as ConfigCompaction from "./compaction.js"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema.js"
import { NonNegativeInt, optional } from "../schema.js"
export class Keep extends Schema.Class<Keep>("Config.Compaction.Keep")({
tokens: NonNegativeInt.pipe(Schema.optional),
tokens: NonNegativeInt.pipe(optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Compaction")({
auto: Schema.Boolean.pipe(Schema.optional),
keep: Keep.pipe(Schema.optional),
buffer: NonNegativeInt.pipe(Schema.optional),
auto: Schema.Boolean.pipe(optional),
keep: Keep.pipe(optional),
buffer: NonNegativeInt.pipe(optional),
}) {}
+3 -3
View File
@@ -1,14 +1,14 @@
export * as ConfigExperimental from "./experimental.js"
import { Schema } from "effect"
import { NonNegativeInt } from "../schema.js"
import { NonNegativeInt, optional } from "../schema.js"
import { ConfigPolicy } from "./policy.js"
export class Info extends Schema.Class<Info>("ConfigExperimental.Info")({
subagent_depth: NonNegativeInt.pipe(Schema.optional).annotate({
subagent_depth: NonNegativeInt.pipe(optional).annotate({
description: "Maximum subagent nesting depth. Defaults to 1.",
}),
policies: ConfigPolicy.Info.pipe(Schema.Array, Schema.optional).annotate({
policies: ConfigPolicy.Info.pipe(Schema.Array, optional).annotate({
description: "Ordered policies controlling access to configured resources",
}),
}) {}
+5 -4
View File
@@ -1,12 +1,13 @@
export * as ConfigFormatter from "./formatter.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Formatter.Entry")({
disabled: Schema.Boolean.pipe(Schema.optional),
command: Schema.String.pipe(Schema.Array, Schema.optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(optional),
command: Schema.String.pipe(Schema.Array, optional),
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
extensions: Schema.String.pipe(Schema.Array, optional),
}) {}
export const Info = Schema.Union([Schema.Boolean, Schema.Record(Schema.String, Entry)])
+5 -4
View File
@@ -1,6 +1,7 @@
export * as ConfigLSP from "./lsp.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export const Disabled = Schema.Struct({
disabled: Schema.Literal(true),
@@ -8,10 +9,10 @@ export const Disabled = Schema.Struct({
export class Server extends Schema.Class<Server>("Config.LSP.Server")({
command: Schema.String.pipe(Schema.Array),
extensions: Schema.String.pipe(Schema.Array, Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
env: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
extensions: Schema.String.pipe(Schema.Array, optional),
disabled: Schema.Boolean.pipe(optional),
env: Schema.Record(Schema.String, Schema.String).pipe(optional),
initialization: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}) {}
export const Entry = Schema.Union([Disabled, Server])
+3 -2
View File
@@ -2,6 +2,7 @@ export * as ConfigMCP from "./mcp.js"
import { Schema } from "effect"
import { Mcp } from "../mcp.js"
import { optional } from "../schema.js"
export const Timeout = Mcp.TimeoutConfig
export type Timeout = Mcp.TimeoutConfig
@@ -14,6 +15,6 @@ export type Remote = Mcp.RemoteConfig
export const Server = Mcp.ServerConfig
export class Info extends Schema.Class<Info>("Config.MCP")({
timeout: Timeout.pipe(Schema.optional),
servers: Schema.Record(Schema.String, Server).pipe(Schema.optional),
timeout: Timeout.pipe(optional),
servers: Schema.Record(Schema.String, Server).pipe(optional),
}) {}
+6 -6
View File
@@ -1,15 +1,15 @@
export * as ConfigMedia from "./media.js"
import { Schema } from "effect"
import { PositiveInt } from "../schema.js"
import { optional, PositiveInt } from "../schema.js"
export class Image extends Schema.Class<Image>("Config.Media.Image")({
auto_resize: Schema.Boolean.pipe(Schema.optional),
max_width: PositiveInt.pipe(Schema.optional),
max_height: PositiveInt.pipe(Schema.optional),
max_base64_bytes: PositiveInt.pipe(Schema.optional),
auto_resize: Schema.Boolean.pipe(optional),
max_width: PositiveInt.pipe(optional),
max_height: PositiveInt.pipe(optional),
max_base64_bytes: PositiveInt.pipe(optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Media")({
image: Image.pipe(Schema.optional),
image: Image.pipe(optional),
}) {}
+2 -1
View File
@@ -3,6 +3,7 @@ export * as ConfigModel from "./model.js"
import { Schema, SchemaGetter } from "effect"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { optional } from "../schema.js"
const ProviderID = Provider.ID.check(Schema.isPattern(/^[^/#]+$/))
const ModelID = Model.ID.check(Schema.isPattern(/^[^#]+$/))
@@ -11,7 +12,7 @@ const VariantID = Model.VariantID.check(Schema.isPattern(/^[^#]+$/))
const Explicit = Schema.Struct({
providerID: ProviderID,
model: ModelID,
variant: VariantID.pipe(Schema.optional),
variant: VariantID.pipe(optional),
})
const Short = Schema.String.check(Schema.isPattern(/^[^/#]+\/[^#]+(?:#[^#]+)?$/))
+2 -1
View File
@@ -1,10 +1,11 @@
export * as ConfigPlugin from "./plugin.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Entry extends Schema.Class<Entry>("Config.Plugin.Entry")({
package: Schema.String,
options: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional),
options: Schema.Record(Schema.String, Schema.Unknown).pipe(optional),
}) {}
export const Plugin = Schema.Union([Schema.String, Entry])
+25 -24
View File
@@ -3,13 +3,14 @@ export * as ConfigProvider from "./provider.js"
import { Schema } from "effect"
import { Money } from "../money.js"
import { Capabilities, Compatibility, Family, ID, VariantID } from "../model.js"
import { optional } from "../schema.js"
const JsonRecord = Schema.Record(Schema.String, Schema.Json)
export const Overlays = {
settings: JsonRecord.pipe(Schema.optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
body: JsonRecord.pipe(Schema.optional),
settings: JsonRecord.pipe(optional),
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
body: JsonRecord.pipe(optional),
}
export class Request extends Schema.Class<Request>("Config.Provider.Request")({
@@ -18,47 +19,47 @@ export class Request extends Schema.Class<Request>("Config.Provider.Request")({
}) {}
class Cache extends Schema.Class<Cache>("Config.Model.Cost.Cache")({
read: Money.USDPerMillionTokens.pipe(Schema.optional),
write: Money.USDPerMillionTokens.pipe(Schema.optional),
read: Money.USDPerMillionTokens.pipe(optional),
write: Money.USDPerMillionTokens.pipe(optional),
}) {}
class Cost extends Schema.Class<Cost>("Config.Model.Cost")({
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Int,
}).pipe(Schema.optional),
}).pipe(optional),
input: Money.USDPerMillionTokens,
output: Money.USDPerMillionTokens,
cache: Cache.pipe(Schema.optional),
cache: Cache.pipe(optional),
}) {}
class Limit extends Schema.Class<Limit>("Config.Model.Limit")({
context: Schema.Int.pipe(Schema.optional),
input: Schema.Int.pipe(Schema.optional),
output: Schema.Int.pipe(Schema.optional),
context: Schema.Int.pipe(optional),
input: Schema.Int.pipe(optional),
output: Schema.Int.pipe(optional),
}) {}
class Model extends Schema.Class<Model>("Config.Model")({
modelID: ID.pipe(Schema.optional),
family: Family.pipe(Schema.optional),
name: Schema.String.pipe(Schema.optional),
compatibility: Compatibility.pipe(Schema.optional),
package: Schema.String.pipe(Schema.optional),
modelID: ID.pipe(optional),
family: Family.pipe(optional),
name: Schema.String.pipe(optional),
compatibility: Compatibility.pipe(optional),
package: Schema.String.pipe(optional),
...Overlays,
capabilities: Capabilities.pipe(Schema.optional),
capabilities: Capabilities.pipe(optional),
variants: Schema.Struct({
id: VariantID,
...Overlays,
}).pipe(Schema.Array, Schema.optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
limit: Limit.pipe(Schema.optional),
}).pipe(Schema.Array, optional),
cost: Schema.Union([Cost, Cost.pipe(Schema.Array)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
limit: Limit.pipe(optional),
}) {}
export class Info extends Schema.Class<Info>("Config.Provider")({
name: Schema.String.pipe(Schema.optional),
env: Schema.String.pipe(Schema.Array, Schema.optional),
package: Schema.String.pipe(Schema.optional),
name: Schema.String.pipe(optional),
env: Schema.String.pipe(Schema.Array, optional),
package: Schema.String.pipe(optional),
...Overlays,
models: Schema.Record(Schema.String, Model).pipe(Schema.optional),
models: Schema.Record(Schema.String, Model).pipe(optional),
}) {}
+6 -5
View File
@@ -1,18 +1,19 @@
export * as ConfigReference from "./reference.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Git extends Schema.Class<Git>("Config.Reference.Git")({
repository: Schema.String,
branch: Schema.String.pipe(Schema.optional),
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
branch: Schema.String.pipe(optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
}) {}
export class Local extends Schema.Class<Local>("Config.Reference.Local")({
path: Schema.String,
description: Schema.String.pipe(Schema.optional),
hidden: Schema.Boolean.pipe(Schema.optional),
description: Schema.String.pipe(optional),
hidden: Schema.Boolean.pipe(optional),
}) {}
export const Entry = Schema.Union([Schema.String, Git, Local])
+3 -3
View File
@@ -1,9 +1,9 @@
export * as ConfigToolOutput from "./tool-output.js"
import { Schema } from "effect"
import { PositiveInt } from "../schema.js"
import { optional, PositiveInt } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.ToolOutput")({
max_lines: PositiveInt.pipe(Schema.optional),
max_bytes: PositiveInt.pipe(Schema.optional),
max_lines: PositiveInt.pipe(optional),
max_bytes: PositiveInt.pipe(optional),
}) {}
+4 -3
View File
@@ -1,15 +1,16 @@
export * as ConfigWarming from "./warming.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Warming")({
prompt: Schema.String.pipe(Schema.optional).annotate({
prompt: Schema.String.pipe(optional).annotate({
description: "Prompt sent for keep-alive requests",
}),
interval: Schema.DurationFromString.pipe(Schema.optional).annotate({
interval: Schema.DurationFromString.pipe(optional).annotate({
description: 'Idle time between keep-alive requests (default: "4 minutes")',
}),
duration: Schema.DurationFromString.pipe(Schema.optional).annotate({
duration: Schema.DurationFromString.pipe(optional).annotate({
description: 'Time after the last active request to keep a session warm (default: "30 minutes")',
}),
}) {}
+2 -1
View File
@@ -1,7 +1,8 @@
export * as ConfigWatcher from "./watcher.js"
import { Schema } from "effect"
import { optional } from "../schema.js"
export class Info extends Schema.Class<Info>("Config.Watcher")({
ignore: Schema.String.pipe(Schema.Array, Schema.optional),
ignore: Schema.String.pipe(Schema.Array, optional),
}) {}
+18 -18
View File
@@ -5,13 +5,13 @@ import { optional, PositiveInt } from "./schema.js"
import { IntegrationID } from "./integration-id.js"
export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfig")({
startup: PositiveInt.pipe(Schema.optional).annotate({
startup: PositiveInt.pipe(optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}),
catalog: PositiveInt.pipe(Schema.optional).annotate({
catalog: PositiveInt.pipe(optional).annotate({
description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.",
}),
execution: PositiveInt.pipe(Schema.optional).annotate({
execution: PositiveInt.pipe(optional).annotate({
description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.",
}),
}) {}
@@ -19,35 +19,35 @@ export class TimeoutConfig extends Schema.Class<TimeoutConfig>("Mcp.TimeoutConfi
export class LocalConfig extends Schema.Class<LocalConfig>("Mcp.LocalConfig")({
type: Schema.Literal("local"),
command: Schema.String.pipe(Schema.Array),
cwd: Schema.String.pipe(Schema.optional).annotate({
cwd: Schema.String.pipe(optional).annotate({
description: "Working directory for the MCP server process. Relative paths resolve from the workspace directory.",
}),
environment: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
environment: Schema.Record(Schema.String, Schema.String).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(Schema.optional),
timeout: TimeoutConfig.pipe(optional),
}) {}
export class OAuthConfig extends Schema.Class<OAuthConfig>("Mcp.OAuthConfig")({
client_id: Schema.String.pipe(Schema.optional),
client_secret: Schema.String.pipe(Schema.optional),
scope: Schema.String.pipe(Schema.optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(Schema.optional),
redirect_uri: Schema.String.pipe(Schema.optional),
client_id: Schema.String.pipe(optional),
client_secret: Schema.String.pipe(optional),
scope: Schema.String.pipe(optional),
callback_port: Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 })).pipe(optional),
redirect_uri: Schema.String.pipe(optional),
}) {}
export class RemoteConfig extends Schema.Class<RemoteConfig>("Mcp.RemoteConfig")({
type: Schema.Literal("remote"),
url: Schema.String,
headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(Schema.optional),
disabled: Schema.Boolean.pipe(Schema.optional),
codemode: Schema.Boolean.pipe(Schema.optional).annotate({
headers: Schema.Record(Schema.String, Schema.String).pipe(optional),
oauth: Schema.Union([OAuthConfig, Schema.Literal(false)]).pipe(optional),
disabled: Schema.Boolean.pipe(optional),
codemode: Schema.Boolean.pipe(optional).annotate({
description: "Expose this server's tools through Code Mode. Defaults to true.",
}),
timeout: TimeoutConfig.pipe(Schema.optional),
timeout: TimeoutConfig.pipe(optional),
}) {}
export const ServerConfig = Schema.Union([LocalConfig, RemoteConfig]).pipe(Schema.toTaggedUnion("type"))
+47 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Config } from "../src/config.js"
import { ConfigAgent } from "../src/config/agent.js"
import { ConfigMCP } from "../src/config/mcp.js"
import { ConfigProvider } from "../src/config/provider.js"
import { Mcp } from "../src/mcp.js"
import { AbsolutePath } from "../src/schema.js"
describe("Config.Entry", () => {
@@ -29,7 +33,14 @@ describe("Config.Entry", () => {
expect(decoded).toEqual(entries)
expect(decoded[0]).toBeInstanceOf(Config.Document)
expect(decoded[1]).not.toHaveProperty("path")
expect(decoded.map((entry) => entry.type)).toEqual(["document", "document", "directory", "file", "agents", "claude"])
expect(decoded.map((entry) => entry.type)).toEqual([
"document",
"document",
"directory",
"file",
"agents",
"claude",
])
expect(decoded[0]?.type === "document" ? decoded[0].info.permissions : undefined).toEqual([
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
@@ -39,4 +50,39 @@ describe("Config.Entry", () => {
test("has a stable public identifier", () => {
expect(Config.Entry.ast.annotations?.identifier).toBe("Config.Entry")
})
test("omits undefined optional properties while encoding", () => {
const entry = new Config.Document({
type: "document",
path: undefined,
info: new Config.Info({
default_agent: undefined,
agents: { reviewer: new ConfigAgent.Info({ description: undefined }) },
mcp: new ConfigMCP.Info({
timeout: undefined,
servers: {
docs: new Mcp.RemoteConfig({
type: "remote",
url: "https://example.com/mcp",
headers: undefined,
oauth: new Mcp.OAuthConfig({ client_id: undefined }),
}),
},
}),
providers: { custom: new ConfigProvider.Info({ headers: undefined }) },
}),
})
const encoded = Schema.encodeSync(Config.Entry)(entry)
if (encoded.type !== "document") throw new Error("Expected a config document")
expect(encoded).not.toHaveProperty("path")
expect(encoded.info).not.toHaveProperty("default_agent")
expect(encoded.info.agents?.reviewer).not.toHaveProperty("description")
expect(encoded.info.mcp).not.toHaveProperty("timeout")
const docs = encoded.info.mcp?.servers?.docs
if (docs?.type !== "remote" || docs.oauth === false) throw new Error("Expected a remote MCP server")
expect(docs).not.toHaveProperty("headers")
expect(docs.oauth).not.toHaveProperty("client_id")
expect(encoded.info.providers?.custom).not.toHaveProperty("headers")
})
})
+20 -4
View File
@@ -16,7 +16,9 @@ it.live("returns ordered config entries for the requested directory", () =>
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const config = path.join(project, "opencode.json")
yield* Effect.promise(() => Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]))
yield* Effect.promise(() =>
Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })]),
)
yield* Effect.promise(() =>
fs.writeFile(
config,
@@ -25,6 +27,7 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "*", effect: "ask" },
{ action: "shell", resource: "git status", effect: "allow" },
],
mcp: { servers: { docs: { type: "remote", url: "https://example.com/mcp" } } },
}),
),
)
@@ -42,9 +45,8 @@ it.live("returns ordered config entries for the requested directory", () =>
const response = yield* Effect.promise(() =>
fetch(url, { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(
yield* Effect.promise(() => response.json()),
)
const body: unknown = yield* Effect.promise(() => response.json())
const entries = Schema.decodeUnknownSync(Schema.Array(Config.Entry))(body)
expect(response.status).toBe(200)
expect(Array.isArray(entries)).toBe(true)
@@ -56,7 +58,21 @@ it.live("returns ordered config entries for the requested directory", () =>
{ action: "shell", resource: "git status", effect: "allow" },
])
expect(entries.some((entry) => entry.type === "file" && entry.path === config)).toBe(true)
if (!Array.isArray(body)) throw new Error("Expected a config entry array")
const raw = body.find((entry) => isRecord(entry) && entry["type"] === "document" && entry["path"] === config)
if (!isRecord(raw) || !isRecord(raw["info"])) throw new Error("Expected a config document")
expect(raw["info"]).not.toHaveProperty("default_agent")
expect(raw["info"]).not.toHaveProperty("model")
const mcp = raw["info"]["mcp"]
if (!isRecord(mcp) || !isRecord(mcp["servers"]) || !isRecord(mcp["servers"]["docs"]))
throw new Error("Expected an MCP server config")
expect(mcp["servers"]["docs"]).not.toHaveProperty("headers")
expect(mcp["servers"]["docs"]).not.toHaveProperty("oauth")
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
+41 -15
View File
@@ -19,13 +19,20 @@ function statusError(status: McpServer["status"]) {
return undefined
}
function Status(props: { enabled: boolean; loading: boolean }) {
const theme = useTheme("elevated")
if (props.loading) return <span style={{ fg: theme.text.subdued }}> Loading</span>
if (props.enabled) {
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}> Enabled</span>
function Status(props: { status: McpServer["status"]; loading: boolean }) {
if (props.loading || props.status.status === "pending") {
return <>Connecting </>
}
return <span style={{ fg: theme.text.subdued }}> Disabled</span>
if (props.status.status === "connected") {
return <span style={{ attributes: TextAttributes.BOLD }}>Connected </span>
}
if (props.status.status === "failed") {
return <>Failed !</>
}
if (props.status.status === "needs_auth") {
return <>Sign in required </>
}
return <>Disabled </>
}
export function DialogMcp() {
@@ -38,6 +45,13 @@ export function DialogMcp() {
const [detail, setDetail] = createSignal<McpServer>()
const [loading, setLoading] = createSignal<string | null>(null)
const statusColor = (status: McpServer["status"]) => {
if (status.status === "connected") return theme.text.feedback.success.default
if (status.status === "failed") return theme.text.feedback.error.default
if (status.status === "needs_auth") return theme.text.feedback.warning.default
return theme.text.subdued
}
const servers = createMemo(() =>
pipe(
data.location.mcp.server.list() ?? [],
@@ -53,17 +67,29 @@ export function DialogMcp() {
const options = createMemo(() => {
const loadingMcp = loading()
return servers().map((server) => ({
value: server.name,
title: server.name,
description: server.status.status,
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
}))
return servers().map((server) => {
const pending = loadingMcp === server.name || server.status.status === "pending"
return {
value: server.name,
title: server.name,
footer: <Status status={server.status} loading={pending} />,
footerColor: pending ? theme.text.subdued : statusColor(server.status),
}
})
})
const focusedServer = createMemo(() => servers().find((server) => server.name === focused()))
const toggleTitle = createMemo(() => {
const status = focusedServer()?.status.status
if (status === "connected") return "disconnect"
if (status === "failed") return "retry"
if (status === "needs_auth") return "sign in"
return "connect"
})
const focusedError = createMemo(() => {
const name = focused()
const server = servers().find((entry) => entry.name === name)
const server = focusedServer()
return server ? statusError(server.status) : undefined
})
@@ -100,7 +126,7 @@ export function DialogMcp() {
onSelect={(option) => open(option.value as string)}
actions={[
{
title: "toggle",
title: toggleTitle(),
command: "dialog.mcp.toggle",
onTrigger: (option) => {
setFocused(option.value as string)
+2 -1
View File
@@ -84,7 +84,8 @@ export function DialogPrompt(props: DialogPromptProps) {
<box gap={1}>
{props.description?.()}
<textarea
height={3}
height={1}
wrapMode="none"
ref={(val: TextareaRenderable) => {
textarea = val
setTextareaTarget(val)
+14 -1
View File
@@ -71,6 +71,7 @@ export interface DialogSelectOption<T = any> {
detailsColor?: RGBA
detailsWrap?: boolean
footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number
truncateTitle?: boolean | "left"
category?: string
@@ -727,6 +728,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
footer={
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
}
footerColor={option.footerColor}
titleWidth={option.titleWidth}
truncateTitle={option.truncateTitle}
description={option.description !== category ? option.description : undefined}
@@ -784,6 +786,7 @@ function Option(props: {
current?: boolean
muted?: boolean
footer?: JSX.Element | string
footerColor?: RGBA
titleWidth?: number
truncateTitle?: boolean | "left"
gutter?: () => JSX.Element
@@ -832,7 +835,17 @@ function Option(props: {
</text>
<Show when={props.footer}>
<box flexShrink={0}>
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
<text
fg={
props.active && !props.muted
? text()
: props.muted && (props.active || props.current)
? theme.text.subdued
: (props.footerColor ?? theme.text.subdued)
}
>
{props.footer}
</text>
</box>
</Show>
</>
@@ -81,7 +81,6 @@ Add `compaction` to any [OpenCode configuration file](/config):
"$schema": "https://opencode.ai/config.json",
"compaction": {
"auto": true,
"prune": false,
"keep": {
"tokens": 15000
},
@@ -93,7 +92,6 @@ Add `compaction` to any [OpenCode configuration file](/config):
| Field | Default | V2 behavior |
| --- | ---: | --- |
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
@@ -135,8 +133,6 @@ behavior.
## Current limitations
- `prune` is reserved configuration; V1-style in-place tool-output pruning is
not implemented in V2.
- Compaction requires a resolvable model with a positive catalog context limit.
There is no separate compaction-model setting or fallback model.
- Summary generation can fail if the summary prompt itself cannot fit beside
+54 -17
View File
@@ -16,15 +16,17 @@ V2 has three intentional breaking changes:
- The [server API and clients](#server-api-and-clients) have new contracts.
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
All other functionality is intended to remain compatible with V1.
Supported V1 functionality outside those areas is intended to remain compatible with V1. Some fields accepted by the V1
schema never had a V2 equivalent and are intentionally ignored; these are listed under
[Accepted but unsupported fields](#accepted-but-unsupported-fields).
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
an expected migration requirement.
Existing supported server config fields, agent definitions, command definitions, skills, and other files in `.opencode/`
should continue to work without changes. If supported behavior described in this guide stops working in V2, treat it as a
beta compatibility bug rather than an expected migration requirement.
<Callout type="tip">
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
a compatibility issue.
Run `/report` if supported V1 functionality does not work in V2. The report skill collects diagnostics and helps you
file a compatibility issue.
</Callout>
<Callout type="warning">
@@ -60,8 +62,9 @@ V2 reads existing global and project configuration from the same locations as V1
<project>/.opencode/opencode.json(c)
```
V2 reads these same locations. It detects V1-shaped configuration and translates it in memory without rewriting the
source file. Existing V1 configuration is intended to keep working, so you do not need to convert it to try or adopt V2.
V2 reads these same locations. It normalizes supported V1 and native V2 fields in memory without rewriting the source
file. Existing supported V1 configuration is intended to keep working, so you do not need to convert it to try or adopt
V2.
### Ask OpenCode to migrate
@@ -76,7 +79,13 @@ Preserve its behavior and all unrelated settings.
```
OpenCode can inspect the complete file, apply the relevant changes below, and avoid rewriting settings that do not need to
change. Do not mix V1 and V2 field names manually in one file.
change. Conversion does not need to happen all at once: supported V1 and native V2 fields may coexist at the top level.
When both forms set the same canonical value, a valid native V2 value takes precedence regardless of JSON key order.
Nested mixing is intentionally bounded. OpenCode recognizes mixed V1 and V2 members within `mcp`, `compaction`, and
`experimental`, but it does not recursively infer formats inside individual agents, providers, commands, or models. Keep
each of those nested entries entirely in one format. Supported V1 syntax remains quiet by itself; malformed values,
unsupported legacy fields, and conflicting V1/V2 values produce warnings while unrelated valid settings continue to load.
### Sharing
@@ -250,8 +259,8 @@ V2 groups the retained-context token budget under `keep` and gives the reserve a
}
```
`auto` and `prune` keep their names. V2 has no native `tail_turns` field; recent context is retained by token budget instead.
See [Compaction](/compaction).
`auto` keeps its name. V2 has no native `tail_turns` or `prune` field; both legacy fields are ignored with a warning. Recent
context is retained by token budget instead. See [Compaction](/compaction).
### Skills
@@ -354,6 +363,17 @@ Rename the singular `provider` map to `providers`. V2 separates the runtime pack
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/providers).
V2 consolidated two legacy provider namespaces:
| V1 provider ID | Canonical V2 provider ID |
| --- | --- |
| `azure-cognitive-services` | `azure` |
| `google-vertex-anthropic` | `google-vertex` |
Migration of unambiguous V1 provider, agent, command, and provider-filter fields uses these canonical IDs. The shared
top-level `model` field keeps its exact provider ID because the same syntax is valid in native V2 config; update that field
to the canonical ID when migrating a legacy built-in provider.
### Models and variants
Models remain nested under their provider, but several model fields become more explicit:
@@ -390,22 +410,39 @@ Models remain nested under their provider, but several model fields become more
See [Models](/models) for the complete native model shape.
### Fields without native equivalents
### Supported fields without direct native equivalents
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`,
`lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration.
These V1 fields do not have one-to-one native V2 config fields:
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
- `disabled_providers` becomes internal deny policies for the listed providers.
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
### Accepted but unsupported fields
The V1 schema also accepted fields that have no supported V2 behavior. V2 ignores these values and emits a warning so
they are not mistaken for active configuration:
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
- `enabled_providers` and `disabled_providers`: there is no native provider allowlist or denylist field yet.
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
- `compaction.tail_turns`: V2 uses `compaction.keep.tokens` instead.
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
- Agent `name` inside V1 JSON configuration.
- An enabled-only V1 MCP entry without a `type`.
- V1 experimental fields `disable_paste_summary`, `batch_tool`, `openTelemetry`, `primary_tools`, and
`continue_loop_on_deny`.
- V1 provider fields `id`, `whitelist`, and `blacklist`.
- V1 provider-model fields `release_date`, `attachment`, `reasoning`, `temperature`, `experimental`, a non-`deprecated`
`status`, and boolean `interleaved`.
If your V1 configuration relies on a field without a native equivalent, keep using the supported V1 format rather than
forcing a manual conversion. Run `/report` if V2 does not preserve the behavior you rely on.
Ignoring these fields is intentional and is not a compatibility regression. If V2 does not preserve behavior identified
as supported elsewhere in this guide, run `/report`.
### Agent files
+677 -1504
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+163
View File
@@ -0,0 +1,163 @@
# Mixed V1/V2 Config Normalization Plan
Status: **Implemented and verified**
## Goal
Replace whole-document V1/V2 detection with one config-domain compatibility pipeline. Supported V1 fields, native V2 fields, and practical mixtures of both should load without an unrelated legacy key changing how the rest of the document is decoded.
## Decision
Normalize recognized fields independently into the encoded side of the V2 `Config.Info` schema, then perform one final complete-document V2 decode:
```text
JSON/JSONC encoded input
-> parse and retain source-property presence
-> validate each recognized field or collection entry
-> migrate supported V1 candidates to V2 encoded values
-> decode and re-encode native V2 candidates
-> merge with native V2 precedence
-> decode Config.Info once
-> log redacted diagnostics
```
There is no whole-document version classification and no independent whole-document V1 and V2 decode.
The encoded boundary matters because schemas such as warming durations transform strings into runtime values. Decoded values must not be fed back into the encoded side of `Config.Info`.
## Behavior
| Situation | Result |
| --- | --- |
| Supported V1-only field | Migrate it to its canonical V2 destination. |
| Native V2 field | Preserve it after schema decode and encode. |
| Disjoint V1 and V2 map entries | Preserve both. |
| Same canonical scalar, map entry, or nested leaf | Valid native V2 wins regardless of JSON key order. |
| Malformed native value with valid legacy fallback | Skip native value, log it, and retain legacy value. |
| Malformed collection entry | Skip only the explicitly supported recovery unit. |
| Unsupported accepted V1 setting | Omit it and log a redacted warning. |
| Unknown field | Continue ignoring it for forward compatibility. |
Valid supported V1 syntax does not warn merely because it is legacy.
## Field Precedence
| Destination | Lowest to highest precedence |
| --- | --- |
| `snapshots` | `snapshot` < `snapshots` |
| `share` | `autoshare` < `share` |
| `references[name]` | `reference[name]` < `references[name]` |
| `agents[name]` | `agent[name]` < `mode[name]` < `agents[name]` |
| `commands[name]` | `command[name]` < `commands[name]` |
| `providers[name]` | `provider[name]` < `providers[name]` |
| `permissions` | `tools` rules < `permission` rules < native `permissions` |
| `plugins` | migrated `plugin` items < native `plugins` items |
| `media` | `attachment` < `media` |
| `experimental.policies` | enabled-provider policies < disabled-provider policies < native policies |
| `mcp.servers[name]` | direct legacy server < native `servers[name]` |
| `mcp.timeout.*` | `experimental.mcp_timeout` < native timeout leaf |
| `compaction.keep.tokens` | `preserve_recent_tokens` < `keep.tokens` |
| `compaction.buffer` | `reserved` < `buffer` |
Ordered rules and plugin directives retain both forms, with migrated V1 entries first and native V2 entries last.
## Shared Shapes
### Skills
- A V2 array retains each valid string item.
- A V1 object combines valid `paths` followed by valid `urls`.
- Empty and unknown-only V1 objects normalize to an empty array under permissive excess-property handling.
### MCP
- Direct entries under `mcp` are V1 servers.
- Entries under `mcp.servers` are native V2 servers.
- Both sets are merged by server name, with a complete native server replacing a duplicate legacy server.
- A malformed native duplicate is skipped so a valid legacy server remains.
- Native global timeout leaves override only matching values migrated from `experimental.mcp_timeout`.
- Raw `type` and `enabled` discriminators preserve legacy servers that happen to be named `servers` or `timeout`.
### Compaction
- `preserve_recent_tokens` becomes `keep.tokens`.
- `reserved` becomes `buffer`.
- Native leaves win conflicts.
- `tail_turns` and `prune` remain unsupported and produce warnings.
### Experimental
- `subagent_depth` is shared.
- Legacy provider lists generate ordered canonical policies.
- Native policies follow generated policies.
- An explicit empty `enabled_providers` keeps deny-all behavior.
- A non-empty list with no valid items contributes no policy, avoiding accidental deny-all from malformed input.
## Recovery Units
Named commands, agents, providers, MCP servers, formatters, language servers, and references recover independently. Plugin, permission, skill, instruction, provider-ID, and policy arrays recover by item. Top-level legacy permissions recover by action/resource rule. Complex interiors of one agent, provider, command, or MCP server remain atomic rather than being recursively salvaged.
Every decoder preserves `propertyOrder: "original"` because V1 permission precedence depends on user order. Excess properties remain ignored except for the explicit unsupported inventory.
## Provider IDs
Provider ID compatibility remains a config migration concern only. Existing V1 agent, command, provider, and provider-policy adapters continue using the migration helper's retired-ID mapping.
The shared top-level `model` field remains exact because its string and object forms are valid native V2 syntax and provider declarations may come from a different config layer. It is never reinterpreted based on unrelated legacy fields.
This change does not add runtime provider aliases or modify provider policy evaluation, catalog state, model resolution, Sessions, plugins, Server behavior, or generation.
## Diagnostics
Diagnostics contain only source, JSON path, category, and action. They never include raw values because config may contain credentials after substitution.
Malformed JSON, empty content, and valid non-object roots reject one document with a source-aware warning. Malformed recognized fields and entries are skipped at their recovery boundary while unrelated valid configuration continues loading.
## Implementation
- Add a pure `ConfigNormalize.normalize` module under `packages/core/src/config/`.
- Reuse field migration primitives from `packages/core/src/v1/config/migrate.ts`.
- Replace `ConfigMigrateV1.isV1` in `packages/core/src/config.ts` with normalization and one final V2 decode.
- Log diagnostics uniformly for files, `OPENCODE_CONFIG_CONTENT`, and well-known virtual config.
- Add property and table-driven config normalization tests.
- Update migration and compaction documentation.
## Verification
The implementation must establish:
1. Valid native V2 config preserves decoded meaning after encoded normalization.
2. Supported V1 fields preserve existing behavior.
3. Adding a legacy field cannot change unrelated native field interpretation.
4. Native V2 wins canonical conflicts independent of key order.
5. One malformed entry does not remove valid siblings.
6. Mixed MCP, compaction, and experimental values normalize deterministically.
7. Diagnostics are precise and value-redacted.
8. False, zero, empty, and absent values retain distinct presence semantics.
Run from `packages/core`:
```sh
bun test test/config
bun typecheck
```
Run from `packages/www` after documentation changes:
```sh
bun typecheck
bun validate
bun run build
```
## Non-Goals
- Runtime provider alias resolution.
- Provider policy or catalog changes.
- Model resolver or Session changes.
- Plugin API changes.
- Server or Protocol changes.
- Generation lifecycle changes.
- Recursive V1/V2 inference inside one agent, provider, command, or model.
- Restoring removed V1 functionality.
- Rewriting user files on disk.