From 9d8f8ddcee818e863eea669cdf0b2e8a9ee938c9 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 14 Apr 2026 22:04:24 -0400 Subject: [PATCH] refactor(permission): make experimental server layer-first Expose the experimental permission and question HttpApi server as Effect layers and test it through NodeHttpServer.layerTest and HttpClient instead of an imperative listen API. --- .../src/server/instance/httpapi/server.ts | 69 +++++--------- .../test/server/permission-httpapi.test.ts | 94 +++++++++++-------- 2 files changed, 78 insertions(+), 85 deletions(-) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts index 207cb145ad..363e93a240 100644 --- a/packages/opencode/src/server/instance/httpapi/server.ts +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -1,15 +1,16 @@ import { NodeHttpServer } from "@effect/platform-node" -import { Context, Effect, Exit, Layer, Redacted, Scope, Schema } from "effect" +import { Effect, Layer, Redacted, Schema } from "effect" import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" -import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { HttpRouter, 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 { Permission } from "@/permission" +import { Question } from "@/question" import { PermissionApi, PermissionLive } from "./permission" import { QuestionApi, QuestionLive } from "./question" @@ -25,13 +26,6 @@ const Headers = Schema.Struct({ }) export namespace ExperimentalHttpApiServer { - export type Listener = { - hostname: string - port: number - url: URL - stop: () => Promise - } - function text(input: string, status: number, headers?: Record) { return HttpServerResponse.text(input, { status, headers }) } @@ -116,41 +110,26 @@ export namespace ExperimentalHttpApiServer { }), ).layer - export async function listen(opts: { hostname: string; port: number }): Promise { - const scope = await Effect.runPromise(Scope.make()) - const serverLayer = NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname }) - const QuestionSecured = QuestionApi.middleware(Authorization) - const PermissionSecured = PermissionApi.middleware(Authorization) - const routes = Layer.mergeAll( - HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe( - Layer.provide(QuestionLive), - ), - HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe( - Layer.provide(PermissionLive), - ), - ).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance)) - const live = Layer.mergeAll( - serverLayer, - HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe(Layer.provide(serverLayer)), + const QuestionSecured = QuestionApi.middleware(Authorization) + const PermissionSecured = PermissionApi.middleware(Authorization) + + export const routes = Layer.mergeAll( + HttpApiBuilder.layer(QuestionSecured, { openapiPath: "/experimental/httpapi/question/doc" }).pipe( + Layer.provide(QuestionLive), + ), + HttpApiBuilder.layer(PermissionSecured, { openapiPath: "/experimental/httpapi/permission/doc" }).pipe( + Layer.provide(PermissionLive), + ), + ).pipe(Layer.provide(auth), Layer.provide(normalize), Layer.provide(instance)) + + export const layer = (opts: { hostname: string; port: number }) => + HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.provideMerge(NodeHttpServer.layer(createServer, { port: opts.port, host: opts.hostname })), ) - 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)), - } - } + export const layerTest = HttpRouter.serve(routes, { disableListenLog: true, disableLogger: true }).pipe( + Layer.provideMerge(NodeHttpServer.layerTest), + Layer.provideMerge(Question.defaultLayer), + Layer.provideMerge(Permission.defaultLayer), + ) } diff --git a/packages/opencode/test/server/permission-httpapi.test.ts b/packages/opencode/test/server/permission-httpapi.test.ts index 6c3c53be15..cb0e2ee925 100644 --- a/packages/opencode/test/server/permission-httpapi.test.ts +++ b/packages/opencode/test/server/permission-httpapi.test.ts @@ -1,34 +1,44 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { describe, expect } from "bun:test" import { AppRuntime } from "../../src/effect/app-runtime" -import { Instance } from "../../src/project/instance" +import { InstanceRef } from "../../src/effect/instance-ref" import { Permission } from "../../src/permission" +import { Instance } from "../../src/project/instance" +import { InstanceBootstrap } from "../../src/project/bootstrap" 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" +import { testEffect } from "../lib/effect" +import { Effect, Fiber } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" Log.init({ print: false }) -const ask = (input: Permission.AskInput) => AppRuntime.runPromise(Permission.Service.use((svc) => svc.ask(input))) - -afterEach(async () => { - await Instance.disposeAll() -}) +const it = testEffect(ExperimentalHttpApiServer.layerTest) describe("experimental permission httpapi", () => { - test("lists pending permissions, replies, and serves docs", async () => { - await using tmp = await tmpdir({ git: true }) - const server = await ExperimentalHttpApiServer.listen({ hostname: "127.0.0.1", port: 0 }) - const headers = { - "content-type": "application/json", - "x-opencode-directory": tmp.path, - } - let pending!: ReturnType + it.live("lists pending permissions, replies, and serves docs", () => + Effect.gen(function* () { + const tmp = yield* Effect.promise(() => tmpdir({ git: true })) + yield* Effect.addFinalizer(() => Effect.promise(() => tmp[Symbol.asyncDispose]())) + yield* Effect.addFinalizer(() => Effect.promise(() => Instance.disposeAll())) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - pending = ask({ + const headers = { + "content-type": "application/json", + "x-opencode-directory": tmp.path, + } + + const ctx = yield* Effect.promise(() => + Instance.provide({ + directory: tmp.path, + init: () => AppRuntime.runPromise(InstanceBootstrap), + fn: () => Instance.current, + }), + ) + + const svc = yield* Permission.Service + const pending = yield* svc + .ask({ sessionID: SessionID.make("ses_test"), permission: "bash", patterns: ["ls"], @@ -36,14 +46,19 @@ describe("experimental permission httpapi", () => { always: ["ls"], ruleset: [], }) - }, - }) + .pipe(Effect.provideService(InstanceRef, ctx), Effect.forkScoped) - try { - const list = await fetch(`${server.url}/experimental/httpapi/permission`, { headers }) + let items: Array = [] + for (let i = 0; i < 10; i++) { + const list = yield* HttpClient.execute( + HttpClientRequest.get("/experimental/httpapi/permission").pipe(HttpClientRequest.setHeaders(headers)), + ) + expect(list.status).toBe(200) + items = (yield* list.json) as Array + if (items.length > 0) break + yield* Effect.sleep("50 millis") + } - expect(list.status).toBe(200) - const items = await list.json() expect(items).toHaveLength(1) expect(items[0]).toMatchObject({ permission: "bash", @@ -52,26 +67,25 @@ describe("experimental permission httpapi", () => { always: ["ls"], }) - const doc = await fetch(`${server.url}/experimental/httpapi/permission/doc`, { headers }) - + const doc = yield* HttpClient.execute( + HttpClientRequest.get("/experimental/httpapi/permission/doc").pipe(HttpClientRequest.setHeaders(headers)), + ) expect(doc.status).toBe(200) - const spec = await doc.json() + const spec = (yield* doc.json) as any 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 fetch(`${server.url}/experimental/httpapi/permission/${items[0].id}/reply`, { - method: "POST", - headers, - body: JSON.stringify({ reply: "once" }), - }) - + const reply = yield* HttpClient.execute( + yield* HttpClientRequest.post(`/experimental/httpapi/permission/${items[0].id}/reply`).pipe( + HttpClientRequest.setHeaders(headers), + HttpClientRequest.bodyJson({ reply: "once" }), + ), + ) expect(reply.status).toBe(200) - expect(await reply.json()).toBe(true) - expect(await pending).toBeUndefined() - } finally { - await server.stop() - } - }) + expect(yield* reply.json).toBe(true) + yield* Fiber.join(pending) + }), + ) })