Skip to content

Commit b99787e

Browse files
authored
refactor(opencode): fetch remote config with http client (anomalyco#28661)
1 parent 562d299 commit b99787e

6 files changed

Lines changed: 360 additions & 251 deletions

File tree

packages/opencode/src/config/config.ts

Lines changed: 76 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import type { ConsoleState } from "./console-state"
1919
import { AppFileSystem } from "@opencode-ai/core/filesystem"
2020
import { InstanceState } from "@/effect/instance-state"
2121
import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect"
22+
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
2223
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
2324
import { containsPath, type InstanceContext } from "../project/instance-context"
2425
import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
@@ -41,6 +42,7 @@ import { ConfigServer } from "./server"
4142
import { ConfigSkills } from "./skills"
4243
import { ConfigVariable } from "./variable"
4344
import { Npm } from "@opencode-ai/core/npm"
45+
import { withTransientReadRetry } from "@/util/effect-http-client"
4446

4547
const log = Log.create({ service: "config" })
4648

@@ -70,14 +72,20 @@ function normalizeLoadedConfig(data: unknown, source: string) {
7072
return copy
7173
}
7274

73-
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
74-
if (!isRecord(input.value) || typeof input.value.url !== "string") return
75+
async function substituteWellKnownRemoteConfig(input: {
76+
value: unknown
77+
dir: string
78+
source: string
79+
env: Record<string, string>
80+
}) {
81+
if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
7582

7683
const url = await ConfigVariable.substitute({
7784
text: input.value.url,
7885
type: "virtual",
7986
dir: input.dir,
8087
source: input.source,
88+
env: input.env,
8189
})
8290
const headers = isRecord(input.value.headers)
8391
? Object.fromEntries(
@@ -91,6 +99,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
9199
type: "virtual",
92100
dir: input.dir,
93101
source: input.source,
102+
env: input.env,
94103
}),
95104
]),
96105
),
@@ -100,6 +109,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
100109
return { url, headers }
101110
}
102111

112+
const WellKnownConfig = Schema.Struct({
113+
config: Schema.optional(Schema.Json),
114+
remote_config: Schema.optional(Schema.Json),
115+
})
116+
103117
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
104118
if (!config.plugin) return config
105119
for (let i = 0; i < config.plugin.length; i++) {
@@ -303,7 +317,7 @@ export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
303317
type State = {
304318
config: Info
305319
directories: string[]
306-
deps: Fiber.Fiber<void, never>[]
320+
deps: Fiber.Fiber<void>[]
307321
consoleState: ConsoleState
308322
}
309323

@@ -372,17 +386,38 @@ export const layer = Layer.effect(
372386
const accountSvc = yield* Account.Service
373387
const env = yield* Env.Service
374388
const npmSvc = yield* Npm.Service
389+
const http = yield* HttpClient.HttpClient
375390

376391
const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
377392

393+
const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
394+
url: string,
395+
headers: Record<string, string> | undefined,
396+
schema: S,
397+
) {
398+
const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
399+
.execute(
400+
HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
401+
)
402+
.pipe(
403+
Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
404+
)
405+
return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
406+
Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
407+
)
408+
})
409+
378410
const loadConfig = Effect.fnUntraced(function* (
379411
text: string,
380412
options: { path: string } | { dir: string; source: string },
413+
env?: Record<string, string>,
381414
) {
382415
const source = "path" in options ? options.path : options.source
383416
const expanded = yield* Effect.promise(() =>
384417
ConfigVariable.substitute(
385-
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
418+
"path" in options
419+
? { text, type: "path", path: options.path, env }
420+
: { text, type: "virtual", ...options, env },
386421
),
387422
)
388423
const parsed = ConfigParse.jsonc(expanded, source)
@@ -398,14 +433,14 @@ export const layer = Layer.effect(
398433
return data
399434
})
400435

401-
const loadFile = Effect.fnUntraced(function* (filepath: string) {
436+
const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
402437
log.info("loading", { path: filepath })
403438
const text = yield* readConfigFile(filepath)
404439
if (!text) return {} as Info
405-
return yield* loadConfig(text, { path: filepath })
440+
return yield* loadConfig(text, { path: filepath }, env)
406441
})
407442

408-
const loadGlobal = Effect.fnUntraced(function* () {
443+
const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
409444
let result: Info = {}
410445
// Seed the default global config with the schema for editor completion, but avoid writing when the user
411446
// explicitly routes config through env-provided paths or content.
@@ -417,9 +452,9 @@ export const layer = Layer.effect(
417452
.pipe(Effect.catch(() => Effect.void))
418453
}
419454
}
420-
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
421-
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
422-
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
455+
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
456+
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
457+
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
423458

424459
const legacy = path.join(Global.Path.config, "config")
425460
if (existsSync(legacy)) {
@@ -477,6 +512,7 @@ export const layer = Layer.effect(
477512
const auth = yield* authSvc.all().pipe(Effect.orDie)
478513

479514
let result: Info = {}
515+
const authEnv: Record<string, string> = {}
480516
const consoleManagedProviders = new Set<string>()
481517
let activeOrgName: string | undefined
482518

@@ -516,56 +552,56 @@ export const layer = Layer.effect(
516552
for (const [key, value] of Object.entries(auth)) {
517553
if (value.type === "wellknown") {
518554
const url = key.replace(/\/+$/, "")
519-
process.env[value.key] = value.token
520-
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
521-
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
522-
if (!response.ok) {
523-
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
524-
}
525-
const wellknown = (yield* Effect.promise(() => response.json())) as {
526-
config?: Record<string, unknown>
527-
remote_config?: unknown
528-
}
555+
authEnv[value.key] = value.token
556+
const wellknownURL = `${url}/.well-known/opencode`
557+
log.debug("fetching remote config", { url: wellknownURL })
558+
const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
529559
const remote = yield* Effect.promise(() =>
530560
substituteWellKnownRemoteConfig({
531561
value: wellknown.remote_config,
532562
dir: url,
533-
source: `${url}/.well-known/opencode`,
563+
source: wellknownURL,
564+
env: authEnv,
534565
}),
535566
)
536567
const fetchedConfig = remote
537-
? ((yield* Effect.promise(async () => {
568+
? yield* Effect.gen(function* () {
538569
log.debug("fetching remote config", { url: remote.url })
539-
const response = await fetch(remote.url, { headers: remote.headers })
540-
if (!response.ok)
541-
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
542-
const data = await response.json()
543-
return isRecord(data) && isRecord(data.config) ? data.config : data
544-
})) as Record<string, unknown>)
570+
const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
571+
if (isRecord(data) && isRecord(data.config)) return data.config
572+
if (isRecord(data)) return data
573+
return yield* Effect.die(
574+
new Error(`failed to decode remote config from ${remote.url}: expected object`),
575+
)
576+
})
545577
: {}
546-
const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
578+
const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
547579
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
548-
const source = `${url}/.well-known/opencode`
549-
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
550-
dir: path.dirname(source),
551-
source,
552-
})
580+
const source = wellknownURL
581+
const next = yield* loadConfig(
582+
JSON.stringify(remoteConfig),
583+
{
584+
dir: path.dirname(source),
585+
source,
586+
},
587+
authEnv,
588+
)
553589
yield* merge(source, next, "global")
554590
log.debug("loaded remote config from well-known", { url })
555591
}
556592
}
557593

558-
const global = yield* getGlobal()
594+
const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
559595
yield* merge(Global.Path.config, global, "global")
560596

561597
if (Flag.OPENCODE_CONFIG) {
562-
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
598+
yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
563599
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
564600
}
565601

566602
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
567603
for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
568-
yield* merge(file, yield* loadFile(file), "local")
604+
yield* merge(file, yield* loadFile(file, authEnv), "local")
569605
}
570606
}
571607

@@ -579,14 +615,14 @@ export const layer = Layer.effect(
579615
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
580616
}
581617

582-
const deps: Fiber.Fiber<void, never>[] = []
618+
const deps: Fiber.Fiber<void>[] = []
583619

584620
for (const dir of directories) {
585621
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
586622
for (const file of ["opencode.json", "opencode.jsonc"]) {
587623
const source = path.join(dir, file)
588624
log.debug(`loading config from ${source}`)
589-
yield* merge(source, yield* loadFile(source))
625+
yield* merge(source, yield* loadFile(source, authEnv))
590626
result.agent ??= {}
591627
result.mode ??= {}
592628
result.plugin ??= []
@@ -835,6 +871,7 @@ export const defaultLayer = layer.pipe(
835871
Layer.provide(Auth.defaultLayer),
836872
Layer.provide(Account.defaultLayer),
837873
Layer.provide(Npm.defaultLayer),
874+
Layer.provide(FetchHttpClient.layer),
838875
)
839876

840877
export * as Config from "./config"

packages/opencode/src/config/variable.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ type ParseSource =
1919
type SubstituteInput = ParseSource & {
2020
text: string
2121
missing?: "error" | "empty"
22+
env?: Record<string, string>
2223
}
2324

2425
function source(input: ParseSource) {
@@ -33,7 +34,7 @@ function dir(input: ParseSource) {
3334
export async function substitute(input: SubstituteInput) {
3435
const missing = input.missing ?? "error"
3536
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
36-
return process.env[varName] || ""
37+
return (input.env?.[varName] ?? process.env[varName]) || ""
3738
})
3839

3940
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
@@ -46,7 +47,7 @@ export async function substitute(input: SubstituteInput) {
4647

4748
for (const match of fileMatches) {
4849
const token = match[0]
49-
const index = match.index!
50+
const index = match.index
5051
out += text.slice(cursor, index)
5152

5253
const lineStart = text.lastIndexOf("\n", index - 1) + 1

packages/opencode/test/agent/plugin-agent-regression.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect } from "bun:test"
22
import { AppFileSystem } from "@opencode-ai/core/filesystem"
33
import { Effect, Layer } from "effect"
4+
import { FetchHttpClient } from "effect/unstable/http"
45
import path from "path"
56
import { pathToFileURL } from "url"
67
import { Agent } from "../../src/agent/agent"
@@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe(
2930
Layer.provide(AuthTest.empty),
3031
Layer.provide(AccountTest.empty),
3132
Layer.provide(NpmTest.noop),
33+
Layer.provide(FetchHttpClient.layer),
3234
)
3335
const pluginLayer = Plugin.layer.pipe(
3436
Layer.provide(Bus.layer),

0 commit comments

Comments
 (0)