mirror of
https://fastgit.cc/https://github.com/anomalyco/opencode
synced 2026-04-30 13:51:48 +08:00
fix(httpapi): preserve provider oauth authorize parity (#24703)
This commit is contained in:
@@ -37,7 +37,15 @@ const ConsoleSwitchBody = z.object({
|
||||
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(() =>
|
||||
new Hono()
|
||||
@@ -368,12 +376,12 @@ export const ExperimentalRoutes = lazy(() =>
|
||||
const sessions: Session.GlobalInfo[] = []
|
||||
for await (const session of Session.listGlobal({
|
||||
directory: query.directory,
|
||||
roots: query.roots,
|
||||
roots: queryBoolean(query.roots),
|
||||
start: query.start,
|
||||
cursor: query.cursor,
|
||||
search: query.search,
|
||||
limit: limit + 1,
|
||||
archived: query.archived,
|
||||
archived: queryBoolean(query.archived),
|
||||
})) {
|
||||
sessions.push(session)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { ProviderID } from "@/provider/schema"
|
||||
import { mapValues } from "remeda"
|
||||
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 { Authorization } from "./auth"
|
||||
|
||||
@@ -35,7 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
|
||||
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
|
||||
params: { providerID: ProviderID },
|
||||
payload: ProviderAuth.AuthorizeInput,
|
||||
success: ProviderAuth.Authorization,
|
||||
success: Schema.UndefinedOr(ProviderAuth.Authorization),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "provider.oauth.authorize",
|
||||
@@ -115,10 +116,22 @@ export const providerHandlers = Layer.unwrap(
|
||||
inputs: ctx.payload.inputs,
|
||||
})
|
||||
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
|
||||
if (!result) return yield* new HttpApiError.BadRequest({})
|
||||
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: {
|
||||
params: { providerID: ProviderID }
|
||||
payload: ProviderAuth.CallbackInput
|
||||
@@ -134,7 +147,7 @@ export const providerHandlers = Layer.unwrap(
|
||||
})
|
||||
|
||||
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(
|
||||
|
||||
@@ -30,7 +30,15 @@ import { jsonRequest, runRequest } from "./trace"
|
||||
|
||||
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(() =>
|
||||
new Hono()
|
||||
@@ -69,7 +77,7 @@ export const SessionRoutes = lazy(() =>
|
||||
const sessions: Session.Info[] = []
|
||||
for await (const session of Session.list({
|
||||
directory: query.directory,
|
||||
roots: query.roots,
|
||||
roots: queryBoolean(query.roots),
|
||||
start: query.start,
|
||||
search: query.search,
|
||||
limit: query.limit,
|
||||
|
||||
152
packages/opencode/test/server/httpapi-provider.test.ts
Normal file
152
packages/opencode/test/server/httpapi-provider.test.ts
Normal file
@@ -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,
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -848,12 +848,12 @@ export class Session extends HeyApiClient {
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
roots?: "true" | "false"
|
||||
roots?: boolean | "true" | "false"
|
||||
start?: number
|
||||
cursor?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
archived?: "true" | "false"
|
||||
archived?: boolean | "true" | "false"
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
@@ -1647,7 +1647,7 @@ export class Session2 extends HeyApiClient {
|
||||
parameters?: {
|
||||
directory?: string
|
||||
workspace?: string
|
||||
roots?: "true" | "false"
|
||||
roots?: boolean | "true" | "false"
|
||||
start?: number
|
||||
search?: string
|
||||
limit?: number
|
||||
|
||||
@@ -3217,7 +3217,7 @@ export type ExperimentalSessionListData = {
|
||||
/**
|
||||
* Only return root sessions (no parentID)
|
||||
*/
|
||||
roots?: "true" | "false"
|
||||
roots?: boolean | "true" | "false"
|
||||
/**
|
||||
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
|
||||
*/
|
||||
@@ -3237,7 +3237,7 @@ export type ExperimentalSessionListData = {
|
||||
/**
|
||||
* Include archived sessions (default false)
|
||||
*/
|
||||
archived?: "true" | "false"
|
||||
archived?: boolean | "true" | "false"
|
||||
}
|
||||
url: "/experimental/session"
|
||||
}
|
||||
@@ -3285,7 +3285,7 @@ export type SessionListData = {
|
||||
/**
|
||||
* Only return root sessions (no parentID)
|
||||
*/
|
||||
roots?: "true" | "false"
|
||||
roots?: boolean | "true" | "false"
|
||||
/**
|
||||
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user