Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/opencode/script/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ function generate(schema: z.ZodType) {
const configFile = process.argv[2]

console.log(configFile)
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
await Bun.write(configFile, JSON.stringify(generate(Config.Info.zod), null, 2))
10 changes: 2 additions & 8 deletions packages/opencode/src/config/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * as ConfigAgent from "./agent"
import { Schema } from "effect"
import z from "zod"
import { Bus } from "@/bus"
import { zod, ZodOverride } from "@/util/effect-zod"
import { zod } from "@/util/effect-zod"
import { Log } from "../util"
import { NamedError } from "@opencode-ai/util/error"
import { Glob } from "@opencode-ai/core/util/glob"
Expand All @@ -22,12 +22,6 @@ const Color = Schema.Union([
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])

// ConfigPermission.Info is a zod schema (its `.preprocess(...).transform(...)`
// shape lives outside the Effect Schema type system), so the walker reaches it
// via ZodOverride rather than a pure Schema reference. This preserves the
// `$ref: PermissionConfig` emitted in openapi.json.
const PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })

const AgentSchema = Schema.StructWithRest(
Schema.Struct({
model: Schema.optional(ConfigModelID),
Expand All @@ -54,7 +48,7 @@ const AgentSchema = Schema.StructWithRest(
description: "Maximum number of agentic iterations before forcing text-only response",
}),
maxSteps: Schema.optional(PositiveInt).annotate({ description: "@deprecated Use 'steps' field instead." }),
permission: Schema.optional(PermissionRef),
permission: Schema.optional(ConfigPermission.Info),
}),
[Schema.Record(Schema.String, Schema.Any)],
)
Expand Down
39 changes: 23 additions & 16 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { Context, Duration, Effect, 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 { withStatics } from "@/util/schema"
import { ConfigAgent } from "./agent"
import { ConfigCommand } from "./command"
import { ConfigFormatter } from "./formatter"
Expand Down Expand Up @@ -138,14 +139,21 @@ export type Layout = ConfigLayout.Layout
// 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 PermissionRef = Schema.Any.annotate({ [ZodOverride]: ConfigPermission.Info })
const LogLevelRef = Schema.Any.annotate({ [ZodOverride]: Log.Level })
const ServerRef = Schema.Any.annotate({ [ZodOverride]: ConfigServer.Server.zod }) as unknown as typeof ConfigServer.Server

const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0))

const InfoSchema = Schema.Struct({
// The Effect Schema is the canonical source of truth. The `.zod` compatibility
// surface is derived so existing Hono validators keep working without a parallel
// Zod definition.
//
// The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json),
// so layer that on after derivation. Re-apply the Config ref afterward
// since `.strict()` strips the walker's meta annotation.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({
description: "JSON schema reference for configuration validation",
}),
Expand Down Expand Up @@ -241,7 +249,7 @@ const InfoSchema = Schema.Struct({
description: "Additional instruction files or patterns to include",
}),
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
permission: Schema.optional(PermissionRef),
permission: Schema.optional(ConfigPermission.Info),
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
enterprise: Schema.optional(
Schema.Struct({
Expand Down Expand Up @@ -287,6 +295,14 @@ const InfoSchema = Schema.Struct({
}),
),
})
.annotate({ identifier: "Config" })
.pipe(
withStatics((s) => ({
zod: (zod(s) as unknown as z.ZodObject<any>)
.strict()
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof s>>>,
})),
)

// Schema.Struct produces readonly types by default, but the service code
// below mutates Info objects directly (e.g. `config.mode = ...`). Strip the
Expand All @@ -308,15 +324,7 @@ type DeepMutable<T> = T extends readonly [unknown, ...unknown[]]
? { -readonly [K in keyof T]: DeepMutable<T[K]> }
: T

// The walker emits `z.object({...})` which is non-strict by default. Config
// historically uses `.strict()` (additionalProperties: false in openapi.json),
// so layer that on after derivation. Re-apply the Config ref afterward
// since `.strict()` strips the walker's meta annotation.
export const Info = (zod(InfoSchema) as unknown as z.ZodObject<any>)
.strict()
.meta({ ref: "Config" }) as unknown as z.ZodType<DeepMutable<Schema.Schema.Type<typeof InfoSchema>>>

export type Info = z.output<typeof Info> & {
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
// plugin_origins is derived state, not a persisted config field. It keeps each winning plugin spec together
// with the file and scope it came from so later runtime code can make location-sensitive decisions.
plugin_origins?: ConfigPlugin.Origin[]
Expand Down Expand Up @@ -424,7 +432,7 @@ const rawLayer = Layer.effect(
),
)
const parsed = ConfigParse.jsonc(expanded, source)
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
const data = ConfigParse.schema(Info.zod, normalizeLoadedConfig(parsed, source), source)
const pluginContextPath = "path" in options ? options.path : virtualConfigFilepath(options)
if (pluginContextPath) {
yield* Effect.promise(() => resolveLoadedPlugins(data, pluginContextPath))
Expand Down Expand Up @@ -840,13 +848,13 @@ const rawLayer = Layer.effect(

let next: Info
if (!file.endsWith(".jsonc")) {
const existing = ConfigParse.schema(Info, ConfigParse.jsonc(before, file), file)
const existing = ConfigParse.schema(Info.zod, 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, ConfigParse.jsonc(updated, file), file)
next = ConfigParse.schema(Info.zod, ConfigParse.jsonc(updated, file), file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}

Expand Down Expand Up @@ -1002,7 +1010,6 @@ export namespace Config {
export const managedConfigDir = ConfigManaged.managedConfigDir
export const parseManagedPlist = ConfigManaged.parseManagedPlist

export const parse = Info.parse
export const get = ConfigGet
export const getGlobal = ConfigGetGlobal
export const getConsoleState = ConfigGetConsoleState
Expand Down
78 changes: 43 additions & 35 deletions packages/opencode/src/config/permission.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
export * as ConfigPermission from "./permission"
import { Schema } from "effect"
import { zod, ZodPreprocess } from "@/util/effect-zod"
import { Schema, SchemaGetter } from "effect"
import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"

export const Action = Schema.Literals(["ask", "allow", "deny"])
Expand All @@ -18,27 +18,30 @@ export const Rule = Schema.Union([Action, Object])
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type Rule = Schema.Schema.Type<typeof Rule>

// Captures the user's original property insertion order before Schema.Struct
// canonicalises the object. See the `ZodPreprocess` comment in
// `util/effect-zod.ts` for the full rationale, in short: rule precedence is
// encoded in JSON key order (`evaluate.ts` uses `findLast`, so later keys win)
// and `Schema.StructWithRest` would otherwise drop that order. Tracked in #113.
const permissionPreprocess = (val: unknown) => {
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
return { __originalKeys: globalThis.Object.keys(val), ...val }
}
return val
}

const ObjectShape = Schema.StructWithRest(
// Known permission keys get explicit types — most are full Rule (either a
// single Action or a per-pattern object), but a handful of tools take no
// sub-target patterns and are Action-only. Unknown keys fall through the
// Record rest signature as Rule.
//
// StructWithRest canonicalises key order on decode (known first, then rest),
// which used to require the `__originalKeys` preprocess hack because
// `Permission.fromConfig` depended on the user's insertion order. That
// dependency is gone — `fromConfig` now sorts top-level keys so wildcard
// permissions come before specifics, making the final precedence
// order-independent.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const InputObject = Schema.StructWithRest(
Schema.Struct({
__originalKeys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))),
read: Schema.optional(Rule),
edit: Schema.optional(Rule),
glob: Schema.optional(Rule),
grep: Schema.optional(Rule),
list: Schema.optional(Rule),
bash: Schema.optional(Rule),
// PawWork's agent rename (#128) made `agent` the canonical permission key
// and tool name. The legacy `task` key is still accepted via the rest
// record + LEGACY_KEY_ALIASES in `permission/index.ts`, but the explicit
// schema field must stay as `agent` so OpenAPI / SDK consumers see the
// post-rename name.
agent: Schema.optional(Rule),
external_directory: Schema.optional(Rule),
todowrite: Schema.optional(Action),
Expand All @@ -53,24 +56,29 @@ const ObjectShape = Schema.StructWithRest(
[Schema.Record(Schema.String, Rule)],
)

const InnerSchema = Schema.Union([ObjectShape, Action]).annotate({
[ZodPreprocess]: permissionPreprocess,
})
// Input the user writes in config: either a single Action (shorthand for "*")
// or an object of per-target rules.
const InputSchema = Schema.Union([Action, InputObject])

// Post-parse: drop the __originalKeys metadata and rebuild the rule map in the
// user's original insertion order. A plain string input (the Action branch of
// the union) becomes `{ "*": action }`.
const transform = (x: unknown): Record<string, Rule> => {
if (typeof x === "string") return { "*": x as Action }
const obj = x as { __originalKeys?: string[] } & Record<string, unknown>
const { __originalKeys, ...rest } = obj
if (!__originalKeys) return rest as Record<string, Rule>
const result: Record<string, Rule> = {}
for (const key of __originalKeys) {
if (key in rest) result[key] = rest[key] as Rule
}
return result
}
// Normalise the Action shorthand into `{ "*": action }`. Object inputs pass
// through untouched.
const normalizeInput = (input: Schema.Schema.Type<typeof InputSchema>): Schema.Schema.Type<typeof InputObject> =>
typeof input === "string" ? { "*": input } : input

export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" })
export type Info = Record<string, Rule>
export const Info = InputSchema.pipe(
Schema.decodeTo(InputObject, {
decode: SchemaGetter.transform(normalizeInput),
// Not perfectly invertible (we lose whether the user originally typed an
// Action shorthand), but the object form is always a valid representation
// of the same rules.
encode: SchemaGetter.passthrough({ strict: false }),
}),
)
.annotate({ identifier: "PermissionConfig" })
.pipe(
// Walker already emits the decodeTo transform into the derived zod (see
// `encoded()` in effect-zod.ts), so just expose that directly.
withStatics((s) => ({ zod: zod(s) })),
)
type _Info = Schema.Schema.Type<typeof InputObject>
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
10 changes: 6 additions & 4 deletions packages/opencode/src/control-plane/schema.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { Schema } from "effect"
import z from "zod"

import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"

const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID"))
const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe(
Schema.brand("WorkspaceID"),
)

export type WorkspaceID = typeof workspaceIdSchema.Type

export const WorkspaceID = workspaceIdSchema.pipe(
withStatics((schema: typeof workspaceIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("workspace", id)),
zod: Identifier.schema("workspace").pipe(z.custom<WorkspaceID>()),
zod: zod(schema),
})),
)
18 changes: 17 additions & 1 deletion packages/opencode/src/permission/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,25 @@ export namespace Permission {
}

export function fromConfig(permission: Config.Permission) {
// Sort top-level keys so wildcard permissions (`*`, `mcp_*`) come before
// specific ones. Combined with `findLast` in `disabled()`, this gives the
// intuitive semantic "specific tool rules override the `*` fallback"
// regardless of the user's JSON key order — which is now reordered by
// ConfigPermission.Info's StructWithRest decoder anyway. Sub-pattern
// order inside a single permission key is preserved.
const entries = Object.entries(permission).sort(([a], [b]) => {
const aWildcard = a.includes("*")
const bWildcard = b.includes("*")
if (aWildcard !== bWildcard) return aWildcard ? -1 : 1
return 0
})
const ruleset: Ruleset = []
for (const [rawKey, value] of Object.entries(permission)) {
for (const [rawKey, value] of entries) {
const key = LEGACY_KEY_ALIASES[rawKey] ?? rawKey
// If a config sets both the canonical key (`agent`) and its legacy alias
// (`task`), drop the legacy entry so the canonical rule isn't silently
// overridden by the alias under last-match-wins precedence.
if (key !== rawKey && Object.prototype.hasOwnProperty.call(permission, key)) continue
if (typeof value === "string") {
Comment thread
Astro-Han marked this conversation as resolved.
ruleset.push({ permission: key, action: value, pattern: "*" })
continue
Expand Down
9 changes: 6 additions & 3 deletions packages/opencode/src/permission/schema.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Schema } from "effect"
import z from "zod"

import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"

export class PermissionID extends Newtype<PermissionID>()("PermissionID", Schema.String) {
export class PermissionID extends Newtype<PermissionID>()(
"PermissionID",
Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }),
) {
static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id))
}

static readonly zod = Identifier.schema("permission") as unknown as z.ZodType<PermissionID>
static readonly zod = zod(this)
}
4 changes: 2 additions & 2 deletions packages/opencode/src/project/schema.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Schema } from "effect"
import z from "zod"

import { zod } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"

const projectIdSchema = Schema.String.pipe(Schema.brand("ProjectID"))
Expand All @@ -10,6 +10,6 @@ export type ProjectID = typeof projectIdSchema.Type
export const ProjectID = projectIdSchema.pipe(
withStatics((schema: typeof projectIdSchema) => ({
global: schema.make("global"),
zod: z.string().pipe(z.custom<ProjectID>()),
zod: zod(schema),
})),
)
6 changes: 3 additions & 3 deletions packages/opencode/src/pty/schema.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { Schema } from "effect"
import z from "zod"

import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema"

const ptyIdSchema = Schema.String.pipe(Schema.brand("PtyID"))
const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID"))

export type PtyID = typeof ptyIdSchema.Type

export const PtyID = ptyIdSchema.pipe(
withStatics((schema: typeof ptyIdSchema) => ({
ascending: (id?: string) => schema.make(Identifier.ascending("pty", id)),
zod: Identifier.schema("pty").pipe(z.custom<PtyID>()),
zod: zod(schema),
})),
)
9 changes: 6 additions & 3 deletions packages/opencode/src/question/schema.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { Schema } from "effect"
import z from "zod"

import { Identifier } from "@/id/id"
import { zod, ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema"

export class QuestionID extends Newtype<QuestionID>()("QuestionID", Schema.String) {
export class QuestionID extends Newtype<QuestionID>()(
"QuestionID",
Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }),
) {
static ascending(id?: string): QuestionID {
return this.make(Identifier.ascending("question", id))
}

static readonly zod = Identifier.schema("question") as unknown as z.ZodType<QuestionID>
static readonly zod = zod(this)
}
6 changes: 3 additions & 3 deletions packages/opencode/src/server/instance/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const ConfigRoutes = lazy(() =>
description: "Get config info",
content: {
"application/json": {
schema: resolver(Config.Info),
schema: resolver(Config.Info.zod),
},
},
},
Expand All @@ -44,14 +44,14 @@ export const ConfigRoutes = lazy(() =>
description: "Successfully updated config",
content: {
"application/json": {
schema: resolver(Config.Info),
schema: resolver(Config.Info.zod),
},
},
},
...errors(400),
},
}),
validator("json", Config.Info),
validator("json", Config.Info.zod),
async (c) => {
const config = c.req.valid("json")
await Config.update(config)
Expand Down
Loading
Loading