瀏覽代碼

fix(httpapi): preserve provider oauth authorize parity (#24703)

Kit Langton 4 月之前
父節點
當前提交
892fd85ba7

+ 11 - 3
packages/opencode/src/server/routes/instance/experimental.ts

@@ -37,7 +37,15 @@ const ConsoleSwitchBody = z.object({
   orgID: z.string(),
   orgID: z.string(),
 })
 })
 
 
-const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
+const QueryBoolean = z.union([
+  z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
+  z.enum(["true", "false"]),
+])
+
+function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
+  if (value === undefined) return
+  return value === true || value === "true"
+}
 
 
 export const ExperimentalRoutes = lazy(() =>
 export const ExperimentalRoutes = lazy(() =>
   new Hono()
   new Hono()
@@ -368,12 +376,12 @@ export const ExperimentalRoutes = lazy(() =>
         const sessions: Session.GlobalInfo[] = []
         const sessions: Session.GlobalInfo[] = []
         for await (const session of Session.listGlobal({
         for await (const session of Session.listGlobal({
           directory: query.directory,
           directory: query.directory,
-          roots: query.roots,
+          roots: queryBoolean(query.roots),
           start: query.start,
           start: query.start,
           cursor: query.cursor,
           cursor: query.cursor,
           search: query.search,
           search: query.search,
           limit: limit + 1,
           limit: limit + 1,
-          archived: query.archived,
+          archived: queryBoolean(query.archived),
         })) {
         })) {
           sessions.push(session)
           sessions.push(session)
         }
         }

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

@@ -5,6 +5,7 @@ import { Provider } from "@/provider/provider"
 import { ProviderID } from "@/provider/schema"
 import { ProviderID } from "@/provider/schema"
 import { mapValues } from "remeda"
 import { mapValues } from "remeda"
 import { Effect, Layer, Schema } from "effect"
 import { Effect, Layer, Schema } from "effect"
+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "./auth"
 import { Authorization } from "./auth"
 
 
@@ -35,7 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
         HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
         HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
           params: { providerID: ProviderID },
           params: { providerID: ProviderID },
           payload: ProviderAuth.AuthorizeInput,
           payload: ProviderAuth.AuthorizeInput,
-          success: ProviderAuth.Authorization,
+          success: Schema.UndefinedOr(ProviderAuth.Authorization),
         }).annotateMerge(
         }).annotateMerge(
           OpenApi.annotations({
           OpenApi.annotations({
             identifier: "provider.oauth.authorize",
             identifier: "provider.oauth.authorize",
@@ -115,10 +116,22 @@ export const providerHandlers = Layer.unwrap(
           inputs: ctx.payload.inputs,
           inputs: ctx.payload.inputs,
         })
         })
         .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
         .pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
-      if (!result) return yield* new HttpApiError.BadRequest({})
       return result
       return result
     })
     })
 
 
+    const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
+      params: { providerID: ProviderID }
+      request: HttpServerRequest.HttpServerRequest
+    }) {
+      const body = yield* Effect.orDie(ctx.request.text)
+      const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
+        Effect.mapError(() => new HttpApiError.BadRequest({})),
+      )
+      const result = yield* authorize({ params: ctx.params, payload })
+      if (result === undefined) return HttpServerResponse.empty({ status: 200 })
+      return HttpServerResponse.jsonUnsafe(result)
+    })
+
     const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
     const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
       params: { providerID: ProviderID }
       params: { providerID: ProviderID }
       payload: ProviderAuth.CallbackInput
       payload: ProviderAuth.CallbackInput
@@ -134,7 +147,7 @@ export const providerHandlers = Layer.unwrap(
     })
     })
 
 
     return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
     return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
-      handlers.handle("list", list).handle("auth", auth).handle("authorize", authorize).handle("callback", callback),
+      handlers.handle("list", list).handle("auth", auth).handleRaw("authorize", authorizeRaw).handle("callback", callback),
     )
     )
   }),
   }),
 ).pipe(
 ).pipe(

+ 10 - 2
packages/opencode/src/server/routes/instance/session.ts

@@ -30,7 +30,15 @@ import { jsonRequest, runRequest } from "./trace"
 
 
 const log = Log.create({ service: "server" })
 const log = Log.create({ service: "server" })
 
 
-const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
+const QueryBoolean = z.union([
+  z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
+  z.enum(["true", "false"]),
+])
+
+function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
+  if (value === undefined) return
+  return value === true || value === "true"
+}
 
 
 export const SessionRoutes = lazy(() =>
 export const SessionRoutes = lazy(() =>
   new Hono()
   new Hono()
@@ -69,7 +77,7 @@ export const SessionRoutes = lazy(() =>
         const sessions: Session.Info[] = []
         const sessions: Session.Info[] = []
         for await (const session of Session.list({
         for await (const session of Session.list({
           directory: query.directory,
           directory: query.directory,
-          roots: query.roots,
+          roots: queryBoolean(query.roots),
           start: query.start,
           start: query.start,
           search: query.search,
           search: query.search,
           limit: query.limit,
           limit: query.limit,

+ 152 - 0
packages/opencode/test/server/httpapi-provider.test.ts

@@ -0,0 +1,152 @@
+import { afterEach, describe, expect } from "bun:test"
+import type { UpgradeWebSocket } from "hono/ws"
+import { Effect, FileSystem, Layer, Path } from "effect"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Flag } from "@opencode-ai/core/flag/flag"
+import { Instance } from "../../src/project/instance"
+import { InstanceRoutes } from "../../src/server/routes/instance"
+import * as Log from "@opencode-ai/core/util/log"
+import { resetDatabase } from "../fixture/db"
+import { provideInstance } from "../fixture/fixture"
+import { testEffect } from "../lib/effect"
+
+void Log.init({ print: false })
+
+const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
+const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
+const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
+const providerID = "test-oauth-parity"
+const oauthURL = "https://example.com/oauth"
+const oauthInstructions = "Finish OAuth"
+
+function app(experimental: boolean) {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
+  return InstanceRoutes(websocket)
+}
+
+function requestAuthorize(input: {
+  app: ReturnType<typeof InstanceRoutes>
+  providerID: string
+  method: number
+  headers: HeadersInit
+}) {
+  return Effect.promise(async () => {
+    const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, {
+      method: "POST",
+      headers: input.headers,
+      body: JSON.stringify({ method: input.method }),
+    })
+    return {
+      status: response.status,
+      body: await response.text(),
+    }
+  })
+}
+
+function writeProviderAuthPlugin(dir: string) {
+  return Effect.gen(function* () {
+    const fs = yield* FileSystem.FileSystem
+    const path = yield* Path.Path
+
+    yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true })
+    yield* fs.writeFileString(
+      path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
+      [
+        "export default {",
+        '  id: "test.provider-oauth-parity",',
+        "  server: async () => ({",
+        "    auth: {",
+        `      provider: "${providerID}",`,
+        "      methods: [",
+        '        { type: "api", label: "API key" },',
+        "        {",
+        '          type: "oauth",',
+        '          label: "OAuth",',
+        "          authorize: async () => ({",
+        `            url: "${oauthURL}",`,
+        '            method: "code",',
+        `            instructions: "${oauthInstructions}",`,
+        "            callback: async () => ({ type: 'success', key: 'token' }),",
+        "          }),",
+        "        },",
+        "      ],",
+        "    },",
+        "  }),",
+        "}",
+        "",
+      ].join("\n"),
+    )
+  })
+}
+
+function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
+  return Effect.gen(function* () {
+    const fs = yield* FileSystem.FileSystem
+    const path = yield* Path.Path
+    const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
+
+    yield* fs.writeFileString(
+      path.join(dir, "opencode.json"),
+      JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }),
+    )
+    yield* writeProviderAuthPlugin(dir)
+    yield* Effect.addFinalizer(() =>
+      Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore),
+    )
+
+    return yield* self(dir).pipe(provideInstance(dir))
+  })
+}
+
+afterEach(async () => {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
+  await Instance.disposeAll()
+  await resetDatabase()
+})
+
+describe("provider HttpApi", () => {
+  it.live(
+    "matches legacy OAuth authorize response shapes",
+    withProviderProject((dir) =>
+      Effect.gen(function* () {
+        const headers = { "x-opencode-directory": dir, "content-type": "application/json" }
+        const legacy = app(false)
+        const httpapi = app(true)
+
+        const apiLegacy = yield* requestAuthorize({
+          app: legacy,
+          providerID,
+          method: 0,
+          headers,
+        })
+        const apiHttpApi = yield* requestAuthorize({
+          app: httpapi,
+          providerID,
+          method: 0,
+          headers,
+        })
+        expect(apiLegacy).toEqual({ status: 200, body: "" })
+        expect(apiHttpApi).toEqual(apiLegacy)
+
+        const oauthLegacy = yield* requestAuthorize({
+          app: legacy,
+          providerID,
+          method: 1,
+          headers,
+        })
+        const oauthHttpApi = yield* requestAuthorize({
+          app: httpapi,
+          providerID,
+          method: 1,
+          headers,
+        })
+        expect(oauthHttpApi).toEqual(oauthLegacy)
+        expect(JSON.parse(oauthHttpApi.body)).toEqual({
+          url: oauthURL,
+          method: "code",
+          instructions: oauthInstructions,
+        })
+      }),
+    ),
+  )
+})

+ 3 - 3
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -848,12 +848,12 @@ export class Session extends HeyApiClient {
     parameters?: {
     parameters?: {
       directory?: string
       directory?: string
       workspace?: string
       workspace?: string
-      roots?: "true" | "false"
+      roots?: boolean | "true" | "false"
       start?: number
       start?: number
       cursor?: number
       cursor?: number
       search?: string
       search?: string
       limit?: number
       limit?: number
-      archived?: "true" | "false"
+      archived?: boolean | "true" | "false"
     },
     },
     options?: Options<never, ThrowOnError>,
     options?: Options<never, ThrowOnError>,
   ) {
   ) {
@@ -1647,7 +1647,7 @@ export class Session2 extends HeyApiClient {
     parameters?: {
     parameters?: {
       directory?: string
       directory?: string
       workspace?: string
       workspace?: string
-      roots?: "true" | "false"
+      roots?: boolean | "true" | "false"
       start?: number
       start?: number
       search?: string
       search?: string
       limit?: number
       limit?: number

+ 3 - 3
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -3217,7 +3217,7 @@ export type ExperimentalSessionListData = {
     /**
     /**
      * Only return root sessions (no parentID)
      * Only return root sessions (no parentID)
      */
      */
-    roots?: "true" | "false"
+    roots?: boolean | "true" | "false"
     /**
     /**
      * Filter sessions updated on or after this timestamp (milliseconds since epoch)
      * Filter sessions updated on or after this timestamp (milliseconds since epoch)
      */
      */
@@ -3237,7 +3237,7 @@ export type ExperimentalSessionListData = {
     /**
     /**
      * Include archived sessions (default false)
      * Include archived sessions (default false)
      */
      */
-    archived?: "true" | "false"
+    archived?: boolean | "true" | "false"
   }
   }
   url: "/experimental/session"
   url: "/experimental/session"
 }
 }
@@ -3285,7 +3285,7 @@ export type SessionListData = {
     /**
     /**
      * Only return root sessions (no parentID)
      * Only return root sessions (no parentID)
      */
      */
-    roots?: "true" | "false"
+    roots?: boolean | "true" | "false"
     /**
     /**
      * Filter sessions updated on or after this timestamp (milliseconds since epoch)
      * Filter sessions updated on or after this timestamp (milliseconds since epoch)
      */
      */