Compare commits

..
11 changed files with 490 additions and 789 deletions
@@ -1,4 +1,3 @@
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
@@ -13,21 +12,9 @@ import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { showToast } from "@/utils/toast"
import {
type Accessor,
type Component,
createEffect,
createMemo,
createResource,
createUniqueId,
For,
Match,
onCleanup,
onMount,
Show,
Switch,
} from "solid-js"
import { createStore, produce } from "solid-js/store"
import { type Accessor, type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useQueryClient } from "@tanstack/solid-query"
import { useParams } from "@solidjs/router"
import { Link } from "@/components/link"
import { useServerSDK } from "@/context/server-sdk"
@@ -37,10 +24,10 @@ import { useSettings } from "@/context/settings"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { CustomProviderForm } from "./dialog-custom-provider"
import { decode64 } from "@/utils/base64"
import { pathKey } from "@/utils/path-key"
import { createProviderConnectionController } from "./provider-connection-controller"
const CUSTOM_ID = "_custom"
type ConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
const [store, setStore] = createStore({ selected: undefined as string | undefined })
const reset = () => setStore("selected", undefined)
@@ -384,118 +371,91 @@ function ProviderConnection(props: {
const dialog = useDialog()
const serverSync = useServerSync()
const serverSDK = useServerSDK()
const queryClient = useQueryClient()
const params = useParams()
const language = useLanguage()
const settings = useSettings()
const newLayout = settings.general.newLayoutDesigns
const providers = useProviders(() => props.directory?.())
const directory = () => props.directory?.() ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const alive = { value: true }
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
onCleanup(() => {
alive.value = false
if (timer.current === undefined) return
clearTimeout(timer.current)
timer.current = undefined
})
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
)
const fallback = createMemo<ConnectMethod[]>(() => [
{
type: "key" as const,
label: language.t("provider.connect.method.apiKey"),
const controller = createProviderConnectionController({
provider: props.provider,
directory,
fallbackKeyLabel: () => language.t("provider.connect.method.apiKey"),
requestFailed: () => language.t("common.requestFailed"),
invalidCode: () => language.t("provider.connect.oauth.code.invalid"),
services: {
integration: {
load: (integrationID, value) =>
serverSDK()
.api.integration.get({
integrationID,
location: value ? { directory: value } : undefined,
})
.then((result) => result.data),
},
connection: {
key: (integrationID, value, key) =>
serverSDK().api.integration.connect.key({
integrationID,
location: value ? { directory: value } : undefined,
key,
}),
oauth: (integrationID, value, methodID, inputs) =>
serverSDK()
.api.integration.oauth.connect({
integrationID,
methodID,
inputs,
location: value ? { directory: value } : undefined,
})
.then((result) => result.data),
status: (integrationID, value, attemptID) =>
serverSDK()
.api.integration.oauth.status({
integrationID,
attemptID,
location: value ? { directory: value } : undefined,
})
.then((result) => result.data),
complete: (integrationID, value, attemptID, code) =>
serverSDK().api.integration.oauth.complete({
integrationID,
attemptID,
location: value ? { directory: value } : undefined,
code,
}),
},
provider: {
refresh: () =>
queryClient.refetchQueries(serverSync().queryOptions.providers(directory() ? pathKey(directory()!) : null)),
},
completion: {
finish: () => {
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
},
},
},
])
const [integration] = createResource(
() => ({ provider: props.provider, directory: directory() }),
(input) =>
serverSDK()
.api.integration.get({
integrationID: input.provider,
location: input.directory ? { directory: input.directory } : undefined,
})
.then((result) => result.data),
)
const loading = createMemo(() => integration.loading)
const methods = createMemo<ConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ConnectMethod => method.type === "key" || method.type === "oauth",
)
return values?.length ? values : fallback()
})
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | IntegrationOauthConnectOutput["data"],
promptInputs: undefined as undefined | Record<string, string>,
state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.inputs"; inputs: Record<string, string> }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] }
| { type: "auth.error"; error: string }
function dispatch(action: Action) {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.promptInputs = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.inputs") {
draft.promptInputs = action.inputs
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const method = createMemo(() => (store.methodIndex !== undefined ? methods().at(store.methodIndex!) : undefined))
const loading = controller.data.loading
const methods = controller.data.methods
const method = controller.data.method
const methodIndex = controller.data.methodIndex
const authorization = controller.data.authorization
const state = controller.auth.state
const error = controller.auth.error
const connectKey = controller.auth.connectKey
const completeCode = controller.auth.completeCode
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -513,56 +473,7 @@ function ProviderConnection(props: {
}
}
function formatError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = (value as { data?: { message?: unknown } }).data
if (typeof data?.message === "string" && data.message) return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatError((value as { error?: unknown }).error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = (value as { message?: unknown }).message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
async function selectMethod(index: number, inputs?: Record<string, string>) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
}
const method = methods()[index]
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
if (method.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
await serverSDK()
.api.integration.oauth.connect({
integrationID: props.provider,
methodID: method.id,
inputs: inputs ?? {},
location: location(),
})
.then((x) => {
if (!alive.value) return
dispatch({ type: "auth.complete", authorization: x.data })
})
.catch((e) => {
if (!alive.value) return
dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) })
})
}
}
const selectMethod = controller.auth.select
function AuthPromptsView() {
const [formStore, setFormStore] = createStore({
@@ -583,7 +494,7 @@ function ProviderConnection(props: {
const current = createMemo(() => {
const all = prompts()
const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
if (index === -1) return
if (index === -1) return undefined
return {
index,
prompt: all[index],
@@ -597,13 +508,14 @@ function ProviderConnection(props: {
})
async function next(index: number, value: Record<string, string>) {
if (store.methodIndex === undefined) return
const selected = methodIndex()
if (selected === undefined) return
const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
if (next !== -1) {
setFormStore("index", next)
return
}
await selectMethod(store.methodIndex, value)
await selectMethod(selected, value)
}
async function handleSubmit(e: SubmitEvent) {
@@ -617,12 +529,12 @@ function ProviderConnection(props: {
const item = () => current()
const text = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "text") return
if (!prompt || prompt.type !== "text") return undefined
return prompt
})
const select = createMemo(() => {
const prompt = item()?.prompt
if (!prompt || prompt.type !== "select") return
if (!prompt || prompt.type !== "select") return undefined
return prompt
})
@@ -693,32 +605,9 @@ function ProviderConnection(props: {
listRef?.onKeyDown(e)
}
let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) {
auto = true
void selectMethod(0)
}
})
async function complete() {
await serverSync()
.refreshProviders()
.catch(() => undefined)
dialog.close()
showToast({
variant: "success",
icon: "circle-check",
title: language.t("provider.connect.toast.connected.title", { provider: provider().name }),
description: language.t("provider.connect.toast.connected.description", { provider: provider().name }),
})
}
function goBack() {
if (methods().length > 1 && store.methodIndex !== undefined) {
dispatch({ type: "method.reset" })
if (methods().length > 1 && methodIndex() !== undefined) {
controller.auth.reset()
return
}
props.onBack()
@@ -806,9 +695,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const apiKey = formData.get("apiKey") as string
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("apiKey")
const apiKey = typeof value === "string" ? value : ""
if (!apiKey?.trim()) {
setFormStore("error", language.t("provider.connect.apiKey.required"))
@@ -816,12 +705,7 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
await serverSDK().api.integration.connect.key({
integrationID: props.provider,
location: location(),
key: apiKey,
})
await complete()
await connectKey(apiKey)
}
if (newLayout())
@@ -936,9 +820,9 @@ function ProviderConnection(props: {
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const form = e.currentTarget as HTMLFormElement
const formData = new FormData(form)
const code = formData.get("code") as string
if (!(e.currentTarget instanceof HTMLFormElement)) return
const value = new FormData(e.currentTarget).get("code")
const code = typeof value === "string" ? value : ""
if (!code?.trim()) {
setFormStore("error", language.t("provider.connect.oauth.code.required"))
@@ -946,20 +830,7 @@ function ProviderConnection(props: {
}
setFormStore("error", undefined)
const result = await serverSDK()
.api.integration.oauth.complete({
integrationID: props.provider,
attemptID: store.authorization!.attemptID,
location: location(),
code,
})
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (result.ok) {
await complete()
return
}
setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid")))
setFormStore("error", await completeCode(code))
}
if (newLayout())
@@ -967,7 +838,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url} class="text-v2-text-text-base">
<Link href={authorization()!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1006,7 +877,7 @@ function ProviderConnection(props: {
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.code.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.code.visit.link")}</Link>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
@@ -1032,52 +903,18 @@ function ProviderConnection(props: {
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = store.authorization?.instructions
const instructions = authorization()?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
onMount(() => {
const poll = async () => {
const authorization = store.authorization
if (!authorization || !alive.value) return
const result = await serverSDK()
.api.integration.oauth.status({
integrationID: props.provider,
attemptID: authorization.attemptID,
location: location(),
})
.then((value) => ({ ok: true as const, status: value.data }))
.catch((error) => ({ ok: false as const, error }))
if (!alive.value) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) })
return
}
if (result.status.status === "complete") {
await complete()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
timer.current = setTimeout(poll, 1_000)
}
void poll()
})
return (
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<Link href={store.authorization!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
<Link href={authorization()!.url}>{language.t("provider.connect.oauth.auto.visit.link")}</Link>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
@@ -1121,7 +958,7 @@ function ProviderConnection(props: {
<div
onKeyDown={handleKey}
tabIndex={newLayout() ? undefined : 0}
autofocus={!newLayout() && store.methodIndex === undefined ? true : undefined}
autofocus={!newLayout() && methodIndex() === undefined ? true : undefined}
>
<Switch>
<Match when={loading()}>
@@ -1132,10 +969,10 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}>
<Match when={methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={store.state === "pending"}>
<Match when={state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
@@ -1143,14 +980,14 @@ function ProviderConnection(props: {
</div>
</div>
</Match>
<Match when={store.state === "prompt"}>
<Match when={state() === "prompt"}>
<AuthPromptsView />
</Match>
<Match when={store.state === "error"}>
<Match when={state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
<span>{language.t("provider.connect.status.failed", { error: error() ?? "" })}</span>
</div>
</div>
</Match>
@@ -1159,10 +996,10 @@ function ProviderConnection(props: {
</Match>
<Match when={method()?.type === "oauth"}>
<Switch>
<Match when={store.authorization?.mode === "code"}>
<Match when={authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={store.authorization?.mode === "auto"}>
<Match when={authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
@@ -0,0 +1,129 @@
import { describe, expect, test, vi } from "bun:test"
import { createRoot } from "solid-js"
import {
createProviderConnectionWorkflowController,
type ProviderConnectMethod,
} from "./provider-connection-controller"
const authorization = {
attemptID: "attempt-1",
url: "https://example.com/auth",
instructions: "Code: ABCD",
mode: "auto" as const,
time: { created: 0, expires: 1 },
}
function services(options: {
methods: readonly ProviderConnectMethod[]
status?: () => Promise<{ status: "pending" | "complete" }>
refresh?: () => void
finish?: () => void
}) {
return {
connection: {
key: async () => undefined,
oauth: async () => authorization,
status: options.status ?? (async () => ({ status: "pending" as const })),
complete: async () => undefined,
},
provider: { refresh: async () => options.refresh?.() },
completion: { finish: options.finish ?? (() => undefined) },
}
}
function create(options: Parameters<typeof services>[0]) {
return createRoot((dispose) => ({
dispose,
controller: createProviderConnectionWorkflowController({
provider: "example",
directory: () => "/project",
requestFailed: () => "Request failed",
invalidCode: () => "Invalid code",
loading: () => false,
methods: () => [...options.methods],
services: services(options),
pollInterval: 100,
}),
}))
}
async function settle() {
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
}
describe("provider connection controller", () => {
test("moves prompted OAuth methods through prompt and authorization states", async () => {
const owned = create({
methods: [
{
id: "oauth",
type: "oauth",
label: "OAuth",
prompts: [{ type: "text", key: "account", message: "Account" }],
},
],
})
await owned.controller.auth.select(0)
expect(owned.controller.auth.state()).toBe("prompt")
await owned.controller.auth.select(0, { account: "team" })
expect(owned.controller.auth.state()).toBe("complete")
expect(owned.controller.data.authorization()).toEqual(authorization)
owned.dispose()
})
test("polls auto OAuth independently and completes after provider refresh", async () => {
vi.useFakeTimers()
try {
const calls: string[] = []
const statuses = [{ status: "pending" as const }, { status: "complete" as const }]
const owned = create({
methods: [{ id: "oauth", type: "oauth", label: "OAuth" }],
status: async () => statuses.shift() ?? { status: "complete" as const },
refresh: () => calls.push("refresh"),
finish: () => calls.push("finish"),
})
await owned.controller.auth.select(0)
await settle()
expect(owned.controller.data.authorization()).toEqual(authorization)
expect(calls).toEqual([])
vi.advanceTimersByTime(100)
await settle()
expect(calls).toEqual(["refresh", "finish"])
owned.dispose()
} finally {
vi.useRealTimers()
}
})
test("cancels an owned poll timer on reset and disposal", async () => {
vi.useFakeTimers()
try {
const status = vi.fn(async () => ({ status: "pending" as const }))
const owned = create({ methods: [{ id: "oauth", type: "oauth", label: "OAuth" }], status })
await owned.controller.auth.select(0)
expect(status).toHaveBeenCalledTimes(1)
owned.controller.auth.reset()
vi.advanceTimersByTime(1_000)
await settle()
expect(status).toHaveBeenCalledTimes(1)
await owned.controller.auth.select(0)
expect(status).toHaveBeenCalledTimes(2)
owned.dispose()
vi.advanceTimersByTime(1_000)
await settle()
expect(status).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,254 @@
import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
type OAuthStatus = { status: "pending" | "complete" | "expired" } | { status: "failed"; message: string }
type ProviderConnectionServices = {
integration: {
load: (provider: string, directory?: string) => Promise<{ methods: readonly IntegrationMethod[] } | null>
}
connection: {
key: (provider: string, directory: string | undefined, key: string) => Promise<unknown>
oauth: (
provider: string,
directory: string | undefined,
method: string,
inputs: Record<string, string>,
) => Promise<Authorization>
status: (provider: string, directory: string | undefined, attempt: string) => Promise<OAuthStatus>
complete: (provider: string, directory: string | undefined, attempt: string, code: string) => Promise<unknown>
}
provider: { refresh: () => Promise<unknown> }
completion: { finish: () => void }
}
export function createProviderConnectionController(options: {
provider: string
directory: () => string | undefined
fallbackKeyLabel: () => string
requestFailed: () => string
invalidCode: () => string
services: ProviderConnectionServices
pollInterval?: number
}) {
const [integration] = createResource(
() => ({ provider: options.provider, directory: options.directory() }),
(input) => options.services.integration.load(input.provider, input.directory),
)
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
(method): method is ProviderConnectMethod => method.type === "key" || method.type === "oauth",
)
if (values?.length) return [...values]
return [{ type: "key", label: options.fallbackKeyLabel() }]
})
return createProviderConnectionWorkflowController({
...options,
loading: () => integration.loading,
methods,
})
}
export function createProviderConnectionWorkflowController(options: {
provider: string
directory: () => string | undefined
requestFailed: () => string
invalidCode: () => string
loading: () => boolean
methods: () => ProviderConnectMethod[]
services: Pick<ProviderConnectionServices, "connection" | "provider" | "completion">
pollInterval?: number
}) {
const [store, setStore] = createStore({
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
state: "pending" as "pending" | "complete" | "error" | "prompt" | undefined,
error: undefined as string | undefined,
})
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
}
const methods = options.methods
const method = createMemo(() => (store.methodIndex === undefined ? undefined : methods().at(store.methodIndex)))
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
| { type: "auth.prompt" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: Authorization }
| { type: "auth.error"; error: string }
const dispatch = (action: Action) => {
setStore(
produce((draft) => {
if (action.type === "method.select") {
draft.methodIndex = action.index
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "method.reset") {
draft.methodIndex = undefined
draft.authorization = undefined
draft.state = undefined
draft.error = undefined
return
}
if (action.type === "auth.prompt") {
draft.state = "prompt"
draft.error = undefined
return
}
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
return
}
if (action.type === "auth.complete") {
draft.state = "complete"
draft.authorization = action.authorization
draft.error = undefined
return
}
draft.state = "error"
draft.error = action.error
}),
)
}
const cancelPolling = () => {
polling.generation++
if (polling.timer === undefined) return
clearTimeout(polling.timer)
polling.timer = undefined
}
const finish = async () => {
cancelPolling()
await options.services.provider.refresh().catch(() => undefined)
if (polling.disposed) return
options.services.completion.finish()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await options.services.connection
.status(options.provider, options.directory(), authorization.attemptID)
.then((status) => ({ ok: true as const, status }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
return
}
if (result.status.status === "complete") {
await finish()
return
}
if (result.status.status === "failed") {
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
dispatch({ type: "auth.error", error: options.requestFailed() })
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const select = async (index: number, inputs?: Record<string, string>) => {
cancelPolling()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
if (selected.type !== "oauth") return
if (selected.prompts?.length && !inputs) {
dispatch({ type: "auth.prompt" })
return
}
dispatch({ type: "auth.pending" })
const result = await options.services.connection
.oauth(options.provider, options.directory(), selected.id, inputs ?? {})
.then((authorization) => ({ ok: true as const, authorization }))
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({ type: "auth.error", error: formatProviderConnectionError(result.error, options.requestFailed()) })
return
}
dispatch({ type: "auth.complete", authorization: result.authorization })
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const reset = () => {
cancelPolling()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await options.services.connection.key(options.provider, options.directory(), key)
await finish()
}
const completeCode = async (code: string) => {
const authorization = store.authorization
if (!authorization) return options.invalidCode()
const result = await options.services.connection
.complete(options.provider, options.directory(), authorization.attemptID, code)
.then(() => ({ ok: true as const }))
.catch((error) => ({ ok: false as const, error }))
if (!result.ok) return formatProviderConnectionError(result.error, options.invalidCode())
await finish()
return undefined
}
let auto = false
createEffect(() => {
if (auto || options.loading() || methods().length !== 1) return
auto = true
void select(0)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
})
return {
data: {
loading: options.loading,
methods,
method,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
},
auth: {
state: () => store.state,
error: () => store.error,
select,
reset,
connectKey,
completeCode,
},
}
}
export type ProviderConnectionController = ReturnType<typeof createProviderConnectionController>
export function formatProviderConnectionError(value: unknown, fallback: string): string {
if (value && typeof value === "object" && "data" in value) {
const data = value.data
if (data && typeof data === "object" && "message" in data && typeof data.message === "string" && data.message)
return data.message
}
if (value && typeof value === "object" && "error" in value) {
const nested = formatProviderConnectionError(value.error, "")
if (nested) return nested
}
if (value && typeof value === "object" && "message" in value) {
const message = value.message
if (typeof message === "string" && message) return message
}
if (value instanceof Error && value.message) return value.message
if (typeof value === "string" && value) return value
return fallback
}
-79
View File
@@ -1,79 +0,0 @@
export * as BrowserControlProtocol from "./browser-control.js"
import { BrowserControl } from "@opencode-ai/schema/browser-control"
import { Effect, Schema } from "effect"
export const Path = "/api/browser/control"
export const Subprotocol = "opencode.browser.control.v1"
export const MaxMessageBytes = 8 * 1_024 * 1_024
class MessageError extends Schema.TaggedErrorClass<MessageError>()("BrowserControlProtocol.MessageError", {
kind: Schema.Literals(["invalid", "too_large"]),
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
const decoder = new TextDecoder("utf-8", { fatal: true })
const encoder = new TextEncoder()
const encodeClient = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromClient))
const encodeServer = Schema.encodeSync(Schema.fromJsonString(BrowserControl.FromServer))
const decodeClient = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserControl.FromClient), {
errors: "all",
onExcessProperty: "error",
})
const decodeServer = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserControl.FromServer), {
errors: "all",
onExcessProperty: "error",
})
export function encodeFromClient(input: BrowserControl.FromClient) {
return encode(input, encodeClient)
}
export function encodeFromServer(input: BrowserControl.FromServer) {
return encode(input, encodeServer)
}
function encode<Message>(input: Message, encodeMessage: (input: Message) => string) {
const output = encodeMessage(input)
if (encoder.encode(output).byteLength > MaxMessageBytes) {
throw new RangeError(`Browser control message must not exceed ${MaxMessageBytes} bytes.`)
}
return output
}
export function decodeFromClient(input: string | Uint8Array) {
return decode(input, decodeClient)
}
export function decodeFromServer(input: string | Uint8Array) {
return decode(input, decodeServer)
}
function decode<Message>(
input: string | Uint8Array,
decodeMessage: (input: unknown) => Effect.Effect<Message, unknown>,
): Effect.Effect<Message, MessageError> {
if (typeof input === "string" && encoder.encode(input).byteLength > MaxMessageBytes) {
return Effect.fail(new MessageError({ kind: "too_large", message: "Browser control message is too large." }))
}
if (typeof input !== "string" && input.byteLength > MaxMessageBytes) {
return Effect.fail(new MessageError({ kind: "too_large", message: "Browser control message is too large." }))
}
const text =
typeof input === "string"
? Effect.succeed(input)
: Effect.try({
try: () => decoder.decode(input),
catch: (cause) =>
new MessageError({ kind: "invalid", message: "Browser control message is not valid UTF-8.", cause }),
})
return text.pipe(
Effect.flatMap(decodeMessage),
Effect.mapError((cause) =>
cause instanceof MessageError
? cause
: new MessageError({ kind: "invalid", message: "Browser control message is invalid.", cause }),
),
)
}
-75
View File
@@ -1,75 +0,0 @@
export * as BrowserTunnelProtocol from "./browser-tunnel.js"
import { BrowserTunnel } from "@opencode-ai/schema/browser-tunnel"
import { Effect, Schema } from "effect"
export const Path = "/api/browser/tunnel"
export const Subprotocol = "opencode.browser.tunnel.v1"
export const MaxFrameBytes = 64 * 1_024
export const MaxHandshakeBytes = 16 * 1_024
class MessageError extends Schema.TaggedErrorClass<MessageError>()("BrowserTunnelProtocol.MessageError", {
kind: Schema.Literals(["invalid", "too_large"]),
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
const encoder = new TextEncoder()
const decoder = new TextDecoder("utf-8", { fatal: true })
const encodeClient = Schema.encodeSync(Schema.fromJsonString(BrowserTunnel.FromClient))
const encodeServer = Schema.encodeSync(Schema.fromJsonString(BrowserTunnel.FromServer))
const decodeClient = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserTunnel.FromClient), {
errors: "all",
onExcessProperty: "error",
})
const decodeServer = Schema.decodeUnknownEffect(Schema.fromJsonString(BrowserTunnel.FromServer), {
errors: "all",
onExcessProperty: "error",
})
export function encodeFromClient(input: BrowserTunnel.FromClient) {
return encode(encodeClient(input))
}
export function encodeFromServer(input: BrowserTunnel.FromServer) {
return encode(encodeServer(input))
}
function encode(input: string) {
if (encoder.encode(input).byteLength > MaxHandshakeBytes) {
throw new RangeError(`Browser tunnel handshake must not exceed ${MaxHandshakeBytes} bytes.`)
}
return input
}
export function decodeFromClient(input: string | Uint8Array) {
return decode(input, decodeClient)
}
export function decodeFromServer(input: string | Uint8Array) {
return decode(input, decodeServer)
}
function decode<Message>(
input: string | Uint8Array,
decodeMessage: (input: unknown) => Effect.Effect<Message, unknown>,
): Effect.Effect<Message, MessageError> {
if ((typeof input === "string" ? encoder.encode(input).byteLength : input.byteLength) > MaxHandshakeBytes) {
return Effect.fail(new MessageError({ kind: "too_large", message: "Browser tunnel handshake is too large." }))
}
const text =
typeof input === "string"
? Effect.succeed(input)
: Effect.try({
try: () => decoder.decode(input),
catch: (cause) => new MessageError({ kind: "invalid", message: "Invalid tunnel handshake UTF-8.", cause }),
})
return text.pipe(
Effect.flatMap(decodeMessage),
Effect.mapError((cause) =>
cause instanceof MessageError
? cause
: new MessageError({ kind: "invalid", message: "Browser tunnel handshake is invalid.", cause }),
),
)
}
-63
View File
@@ -1,63 +0,0 @@
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
import { ConflictError, ServiceUnavailableError } from "../errors.js"
import { BrowserControlProtocol } from "../browser-control.js"
import { BrowserTunnelProtocol } from "../browser-tunnel.js"
import { HeaderOnlyAuthorization } from "../middleware/authorization.js"
const websocket = (identifier: string, summary: string, description: string, subprotocol: string) =>
OpenApi.annotations({
identifier,
summary,
description,
transform: (operation) => ({
...operation,
"x-websocket": true,
"x-websocket-subprotocol": subprotocol,
responses: {
...operation.responses,
403: { description: "WebSocket Origin is not allowed." },
426: { description: `WebSocket subprotocol ${subprotocol} is required.` },
},
}),
})
export const BrowserGroup = HttpApiGroup.make("server.browser")
.add(
HttpApiEndpoint.get("browser.control.connect", BrowserControlProtocol.Path, {
success: Schema.Boolean,
error: ConflictError,
})
.annotate(HeaderOnlyAuthorization, true)
.annotate(OpenApi.Exclude, true)
.annotateMerge(
websocket(
"v2.browser.control.connect",
"Connect Session browser host",
"Establish an authenticated WebSocket controlling the browser attachment for one Session.",
BrowserControlProtocol.Subprotocol,
),
),
)
.add(
HttpApiEndpoint.get("browser.tunnel.connect", BrowserTunnelProtocol.Path, {
success: Schema.Boolean,
error: ServiceUnavailableError,
})
.annotate(HeaderOnlyAuthorization, true)
.annotate(OpenApi.Exclude, true)
.annotateMerge(
websocket(
"v2.browser.tunnel.connect",
"Open browser network tunnel",
"Establish an authenticated WebSocket carrying one TCP stream dialed from the OpenCode server.",
BrowserTunnelProtocol.Subprotocol,
),
),
)
.annotateMerge(
OpenApi.annotations({
title: "browser",
description: "Desktop browser host control and server-network tunnel routes.",
}),
)
@@ -1,11 +1,6 @@
import { Context } from "effect"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
import { UnauthorizedError } from "../errors.js"
export const HeaderOnlyAuthorization = Context.Reference<boolean>("@opencode/HttpApiAuthorization/HeaderOnly", {
defaultValue: () => false,
})
export class Authorization extends HttpApiMiddleware.Service<Authorization>()("@opencode/HttpApiAuthorization", {
error: UnauthorizedError,
}) {}
-78
View File
@@ -1,78 +0,0 @@
export * as BrowserControl from "./browser-control.js"
import { Schema } from "effect"
import { Browser } from "./browser.js"
import { ascending } from "./identifier.js"
import { SessionID } from "./session-id.js"
import { statics } from "./schema.js"
const RequestIDSchema = Schema.String.check(Schema.isPattern(/^brr_[0-9A-Za-z]+$/))
.pipe(Schema.brand("BrowserControl.RequestID"))
.annotate({ identifier: "BrowserControl.RequestID" })
export const RequestID = RequestIDSchema.pipe(
statics((schema: typeof RequestIDSchema) => ({
create: () => schema.make("brr_" + ascending()),
})),
)
export type RequestID = typeof RequestID.Type
const Register = Schema.Struct({
type: Schema.Literal("browser.control.register"),
sessionID: SessionID,
})
const Attach = Schema.Struct({
type: Schema.Literal("browser.control.attach"),
leaseID: Browser.LeaseID,
state: Browser.State,
})
const State = Schema.Struct({
type: Schema.Literal("browser.control.state"),
leaseID: Browser.LeaseID,
state: Browser.State,
})
const Detach = Schema.Struct({
type: Schema.Literal("browser.control.detach"),
leaseID: Browser.LeaseID,
})
const Response = Schema.Struct({
type: Schema.Literal("browser.control.response"),
requestID: RequestID,
leaseID: Browser.LeaseID,
outcome: Browser.Outcome,
})
const Registered = Schema.Struct({ type: Schema.Literal("browser.control.registered") })
const Open = Schema.Struct({ type: Schema.Literal("browser.control.open") })
const Attached = Schema.Struct({
type: Schema.Literal("browser.control.attached"),
leaseID: Browser.LeaseID,
})
const Request = Schema.Struct({
type: Schema.Literal("browser.control.request"),
requestID: RequestID,
leaseID: Browser.LeaseID,
command: Browser.Command,
})
const Cancel = Schema.Struct({
type: Schema.Literal("browser.control.cancel"),
requestID: RequestID,
leaseID: Browser.LeaseID,
})
export const FromClient = Schema.Union([Register, Attach, State, Detach, Response])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "BrowserControl.FromClient" })
export type FromClient = typeof FromClient.Type
export const FromServer = Schema.Union([Registered, Open, Attached, Request, Cancel])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "BrowserControl.FromServer" })
export type FromServer = typeof FromServer.Type
-55
View File
@@ -1,55 +0,0 @@
export * as BrowserTunnel from "./browser-tunnel.js"
import { Schema } from "effect"
import { Browser } from "./browser.js"
import { SessionID } from "./session-id.js"
export const Host = Schema.NonEmptyString.check(Schema.isMaxLength(253), Schema.isPattern(/^[^\s/?#]+$/))
.pipe(Schema.brand("BrowserTunnel.Host"))
.annotate({ identifier: "BrowserTunnel.Host" })
export type Host = typeof Host.Type
export const Port = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65_535 }))
.pipe(Schema.brand("BrowserTunnel.Port"))
.annotate({ identifier: "BrowserTunnel.Port" })
export type Port = typeof Port.Type
export interface Target extends Schema.Schema.Type<typeof Target> {}
export const Target = Schema.Struct({
host: Host,
port: Port,
}).annotate({ identifier: "BrowserTunnel.Target" })
const Open = Schema.Struct({
type: Schema.Literal("browser.tunnel.open"),
sessionID: SessionID,
leaseID: Browser.LeaseID,
target: Target,
}).annotate({ identifier: "BrowserTunnel.Open" })
const Opened = Schema.Struct({
type: Schema.Literal("browser.tunnel.opened"),
}).annotate({ identifier: "BrowserTunnel.Opened" })
export const OpenErrorCode = Schema.Literals([
"invalid_open",
"not_attached",
"stale_lease",
"connect_failed",
"connect_timeout",
]).annotate({ identifier: "BrowserTunnel.OpenErrorCode" })
export type OpenErrorCode = typeof OpenErrorCode.Type
const Rejected = Schema.Struct({
type: Schema.Literal("browser.tunnel.rejected"),
code: OpenErrorCode,
message: Schema.String.check(Schema.isMaxLength(1_024)),
}).annotate({ identifier: "BrowserTunnel.Rejected" })
export const FromClient = Open.annotate({ identifier: "BrowserTunnel.FromClient" })
export type FromClient = typeof FromClient.Type
export const FromServer = Schema.Union([Opened, Rejected])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "BrowserTunnel.FromServer" })
export type FromServer = typeof FromServer.Type
-163
View File
@@ -1,163 +0,0 @@
export * as Browser from "./browser.js"
import { Schema } from "effect"
import { ascending } from "./identifier.js"
import { NonNegativeInt, PositiveInt, statics } from "./schema.js"
const LeaseIDSchema = Schema.String.check(Schema.isPattern(/^brl_[0-9A-Za-z]+$/))
.pipe(Schema.brand("Browser.LeaseID"))
.annotate({ identifier: "Browser.LeaseID" })
export const LeaseID = LeaseIDSchema.pipe(
statics((schema: typeof LeaseIDSchema) => ({
create: () => schema.make("brl_" + ascending()),
})),
)
export type LeaseID = typeof LeaseID.Type
export const Ref = Schema.String.check(Schema.isPattern(/^e[1-9][0-9]*$/))
.pipe(Schema.brand("Browser.Ref"))
.annotate({ identifier: "Browser.Ref" })
export type Ref = typeof Ref.Type
export interface State extends Schema.Schema.Type<typeof State> {}
export const State = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: Schema.String.check(Schema.isMaxLength(1_024)),
loading: Schema.Boolean,
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: NonNegativeInt,
}).annotate({ identifier: "Browser.State" })
export const Key = Schema.Literals([
"Enter",
"Tab",
"Escape",
"Backspace",
"Delete",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"PageUp",
"PageDown",
"Home",
"End",
"Space",
]).annotate({ identifier: "Browser.Key" })
export type Key = typeof Key.Type
export const Direction = Schema.Literals(["up", "down", "left", "right"]).annotate({
identifier: "Browser.Direction",
})
export type Direction = typeof Direction.Type
const generation = { generation: NonNegativeInt }
export const Command = Schema.Union([
Schema.Struct({
type: Schema.Literal("navigate"),
url: Schema.String.check(Schema.isMaxLength(16_384)),
...generation,
}),
Schema.Struct({ type: Schema.Literal("snapshot"), ...generation }),
Schema.Struct({ type: Schema.Literal("click"), ref: Ref, ...generation }),
Schema.Struct({
type: Schema.Literal("fill"),
ref: Ref,
text: Schema.String.check(Schema.isMaxLength(10_000)),
...generation,
}),
Schema.Struct({ type: Schema.Literal("press"), key: Key, ...generation }),
Schema.Struct({ type: Schema.Literal("scroll"), direction: Direction, pixels: PositiveInt, ...generation }),
Schema.Struct({ type: Schema.Literal("screenshot"), ...generation }),
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Command" })
export type Command = typeof Command.Type
const NavigateResult = Schema.Struct({
type: Schema.Literal("navigate"),
state: State,
}).annotate({ identifier: "Browser.NavigateResult" })
const SnapshotResult = Schema.Struct({
type: Schema.Literal("snapshot"),
state: State,
format: Schema.Literal("opencode.semantic.v1"),
content: Schema.String.check(Schema.isMaxLength(100_000)),
}).annotate({ identifier: "Browser.SnapshotResult" })
const ClickResult = Schema.Struct({
type: Schema.Literal("click"),
state: State,
}).annotate({ identifier: "Browser.ClickResult" })
const FillResult = Schema.Struct({
type: Schema.Literal("fill"),
state: State,
}).annotate({ identifier: "Browser.FillResult" })
const PressResult = Schema.Struct({
type: Schema.Literal("press"),
state: State,
}).annotate({ identifier: "Browser.PressResult" })
const ScrollResult = Schema.Struct({
type: Schema.Literal("scroll"),
state: State,
}).annotate({ identifier: "Browser.ScrollResult" })
const ScreenshotResult = Schema.Struct({
type: Schema.Literal("screenshot"),
state: State,
mediaType: Schema.Literal("image/png"),
data: Schema.Uint8ArrayFromBase64.check(Schema.isMaxLength(5 * 1_024 * 1_024)),
width: PositiveInt,
height: PositiveInt,
}).annotate({ identifier: "Browser.ScreenshotResult" })
export const Result = Schema.Union([
NavigateResult,
SnapshotResult,
ClickResult,
FillResult,
PressResult,
ScrollResult,
ScreenshotResult,
])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Result" })
export type Result = typeof Result.Type
export const ErrorCode = Schema.Literals([
"not_attached",
"stale_ref",
"invalid_url",
"navigation_failed",
"timeout",
"aborted",
"page_crashed",
"result_too_large",
"overloaded",
"protocol",
"internal",
]).annotate({ identifier: "Browser.ErrorCode" })
export type ErrorCode = typeof ErrorCode.Type
const Failure = Schema.Struct({
type: Schema.Literal("failure"),
code: ErrorCode,
message: Schema.String.check(Schema.isMaxLength(1_024)),
}).annotate({ identifier: "Browser.Failure" })
const Success = Schema.Struct({
type: Schema.Literal("success"),
result: Result,
}).annotate({ identifier: "Browser.Success" })
export const Outcome = Schema.Union([Success, Failure])
.pipe(Schema.toTaggedUnion("type"))
.annotate({ identifier: "Browser.Outcome" })
export type Outcome = typeof Outcome.Type
-1
View File
@@ -1,5 +1,4 @@
export { Agent } from "./agent.js"
export { Browser } from "./browser.js"
export { Command } from "./command.js"
export { Config } from "./config.js"
export { Connection } from "./connection.js"