Browse Source

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.
Kit Langton 3 months ago
parent
commit
da5e29b320

+ 23 - 2
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<Config>()("@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<typeof Config>
 

+ 1 - 1
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"

+ 18 - 16
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)
+      }
+    })
+  }
 })

+ 13 - 17
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()
 })

+ 10 - 14
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<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* () {
@@ -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 },