diff --git a/bun.lock b/bun.lock index 4e7e1f72e03b..0f044f079104 100644 --- a/bun.lock +++ b/bun.lock @@ -101,6 +101,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/cli/package.json b/packages/cli/package.json index e11986e5b2ef..fdd5f496660f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -33,6 +33,7 @@ "dependencies": { "@effect/platform-node": "catalog:", "@opencode-ai/client": "workspace:*", + "@opencode-ai/codemode": "workspace:*", "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/schema": "workspace:*", diff --git a/packages/cli/src/code-mode.ts b/packages/cli/src/code-mode.ts new file mode 100644 index 000000000000..55784971e87b --- /dev/null +++ b/packages/cli/src/code-mode.ts @@ -0,0 +1,66 @@ +export * as CodeModeHost 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 { Api } from "@opencode-ai/server/api" +import { ServerAuth } from "@opencode-ai/server/auth" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { OpenApi } from "effect/unstable/httpapi" +import type { Server } from "node:http" + +export function replacements(server: Server, password: string): LayerNode.Replacements { + 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/cli/src/server-process.ts b/packages/cli/src/server-process.ts index 48471a573aec..2d5f7261e40a 100644 --- a/packages/cli/src/server-process.ts +++ b/packages/cli/src/server-process.ts @@ -13,6 +13,7 @@ import { randomBytes, randomUUID } from "node:crypto" import path from "node:path" import { Effect, FileSystem, Logger, Option, Redacted, Schedule, Schema } from "effect" import { HttpServer } from "effect/unstable/http" +import { CodeModeHost } from "./code-mode" import { Env } from "./env" import { ServiceConfig } from "./services/service-config" import { Updater } from "./services/updater" @@ -63,6 +64,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) { hostname: options.hostname ?? config.hostname ?? "127.0.0.1", port: Option.fromNullishOr(options.port ?? config.port), password, + replacements: (server) => CodeModeHost.replacements(server, password), }).pipe(Effect.provide(Logger.layer([], { mergeWithExisting: false }))) if (options.mode === "service") yield* register(address, password) const url = HttpServer.formatAddress(address) diff --git a/packages/cli/test/code-mode.test.ts b/packages/cli/test/code-mode.test.ts new file mode 100644 index 000000000000..54f7c1cf0ec1 --- /dev/null +++ b/packages/cli/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 { CodeModeHost } from "../src/code-mode" + +test("exposes the authenticated OpenCode 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: CodeModeHost.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")}`, + }, + ]) +}) diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index f5bd776e5abc..1331a7bcd79a 100644 --- a/packages/core/src/tool/execute.ts +++ b/packages/core/src/tool/execute.ts @@ -43,15 +43,20 @@ export interface Registration { readonly group?: string } +export interface CodeModeTools { + [name: string]: Tool.Definition | CodeModeTools +} + export const create = (options: { readonly registrations: ReadonlyMap readonly current: (name: string) => Registration | undefined + readonly tools: CodeModeTools }) => { const runtime = ( invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect, hooks?: CodeMode.ToolCallHooks, ) => { - const tools: Record | Record>> = {} + const tools = cloneTools(options.tools) for (const [name, registration] of options.registrations) { const child = definition(name, registration.tool) const value = Tool.make({ @@ -163,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 0275cd8d875d..c316a7e54bc1 100644 --- a/packages/core/src/tool/registry.ts +++ b/packages/core/src/tool/registry.ts @@ -1,4 +1,5 @@ export * as ToolRegistry from "./registry" +export type { CodeModeTools } from "./execute" import { ToolOutput, type ToolCall, type ToolDefinition, type ToolResultValue } from "@opencode-ai/llm" import { Context, Effect, Layer, Scope } from "effect" @@ -9,11 +10,12 @@ import { SessionMessage } from "../session/message" import { SessionSchema } from "../session/schema" import { ToolOutputStore } from "../tool-output-store" import { Wildcard } from "../util/wildcard" -import { ExecuteTool } from "./execute" +import { ExecuteTool, type CodeModeTools } from "./execute" import { definition, permission, registrationEntries, RegistrationError, settle, type AnyTool } from "./tool" 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" @@ -51,10 +53,20 @@ export interface Settlement { } export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} +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* () { + const codeModeTools = (yield* CodeModeCatalog).tools const resources = yield* ToolOutputStore.Service const toolHooks = yield* ToolHooks.Service type Registration = { @@ -204,11 +216,13 @@ 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 : {} const execute = - deferred.size > 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, + tools, }) : undefined return { @@ -241,14 +255,18 @@ function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { return rule?.resource === "*" && rule.effect === "deny" } +export function codeModeReplacement(tools: CodeModeTools): LayerNode.Replacement { + return [codeModeCatalogNode, Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools }))] +} + export const node = makeLocationNode({ service: Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node], + deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode], }) export const toolsNode = makeLocationNode({ service: Tools.Service, layer, - deps: [ToolOutputStore.node, ToolHooks.node], + 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 3c03094b5e90..c259c5fd2bf5 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -30,6 +30,27 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { }) const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const it = testEffect(registryLayer) +const codeModeTools: ToolRegistry.CodeModeTools = { + opencode: { + v2: { + health: { + get: { + _tag: "CodeModeTool" as const, + description: "Get server health", + input: Schema.Struct({}), + output: Schema.Struct({ healthy: Schema.Boolean }), + run: () => Effect.succeed({ healthy: true }), + }, + }, + }, + }, +} +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"), @@ -53,6 +74,50 @@ const make = (permission?: string) => { } describe("ToolRegistry", () => { + codeModeIt.effect("includes host Code Mode trees without hosted tool registration", () => + Effect.gen(function* () { + const service = yield* ToolRegistry.Service + const definitions = yield* toolDefinitions(service) + expect(definitions.map((tool) => tool.name)).toEqual(["execute"]) + expect(definitions[0]?.description).toContain("tools.opencode.v2.health.get") + + expect( + yield* executeTool(service, { + sessionID, + ...identity, + call: { + type: "tool-call", + id: "call-opencode-health", + name: "execute", + input: { code: "return await tools.opencode.v2.health.get({})" }, + }, + }), + ).toEqual({ type: "text", value: '{\n "healthy": true\n}' }) + }), + ) + + 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/process.ts b/packages/server/src/process.ts index de6f4b328f56..dea9a3f30daa 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -10,7 +10,7 @@ import { HealthGroup } from "@opencode-ai/protocol/groups/health" import { Context, Effect, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/unstable/http" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" -import { createServer } from "node:http" +import { createServer, type Server } from "node:http" import { ServerAuth } from "./auth" import { createRoutes } from "./routes" @@ -18,6 +18,7 @@ export type Options = { readonly hostname: string readonly port: Option.Option readonly password: string + readonly replacements?: (server: Server) => LayerNode.Replacements } const ReadinessApi = HttpApi.make("readiness").add(HealthGroup) @@ -42,19 +43,21 @@ export const start = Effect.fn("ServerProcess.start")(function* (options: Option }) function listen(options: Options) { - if (Option.isSome(options.port)) return bind(options.hostname, options.port.value, options.password) + if (Option.isSome(options.port)) return bind(options, options.port.value) const next = (port: number): ReturnType => - bind(options.hostname, port, options.password).pipe( + bind(options, port).pipe( Effect.catch((error) => (port === 65_535 ? Effect.fail(error) : next(port + 1))), ) return next(4096) } -function bind(hostname: string, port: number, password: string) { +function bind(options: Options, port: number) { const server = createServer() return Layer.build( - HttpRouter.serve(createRoutes(password), { disableListenLog: true }).pipe( - Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), + HttpRouter.serve(createRoutes(options.password, options.replacements?.(server)), { + disableListenLog: true, + }).pipe( + Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: options.hostname })), Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), ).pipe( diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index 1c5a57f62ff6..68cba323da5c 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -47,21 +47,28 @@ const applicationServices = LayerNode.group([ LocationServiceMap.node, ]) -export function createRoutes(password?: string) { +export function createRoutes(password?: string, replacements: LayerNode.Replacements = []) { return makeRoutes( password ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) : ServerAuth.Config.layer, + 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, + hostReplacements: LayerNode.Replacements = [], ) { const pluginRuntimeCell = PluginRuntime.makeCell() const replacements: LayerNode.Replacements = [ @@ -69,6 +76,7 @@ function makeRoutes( [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), + ...hostReplacements, ] const serviceLayer = simulateEnabled() ? Layer.unwrap(