add experimental permission HttpApi slice (#22385)

This commit is contained in:
Kit Langton
2026-04-15 21:28:01 +00:00
committed by GitHub
parent e83b22159d
commit 250e30bc7d
21 changed files with 553 additions and 254 deletions
+27 -50
View File
@@ -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. 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 The standalone server (`httpapi/server.ts`) can be started independently and proves the routes work. Tests exercise it via `HttpRouter.serve` with `NodeHttpServer.layerTest`.
- OpenAPI output
- auth and middleware integration 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.
- test ergonomics
### 5. Migrate JSON route groups gradually ### 5. Migrate JSON route groups gradually
@@ -218,17 +217,15 @@ Placement rule:
Suggested file layout for a repeatable spike: Suggested file layout for a repeatable spike:
- `src/server/instance/httpapi/question.ts` - `src/server/instance/httpapi/question.ts` — contract and handler layer for one route group
- `src/server/instance/httpapi/index.ts` - `src/server/instance/httpapi/server.ts` — standalone Effect HTTP server that composes all groups
- `test/server/question-httpapi.test.ts` - `test/server/question-httpapi.test.ts` — end-to-end test against the real service
- `test/server/question-httpapi-openapi.test.ts`
Suggested responsibilities: Suggested responsibilities:
- `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers for the experimental slice - `question.ts` defines the `HttpApi` contract and `HttpApiBuilder.group(...)` handlers
- `index.ts` combines experimental `HttpApi` groups and exposes the mounted handler or layer - `server.ts` composes all route groups into one `HttpRouter.serve` layer with shared middleware (auth, instance lookup)
- `question-httpapi.test.ts` proves the route works end-to-end against the real service - tests use `ExperimentalHttpApiServer.layerTest` to run against a real in-process HTTP server
- `question-httpapi-openapi.test.ts` proves the generated OpenAPI is acceptable for the migrated endpoints
## Example migration shape ## Example migration shape
@@ -248,11 +245,12 @@ Each route-group spike should follow the same shape.
- keep handler bodies thin - keep handler bodies thin
- keep transport mapping at the HTTP boundary only - keep transport mapping at the HTTP boundary only
### 3. Mounting ### 3. Standalone server
- mount under an experimental prefix such as `/experimental/httpapi` - the Effect HTTP server is self-contained in `httpapi/server.ts`
- keep existing Hono routes unchanged - it is **not** mounted into the Hono app — no bridge, no `toWebHandler`
- expose separate OpenAPI output for the experimental slice first - route paths use the `/experimental/httpapi` prefix so they match the eventual cutover
- each route group exposes its own OpenAPI doc endpoint
### 4. Verification ### 4. Verification
@@ -263,53 +261,32 @@ Each route-group spike should follow the same shape.
## Boundary composition ## 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 ### Auth
- keep `AuthMiddleware` at the outer Hono app level - the standalone server implements auth as an `HttpApiMiddleware.Service` using `HttpApiSecurity.basic`
- do not duplicate auth checks inside each `HttpApi` group for the first parallel slices - each route group's `HttpApi` is wrapped with `.middleware(Authorization)` before being served
- treat auth as an already-satisfied transport concern before the request reaches the `HttpApi` handler - this is independent of the Hono `AuthMiddleware` — when the Effect server eventually replaces Hono, this becomes the only auth layer
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
### Instance and workspace lookup ### Instance and workspace lookup
- keep `WorkspaceRouterMiddleware` as the source of truth for resolving `directory`, `workspace`, and session-derived workspace context - the standalone server resolves instance context via an `HttpRouter.middleware` that reads `x-opencode-directory` headers and `directory` query params
- let that middleware provide `Instance.current` and `WorkspaceContext` before the request reaches the `HttpApi` handler - this is the Effect equivalent of the Hono `WorkspaceRouterMiddleware`
- keep the `HttpApi` handlers unaware of path-to-instance lookup details when the existing Hono middleware already handles them - `HttpApi` handlers yield services from context and assume the correct instance has already been provided
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
### Error mapping ### Error mapping
- keep domain and service errors typed in the service layer - 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 - 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 - request decoding failures are transport-level `400`s handled by Effect `HttpApi` automatically
Practical rule:
- request decoding failures should remain transport-level `400`s
- storage or lookup failures that are part of the route contract should be declared as typed endpoint errors - 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 ## Exit criteria for the spike
The first slice is successful if: 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 - the handlers reuse the existing Effect service
- request decoding and response shapes are schema-defined from canonical Effect schemas - request decoding and response shapes are schema-defined from canonical Effect schemas
- any remaining Zod boundary usage is derived from `.zod` or clearly temporary - 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. - 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. - 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. - 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. - 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; in the current setup the exported OpenAPI paths do not include the outer Hono mount prefix. - compare generated OpenAPI semantically at the route and schema level.
## Route inventory ## Route inventory
+1 -1
View File
@@ -35,7 +35,7 @@ export namespace Agent {
topP: z.number().optional(), topP: z.number().optional(),
temperature: z.number().optional(), temperature: z.number().optional(),
color: z.string().optional(), color: z.string().optional(),
permission: Permission.Ruleset, permission: Permission.Ruleset.zod,
model: z model: z
.object({ .object({
modelID: ModelID.zod, modelID: ModelID.zod,
@@ -1,10 +1,13 @@
import { Schema } from "effect" import { Schema } from "effect"
import z from "zod" import z from "zod"
import { withStatics } from "@/util/schema"
import { Identifier } from "@/id/id" 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 export type WorkspaceID = typeof workspaceIdSchema.Type
+78 -62
View File
@@ -7,75 +7,84 @@ import { Instance } from "@/project/instance"
import { MessageID, SessionID } from "@/session/schema" import { MessageID, SessionID } from "@/session/schema"
import { PermissionTable } from "@/session/session.sql" import { PermissionTable } from "@/session/session.sql"
import { Database, eq } from "@/storage/db" import { Database, eq } from "@/storage/db"
import { zod } from "@/util/effect-zod"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { withStatics } from "@/util/schema"
import { Wildcard } from "@/util/wildcard" import { Wildcard } from "@/util/wildcard"
import { Deferred, Effect, Layer, Schema, Context } from "effect" import { Deferred, Effect, Layer, Schema, Context } from "effect"
import os from "os" import os from "os"
import z from "zod"
import { evaluate as evalRule } from "./evaluate" import { evaluate as evalRule } from "./evaluate"
import { PermissionID } from "./schema" import { PermissionID } from "./schema"
export namespace Permission { export namespace Permission {
const log = Log.create({ service: "permission" }) const log = Log.create({ service: "permission" })
export const Action = z.enum(["allow", "deny", "ask"]).meta({ export const Action = Schema.Literals(["allow", "deny", "ask"])
ref: "PermissionAction", .annotate({ identifier: "PermissionAction" })
}) .pipe(withStatics((s) => ({ zod: zod(s) })))
export type Action = z.infer<typeof Action> export type Action = Schema.Schema.Type<typeof Action>
export const Rule = z export class Rule extends Schema.Class<Rule>("PermissionRule")({
.object({ permission: Schema.String,
permission: z.string(), pattern: Schema.String,
pattern: z.string(), action: Action,
action: Action, }) {
}) static readonly zod = zod(this)
.meta({ }
ref: "PermissionRule",
})
export type Rule = z.infer<typeof Rule>
export const Ruleset = Rule.array().meta({ export const Ruleset = Schema.mutable(Schema.Array(Rule))
ref: "PermissionRuleset", .annotate({ identifier: "PermissionRuleset" })
}) .pipe(withStatics((s) => ({ zod: zod(s) })))
export type Ruleset = z.infer<typeof Ruleset> export type Ruleset = Schema.Schema.Type<typeof Ruleset>
export const Request = z export class Request extends Schema.Class<Request>("PermissionRequest")({
.object({ id: PermissionID,
id: PermissionID.zod, sessionID: SessionID,
sessionID: SessionID.zod, permission: Schema.String,
permission: z.string(), patterns: Schema.Array(Schema.String),
patterns: z.string().array(), metadata: Schema.Record(Schema.String, Schema.Unknown),
metadata: z.record(z.string(), z.any()), always: Schema.Array(Schema.String),
always: z.string().array(), tool: Schema.optional(
tool: z Schema.Struct({
.object({ messageID: MessageID,
messageID: MessageID.zod, callID: Schema.String,
callID: z.string(), }),
}) ),
.optional(), }) {
}) static readonly zod = zod(this)
.meta({ }
ref: "PermissionRequest",
})
export type Request = z.infer<typeof Request>
export const Reply = z.enum(["once", "always", "reject"]) export const Reply = Schema.Literals(["once", "always", "reject"]).pipe(withStatics((s) => ({ zod: zod(s) })))
export type Reply = z.infer<typeof Reply> export type Reply = Schema.Schema.Type<typeof Reply>
export const Approval = z.object({ const reply = {
projectID: ProjectID.zod, reply: Reply,
patterns: z.string().array(), 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 = { export const Event = {
Asked: BusEvent.define("permission.asked", Request), Asked: BusEvent.define("permission.asked", Request.zod),
Replied: BusEvent.define( Replied: BusEvent.define(
"permission.replied", "permission.replied",
z.object({ zod(
sessionID: SessionID.zod, Schema.Struct({
requestID: PermissionID.zod, sessionID: SessionID,
reply: Reply, requestID: PermissionID,
}), reply: Reply,
}),
),
), ),
} }
@@ -103,20 +112,27 @@ export namespace Permission {
export type Error = DeniedError | RejectedError | CorrectedError 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, ruleset: Ruleset,
}) })
.annotate({ identifier: "PermissionAskInput" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type AskInput = Schema.Schema.Type<typeof AskInput>
export const ReplyInput = z.object({ export const ReplyInput = Schema.Struct({
requestID: PermissionID.zod, requestID: PermissionID,
reply: Reply, ...reply,
message: z.string().optional(),
}) })
.annotate({ identifier: "PermissionReplyInput" })
.pipe(withStatics((s) => ({ zod: zod(s) })))
export type ReplyInput = Schema.Schema.Type<typeof ReplyInput>
export interface Interface { export interface Interface {
readonly ask: (input: z.infer<typeof AskInput>) => Effect.Effect<void, Error> readonly ask: (input: AskInput) => Effect.Effect<void, Error>
readonly reply: (input: z.infer<typeof ReplyInput>) => Effect.Effect<void> readonly reply: (input: ReplyInput) => Effect.Effect<void>
readonly list: () => Effect.Effect<Request[]> readonly list: () => Effect.Effect<ReadonlyArray<Request>>
} }
interface PendingEntry { 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 { approved, pending } = yield* InstanceState.get(state)
const { ruleset, ...request } = input const { ruleset, ...request } = input
let needsAsk = false let needsAsk = false
@@ -183,10 +199,10 @@ export namespace Permission {
if (!needsAsk) return if (!needsAsk) return
const id = request.id ?? PermissionID.ascending() const id = request.id ?? PermissionID.ascending()
const info: Request = { const info = Schema.decodeUnknownSync(Request)({
id, id,
...request, ...request,
} })
log.info("asking", { id, permission: info.permission, patterns: info.patterns }) log.info("asking", { id, permission: info.permission, patterns: info.patterns })
const deferred = yield* Deferred.make<void, RejectedError | CorrectedError>() 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 { approved, pending } = yield* InstanceState.get(state)
const existing = pending.get(input.requestID) const existing = pending.get(input.requestID)
if (!existing) return if (!existing) return
+5 -1
View File
@@ -2,9 +2,13 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema" 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 { static ascending(id?: string): PermissionID {
return this.make(Identifier.ascending("permission", id)) return this.make(Identifier.ascending("permission", id))
} }
+2 -1
View File
@@ -2,9 +2,10 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" 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 export type PtyID = typeof ptyIdSchema.Type
+5 -1
View File
@@ -2,9 +2,13 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { ZodOverride } from "@/util/effect-zod"
import { Newtype } from "@/util/schema" 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 { static ascending(id?: string): QuestionID {
return this.make(Identifier.ascending("question", id)) return this.make(Identifier.ascending("question", id))
} }
@@ -18,7 +18,6 @@ import { lazy } from "../../util/lazy"
import { Effect, Option } from "effect" import { Effect, Option } from "effect"
import { WorkspaceRoutes } from "./workspace" import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent" import { Agent } from "@/agent/agent"
import { HttpApiRoutes } from "./httpapi"
const ConsoleOrgOption = z.object({ const ConsoleOrgOption = z.object({
accountID: z.string(), accountID: z.string(),
@@ -40,7 +39,6 @@ const ConsoleSwitchBody = z.object({
export const ExperimentalRoutes = lazy(() => export const ExperimentalRoutes = lazy(() =>
new Hono() new Hono()
.route("/httpapi", HttpApiRoutes())
.get( .get(
"/console", "/console",
describeRoute({ describeRoute({
@@ -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),
)
@@ -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))
@@ -1,44 +1,71 @@
import { AppLayer } from "@/effect/app-runtime"
import { memoMap } from "@/effect/run-service"
import { Question } from "@/question" import { Question } from "@/question"
import { QuestionID } from "@/question/schema" import { QuestionID } from "@/question/schema"
import { lazy } from "@/util/lazy" import { Effect, Layer, Schema } from "effect"
import { makeQuestionHandler, questionApi } from "@opencode-ai/server" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { Effect, Layer } from "effect"
import { HttpRouter, HttpServer } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import type { Handler } from "hono"
const root = "/experimental/httpapi/question" const root = "/experimental/httpapi/question"
const QuestionLive = makeQuestionHandler({ export const QuestionApi = HttpApi.make("question")
list: Effect.fn("QuestionHttpApi.host.list")(function* () { .add(
const svc = yield* Question.Service HttpApiGroup.make("question")
return yield* svc.list() .add(
}), HttpApiEndpoint.get("list", root, {
reply: Effect.fn("QuestionHttpApi.host.reply")(function* (input) { success: Schema.Array(Question.Request),
const svc = yield* Question.Service }).annotateMerge(
yield* svc.reply({ OpenApi.annotations({
requestID: QuestionID.make(input.requestID), identifier: "question.list",
answers: input.answers, summary: "List pending questions",
}) description: "Get all pending question requests across all sessions.",
}), }),
}).pipe(Layer.provide(Question.defaultLayer)) ),
HttpApiEndpoint.post("reply", `${root}/:requestID/reply`, {
const web = lazy(() => params: { requestID: QuestionID },
HttpRouter.toWebHandler( payload: Question.Reply,
Layer.mergeAll( success: Schema.Boolean,
AppLayer, }).annotateMerge(
HttpApiBuilder.layer(questionApi, { openapiPath: `${root}/doc` }).pipe( OpenApi.annotations({
Layer.provide(QuestionLive), identifier: "question.reply",
Layer.provide(HttpServer.layerServices), 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(
disableLogger: true, OpenApi.annotations({
memoMap, 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))
@@ -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),
)
}
@@ -33,7 +33,7 @@ export const PermissionRoutes = lazy(() =>
requestID: PermissionID.zod, 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) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
const json = c.req.valid("json") const json = c.req.valid("json")
@@ -60,7 +60,7 @@ export const PermissionRoutes = lazy(() =>
description: "List of pending permissions", description: "List of pending permissions",
content: { content: {
"application/json": { "application/json": {
schema: resolver(Permission.Request.array()), schema: resolver(Permission.Request.zod.array()),
}, },
}, },
}, },
@@ -274,7 +274,7 @@ export const SessionRoutes = lazy(() =>
"json", "json",
z.object({ z.object({
title: z.string().optional(), title: z.string().optional(),
permission: Permission.Ruleset.optional(), permission: Permission.Ruleset.zod.optional(),
time: z time: z
.object({ .object({
archived: z.number().optional(), archived: z.number().optional(),
@@ -1093,7 +1093,7 @@ export const SessionRoutes = lazy(() =>
permissionID: PermissionID.zod, permissionID: PermissionID.zod,
}), }),
), ),
validator("json", z.object({ response: Permission.Reply })), validator("json", z.object({ response: Permission.Reply.zod })),
async (c) => { async (c) => {
const params = c.req.valid("param") const params = c.req.valid("param")
await AppRuntime.runPromise( await AppRuntime.runPromise(
+2 -2
View File
@@ -144,7 +144,7 @@ export namespace Session {
compacting: z.number().optional(), compacting: z.number().optional(),
archived: z.number().optional(), archived: z.number().optional(),
}), }),
permission: Permission.Ruleset.optional(), permission: Permission.Ruleset.zod.optional(),
revert: z revert: z
.object({ .object({
messageID: MessageID.zod, messageID: MessageID.zod,
@@ -193,7 +193,7 @@ export namespace Session {
export const RemoveInput = SessionID.zod export const RemoveInput = SessionID.zod
export const SetTitleInput = z.object({ sessionID: SessionID.zod, title: z.string() }) 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 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({ export const SetRevertInput = z.object({
sessionID: SessionID.zod, sessionID: SessionID.zod,
revert: Info.shape.revert, revert: Info.shape.revert,
+4 -3
View File
@@ -2,9 +2,10 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" 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"), Schema.brand("SessionID"),
withStatics((s) => ({ withStatics((s) => ({
descending: (id?: string) => s.make(Identifier.descending("session", id)), 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 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"), Schema.brand("MessageID"),
withStatics((s) => ({ withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("message", id)), 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 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"), Schema.brand("PartID"),
withStatics((s) => ({ withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("part", id)), ascending: (id?: string) => s.make(Identifier.ascending("part", id)),
+2 -1
View File
@@ -2,9 +2,10 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" 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"), Schema.brand("EventID"),
withStatics((s) => ({ withStatics((s) => ({
ascending: (id?: string) => s.make(Identifier.ascending("event", id)), ascending: (id?: string) => s.make(Identifier.ascending("event", id)),
+2 -1
View File
@@ -2,9 +2,10 @@ import { Schema } from "effect"
import z from "zod" import z from "zod"
import { Identifier } from "@/id/id" import { Identifier } from "@/id/id"
import { ZodOverride } from "@/util/effect-zod"
import { withStatics } from "@/util/schema" 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 export type ToolID = typeof toolIdSchema.Type
+16
View File
@@ -1,11 +1,21 @@
import { Schema, SchemaAST } from "effect" import { Schema, SchemaAST } from "effect"
import z from "zod" 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>> { 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>> return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
} }
function walk(ast: SchemaAST.AST): z.ZodTypeAny { 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 out = body(ast)
const desc = SchemaAST.resolveDescription(ast) const desc = SchemaAST.resolveDescription(ast)
const ref = SchemaAST.resolveIdentifier(ast) const ref = SchemaAST.resolveIdentifier(ast)
@@ -57,6 +67,12 @@ function opt(ast: SchemaAST.AST): z.ZodTypeAny {
} }
function union(ast: SchemaAST.Union): 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) const items = ast.types.map(walk)
if (items.length === 1) return items[0] if (items.length === 1) return items[0]
if (items.length < 2) return fail(ast) if (items.length < 2) return fail(ast)
@@ -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
View File
@@ -1,7 +1,13 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import { Schema } from "effect" 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", () => { describe("util.effect-zod", () => {
test("converts class schemas for route dto shapes", () => { test("converts class schemas for route dto shapes", () => {
@@ -58,4 +64,126 @@ describe("util.effect-zod", () => {
test("throws for unsupported tuple schemas", () => { test("throws for unsupported tuple schemas", () => {
expect(() => zod(Schema.Tuple([Schema.String, Schema.Number]))).toThrow("unsupported effect schema") 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.*" })
})
}) })