From 5cf97f1b968edc63180d43998033f59fb4b27e88 Mon Sep 17 00:00:00 2001 From: Dax Date: Thu, 6 Aug 2026 14:11:13 -0700 Subject: [PATCH] feat(core): normalize mixed config formats (#40919) --- packages/core/src/config.ts | 50 +- packages/core/src/config/normalize.ts | 796 ++++++++++++++++++ packages/core/src/v1/config/migrate.ts | 52 +- packages/core/test/config/config.test.ts | 117 ++- .../core/test/config/normalization.test.ts | 489 +++++++++++ .../content/docs/(Configure)/compaction.mdx | 4 - packages/www/content/docs/migrate-v1.mdx | 71 +- plans/config-normalization.md | 163 ++++ 8 files changed, 1636 insertions(+), 106 deletions(-) create mode 100644 packages/core/src/config/normalize.ts create mode 100644 packages/core/test/config/normalization.test.ts create mode 100644 plans/config-normalization.md diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index 221432fd3f7..6072acb9cbe 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -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(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, ) diff --git a/packages/core/src/config/normalize.ts b/packages/core/src/config/normalize.ts new file mode 100644 index 00000000000..ee340f70740 --- /dev/null +++ b/packages/core/src/config/normalize.ts @@ -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> + 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 = {} + 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, encoded: Record, 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, encoded: Record, diagnostics: Diagnostic[]) { + const legacyServers: Record = {} + const nativeServers: Record = {} + const timeout: Record = {} + 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, + 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, + encoded: Record, + 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 = {} + 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, + encoded: Record, + diagnostics: Diagnostic[], +) { + const result: Record = {} + 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, encoded: Record, 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, + encoded: Record, + 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, encoded: Record, 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 = {} + 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, 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, + 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>( + 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>( + value: unknown, + schema: S, + path: string[], + diagnostics: Diagnostic[], +) { + if (value === undefined) return {} as Record + if (!isRecord(value)) { + invalid(path, diagnostics) + return {} as Record + } + return Object.fromEntries( + Object.entries(value).flatMap(([name, raw]) => { + const decoded = decodeValue(schema, raw, [...path, name], diagnostics) + return decoded === undefined ? [] : [[name, decoded]] + }), + ) as Record +} + +function decodeEncodedList>( + 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>( + 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>( + 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>( + 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>(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, + key: string, + legacy: Readonly>, + native: Readonly>, + present: boolean, + diagnostics: Diagnostic[], +) { + const merged = mergeMaps(legacy, native, [key], diagnostics) + if (present) target[key] = merged +} + +function mergeMaps( + legacy: Readonly>, + native: Readonly>, + 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(input: Readonly>, 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, + 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, 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 { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false + const prototype = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function own(value: Record, key: string) { + return Object.prototype.hasOwnProperty.call(value, key) +} + +function setOwn(value: Record, key: string, item: unknown) { + Object.defineProperty(value, key, { value: item, enumerable: true, configurable: true, writable: true }) +} diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 3b0b6704b7a..11d15f5c06d 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -18,44 +18,6 @@ const decodeInfo = Schema.decodeUnknownSync(Schema.fromJsonString(Info), decodeO const encodeInfo = Schema.encodeSync(Info) const decodeAgent = Schema.decodeUnknownSync(Schema.fromJsonString(ConfigAgent.Info), decodeOptions) const encodeAgent = Schema.encodeSync(ConfigAgent.Info) - -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 - 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) - ) -} - export function migrate(info: typeof ConfigV1.Info.Type) { return encodeInfo( decodeInfo( @@ -145,7 +107,7 @@ function permissions(info?: ConfigPermissionV1.Info, tools?: Readonly>) { +export function commands(info?: Readonly>) { if (!info) return undefined return Object.fromEntries( Object.entries(info).map(([id, command]) => [ @@ -222,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 { @@ -261,7 +223,7 @@ function providers(info?: Readonly>) { ) } -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) @@ -273,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: @@ -317,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 diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 04e876e4d08..d7982085281 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -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> = [] + 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) + }) + 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(() => { diff --git a/packages/core/test/config/normalization.test.ts b/packages/core/test/config/normalization.test.ts new file mode 100644 index 00000000000..97048e727c7 --- /dev/null +++ b/packages/core/test/config/normalization.test.ts @@ -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) { + const result = structuredClone(input) + if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) { + const mcp = result.mcp as Record + 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 + 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 }).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)).toEqual([ + "invalidModel", + "invalidVariant", + "missingModel", + ]) + expect(Object.keys(result.encoded.agents as Record)).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() + }) +}) diff --git a/packages/www/content/docs/(Configure)/compaction.mdx b/packages/www/content/docs/(Configure)/compaction.mdx index 85a742032af..bae1831de6a 100644 --- a/packages/www/content/docs/(Configure)/compaction.mdx +++ b/packages/www/content/docs/(Configure)/compaction.mdx @@ -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 diff --git a/packages/www/content/docs/migrate-v1.mdx b/packages/www/content/docs/migrate-v1.mdx index bdb6be3c432..ad110a48582 100644 --- a/packages/www/content/docs/migrate-v1.mdx +++ b/packages/www/content/docs/migrate-v1.mdx @@ -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. - 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. @@ -60,8 +62,9 @@ V2 reads existing global and project configuration from the same locations as V1 /.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 diff --git a/plans/config-normalization.md b/plans/config-normalization.md new file mode 100644 index 00000000000..b8901de7e1a --- /dev/null +++ b/plans/config-normalization.md @@ -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.