Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 8 additions & 16 deletions bun.lock

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

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,8 @@
"@opentui/keymap": "catalog:",
"@opentui/solid": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:"
"@types/node": "catalog:",
"open": "11.0.4"
},
"patchedDependencies": {
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
Expand Down
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/open.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 16 additions & 0 deletions packages/core/test/open.test.ts
Original file line number Diff line number Diff line change
@@ -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 change: 0 additions & 1 deletion packages/opencode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:",
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/cli/cmd/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down
6 changes: 3 additions & 3 deletions packages/opencode/src/cli/cmd/web.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/mcp/browser.ts
Original file line number Diff line number Diff line change
@@ -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>
Expand All @@ -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) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/opencode/src/mcp/oauth-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/plugin/digitalocean.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/plugin/snowflake-cortex.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
31 changes: 31 additions & 0 deletions packages/opencode/test/mcp/oauth-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
1 change: 0 additions & 1 deletion packages/tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@
"diff": "catalog:",
"effect": "catalog:",
"fuzzysort": "catalog:",
"open": "10.1.2",
"opentui-spinner": "catalog:",
"remeda": "catalog:",
"strip-ansi": "7.1.2",
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/component/dialog-retry-action.tsx
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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()
}
Expand Down
4 changes: 2 additions & 2 deletions packages/tui/src/ui/link.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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}
Expand Down
Loading