Parcourir la source

feat(core): normalize mixed config formats (#40919)

Dax il y a 1 semaine
Parent
commit
5cf97f1b96

+ 34 - 16
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<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 - 0
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<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 })
+}

+ 7 - 45
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<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)
-  )
-}
-
 export function migrate(info: typeof ConfigV1.Info.Type) {
   return encodeInfo(
     decodeInfo(
@@ -145,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"
@@ -185,7 +147,7 @@ export function migrateAgent(info: ConfigAgentV1.Info) {
   )
 }
 
-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]) => [
@@ -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<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)
@@ -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

+ 93 - 24
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<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(() => {

+ 489 - 0
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<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()
+  })
+})

+ 0 - 4
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

+ 55 - 18
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.
 
 <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.
-
-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.
+- 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`.
+
+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
 

+ 163 - 0
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.