fix(config): preserve permission order with Effect decode (#24308)

This commit is contained in:
Kit Langton 2026-04-25 13:30:12 -04:00 committed by GitHub
commit a9740b9133
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 146 additions and 90 deletions

View file

@ -24,7 +24,7 @@ import { InstanceState } from "@/effect"
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
import { InstanceRef } from "@/effect/instance-ref"
import { zod, ZodOverride } from "@/util/effect-zod"
import { zod } from "@/util/effect-zod"
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
@ -81,12 +81,10 @@ export const Server = ConfigServer.Server.zod
export const Layout = ConfigLayout.Layout.zod
export type Layout = ConfigLayout.Layout
// Schemas that still live at the zod layer (have .transform / .preprocess /
// .meta not expressible in current Effect Schema) get referenced via a
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
// exact zod directly, preserving component $refs.
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
// surface is derived so existing Hono validators keep working without a parallel
@ -152,27 +150,27 @@ export const Info = Schema.Struct({
mode: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
build: Schema.optional(AgentRef),
plan: Schema.optional(AgentRef),
build: Schema.optional(ConfigAgent.Info),
plan: Schema.optional(ConfigAgent.Info),
}),
[Schema.Record(Schema.String, AgentRef)],
[Schema.Record(Schema.String, ConfigAgent.Info)],
),
).annotate({ description: "@deprecated Use `agent` field instead." }),
agent: Schema.optional(
Schema.StructWithRest(
Schema.Struct({
// primary
plan: Schema.optional(AgentRef),
build: Schema.optional(AgentRef),
plan: Schema.optional(ConfigAgent.Info),
build: Schema.optional(ConfigAgent.Info),
// subagent
general: Schema.optional(AgentRef),
explore: Schema.optional(AgentRef),
general: Schema.optional(ConfigAgent.Info),
explore: Schema.optional(ConfigAgent.Info),
// specialized
title: Schema.optional(AgentRef),
summary: Schema.optional(AgentRef),
compaction: Schema.optional(AgentRef),
title: Schema.optional(ConfigAgent.Info),
summary: Schema.optional(ConfigAgent.Info),
compaction: Schema.optional(ConfigAgent.Info),
}),
[Schema.Record(Schema.String, AgentRef)],
[Schema.Record(Schema.String, ConfigAgent.Info)],
),
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
@ -184,7 +182,7 @@ export const Info = Schema.Struct({
Schema.Union([
ConfigMCP.Info,
// Matches the legacy `{ enabled: false }` form used to disable a server.
Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
Schema.Struct({ enabled: Schema.Boolean }),
]),
),
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
@ -362,7 +360,7 @@ export const layer = Layer.effect(
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source)
const data = ConfigParse.effectSchema(Info, normalizeLoadedConfig(parsed, source), source)
if (!("path" in options)) return data
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
@ -754,13 +752,13 @@ export const layer = Layer.effect(
let next: Info
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file)
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
const merged = mergeDeep(writable(existing), writable(config))
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged
} else {
const updated = patchJsonc(before, writable(config))
next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}