Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 11 additions & 11 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 33 additions & 4 deletions packages/core/src/wellknown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ export interface Interface {
readonly refresh: () => Effect.Effect<boolean, Error>
readonly add: (origin: string) => Effect.Effect<Entry, Error>
readonly remove: (origin: string) => Effect.Effect<void>
/** Resolves and caches the configuration for a registered source. */
readonly resolve: (entry: Entry, variables: Readonly<Record<string, string>>) => Effect.Effect<Config[], Error>
/** Returns the last configuration resolved for an origin, kept until the source is removed. */
readonly cached: (origin: string) => Effect.Effect<Config[]>
}

export class Service extends Context.Service<Service, Interface>()("@opencode/WellKnown") {}
Expand Down Expand Up @@ -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,
Expand All @@ -107,7 +113,22 @@ const layer = Layer.effect(
const cache = yield* Ref.make(new Map<string, Entry>())
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 }
})

Expand Down Expand Up @@ -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)
Expand All @@ -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] : []
}),
})
}),
)
Expand Down
144 changes: 143 additions & 1 deletion packages/core/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -512,6 +513,7 @@ describe("Config", () => {
available
? Effect.succeed([{ shell: variables.TOKEN }])
: Effect.fail(new Error("expired credential")),
cached: () => Effect.succeed([]),
}),
),
deps: [],
Expand Down Expand Up @@ -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<unknown, Error>) {
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<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
Expand Down Expand Up @@ -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: [],
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/fixture/config-nodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
Loading