fix(opencode): improve console login transport errors (#21350)

pull/21243/merge
Kit Langton 2026-04-07 14:31:53 -04:00 committed by GitHub
parent 81bdffc81c
commit d83fe4b540
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 182 additions and 13 deletions

View File

@ -1,9 +1,10 @@
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect" import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { AccountRepo, type AccountRow } from "./repo" import { AccountRepo, type AccountRow } from "./repo"
import { normalizeServerUrl } from "./url"
import { import {
type AccountError, type AccountError,
AccessToken, AccessToken,
@ -12,6 +13,7 @@ import {
Info, Info,
RefreshToken, RefreshToken,
AccountServiceError, AccountServiceError,
AccountTransportError,
Login, Login,
Org, Org,
OrgID, OrgID,
@ -30,6 +32,7 @@ export {
type AccountError, type AccountError,
AccountRepoError, AccountRepoError,
AccountServiceError, AccountServiceError,
AccountTransportError,
AccessToken, AccessToken,
RefreshToken, RefreshToken,
DeviceCode, DeviceCode,
@ -132,13 +135,30 @@ const isTokenFresh = (tokenExpiry: number | null, now: number) =>
const mapAccountServiceError = const mapAccountServiceError =
(message = "Account service operation failed") => (message = "Account service operation failed") =>
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> => <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
effect.pipe( effect.pipe(
Effect.mapError((cause) => Effect.mapError((cause) => accountErrorFromCause(cause, message)),
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
),
) )
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
return cause
}
if (HttpClientError.isHttpClientError(cause)) {
switch (cause.reason._tag) {
case "TransportError": {
return AccountTransportError.fromHttpClientError(cause.reason)
}
default: {
return new AccountServiceError({ message, cause })
}
}
}
return new AccountServiceError({ message, cause })
}
export namespace Account { export namespace Account {
export interface Interface { export interface Interface {
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError> readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
@ -346,8 +366,9 @@ export namespace Account {
}) })
const login = Effect.fn("Account.login")(function* (server: string) { const login = Effect.fn("Account.login")(function* (server: string) {
const normalizedServer = normalizeServerUrl(server)
const response = yield* executeEffectOk( const response = yield* executeEffectOk(
HttpClientRequest.post(`${server}/auth/device/code`).pipe( HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
HttpClientRequest.acceptJson, HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
), ),
@ -359,8 +380,8 @@ export namespace Account {
return new Login({ return new Login({
code: parsed.device_code, code: parsed.device_code,
user: parsed.user_code, user: parsed.user_code,
url: `${server}${parsed.verification_uri_complete}`, url: `${normalizedServer}${parsed.verification_uri_complete}`,
server, server: normalizedServer,
expiry: parsed.expires_in, expiry: parsed.expires_in,
interval: parsed.interval, interval: parsed.interval,
}) })

View File

@ -4,6 +4,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
import { Database } from "@/storage/db" import { Database } from "@/storage/db"
import { AccountStateTable, AccountTable } from "./account.sql" import { AccountStateTable, AccountTable } from "./account.sql"
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
import { normalizeServerUrl } from "./url"
export type AccountRow = (typeof AccountTable)["$inferSelect"] export type AccountRow = (typeof AccountTable)["$inferSelect"]
@ -125,11 +126,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) => const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
tx((db) => { tx((db) => {
const url = normalizeServerUrl(input.url)
db.insert(AccountTable) db.insert(AccountTable)
.values({ .values({
id: input.id, id: input.id,
email: input.email, email: input.email,
url: input.url, url,
access_token: input.accessToken, access_token: input.accessToken,
refresh_token: input.refreshToken, refresh_token: input.refreshToken,
token_expiry: input.expiry, token_expiry: input.expiry,
@ -138,7 +141,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
target: AccountTable.id, target: AccountTable.id,
set: { set: {
email: input.email, email: input.email,
url: input.url, url,
access_token: input.accessToken, access_token: input.accessToken,
refresh_token: input.refreshToken, refresh_token: input.refreshToken,
token_expiry: input.expiry, token_expiry: input.expiry,

View File

@ -1,4 +1,5 @@
import { Schema } from "effect" import { Schema } from "effect"
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
import { withStatics } from "@/util/schema" import { withStatics } from "@/util/schema"
@ -60,7 +61,34 @@ export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceE
cause: Schema.optional(Schema.Defect), cause: Schema.optional(Schema.Defect),
}) {} }) {}
export type AccountError = AccountRepoError | AccountServiceError export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
method: Schema.String,
url: Schema.String,
description: Schema.optional(Schema.String),
cause: Schema.optional(Schema.Defect),
}) {
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
return new AccountTransportError({
method: error.request.method,
url: error.request.url,
description: error.description,
cause: error.cause,
})
}
override get message(): string {
return [
`Could not reach ${this.method} ${this.url}.`,
`This failed before the server returned an HTTP response.`,
this.description,
`Check your network, proxy, or VPN configuration and try again.`,
]
.filter(Boolean)
.join("\n")
}
}
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
export class Login extends Schema.Class<Login>("Login")({ export class Login extends Schema.Class<Login>("Login")({
code: DeviceCode, code: DeviceCode,

View File

@ -0,0 +1,8 @@
export const normalizeServerUrl = (input: string): string => {
const url = new URL(input)
url.search = ""
url.hash = ""
const pathname = url.pathname.replace(/\/+$/, "")
return pathname.length === 0 ? url.origin : `${url.origin}${pathname}`
}

View File

@ -1,3 +1,4 @@
import { AccountServiceError, AccountTransportError } from "@/account"
import { ConfigMarkdown } from "@/config/markdown" import { ConfigMarkdown } from "@/config/markdown"
import { errorFormat } from "@/util/error" import { errorFormat } from "@/util/error"
import { Config } from "../config/config" import { Config } from "../config/config"
@ -8,6 +9,9 @@ import { UI } from "./ui"
export function FormatError(input: unknown) { export function FormatError(input: unknown) {
if (MCP.Failed.isInstance(input)) if (MCP.Failed.isInstance(input))
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
if (input instanceof AccountTransportError || input instanceof AccountServiceError) {
return input.message
}
if (Provider.ModelNotFoundError.isInstance(input)) { if (Provider.ModelNotFoundError.isInstance(input)) {
const { providerID, modelID, suggestions } = input.data const { providerID, modelID, suggestions } = input.data
return [ return [

View File

@ -56,6 +56,32 @@ it.live("persistAccount inserts and getRow retrieves", () =>
}), }),
) )
it.live("persistAccount normalizes trailing slashes in stored server URLs", () =>
Effect.gen(function* () {
const id = AccountID.make("user-1")
yield* AccountRepo.use((r) =>
r.persistAccount({
id,
email: "test@example.com",
url: "https://control.example.com/",
accessToken: AccessToken.make("at_123"),
refreshToken: RefreshToken.make("rt_456"),
expiry: Date.now() + 3600_000,
orgID: Option.none(),
}),
)
const row = yield* AccountRepo.use((r) => r.getRow(id))
const active = yield* AccountRepo.use((r) => r.active())
const list = yield* AccountRepo.use((r) => r.list())
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
expect(list[0]?.url).toBe("https://control.example.com")
}),
)
it.live("persistAccount sets the active account and org", () => it.live("persistAccount sets the active account and org", () =>
Effect.gen(function* () { Effect.gen(function* () {
const id1 = AccountID.make("user-1") const id1 = AccountID.make("user-1")

View File

@ -1,10 +1,20 @@
import { expect } from "bun:test" import { expect } from "bun:test"
import { Duration, Effect, Layer, Option, Schema } from "effect" import { Duration, Effect, Layer, Option, Schema } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http" import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
import { AccountRepo } from "../../src/account/repo" import { AccountRepo } from "../../src/account/repo"
import { Account } from "../../src/account" import { Account } from "../../src/account"
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema" import {
AccessToken,
AccountID,
AccountTransportError,
DeviceCode,
Login,
Org,
OrgID,
RefreshToken,
UserCode,
} from "../../src/account/schema"
import { Database } from "../../src/storage/db" import { Database } from "../../src/storage/db"
import { testEffect } from "../lib/effect" import { testEffect } from "../lib/effect"
@ -57,6 +67,57 @@ const deviceTokenClient = (body: unknown, status = 400) =>
const poll = (body: unknown, status = 400) => const poll = (body: unknown, status = 400) =>
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status)))) Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
it.live("login normalizes trailing slashes in the provided server URL", () =>
Effect.gen(function* () {
const seen: Array<string> = []
const client = HttpClient.make((req) =>
Effect.gen(function* () {
seen.push(`${req.method} ${req.url}`)
if (req.url === "https://one.example.com/auth/device/code") {
return json(req, {
device_code: "device-code",
user_code: "user-code",
verification_uri_complete: "/device?user_code=user-code",
expires_in: 600,
interval: 5,
})
}
return json(req, {}, 404)
}),
)
const result = yield* Account.Service.use((s) => s.login("https://one.example.com/")).pipe(Effect.provide(live(client)))
expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
expect(result.server).toBe("https://one.example.com")
expect(result.url).toBe("https://one.example.com/device?user_code=user-code")
}),
)
it.live("login maps transport failures to account transport errors", () =>
Effect.gen(function* () {
const client = HttpClient.make((req) =>
Effect.fail(
new HttpClientError.HttpClientError({
reason: new HttpClientError.TransportError({ request: req }),
}),
),
)
const error = yield* Effect.flip(
Account.Service.use((s) => s.login("https://one.example.com")).pipe(Effect.provide(live(client))),
)
expect(error).toBeInstanceOf(AccountTransportError)
if (error instanceof AccountTransportError) {
expect(error.method).toBe("POST")
expect(error.url).toBe("https://one.example.com/auth/device/code")
}
}),
)
it.live("orgsByAccount groups orgs per account", () => it.live("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* AccountRepo.use((r) => yield* AccountRepo.use((r) =>

View File

@ -0,0 +1,18 @@
import { describe, expect, test } from "bun:test"
import { AccountTransportError } from "../../src/account/schema"
import { FormatError } from "../../src/cli/error"
describe("cli.error", () => {
test("formats account transport errors clearly", () => {
const error = new AccountTransportError({
method: "POST",
url: "https://console.opencode.ai/auth/device/code",
})
const formatted = FormatError(error)
expect(formatted).toContain("Could not reach POST https://console.opencode.ai/auth/device/code.")
expect(formatted).toContain("This failed before the server returned an HTTP response.")
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
})
})