diff --git a/packages/app/src/providers/connect/controller.ts b/packages/app/src/providers/connect/controller.ts
index 4fe7ea1fbce..d910d71d51b 100644
--- a/packages/app/src/providers/connect/controller.ts
+++ b/packages/app/src/providers/connect/controller.ts
@@ -397,6 +397,18 @@ export function createProviderDialogController(options: {
return
}
+ if (effect.type === "paste") {
+ void serverSDK.api.integration.oauth
+ .complete({ integrationID, attemptID: effect.signIn.authorization.attemptID, location, code: effect.value })
+ .then(
+ () => result({ type: "paste.accepted", attempt: effect.attempt }),
+ (error) =>
+ result({ type: "paste.rejected", attempt: effect.attempt, error: formatServerError(error, language.t) }),
+ )
+
+ return
+ }
+
if (effect.type === "sync") return void sync()
void refresh(effect, integrationID, result)
diff --git a/packages/app/src/providers/connect/dialog.tsx b/packages/app/src/providers/connect/dialog.tsx
index 5e07a913da5..469e414481e 100644
--- a/packages/app/src/providers/connect/dialog.tsx
+++ b/packages/app/src/providers/connect/dialog.tsx
@@ -7,6 +7,7 @@ import { Checkbox } from "@opencode/ui/checkbox"
import { Loader } from "@opencode/ui/loader"
import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, DialogTitleGroup, Dialog } from "@opencode/ui/dialog"
+import { RedirectPasteForm } from "@opencode/ui/redirect-paste"
import { TextInput } from "@opencode/ui/text-input"
import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
@@ -443,11 +444,10 @@ function ProviderSteps(props: { provider: string; controller: ProviderDialogCont
const userCode = new URL(authorization.url).searchParams.get("user_code")
if (userCode) return userCode
+ // Device sign-ins put the code after a colon ("Enter code: ABCD-1234"); other instructions are not a code.
const instructions = authorization.instructions
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
-
- return instructions
})
// Asks one visible field at a time, like the TUI, so `when` conditions see every earlier answer.
@@ -913,7 +913,14 @@ function ProviderSteps(props: { provider: string; controller: ProviderDialogCont
function OAuthAutoView() {
return (
-
{language.t("provider.connect.oauth.auto.description", { provider: name() })}
+
+ {language.t(
+ connect().paste
+ ? "provider.connect.oauth.auto.paste.description"
+ : "provider.connect.oauth.auto.description",
+ { provider: name() },
+ )}
+
{language.t("provider.connect.status.waiting")}
)}
+
+ {(paste) => (
+ props.controller.input({ type: "paste.changed", value })}
+ onSubmit={() => props.controller.input({ type: "paste.submitted" })}
+ />
+ )}
+
)
}
diff --git a/packages/app/src/providers/connect/machine.test.ts b/packages/app/src/providers/connect/machine.test.ts
index ad4f8e4f990..e9500f77fe6 100644
--- a/packages/app/src/providers/connect/machine.test.ts
+++ b/packages/app/src/providers/connect/machine.test.ts
@@ -292,6 +292,129 @@ test("an unreachable OAuth status resumes the same attempt, and Back from it can
])
})
+// The browser can't reach a remote server's callback, so the user pastes the URL it landed on instead.
+function waitingWithPaste() {
+ const starting = replay([loaded([oauth]), { type: "method.selected", index: 0 }])
+ const start = starting.effects.find((effect) => effect.type === "start")
+
+ if (start?.type !== "start") throw new Error("expected a start effect")
+
+ return {
+ attempt: start.attempt,
+ state: transitionProviderConnect(starting.state, {
+ type: "oauth.started",
+ attempt: start.attempt,
+ authorization: { ...authorization("auto"), paste: true },
+ }).state,
+ }
+}
+
+test("a browser sign-in offers a paste box only when its attempt takes a pasted redirect", () => {
+ expect(selectProviderConnectView(waitingWithPaste().state).paste).toEqual({
+ value: "",
+ error: undefined,
+ locked: false,
+ submitting: false,
+ })
+
+ const plain = replay([
+ loaded([oauth]),
+ { type: "method.selected", index: 0 },
+ { type: "oauth.started", attempt: 1, authorization: authorization("auto") },
+ ])
+
+ expect(selectProviderConnectView(plain.state).paste).toBeUndefined()
+ expect(transitionProviderConnect(plain.state, { type: "paste.changed", value: "http://x" }).state).toBe(plain.state)
+})
+
+test("a rejected paste shows the server error and keeps polling the same attempt", () => {
+ const waiting = waitingWithPaste()
+ const empty = transitionProviderConnect(waiting.state, { type: "paste.submitted" })
+
+ expect(empty.effects).toEqual([])
+ expect(selectProviderConnectView(empty.state).paste?.error).toEqual({ type: "required" })
+
+ const typed = transitionProviderConnect(empty.state, {
+ type: "paste.changed",
+ value: " http://127.0.0.1:1/callback?code=c&state=s ",
+ })
+
+ expect(selectProviderConnectView(typed.state).paste?.error).toBeUndefined()
+
+ const sent = transitionProviderConnect(typed.state, { type: "paste.submitted" })
+
+ expect(sent.effects).toMatchObject([
+ { type: "paste", attempt: waiting.attempt, value: "http://127.0.0.1:1/callback?code=c&state=s" },
+ ])
+ // The field takes no edits or second submit while the first is in flight.
+ expect(selectProviderConnectView(sent.state).paste).toMatchObject({ locked: true, submitting: true })
+ expect(transitionProviderConnect(sent.state, { type: "paste.submitted" }).effects).toEqual([])
+ expect(transitionProviderConnect(sent.state, { type: "paste.changed", value: "again" }).state).toBe(sent.state)
+
+ const rejected = transitionProviderConnect(sent.state, {
+ type: "paste.rejected",
+ attempt: waiting.attempt,
+ error: "This URL belongs to a different sign-in",
+ })
+
+ expect(selectProviderConnectView(rejected.state)).toMatchObject({
+ screen: "browser",
+ paste: {
+ value: " http://127.0.0.1:1/callback?code=c&state=s ",
+ error: { type: "rejected", message: "This URL belongs to a different sign-in" },
+ locked: false,
+ },
+ })
+
+ // Polling still decides how the sign-in ends.
+ const done = transitionProviderConnect(rejected.state, {
+ type: "polled",
+ attempt: waiting.attempt,
+ outcome: { status: "complete" },
+ })
+
+ expect(done.state.status).toBe("refreshing")
+})
+
+test("an accepted paste stays locked while polling reports how the sign-in ends", () => {
+ const waiting = waitingWithPaste()
+ const typed = transitionProviderConnect(waiting.state, { type: "paste.changed", value: "http://x" })
+ const sent = transitionProviderConnect(typed.state, { type: "paste.submitted" })
+ const accepted = transitionProviderConnect(sent.state, { type: "paste.accepted", attempt: waiting.attempt })
+
+ expect(selectProviderConnectView(accepted.state).paste).toEqual({
+ value: "http://x",
+ error: undefined,
+ locked: true,
+ submitting: false,
+ })
+ expect(transitionProviderConnect(accepted.state, { type: "paste.submitted" }).effects).toEqual([])
+})
+
+test("a paste result after Back is ignored, and a resumed sign-in starts editable", () => {
+ const waiting = waitingWithPaste()
+ const typed = transitionProviderConnect(waiting.state, { type: "paste.changed", value: "http://x" })
+ const sent = transitionProviderConnect(typed.state, { type: "paste.submitted" })
+ const back = transitionProviderConnect(sent.state, { type: "back" })
+
+ expect(transitionProviderConnect(back.state, { type: "paste.accepted", attempt: waiting.attempt }).state).toBe(
+ back.state,
+ )
+
+ const failed = transitionProviderConnect(sent.state, {
+ type: "polled",
+ attempt: waiting.attempt,
+ outcome: { status: "unreachable", error: "offline" },
+ })
+
+ const resumed = transitionProviderConnect(failed.state, { type: "retry" })
+
+ expect(selectProviderConnectView(resumed.state).paste).toMatchObject({ value: "http://x", locked: false })
+ expect(
+ transitionProviderConnect(resumed.state, { type: "paste.rejected", attempt: waiting.attempt, error: "late" }).state,
+ ).toBe(resumed.state)
+})
+
test("a failed refresh retries the refresh, not the sign-in", () => {
const sent = submittingKey()
const stored = transitionProviderConnect(sent.state, { type: "submit.succeeded", attempt: sent.attempt })
diff --git a/packages/app/src/providers/connect/machine.ts b/packages/app/src/providers/connect/machine.ts
index 0b22cdb1153..171bcc45141 100644
--- a/packages/app/src/providers/connect/machine.ts
+++ b/packages/app/src/providers/connect/machine.ts
@@ -33,6 +33,8 @@ export type ProviderSignIn =
readonly method: Extract
readonly authorization: ProviderAuthorization
readonly browserFailed: boolean
+ /** Set when the attempt also takes the redirect URL pasted from a browser that cannot reach the server. */
+ readonly paste?: ProviderPaste
}
| {
readonly kind: "code"
@@ -55,6 +57,13 @@ export type ProviderPolledSignIn = Extract }
@@ -110,6 +119,11 @@ export type ProviderConnectEvent =
readonly opened: boolean
}
| { readonly type: "entry.submitted"; readonly value: string }
+ | { readonly type: "paste.changed"; readonly value: string }
+ | { readonly type: "paste.submitted" }
+ /** The server took the pasted URL; the sign-in still settles through polling. */
+ | { readonly type: "paste.accepted"; readonly attempt: number }
+ | { readonly type: "paste.rejected"; readonly attempt: number; readonly error: string }
| { readonly type: "oauth.started"; readonly attempt: number; readonly authorization: ProviderAuthorization }
| { readonly type: "command.started"; readonly attempt: number; readonly command: ProviderCommandAttempt }
| { readonly type: "external.saved"; readonly attempt: number }
@@ -152,6 +166,12 @@ export type ProviderConnectEffect =
readonly value: string
readonly answer?: FormAnswer
}
+ | {
+ readonly type: "paste"
+ readonly attempt: number
+ readonly signIn: Extract
+ readonly value: string
+ }
| { readonly type: "refresh"; readonly attempt: number; readonly method: ProviderConnectMethod }
/** Reloads the catalogs without reporting back, for a credential saved after the dialog moved on. */
| { readonly type: "sync" }
@@ -333,6 +353,7 @@ export function selectProviderConnectView(state: ProviderConnectState) {
commandOutput: signIn?.kind === "command" ? signIn.output : "",
form: state.status === "form" ? selectProviderFormView(state.form) : undefined,
entryError: state.status === "entering" ? state.error : undefined,
+ paste: state.status === "waiting" && signIn?.kind === "browser" && signIn.paste ? pasteView(signIn.paste) : undefined,
// A typed secret is sent and the catalogs are refreshing: the form takes no second submit.
submitting: state.status === "submitting" || state.status === "refreshing",
error: state.status === "failed" ? state.error : undefined,
@@ -452,6 +473,14 @@ function polled(
state: Extract,
event: ProviderConnectEvent,
): ProviderConnectTransition {
+ if (
+ event.type === "paste.changed" ||
+ event.type === "paste.submitted" ||
+ event.type === "paste.accepted" ||
+ event.type === "paste.rejected"
+ )
+ return pasted(state, event)
+
if (event.type !== "polled") return unchanged(state)
const outcome = event.outcome
@@ -473,15 +502,69 @@ function polled(
return unchanged({ ...selection(state, state.attempt), status: "failed", error: outcome.error, retry })
}
+// A paste only hands the server the URL: polling keeps running and reports how the sign-in ends.
+function pasted(
+ state: Extract,
+ event: Extract<
+ ProviderConnectEvent,
+ { type: "paste.changed" | "paste.submitted" | "paste.accepted" | "paste.rejected" }
+ >,
+): ProviderConnectTransition {
+ const signIn = state.signIn
+
+ if (signIn.kind !== "browser" || !signIn.paste) return unchanged(state)
+ const paste = signIn.paste
+ const set = (next: ProviderPaste) => ({ ...state, signIn: { ...signIn, paste: next } })
+
+ if (event.type === "paste.changed") {
+ if (paste.status !== "editing" || (event.value === paste.value && !paste.error)) return unchanged(state)
+
+ return unchanged(set({ status: "editing", value: event.value }))
+ }
+
+ if (event.type === "paste.submitted") {
+ if (paste.status !== "editing") return unchanged(state)
+ const value = paste.value.trim()
+
+ if (!value) return unchanged(set({ status: "editing", value: paste.value, error: { type: "required" } }))
+
+ return {
+ state: set({ status: "submitting", value: paste.value }),
+ effects: [{ type: "paste", attempt: state.attempt, signIn, value }],
+ }
+ }
+
+ if (paste.status !== "submitting") return unchanged(state)
+
+ if (event.type === "paste.accepted") return unchanged(set({ status: "accepted", value: paste.value }))
+
+ return unchanged(set({ status: "editing", value: paste.value, error: { type: "rejected", message: event.error } }))
+}
+
+function pasteView(paste: ProviderPaste) {
+ return {
+ value: paste.value,
+ error: paste.status === "editing" ? paste.error : undefined,
+ locked: paste.status !== "editing",
+ submitting: paste.status === "submitting",
+ }
+}
+
function poll(
state: Extract,
signIn: ProviderPolledSignIn,
): ProviderConnectTransition {
const attempt = state.status === "starting" ? state.attempt : state.attempt + 1
+ // A paste in flight when polling failed reports to the old attempt, so a resumed sign-in starts editable too.
+ const next: ProviderPolledSignIn =
+ signIn.kind === "browser" && signIn.authorization.paste
+ ? { ...signIn, paste: { status: "editing", value: signIn.paste?.value ?? "" } }
+ : signIn
+
return {
- state: { ...selection(state, attempt), status: "waiting", signIn },
- effects: [{ type: "poll", attempt, signIn, delay: false }],
+ state: { ...selection(state, attempt), status: "waiting", signIn: next },
+ effects: [{ type: "poll", attempt, signIn: next, delay: false }],
}
}
diff --git a/packages/app/src/providers/connect/mcp-sign-in.tsx b/packages/app/src/providers/connect/mcp-sign-in.tsx
index 5f7c9492507..da4f1a75444 100644
--- a/packages/app/src/providers/connect/mcp-sign-in.tsx
+++ b/packages/app/src/providers/connect/mcp-sign-in.tsx
@@ -1,9 +1,9 @@
-import { createUniqueId, onMount, Show } from "solid-js"
+import { Show } from "solid-js"
import { Button } from "@opencode/ui/button"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Loader } from "@opencode/ui/loader"
import { createMcpSignIn, type McpSignInFailure } from "@opencode/ui/mcp-sign-in"
-import { TextInput } from "@opencode/ui/text-input"
+import { RedirectPasteForm } from "@opencode/ui/redirect-paste"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
@@ -28,7 +28,6 @@ export function DialogMcpSignIn(props: {
const language = useLanguage()
const platform = usePlatform()
const serverSDK = useServerSDK()
- const errorID = createUniqueId()
const attempt = { integrationID: props.integrationID, attemptID: props.attemptID, location: props.location }
const signIn = createMcpSignIn({
@@ -45,7 +44,6 @@ export function DialogMcpSignIn(props: {
const view = signIn.view
const dispatch = signIn.dispatch
- let input: HTMLInputElement | undefined
const failure = (value: McpSignInFailure) => {
if (value.type === "auth-expired")
@@ -59,16 +57,6 @@ export function DialogMcpSignIn(props: {
return language.t("mcp.timedOut", { name: props.name })
}
- const entryError = () => {
- const error = view().entryError
-
- if (!error) return
-
- return error.type === "rejected" ? error.message : language.t("dialog.mcp.signIn.paste.required")
- }
-
- onMount(() => input?.focus({ preventScroll: true }))
-
return (