Compare commits

...
Author SHA1 Message Date
Kit Langton 57d70a3edf refactor(plugin): narrow cross-instance schema fix 2026-08-23 22:40:16 -04:00
Kit Langton a5c2458a5b fix(plugin): keep tool schema validation in the authoring effect instance
Plugins load their own copy of effect, so their live Effect schemas cannot be
interpreted by the host instance: parser sentinels and AST class identity are
per-instance, making checks false-fail on valid input (bogus minLength errors)
and branded IDs die as defects surfaced as bare 'Tool execution failed'.

Plugin.define now converts tool input/output schemas to detached Standard
Schema wrappers at the draft.add boundary, so validation and JSON Schema
generation run as closures bound to the instance that created the schema. The
core tool runtime detects still-foreign live schemas from older plugin
packages and skips validation with a warning instead of misvalidating, and
standard-schema validation errors now include the issue path.
2026-08-23 20:24:08 -04:00
3 changed files with 129 additions and 2 deletions
+20 -2
View File
@@ -1,5 +1,6 @@
import type { PluginApi } from "@opencode-ai/client/effect/api"
import type { Effect, Scope } from "effect"
import { instanceSafeTool } from "./tool-schema.js"
import type { PluginOptions } from "../options.js"
import type { App } from "../app.js"
import type { AgentDomain } from "./agent.js"
@@ -43,6 +44,23 @@ export interface Plugin<R = Scope.Scope> {
readonly effect: (context: Context) => Effect.Effect<void, never, R>
}
export function define<R = Scope.Scope>(plugin: Plugin<R>) {
return plugin
export function define<R = Scope.Scope>(plugin: Plugin<R>): Plugin<R> {
return {
...plugin,
effect: (context) => plugin.effect(instanceSafeContext(context)),
}
}
// Tool schemas cross from the plugin's module world into the host at `draft.add`;
// convert them while authoring-instance code is still on the stack so the host never
// interprets a foreign Effect schema. See `instanceSafeTool`.
function instanceSafeContext(context: Context): Context {
return {
...context,
tool: {
...context.tool,
transform: (callback) =>
context.tool.transform((draft) => callback({ add: (tool) => draft.add(instanceSafeTool(tool)) })),
},
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Schema } from "effect"
import type { Tool } from "@opencode-ai/schema/tool"
/**
* Converts a tool's Effect schemas into detached Standard Schema wrappers so they
* survive the crossing from the plugin's module world into the host.
*
* Plugins often load their own copy of `effect` (for example from the config
* directory's node_modules) while the host bundles a different instance. A live
* Effect schema cannot be interpreted across that boundary: schema parsing relies on
* per-instance sentinels and class identity, so the host misvalidates checks and
* turns branded-type failures into defects. A Standard Schema wrapper instead carries
* validation and JSON Schema generation as closures bound to the instance that
* created the schema, which the host invokes as-is.
*/
export function instanceSafeTool(tool: Tool.Info<any, any>): Tool.Info<any, any> {
const input = instanceSafeValueSchema(tool.input, "input")
const output = tool.output === undefined ? undefined : instanceSafeValueSchema(tool.output, "output")
if (input === tool.input && output === tool.output) return tool
return { ...tool, input, ...(output === undefined ? {} : { output }) }
}
function instanceSafeValueSchema(schema: Tool.ValueSchema<any>, direction: "input" | "output"): Tool.ValueSchema<any> {
if (!Schema.isSchema(schema)) return schema
// Inputs are decoded (Encoded -> Type) but outputs are encoded (Type -> Encoded),
// so outputs use the flipped schema: its standard `validate` runs in the encode
// direction and its `jsonSchema.output` still describes the encoded shape.
const oriented = direction === "input" ? (schema as Schema.Top) : Schema.flip(schema as Schema.Top)
// Both converters augment the schema object in place and return it; the host must
// receive a plain wrapper instead, because the augmented object still satisfies
// `Schema.isSchema` and would route back into cross-instance interpretation.
const augmented = Schema.toStandardJSONSchemaV1(
Schema.toStandardSchemaV1(oriented as never) as never,
) as unknown as StandardWrapper
return { "~standard": augmented["~standard"] } as Tool.ValueSchema<any>
}
type StandardWrapper = { readonly "~standard": Record<string, unknown> }
@@ -0,0 +1,71 @@
import { expect, test } from "bun:test"
import type { StandardJSONSchemaV1, StandardSchemaV1 } from "@standard-schema/spec"
import { Effect, Schema } from "effect"
import { Plugin } from "../src/effect/index.js"
import type { Tool } from "@opencode-ai/schema/tool"
// `define` must hand the host detached Standard Schema wrappers instead of live
// Effect schemas: hosts may run a different `effect` instance, which cannot
// interpret foreign schemas (checks false-fail and branded types die as defects).
const collectTool = async (tool: Tool.Info<any, any>) => {
const added: Array<Tool.Info<any, any>> = []
const context = {
tool: {
transform: (callback: (draft: { add: (tool: Tool.Info<any, any>) => void }) => void) => {
callback({ add: (item) => added.push(item) })
return Effect.succeed({ dispose: Effect.void })
},
},
} as unknown as Plugin.Context
const plugin = Plugin.define({
id: "test.instance-safe",
effect: (ctx) => ctx.tool.transform((draft) => draft.add(tool)).pipe(Effect.asVoid),
})
await Effect.runPromise(Effect.scoped(plugin.effect(context)))
expect(added).toHaveLength(1)
return added[0]
}
type StandardValue = StandardSchemaV1<any, any> & StandardJSONSchemaV1<any, any>
test("define converts Effect schemas to detached standard wrappers", async () => {
const execute = (input: { title?: string }) => Effect.succeed({ output: { id: `ses_${input.title}` } })
const registered = await collectTool({
name: "create",
description: "Create",
input: Schema.Struct({ title: Schema.optional(Schema.String.check(Schema.isMinLength(1))) }),
output: Schema.Struct({ id: Schema.String }),
execute,
})
expect(registered.execute).toBe(execute)
expect(Schema.isSchema(registered.input)).toBe(false)
expect(Schema.isSchema(registered.output)).toBe(false)
const input = registered.input as StandardValue
expect(await input["~standard"].validate({ title: "probe" })).toEqual({ value: { title: "probe" } })
const invalid = await input["~standard"].validate({ title: "" })
expect(invalid.issues?.[0]?.message).toContain("a value with a length of at least 1")
expect(input["~standard"].jsonSchema.input({ target: "draft-2020-12" })).toMatchObject({ type: "object" })
// Outputs validate in the encode direction (Type -> Encoded) and describe the
// encoded shape.
const output = registered.output as StandardValue
expect(await output["~standard"].validate({ id: "ses_x" })).toEqual({ value: { id: "ses_x" } })
expect(output["~standard"].jsonSchema.output({ target: "draft-2020-12" })).toMatchObject({
type: "object",
required: ["id"],
})
})
test("define leaves non-Effect schemas untouched", async () => {
const input = { type: "object" as const }
const registered = await collectTool({
name: "raw",
description: "Raw",
input,
execute: () => Effect.succeed({ content: "ok" }),
})
expect(registered.input).toBe(input)
expect(registered.output).toBeUndefined()
})