Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions packages/opencode/src/server/routes/instance/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}
Expand Down
19 changes: 16 additions & 3 deletions packages/opencode/src/server/routes/instance/httpapi/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand Down
12 changes: 10 additions & 2 deletions packages/opencode/src/server/routes/instance/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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,
Expand Down
152 changes: 152 additions & 0 deletions packages/opencode/test/server/httpapi-provider.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
}),
),
)
})
6 changes: 3 additions & 3 deletions packages/sdk/js/src/v2/gen/sdk.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
) {
Expand Down Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions packages/sdk/js/src/v2/gen/types.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand All @@ -3237,7 +3237,7 @@ export type ExperimentalSessionListData = {
/**
* Include archived sessions (default false)
*/
archived?: "true" | "false"
archived?: boolean | "true" | "false"
}
url: "/experimental/session"
}
Expand Down Expand Up @@ -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)
*/
Expand Down
Loading