From b99787e95b7f2c6e77f51bd978365d2d2c8d0ced Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 21 May 2026 15:53:44 -0400 Subject: [PATCH] refactor(opencode): fetch remote config with http client (#28661) --- packages/opencode/src/config/config.ts | 115 +++-- packages/opencode/src/config/variable.ts | 5 +- .../agent/plugin-agent-regression.test.ts | 2 + packages/opencode/test/config/config.test.ts | 465 ++++++++++-------- packages/opencode/test/plugin/trigger.test.ts | 19 +- .../test/plugin/workspace-adapter.test.ts | 17 +- 6 files changed, 366 insertions(+), 257 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index e3931aa368..349b7e6a07 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -19,6 +19,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 { 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" @@ -41,6 +42,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,14 +72,20 @@ function normalizeLoadedConfig(data: unknown, source: string) { 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 +}) { + if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined const url = await ConfigVariable.substitute({ text: input.value.url, type: "virtual", dir: input.dir, source: input.source, + env: input.env, }) const headers = isRecord(input.value.headers) ? Object.fromEntries( @@ -91,6 +99,7 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str type: "virtual", dir: input.dir, source: input.source, + env: input.env, }), ]), ), @@ -100,6 +109,11 @@ async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: str return { url, headers } } +const WellKnownConfig = Schema.Struct({ + config: Schema.optional(Schema.Json), + remote_config: Schema.optional(Schema.Json), +}) + async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -303,7 +317,7 @@ export type Info = DeepMutable> & { type State = { config: Info directories: string[] - deps: Fiber.Fiber[] + deps: Fiber.Fiber[] consoleState: ConsoleState } @@ -372,17 +386,38 @@ 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) + const fetchRemoteJson = Effect.fnUntraced(function* ( + url: string, + headers: Record | 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* ( 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) @@ -398,14 +433,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. @@ -417,9 +452,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)) { @@ -477,6 +512,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 @@ -516,56 +552,56 @@ 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 - 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 - } + 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(() => substituteWellKnownRemoteConfig({ value: wellknown.remote_config, dir: url, - source: `${url}/.well-known/opencode`, + source: wellknownURL, + env: authEnv, }), ) 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, 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" - 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") 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") } } @@ -579,14 +615,14 @@ 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) { 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 ??= [] @@ -835,6 +871,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/src/config/variable.ts b/packages/opencode/src/config/variable.ts index e61e06d41b..44c985c991 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/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 02c73b4109..d79e01c788 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 0f6ce14b47..04dcde32e1 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 { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" @@ -36,35 +37,72 @@ 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" - -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({}), -}) +import { AccountTest } from "../fake/account" +import { AuthTest } from "../fake/auth" +import { NpmTest } from "../fake/npm" const testFlock = EffectFlock.defaultLayer -const noopNpm = Layer.mock(Npm.Service)({ - install: () => Effect.void, - add: () => Effect.die("not implemented"), - which: () => Effect.succeed(Option.none()), -}) - -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), +const unexpectedHttp = HttpClient.make((request) => + Effect.die(`unexpected http request: ${request.method} ${request.url}`), ) +const json = (request: Parameters[0], body: unknown, status = 200) => + HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ) + +const wellKnownAuth = (url: string) => + Layer.mock(Auth.Service)({ + all: () => + Effect.succeed({ + [url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), + }), + }) + +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) const provideCurrentInstance = (effect: Effect.Effect, ctx: InstanceContext) => @@ -95,6 +133,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) @@ -102,6 +141,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) }) @@ -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"))), }) - 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(() => @@ -900,15 +933,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({ @@ -1559,42 +1584,79 @@ 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, ) +}) + +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 } }, + }, + }, + }) + + 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("remote well-known config can use FetchHttpClient layer", async () => { + 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" } }, + ) + }, + }) try { await provideTmpdirInstance( @@ -1602,151 +1664,168 @@ test("project config overrides remote well-known config", async () => { Config.Service.use((svc) => Effect.gen(function* () { const config = yield* svc.get() - expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") + expect(fetchedUrl).toBe(`${server.url.origin}/.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 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* () { - yield* svc.get() - expect(fetchedUrl).toBe("https://example.com/.well-known/opencode") - }), - ), { 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 { - globalThis.fetch = originalFetch + await server.stop(true) } }) 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 } }) +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({ + 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() diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 94642fba62..3716bc3aca 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -1,12 +1,11 @@ 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 { 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,22 +14,18 @@ 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), + 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 b4b40fe767..79964d3dee 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,22 +23,18 @@ 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), + Layer.provide(FetchHttpClient.layer), ) const pluginLayer = Plugin.layer.pipe( Layer.provide(Bus.layer),