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(/`
+ // 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"