Переглянути джерело

fix(httpapi): align runtime query schemas with workspace routing params (#26581)

Co-authored-by: Developer <temp@example.com>
Kit Langton 3 місяців тому
батько
коміт
43b51f09d0

+ 3 - 2
packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts

@@ -8,7 +8,7 @@ import { Schema, SchemaGetter } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
 import { described } from "./metadata"
 
 const ConsoleStateResponse = Schema.Struct({
@@ -43,6 +43,7 @@ const ToolListItem = Schema.Struct({
 }).annotate({ identifier: "ToolListItem" })
 const ToolList = Schema.Array(ToolListItem).annotate({ identifier: "ToolList" })
 export const ToolListQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
   provider: ProviderID,
   model: ModelID,
 })
@@ -55,7 +56,7 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
 )
 const WorktreeList = Schema.Array(Schema.String)
 export const SessionListQuery = Schema.Struct({
-  directory: Schema.optional(Schema.String),
+  ...WorkspaceRoutingQueryFields,
   roots: Schema.optional(QueryBoolean),
   start: Schema.optional(Schema.NumberFromString),
   cursor: Schema.optional(Schema.NumberFromString),

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

@@ -5,18 +5,21 @@ import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
 import { described } from "./metadata"
 
 export const FileQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
   path: Schema.String,
 })
 
 export const FindTextQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
   pattern: Schema.String,
 })
 
 export const FindFileQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
   query: Schema.String,
   dirs: Schema.optional(Schema.Literals(["true", "false"])),
   type: Schema.optional(Schema.Literals(["file", "directory"])),
@@ -26,6 +29,7 @@ export const FindFileQuery = Schema.Struct({
 })
 
 export const FindSymbolQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
   query: Schema.String,
 })
 

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

@@ -8,7 +8,7 @@ import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
 import { described } from "./metadata"
 
 const PathInfo = Schema.Struct({
@@ -20,6 +20,7 @@ const PathInfo = Schema.Struct({
 }).annotate({ identifier: "Path" })
 
 export const VcsDiffQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
   mode: Vcs.Mode,
 })
 

+ 5 - 2
packages/opencode/src/server/routes/instance/httpapi/groups/pty.ts

@@ -5,13 +5,16 @@ import { Schema } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
 import { ApiNotFoundError } from "../errors"
 import { described } from "./metadata"
 
 const root = "/pty"
 export const Params = Schema.Struct({ ptyID: PtyID })
-export const CursorQuery = Schema.Struct({ cursor: Schema.optional(Schema.String) })
+export const CursorQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
+  cursor: Schema.optional(Schema.String),
+})
 export const ShellItem = Schema.Struct({
   path: Schema.String,
   name: Schema.String,

+ 7 - 8
packages/opencode/src/server/routes/instance/httpapi/groups/session.ts

@@ -14,7 +14,7 @@ import { Schema, SchemaGetter, Struct } from "effect"
 import { HttpApi, HttpApiEndpoint, HttpApiError, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "../middleware/authorization"
 import { InstanceContextMiddleware } from "../middleware/instance-context"
-import { WorkspaceRoutingMiddleware } from "../middleware/workspace-routing"
+import { WorkspaceRoutingMiddleware, WorkspaceRoutingQueryFields } from "../middleware/workspace-routing"
 import { ApiNotFoundError } from "../errors"
 import { described } from "./metadata"
 
@@ -25,12 +25,8 @@ const QueryBoolean = Schema.Literals(["true", "false"]).pipe(
     encode: SchemaGetter.transform((value) => (value ? "true" : "false")),
   }),
 )
-const WorkspaceRoutingQuery = {
-  directory: Schema.optional(Schema.String),
-  workspace: Schema.optional(Schema.String),
-}
 export const ListQuery = Schema.Struct({
-  ...WorkspaceRoutingQuery,
+  ...WorkspaceRoutingQueryFields,
   scope: Schema.optional(Schema.Literals(["project"])),
   path: Schema.optional(Schema.String),
   roots: Schema.optional(QueryBoolean),
@@ -38,9 +34,12 @@ export const ListQuery = Schema.Struct({
   search: Schema.optional(Schema.String),
   limit: Schema.optional(Schema.NumberFromString),
 })
-export const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
+export const DiffQuery = Schema.Struct({
+  ...WorkspaceRoutingQueryFields,
+  ...Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]),
+})
 export const MessagesQuery = Schema.Struct({
-  ...WorkspaceRoutingQuery,
+  ...WorkspaceRoutingQueryFields,
   limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
   before: Schema.optional(Schema.String),
 })

+ 11 - 1
packages/opencode/src/server/routes/instance/httpapi/middleware/workspace-routing.ts

@@ -9,11 +9,21 @@ 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 } from "effect"
+import { Context, Data, Effect, Layer, 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"
 
+// Query fields this middleware reads from the URL. Spread into every
+// endpoint query schema in groups that apply WorkspaceRoutingMiddleware,
+// otherwise HttpApi rejects requests carrying these params with 400.
+// HttpApiMiddleware in effect-smol cannot declare query params today —
+// remove this once upstream supports middleware-declared query schemas.
+export const WorkspaceRoutingQueryFields = {
+  directory: Schema.optional(Schema.String),
+  workspace: Schema.optional(Schema.String),
+}
+
 type RemoteTarget = Extract<Target, { type: "remote" }>
 
 type RequestPlan = Data.TaggedEnum<{

+ 134 - 0
packages/opencode/test/server/httpapi-query-schema-drift.test.ts

@@ -0,0 +1,134 @@
+import { afterEach, describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { Server } from "../../src/server/server"
+import { SessionID } from "../../src/session/schema"
+import { resetDatabase } from "../fixture/db"
+import { disposeAllInstances, tmpdir } from "../fixture/fixture"
+import { it } from "../lib/effect"
+
+const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
+
+function app() {
+  return Server.Default().app
+}
+
+function request(url: string, init?: RequestInit) {
+  return Effect.promise(async () => app().request(url, init))
+}
+
+function withTmp<A, E, R>(
+  options: Parameters<typeof tmpdir>[0],
+  fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
+) {
+  return Effect.acquireRelease(
+    Effect.promise(() => tmpdir(options)),
+    (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+  ).pipe(Effect.flatMap(fn))
+}
+
+afterEach(async () => {
+  Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
+  await disposeAllInstances()
+  await resetDatabase()
+})
+
+// Regression for the "OpenAPI advertises ?directory&workspace, runtime
+// rejects them" drift class. Each affected route must accept both params
+// without 400.
+describe("httpapi query schema drift", () => {
+  const routingParams = (dir: string) =>
+    `directory=${encodeURIComponent(dir)}&workspace=${encodeURIComponent("ws_test")}`
+
+  const expectNotSchemaRejection = (status: number, url: string) => {
+    expect(status, `route ${url} 400'd, query schema is missing routing fields`).not.toBe(400)
+  }
+
+  it.live(
+    "session list accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/session?${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+
+  it.live(
+    "session messages accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/session/${SessionID.descending()}/message?limit=80&${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+
+  it.live(
+    "file find/file accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/find/file?query=foo&${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+
+  it.live(
+    "file find/text accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/find?pattern=foo&${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+
+  it.live(
+    "file read accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/file?path=foo&${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+
+  it.live(
+    "experimental session list accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/experimental/session?${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+
+  it.live(
+    "experimental tool list accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/experimental/tool?provider=anthropic&model=claude&${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+
+  it.live(
+    "vcs diff accepts directory and workspace",
+    withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
+      Effect.gen(function* () {
+        const url = `/vcs/diff?mode=working&${routingParams(tmp.path)}`
+        const response = yield* request(url)
+        expectNotSchemaRejection(response.status, url)
+      }),
+    ),
+  )
+})