Skip to content

Commit 892fd85

Browse files
authored
fix(httpapi): preserve provider oauth authorize parity (#24703)
1 parent 0eaa47d commit 892fd85

6 files changed

Lines changed: 195 additions & 14 deletions

File tree

packages/opencode/src/server/routes/instance/experimental.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,15 @@ const ConsoleSwitchBody = z.object({
3737
orgID: z.string(),
3838
})
3939

40-
const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
40+
const QueryBoolean = z.union([
41+
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
42+
z.enum(["true", "false"]),
43+
])
44+
45+
function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
46+
if (value === undefined) return
47+
return value === true || value === "true"
48+
}
4149

4250
export const ExperimentalRoutes = lazy(() =>
4351
new Hono()
@@ -368,12 +376,12 @@ export const ExperimentalRoutes = lazy(() =>
368376
const sessions: Session.GlobalInfo[] = []
369377
for await (const session of Session.listGlobal({
370378
directory: query.directory,
371-
roots: query.roots,
379+
roots: queryBoolean(query.roots),
372380
start: query.start,
373381
cursor: query.cursor,
374382
search: query.search,
375383
limit: limit + 1,
376-
archived: query.archived,
384+
archived: queryBoolean(query.archived),
377385
})) {
378386
sessions.push(session)
379387
}

packages/opencode/src/server/routes/instance/httpapi/provider.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { Provider } from "@/provider/provider"
55
import { ProviderID } from "@/provider/schema"
66
import { mapValues } from "remeda"
77
import { Effect, Layer, Schema } from "effect"
8+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
89
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
910
import { Authorization } from "./auth"
1011

@@ -35,7 +36,7 @@ export const ProviderApi = HttpApi.make("provider")
3536
HttpApiEndpoint.post("authorize", `${root}/:providerID/oauth/authorize`, {
3637
params: { providerID: ProviderID },
3738
payload: ProviderAuth.AuthorizeInput,
38-
success: ProviderAuth.Authorization,
39+
success: Schema.UndefinedOr(ProviderAuth.Authorization),
3940
}).annotateMerge(
4041
OpenApi.annotations({
4142
identifier: "provider.oauth.authorize",
@@ -115,10 +116,22 @@ export const providerHandlers = Layer.unwrap(
115116
inputs: ctx.payload.inputs,
116117
})
117118
.pipe(Effect.catch(() => Effect.fail(new HttpApiError.BadRequest({}))))
118-
if (!result) return yield* new HttpApiError.BadRequest({})
119119
return result
120120
})
121121

122+
const authorizeRaw = Effect.fn("ProviderHttpApi.authorizeRaw")(function* (ctx: {
123+
params: { providerID: ProviderID }
124+
request: HttpServerRequest.HttpServerRequest
125+
}) {
126+
const body = yield* Effect.orDie(ctx.request.text)
127+
const payload = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(ProviderAuth.AuthorizeInput))(body).pipe(
128+
Effect.mapError(() => new HttpApiError.BadRequest({})),
129+
)
130+
const result = yield* authorize({ params: ctx.params, payload })
131+
if (result === undefined) return HttpServerResponse.empty({ status: 200 })
132+
return HttpServerResponse.jsonUnsafe(result)
133+
})
134+
122135
const callback = Effect.fn("ProviderHttpApi.callback")(function* (ctx: {
123136
params: { providerID: ProviderID }
124137
payload: ProviderAuth.CallbackInput
@@ -134,7 +147,7 @@ export const providerHandlers = Layer.unwrap(
134147
})
135148

136149
return HttpApiBuilder.group(ProviderApi, "provider", (handlers) =>
137-
handlers.handle("list", list).handle("auth", auth).handle("authorize", authorize).handle("callback", callback),
150+
handlers.handle("list", list).handle("auth", auth).handleRaw("authorize", authorizeRaw).handle("callback", callback),
138151
)
139152
}),
140153
).pipe(

packages/opencode/src/server/routes/instance/session.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,15 @@ import { jsonRequest, runRequest } from "./trace"
3030

3131
const log = Log.create({ service: "server" })
3232

33-
const QueryBoolean = z.enum(["true", "false"]).transform((value) => value === "true")
33+
const QueryBoolean = z.union([
34+
z.preprocess((value) => (value === "true" ? true : value === "false" ? false : value), z.boolean()),
35+
z.enum(["true", "false"]),
36+
])
37+
38+
function queryBoolean(value: z.infer<typeof QueryBoolean> | undefined) {
39+
if (value === undefined) return
40+
return value === true || value === "true"
41+
}
3442

3543
export const SessionRoutes = lazy(() =>
3644
new Hono()
@@ -69,7 +77,7 @@ export const SessionRoutes = lazy(() =>
6977
const sessions: Session.Info[] = []
7078
for await (const session of Session.list({
7179
directory: query.directory,
72-
roots: query.roots,
80+
roots: queryBoolean(query.roots),
7381
start: query.start,
7482
search: query.search,
7583
limit: query.limit,
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import { afterEach, describe, expect } from "bun:test"
2+
import type { UpgradeWebSocket } from "hono/ws"
3+
import { Effect, FileSystem, Layer, Path } from "effect"
4+
import { NodeFileSystem, NodePath } from "@effect/platform-node"
5+
import { Flag } from "@opencode-ai/core/flag/flag"
6+
import { Instance } from "../../src/project/instance"
7+
import { InstanceRoutes } from "../../src/server/routes/instance"
8+
import * as Log from "@opencode-ai/core/util/log"
9+
import { resetDatabase } from "../fixture/db"
10+
import { provideInstance } from "../fixture/fixture"
11+
import { testEffect } from "../lib/effect"
12+
13+
void Log.init({ print: false })
14+
15+
const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
16+
const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
17+
const it = testEffect(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))
18+
const providerID = "test-oauth-parity"
19+
const oauthURL = "https://example.com/oauth"
20+
const oauthInstructions = "Finish OAuth"
21+
22+
function app(experimental: boolean) {
23+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = experimental
24+
return InstanceRoutes(websocket)
25+
}
26+
27+
function requestAuthorize(input: {
28+
app: ReturnType<typeof InstanceRoutes>
29+
providerID: string
30+
method: number
31+
headers: HeadersInit
32+
}) {
33+
return Effect.promise(async () => {
34+
const response = await input.app.request(`/provider/${input.providerID}/oauth/authorize`, {
35+
method: "POST",
36+
headers: input.headers,
37+
body: JSON.stringify({ method: input.method }),
38+
})
39+
return {
40+
status: response.status,
41+
body: await response.text(),
42+
}
43+
})
44+
}
45+
46+
function writeProviderAuthPlugin(dir: string) {
47+
return Effect.gen(function* () {
48+
const fs = yield* FileSystem.FileSystem
49+
const path = yield* Path.Path
50+
51+
yield* fs.makeDirectory(path.join(dir, ".opencode", "plugin"), { recursive: true })
52+
yield* fs.writeFileString(
53+
path.join(dir, ".opencode", "plugin", "provider-oauth-parity.ts"),
54+
[
55+
"export default {",
56+
' id: "test.provider-oauth-parity",',
57+
" server: async () => ({",
58+
" auth: {",
59+
` provider: "${providerID}",`,
60+
" methods: [",
61+
' { type: "api", label: "API key" },',
62+
" {",
63+
' type: "oauth",',
64+
' label: "OAuth",',
65+
" authorize: async () => ({",
66+
` url: "${oauthURL}",`,
67+
' method: "code",',
68+
` instructions: "${oauthInstructions}",`,
69+
" callback: async () => ({ type: 'success', key: 'token' }),",
70+
" }),",
71+
" },",
72+
" ],",
73+
" },",
74+
" }),",
75+
"}",
76+
"",
77+
].join("\n"),
78+
)
79+
})
80+
}
81+
82+
function withProviderProject<A, E, R>(self: (dir: string) => Effect.Effect<A, E, R>) {
83+
return Effect.gen(function* () {
84+
const fs = yield* FileSystem.FileSystem
85+
const path = yield* Path.Path
86+
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
87+
88+
yield* fs.writeFileString(
89+
path.join(dir, "opencode.json"),
90+
JSON.stringify({ $schema: "https://opencode.ai/config.json", formatter: false, lsp: false }),
91+
)
92+
yield* writeProviderAuthPlugin(dir)
93+
yield* Effect.addFinalizer(() =>
94+
Effect.promise(() => Instance.provide({ directory: dir, fn: () => Instance.dispose() })).pipe(Effect.ignore),
95+
)
96+
97+
return yield* self(dir).pipe(provideInstance(dir))
98+
})
99+
}
100+
101+
afterEach(async () => {
102+
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
103+
await Instance.disposeAll()
104+
await resetDatabase()
105+
})
106+
107+
describe("provider HttpApi", () => {
108+
it.live(
109+
"matches legacy OAuth authorize response shapes",
110+
withProviderProject((dir) =>
111+
Effect.gen(function* () {
112+
const headers = { "x-opencode-directory": dir, "content-type": "application/json" }
113+
const legacy = app(false)
114+
const httpapi = app(true)
115+
116+
const apiLegacy = yield* requestAuthorize({
117+
app: legacy,
118+
providerID,
119+
method: 0,
120+
headers,
121+
})
122+
const apiHttpApi = yield* requestAuthorize({
123+
app: httpapi,
124+
providerID,
125+
method: 0,
126+
headers,
127+
})
128+
expect(apiLegacy).toEqual({ status: 200, body: "" })
129+
expect(apiHttpApi).toEqual(apiLegacy)
130+
131+
const oauthLegacy = yield* requestAuthorize({
132+
app: legacy,
133+
providerID,
134+
method: 1,
135+
headers,
136+
})
137+
const oauthHttpApi = yield* requestAuthorize({
138+
app: httpapi,
139+
providerID,
140+
method: 1,
141+
headers,
142+
})
143+
expect(oauthHttpApi).toEqual(oauthLegacy)
144+
expect(JSON.parse(oauthHttpApi.body)).toEqual({
145+
url: oauthURL,
146+
method: "code",
147+
instructions: oauthInstructions,
148+
})
149+
}),
150+
),
151+
)
152+
})

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -848,12 +848,12 @@ export class Session extends HeyApiClient {
848848
parameters?: {
849849
directory?: string
850850
workspace?: string
851-
roots?: "true" | "false"
851+
roots?: boolean | "true" | "false"
852852
start?: number
853853
cursor?: number
854854
search?: string
855855
limit?: number
856-
archived?: "true" | "false"
856+
archived?: boolean | "true" | "false"
857857
},
858858
options?: Options<never, ThrowOnError>,
859859
) {
@@ -1647,7 +1647,7 @@ export class Session2 extends HeyApiClient {
16471647
parameters?: {
16481648
directory?: string
16491649
workspace?: string
1650-
roots?: "true" | "false"
1650+
roots?: boolean | "true" | "false"
16511651
start?: number
16521652
search?: string
16531653
limit?: number

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3217,7 +3217,7 @@ export type ExperimentalSessionListData = {
32173217
/**
32183218
* Only return root sessions (no parentID)
32193219
*/
3220-
roots?: "true" | "false"
3220+
roots?: boolean | "true" | "false"
32213221
/**
32223222
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
32233223
*/
@@ -3237,7 +3237,7 @@ export type ExperimentalSessionListData = {
32373237
/**
32383238
* Include archived sessions (default false)
32393239
*/
3240-
archived?: "true" | "false"
3240+
archived?: boolean | "true" | "false"
32413241
}
32423242
url: "/experimental/session"
32433243
}
@@ -3285,7 +3285,7 @@ export type SessionListData = {
32853285
/**
32863286
* Only return root sessions (no parentID)
32873287
*/
3288-
roots?: "true" | "false"
3288+
roots?: boolean | "true" | "false"
32893289
/**
32903290
* Filter sessions updated on or after this timestamp (milliseconds since epoch)
32913291
*/

0 commit comments

Comments
 (0)