mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-22 10:46:16 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b09ebbea3c | ||
|
|
42b63d6660 | ||
|
|
e113aad5e0 |
@@ -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:*",
|
||||
|
||||
@@ -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:*",
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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")}`,
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -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({
|
||||
@@ -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 }
|
||||
|
||||
@@ -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<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 = {
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<number>
|
||||
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<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(
|
||||
|
||||
@@ -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<AuthError, AuthServices>(
|
||||
auth: Layer.Layer<ServerAuth.Config, AuthError, AuthServices>,
|
||||
sdkPlugins?: SdkPlugins.Store,
|
||||
hostReplacements: LayerNode.Replacements = [],
|
||||
) {
|
||||
const pluginRuntimeCell = PluginRuntime.makeCell()
|
||||
const replacements: LayerNode.Replacements = [
|
||||
@@ -69,6 +76,7 @@ function makeRoutes<AuthError, AuthServices>(
|
||||
[PluginRuntime.node, PluginRuntime.layerWithCell(pluginRuntimeCell)],
|
||||
[PluginRuntime.providerNode, PluginRuntime.providerNodeWithCell(pluginRuntimeCell)],
|
||||
...(sdkPlugins ? [[SdkPlugins.node, SdkPlugins.layerWithStore(sdkPlugins)] as const] : []),
|
||||
...hostReplacements,
|
||||
]
|
||||
const serviceLayer = simulateEnabled()
|
||||
? Layer.unwrap(
|
||||
|
||||
Reference in New Issue
Block a user