fce074726f
Follow-up to #23716. Moves ConfigPermission.Info from zod-first (with a preprocess hack) to Effect Schema canonical using Schema.StructWithRest + Schema.decodeTo, and deletes the now-unused ZodPreprocess plumbing. Core change: rule precedence in `Permission.fromConfig` now sorts top-level keys so wildcard permissions (e.g. `*`, `mcp_*`) come before specific ones (e.g. `bash`, `edit`). Combined with `findLast` in evaluate(), this gives the intuitive semantic 'specific tool rules override the `*` fallback' regardless of the user's JSON key order. This silently fixes the previously-broken case `{bash: "allow", "*": "deny"}` (which under the old semantics denied bash because `*` came last). Once rule precedence no longer depends on JSON insertion order, the `__originalKeys` + ZodPreprocess hack can go — StructWithRest's natural canonicalisation is fine because fromConfig sorts anyway. - src/config/permission.ts: rewrite. InputObject is StructWithRest with known permission keys (read/edit/bash/... as Rule, todowrite/webfetch/... as Action-only for type narrowing) + Record rest. Schema.decodeTo normalises the Action shorthand into { "*": action }. .zod is derived — walker already carries the decodeTo transform. - src/config/config.ts, src/config/agent.ts: reference ConfigPermission.Info directly instead of via Schema.Any + ZodOverride. The Effect decoder now applies the permission transform at load time. - src/permission/index.ts: fromConfig sorts wildcards-before-specifics at top level. Sub-pattern order inside a tool key is preserved (documented `*` first, specifics after). - src/util/effect-zod.ts: delete ZodPreprocess symbol, its walkUncached branch, and the TODO comment. Zero remaining consumers. - test/permission/next.test.ts: 6 new tests pinning the new semantics — order-independent precedence, wildcard-as-fallback, sub-pattern order preservation, canonical documented-example regression guard. - test/config/config.test.ts: updated the "preserves key order" test to reflect the new canonical output shape (declaration-order known fields, then input-order rest keys). Behavioural guarantees live in the new permission tests. - test/util/effect-zod.test.ts: delete the ZodPreprocess describe block (~115 lines of tests for the now-removed feature). SDK diff vs dev: - Removed `__originalKeys?: Array<string>` (internal leak). - Catchall cleaned up (no unrelated `Array<string>`). - Known-field types preserved (autocomplete + narrowing). - Only shape change: PermissionConfig union order swap (commutative). Safety audit: no config, test, or doc in the repo (including all 16 translations) exercises the pattern where specifics come before wildcards at the top level. The only configs whose behaviour changes are ones that were silently broken.
182 lines
6.2 KiB
TypeScript
182 lines
6.2 KiB
TypeScript
export * as ConfigAgent from "./agent"
|
|
|
|
import { Schema } from "effect"
|
|
import z from "zod"
|
|
import { Bus } from "@/bus"
|
|
import { zod } from "@/util/effect-zod"
|
|
import { Log } from "../util"
|
|
import { NamedError } from "@opencode-ai/shared/util/error"
|
|
import { Glob } from "@opencode-ai/shared/util/glob"
|
|
import { configEntryNameFromPath } from "./entry-name"
|
|
import { InvalidError } from "./error"
|
|
import * as ConfigMarkdown from "./markdown"
|
|
import { ConfigModelID } from "./model-id"
|
|
import { ConfigPermission } from "./permission"
|
|
|
|
const log = Log.create({ service: "config" })
|
|
|
|
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
|
|
|
const Color = Schema.Union([
|
|
Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/)),
|
|
Schema.Literals(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
|
])
|
|
|
|
const AgentSchema = Schema.StructWithRest(
|
|
Schema.Struct({
|
|
model: Schema.optional(ConfigModelID),
|
|
variant: Schema.optional(Schema.String).annotate({
|
|
description: "Default model variant for this agent (applies only when using the agent's configured model).",
|
|
}),
|
|
temperature: Schema.optional(Schema.Number),
|
|
top_p: Schema.optional(Schema.Number),
|
|
prompt: Schema.optional(Schema.String),
|
|
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)).annotate({
|
|
description: "@deprecated Use 'permission' field instead",
|
|
}),
|
|
disable: Schema.optional(Schema.Boolean),
|
|
description: Schema.optional(Schema.String).annotate({ description: "Description of when to use the agent" }),
|
|
mode: Schema.optional(Schema.Literals(["subagent", "primary", "all"])),
|
|
hidden: Schema.optional(Schema.Boolean).annotate({
|
|
description: "Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)",
|
|
}),
|
|
options: Schema.optional(Schema.Record(Schema.String, Schema.Any)),
|
|
color: Schema.optional(Color).annotate({
|
|
description: "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
|
|
}),
|
|
steps: Schema.optional(PositiveInt).annotate({
|
|
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(ConfigPermission.Info),
|
|
}),
|
|
[Schema.Record(Schema.String, Schema.Any)],
|
|
)
|
|
|
|
const KNOWN_KEYS = new Set([
|
|
"name",
|
|
"model",
|
|
"variant",
|
|
"prompt",
|
|
"description",
|
|
"temperature",
|
|
"top_p",
|
|
"mode",
|
|
"hidden",
|
|
"color",
|
|
"steps",
|
|
"maxSteps",
|
|
"options",
|
|
"permission",
|
|
"disable",
|
|
"tools",
|
|
])
|
|
|
|
// Post-parse normalisation:
|
|
// - Promote any unknown-but-present keys into `options` so they survive the
|
|
// round-trip in a well-known field.
|
|
// - Translate the deprecated `tools: { name: boolean }` map into the new
|
|
// `permission` shape (write-adjacent tools collapse into `permission.edit`).
|
|
// - Coalesce `steps ?? maxSteps` so downstream can ignore the deprecated alias.
|
|
const normalize = (agent: z.infer<typeof Info>) => {
|
|
const options: Record<string, unknown> = { ...agent.options }
|
|
for (const [key, value] of Object.entries(agent)) {
|
|
if (!KNOWN_KEYS.has(key)) options[key] = value
|
|
}
|
|
|
|
const permission: ConfigPermission.Info = {}
|
|
for (const [tool, enabled] of Object.entries(agent.tools ?? {})) {
|
|
const action = enabled ? "allow" : "deny"
|
|
if (tool === "write" || tool === "edit" || tool === "patch") {
|
|
permission.edit = action
|
|
continue
|
|
}
|
|
permission[tool] = action
|
|
}
|
|
globalThis.Object.assign(permission, agent.permission)
|
|
|
|
const steps = agent.steps ?? agent.maxSteps
|
|
return { ...agent, options, permission, ...(steps !== undefined ? { steps } : {}) }
|
|
}
|
|
|
|
export const Info = zod(AgentSchema).transform(normalize).meta({ ref: "AgentConfig" }) as unknown as z.ZodType<
|
|
Omit<z.infer<ReturnType<typeof zod<typeof AgentSchema>>>, "options" | "permission" | "steps"> & {
|
|
options?: Record<string, unknown>
|
|
permission?: ConfigPermission.Info
|
|
steps?: number
|
|
}
|
|
>
|
|
export type Info = z.infer<typeof Info>
|
|
|
|
export async function load(dir: string) {
|
|
const result: Record<string, Info> = {}
|
|
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
|
|
cwd: dir,
|
|
absolute: true,
|
|
dot: true,
|
|
symlink: true,
|
|
})) {
|
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
? err.data.message
|
|
: `Failed to parse agent ${item}`
|
|
const { Session } = await import("@/session")
|
|
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
log.error("failed to load agent", { agent: item, err })
|
|
return undefined
|
|
})
|
|
if (!md) continue
|
|
|
|
const patterns = ["/.opencode/agent/", "/.opencode/agents/", "/agent/", "/agents/"]
|
|
const name = configEntryNameFromPath(item, patterns)
|
|
|
|
const config = {
|
|
name,
|
|
...md.data,
|
|
prompt: md.content.trim(),
|
|
}
|
|
const parsed = Info.safeParse(config)
|
|
if (parsed.success) {
|
|
result[config.name] = parsed.data
|
|
continue
|
|
}
|
|
throw new InvalidError({ path: item, issues: parsed.error.issues }, { cause: parsed.error })
|
|
}
|
|
return result
|
|
}
|
|
|
|
export async function loadMode(dir: string) {
|
|
const result: Record<string, Info> = {}
|
|
for (const item of await Glob.scan("{mode,modes}/*.md", {
|
|
cwd: dir,
|
|
absolute: true,
|
|
dot: true,
|
|
symlink: true,
|
|
})) {
|
|
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
|
|
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
|
|
? err.data.message
|
|
: `Failed to parse mode ${item}`
|
|
const { Session } = await import("@/session")
|
|
void Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
|
|
log.error("failed to load mode", { mode: item, err })
|
|
return undefined
|
|
})
|
|
if (!md) continue
|
|
|
|
const config = {
|
|
name: configEntryNameFromPath(item, []),
|
|
...md.data,
|
|
prompt: md.content.trim(),
|
|
}
|
|
const parsed = Info.safeParse(config)
|
|
if (parsed.success) {
|
|
result[config.name] = {
|
|
...parsed.data,
|
|
mode: "primary" as const,
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|