diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5b9223b5ee..40c8ca9f46 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -255,6 +255,7 @@ declare global { interface Window { __OPENCODE__?: { deepLinks?: string[] + servers?: unknown } api?: { setTitlebar?: (theme: { mode: "light" | "dark" }) => Promise diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index c41c1bb4b6..975239b3df 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -8,6 +8,7 @@ import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" +import { parseStartupServers } from "@/utils/startup-servers" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -112,6 +113,14 @@ const getDefaultUrl = () => { return getCurrentUrl() } +const remoteServersMeta = () => document.querySelector('meta[name="opencode-remote-servers"]')?.content + +const getStartupServers = () => [ + ...parseStartupServers(import.meta.env.VITE_OPENCODE_REMOTE_SERVERS), + ...parseStartupServers(remoteServersMeta()), + ...parseStartupServers(window.__OPENCODE__?.servers), +] + const clearAuthToken = () => { const params = new URLSearchParams(location.search) if (!params.has("auth_token")) return @@ -171,7 +180,7 @@ if (root instanceof HTMLElement) { diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 39f827eb64..4ecce6f9ee 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" + readonly VITE_OPENCODE_REMOTE_SERVERS?: string readonly VITE_SENTRY_DSN?: string readonly VITE_SENTRY_ENVIRONMENT?: string diff --git a/packages/app/src/utils/startup-servers.test.ts b/packages/app/src/utils/startup-servers.test.ts new file mode 100644 index 0000000000..8cf0ba2825 --- /dev/null +++ b/packages/app/src/utils/startup-servers.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import { parseStartupServers } from "./startup-servers" + +describe("parseStartupServers", () => { + test("parses comma separated startup servers", () => { + expect(parseStartupServers("Dax=http://romulus.example.test:4096, apollo.example.test:4096")).toEqual([ + { + type: "http", + displayName: "Dax", + http: { url: "http://romulus.example.test:4096" }, + }, + { + type: "http", + http: { url: "http://apollo.example.test:4096" }, + }, + ]) + }) + + test("parses JSON startup servers with credentials", () => { + expect( + parseStartupServers( + JSON.stringify([ + { + displayName: "Kit box", + url: "kit.example.test:4096/", + username: "opencode", + password: "secret", + }, + ]), + ), + ).toEqual([ + { + type: "http", + displayName: "Kit box", + http: { url: "http://kit.example.test:4096", username: "opencode", password: "secret" }, + }, + ]) + }) + + test("parses JSON name to URL maps", () => { + expect(parseStartupServers('{"Dax":"http://romulus.example.test:4096"}')).toEqual([ + { + type: "http", + displayName: "Dax", + http: { url: "http://romulus.example.test:4096" }, + }, + ]) + }) +}) diff --git a/packages/app/src/utils/startup-servers.ts b/packages/app/src/utils/startup-servers.ts new file mode 100644 index 0000000000..9dd48043de --- /dev/null +++ b/packages/app/src/utils/startup-servers.ts @@ -0,0 +1,62 @@ +import { normalizeServerUrl, ServerConnection } from "@/context/server" + +export type StartupServerConfig = + | string + | { + url?: unknown + name?: unknown + displayName?: unknown + username?: unknown + password?: unknown + } + +export function parseStartupServers(input: unknown): ServerConnection.Http[] { + const parsed = typeof input === "string" ? parseServerString(input) : input + if (Array.isArray(parsed)) return parsed.flatMap((value) => serverFromConfig(value)) + if (!isRecord(parsed)) return [] + return Object.entries(parsed).flatMap(([name, value]) => + typeof value === "string" ? serverFromConfig({ name, url: value }) : serverFromConfig(value), + ) +} + +function parseServerString(input: string): unknown { + const trimmed = input.trim() + if (!trimmed) return [] + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { + try { + return JSON.parse(trimmed) + } catch { + return [] + } + } + return trimmed.split(",").map((item) => { + const value = item.trim() + const index = value.indexOf("=") + if (index <= 0) return value + return { name: value.slice(0, index).trim(), url: value.slice(index + 1).trim() } + }) +} + +function serverFromConfig(input: unknown): ServerConnection.Http[] { + const url = normalizeServerUrl(typeof input === "string" ? input : (isRecord(input) && stringValue(input.url)) || "") + if (!url) return [] + + const conn: ServerConnection.Http = { type: "http", http: { url } } + if (isRecord(input)) { + const displayName = stringValue(input.displayName) ?? stringValue(input.name) + if (displayName) conn.displayName = displayName + const username = stringValue(input.username) + if (username) conn.http.username = username + const password = stringValue(input.password) + if (password) conn.http.password = password + } + return [conn] +} + +function stringValue(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : undefined +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} diff --git a/packages/desktop/src/renderer/env.d.ts b/packages/desktop/src/renderer/env.d.ts index 6dff3baf1c..2ceff41a07 100644 --- a/packages/desktop/src/renderer/env.d.ts +++ b/packages/desktop/src/renderer/env.d.ts @@ -5,6 +5,7 @@ declare global { api: ElectronAPI __OPENCODE__?: { deepLinks?: string[] + servers?: unknown } } } diff --git a/packages/opencode/src/server/shared/ui.ts b/packages/opencode/src/server/shared/ui.ts index c2fd3b8637..46566b8984 100644 --- a/packages/opencode/src/server/shared/ui.ts +++ b/packages/opencode/src/server/shared/ui.ts @@ -55,10 +55,24 @@ function notFound() { function embeddedUIResponse(file: string, body: Uint8Array) { const mime = FSUtil.mimeType(file) const headers = new Headers({ "content-type": mime }) - if (mime.startsWith("text/html")) { - headers.set("content-security-policy", cspForHtml(new TextDecoder().decode(body))) - } - return HttpServerResponse.raw(body, { headers }) + if (!mime.startsWith("text/html")) return HttpServerResponse.raw(body, { headers }) + + const html = injectRemoteServersMeta(new TextDecoder().decode(body)) + headers.set("content-security-policy", cspForHtml(html)) + return HttpServerResponse.raw(new TextEncoder().encode(html), { headers }) +} + +export function injectRemoteServersMeta(body: string, remoteServers = process.env.OPENCODE_WEB_REMOTE_SERVERS) { + const value = remoteServers?.trim() + if (!value) return body + if (body.includes('name="opencode-remote-servers"')) return body + const meta = `` + if (/]*>/i.test(body)) return body.replace(/]*)>/i, `${meta}`) + return `${meta}${body}` +} + +function escapeHtmlAttribute(value: string) { + return value.replaceAll("&", "&").replaceAll('"', """).replaceAll("<", "<").replaceAll(">", ">") } export function serveEmbeddedUIEffect( @@ -94,7 +108,7 @@ export function serveUIEffect( const headers = proxyResponseHeaders(response.headers) if (response.headers["content-type"]?.includes("text/html")) { - const body = yield* response.text + const body = injectRemoteServersMeta(yield* response.text) headers.set("Content-Security-Policy", cspForHtml(body)) return HttpServerResponse.text(body, { status: response.status, headers }) } diff --git a/packages/opencode/test/server/httpapi-ui.test.ts b/packages/opencode/test/server/httpapi-ui.test.ts index e90220817d..0903f81e9e 100644 --- a/packages/opencode/test/server/httpapi-ui.test.ts +++ b/packages/opencode/test/server/httpapi-ui.test.ts @@ -17,7 +17,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" import { ServerAuth } from "../../src/server/auth" import { authorizationRouterMiddleware } from "../../src/server/routes/instance/httpapi/middleware/authorization" import { HttpApiApp } from "../../src/server/routes/instance/httpapi/server" -import { serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" +import { injectRemoteServersMeta, serveEmbeddedUIEffect, serveUIEffect } from "../../src/server/shared/ui" import { testEffect } from "../lib/effect" const testStateLayer = Layer.effectDiscard( @@ -27,6 +27,7 @@ const testStateLayer = Layer.effectDiscard( OPENCODE_SERVER_USERNAME: Flag.OPENCODE_SERVER_USERNAME, envPassword: process.env.OPENCODE_SERVER_PASSWORD, envUsername: process.env.OPENCODE_SERVER_USERNAME, + envWebRemoteServers: process.env.OPENCODE_WEB_REMOTE_SERVERS, } yield* Effect.addFinalizer(() => @@ -35,6 +36,7 @@ const testStateLayer = Layer.effectDiscard( Flag.OPENCODE_SERVER_USERNAME = original.OPENCODE_SERVER_USERNAME restoreEnv("OPENCODE_SERVER_PASSWORD", original.envPassword) restoreEnv("OPENCODE_SERVER_USERNAME", original.envUsername) + restoreEnv("OPENCODE_WEB_REMOTE_SERVERS", original.envWebRemoteServers) }), ) }), @@ -184,6 +186,17 @@ function responseText(response: Response) { } describe("HttpApi UI fallback", () => { + it.live("injects configured remote servers into web UI html", () => + Effect.gen(function* () { + const config = '[{"name":"Dax","url":"http://romulus.example.test:4096"}]' + const html = injectRemoteServersMeta("opencode", config) + + expect(html).toContain(' Effect.gen(function* () { let proxiedUrl: string | undefined