2026-04-24 22:03:51 +00:00
import { afterEach , describe , expect , test } from "bun:test"
2026-04-25 17:29:52 +00:00
import { Flag } from "@opencode-ai/core/flag/flag"
2026-04-24 22:03:51 +00:00
import { Instance } from "../../src/project/instance"
2026-04-29 13:34:50 +00:00
import { ControlPaths } from "../../src/server/routes/instance/httpapi/groups/control"
2026-04-30 02:22:32 +00:00
import { FilePaths } from "../../src/server/routes/instance/httpapi/groups/file"
2026-04-29 13:34:50 +00:00
import { GlobalPaths } from "../../src/server/routes/instance/httpapi/groups/global"
2026-04-27 20:51:24 +00:00
import { PublicApi } from "../../src/server/routes/instance/httpapi/public"
2026-04-30 02:22:32 +00:00
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
2026-04-27 20:51:24 +00:00
import { Server } from "../../src/server/server"
2026-04-27 18:33:33 +00:00
import * as Log from "@opencode-ai/core/util/log"
2026-04-30 02:22:32 +00:00
import { ConfigProvider , Layer } from "effect"
import { HttpRouter } from "effect/unstable/http"
2026-04-28 15:02:35 +00:00
import { OpenApi } from "effect/unstable/httpapi"
2026-04-24 22:03:51 +00:00
import { resetDatabase } from "../fixture/db"
import { tmpdir } from "../fixture/fixture"
void Log . init ( { print : false } )
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI : Flag.OPENCODE_EXPERIMENTAL_HTTPAPI ,
OPENCODE_SERVER_PASSWORD : Flag.OPENCODE_SERVER_PASSWORD ,
OPENCODE_SERVER_USERNAME : Flag.OPENCODE_SERVER_USERNAME ,
}
2026-04-27 20:51:24 +00:00
const methods = [ "get" , "post" , "put" , "delete" , "patch" ] as const
2026-04-28 18:24:10 +00:00
let effectSpec : ReturnType < typeof OpenApi.fromApi > | undefined
function effectOpenApi() {
return ( effectSpec ? ? = OpenApi . fromApi ( PublicApi ) )
}
2026-04-24 22:03:51 +00:00
function app ( input ? : { password? : string ; username? : string } ) {
Flag . OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag . OPENCODE_SERVER_PASSWORD = input ? . password
Flag . OPENCODE_SERVER_USERNAME = input ? . username
2026-04-30 02:22:32 +00:00
const handler = HttpRouter . toWebHandler (
ExperimentalHttpApiServer . routes . pipe (
Layer . provide (
ConfigProvider . layer (
ConfigProvider . fromUnknown ( {
OPENCODE_SERVER_PASSWORD : input?.password ,
OPENCODE_SERVER_USERNAME : input?.username ,
} ) ,
) ,
) ,
) ,
{ disableLogger : true } ,
) . handler
return {
fetch : ( request : Request ) = > handler ( request , ExperimentalHttpApiServer . context ) ,
request ( input : string | URL | Request , init? : RequestInit ) {
return this . fetch ( input instanceof Request ? input : new Request ( new URL ( input , "http://localhost" ) , init ) )
} ,
}
2026-04-27 20:29:58 +00:00
}
2026-04-27 20:51:24 +00:00
function openApiRouteKeys ( spec : { paths : Record < string , Partial < Record < ( typeof methods ) [ number ] , unknown > >> } ) {
return Object . entries ( spec . paths )
2026-04-27 20:52:42 +00:00
. flatMap ( ( [ path , item ] ) = >
methods . filter ( ( method ) = > item [ method ] ) . map ( ( method ) = > ` ${ method . toUpperCase ( ) } ${ path } ` ) ,
)
2026-04-27 20:51:24 +00:00
. sort ( )
}
2026-04-28 15:10:00 +00:00
function openApiParameters ( spec : { paths : Record < string , Partial < Record < ( typeof methods ) [ number ] , Operation > >> } ) {
return Object . fromEntries (
Object . entries ( spec . paths ) . flatMap ( ( [ path , item ] ) = >
methods
. filter ( ( method ) = > item [ method ] )
. map ( ( method ) = > [
` ${ method . toUpperCase ( ) } ${ path } ` ,
( item [ method ] ? . parameters ? ? [ ] )
. map ( parameterKey )
. filter ( ( param ) = > param !== undefined )
. sort ( ) ,
] ) ,
) ,
)
}
2026-04-29 13:34:50 +00:00
function openApiRequestBodies ( spec : OpenApiSpec ) {
2026-04-28 16:55:37 +00:00
return Object . fromEntries (
Object . entries ( spec . paths ) . flatMap ( ( [ path , item ] ) = >
methods
. filter ( ( method ) = > item [ method ] )
2026-04-29 13:34:50 +00:00
. map ( ( method ) = > [ ` ${ method . toUpperCase ( ) } ${ path } ` , requestBodyKey ( spec , item [ method ] ? . requestBody ) ] ) ,
2026-04-28 16:55:37 +00:00
) ,
)
}
2026-04-29 13:34:50 +00:00
type OpenApiSpec = {
components ? : {
schemas? : Record < string , unknown >
}
paths : Record < string , Partial < Record < ( typeof methods ) [ number ] , Operation > >>
}
type OpenApiSchema = {
$ref? : string
allOf? : unknown [ ]
anyOf? : unknown [ ]
oneOf? : unknown [ ]
properties? : Record < string , unknown >
type ? : string | string [ ]
}
2026-04-28 15:10:00 +00:00
type Operation = {
parameters? : unknown [ ]
2026-04-28 18:24:10 +00:00
responses? : unknown
2026-04-28 16:55:37 +00:00
requestBody? : unknown
}
type RequestBody = {
2026-04-29 13:34:50 +00:00
content? : Record < string , { schema ? : OpenApiSchema } >
2026-04-28 16:55:37 +00:00
required? : boolean
2026-04-28 15:10:00 +00:00
}
2026-04-30 02:22:32 +00:00
function parameterKey ( param : unknown ) : string | undefined {
if ( ! param || typeof param !== "object" || ! ( "in" in param ) || ! ( "name" in param ) ) return undefined
if ( typeof param . in !== "string" || typeof param . name !== "string" ) return undefined
2026-04-28 15:10:00 +00:00
return ` ${ param . in } : ${ param . name } : ${ "required" in param && param . required === true } `
}
2026-04-28 18:24:10 +00:00
function parameterSchema ( input : {
spec : { paths : Record < string , Partial < Record < ( typeof methods ) [ number ] , Operation > >> }
path : string
method : ( typeof methods ) [ number ]
name : string
2026-04-30 02:22:32 +00:00
} ) : unknown {
2026-04-28 18:24:10 +00:00
const param = input . spec . paths [ input . path ] ? . [ input . method ] ? . parameters ? . find (
( param ) = > ! ! param && typeof param === "object" && "name" in param && param . name === input . name ,
)
2026-04-30 02:22:32 +00:00
if ( ! param || typeof param !== "object" || ! ( "schema" in param ) ) return undefined
2026-04-28 18:24:10 +00:00
return param . schema
}
2026-04-29 13:34:50 +00:00
function requestBodyKey ( spec : OpenApiSpec , body : unknown ) {
2026-04-28 16:55:37 +00:00
if ( ! body || typeof body !== "object" || ! ( "content" in body ) ) return ""
2026-04-30 02:22:32 +00:00
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded above; test helper only needs this OpenAPI subset.
2026-04-28 16:55:37 +00:00
const requestBody = body as RequestBody
return JSON . stringify ( {
required : requestBody.required === true ,
content : Object.entries ( requestBody . content ? ? { } )
2026-04-30 02:22:32 +00:00
. map ( ( [ type , value ] ) = > [ type , requestBodySchemaKind ( spec , value . schema ) ] as const )
. sort ( ( [ left ] , [ right ] ) = > left . localeCompare ( right ) ) ,
2026-04-28 16:55:37 +00:00
} )
}
2026-04-29 13:34:50 +00:00
function requestBodySchemaKind ( spec : OpenApiSpec , schema : OpenApiSchema | undefined ) {
if ( ! schema ) return ""
2026-04-30 02:22:32 +00:00
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- `$ref` lookup is constrained to OpenAPI schema components in this test helper.
2026-04-29 13:36:05 +00:00
const resolved = (
schema . $ref ? spec . components ? . schemas ? . [ schema . $ref . replace ( "#/components/schemas/" , "" ) ] : schema
) as OpenApiSchema | undefined
2026-04-29 13:34:50 +00:00
if ( resolved ? . properties ) return "object"
if ( resolved ? . anyOf ? ? resolved ? . oneOf ? ? resolved ? . allOf ) return "object"
return resolved ? . type ? ? schema . type ? ? "inline"
}
2026-04-28 18:24:10 +00:00
function responseContentTypes ( input : {
spec : { paths : Record < string , Partial < Record < ( typeof methods ) [ number ] , Operation > >> }
path : string
method : ( typeof methods ) [ number ]
status : string
} ) {
const responses = input . spec . paths [ input . path ] ? . [ input . method ] ? . responses
if ( ! responses || typeof responses !== "object" || ! ( input . status in responses ) ) return [ ]
2026-04-30 02:22:32 +00:00
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Guarded dynamic OpenAPI response lookup.
2026-04-28 18:24:10 +00:00
const response = ( responses as Record < string , unknown > ) [ input . status ]
if ( ! response || typeof response !== "object" || ! ( "content" in response ) ) return [ ]
const content = ( response as { content? : unknown } ) . content
if ( ! content || typeof content !== "object" ) {
return [ ]
}
return Object . keys ( content ) . sort ( )
}
2026-04-24 22:03:51 +00:00
function authorization ( username : string , password : string ) {
return ` Basic ${ Buffer . from ( ` ${ username } : ${ password } ` ) . toString ( "base64" ) } `
}
function fileUrl ( input ? : { directory? : string ; token? : string } ) {
const url = new URL ( ` http://localhost ${ FilePaths . content } ` )
url . searchParams . set ( "path" , "hello.txt" )
if ( input ? . directory ) url . searchParams . set ( "directory" , input . directory )
if ( input ? . token ) url . searchParams . set ( "auth_token" , input . token )
return url
}
afterEach ( async ( ) = > {
Flag . OPENCODE_EXPERIMENTAL_HTTPAPI = original . OPENCODE_EXPERIMENTAL_HTTPAPI
Flag . OPENCODE_SERVER_PASSWORD = original . OPENCODE_SERVER_PASSWORD
Flag . OPENCODE_SERVER_USERNAME = original . OPENCODE_SERVER_USERNAME
await Instance . disposeAll ( )
await resetDatabase ( )
} )
2026-04-28 15:02:35 +00:00
describe ( "HttpApi server" , ( ) = > {
2026-04-29 13:34:50 +00:00
test ( "keeps Effect HttpApi behind the feature flag" , ( ) = > {
Flag . OPENCODE_EXPERIMENTAL_HTTPAPI = false
expect ( Server . backend ( ) ) . toEqual ( { backend : "hono" , reason : "stable" } )
Flag . OPENCODE_EXPERIMENTAL_HTTPAPI = true
expect ( Server . backend ( ) ) . toEqual ( { backend : "effect-httpapi" , reason : "env" } )
} )
2026-04-27 20:51:24 +00:00
test ( "covers every generated OpenAPI route with Effect HttpApi contracts" , async ( ) = > {
const honoRoutes = openApiRouteKeys ( await Server . openapi ( ) )
2026-04-28 18:24:10 +00:00
const effectRoutes = openApiRouteKeys ( effectOpenApi ( ) )
2026-04-27 20:51:24 +00:00
expect ( honoRoutes . filter ( ( route ) = > ! effectRoutes . includes ( route ) ) ) . toEqual ( [ ] )
expect ( effectRoutes . filter ( ( route ) = > ! honoRoutes . includes ( route ) ) ) . toEqual ( [ ] )
} )
2026-04-28 15:10:00 +00:00
test ( "matches generated OpenAPI route parameters" , async ( ) = > {
const hono = openApiParameters ( await Server . openapi ( ) )
2026-04-28 18:24:10 +00:00
const effect = openApiParameters ( effectOpenApi ( ) )
2026-04-28 15:10:00 +00:00
expect (
Object . keys ( hono )
. filter ( ( route ) = > JSON . stringify ( hono [ route ] ) !== JSON . stringify ( effect [ route ] ) )
. map ( ( route ) = > ( { route , hono : hono [ route ] , effect : effect [ route ] } ) ) ,
) . toEqual ( [ ] )
} )
2026-04-28 16:55:37 +00:00
test ( "matches generated OpenAPI request body shape" , async ( ) = > {
const hono = openApiRequestBodies ( await Server . openapi ( ) )
2026-04-28 18:24:10 +00:00
const effect = openApiRequestBodies ( effectOpenApi ( ) )
2026-04-28 16:55:37 +00:00
expect (
Object . keys ( hono )
. filter ( ( route ) = > hono [ route ] !== effect [ route ] )
. map ( ( route ) = > ( { route , hono : hono [ route ] , effect : effect [ route ] } ) ) ,
) . toEqual ( [ ] )
} )
2026-04-28 18:24:10 +00:00
test ( "matches SDK-affecting query parameter schemas" , async ( ) = > {
const effect = effectOpenApi ( )
expect ( parameterSchema ( { spec : effect , path : "/session" , method : "get" , name : "roots" } ) ) . toEqual ( {
anyOf : [ { type : "boolean" } , { type : "string" , enum : [ "true" , "false" ] } ] ,
} )
expect ( parameterSchema ( { spec : effect , path : "/session" , method : "get" , name : "start" } ) ) . toEqual ( {
type : "number" ,
} )
expect ( parameterSchema ( { spec : effect , path : "/find/file" , method : "get" , name : "limit" } ) ) . toEqual ( {
type : "integer" ,
minimum : 1 ,
maximum : 200 ,
} )
2026-04-28 18:25:23 +00:00
expect (
parameterSchema ( { spec : effect , path : "/session/{sessionID}/message" , method : "get" , name : "limit" } ) ,
) . toEqual ( {
2026-04-28 18:24:10 +00:00
type : "integer" ,
minimum : 0 ,
maximum : Number.MAX_SAFE_INTEGER ,
} )
} )
test ( "documents event routes as server-sent events" , ( ) = > {
const effect = effectOpenApi ( )
expect ( responseContentTypes ( { spec : effect , path : "/event" , method : "get" , status : "200" } ) ) . toEqual ( [
"text/event-stream" ,
] )
expect ( responseContentTypes ( { spec : effect , path : "/global/event" , method : "get" , status : "200" } ) ) . toEqual ( [
"text/event-stream" ,
] )
} )
2026-04-24 22:03:51 +00:00
test ( "allows requests when auth is disabled" , async ( ) = > {
await using tmp = await tmpdir ( { git : true } )
await Bun . write ( ` ${ tmp . path } /hello.txt ` , "hello" )
const response = await app ( ) . request ( fileUrl ( ) , {
headers : {
"x-opencode-directory" : tmp . path ,
} ,
} )
expect ( response . status ) . toBe ( 200 )
expect ( await response . json ( ) ) . toMatchObject ( { content : "hello" } )
} )
test ( "provides instance context to bridged handlers" , async ( ) = > {
await using tmp = await tmpdir ( { git : true } )
const response = await app ( ) . request ( "/project/current" , {
headers : {
"x-opencode-directory" : tmp . path ,
} ,
} )
expect ( response . status ) . toBe ( 200 )
expect ( await response . json ( ) ) . toMatchObject ( { worktree : tmp.path } )
} )
test ( "requires credentials when auth is enabled" , async ( ) = > {
await using tmp = await tmpdir ( { git : true } )
await Bun . write ( ` ${ tmp . path } /hello.txt ` , "hello" )
const [ missing , bad , good ] = await Promise . all ( [
app ( { password : "secret" } ) . request ( fileUrl ( ) , {
headers : { "x-opencode-directory" : tmp . path } ,
} ) ,
app ( { password : "secret" } ) . request ( fileUrl ( ) , {
headers : {
authorization : authorization ( "opencode" , "wrong" ) ,
"x-opencode-directory" : tmp . path ,
} ,
} ) ,
app ( { password : "secret" } ) . request ( fileUrl ( ) , {
headers : {
authorization : authorization ( "opencode" , "secret" ) ,
"x-opencode-directory" : tmp . path ,
} ,
} ) ,
] )
expect ( missing . status ) . toBe ( 401 )
expect ( bad . status ) . toBe ( 401 )
expect ( good . status ) . toBe ( 200 )
} )
test ( "accepts auth_token query credentials" , async ( ) = > {
await using tmp = await tmpdir ( { git : true } )
await Bun . write ( ` ${ tmp . path } /hello.txt ` , "hello" )
2026-04-24 22:05:23 +00:00
const response = await app ( { password : "secret" } ) . request (
fileUrl ( { token : Buffer.from ( "opencode:secret" ) . toString ( "base64" ) } ) ,
{
headers : {
"x-opencode-directory" : tmp . path ,
} ,
2026-04-24 22:03:51 +00:00
} ,
2026-04-24 22:05:23 +00:00
)
2026-04-24 22:03:51 +00:00
expect ( response . status ) . toBe ( 200 )
} )
test ( "selects instance from query before directory header" , async ( ) = > {
await using header = await tmpdir ( { git : true } )
await using query = await tmpdir ( { git : true } )
await Bun . write ( ` ${ header . path } /hello.txt ` , "header" )
await Bun . write ( ` ${ query . path } /hello.txt ` , "query" )
const response = await app ( ) . request ( fileUrl ( { directory : query.path } ) , {
headers : {
"x-opencode-directory" : header . path ,
} ,
} )
expect ( response . status ) . toBe ( 200 )
expect ( await response . json ( ) ) . toMatchObject ( { content : "query" } )
} )
2026-04-28 20:31:45 +00:00
test ( "serves global health from Effect HttpApi" , async ( ) = > {
const response = await app ( ) . request ( ` ${ GlobalPaths . health } ?directory=/does/not/exist/opencode-test ` )
expect ( response . status ) . toBe ( 200 )
expect ( await response . json ( ) ) . toMatchObject ( { healthy : true } )
} )
test ( "serves global event stream from Effect HttpApi" , async ( ) = > {
const response = await app ( ) . request ( GlobalPaths . event )
if ( ! response . body ) throw new Error ( "missing event stream body" )
const reader = response . body . getReader ( )
const chunk = await reader . read ( )
await reader . cancel ( )
expect ( response . status ) . toBe ( 200 )
expect ( response . headers . get ( "content-type" ) ) . toContain ( "text/event-stream" )
expect ( new TextDecoder ( ) . decode ( chunk . value ) ) . toContain ( "server.connected" )
} )
test ( "serves control log from Effect HttpApi" , async ( ) = > {
const response = await app ( ) . request ( ControlPaths . log , {
method : "POST" ,
headers : { "content-type" : "application/json" } ,
body : JSON.stringify ( { service : "httpapi-test" , level : "info" , message : "hello" } ) ,
} )
expect ( response . status ) . toBe ( 200 )
expect ( await response . json ( ) ) . toBe ( true )
} )
test ( "validates control auth without falling through to 404" , async ( ) = > {
const response = await app ( ) . request ( ControlPaths . auth . replace ( ":providerID" , "test" ) , {
method : "PUT" ,
headers : { "content-type" : "application/json" } ,
body : JSON.stringify ( { type : "api" } ) ,
} )
expect ( response . status ) . toBe ( 400 )
} )
test ( "validates global upgrade without invoking installers" , async ( ) = > {
const response = await app ( ) . request ( GlobalPaths . upgrade , {
method : "POST" ,
headers : { "content-type" : "application/json" } ,
body : "not-json" ,
} )
expect ( response . status ) . toBe ( 400 )
expect ( await response . json ( ) ) . toMatchObject ( { success : false } )
} )
2026-04-24 22:03:51 +00:00
} )