From 189c5f700335d9234b30fdbe94dca5a6bf7e33ca Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Apr 2026 12:39:31 -0400 Subject: [PATCH 1/6] fix(opencode): improve console login transport errors --- packages/opencode/src/account/index.ts | 20 ++++++---- packages/opencode/src/account/repo.ts | 15 ++++--- packages/opencode/src/account/url.ts | 8 ++++ packages/opencode/src/cli/error.ts | 34 ++++++++++++++++ packages/opencode/src/util/network.ts | 8 +++- packages/opencode/test/account/repo.test.ts | 26 +++++++++++++ .../opencode/test/account/service.test.ts | 29 ++++++++++++++ packages/opencode/test/cli/error.test.ts | 39 +++++++++++++++++++ 8 files changed, 166 insertions(+), 13 deletions(-) create mode 100644 packages/opencode/src/account/url.ts create mode 100644 packages/opencode/test/cli/error.test.ts diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index a1bb614ce41f..e9b46c01706a 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -4,6 +4,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } fr import { makeRuntime } from "@/effect/run-service" import { withTransientReadRetry } from "@/util/effect-http-client" import { AccountRepo, type AccountRow } from "./repo" +import { normalizeServerUrl } from "./url" import { type AccountError, AccessToken, @@ -187,10 +188,11 @@ export namespace Account { ) const refreshToken = Effect.fnUntraced(function* (row: AccountRow) { + const server = normalizeServerUrl(row.url) const now = yield* Clock.currentTimeMillis const response = yield* executeEffectOk( - HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( + HttpClientRequest.post(`${server}/auth/device/token`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( new TokenRefreshRequest({ @@ -256,8 +258,9 @@ export namespace Account { }) const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const server = normalizeServerUrl(url) const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/orgs`).pipe( + HttpClientRequest.get(`${server}/api/orgs`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(accessToken), ), @@ -269,8 +272,9 @@ export namespace Account { }) const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { + const server = normalizeServerUrl(url) const response = yield* executeReadOk( - HttpClientRequest.get(`${url}/api/user`).pipe( + HttpClientRequest.get(`${server}/api/user`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(accessToken), ), @@ -326,9 +330,10 @@ export namespace Account { if (Option.isNone(resolved)) return Option.none() const { account, accessToken } = resolved.value + const server = normalizeServerUrl(account.url) const response = yield* executeRead( - HttpClientRequest.get(`${account.url}/api/config`).pipe( + HttpClientRequest.get(`${server}/api/config`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(accessToken), HttpClientRequest.setHeaders({ "x-org-id": orgID }), @@ -346,8 +351,9 @@ export namespace Account { }) const login = Effect.fn("Account.login")(function* (server: string) { + const normalizedServer = normalizeServerUrl(server) const response = yield* executeEffectOk( - HttpClientRequest.post(`${server}/auth/device/code`).pipe( + HttpClientRequest.post(`${normalizedServer}/auth/device/code`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.schemaBodyJson(ClientId)(new ClientId({ client_id: clientId })), ), @@ -359,8 +365,8 @@ export namespace Account { return new Login({ code: parsed.device_code, user: parsed.user_code, - url: `${server}${parsed.verification_uri_complete}`, - server, + url: `${normalizedServer}${parsed.verification_uri_complete}`, + server: normalizedServer, expiry: parsed.expires_in, interval: parsed.interval, }) diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index d02cf1b637f8..cb8b84449837 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -4,6 +4,7 @@ import { Effect, Layer, Option, Schema, ServiceMap } from "effect" import { Database } from "@/storage/db" import { AccountStateTable, AccountTable } from "./account.sql" import { AccessToken, AccountID, AccountRepoError, Info, OrgID, RefreshToken } from "./schema" +import { normalizeServerUrl } from "./url" export type AccountRow = (typeof AccountTable)["$inferSelect"] @@ -60,7 +61,7 @@ export class AccountRepo extends ServiceMap.Service) => { @@ -85,7 +86,7 @@ export class AccountRepo extends ServiceMap.Service decode({ ...row, active_org_id: null })), + .map((row: AccountRow) => decode({ ...row, url: normalizeServerUrl(row.url), active_org_id: null })), ), ) @@ -105,7 +106,9 @@ export class AccountRepo extends ServiceMap.Service query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( - Effect.map(Option.fromNullishOr), + Effect.map((row) => + row == null ? Option.none() : Option.some({ ...row, url: normalizeServerUrl(row.url) }), + ), ), ) @@ -125,11 +128,13 @@ export class AccountRepo extends ServiceMap.Service tx((db) => { + const url = normalizeServerUrl(input.url) + db.insert(AccountTable) .values({ id: input.id, email: input.email, - url: input.url, + url, access_token: input.accessToken, refresh_token: input.refreshToken, token_expiry: input.expiry, @@ -138,7 +143,7 @@ export class AccountRepo extends ServiceMap.Service { + const url = new URL(input) + url.search = "" + url.hash = "" + + const pathname = url.pathname.replace(/\/+$/, "") + return pathname.length === 0 ? url.origin : `${url.origin}${pathname}` +} diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 52bad892eb82..0f604d681c84 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,13 +1,47 @@ +import { HttpClientError } from "effect/unstable/http" +import { AccountServiceError } from "@/account" import { ConfigMarkdown } from "@/config/markdown" +import { activeProxyEnvVars } from "@/util/network" import { errorFormat } from "@/util/error" import { Config } from "../config/config" import { MCP } from "../mcp" import { Provider } from "../provider/provider" import { UI } from "./ui" +const findTransportError = (input: unknown): HttpClientError.TransportError | undefined => { + let current = input + const seen = new Set() + + while (typeof current === "object" && current !== null && !seen.has(current)) { + if (current instanceof HttpClientError.TransportError) return current + seen.add(current) + current = Reflect.get(current, "cause") + } +} + +const formatProxyHint = () => { + const envVars = activeProxyEnvVars() + if (envVars.length === 0) return undefined + return `Proxy environment variables are set (${envVars.join(", ")}). If that proxy is unavailable, unset them and try again.` +} + export function FormatError(input: unknown) { if (MCP.Failed.isInstance(input)) return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` + if (input instanceof AccountServiceError) { + const transportError = findTransportError(input) + if (transportError) { + return [ + `Could not reach ${transportError.methodAndUrl}.`, + `This failed before the server returned an HTTP response.`, + formatProxyHint(), + ] + .filter(Boolean) + .join("\n") + } + + return input.message + } if (Provider.ModelNotFoundError.isInstance(input)) { const { providerID, modelID, suggestions } = input.data return [ diff --git a/packages/opencode/src/util/network.ts b/packages/opencode/src/util/network.ts index 69e5d17588ac..60baa76ba571 100644 --- a/packages/opencode/src/util/network.ts +++ b/packages/opencode/src/util/network.ts @@ -1,3 +1,5 @@ +const proxyEnvVarNames = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const + export function online() { const nav = globalThis.navigator if (!nav || typeof nav.onLine !== "boolean") return true @@ -5,5 +7,9 @@ export function online() { } export function proxied() { - return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) + return activeProxyEnvVars().length > 0 +} + +export function activeProxyEnvVars() { + return proxyEnvVarNames.filter((name) => process.env[name]) } diff --git a/packages/opencode/test/account/repo.test.ts b/packages/opencode/test/account/repo.test.ts index 460c47443f3b..2f17d1b22fc0 100644 --- a/packages/opencode/test/account/repo.test.ts +++ b/packages/opencode/test/account/repo.test.ts @@ -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", () => Effect.gen(function* () { const id1 = AccountID.make("user-1") diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index 85ab259f1da6..c4d90d92947f 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -57,6 +57,35 @@ const deviceTokenClient = (body: unknown, status = 400) => const poll = (body: unknown, status = 400) => 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 = [] + 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("orgsByAccount groups orgs per account", () => Effect.gen(function* () { yield* AccountRepo.use((r) => diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts new file mode 100644 index 000000000000..2f573f81cd70 --- /dev/null +++ b/packages/opencode/test/cli/error.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test } from "bun:test" +import { HttpClientError, HttpClientRequest } from "effect/unstable/http" + +import { AccountServiceError } from "../../src/account/schema" +import { FormatError } from "../../src/cli/error" + +const proxyEnvVarNames = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const +const originalEnv = new Map(proxyEnvVarNames.map((name) => [name, process.env[name]])) + +afterEach(() => { + for (const name of proxyEnvVarNames) { + const value = originalEnv.get(name) + if (value === undefined) { + delete process.env[name] + continue + } + + process.env[name] = value + } +}) + +describe("cli.error", () => { + test("formats account transport errors with a proxy hint when proxy env vars are set", () => { + process.env.HTTPS_PROXY = "http://proxy.internal:8080" + + const error = new AccountServiceError({ + message: "HTTP request failed", + cause: new HttpClientError.TransportError({ + request: HttpClientRequest.post("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("HTTPS_PROXY") + }) +}) From f09749886565213c123b11a523837773ca16e736 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Apr 2026 13:49:23 -0400 Subject: [PATCH 2/6] fix(opencode): simplify console login transport guidance --- packages/opencode/src/account/index.ts | 12 ++++-------- packages/opencode/src/account/repo.ts | 8 +++----- packages/opencode/src/cli/error.ts | 10 +--------- packages/opencode/src/util/network.ts | 8 +------- packages/opencode/test/cli/error.test.ts | 23 +++-------------------- 5 files changed, 12 insertions(+), 49 deletions(-) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index e9b46c01706a..8d54088306b0 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -188,11 +188,10 @@ export namespace Account { ) const refreshToken = Effect.fnUntraced(function* (row: AccountRow) { - const server = normalizeServerUrl(row.url) const now = yield* Clock.currentTimeMillis const response = yield* executeEffectOk( - HttpClientRequest.post(`${server}/auth/device/token`).pipe( + HttpClientRequest.post(`${row.url}/auth/device/token`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.schemaBodyJson(TokenRefreshRequest)( new TokenRefreshRequest({ @@ -258,9 +257,8 @@ export namespace Account { }) const fetchOrgs = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const server = normalizeServerUrl(url) const response = yield* executeReadOk( - HttpClientRequest.get(`${server}/api/orgs`).pipe( + HttpClientRequest.get(`${url}/api/orgs`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(accessToken), ), @@ -272,9 +270,8 @@ export namespace Account { }) const fetchUser = Effect.fnUntraced(function* (url: string, accessToken: AccessToken) { - const server = normalizeServerUrl(url) const response = yield* executeReadOk( - HttpClientRequest.get(`${server}/api/user`).pipe( + HttpClientRequest.get(`${url}/api/user`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(accessToken), ), @@ -330,10 +327,9 @@ export namespace Account { if (Option.isNone(resolved)) return Option.none() const { account, accessToken } = resolved.value - const server = normalizeServerUrl(account.url) const response = yield* executeRead( - HttpClientRequest.get(`${server}/api/config`).pipe( + HttpClientRequest.get(`${account.url}/api/config`).pipe( HttpClientRequest.acceptJson, HttpClientRequest.bearerToken(accessToken), HttpClientRequest.setHeaders({ "x-org-id": orgID }), diff --git a/packages/opencode/src/account/repo.ts b/packages/opencode/src/account/repo.ts index cb8b84449837..4dbb9cab43d6 100644 --- a/packages/opencode/src/account/repo.ts +++ b/packages/opencode/src/account/repo.ts @@ -61,7 +61,7 @@ export class AccountRepo extends ServiceMap.Service) => { @@ -86,7 +86,7 @@ export class AccountRepo extends ServiceMap.Service decode({ ...row, url: normalizeServerUrl(row.url), active_org_id: null })), + .map((row: AccountRow) => decode({ ...row, active_org_id: null })), ), ) @@ -106,9 +106,7 @@ export class AccountRepo extends ServiceMap.Service query((db) => db.select().from(AccountTable).where(eq(AccountTable.id, accountID)).get()).pipe( - Effect.map((row) => - row == null ? Option.none() : Option.some({ ...row, url: normalizeServerUrl(row.url) }), - ), + Effect.map(Option.fromNullishOr), ), ) diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 0f604d681c84..9d840bf9a881 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,7 +1,6 @@ import { HttpClientError } from "effect/unstable/http" import { AccountServiceError } from "@/account" import { ConfigMarkdown } from "@/config/markdown" -import { activeProxyEnvVars } from "@/util/network" import { errorFormat } from "@/util/error" import { Config } from "../config/config" import { MCP } from "../mcp" @@ -19,12 +18,6 @@ const findTransportError = (input: unknown): HttpClientError.TransportError | un } } -const formatProxyHint = () => { - const envVars = activeProxyEnvVars() - if (envVars.length === 0) return undefined - return `Proxy environment variables are set (${envVars.join(", ")}). If that proxy is unavailable, unset them and try again.` -} - export function FormatError(input: unknown) { if (MCP.Failed.isInstance(input)) return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` @@ -34,9 +27,8 @@ export function FormatError(input: unknown) { return [ `Could not reach ${transportError.methodAndUrl}.`, `This failed before the server returned an HTTP response.`, - formatProxyHint(), + `Check your network, proxy, or VPN configuration and try again.`, ] - .filter(Boolean) .join("\n") } diff --git a/packages/opencode/src/util/network.ts b/packages/opencode/src/util/network.ts index 60baa76ba571..69e5d17588ac 100644 --- a/packages/opencode/src/util/network.ts +++ b/packages/opencode/src/util/network.ts @@ -1,5 +1,3 @@ -const proxyEnvVarNames = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const - export function online() { const nav = globalThis.navigator if (!nav || typeof nav.onLine !== "boolean") return true @@ -7,9 +5,5 @@ export function online() { } export function proxied() { - return activeProxyEnvVars().length > 0 -} - -export function activeProxyEnvVars() { - return proxyEnvVarNames.filter((name) => process.env[name]) + return !!(process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy) } diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index 2f573f81cd70..1e10537174c0 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -1,28 +1,11 @@ -import { afterEach, describe, expect, test } from "bun:test" +import { describe, expect, test } from "bun:test" import { HttpClientError, HttpClientRequest } from "effect/unstable/http" import { AccountServiceError } from "../../src/account/schema" import { FormatError } from "../../src/cli/error" -const proxyEnvVarNames = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const -const originalEnv = new Map(proxyEnvVarNames.map((name) => [name, process.env[name]])) - -afterEach(() => { - for (const name of proxyEnvVarNames) { - const value = originalEnv.get(name) - if (value === undefined) { - delete process.env[name] - continue - } - - process.env[name] = value - } -}) - describe("cli.error", () => { - test("formats account transport errors with a proxy hint when proxy env vars are set", () => { - process.env.HTTPS_PROXY = "http://proxy.internal:8080" - + test("formats account transport errors clearly", () => { const error = new AccountServiceError({ message: "HTTP request failed", cause: new HttpClientError.TransportError({ @@ -34,6 +17,6 @@ describe("cli.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("HTTPS_PROXY") + expect(formatted).toContain("Check your network, proxy, or VPN configuration and try again.") }) }) From 10844e69628a55c92e7dd10b29111f536e565e0a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Apr 2026 13:54:28 -0400 Subject: [PATCH 3/6] refactor(opencode): model account transport errors explicitly --- packages/opencode/src/account/index.ts | 17 +++++++-- packages/opencode/src/account/schema.ts | 9 ++++- packages/opencode/src/cli/error.ts | 31 +++++----------- .../opencode/test/account/service.test.ts | 36 +++++++++++++++++-- packages/opencode/test/cli/error.test.ts | 12 +++---- 5 files changed, 68 insertions(+), 37 deletions(-) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index 8d54088306b0..a9fe0e11eef5 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -1,5 +1,5 @@ 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 { withTransientReadRetry } from "@/util/effect-http-client" @@ -13,6 +13,7 @@ import { Info, RefreshToken, AccountServiceError, + AccountTransportError, Login, Org, OrgID, @@ -31,6 +32,7 @@ export { type AccountError, AccountRepoError, AccountServiceError, + AccountTransportError, AccessToken, RefreshToken, DeviceCode, @@ -133,10 +135,19 @@ const isTokenFresh = (tokenExpiry: number | null, now: number) => const mapAccountServiceError = (message = "Account service operation failed") => - (effect: Effect.Effect): Effect.Effect => + (effect: Effect.Effect): Effect.Effect => effect.pipe( Effect.mapError((cause) => - cause instanceof AccountServiceError ? cause : new AccountServiceError({ message, cause }), + cause instanceof AccountServiceError || cause instanceof AccountTransportError + ? cause + : HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError" + ? new AccountTransportError({ + method: cause.request.method, + url: cause.request.url, + description: cause.reason.description, + cause: cause.reason.cause, + }) + : new AccountServiceError({ message, cause }), ), ) diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 830b203a9f8e..7b8f5a3da553 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -60,7 +60,14 @@ export class AccountServiceError extends Schema.TaggedErrorClass()("AccountTransportError", { + method: Schema.String, + url: Schema.String, + description: Schema.optional(Schema.String), + cause: Schema.optional(Schema.Defect), +}) {} + +export type AccountError = AccountRepoError | AccountServiceError | AccountTransportError export class Login extends Schema.Class("Login")({ code: DeviceCode, diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index 9d840bf9a881..d1a157f510df 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -1,5 +1,4 @@ -import { HttpClientError } from "effect/unstable/http" -import { AccountServiceError } from "@/account" +import { AccountServiceError, AccountTransportError } from "@/account" import { ConfigMarkdown } from "@/config/markdown" import { errorFormat } from "@/util/error" import { Config } from "../config/config" @@ -7,31 +6,17 @@ import { MCP } from "../mcp" import { Provider } from "../provider/provider" import { UI } from "./ui" -const findTransportError = (input: unknown): HttpClientError.TransportError | undefined => { - let current = input - const seen = new Set() - - while (typeof current === "object" && current !== null && !seen.has(current)) { - if (current instanceof HttpClientError.TransportError) return current - seen.add(current) - current = Reflect.get(current, "cause") - } -} - export function FormatError(input: unknown) { if (MCP.Failed.isInstance(input)) return `MCP server "${input.data.name}" failed. Note, opencode does not support MCP authentication yet.` + if (input instanceof AccountTransportError) { + return [ + `Could not reach ${input.method} ${input.url}.`, + `This failed before the server returned an HTTP response.`, + `Check your network, proxy, or VPN configuration and try again.`, + ].join("\n") + } if (input instanceof AccountServiceError) { - const transportError = findTransportError(input) - if (transportError) { - return [ - `Could not reach ${transportError.methodAndUrl}.`, - `This failed before the server returned an HTTP response.`, - `Check your network, proxy, or VPN configuration and try again.`, - ] - .join("\n") - } - return input.message } if (Provider.ModelNotFoundError.isInstance(input)) { diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts index c4d90d92947f..265fdbbe1ac5 100644 --- a/packages/opencode/test/account/service.test.ts +++ b/packages/opencode/test/account/service.test.ts @@ -1,10 +1,20 @@ import { expect } from "bun:test" 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 { 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 { testEffect } from "../lib/effect" @@ -86,6 +96,28 @@ it.live("login normalizes trailing slashes in the provided server URL", () => }), ) +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", () => Effect.gen(function* () { yield* AccountRepo.use((r) => diff --git a/packages/opencode/test/cli/error.test.ts b/packages/opencode/test/cli/error.test.ts index 1e10537174c0..6af2633ce622 100644 --- a/packages/opencode/test/cli/error.test.ts +++ b/packages/opencode/test/cli/error.test.ts @@ -1,16 +1,12 @@ import { describe, expect, test } from "bun:test" -import { HttpClientError, HttpClientRequest } from "effect/unstable/http" - -import { AccountServiceError } from "../../src/account/schema" +import { AccountTransportError } from "../../src/account/schema" import { FormatError } from "../../src/cli/error" describe("cli.error", () => { test("formats account transport errors clearly", () => { - const error = new AccountServiceError({ - message: "HTTP request failed", - cause: new HttpClientError.TransportError({ - request: HttpClientRequest.post("https://console.opencode.ai/auth/device/code"), - }), + const error = new AccountTransportError({ + method: "POST", + url: "https://console.opencode.ai/auth/device/code", }) const formatted = FormatError(error) From 135cf78170d54d7b424c069bc993eb5fe32525cd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Apr 2026 13:56:11 -0400 Subject: [PATCH 4/6] refactor(opencode): isolate account http error mapping --- packages/opencode/src/account/index.ts | 44 +++++++++++++++++++------- 1 file changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index a9fe0e11eef5..d74bf30fcd41 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -137,20 +137,40 @@ const mapAccountServiceError = (message = "Account service operation failed") => (effect: Effect.Effect): Effect.Effect => effect.pipe( - Effect.mapError((cause) => - cause instanceof AccountServiceError || cause instanceof AccountTransportError - ? cause - : HttpClientError.isHttpClientError(cause) && cause.reason._tag === "TransportError" - ? new AccountTransportError({ - method: cause.request.method, - url: cause.request.url, - description: cause.reason.description, - cause: cause.reason.cause, - }) - : new AccountServiceError({ message, cause }), - ), + Effect.mapError((cause) => accountErrorFromCause(cause, message)), ) +const accountErrorFromCause = (cause: unknown, message: string): AccountError => { + if (cause instanceof AccountServiceError || cause instanceof AccountTransportError) { + return cause + } + + if (HttpClientError.isHttpClientError(cause)) { + return accountErrorFromHttpClientError(cause, message) + } + + return new AccountServiceError({ message, cause }) +} + +const accountErrorFromHttpClientError = ( + error: HttpClientError.HttpClientError, + message: string, +): AccountError => { + switch (error.reason._tag) { + case "TransportError": { + return new AccountTransportError({ + method: error.request.method, + url: error.request.url, + description: error.reason.description, + cause: error.reason.cause, + }) + } + default: { + return new AccountServiceError({ message, cause: error }) + } + } +} + export namespace Account { export interface Interface { readonly active: () => Effect.Effect, AccountError> From 6daa6841f664cb0bab1f81dbd7f8dc17713b7504 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 7 Apr 2026 14:05:48 -0400 Subject: [PATCH 5/6] refactor(opencode): move transport mapping into error type --- packages/opencode/src/account/index.ts | 28 +++++++------------------ packages/opencode/src/account/schema.ts | 12 ++++++++++- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/account/index.ts b/packages/opencode/src/account/index.ts index d74bf30fcd41..bc2c7a799994 100644 --- a/packages/opencode/src/account/index.ts +++ b/packages/opencode/src/account/index.ts @@ -146,31 +146,19 @@ const accountErrorFromCause = (cause: unknown, message: string): AccountError => } if (HttpClientError.isHttpClientError(cause)) { - return accountErrorFromHttpClientError(cause, message) + switch (cause.reason._tag) { + case "TransportError": { + return AccountTransportError.fromHttpClientError(cause.reason) + } + default: { + return new AccountServiceError({ message, cause }) + } + } } return new AccountServiceError({ message, cause }) } -const accountErrorFromHttpClientError = ( - error: HttpClientError.HttpClientError, - message: string, -): AccountError => { - switch (error.reason._tag) { - case "TransportError": { - return new AccountTransportError({ - method: error.request.method, - url: error.request.url, - description: error.reason.description, - cause: error.reason.cause, - }) - } - default: { - return new AccountServiceError({ message, cause: error }) - } - } -} - export namespace Account { export interface Interface { readonly active: () => Effect.Effect, AccountError> diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 7b8f5a3da553..2a49b49d815d 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -1,4 +1,5 @@ import { Schema } from "effect" +import type * as HttpClientError from "effect/unstable/http/HttpClientError" import { withStatics } from "@/util/schema" @@ -65,7 +66,16 @@ export class AccountTransportError extends Schema.TaggedErrorClass Date: Tue, 7 Apr 2026 14:16:17 -0400 Subject: [PATCH 6/6] refactor(opencode): let account transport errors format themselves --- packages/opencode/src/account/schema.ts | 11 +++++++++++ packages/opencode/src/cli/error.ts | 9 +-------- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/account/schema.ts b/packages/opencode/src/account/schema.ts index 2a49b49d815d..f8b3c2cf93cf 100644 --- a/packages/opencode/src/account/schema.ts +++ b/packages/opencode/src/account/schema.ts @@ -75,6 +75,17 @@ export class AccountTransportError extends Schema.TaggedErrorClass