refactor(core): separate Code Mode host wiring

This commit is contained in:
Aiden Cline 2026-07-07 09:31:59 -05:00
commit 42b63d6660
8 changed files with 190 additions and 93 deletions

View file

@ -813,6 +813,7 @@
"version": "1.17.14", "version": "1.17.14",
"dependencies": { "dependencies": {
"@effect/platform-node": "catalog:", "@effect/platform-node": "catalog:",
"@opencode-ai/codemode": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*", "@opencode-ai/protocol": "workspace:*",
"@opencode-ai/simulation": "workspace:*", "@opencode-ai/simulation": "workspace:*",

View file

@ -50,13 +50,13 @@ export interface CodeModeTools {
export const create = (options: { export const create = (options: {
readonly registrations: ReadonlyMap<string, Registration> readonly registrations: ReadonlyMap<string, Registration>
readonly current: (name: string) => Registration | undefined readonly current: (name: string) => Registration | undefined
readonly tools?: CodeModeTools readonly tools: CodeModeTools
}) => { }) => {
const runtime = ( const runtime = (
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>, invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks, 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) { for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool) const child = definition(name, registration.tool)
const value = Tool.make({ 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<string, unknown> | undefined { function displayInput(input: unknown): Record<string, unknown> | undefined {
if (input === null || input === undefined) return if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input } if (typeof input !== "object" || Array.isArray(input)) return { input }

View file

@ -15,6 +15,7 @@ import { definition, permission, registrationEntries, RegistrationError, settle,
import { Tools } from "./tools" import { Tools } from "./tools"
import { ToolHooks } from "./hooks" import { ToolHooks } from "./hooks"
import { makeLocationNode } from "../effect/app-node" import { makeLocationNode } from "../effect/app-node"
import { LayerNode } from "../effect/layer-node"
import { SessionError } from "@opencode-ai/schema/session-error" import { SessionError } from "@opencode-ai/schema/session-error"
import { toSessionError } from "../session/to-session-error" import { toSessionError } from "../session/to-session-error"
@ -52,10 +53,16 @@ export interface Settlement {
} }
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {} export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools?: CodeModeTools }>()( class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools: CodeModeTools }>()(
"@opencode/v2/CodeModeCatalog", "@opencode/v2/CodeModeCatalog",
) {} ) {}
const codeModeCatalogNode = makeLocationNode({
service: CodeModeCatalog,
layer: Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: {} })),
deps: [],
})
const registryLayer = Layer.effect( const registryLayer = Layer.effect(
Service, Service,
Effect.gen(function* () { Effect.gen(function* () {
@ -209,10 +216,9 @@ const registryLayer = Layer.effect(
} }
const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred)) const direct = new Map(Array.from(registrations).filter(([, registration]) => !registration.deferred))
const deferred = 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 = const execute =
(deferred.size > 0 || (tools !== undefined && Object.keys(tools).length > 0)) && (deferred.size > 0 || Object.keys(tools).length > 0) && !whollyDisabled("execute", input.permissions ?? [])
!whollyDisabled("execute", input.permissions ?? [])
? ExecuteTool.create({ ? ExecuteTool.create({
registrations: deferred, registrations: deferred,
current: (name) => local.get(name)?.at(-1)?.registration, current: (name) => local.get(name)?.at(-1)?.registration,
@ -239,37 +245,28 @@ const registryLayer = Layer.effect(
}), }),
) )
const makeLayer = (codeModeTools?: CodeModeTools) => { const layer = Layer.effect(
return Layer.effect( Tools.Service,
Tools.Service, Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))),
Service.use((registry) => Effect.succeed(Tools.Service.of({ register: registry.register }))), ).pipe(Layer.provideMerge(registryLayer))
).pipe(
Layer.provideMerge(registryLayer),
Layer.provide(Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools: codeModeTools }))),
)
}
function whollyDisabled(action: string, rules: PermissionV2.Ruleset) { function whollyDisabled(action: string, rules: PermissionV2.Ruleset) {
const rule = rules.findLast((rule) => Wildcard.match(action, rule.action)) const rule = rules.findLast((rule) => Wildcard.match(action, rule.action))
return rule?.resource === "*" && rule.effect === "deny" return rule?.resource === "*" && rule.effect === "deny"
} }
export function nodes(codeModeTools?: CodeModeTools) { export function codeModeReplacement(tools: CodeModeTools): LayerNode.Replacement {
const layer = makeLayer(codeModeTools) return [codeModeCatalogNode, Layer.succeed(CodeModeCatalog, CodeModeCatalog.of({ tools }))]
return {
node: makeLocationNode({
service: Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
}),
toolsNode: makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node],
}),
}
} }
const defaults = nodes() export const node = makeLocationNode({
export const node = defaults.node service: Service,
export const toolsNode = defaults.toolsNode layer,
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
})
export const toolsNode = makeLocationNode({
service: Tools.Service,
layer,
deps: [ToolOutputStore.node, ToolHooks.node, codeModeCatalogNode],
})

View file

@ -30,7 +30,7 @@ const outputStore = Layer.mock(ToolOutputStore.Service, {
}) })
const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]]) const registryLayer = AppNodeBuilder.build(ToolRegistry.node, [[ToolOutputStore.node, outputStore]])
const it = testEffect(registryLayer) const it = testEffect(registryLayer)
const codeModeNodes = ToolRegistry.nodes({ const codeModeTools: ToolRegistry.CodeModeTools = {
opencode: { opencode: {
v2: { v2: {
health: { 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 = { const identity = {
agent: AgentV2.ID.make("build"), agent: AgentV2.ID.make("build"),
assistantMessageID: SessionMessage.ID.make("msg_registry"), 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", () => it.effect("filters disabled tools with edit aliases and ordered wildcard precedence", () =>
Effect.gen(function* () { Effect.gen(function* () {
const service = yield* ToolRegistry.Service const service = yield* ToolRegistry.Service

View file

@ -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<HttpClient.HttpClient>, 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<HttpClient.HttpClient>): ToolRegistry.CodeModeTools {
return Object.fromEntries(
Object.entries(tools).map(([name, value]) => [
name,
Tool.isDefinition<HttpClient.HttpClient>(value)
? Tool.make({
description: value.description,
input: value.input,
output: value.output,
run: (input) => value.run(input).pipe(Effect.provide(client)),
})
: bindTools(value, client),
]),
)
}

View file

@ -12,6 +12,7 @@ import { HttpClient, HttpClientRequest, HttpRouter, HttpServer } from "effect/un
import { HttpApi, HttpApiClient } from "effect/unstable/httpapi" import { HttpApi, HttpApiClient } from "effect/unstable/httpapi"
import { createServer } from "node:http" import { createServer } from "node:http"
import { ServerAuth } from "./auth" import { ServerAuth } from "./auth"
import { ServerCodeMode } from "./code-mode"
import { createRoutes } from "./routes" import { createRoutes } from "./routes"
export type Options = { export type Options = {
@ -52,25 +53,10 @@ function listen(options: Options) {
function bind(hostname: string, port: number, password: string) { function bind(hostname: string, port: number, password: string) {
const server = createServer() 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( 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.provideMerge(NodeHttpServer.layer(() => server, { port, host: hostname })),
Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))), Layer.provide(AppNodeBuilder.build(LayerNode.group([Credential.node, PermissionSaved.node, Project.node]))),
), ),

View file

@ -17,10 +17,8 @@ import { SessionExecutionLocal } from "@opencode-ai/core/session/execution/local
import { PluginRuntime } from "@opencode-ai/core/plugin/runtime" import { PluginRuntime } from "@opencode-ai/core/plugin/runtime"
import { SdkPlugins } from "@opencode-ai/core/plugin/sdk" import { SdkPlugins } from "@opencode-ai/core/plugin/sdk"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store" import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { ToolRegistry } from "@opencode-ai/core/tool/registry" import { HttpRouter, HttpServer } from "effect/unstable/http"
import { OpenAPI, Tool } from "@opencode-ai/codemode" import { HttpApiBuilder } from "effect/unstable/httpapi"
import { HttpClient, HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder, OpenApi } from "effect/unstable/httpapi"
import { Effect, Layer, Option } from "effect" import { Effect, Layer, Option } from "effect"
import { Api } from "./api" import { Api } from "./api"
import { ServerAuth } from "./auth" import { ServerAuth } from "./auth"
@ -49,46 +47,36 @@ const applicationServices = LayerNode.group([
LocationServiceMap.node, LocationServiceMap.node,
]) ])
export function createRoutes(password?: string, codeModeClient?: Layer.Layer<HttpClient.HttpClient>) { export function createRoutes(password?: string, replacements: LayerNode.Replacements = []) {
return makeRoutes( return makeRoutes(
password password
? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) }) ? ServerAuth.Config.configLayer({ username: "opencode", password: Option.some(password) })
: ServerAuth.Config.layer, : ServerAuth.Config.layer,
undefined, undefined,
codeModeClient replacements,
? {
opencode: openCodeTools(
OpenAPI.fromSpec({
spec: { ...OpenApi.fromApi(Api) },
baseUrl: "http://opencode.local",
headers: ServerAuth.headers({ username: "opencode", password }),
}).tools,
codeModeClient,
),
}
: undefined,
) )
} }
export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store) { export function createEmbeddedRoutes(sdkPlugins?: SdkPlugins.Store, replacements: LayerNode.Replacements = []) {
return makeRoutes(ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }), sdkPlugins) return makeRoutes(
ServerAuth.Config.configLayer({ username: "opencode", password: Option.none() }),
sdkPlugins,
replacements,
)
} }
function makeRoutes<AuthError, AuthServices>( function makeRoutes<AuthError, AuthServices>(
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>, auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
sdkPlugins?: SdkPlugins.Store, sdkPlugins?: SdkPlugins.Store,
codeModeTools?: ToolRegistry.CodeModeTools, hostReplacements: LayerNode.Replacements = [],
) { ) {
const pluginRuntimeCell = PluginRuntime.makeCell() const pluginRuntimeCell = PluginRuntime.makeCell()
const codeMode = codeModeTools ? ToolRegistry.nodes(codeModeTools) : undefined
const replacements: LayerNode.Replacements = [ const replacements: LayerNode.Replacements = [
[SessionExecution.node, SessionExecutionLocal.node], [SessionExecution.node, SessionExecutionLocal.node],
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)], [PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)], [PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []), ...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
...(codeMode ...hostReplacements,
? [[ToolRegistry.node, codeMode.node] as const, [ToolRegistry.toolsNode, codeMode.toolsNode] as const]
: []),
] ]
const serviceLayer = simulateEnabled() const serviceLayer = simulateEnabled()
? Layer.unwrap( ? Layer.unwrap(
@ -118,22 +106,6 @@ function makeRoutes<AuthError, AuthServices>(
) )
} }
function openCodeTools(tools: OpenAPI.Tools, client: Layer.Layer<HttpClient.HttpClient>): ToolRegistry.CodeModeTools {
return Object.fromEntries(
Object.entries(tools).map(([name, value]) => [
name,
Tool.isDefinition<HttpClient.HttpClient>(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() { function simulateEnabled() {
return !!process.env.OPENCODE_SIMULATE return !!process.env.OPENCODE_SIMULATE
} }

View file

@ -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")}`,
},
])
})