Просмотр исходного кода

refactor(opencode): fetch remote config with http client (#28661)

Kit Langton 2 месяцев назад
Родитель
Сommit
b99787e95b

+ 76 - 39
packages/opencode/src/config/config.ts

@@ -19,6 +19,7 @@ import type { ConsoleState } from "./console-state"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 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 { 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 { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
 import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema"
@@ -41,6 +42,7 @@ import { ConfigServer } from "./server"
 import { ConfigSkills } from "./skills"
 import { ConfigSkills } from "./skills"
 import { ConfigVariable } from "./variable"
 import { ConfigVariable } from "./variable"
 import { Npm } from "@opencode-ai/core/npm"
 import { Npm } from "@opencode-ai/core/npm"
+import { withTransientReadRetry } from "@/util/effect-http-client"
 
 
 const log = Log.create({ service: "config" })
 const log = Log.create({ service: "config" })
 
 
@@ -70,14 +72,20 @@ function normalizeLoadedConfig(data: unknown, source: string) {
   return copy
   return copy
 }
 }
 
 
-async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
-  if (!isRecord(input.value) || typeof input.value.url !== "string") return
+async function substituteWellKnownRemoteConfig(input: {
+  value: unknown
+  dir: string
+  source: string
+  env: Record<string, string>
+}) {
+  if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined
 
 
   const url = await ConfigVariable.substitute({
   const url = await ConfigVariable.substitute({
     text: input.value.url,
     text: input.value.url,
     type: "virtual",
     type: "virtual",
     dir: input.dir,
     dir: input.dir,
     source: input.source,
     source: input.source,
+    env: input.env,
   })
   })
   const headers = isRecord(input.value.headers)
   const headers = isRecord(input.value.headers)
     ? Object.fromEntries(
     ? Object.fromEntries(
@@ -91,6 +99,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
                 type: "virtual",
                 type: "virtual",
                 dir: input.dir,
                 dir: input.dir,
                 source: input.source,
                 source: input.source,
+                env: input.env,
               }),
               }),
             ]),
             ]),
         ),
         ),
@@ -100,6 +109,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str
   return { url, headers }
   return { url, headers }
 }
 }
 
 
+const WellKnownConfig = Schema.Struct({
+  config: Schema.optional(Schema.Json),
+  remote_config: Schema.optional(Schema.Json),
+})
+
 async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
 async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
   if (!config.plugin) return config
   if (!config.plugin) return config
   for (let i = 0; i < config.plugin.length; i++) {
   for (let i = 0; i < config.plugin.length; i++) {
@@ -303,7 +317,7 @@ export type Info = DeepMutable<Schema.Schema.Type<typeof Info>> & {
 type State = {
 type State = {
   config: Info
   config: Info
   directories: string[]
   directories: string[]
-  deps: Fiber.Fiber<void, never>[]
+  deps: Fiber.Fiber<void>[]
   consoleState: ConsoleState
   consoleState: ConsoleState
 }
 }
 
 
@@ -372,17 +386,38 @@ export const layer = Layer.effect(
     const accountSvc = yield* Account.Service
     const accountSvc = yield* Account.Service
     const env = yield* Env.Service
     const env = yield* Env.Service
     const npmSvc = yield* Npm.Service
     const npmSvc = yield* Npm.Service
+    const http = yield* HttpClient.HttpClient
 
 
     const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
     const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie)
 
 
+    const fetchRemoteJson = Effect.fnUntraced(function* <S extends Schema.Top>(
+      url: string,
+      headers: Record<string, string> | undefined,
+      schema: S,
+    ) {
+      const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http))
+        .execute(
+          HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})),
+        )
+        .pipe(
+          Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))),
+        )
+      return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe(
+        Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))),
+      )
+    })
+
     const loadConfig = Effect.fnUntraced(function* (
     const loadConfig = Effect.fnUntraced(function* (
       text: string,
       text: string,
       options: { path: string } | { dir: string; source: string },
       options: { path: string } | { dir: string; source: string },
+      env?: Record<string, string>,
     ) {
     ) {
       const source = "path" in options ? options.path : options.source
       const source = "path" in options ? options.path : options.source
       const expanded = yield* Effect.promise(() =>
       const expanded = yield* Effect.promise(() =>
         ConfigVariable.substitute(
         ConfigVariable.substitute(
-          "path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
+          "path" in options
+            ? { text, type: "path", path: options.path, env }
+            : { text, type: "virtual", ...options, env },
         ),
         ),
       )
       )
       const parsed = ConfigParse.jsonc(expanded, source)
       const parsed = ConfigParse.jsonc(expanded, source)
@@ -398,14 +433,14 @@ export const layer = Layer.effect(
       return data
       return data
     })
     })
 
 
-    const loadFile = Effect.fnUntraced(function* (filepath: string) {
+    const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record<string, string>) {
       log.info("loading", { path: filepath })
       log.info("loading", { path: filepath })
       const text = yield* readConfigFile(filepath)
       const text = yield* readConfigFile(filepath)
       if (!text) return {} as Info
       if (!text) return {} as Info
-      return yield* loadConfig(text, { path: filepath })
+      return yield* loadConfig(text, { path: filepath }, env)
     })
     })
 
 
-    const loadGlobal = Effect.fnUntraced(function* () {
+    const loadGlobal = Effect.fnUntraced(function* (env?: Record<string, string>) {
       let result: Info = {}
       let result: Info = {}
       // Seed the default global config with the schema for editor completion, but avoid writing when the user
       // Seed the default global config with the schema for editor completion, but avoid writing when the user
       // explicitly routes config through env-provided paths or content.
       // explicitly routes config through env-provided paths or content.
@@ -417,9 +452,9 @@ export const layer = Layer.effect(
             .pipe(Effect.catch(() => Effect.void))
             .pipe(Effect.catch(() => Effect.void))
         }
         }
       }
       }
-      result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
-      result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json")))
-      result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc")))
+      result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json"), env))
+      result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.json"), env))
+      result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"), env))
 
 
       const legacy = path.join(Global.Path.config, "config")
       const legacy = path.join(Global.Path.config, "config")
       if (existsSync(legacy)) {
       if (existsSync(legacy)) {
@@ -477,6 +512,7 @@ export const layer = Layer.effect(
         const auth = yield* authSvc.all().pipe(Effect.orDie)
         const auth = yield* authSvc.all().pipe(Effect.orDie)
 
 
         let result: Info = {}
         let result: Info = {}
+        const authEnv: Record<string, string> = {}
         const consoleManagedProviders = new Set<string>()
         const consoleManagedProviders = new Set<string>()
         let activeOrgName: string | undefined
         let activeOrgName: string | undefined
 
 
@@ -516,56 +552,56 @@ export const layer = Layer.effect(
         for (const [key, value] of Object.entries(auth)) {
         for (const [key, value] of Object.entries(auth)) {
           if (value.type === "wellknown") {
           if (value.type === "wellknown") {
             const url = key.replace(/\/+$/, "")
             const url = key.replace(/\/+$/, "")
-            process.env[value.key] = value.token
-            log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
-            const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
-            if (!response.ok) {
-              throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
-            }
-            const wellknown = (yield* Effect.promise(() => response.json())) as {
-              config?: Record<string, unknown>
-              remote_config?: unknown
-            }
+            authEnv[value.key] = value.token
+            const wellknownURL = `${url}/.well-known/opencode`
+            log.debug("fetching remote config", { url: wellknownURL })
+            const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig)
             const remote = yield* Effect.promise(() =>
             const remote = yield* Effect.promise(() =>
               substituteWellKnownRemoteConfig({
               substituteWellKnownRemoteConfig({
                 value: wellknown.remote_config,
                 value: wellknown.remote_config,
                 dir: url,
                 dir: url,
-                source: `${url}/.well-known/opencode`,
+                source: wellknownURL,
+                env: authEnv,
               }),
               }),
             )
             )
             const fetchedConfig = remote
             const fetchedConfig = remote
-              ? ((yield* Effect.promise(async () => {
+              ? yield* Effect.gen(function* () {
                   log.debug("fetching remote config", { url: remote.url })
                   log.debug("fetching remote config", { url: remote.url })
-                  const response = await fetch(remote.url, { headers: remote.headers })
-                  if (!response.ok)
-                    throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
-                  const data = await response.json()
-                  return isRecord(data) && isRecord(data.config) ? data.config : data
-                })) as Record<string, unknown>)
+                  const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json)
+                  if (isRecord(data) && isRecord(data.config)) return data.config
+                  if (isRecord(data)) return data
+                  return yield* Effect.die(
+                    new Error(`failed to decode remote config from ${remote.url}: expected object`),
+                  )
+                })
               : {}
               : {}
-            const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
+            const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig)
             if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
             if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
-            const source = `${url}/.well-known/opencode`
-            const next = yield* loadConfig(JSON.stringify(remoteConfig), {
-              dir: path.dirname(source),
-              source,
-            })
+            const source = wellknownURL
+            const next = yield* loadConfig(
+              JSON.stringify(remoteConfig),
+              {
+                dir: path.dirname(source),
+                source,
+              },
+              authEnv,
+            )
             yield* merge(source, next, "global")
             yield* merge(source, next, "global")
             log.debug("loaded remote config from well-known", { url })
             log.debug("loaded remote config from well-known", { url })
           }
           }
         }
         }
 
 
-        const global = yield* getGlobal()
+        const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal()
         yield* merge(Global.Path.config, global, "global")
         yield* merge(Global.Path.config, global, "global")
 
 
         if (Flag.OPENCODE_CONFIG) {
         if (Flag.OPENCODE_CONFIG) {
-          yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
+          yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv))
           log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
           log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
         }
         }
 
 
         if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
         if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
           for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
           for (const file of yield* ConfigPaths.files("opencode", ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
-            yield* merge(file, yield* loadFile(file), "local")
+            yield* merge(file, yield* loadFile(file, authEnv), "local")
           }
           }
         }
         }
 
 
@@ -579,14 +615,14 @@ export const layer = Layer.effect(
           log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
           log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
         }
         }
 
 
-        const deps: Fiber.Fiber<void, never>[] = []
+        const deps: Fiber.Fiber<void>[] = []
 
 
         for (const dir of directories) {
         for (const dir of directories) {
           if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
           if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
             for (const file of ["opencode.json", "opencode.jsonc"]) {
             for (const file of ["opencode.json", "opencode.jsonc"]) {
               const source = path.join(dir, file)
               const source = path.join(dir, file)
               log.debug(`loading config from ${source}`)
               log.debug(`loading config from ${source}`)
-              yield* merge(source, yield* loadFile(source))
+              yield* merge(source, yield* loadFile(source, authEnv))
               result.agent ??= {}
               result.agent ??= {}
               result.mode ??= {}
               result.mode ??= {}
               result.plugin ??= []
               result.plugin ??= []
@@ -835,6 +871,7 @@ export const defaultLayer = layer.pipe(
   Layer.provide(Auth.defaultLayer),
   Layer.provide(Auth.defaultLayer),
   Layer.provide(Account.defaultLayer),
   Layer.provide(Account.defaultLayer),
   Layer.provide(Npm.defaultLayer),
   Layer.provide(Npm.defaultLayer),
+  Layer.provide(FetchHttpClient.layer),
 )
 )
 
 
 export * as Config from "./config"
 export * as Config from "./config"

+ 3 - 2
packages/opencode/src/config/variable.ts

@@ -19,6 +19,7 @@ type ParseSource =
 type SubstituteInput = ParseSource & {
 type SubstituteInput = ParseSource & {
   text: string
   text: string
   missing?: "error" | "empty"
   missing?: "error" | "empty"
+  env?: Record<string, string>
 }
 }
 
 
 function source(input: ParseSource) {
 function source(input: ParseSource) {
@@ -33,7 +34,7 @@ function dir(input: ParseSource) {
 export async function substitute(input: SubstituteInput) {
 export async function substitute(input: SubstituteInput) {
   const missing = input.missing ?? "error"
   const missing = input.missing ?? "error"
   let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
   let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
-    return process.env[varName] || ""
+    return (input.env?.[varName] ?? process.env[varName]) || ""
   })
   })
 
 
   const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
   const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
@@ -46,7 +47,7 @@ export async function substitute(input: SubstituteInput) {
 
 
   for (const match of fileMatches) {
   for (const match of fileMatches) {
     const token = match[0]
     const token = match[0]
-    const index = match.index!
+    const index = match.index
     out += text.slice(cursor, index)
     out += text.slice(cursor, index)
 
 
     const lineStart = text.lastIndexOf("\n", index - 1) + 1
     const lineStart = text.lastIndexOf("\n", index - 1) + 1

+ 2 - 0
packages/opencode/test/agent/plugin-agent-regression.test.ts

@@ -1,6 +1,7 @@
 import { expect } from "bun:test"
 import { expect } from "bun:test"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { Effect, Layer } from "effect"
 import { Effect, Layer } from "effect"
+import { FetchHttpClient } from "effect/unstable/http"
 import path from "path"
 import path from "path"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
 import { Agent } from "../../src/agent/agent"
 import { Agent } from "../../src/agent/agent"
@@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe(
   Layer.provide(AuthTest.empty),
   Layer.provide(AuthTest.empty),
   Layer.provide(AccountTest.empty),
   Layer.provide(AccountTest.empty),
   Layer.provide(NpmTest.noop),
   Layer.provide(NpmTest.noop),
+  Layer.provide(FetchHttpClient.layer),
 )
 )
 const pluginLayer = Plugin.layer.pipe(
 const pluginLayer = Plugin.layer.pipe(
   Layer.provide(Bus.layer),
   Layer.provide(Bus.layer),

+ 266 - 187
packages/opencode/test/config/config.test.ts

@@ -1,5 +1,6 @@
-import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test"
+import { test, expect, describe, afterEach, beforeEach } from "bun:test"
 import { Effect, Exit, Layer, Option } from "effect"
 import { Effect, Exit, Layer, Option } from "effect"
+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"
 import { ConfigManaged } from "@/config/managed"
 import { ConfigManaged } from "@/config/managed"
@@ -36,34 +37,71 @@ import { Global } from "@opencode-ai/core/global"
 import { ProjectID } from "../../src/project/schema"
 import { ProjectID } from "../../src/project/schema"
 import { Filesystem } from "@/util/filesystem"
 import { Filesystem } from "@/util/filesystem"
 import { ConfigPlugin } from "@/config/plugin"
 import { ConfigPlugin } from "@/config/plugin"
-import { Npm } from "@opencode-ai/core/npm"
+import { AccountTest } from "../fake/account"
+import { AuthTest } from "../fake/auth"
+import { NpmTest } from "../fake/npm"
 
 
-const emptyAccount = Layer.mock(Account.Service)({
-  active: () => Effect.succeed(Option.none()),
-  activeOrg: () => Effect.succeed(Option.none()),
-})
+const testFlock = EffectFlock.defaultLayer
 
 
-const emptyAuth = Layer.mock(Auth.Service)({
-  all: () => Effect.succeed({}),
-})
+const unexpectedHttp = HttpClient.make((request) =>
+  Effect.die(`unexpected http request: ${request.method} ${request.url}`),
+)
 
 
-const testFlock = EffectFlock.defaultLayer
+const json = (request: Parameters<typeof HttpClientResponse.fromWeb>[0], body: unknown, status = 200) =>
+  HttpClientResponse.fromWeb(
+    request,
+    new Response(JSON.stringify(body), {
+      status,
+      headers: { "content-type": "application/json" },
+    }),
+  )
 
 
-const noopNpm = Layer.mock(Npm.Service)({
-  install: () => Effect.void,
-  add: () => Effect.die("not implemented"),
-  which: () => Effect.succeed(Option.none()),
-})
+const wellKnownAuth = (url: string) =>
+  Layer.mock(Auth.Service)({
+    all: () =>
+      Effect.succeed({
+        [url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
+      }),
+  })
 
 
-const layer = Config.layer.pipe(
-  Layer.provide(testFlock),
-  Layer.provide(AppFileSystem.defaultLayer),
-  Layer.provide(Env.defaultLayer),
-  Layer.provide(emptyAuth),
-  Layer.provide(emptyAccount),
-  Layer.provideMerge(infra),
-  Layer.provide(noopNpm),
-)
+function remoteConfigClient(input: {
+  wellKnown: unknown
+  remote?: unknown
+  seen: { wellKnown?: string; remote?: string; authorization?: string }
+}) {
+  return HttpClient.make((request) => {
+    if (request.url.includes(".well-known/opencode")) {
+      input.seen.wellKnown = request.url
+      return Effect.succeed(json(request, input.wellKnown))
+    }
+    if (input.remote !== undefined && request.url.includes("config.example.com")) {
+      input.seen.remote = request.url
+      input.seen.authorization = request.headers.authorization
+      return Effect.succeed(json(request, input.remote))
+    }
+    return Effect.succeed(json(request, {}, 404))
+  })
+}
+
+const configLayer = (
+  options: {
+    auth?: Layer.Layer<Auth.Service>
+    account?: Layer.Layer<Account.Service>
+    client?: HttpClient.HttpClient
+  } = {},
+) =>
+  Config.layer.pipe(
+    Layer.provide(testFlock),
+    Layer.provide(AppFileSystem.defaultLayer),
+    Layer.provide(Env.defaultLayer),
+    Layer.provide(options.auth ?? AuthTest.empty),
+    Layer.provide(options.account ?? AccountTest.empty),
+    Layer.provideMerge(infra),
+    Layer.provide(NpmTest.noop),
+    Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)),
+  )
+
+const layer = configLayer()
 
 
 const it = testEffect(layer)
 const it = testEffect(layer)
 
 
@@ -95,6 +133,7 @@ const listDirs = (ctx: InstanceContext) =>
   )
   )
 // Get managed config directory from environment (set in preload.ts)
 // Get managed config directory from environment (set in preload.ts)
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
 const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
+const originalTestToken = process.env.TEST_TOKEN
 
 
 beforeEach(async () => {
 beforeEach(async () => {
   await clear(true)
   await clear(true)
@@ -102,6 +141,8 @@ beforeEach(async () => {
 
 
 afterEach(async () => {
 afterEach(async () => {
   await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
   await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
+  if (originalTestToken === undefined) delete process.env.TEST_TOKEN
+  else process.env.TEST_TOKEN = originalTestToken
   await clear(true)
   await clear(true)
 })
 })
 
 
@@ -528,15 +569,7 @@ test("resolves env templates in account config with account token", async () =>
     token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
     token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
   })
   })
 
 
-  const layer = Config.layer.pipe(
-    Layer.provide(testFlock),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(Env.defaultLayer),
-    Layer.provide(emptyAuth),
-    Layer.provide(fakeAccount),
-    Layer.provideMerge(infra),
-    Layer.provide(noopNpm),
-  )
+  const layer = configLayer({ account: fakeAccount })
 
 
   try {
   try {
     await provideTmpdirInstance(() =>
     await provideTmpdirInstance(() =>
@@ -900,15 +933,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
   const prev = process.env.OPENCODE_CONFIG_DIR
   const prev = process.env.OPENCODE_CONFIG_DIR
   process.env.OPENCODE_CONFIG_DIR = tmp.extra
   process.env.OPENCODE_CONFIG_DIR = tmp.extra
 
 
-  const testLayer = Config.layer.pipe(
-    Layer.provide(testFlock),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(Env.defaultLayer),
-    Layer.provide(emptyAuth),
-    Layer.provide(emptyAccount),
-    Layer.provideMerge(infra),
-    Layer.provide(noopNpm),
-  )
+  const testLayer = configLayer()
 
 
   try {
   try {
     await withTestInstance({
     await withTestInstance({
@@ -1559,194 +1584,248 @@ it.instance("local .opencode config can override MCP from project config", () =>
 )
 )
 
 
 test("project config overrides remote well-known config", async () => {
 test("project config overrides remote well-known config", async () => {
-  const originalFetch = globalThis.fetch
-  let fetchedUrl: string | undefined
-  globalThis.fetch = mock((url: string | URL | Request) => {
-    const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
-    if (urlStr.includes(".well-known/opencode")) {
-      fetchedUrl = urlStr
-      return Promise.resolve(
-        new Response(
-          JSON.stringify({
-            config: {
-              mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
-            },
-          }),
-          { status: 200 },
-        ),
-      )
-    }
-    return originalFetch(url)
-  }) as unknown as typeof fetch
-
-  const fakeAuth = Layer.mock(Auth.Service)({
-    all: () =>
-      Effect.succeed({
-        "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
-      }),
+  const seen: { wellKnown?: string } = {}
+  const client = remoteConfigClient({
+    seen,
+    wellKnown: {
+      config: {
+        mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
+      },
+    },
   })
   })
 
 
-  const layer = Config.layer.pipe(
-    Layer.provide(testFlock),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(Env.defaultLayer),
-    Layer.provide(fakeAuth),
-    Layer.provide(emptyAccount),
-    Layer.provideMerge(infra),
-    Layer.provide(noopNpm),
+  await provideTmpdirInstance(
+    () =>
+      Config.Service.use((svc) =>
+        Effect.gen(function* () {
+          const config = yield* svc.get()
+          expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
+          expect(config.mcp?.jira?.enabled).toBe(true)
+        }),
+      ),
+    {
+      git: true,
+      config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
+    },
+  ).pipe(
+    Effect.scoped,
+    Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
+    Effect.runPromise,
   )
   )
+})
 
 
-  try {
-    await provideTmpdirInstance(
-      () =>
-        Config.Service.use((svc) =>
-          Effect.gen(function* () {
-            const config = yield* svc.get()
-            expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
-            expect(config.mcp?.jira?.enabled).toBe(true)
-          }),
-        ),
-      {
-        git: true,
-        config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
+test("wellknown URL with trailing slash is normalized", async () => {
+  const seen: { wellKnown?: string } = {}
+  const client = remoteConfigClient({
+    seen,
+    wellKnown: {
+      config: {
+        mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
       },
       },
-    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
-  } finally {
-    globalThis.fetch = originalFetch
-  }
+    },
+  })
+
+  await provideTmpdirInstance(
+    () =>
+      Config.Service.use((svc) =>
+        Effect.gen(function* () {
+          yield* svc.get()
+          expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
+        }),
+      ),
+    { git: true },
+  ).pipe(
+    Effect.scoped,
+    Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com/"), client })),
+    Effect.runPromise,
+  )
 })
 })
 
 
-test("wellknown URL with trailing slash is normalized", async () => {
-  const originalFetch = globalThis.fetch
+test("remote well-known config can use FetchHttpClient layer", async () => {
   let fetchedUrl: string | undefined
   let fetchedUrl: string | undefined
-  globalThis.fetch = mock((url: string | URL | Request) => {
-    const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
-    if (urlStr.includes(".well-known/opencode")) {
-      fetchedUrl = urlStr
-      return Promise.resolve(
-        new Response(
-          JSON.stringify({
-            config: {
-              mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
-            },
-          }),
-          { status: 200 },
-        ),
+  const server = Bun.serve({
+    port: 0,
+    fetch: (request) => {
+      fetchedUrl = request.url
+      return new Response(
+        JSON.stringify({
+          config: {
+            mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } },
+          },
+        }),
+        { status: 200, headers: { "content-type": "application/json" } },
       )
       )
-    }
-    return originalFetch(url)
-  }) as unknown as typeof fetch
-
-  const fakeAuth = Layer.mock(Auth.Service)({
-    all: () =>
-      Effect.succeed({
-        "https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
-      }),
+    },
   })
   })
 
 
-  const layer = Config.layer.pipe(
-    Layer.provide(testFlock),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(Env.defaultLayer),
-    Layer.provide(fakeAuth),
-    Layer.provide(emptyAccount),
-    Layer.provideMerge(infra),
-    Layer.provide(noopNpm),
-  )
-
   try {
   try {
     await provideTmpdirInstance(
     await provideTmpdirInstance(
       () =>
       () =>
         Config.Service.use((svc) =>
         Config.Service.use((svc) =>
           Effect.gen(function* () {
           Effect.gen(function* () {
-            yield* svc.get()
-            expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
+            const config = yield* svc.get()
+            expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`)
+            expect(config.mcp?.jira?.enabled).toBe(true)
           }),
           }),
         ),
         ),
       { git: true },
       { git: true },
-    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
+    ).pipe(
+      Effect.scoped,
+      Effect.provide(
+        Config.layer.pipe(
+          Layer.provide(testFlock),
+          Layer.provide(AppFileSystem.defaultLayer),
+          Layer.provide(Env.defaultLayer),
+          Layer.provide(wellKnownAuth(server.url.origin)),
+          Layer.provide(AccountTest.empty),
+          Layer.provideMerge(infra),
+          Layer.provide(NpmTest.noop),
+          Layer.provide(FetchHttpClient.layer),
+        ),
+      ),
+      Effect.runPromise,
+    )
   } finally {
   } finally {
-    globalThis.fetch = originalFetch
+    await server.stop(true)
   }
   }
 })
 })
 
 
 test("wellknown remote_config supports templated env vars in headers", async () => {
 test("wellknown remote_config supports templated env vars in headers", async () => {
-  const originalFetch = globalThis.fetch
   const originalToken = process.env.TEST_TOKEN
   const originalToken = process.env.TEST_TOKEN
-  let wellknownFetchedUrl: string | undefined
-  let remoteFetchedUrl: string | undefined
-  let remoteHeaders: HeadersInit | undefined
-  globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
-    const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
-    if (urlStr.includes(".well-known/opencode")) {
-      wellknownFetchedUrl = urlStr
-      return Promise.resolve(
-        new Response(
-          JSON.stringify({
-            remote_config: {
-              url: "https://config.example.com/opencode.json",
-              headers: {
-                Authorization: "Bearer {env:TEST_TOKEN}",
-              },
-            },
-          }),
-          { status: 200 },
-        ),
-      )
-    }
-    if (urlStr.includes("config.example.com")) {
-      remoteFetchedUrl = urlStr
-      remoteHeaders = init?.headers
-      return Promise.resolve(
-        new Response(
-          JSON.stringify({
-            mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
-          }),
-          { status: 200 },
-        ),
-      )
-    }
-    return originalFetch(url, init)
-  }) as unknown as typeof fetch
-
-  const fakeAuth = Layer.mock(Auth.Service)({
-    all: () =>
-      Effect.succeed({
-        "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
-      }),
+  const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
+  const client = remoteConfigClient({
+    seen,
+    wellKnown: {
+      remote_config: {
+        url: "https://config.example.com/opencode.json",
+        headers: {
+          Authorization: "Bearer {env:TEST_TOKEN}",
+        },
+      },
+    },
+    remote: {
+      mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
+    },
   })
   })
 
 
-  const layer = Config.layer.pipe(
-    Layer.provide(testFlock),
-    Layer.provide(AppFileSystem.defaultLayer),
-    Layer.provide(Env.defaultLayer),
-    Layer.provide(fakeAuth),
-    Layer.provide(emptyAccount),
-    Layer.provideMerge(infra),
-    Layer.provide(noopNpm),
-  )
-
   try {
   try {
     await provideTmpdirInstance(
     await provideTmpdirInstance(
       () =>
       () =>
         Config.Service.use((svc) =>
         Config.Service.use((svc) =>
           Effect.gen(function* () {
           Effect.gen(function* () {
             const config = yield* svc.get()
             const config = yield* svc.get()
-            expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode")
-            expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json")
-            expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" })
+            expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode")
+            expect(seen.remote).toBe("https://config.example.com/opencode.json")
+            expect(seen.authorization).toBe("Bearer test-token")
             expect(config.mcp?.confluence?.enabled).toBe(true)
             expect(config.mcp?.confluence?.enabled).toBe(true)
           }),
           }),
         ),
         ),
       { git: true },
       { git: true },
-    ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
+    ).pipe(
+      Effect.scoped,
+      Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
+      Effect.runPromise,
+    )
+  } finally {
+    if (originalToken === undefined) delete process.env.TEST_TOKEN
+    else process.env.TEST_TOKEN = originalToken
+  }
+})
+
+test("wellknown token env substitution does not mutate process env", async () => {
+  const originalToken = process.env.TEST_TOKEN
+  process.env.TEST_TOKEN = "preexisting-token"
+  const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
+  const client = remoteConfigClient({
+    seen,
+    wellKnown: {
+      remote_config: {
+        url: "https://config.example.com/opencode.json",
+        headers: {
+          Authorization: "Bearer {env:TEST_TOKEN}",
+        },
+      },
+    },
+    remote: {
+      mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
+    },
+  })
+
+  try {
+    const config = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()), {
+      git: true,
+      config: { username: "{env:TEST_TOKEN}" },
+    }).pipe(
+      Effect.scoped,
+      Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
+      Effect.runPromise,
+    )
+
+    expect(seen.authorization).toBe("Bearer test-token")
+    expect(config.username).toBe("test-token")
+    expect(process.env.TEST_TOKEN).toBe("preexisting-token")
   } finally {
   } finally {
-    globalThis.fetch = originalFetch
     if (originalToken === undefined) delete process.env.TEST_TOKEN
     if (originalToken === undefined) delete process.env.TEST_TOKEN
     else process.env.TEST_TOKEN = originalToken
     else process.env.TEST_TOKEN = originalToken
   }
   }
 })
 })
 
 
+test("wellknown config null is treated as absent", async () => {
+  const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
+  const client = remoteConfigClient({
+    seen,
+    wellKnown: {
+      config: null,
+      remote_config: {
+        url: "https://config.example.com/opencode.json",
+      },
+    },
+    remote: {
+      mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
+    },
+  })
+
+  await provideTmpdirInstance(
+    () =>
+      Config.Service.use((svc) =>
+        Effect.gen(function* () {
+          const config = yield* svc.get()
+          expect(seen.remote).toBe("https://config.example.com/opencode.json")
+          expect(config.mcp?.confluence?.enabled).toBe(true)
+        }),
+      ),
+    { git: true },
+  ).pipe(
+    Effect.scoped,
+    Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
+    Effect.runPromise,
+  )
+})
+
+test("wellknown remote_config rejects non-object config responses", async () => {
+  const seen: { wellKnown?: string; remote?: string; authorization?: string } = {}
+  const client = remoteConfigClient({
+    seen,
+    wellKnown: {
+      remote_config: {
+        url: "https://config.example.com/opencode.json",
+      },
+    },
+    remote: "not an object",
+  })
+
+  const exit = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()).pipe(Effect.exit), {
+    git: true,
+  }).pipe(
+    Effect.scoped,
+    Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })),
+    Effect.runPromise,
+  )
+
+  expect(seen.remote).toBe("https://config.example.com/opencode.json")
+  expect(Exit.isFailure(exit)).toBe(true)
+})
+
 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()

+ 7 - 12
packages/opencode/test/plugin/trigger.test.ts

@@ -1,12 +1,11 @@
 import { describe, expect } from "bun:test"
 import { describe, expect } from "bun:test"
-import { Effect, Layer, Option } from "effect"
+import { Effect, Layer } from "effect"
+import { FetchHttpClient } from "effect/unstable/http"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import path from "path"
 import path from "path"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
-import { Account } from "../../src/account/account"
-import { Auth } from "../../src/auth"
 import { Bus } from "../../src/bus"
 import { Bus } from "../../src/bus"
 import { Config } from "../../src/config/config"
 import { Config } from "../../src/config/config"
 import { Env } from "../../src/env"
 import { Env } from "../../src/env"
@@ -15,22 +14,18 @@ import { Plugin } from "../../src/plugin/index"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { ModelID, ProviderID } from "../../src/provider/schema"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
+import { AccountTest } from "../fake/account"
+import { AuthTest } from "../fake/auth"
 import { NpmTest } from "../fake/npm"
 import { NpmTest } from "../fake/npm"
 
 
-const emptyAccount = Layer.mock(Account.Service)({
-  active: () => Effect.succeed(Option.none()),
-  activeOrg: () => Effect.succeed(Option.none()),
-})
-const emptyAuth = Layer.mock(Auth.Service)({
-  all: () => Effect.succeed({}),
-})
 const configLayer = Config.layer.pipe(
 const configLayer = Config.layer.pipe(
   Layer.provide(EffectFlock.defaultLayer),
   Layer.provide(EffectFlock.defaultLayer),
   Layer.provide(AppFileSystem.defaultLayer),
   Layer.provide(AppFileSystem.defaultLayer),
   Layer.provide(Env.defaultLayer),
   Layer.provide(Env.defaultLayer),
-  Layer.provide(emptyAuth),
-  Layer.provide(emptyAccount),
+  Layer.provide(AuthTest.empty),
+  Layer.provide(AccountTest.empty),
   Layer.provide(NpmTest.noop),
   Layer.provide(NpmTest.noop),
+  Layer.provide(FetchHttpClient.layer),
 )
 )
 const it = testEffect(
 const it = testEffect(
   Layer.mergeAll(
   Layer.mergeAll(

+ 6 - 11
packages/opencode/test/plugin/workspace-adapter.test.ts

@@ -1,12 +1,11 @@
 import { afterEach, describe, expect } from "bun:test"
 import { afterEach, describe, expect } from "bun:test"
-import { Effect, Layer, Option } from "effect"
+import { Effect, Layer } from "effect"
 import { FetchHttpClient } from "effect/unstable/http"
 import { FetchHttpClient } from "effect/unstable/http"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { AppFileSystem } from "@opencode-ai/core/filesystem"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
 import path from "path"
 import path from "path"
 import { pathToFileURL } from "url"
 import { pathToFileURL } from "url"
-import { Account } from "../../src/account/account"
 import { Auth } from "../../src/auth"
 import { Auth } from "../../src/auth"
 import { Bus } from "../../src/bus"
 import { Bus } from "../../src/bus"
 import { Config } from "../../src/config/config"
 import { Config } from "../../src/config/config"
@@ -24,22 +23,18 @@ import { SessionPrompt } from "../../src/session/prompt"
 import { SyncEvent } from "../../src/sync"
 import { SyncEvent } from "../../src/sync"
 import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
 import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
 import { testEffect } from "../lib/effect"
 import { testEffect } from "../lib/effect"
+import { AccountTest } from "../fake/account"
+import { AuthTest } from "../fake/auth"
 import { NpmTest } from "../fake/npm"
 import { NpmTest } from "../fake/npm"
 
 
-const emptyAccount = Layer.mock(Account.Service)({
-  active: () => Effect.succeed(Option.none()),
-  activeOrg: () => Effect.succeed(Option.none()),
-})
-const emptyAuth = Layer.mock(Auth.Service)({
-  all: () => Effect.succeed({}),
-})
 const configLayer = Config.layer.pipe(
 const configLayer = Config.layer.pipe(
   Layer.provide(EffectFlock.defaultLayer),
   Layer.provide(EffectFlock.defaultLayer),
   Layer.provide(AppFileSystem.defaultLayer),
   Layer.provide(AppFileSystem.defaultLayer),
   Layer.provide(Env.defaultLayer),
   Layer.provide(Env.defaultLayer),
-  Layer.provide(emptyAuth),
-  Layer.provide(emptyAccount),
+  Layer.provide(AuthTest.empty),
+  Layer.provide(AccountTest.empty),
   Layer.provide(NpmTest.noop),
   Layer.provide(NpmTest.noop),
+  Layer.provide(FetchHttpClient.layer),
 )
 )
 const pluginLayer = Plugin.layer.pipe(
 const pluginLayer = Plugin.layer.pipe(
   Layer.provide(Bus.layer),
   Layer.provide(Bus.layer),