From fbc01dd084d879e1328e1d71dca415a644c7484d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 30 Oct 2026 14:14:12 +1000 Subject: [PATCH] fix(app): match own server loopback port before treating http servers as local --- packages/app/src/home/projects/reveal.ts | 5 ++- packages/app/src/runtime/plugin/host-apis.tsx | 2 +- packages/app/src/runtime/server/current.tsx | 4 +- .../app/src/runtime/server/registry.test.ts | 42 ++++++++++++------- packages/app/src/runtime/server/registry.tsx | 28 ++++++++++--- packages/app/src/runtime/server/runtime.tsx | 22 +++++----- packages/app/src/settings/surface.tsx | 5 ++- .../app/src/workspaces/selection/dialog.tsx | 5 ++- .../src/workspaces/selection/picker.test.ts | 23 ++++++---- .../app/src/workspaces/selection/picker.tsx | 8 +++- .../app/src/workspaces/selection/policy.ts | 4 +- packages/plugin/src/gui/host-apis.ts | 2 +- 12 files changed, 99 insertions(+), 51 deletions(-) diff --git a/packages/app/src/home/projects/reveal.ts b/packages/app/src/home/projects/reveal.ts index 5de6f8369ff9..d9bf5c048ef4 100644 --- a/packages/app/src/home/projects/reveal.ts +++ b/packages/app/src/home/projects/reveal.ts @@ -1,6 +1,6 @@ import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" -import { ServerConnection } from "@/runtime/server/registry" +import { ServerConnection, useServers } from "@/runtime/server/registry" import { displayName } from "@opencode/ui/project-avatar" import { errorMessage } from "@/shell/layout/helpers" import type { LocalProject } from "@/shell/state/layout" @@ -9,9 +9,10 @@ import { showToast } from "@/shell/notifications/toast" export function useRevealProject() { const language = useLanguage() const platform = usePlatform() + const servers = useServers() const available = (conn: ServerConnection.Any) => - platform.platform === "desktop" && !!platform.revealPath && ServerConnection.local(conn) + platform.platform === "desktop" && !!platform.revealPath && servers.local(conn) return { available, diff --git a/packages/app/src/runtime/plugin/host-apis.tsx b/packages/app/src/runtime/plugin/host-apis.tsx index 3b180b1e9592..bb02f6942883 100644 --- a/packages/app/src/runtime/plugin/host-apis.tsx +++ b/packages/app/src/runtime/plugin/host-apis.tsx @@ -535,7 +535,7 @@ export function createPluginAttachment(apis: HostApis) { return live().data }, get local() { - return ServerConnection.local(listed()) + return servers.local(listed()) }, get builtin() { return ServerConnection.builtin(listed()) diff --git a/packages/app/src/runtime/server/current.tsx b/packages/app/src/runtime/server/current.tsx index 93a9ca805463..18c254b9e28d 100644 --- a/packages/app/src/runtime/server/current.tsx +++ b/packages/app/src/runtime/server/current.tsx @@ -14,7 +14,9 @@ const context = createSimpleContext({ return { conn, key, - isLocal: ServerConnection.local(props.conn), + get isLocal() { + return props.ctx.isLocal + }, ctx: props.ctx, get health() { return global.servers.health[key] diff --git a/packages/app/src/runtime/server/registry.test.ts b/packages/app/src/runtime/server/registry.test.ts index 5f42989b7268..b7b41d44a651 100644 --- a/packages/app/src/runtime/server/registry.test.ts +++ b/packages/app/src/runtime/server/registry.test.ts @@ -52,24 +52,36 @@ test("startup auth_token credentials override a persisted same-url server; witho expect(kept[0]?.type === "http" ? kept[0].authToken : true).toBeUndefined() }) -test("treats WSL sidecars as remote server connections", () => { +test("treats WSL and mismatched-port loopback servers as remote connections", () => { + const windowsSidecar = "http://127.0.0.1:28894" + expect( - ServerConnection.local({ - type: "plugin", - key: "wsl:Debian", - plugin: "wsl", - state: "ready", - connecting: false, - authenticationRequired: false, - managed: false, - http: { url: "http://127.0.0.1:4097" }, - }), + ServerConnection.local( + { + type: "plugin", + key: "wsl:Debian", + plugin: "wsl", + state: "ready", + connecting: false, + authenticationRequired: false, + managed: false, + http: { url: "http://127.0.0.1:4097" }, + }, + windowsSidecar, + ), ).toBe(false) - expect(ServerConnection.local({ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } })).toBe( - true, + expect( + ServerConnection.local({ type: "sidecar", variant: "base", http: { url: windowsSidecar } }), + ).toBe(true) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:28894" } }, windowsSidecar)).toBe(true) + expect(ServerConnection.local({ type: "http", http: { url: "http://[::1]:28894" } }, windowsSidecar)).toBe(true) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } }, windowsSidecar)).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "http://127.0.0.1:4096" } }, windowsSidecar)).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:49374" } }, windowsSidecar)).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(false) + expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } }, windowsSidecar)).toBe( + false, ) - expect(ServerConnection.local({ type: "http", http: { url: "http://localhost:4096" } })).toBe(true) - expect(ServerConnection.local({ type: "http", http: { url: "https://server.example.test" } })).toBe(false) }) test("keeps exact persisted server identities and prevents removing provided servers", () => { diff --git a/packages/app/src/runtime/server/registry.tsx b/packages/app/src/runtime/server/registry.tsx index 8bbd24e45d49..138f77f98f79 100644 --- a/packages/app/src/runtime/server/registry.tsx +++ b/packages/app/src/runtime/server/registry.tsx @@ -38,10 +38,13 @@ export function serverLabel(conn: ServerConnection.Any, current?: { hostname?: s return serverName(conn) } -function isLocalHost(url: string) { - const host = url.replace(/^https?:\/\//, "").split(":")[0] +function loopbackOrigin(url: string) { + if (!URL.canParse(url)) return + const parsed = new URL(url) - if (host === "localhost" || host === "127.0.0.1") return "local" + if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "[::1]") return + + return `${parsed.protocol}//127.0.0.1:${parsed.port || (parsed.protocol === "https:" ? "443" : "80")}` } // Project data comes from the server; this keeps which projects this client has used and the last one. @@ -192,8 +195,18 @@ export namespace ServerConnection { return true } - export const local = (conn?: Any) => - !!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local")) + // The desktop sidecar, or a loopback http connection on the same port as the app's own server. Other loopback + // ports may belong to a WSL server, tunnel, or container with a separate filesystem. + export const local = (conn?: Any, ownUrl?: string) => { + if (!conn) return false + + if (builtin(conn)) return true + + if (conn.type !== "http" || !ownUrl) return false + const origin = loopbackOrigin(conn.http.url) + + return !!origin && origin === loopbackOrigin(ownUrl) + } } export const { use: useServers, provider: ServersProvider } = createSimpleContext({ @@ -265,6 +278,9 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex const current = (conn: ServerConnection.Any) => ServerConnection.builtin(conn) || ServerConnection.key(conn) === props.canonicalLocalServer + const ownUrl = () => props.servers?.find(ServerConnection.builtin)?.http.url ?? props.canonicalLocalServer + const local = (conn?: ServerConnection.Any) => ServerConnection.local(conn, ownUrl()) + const label = (conn: ServerConnection.Any) => serverLabel( conn, @@ -294,6 +310,8 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex canRemove, scope, current, + ownUrl, + local, /** The name switchers, menus and titles show. */ label, /** The name a list row shows next to its item: nothing for the current server or when it is the only one. */ diff --git a/packages/app/src/runtime/server/runtime.tsx b/packages/app/src/runtime/server/runtime.tsx index 4624a878374b..aab15bff34f6 100644 --- a/packages/app/src/runtime/server/runtime.tsx +++ b/packages/app/src/runtime/server/runtime.tsx @@ -70,7 +70,13 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext( const serverCtx = createRoot((dispose) => { serverCtxDisposers.set(key, dispose) - return createServerController(conn, server.scope(key), server.projects.forServer(key), notificationCoordinator) + return createServerController( + conn, + server.scope(key), + () => server.local(conn), + server.projects.forServer(key), + notificationCoordinator, + ) }, owner) serverCtxs.set(key, serverCtx) @@ -185,6 +191,7 @@ function createGlobalModels() { function createServerController( conn: ServerConnection.Any, scope: ServerScope, + isLocal: Accessor, projects: ReturnType, notificationCoordinator: ReturnType, ) { @@ -321,14 +328,13 @@ function createServerController( }) } - const isLocal = - (conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url)) - return { data, sdk, sync, - isLocal, + get isLocal() { + return isLocal() + }, projects: { list, unique, @@ -367,12 +373,6 @@ export function useServerCtx(server: Accessor) export type ServerCtx = ReturnType -function isLocalHost(url: string) { - const host = url.replace(/^https?:\/\//, "").split(":")[0] - - if (host === "localhost" || host === "127.0.0.1") return "local" -} - /** What an HTTP server's controller authenticates with; other servers keep one controller per id. */ function credentials(conn: ServerConnection.Any) { return conn.type === "http" ? `${conn.http.url}\n${conn.http.password ?? ""}` : "" diff --git a/packages/app/src/settings/surface.tsx b/packages/app/src/settings/surface.tsx index 15da98cb1fd5..e893a21d0926 100644 --- a/packages/app/src/settings/surface.tsx +++ b/packages/app/src/settings/surface.tsx @@ -4,7 +4,7 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode/ui/context" import { useLayout, type LayoutRoute } from "@/shell/state/layout" import { useTabs } from "@/shell/tabs/tabs" -import { ServerConnection } from "@/runtime/server/registry" +import { useServers } from "@/runtime/server/registry" import { useCommand } from "@/shell/commands/command" import { createSettingsPages } from "@/runtime/plugin/settings-pages" import { useSettingsServers } from "./servers/inventory" @@ -38,6 +38,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr const layout = useLayout() const command = useCommand() const servers = useSettingsServers() + const registry = useServers() const tabs = useTabs() const location = useLocation<{ @@ -76,7 +77,7 @@ export const { use: useSettingsSurface, provider: SettingsSurfaceProvider } = cr return ( list.find((item) => item.key === key) ?? - list.find((item) => !!item.connection && ServerConnection.local(item.connection)) ?? + list.find((item) => !!item.connection && registry.local(item.connection)) ?? list[0] ) }) diff --git a/packages/app/src/workspaces/selection/dialog.tsx b/packages/app/src/workspaces/selection/dialog.tsx index b92e92c5c32f..8207aa302adc 100644 --- a/packages/app/src/workspaces/selection/dialog.tsx +++ b/packages/app/src/workspaces/selection/dialog.tsx @@ -18,7 +18,7 @@ import { createStore } from "solid-js/store" import { useGlobal } from "@/runtime/server/runtime" import { inferHomeDirectory } from "@/runtime/server/global-sync/bootstrap" import { useLanguage } from "@/runtime/i18n/language" -import { ServerConnection, serverName } from "@/runtime/server/registry" +import { ServerConnection, serverName, useServers } from "@/runtime/server/registry" import { pathKey } from "@/workspaces/path-key" import type { LocationRef } from "@opencode/client/promise" import { @@ -61,6 +61,7 @@ interface DirectoryPickerDialogProps { export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { const global = useGlobal() + const servers = useServers() const serverCtx = global.ensureServerCtx(props.server) const sync = serverCtx.sync const sdk = serverCtx.sdk @@ -121,7 +122,7 @@ export function DirectoryPickerDialog(props: DirectoryPickerDialogProps) { sync.data.path.home || inferHomeDirectory(props.location?.directory ?? ""), ) - const subtitle = createMemo(() => (ServerConnection.local(props.server) ? undefined : serverName(props.server))) + const subtitle = createMemo(() => (servers.local(props.server) ? undefined : serverName(props.server))) const location = createMemo(() => { const current = props.location ?? fallbackPath.latest diff --git a/packages/app/src/workspaces/selection/picker.test.ts b/packages/app/src/workspaces/selection/picker.test.ts index 4e49b6f88257..547d3d929242 100644 --- a/packages/app/src/workspaces/selection/picker.test.ts +++ b/packages/app/src/workspaces/selection/picker.test.ts @@ -4,7 +4,7 @@ import { directoryPickerKind } from "./policy" const local = { type: "sidecar", variant: "base", - http: { url: "http://localhost:4096" }, + http: { url: "http://127.0.0.1:28894" }, } as const const remote = { @@ -20,11 +20,20 @@ const remote = { describe("directoryPickerKind", () => { test("uses the native picker only for local desktop projects", () => { - expect(directoryPickerKind("desktop", local)).toBe("native") - expect(directoryPickerKind("desktop", remote)).toBe("server") - expect(directoryPickerKind("desktop", { ...remote, key: "wsl:Ubuntu", plugin: "wsl", managed: false })).toBe( - "server", - ) - expect(directoryPickerKind("web", local)).toBe("server") + expect(directoryPickerKind("desktop", local, local.http.url)).toBe("native") + expect( + directoryPickerKind("desktop", { type: "http", http: { url: "http://localhost:28894" } }, local.http.url), + ).toBe("native") + expect( + directoryPickerKind("desktop", { type: "http", http: { url: "http://localhost:4096" } }, local.http.url), + ).toBe("server") + expect( + directoryPickerKind("desktop", { type: "http", http: { url: "http://localhost:49374" } }, local.http.url), + ).toBe("server") + expect(directoryPickerKind("desktop", remote, local.http.url)).toBe("server") + expect( + directoryPickerKind("desktop", { ...remote, key: "wsl:Ubuntu", plugin: "wsl", managed: false }, local.http.url), + ).toBe("server") + expect(directoryPickerKind("web", local, local.http.url)).toBe("server") }) }) diff --git a/packages/app/src/workspaces/selection/picker.tsx b/packages/app/src/workspaces/selection/picker.tsx index 6697c5687446..2c2048c731ee 100644 --- a/packages/app/src/workspaces/selection/picker.tsx +++ b/packages/app/src/workspaces/selection/picker.tsx @@ -1,5 +1,5 @@ import { useDialog } from "@opencode/ui/context/dialog" -import { ServerConnection } from "@/runtime/server/registry" +import { ServerConnection, useServers } from "@/runtime/server/registry" import { usePlatform } from "@/runtime/platform/platform" import { lazy, Suspense } from "solid-js" import type { LocationRef } from "@opencode/client/promise" @@ -19,10 +19,14 @@ type DirectoryPickerInput = { export function useDirectoryPicker() { const platform = usePlatform() + const servers = useServers() const dialog = useDialog() return (input: DirectoryPickerInput) => { - if (directoryPickerKind(platform.platform, input.server) === "native" && platform.platform === "desktop") { + if ( + directoryPickerKind(platform.platform, input.server, servers.ownUrl()) === "native" && + platform.platform === "desktop" + ) { void platform.openDirectoryPickerDialog({ title: input.title, multiple: input.multiple }).then(input.onSelect) return diff --git a/packages/app/src/workspaces/selection/policy.ts b/packages/app/src/workspaces/selection/policy.ts index b92a2828ffcf..952c18591cb1 100644 --- a/packages/app/src/workspaces/selection/policy.ts +++ b/packages/app/src/workspaces/selection/policy.ts @@ -1,8 +1,8 @@ import { ServerConnection } from "@/runtime/server/registry" import type { Platform } from "@/runtime/platform/platform" -export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) { - if (platform === "desktop" && ServerConnection.local(server)) return "native" as const +export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any, ownUrl?: string) { + if (platform === "desktop" && ServerConnection.local(server, ownUrl)) return "native" as const return "server" as const } diff --git a/packages/plugin/src/gui/host-apis.ts b/packages/plugin/src/gui/host-apis.ts index f22fa830ed9b..30d87cdaae29 100644 --- a/packages/plugin/src/gui/host-apis.ts +++ b/packages/plugin/src/gui/host-apis.ts @@ -23,7 +23,7 @@ export interface ServerRef { readonly client: OpenCodeClient /** The server's synced data (sessions, projects, …). Reactive. */ readonly data: Data - /** The built-in local server or a loopback http server. */ + /** The app's own local server, or a loopback http connection to its port. */ readonly local: boolean /** The app's own server, the desktop sidecar. */ readonly builtin: boolean