don't use proxy server
This commit is contained in:
@@ -39,7 +39,7 @@ export default Runtime.handler(Commands, (input) =>
|
||||
)
|
||||
preflight.loading()
|
||||
const endpoint = yield* Ref.make(server.endpoint)
|
||||
const web = yield* Effect.cached(WebUi.start(endpoint))
|
||||
const web = Ref.get(endpoint).pipe(Effect.map(WebUi.url))
|
||||
const config = yield* Config.Service
|
||||
const npm = yield* Npm.Service
|
||||
const fileSystem = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export * as ServerProcess from "./server-process"
|
||||
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { Service, type DiscoverOptions, type Endpoint, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { randomBytes, randomUUID } from "node:crypto"
|
||||
import path from "node:path"
|
||||
import { Effect, FileSystem, Option, Redacted, Ref, Schedule, Schema } from "effect"
|
||||
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
@@ -70,6 +70,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
: randomBytes(32).toString("base64url")
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
const instanceID = randomUUID()
|
||||
const transform = yield* WebUi.handler()
|
||||
const server = yield* start(
|
||||
{
|
||||
app: {
|
||||
@@ -77,8 +78,8 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
version: OPENCODE_VERSION,
|
||||
channel: OPENCODE_CHANNEL,
|
||||
},
|
||||
hostname: foreground ? "127.0.0.1" : hostname,
|
||||
port: foreground ? 0 : port,
|
||||
hostname,
|
||||
port,
|
||||
password,
|
||||
simulation: truthy(process.env.OPENCODE_SIMULATE),
|
||||
database: {
|
||||
@@ -124,6 +125,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
|
||||
}),
|
||||
},
|
||||
transform,
|
||||
).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (serviceOptions === undefined || port === undefined || !addressInUse(error)) return Effect.fail(error)
|
||||
@@ -143,23 +145,12 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
}),
|
||||
)
|
||||
if (server === undefined) return
|
||||
const url =
|
||||
foreground
|
||||
? yield* WebUi.serve(
|
||||
yield* Ref.make<Endpoint>({
|
||||
url: HttpServer.formatAddress(server.address),
|
||||
auth: { type: "basic", username: "opencode", password },
|
||||
}),
|
||||
{ hostname, port, password },
|
||||
)
|
||||
: HttpServer.formatAddress(server.address)
|
||||
const url = HttpServer.formatAddress(server.address)
|
||||
console.log(options.mode === "stdio" ? JSON.stringify({ url }) : `server listening on ${url}`)
|
||||
if (foreground && !environmentPassword) console.log(`server password ${password}`)
|
||||
if (options.mode === "web") {
|
||||
const target = new URL(url)
|
||||
const target = new URL(WebUi.url({ url, auth: { type: "basic", username: "opencode", password } }))
|
||||
if (target.hostname === "0.0.0.0" || target.hostname === "::") target.hostname = "localhost"
|
||||
target.username = "opencode"
|
||||
target.password = password
|
||||
yield* Effect.promise(() => open(target.toString()).catch(() => undefined))
|
||||
}
|
||||
const updater = yield* Updater.Service
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
import { NodeHttpServer, NodeSocket } from "@effect/platform-node"
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { ServerInfo } from "@opencode-ai/server/server-info"
|
||||
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Context, Effect, Exit, Ref, Scope, Stream } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpBody,
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
HttpServer,
|
||||
HttpServerError,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { createHash, randomBytes, timingSafeEqual } from "node:crypto"
|
||||
import { createHash } from "node:crypto"
|
||||
import { readFile } from "node:fs/promises"
|
||||
import { createServer } from "node:http"
|
||||
import { load } from "../app-assets"
|
||||
|
||||
const UI_UPSTREAM = new URL("https://app.opencode.ai")
|
||||
const COOKIE = "opencode-web"
|
||||
const hop = new Set([
|
||||
"connection",
|
||||
"keep-alive",
|
||||
@@ -33,109 +28,28 @@ const hop = new Set([
|
||||
"host",
|
||||
])
|
||||
|
||||
export const start = Effect.fn("cli.web-ui.start")(function* (
|
||||
endpoint: Ref.Ref<Endpoint>,
|
||||
options?: { readonly assets?: Readonly<Record<string, string>> },
|
||||
) {
|
||||
const token = randomBytes(32).toString("base64url")
|
||||
const origin = yield* listen(endpoint, {
|
||||
auth: { type: "cookie", token },
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
assets: options?.assets,
|
||||
})
|
||||
return `${origin}/?cli_token=${encodeURIComponent(token)}`
|
||||
})
|
||||
|
||||
export const serve = Effect.fn("cli.web-ui.serve")(function* (
|
||||
endpoint: Ref.Ref<Endpoint>,
|
||||
options: {
|
||||
readonly hostname: string
|
||||
readonly port?: number
|
||||
readonly password: string
|
||||
readonly assets?: Readonly<Record<string, string>>
|
||||
},
|
||||
) {
|
||||
return yield* listen(endpoint, {
|
||||
auth: { type: "basic", password: options.password },
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
assets: options.assets,
|
||||
})
|
||||
})
|
||||
|
||||
const listen = Effect.fnUntraced(function* (
|
||||
endpoint: Ref.Ref<Endpoint>,
|
||||
options: {
|
||||
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
|
||||
readonly hostname: string
|
||||
readonly port?: number
|
||||
readonly assets?: Readonly<Record<string, string>>
|
||||
},
|
||||
) {
|
||||
const assets = options.assets ?? (yield* Effect.promise(load))
|
||||
const client = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
|
||||
const websocket = yield* Socket.WebSocketConstructor.pipe(Effect.provide(NodeSocket.layerWebSocketConstructorWS))
|
||||
const server = yield* bind(options.hostname, options.port)
|
||||
const origin = formatAddress(server.http.address)
|
||||
const urls = ServerInfo.connectionURLs(origin, options.hostname)
|
||||
yield* server.http.serve(
|
||||
handle({ endpoint, auth: options.auth, assets, client, websocket, origin, urls }),
|
||||
).pipe(Effect.provideService(Scope.Scope, server.scope))
|
||||
return origin
|
||||
})
|
||||
|
||||
function handle(input: {
|
||||
readonly endpoint: Ref.Ref<Endpoint>
|
||||
readonly auth: { readonly type: "cookie"; readonly token: string } | { readonly type: "basic"; readonly password: string }
|
||||
readonly assets: Readonly<Record<string, string>>
|
||||
readonly client: HttpClient.HttpClient
|
||||
readonly websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>
|
||||
readonly origin: string
|
||||
readonly urls: ReadonlyArray<string>
|
||||
export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: {
|
||||
readonly assets?: Readonly<Record<string, string>>
|
||||
}) {
|
||||
return Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, input.origin)
|
||||
if (input.auth.type === "cookie" && request.headers.host !== new URL(input.origin).host)
|
||||
return HttpServerResponse.empty({ status: 403 })
|
||||
const queryToken = url.searchParams.get(input.auth.type === "cookie" ? "cli_token" : "auth_token")
|
||||
const queryAuthorized = input.auth.type === "cookie" && matches(queryToken, input.auth.token)
|
||||
if (input.auth.type === "cookie" && queryToken !== null && request.headers.upgrade?.toLowerCase() !== "websocket") {
|
||||
if (!queryAuthorized) return HttpServerResponse.empty({ status: 401 })
|
||||
url.searchParams.delete("cli_token")
|
||||
return HttpServerResponse.empty({
|
||||
status: 302,
|
||||
headers: {
|
||||
location: url.pathname + url.search + url.hash,
|
||||
"set-cookie": `${COOKIE}=${input.auth.token}; HttpOnly; SameSite=Strict; Path=/`,
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
})
|
||||
}
|
||||
if (input.auth.type === "cookie" && !queryAuthorized && !authorized(request.headers.cookie, input.auth.token))
|
||||
return unauthorized(false)
|
||||
if (
|
||||
input.auth.type === "basic" &&
|
||||
!hasPtyTicket(url) &&
|
||||
!basicAuthorized(request.headers.authorization, queryToken, input.auth.password)
|
||||
const assets = options?.assets ?? (yield* Effect.promise(load))
|
||||
const client = yield* HttpClient.HttpClient.pipe(Effect.provide(FetchHttpClient.layer))
|
||||
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
|
||||
api.pipe(
|
||||
Effect.catchIf(
|
||||
isRouteNotFound,
|
||||
() =>
|
||||
HttpServerRequest.HttpServerRequest.pipe(
|
||||
Effect.flatMap((request) => serveUI(client, request, new URL(request.url, "http://localhost"), assets)),
|
||||
),
|
||||
),
|
||||
)
|
||||
return unauthorized(true)
|
||||
url.searchParams.delete("cli_token")
|
||||
url.searchParams.delete("auth_token")
|
||||
const requestOrigin = request.headers.host ? `http://${request.headers.host}` : input.origin
|
||||
if (request.headers.origin !== undefined && request.headers.origin !== requestOrigin)
|
||||
return HttpServerResponse.empty({ status: 403 })
|
||||
})
|
||||
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
|
||||
const endpoint = yield* Ref.get(input.endpoint)
|
||||
const target = new URL(url.pathname + url.search, endpoint.url)
|
||||
if (request.headers.upgrade?.toLowerCase() === "websocket")
|
||||
return yield* proxyWebSocket(request, target, input.websocket)
|
||||
return yield* proxyHttp(input.client, request, target, Service.headers(endpoint), false, input.urls)
|
||||
}
|
||||
return yield* serveUI(input.client, request, url, input.assets)
|
||||
})
|
||||
export function url(endpoint: Endpoint) {
|
||||
const target = new URL(endpoint.url)
|
||||
if (endpoint.auth)
|
||||
target.searchParams.set("auth_token", btoa(`${endpoint.auth.username}:${endpoint.auth.password}`))
|
||||
return target.toString()
|
||||
}
|
||||
|
||||
function serveUI(
|
||||
@@ -146,7 +60,7 @@ function serveUI(
|
||||
) {
|
||||
const key = url.pathname.replace(/^\//, "")
|
||||
const file = assets[key] ?? assets["index.html"]
|
||||
if (!file) return proxyHttp(client, request, new URL(url.pathname + url.search, UI_UPSTREAM), undefined, true)
|
||||
if (!file) return proxyHttp(client, request, new URL(url.pathname + url.search, UI_UPSTREAM))
|
||||
if (request.method !== "GET" && request.method !== "HEAD") return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
|
||||
return Effect.tryPromise(() => readFile(file)).pipe(
|
||||
Effect.map((body) => {
|
||||
@@ -164,18 +78,11 @@ function serveUI(
|
||||
)
|
||||
}
|
||||
|
||||
function proxyHttp(
|
||||
client: HttpClient.HttpClient,
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
target: URL,
|
||||
extra: HeadersInit | undefined,
|
||||
ui = false,
|
||||
publicURLs?: ReadonlyArray<string>,
|
||||
) {
|
||||
function proxyHttp(client: HttpClient.HttpClient, request: HttpServerRequest.HttpServerRequest, target: URL) {
|
||||
return client
|
||||
.execute(
|
||||
HttpClientRequest.make(request.method as never)(target, {
|
||||
headers: proxyHeaders(request.headers, extra),
|
||||
headers: proxyHeaders(request.headers),
|
||||
body: requestBody(request),
|
||||
}),
|
||||
)
|
||||
@@ -185,9 +92,7 @@ function proxyHttp(
|
||||
headers.delete("content-encoding")
|
||||
headers.delete("content-length")
|
||||
headers.delete("set-cookie")
|
||||
if (publicURLs && target.pathname === "/api/server")
|
||||
return Effect.succeed(HttpServerResponse.jsonUnsafe({ urls: publicURLs }, { status: response.status }))
|
||||
if (ui && response.headers["content-type"]?.includes("text/html")) {
|
||||
if (response.headers["content-type"]?.includes("text/html")) {
|
||||
return response.text.pipe(
|
||||
Effect.map((body) => {
|
||||
headers.set("content-security-policy", cspForHtml(body))
|
||||
@@ -196,7 +101,7 @@ function proxyHttp(
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (ui) headers.set("content-security-policy", csp())
|
||||
headers.set("content-security-policy", csp())
|
||||
return Effect.succeed(
|
||||
HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), {
|
||||
status: response.status,
|
||||
@@ -208,34 +113,6 @@ function proxyHttp(
|
||||
)
|
||||
}
|
||||
|
||||
function proxyWebSocket(
|
||||
request: HttpServerRequest.HttpServerRequest,
|
||||
target: URL,
|
||||
websocket: Context.Service.Shape<typeof Socket.WebSocketConstructor>,
|
||||
) {
|
||||
target.protocol = target.protocol === "https:" ? "wss:" : "ws:"
|
||||
return Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const inbound = yield* Effect.orDie(request.upgrade)
|
||||
const outbound = yield* Socket.makeWebSocket(target.toString(), {
|
||||
protocols: protocols(request.headers["sec-websocket-protocol"]),
|
||||
closeCodeIsError: () => false,
|
||||
}).pipe(Effect.provideService(Socket.WebSocketConstructor, websocket))
|
||||
const writeInbound = yield* inbound.writer
|
||||
const writeOutbound = yield* outbound.writer
|
||||
const close = Effect.all(
|
||||
[writeInbound(new Socket.CloseEvent()), writeOutbound(new Socket.CloseEvent())],
|
||||
{ concurrency: "unbounded", discard: true },
|
||||
).pipe(Effect.timeout("1 second"), Effect.catch(() => Effect.void))
|
||||
yield* Effect.raceFirst(
|
||||
outbound.runRaw((message) => writeInbound(typeof message === "string" ? message : message.slice())),
|
||||
inbound.runRaw((message) => writeOutbound(typeof message === "string" ? message : message.slice())),
|
||||
).pipe(Effect.catch(() => Effect.void), Effect.ensuring(close))
|
||||
return HttpServerResponse.empty()
|
||||
}).pipe(Effect.orDie),
|
||||
)
|
||||
}
|
||||
|
||||
function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
if (request.method === "GET" || request.method === "HEAD") return HttpBody.empty
|
||||
if (request.source instanceof Request && request.source.body === null) return HttpBody.empty
|
||||
@@ -243,55 +120,18 @@ function requestBody(request: HttpServerRequest.HttpServerRequest) {
|
||||
return HttpBody.stream(request.stream, request.headers["content-type"], length ? Number(length) : undefined)
|
||||
}
|
||||
|
||||
function proxyHeaders(input: Record<string, string>, extra?: HeadersInit) {
|
||||
function proxyHeaders(input: Record<string, string>) {
|
||||
const headers = new Headers(input)
|
||||
for (const key of input.connection?.split(",").map((item) => item.trim()) ?? []) headers.delete(key)
|
||||
for (const key of hop) headers.delete(key)
|
||||
headers.delete("accept-encoding")
|
||||
headers.delete("authorization")
|
||||
headers.delete("cookie")
|
||||
if (extra) for (const [key, value] of new Headers(extra)) headers.set(key, value)
|
||||
return headers
|
||||
}
|
||||
|
||||
function authorized(cookie: string | undefined, token: string) {
|
||||
const value = cookie
|
||||
?.split(";")
|
||||
.map((item) => item.trim().split("="))
|
||||
.find(([key]) => key === COOKIE)?.[1]
|
||||
return matches(value ?? null, token)
|
||||
}
|
||||
|
||||
function basicAuthorized(header: string | undefined, queryToken: string | null, password: string) {
|
||||
const expected = Buffer.from(`opencode:${password}`).toString("base64")
|
||||
if (matches(queryToken, expected)) return true
|
||||
if (!header?.startsWith("Basic ")) return false
|
||||
return matches(header.slice("Basic ".length), expected)
|
||||
}
|
||||
|
||||
function hasPtyTicket(url: URL) {
|
||||
return /^\/api\/pty\/[^/]+\/connect$/.test(url.pathname) && !!url.searchParams.get("ticket")
|
||||
}
|
||||
|
||||
function unauthorized(basic: boolean) {
|
||||
return HttpServerResponse.empty({
|
||||
status: 401,
|
||||
headers: basic ? { "www-authenticate": 'Basic realm="Secure Area"' } : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
function matches(value: string | null, expected: string) {
|
||||
if (value === null) return false
|
||||
const left = Buffer.from(value)
|
||||
const right = Buffer.from(expected)
|
||||
return left.length === right.length && timingSafeEqual(left, right)
|
||||
}
|
||||
|
||||
function protocols(value: string | undefined) {
|
||||
return value
|
||||
?.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
function isRouteNotFound(error: unknown) {
|
||||
return error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound"
|
||||
}
|
||||
|
||||
function csp(hash = "") {
|
||||
@@ -303,48 +143,4 @@ function cspForHtml(body: string) {
|
||||
return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "")
|
||||
}
|
||||
|
||||
function bind(hostname: string, port: number | undefined) {
|
||||
if (port !== undefined) return bindPort(hostname, port)
|
||||
const next = (candidate: number): ReturnType<typeof bindPort> =>
|
||||
bindPort(hostname, candidate).pipe(
|
||||
Effect.catch((error) =>
|
||||
candidate < 65_535 && addressInUse(error) ? next(candidate + 1) : Effect.fail(error),
|
||||
),
|
||||
)
|
||||
return next(4096)
|
||||
}
|
||||
|
||||
function bindPort(hostname: string, port: number) {
|
||||
return Effect.gen(function* () {
|
||||
const sockets = new Set<{ destroy(): void }>()
|
||||
const server = createServer()
|
||||
const scope = yield* Scope.make()
|
||||
server.on("connection", (socket) => {
|
||||
sockets.add(socket)
|
||||
socket.once("close", () => sockets.delete(socket))
|
||||
})
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.sync(() => sockets.forEach((socket) => socket.destroy())).pipe(
|
||||
Effect.andThen(Scope.close(scope, Exit.void)),
|
||||
),
|
||||
)
|
||||
const http = yield* NodeHttpServer.make(() => server, { host: hostname, port }).pipe(
|
||||
Effect.provideService(Scope.Scope, scope),
|
||||
)
|
||||
return { http, scope }
|
||||
})
|
||||
}
|
||||
|
||||
function formatAddress(address: HttpServer.Address) {
|
||||
if (address._tag === "UnixAddress") return HttpServer.formatAddress(address)
|
||||
const hostname = address.hostname.includes(":") ? `[${address.hostname}]` : address.hostname
|
||||
return `http://${hostname}:${address.port}`
|
||||
}
|
||||
|
||||
function addressInUse(error: unknown): boolean {
|
||||
if (typeof error !== "object" || error === null) return false
|
||||
if ("code" in error && error.code === "EADDRINUSE") return true
|
||||
return "cause" in error && addressInUse(error.cause)
|
||||
}
|
||||
|
||||
export * as WebUi from "./web-ui"
|
||||
|
||||
@@ -1,240 +1,71 @@
|
||||
import { NodeHttpServer } from "@effect/platform-node"
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import { WebUi } from "../src/services/web-ui"
|
||||
import type { Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { Effect, Ref } from "effect"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import { createServer } from "node:http"
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import path from "node:path"
|
||||
import { WebUi } from "../src/services/web-ui"
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
|
||||
afterAll(() => rm(root, { recursive: true, force: true }))
|
||||
|
||||
describe("TUI web UI", () => {
|
||||
test("bootstraps a private browser session and proxies the current API endpoint", async () => {
|
||||
describe("web UI", () => {
|
||||
test("falls back from API routes to assets and the SPA index", async () => {
|
||||
const index = path.join(root, "index.html")
|
||||
const asset = path.join(root, "app.js")
|
||||
await writeFile(index, "<html><body>embedded</body></html>")
|
||||
const first = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: () => Response.json({ server: "first" }),
|
||||
})
|
||||
const second = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: () => Response.json({ server: "second" }),
|
||||
})
|
||||
await writeFile(asset, "console.log('embedded')")
|
||||
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: first.url.toString() })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const origin = new URL(launch).origin
|
||||
|
||||
expect((yield* Effect.promise(() => fetch(origin))).status).toBe(401)
|
||||
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
|
||||
expect(bootstrap.status).toBe(302)
|
||||
expect(bootstrap.headers.get("location")).toBe("/")
|
||||
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
|
||||
expect(cookie).toStartWith("opencode-web=")
|
||||
|
||||
const page = yield* Effect.promise(() => fetch(origin, { headers: { cookie: cookie ?? "" } }))
|
||||
expect(yield* Effect.promise(() => page.text())).toContain("embedded")
|
||||
expect(page.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
|
||||
const before = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
|
||||
expect(yield* Effect.promise(() => before.json())).toEqual({ server: "first" })
|
||||
yield* Ref.set(endpoint, { url: second.url.toString() })
|
||||
const after = yield* Effect.promise(() => fetch(`${origin}/api/health`, { headers: { cookie: cookie ?? "" } }))
|
||||
expect(yield* Effect.promise(() => after.json())).toEqual({ server: "second" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
first.stop(true)
|
||||
second.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("rejects foreign origins", async () => {
|
||||
const index = path.join(root, "origin.html")
|
||||
await writeFile(index, "embedded")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const bootstrap = yield* Effect.promise(() => fetch(launch, { redirect: "manual" }))
|
||||
const cookie = bootstrap.headers.get("set-cookie")?.split(";", 1)[0]
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL(launch).origin, {
|
||||
headers: { cookie: cookie ?? "", origin: "https://example.com" },
|
||||
}),
|
||||
)
|
||||
expect(response.status).toBe(403)
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("forwards websocket messages", async () => {
|
||||
const index = path.join(root, "websocket.html")
|
||||
await writeFile(index, "embedded")
|
||||
const upstream = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request)) return
|
||||
return new Response(null, { status: 426 })
|
||||
},
|
||||
websocket: {
|
||||
message(socket, message) {
|
||||
socket.send(message)
|
||||
},
|
||||
},
|
||||
})
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const target = new URL("/api/pty/test/connect?ticket=test", launch)
|
||||
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
|
||||
target.protocol = "ws:"
|
||||
const message = yield* Effect.promise(
|
||||
() =>
|
||||
new Promise<string>((resolve, reject) => {
|
||||
const socket = new WebSocket(target)
|
||||
socket.addEventListener("open", () => socket.send("hello"), { once: true })
|
||||
socket.addEventListener("message", (event) => {
|
||||
resolve(event.data.toString())
|
||||
socket.close()
|
||||
}, { once: true })
|
||||
socket.addEventListener("error", reject, { once: true })
|
||||
}),
|
||||
)
|
||||
expect(message).toBe("hello")
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
upstream.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("serves foreground UI with server credentials", async () => {
|
||||
const index = path.join(root, "serve.html")
|
||||
await writeFile(index, "<html>foreground</html>")
|
||||
const upstream = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
new URL(request.url).pathname === "/api/server"
|
||||
? Response.json({ urls: ["http://private"] })
|
||||
: Response.json({ url: request.url }),
|
||||
})
|
||||
try {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make<Endpoint>({
|
||||
url: upstream.url.toString(),
|
||||
auth: { type: "basic", username: "opencode", password: "private" },
|
||||
})
|
||||
const origin = yield* WebUi.serve(endpoint, {
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
assets: { "index.html": index },
|
||||
})
|
||||
const denied = yield* Effect.promise(() => fetch(origin))
|
||||
expect(denied.status).toBe(401)
|
||||
expect(denied.headers.get("www-authenticate")).toContain("Basic")
|
||||
|
||||
const authorization = `Basic ${Buffer.from("opencode:secret").toString("base64")}`
|
||||
const page = yield* Effect.promise(() => fetch(origin, { headers: { authorization } }))
|
||||
expect(yield* Effect.promise(() => page.text())).toContain("foreground")
|
||||
|
||||
const token = Buffer.from("opencode:secret").toString("base64")
|
||||
const query = yield* Effect.promise(() => fetch(`${origin}/?auth_token=${encodeURIComponent(token)}`))
|
||||
expect(query.status).toBe(200)
|
||||
|
||||
const proxied = yield* Effect.promise(() =>
|
||||
fetch(`${origin}/api/health?auth_token=${encodeURIComponent(token)}&keep=yes`),
|
||||
)
|
||||
const proxiedBody = yield* Effect.promise(() => proxied.json())
|
||||
expect(new URL(proxiedBody.url).search).toBe("?keep=yes")
|
||||
|
||||
const info = yield* Effect.promise(() => fetch(`${origin}/api/server`, { headers: { authorization } }))
|
||||
expect(yield* Effect.promise(() => info.json())).toEqual({ urls: [origin] })
|
||||
}),
|
||||
),
|
||||
)
|
||||
} finally {
|
||||
upstream.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("formats localhost listeners as valid URLs", async () => {
|
||||
const index = path.join(root, "localhost.html")
|
||||
await writeFile(index, "embedded")
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: "http://127.0.0.1:1" })
|
||||
const origin = yield* WebUi.serve(endpoint, {
|
||||
hostname: "localhost",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
assets: { "index.html": index },
|
||||
})
|
||||
expect(new URL(origin).protocol).toBe("http:")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("shuts down with an active websocket", async () => {
|
||||
const index = path.join(root, "shutdown.html")
|
||||
await writeFile(index, "embedded")
|
||||
const upstream = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
fetch(request, server) {
|
||||
if (server.upgrade(request)) return
|
||||
return new Response(null, { status: 426 })
|
||||
},
|
||||
websocket: { message() {} },
|
||||
})
|
||||
let socket: WebSocket | undefined
|
||||
try {
|
||||
const run = Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const endpoint = yield* Ref.make({ url: upstream.url.toString() })
|
||||
const launch = yield* WebUi.start(endpoint, { assets: { "index.html": index } })
|
||||
const target = new URL("/api/pty/test/connect?ticket=test", launch)
|
||||
target.searchParams.set("cli_token", new URL(launch).searchParams.get("cli_token") ?? "")
|
||||
target.protocol = "ws:"
|
||||
socket = new WebSocket(target)
|
||||
yield* Effect.promise(
|
||||
() => new Promise<void>((resolve, reject) => {
|
||||
socket?.addEventListener("open", () => resolve(), { once: true })
|
||||
socket?.addEventListener("error", reject, { once: true })
|
||||
const transform = yield* WebUi.handler({ assets: { "index.html": index, "app.js": asset } })
|
||||
const http = yield* NodeHttpServer.make(createServer, { host: "127.0.0.1", port: 0 })
|
||||
yield* http.serve(
|
||||
transform(
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const pathname = new URL(request.url, "http://localhost").pathname
|
||||
if (pathname === "/api/health") return HttpServerResponse.jsonUnsafe({ healthy: true })
|
||||
if (pathname === "/api/missing")
|
||||
return HttpServerResponse.jsonUnsafe({ code: "missing" }, { status: 404 })
|
||||
return yield* Effect.fail(
|
||||
new HttpServerError.HttpServerError({
|
||||
reason: new HttpServerError.RouteNotFound({ request }),
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
await Promise.race([
|
||||
run,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("web UI shutdown timed out")), 2_000)),
|
||||
])
|
||||
} finally {
|
||||
socket?.close()
|
||||
upstream.stop(true)
|
||||
}
|
||||
),
|
||||
)
|
||||
const origin = HttpServer.formatAddress(http.address)
|
||||
|
||||
const health = yield* Effect.promise(() => fetch(`${origin}/api/health`))
|
||||
expect(yield* Effect.promise(() => health.json())).toEqual({ healthy: true })
|
||||
|
||||
const missing = yield* Effect.promise(() => fetch(`${origin}/api/missing`))
|
||||
expect(missing.status).toBe(404)
|
||||
expect(yield* Effect.promise(() => missing.json())).toEqual({ code: "missing" })
|
||||
|
||||
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
|
||||
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
|
||||
|
||||
const fallback = yield* Effect.promise(() => fetch(`${origin}/workspace/example`))
|
||||
expect(yield* Effect.promise(() => fallback.text())).toContain("embedded")
|
||||
expect(fallback.headers.get("content-security-policy")).toContain("default-src 'self'")
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("adds server credentials to browser URLs", () => {
|
||||
const target = new URL(
|
||||
WebUi.url({
|
||||
url: "http://localhost:4096",
|
||||
auth: { type: "basic", username: "opencode", password: "secret" },
|
||||
}),
|
||||
)
|
||||
expect(target.searchParams.get("auth_token")).toBe(btoa("opencode:secret"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,7 +5,13 @@ import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
|
||||
import { ServiceStatus } from "@opencode-ai/protocol/groups/health"
|
||||
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
|
||||
import { Cause, Context, Deferred, Effect, Exit, Layer, Option, Ref, Schema, Scope } from "effect"
|
||||
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||
import {
|
||||
HttpMiddleware,
|
||||
HttpRouter,
|
||||
HttpServer,
|
||||
HttpServerRequest,
|
||||
HttpServerResponse,
|
||||
} from "effect/unstable/http"
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { createServer } from "node:http"
|
||||
import { ServerAuth } from "./auth"
|
||||
@@ -31,6 +37,8 @@ type App = Effect.Effect<
|
||||
HttpServerRequest.HttpServerRequest | Scope.Scope
|
||||
>
|
||||
|
||||
export type Transform = (app: App) => App
|
||||
|
||||
const errorResponseLogger = HttpMiddleware.make((app) =>
|
||||
HttpMiddleware.logger(
|
||||
Effect.tap(app, (response) =>
|
||||
@@ -42,6 +50,7 @@ const errorResponseLogger = HttpMiddleware.make((app) =>
|
||||
export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
options: ServerOptions,
|
||||
lifecycle?: Lifecycle<E, R>,
|
||||
transform?: Transform,
|
||||
) {
|
||||
const password = options.password
|
||||
if (!password) return yield* Effect.fail(new Error("Missing server password"))
|
||||
@@ -101,7 +110,8 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
|
||||
Effect.provideService(Scope.Scope, applicationScope),
|
||||
)
|
||||
}
|
||||
yield* Ref.set(application, Option.some(Context.get(context, HttpRouter.HttpRouter).asHttpEffect()))
|
||||
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
|
||||
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
|
||||
yield* status.ready
|
||||
return { address: bound.http.address, shutdown: Deferred.await(shutdown) }
|
||||
}).pipe(
|
||||
|
||||
@@ -1,18 +1,28 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { HttpServer } from "effect/unstable/http"
|
||||
import { HttpServer, HttpServerError, HttpServerResponse } from "effect/unstable/http"
|
||||
import { it } from "../../core/test/lib/effect"
|
||||
import { ServerProcess } from "../src/process"
|
||||
|
||||
it.live("allows browser preflight requests without credentials", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* ServerProcess.start<never, never>({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
})
|
||||
const server = yield* ServerProcess.start<never, never>(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
password: "secret",
|
||||
app: { version: "test-version" },
|
||||
database: { path: ":memory:" },
|
||||
},
|
||||
undefined,
|
||||
(api) =>
|
||||
api.pipe(
|
||||
Effect.catchIf(
|
||||
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
|
||||
() => Effect.succeed(HttpServerResponse.text("fallback")),
|
||||
),
|
||||
),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/health", HttpServer.formatAddress(server.address)), {
|
||||
method: "OPTIONS",
|
||||
@@ -40,5 +50,13 @@ it.live("allows browser preflight requests without credentials", () =>
|
||||
expect(health.status).toBe(200)
|
||||
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
|
||||
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
|
||||
|
||||
const missing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}),
|
||||
)
|
||||
expect(missing.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => missing.text())).toBe("fallback")
|
||||
}),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user