diff --git a/bun.lock b/bun.lock index 0efe1b76b05f..24a8ff886cb6 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-prefix.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -801,7 +801,7 @@ "version": "1.18.29", "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-prefix.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -6018,7 +6018,7 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], - "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], + "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-prefix.tgz", {}, "sha512-xxrTEXfkoi9mlNQTHNScfNhO8CGtbsNK+YczGTgI0FyjD16/7GsJSJXI6JiYIVYLDz+98/h6pWT23+uALE/aKg=="], "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.88", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-6M4+bxK/UMijDYK2ia1hCKYma1iajFYxHDlhbinFUdhH2WbFsjoZRMpSjEMlTOBptqmh2J1JclKnprGulF8WwQ=="], @@ -6036,7 +6036,7 @@ "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], - "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-prefix.tgz", {}, "sha512-xxrTEXfkoi9mlNQTHNScfNhO8CGtbsNK+YczGTgI0FyjD16/7GsJSJXI6JiYIVYLDz+98/h6pWT23+uALE/aKg=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], diff --git a/packages/app/package.json b/packages/app/package.json index 4accd2c499e1..3fe5c7d3a992 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -54,7 +54,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13-prefix.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/app/script/patch-client-prefix.ts b/packages/app/script/patch-client-prefix.ts new file mode 100644 index 000000000000..d084e6772ccd --- /dev/null +++ b/packages/app/script/patch-client-prefix.ts @@ -0,0 +1,26 @@ +import { $ } from "bun" +import { mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import path from "node:path" + +// The app consumes a frozen V2 client, not the workspace's current client. +// Port anomalyco/opencode#47442 without replacing that client's API contract. +const archive = path.resolve(import.meta.dir, "../vendor/opencode-ai-client-1.17.13-v2.tgz") +const output = archive.replace("-v2.tgz", "-prefix.tgz") +const directory = await mkdtemp(path.join(tmpdir(), "opencode-client-prefix-")) +try { + await $`tar -xzf ${archive} -C ${directory}` + const file = Bun.file(path.join(directory, "package/dist/promise/generated/client.js")) + const source = await file.text() + const previous = 'const url = new URL(options.baseUrl.replace(/[/]+$/, "") + descriptor.path);' + const patched = `const base = new URL(options.baseUrl); + base.pathname = base.pathname.replace(/[/]+$/, "") + "/"; + const url = new URL(descriptor.path.replace(/^[/]+/, ""), base);` + if (!source.includes(patched)) { + if (!source.includes(previous)) throw new Error("Vendored client changed: review the prefix patch before repacking") + await Bun.write(file, source.replace(previous, patched)) + await $`tar --sort=name --mtime=@0 --owner=0 --group=0 --numeric-owner -czf ${output} -C ${directory} package` + } +} finally { + await rm(directory, { recursive: true, force: true }) +} diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0c950871a335..0e9898574575 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,3 +1,4 @@ +import { appBasePath } from "./utils/base-path" import "@/index.css" import * as Sentry from "@sentry/solid" import { I18nProvider } from "@opencode-ai/ui/context" @@ -601,6 +602,7 @@ export function AppInterface(props: { )} + base={appBasePath() || undefined} > diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 9465374cbb06..b4a43777e067 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -1,3 +1,4 @@ +import { appPath } from "@/utils/base-path" import { useIsRouting, useLocation } from "@solidjs/router" import { batch, createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" @@ -222,7 +223,7 @@ export function DebugBar(props: { inline?: boolean } = {}) { createEffect(() => { const busy = routing() - const next = `${location.pathname}${location.search}` + const next = `${appPath(location.pathname)}${location.search}` if (!init) { init = true diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index b016f286a250..f8eb4bdb1cf0 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -1,3 +1,4 @@ +import { A } from "@solidjs/router" import { createEffect, createMemo, createSignal, onCleanup, Show, type Ref } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" @@ -201,7 +202,7 @@ export function TabNavItem(props: { }} > - {props.title} - +
`${location.pathname}${location.search}${location.hash}` + const path = () => `${appPath(location.pathname)}${location.search}${location.hash}` const creating = createMemo(() => { const route = layout.route() if (route.type === "draft" || route.type === "dir-new-sesssion") return true if (!params.dir) return false if (params.id) return false - const parts = location.pathname.replace(/\/+$/, "").split("/") + const parts = appPath(location.pathname).replace(/\/+$/, "").split("/") return parts.at(-1) === "session" }) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 6235f35c45ae..4825b97e277f 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -1,3 +1,4 @@ +import { appPath } from "@/utils/base-path" import { createStore, produce, reconcile } from "solid-js/store" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { useLocation } from "@solidjs/router" @@ -167,7 +168,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const platform = usePlatform() const location = useLocation() const route = createMemo(() => { - const value = currentRoute(location.pathname, location.search) + const value = currentRoute(appPath(location.pathname), location.search) if (value.type === "home") return value if (value.server) return value if (value.type === "draft") { diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index b6608e868112..c1351cb87e65 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -1,3 +1,4 @@ +import { appPath } from "@/utils/base-path" import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createStore, produce } from "solid-js/store" @@ -157,7 +158,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/") + const nextTab = nextTabAfterClose(store, index, recentKey() === key && appPath(location.pathname) !== "/") closing.add(key) void startTransition(() => { setStore( @@ -231,7 +232,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ promoteDraft(draftID: string, session: Omit) { // Keep the replacement and navigation atomic so /new-session never renders // after its backing draft tab has been removed from the store. - const active = location.pathname === "/new-session" && location.query.draftId === draftID + const active = appPath(location.pathname) === "/new-session" && location.query.draftId === draftID const next = { type: "session" as const, ...session } void startTransition(() => { setStore( diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 6e5f63fd4e95..13b9aa5f9d32 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -9,6 +9,7 @@ import { createBrowserDraftStore } from "@/utils/draft-store" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import { authFromToken } from "@/utils/server" +import { appBasePath } from "./utils/base-path" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -97,10 +98,11 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { } const getCurrentUrl = () => { + const basePath = appBasePath() if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - return location.origin + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}${basePath}` + return location.origin + basePath } const getDefaultUrl = () => { diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 5754df76838d..da134df04818 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -1,4 +1,5 @@ interface ImportMetaEnv { + readonly BASE_URL: string readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" @@ -12,6 +13,12 @@ interface ImportMeta { readonly env: ImportMetaEnv } +declare global { + interface Window { + __OPENCODE_BASE_PATH__?: string + } +} + declare module "*.png" { const src: string export default src diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index a0b357639655..be038a3ab5ab 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -1,3 +1,4 @@ +import { appPath } from "@/utils/base-path" import { DataProvider } from "@opencode-ai/session-ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" @@ -38,7 +39,7 @@ export function DirectoryDataProvider( if (props.draftID || props.server?.()) return const next = sync().data.path.directory if (!next || next === directory()) return - const path = location.pathname.slice(slug().length + 1) + const path = appPath(location.pathname).slice(slug().length + 1) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) diff --git a/packages/app/src/pages/session/use-session-hash-scroll.ts b/packages/app/src/pages/session/use-session-hash-scroll.ts index 65577abae229..bf2f48faa6ab 100644 --- a/packages/app/src/pages/session/use-session-hash-scroll.ts +++ b/packages/app/src/pages/session/use-session-hash-scroll.ts @@ -1,3 +1,4 @@ +import { appPath } from "@/utils/base-path" import type { UserMessage } from "@opencode-ai/sdk/v2" import { useLocation, useNavigate } from "@solidjs/router" import { createEffect, createMemo, onCleanup, onMount } from "solid-js" @@ -49,14 +50,14 @@ export const useSessionHashScroll = (input: { if (input.pendingMessage()) input.setPendingMessage(undefined) if (!location.hash) return clearing = true - navigate(location.pathname + location.search, { replace: true }) + navigate(appPath(location.pathname) + location.search, { replace: true }) } const updateHash = (id: string) => { const hash = `#${input.anchor(id)}` if (location.hash === hash) return clearing = false - navigate(location.pathname + location.search + hash, { + navigate(appPath(location.pathname) + location.search + hash, { replace: true, }) } diff --git a/packages/app/src/utils/base-path.test.ts b/packages/app/src/utils/base-path.test.ts new file mode 100644 index 000000000000..eb18bfcffbea --- /dev/null +++ b/packages/app/src/utils/base-path.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, afterEach, describe, expect, test } from "bun:test" +import { appBasePath, appPath, serverRequestURL } from "./base-path" + +const originalURL = location.href +beforeEach(() => { + location.href = "http://localhost/" +}) + +afterEach(() => { + delete window.__OPENCODE_BASE_PATH__ + document.querySelectorAll("base").forEach((base) => base.remove()) + location.href = originalURL +}) + +describe("appBasePath", () => { + test("does not confuse a reloaded route with the server prefix", () => { + history.replaceState(null, "", "/project/session/test") + expect(appBasePath()).toBe("") + }) + test("uses the injected prefix, including an explicitly empty prefix", () => { + history.replaceState(null, "", "/apps/opencode/project/session/test") + window.__OPENCODE_BASE_PATH__ = "/apps/opencode/" + expect(appBasePath()).toBe("/apps/opencode") + window.__OPENCODE_BASE_PATH__ = "" + expect(appBasePath()).toBe("") + }) + test("supports an explicit same-origin proxy base element", () => { + const base = document.createElement("base") + base.href = "/nested/proxy/service/" + document.head.append(base) + expect(appBasePath()).toBe("/nested/proxy/service") + base.href = "https://unrelated.example/other/" + expect(appBasePath()).toBe("") + }) +}) + +test.each(["", "/", "/proxy", "/proxy/", "/nested/proxy%20path/"])("server URL preserves prefix %j", (prefix) => { + expect(serverRequestURL("https://example.com" + prefix, "/api/health?test=a%3Fb").href).toBe( + "https://example.com" + prefix.replace(/\/+$/, "") + "/api/health?test=a%3Fb", + ) +}) + +test("application route matching strips exactly one prefix at a segment boundary", () => { + window.__OPENCODE_BASE_PATH__ = "/apps/opencode" + expect(appPath("/apps/opencode/new-session")).toBe("/new-session") + expect(appPath("/apps/opencode/server/test/session/ses_test")).toBe("/server/test/session/ses_test") + expect(appPath("/apps/opencode")).toBe("/") + expect(appPath("/apps/opencode/")).toBe("/") + expect(appPath("/apps/opencode-other/new-session")).toBe("/apps/opencode-other/new-session") + window.__OPENCODE_BASE_PATH__ = "" + expect(appPath("/new-session")).toBe("/new-session") +}) diff --git a/packages/app/src/utils/base-path.ts b/packages/app/src/utils/base-path.ts new file mode 100644 index 000000000000..faf52049d0f2 --- /dev/null +++ b/packages/app/src/utils/base-path.ts @@ -0,0 +1,23 @@ +export function appBasePath() { + if (typeof window === "undefined") return "" + const injected = window.__OPENCODE_BASE_PATH__ + if (injected !== undefined) return injected.replace(/\/+$/, "") + const base = document.querySelector("base[href]") + if (!base) return "" + const url = new URL(base.getAttribute("href")!, location.href) + return url.origin === location.origin ? url.pathname.replace(/\/+$/, "") : "" +} + +export function serverRequestURL(server: string, path: string) { + const base = new URL(server) + base.pathname = base.pathname.replace(/\/+$/, "") + "/" + return new URL(path.replace(/^\/+/, ""), base) +} + +// Solid Router's location includes its base, while application route parsers do not. +export function appPath(pathname: string) { + const base = appBasePath() + if (base && pathname === base) return "/" + if (base && pathname.startsWith(base + "/")) return pathname.slice(base.length) + return pathname +} diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts index 2130a968c4bc..0a204c1a571b 100644 --- a/packages/app/src/utils/server-protocol.test.ts +++ b/packages/app/src/utils/server-protocol.test.ts @@ -38,3 +38,14 @@ describe("detectServerProtocol", () => { expect(await detectServerProtocol(server, fetcher)).toBe("v1") }) }) + +test.each(["/proxy", "/nested/proxy/service/"])("health probes keep the prefix %j", async (prefix) => { + const paths: string[] = [] + const fetcher = mockFetch(async (input) => { + const url = new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + return url.pathname.endsWith("/global/health") ? json({}, 404) : json({ healthy: true, pid: 1 }) + }) + expect(await detectServerProtocol({ url: "https://example.com" + prefix }, fetcher)).toBe("v2") + expect(paths).toEqual([prefix.replace(/\/+$/, "") + "/global/health", prefix.replace(/\/+$/, "") + "/api/health"]) +}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts index 27b8dc208eac..5294ff1a9a22 100644 --- a/packages/app/src/utils/server-protocol.ts +++ b/packages/app/src/utils/server-protocol.ts @@ -1,3 +1,4 @@ +import { serverRequestURL } from "./base-path" import type { ServerConnection } from "@/context/server" import { authTokenFromCredentials } from "./server" @@ -11,7 +12,7 @@ function headers(server: ServerConnection.HttpBase) { } async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { - const response = await fetch(new URL(path, server.url), { + const response = await fetch(serverRequestURL(server.url, path), { headers: headers(server), signal: AbortSignal.timeout(5_000), }) diff --git a/packages/app/src/utils/server.test.ts b/packages/app/src/utils/server.test.ts index 4666b7d6d03c..97654caae7d4 100644 --- a/packages/app/src/utils/server.test.ts +++ b/packages/app/src/utils/server.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { authFromToken, authTokenFromCredentials } from "./server" +import { authFromToken, authTokenFromCredentials, createApiForServer } from "./server" describe("authFromToken", () => { test("decodes basic auth credentials from auth_token", () => { @@ -21,3 +21,30 @@ describe("authTokenFromCredentials", () => { expect(authTokenFromCredentials({ password: "secret" })).toBe(btoa("opencode:secret")) }) }) + +test("the vendored web client keeps the public prefix for HTTP and SSE", async () => { + const paths: string[] = [] + const client = createApiForServer({ + server: { url: "https://example.com/nested/proxy/service/", password: "secret" }, + fetch: Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + expect(new Headers(init?.headers).get("authorization")).toBe("Basic " + btoa("opencode:secret")) + if (url.pathname.endsWith("/api/health")) return Response.json({ healthy: true, version: "test", pid: 1 }) + return new Response('data: {"id":"evt_test","type":"server.connected","data":{}}\n\n', { + headers: { "content-type": "text/event-stream" }, + }) + }, + { preconnect: globalThis.fetch.preconnect }, + ), + }) + expect((await client.health.get()).healthy).toBe(true) + const events = client.event.subscribe()[Symbol.asyncIterator]() + try { + expect((await events.next()).value?.type).toBe("server.connected") + } finally { + await events.return?.() + } + expect(paths).toEqual(["/nested/proxy/service/api/health", "/nested/proxy/service/api/event"]) +}) diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index aac854ca82d6..ea3b7cccc486 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -78,3 +78,17 @@ describe("terminalWebSocketURL", () => { expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) }) + +test.each(["v1", "v2"] as const)("preserves nested prefixes in %s WebSocket connections", (protocol) => { + const url = terminalWebSocketURL({ + protocol, + url: "https://example.com/nested/proxy/service/", + id: "pty_test", + directory: "/tmp/project", + cursor: 42, + ticket: "ticket", + }) + expect(url.pathname).toBe(`/nested/proxy/service${protocol === "v1" ? "" : "/api"}/pty/pty_test/connect`) + expect(url.protocol).toBe("wss:") + expect(url.searchParams.get("ticket")).toBe("ticket") +}) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index a32b239cc932..e3b70ceab4d1 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,3 +1,4 @@ +import { serverRequestURL } from "./base-path" import { authTokenFromCredentials } from "@/utils/server" export function terminalWebSocketURL(input: { @@ -13,7 +14,7 @@ export function terminalWebSocketURL(input: { authToken?: boolean }) { const isV1 = input.protocol === "v1" - const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`) + const next = serverRequestURL(input.url, `${isV1 ? "" : "/api"}/pty/${encodeURIComponent(input.id)}/connect`) if (isV1) { next.searchParams.set("directory", input.directory) } else { diff --git a/packages/app/vendor/opencode-ai-client-1.17.13-prefix.tgz b/packages/app/vendor/opencode-ai-client-1.17.13-prefix.tgz new file mode 100644 index 000000000000..a664e1c256be Binary files /dev/null and b/packages/app/vendor/opencode-ai-client-1.17.13-prefix.tgz differ diff --git a/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz b/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz index bc1b664f82dc..11ea61e98631 100644 Binary files a/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz and b/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz differ diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index 8df324ddc916..1cd118fb5667 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -20,6 +20,7 @@ const sentry = : false export default defineConfig({ + base: process.env.VITE_BASE_URL || "./", plugins: [desktopPlugin, sentry] as any, server: { host: "0.0.0.0", diff --git a/packages/cli/src/commands/handlers/api.ts b/packages/cli/src/commands/handlers/api.ts index cf00394cb902..2f8439d1f9ba 100644 --- a/packages/cli/src/commands/handlers/api.ts +++ b/packages/cli/src/commands/handlers/api.ts @@ -31,7 +31,7 @@ export default Runtime.handler( if (body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json") const response = yield* Effect.tryPromise(() => - fetch(new URL(request.path, transport.url), { + fetch(requestURL(transport.url, request.path), { method: request.method, headers, body, @@ -66,7 +66,7 @@ function resolveRequest( if (raw) return Effect.succeed(raw) if (input.length !== 1) return Effect.fail(new Error("Expected an operation name or an HTTP method and path")) return Effect.tryPromise(async () => { - const response = await fetch(new URL("/openapi.json", transport.url), { headers: transport.headers }) + const response = await fetch(requestURL(transport.url, "/openapi.json"), { headers: transport.headers }) if (!response.ok) throw new Error(`Failed to load OpenAPI document: HTTP ${response.status}`) return resolveOperation((await response.json()) as OpenApi, input[0], params) }) @@ -83,3 +83,9 @@ function interpolate(path: string, params: Record) { const query = new URLSearchParams(Object.entries(params).filter(([name]) => !used.has(name))).toString() return query ? `${pathname}?${query}` : pathname } + +export function requestURL(server: string, path: string) { + const base = new URL(server) + base.pathname = base.pathname.replace(/\/+$/, "") + "/" + return new URL(path.replace(/^\/+/, ""), base) +} diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts index 27ec3d81ba2c..6d3f2a58be50 100644 --- a/packages/client/src/generated/client.ts +++ b/packages/client/src/generated/client.ts @@ -141,7 +141,9 @@ export function make(options: ClientOptions) { const fetch = options.fetch ?? globalThis.fetch const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => { - const url = new URL(descriptor.path, options.baseUrl) + const base = new URL(options.baseUrl) + base.pathname = base.pathname.replace(/[/]+$/, "") + "/" + const url = new URL(descriptor.path.replace(/^[/]+/, ""), base) for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value) const headers = new Headers(options.headers) for (const [key, value] of Object.entries(descriptor.headers ?? {})) { diff --git a/packages/client/test/base-url.test.ts b/packages/client/test/base-url.test.ts new file mode 100644 index 000000000000..f6167884a0a0 --- /dev/null +++ b/packages/client/test/base-url.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test" +import { OpenCode } from "../src" + +test.each(["", "/", "/proxy", "/proxy/", "/nested/proxy%20path", "/nested/proxy/service/"])( + "preserves server base path %j for HTTP, queries, auth and SSE", + async (prefix) => { + const requests: string[] = [] + const base = prefix.replace(/\/+$/, "") + const event = { id: "evt_connected", type: "server.connected", data: {} } + using server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(request) { + const url = new URL(request.url) + requests.push(url.pathname + url.search) + expect(request.headers.get("authorization")).toBe("Bearer test") + if (url.pathname === `${base}/api/health`) return Response.json({ healthy: true }) + if (url.pathname === `${base}/api/session`) return Response.json([]) + if (url.pathname === `${base}/api/event`) + return new Response(`data: ${JSON.stringify(event)}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }) + return new Response("Not found", { status: 404 }) + }, + }) + const client = OpenCode.make({ baseUrl: server.url.origin + prefix, headers: { authorization: "Bearer test" } }) + expect(await client.health.get()).toEqual({ healthy: true }) + await client.sessions.list({ directory: "/tmp/project" }) + const events = client.events.subscribe()[Symbol.asyncIterator]() + try { + expect((await events.next()).value).toEqual(event) + } finally { + await events.return?.() + } + expect(requests).toEqual([ + `${base}/api/health`, + `${base}/api/session?directory=%2Ftmp%2Fproject`, + `${base}/api/event`, + ]) + }, +) diff --git a/packages/client/test/promise.test.ts b/packages/client/test/promise.test.ts index 322a39cd6b29..6a48f669afbe 100644 --- a/packages/client/test/promise.test.ts +++ b/packages/client/test/promise.test.ts @@ -171,6 +171,18 @@ test("session methods use the public HTTP contract", async () => { }) }) +test("preserves base path in request URLs", async () => { + const client = OpenCode.make({ + baseUrl: "http://localhost:3000/opencode", + fetch: async (input) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url + expect(url).toBe("http://localhost:3000/opencode/api/session/ses_test") + return Response.json(session) + }, + }) + await client.sessions.get({ sessionID: "ses_test" }) +}) + test("middleware errors remain declared client errors", async () => { const client = OpenCode.make({ baseUrl: "http://localhost:3000", diff --git a/packages/core/src/v1/config/server.ts b/packages/core/src/v1/config/server.ts index 64a89186b33a..f69f98820d00 100644 --- a/packages/core/src/v1/config/server.ts +++ b/packages/core/src/v1/config/server.ts @@ -15,5 +15,11 @@ export const Server = Schema.Struct({ cors: Schema.optional(Schema.mutable(Schema.Array(Schema.String))).annotate({ description: "Additional domains to allow for CORS", }), + basePathStripped: Schema.optional(Schema.Boolean).annotate({ + description: "Reverse proxy strips basePath before forwarding requests", + }), + basePath: Schema.optional(Schema.String).annotate({ + description: "Base path prefix for hosting behind a reverse proxy (e.g., '/opencode')", + }), }).annotate({ identifier: "ServerConfig" }) export type Server = Schema.Schema.Type diff --git a/packages/httpapi-codegen/src/index.ts b/packages/httpapi-codegen/src/index.ts index 298a1211dddc..b195d4f9f026 100644 --- a/packages/httpapi-codegen/src/index.ts +++ b/packages/httpapi-codegen/src/index.ts @@ -534,7 +534,7 @@ function renderPromiseClient(groups: ReadonlyArray) { if (group.endpoints[0]?.topLevel) return methods.join(", ") return `${JSON.stringify(group.identifier)}: { ${methods.join(", ")} }` }) - return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const url = new URL(descriptor.path, options.baseUrl)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` + return `import type { ${imports.join(", ")} } from "./types"\nimport { ClientError } from "./client-error"\n\nexport interface ClientOptions {\n readonly baseUrl: string\n readonly fetch?: typeof globalThis.fetch\n readonly headers?: HeadersInit\n}\n\nexport interface RequestOptions {\n readonly signal?: AbortSignal\n readonly headers?: HeadersInit\n}\n\ninterface RequestDescriptor {\n readonly method: string\n readonly path: string\n readonly query?: Record\n readonly headers?: Record\n readonly body?: unknown\n readonly successStatus: number\n readonly declaredStatuses: ReadonlyArray\n readonly empty: boolean\n}\n\nexport function make(options: ClientOptions) {\n const fetch = options.fetch ?? globalThis.fetch\n\n const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n const base = new URL(options.baseUrl)\n base.pathname = base.pathname.replace(/[/]+$/, "") + "/"\n const url = new URL(descriptor.path.replace(/^[/]+/, ""), base)\n for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)\n const headers = new Headers(options.headers)\n for (const [key, value] of Object.entries(descriptor.headers ?? {})) {\n if (value !== undefined && value !== null) headers.set(key, String(value))\n }\n for (const [key, value] of new Headers(requestOptions?.headers)) headers.set(key, value)\n if (descriptor.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json")\n return {\n url,\n init: {\n method: descriptor.method,\n signal: requestOptions?.signal,\n headers,\n body: descriptor.body === undefined ? undefined : JSON.stringify(descriptor.body),\n } satisfies RequestInit,\n }\n }\n\n const execute = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {\n try {\n const prepared = prepare(descriptor, requestOptions)\n return await fetch(prepared.url, prepared.init)\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n }\n\n const responseError = async (response: Response, descriptor: RequestDescriptor): Promise => {\n if (descriptor.declaredStatuses.includes(response.status)) throw await json(response)\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })\n }\n\n const request = async (descriptor: RequestDescriptor, requestOptions?: RequestOptions): Promise => {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) return responseError(response, descriptor)\n if (descriptor.empty) {\n try {\n await response.body?.cancel()\n } catch {}\n return undefined as A\n }\n return await json(response) as A\n }\n\n const sse = (descriptor: RequestDescriptor, requestOptions?: RequestOptions): AsyncIterable => ({\n async *[Symbol.asyncIterator]() {\n const response = await execute(descriptor, requestOptions)\n if (response.status !== descriptor.successStatus) await responseError(response, descriptor)\n if (!isContentType(response, "text/event-stream")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n if (response.body === null) throw new ClientError("MalformedResponse")\n const reader = response.body.getReader()\n const decoder = new TextDecoder()\n let buffer = ""\n try {\n while (true) {\n let next: ReadableStreamReadResult\n try {\n next = await reader.read()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n buffer += decoder.decode(next.value, { stream: !next.done })\n if (buffer.length > 1_048_576) throw new ClientError("MalformedResponse")\n const trailingCarriageReturn = !next.done && buffer.endsWith("\\r")\n if (trailingCarriageReturn) buffer = buffer.slice(0, -1)\n buffer = buffer.replaceAll("\\r\\n", "\\n").replaceAll("\\r", "\\n")\n if (trailingCarriageReturn) buffer += "\\r"\n if (next.done && buffer !== "") buffer += "\\n\\n"\n let boundary = buffer.indexOf("\\n\\n")\n while (boundary >= 0) {\n const block = buffer.slice(0, boundary)\n buffer = buffer.slice(boundary + 2)\n const data = block.split("\\n").flatMap((line) => line.startsWith("data:") ? [line.slice(5).trimStart()] : []).join("\\n")\n if (data !== "") {\n try {\n yield JSON.parse(data) as A\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n }\n boundary = buffer.indexOf("\\n\\n")\n }\n if (next.done) return\n }\n } finally {\n try {\n await reader.cancel()\n } catch {}\n reader.releaseLock()\n }\n },\n })\n\n return { ${fields.join(", ")} }\n}\n\nfunction appendQuery(params: URLSearchParams, key: string, value: unknown): void {\n if (value === undefined || value === null) return\n if (Array.isArray(value)) {\n for (const item of value) appendQuery(params, key, item)\n return\n }\n if (typeof value === "object") {\n for (const [child, item] of Object.entries(value)) appendQuery(params, \`\${key}[\${child}]\`, item)\n return\n }\n params.append(key, String(value))\n}\n\nasync function json(response: Response): Promise {\n if (!isContentType(response, "application/json") && !response.headers.get("content-type")?.includes("+json")) {\n try {\n await response.body?.cancel()\n } catch {}\n throw new ClientError("UnsupportedContentType")\n }\n let text: string\n try {\n text = await response.text()\n } catch (cause) {\n throw new ClientError("Transport", { cause })\n }\n if (text === "") throw new ClientError("MalformedResponse")\n try {\n return JSON.parse(text)\n } catch (cause) {\n throw new ClientError("MalformedResponse", { cause })\n }\n}\n\nfunction isContentType(response: Response, expected: string) {\n return response.headers.get("content-type")?.split(";", 1)[0]?.trim().toLowerCase() === expected\n}\n` } function promiseTypePrefix(group: string, endpoint: string) { diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index c0f62b3ca071..776b52265d17 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -17,7 +17,7 @@ export const ServeCommand = effectCmd({ } const opts = yield* resolveNetworkOptions(args) const server = yield* Effect.promise(() => Server.listen(opts)) - console.log(`opencode server listening on http://${server.hostname}:${server.port}`) + console.log(`opencode server listening on http://${server.hostname}:${server.port}${server.basePath}`) yield* Effect.never }), diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 69a981aada49..c1d1b72a330c 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -46,9 +46,10 @@ export const WebCommand = effectCmd({ UI.println(UI.logo(" ")) UI.empty() + const suffix = server.basePath || "" if (opts.hostname === "0.0.0.0") { // Show localhost for local access - const localhostUrl = `http://localhost:${server.port}` + const localhostUrl = `http://localhost:${server.port}${suffix}` UI.println(UI.Style.TEXT_INFO_BOLD + " Local access: ", UI.Style.TEXT_NORMAL, localhostUrl) // Show network IPs for remote access @@ -58,7 +59,7 @@ export const WebCommand = effectCmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " Network access: ", UI.Style.TEXT_NORMAL, - `http://${ip}:${server.port}`, + `http://${ip}:${server.port}${suffix}`, ) } } @@ -67,7 +68,7 @@ export const WebCommand = effectCmd({ UI.println( UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, - `${opts.mdnsDomain}:${server.port}`, + `${opts.mdnsDomain}:${server.port}${suffix}`, ) } diff --git a/packages/opencode/src/cli/network.ts b/packages/opencode/src/cli/network.ts index 22040ed41015..60a4bd0b4267 100644 --- a/packages/opencode/src/cli/network.ts +++ b/packages/opencode/src/cli/network.ts @@ -1,6 +1,7 @@ +import { normalizeBasePath } from "../server/base-path" +export { normalizeBasePath } from "../server/base-path" import type { Argv, InferredOptionTypes } from "yargs" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import type { Config } from "@/config/config" import { Effect } from "effect" const options = { @@ -30,6 +31,16 @@ const options = { describe: "additional domains to allow for CORS", default: [] as string[], }, + "base-path-stripped": { + type: "boolean" as const, + describe: "reverse proxy strips the base path before forwarding requests", + default: false, + }, + "base-path": { + type: "string" as const, + describe: "base URL path prefix for reverse proxy (e.g., /opencode)", + default: "", + }, } export type NetworkOptions = InferredOptionTypes @@ -75,6 +86,14 @@ export function resolveNetworkOptionsNoConfig(args: NetworkOptions, config?: Con const configCors = config?.server?.cors ?? [] const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : [] const cors = [...configCors, ...argsCors] + const basePathExplicitlySet = hasArg("--base-path") + const basePath = normalizeBasePath( + basePathExplicitlySet ? args["base-path"] : (config?.server?.basePath ?? args["base-path"]), + ) + + const basePathStripped = hasBooleanArg("--base-path-stripped") + ? args["base-path-stripped"] + : (config?.server?.basePathStripped ?? args["base-path-stripped"]) - return { hostname, port, mdns, mdnsDomain, cors } + return { hostname, port, mdns, mdnsDomain, cors, basePath, basePathStripped } } diff --git a/packages/opencode/src/server/base-path.ts b/packages/opencode/src/server/base-path.ts new file mode 100644 index 000000000000..3d1cd7dde006 --- /dev/null +++ b/packages/opencode/src/server/base-path.ts @@ -0,0 +1,54 @@ +import { createServer, type IncomingMessage, type ServerResponse } from "node:http" +import type { Duplex } from "node:stream" + +export function normalizeBasePath(input: string | undefined) { + const value = input?.trim().replace(/\/+$/, "") ?? "" + if (!value) return "" + const path = value.startsWith("/") ? value : `/${value}` + if ( + path.startsWith("//") || + /[?#\\\u0000-\u0020"'<>]/.test(path) || + new URL(path, "http://localhost").pathname !== path + ) + throw new Error("base-path must be a URL-encoded path without a query, fragment, or dot segments") + return path +} + +export function basePathServer(options: { basePath?: string; basePathStripped?: boolean }) { + const server = createServer() + const base = normalizeBasePath(options.basePath) + // The public prefix is still injected into HTML when a reverse proxy removes it on ingress. + if (!base || options.basePathStripped) return server + + // EventEmitter overloads do not correlate the event name with the argument types. + /* oxlint-disable typescript-eslint/no-unsafe-type-assertion */ + const emit = server.emit.bind(server) + server.emit = ((event: string, ...args: unknown[]) => { + if (event !== "request" && event !== "upgrade") return emit(event, ...args) + const request = args[0] as IncomingMessage + const url = request.url ?? "/" + const index = url.indexOf("?") + const pathname = index === -1 ? url : url.slice(0, index) + if (pathname === base && event === "request") { + const response = args[1] as ServerResponse + response.writeHead(308, { Location: base + "/" + (index === -1 ? "" : url.slice(index)) }) + response.end() + return true + } + if (pathname.startsWith(base + "/")) { + request.url = url.slice(base.length) + return emit(event, ...args) + } + if (event === "request") { + const response = args[1] as ServerResponse + response.writeHead(404, { "Content-Type": "text/plain" }) + response.end("Not Found") + return true + } + const socket = args[1] as Duplex + socket.destroy() + return true + }) as typeof server.emit + /* oxlint-enable typescript-eslint/no-unsafe-type-assertion */ + return server +} diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fb9d2db65621..01c3d9de4850 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/server.ts @@ -191,16 +191,18 @@ const docRoute = HttpRouter.use((router) => router.add("GET", "/doc", () => Effe Layer.provide(authOnlyRouterLayer), ) -const uiRoute = HttpRouter.use((router) => - Effect.gen(function* () { - const fs = yield* FSUtil.Service - const client = yield* HttpClient.HttpClient - const flags = yield* RuntimeFlags.Service - yield* router.add("*", "/*", (request) => - serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi }), - ) - }), -).pipe(Layer.provide(authOnlyRouterLayer)) +function uiRouteWithBasePath(basePath?: string) { + return HttpRouter.use((router) => + Effect.gen(function* () { + const fs = yield* FSUtil.Service + const client = yield* HttpClient.HttpClient + const flags = yield* RuntimeFlags.Service + yield* router.add("*", "/*", (request) => + serveUIEffect(request, { fs, client, disableEmbeddedWebUi: flags.disableEmbeddedWebUi, basePath }), + ) + }), + ).pipe(Layer.provide(authOnlyRouterLayer)) +} type RouteRequirements = | HttpRouter.HttpRouter @@ -269,10 +271,10 @@ const app = LayerNode.group([ ]) export function createRoutes( - corsOptions?: CorsOptions, + options?: CorsOptions & { basePath?: string }, ): Layer.Layer { + const uiRoute = uiRouteWithBasePath(options?.basePath) const locationServiceMapV2 = buildLocationServiceMap() - return Layer.mergeAll( rootApiRoutes, eventApiRoutes, @@ -287,11 +289,11 @@ export function createRoutes( compressionLayer, corsVaryFix, fenceLayer, - cors(corsOptions), + cors(options), AppNodeBuilderV1.build(MoveSession.node, [[LocationServiceMap.node, locationServiceMapV2]]), HttpServer.layerServices, ]), - Layer.provide(Layer.succeed(CorsConfig)(corsOptions)), + Layer.provide(Layer.succeed(CorsConfig)(options)), Layer.provide(sessionLocationLayer), Layer.provide(locationLayer), Layer.provide(PtyEnvironment.layer), diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 440b992c1557..fb2c7997442d 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -5,7 +5,7 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { ConfigProvider, Context, Effect, Exit, Layer, Scope } from "effect" import { HttpRouter, HttpServer } from "effect/unstable/http" import { OpenApi } from "effect/unstable/httpapi" -import { createServer } from "node:http" +import { basePathServer, normalizeBasePath } from "./base-path" import { MDNS } from "./mdns" import { HttpApiApp } from "./routes/instance/httpapi/server" import { disposeMiddleware } from "./routes/instance/httpapi/lifecycle" @@ -21,6 +21,7 @@ export type Listener = { hostname: string port: number url: URL + basePath: string stop: (close?: boolean) => Promise } @@ -34,6 +35,8 @@ type ListenOptions = CorsOptions & { hostname: string mdns?: boolean mdnsDomain?: string + basePath?: string + basePathStripped?: boolean } type ListenerState = { scope: Scope.Scope @@ -71,20 +74,22 @@ export async function openapi() { export let url: URL | undefined export async function listen(opts: ListenOptions): Promise { - const listener = await Effect.runPromise(listenEffect(opts)) + const listener = await Effect.runPromise(listenEffect({ ...opts, basePath: normalizeBasePath(opts.basePath) })) return { hostname: listener.hostname, port: listener.port, url: listener.url, + basePath: listener.basePath, stop: (close?: boolean) => Effect.runPromiseExit(listener.stop(close)).then(() => undefined), } } const listenEffect: (opts: ListenOptions) => Effect.Effect = Effect.fn("Server.listen")( function* (opts: ListenOptions) { + const basePath = opts.basePath ?? "" const state = yield* startWithPortFallback(opts) const address = yield* tcpAddress(state) - const listenerUrl = makeURL(opts.hostname, address.port) + const listenerUrl = makeURL(opts.hostname, address.port, basePath) const unpublishMdns = yield* setupMdns(opts, address.port, state.scope) url = listenerUrl @@ -92,6 +97,7 @@ const listenEffect: (opts: ListenOptions) => Effect.Effect | null> | undefined export const UI_UPSTREAM = new URL("https://app.opencode.ai") -export const csp = (hash = "") => - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:` +export const csp = (...hashes: string[]) => { + const hashParts = hashes + .filter(Boolean) + .map((h) => ` 'sha256-${h}'`) + .join("") + return `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hashParts}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; media-src 'self' data:; connect-src * data: blob:` +} export const DEFAULT_CSP = csp() -export function themePreloadHash(body: string) { - return body.match(/]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i) +function inlineScriptHashes(body: string): string[] { + const hashes: string[] = [] + const re = /]*\bsrc\s*=)[^>]*>([\s\S]*?)<\/script>/gi + let m + while ((m = re.exec(body))) { + if (m[1].trim()) hashes.push(createHash("sha256").update(m[1]).digest("base64")) + } + return hashes } export function cspForHtml(body: string) { - const match = themePreloadHash(body) - return csp(match ? createHash("sha256").update(match[2]).digest("base64") : "") + return csp(...inlineScriptHashes(body)) } function requestBody(request: HttpServerRequest.HttpServerRequest) { @@ -48,15 +58,28 @@ export function embeddedUI(disableEmbeddedWebUi: boolean) { import("opencode-web-ui.gen.ts").then((module) => module.default as Record).catch(() => null)) } +export function injectBasePath(html: string, basePath: string): string { + const safe = JSON.stringify(basePath).replace(/window.__OPENCODE_BASE_PATH__=${safe}` + // Vite emits relative entrypoints; the base must also work on a deep-link reload. + const body = html + .replace(/]*>/gi, "") + .replace(/((?:href|src)=["'])\/(?!\/)/g, (_, attribute: string) => `${attribute}${href}`) + return body.replace(/]*>/i, (head) => `${head}\n\n${script}`) +} + function notFound() { return HttpServerResponse.jsonUnsafe({ error: "Not Found" }, { status: 404 }) } -function embeddedUIResponse(file: string, body: Uint8Array) { +function embeddedUIResponse(file: string, body: Uint8Array, basePath = "") { 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))) + const html = injectBasePath(new TextDecoder().decode(body), basePath) + headers.set("content-security-policy", cspForHtml(html)) + return HttpServerResponse.text(html, { headers }) } return HttpServerResponse.raw(body, { headers }) } @@ -65,25 +88,27 @@ export function serveEmbeddedUIEffect( requestPath: string, fs: FSUtil.Interface, embeddedWebUI: Record, + basePath = "", ) { const file = embeddedWebUI[requestPath.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null if (!file) return Effect.succeed(notFound()) return fs.readFile(file).pipe( - Effect.map((body) => embeddedUIResponse(file, body)), + Effect.map((body) => embeddedUIResponse(file, body, basePath)), Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(notFound())), ) } export function serveUIEffect( request: HttpServerRequest.HttpServerRequest, - services: { fs: FSUtil.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean }, + services: { fs: FSUtil.Interface; client: HttpClient.HttpClient; disableEmbeddedWebUi: boolean; basePath?: string }, ) { return Effect.gen(function* () { const embeddedWebUI = yield* Effect.promise(() => embeddedUI(services.disableEmbeddedWebUi)) const path = new URL(request.url, "http://localhost").pathname + const bp = services.basePath ?? "" - if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI) + if (embeddedWebUI) return yield* serveEmbeddedUIEffect(path, services.fs, embeddedWebUI, bp) const response = yield* services.client.execute( HttpClientRequest.make(request.method)(upstreamURL(path), { @@ -94,7 +119,7 @@ export function serveUIEffect( const headers = proxyResponseHeaders(response.headers) if (response.headers["content-type"]?.includes("text/html")) { - const body = yield* response.text + const body = injectBasePath(yield* response.text, bp) headers.set("Content-Security-Policy", cspForHtml(body)) return HttpServerResponse.text(body, { status: response.status, headers }) } diff --git a/packages/opencode/test/server/base-path-proxy.test.ts b/packages/opencode/test/server/base-path-proxy.test.ts new file mode 100644 index 000000000000..2661df264684 --- /dev/null +++ b/packages/opencode/test/server/base-path-proxy.test.ts @@ -0,0 +1,120 @@ +import { NodeHttpServer } from "@effect/platform-node" +import { describe, expect } from "bun:test" +import { Context, Effect, Layer, Queue } from "effect" +import { Socket } from "effect/unstable/socket" +import { + FetchHttpClient, + HttpClient, + HttpClientRequest, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" +import { basePathServer } from "../../src/server/base-path" +import { HttpApiProxy } from "../../src/server/routes/instance/httpapi/middleware/proxy" +import { injectBasePath } from "../../src/server/shared/ui" +import { testEffect } from "../lib/effect" + +const prefix = "/nested/proxy/service" +const it = testEffect(Layer.mergeAll(NodeHttpServer.layerTest, Socket.layerWebSocketConstructorGlobal)) + +for (const stripped of [false, true]) { + describe(`base path through proxy (stripped=${stripped})`, () => { + it.live("forwards HTTP bodies, auth, queries, HTML and WebSocket frames", () => + Effect.gen(function* () { + const context = yield* Layer.build( + NodeHttpServer.layer(() => basePathServer({ basePath: prefix, basePathStripped: stripped }), { + host: "127.0.0.1", + port: 0, + }), + ) + const backend = Context.get(context, HttpServer.HttpServer) + yield* backend.serve( + HttpServerRequest.HttpServerRequest.use((request) => + Effect.gen(function* () { + if (request.url.startsWith("/api/pty/")) { + const socket = yield* request.upgrade + const write = yield* socket.writer + yield* socket + .runRaw((message) => write(`${request.url}:${String(message)}`)) + .pipe(Effect.catch(() => Effect.void)) + return HttpServerResponse.empty() + } + if (request.url.startsWith("/project/session/")) + return HttpServerResponse.html( + injectBasePath('', prefix), + ) + return HttpServerResponse.jsonUnsafe({ + path: request.url, + method: request.method, + authorization: request.headers.authorization, + body: yield* request.text, + }) + }), + ), + ) + const upstream = HttpServer.formatAddress(backend.address) + const client = yield* HttpClient.HttpClient + const upstreamClient = yield* Effect.provide(HttpClient.HttpClient, FetchHttpClient.layer) + // A reverse proxy can strip the entire configured prefix before forwarding. + yield* HttpServer.serveEffect()( + HttpServerRequest.HttpServerRequest.use((request) => { + const path = stripped ? request.url.slice(prefix.length) : request.url + if (request.headers.upgrade === "websocket") return HttpApiProxy.websocket(request, upstream + path) + return HttpApiProxy.http(upstreamClient, upstream + path, {}, request) + }), + ) + + const response = yield* client.execute( + HttpClientRequest.post(prefix + "/api/echo?q=a?b&value=%2F", { + headers: { authorization: "Bearer test" }, + }).pipe(HttpClientRequest.bodyText("payload")), + ) + expect(response.status).toBe(200) + expect(yield* response.json).toEqual({ + path: "/api/echo?q=a?b&value=%2F", + method: "POST", + authorization: "Bearer test", + body: "payload", + }) + const html = yield* client.get(prefix + "/project/session/test").pipe(Effect.flatMap((r) => r.text)) + expect(html).toContain(``) + expect(html).toContain(`window.__OPENCODE_BASE_PATH__="${prefix}"`) + + const server = yield* HttpServer.HttpServer + const socket = yield* Socket.makeWebSocket( + HttpServer.formatAddress(server.address).replace(/^http/, "ws") + prefix + "/api/pty/test/connect?ticket=abc", + { closeCodeIsError: () => false }, + ) + const messages = yield* Queue.unbounded() + yield* socket.runRaw((message) => Queue.offer(messages, String(message))).pipe(Effect.forkScoped) + const write = yield* socket.writer + yield* write("hello") + expect(yield* Queue.take(messages)).toBe("/api/pty/test/connect?ticket=abc:hello") + }).pipe(Effect.timeout("10 seconds")), + ) + }) +} + +it.live("redirects only the exact prefix and preserves the entire query and POST method", () => + Effect.gen(function* () { + const context = yield* Layer.build( + NodeHttpServer.layer(() => basePathServer({ basePath: prefix }), { + host: "127.0.0.1", + port: 0, + }), + ) + const server = Context.get(context, HttpServer.HttpServer) + yield* server.serve(Effect.succeed(HttpServerResponse.text("ok"))) + const client = yield* Effect.provide( + HttpClient.HttpClient, + FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.RequestInit)({ redirect: "manual" }))), + ) + const url = HttpServer.formatAddress(server.address) + const response = yield* client.post(url + prefix + "?next=a?b&value=%2F") + expect(response.status).toBe(308) + expect(response.headers.location).toBe(prefix + "/?next=a?b&value=%2F") + expect((yield* client.get(url + prefix + "-other/api/health")).status).toBe(404) + expect((yield* client.get(url + "/api/health")).status).toBe(404) + }), +) diff --git a/packages/opencode/test/server/base-path.test.ts b/packages/opencode/test/server/base-path.test.ts new file mode 100644 index 000000000000..fb81f2e8bd56 --- /dev/null +++ b/packages/opencode/test/server/base-path.test.ts @@ -0,0 +1,126 @@ +import { createHash } from "node:crypto" +import { describe, expect, test } from "bun:test" +import { normalizeBasePath } from "../../src/cli/network" +import { cspForHtml, injectBasePath } from "../../src/server/shared/ui" + +describe("normalizeBasePath", () => { + test("returns empty for undefined", () => { + expect(normalizeBasePath(undefined)).toBe("") + }) + + test("returns empty for empty string", () => { + expect(normalizeBasePath("")).toBe("") + }) + + test("returns empty for whitespace", () => { + expect(normalizeBasePath(" ")).toBe("") + }) + + test("returns empty for single slash", () => { + expect(normalizeBasePath("/")).toBe("") + }) + + test("ensures leading slash", () => { + expect(normalizeBasePath("opencode")).toBe("/opencode") + }) + + test("keeps existing leading slash", () => { + expect(normalizeBasePath("/opencode")).toBe("/opencode") + }) + + test("strips trailing slashes", () => { + expect(normalizeBasePath("/opencode/")).toBe("/opencode") + expect(normalizeBasePath("/opencode///")).toBe("/opencode") + }) + + test("handles nested paths", () => { + expect(normalizeBasePath("/apps/opencode")).toBe("/apps/opencode") + }) + + test("trims whitespace around path", () => { + expect(normalizeBasePath(" /opencode ")).toBe("/opencode") + }) +}) + +describe("injectBasePath", () => { + const minimalHtml = `Test` + + test("sets the document base for deep links on an unprefixed server", () => { + expect(injectBasePath(minimalHtml, "")).toContain('') + }) + + test("injects global script into head", () => { + const result = injectBasePath(minimalHtml, "/opencode") + expect(result).toContain(``) + }) + + test("rewrites href attributes", () => { + const html = `` + const result = injectBasePath(html, "/opencode") + expect(result).toContain(`href="/opencode/assets/style.css"`) + }) + + test("rewrites src attributes", () => { + const html = `` + const result = injectBasePath(html, "/opencode") + expect(result).toContain(`src="/opencode/assets/app.js"`) + }) + + test("does not rewrite protocol-relative URLs", () => { + const html = `` + const result = injectBasePath(html, "/opencode") + expect(result).toContain(`href="//cdn.example.com/style.css"`) + }) + + test("does not rewrite content attributes", () => { + const html = `` + const result = injectBasePath(html, "/opencode") + expect(result).toContain(`content="/not-a-url"`) + }) + + test("escapes basePath to prevent XSS via script tag breakout", () => { + const result = injectBasePath(minimalHtml, '/test"') + expect(result).not.toContain("alert(1)") + expect(result).toContain("\\u003c") + }) +}) + +describe("cspForHtml with injected base path", () => { + test("includes hash for injected base path script", () => { + const html = `Test` + const injected = injectBasePath(html, "/opencode") + const csp = cspForHtml(injected) + + const scriptContent = `window.__OPENCODE_BASE_PATH__="/opencode"` + const expectedHash = createHash("sha256").update(scriptContent).digest("base64") + expect(csp).toContain(`'sha256-${expectedHash}'`) + }) + + test("includes hashes for both injected and existing inline scripts", () => { + const existingScript = 'document.documentElement.dataset.theme = "dark"' + const html = `` + const injected = injectBasePath(html, "/opencode") + const csp = cspForHtml(injected) + + const existingHash = createHash("sha256").update(existingScript).digest("base64") + const injectedScript = `window.__OPENCODE_BASE_PATH__="/opencode"` + const injectedHash = createHash("sha256").update(injectedScript).digest("base64") + + expect(csp).toContain(`'sha256-${existingHash}'`) + expect(csp).toContain(`'sha256-${injectedHash}'`) + }) +}) + +test.each(["//example.com/path", "/a/../b", "/prefix?query", "/prefix#hash", "/test\\path", '/test"bad', "/test path"])( + "rejects an invalid base path %j", + (value) => expect(() => normalizeBasePath(value)).toThrow(), +) + +test("replaces an existing base and safely handles replacement metacharacters", () => { + const html = + '' + const result = injectBasePath(html, "/apps/$&") + expect(result).toContain('') + expect(result).not.toContain("/old/") + expect(result).toContain('href="/apps/$&/icon.png"') +}) diff --git a/packages/opencode/test/server/network-prefix.test.ts b/packages/opencode/test/server/network-prefix.test.ts new file mode 100644 index 000000000000..74bd39f4ae4d --- /dev/null +++ b/packages/opencode/test/server/network-prefix.test.ts @@ -0,0 +1,35 @@ +import { afterEach, expect, test } from "bun:test" +import yargs from "yargs" +import { resolveNetworkOptionsNoConfig, withNetworkOptions } from "../../src/cli/network" + +const argv = process.argv +afterEach(() => { + process.argv = argv +}) + +async function resolve(args: string[], server = { basePath: "/configured/", basePathStripped: true }) { + process.argv = ["bun", "opencode", "web", ...args] + return resolveNetworkOptionsNoConfig(await withNetworkOptions(yargs(args)).parse(), { server }) +} + +test("reads prefix and stripping mode from config", async () => { + expect(await resolve([])).toMatchObject({ basePath: "/configured", basePathStripped: true }) +}) + +test("explicit CLI arguments override the config", async () => { + expect(await resolve(["--base-path=/nested/proxy/service/", "--no-base-path-stripped"])).toMatchObject({ + basePath: "/nested/proxy/service", + basePathStripped: false, + }) +}) + +test("an explicit empty prefix disables the configured prefix", async () => { + expect(await resolve(["--base-path="])).toMatchObject({ basePath: "" }) +}) + +test("arguments after -- do not override the config", async () => { + expect(await resolve(["--", "--base-path=/other", "--no-base-path-stripped"])).toMatchObject({ + basePath: "/configured", + basePathStripped: true, + }) +}) diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 0c1c656bb503..24d04dbca9cd 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -38,7 +38,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-prefix.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", diff --git a/packages/ui/src/assets/favicon/site.webmanifest b/packages/ui/src/assets/favicon/site.webmanifest index 427c3adf423d..34ad23974676 100644 --- a/packages/ui/src/assets/favicon/site.webmanifest +++ b/packages/ui/src/assets/favicon/site.webmanifest @@ -1,18 +1,18 @@ { "name": "OpenCode", "short_name": "OpenCode", - "id": "/", - "start_url": "/", - "scope": "/", + "id": "./", + "start_url": "./", + "scope": "./", "icons": [ { - "src": "/web-app-manifest-192x192.png", + "src": "./web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", "purpose": "maskable" }, { - "src": "/web-app-manifest-512x512.png", + "src": "./web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"