diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index cce3f4081f..1794927cce 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -121,14 +121,13 @@ Why `question` first: Do not re-architect business logic during the HTTP migration. `HttpApi` handlers should call the same Effect services already used by the Hono handlers. -### 4. Run in parallel before replacing +### 4. Build in parallel, do not bridge into Hono -Prefer mounting an experimental `HttpApi` surface alongside the existing Hono routes first. That lowers migration risk and lets us compare: +The `HttpApi` implementation lives under `src/server/instance/httpapi/` as a standalone Effect HTTP server. It is **not mounted into the Hono app**. There is no `toWebHandler` bridge, no Hono `Handler` export, and no `.route()` call wiring it into `experimental.ts`. -- handler ergonomics -- OpenAPI output -- auth and middleware integration -- test ergonomics +The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`. + +The goal is to build enough route coverage in the Effect server that the Hono server can eventually be replaced entirely. Until then, the two implementations exist side by side but are completely separate processes. ### 5. Migrate JSON route groups gradually @@ -218,17 +217,15 @@ Placement rule: Suggested file layout for a repeatable spike: -- `src/server/instance/httpapi/question.ts` -- `src/server/instance/httpapi/index.ts` -- `test/server/question-httpapi.test.ts` -- `test/server/question-httpapi-openapi.test.ts` +- `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group +- `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups +- `test/server/question-httpapi.test.ts` — end-to-end test against the real service Suggested responsibilities: -- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice -- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer -- `question-httpapi.test.ts` proves the route works end-to-end against the real service -- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints +- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers +- `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup) +- tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server ## Example migration shape @@ -248,11 +245,12 @@ Each route-group spike should follow the same shape. - keep handler bodies thin - keep transport mapping at the HTTP boundary only -### 3. Mounting +### 3. Standalone server -- mount under an experimental prefix such as `/experimental/httpapi` -- keep existing Hono routes unchanged -- expose separate OpenAPI output for the experimental slice first +- the Effect HTTP server is self-contained in `httpapi/server.ts` +- it is **not** mounted into the Hono app — no bridge, no `toWebHandler` +- route paths use the `/experimental/httpapi` prefix so they match the eventual cutover +- each route group exposes its own OpenAPI doc endpoint ### 4. Verification @@ -263,53 +261,32 @@ Each route-group spike should follow the same shape. ## Boundary composition -The first slices should keep the existing outer server composition and only replace the route contract and handler layer. +The standalone Effect server owns its own middleware stack. It does not share middleware with the Hono server. ### Auth -- keep `AuthMiddleware` at the outer Hono app level -- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices -- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler - -Practical rule: - -- if a route is currently protected by the shared server middleware stack, the experimental `HttpApi` route should stay mounted behind that same stack +- the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic` +- each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served +- this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer ### Instance and workspace lookup -- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context -- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler -- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them - -Practical rule: - -- `HttpApi` handlers should yield services from context and assume the correct instance has already been provided -- only move instance lookup into the `HttpApi` layer if we later decide to migrate the outer middleware boundary itself +- the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params +- this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware` +- `HttpApi` handlers yield services from context and assume the correct instance has already been provided ### Error mapping - keep domain and service errors typed in the service layer - declare typed transport errors on the endpoint only when the route can actually return them intentionally -- prefer explicit endpoint-level error schemas over relying on the outer Hono `ErrorMiddleware` for expected route behavior - -Practical rule: - -- request decoding failures should remain transport-level `400`s +- request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically - storage or lookup failures that are part of the route contract should be declared as typed endpoint errors -- unexpected defects can still fall through to the outer error middleware while the slice is experimental - -For the current parallel slices, this means: - -- auth still composes outside `HttpApi` -- instance selection still composes outside `HttpApi` -- success payloads should be schema-defined from canonical Effect schemas -- known route errors should be modeled at the endpoint boundary incrementally instead of all at once ## Exit criteria for the spike The first slice is successful if: -- the endpoints run in parallel with the current Hono routes +- the standalone Effect server starts and serves the endpoints independently of the Hono server - the handlers reuse the existing Effect service - request decoding and response shapes are schema-defined from canonical Effect schemas - any remaining Zod boundary usage is derived from `.zod` or clearly temporary @@ -324,8 +301,8 @@ The first parallel `question` spike gave us a concrete pattern to reuse. - scalar or collection schemas such as `Question.Answer` should stay as schemas and use helpers like `withStatics(...)` instead of being forced into classes. - if an `HttpApi` success schema uses `Schema.Class`, the handler or underlying service needs to return real schema instances rather than plain objects. - internal event payloads can stay anonymous when we want to avoid adding extra named OpenAPI component churn for non-route shapes. -- the experimental slice should stay mounted in parallel and keep calling the existing service layer unchanged. -- compare generated OpenAPI semantically at the route and schema level; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix. +- the experimental slice should stay as a standalone Effect server and keep calling the existing service layer unchanged. +- compare generated OpenAPI semantically at the route and schema level. ## Route inventory diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ce49218b71..8857696b05 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -35,7 +35,7 @@ export namespace Agent { topP: z.number().optional(), temperature: z.number().optional(), color: z.string().optional(), - permission: Permission.Ruleset, + permission: Permission.Ruleset.zod, model: z .object({ modelID: ModelID.zod, diff --git a/packages/opencode/src/control-plane/schema.ts b/packages/opencode/src/control-plane/schema.ts index 7262a380b0..4c7ced010d 100644 --- a/packages/opencode/src/control-plane/schema.ts +++ b/packages/opencode/src/control-plane/schema.ts @@ -1,10 +1,13 @@ import { Schema } from "effect" import z from "zod" -import { withStatics } from "@/util/schema" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" +import { withStatics } from "@/util/schema" -const workspaceIdSchema = Schema.String.pipe(Schema.brand("WorkspaceID")) +const workspaceIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("workspace") }).pipe( + Schema.brand("WorkspaceID"), +) export type WorkspaceID = typeof workspaceIdSchema.Type diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index dc22d32b4b..b6a44e2582 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -7,75 +7,84 @@ import { Instance } from "@/project/instance" import { MessageID, SessionID } from "@/session/schema" import { PermissionTable } from "@/session/session.sql" import { Database, eq } from "@/storage/db" +import { zod } from "@/util/effect-zod" import { Log } from "@/util/log" +import { withStatics } from "@/util/schema" import { Wildcard } from "@/util/wildcard" import { Deferred, Effect, Layer, Schema, Context } from "effect" import os from "os" -import z from "zod" import { evaluate as evalRule } from "./evaluate" import { PermissionID } from "./schema" export namespace Permission { const log = Log.create({ service: "permission" }) - export const Action = z.enum(["allow", "deny", "ask"]).meta({ - ref: "PermissionAction", - }) - export type Action = z.infer + export const Action = Schema.Literals(["allow", "deny", "ask"]) + .annotate({ identifier: "PermissionAction" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Action = Schema.Schema.Type - export const Rule = z - .object({ - permission: z.string(), - pattern: z.string(), - action: Action, - }) - .meta({ - ref: "PermissionRule", - }) - export type Rule = z.infer + export class Rule extends Schema.Class("PermissionRule")({ + permission: Schema.String, + pattern: Schema.String, + action: Action, + }) { + static readonly zod = zod(this) + } - export const Ruleset = Rule.array().meta({ - ref: "PermissionRuleset", - }) - export type Ruleset = z.infer + export const Ruleset = Schema.mutable(Schema.Array(Rule)) + .annotate({ identifier: "PermissionRuleset" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Ruleset = Schema.Schema.Type - export const Request = z - .object({ - id: PermissionID.zod, - sessionID: SessionID.zod, - permission: z.string(), - patterns: z.string().array(), - metadata: z.record(z.string(), z.any()), - always: z.string().array(), - tool: z - .object({ - messageID: MessageID.zod, - callID: z.string(), - }) - .optional(), - }) - .meta({ - ref: "PermissionRequest", - }) - export type Request = z.infer + export class Request extends Schema.Class("PermissionRequest")({ + id: PermissionID, + sessionID: SessionID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional( + Schema.Struct({ + messageID: MessageID, + callID: Schema.String, + }), + ), + }) { + static readonly zod = zod(this) + } - export const Reply = z.enum(["once", "always", "reject"]) - export type Reply = z.infer + export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) }))) + export type Reply = Schema.Schema.Type - export const Approval = z.object({ - projectID: ProjectID.zod, - patterns: z.string().array(), - }) + const reply = { + reply: Reply, + message: Schema.optional(Schema.String), + } + + export const ReplyBody = Schema.Struct(reply) + .annotate({ identifier: "PermissionReplyBody" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type ReplyBody = Schema.Schema.Type + + export class Approval extends Schema.Class("PermissionApproval")({ + projectID: ProjectID, + patterns: Schema.Array(Schema.String), + }) { + static readonly zod = zod(this) + } export const Event = { - Asked: BusEvent.define("permission.asked", Request), + Asked: BusEvent.define("permission.asked", Request.zod), Replied: BusEvent.define( "permission.replied", - z.object({ - sessionID: SessionID.zod, - requestID: PermissionID.zod, - reply: Reply, - }), + zod( + Schema.Struct({ + sessionID: SessionID, + requestID: PermissionID, + reply: Reply, + }), + ), ), } @@ -103,20 +112,27 @@ export namespace Permission { export type Error = DeniedError | RejectedError | CorrectedError - export const AskInput = Request.partial({ id: true }).extend({ + export const AskInput = Schema.Struct({ + ...Request.fields, + id: Schema.optional(PermissionID), ruleset: Ruleset, }) + .annotate({ identifier: "PermissionAskInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type AskInput = Schema.Schema.Type - export const ReplyInput = z.object({ - requestID: PermissionID.zod, - reply: Reply, - message: z.string().optional(), + export const ReplyInput = Schema.Struct({ + requestID: PermissionID, + ...reply, }) + .annotate({ identifier: "PermissionReplyInput" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) + export type ReplyInput = Schema.Schema.Type export interface Interface { - readonly ask: (input: z.infer) => Effect.Effect - readonly reply: (input: z.infer) => Effect.Effect - readonly list: () => Effect.Effect + readonly ask: (input: AskInput) => Effect.Effect + readonly reply: (input: ReplyInput) => Effect.Effect + readonly list: () => Effect.Effect> } interface PendingEntry { @@ -163,7 +179,7 @@ export namespace Permission { }), ) - const ask = Effect.fn("Permission.ask")(function* (input: z.infer) { + const ask = Effect.fn("Permission.ask")(function* (input: AskInput) { const { approved, pending } = yield* InstanceState.get(state) const { ruleset, ...request } = input let needsAsk = false @@ -183,10 +199,10 @@ export namespace Permission { if (!needsAsk) return const id = request.id ?? PermissionID.ascending() - const info: Request = { + const info = Schema.decodeUnknownSync(Request)({ id, ...request, - } + }) log.info("asking", { id, permission: info.permission, patterns: info.patterns }) const deferred = yield* Deferred.make() @@ -200,7 +216,7 @@ export namespace Permission { ) }) - const reply = Effect.fn("Permission.reply")(function* (input: z.infer) { + const reply = Effect.fn("Permission.reply")(function* (input: ReplyInput) { const { approved, pending } = yield* InstanceState.get(state) const existing = pending.get(input.requestID) if (!existing) return diff --git a/packages/opencode/src/permission/schema.ts b/packages/opencode/src/permission/schema.ts index 2f1190a238..6ac9389a58 100644 --- a/packages/opencode/src/permission/schema.ts +++ b/packages/opencode/src/permission/schema.ts @@ -2,9 +2,13 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" -export class PermissionID extends Newtype()("PermissionID", Schema.String) { +export class PermissionID extends Newtype()( + "PermissionID", + Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }), +) { static ascending(id?: string): PermissionID { return this.make(Identifier.ascending("permission", id)) } diff --git a/packages/opencode/src/pty/schema.ts b/packages/opencode/src/pty/schema.ts index deb498891a..0758fe8206 100644 --- a/packages/opencode/src/pty/schema.ts +++ b/packages/opencode/src/pty/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -const ptyIdSchema = Schema.String.pipe(Schema.brand("PtyID")) +const ptyIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("pty") }).pipe(Schema.brand("PtyID")) export type PtyID = typeof ptyIdSchema.Type diff --git a/packages/opencode/src/question/schema.ts b/packages/opencode/src/question/schema.ts index e5a0496c96..41186161d0 100644 --- a/packages/opencode/src/question/schema.ts +++ b/packages/opencode/src/question/schema.ts @@ -2,9 +2,13 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { Newtype } from "@/util/schema" -export class QuestionID extends Newtype()("QuestionID", Schema.String) { +export class QuestionID extends Newtype()( + "QuestionID", + Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }), +) { static ascending(id?: string): QuestionID { return this.make(Identifier.ascending("question", id)) } diff --git a/packages/opencode/src/server/instance/experimental.ts b/packages/opencode/src/server/instance/experimental.ts index 6309a21bb9..e8e46b2e3b 100644 --- a/packages/opencode/src/server/instance/experimental.ts +++ b/packages/opencode/src/server/instance/experimental.ts @@ -18,7 +18,6 @@ import { lazy } from "../../util/lazy" import { Effect, Option } from "effect" import { WorkspaceRoutes } from "./workspace" import { Agent } from "@/agent/agent" -import { HttpApiRoutes } from "./httpapi" const ConsoleOrgOption = z.object({ accountID: z.string(), @@ -40,7 +39,6 @@ const ConsoleSwitchBody = z.object({ export const ExperimentalRoutes = lazy(() => new Hono() - .route("/httpapi", HttpApiRoutes()) .get( "/console", describeRoute({ diff --git a/packages/opencode/src/server/instance/httpapi/index.ts b/packages/opencode/src/server/instance/httpapi/index.ts deleted file mode 100644 index 523041de84..0000000000 --- a/packages/opencode/src/server/instance/httpapi/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { lazy } from "@/util/lazy" -import { Hono } from "hono" -import { QuestionHttpApiHandler } from "./question" - -export const HttpApiRoutes = lazy(() => - new Hono().all("/question", QuestionHttpApiHandler).all("/question/*", QuestionHttpApiHandler), -) diff --git a/packages/opencode/src/server/instance/httpapi/permission.ts b/packages/opencode/src/server/instance/httpapi/permission.ts new file mode 100644 index 0000000000..e3d152c5a4 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/permission.ts @@ -0,0 +1,72 @@ +import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" + +const root = "/experimental/httpapi/permission" + +export const PermissionApi = HttpApi.make("permission") + .add( + HttpApiGroup.make("permission") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Permission.Request), + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.list", + summary: "List pending permissions", + description: "Get all pending permission requests across all sessions.", + }), + ), + HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { + params: { requestID: PermissionID }, + payload: Permission.ReplyBody, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.reply", + summary: "Respond to permission request", + description: "Approve or deny a permission request from the AI assistant.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "permission", + description: "Experimental HttpApi permission routes.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) + +export const PermissionLive = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Permission.Service + + const list = Effect.fn("PermissionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("PermissionHttpApi.reply")(function* (ctx: { + params: { requestID: PermissionID } + payload: Permission.ReplyBody + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + reply: ctx.payload.reply, + message: ctx.payload.message, + }) + return true + }) + + return HttpApiBuilder.group(PermissionApi, "permission", (handlers) => + handlers.handle("list", list).handle("reply", reply), + ) + }), +).pipe(Layer.provide(Permission.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/question.ts b/packages/opencode/src/server/instance/httpapi/question.ts index ef0f41734b..686c6abb17 100644 --- a/packages/opencode/src/server/instance/httpapi/question.ts +++ b/packages/opencode/src/server/instance/httpapi/question.ts @@ -1,44 +1,71 @@ -import { AppLayer } from "@/effect/app-runtime" -import { memoMap } from "@/effect/run-service" import { Question } from "@/question" import { QuestionID } from "@/question/schema" -import { lazy } from "@/util/lazy" -import { makeQuestionHandler, questionApi } from "@opencode-ai/server" -import { Effect, Layer } from "effect" -import { HttpRouter, HttpServer } from "effect/unstable/http" -import { HttpApiBuilder } from "effect/unstable/httpapi" -import type { Handler } from "hono" +import { Effect, Layer, Schema } from "effect" +import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" const root = "/experimental/httpapi/question" -const QuestionLive = makeQuestionHandler({ - list: Effect.fn("QuestionHttpApi.host.list")(function* () { - const svc = yield* Question.Service - return yield* svc.list() - }), - reply: Effect.fn("QuestionHttpApi.host.reply")(function* (input) { - const svc = yield* Question.Service - yield* svc.reply({ - requestID: QuestionID.make(input.requestID), - answers: input.answers, - }) - }), -}).pipe(Layer.provide(Question.defaultLayer)) - -const web = lazy(() => - HttpRouter.toWebHandler( - Layer.mergeAll( - AppLayer, - HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe( - Layer.provide(QuestionLive), - Layer.provide(HttpServer.layerServices), +export const QuestionApi = HttpApi.make("question") + .add( + HttpApiGroup.make("question") + .add( + HttpApiEndpoint.get("list", root, { + success: Schema.Array(Question.Request), + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.list", + summary: "List pending questions", + description: "Get all pending question requests across all sessions.", + }), + ), + HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, { + params: { requestID: QuestionID }, + payload: Question.Reply, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "question.reply", + summary: "Reply to question request", + description: "Provide answers to a question request from the AI assistant.", + }), + ), + ) + .annotateMerge( + OpenApi.annotations({ + title: "question", + description: "Experimental HttpApi question routes.", + }), ), - ), - { - disableLogger: true, - memoMap, - }, - ), -) + ) + .annotateMerge( + OpenApi.annotations({ + title: "opencode experimental HttpApi", + version: "0.0.1", + description: "Experimental HttpApi surface for selected instance routes.", + }), + ) -export const QuestionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw) +export const QuestionLive = Layer.unwrap( + Effect.gen(function* () { + const svc = yield* Question.Service + + const list = Effect.fn("QuestionHttpApi.list")(function* () { + return yield* svc.list() + }) + + const reply = Effect.fn("QuestionHttpApi.reply")(function* (ctx: { + params: { requestID: QuestionID } + payload: Question.Reply + }) { + yield* svc.reply({ + requestID: ctx.params.requestID, + answers: ctx.payload.answers, + }) + return true + }) + + return HttpApiBuilder.group(QuestionApi, "question", (handlers) => + handlers.handle("list", list).handle("reply", reply), + ) + }), +).pipe(Layer.provide(Question.defaultLayer)) diff --git a/packages/opencode/src/server/instance/httpapi/server.ts b/packages/opencode/src/server/instance/httpapi/server.ts new file mode 100644 index 0000000000..363e93a240 --- /dev/null +++ b/packages/opencode/src/server/instance/httpapi/server.ts @@ -0,0 +1,135 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { Effect, Layer, Redacted, Schema } from "effect" +import { HttpApiBuilder, HttpApiMiddleware, HttpApiSecurity } from "effect/unstable/httpapi" +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 { 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" + +const Query = Schema.Struct({ + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String), + auth_token: Schema.optional(Schema.String), +}) + +const Headers = Schema.Struct({ + authorization: Schema.optional(Schema.String), + "x-opencode-directory": Schema.optional(Schema.String), +}) + +export namespace ExperimentalHttpApiServer { + function text(input: string, status: number, headers?: Record) { + return HttpServerResponse.text(input, { status, headers }) + } + + function decode(input: string) { + try { + return decodeURIComponent(input) + } catch { + return input + } + } + + class Unauthorized extends Schema.TaggedErrorClass()( + "Unauthorized", + { message: Schema.String }, + { httpApiStatus: 401 }, + ) {} + + class Authorization extends HttpApiMiddleware.Service()("@opencode/ExperimentalHttpApiAuthorization", { + error: Unauthorized, + security: { + basic: HttpApiSecurity.basic, + }, + }) {} + + const normalize = HttpRouter.middleware()( + Effect.gen(function* () { + return (effect) => + Effect.gen(function* () { + const query = yield* HttpServerRequest.schemaSearchParams(Query) + if (!query.auth_token) return yield* effect + const req = yield* HttpServerRequest.HttpServerRequest + const next = req.modify({ + headers: { + ...req.headers, + authorization: `Basic ${query.auth_token}`, + }, + }) + return yield* effect.pipe(Effect.provideService(HttpServerRequest.HttpServerRequest, next)) + }) + }), + ).layer + + const auth = Layer.succeed( + Authorization, + Authorization.of({ + basic: (effect, { credential }) => + Effect.gen(function* () { + if (!Flag.OPENCODE_SERVER_PASSWORD) return yield* effect + + const user = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" + if (credential.username !== user) { + return yield* new Unauthorized({ message: "Unauthorized" }) + } + if (Redacted.value(credential.password) !== Flag.OPENCODE_SERVER_PASSWORD) { + return yield* new Unauthorized({ message: "Unauthorized" }) + } + return yield* effect + }), + }), + ) + + const instance = HttpRouter.middleware()( + Effect.gen(function* () { + return (effect) => + Effect.gen(function* () { + const query = yield* HttpServerRequest.schemaSearchParams(Query) + const headers = yield* HttpServerRequest.schemaHeaders(Headers) + const raw = query.directory || headers["x-opencode-directory"] || process.cwd() + const workspace = query.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)) + }) + }), + ).layer + + 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 })), + ) + + 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/src/server/instance/permission.ts b/packages/opencode/src/server/instance/permission.ts index 3f93709354..b8c2244140 100644 --- a/packages/opencode/src/server/instance/permission.ts +++ b/packages/opencode/src/server/instance/permission.ts @@ -33,7 +33,7 @@ export const PermissionRoutes = lazy(() => requestID: PermissionID.zod, }), ), - validator("json", z.object({ reply: Permission.Reply, message: z.string().optional() })), + validator("json", z.object({ reply: Permission.Reply.zod, message: z.string().optional() })), async (c) => { const params = c.req.valid("param") const json = c.req.valid("json") @@ -60,7 +60,7 @@ export const PermissionRoutes = lazy(() => description: "List of pending permissions", content: { "application/json": { - schema: resolver(Permission.Request.array()), + schema: resolver(Permission.Request.zod.array()), }, }, }, diff --git a/packages/opencode/src/server/instance/session.ts b/packages/opencode/src/server/instance/session.ts index a011c32f9b..4f02e35fac 100644 --- a/packages/opencode/src/server/instance/session.ts +++ b/packages/opencode/src/server/instance/session.ts @@ -274,7 +274,7 @@ export const SessionRoutes = lazy(() => "json", z.object({ title: z.string().optional(), - permission: Permission.Ruleset.optional(), + permission: Permission.Ruleset.zod.optional(), time: z .object({ archived: z.number().optional(), @@ -1093,7 +1093,7 @@ export const SessionRoutes = lazy(() => permissionID: PermissionID.zod, }), ), - validator("json", z.object({ response: Permission.Reply })), + validator("json", z.object({ response: Permission.Reply.zod })), async (c) => { const params = c.req.valid("param") await AppRuntime.runPromise( diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index d8ab812349..49d8359497 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -144,7 +144,7 @@ export namespace Session { compacting: z.number().optional(), archived: z.number().optional(), }), - permission: Permission.Ruleset.optional(), + permission: Permission.Ruleset.zod.optional(), revert: z .object({ messageID: MessageID.zod, @@ -193,7 +193,7 @@ export namespace Session { export const RemoveInput = SessionID.zod export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() }) export const SetArchivedInput = z.object({ sessionID: SessionID.zod, time: z.number().optional() }) - export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }) + export const SetPermissionInput = z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset.zod }) export const SetRevertInput = z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, diff --git a/packages/opencode/src/session/schema.ts b/packages/opencode/src/session/schema.ts index 856ab31142..efed280c98 100644 --- a/packages/opencode/src/session/schema.ts +++ b/packages/opencode/src/session/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -export const SessionID = Schema.String.pipe( +export const SessionID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("session") }).pipe( Schema.brand("SessionID"), withStatics((s) => ({ descending: (id?: string) => s.make(Identifier.descending("session", id)), @@ -14,7 +15,7 @@ export const SessionID = Schema.String.pipe( export type SessionID = Schema.Schema.Type -export const MessageID = Schema.String.pipe( +export const MessageID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("message") }).pipe( Schema.brand("MessageID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("message", id)), @@ -24,7 +25,7 @@ export const MessageID = Schema.String.pipe( export type MessageID = Schema.Schema.Type -export const PartID = Schema.String.pipe( +export const PartID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("part") }).pipe( Schema.brand("PartID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("part", id)), diff --git a/packages/opencode/src/sync/schema.ts b/packages/opencode/src/sync/schema.ts index 5cec8b1f7a..37cdbd718f 100644 --- a/packages/opencode/src/sync/schema.ts +++ b/packages/opencode/src/sync/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -export const EventID = Schema.String.pipe( +export const EventID = Schema.String.annotate({ [ZodOverride]: Identifier.schema("event") }).pipe( Schema.brand("EventID"), withStatics((s) => ({ ascending: (id?: string) => s.make(Identifier.ascending("event", id)), diff --git a/packages/opencode/src/tool/schema.ts b/packages/opencode/src/tool/schema.ts index 823bb0aede..ac41fd1606 100644 --- a/packages/opencode/src/tool/schema.ts +++ b/packages/opencode/src/tool/schema.ts @@ -2,9 +2,10 @@ import { Schema } from "effect" import z from "zod" import { Identifier } from "@/id/id" +import { ZodOverride } from "@/util/effect-zod" import { withStatics } from "@/util/schema" -const toolIdSchema = Schema.String.pipe(Schema.brand("ToolID")) +const toolIdSchema = Schema.String.annotate({ [ZodOverride]: Identifier.schema("tool") }).pipe(Schema.brand("ToolID")) export type ToolID = typeof toolIdSchema.Type diff --git a/packages/opencode/src/util/effect-zod.ts b/packages/opencode/src/util/effect-zod.ts index 97cbbd2fc9..553d7a0650 100644 --- a/packages/opencode/src/util/effect-zod.ts +++ b/packages/opencode/src/util/effect-zod.ts @@ -1,11 +1,21 @@ import { Schema, SchemaAST } from "effect" import z from "zod" +/** + * Annotation key for providing a hand-crafted Zod schema that the walker + * should use instead of re-deriving from the AST. Attach it via + * `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`. + */ +export const ZodOverride: unique symbol = Symbol.for("effect-zod/override") + export function zod(schema: S): z.ZodType> { return walk(schema.ast) as z.ZodType> } function walk(ast: SchemaAST.AST): z.ZodTypeAny { + const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined + if (override) return override + const out = body(ast) const desc = SchemaAST.resolveDescription(ast) const ref = SchemaAST.resolveIdentifier(ast) @@ -57,6 +67,12 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny { } function union(ast: SchemaAST.Union): z.ZodTypeAny { + // When every member is a string literal, emit z.enum() so that + // JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }. + if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) { + return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]]) + } + const items = ast.types.map(walk) if (items.length === 1) return items[0] if (items.length < 2) return fail(ast) diff --git a/packages/opencode/test/server/question-httpapi.test.ts b/packages/opencode/test/server/question-httpapi.test.ts deleted file mode 100644 index 00cc32f59e..0000000000 --- a/packages/opencode/test/server/question-httpapi.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { afterEach, describe, expect, test } from "bun:test" -import { AppRuntime } from "../../src/effect/app-runtime" -import { Instance } from "../../src/project/instance" -import { Question } from "../../src/question" -import { Server } from "../../src/server/server" -import { SessionID } from "../../src/session/schema" -import { Log } from "../../src/util/log" -import { tmpdir } from "../fixture/fixture" - -Log.init({ print: false }) - -const ask = (input: { sessionID: SessionID; questions: ReadonlyArray }) => - AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input))) - -afterEach(async () => { - await Instance.disposeAll() -}) - -describe("experimental question httpapi", () => { - test("lists pending questions, replies, and serves docs", async () => { - await using tmp = await tmpdir({ git: true }) - const app = Server.Default().app - const headers = { - "content-type": "application/json", - "x-opencode-directory": tmp.path, - } - const questions: ReadonlyArray = [ - { - question: "What would you like to do?", - header: "Action", - options: [ - { label: "Option 1", description: "First option" }, - { label: "Option 2", description: "Second option" }, - ], - }, - ] - - let pending!: ReturnType - - await Instance.provide({ - directory: tmp.path, - fn: async () => { - pending = ask({ - sessionID: SessionID.make("ses_test"), - questions, - }) - }, - }) - - const list = await app.request("/experimental/httpapi/question", { - headers, - }) - - expect(list.status).toBe(200) - const items = await list.json() - expect(items).toHaveLength(1) - expect(items[0]).toMatchObject({ questions }) - - const doc = await app.request("/experimental/httpapi/question/doc", { - headers, - }) - - expect(doc.status).toBe(200) - const spec = await doc.json() - expect(spec.paths["/experimental/httpapi/question"]?.get?.operationId).toBe("question.list") - expect(spec.paths["/experimental/httpapi/question/{requestID}/reply"]?.post?.operationId).toBe("question.reply") - - const reply = await app.request(`/experimental/httpapi/question/${items[0].id}/reply`, { - method: "POST", - headers, - body: JSON.stringify({ answers: [["Option 1"]] }), - }) - - expect(reply.status).toBe(200) - expect(await reply.json()).toBe(true) - expect(await pending).toEqual([["Option 1"]]) - }) -}) diff --git a/packages/opencode/test/util/effect-zod.test.ts b/packages/opencode/test/util/effect-zod.test.ts index 4004ca2d23..7f7249514d 100644 --- a/packages/opencode/test/util/effect-zod.test.ts +++ b/packages/opencode/test/util/effect-zod.test.ts @@ -1,7 +1,13 @@ import { describe, expect, test } from "bun:test" import { Schema } from "effect" +import z from "zod" -import { zod } from "../../src/util/effect-zod" +import { zod, ZodOverride } from "../../src/util/effect-zod" + +function json(schema: z.ZodTypeAny) { + const { $schema: _, ...rest } = z.toJSONSchema(schema) + return rest +} describe("util.effect-zod", () => { test("converts class schemas for route dto shapes", () => { @@ -58,4 +64,126 @@ describe("util.effect-zod", () => { test("throws for unsupported tuple schemas", () => { expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") }) + + test("string literal unions produce z.enum with enum in JSON Schema", () => { + const Action = Schema.Literals(["allow", "deny", "ask"]) + const out = zod(Action) + + expect(out.parse("allow")).toBe("allow") + expect(out.parse("deny")).toBe("deny") + expect(() => out.parse("nope")).toThrow() + + // Matches native z.enum JSON Schema output + const bridged = json(out) + const native = json(z.enum(["allow", "deny", "ask"])) + expect(bridged).toEqual(native) + expect(bridged.enum).toEqual(["allow", "deny", "ask"]) + }) + + test("ZodOverride annotation provides the Zod schema for branded IDs", () => { + const override = z.string().startsWith("per") + const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID")) + + const Parent = Schema.Struct({ id: ID, name: Schema.String }) + const out = zod(Parent) + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" }) + + const schema = json(out) as any + expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) + }) + + test("Schema.Class nested in a parent preserves ref via identifier", () => { + class Inner extends Schema.Class("MyInner")({ + value: Schema.String, + }) {} + + class Outer extends Schema.Class("MyOuter")({ + inner: Inner, + }) {} + + const out = zod(Outer) + expect(out.meta()?.ref).toBe("MyOuter") + + const shape = (out as any).shape ?? (out as any)._def?.shape?.() + expect(shape.inner.meta()?.ref).toBe("MyInner") + }) + + test("Schema.Class preserves identifier and uses enum format", () => { + class Rule extends Schema.Class("PermissionRule")({ + permission: Schema.String, + pattern: Schema.String, + action: Schema.Literals(["allow", "deny", "ask"]), + }) {} + + const out = zod(Rule) + expect(out.meta()?.ref).toBe("PermissionRule") + + const schema = json(out) as any + expect(schema.properties.action).toEqual({ + type: "string", + enum: ["allow", "deny", "ask"], + }) + }) + + test("ZodOverride on ID carries pattern through Schema.Class", () => { + const ID = Schema.String.annotate({ + [ZodOverride]: z.string().startsWith("per"), + }) + + class Request extends Schema.Class("TestRequest")({ + id: ID, + name: Schema.String, + }) {} + + const schema = json(zod(Request)) as any + expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" }) + expect(schema.properties.name).toEqual({ type: "string" }) + }) + + test("Permission schemas match original Zod equivalents", () => { + const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") }) + const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") }) + const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") }) + + class Tool extends Schema.Class("PermissionTool")({ + messageID: MsgID, + callID: Schema.String, + }) {} + + class Request extends Schema.Class("PermissionRequest")({ + id: PerID, + sessionID: SesID, + permission: Schema.String, + patterns: Schema.Array(Schema.String), + metadata: Schema.Record(Schema.String, Schema.Unknown), + always: Schema.Array(Schema.String), + tool: Schema.optional(Tool), + }) {} + + const bridged = json(zod(Request)) as any + expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" }) + expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) + expect(bridged.properties.permission).toEqual({ type: "string" }) + expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort()) + + // Tool field is present with the ref from Schema.Class identifier + const toolSchema = json(zod(Tool)) as any + expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" }) + expect(toolSchema.properties.callID).toEqual({ type: "string" }) + }) + + test("ZodOverride survives Schema.brand", () => { + const override = z.string().startsWith("ses") + const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID")) + + // The branded schema's AST still has the override + class Parent extends Schema.Class("Parent")({ + sessionID: ID, + }) {} + + const schema = json(zod(Parent)) as any + expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" }) + }) })