From 71ffc7327231fd85717e8585a577c03b8798bce2 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 30 Jun 2026 09:36:59 -0500 Subject: [PATCH] feat(opencode): run code mode on the vendored rune interpreter Replace the in-process AsyncFunction engine with Rune.execute. MCP tools are exposed as a host-tool tree (tools..) plus top-level search/describe; each call is permission-gated and coerced to the { result, attachments? } envelope. Raise data limits for base64 media. This adds real sandboxing: host globals are isolated and runaway loops terminate via the operation limit instead of hanging the event loop. --- packages/opencode/src/session/code-mode.ts | 142 +++++++++--------- .../opencode/test/session/code-mode.test.ts | 17 ++- 2 files changed, 82 insertions(+), 77 deletions(-) diff --git a/packages/opencode/src/session/code-mode.ts b/packages/opencode/src/session/code-mode.ts index 3623ecb9cd..09fb15fcff 100644 --- a/packages/opencode/src/session/code-mode.ts +++ b/packages/opencode/src/session/code-mode.ts @@ -1,11 +1,23 @@ import { Tool } from "@/tool/tool" -import { EffectBridge } from "@/effect/bridge" import { asSchema, type Tool as AITool, type JSONSchema7 } from "ai" import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { Effect, Schema } from "effect" +import { Rune } from "./rune/rune" +import type { ExecutionLimits } from "./rune/rune" +import type { HostTools } from "./rune/tool-runtime" export const CODE_MODE_TOOL = "execute" +/** + * Execution limits for the Rune interpreter. `maxDataBytes` is raised well above + * the Rune default (256KB) because code mode forwards base64 media attachments, + * and the timeout matches the default MCP request timeout. + */ +const CODE_LIMITS: ExecutionLimits = { + maxDataBytes: 10_000_000, + timeoutMs: 30_000, +} + export const Parameters = Schema.Struct({ code: Schema.String.annotate({ description: "JavaScript to run. Discover tools with `tools.search`/`tools.describe`, call them, and `return` the final value.", @@ -27,12 +39,6 @@ export type Attachment = NonNullable[number] /** The envelope every tool call resolves to, and the shape a program should `return`. */ export type Envelope = { result: unknown; attachments?: Attachment[] } -// `new Function`/`AsyncFunction` is not on the global scope, so reach it via the -// prototype of an async function literal. The body may use top-level `await` and `return`. -const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as { - new (...args: string[]): (...args: unknown[]) => Promise -} - const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ const SEARCH = "search" const DESCRIBE = "describe" @@ -282,16 +288,6 @@ export function fromReturn(value: unknown): { output: string; attachments?: Atta return { output: formatValue(value) } } -function errorMessage(error: unknown): string { - if (error instanceof Error) return error.message - if (typeof error === "string") return error - try { - return JSON.stringify(error) ?? String(error) - } catch { - return String(error) - } -} - export function define( mcpTools: Record, mcpDefs: Record, @@ -347,73 +343,69 @@ export function define( description: describe(groups), parameters: Parameters, execute: Effect.fn("CodeMode.execute")(function* (params, ctx) { - const run = yield* EffectBridge.make() const calls: string[] = [] - // Each tool call runs the native MCP tool through the permission gate, so - // approving `execute` does not approve every child call. - const invoke = (key: string, tool: AITool, args: unknown) => + // One host function per MCP tool: gate on permission, dispatch to the native + // MCP tool, and coerce the result into the { result, attachments? } envelope. + // A failure (e.g. an MCP isError) fails the Effect, which the interpreter + // surfaces as a catchable in-program error. + const callTool = (key: string, tool: AITool) => (input: unknown) => Effect.gen(function* () { yield* ctx.ask({ permission: key, metadata: {}, patterns: ["*"], always: ["*"] }) - const result = yield* Effect.promise(() => - Promise.resolve( - tool.execute!(args ?? {}, { - toolCallId: ctx.callID ?? key, - abortSignal: ctx.abort, - messages: [], - }), - ), - ) + calls.push(key) + const result = yield* Effect.tryPromise({ + try: () => + Promise.resolve( + tool.execute!(input ?? {}, { toolCallId: ctx.callID ?? key, abortSignal: ctx.abort, messages: [] }), + ), + catch: (error) => (error instanceof Error ? error : new Error(String(error))), + }) return toEnvelope(result) }) - // Recursive path-accumulating proxy: `tools..(args)` and - // `tools[path](args)` both resolve to a flat catalog key, while the reserved - // top-level `tools.search`/`tools.describe` provide on-demand discovery. - const make = (segments: readonly string[]): unknown => - new Proxy(function () {} as object, { - get(_target, prop) { - if (typeof prop !== "string" || prop === "then") return undefined - return make([...segments, prop]) - }, - apply(_target, _thisArg, args: unknown[]) { - if (segments.length === 1 && segments[0] === SEARCH) return search(args[0], args[1]) - if (segments.length === 1 && segments[0] === DESCRIBE) return describeTool(args[0]) - const key = toKey(segments) - const tool = mcpTools[key] - if (!tool || !tool.execute) { - throw new Error( - `Unknown tool 'tools.${segments.join(".")}'. Use tools.search(query) to discover available tools.`, - ) - } - calls.push(key) - return run.promise(invoke(key, tool, args[0])) - }, - }) + // The Rune host-tool tree: per-server namespaces (`tools..`) + // plus the top-level discovery helpers. The interpreter resolves and invokes + // these; approving `execute` does not approve any child call. + const tools: HostTools = { + [SEARCH]: (query: unknown, options: unknown) => Effect.succeed(search(query, options)), + [DESCRIBE]: (path: unknown) => Effect.succeed(describeTool(path)), + } + for (const entry of catalog) { + if (!entry.tool.execute) continue + let namespace = tools[entry.server] as HostTools | undefined + if (!namespace) { + namespace = {} + tools[entry.server] = namespace + } + namespace[entry.local] = callTool(entry.key, entry.tool) + } - const tools = make([]) + const result = yield* Rune.execute({ + code: params.code, + tools: tools as unknown as Record, + limits: CODE_LIMITS, + }) - return yield* Effect.tryPromise({ - try: () => new AsyncFunction("tools", params.code)(tools), - catch: (error) => error, - }).pipe( - Effect.map((value) => { - const { output, attachments } = fromReturn(value) - return { - title: "Code mode", - metadata: { toolCalls: calls }, - output, - ...(attachments && attachments.length > 0 ? { attachments } : {}), - } satisfies Tool.ExecuteResult - }), - Effect.catch((error) => - Effect.succeed({ - title: "Code mode", - metadata: { toolCalls: calls, error: true }, - output: errorMessage(error), - } satisfies Tool.ExecuteResult), - ), - ) + if (result.ok) { + const { output, attachments } = fromReturn(result.value) + return { + title: "Code mode", + metadata: { toolCalls: calls }, + output, + ...(attachments && attachments.length > 0 ? { attachments } : {}), + } satisfies Tool.ExecuteResult + } + // Rune's built-in unknown-capability hint points at `$rune.search`; redirect + // the model to this integration's actual discovery entrypoint instead. + const hint = + result.error.kind === "UnknownCapability" + ? "\nUse tools.search(query) to discover available tools." + : "" + return { + title: "Code mode", + metadata: { toolCalls: calls, error: true }, + output: result.error.message + hint, + } satisfies Tool.ExecuteResult }), }), ) diff --git a/packages/opencode/test/session/code-mode.test.ts b/packages/opencode/test/session/code-mode.test.ts index 8f9811b75d..4e140e1816 100644 --- a/packages/opencode/test/session/code-mode.test.ts +++ b/packages/opencode/test/session/code-mode.test.ts @@ -182,7 +182,7 @@ describe("code mode execute", () => { test("returns a readable error when the program throws", async () => { const tool = await build({}) const output = await Effect.runPromise(tool.execute({ code: "throw new Error('boom')" }, ctx)) - expect(output.output).toBe("boom") + expect(output.output).toBe("Uncaught: boom") expect(output.metadata.error).toBe(true) }) @@ -190,7 +190,7 @@ describe("code mode execute", () => { const tool = await build({ known_tool: mcpTool("tool", () => "ok") }) const output = await Effect.runPromise(tool.execute({ code: "return await tools.known.missing({})" }, ctx)) expect(output.metadata.error).toBe(true) - expect(output.output).toContain("Unknown tool 'tools.known.missing'") + expect(output.output).toContain("Unknown tool 'known.missing'") expect(output.output).toContain("tools.search") }) @@ -251,6 +251,19 @@ describe("code mode execute", () => { expect(formatValue(undefined)).toBe("undefined") }) + test("terminates a runaway loop via the operation limit instead of hanging", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "while (true) {}" }, ctx)) + expect(output.metadata.error).toBe(true) + expect(output.output.toLowerCase()).toContain("operation") + }) + + test("isolates the sandbox from host globals", async () => { + const tool = await build({}) + const output = await Effect.runPromise(tool.execute({ code: "return process.env" }, ctx)) + expect(output.metadata.error).toBe(true) + }) + test("describe shows the structured return type when the tool declares an outputSchema", async () => { const tools = { weather_current: mcpTool("current", () => "", { type: "object", properties: { city: { type: "string" } } }) } const defs: Record = {