mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-22 11:25:15 +00:00
refactor(opencode): fetch remote config with http client (#28661)
This commit is contained in:
parent
562d299a41
commit
b99787e95b
6 changed files with 366 additions and 257 deletions
|
|
@ -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<string, string>
|
||||
}) {
|
||||
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<T extends { plugin?: ConfigPlugin.Spec[] }>(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<Schema.Schema.Type<typeof Info>> & {
|
|||
type State = {
|
||||
config: Info
|
||||
directories: string[]
|
||||
deps: Fiber.Fiber<void, never>[]
|
||||
deps: Fiber.Fiber<void>[]
|
||||
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* <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* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
env?: Record<string, string>,
|
||||
) {
|
||||
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<string, string>) {
|
||||
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<string, string>) {
|
||||
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<string, string> = {}
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
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<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(() =>
|
||||
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<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"
|
||||
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<void, never>[] = []
|
||||
const deps: Fiber.Fiber<void>[] = []
|
||||
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ type ParseSource =
|
|||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
env?: Record<string, string>
|
||||
}
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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<typeof HttpClientResponse.fromWeb>[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<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 provideCurrentInstance = <A, E, R>(effect: Effect.Effect<A, E, R>, 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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue