diff --git a/packages/core/src/config.ts b/packages/core/src/config.ts index a56c1119f91b..fce18f08aecb 100644 --- a/packages/core/src/config.ts +++ b/packages/core/src/config.ts @@ -144,19 +144,19 @@ export const layer = (options?: Options) => const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) { const auth = entry.manifest.auth - if (!auth) return [] const credential = (yield* credentials.list(entry.integrationID)).at(-1) - if (!credential || credential.value.type !== "key") return [] - const variables = { [auth.env]: credential.value.key } - const configs = yield* wellknown - .resolve(entry, variables) - .pipe( - Effect.catch(() => - Effect.logWarning("failed to load wellknown config", { source: entry.origin }).pipe( - Effect.as([] as const), - ), + const variables = auth && credential?.value.type === "key" ? { [auth.env]: credential.value.key } : undefined + // A failed refresh, including a missing credential, keeps the last resolved config so remote providers + // and allowlists do not disappear; only removing the source clears it. + const configs = yield* ( + variables ? wellknown.resolve(entry, variables) : Effect.fail(new Error("No usable wellknown credential")) + ).pipe( + Effect.catch((error) => + Effect.logWarning("failed to load wellknown config", { source: entry.origin, error }).pipe( + Effect.andThen(wellknown.cached(entry.origin)), ), - ) + ), + ) return yield* Effect.forEach(configs, (config) => ConfigVariable.substitute({ type: "virtual", diff --git a/packages/core/src/wellknown.ts b/packages/core/src/wellknown.ts index 206e42071fb0..34b651296e62 100644 --- a/packages/core/src/wellknown.ts +++ b/packages/core/src/wellknown.ts @@ -48,7 +48,10 @@ export interface Interface { readonly refresh: () => Effect.Effect readonly add: (origin: string) => Effect.Effect readonly remove: (origin: string) => Effect.Effect + /** Resolves and caches the configuration for a registered source. */ readonly resolve: (entry: Entry, variables: Readonly>) => Effect.Effect + /** Returns the last configuration resolved for an origin, kept until the source is removed. */ + readonly cached: (origin: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/WellKnown") {} @@ -97,6 +100,9 @@ const resolveEntry = Effect.fnUntraced(function* (entry: Entry, variables: Reado const sourcesKey = "wellknown:sources" const Sources = Schema.Array(Schema.String) +const manifestKey = (origin: string) => `wellknown:manifest:${origin}` +const configKey = (origin: string) => `wellknown:config:${origin}` +const Configs = Schema.Array(Config) const layer = Layer.effect( Service, @@ -107,7 +113,22 @@ const layer = Layer.effect( const cache = yield* Ref.make(new Map()) const lock = Semaphore.makeUnsafe(1) const loadEntry = Effect.fn("WellKnown.loadEntry")(function* (origin: string) { - const manifest = yield* inspect(origin).pipe(Effect.provideService(HttpClient.HttpClient, http)) + const manifest = yield* inspect(origin).pipe( + Effect.provideService(HttpClient.HttpClient, http), + Effect.tap((manifest) => kv.set(manifestKey(origin), manifest)), + // An unreachable source keeps its last manifest so its remote config still loads after a restart. + Effect.catch((error) => + kv + .get(manifestKey(origin)) + .pipe( + Effect.flatMap((cached) => + Schema.is(Manifest)(cached) + ? Effect.logWarning("failed to load wellknown manifest", { origin, error }).pipe(Effect.as(cached)) + : Effect.fail(error), + ), + ), + ), + ) return { origin, integrationID: Integration.ID.make(origin), manifest } }) @@ -167,6 +188,8 @@ const layer = Layer.effect( sourcesKey, origins.filter((item) => item !== origin), ) + yield* kv.remove(manifestKey(origin)) + yield* kv.remove(configKey(origin)) yield* Ref.update(cache, (current) => { const next = new Map(current) next.delete(origin) @@ -176,9 +199,15 @@ const layer = Layer.effect( }, (effect, _value) => lock.withPermit(effect), ), - resolve: Effect.fn("WellKnown.resolveEntry")((entry, variables) => - resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)), - ), + resolve: Effect.fn("WellKnown.resolveEntry")(function* (entry, variables) { + const configs = yield* resolveEntry(entry, variables).pipe(Effect.provideService(HttpClient.HttpClient, http)) + yield* kv.set(configKey(entry.origin), configs) + return configs + }), + cached: Effect.fn("WellKnown.cached")(function* (origin) { + const value = yield* kv.get(configKey(origin)) + return Schema.is(Configs)(value) ? [...value] : [] + }), }) }), ) diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index 2acdb5970b56..64baf92cc7a6 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -4,7 +4,7 @@ import { describe, expect, test } from "bun:test" import { Effect, Fiber, Layer, Logger, Schema, Stream } from "effect" import { FastCheck } from "effect/testing" import { Config } from "@opencode/core/config" -import { Directory, Document, Event, Info } from "@opencode/schema/config" +import { Directory, Document, type Entry, Event, Info } from "@opencode/schema/config" import { ConfigModel } from "@opencode/schema/config/model" import { ConfigProvider } from "@opencode/schema/config/provider" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" @@ -21,6 +21,7 @@ import { Project } from "@opencode/core/project" import { Provider } from "@opencode/core/provider" import { AbsolutePath } from "@opencode/core/schema" import { WellKnown } from "@opencode/core/wellknown" +import { KV } from "@opencode/core/kv" import { Integration } from "@opencode/schema/integration" import { emptyCredentialNode, emptyWellknownNode } from "../fixture/config-nodes" import { location } from "../fixture/location" @@ -512,6 +513,7 @@ describe("Config", () => { available ? Effect.succeed([{ shell: variables.TOKEN }]) : Effect.fail(new Error("expired credential")), + cached: () => Effect.succeed([]), }), ), deps: [], @@ -550,6 +552,145 @@ describe("Config", () => { ), ) + it.live("keeps the last resolved wellknown config across failures and restarts until its source is removed", () => + Effect.acquireUseRelease( + Effect.promise(() => tmpdir()), + (tmp) => { + let manifestStatus = 200 + let configStatus = 200 + let requests = 0 + let key: string | undefined = "secret" + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url) + if (url.pathname === "/.well-known/opencode") { + if (manifestStatus !== 200) return new Response("Unavailable", { status: manifestStatus }) + return Response.json({ + auth: { command: ["login"], env: "TOKEN" }, + remote_config: { url: `${url.origin}/config`, headers: { authorization: "Bearer {env:TOKEN}" } }, + }) + } + requests++ + if (configStatus !== 200) return new Response("Unavailable", { status: configStatus }) + return Response.json({ + config: { + enabled_providers: ["remote"], + provider: { remote: { npm: "@ai-sdk/openai-compatible", models: { chat: {} } } }, + }, + }) + }, + }) + const origin = server.url.origin + const integrationID = Integration.ID.make(origin) + const credentialNode = makeGlobalNode({ + service: Credential.Service, + layer: Layer.mock(Credential.Service)({ + list: () => + Effect.succeed( + key === undefined + ? [] + : [ + new Credential.Info({ + id: Credential.ID.create(), + integrationID, + label: "default", + value: Credential.Key.make({ type: "key", key }), + }), + ], + ), + }), + deps: [], + }) + const global = path.join(tmp.path, "global") + const project = path.join(tmp.path, "project") + const remote = (entries: readonly Entry[]) => ({ + provider: Config.latest(entries, "providers")?.remote?.package, + policies: Config.latest(entries, "experimental")?.policies, + }) + const loaded = { + provider: "aisdk:@ai-sdk/openai-compatible", + policies: [ + { action: "provider.use", resource: "*", effect: "deny" }, + { action: "provider.use", resource: "remote", effect: "allow" }, + ], + } as const + const removed = { provider: undefined, policies: undefined } + return Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(project, { recursive: true })) + const kv = Layer.succeedContext(yield* Layer.build(LayerNode.compile(KV.node))) + // Each build is a fresh process sharing one persisted KV store. + const instance = () => + AppNodeBuilder.build(LayerNode.group([Config.node, Bus.node, WellKnown.node]), [ + Location.node.replace( + Layer.succeed( + Location.Service, + Location.Service.of(location({ directory: AbsolutePath.make(project) })), + ), + ), + Global.node.replace(Global.layerWith({ config: global, home: path.join(global, "home") })), + Credential.node.replace(credentialNode), + KV.node.replace(kv), + Watcher.node.replace(Watcher.testLayer), + ]) + const services = Effect.gen(function* () { + const config = yield* Config.Service + const bus = yield* Bus.Service + // Each reload also rewrites a local file so Config publishes an update to wait on. + const reload = Effect.fnUntraced(function* (shell: string, trigger: Effect.Effect) { + const updated = yield* bus + .subscribe(Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) + yield* Effect.promise(() => fs.writeFile(path.join(project, "opencode.json"), JSON.stringify({ shell }))) + yield* trigger + yield* Fiber.join(updated) + return remote(yield* config.entries()) + }) + const switched = bus.publish( + Credential.Event.Switched, + { credentialID: Credential.ID.create(), integrationID }, + { global: true }, + ) + return { config, bus, wellknown: yield* WellKnown.Service, reload, switched } + }) + + yield* Effect.gen(function* () { + const warm = yield* services + expect(yield* warm.reload("initial", warm.wellknown.add(origin))).toEqual(loaded) + + configStatus = 503 + const before = requests + expect(yield* warm.reload("unavailable", warm.switched)).toEqual(loaded) + expect(requests).toBeGreaterThan(before) + + key = undefined + expect(yield* warm.reload("logged-out", warm.switched)).toEqual(loaded) + + manifestStatus = 503 + const updated = yield* warm.bus + .subscribe(WellKnown.Event.Updated) + .pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped({ startImmediately: true })) + expect(yield* warm.wellknown.refresh()).toBe(false) + yield* Effect.sleep("50 millis") + expect(updated.pollUnsafe()).toBeUndefined() + }).pipe(Effect.scoped, Effect.provide(instance())) + + key = "secret" + yield* Effect.gen(function* () { + const cold = yield* services + expect(remote(yield* cold.config.entries())).toEqual(loaded) + // The wellknown plugin loads these entries at startup and dies if they fail. + expect(yield* cold.wellknown.entries()).toHaveLength(1) + expect(yield* cold.reload("removed", cold.wellknown.remove(origin))).toEqual(removed) + manifestStatus = 200 + expect(yield* cold.reload("re-added", cold.wellknown.add(origin))).toEqual(removed) + }).pipe(Effect.scoped, Effect.provide(instance())) + }).pipe(Effect.ensuring(Effect.promise(() => server.stop(true)))) + }, + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ), + ) + it.live("logs redacted source-aware diagnostics for every config source", () => { const output: Array> = [] const logger = Logger.map(Logger.formatStructured, (entry) => { @@ -613,6 +754,7 @@ describe("Config", () => { remove: () => Effect.die("unused Wellknown.remove"), // Exercise the loader boundary against a malformed implementation response. resolve: () => Effect.succeed([null as unknown as WellKnown.Config]), + cached: () => Effect.succeed([]), }), ), deps: [], diff --git a/packages/core/test/fixture/config-nodes.ts b/packages/core/test/fixture/config-nodes.ts index 5f4cb935331c..267b2e8a41e9 100644 --- a/packages/core/test/fixture/config-nodes.ts +++ b/packages/core/test/fixture/config-nodes.ts @@ -31,6 +31,7 @@ export const emptyWellknownNode = makeGlobalNode({ add: () => Effect.die("unused Wellknown.add"), remove: () => Effect.die("unused Wellknown.remove"), resolve: () => Effect.die("unused Wellknown.resolve"), + cached: () => Effect.succeed([]), }), ), deps: [],