diff --git a/bun.lock b/bun.lock index 4e7e1f72e0..3087110df5 100644 --- a/bun.lock +++ b/bun.lock @@ -813,6 +813,7 @@ "version": "1.17.14", "dependencies": { "@effect/platform-node": "catalog:", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", "@opencode-ai/simulation": "workspace:*", diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index 3b04c3c29b..1331a7bcd7 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -50,13 +50,13 @@ export interface CodeModeTools { export const create = (options: { readonly registrations: ReadonlyMap readonly current: (name: string) => Registration | undefined - readonly tools?: CodeModeTools + readonly tools: CodeModeTools }) => { const runtime = ( invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) => { - const tools: CodeModeTools = Object.assign(Object.create(null), options.tools) + const tools = cloneTools(options.tools) for (const [name, registration] of options.registrations) { const child = definition(name, registration.tool) const value = Tool.make({ @@ -168,6 +168,15 @@ export const create = (options: { }) } +function cloneTools(tools: CodeModeTools): CodeModeTools { + return Object.assign( + Object.create(null), + Object.fromEntries( + Object.entries(tools).map(([name, value]) => [name, Tool.isDefinition(value) ? value : cloneTools(value)]), + ), + ) +} + function displayInput(input: unknown): Record | undefined { if (input === null || input === undefined) return if (typeof input !== "object" || Array.isArray(input)) return { input } diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index e1e78ec4d7..c316a7e54b 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -15,6 +15,7 @@ import { definition, permission, registrationEntries, RegistrationError, settle, import { Tools } from "./tools" import { ToolHooks } from "./hooks" import { makeLocationNode } from "../effect/app-node" +import { LayerNode } from "../effect/layer-node" import { SessionError } from "@opencode-ai/schema/session-error" import { toSessionError } from "../session/to-session-error" @@ -52,10 +53,16 @@ export interface Settlement { } export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} -class CodeModeCatalog extends Context.Service()( +class CodeModeCatalog extends Context.Service()( "@opencode/v2/CodeModeCatalog", ) {} +const codeModeCatalogNode = makeLocationNode({ + service: CodeModeCatalog, + layer: Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: {} })), + deps: [], +}) + const registryLayer = Layer.effect( Service, Effect.gen(function* () { @@ -209,10 +216,9 @@ const registryLayer = Layer.effect( } const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred)) const deferred = new Map(Array.from(registrations).filter(([, registration]) => registration.deferred)) - const tools = Flag.CODEMODE_ENABLED ? codeModeTools : undefined + const tools = Flag.CODEMODE_ENABLED ? codeModeTools : {} const execute = - (deferred.size > 0 || (tools !== undefined && Object.keys(tools).length > 0)) && - !whollyDisabled("execute", input.permissions ?? []) + (deferred.size > 0 || Object.keys(tools).length > 0) && !whollyDisabled("execute", input.permissions ?? []) ? ExecuteTool.create({ registrations: deferred, current: (name) => local.get(name)?.at(-1)?.registration, @@ -239,37 +245,28 @@ const registryLayer = Layer.effect( }), ) -const makeLayer = (codeModeTools?: CodeModeTools) => { - return Layer.effect( - Tools.Service, - Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), - ).pipe( - Layer.provideMerge(registryLayer), - Layer.provide(Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: codeModeTools }))), - ) -} +const layer = Layer.effect( + Tools.Service, + Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), +).pipe(Layer.provideMerge(registryLayer)) function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) return rule?.resource === "*" && rule.effect === "deny" } -export function nodes(codeModeTools?: CodeModeTools) { - const layer = makeLayer(codeModeTools) - return { - node: makeLocationNode({ - service: Service, - layer, - deps: [ToolOutputStore.node, ToolHooks.node], - }), - toolsNode: makeLocationNode({ - service: Tools.Service, - layer, - deps: [ToolOutputStore.node, ToolHooks.node], - }), - } +export function codeModeReplacement(tools: CodeModeTools): LayerNode.Replacement { + return [codeModeCatalogNode, Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools }))] } -const defaults = nodes() -export const node = defaults.node -export const toolsNode = defaults.toolsNode +export const node = makeLocationNode({ + service: Service, + layer, + deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode], +}) + +export const toolsNode = makeLocationNode({ + service: Tools.Service, + layer, + deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode], +}) diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index c9e8cb89d5..c259c5fd2b 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -30,7 +30,7 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { }) const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const it = testEffect(registryLayer) -const codeModeNodes = ToolRegistry.nodes({ +const codeModeTools: ToolRegistry.CodeModeTools = { opencode: { v2: { health: { @@ -44,8 +44,13 @@ const codeModeNodes = ToolRegistry.nodes({ }, }, }, -}) -const codeModeIt = testEffect(AppNodeBuilder.build(codeModeNodes.node, [[ToolOutputStore.node, outputStore]])) +} +const codeModeIt = testEffect( + AppNodeBuilder.build(ToolRegistry.node, [ + [ToolOutputStore.node, outputStore], + ToolRegistry.codeModeReplacement(codeModeTools), + ]), +) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_registry"), @@ -91,6 +96,28 @@ describe("ToolRegistry", () => { }), ) + codeModeIt.effect("keeps host Code Mode trees immutable while merging deferred tools", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + yield* service.register({ echo: make() }, { group: "opencode", deferred: true }) + + expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo") + expect((yield* toolDefinitions(service))[0]?.description).toContain("tools.opencode.echo") + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-opencode-echo", + name: "execute", + input: { code: 'return await tools.opencode.echo({ text: "hello" })' }, + }, + }), + ).toEqual({ type: "text", value: '{\n "text": "hello"\n}' }) + }), + ) + it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () => Effect.gen(function* () { const service = yield* ToolRegistry.Service diff --git a/packages/server/src/code-mode.ts b/packages/server/src/code-mode.ts new file mode 100644 index 0000000000..3976a5bb03 --- /dev/null +++ b/packages/server/src/code-mode.ts @@ -0,0 +1,66 @@ +export * as ServerCodeMode from "./code-mode" + +import { NodeHttpClient } from "@effect/platform-node" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { ToolRegistry } from "@opencode-ai/core/tool/registry" +import { OpenAPI, Tool } from "@opencode-ai/codemode" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import type { Server } from "node:http" +import { Api } from "./api" +import { ServerAuth } from "./auth" + +export function replacement(server: Server, password: string): LayerNode.Replacement { + return ToolRegistry.codeModeReplacement(makeTools(client(server), password)) +} + +export function makeTools(client: Layer.Layer, password: string): ToolRegistry.CodeModeTools { + return { + opencode: bindTools( + OpenAPI.fromSpec({ + spec: { ...OpenApi.fromApi(Api) }, + baseUrl: "http://opencode.local", + headers: ServerAuth.headers({ username: "opencode", password }), + }).tools, + client, + ), + } +} + +function client(server: Server) { + return Layer.effect( + HttpClient.HttpClient, + Effect.gen(function* () { + const client = yield* HttpClient.HttpClient + return HttpClient.mapRequest(client, (request) => { + const address = server.address() + if (!address || typeof address === "string") throw new Error("OpenCode server is not listening") + const local = + address.address === "0.0.0.0" ? "127.0.0.1" : address.address === "::" ? "::1" : address.address + const host = local.includes(":") && !local.startsWith("[") ? `[${local}]` : local + const url = new URL(request.url) + return HttpClientRequest.setUrl( + request, + new URL(`${url.pathname}${url.search}${url.hash}`, `http://${host}:${address.port}`), + ) + }) + }), + ).pipe(Layer.provide(NodeHttpClient.layerNodeHttp)) +} + +function bindTools(tools: OpenAPI.Tools, client: Layer.Layer): ToolRegistry.CodeModeTools { + return Object.fromEntries( + Object.entries(tools).map(([name, value]) => [ + name, + Tool.isDefinition(value) + ? Tool.make({ + description: value.description, + input: value.input, + output: value.output, + run: (input) => value.run(input).pipe(Effect.provide(client)), + }) + : bindTools(value, client), + ]), + ) +} diff --git a/packages/server/src/process.ts b/packages/server/src/process.ts index 0e08574acc..e62c29f3e2 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -12,6 +12,7 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/un import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { createServer } from "node:http" import { ServerAuth } from "./auth" +import { ServerCodeMode } from "./code-mode" import { createRoutes } from "./routes" export type Options = { @@ -52,25 +53,10 @@ function listen(options: Options) { function bind(hostname: string, port: number, password: string) { const server = createServer() - const codeModeClient = Layer.effect( - HttpClient.HttpClient, - Effect.gen(function* () { - const client = yield* HttpClient.HttpClient - return HttpClient.mapRequest(client, (request) => { - const address = server.address() - if (!address || typeof address === "string") throw new Error("OpenCode server is not listening") - const local = hostname === "0.0.0.0" ? "127.0.0.1" : hostname === "::" ? "::1" : hostname - const host = local.includes(":") && !local.startsWith("[") ? `[${local}]` : local - const url = new URL(request.url) - return HttpClientRequest.setUrl( - request, - new URL(`${url.pathname}${url.search}${url.hash}`, `http://${host}:${address.port}`), - ) - }) - }), - ).pipe(Layer.provide(NodeHttpClient.layerNodeHttp)) return Layer.build( - HttpRouter.serve(createRoutes(password, codeModeClient), { disableListenLog: true }).pipe( + HttpRouter.serve(createRoutes(password, [ServerCodeMode.replacement(server, password)]), { + disableListenLog: true, + }).pipe( Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c8162c84bc..68cba323da 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -17,10 +17,8 @@ import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" -import { ToolRegistry } from "@opencode-ai/core/tool/registry" -import { OpenAPI, Tool } from "@opencode-ai/codemode" -import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http" -import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi" +import { HttpRouter, HttpServer } from "effect/unstable/http" +import { HttpApiBuilder } from "effect/unstable/httpapi" import { Effect, Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" @@ -49,46 +47,36 @@ const applicationServices = LayerNode.group([ LocationServiceMap.node, ]) -export function createRoutes(password?: string, codeModeClient?: Layer.Layer) { +export function createRoutes(password?: string, replacements: LayerNode.Replacements = []) { return makeRoutes( password ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) : ServerAuth.Config.layer, undefined, - codeModeClient - ? { - opencode: openCodeTools( - OpenAPI.fromSpec({ - spec: { ...OpenApi.fromApi(Api) }, - baseUrl: "http://opencode.local", - headers: ServerAuth.headers({ username: "opencode", password }), - }).tools, - codeModeClient, - ), - } - : undefined, + replacements, ) } -export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { - return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins) +export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store, replacements: LayerNode.Replacements = []) { + return makeRoutes( + ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), + sdkPlugins, + replacements, + ) } function makeRoutes( auth: Layer.Layer, sdkPlugins?: SdkPlugins.Store, - codeModeTools?: ToolRegistry.CodeModeTools, + hostReplacements: LayerNode.Replacements = [], ) { const pluginRuntimeCell = PluginRuntime.makeCell() - const codeMode = codeModeTools ? ToolRegistry.nodes(codeModeTools) : undefined const replacements: LayerNode.Replacements = [ [SessionExecution.node, SessionExecutionLocal.node], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), - ...(codeMode - ? [[ToolRegistry.node, codeMode.node] as const, [ToolRegistry.toolsNode, codeMode.toolsNode] as const] - : []), + ...hostReplacements, ] const serviceLayer = simulateEnabled() ? Layer.unwrap( @@ -118,22 +106,6 @@ function makeRoutes( ) } -function openCodeTools(tools: OpenAPI.Tools, client: Layer.Layer): ToolRegistry.CodeModeTools { - return Object.fromEntries( - Object.entries(tools).map(([name, value]) => [ - name, - Tool.isDefinition(value) - ? Tool.make({ - description: value.description, - input: value.input, - output: value.output, - run: (input) => value.run(input).pipe(Effect.provide(client)), - }) - : openCodeTools(value, client), - ]), - ) -} - function simulateEnabled() { return !!process.env.OPENCODE_SIMULATE } diff --git a/packages/server/test/code-mode.test.ts b/packages/server/test/code-mode.test.ts new file mode 100644 index 0000000000..671900a6f7 --- /dev/null +++ b/packages/server/test/code-mode.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "bun:test" +import { CodeMode } from "@opencode-ai/codemode" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { ServerCodeMode } from "../src/code-mode" + +test("exposes the authenticated server API through CodeMode", async () => { + const requests: Array<{ readonly url: string; readonly authorization?: string }> = [] + const client = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + requests.push({ url: request.url, authorization: request.headers.authorization }) + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json( + { healthy: true, version: "test", pid: 1 }, + { headers: { "content-type": "application/json" } }, + ), + ), + ) + }), + ) + const result = await CodeMode.make({ tools: ServerCodeMode.makeTools(client, "secret") }) + .execute("return await tools.opencode.v2.health.get({})") + .pipe(Effect.runPromise) + + expect(result).toEqual({ + ok: true, + value: { healthy: true, version: "test", pid: 1 }, + toolCalls: [{ name: "opencode.v2.health.get" }], + }) + expect(requests).toEqual([ + { + url: "http://opencode.local/api/health", + authorization: `Basic ${Buffer.from("opencode:secret").toString("base64")}`, + }, + ]) +})