mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-17 10:02:51 +00:00
refactor(flags): move embedded web ui flag to runtime flags (#27613)
This commit is contained in:
@@ -62,7 +62,6 @@ export const Flag = {
|
||||
OPENCODE_ENABLE_PARALLEL: truthy("OPENCODE_ENABLE_PARALLEL") || truthy("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
OPENCODE_MODELS_URL: process.env["OPENCODE_MODELS_URL"],
|
||||
OPENCODE_MODELS_PATH: process.env["OPENCODE_MODELS_PATH"],
|
||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
OPENCODE_DB: process.env["OPENCODE_DB"],
|
||||
OPENCODE_DISABLE_CHANNEL_DB: truthy("OPENCODE_DISABLE_CHANNEL_DB"),
|
||||
OPENCODE_SKIP_MIGRATIONS: truthy("OPENCODE_SKIP_MIGRATIONS"),
|
||||
|
||||
@@ -14,6 +14,7 @@ const enabledByExperimental = (name: string) =>
|
||||
export class Service extends ConfigService.Service<Service>()("@opencode/RuntimeFlags", {
|
||||
pure: bool("OPENCODE_PURE"),
|
||||
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
disableEmbeddedWebUi: bool("OPENCODE_DISABLE_EMBEDDED_WEB_UI"),
|
||||
disableClaudeCodeSkills: Config.all({
|
||||
broad: bool("OPENCODE_DISABLE_CLAUDE_CODE"),
|
||||
direct: bool("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { File } from "@/file"
|
||||
import { FileWatcher } from "@/file/watcher"
|
||||
import { Ripgrep } from "@/file/ripgrep"
|
||||
import { Format } from "@/format"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Permission } from "@/permission"
|
||||
@@ -172,7 +173,10 @@ const uiRoute = HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
yield* router.add("*", "/*", (request) => serveUIEffect(request, { fs, client }))
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
yield* router.add("*", "/*", (request) =>
|
||||
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
|
||||
)
|
||||
}),
|
||||
).pipe(Layer.provide(authOnlyRouterLayer))
|
||||
|
||||
@@ -206,6 +210,7 @@ export function createRoutes(corsOptions?: CorsOptions) {
|
||||
PtyTicket.defaultLayer,
|
||||
Question.defaultLayer,
|
||||
Ripgrep.defaultLayer,
|
||||
RuntimeFlags.defaultLayer,
|
||||
Session.defaultLayer,
|
||||
SessionCompaction.defaultLayer,
|
||||
SessionPrompt.defaultLayer,
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Stream } from "effect"
|
||||
import { HttpBody, HttpClient, HttpClientRequest, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createHash } from "node:crypto"
|
||||
import { ProxyUtil } from "../proxy-util"
|
||||
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
let embeddedUIPromise: Promise<Record<string, string> | null> | undefined
|
||||
|
||||
export const UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||
|
||||
@@ -45,9 +41,11 @@ export function upstreamURL(path: string) {
|
||||
return new URL(path, UI_UPSTREAM).toString()
|
||||
}
|
||||
|
||||
export function embeddedUI() {
|
||||
if (Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI) return Promise.resolve(null)
|
||||
return embeddedUIPromise
|
||||
export function embeddedUI(disableEmbeddedWebUi: boolean) {
|
||||
if (disableEmbeddedWebUi) return Promise.resolve(null)
|
||||
return (embeddedUIPromise ??=
|
||||
// @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null))
|
||||
}
|
||||
|
||||
function notFound() {
|
||||
@@ -79,10 +77,10 @@ export function serveEmbeddedUIEffect(
|
||||
|
||||
export function serveUIEffect(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient },
|
||||
services: { fs: AppFileSystem.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean },
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI())
|
||||
const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi))
|
||||
const path = new URL(request.url, "http://localhost").pathname
|
||||
|
||||
if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI)
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("RuntimeFlags", () => {
|
||||
fromConfig({
|
||||
OPENCODE_PURE: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_DISABLE_EMBEDDED_WEB_UI: "true",
|
||||
OPENCODE_EXPERIMENTAL: "true",
|
||||
OPENCODE_ENABLE_EXA: "true",
|
||||
OPENCODE_ENABLE_PARALLEL: "true",
|
||||
@@ -28,6 +29,7 @@ describe("RuntimeFlags", () => {
|
||||
|
||||
expect(flags.pure).toBe(true)
|
||||
expect(flags.disableDefaultPlugins).toBe(true)
|
||||
expect(flags.disableEmbeddedWebUi).toBe(true)
|
||||
expect(flags.enableExa).toBe(true)
|
||||
expect(flags.enableParallel).toBe(true)
|
||||
expect(flags.enableExperimentalModels).toBe(true)
|
||||
@@ -67,6 +69,7 @@ describe("RuntimeFlags", () => {
|
||||
|
||||
expect(flags.pure).toBe(false)
|
||||
expect(flags.disableDefaultPlugins).toBe(true)
|
||||
expect(flags.disableEmbeddedWebUi).toBe(false)
|
||||
expect(flags.disableClaudeCodeSkills).toBe(false)
|
||||
expect(flags.enableExa).toBe(false)
|
||||
expect(flags.experimentalIconDiscovery).toBe(false)
|
||||
@@ -186,6 +189,7 @@ describe("RuntimeFlags", () => {
|
||||
|
||||
expect(flags.pure).toBe(false)
|
||||
expect(flags.disableDefaultPlugins).toBe(false)
|
||||
expect(flags.disableEmbeddedWebUi).toBe(false)
|
||||
expect(flags.disableClaudeCodeSkills).toBe(false)
|
||||
expect(flags.enableExa).toBe(false)
|
||||
expect(flags.experimentalIconDiscovery).toBe(false)
|
||||
|
||||
@@ -13,11 +13,11 @@ import {
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
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"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
@@ -25,7 +25,6 @@ void Log.init({ print: false })
|
||||
const testStateLayer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const original = {
|
||||
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,
|
||||
@@ -34,7 +33,6 @@ const testStateLayer = Layer.effectDiscard(
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => {
|
||||
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)
|
||||
@@ -44,7 +42,7 @@ const testStateLayer = Layer.effectDiscard(
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(testStateLayer, AppFileSystem.defaultLayer, RuntimeFlags.layer()))
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
@@ -82,19 +80,28 @@ function app(input?: { password?: string; username?: string }) {
|
||||
}
|
||||
}
|
||||
|
||||
function uiApp(input?: { password?: string; username?: string; client?: Layer.Layer<HttpClient.HttpClient> }) {
|
||||
function uiApp(input?: {
|
||||
password?: string
|
||||
username?: string
|
||||
client?: Layer.Layer<HttpClient.HttpClient>
|
||||
disableEmbeddedWebUi?: boolean
|
||||
}) {
|
||||
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 }))
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
yield* router.add("*", "/*", (request) =>
|
||||
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide(authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.defaultLayer))),
|
||||
Layer.provide([
|
||||
AppFileSystem.defaultLayer,
|
||||
input?.client ?? httpClient(new Response("ui")),
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: input?.disableEmbeddedWebUi ?? false }),
|
||||
HttpServer.layerServices,
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
@@ -120,6 +127,48 @@ function uiApp(input?: { password?: string; username?: string; client?: Layer.La
|
||||
}
|
||||
}
|
||||
|
||||
function routeOrderingApp() {
|
||||
let proxiedUrl: string | undefined
|
||||
const handler = HttpRouter.toWebHandler(
|
||||
HttpRouter.use((router) =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
yield* router.add("GET", "/session/:sessionID", () =>
|
||||
Effect.succeed(HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 })),
|
||||
)
|
||||
yield* router.add("*", "/*", (request) =>
|
||||
serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }),
|
||||
)
|
||||
}),
|
||||
).pipe(
|
||||
Layer.provide([
|
||||
AppFileSystem.defaultLayer,
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
|
||||
httpClient(new Response("ui"), (request) => {
|
||||
proxiedUrl = request.url
|
||||
}),
|
||||
HttpServer.layerServices,
|
||||
]),
|
||||
),
|
||||
{ disableLogger: true },
|
||||
).handler
|
||||
return {
|
||||
proxiedUrl: () => proxiedUrl,
|
||||
request(input: string | URL | Request, init?: RequestInit) {
|
||||
return Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
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,
|
||||
@@ -137,10 +186,10 @@ function responseText(response: Response) {
|
||||
describe("HttpApi UI fallback", () => {
|
||||
it.live("serves the web UI through the experimental backend", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
let proxiedUrl: string | undefined
|
||||
|
||||
const response = yield* uiApp({
|
||||
disableEmbeddedWebUi: true,
|
||||
client: httpClient(
|
||||
new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } }),
|
||||
(request) => {
|
||||
@@ -158,35 +207,39 @@ describe("HttpApi UI fallback", () => {
|
||||
|
||||
it.live("strips upstream transfer encoding headers from proxied assets", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
let proxiedUrl: string | undefined
|
||||
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/assets/app.js")), {
|
||||
fs,
|
||||
client,
|
||||
disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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",
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
Layer.mergeAll(
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
|
||||
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),
|
||||
@@ -206,29 +259,32 @@ describe("HttpApi UI fallback", () => {
|
||||
// causing browsers to fail with `ERR_INVALID_CHUNKED_ENCODING`.
|
||||
it.live("strips upstream transfer-encoding header from proxied assets", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
return yield* serveUIEffect(HttpServerRequest.fromWeb(new Request("http://localhost/")), {
|
||||
fs,
|
||||
client,
|
||||
disableEmbeddedWebUi: flags.disableEmbeddedWebUi,
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response("<html>opencode</html>", {
|
||||
headers: {
|
||||
"transfer-encoding": "chunked",
|
||||
"content-type": "text/html",
|
||||
},
|
||||
}),
|
||||
Layer.mergeAll(
|
||||
RuntimeFlags.layer({ disableEmbeddedWebUi: true }),
|
||||
Layer.succeed(
|
||||
HttpClient.HttpClient,
|
||||
HttpClient.make((request) =>
|
||||
Effect.succeed(
|
||||
HttpClientResponse.fromWeb(
|
||||
request,
|
||||
new Response("<html>opencode</html>", {
|
||||
headers: {
|
||||
"transfer-encoding": "chunked",
|
||||
"content-type": "text/html",
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -301,17 +357,21 @@ describe("HttpApi UI fallback", () => {
|
||||
|
||||
it.live("keeps matched API routes ahead of the UI fallback", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Effect.promise(() => Promise.resolve(Server.Default().app.request("/session/ses_nope")))
|
||||
const server = routeOrderingApp()
|
||||
const response = yield* server.request("/session/ses_nope")
|
||||
|
||||
expect(response.status).toBe(404)
|
||||
expect(server.proxiedUrl()).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("requires server password for the web UI", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
|
||||
const response = yield* uiApp({ password: "secret", username: "opencode" }).request("/")
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
}).request("/")
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
|
||||
@@ -320,11 +380,10 @@ describe("HttpApi UI fallback", () => {
|
||||
|
||||
it.live("accepts auth token for the web UI", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
client: httpClient(new Response("<html>opencode</html>", { headers: { "content-type": "text/html" } })),
|
||||
}).request(`/?auth_token=${btoa("opencode:secret")}`)
|
||||
|
||||
@@ -335,9 +394,11 @@ describe("HttpApi UI fallback", () => {
|
||||
|
||||
it.live("accepts basic auth for the web UI", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
|
||||
const response = yield* uiApp({ password: "secret", username: "opencode" }).request("/", {
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
}).request("/", {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
})
|
||||
|
||||
@@ -352,12 +413,11 @@ describe("HttpApi UI fallback", () => {
|
||||
// should bypass auth.
|
||||
it.live("serves the PWA manifest without auth even when a server password is set", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI = true
|
||||
|
||||
for (const path of ["/site.webmanifest", "/web-app-manifest-192x192.png", "/web-app-manifest-512x512.png"]) {
|
||||
const response = yield* uiApp({
|
||||
password: "secret",
|
||||
username: "opencode",
|
||||
disableEmbeddedWebUi: true,
|
||||
client: httpClient(new Response("ok")),
|
||||
}).request(path)
|
||||
expect(response.status).not.toBe(401)
|
||||
|
||||
Reference in New Issue
Block a user