Files
opencode/packages/opencode/test/server/httpapi-ui.test.ts
Kit Langton da5e29b320 fix(server): read auth Config from Flag for HttpApi/Hono parity
HttpApi auth middleware was reading ServerAuth.Config via Effect's generated
defaultLayer, which resolves Config.string('OPENCODE_SERVER_PASSWORD') once
and is memoized by Layer identity. Subsequent runtime mutation of process.env
(or Flag.OPENCODE_SERVER_PASSWORD) was never observed, so the middleware kept
serving 401 even when auth was disabled at runtime.

Hono's AuthMiddleware reads Flag.OPENCODE_SERVER_PASSWORD per request, so it
picks up mutations immediately. With Hono still the production default and
HttpApi gated by OPENCODE_EXPERIMENTAL_HTTPAPI, the gap was masked by tests
that flipped the flag back to Hono for no-auth scenarios.

Override ServerAuth.Config.defaultLayer to read Flag.* via Layer.sync at
layer-build time. Each fresh listener (memoMap) picks up current Flag values.
This matches Hono behavior across listeners; per-request mutation within a
single listener is not preserved (would require reading Flag in the middleware
itself, which is a separate concern).

Tests:
- httpapi-listen: parameterize 'tickets optional when auth disabled' across
  both backends to lock in parity.
- httpapi-raw-route-auth + httpapi-ui: switch from ConfigProvider injection
  (which is now a no-op since defaultLayer is Flag-backed, not Config-backed)
  to ServerAuth.Config.layer({...}) for explicit overrides, or Flag mutation
  for tests that exercise the production read path.

46/46 auth + PTY tests pass.
2026-05-04 00:13:02 -04:00

271 lines
9.5 KiB
TypeScript

import { afterEach, describe, expect, test } from "bun:test"
import { Flag } from "@opencode-ai/core/flag/flag"
import * as Log from "@opencode-ai/core/util/log"
import { Effect, Layer, Option } from "effect"
import {
HttpClient,
HttpClientRequest,
HttpClientResponse,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { ServerAuth } from "../../src/server/auth"
import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization"
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui"
import { Server } from "../../src/server/server"
void Log.init({ print: false })
const original = {
OPENCODE_EXPERIMENTAL_HTTPAPI: Flag.OPENCODE_EXPERIMENTAL_HTTPAPI,
OPENCODE_DISABLE_EMBEDDED_WEB_UI: Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI,
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME,
envPassword: process.env.OPENCODE_SERVER_PASSWORD,
envUsername: process.env.OPENCODE_SERVER_USERNAME,
}
afterEach(() => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original.OPENCODE_EXPERIMENTAL_HTTPAPI
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = original.OPENCODE_DISABLE_EMBEDDED_WEB_UI
Flag.OPENCODE_SERVER_PASSWORD = original.OPENCODE_SERVER_PASSWORD
Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME
restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword)
restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername)
})
function restoreEnv(key: string, value: string | undefined) {
if (value === undefined) {
delete process.env[key]
return
}
process.env[key] = value
}
function app(input?: { password?: string; username?: string }) {
const handler = HttpRouter.toWebHandler(
ExperimentalHttpApiServer.routes.pipe(
Layer.provide(
ServerAuth.Config.layer({
password: input?.password ? Option.some(input.password) : Option.none(),
username: input?.username ?? "opencode",
}),
),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
ExperimentalHttpApiServer.context,
)
},
}
}
function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
const authConfigLayer = ServerAuth.Config.layer({
password: input?.password ? Option.some(input.password) : Option.none(),
username: input?.username ?? "opencode",
})
const handler = HttpRouter.toWebHandler(
HttpRouter.use((router) =>
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
}),
).pipe(
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer))),
Layer.provide([
AppFileSystem.defaultLayer,
input?.client ?? httpClient(new Response("ui")),
HttpServer.layerServices,
]),
),
{ disableLogger: true },
).handler
return {
request(input: string | URL | Request, init?: RequestInit) {
return handler(
input instanceof Request ? input : new Request(new URL(input, "http://localhost"), init),
ExperimentalHttpApiServer.context,
)
},
}
}
function httpClient(response: Response, onRequest?: (request: HttpClientRequest.HttpClientRequest) => void) {
return Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => {
onRequest?.(request)
return Effect.succeed(HttpClientResponse.fromWeb(request, response))
}),
)
}
describe("HttpApi UI fallback", () => {
test("serves the web UI through the experimental backend", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
const response = await uiApp({
client: httpClient(
new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
(request) => {
proxiedUrl = request.url
},
),
}).request("/")
expect(response.status).toBe(200)
expect(response.headers.get("content-type")).toContain("text/html")
expect(await response.text()).toBe("<html>opencode</html>")
expect(proxiedUrl).toBe("https://app.opencode.ai/")
})
test("strips upstream transfer encoding headers from proxied assets", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
let proxiedUrl: string | undefined
const response = await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
const client = yield* HttpClient.HttpClient
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
fs,
client,
})
}).pipe(
Effect.provide(
Layer.mergeAll(
AppFileSystem.defaultLayer,
Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) => {
proxiedUrl = request.url
return Effect.succeed(
HttpClientResponse.fromWeb(
request,
new Response("console.log('ok')", {
headers: {
"content-encoding": "br",
"content-length": "999",
"content-type": "text/javascript",
},
}),
),
)
}),
),
),
),
Effect.map(HttpServerResponse.toWeb),
),
)
expect(response.status).toBe(200)
expect(proxiedUrl).toBe("https://app.opencode.ai/assets/app.js")
expect(response.headers.get("content-encoding")).toBeNull()
expect(response.headers.get("content-length")).not.toBe("999")
expect(response.headers.get("content-type")).toContain("text/javascript")
expect(await response.text()).toBe("console.log('ok')")
})
test("serves embedded UI assets when Bun can read them but access reports missing", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
let readPath: string | undefined
const response = await Effect.runPromise(
Effect.gen(function* () {
const fs = yield* AppFileSystem.Service
return yield* serveEmbeddedUIEffect(
"/assets/app.js",
{
...fs,
existsSafe: () => Effect.die("embedded UI should not rely on filesystem access checks"),
readFile: (path) => {
readPath = path
return path === "/$bunfs/root/assets/app.js"
? Effect.succeed(new TextEncoder().encode("console.log('embedded')"))
: Effect.die(`unexpected embedded UI path: ${path}`)
},
},
{ "assets/app.js": "/$bunfs/root/assets/app.js" },
)
}).pipe(Effect.provide(AppFileSystem.defaultLayer), Effect.map(HttpServerResponse.toWeb)),
)
expect(response.status).toBe(200)
expect(readPath).toBe("/$bunfs/root/assets/app.js")
expect(response.headers.get("content-type")).toContain("text/javascript")
expect(await response.text()).toBe("console.log('embedded')")
})
test("keeps matched API routes ahead of the UI fallback", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const response = await Server.Default().app.request("/session/nope")
expect(response.status).toBe(404)
})
test("requires server password for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/")
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
})
test("accepts auth token for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({
password: "secret",
username: "opencode",
client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
}).request(`/?auth_token=${btoa("opencode:secret")}`)
expect(response.status).toBe(200)
expect(await response.text()).toBe("<html>opencode</html>")
})
test("accepts basic auth for the web UI", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
const response = await uiApp({ password: "secret", username: "opencode" }).request("/", {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
})
expect(response.status).toBe(200)
})
test("allows web UI preflight without auth", async () => {
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
const response = await app({ password: "secret", username: "opencode" }).request("/", {
method: "OPTIONS",
headers: {
origin: "http://localhost:3000",
"access-control-request-method": "GET",
},
})
expect(response.status).toBe(204)
expect(response.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
})
})