Преглед изворни кода

add experimental permission HttpApi slice (#22385)

Kit Langton пре 4 дана
родитељ
комит
250e30bc7d

+ 27 - 50
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
 

+ 1 - 1
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,

+ 5 - 2
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
 

+ 83 - 67
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<typeof Action>
-
-  export const Rule = z
-    .object({
-      permission: z.string(),
-      pattern: z.string(),
-      action: Action,
-    })
-    .meta({
-      ref: "PermissionRule",
-    })
-  export type Rule = z.infer<typeof Rule>
-
-  export const Ruleset = Rule.array().meta({
-    ref: "PermissionRuleset",
-  })
-  export type Ruleset = z.infer<typeof Ruleset>
-
-  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<typeof Request>
-
-  export const Reply = z.enum(["once", "always", "reject"])
-  export type Reply = z.infer<typeof Reply>
-
-  export const Approval = z.object({
-    projectID: ProjectID.zod,
-    patterns: z.string().array(),
-  })
+  export const Action = Schema.Literals(["allow", "deny", "ask"])
+    .annotate({ identifier: "PermissionAction" })
+    .pipe(withStatics((s) => ({ zod: zod(s) })))
+  export type Action = Schema.Schema.Type<typeof Action>
+
+  export class Rule extends Schema.Class<Rule>("PermissionRule")({
+    permission: Schema.String,
+    pattern: Schema.String,
+    action: Action,
+  }) {
+    static readonly zod = zod(this)
+  }
+
+  export const Ruleset = Schema.mutable(Schema.Array(Rule))
+    .annotate({ identifier: "PermissionRuleset" })
+    .pipe(withStatics((s) => ({ zod: zod(s) })))
+  export type Ruleset = Schema.Schema.Type<typeof Ruleset>
+
+  export class Request extends Schema.Class<Request>("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 = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
+  export type Reply = Schema.Schema.Type<typeof Reply>
+
+  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<typeof ReplyBody>
+
+  export class Approval extends Schema.Class<Approval>("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<typeof AskInput>
 
-  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<typeof ReplyInput>
 
   export interface Interface {
-    readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error>
-    readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void>
-    readonly list: () => Effect.Effect<Request[]>
+    readonly ask: (input: AskInput) => Effect.Effect<void, Error>
+    readonly reply: (input: ReplyInput) => Effect.Effect<void>
+    readonly list: () => Effect.Effect<ReadonlyArray<Request>>
   }
 
   interface PendingEntry {
@@ -163,7 +179,7 @@ export namespace Permission {
         }),
       )
 
-      const ask = Effect.fn("Permission.ask")(function* (input: z.infer<typeof AskInput>) {
+      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<void, RejectedError | CorrectedError>()
@@ -200,7 +216,7 @@ export namespace Permission {
         )
       })
 
-      const reply = Effect.fn("Permission.reply")(function* (input: z.infer<typeof ReplyInput>) {
+      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

+ 5 - 1
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>()("PermissionID", Schema.String) {
+export class PermissionID extends Newtype<PermissionID>()(
+  "PermissionID",
+  Schema.String.annotate({ [ZodOverride]: Identifier.schema("permission") }),
+) {
   static ascending(id?: string): PermissionID {
     return this.make(Identifier.ascending("permission", id))
   }

+ 2 - 1
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
 

+ 5 - 1
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>()("QuestionID", Schema.String) {
+export class QuestionID extends Newtype<QuestionID>()(
+  "QuestionID",
+  Schema.String.annotate({ [ZodOverride]: Identifier.schema("question") }),
+) {
   static ascending(id?: string): QuestionID {
     return this.make(Identifier.ascending("question", id))
   }

+ 0 - 2
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({

+ 0 - 7
packages/opencode/src/server/instance/httpapi/index.ts

@@ -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),
-)

+ 72 - 0
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))

+ 62 - 35
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) {
+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.",
+        }),
+      ),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode experimental HttpApi",
+      version: "0.0.1",
+      description: "Experimental HttpApi surface for selected instance routes.",
+    }),
+  )
+
+export const QuestionLive = Layer.unwrap(
+  Effect.gen(function* () {
     const svc = yield* Question.Service
-    yield* svc.reply({
-      requestID: QuestionID.make(input.requestID),
-      answers: input.answers,
+
+    const list = Effect.fn("QuestionHttpApi.list")(function* () {
+      return yield* svc.list()
     })
-  }),
-}).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),
-      ),
-    ),
-    {
-      disableLogger: true,
-      memoMap,
-    },
-  ),
-)
+    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
+    })
 
-export const QuestionHttpApiHandler: Handler = (c, _next) => web().handler(c.req.raw)
+    return HttpApiBuilder.group(QuestionApi, "question", (handlers) =>
+      handlers.handle("list", list).handle("reply", reply),
+    )
+  }),
+).pipe(Layer.provide(Question.defaultLayer))

+ 135 - 0
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<string, string>) {
+    return HttpServerResponse.text(input, { status, headers })
+  }
+
+  function decode(input: string) {
+    try {
+      return decodeURIComponent(input)
+    } catch {
+      return input
+    }
+  }
+
+  class Unauthorized extends Schema.TaggedErrorClass<Unauthorized>()(
+    "Unauthorized",
+    { message: Schema.String },
+    { httpApiStatus: 401 },
+  ) {}
+
+  class Authorization extends HttpApiMiddleware.Service<Authorization>()("@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),
+  )
+}

+ 2 - 2
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()),
               },
             },
           },

+ 2 - 2
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(

+ 2 - 2
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,

+ 4 - 3
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<typeof SessionID>
 
-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<typeof MessageID>
 
-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)),

+ 2 - 1
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)),

+ 2 - 1
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
 

+ 16 - 0
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<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
   return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
 }
 
 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)

+ 0 - 78
packages/opencode/test/server/question-httpapi.test.ts

@@ -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<Question.Info> }) =>
-  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.Info> = [
-      {
-        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<typeof ask>
-
-    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"]])
-  })
-})

+ 129 - 1
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<Inner>("MyInner")({
+      value: Schema.String,
+    }) {}
+
+    class Outer extends Schema.Class<Outer>("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<Rule>("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<Request>("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<Tool>("PermissionTool")({
+      messageID: MsgID,
+      callID: Schema.String,
+    }) {}
+
+    class Request extends Schema.Class<Request>("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>("Parent")({
+      sessionID: ID,
+    }) {}
+
+    const schema = json(zod(Parent)) as any
+    expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
+  })
 })