From da5e29b3206ce2d557290cbac917a350c364fe3e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Mon, 4 May 2026 00:13:02 -0400 Subject: [PATCH] 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. --- packages/opencode/src/server/auth.ts | 25 ++++++++++++-- .../server/routes/instance/httpapi/server.ts | 2 +- .../test/server/httpapi-listen.test.ts | 34 ++++++++++--------- .../server/httpapi-raw-route-auth.test.ts | 30 +++++++--------- .../opencode/test/server/httpapi-ui.test.ts | 24 ++++++------- 5 files changed, 65 insertions(+), 50 deletions(-) diff --git a/packages/opencode/src/server/auth.ts b/packages/opencode/src/server/auth.ts index 9630ddbe20..65de3418b1 100644 --- a/packages/opencode/src/server/auth.ts +++ b/packages/opencode/src/server/auth.ts @@ -2,7 +2,7 @@ export * as ServerAuth from "./auth" import { ConfigService } from "@/effect/config-service" import { Flag } from "@opencode-ai/core/flag/flag" -import { Config as EffectConfig, Context, Option, Redacted } from "effect" +import { Config as EffectConfig, Context, Layer, Option, Redacted } from "effect" export type Credentials = { password?: string @@ -14,10 +14,31 @@ export type DecodedCredentials = { readonly password: Redacted.Redacted } +// Read auth config from `Flag.*` instead of via Effect's `Config` system. +// Effect's generated `defaultLayer` reads `Config.string(...)` once and is +// memoized by `Layer` identity, so subsequent runtime mutation of +// `process.env` is never observed by the resolved layer. Tests and dynamic +// deploys mutate `Flag.OPENCODE_SERVER_*` at runtime; matching Hono's +// behavior requires re-reading `Flag.*` whenever a fresh listener (i.e. a +// fresh `memoMap`) is built. `Layer.sync` defers the read until layer-build +// time, so each new listener picks up the current `Flag.*` values. +// +// Note: this is per-listener, not per-request. Hono's `AuthMiddleware` reads +// `Flag.*` on every request; if exact per-request parity is ever required, +// the middleware itself must read `Flag.*` rather than yielding `Config`. export class Config extends ConfigService.Service()("@opencode/ServerAuthConfig", { password: EffectConfig.string("OPENCODE_SERVER_PASSWORD").pipe(EffectConfig.option), username: EffectConfig.string("OPENCODE_SERVER_USERNAME").pipe(EffectConfig.withDefault("opencode")), -}) {} +}) { + static override get defaultLayer() { + return Layer.sync(this, () => + this.of({ + password: Flag.OPENCODE_SERVER_PASSWORD ? Option.some(Flag.OPENCODE_SERVER_PASSWORD) : Option.none(), + username: Flag.OPENCODE_SERVER_USERNAME ?? "opencode", + }), + ) + } +} export type Info = Context.Service.Shape diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index a3754c2e19..a40a7fd272 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -45,9 +45,9 @@ import { lazy } from "@/util/lazy" import { Vcs } from "@/project/vcs" import { Worktree } from "@/worktree" import { Workspace } from "@/control-plane/workspace" +import { ServerAuth } from "@/server/auth" import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@/server/cors" import { serveUIEffect } from "@/server/shared/ui" -import { ServerAuth } from "@/server/auth" import { InstanceHttpApi, RootHttpApi } from "./api" import { authorizationLayer, authorizationRouterMiddleware } from "./middleware/authorization" import { EventApi, eventHandlers } from "./event" diff --git a/packages/opencode/test/server/httpapi-listen.test.ts b/packages/opencode/test/server/httpapi-listen.test.ts index af4c0a01ce..215300bc95 100644 --- a/packages/opencode/test/server/httpapi-listen.test.ts +++ b/packages/opencode/test/server/httpapi-listen.test.ts @@ -40,8 +40,8 @@ async function startListener(backend: "effect-httpapi" | "hono" = "effect-httpap return Server.listen({ hostname: "127.0.0.1", port: 0 }) } -async function startNoAuthListener() { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = false +async function startNoAuthListener(backend: "effect-httpapi" | "hono" = "effect-httpapi") { + Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = backend === "effect-httpapi" Flag.OPENCODE_SERVER_PASSWORD = undefined Flag.OPENCODE_SERVER_USERNAME = auth.username delete process.env.OPENCODE_SERVER_PASSWORD @@ -257,18 +257,20 @@ describe("HttpApi Server.listen", () => { } }) - testPty("keeps PTY websocket tickets optional when server auth is disabled", async () => { - await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) - const listener = await startNoAuthListener() - try { - const info = await createCat(listener, tmp.path) - const ws = await openSocket(socketURL(listener, info.id, tmp.path)) - const message = waitForMessage(ws, (message) => message.includes("ping-no-auth")) - ws.send("ping-no-auth\n") - expect(await message).toContain("ping-no-auth") - ws.close(1000) - } finally { - await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) - } - }) + for (const backend of ["effect-httpapi", "hono"] as const) { + testPty(`keeps PTY websocket tickets optional when server auth is disabled (${backend})`, async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } }) + const listener = await startNoAuthListener(backend) + try { + const info = await createCat(listener, tmp.path) + const ws = await openSocket(socketURL(listener, info.id, tmp.path)) + const message = waitForMessage(ws, (message) => message.includes(`ping-no-auth-${backend}`)) + ws.send(`ping-no-auth-${backend}\n`) + expect(await message).toContain(`ping-no-auth-${backend}`) + ws.close(1000) + } finally { + await stop(listener, "timed out cleaning up no-auth listener").catch(() => undefined) + } + }) + } }) diff --git a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts index fd82e78639..0136f83ce9 100644 --- a/packages/opencode/test/server/httpapi-raw-route-auth.test.ts +++ b/packages/opencode/test/server/httpapi-raw-route-auth.test.ts @@ -1,8 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test" -import { ConfigProvider, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { Flag } from "@opencode-ai/core/flag/flag" -import { Instance } from "../../src/project/instance" 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" @@ -13,23 +11,19 @@ import * as Log from "@opencode-ai/core/util/log" void Log.init({ print: false }) -const originalHttpApi = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI +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 - 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 + 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), @@ -48,7 +42,9 @@ async function cancelBody(response: Response) { } afterEach(async () => { - Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = originalHttpApi + 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() }) diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index f364491ace..1f5d2fc9a1 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -1,7 +1,7 @@ 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 { ConfigProvider, Effect, Layer } from "effect" +import { Effect, Layer, Option } from "effect" import { HttpClient, HttpClientRequest, @@ -50,12 +50,10 @@ function app(input?: { password?: string; username?: string }) { const handler = HttpRouter.toWebHandler( ExperimentalHttpApiServer.routes.pipe( Layer.provide( - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_SERVER_PASSWORD: input?.password, - OPENCODE_SERVER_USERNAME: input?.username, - }), - ), + ServerAuth.Config.layer({ + password: input?.password ? Option.some(input.password) : Option.none(), + username: input?.username ?? "opencode", + }), ), ), { disableLogger: true }, @@ -71,6 +69,10 @@ function app(input?: { password?: string; username?: string }) { } function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer }) { + 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* () { @@ -79,17 +81,11 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client })) }), ).pipe( - Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))), + Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(authConfigLayer))), Layer.provide([ AppFileSystem.defaultLayer, input?.client ?? httpClient(new Response("ui")), HttpServer.layerServices, - ConfigProvider.layer( - ConfigProvider.fromUnknown({ - OPENCODE_SERVER_PASSWORD: input?.password, - OPENCODE_SERVER_USERNAME: input?.username, - }), - ), ]), ), { disableLogger: true },