Skip to content

Commit 3d3f566

Browse files
authored
fix(config): preserve permission order with Effect decode (anomalyco#24308)
1 parent 88dfa23 commit 3d3f566

5 files changed

Lines changed: 146 additions & 90 deletions

File tree

packages/opencode/src/config/agent.ts

Lines changed: 17 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,16 @@
11
export * as ConfigAgent from "./agent"
22

3-
import { Schema } from "effect"
4-
import z from "zod"
3+
import { Exit, Schema, SchemaGetter } from "effect"
54
import { Bus } from "@/bus"
65
import { zod } from "@/util/effect-zod"
7-
import { PositiveInt } from "@/util/schema"
6+
import { PositiveInt, withStatics } from "@/util/schema"
87
import { Log } from "../util"
98
import { NamedError } from "@opencode-ai/core/util/error"
109
import { Glob } from "@opencode-ai/core/util/glob"
1110
import { configEntryNameFromPath } from "./entry-name"
12-
import { InvalidError } from "./error"
1311
import * as ConfigMarkdown from "./markdown"
1412
import { ConfigModelID } from "./model-id"
13+
import { ConfigParse } from "./parse"
1514
import { ConfigPermission } from "./permission"
1615

1716
const log = Log.create({ service: "config" })
@@ -77,7 +76,7 @@ const KNOWN_KEYS = new Set([
7776
// - Translate the deprecated `tools: { name: boolean }` map into the new
7877
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
7978
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
80-
const normalize = (agent: z.infer<typeof Info>) => {
79+
const normalize = (agent: Schema.Schema.Type<typeof AgentSchema>): Schema.Schema.Type<typeof AgentSchema> => {
8180
const options: Record<string, unknown> = { ...agent.options }
8281
for (const [key, value] of Object.entries(agent)) {
8382
if (!KNOWN_KEYS.has(key)) options[key] = value
@@ -98,14 +97,15 @@ const normalize = (agent: z.infer<typeof Info>) => {
9897
return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
9998
}
10099

101-
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
102-
Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
103-
options?: Record<string, unknown>
104-
permission?: ConfigPermission.Info
105-
steps?: number
106-
}
107-
>
108-
export type Info = z.infer<typeof Info>
100+
export const Info = AgentSchema.pipe(
101+
Schema.decodeTo(AgentSchema, {
102+
decode: SchemaGetter.transform(normalize),
103+
encode: SchemaGetter.passthrough({ strict: false }),
104+
}),
105+
)
106+
.annotate({ identifier: "AgentConfig" })
107+
.pipe(withStatics((s) => ({ zod: zod(s) })))
108+
export type Info = Schema.Schema.Type<typeof Info>
109109

110110
export async function load(dir: string) {
111111
const result: Record<string, Info> = {}
@@ -134,12 +134,7 @@ export async function load(dir: string) {
134134
...md.data,
135135
prompt: md.content.trim(),
136136
}
137-
const parsed = Info.safeParse(config)
138-
if (parsed.success) {
139-
result[config.name] = parsed.data
140-
continue
141-
}
142-
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
137+
result[config.name] = ConfigParse.effectSchema(Info, config, item)
143138
}
144139
return result
145140
}
@@ -168,10 +163,10 @@ export async function loadMode(dir: string) {
168163
...md.data,
169164
prompt: md.content.trim(),
170165
}
171-
const parsed = Info.safeParse(config)
172-
if (parsed.success) {
166+
const parsed = Schema.decodeUnknownExit(Info)(config, { errors: "all", propertyOrder: "original" })
167+
if (Exit.isSuccess(parsed)) {
173168
result[config.name] = {
174-
...parsed.data,
169+
...parsed.value,
175170
mode: "primary" as const,
176171
}
177172
}

packages/opencode/src/config/config.ts

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import { InstanceState } from "@/effect"
2424
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
2525
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
2626
import { InstanceRef } from "@/effect/instance-ref"
27-
import { zod, ZodOverride } from "@/util/effect-zod"
27+
import { zod } from "@/util/effect-zod"
2828
import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema"
2929
import { ConfigAgent } from "./agent"
3030
import { ConfigCommand } from "./command"
@@ -81,12 +81,10 @@ export const Server = ConfigServer.Server.zod
8181
export const Layout = ConfigLayout.Layout.zod
8282
export type Layout = ConfigLayout.Layout
8383

84-
// Schemas that still live at the zod layer (have .transform / .preprocess /
85-
// .meta not expressible in current Effect Schema) get referenced via a
86-
// ZodOverride-annotated Schema.Any. Walker sees the annotation and emits the
87-
// exact zod directly, preserving component $refs.
88-
const AgentRef = Schema.Any.annotate({ [ZodOverride]: ConfigAgent.Info })
89-
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
84+
const LogLevelRef = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
85+
identifier: "LogLevel",
86+
description: "Log level",
87+
})
9088

9189
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
9290
// surface is derived so existing Hono validators keep working without a parallel
@@ -152,27 +150,27 @@ export const Info = Schema.Struct({
152150
mode: Schema.optional(
153151
Schema.StructWithRest(
154152
Schema.Struct({
155-
build: Schema.optional(AgentRef),
156-
plan: Schema.optional(AgentRef),
153+
build: Schema.optional(ConfigAgent.Info),
154+
plan: Schema.optional(ConfigAgent.Info),
157155
}),
158-
[Schema.Record(Schema.String, AgentRef)],
156+
[Schema.Record(Schema.String, ConfigAgent.Info)],
159157
),
160158
).annotate({ description: "@deprecated Use `agent` field instead." }),
161159
agent: Schema.optional(
162160
Schema.StructWithRest(
163161
Schema.Struct({
164162
// primary
165-
plan: Schema.optional(AgentRef),
166-
build: Schema.optional(AgentRef),
163+
plan: Schema.optional(ConfigAgent.Info),
164+
build: Schema.optional(ConfigAgent.Info),
167165
// subagent
168-
general: Schema.optional(AgentRef),
169-
explore: Schema.optional(AgentRef),
166+
general: Schema.optional(ConfigAgent.Info),
167+
explore: Schema.optional(ConfigAgent.Info),
170168
// specialized
171-
title: Schema.optional(AgentRef),
172-
summary: Schema.optional(AgentRef),
173-
compaction: Schema.optional(AgentRef),
169+
title: Schema.optional(ConfigAgent.Info),
170+
summary: Schema.optional(ConfigAgent.Info),
171+
compaction: Schema.optional(ConfigAgent.Info),
174172
}),
175-
[Schema.Record(Schema.String, AgentRef)],
173+
[Schema.Record(Schema.String, ConfigAgent.Info)],
176174
),
177175
).annotate({ description: "Agent configuration, see https://opencode.ai/docs/agents" }),
178176
provider: Schema.optional(Schema.Record(Schema.String, ConfigProvider.Info)).annotate({
@@ -184,7 +182,7 @@ export const Info = Schema.Struct({
184182
Schema.Union([
185183
ConfigMCP.Info,
186184
// Matches the legacy `{ enabled: false }` form used to disable a server.
187-
Schema.Any.annotate({ [ZodOverride]: z.object({ enabled: z.boolean() }).strict() }),
185+
Schema.Struct({ enabled: Schema.Boolean }),
188186
]),
189187
),
190188
).annotate({ description: "MCP (Model Context Protocol) server configurations" }),
@@ -362,7 +360,7 @@ export const layer = Layer.effect(
362360
),
363361
)
364362
const parsed = ConfigParse.jsonc(expanded, source)
365-
const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source)
363+
const data = ConfigParse.effectSchema(Info, normalizeLoadedConfig(parsed, source), source)
366364
if (!("path" in options)) return data
367365

368366
yield* Effect.promise(() => resolveLoadedPlugins(data, options.path))
@@ -754,13 +752,13 @@ export const layer = Layer.effect(
754752

755753
let next: Info
756754
if (!file.endsWith(".jsonc")) {
757-
const existing = ConfigParse.schema(Info.zod, ConfigParse.jsonc(before, file), file)
755+
const existing = ConfigParse.effectSchema(Info, ConfigParse.jsonc(before, file), file)
758756
const merged = mergeDeep(writable(existing), writable(config))
759757
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
760758
next = merged
761759
} else {
762760
const updated = patchJsonc(before, writable(config))
763-
next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
761+
next = ConfigParse.effectSchema(Info, ConfigParse.jsonc(updated, file), file)
764762
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
765763
}
766764

packages/opencode/src/config/parse.ts

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
export * as ConfigParse from "./parse"
22

33
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
4+
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
45
import z from "zod"
6+
import type { DeepMutable } from "@/util/schema"
57
import { InvalidError, JsonError } from "./error"
68

7-
type Schema<T> = z.ZodType<T>
9+
type ZodSchema<T> = z.ZodType<T>
810

911
export function jsonc(text: string, filepath: string): unknown {
1012
const errors: JsoncParseError[] = []
@@ -33,7 +35,7 @@ export function jsonc(text: string, filepath: string): unknown {
3335
return data
3436
}
3537

36-
export function schema<T>(schema: Schema<T>, data: unknown, source: string): T {
38+
export function schema<T>(schema: ZodSchema<T>, data: unknown, source: string): T {
3739
const parsed = schema.safeParse(data)
3840
if (parsed.success) return parsed.data
3941

@@ -42,3 +44,45 @@ export function schema<T>(schema: Schema<T>, data: unknown, source: string): T {
4244
issues: parsed.error.issues,
4345
})
4446
}
47+
48+
export function effectSchema<S extends EffectSchema.Decoder<unknown, never>>(
49+
schema: S,
50+
data: unknown,
51+
source: string,
52+
): DeepMutable<S["Type"]> {
53+
const extra = topLevelExtraKeys(schema, data)
54+
if (extra.length) {
55+
throw new InvalidError({
56+
path: source,
57+
issues: [
58+
{
59+
code: "unrecognized_keys",
60+
keys: extra,
61+
path: [],
62+
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
63+
} as z.core.$ZodIssue,
64+
],
65+
})
66+
}
67+
68+
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
69+
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
70+
const error = Cause.squash(decoded.cause)
71+
72+
throw new InvalidError(
73+
{
74+
path: source,
75+
issues: EffectSchema.isSchemaError(error)
76+
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
77+
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
78+
},
79+
{ cause: error },
80+
)
81+
}
82+
83+
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
84+
if (typeof data !== "object" || data === null || Array.isArray(data)) return []
85+
if (schema.ast._tag !== "Objects" || schema.ast.indexSignatures.length > 0) return []
86+
const known = new Set(schema.ast.propertySignatures.map((item) => String(item.name)))
87+
return Object.keys(data).filter((key) => !known.has(key))
88+
}

packages/opencode/src/config/permission.ts

Lines changed: 3 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
export * as ConfigPermission from "./permission"
22
import { Schema, SchemaGetter } from "effect"
3-
import z from "zod"
4-
import { ZodOverride, zod } from "@/util/effect-zod"
3+
import { zod } from "@/util/effect-zod"
54
import { withStatics } from "@/util/schema"
65

76
export const Action = Schema.Literals(["ask", "allow", "deny"])
@@ -20,8 +19,8 @@ export const Rule = Schema.Union([Action, Object])
2019
export type Rule = Schema.Schema.Type<typeof Rule>
2120

2221
// Known permission keys get explicit types in the Effect schema for generated
23-
// docs/types. Runtime config parsing uses `InfoZod` below so user key order is
24-
// preserved for permission precedence.
22+
// docs/types. Runtime config parsing uses Effect's `propertyOrder: "original"`
23+
// parse option so user key order is preserved for permission precedence.
2524
const InputObject = Schema.StructWithRest(
2625
Schema.Struct({
2726
read: Schema.optional(Rule),
@@ -53,35 +52,6 @@ const InputSchema = Schema.Union([Action, InputObject])
5352
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
5453
typeof input === "string" ? { "*": input } : input
5554

56-
const InfoZod = z
57-
.union([
58-
zod(Action),
59-
z.intersection(
60-
z.record(z.string(), zod(Rule)),
61-
z
62-
.object({
63-
read: zod(Rule).optional(),
64-
edit: zod(Rule).optional(),
65-
glob: zod(Rule).optional(),
66-
grep: zod(Rule).optional(),
67-
list: zod(Rule).optional(),
68-
bash: zod(Rule).optional(),
69-
task: zod(Rule).optional(),
70-
external_directory: zod(Rule).optional(),
71-
todowrite: zod(Action).optional(),
72-
question: zod(Action).optional(),
73-
webfetch: zod(Action).optional(),
74-
websearch: zod(Action).optional(),
75-
codesearch: zod(Action).optional(),
76-
lsp: zod(Rule).optional(),
77-
doom_loop: zod(Action).optional(),
78-
skill: zod(Rule).optional(),
79-
})
80-
.catchall(zod(Rule)),
81-
),
82-
])
83-
.transform(normalizeInput)
84-
8555
export const Info = InputSchema.pipe(
8656
Schema.decodeTo(InputObject, {
8757
decode: SchemaGetter.transform(normalizeInput),
@@ -92,7 +62,6 @@ export const Info = InputSchema.pipe(
9262
}),
9363
)
9464
.annotate({ identifier: "PermissionConfig" })
95-
.annotate({ [ZodOverride]: InfoZod })
9665
.pipe(
9766
// Walker already emits the decodeTo transform into the derived zod (see
9867
// `encoded()` in effect-zod.ts), so just expose that directly.

0 commit comments

Comments
 (0)