Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
66 changes: 66 additions & 0 deletions packages/cli/src/code-mode.ts
Original file line number Diff line number Diff line change
@@ -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<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),
]),
)
}
2 changes: 2 additions & 0 deletions packages/cli/src/server-process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions packages/cli/test/code-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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")}`,
},
])
})
16 changes: 15 additions & 1 deletion packages/core/src/tool/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,20 @@ export interface Registration {
readonly group?: string
}

export interface CodeModeTools {
[name: string]: Tool.Definition<never> | CodeModeTools
}

export const create = (options: {
readonly registrations: ReadonlyMap<string, Registration>
readonly current: (name: string) => Registration | undefined
readonly tools: CodeModeTools
}) => {
const runtime = (
invoke: (name: string, registration: Registration, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
) => {
const tools: Record<string, Tool.Definition<never> | Record<string, Tool.Definition<never>>> = {}
const tools = cloneTools(options.tools)
for (const [name, registration] of options.registrations) {
const child = definition(name, registration.tool)
const value = Tool.make({
Expand Down Expand Up @@ -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<string, unknown> | undefined {
if (input === null || input === undefined) return
if (typeof input !== "object" || Array.isArray(input)) return { input }
Expand Down
26 changes: 22 additions & 4 deletions packages/core/src/tool/registry.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"

Expand Down Expand Up @@ -51,10 +53,20 @@ export interface Settlement {
}

export class Service extends Context.Service<Service, Interface>()("@opencode/v2/ToolRegistry") {}
class CodeModeCatalog extends Context.Service<CodeModeCatalog, { readonly tools: CodeModeTools }>()(
"@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 = {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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],
})
65 changes: 65 additions & 0 deletions packages/core/test/session-runner-tool-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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
Expand Down
15 changes: 9 additions & 6 deletions packages/server/src/process.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +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 { createRoutes } from "./routes"

export type Options = {
readonly hostname: string
readonly port: Option.Option<number>
readonly password: string
readonly replacements?: (server: Server) => LayerNode.Replacements
}

const ReadinessApi = HttpApi.make("readiness").add(HealthGroup)
Expand All @@ -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<typeof bind> =>
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(
Expand Down
Loading
Loading