Skip to content

Commit d83fe4b

Browse files
authored
fix(opencode): improve console login transport errors (anomalyco#21350)
1 parent 81bdffc commit d83fe4b

File tree

8 files changed

+182
-13
lines changed

8 files changed

+182
-13
lines changed

packages/opencode/src/account/index.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { Cache, Clock, Duration, Effect, Layer, Option, Schema, SchemaGetter, ServiceMap } from "effect"
2-
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
2+
import { FetchHttpClient, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
33

44
import { makeRuntime } from "@/effect/run-service"
55
import { withTransientReadRetry } from "@/util/effect-http-client"
66
import { AccountRepo, type AccountRow } from "./repo"
7+
import { normalizeServerUrl } from "./url"
78
import {
89
type AccountError,
910
AccessToken,
@@ -12,6 +13,7 @@ import {
1213
Info,
1314
RefreshToken,
1415
AccountServiceError,
16+
AccountTransportError,
1517
Login,
1618
Org,
1719
OrgID,
@@ -30,6 +32,7 @@ export {
3032
type AccountError,
3133
AccountRepoError,
3234
AccountServiceError,
35+
AccountTransportError,
3336
AccessToken,
3437
RefreshToken,
3538
DeviceCode,
@@ -132,13 +135,30 @@ const isTokenFresh = (tokenExpiry: number | null, now: number) =>
132135

133136
const mapAccountServiceError =
134137
(message = "Account service operation failed") =>
135-
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountServiceError, R> =>
138+
<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, AccountError, R> =>
136139
effect.pipe(
137-
Effect.mapError((cause) =>
138-
cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }),
139-
),
140+
Effect.mapError((cause) => accountErrorFromCause(cause, message)),
140141
)
141142

143+
const accountErrorFromCause = (cause: unknown, message: string): AccountError => {
144+
if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) {
145+
return cause
146+
}
147+
148+
if (HttpClientError.isHttpClientError(cause)) {
149+
switch (cause.reason._tag) {
150+
case "TransportError": {
151+
return AccountTransportError.fromHttpClientError(cause.reason)
152+
}
153+
default: {
154+
return new AccountServiceError({ message, cause })
155+
}
156+
}
157+
}
158+
159+
return new AccountServiceError({ message, cause })
160+
}
161+
142162
export namespace Account {
143163
export interface Interface {
144164
readonly active: () => Effect.Effect<Option.Option<Info>, AccountError>
@@ -346,8 +366,9 @@ export namespace Account {
346366
})
347367

348368
const login = Effect.fn("Account.login")(function* (server: string) {
369+
const normalizedServer = normalizeServerUrl(server)
349370
const response = yield* executeEffectOk(
350-
HttpClientRequest.post(`${server}/auth/device/code`).pipe(
371+
HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe(
351372
HttpClientRequest.acceptJson,
352373
HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })),
353374
),
@@ -359,8 +380,8 @@ export namespace Account {
359380
return new Login({
360381
code: parsed.device_code,
361382
user: parsed.user_code,
362-
url: `${server}${parsed.verification_uri_complete}`,
363-
server,
383+
url: `${normalizedServer}${parsed.verification_uri_complete}`,
384+
server: normalizedServer,
364385
expiry: parsed.expires_in,
365386
interval: parsed.interval,
366387
})

packages/opencode/src/account/repo.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect"
44
import { Database } from "@/storage/db"
55
import { AccountStateTable, AccountTable } from "./account.sql"
66
import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema"
7+
import { normalizeServerUrl } from "./url"
78

89
export type AccountRow = (typeof AccountTable)["$inferSelect"]
910

@@ -125,11 +126,13 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
125126

126127
const persistAccount = Effect.fn("AccountRepo.persistAccount")((input) =>
127128
tx((db) => {
129+
const url = normalizeServerUrl(input.url)
130+
128131
db.insert(AccountTable)
129132
.values({
130133
id: input.id,
131134
email: input.email,
132-
url: input.url,
135+
url,
133136
access_token: input.accessToken,
134137
refresh_token: input.refreshToken,
135138
token_expiry: input.expiry,
@@ -138,7 +141,7 @@ export class AccountRepo extends ServiceMap.Service<AccountRepo, AccountRepo.Ser
138141
target: AccountTable.id,
139142
set: {
140143
email: input.email,
141-
url: input.url,
144+
url,
142145
access_token: input.accessToken,
143146
refresh_token: input.refreshToken,
144147
token_expiry: input.expiry,

packages/opencode/src/account/schema.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Schema } from "effect"
2+
import type * as HttpClientError from "effect/unstable/http/HttpClientError"
23

34
import { withStatics } from "@/util/schema"
45

@@ -60,7 +61,34 @@ export class AccountServiceError extends Schema.TaggedErrorClass<AccountServiceE
6061
cause: Schema.optional(Schema.Defect),
6162
}) {}
6263

63-
export type AccountError = AccountRepoError | AccountServiceError
64+
export class AccountTransportError extends Schema.TaggedErrorClass<AccountTransportError>()("AccountTransportError", {
65+
method: Schema.String,
66+
url: Schema.String,
67+
description: Schema.optional(Schema.String),
68+
cause: Schema.optional(Schema.Defect),
69+
}) {
70+
static fromHttpClientError(error: HttpClientError.TransportError): AccountTransportError {
71+
return new AccountTransportError({
72+
method: error.request.method,
73+
url: error.request.url,
74+
description: error.description,
75+
cause: error.cause,
76+
})
77+
}
78+
79+
override get message(): string {
80+
return [
81+
`Could not reach ${this.method} ${this.url}.`,
82+
`This failed before the server returned an HTTP response.`,
83+
this.description,
84+
`Check your network, proxy, or VPN configuration and try again.`,
85+
]
86+
.filter(Boolean)
87+
.join("\n")
88+
}
89+
}
90+
91+
export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError
6492

6593
export class Login extends Schema.Class<Login>("Login")({
6694
code: DeviceCode,
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const normalizeServerUrl = (input: string): string => {
2+
const url = new URL(input)
3+
url.search = ""
4+
url.hash = ""
5+
6+
const pathname = url.pathname.replace(/\/+$/, "")
7+
return pathname.length === 0 ? url.origin : `${url.origin}${pathname}`
8+
}

packages/opencode/src/cli/error.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AccountServiceError, AccountTransportError } from "@/account"
12
import { ConfigMarkdown } from "@/config/markdown"
23
import { errorFormat } from "@/util/error"
34
import { Config } from "../config/config"
@@ -8,6 +9,9 @@ import { UI } from "./ui"
89
export function FormatError(input: unknown) {
910
if (MCP.Failed.isInstance(input))
1011
return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.`
12+
if (input instanceof AccountTransportError || input instanceof AccountServiceError) {
13+
return input.message
14+
}
1115
if (Provider.ModelNotFoundError.isInstance(input)) {
1216
const { providerID, modelID, suggestions } = input.data
1317
return [

packages/opencode/test/account/repo.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,32 @@ it.live("persistAccount inserts and getRow retrieves", () =>
5656
}),
5757
)
5858

59+
it.live("persistAccount normalizes trailing slashes in stored server URLs", () =>
60+
Effect.gen(function* () {
61+
const id = AccountID.make("user-1")
62+
63+
yield* AccountRepo.use((r) =>
64+
r.persistAccount({
65+
id,
66+
email: "test@example.com",
67+
url: "https://control.example.com/",
68+
accessToken: AccessToken.make("at_123"),
69+
refreshToken: RefreshToken.make("rt_456"),
70+
expiry: Date.now() + 3600_000,
71+
orgID: Option.none(),
72+
}),
73+
)
74+
75+
const row = yield* AccountRepo.use((r) => r.getRow(id))
76+
const active = yield* AccountRepo.use((r) => r.active())
77+
const list = yield* AccountRepo.use((r) => r.list())
78+
79+
expect(Option.getOrThrow(row).url).toBe("https://control.example.com")
80+
expect(Option.getOrThrow(active).url).toBe("https://control.example.com")
81+
expect(list[0]?.url).toBe("https://control.example.com")
82+
}),
83+
)
84+
5985
it.live("persistAccount sets the active account and org", () =>
6086
Effect.gen(function* () {
6187
const id1 = AccountID.make("user-1")

packages/opencode/test/account/service.test.ts

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,20 @@
11
import { expect } from "bun:test"
22
import { Duration, Effect, Layer, Option, Schema } from "effect"
3-
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
3+
import { HttpClient, HttpClientError, HttpClientResponse } from "effect/unstable/http"
44

55
import { AccountRepo } from "../../src/account/repo"
66
import { Account } from "../../src/account"
7-
import { AccessToken, AccountID, DeviceCode, Login, Org, OrgID, RefreshToken, UserCode } from "../../src/account/schema"
7+
import {
8+
AccessToken,
9+
AccountID,
10+
AccountTransportError,
11+
DeviceCode,
12+
Login,
13+
Org,
14+
OrgID,
15+
RefreshToken,
16+
UserCode,
17+
} from "../../src/account/schema"
818
import { Database } from "../../src/storage/db"
919
import { testEffect } from "../lib/effect"
1020

@@ -57,6 +67,57 @@ const deviceTokenClient = (body: unknown, status = 400) =>
5767
const poll = (body: unknown, status = 400) =>
5868
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
5969

70+
it.live("login normalizes trailing slashes in the provided server URL", () =>
71+
Effect.gen(function* () {
72+
const seen: Array<string> = []
73+
const client = HttpClient.make((req) =>
74+
Effect.gen(function* () {
75+
seen.push(`${req.method} ${req.url}`)
76+
77+
if (req.url === "https://one.example.com/auth/device/code") {
78+
return json(req, {
79+
device_code: "device-code",
80+
user_code: "user-code",
81+
verification_uri_complete: "/device?user_code=user-code",
82+
expires_in: 600,
83+
interval: 5,
84+
})
85+
}
86+
87+
return json(req, {}, 404)
88+
}),
89+
)
90+
91+
const result = yield* Account.Service.use((s) => s.login("https://one.example.com/")).pipe(Effect.provide(live(client)))
92+
93+
expect(seen).toEqual(["POST https://one.example.com/auth/device/code"])
94+
expect(result.server).toBe("https://one.example.com")
95+
expect(result.url).toBe("https://one.example.com/device?user_code=user-code")
96+
}),
97+
)
98+
99+
it.live("login maps transport failures to account transport errors", () =>
100+
Effect.gen(function* () {
101+
const client = HttpClient.make((req) =>
102+
Effect.fail(
103+
new HttpClientError.HttpClientError({
104+
reason: new HttpClientError.TransportError({ request: req }),
105+
}),
106+
),
107+
)
108+
109+
const error = yield* Effect.flip(
110+
Account.Service.use((s) => s.login("https://one.example.com")).pipe(Effect.provide(live(client))),
111+
)
112+
113+
expect(error).toBeInstanceOf(AccountTransportError)
114+
if (error instanceof AccountTransportError) {
115+
expect(error.method).toBe("POST")
116+
expect(error.url).toBe("https://one.example.com/auth/device/code")
117+
}
118+
}),
119+
)
120+
60121
it.live("orgsByAccount groups orgs per account", () =>
61122
Effect.gen(function* () {
62123
yield* AccountRepo.use((r) =>
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { AccountTransportError } from "../../src/account/schema"
3+
import { FormatError } from "../../src/cli/error"
4+
5+
describe("cli.error", () => {
6+
test("formats account transport errors clearly", () => {
7+
const error = new AccountTransportError({
8+
method: "POST",
9+
url: "https://console.opencode.ai/auth/device/code",
10+
})
11+
12+
const formatted = FormatError(error)
13+
14+
expect(formatted).toContain("Could not reach POST https://console.opencode.ai/auth/device/code.")
15+
expect(formatted).toContain("This failed before the server returned an HTTP response.")
16+
expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.")
17+
})
18+
})

0 commit comments

Comments
 (0)