From e113aad5e0bda72222ac8321ecbfc85bbd82ae94 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 6 Jul 2026 23:05:58 -0500 Subject: [PATCH 1/3] feat(core): expose server API in Code Mode --- packages/core/src/tool/execute.ts | 7 ++- packages/core/src/tool/registry.ts | 53 +++++++++++++------ .../test/session-runner-tool-registry.test.ts | 38 +++++++++++++ packages/server/package.json | 1 + packages/server/src/process.ts | 19 ++++++- packages/server/src/routes.ts | 42 +++++++++++++-- 6 files changed, 139 insertions(+), 21 deletions(-) diff --git a/packages/core/src/tool/execute.ts b/packages/core/src/tool/execute.ts index f5bd776e5abc..3b04c3c29bc0 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: CodeModeTools = Object.assign(Object.create(null), options.tools) for (const [name, registration] of options.registrations) { const child = definition(name, registration.tool) const value = Tool.make({ diff --git a/packages/core/src/tool/registry.ts b/packages/core/src/tool/registry.ts index 0275cd8d875d..e1e78ec4d763 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,7 +10,7 @@ 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" @@ -51,10 +52,14 @@ export interface Settlement { } export class Service extends Context.Service()("@opencode/v2/ToolRegistry") {} +class CodeModeCatalog extends Context.Service()( + "@opencode/v2/CodeModeCatalog", +) {} 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 +209,14 @@ 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 execute = - deferred.size > 0 && !whollyDisabled("execute", input.permissions ?? []) + (deferred.size > 0 || (tools !== undefined && Object.keys(tools).length > 0)) && + !whollyDisabled("execute", input.permissions ?? []) ? ExecuteTool.create({ registrations: deferred, current: (name) => local.get(name)?.at(-1)?.registration, + tools, }) : undefined return { @@ -231,24 +239,37 @@ const registryLayer = Layer.effect( }), ) -const layer = Layer.effect( - Tools.Service, - Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), -).pipe(Layer.provideMerge(registryLayer)) +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 }))), + ) +} function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) return rule?.resource === "*" && rule.effect === "deny" } -export const node = makeLocationNode({ - service: Service, - layer, - deps: [ToolOutputStore.node, ToolHooks.node], -}) +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 const toolsNode = makeLocationNode({ - service: Tools.Service, - layer, - deps: [ToolOutputStore.node, ToolHooks.node], -}) +const defaults = nodes() +export const node = defaults.node +export const toolsNode = defaults.toolsNode diff --git a/packages/core/test/session-runner-tool-registry.test.ts b/packages/core/test/session-runner-tool-registry.test.ts index 3c03094b5e90..c9e8cb89d54f 100644 --- a/packages/core/test/session-runner-tool-registry.test.ts +++ b/packages/core/test/session-runner-tool-registry.test.ts @@ -30,6 +30,22 @@ const outputStore = Layer.mock(ToolOutputStore.Service, { }) const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const it = testEffect(registryLayer) +const codeModeNodes = ToolRegistry.nodes({ + 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(codeModeNodes.node, [[ToolOutputStore.node, outputStore]])) const identity = { agent: AgentV2.ID.make("build"), assistantMessageID: SessionMessage.ID.make("msg_registry"), @@ -53,6 +69,28 @@ 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}' }) + }), + ) + 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/package.json b/packages/server/package.json index 6a0fc15ad3ff..5ffcd7d4faa8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,6 +13,7 @@ }, "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/server/src/process.ts b/packages/server/src/process.ts index de6f4b328f56..0e08574acc97 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -52,8 +52,25 @@ 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), { disableListenLog: true }).pipe( + HttpRouter.serve(createRoutes(password, codeModeClient), { 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 1c5a57f62ff6..c8162c84bcd1 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -17,8 +17,10 @@ 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 { HttpRouter, HttpServer } from "effect/unstable/http" -import { HttpApiBuilder } from "effect/unstable/httpapi" +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 { Effect, Layer, Option } from "effect" import { Api } from "./api" import { ServerAuth } from "./auth" @@ -47,11 +49,24 @@ const applicationServices = LayerNode.group([ LocationServiceMap.node, ]) -export function createRoutes(password?: string) { +export function createRoutes(password?: string, codeModeClient?: Layer.Layer) { 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, ) } @@ -62,13 +77,18 @@ export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { function makeRoutes( auth: Layer.Layer, sdkPlugins?: SdkPlugins.Store, + codeModeTools?: ToolRegistry.CodeModeTools, ) { 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] + : []), ] const serviceLayer = simulateEnabled() ? Layer.unwrap( @@ -98,6 +118,22 @@ 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 } From 42b63d666046217ff4f7444ea82f5b21acca5ffd Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 09:31:59 -0500 Subject: [PATCH 2/3] refactor(core): separate Code Mode host wiring --- bun.lock | 1 + packages/core/src/tool/execute.ts | 13 +++- packages/core/src/tool/registry.ts | 57 ++++++++-------- .../test/session-runner-tool-registry.test.ts | 33 +++++++++- packages/server/src/code-mode.ts | 66 +++++++++++++++++++ packages/server/src/process.ts | 22 ++----- packages/server/src/routes.ts | 52 ++++----------- packages/server/test/code-mode.test.ts | 39 +++++++++++ 8 files changed, 190 insertions(+), 93 deletions(-) create mode 100644 packages/server/src/code-mode.ts create mode 100644 packages/server/test/code-mode.test.ts diff --git a/bun.lock b/bun.lock index 4e7e1f72e03b..3087110df568 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 3b04c3c29bc0..1331a7bcd79a 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 e1e78ec4d763..c316a7e54bc1 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 c9e8cb89d54f..c259c5fd2bf5 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 000000000000..3976a5bb0374 --- /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 0e08574acc97..e62c29f3e2aa 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 c8162c84bcd1..68cba323da5c 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 000000000000..671900a6f7be --- /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")}`, + }, + ]) +}) From b09ebbea3cc8b02e6bd50f1194007c43776e7845 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 7 Jul 2026 10:39:32 -0500 Subject: [PATCH 3/3] refactor(cli): own Code Mode host wiring --- bun.lock | 2 +- packages/cli/package.json | 1 + packages/{server => cli}/src/code-mode.ts | 10 +++++----- packages/cli/src/server-process.ts | 2 ++ packages/{server => cli}/test/code-mode.test.ts | 6 +++--- packages/server/package.json | 1 - packages/server/src/process.ts | 14 +++++++------- 7 files changed, 19 insertions(+), 17 deletions(-) rename packages/{server => cli}/src/code-mode.ts (87%) rename packages/{server => cli}/test/code-mode.test.ts (84%) diff --git a/bun.lock b/bun.lock index 3087110df568..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:*", @@ -813,7 +814,6 @@ "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/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/server/src/code-mode.ts b/packages/cli/src/code-mode.ts similarity index 87% rename from packages/server/src/code-mode.ts rename to packages/cli/src/code-mode.ts index 3976a5bb0374..55784971e87b 100644 --- a/packages/server/src/code-mode.ts +++ b/packages/cli/src/code-mode.ts @@ -1,18 +1,18 @@ -export * as ServerCodeMode from "./code-mode" +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" -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 replacements(server: Server, password: string): LayerNode.Replacements { + return [ToolRegistry.codeModeReplacement(makeTools(client(server), password))] } export function makeTools(client: Layer.Layer, password: string): ToolRegistry.CodeModeTools { 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/server/test/code-mode.test.ts b/packages/cli/test/code-mode.test.ts similarity index 84% rename from packages/server/test/code-mode.test.ts rename to packages/cli/test/code-mode.test.ts index 671900a6f7be..54f7c1cf0ec1 100644 --- a/packages/server/test/code-mode.test.ts +++ b/packages/cli/test/code-mode.test.ts @@ -2,9 +2,9 @@ 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" +import { CodeModeHost } from "../src/code-mode" -test("exposes the authenticated server API through CodeMode", async () => { +test("exposes the authenticated OpenCode API through CodeMode", async () => { const requests: Array<{ readonly url: string; readonly authorization?: string }> = [] const client = Layer.succeed( HttpClient.HttpClient, @@ -21,7 +21,7 @@ test("exposes the authenticated server API through CodeMode", async () => { ) }), ) - const result = await CodeMode.make({ tools: ServerCodeMode.makeTools(client, "secret") }) + const result = await CodeMode.make({ tools: CodeModeHost.makeTools(client, "secret") }) .execute("return await tools.opencode.v2.health.get({})") .pipe(Effect.runPromise) diff --git a/packages/server/package.json b/packages/server/package.json index 5ffcd7d4faa8..6a0fc15ad3ff 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,7 +13,6 @@ }, "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/server/src/process.ts b/packages/server/src/process.ts index e62c29f3e2aa..dea9a3f30daa 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -10,15 +10,15 @@ 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 { ServerCodeMode } from "./code-mode" import { createRoutes } from "./routes" 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) @@ -43,21 +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, [ServerCodeMode.replacement(server, password)]), { + HttpRouter.serve(createRoutes(options.password, options.replacements?.(server)), { disableListenLog: true, }).pipe( - Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })), + Layer.provideMerge(NodeHttpServer.layer(() => server, { port, host: options.hostname })), Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), ), ).pipe(