Przeglądaj źródła

fix(httpapi): expose v2 request errors (#28495)

Shoubhit Dash 3 miesięcy temu
rodzic
commit
40e73c4910

+ 5 - 4
packages/opencode/src/server/routes/instance/httpapi/groups/v2/message.ts

@@ -1,8 +1,9 @@
 import { SessionID } from "@/session/schema"
 import { SessionMessage } from "@opencode-ai/core/session-message"
 import { Schema } from "effect"
-import { HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
-import { Authorization } from "../../middleware/authorization"
+import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { InvalidCursorError } from "../../errors"
+import { V2Authorization } from "../../middleware/authorization"
 import { WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
 
 export const MessagesQuery = Schema.Struct({
@@ -35,7 +36,7 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
           next: Schema.String.pipe(Schema.optional),
         }),
       }).annotate({ identifier: "V2SessionMessagesResponse" }),
-      error: HttpApiError.BadRequest,
+      error: InvalidCursorError,
     }).annotateMerge(
       OpenApi.annotations({
         identifier: "v2.session.messages",
@@ -51,4 +52,4 @@ export const MessageGroup = HttpApiGroup.make("v2.message")
       description: "Experimental v2 message routes.",
     }),
   )
-  .middleware(Authorization)
+  .middleware(V2Authorization)

+ 2 - 2
packages/opencode/src/server/routes/instance/httpapi/groups/v2/model.ts

@@ -1,7 +1,7 @@
 import { ModelV2 } from "@opencode-ai/core/model"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
-import { Authorization } from "../../middleware/authorization"
+import { V2Authorization } from "../../middleware/authorization"
 import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
 
 export const ModelGroup = HttpApiGroup.make("v2.model")
@@ -26,4 +26,4 @@ export const ModelGroup = HttpApiGroup.make("v2.model")
     }),
   )
   .middleware(V2LocationMiddleware)
-  .middleware(Authorization)
+  .middleware(V2Authorization)

+ 2 - 2
packages/opencode/src/server/routes/instance/httpapi/groups/v2/provider.ts

@@ -2,7 +2,7 @@ import { ProviderV2 } from "@opencode-ai/core/provider"
 import { Schema } from "effect"
 import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { ApiNotFoundError } from "../../errors"
-import { Authorization } from "../../middleware/authorization"
+import { V2Authorization } from "../../middleware/authorization"
 import { LocationQuery, locationQueryOpenApi, V2LocationMiddleware } from "./location"
 
 export const ProviderGroup = HttpApiGroup.make("v2.provider")
@@ -44,4 +44,4 @@ export const ProviderGroup = HttpApiGroup.make("v2.provider")
     }),
   )
   .middleware(V2LocationMiddleware)
-  .middleware(Authorization)
+  .middleware(V2Authorization)

+ 5 - 4
packages/opencode/src/server/routes/instance/httpapi/groups/v2/session.ts

@@ -3,8 +3,9 @@ import { SessionMessage } from "@opencode-ai/core/session-message"
 import { Prompt } from "@opencode-ai/core/session-prompt"
 import { SessionV2 } from "@/v2/session"
 import { Schema } from "effect"
-import { HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
-import { Authorization } from "../../middleware/authorization"
+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
+import { InvalidCursorError, InvalidRequestError } from "../../errors"
+import { V2Authorization } from "../../middleware/authorization"
 import { WorkspaceRoutingQuery, WorkspaceRoutingQueryFields } from "../../middleware/workspace-routing"
 import { QueryBoolean } from "../query"
 
@@ -41,7 +42,7 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
           next: Schema.String.pipe(Schema.optional),
         }),
       }).annotate({ identifier: "V2SessionsResponse" }),
-      error: HttpApiError.BadRequest,
+      error: [InvalidCursorError, InvalidRequestError],
     }).annotateMerge(
       OpenApi.annotations({
         identifier: "v2.session.list",
@@ -113,4 +114,4 @@ export const SessionGroup = HttpApiGroup.make("v2.session")
       description: "Experimental v2 routes.",
     }),
   )
-  .middleware(Authorization)
+  .middleware(V2Authorization)

+ 5 - 3
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/message.ts

@@ -2,8 +2,9 @@ import { SessionMessage } from "@opencode-ai/core/session-message"
 import { SessionV2 } from "@/v2/session"
 import { Effect, Schema } from "effect"
 import * as DateTime from "effect/DateTime"
-import { HttpApiBuilder, HttpApiError } from "effect/unstable/httpapi"
+import { HttpApiBuilder } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../../api"
+import { InvalidCursorError } from "../../errors"
 
 const DefaultMessagesLimit = 50
 
@@ -34,10 +35,11 @@ export const messageHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.message
     return handlers.handle(
       "messages",
       Effect.fn(function* (ctx) {
-        if (ctx.query.cursor && ctx.query.order !== undefined) return yield* new HttpApiError.BadRequest({})
+        if (ctx.query.cursor && ctx.query.order !== undefined)
+          return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order" })
         const decoded = yield* Effect.try({
           try: () => (ctx.query.cursor ? cursor.decode(ctx.query.cursor) : undefined),
-          catch: () => new HttpApiError.BadRequest({}),
+          catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
         })
         const order = decoded?.order ?? ctx.query.order ?? "desc"
         const messages = yield* session.messages({

+ 22 - 6
packages/opencode/src/server/routes/instance/httpapi/handlers/v2/session.ts

@@ -1,8 +1,9 @@
 import { WorkspaceID } from "@/control-plane/schema"
 import { SessionV2 } from "@/v2/session"
-import { DateTime, Effect, Schema } from "effect"
-import { HttpApiBuilder, HttpApiError, HttpApiSchema } from "effect/unstable/httpapi"
+import { DateTime, Effect, Option, Schema } from "effect"
+import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
 import { InstanceHttpApi } from "../../api"
+import { InvalidCursorError, InvalidRequestError } from "../../errors"
 
 const DefaultSessionsLimit = 50
 
@@ -69,6 +70,19 @@ const sessionCursor = {
   },
 }
 
+function decodeWorkspaceID(input: string | undefined) {
+  if (input === undefined) return Effect.succeed(undefined)
+  const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(input)
+  if (Option.isSome(workspaceID)) return Effect.succeed(workspaceID.value)
+  return Effect.fail(
+    new InvalidRequestError({
+      message: "Invalid workspace query parameter",
+      kind: "Query",
+      field: "workspace",
+    }),
+  )
+}
+
 export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session", (handlers) =>
   Effect.gen(function* () {
     const session = yield* SessionV2.Service
@@ -77,17 +91,19 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "v2.session
       .handle(
         "sessions",
         Effect.fn(function* (ctx) {
-          if (ctx.query.cursor && hasCursorFilter(ctx.query)) return yield* new HttpApiError.BadRequest({})
+          if (ctx.query.cursor && hasCursorFilter(ctx.query))
+            return yield* new InvalidCursorError({ message: "Cursor cannot be combined with order or filters" })
           const decoded = yield* Effect.try({
             try: () => (ctx.query.cursor ? sessionCursor.decode(ctx.query.cursor) : undefined),
-            catch: () => new HttpApiError.BadRequest({}),
+            catch: () => new InvalidCursorError({ message: "Invalid cursor" }),
           })
-          if (hasCursorRoutingMismatch(ctx.query, decoded)) return yield* new HttpApiError.BadRequest({})
+          if (hasCursorRoutingMismatch(ctx.query, decoded))
+            return yield* new InvalidCursorError({ message: "Cursor does not match requested directory or workspace" })
           const order = decoded?.order ?? ctx.query.order ?? "desc"
           const filters = decoded ?? {
             directory: ctx.query.directory,
             path: ctx.query.path,
-            workspaceID: ctx.query.workspace ? WorkspaceID.make(ctx.query.workspace) : undefined,
+            workspaceID: yield* decodeWorkspaceID(ctx.query.workspace),
             roots: ctx.query.roots,
             start: ctx.query.start,
             search: ctx.query.search,

+ 32 - 0
packages/opencode/src/server/routes/instance/httpapi/middleware/authorization.ts

@@ -4,6 +4,7 @@ import { HttpEffect, HttpRouter, HttpServerRequest, HttpServerResponse } from "e
 import { HttpApiError, HttpApiMiddleware } from "effect/unstable/httpapi"
 import { hasPtyConnectTicketURL } from "@/server/shared/pty-ticket"
 import { isPublicUIPath } from "@/server/shared/public-ui"
+import { UnauthorizedError } from "../errors"
 
 const AUTH_TOKEN_QUERY = "auth_token"
 const UNAUTHORIZED = 401
@@ -19,6 +20,13 @@ export class Authorization extends HttpApiMiddleware.Service<Authorization>()(
   },
 ) {}
 
+export class V2Authorization extends HttpApiMiddleware.Service<V2Authorization>()(
+  "@opencode/ExperimentalHttpApiV2Authorization",
+  {
+    error: UnauthorizedError,
+  },
+) {}
+
 function emptyCredential() {
   return {
     username: "",
@@ -122,3 +130,27 @@ export const authorizationLayer = Layer.effect(
     )
   }),
 )
+
+export const v2AuthorizationLayer = Layer.effect(
+  V2Authorization,
+  Effect.gen(function* () {
+    const config = yield* ServerAuth.Config
+    if (!ServerAuth.required(config)) return V2Authorization.of((effect) => effect)
+    return V2Authorization.of((effect) =>
+      Effect.gen(function* () {
+        const request = yield* HttpServerRequest.HttpServerRequest
+        return yield* credentialFromRequest(request).pipe(
+          Effect.flatMap((credential) =>
+            Effect.gen(function* () {
+              if (ServerAuth.authorized(credential, config)) return yield* effect
+              yield* HttpEffect.appendPreResponseHandler((_request, response) =>
+                Effect.succeed(HttpServerResponse.setHeader(response, "www-authenticate", WWW_AUTHENTICATE)),
+              )
+              return yield* new UnauthorizedError({ message: "Authentication required" })
+            }),
+          ),
+        )
+      }),
+    )
+  }),
+)

+ 13 - 1
packages/opencode/src/server/routes/instance/httpapi/middleware/schema-error.ts

@@ -2,6 +2,7 @@ import { Effect } from "effect"
 import { HttpServerResponse } from "effect/unstable/http"
 import { HttpApiMiddleware } from "effect/unstable/httpapi"
 import * as Log from "@opencode-ai/core/util/log"
+import { InvalidRequestError } from "../errors"
 
 const log = Log.create({ service: "server" })
 
@@ -19,11 +20,22 @@ function truncateReason(reason: string) {
 // used by other 4xx/5xx so the SDK's `wrapClientError` extracts `.data.message`.
 export class SchemaErrorMiddleware extends HttpApiMiddleware.Service<SchemaErrorMiddleware>()(
   "@opencode/HttpApiSchemaError",
+  {
+    error: InvalidRequestError,
+  },
 ) {}
 
-export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error) => {
+export const schemaErrorLayer = HttpApiMiddleware.layerSchemaErrorTransform(SchemaErrorMiddleware, (error, context) => {
   const reason = truncateReason(error.cause.message)
   log.warn("schema rejection", { kind: error.kind, reason })
+  if (context.endpoint.path.startsWith("/api/")) {
+    return Effect.fail(
+      new InvalidRequestError({
+        message: reason,
+        kind: error.kind,
+      }),
+    )
+  }
   return Effect.succeed(
     HttpServerResponse.jsonUnsafe({ name: "BadRequest", data: { message: reason, kind: error.kind } }, { status: 400 }),
   )

+ 28 - 2
packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts

@@ -8,10 +8,11 @@ import * as Fence from "@/server/shared/fence"
 import { getWorkspaceRouteSessionID, isLocalWorkspaceRoute, workspaceProxyURL } from "@/server/shared/workspace-routing"
 import { NotFoundError } from "@/storage/storage"
 import { Flag } from "@opencode-ai/core/flag/flag"
-import { Context, Data, Effect, Layer, Schema } from "effect"
+import { Context, Data, Effect, Layer, Option, Schema } from "effect"
 import { HttpClient, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import { HttpApiMiddleware } from "effect/unstable/httpapi"
 import * as Socket from "effect/unstable/socket/Socket"
+import { InvalidRequestError } from "../errors"
 
 // Query fields this middleware reads from the URL. Spread into every
 // endpoint query schema in groups that apply WorkspaceRoutingMiddleware,
@@ -28,6 +29,7 @@ export const WorkspaceRoutingQuery = Schema.Struct(WorkspaceRoutingQueryFields)
 type RemoteTarget = Extract<Target, { type: "remote" }>
 
 type RequestPlan = Data.TaggedEnum<{
+  InvalidWorkspace: {}
   MissingWorkspace: { readonly workspaceID: WorkspaceID }
   Local: { readonly directory: string; readonly workspaceID?: WorkspaceID }
   Remote: {
@@ -38,6 +40,7 @@ type RequestPlan = Data.TaggedEnum<{
   }
 }>
 const RequestPlan = Data.taggedEnum<RequestPlan>()
+const InvalidWorkspaceID = Symbol("InvalidWorkspaceID")
 
 export class WorkspaceRouteContext extends Context.Service<
   WorkspaceRouteContext,
@@ -68,6 +71,15 @@ function selectedWorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): Worksp
   return sessionWorkspaceID ?? (workspaceParam ? WorkspaceID.make(workspaceParam) : undefined)
 }
 
+function selectedV2WorkspaceID(url: URL, sessionWorkspaceID?: WorkspaceID): WorkspaceID | typeof InvalidWorkspaceID | undefined {
+  if (sessionWorkspaceID) return sessionWorkspaceID
+  const workspaceParam = url.searchParams.get("workspace")
+  if (!workspaceParam) return undefined
+  const workspaceID = Schema.decodeUnknownOption(WorkspaceID)(workspaceParam)
+  if (Option.isNone(workspaceID)) return InvalidWorkspaceID
+  return workspaceID.value
+}
+
 function defaultDirectory(request: HttpServerRequest.HttpServerRequest, url: URL): string {
   return url.searchParams.get("directory") || request.headers["x-opencode-directory"] || process.cwd()
 }
@@ -149,7 +161,10 @@ function planRequest(
   return Effect.gen(function* () {
     const url = requestURL(request)
     const envWorkspaceID = configuredWorkspaceID()
-    const workspaceID = selectedWorkspaceID(url, sessionWorkspaceID)
+    const workspaceID = url.pathname.startsWith("/api/")
+      ? selectedV2WorkspaceID(url, sessionWorkspaceID)
+      : selectedWorkspaceID(url, sessionWorkspaceID)
+    if (workspaceID === InvalidWorkspaceID) return RequestPlan.InvalidWorkspace()
     const workspace = yield* resolveWorkspace(workspaceID, envWorkspaceID)
 
     if (workspaceID && workspace === undefined && !envWorkspaceID) {
@@ -170,6 +185,17 @@ function routeWorkspace<E>(
   plan: RequestPlan,
 ): Effect.Effect<HttpServerResponse.HttpServerResponse, E, Socket.WebSocketConstructor | Workspace.Service> {
   return RequestPlan.$match(plan, {
+    InvalidWorkspace: () =>
+      Effect.succeed(
+        HttpServerResponse.jsonUnsafe(
+          new InvalidRequestError({
+            message: "Invalid workspace query parameter",
+            kind: "Query",
+            field: "workspace",
+          }),
+          { status: 400 },
+        ),
+      ),
     MissingWorkspace: ({ workspaceID }) => Effect.succeed(missingWorkspaceResponse(workspaceID)),
     Remote: ({ request, workspace, target, url }) => proxyRemote(client, request, workspace, target, url),
     Local: ({ directory, workspaceID }) =>

+ 5 - 1
packages/opencode/src/server/routes/instance/httpapi/public.ts

@@ -341,7 +341,7 @@ function rewriteRefs(input: unknown, from: string, to: string): void {
 }
 
 function normalizeLegacyErrorResponses(operation: OpenApiOperation) {
-  if (operation.responses?.["400"] && isBuiltInErrorResponse(operation.responses["400"], "BadRequest")) {
+  if (operation.responses?.["400"] && isLegacyBadRequestResponse(operation.responses["400"])) {
     operation.responses["400"] = legacyErrorResponse("Bad request", "BadRequestError")
   }
   if (operation.responses?.["404"] && isBuiltInErrorResponse(operation.responses["404"], "NotFound")) {
@@ -396,6 +396,10 @@ function isBuiltInErrorResponse(response: OpenApiResponse, name: "BadRequest" |
   return response.description === name || isRefResponse(response, `EffectHttpApiError${name}`)
 }
 
+function isLegacyBadRequestResponse(response: OpenApiResponse) {
+  return isBuiltInErrorResponse(response, "BadRequest") || isRefResponse(response, "InvalidRequestError")
+}
+
 function legacyErrorResponse(description: string, name: "BadRequestError" | "NotFoundError"): OpenApiResponse {
   return {
     description,

+ 3 - 1
packages/opencode/src/server/routes/instance/httpapi/server.ts

@@ -59,7 +59,7 @@ import { serveUIEffect } from "@/server/shared/ui"
 import { ServerAuth } from "@/server/auth"
 import { InstanceHttpApi, RootHttpApi } from "./api"
 import { PublicApi } from "./public"
-import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization"
+import { authorizationLayer, authorizationRouterMiddleware, v2AuthorizationLayer } from "./middleware/authorization"
 import { EventApi } from "./groups/event"
 import { eventHandlers } from "./handlers/event"
 import { configHandlers } from "./handlers/config"
@@ -107,6 +107,7 @@ const cors = (corsOptions?: CorsOptions) =>
 // - uiRoute: raw catch-all fallback; auth is router middleware so public static assets can bypass it.
 const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
 const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
+const v2HttpApiAuthLayer = v2AuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))
 const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
   Layer.provide([controlHandlers, globalHandlers]),
   Layer.provide(schemaErrorLayer),
@@ -144,6 +145,7 @@ const rawInstanceRoutes = Layer.mergeAll(ptyConnectRoute).pipe(Layer.provide(ins
 const instanceRoutes = Layer.mergeAll(rawInstanceRoutes, instanceApiRoutes).pipe(
   Layer.provide([
     httpApiAuthLayer,
+    v2HttpApiAuthLayer,
     workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal)),
     instanceContextLayer,
     schemaErrorLayer,

+ 37 - 1
packages/opencode/test/server/httpapi-authorization.test.ts

@@ -4,7 +4,12 @@ import { Effect, Layer, Option, Schema } from "effect"
 import { HttpClient, HttpClientRequest, HttpRouter } from "effect/unstable/http"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup } from "effect/unstable/httpapi"
 import { ServerAuth } from "../../src/server/auth"
-import { Authorization, authorizationLayer } from "../../src/server/routes/instance/httpapi/middleware/authorization"
+import {
+  Authorization,
+  authorizationLayer,
+  V2Authorization,
+  v2AuthorizationLayer,
+} from "../../src/server/routes/instance/httpapi/middleware/authorization"
 import { testEffect } from "../lib/effect"
 
 const Api = HttpApi.make("test-authorization").add(
@@ -21,17 +26,36 @@ const Api = HttpApi.make("test-authorization").add(
     .middleware(Authorization),
 )
 
+const V2Api = HttpApi.make("test-v2-authorization").add(
+  HttpApiGroup.make("test.v2")
+    .add(
+      HttpApiEndpoint.get("probe", "/api/probe", {
+        success: Schema.String,
+      }),
+    )
+    .middleware(V2Authorization),
+)
+
 const handlers = HttpApiBuilder.group(Api, "test", (handlers) =>
   handlers
     .handle("probe", () => Effect.succeed("ok"))
     .handle("missing", () => Effect.fail(new HttpApiError.NotFound({}))),
 )
 
+const v2Handlers = HttpApiBuilder.group(V2Api, "test.v2", (handlers) =>
+  handlers.handle("probe", () => Effect.succeed("ok")),
+)
+
 const apiLayer = HttpRouter.serve(
   HttpApiBuilder.layer(Api).pipe(Layer.provide(handlers), Layer.provide(authorizationLayer)),
   { disableListenLog: true, disableLogger: true },
 ).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
 
+const v2ApiLayer = HttpRouter.serve(
+  HttpApiBuilder.layer(V2Api).pipe(Layer.provide(v2Handlers), Layer.provide(v2AuthorizationLayer)),
+  { disableListenLog: true, disableLogger: true },
+).pipe(Layer.provideMerge(NodeHttpServer.layerTest))
+
 const noAuthLayer = ServerAuth.Config.layer({ password: Option.none(), username: "opencode" })
 const secretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "opencode" })
 const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret"), username: "kit" })
@@ -39,6 +63,7 @@ const kitSecretLayer = ServerAuth.Config.layer({ password: Option.some("secret")
 const it = testEffect(apiLayer.pipe(Layer.provide(noAuthLayer)))
 const itSecret = testEffect(apiLayer.pipe(Layer.provide(secretLayer)))
 const itKitSecret = testEffect(apiLayer.pipe(Layer.provide(kitSecretLayer)))
+const itV2Secret = testEffect(v2ApiLayer.pipe(Layer.provide(secretLayer)))
 
 const basic = (username: string, password: string) => ServerAuth.header({ username, password }) ?? ""
 
@@ -135,4 +160,15 @@ describe("HttpApi authorization middleware", () => {
       expect(response.status).toBe(401)
     }),
   )
+
+  itV2Secret.live("returns bodyful v2 unauthorized errors", () =>
+    Effect.gen(function* () {
+      const response = yield* HttpClient.get("/api/probe")
+      const body = yield* response.json
+
+      expect(response.status).toBe(401)
+      expect(response.headers["www-authenticate"] ?? "").toContain("Basic")
+      expect(body).toEqual({ _tag: "UnauthorizedError", message: "Authentication required" })
+    }),
+  )
 })

+ 1 - 4
packages/opencode/test/server/httpapi-public-openapi.test.ts

@@ -17,10 +17,7 @@ type OpenApiSpec = { readonly paths: Record<string, OpenApiPathItem> }
 
 const methods = ["get", "post", "put", "delete", "patch"] as const
 
-const allowedV2BuiltInEndpointErrors = [
-  "GET /api/session 400 effect_HttpApiError_BadRequest",
-  "GET /api/session/{sessionID}/message 400 effect_HttpApiError_BadRequest",
-]
+const allowedV2BuiltInEndpointErrors: string[] = []
 
 function v2Operations(spec: OpenApiSpec) {
   return Object.entries(spec.paths).flatMap(([path, item]) =>

+ 18 - 0
packages/opencode/test/server/httpapi-schema-error-body.test.ts

@@ -105,6 +105,24 @@ describe("schema-rejection wire shape", () => {
     { git: true, config: { formatter: false, lsp: false } },
   )
 
+  it.instance(
+    "v2 query schema rejection returns InvalidRequestError JSON",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const res = yield* Effect.promise(async () =>
+          Server.Default().app.request("/api/session?limit=0", {
+            headers: { "x-opencode-directory": test.directory },
+          }),
+        )
+        const parsed = JSON.parse(yield* Effect.promise(async () => res.text()))
+        expect(res.status).toBe(400)
+        expect(parsed).toMatchObject({ _tag: "InvalidRequestError", kind: "Query" })
+        expect(parsed.message).toEqual(expect.any(String))
+      }),
+    { git: true, config: { formatter: false, lsp: false } },
+  )
+
   it.instance(
     "rejected request body never echoes back unbounded — message is capped",
     // Defense against DoS-amplification + secret-echo: Effect's Issue formatter

+ 71 - 4
packages/opencode/test/server/httpapi-session.test.ts

@@ -104,7 +104,7 @@ const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: stri
     (info) => Workspace.Service.use((svc) => svc.remove(info.id)).pipe(Effect.ignore),
   )
 
-const insertLegacyAssistantMessage = (sessionID: SessionIDType) =>
+const insertLegacyAssistantMessage = (sessionID: SessionIDType, time = 1) =>
   Effect.sync(() => {
     const message = new SessionMessage.Assistant({
       id: SessionMessage.ID.create(),
@@ -115,7 +115,7 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) =>
         providerID: ProviderV2.ID.make("provider"),
         variant: ModelV2.VariantID.make("default"),
       },
-      time: { created: DateTime.makeUnsafe(1) },
+      time: { created: DateTime.makeUnsafe(time) },
       content: [],
     })
     Database.use((db) =>
@@ -126,9 +126,9 @@ const insertLegacyAssistantMessage = (sessionID: SessionIDType) =>
             id: message.id,
             session_id: sessionID,
             type: message.type,
-            time_created: 1,
+            time_created: time,
             data: {
-              time: { created: 1 },
+              time: { created: time },
               agent: message.agent,
               model: message.model,
               content: message.content,
@@ -333,6 +333,73 @@ describe("session HttpApi", () => {
     { git: true, config: { formatter: false, lsp: false } },
   )
 
+  it.instance(
+    "returns v2 public request errors for cursor and workspace query failures",
+    () =>
+      Effect.gen(function* () {
+        const test = yield* TestInstance
+        const headers = { "x-opencode-directory": test.directory }
+        const session = yield* createSession({ title: "v2 cursor" })
+        yield* insertLegacyAssistantMessage(session.id, 1)
+        yield* insertLegacyAssistantMessage(session.id, 2)
+
+        const sessionPage = yield* request(`/api/session?limit=1`, { headers })
+        const sessionCursor = (yield* json<{ cursor: { next?: string } }>(sessionPage)).cursor.next
+        expect(sessionCursor).toBeTruthy()
+
+        const cursorWithFilter = yield* request(`/api/session?cursor=${sessionCursor}&search=v2`, { headers })
+        expect(cursorWithFilter.status).toBe(400)
+        expect(yield* responseJson(cursorWithFilter)).toMatchObject({
+          _tag: "InvalidCursorError",
+          message: "Cursor cannot be combined with order or filters",
+        })
+
+        const invalidSessionCursor = yield* request(`/api/session?cursor=invalid`, { headers })
+        expect(invalidSessionCursor.status).toBe(400)
+        expect(yield* responseJson(invalidSessionCursor)).toMatchObject({
+          _tag: "InvalidCursorError",
+          message: "Invalid cursor",
+        })
+
+        const mismatchedRouting = yield* request(`/api/session?cursor=${sessionCursor}&directory=/elsewhere`, { headers })
+        expect(mismatchedRouting.status).toBe(400)
+        expect(yield* responseJson(mismatchedRouting)).toMatchObject({
+          _tag: "InvalidCursorError",
+          message: "Cursor does not match requested directory or workspace",
+        })
+
+        const invalidWorkspace = yield* request(`/api/session?workspace=bad`, { headers })
+        expect(invalidWorkspace.status).toBe(400)
+        expect(yield* responseJson(invalidWorkspace)).toMatchObject({
+          _tag: "InvalidRequestError",
+          message: "Invalid workspace query parameter",
+          field: "workspace",
+        })
+
+        const messagePage = yield* request(`/api/session/${session.id}/message?limit=1`, { headers })
+        const messageCursor = (yield* json<{ cursor: { next?: string } }>(messagePage)).cursor.next
+        expect(messageCursor).toBeTruthy()
+
+        const messageCursorWithOrder = yield* request(
+          `/api/session/${session.id}/message?cursor=${messageCursor}&order=asc`,
+          { headers },
+        )
+        expect(messageCursorWithOrder.status).toBe(400)
+        expect(yield* responseJson(messageCursorWithOrder)).toMatchObject({
+          _tag: "InvalidCursorError",
+          message: "Cursor cannot be combined with order",
+        })
+
+        const invalidMessageCursor = yield* request(`/api/session/${session.id}/message?cursor=invalid`, { headers })
+        expect(invalidMessageCursor.status).toBe(400)
+        expect(yield* responseJson(invalidMessageCursor)).toMatchObject({
+          _tag: "InvalidCursorError",
+          message: "Invalid cursor",
+        })
+      }),
+    { git: true, config: { formatter: false, lsp: false } },
+  )
+
   it.instance(
     "serves sessions with migrated summary diffs missing file details",
     () =>