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.
85 lines
3.3 KiB
TypeScript
85 lines
3.3 KiB
TypeScript
import { afterEach, describe, expect, test } from "bun:test"
|
|
import { HttpRouter } from "effect/unstable/http"
|
|
import { Flag } from "@opencode-ai/core/flag/flag"
|
|
import { EventPaths } from "../../src/server/routes/instance/httpapi/event"
|
|
import { PtyPaths } from "../../src/server/routes/instance/httpapi/groups/pty"
|
|
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
|
import { PtyID } from "../../src/pty/schema"
|
|
import { resetDatabase } from "../fixture/db"
|
|
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
|
import * as Log from "@opencode-ai/core/util/log"
|
|
|
|
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,
|
|
}
|
|
|
|
function app(input: { password?: string; username?: string }) {
|
|
Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
|
|
Flag.OPENCODE_SERVER_PASSWORD = input.password
|
|
Flag.OPENCODE_SERVER_USERNAME = input.username
|
|
const handler = HttpRouter.toWebHandler(ExperimentalHttpApiServer.routes, {
|
|
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))
|
|
},
|
|
}
|
|
}
|
|
|
|
function basic(username: string, password: string) {
|
|
return `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`
|
|
}
|
|
|
|
async function cancelBody(response: Response) {
|
|
await response.body?.cancel().catch(() => {})
|
|
}
|
|
|
|
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 disposeAllInstances()
|
|
await resetDatabase()
|
|
})
|
|
|
|
describe("HttpApi raw route authorization", () => {
|
|
test("requires configured auth before opening the raw instance event stream", async () => {
|
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
|
const server = app({ password: "secret" })
|
|
const headers = { "x-opencode-directory": tmp.path }
|
|
|
|
const missing = await server.request(EventPaths.event, { headers })
|
|
await cancelBody(missing)
|
|
expect(missing.status).toBe(401)
|
|
|
|
const authed = await server.request(EventPaths.event, {
|
|
headers: { ...headers, authorization: basic("opencode", "secret") },
|
|
})
|
|
await cancelBody(authed)
|
|
expect(authed.status).toBe(200)
|
|
})
|
|
|
|
test("requires configured auth before resolving the raw PTY websocket route", async () => {
|
|
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
|
const server = app({ password: "secret" })
|
|
const route = PtyPaths.connect.replace(":ptyID", PtyID.ascending())
|
|
const headers = { "x-opencode-directory": tmp.path }
|
|
|
|
const missing = await server.request(route, { headers })
|
|
await cancelBody(missing)
|
|
expect(missing.status).toBe(401)
|
|
|
|
const authed = await server.request(route, {
|
|
headers: { ...headers, authorization: basic("opencode", "secret") },
|
|
})
|
|
await cancelBody(authed)
|
|
expect(authed.status).toBe(404)
|
|
})
|
|
})
|