diff --git a/packages/opencode/script/schema.ts b/packages/opencode/script/schema.ts index e7c44b057..ecb059543 100755 --- a/packages/opencode/script/schema.ts +++ b/packages/opencode/script/schema.ts @@ -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)) diff --git a/packages/opencode/src/config/agent.ts b/packages/opencode/src/config/agent.ts index 2fb511dbe..c53d6c13e 100644 --- a/packages/opencode/src/config/agent.ts +++ b/packages/opencode/src/config/agent.ts @@ -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" @@ -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), @@ -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)], ) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index d979f7702..d9a775654 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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" @@ -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. +export const Info = Schema.Struct({ $schema: Schema.optional(Schema.String).annotate({ description: "JSON schema reference for configuration validation", }), @@ -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({ @@ -287,6 +295,14 @@ const InfoSchema = Schema.Struct({ }), ), }) + .annotate({ identifier: "Config" }) + .pipe( + withStatics((s) => ({ + zod: (zod(s) as unknown as z.ZodObject) + .strict() + .meta({ ref: "Config" }) as unknown as z.ZodType>>, + })), + ) // Schema.Struct produces readonly types by default, but the service code // below mutates Info objects directly (e.g. `config.mode = ...`). Strip the @@ -308,15 +324,7 @@ type DeepMutable = T extends readonly [unknown, ...unknown[]] ? { -readonly [K in keyof T]: DeepMutable } : 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) - .strict() - .meta({ ref: "Config" }) as unknown as z.ZodType>> - -export type Info = z.output & { +export type Info = DeepMutable> & { // 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[] @@ -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)) @@ -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) } @@ -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 diff --git a/packages/opencode/src/config/permission.ts b/packages/opencode/src/config/permission.ts index adf349bb1..db84333f1 100644 --- a/packages/opencode/src/config/permission.ts +++ b/packages/opencode/src/config/permission.ts @@ -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"]) @@ -18,27 +18,30 @@ export const Rule = Schema.Union([Action, Object]) .pipe(withStatics((s) => ({ zod: zod(s) }))) export type Rule = Schema.Schema.Type -// 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. +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), @@ -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 => { - if (typeof x === "string") return { "*": x as Action } - const obj = x as { __originalKeys?: string[] } & Record - const { __originalKeys, ...rest } = obj - if (!__originalKeys) return rest as Record - const result: Record = {} - 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): Schema.Schema.Type => + typeof input === "string" ? { "*": input } : input -export const Info = zod(InnerSchema).transform(transform).meta({ ref: "PermissionConfig" }) -export type Info = Record +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 +export type Info = { -readonly [K in keyof _Info]: _Info[K] } diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 7262a380b..5a0850a24 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -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()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index 81c876d62..a8da1cdb9 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -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") { ruleset.push({ permission: key, action: value, pattern: "*" }) continue diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 2f1190a23..4eddc6a47 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -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", Schema.String) { +export class PermissionID extends Newtype()( + "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 + static readonly zod = zod(this) } diff --git a/packages/opencode/src/project/schema.ts b/packages/opencode/src/project/schema.ts index d10c82e2c..7708b8de1 100644 --- a/packages/opencode/src/project/schema.ts +++ b/packages/opencode/src/project/schema.ts @@ -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")) @@ -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()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index deb498891..6b4d779f2 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -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()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index e5a0496c9..f7a0e096a 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -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", Schema.String) { +export class QuestionID extends Newtype()( + "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 + static readonly zod = zod(this) } diff --git a/packages/opencode/src/server/instance/config.ts b/packages/opencode/src/server/instance/config.ts index e62754568..c6537fc20 100644 --- a/packages/opencode/src/server/instance/config.ts +++ b/packages/opencode/src/server/instance/config.ts @@ -23,7 +23,7 @@ export const ConfigRoutes = lazy(() => description: "Get config info", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, @@ -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) diff --git a/packages/opencode/src/server/instance/global.ts b/packages/opencode/src/server/instance/global.ts index 44557b8fd..7da22212c 100644 --- a/packages/opencode/src/server/instance/global.ts +++ b/packages/opencode/src/server/instance/global.ts @@ -192,7 +192,7 @@ export const GlobalRoutes = lazy(() => description: "Get global config info", content: { "application/json": { - schema: resolver(Config.Info), + schema: resolver(Config.Info.zod), }, }, }, @@ -213,14 +213,14 @@ export const GlobalRoutes = lazy(() => description: "Successfully updated global 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") const next = await Config.updateGlobal(config) diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index efed280c9..487cbcd34 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -1,15 +1,14 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), - zod: Identifier.schema("session").pipe(z.custom>()), + zod: zod(s), })), ) @@ -19,7 +18,7 @@ export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.sche Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), - zod: Identifier.schema("message").pipe(z.custom>()), + zod: zod(s), })), ) @@ -29,7 +28,7 @@ export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), - zod: Identifier.schema("part").pipe(z.custom>()), + zod: zod(s), })), ) diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index 5cec8b1f7..e714b86ae 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -1,13 +1,13 @@ 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" -export const EventID = Schema.String.pipe( +export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), - zod: Identifier.schema("event").pipe(z.custom>()), + zod: zod(s), })), ) diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index f69473f76..f2de7d91c 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -146,15 +146,17 @@ export const EditTool = Tool.define( (lock) => Effect.sync(() => lock[Symbol.dispose]()), ).pipe(Effect.orDie) + let additions = 0 + let deletions = 0 + for (const change of diffLines(contentOld, contentNew)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } const filediff: Snapshot.FileDiff = { file: filePath, patch: diff, - additions: 0, - deletions: 0, - } - for (const change of diffLines(contentOld, contentNew)) { - if (change.added) filediff.additions += change.count || 0 - if (change.removed) filediff.deletions += change.count || 0 + additions, + deletions, } yield* ctx.metadata({ diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index ac41fd160..9ce7bece2 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -1,8 +1,7 @@ import { Schema } from "effect" -import z from "zod" import { Identifier } from "@/id/id" -import { ZodOverride } from "@/util/effect-zod" +import { zod, ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) @@ -12,6 +11,6 @@ export type ToolID = typeof toolIdSchema.Type export const ToolID = toolIdSchema.pipe( withStatics((schema: typeof toolIdSchema) => ({ ascending: (id?: string) => schema.make(Identifier.ascending("tool", id)), - zod: Identifier.schema("tool").pipe(z.custom()), + zod: zod(schema), })), ) diff --git a/packages/opencode/src/tool/todo.ts b/packages/opencode/src/tool/todo.ts index 5090f17a7..c08fb0411 100644 --- a/packages/opencode/src/tool/todo.ts +++ b/packages/opencode/src/tool/todo.ts @@ -4,8 +4,21 @@ import * as Tool from "./tool" import DESCRIPTION_WRITE from "./todowrite.txt" import { Todo } from "../session/todo" +// Parameters are kept inline rather than derived from Todo.Info because +// Tool.define requires z.ZodObject-typed parameters for execute() inference, +// and zodObject(Todo.Info) returns ZodObject — reaching into .shape would +// erase field types. Tool schemas migrate to Effect Schema as a separate slice +// per specs/effect/schema.md. const parameters = z.object({ - todos: z.array(z.object(Todo.Info.shape)).describe("The updated todo list"), + todos: z + .array( + z.object({ + content: z.string().describe("Brief description of the task"), + status: z.string().describe("Current status of the task: pending, in_progress, completed, cancelled"), + priority: z.string().describe("Priority level of the task: high, medium, low"), + }), + ) + .describe("The updated todo list"), }) type Metadata = { diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index d7d0ad022..a545e93e4 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -8,43 +8,6 @@ import z from "zod" */ export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") -/** - * Annotation key for a pre-parse transform that runs on the raw input before - * the derived Zod schema validates it. The walker emits - * `z.preprocess(fn, inner)` when this annotation is present. - * - * Models zod's `z.preprocess(fn, schema)` pattern — useful when the schema - * needs to inspect the user's raw input (e.g. to capture insertion order) - * before `Schema.Struct` canonicalises the object. - * - * TODO: This exists to paper over a missing Effect Schema feature. The - * parser canonicalises open struct output (known fields first in - * declaration order, then catchall fields) before any user-defined - * transform sees the value, and there is no pre-parse hook — so the - * user's original property insertion order is gone by the time - * `Schema.decodeTo` or `middlewareDecoding` runs. - * - * That canonicalisation is a reasonable default, but `config/permission.ts` - * encodes rule precedence in the user's JSON key order (`evaluate.ts` - * uses `findLast`, so later entries win), which the canonicalisation - * silently destroys. - * - * The cleanest upstream fix would be either: - * - * 1. A `preserveInputOrder` option on `Schema.Struct` / - * `Schema.StructWithRest` that keeps the input's insertion order in - * the parsed object (opt-in; canonical order stays default). - * 2. A generic pre-parse hook (`Schema.preprocess(schema, fn)` or a - * transformation whose decode receives the raw `unknown`). - * - * Either of those would let us delete `ZodPreprocess` and the - * `__originalKeys` hack. Alternatively, the permission model could move - * to specificity-based precedence (exact keys beat wildcards) or an - * explicit ordered array of rules, which removes the ordering - * dependency at the data-model level. - */ -export const ZodPreprocess: unique symbol = Symbol.for("effect-zod/preprocess") - // AST nodes are immutable and frequently shared across schemas (e.g. a single // Schema.Class embedded in multiple parents). Memoizing by node identity // avoids rebuilding equivalent Zod subtrees and keeps derived children stable @@ -59,6 +22,36 @@ export function zod(schema: S): z.ZodType> } +/** + * Derive a Zod value from an Effect Schema (or a Schema-backed export with a + * `.zod` static) and narrow the result to `z.ZodObject` so `.shape`, + * `.omit`, `.extend`, and friends are accessible. + * + * The `zod()` walker returns `z.ZodType` because not every AST node decodes + * to an object; this helper keeps the "I started from a `Schema.Struct`" cast + * in one place instead of sprinkling `as unknown as z.ZodObject` across + * call sites. + * + * The return is intentionally loose — carrying Schema field types through the + * mapped `.omit()` / `.extend()` surface triggers brand-intersection + * explosions for branded primitives (`string & Brand<"SessionID">` extends + * `object` via the brand and gets walked into the prototype by `DeepPartial`, + * `updateSchema`, etc.), and zod's inference through `z.ZodType` + * wrappers also can't reconstruct `T` cleanly. Consumers that care about the + * post-`.omit()` shape should cast `c.req.valid(...)` to the expected type. + */ +export function zodObject(schema: S): z.ZodObject { + const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast) + if (!(derived instanceof z.ZodObject)) { + throw new Error("effect-zod: zodObject() expected a Zod object schema") + } + return derived +} + +function isZodType(value: unknown): value is z.ZodTypeAny { + return value instanceof z.ZodType +} + function walk(ast: SchemaAST.AST): z.ZodTypeAny { const cached = walkCache.get(ast) if (cached) return cached @@ -69,8 +62,24 @@ function walk(ast: SchemaAST.AST): z.ZodTypeAny { function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined - if (override) return override + // `description` annotations layer on top of an override so callers can + // reuse a shared override schema (e.g. `SessionID`) and still add a + // per-field description on the outer wrapper. Effect-level checks on the + // outer node (e.g. `Schema.minLength` piped onto an override-annotated + // schema) layer on the same way — otherwise the override silently strips + // them from runtime validation and JSON Schema output. + const base = override + ? ast.checks?.length + ? applyChecks(override, ast.checks, ast) + : override + : bodyWithChecks(ast) + const desc = SchemaAST.resolveDescription(ast) + const ref = SchemaAST.resolveIdentifier(ast) + const described = desc ? base.describe(desc) : base + return ref ? described.meta({ ref }) : described +} +function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny { // Schema.Class wraps its fields in a Declaration AST plus an encoding that // constructs the class instance. For the Zod derivation we want the plain // field shape (the decoded/consumer view), not the class instance — so @@ -84,13 +93,7 @@ function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny { const hasEncoding = ast.encoding?.length && ast._tag !== "Declaration" const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined) const base = hasTransform ? encoded(ast) : body(ast) - const checked = ast.checks?.length ? applyChecks(base, ast.checks, ast) : base - const preprocess = (ast.annotations as { [ZodPreprocess]?: (val: unknown) => unknown } | undefined)?.[ZodPreprocess] - const out = preprocess ? z.preprocess(preprocess, checked) : checked - const desc = SchemaAST.resolveDescription(ast) - const ref = SchemaAST.resolveIdentifier(ast) - const described = desc ? out.describe(desc) : out - return ref ? described.meta({ ref }) : described + return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base } // Walk the encoded side and apply each link's decode to produce the decoded diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 32a62f8c2..b6b1da12e 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2103,7 +2103,16 @@ test("permission config wins over legacy tools on conflicts", async () => { }) }) -test("permission config preserves key order", async () => { +test("permission config canonicalises known keys first, preserves rest-key insertion order", async () => { + // ConfigPermission.Info is a StructWithRest schema — the decoder reorders + // keys into declaration-order for known permission names (read, edit, ..., + // external_directory, todowrite are declared in `config/permission.ts`), + // followed by rest keys in the user's insertion order. + // + // Rule precedence is NOT affected by this reordering: `Permission.fromConfig` + // sorts wildcards before specifics before iterating. See the + // "fromConfig sorts wildcards before specifics" test in + // test/permission/next.test.ts for the behavioural guarantee. await using tmp = await tmpdir({ init: async (dir) => { await Filesystem.write( @@ -2131,12 +2140,15 @@ test("permission config preserves key order", async () => { fn: async () => { const config = await load() expect(Object.keys(config.permission!)).toEqual([ - "*", + // known fields the user provided, in declaration order from + // config/permission.ts (read, edit, external_directory, todowrite) + "read", "edit", - "write", "external_directory", - "read", "todowrite", + // rest keys (not in the known list), in user's insertion order + "*", + "write", "thoughts_*", "reasoning_model_*", "tools_*", @@ -2947,7 +2959,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => { test("parseManagedPlist strips MDM metadata keys", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2979,7 +2991,7 @@ test("parseManagedPlist rejects malformed JSON", () => { test("parseManagedPlist parses server settings", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -2999,7 +3011,7 @@ test("parseManagedPlist parses server settings", async () => { test("parseManagedPlist parses permission rules", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -3029,7 +3041,7 @@ test("parseManagedPlist parses permission rules", async () => { test("parseManagedPlist parses enabled_providers", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist( JSON.stringify({ @@ -3046,7 +3058,7 @@ test("parseManagedPlist parses enabled_providers", async () => { test("parseManagedPlist handles empty config", async () => { const config = ConfigParse.schema( - Config.Info, + Config.Info.zod, ConfigParse.jsonc( await ConfigManaged.parseManagedPlist(JSON.stringify({ $schema: "https://opencode.ai/config.json" })), "test:mobileconfig", diff --git a/packages/opencode/test/permission-agent.test.ts b/packages/opencode/test/permission-agent.test.ts index 00b1c78c7..6ce59010a 100644 --- a/packages/opencode/test/permission-agent.test.ts +++ b/packages/opencode/test/permission-agent.test.ts @@ -346,8 +346,12 @@ describe("legacy permission.task config compatibility (#128)", () => { task: { "*": "deny" }, // agent-rename:legacy-render agent: { "*": "allow" }, } as any) - // Both produce permission: "agent" rules; later rules win in evaluate() via findLast. - expect(ruleset.map((r) => r.permission)).toEqual(["agent", "agent"]) + // The legacy `task` entry is dropped when canonical `agent` is also present + // — explicit canonical precedence rather than relying on findLast/insertion + // order. Both keys would otherwise emit `permission: "agent"` rules and the + // resolution would be order-sensitive (broken once StructWithRest reorders + // struct keys before rest keys on decode). + expect(ruleset).toEqual([{ permission: "agent", pattern: "*", action: "allow" }]) expect(Permission.evaluate("agent", "any", ruleset).action).toBe("allow") }) }) diff --git a/packages/opencode/test/permission/next.test.ts b/packages/opencode/test/permission/next.test.ts index 334d7d63c..c99b16d67 100644 --- a/packages/opencode/test/permission/next.test.ts +++ b/packages/opencode/test/permission/next.test.ts @@ -84,6 +84,79 @@ test("fromConfig - does not expand tilde in middle of path", () => { expect(result).toEqual([{ permission: "external_directory", pattern: "/some/~/path", action: "allow" }]) }) +// Top-level wildcard-vs-specific precedence semantics. +// +// fromConfig sorts top-level keys so wildcard permissions (containing "*") +// come before specific permissions. Combined with `findLast` in evaluate(), +// this gives the intuitive semantic "specific tool rules override the `*` +// fallback", regardless of the order the user wrote the keys in their JSON. +// +// Sub-pattern order inside a single permission key (e.g. `bash: { "*": "allow", "rm": "deny" }`) +// still depends on insertion order — only top-level keys are sorted. + +test("fromConfig - specific key beats wildcard regardless of JSON key order", () => { + const wildcardFirst = Permission.fromConfig({ "*": "deny", bash: "allow" }) + const specificFirst = Permission.fromConfig({ bash: "allow", "*": "deny" }) + + // Both orderings produce the same ruleset + expect(wildcardFirst).toEqual(specificFirst) + + // And both evaluate bash → allow (bash rule wins over * fallback) + expect(Permission.evaluate("bash", "ls", wildcardFirst).action).toBe("allow") + expect(Permission.evaluate("bash", "ls", specificFirst).action).toBe("allow") +}) + +test("fromConfig - wildcard acts as fallback for permissions with no specific rule", () => { + const ruleset = Permission.fromConfig({ bash: "allow", "*": "ask" }) + expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("ask") + expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow") +}) + +test("fromConfig - top-level ordering: wildcards first, specifics after", () => { + const ruleset = Permission.fromConfig({ + bash: "allow", + "*": "ask", + edit: "deny", + "mcp_*": "allow", + }) + // wildcards (* and mcp_*) come before specifics (bash, edit) + const permissions = ruleset.map((r) => r.permission) + expect(permissions.slice(0, 2).sort()).toEqual(["*", "mcp_*"]) + expect(permissions.slice(2)).toEqual(["bash", "edit"]) +}) + +test("fromConfig - sub-pattern insertion order inside a tool key is preserved (only top-level sorts)", () => { + // Sub-patterns within a single tool key use the documented "`*` first, + // specific patterns after" convention (findLast picks specifics). The + // top-level sort must not touch sub-pattern ordering. + const ruleset = Permission.fromConfig({ bash: { "*": "deny", "git *": "allow" } }) + expect(ruleset.map((r) => r.pattern)).toEqual(["*", "git *"]) + // * fallback for unknown commands + expect(Permission.evaluate("bash", "rm foo", ruleset).action).toBe("deny") + // specific pattern wins for git commands (it's last, findLast picks it) + expect(Permission.evaluate("bash", "git status", ruleset).action).toBe("allow") +}) + +test("fromConfig - canonical documented example unchanged", () => { + // Regression guard for the example in docs/permissions.mdx + const ruleset = Permission.fromConfig({ "*": "ask", bash: "allow", edit: "deny" }) + expect(Permission.evaluate("bash", "ls", ruleset).action).toBe("allow") + expect(Permission.evaluate("edit", "foo.ts", ruleset).action).toBe("deny") + expect(Permission.evaluate("read", "foo.ts", ruleset).action).toBe("ask") +}) + +test("fromConfig - canonical agent overrides legacy task when both keys present", () => { + // Mixed-key migration scenario: a config carrying both `agent` (canonical + // post-#128 rename) and the legacy `task` alias. Without the legacy-skip + // guard in fromConfig, `task -> agent` would land last under last-match-wins + // and silently override the explicit canonical entry. + const ruleset = Permission.fromConfig({ agent: "allow", task: "deny" }) + expect(Permission.evaluate("agent", "*", ruleset).action).toBe("allow") + // Legacy-only configs still work — task maps to agent, agent gets the rule. + const legacyOnly = Permission.fromConfig({ task: "deny" }) + expect(Permission.evaluate("agent", "*", legacyOnly).action).toBe("deny") +}) + test("fromConfig - expands exact tilde to home directory", () => { const result = Permission.fromConfig({ external_directory: { "~": "allow" } }) expect(result).toEqual([{ permission: "external_directory", pattern: os.homedir(), action: "allow" }]) diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 74c556333..fd5235eea 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -175,7 +175,7 @@ function layer(result: "continue" | "compact") { } function cfg(compaction?: Config.Info["compaction"]) { - const base = Config.Info.parse({}) + const base = Config.Info.zod.parse({}) return Layer.mock(Config.Service)({ get: () => Effect.succeed({ ...base, compaction }), }) diff --git a/packages/opencode/test/session/session.test.ts b/packages/opencode/test/session/session.test.ts index d34415d66..bd124e3b2 100644 --- a/packages/opencode/test/session/session.test.ts +++ b/packages/opencode/test/session/session.test.ts @@ -120,9 +120,12 @@ describe("step-finish token propagation via Bus event", () => { mode: "", } as unknown as MessageV2.Info) + // Bus subscribers receive readonly Schema.Type payloads; `MessageV2.Part` + // is the mutable domain type. Cast bridges the two — safe because the + // test only reads the value afterwards. let received: MessageV2.Part | undefined const unsub = Bus.subscribe(MessageV2.Event.PartUpdated, (event) => { - received = event.properties.part + received = event.properties.part as MessageV2.Part }) const tokens = { diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 6af3d1ba8..a26d38fb1 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Schema, SchemaGetter } from "effect" import z from "zod" -import { zod, ZodOverride, ZodPreprocess } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "../../src/util/effect-zod" function json(schema: z.ZodTypeAny) { const { $schema: _, ...rest } = z.toJSONSchema(schema) @@ -762,119 +762,4 @@ describe("util.effect-zod", () => { expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" }) }) }) - - describe("ZodPreprocess annotation", () => { - test("preprocess runs on raw input before the inner schema parses", () => { - // Models the permission.ts __originalKeys pattern: capture the original - // insertion order of a user-provided object BEFORE Schema parsing - // canonicalises the keys. - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - a: Schema.optional(Schema.String), - b: Schema.optional(Schema.String), - }).annotate({ [ZodPreprocess]: preprocess }) - - const schema = zod(Inner) - const parsed = schema.parse({ b: "1", a: "2" }) as { - __keys?: string[] - a?: string - b?: string - } - expect(parsed.__keys).toEqual(["b", "a"]) - expect(parsed.a).toBe("2") - expect(parsed.b).toBe("1") - }) - - test("preprocess does not transform already-shaped input", () => { - // When the user passes an object that already has __keys, preprocess - // returns it unchanged because spreading preserves any existing key. - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !("__keys" in val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - a: Schema.optional(Schema.String), - }).annotate({ [ZodPreprocess]: preprocess }) - - const schema = zod(Inner) - const parsed = schema.parse({ __keys: ["existing"], a: "hi" }) as { - __keys?: string[] - a?: string - } - expect(parsed.__keys).toEqual(["existing"]) - }) - - test("preprocess composes with a union (either object or string)", () => { - // Mirrors permission.ts exactly: input can be either an object (with - // preprocess injecting metadata) or a plain string action. - const Action = Schema.Literals(["ask", "allow", "deny"]) - const Obj = Schema.Struct({ - __keys: Schema.optional(Schema.mutable(Schema.Array(Schema.String))), - read: Schema.optional(Action), - write: Schema.optional(Action), - }) - const preprocess = (val: unknown) => { - if (typeof val === "object" && val !== null && !Array.isArray(val)) { - return { __keys: Object.keys(val), ...(val as Record) } - } - return val - } - const Inner = Schema.Union([Obj, Action]).annotate({ [ZodPreprocess]: preprocess }) - const schema = zod(Inner) - - // String branch — passes through preprocess unchanged - expect(schema.parse("allow")).toBe("allow") - - // Object branch — __keys injected, preserves order - const parsed = schema.parse({ write: "allow", read: "deny" }) as { - __keys?: string[] - read?: string - write?: string - } - expect(parsed.__keys).toEqual(["write", "read"]) - expect(parsed.write).toBe("allow") - expect(parsed.read).toBe("deny") - }) - - test("JSON Schema output comes from the inner schema — preprocess is runtime-only", () => { - const Inner = Schema.Struct({ - a: Schema.optional(Schema.String), - b: Schema.optional(Schema.Number), - }).annotate({ [ZodPreprocess]: (v: unknown) => v }) - const shape = json(zod(Inner)) as any - expect(shape.type).toBe("object") - expect(shape.properties.a.type).toBe("string") - expect(shape.properties.b.type).toBe("number") - }) - - test("identifier + description propagate through the preprocess wrapper", () => { - const Inner = Schema.Struct({ - x: Schema.optional(Schema.String), - }).annotate({ - identifier: "WithPreproc", - description: "A schema with preprocess", - [ZodPreprocess]: (v: unknown) => v, - }) - const schema = zod(Inner) - expect(schema.meta()?.ref).toBe("WithPreproc") - expect(schema.meta()?.description).toBe("A schema with preprocess") - }) - - test("preprocess inside a struct field applies only to that field", () => { - const Inner = Schema.String.annotate({ - [ZodPreprocess]: (v: unknown) => (typeof v === "number" ? String(v) : v), - }) - const schema = zod(Schema.Struct({ name: Inner, raw: Schema.Number })) - expect(schema.parse({ name: 42, raw: 7 })).toEqual({ name: "42", raw: 7 }) - }) - }) }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 8ee88ca82..3545e07f6 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1139,8 +1139,8 @@ export type PermissionObjectConfig = { export type PermissionRuleConfig = PermissionActionConfig | PermissionObjectConfig export type PermissionConfig = + | PermissionActionConfig | { - __originalKeys?: Array read?: PermissionRuleConfig edit?: PermissionRuleConfig glob?: PermissionRuleConfig @@ -1157,9 +1157,8 @@ export type PermissionConfig = lsp?: PermissionRuleConfig doom_loop?: PermissionActionConfig skill?: PermissionRuleConfig - [key: string]: PermissionRuleConfig | Array | PermissionActionConfig | undefined + [key: string]: PermissionRuleConfig | PermissionActionConfig | undefined } - | PermissionActionConfig export type AgentConfig = { model?: string