diff --git a/packages/opencode/src/server/routes/instance/experimental.ts b/packages/opencode/src/server/routes/instance/experimental.ts index ec46298f5a96..7e09fb9ad322 100644 --- a/packages/opencode/src/server/routes/instance/experimental.ts +++ b/packages/opencode/src/server/routes/instance/experimental.ts @@ -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 | 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) } diff --git a/packages/opencode/src/server/routes/instance/httpapi/provider.ts b/packages/opencode/src/server/routes/instance/httpapi/provider.ts index 9f4be61ad692..59a17cf0f166 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/provider.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/provider.ts @@ -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( diff --git a/packages/opencode/src/server/routes/instance/session.ts b/packages/opencode/src/server/routes/instance/session.ts index a5958d37ad97..8a7752e34183 100644 --- a/packages/opencode/src/server/routes/instance/session.ts +++ b/packages/opencode/src/server/routes/instance/session.ts @@ -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 | 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, diff --git a/packages/opencode/test/server/httpapi-provider.test.ts b/packages/opencode/test/server/httpapi-provider.test.ts new file mode 100644 index 000000000000..8a07935146f2 --- /dev/null +++ b/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 + 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(self: (dir: string) => Effect.Effect) { + 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, + }) + }), + ), + ) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index b361a095e929..1dafe88d1cce 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -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, ) { @@ -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 diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index ab23265483bd..201bf226c7ed 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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) */