From 5d9b5ac26c81d6942a6f071b03ecd86fc7444b60 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 10:07:04 -0400 Subject: [PATCH 1/8] refactor(opencode): fetch remote config with http client --- packages/opencode/src/config/config.ts | 64 ++-- packages/opencode/test/config/config.test.ts | 331 +++++++----------- packages/opencode/test/plugin/trigger.test.ts | 17 +- .../test/plugin/workspace-adapter.test.ts | 16 +- 4 files changed, 184 insertions(+), 244 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index cdc32b971d62..8c62bffbf00a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -18,6 +18,7 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" +import { HttpClient, HttpClientRequest } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" @@ -40,6 +41,7 @@ import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" +import { withTransientReadRetry } from "@/util/effect-http-client" const log = Log.create({ service: "config" }) @@ -70,7 +72,7 @@ function normalizeLoadedConfig(data: unknown, source: string) { } async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) { - if (!isRecord(input.value) || typeof input.value.url !== "string") return + if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined const url = await ConfigVariable.substitute({ text: input.value.url, @@ -99,6 +101,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str return { url, headers } } +const WellKnownConfig = Schema.Struct({ + config: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), + remote_config: Schema.optional(Schema.Unknown), +}) + async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -302,7 +309,7 @@ export type Info = DeepMutable> & { type State = { config: Info directories: string[] - deps: Fiber.Fiber[] + deps: Fiber.Fiber[] consoleState: ConsoleState } @@ -372,6 +379,27 @@ export const layer = Layer.effect( const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) + const fetchRemoteJson = Effect.fnUntraced(function* (url: string, headers?: Record) { + const http = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)) + if (!http) return yield* Effect.die(new Error(`HttpClient required to fetch remote config from ${url}`)) + 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* response.json.pipe( + Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), + ) + }) + + const fetchWellKnownConfig = Effect.fnUntraced(function* (url: string) { + const parsed = Option.getOrUndefined(Schema.decodeUnknownOption(WellKnownConfig)(yield* fetchRemoteJson(url))) + if (!parsed) return yield* Effect.die(new Error(`failed to decode remote config from ${url}`)) + return parsed + }) + const loadConfig = Effect.fnUntraced(function* ( text: string, options: { path: string } | { dir: string; source: string }, @@ -514,35 +542,27 @@ export const layer = Layer.effect( if (value.type === "wellknown") { 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 - remote_config?: unknown - } + const wellknownURL = `${url}/.well-known/opencode` + log.debug("fetching remote config", { url: wellknownURL }) + const wellknown = yield* fetchWellKnownConfig(wellknownURL) const remote = yield* Effect.promise(() => substituteWellKnownRemoteConfig({ value: wellknown.remote_config, dir: url, - source: `${url}/.well-known/opencode`, + source: wellknownURL, }), ) const fetchedConfig = remote - ? ((yield* Effect.promise(async () => { + ? yield* Effect.gen(function* () { 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) + const data = yield* fetchRemoteJson(remote.url, remote.headers) + if (isRecord(data) && isRecord(data.config)) return data.config + return isRecord(data) ? data : {} + }) : {} - const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info) + const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig) if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - const source = `${url}/.well-known/opencode` + const source = wellknownURL const next = yield* loadConfig(JSON.stringify(remoteConfig), { dir: path.dirname(source), source, @@ -576,7 +596,7 @@ export const layer = Layer.effect( log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR }) } - const deps: Fiber.Fiber[] = [] + const deps: Fiber.Fiber[] = [] for (const dir of directories) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 12fb96dc6744..e9c48a5ee33b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/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 { HttpClient, HttpClientResponse } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" @@ -30,34 +31,71 @@ import { Global } from "@opencode-ai/core/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "@/util/filesystem" 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[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 + account?: Layer.Layer + 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) @@ -512,15 +550,7 @@ test("resolves env templates in account config with account token", async () => 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 { await provideTmpdirInstance(() => @@ -884,15 +914,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const prev = process.env.OPENCODE_CONFIG_DIR 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 { await withTestInstance({ @@ -1543,189 +1565,100 @@ it.instance("local .opencode config can override MCP from project config", () => ) 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 } } }, - }, - ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) - } finally { - globalThis.fetch = originalFetch - } }) test("wellknown URL with trailing slash is normalized", 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: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } }, - }, - }), - { 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: { slack: { type: "remote", url: "https://slack.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), + 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, ) - - try { - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - yield* svc.get() - expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") - }), - ), - { git: true }, - ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) - } finally { - globalThis.fetch = originalFetch - } }) test("wellknown remote_config supports templated env vars in headers", async () => { - const originalFetch = globalThis.fetch 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 { await provideTmpdirInstance( () => Config.Service.use((svc) => Effect.gen(function* () { 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) }), ), { 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 { - globalThis.fetch = originalFetch if (originalToken === undefined) delete process.env.TEST_TOKEN else process.env.TEST_TOKEN = originalToken } diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 94642fba629c..4a3e2e56dec2 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,12 +1,10 @@ import { describe, expect } from "bun:test" -import { Effect, Layer, Option } from "effect" +import { Effect, Layer } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" -import { Account } from "../../src/account/account" -import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" import { Env } from "../../src/env" @@ -15,21 +13,16 @@ import { Plugin } from "../../src/plugin/index" import { ModelID, ProviderID } from "../../src/provider/schema" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +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 emptyAuth = Layer.mock(Auth.Service)({ - all: () => Effect.succeed({}), -}) const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), + Layer.provide(AuthTest.empty), + Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), ) const it = testEffect( diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index b4b40fe7677b..13073bf4510a 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -1,12 +1,11 @@ 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 { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import path from "path" import { pathToFileURL } from "url" -import { Account } from "../../src/account/account" import { Auth } from "../../src/auth" import { Bus } from "../../src/bus" import { Config } from "../../src/config/config" @@ -24,21 +23,16 @@ import { SessionPrompt } from "../../src/session/prompt" import { SyncEvent } from "../../src/sync" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" +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 emptyAuth = Layer.mock(Auth.Service)({ - all: () => Effect.succeed({}), -}) const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), - Layer.provide(emptyAccount), + Layer.provide(AuthTest.empty), + Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), ) const pluginLayer = Plugin.layer.pipe( From e7c5d8ca0a9d7d5284c8b15c42f7957d0ca26099 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 10:46:17 -0400 Subject: [PATCH 2/8] refactor(opencode): use schema response decoding --- packages/opencode/src/config/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8c62bffbf00a..177faaff401f 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -18,7 +18,7 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { HttpClient, HttpClientRequest } from "effect/unstable/http" +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" @@ -389,7 +389,7 @@ export const layer = Layer.effect( .pipe( Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))), ) - return yield* response.json.pipe( + return yield* HttpClientResponse.schemaBodyJson(Schema.Unknown)(response).pipe( Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), ) }) From 9808a9deacb2e327342964a788d14e0363a2f5ae Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 10:59:29 -0400 Subject: [PATCH 3/8] refactor(opencode): type remote config as json --- packages/opencode/src/config/config.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 177faaff401f..b5c98481d7eb 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -102,8 +102,8 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str } const WellKnownConfig = Schema.Struct({ - config: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), - remote_config: Schema.optional(Schema.Unknown), + config: Schema.optional(Schema.Record(Schema.String, Schema.Json)), + remote_config: Schema.optional(Schema.Json), }) async function resolveLoadedPlugins(config: T, filepath: string) { @@ -389,7 +389,7 @@ export const layer = Layer.effect( .pipe( Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))), ) - return yield* HttpClientResponse.schemaBodyJson(Schema.Unknown)(response).pipe( + return yield* HttpClientResponse.schemaBodyJson(Schema.Json)(response).pipe( Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), ) }) From aa93f0fdbf69b823b19532766f592191c0a13b4b Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 11:26:44 -0400 Subject: [PATCH 4/8] refactor(opencode): decode remote config at fetch boundary --- packages/opencode/src/config/config.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b5c98481d7eb..4917a9977167 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -379,7 +379,11 @@ export const layer = Layer.effect( const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) - const fetchRemoteJson = Effect.fnUntraced(function* (url: string, headers?: Record) { + const fetchRemoteJson = Effect.fnUntraced(function* ( + url: string, + headers: Record | undefined, + schema: S, + ) { const http = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)) if (!http) return yield* Effect.die(new Error(`HttpClient required to fetch remote config from ${url}`)) const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http)) @@ -389,17 +393,11 @@ export const layer = Layer.effect( .pipe( Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))), ) - return yield* HttpClientResponse.schemaBodyJson(Schema.Json)(response).pipe( + return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe( Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), ) }) - const fetchWellKnownConfig = Effect.fnUntraced(function* (url: string) { - const parsed = Option.getOrUndefined(Schema.decodeUnknownOption(WellKnownConfig)(yield* fetchRemoteJson(url))) - if (!parsed) return yield* Effect.die(new Error(`failed to decode remote config from ${url}`)) - return parsed - }) - const loadConfig = Effect.fnUntraced(function* ( text: string, options: { path: string } | { dir: string; source: string }, @@ -544,7 +542,7 @@ export const layer = Layer.effect( process.env[value.key] = value.token const wellknownURL = `${url}/.well-known/opencode` log.debug("fetching remote config", { url: wellknownURL }) - const wellknown = yield* fetchWellKnownConfig(wellknownURL) + const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig) const remote = yield* Effect.promise(() => substituteWellKnownRemoteConfig({ value: wellknown.remote_config, @@ -555,7 +553,7 @@ export const layer = Layer.effect( const fetchedConfig = remote ? yield* Effect.gen(function* () { log.debug("fetching remote config", { url: remote.url }) - const data = yield* fetchRemoteJson(remote.url, remote.headers) + const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json) if (isRecord(data) && isRecord(data.config)) return data.config return isRecord(data) ? data : {} }) From fbbd014dcb0b56982e2bf54fe7b07b5afb176e13 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 11:50:41 -0400 Subject: [PATCH 5/8] fix(opencode): provide http client for remote config --- packages/opencode/src/config/config.ts | 8 ++- packages/opencode/test/config/config.test.ts | 65 ++++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4917a9977167..bc4b3482afb5 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -18,7 +18,7 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" @@ -555,7 +555,10 @@ export const layer = Layer.effect( log.debug("fetching remote config", { url: remote.url }) const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json) if (isRecord(data) && isRecord(data.config)) return data.config - return isRecord(data) ? data : {} + 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) @@ -850,6 +853,7 @@ export const defaultLayer = layer.pipe( Layer.provide(Auth.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), + Layer.provide(FetchHttpClient.layer), ) export * as Config from "./config" diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index e9c48a5ee33b..dc96ed6069d3 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1622,6 +1622,47 @@ test("wellknown URL with trailing slash is normalized", async () => { ) }) +test("default layer provides HTTP client for remote well-known config", async () => { + const originalAuth = process.env.OPENCODE_AUTH_CONTENT + let fetchedUrl: string | undefined + 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" } }, + ) + }, + }) + + process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ + [server.url.origin]: { type: "wellknown", key: "TEST_TOKEN", token: "test-token" }, + }) + + try { + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`) + expect(config.mcp?.jira?.enabled).toBe(true) + }), + ), + { git: true }, + ).pipe(Effect.scoped, Effect.provide(Config.defaultLayer), Effect.provide(infra), Effect.runPromise) + } finally { + await server.stop(true) + if (originalAuth === undefined) delete process.env.OPENCODE_AUTH_CONTENT + else process.env.OPENCODE_AUTH_CONTENT = originalAuth + } +}) + test("wellknown remote_config supports templated env vars in headers", async () => { const originalToken = process.env.TEST_TOKEN const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} @@ -1664,6 +1705,30 @@ test("wellknown remote_config supports templated env vars in headers", async () } }) +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", () => { test("keeps package specs unchanged", async () => { await using tmp = await tmpdir() From d21f8b9c1bafb6c21ed65e94b8347c3e394abaa8 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 12:03:09 -0400 Subject: [PATCH 6/8] test(opencode): avoid global auth in remote config test --- packages/opencode/test/config/config.test.ts | 28 +++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index dc96ed6069d3..8b9cd7f55bd3 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,6 +1,6 @@ import { test, expect, describe, afterEach, beforeEach } from "bun:test" import { Effect, Exit, Layer, Option } from "effect" -import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" @@ -1622,8 +1622,7 @@ test("wellknown URL with trailing slash is normalized", async () => { ) }) -test("default layer provides HTTP client for remote well-known config", async () => { - const originalAuth = process.env.OPENCODE_AUTH_CONTENT +test("remote well-known config can use FetchHttpClient layer", async () => { let fetchedUrl: string | undefined const server = Bun.serve({ port: 0, @@ -1640,10 +1639,6 @@ test("default layer provides HTTP client for remote well-known config", async () }, }) - process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({ - [server.url.origin]: { type: "wellknown", key: "TEST_TOKEN", token: "test-token" }, - }) - try { await provideTmpdirInstance( () => @@ -1655,11 +1650,24 @@ test("default layer provides HTTP client for remote well-known config", async () }), ), { git: true }, - ).pipe(Effect.scoped, Effect.provide(Config.defaultLayer), Effect.provide(infra), 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 { await server.stop(true) - if (originalAuth === undefined) delete process.env.OPENCODE_AUTH_CONTENT - else process.env.OPENCODE_AUTH_CONTENT = originalAuth } }) From 045a2c9e11cd78bd40c1e5d26e1386c1dbacb453 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 12:54:17 -0400 Subject: [PATCH 7/8] fix(opencode): make config http dependency explicit --- packages/opencode/src/config/config.ts | 3 +-- packages/opencode/test/agent/plugin-agent-regression.test.ts | 2 ++ packages/opencode/test/config/config.test.ts | 3 +++ packages/opencode/test/plugin/trigger.test.ts | 2 ++ packages/opencode/test/plugin/workspace-adapter.test.ts | 1 + 5 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index bc4b3482afb5..9b98919a9224 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -376,6 +376,7 @@ export const layer = Layer.effect( const accountSvc = yield* Account.Service const env = yield* Env.Service const npmSvc = yield* Npm.Service + const http = yield* HttpClient.HttpClient const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) @@ -384,8 +385,6 @@ export const layer = Layer.effect( headers: Record | undefined, schema: S, ) { - const http = Option.getOrUndefined(yield* Effect.serviceOption(HttpClient.HttpClient)) - if (!http) return yield* Effect.die(new Error(`HttpClient required to fetch remote config from ${url}`)) const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http)) .execute( HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})), diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index c437281cc6b6..751535147731 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import path from "path" import { pathToFileURL } from "url" import { Agent } from "../../src/agent/agent" @@ -29,6 +30,7 @@ const configLayer = Config.layer.pipe( Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), + Layer.provide(FetchHttpClient.layer), ) const pluginLayer = Plugin.layer.pipe( Layer.provide(Bus.layer), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 8b9cd7f55bd3..9aba59d04d01 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -127,6 +127,7 @@ const listDirs = (ctx: InstanceContext) => ) // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! +const originalTestToken = process.env.TEST_TOKEN beforeEach(async () => { await clear(true) @@ -134,6 +135,8 @@ beforeEach(async () => { afterEach(async () => { 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) }) diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 4a3e2e56dec2..3716bc3aca5e 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,5 +1,6 @@ import { describe, expect } from "bun:test" import { Effect, Layer } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" @@ -24,6 +25,7 @@ const configLayer = Config.layer.pipe( Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), + Layer.provide(FetchHttpClient.layer), ) const it = testEffect( Layer.mergeAll( diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 13073bf4510a..79964d3deeb7 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -34,6 +34,7 @@ const configLayer = Config.layer.pipe( Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), Layer.provide(NpmTest.noop), + Layer.provide(FetchHttpClient.layer), ) const pluginLayer = Plugin.layer.pipe( Layer.provide(Bus.layer), From cf6b21efdd900e43e43e64426092bb62cf667e2c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 15:03:18 -0400 Subject: [PATCH 8/8] fix(opencode): avoid global env for well-known tokens --- packages/opencode/src/config/config.ts | 54 +++++++++------ packages/opencode/src/config/variable.ts | 5 +- packages/opencode/test/config/config.test.ts | 70 ++++++++++++++++++++ 3 files changed, 108 insertions(+), 21 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 9b98919a9224..730dfcf1deb6 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -71,7 +71,12 @@ function normalizeLoadedConfig(data: unknown, source: string) { return copy } -async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) { +async function substituteWellKnownRemoteConfig(input: { + value: unknown + dir: string + source: string + env: Record +}) { if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined const url = await ConfigVariable.substitute({ @@ -79,6 +84,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str type: "virtual", dir: input.dir, source: input.source, + env: input.env, }) const headers = isRecord(input.value.headers) ? Object.fromEntries( @@ -92,6 +98,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str type: "virtual", dir: input.dir, source: input.source, + env: input.env, }), ]), ), @@ -102,7 +109,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str } const WellKnownConfig = Schema.Struct({ - config: Schema.optional(Schema.Record(Schema.String, Schema.Json)), + config: Schema.optional(Schema.Json), remote_config: Schema.optional(Schema.Json), }) @@ -400,11 +407,14 @@ export const layer = Layer.effect( const loadConfig = Effect.fnUntraced(function* ( text: string, options: { path: string } | { dir: string; source: string }, + env?: Record, ) { const source = "path" in options ? options.path : options.source const expanded = yield* Effect.promise(() => 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) @@ -420,14 +430,14 @@ export const layer = Layer.effect( return data }) - const loadFile = Effect.fnUntraced(function* (filepath: string) { + const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { log.info("loading", { path: filepath }) const text = yield* readConfigFile(filepath) 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) { let result: Info = {} // 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. @@ -439,9 +449,9 @@ export const layer = Layer.effect( .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") if (existsSync(legacy)) { @@ -499,6 +509,7 @@ export const layer = Layer.effect( const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} + const authEnv: Record = {} const consoleManagedProviders = new Set() let activeOrgName: string | undefined @@ -538,7 +549,7 @@ export const layer = Layer.effect( for (const [key, value] of Object.entries(auth)) { if (value.type === "wellknown") { const url = key.replace(/\/+$/, "") - process.env[value.key] = value.token + 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) @@ -547,6 +558,7 @@ export const layer = Layer.effect( value: wellknown.remote_config, dir: url, source: wellknownURL, + env: authEnv, }), ) const fetchedConfig = remote @@ -560,29 +572,33 @@ export const layer = Layer.effect( ) }) : {} - const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig) + const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig) if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" const source = wellknownURL - const next = yield* loadConfig(JSON.stringify(remoteConfig), { - dir: path.dirname(source), - source, - }) + const next = yield* loadConfig( + JSON.stringify(remoteConfig), + { + dir: path.dirname(source), + source, + }, + authEnv, + ) yield* merge(source, next, "global") 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") 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 }) } if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) { 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") } } @@ -603,7 +619,7 @@ export const layer = Layer.effect( for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) - yield* merge(source, yield* loadFile(source)) + yield* merge(source, yield* loadFile(source, authEnv)) result.agent ??= {} result.mode ??= {} result.plugin ??= [] diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts index e61e06d41bbe..44c985c991dd 100644 --- a/packages/opencode/src/config/variable.ts +++ b/packages/opencode/src/config/variable.ts @@ -19,6 +19,7 @@ type ParseSource = type SubstituteInput = ParseSource & { text: string missing?: "error" | "empty" + env?: Record } function source(input: ParseSource) { @@ -33,7 +34,7 @@ function dir(input: ParseSource) { export async function substitute(input: SubstituteInput) { const missing = input.missing ?? "error" 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)) @@ -46,7 +47,7 @@ export async function substitute(input: SubstituteInput) { for (const match of fileMatches) { const token = match[0] - const index = match.index! + const index = match.index out += text.slice(cursor, index) const lineStart = text.lastIndexOf("\n", index - 1) + 1 diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 9aba59d04d01..90ff0d4f1883 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1716,6 +1716,76 @@ test("wellknown remote_config supports templated env vars in headers", async () } }) +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 { + if (originalToken === undefined) delete process.env.TEST_TOKEN + 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({