@@ -19,6 +19,7 @@ import type { ConsoleState } from "./console-state"
1919import { AppFileSystem } from "@opencode-ai/core/filesystem"
2020import { InstanceState } from "@/effect/instance-state"
2121import { Context , Duration , Effect , Exit , Fiber , Layer , Option , Schema } from "effect"
22+ import { FetchHttpClient , HttpClient , HttpClientRequest , HttpClientResponse } from "effect/unstable/http"
2223import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
2324import { containsPath , type InstanceContext } from "../project/instance-context"
2425import { NonNegativeInt , PositiveInt , type DeepMutable } from "@opencode-ai/core/schema"
@@ -41,6 +42,7 @@ import { ConfigServer } from "./server"
4142import { ConfigSkills } from "./skills"
4243import { ConfigVariable } from "./variable"
4344import { Npm } from "@opencode-ai/core/npm"
45+ import { withTransientReadRetry } from "@/util/effect-http-client"
4446
4547const 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+
103117async 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>> & {
303317type 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
840877export * as Config from "./config"
0 commit comments