mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 17:19:49 -04:00
Compare commits
3 Commits
tool-truncation
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cf97f1b96 | |||
| 65c6a71903 | |||
| a779027303 |
+34
-16
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -19,13 +19,20 @@ function statusError(status: McpServer["status"]) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
function Status(props: { enabled: boolean; loading: boolean }) {
|
||||
const theme = useTheme("elevated")
|
||||
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
||||
if (props.enabled) {
|
||||
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
||||
function Status(props: { status: McpServer["status"]; loading: boolean }) {
|
||||
if (props.loading || props.status.status === "pending") {
|
||||
return <>Connecting …</>
|
||||
}
|
||||
return <span style={{ fg: theme.text.subdued }}>○ Disabled</span>
|
||||
if (props.status.status === "connected") {
|
||||
return <span style={{ attributes: TextAttributes.BOLD }}>Connected ✓</span>
|
||||
}
|
||||
if (props.status.status === "failed") {
|
||||
return <>Failed !</>
|
||||
}
|
||||
if (props.status.status === "needs_auth") {
|
||||
return <>Sign in required →</>
|
||||
}
|
||||
return <>Disabled ○</>
|
||||
}
|
||||
|
||||
export function DialogMcp() {
|
||||
@@ -38,6 +45,13 @@ export function DialogMcp() {
|
||||
const [detail, setDetail] = createSignal<McpServer>()
|
||||
const [loading, setLoading] = createSignal<string | null>(null)
|
||||
|
||||
const statusColor = (status: McpServer["status"]) => {
|
||||
if (status.status === "connected") return theme.text.feedback.success.default
|
||||
if (status.status === "failed") return theme.text.feedback.error.default
|
||||
if (status.status === "needs_auth") return theme.text.feedback.warning.default
|
||||
return theme.text.subdued
|
||||
}
|
||||
|
||||
const servers = createMemo(() =>
|
||||
pipe(
|
||||
data.location.mcp.server.list() ?? [],
|
||||
@@ -53,17 +67,29 @@ export function DialogMcp() {
|
||||
|
||||
const options = createMemo(() => {
|
||||
const loadingMcp = loading()
|
||||
return servers().map((server) => ({
|
||||
value: server.name,
|
||||
title: server.name,
|
||||
description: server.status.status,
|
||||
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
|
||||
}))
|
||||
return servers().map((server) => {
|
||||
const pending = loadingMcp === server.name || server.status.status === "pending"
|
||||
return {
|
||||
value: server.name,
|
||||
title: server.name,
|
||||
footer: <Status status={server.status} loading={pending} />,
|
||||
footerColor: pending ? theme.text.subdued : statusColor(server.status),
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const focusedServer = createMemo(() => servers().find((server) => server.name === focused()))
|
||||
|
||||
const toggleTitle = createMemo(() => {
|
||||
const status = focusedServer()?.status.status
|
||||
if (status === "connected") return "disconnect"
|
||||
if (status === "failed") return "retry"
|
||||
if (status === "needs_auth") return "sign in"
|
||||
return "connect"
|
||||
})
|
||||
|
||||
const focusedError = createMemo(() => {
|
||||
const name = focused()
|
||||
const server = servers().find((entry) => entry.name === name)
|
||||
const server = focusedServer()
|
||||
return server ? statusError(server.status) : undefined
|
||||
})
|
||||
|
||||
@@ -100,7 +126,7 @@ export function DialogMcp() {
|
||||
onSelect={(option) => open(option.value as string)}
|
||||
actions={[
|
||||
{
|
||||
title: "toggle",
|
||||
title: toggleTitle(),
|
||||
command: "dialog.mcp.toggle",
|
||||
onTrigger: (option) => {
|
||||
setFocused(option.value as string)
|
||||
|
||||
@@ -84,7 +84,8 @@ export function DialogPrompt(props: DialogPromptProps) {
|
||||
<box gap={1}>
|
||||
{props.description?.()}
|
||||
<textarea
|
||||
height={3}
|
||||
height={1}
|
||||
wrapMode="none"
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
setTextareaTarget(val)
|
||||
|
||||
@@ -71,6 +71,7 @@ export interface DialogSelectOption<T = any> {
|
||||
detailsColor?: RGBA
|
||||
detailsWrap?: boolean
|
||||
footer?: JSX.Element | string
|
||||
footerColor?: RGBA
|
||||
titleWidth?: number
|
||||
truncateTitle?: boolean | "left"
|
||||
category?: string
|
||||
@@ -727,6 +728,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
||||
footer={
|
||||
flatten() ? (option.searchFooter ?? option.category ?? option.footer) : option.footer
|
||||
}
|
||||
footerColor={option.footerColor}
|
||||
titleWidth={option.titleWidth}
|
||||
truncateTitle={option.truncateTitle}
|
||||
description={option.description !== category ? option.description : undefined}
|
||||
@@ -784,6 +786,7 @@ function Option(props: {
|
||||
current?: boolean
|
||||
muted?: boolean
|
||||
footer?: JSX.Element | string
|
||||
footerColor?: RGBA
|
||||
titleWidth?: number
|
||||
truncateTitle?: boolean | "left"
|
||||
gutter?: () => JSX.Element
|
||||
@@ -832,7 +835,17 @@ function Option(props: {
|
||||
</text>
|
||||
<Show when={props.footer}>
|
||||
<box flexShrink={0}>
|
||||
<text fg={props.active && !props.muted ? text() : theme.text.subdued}>{props.footer}</text>
|
||||
<text
|
||||
fg={
|
||||
props.active && !props.muted
|
||||
? text()
|
||||
: props.muted && (props.active || props.current)
|
||||
? theme.text.subdued
|
||||
: (props.footerColor ?? theme.text.subdued)
|
||||
}
|
||||
>
|
||||
{props.footer}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
</>
|
||||
|
||||
@@ -81,7 +81,6 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"compaction": {
|
||||
"auto": true,
|
||||
"prune": false,
|
||||
"keep": {
|
||||
"tokens": 15000
|
||||
},
|
||||
@@ -93,7 +92,6 @@ Add `compaction` to any [OpenCode configuration file](/config):
|
||||
| Field | Default | V2 behavior |
|
||||
| --- | ---: | --- |
|
||||
| `auto` | `true` | Runs the preflight context-size check. It does not disable manual compaction or one-shot provider-overflow recovery. |
|
||||
| `prune` | None | Accepted by the V2 schema, but currently has no runtime effect. V2 does not prune old tool outputs in place. |
|
||||
| `keep.tokens` | `15000` | Approximate number of tokens from the newest serialized conversation context to retain beside the summary. |
|
||||
| `buffer` | `20000` | Safety reserve below an explicit input limit. Without one, it is the minimum context reserve and the model output allowance wins when larger. |
|
||||
|
||||
@@ -135,8 +133,6 @@ behavior.
|
||||
|
||||
## Current limitations
|
||||
|
||||
- `prune` is reserved configuration; V1-style in-place tool-output pruning is
|
||||
not implemented in V2.
|
||||
- Compaction requires a resolvable model with a positive catalog context limit.
|
||||
There is no separate compaction-model setting or fallback model.
|
||||
- Summary generation can fail if the summary prompt itself cannot fit beside
|
||||
|
||||
@@ -16,15 +16,17 @@ V2 has three intentional breaking changes:
|
||||
- The [server API and clients](#server-api-and-clients) have new contracts.
|
||||
- [TUI configuration](#tui-configuration) moves from layered `tui.json(c)` files to one global `cli.json` file (auto migrated).
|
||||
|
||||
All other functionality is intended to remain compatible with V1.
|
||||
Supported V1 functionality outside those areas is intended to remain compatible with V1. Some fields accepted by the V1
|
||||
schema never had a V2 equivalent and are intentionally ignored; these are listed under
|
||||
[Accepted but unsupported fields](#accepted-but-unsupported-fields).
|
||||
|
||||
Existing server config files, agent definitions, command definitions, skills, and other files in `.opencode/` should
|
||||
continue to work without changes. If one of these stops working in V2, treat it as a beta compatibility bug rather than
|
||||
an expected migration requirement.
|
||||
Existing supported server config fields, agent definitions, command definitions, skills, and other files in `.opencode/`
|
||||
should continue to work without changes. If supported behavior described in this guide stops working in V2, treat it as a
|
||||
beta compatibility bug rather than an expected migration requirement.
|
||||
|
||||
<Callout type="tip">
|
||||
Run `/report` if existing V1 functionality does not work in V2. The report skill collects diagnostics and helps you file
|
||||
a compatibility issue.
|
||||
Run `/report` if supported V1 functionality does not work in V2. The report skill collects diagnostics and helps you
|
||||
file a compatibility issue.
|
||||
</Callout>
|
||||
|
||||
<Callout type="warning">
|
||||
@@ -60,8 +62,9 @@ V2 reads existing global and project configuration from the same locations as V1
|
||||
<project>/.opencode/opencode.json(c)
|
||||
```
|
||||
|
||||
V2 reads these same locations. It detects V1-shaped configuration and translates it in memory without rewriting the
|
||||
source file. Existing V1 configuration is intended to keep working, so you do not need to convert it to try or adopt V2.
|
||||
V2 reads these same locations. It normalizes supported V1 and native V2 fields in memory without rewriting the source
|
||||
file. Existing supported V1 configuration is intended to keep working, so you do not need to convert it to try or adopt
|
||||
V2.
|
||||
|
||||
### Ask OpenCode to migrate
|
||||
|
||||
@@ -76,7 +79,13 @@ Preserve its behavior and all unrelated settings.
|
||||
```
|
||||
|
||||
OpenCode can inspect the complete file, apply the relevant changes below, and avoid rewriting settings that do not need to
|
||||
change. Do not mix V1 and V2 field names manually in one file.
|
||||
change. Conversion does not need to happen all at once: supported V1 and native V2 fields may coexist at the top level.
|
||||
When both forms set the same canonical value, a valid native V2 value takes precedence regardless of JSON key order.
|
||||
|
||||
Nested mixing is intentionally bounded. OpenCode recognizes mixed V1 and V2 members within `mcp`, `compaction`, and
|
||||
`experimental`, but it does not recursively infer formats inside individual agents, providers, commands, or models. Keep
|
||||
each of those nested entries entirely in one format. Supported V1 syntax remains quiet by itself; malformed values,
|
||||
unsupported legacy fields, and conflicting V1/V2 values produce warnings while unrelated valid settings continue to load.
|
||||
|
||||
### Sharing
|
||||
|
||||
@@ -250,8 +259,8 @@ V2 groups the retained-context token budget under `keep` and gives the reserve a
|
||||
}
|
||||
```
|
||||
|
||||
`auto` and `prune` keep their names. V2 has no native `tail_turns` field; recent context is retained by token budget instead.
|
||||
See [Compaction](/compaction).
|
||||
`auto` keeps its name. V2 has no native `tail_turns` or `prune` field; both legacy fields are ignored with a warning. Recent
|
||||
context is retained by token budget instead. See [Compaction](/compaction).
|
||||
|
||||
### Skills
|
||||
|
||||
@@ -354,6 +363,17 @@ Rename the singular `provider` map to `providers`. V2 separates the runtime pack
|
||||
V1 `npm` becomes `package`, and AI SDK packages receive the `aisdk:` prefix. `api` becomes `settings.baseURL`. Provider
|
||||
`options` are separated into `settings`, `headers`, and `body` according to their request role. See [Providers](/providers).
|
||||
|
||||
V2 consolidated two legacy provider namespaces:
|
||||
|
||||
| V1 provider ID | Canonical V2 provider ID |
|
||||
| --- | --- |
|
||||
| `azure-cognitive-services` | `azure` |
|
||||
| `google-vertex-anthropic` | `google-vertex` |
|
||||
|
||||
Migration of unambiguous V1 provider, agent, command, and provider-filter fields uses these canonical IDs. The shared
|
||||
top-level `model` field keeps its exact provider ID because the same syntax is valid in native V2 config; update that field
|
||||
to the canonical ID when migrating a legacy built-in provider.
|
||||
|
||||
### Models and variants
|
||||
|
||||
Models remain nested under their provider, but several model fields become more explicit:
|
||||
@@ -390,22 +410,39 @@ Models remain nested under their provider, but several model fields become more
|
||||
|
||||
See [Models](/models) for the complete native model shape.
|
||||
|
||||
### Fields without native equivalents
|
||||
### Supported fields without direct native equivalents
|
||||
|
||||
Most fields that keep the same shape, including `shell`, `model`, `default_agent`, `autoupdate`, `watcher`, `formatter`,
|
||||
`lsp`, `instructions`, `enterprise`, and `tool_output`, require no migration.
|
||||
|
||||
These V1 fields do not have one-to-one native V2 config fields:
|
||||
The V1 provider filters do not have one-to-one native V2 config fields, but their behavior remains supported:
|
||||
|
||||
- `enabled_providers` becomes an internal deny-by-default provider policy followed by allows for the listed providers.
|
||||
- `disabled_providers` becomes internal deny policies for the listed providers.
|
||||
|
||||
You may keep these fields in V1 syntax. OpenCode normalizes them without warning.
|
||||
|
||||
### Accepted but unsupported fields
|
||||
|
||||
The V1 schema also accepted fields that have no supported V2 behavior. V2 ignores these values and emits a warning so
|
||||
they are not mistaken for active configuration:
|
||||
|
||||
- `logLevel`: use `OPENCODE_LOG_LEVEL` when starting OpenCode.
|
||||
- `server`: use the V2 service and explicit server options; the server API is an intentional breaking change.
|
||||
- `layout`: remove it; V1 already treated it as deprecated and always used stretch layout.
|
||||
- `enabled_providers` and `disabled_providers`: there is no native provider allowlist or denylist field yet.
|
||||
- `small_model`: V2 selects models for internal maintenance agents without a separate top-level field.
|
||||
- `compaction.tail_turns`: V2 uses `compaction.keep.tokens` instead.
|
||||
- Top-level `subagent_depth`: use `experimental.subagent_depth` instead.
|
||||
- `compaction.tail_turns` and `compaction.prune`: V2 uses `compaction.keep.tokens` and checkpoint-based compaction instead.
|
||||
- Agent `name` inside V1 JSON configuration.
|
||||
- An enabled-only V1 MCP entry without a `type`.
|
||||
- V1 experimental fields `disable_paste_summary`, `batch_tool`, `openTelemetry`, `primary_tools`, and
|
||||
`continue_loop_on_deny`.
|
||||
- V1 provider fields `id`, `whitelist`, and `blacklist`.
|
||||
- V1 provider-model fields `release_date`, `attachment`, `reasoning`, `temperature`, `experimental`, a non-`deprecated`
|
||||
`status`, and boolean `interleaved`.
|
||||
|
||||
If your V1 configuration relies on a field without a native equivalent, keep using the supported V1 format rather than
|
||||
forcing a manual conversion. Run `/report` if V2 does not preserve the behavior you rely on.
|
||||
Ignoring these fields is intentional and is not a compatibility regression. If V2 does not preserve behavior identified
|
||||
as supported elsewhere in this guide, run `/report`.
|
||||
|
||||
### Agent files
|
||||
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user