refactor(core): share a browser opener across opencode and tui (#51414)

This commit is contained in:
Aiden Cline
2026-09-25 18:09:47 -05:00
committed by GitHub
parent adee738d1e
commit 29f07e0c69
17 changed files with 87 additions and 36 deletions
+1
View File
@@ -119,6 +119,7 @@
"mime-types": "3.0.2",
"minimatch": "10.2.5",
"npm-package-arg": "13.0.2",
"open": "11.0.4",
"semver": "^7.6.3",
"turndown": "7.2.0",
"venice-ai-sdk-provider": "2.1.1",
+8
View File
@@ -0,0 +1,8 @@
import open from "open"
export function openUrl(input: string) {
const url = URL.canParse(input) ? new URL(input) : undefined
if (!url || (url.protocol !== "http:" && url.protocol !== "https:"))
return Promise.reject(new Error(`Only http and https links can be opened in the browser: ${input}`))
return open(url.href)
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, test } from "bun:test"
import { openUrl } from "@opencode-ai/core/open"
describe("openUrl", () => {
test("rejects values that are not URLs", async () => {
await expect(openUrl("not a url")).rejects.toThrow("Only http and https links")
await expect(openUrl("")).rejects.toThrow("Only http and https links")
})
test("rejects non-http schemes", async () => {
await expect(openUrl("file:///etc/hosts")).rejects.toThrow("Only http and https links")
await expect(openUrl("javascript:alert(1)")).rejects.toThrow("Only http and https links")
await expect(openUrl("ms-msdt:/id PCWDiagnostic")).rejects.toThrow("Only http and https links")
await expect(openUrl("\\\\server\\share\\file.html")).rejects.toThrow("Only http and https links")
})
})
-1
View File
@@ -131,7 +131,6 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"npm-package-arg": "13.0.2",
"open": "10.1.2",
"opencode-gitlab-auth": "2.1.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "catalog:",
+2 -2
View File
@@ -5,9 +5,9 @@ import { Account } from "@/account/account"
import { AccountID, OrgID, PollExpired, type PollResult, type AccountError } from "@/account/schema"
import { effectCmd } from "../effect-cmd"
import * as Prompt from "../effect/prompt"
import open from "open"
import { openUrl } from "@opencode-ai/core/open"
const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() => undefined))
const openBrowser = (url: string) => Effect.promise(() => openUrl(url).catch(() => undefined))
const println = (msg: string) => Effect.sync(() => UI.println(msg))
+3 -3
View File
@@ -3,7 +3,7 @@ import { UI } from "../ui"
import { effectCmd } from "../effect-cmd"
import { withNetworkOptions, resolveNetworkOptions } from "../network"
import { Flag } from "@opencode-ai/core/flag/flag"
import open from "open"
import { openUrl } from "@opencode-ai/core/open"
import { networkInterfaces } from "os"
function getNetworkIPs() {
@@ -72,11 +72,11 @@ export const WebCommand = effectCmd({
}
// Open localhost in browser
open(localhostUrl).catch(() => {})
openUrl(localhostUrl).catch(() => {})
} else {
const displayUrl = server.url.toString()
UI.println(UI.Style.TEXT_INFO_BOLD + " Web interface: ", UI.Style.TEXT_NORMAL, displayUrl)
open(displayUrl).catch(() => {})
openUrl(displayUrl).catch(() => {})
}
yield* Effect.never
+2 -2
View File
@@ -1,6 +1,6 @@
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { openUrl } from "@opencode-ai/core/open"
import { Context, Effect, Layer } from "effect"
import open from "open"
export interface Interface {
readonly open: (url: string) => Effect.Effect<void, Error>
@@ -13,7 +13,7 @@ const layer = Layer.succeed(
Service.of({
open: Effect.fn("McpBrowser.open")(function* (url: string) {
const subprocess = yield* Effect.tryPromise({
try: () => open(url),
try: () => openUrl(url),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
yield* Effect.callback<void, Error>((resume) => {
@@ -125,6 +125,10 @@ export class McpOAuthProvider implements OAuthClientProvider {
}
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
if (authorizationUrl.protocol !== "http:" && authorizationUrl.protocol !== "https:")
throw new Error(
`MCP server "${this.serverUrl}" returned a ${authorizationUrl.protocol} authorization URL; only http and https are supported`,
)
await this.callbacks.onRedirect(authorizationUrl)
}
+2 -2
View File
@@ -3,7 +3,7 @@ import type { Model } from "@opencode-ai/sdk/v2"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http"
import open from "open"
import { openUrl } from "@opencode-ai/core/open"
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
@@ -279,7 +279,7 @@ export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks>
const state = generateState()
const callbackPromise = waitForOAuthCallback(state)
const url = buildAuthorizeUrl(state)
await open(url).catch(() => undefined)
await openUrl(url).catch(() => undefined)
return {
url,
instructions:
@@ -3,7 +3,7 @@ import { OAUTH_DUMMY_KEY } from "../auth"
import { InstallationVersion } from "@opencode-ai/core/installation/version"
import { OauthCallbackPage } from "@opencode-ai/core/oauth/page"
import { createServer } from "http"
import open from "open"
import { openUrl } from "@opencode-ai/core/open"
const OAUTH_CLIENT_ID = "LOCAL_APPLICATION"
const OAUTH_CALLBACK_HOST = "127.0.0.1"
@@ -470,7 +470,7 @@ export async function SnowflakeCortexAuthPlugin(_input: PluginInput): Promise<Ho
const role = (inputs.role || "").trim() || undefined
const url = buildAuthorizeUrl(account, role, state, pkce)
const callbackPromise = waitForOAuthCallback(account, pkce, state)
await open(url).catch(() => undefined)
await openUrl(url).catch(() => undefined)
return {
url,
@@ -34,6 +34,37 @@ describe("McpOAuthProvider.redirectUrl", () => {
})
})
describe("McpOAuthProvider.redirectToAuthorization", () => {
test("forwards http and https authorization URLs", async () => {
const seen: string[] = []
const provider = new McpOAuthProvider(
"test-server",
"https://mcp.example.com/mcp",
{},
{ onRedirect: (url) => void seen.push(url.href) },
stubAuth,
)
await provider.redirectToAuthorization(new URL("https://auth.example.com/authorize?state=1"))
await provider.redirectToAuthorization(new URL("http://127.0.0.1:8080/authorize"))
expect(seen).toEqual(["https://auth.example.com/authorize?state=1", "http://127.0.0.1:8080/authorize"])
})
test("rejects authorization URLs that are not http or https", async () => {
const seen: string[] = []
const provider = new McpOAuthProvider(
"test-server",
"https://mcp.example.com/mcp",
{},
{ onRedirect: (url) => void seen.push(url.href) },
stubAuth,
)
await expect(provider.redirectToAuthorization(new URL("file:///tmp/authorize"))).rejects.toThrow(
"returned a file: authorization URL",
)
expect(seen).toEqual([])
})
})
describe("McpOAuthProvider.clientMetadata", () => {
test("includes redirect_uris from redirectUrl", () => {
const provider = makeProvider({ callbackPort: 6620 })
-1
View File
@@ -59,7 +59,6 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"strip-ansi": "7.1.2",
+2 -2
View File
@@ -63,7 +63,7 @@ import { isDefaultTitle } from "./util/session"
import { KVProvider, useKV } from "./context/kv"
import * as Model from "./util/model"
import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { openUrl } from "@opencode-ai/core/open"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider, useTuiConfig, type TuiConfig } from "./config"
import { createTuiApiAdapters } from "./plugin/adapters"
@@ -821,7 +821,7 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
name: "docs.open",
title: "Open docs",
run: () => {
open("https://opencode.ai/docs").catch(() => {})
openUrl("https://opencode.ai/docs").catch(() => {})
dialog.clear()
},
category: "System",
@@ -1,5 +1,5 @@
import { RGBA, TextAttributes } from "@opentui/core"
import open from "open"
import { openUrl } from "@opencode-ai/core/open"
import { createSignal } from "solid-js"
import { selectedForeground, useTheme } from "../context/theme"
import { useDialog, type DialogContext } from "../ui/dialog"
@@ -21,7 +21,7 @@ export type DialogRetryActionProps = {
}
function runAction(props: DialogRetryActionProps, dialog: ReturnType<typeof useDialog>) {
if (props.link) open(props.link).catch(() => {})
if (props.link) openUrl(props.link).catch(() => {})
props.onClose?.()
dialog.clear()
}
+2 -2
View File
@@ -1,6 +1,6 @@
import type { JSX } from "solid-js"
import type { RGBA } from "@opentui/core"
import open from "open"
import { openUrl } from "@opencode-ai/core/open"
export interface LinkProps {
href: string
@@ -25,7 +25,7 @@ export function Link(props: LinkProps) {
width={props.width}
wrapMode={props.wrapMode}
onMouseUp={() => {
open(props.href).catch(() => {})
openUrl(props.href).catch(() => {})
}}
>
{displayText}