From 02608a4e9791c256df7e32ae88ec72a7df16623e Mon Sep 17 00:00:00 2001 From: Ayush Thakur <51413362+Ayushlm10@users.noreply.github.com> Date: Wed, 10 Jun 2026 20:32:09 +0530 Subject: [PATCH] fix: recover from expired enterprise auth on remote config load (#31661) --- packages/core/src/v1/config/error.ts | 5 +++ packages/opencode/src/cli/cmd/providers.ts | 5 ++- packages/opencode/src/cli/error.ts | 12 +++++++ packages/opencode/src/config/config.ts | 18 ++++++++--- packages/opencode/test/config/config.test.ts | 34 ++++++++++++++++++-- 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/packages/core/src/v1/config/error.ts b/packages/core/src/v1/config/error.ts index 268a6eb20..abf3a0927 100644 --- a/packages/core/src/v1/config/error.ts +++ b/packages/core/src/v1/config/error.ts @@ -32,3 +32,8 @@ export const DirectoryTypoError = NamedError.create("ConfigDirectoryTypoError", dir: Schema.String, suggestion: Schema.String, }) + +export const RemoteAuthError = NamedError.create("ConfigRemoteAuthError", { + url: Schema.String, + remote: Schema.String, +}) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 620940b38..cec20a023 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,3 +1,4 @@ +import type { Argv } from "yargs" import { Auth } from "../../auth" import { cmd } from "./cmd" import { CliError, effectCmd, fail } from "../effect-cmd" @@ -298,7 +299,9 @@ export const ProvidersListCommand = effectCmd({ export const ProvidersLoginCommand = effectCmd({ command: "login [url]", describe: "log in to a provider", - builder: (yargs) => + // URL login skips instance bootstrap, which would load remote config with the stale token and crash before re-auth. + instance: (args) => !args.url, + builder: (yargs: Argv) => yargs .positional("url", { describe: "opencode auth provider", diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index ef724bbbe..407547e4e 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -94,6 +94,18 @@ export function FormatError(input: unknown): string | undefined { return stringField(configFrontmatter, "message") ?? "" } + // ConfigRemoteAuthError: { url: string, remote: string } + const remoteAuth = configData(input, "ConfigRemoteAuthError") + if (remoteAuth) { + const url = stringField(remoteAuth, "url") + const remote = stringField(remoteAuth, "remote") + return [ + `Failed to load remote config${remote ? ` from ${remote}` : ""}: the server returned a login page instead of JSON.`, + `Authentication is missing or has expired (the endpoint is likely behind an SSO or identity-aware proxy).`, + ...(url ? [`Run \`opencode auth login ${url}\` to re-authenticate.`] : []), + ].join("\n") + } + // ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> } const configInvalid = configData(input, "ConfigInvalidError") if (configInvalid) { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 480f08f81..7f568f492 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,10 +19,11 @@ import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" +import { RemoteAuthError } from "@opencode-ai/core/v1/config/error" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { ConfigAgent } from "./agent" @@ -187,6 +188,7 @@ export const layer = Layer.effect( url: string, headers: Record | undefined, schema: S, + loginOrigin: string, ) { const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http)) .execute( @@ -195,7 +197,15 @@ export const layer = Layer.effect( .pipe( Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))), ) - return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe( + const body = yield* response.text.pipe( + Effect.catch((error) => Effect.die(new Error(`failed to read remote config from ${url}: ${String(error)}`))), + ) + // An auth proxy can answer with an HTML login page at HTTP 200 (passes filterStatusOk); treat it as a re-auth error, not a decode failure. + const contentType = (response.headers["content-type"] ?? "").toLowerCase() + if (contentType.includes("html") || /^\s* Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), ) }) @@ -348,7 +358,7 @@ export const layer = Layer.effect( authEnv[value.key] = value.token const wellknownURL = `${url}/.well-known/opencode` yield* Effect.logDebug("fetching remote config", { url: wellknownURL }) - const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown) + const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url) const remote = yield* Effect.promise(() => substituteWellKnownRemoteConfig({ value: wellknown.remote_config, @@ -360,7 +370,7 @@ export const layer = Layer.effect( const fetchedConfig = remote ? yield* Effect.gen(function* () { yield* Effect.logDebug("fetching remote config", { url: remote.url }) - const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json) + const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url) if (isRecord(data) && isRecord(data.config)) return data.config if (isRecord(data)) return data return yield* Effect.die( diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index f4a0cd4a2..02ace5366 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,6 +1,7 @@ import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { Effect, Exit, Layer, Option } from "effect" +import { Cause, Effect, Exit, Layer, Option } from "effect" +import { NamedError } from "@opencode-ai/core/util/error" import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" @@ -71,6 +72,7 @@ const wellKnownAuth = (url: string) => function remoteConfigClient(input: { wellKnown: unknown remote?: unknown + remoteHtml?: string seen: { wellKnown?: string; remote?: string; authorization?: string } }) { return HttpClient.make((request) => { @@ -78,9 +80,17 @@ function remoteConfigClient(input: { input.seen.wellKnown = request.url return Effect.succeed(json(request, input.wellKnown)) } - if (input.remote !== undefined && request.url.includes("config.example.com")) { + if (request.url.includes("config.example.com") && (input.remote !== undefined || input.remoteHtml !== undefined)) { input.seen.remote = request.url input.seen.authorization = request.headers.authorization + if (input.remoteHtml !== undefined) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response(input.remoteHtml, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }), + ), + ) + } return Effect.succeed(json(request, input.remote)) } return Effect.succeed(json(request, {}, 404)) @@ -214,6 +224,7 @@ const wellKnown = (input: { config?: unknown remoteConfig?: { url: string; headers?: Record } remote?: unknown + remoteHtml?: string wellKnown?: unknown }) => { const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} @@ -224,6 +235,7 @@ const wellKnown = (input: { ...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}), }, remote: input.remote, + remoteHtml: input.remoteHtml, }) return { seen, @@ -1635,6 +1647,24 @@ invalidRemoteWellKnown.it.instance("wellknown remote_config rejects non-object c }), ) +const loginPageWellKnown = wellKnown({ + remoteConfig: { url: "https://config.example.com/opencode.json" }, + remoteHtml: "Sign inLogin required", +}) + +loginPageWellKnown.it.instance( + "wellknown remote_config surfaces an actionable auth error when the gateway returns an HTML login page", + () => + Effect.gen(function* () { + const exit = yield* Config.use.get().pipe(Effect.exit) + expect(loginPageWellKnown.seen.remote).toBe("https://config.example.com/opencode.json") + expect(Exit.isFailure(exit)).toBe(true) + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined + expect(NamedError.hasName(error, "ConfigRemoteAuthError")).toBe(true) + expect((error as { data?: { url?: string } }).data?.url).toBe("https://example.com") + }), +) + describe("resolvePluginSpec", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir()