mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-24 06:45:22 +00:00
refactor(permission): serve experimental httpapi with effect
Move the permission slice onto the parallel Effect-native experimental HttpApi server, stop extending the Hono mount path for this PR, and keep the question and permission groups served through the direct Effect server boundary.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { Server } from "../../server/server"
|
||||
import { ExperimentalHttpApiServer } from "../../server/instance/httpapi/server"
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { Flag } from "../../flag/flag"
|
||||
@@ -17,8 +18,18 @@ export const ServeCommand = cmd({
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = await Server.listen(opts)
|
||||
console.log(`opencode server listening on http://${server.hostname}:${server.port}`)
|
||||
const httpapi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI_PORT
|
||||
? await ExperimentalHttpApiServer.listen({
|
||||
hostname: opts.hostname,
|
||||
port: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI_PORT,
|
||||
})
|
||||
: undefined
|
||||
if (httpapi) {
|
||||
console.log(`experimental httpapi listening on http://${httpapi.hostname}:${httpapi.port}`)
|
||||
}
|
||||
|
||||
await new Promise(() => {})
|
||||
await httpapi?.stop()
|
||||
await server.stop()
|
||||
},
|
||||
})
|
||||
|
||||
@@ -47,6 +47,7 @@ export namespace Flag {
|
||||
export declare const OPENCODE_CLIENT: string
|
||||
export const OPENCODE_SERVER_PASSWORD = process.env["OPENCODE_SERVER_PASSWORD"]
|
||||
export const OPENCODE_SERVER_USERNAME = process.env["OPENCODE_SERVER_USERNAME"]
|
||||
export const OPENCODE_EXPERIMENTAL_HTTPAPI_PORT = number("OPENCODE_EXPERIMENTAL_HTTPAPI_PORT")
|
||||
export const OPENCODE_ENABLE_QUESTION_TOOL = truthy("OPENCODE_ENABLE_QUESTION_TOOL")
|
||||
|
||||
// Experimental
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { lazy } from "@/util/lazy"
|
||||
import { Hono } from "hono"
|
||||
import { PermissionHttpApiHandler } from "./permission"
|
||||
import { QuestionHttpApiHandler } from "./question"
|
||||
|
||||
export const HttpApiRoutes = lazy(() =>
|
||||
new Hono()
|
||||
.all("/question", QuestionHttpApiHandler)
|
||||
.all("/question/*", QuestionHttpApiHandler)
|
||||
.all("/permission", PermissionHttpApiHandler)
|
||||
.all("/permission/*", PermissionHttpApiHandler),
|
||||
new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler),
|
||||
)
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { Handler } from "hono"
|
||||
|
||||
const root = "/experimental/httpapi/permission"
|
||||
|
||||
const Api = HttpApi.make("permission")
|
||||
export const PermissionApi = HttpApi.make("permission")
|
||||
.add(
|
||||
HttpApiGroup.make("permission")
|
||||
.add(
|
||||
@@ -50,10 +50,8 @@ const Api = HttpApi.make("permission")
|
||||
}),
|
||||
)
|
||||
|
||||
const PermissionLive = HttpApiBuilder.group(
|
||||
Api,
|
||||
"permission",
|
||||
Effect.fn("PermissionHttpApi.handlers")(function* (handlers) {
|
||||
export const PermissionLive = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Permission.Service
|
||||
|
||||
const list = Effect.fn("PermissionHttpApi.list")(function* () {
|
||||
@@ -72,15 +70,17 @@ const PermissionLive = HttpApiBuilder.group(
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
return HttpApiBuilder.group(PermissionApi, "permission", (handlers) =>
|
||||
handlers.handle("list", list).handle("reply", reply),
|
||||
)
|
||||
}),
|
||||
)
|
||||
).pipe(Layer.provide(Permission.defaultLayer))
|
||||
|
||||
const web = lazy(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
Layer.mergeAll(
|
||||
AppLayer,
|
||||
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
|
||||
HttpApiBuilder.layer(PermissionApi, { openapiPath: `${root}/doc` }).pipe(
|
||||
Layer.provide(PermissionLive),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
|
||||
@@ -15,7 +15,7 @@ const Reply = Schema.Struct({
|
||||
}),
|
||||
})
|
||||
|
||||
const Api = HttpApi.make("question")
|
||||
export const QuestionApi = HttpApi.make("question")
|
||||
.add(
|
||||
HttpApiGroup.make("question")
|
||||
.add(
|
||||
@@ -55,10 +55,8 @@ const Api = HttpApi.make("question")
|
||||
}),
|
||||
)
|
||||
|
||||
const QuestionLive = HttpApiBuilder.group(
|
||||
Api,
|
||||
"question",
|
||||
Effect.fn("QuestionHttpApi.handlers")(function* (handlers) {
|
||||
export const QuestionLive = Layer.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Question.Service
|
||||
|
||||
const list = Effect.fn("QuestionHttpApi.list")(function* () {
|
||||
@@ -76,7 +74,9 @@ const QuestionLive = HttpApiBuilder.group(
|
||||
return true
|
||||
})
|
||||
|
||||
return handlers.handle("list", list).handle("reply", reply)
|
||||
return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
|
||||
handlers.handle("list", list).handle("reply", reply),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(Question.defaultLayer))
|
||||
|
||||
@@ -84,7 +84,7 @@ const web = lazy(() =>
|
||||
HttpRouter.toWebHandler(
|
||||
Layer.mergeAll(
|
||||
AppLayer,
|
||||
HttpApiBuilder.layer(Api, { openapiPath: `${root}/doc` }).pipe(
|
||||
HttpApiBuilder.layer(QuestionApi, { openapiPath: `${root}/doc` }).pipe(
|
||||
Layer.provide(QuestionLive),
|
||||
Layer.provide(HttpServer.layerServices),
|
||||
),
|
||||
|
||||
109
packages/opencode/src/server/instance/httpapi/server.ts
Normal file
109
packages/opencode/src/server/instance/httpapi/server.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { Context, Effect, Exit, Layer, Scope } from "effect"
|
||||
import { HttpApiBuilder } from "effect/unstable/httpapi"
|
||||
import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { AppRuntime } from "@/effect/app-runtime"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { memoMap } from "@/effect/run-service"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Instance } from "@/project/instance"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { PermissionApi, PermissionLive } from "./permission"
|
||||
import { QuestionApi, QuestionLive } from "./question"
|
||||
|
||||
export namespace ExperimentalHttpApiServer {
|
||||
export type Listener = {
|
||||
hostname: string
|
||||
port: number
|
||||
url: URL
|
||||
stop: () => Promise<void>
|
||||
}
|
||||
|
||||
function text(input: string, status: number, headers?: Record<string, string>) {
|
||||
return HttpServerResponse.text(input, { status, headers })
|
||||
}
|
||||
|
||||
function decode(input: string) {
|
||||
try {
|
||||
return decodeURIComponent(input)
|
||||
} catch {
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
const auth = <E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect
|
||||
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(req.url, "http://localhost")
|
||||
const token = url.searchParams.get("auth_token")
|
||||
const header = token ? `Basic ${token}` : req.headers.authorization
|
||||
const expected = `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`
|
||||
if (header === expected) return yield* effect
|
||||
|
||||
return text("Unauthorized", 401, {
|
||||
"www-authenticate": 'Basic realm="opencode experimental httpapi"',
|
||||
})
|
||||
})
|
||||
|
||||
const instance = <E, R>(effect: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const req = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(req.url, "http://localhost")
|
||||
const raw = url.searchParams.get("directory") || req.headers["x-opencode-directory"] || process.cwd()
|
||||
const workspace = url.searchParams.get("workspace") || undefined
|
||||
const ctx = yield* Effect.promise(() =>
|
||||
Instance.provide({
|
||||
directory: Filesystem.resolve(decode(raw)),
|
||||
init: () => AppRuntime.runPromise(InstanceBootstrap),
|
||||
fn: () => Instance.current,
|
||||
}),
|
||||
)
|
||||
|
||||
const next = workspace ? effect.pipe(Effect.provideService(WorkspaceRef, workspace)) : effect
|
||||
return yield* next.pipe(Effect.provideService(InstanceRef, ctx))
|
||||
})
|
||||
|
||||
export async function listen(opts: { hostname: string; port: number }): Promise<Listener> {
|
||||
const scope = await Effect.runPromise(Scope.make())
|
||||
const serverLayer = NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })
|
||||
const routes = Layer.mergeAll(
|
||||
HttpApiBuilder.layer(QuestionApi, { openapiPath: "/experimental/httpapi/question/doc" }).pipe(
|
||||
Layer.provide(QuestionLive),
|
||||
),
|
||||
HttpApiBuilder.layer(PermissionApi, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe(
|
||||
Layer.provide(PermissionLive),
|
||||
),
|
||||
)
|
||||
const live = Layer.mergeAll(
|
||||
serverLayer,
|
||||
HttpRouter.serve(routes, {
|
||||
disableListenLog: true,
|
||||
disableLogger: true,
|
||||
middleware: (effect) => auth(instance(effect)),
|
||||
}).pipe(Layer.provide(serverLayer)),
|
||||
)
|
||||
|
||||
const ctx = await Effect.runPromise(Layer.buildWithMemoMap(live, memoMap, scope))
|
||||
const server = Context.get(ctx, HttpServer.HttpServer)
|
||||
|
||||
if (server.address._tag !== "TcpAddress") {
|
||||
await Effect.runPromise(Scope.close(scope, Exit.void))
|
||||
throw new Error("Experimental HttpApi server requires a TCP address")
|
||||
}
|
||||
|
||||
const url = new URL("http://localhost")
|
||||
url.hostname = server.address.hostname
|
||||
url.port = String(server.address.port)
|
||||
|
||||
return {
|
||||
hostname: server.address.hostname,
|
||||
port: server.address.port,
|
||||
url,
|
||||
stop: () => Effect.runPromise(Scope.close(scope, Exit.void)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,9 @@ import type { SQL } from "../storage/db"
|
||||
import { PartTable, SessionTable } from "./session.sql"
|
||||
import { ProjectTable } from "../project/project.sql"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Log } from "../util/log"
|
||||
import { fn } from "../util/fn"
|
||||
import { updateSchema } from "../util/update-schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import { Instance } from "../project/instance"
|
||||
|
||||
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/instance/httpapi/server"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import { Log } from "../../src/util/log"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
@@ -18,7 +18,7 @@ afterEach(async () => {
|
||||
describe("experimental permission httpapi", () => {
|
||||
test("lists pending permissions, replies, and serves docs", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const app = Server.Default().app
|
||||
const server = await ExperimentalHttpApiServer.listen({ hostname: "127.0.0.1", port: 0 })
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
"x-opencode-directory": tmp.path,
|
||||
@@ -39,37 +39,39 @@ describe("experimental permission httpapi", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const list = await app.request("/experimental/httpapi/permission", {
|
||||
headers,
|
||||
})
|
||||
try {
|
||||
const list = await fetch(`${server.url}/experimental/httpapi/permission`, { headers })
|
||||
|
||||
expect(list.status).toBe(200)
|
||||
const items = await list.json()
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: { cmd: "ls" },
|
||||
always: ["ls"],
|
||||
})
|
||||
expect(list.status).toBe(200)
|
||||
const items = await list.json()
|
||||
expect(items).toHaveLength(1)
|
||||
expect(items[0]).toMatchObject({
|
||||
permission: "bash",
|
||||
patterns: ["ls"],
|
||||
metadata: { cmd: "ls" },
|
||||
always: ["ls"],
|
||||
})
|
||||
|
||||
const doc = await app.request("/experimental/httpapi/permission/doc", {
|
||||
headers,
|
||||
})
|
||||
const doc = await fetch(`${server.url}/experimental/httpapi/permission/doc`, { headers })
|
||||
|
||||
expect(doc.status).toBe(200)
|
||||
const spec = await doc.json()
|
||||
expect(spec.paths["/experimental/httpapi/permission"]?.get?.operationId).toBe("permission.list")
|
||||
expect(spec.paths["/experimental/httpapi/permission/{requestID}/reply"]?.post?.operationId).toBe("permission.reply")
|
||||
expect(doc.status).toBe(200)
|
||||
const spec = await doc.json()
|
||||
expect(spec.paths["/experimental/httpapi/permission"]?.get?.operationId).toBe("permission.list")
|
||||
expect(spec.paths["/experimental/httpapi/permission/{requestID}/reply"]?.post?.operationId).toBe(
|
||||
"permission.reply",
|
||||
)
|
||||
|
||||
const reply = await app.request(`/experimental/httpapi/permission/${items[0].id}/reply`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ reply: "once" }),
|
||||
})
|
||||
const reply = await fetch(`${server.url}/experimental/httpapi/permission/${items[0].id}/reply`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ reply: "once" }),
|
||||
})
|
||||
|
||||
expect(reply.status).toBe(200)
|
||||
expect(await reply.json()).toBe(true)
|
||||
expect(await pending).toBeUndefined()
|
||||
expect(reply.status).toBe(200)
|
||||
expect(await reply.json()).toBe(true)
|
||||
expect(await pending).toBeUndefined()
|
||||
} finally {
|
||||
await server.stop()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user