Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
915da3e
feat(server): add --base-path CLI flag and config option
fabiovincenzi May 19, 2026
0abe073
feat(server): base path URL rewriting and prefix stripping
fabiovincenzi May 19, 2026
735365c
feat(server): runtime HTML injection and CSP for base path
fabiovincenzi May 19, 2026
3ed473b
feat(app): base path support for router and API URLs
fabiovincenzi May 19, 2026
67f424d
fix(server): prevent XSS via script tag breakout in base path injection
fabiovincenzi Jun 1, 2026
9df3ba8
fix(server): preserve query string in base path redirect and harden a…
fabiovincenzi Jun 1, 2026
9dc842c
test(server): add base path unit tests
fabiovincenzi Jun 1, 2026
bd7fff5
merge upstream/dev into feat/base-path-support
fabiovincenzi Jun 11, 2026
0d337d9
fix(app): use router navigate instead of native <a> hrefs in titlebar
fabiovincenzi Jun 22, 2026
d6b9962
merge upstream/dev, resolve conflicts preserving base-path support
fabiovincenzi Jun 22, 2026
1ec0e57
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jun 22, 2026
413fd3d
fix(app): use only server-injected base path for router base
fabiovincenzi Jun 22, 2026
457254a
Merge branch 'feat/base-path-support' of https://github.com/fabiovinc…
fabiovincenzi Jun 22, 2026
089077d
feat(app): add document.baseURI fallback for proxy-injected base paths
fabiovincenzi Jun 26, 2026
08b53db
merge upstream/dev, resolve conflicts preserving base-path support
fabiovincenzi Jun 26, 2026
449d3bb
fix: use correct parameter name in CorsConfig
fabiovincenzi Jun 26, 2026
4f9538c
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jun 26, 2026
c1914bc
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jun 26, 2026
354c0f6
Merge remote-tracking branch 'upstream/dev' into feat/base-path-support
fabiovincenzi Jul 1, 2026
c87e9ba
Merge branch 'feat/base-path-support' of https://github.com/fabiovinc…
fabiovincenzi Jul 1, 2026
469f7dd
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jul 1, 2026
01c10b4
Merge remote-tracking branch 'upstream/dev' into feat/base-path-support
fabiovincenzi Jul 7, 2026
1d6b544
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jul 7, 2026
fb74b2d
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Jul 9, 2026
9208645
Merge remote-tracking branch 'upstream/dev' into feat/base-path-support
fabiovincenzi Aug 4, 2026
834d91d
Merge remote-tracking branch 'origin/feat/base-path-support' into fea…
fabiovincenzi Aug 4, 2026
081ef35
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Aug 4, 2026
f3d3630
Merge remote-tracking branch 'upstream/dev' into feat/base-path-support
fabiovincenzi Aug 10, 2026
49e3ada
Merge remote-tracking branch 'origin/feat/base-path-support' into fea…
fabiovincenzi Aug 10, 2026
970295c
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Aug 11, 2026
0a2537f
Merge branch 'dev' into feat/base-path-support
fabiovincenzi Aug 23, 2026
3cd1529
fix: preserve base path in v1 client URL construction
Aug 26, 2026
0b80b00
feat(web): merge base path support and client follow-up
fromelicks Sep 8, 2026
be99479
fix(web): complete prefix routing through stripping proxies
fromelicks Sep 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
26 changes: 26 additions & 0 deletions packages/app/script/patch-client-prefix.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
2 changes: 2 additions & 0 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -601,6 +602,7 @@ export function AppInterface(props: {
</PermissionProvider>
</TabsProvider>
)}
base={appBasePath() || undefined}
>
<Routes serverScoped={props.serverScoped} />
</Dynamic>
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/components/debug-bar.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions packages/app/src/components/titlebar-tab-nav.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -201,7 +202,7 @@ export function TabNavItem(props: {
}}
>
<MenuV2.Context.Trigger
as="a"
as={A}
disabled={editing() || props.dragging}
aria-haspopup="menu"
aria-expanded={menu.open}
Expand Down Expand Up @@ -381,7 +382,7 @@ export function DraftTabItem(props: {
closeTab(event)
}}
>
<a
<A
data-slot="tab-link"
data-titlebar-tab-link
href={props.href}
Expand Down Expand Up @@ -414,7 +415,7 @@ export function DraftTabItem(props: {
>
{props.title}
</span>
</a>
</A>
<div data-slot="tab-close">
<IconButtonV2
size="small"
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/components/titlebar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { appPath } from "@/utils/base-path"
import {
createEffect,
createMemo,
Expand Down Expand Up @@ -97,13 +98,13 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
action: undefined as "back" | "forward" | undefined,
})

const path = () => `${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"
})

Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/context/layout.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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") {
Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/context/tabs.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -231,7 +232,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
// 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(
Expand Down
6 changes: 4 additions & 2 deletions packages/app/src/entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 = () => {
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/env.d.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/app/src/pages/directory-layout.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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 })
})

Expand Down
5 changes: 3 additions & 2 deletions packages/app/src/pages/session/use-session-hash-scroll.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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,
})
}
Expand Down
52 changes: 52 additions & 0 deletions packages/app/src/utils/base-path.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
23 changes: 23 additions & 0 deletions packages/app/src/utils/base-path.ts
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions packages/app/src/utils/server-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
3 changes: 2 additions & 1 deletion packages/app/src/utils/server-protocol.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { serverRequestURL } from "./base-path"
import type { ServerConnection } from "@/context/server"
import { authTokenFromCredentials } from "./server"

Expand All @@ -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),
})
Expand Down
Loading
Loading