fix: recover from expired enterprise auth on remote config load (#31661)
This commit is contained in:
parent
3ad6923c61
commit
02608a4e97
5 changed files with 67 additions and 7 deletions
|
|
@ -32,3 +32,8 @@ export const DirectoryTypoError = NamedError.create("ConfigDirectoryTypoError",
|
||||||
dir: Schema.String,
|
dir: Schema.String,
|
||||||
suggestion: Schema.String,
|
suggestion: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const RemoteAuthError = NamedError.create("ConfigRemoteAuthError", {
|
||||||
|
url: Schema.String,
|
||||||
|
remote: Schema.String,
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import type { Argv } from "yargs"
|
||||||
import { Auth } from "../../auth"
|
import { Auth } from "../../auth"
|
||||||
import { cmd } from "./cmd"
|
import { cmd } from "./cmd"
|
||||||
import { CliError, effectCmd, fail } from "../effect-cmd"
|
import { CliError, effectCmd, fail } from "../effect-cmd"
|
||||||
|
|
@ -298,7 +299,9 @@ export const ProvidersListCommand = effectCmd({
|
||||||
export const ProvidersLoginCommand = effectCmd({
|
export const ProvidersLoginCommand = effectCmd({
|
||||||
command: "login [url]",
|
command: "login [url]",
|
||||||
describe: "log in to a provider",
|
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
|
yargs
|
||||||
.positional("url", {
|
.positional("url", {
|
||||||
describe: "opencode auth provider",
|
describe: "opencode auth provider",
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,18 @@ export function FormatError(input: unknown): string | undefined {
|
||||||
return stringField(configFrontmatter, "message") ?? ""
|
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[] }> }
|
// ConfigInvalidError: { path?: string, message?: string, issues?: Array<{ message: string, path: string[] }> }
|
||||||
const configInvalid = configData(input, "ConfigInvalidError")
|
const configInvalid = configData(input, "ConfigInvalidError")
|
||||||
if (configInvalid) {
|
if (configInvalid) {
|
||||||
|
|
|
||||||
|
|
@ -19,10 +19,11 @@ import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state"
|
||||||
import { FSUtil } from "@opencode-ai/core/fs-util"
|
import { FSUtil } from "@opencode-ai/core/fs-util"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
|
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 { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||||
import { containsPath, type InstanceContext } from "../project/instance-context"
|
import { containsPath, type InstanceContext } from "../project/instance-context"
|
||||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
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 { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission"
|
||||||
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
|
import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin"
|
||||||
import { ConfigAgent } from "./agent"
|
import { ConfigAgent } from "./agent"
|
||||||
|
|
@ -187,6 +188,7 @@ export const layer = Layer.effect(
|
||||||
url: string,
|
url: string,
|
||||||
headers: Record<string, string> | undefined,
|
headers: Record<string, string> | undefined,
|
||||||
schema: S,
|
schema: S,
|
||||||
|
loginOrigin: string,
|
||||||
) {
|
) {
|
||||||
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
|
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
|
||||||
.execute(
|
.execute(
|
||||||
|
|
@ -195,7 +197,15 @@ export const layer = Layer.effect(
|
||||||
.pipe(
|
.pipe(
|
||||||
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
|
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*<!doctype|^\s*<html/i.test(body)) {
|
||||||
|
return yield* Effect.die(new RemoteAuthError({ url: loginOrigin, remote: url }))
|
||||||
|
}
|
||||||
|
return yield* Schema.decodeEffect(Schema.fromJsonString(schema))(body).pipe(
|
||||||
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
|
Effect.catch((error) => 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
|
authEnv[value.key] = value.token
|
||||||
const wellknownURL = `${url}/.well-known/opencode`
|
const wellknownURL = `${url}/.well-known/opencode`
|
||||||
yield* Effect.logDebug("fetching remote config", { url: wellknownURL })
|
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(() =>
|
const remote = yield* Effect.promise(() =>
|
||||||
substituteWellKnownRemoteConfig({
|
substituteWellKnownRemoteConfig({
|
||||||
value: wellknown.remote_config,
|
value: wellknown.remote_config,
|
||||||
|
|
@ -360,7 +370,7 @@ export const layer = Layer.effect(
|
||||||
const fetchedConfig = remote
|
const fetchedConfig = remote
|
||||||
? yield* Effect.gen(function* () {
|
? yield* Effect.gen(function* () {
|
||||||
yield* Effect.logDebug("fetching remote config", { url: remote.url })
|
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) && isRecord(data.config)) return data.config
|
||||||
if (isRecord(data)) return data
|
if (isRecord(data)) return data
|
||||||
return yield* Effect.die(
|
return yield* Effect.die(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
|
import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test"
|
||||||
import { ConfigV1 } from "@opencode-ai/core/v1/config/config"
|
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 { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
|
|
@ -71,6 +72,7 @@ const wellKnownAuth = (url: string) =>
|
||||||
function remoteConfigClient(input: {
|
function remoteConfigClient(input: {
|
||||||
wellKnown: unknown
|
wellKnown: unknown
|
||||||
remote?: unknown
|
remote?: unknown
|
||||||
|
remoteHtml?: string
|
||||||
seen: { wellKnown?: string; remote?: string; authorization?: string }
|
seen: { wellKnown?: string; remote?: string; authorization?: string }
|
||||||
}) {
|
}) {
|
||||||
return HttpClient.make((request) => {
|
return HttpClient.make((request) => {
|
||||||
|
|
@ -78,9 +80,17 @@ function remoteConfigClient(input: {
|
||||||
input.seen.wellKnown = request.url
|
input.seen.wellKnown = request.url
|
||||||
return Effect.succeed(json(request, input.wellKnown))
|
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.remote = request.url
|
||||||
input.seen.authorization = request.headers.authorization
|
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, input.remote))
|
||||||
}
|
}
|
||||||
return Effect.succeed(json(request, {}, 404))
|
return Effect.succeed(json(request, {}, 404))
|
||||||
|
|
@ -214,6 +224,7 @@ const wellKnown = (input: {
|
||||||
config?: unknown
|
config?: unknown
|
||||||
remoteConfig?: { url: string; headers?: Record<string, string> }
|
remoteConfig?: { url: string; headers?: Record<string, string> }
|
||||||
remote?: unknown
|
remote?: unknown
|
||||||
|
remoteHtml?: string
|
||||||
wellKnown?: unknown
|
wellKnown?: unknown
|
||||||
}) => {
|
}) => {
|
||||||
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
|
const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
|
||||||
|
|
@ -224,6 +235,7 @@ const wellKnown = (input: {
|
||||||
...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}),
|
...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}),
|
||||||
},
|
},
|
||||||
remote: input.remote,
|
remote: input.remote,
|
||||||
|
remoteHtml: input.remoteHtml,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
seen,
|
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: "<!DOCTYPE html><html><head><title>Sign in</title></head><body>Login required</body></html>",
|
||||||
|
})
|
||||||
|
|
||||||
|
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", () => {
|
describe("resolvePluginSpec", () => {
|
||||||
test("keeps package specs unchanged", async () => {
|
test("keeps package specs unchanged", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue