Skip to content
Open
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
31 changes: 31 additions & 0 deletions packages/app/e2e/regression/settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,37 @@ test("providers: OpenCode Console signs in from its method list", async ({ page,
await expect(signIn.getByRole("button", { name: "Copy sign-in link", exact: true })).toBeVisible()
})

test("providers: a device sign-in copies the code its page does not prefill", async ({ page, context }) => {
await context.grantPermissions(["clipboard-read", "clipboard-write"])
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))

const { settings } = await open(page, {
integrations: [
{
id: "github-copilot",
name: "GitHub Copilot",
methods: [{ id: "device", type: "oauth", label: "GitHub" }],
connections: [],
},
],
// Like GitHub's device page, the URL carries no `user_code`, so the code has to be pasted.
onIntegrationOAuth: () => ({
url: "https://auth.example.test/login/device",
instructions: "Enter code: 8EA6-ACBA",
}),
})

await settings.getByRole("tab", { name: "Providers", exact: true }).click()
const popup = page.waitForEvent("popup")
await settings
.locator(".settings-provider-row", { hasText: "GitHub Copilot" })
.getByRole("button", { name: "Connect", exact: true })
.click()
await popup
// Read once rather than poll: the copy finishes before the browser opens.
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe("8EA6-ACBA")
})

test("providers: OpenCode Go connects with its own methods, not the Console sign-in", async ({ page }) => {
const attempts: string[] = []

Expand Down
10 changes: 7 additions & 3 deletions packages/app/e2e/utils/mock-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,13 @@ export interface MockServerConfig {
// Connect/disconnect record the server's new status in that workspace (`connected`/`disabled`); the hook may return
// another status (for example `{ status: "failed", error }`). Unknown servers answer 404.
onMcpAction?: (input: { server: string; action: "connect" | "disconnect"; directory: string }) => void | MockMcpStatus
// Starts an OAuth attempt (POST .../connect/oauth) and returns its authorization URL; the attempt then stays pending.
// Starts an OAuth attempt (POST .../connect/oauth) and returns its authorization URL and optional instructions; the
// attempt then stays pending.
// Without it, OAuth connects answer 501 MockUnsupported.
onIntegrationOAuth?: (input: { integrationID: string; directory: string; body: unknown }) => { url: string }
onIntegrationOAuth?: (input: { integrationID: string; directory: string; body: unknown }) => {
url: string
instructions?: string
}
// Answers status polls for an OAuth attempt; without it every attempt stays pending.
onIntegrationOAuthStatus?: (attemptID: string) => "pending" | "complete"
plugins?: Resolvable<unknown[]>
Expand Down Expand Up @@ -937,7 +941,7 @@ function mockHandlers(
data: {
attemptID,
url: started.url,
instructions: "",
instructions: started.instructions ?? "",
mode: "auto",
time: { created, expires: created + 600_000 },
},
Expand Down
20 changes: 18 additions & 2 deletions packages/app/src/providers/connect/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ type Polling = {
// and connects with its own methods, the same as the TUI.
export const CONSOLE_INTEGRATION = "opencode"

// Device-flow codes such as "8EA6-ACBA", matched the same way as the TUI.
const DEVICE_CODE = /[A-Z0-9]{4}-[A-Z0-9]{4,5}/

// Same order as the TUI: browser and command sign-ins first, API keys last.
export function connectMethods(integration: IntegrationInfo) {
return integration.methods
Expand Down Expand Up @@ -379,11 +382,24 @@ export function createProviderConnectionController(options: {
}

const open = async () => {
const url = store.authorization?.url
const authorization = store.authorization

if (!url) return
if (!authorization?.url) return
const url = authorization.url
const generation = polling.generation

// Device pages such as GitHub's ask for a code the URL does not prefill (no `user_code`). Copy it
// before opening the browser: once the browser has focus, the app can no longer write to the clipboard.
const code =
URL.canParse(url) && new URL(url).searchParams.has("user_code")
? undefined
: authorization.instructions.match(DEVICE_CODE)?.[0]

if (code)
await Promise.resolve()
.then(() => platform.writeClipboardText?.(code) ?? navigator.clipboard.writeText(code))
.catch(() => undefined)

const opened = await Promise.resolve()
.then(() => {
if (platform.openBrowser) return platform.openBrowser(url)
Expand Down
Loading