diff --git a/packages/core/src/auth-well-known.ts b/packages/core/src/auth-well-known.ts new file mode 100644 index 00000000000..d0ca543c4f0 --- /dev/null +++ b/packages/core/src/auth-well-known.ts @@ -0,0 +1,243 @@ +export * as AuthWellKnown from "./auth-well-known" + +import path from "path" +import { Context, Effect, Layer, Option, Schema, SynchronizedRef } from "effect" +import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { AppFileSystem } from "./filesystem" +import { FSUtil } from "./fs-util" +import { Global } from "./global" +import { Substitution } from "./substitution" + +export class Entry extends Schema.Class("AuthWellKnown.Entry")({ + key: Schema.String, + token: Schema.String, +}) {} + +export class FileWriteError extends Schema.TaggedErrorClass()("AuthWellKnown.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export class RemoteConfigError extends Schema.TaggedErrorClass()("AuthWellKnown.RemoteConfigError", { + url: Schema.String, + status: Schema.Number.pipe(Schema.optional), + cause: Schema.Defect.pipe(Schema.optional), +}) {} + +export type Error = FileWriteError | RemoteConfigError + +const RemoteConfig = Schema.Struct({ + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}) + +export class Metadata extends Schema.Class("AuthWellKnown.Metadata")({ + auth: Schema.Struct({ + command: Schema.Array(Schema.String), + env: Schema.String, + }).pipe(Schema.optional), + config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + remote_config: RemoteConfig.pipe(Schema.optional), +}) {} + +export type ConfigDocument = { + url: string + source: string + dir: string + content: unknown +} + +export interface Interface { + readonly all: () => Effect.Effect, Error> + readonly get: (url: string) => Effect.Effect + readonly set: (url: string, entry: Entry) => Effect.Effect + readonly remove: (url: string) => Effect.Effect + readonly metadata: (url: string) => Effect.Effect + readonly configs: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/AuthWellKnown") {} +const decodeMetadata = Schema.decodeUnknownEffect(Metadata) +const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig) + +function loadLegacyAuth(input: { + fsys: FSUtil.Interface + dataDir: string + write: (data: Record) => Effect.Effect +}) { + return Effect.gen(function* () { + const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) + const decodeLegacyCredential = Schema.decodeUnknownOption( + Schema.Struct({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, + }), + ) + const legacy = Object.fromEntries( + Object.entries( + Option.getOrElse( + decodeLegacy( + yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)), + ), + () => ({}), + ), + ).flatMap(([url, value]) => { + const decoded = Option.getOrUndefined(decodeLegacyCredential(value)) + return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : [] + }), + ) + if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore) + return legacy + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* AppFileSystem.Service + const global = yield* Global.Service + const http = yield* HttpClient.HttpClient + const substitution = yield* Substitution.Service + const file = path.join(global.data, "well-known.json") + const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry)) + const normalizeUrl = (url: string) => url.replace(/\/+$/, "") + + const write = (operation: "migrate" | "write", data: Record) => + fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause }))) + + const load = Effect.fnUntraced(function* () { + const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + if (current && typeof current === "object") + return Option.getOrElse(decodeEntries(current), () => ({}) as Record) + return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) }) + }) + + const state = SynchronizedRef.makeUnsafe>(yield* load()) + + const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) { + const normalized = normalizeUrl(url) + const source = `${normalized}/.well-known/opencode` + const response = yield* HttpClientRequest.get(source).pipe( + HttpClientRequest.acceptJson, + http.execute, + Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new RemoteConfigError({ url: source, status: response.status }) + } + const metadata = yield* response.json.pipe( + Effect.flatMap(decodeMetadata), + Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })), + ) + return { url: normalized, source, dir: path.dirname(source), metadata } + }) + + const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record }) { + const response = yield* HttpClientRequest.get(input.url).pipe( + HttpClientRequest.acceptJson, + input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request, + http.execute, + Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new RemoteConfigError({ url: input.url, status: response.status }) + } + return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause }))) + }) + + return Service.of({ + all: Effect.fn("AuthWellKnown.all")(function* () { + return yield* SynchronizedRef.get(state) + }), + + get: Effect.fn("AuthWellKnown.get")(function* (url) { + return (yield* SynchronizedRef.get(state))[normalizeUrl(url)] + }), + + set: Effect.fn("AuthWellKnown.set")(function* (url, entry) { + yield* SynchronizedRef.updateEffect( + state, + Effect.fnUntraced(function* (data) { + const next = { ...data, [normalizeUrl(url)]: entry } + yield* write("write", next) + return next + }), + ) + }), + + remove: Effect.fn("AuthWellKnown.remove")(function* (url) { + yield* SynchronizedRef.updateEffect( + state, + Effect.fnUntraced(function* (data) { + const next = { ...data } + delete next[url] + delete next[normalizeUrl(url)] + yield* write("write", next) + return next + }), + ) + }), + + metadata: Effect.fn("AuthWellKnown.metadata.public")(function* (url) { + return (yield* metadata(url)).metadata + }), + + configs: Effect.fn("AuthWellKnown.configs")(function* () { + const documents = yield* Effect.all( + Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) => + Effect.gen(function* () { + const configs: ConfigDocument[] = [] + const response = yield* metadata(url) + const env = { [entry.key]: entry.token } + if (response.metadata.config) { + configs.push({ + url: response.url, + source: response.source, + dir: response.dir, + content: response.metadata.config, + }) + } + if (response.metadata.remote_config) { + const remoteConfig = yield* substitution + .substitute({ + text: JSON.stringify(response.metadata.remote_config), + type: "virtual", + dir: response.url, + source: response.source, + env, + }) + .pipe( + Effect.flatMap((text) => + Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: (cause) => new RemoteConfigError({ url: response.source, cause }), + }), + ), + Effect.flatMap(decodeRemoteConfig), + Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })), + ) + configs.push({ + url: remoteConfig.url, + source: remoteConfig.url, + dir: path.dirname(remoteConfig.url), + content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }), + }) + } + return configs + }), + ), + { concurrency: "unbounded" }, + ) + return documents.flat() + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Global.defaultLayer), + Layer.provide(FetchHttpClient.layer), + Layer.provide(Substitution.defaultLayer), +) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe88401..f5e587806e5 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -10,6 +10,13 @@ import { FileSystemSearch } from "./filesystem/search" import { Entry, Match } from "./filesystem/schema" export { Entry, Match, Submatch } from "./filesystem/schema" +export const AppFileSystem = { + Service: FSUtil.Service, + layer: FSUtil.layer, + defaultLayer: FSUtil.defaultLayer, + node: FSUtil.node, +} + export const ReadInput = Schema.Struct({ path: RelativePath, }) diff --git a/packages/core/src/substitution.ts b/packages/core/src/substitution.ts new file mode 100644 index 00000000000..b387e35d75e --- /dev/null +++ b/packages/core/src/substitution.ts @@ -0,0 +1,94 @@ +export * as Substitution from "./substitution" + +import os from "os" +import path from "path" +import { Context, Effect, Layer, Schema } from "effect" +import { AppFileSystem } from "./filesystem" + +type Source = + | { + type: "path" + path: string + } + | { + type: "virtual" + source: string + dir: string + } + +export type Input = Source & { + text: string + missing?: "error" | "empty" + env?: Record +} + +export class FileReferenceError extends Schema.TaggedErrorClass()("Substitution.FileReferenceError", { + source: Schema.String, + token: Schema.String, + resolved: Schema.String, + cause: Schema.Defect, +}) {} + +export type Error = FileReferenceError + +export interface Interface { + readonly substitute: (input: Input) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/Substitution") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fs = yield* AppFileSystem.Service + + return Service.of({ + substitute: Effect.fn("Substitution.substitute")(function* (input) { + const missing = input.missing ?? "error" + const text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { + return input.env?.[varName] ?? process.env[varName] ?? "" + }) + + const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) + if (!fileMatches.length) return text + + const configDir = input.type === "path" ? path.dirname(input.path) : input.dir + const configSource = input.type === "path" ? input.path : input.source + let out = "" + let cursor = 0 + + for (const match of fileMatches) { + const token = match[0] + const index = match.index! + out += text.slice(cursor, index) + + const lineStart = text.lastIndexOf("\n", index - 1) + 1 + const prefix = text.slice(lineStart, index).trimStart() + if (prefix.startsWith("//")) { + out += token + cursor = index + token.length + continue + } + + const reference = token.replace(/^\{file:/, "").replace(/\}$/, "") + const filepath = reference.startsWith("~/") ? path.join(os.homedir(), reference.slice(2)) : reference + const resolved = path.isAbsolute(filepath) ? filepath : path.resolve(configDir, filepath) + const content = yield* fs.readFileString(resolved).pipe( + Effect.catch((cause) => { + if (missing === "empty") return Effect.succeed("") + return Effect.fail(new FileReferenceError({ source: configSource, token, resolved, cause })) + }), + ) + + out += JSON.stringify(content.trim()).slice(1, -1) + cursor = index + token.length + } + + out += text.slice(cursor) + return out + }), + }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer)) diff --git a/packages/core/test/auth-well-known.test.ts b/packages/core/test/auth-well-known.test.ts new file mode 100644 index 00000000000..11dee975dc5 --- /dev/null +++ b/packages/core/test/auth-well-known.test.ts @@ -0,0 +1,161 @@ +import { describe, expect } from "bun:test" +import path from "path" +import { Effect, Layer } from "effect" +import { HttpClient, HttpClientResponse } from "effect/unstable/http" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { Substitution } from "@opencode-ai/core/substitution" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.empty) + +const unexpectedHttpClient = HttpClient.make((request) => Effect.die(`unexpected http request: ${request.url}`)) + +const withAuthWellKnown = ( + dir: string, + effect: Effect.Effect, + client = unexpectedHttpClient, +) => + effect.pipe( + Effect.provide(AuthWellKnown.layer), + Effect.provide(AppFileSystem.defaultLayer), + Effect.provide(Global.layerWith({ data: dir })), + Effect.provide(Layer.succeed(HttpClient.HttpClient, client)), + Effect.provide(Substitution.defaultLayer), + ) + +const wellKnownConfigClient = HttpClient.make((request) => { + if (request.url === "https://example.com/.well-known/opencode") { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + Response.json({ + config: { instructions: ["local"] }, + remote_config: { + url: "https://remote.example.com/config", + headers: { + authorization: "Bearer {env:TEST_TOKEN}", + }, + }, + }), + ), + ) + } + if (request.url === "https://remote.example.com/config") { + expect(request.headers.authorization).toBe("Bearer secret") + return Effect.succeed(HttpClientResponse.fromWeb(request, Response.json({ model: "remote/model" }))) + } + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))) +}) + +describe("AuthWellKnown", () => { + it.live("stores well-known credentials", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + + yield* withAuthWellKnown( + tmp.path, + Effect.gen(function* () { + const auth = yield* AuthWellKnown.Service + yield* auth.set("https://example.com/", new AuthWellKnown.Entry({ key: "TEST_TOKEN", token: "secret" })) + }), + ) + + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({ + "https://example.com": { + key: "TEST_TOKEN", + token: "secret", + }, + }) + }), + ) + + it.live("migrates legacy well-known auth records", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, "auth.json"), + JSON.stringify({ + "https://example.com": { + type: "wellknown", + key: "TEST_TOKEN", + token: "secret", + }, + }), + ), + ) + + const entry = yield* withAuthWellKnown( + tmp.path, + Effect.gen(function* () { + const auth = yield* AuthWellKnown.Service + return yield* auth.get("https://example.com/") + }), + ) + + expect(entry).toEqual({ + key: "TEST_TOKEN", + token: "secret", + }) + expect(yield* Effect.promise(() => Bun.file(path.join(tmp.path, "well-known.json")).json())).toEqual({ + "https://example.com": { + key: "TEST_TOKEN", + token: "secret", + }, + }) + }), + ) + + it.live("loads config documents", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + yield* Effect.promise(() => + Bun.write( + path.join(tmp.path, "well-known.json"), + JSON.stringify({ + "https://example.com": { + key: "TEST_TOKEN", + token: "secret", + }, + }), + ), + ) + + const result = yield* withAuthWellKnown( + tmp.path, + Effect.gen(function* () { + const auth = yield* AuthWellKnown.Service + return yield* auth.configs() + }), + wellKnownConfigClient, + ) + + expect(result).toEqual([ + { + url: "https://example.com", + source: "https://example.com/.well-known/opencode", + dir: "https://example.com/.well-known", + content: { instructions: ["local"] }, + }, + { + url: "https://remote.example.com/config", + source: "https://remote.example.com/config", + dir: "https://remote.example.com", + content: { model: "remote/model" }, + }, + ]) + }), + ) +}) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 3775123d83b..9c2245e43ad 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,5 +1,6 @@ import type { Argv } from "yargs" import { Auth } from "../../auth" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" import { cmd } from "./cmd" import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" @@ -253,6 +254,7 @@ export const ProvidersListCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.list")(function* (_args) { const authSvc = yield* Auth.Service + const authWellKnown = yield* AuthWellKnown.Service const modelsDev = yield* ModelsDev.Service UI.empty() @@ -260,7 +262,8 @@ export const ProvidersListCommand = effectCmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(yield* Effect.orDie(authSvc.all())) + const results = Object.entries(yield* Effect.orDie(authSvc.all())).filter(([, result]) => result.type !== "wellknown") + const wellKnownResults = Object.entries(yield* Effect.orDie(authWellKnown.all())) const database = yield* modelsDev.get() for (const [providerID, result] of results) { @@ -268,7 +271,11 @@ export const ProvidersListCommand = effectCmd({ yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) } - yield* Prompt.outro(`${results.length} credentials`) + for (const [url] of wellKnownResults) { + yield* Prompt.log.info(`${url} ${UI.Style.TEXT_DIM}wellknown`) + } + + yield* Prompt.outro(`${results.length + wellKnownResults.length} credentials`) const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -319,19 +326,19 @@ export const ProvidersLoginCommand = effectCmd({ }), handler: Effect.fn("Cli.providers.login")(function* (args) { const authSvc = yield* Auth.Service + const authWellKnown = yield* AuthWellKnown.Service UI.empty() yield* Prompt.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => - fetch(`${url}/.well-known/opencode`).then((x) => x.json()), - )) as { - auth: { command: string[]; env: string } - } + const wellknown = yield* authWellKnown.metadata(url).pipe( + Effect.mapError((error) => new CliError({ message: `Failed to load auth provider metadata from ${url}: ${errorMessage(error)}` })), + ) + if (!wellknown.auth) return yield* fail(`Auth provider metadata from ${url} is missing auth configuration`) yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const abort = new AbortController() - const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal }) + const proc = Process.spawn([...wellknown.auth.command], { stdout: "pipe", stderr: "inherit", abort: abort.signal }) if (!proc.stdout) { yield* Prompt.log.error("Failed") yield* Prompt.outro("Done") @@ -345,7 +352,7 @@ export const ProvidersLoginCommand = effectCmd({ yield* Prompt.outro("Done") return } - yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) + yield* Effect.orDie(authWellKnown.set(url, new AuthWellKnown.Entry({ key: wellknown.auth.env, token: token.trim() }))) yield* Prompt.log.success("Logged into " + url) yield* Prompt.outro("Done") return @@ -500,35 +507,47 @@ export const ProvidersLogoutCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.logout")(function* (args) { const authSvc = yield* Auth.Service + const authWellKnown = yield* AuthWellKnown.Service const modelsDev = yield* ModelsDev.Service UI.empty() - const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + const credentials = [ + ...Object.entries(yield* Effect.orDie(authSvc.all())) + .filter(([, value]) => value.type !== "wellknown") + .map(([key, value]) => ({ key, type: value.type, auth: "provider" as const })), + ...Object.keys(yield* Effect.orDie(authWellKnown.all())).map((key) => ({ + key, + type: "wellknown" as const, + auth: "wellknown" as const, + })), + ] yield* Prompt.intro("Remove credential") if (credentials.length === 0) { yield* Prompt.log.error("No credentials found") return } const database = yield* modelsDev.get() - const options = credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, - })) - const provider = args.provider - ? options.find( - (option) => - option.value === args.provider || - database[option.value]?.name?.toLowerCase() === args.provider?.toLowerCase(), - )?.value - : yield* promptValue( - yield* Prompt.autocomplete({ - message: "Select provider", - maxItems: 8, - options, - }), + const credential = args.provider + ? credentials.find( + (item) => + item.key === args.provider || + database[item.key]?.name?.toLowerCase() === args.provider?.toLowerCase(), ) - if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) - yield* Effect.orDie(authSvc.remove(provider)) + : credentials[ + yield* promptValue( + yield* Prompt.autocomplete({ + message: "Select provider", + maxItems: 8, + options: credentials.map((item, index) => ({ + label: (database[item.key]?.name || item.key) + UI.Style.TEXT_DIM + " (" + item.type + ")", + value: index, + })), + }), + ) + ] + if (!credential) return yield* fail(`Unknown configured provider "${args.provider}"`) + if (credential.auth === "wellknown") yield* Effect.orDie(authWellKnown.remove(credential.key)) + else yield* Effect.orDie(authSvc.remove(credential.key)) yield* Prompt.outro("Logout successful") }), }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7f568f49207..0d632239bce 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -8,7 +8,8 @@ import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" import { Flag } from "@opencode-ai/core/flag/flag" -import { Auth } from "../auth" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" +import { Substitution } from "@opencode-ai/core/substitution" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" @@ -16,14 +17,14 @@ import { existsSync } from "fs" import { Account } from "@/account/account" import { isRecord } from "@/util/record" import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { InstanceState } from "@/effect/instance-state" -import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { Context, Duration, Effect, Exit, Fiber, Layer, Option } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { RemoteAuthError } from "@opencode-ai/core/v1/config/error" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { ConfigAgent } from "./agent" @@ -32,9 +33,7 @@ import { ConfigManaged } from "./managed" import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" import { ConfigPlugin } from "./plugin" -import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" -import { withTransientReadRetry } from "@/util/effect-http-client" // Custom merge function that concatenates array fields instead of replacing them // Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. @@ -61,43 +60,6 @@ function normalizeLoadedConfig(data: unknown) { return copy } -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( - await Promise.all( - Object.entries(input.value.headers) - .filter((entry): entry is [string, string] => typeof entry[1] === "string") - .map(async ([key, value]) => [ - key, - await ConfigVariable.substitute({ - text: value, - type: "virtual", - dir: input.dir, - source: input.source, - env: input.env, - }), - ]), - ), - ) - : undefined - - return { url, headers } -} - async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -175,54 +137,23 @@ function writableGlobal(info: Info) { export const layer = Layer.effect( Service, Effect.gen(function* () { - const fs = yield* FSUtil.Service - const authSvc = yield* Auth.Service + const fs = yield* AppFileSystem.Service + const authWellKnown = yield* AuthWellKnown.Service + const substitution = yield* Substitution.Service 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, - loginOrigin: string, - ) { - 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)}`))), - ) - const body = yield* response.text.pipe( - Effect.catch((error) => Effect.die(new Error(`failed to read remote config from ${url}: ${String(error)}`))), - ) - // An auth proxy can answer with an HTML login page at HTTP 200 (passes filterStatusOk); treat it as a re-auth error, not a decode failure. - const contentType = (response.headers["content-type"] ?? "").toLowerCase() - if (contentType.includes("html") || /^\s* 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, env } - : { text, type: "virtual", ...options, env }, - ), - ) + const expanded = yield* substitution.substitute( + "path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options }, + ).pipe(Effect.orDie) const parsed = ConfigParse.jsonc(expanded, source) const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed), source) if (!("path" in options)) return data @@ -236,14 +167,14 @@ export const layer = Layer.effect( return data }) - const loadFile = Effect.fnUntraced(function* (filepath: string, env?: Record) { + const loadFile = Effect.fnUntraced(function* (filepath: string) { yield* Effect.logInfo("loading", { path: filepath }) const text = yield* readConfigFile(filepath) if (!text) return {} as Info - return yield* loadConfig(text, { path: filepath }, env) + return yield* loadConfig(text, { path: filepath }) }) - const loadGlobal = Effect.fnUntraced(function* (env?: Record) { + const loadGlobal = Effect.fnUntraced(function* () { 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. @@ -255,9 +186,9 @@ export const layer = Layer.effect( .pipe(Effect.catch(() => Effect.void)) } } - 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)) + 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"))) const legacy = path.join(Global.Path.config, "config") if (existsSync(legacy)) { @@ -312,10 +243,7 @@ export const layer = Layer.effect( const loadInstanceState = Effect.fn("Config.loadInstanceState")( function* (ctx: InstanceContext) { - const auth = yield* authSvc.all().pipe(Effect.orDie) - let result: Info = {} - const authEnv: Record = {} const consoleManagedProviders = new Set() let activeOrgName: string | undefined @@ -352,59 +280,26 @@ export const layer = Layer.effect( return mergePluginOrigins(source, next.plugin, kind) } - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - const url = key.replace(/\/+$/, "") - authEnv[value.key] = value.token - const wellknownURL = `${url}/.well-known/opencode` - yield* Effect.logDebug("fetching remote config", { url: wellknownURL }) - const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url) - const remote = yield* Effect.promise(() => - substituteWellKnownRemoteConfig({ - value: wellknown.remote_config, - dir: url, - source: wellknownURL, - env: authEnv, - }), - ) - const fetchedConfig = remote - ? yield* Effect.gen(function* () { - yield* Effect.logDebug("fetching remote config", { url: remote.url }) - const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url) - 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(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, - }, - authEnv, - ) - yield* merge(source, next, "global") - yield* Effect.logDebug("loaded remote config from well-known", { url }) - } + for (const item of yield* authWellKnown.configs().pipe(Effect.orDie)) { + yield* merge( + item.source, + yield* loadConfig(JSON.stringify(item.content), { dir: item.dir, source: item.source }), + "global", + ) + yield* Effect.logDebug("loaded well-known config", { url: item.url }) } - const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal() + const global = yield* getGlobal() yield* merge(Global.Path.config, global, "global") if (Flag.OPENCODE_CONFIG) { - yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG, authEnv)) + yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) yield* Effect.logDebug("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, authEnv), "local") + yield* merge(file, yield* loadFile(file), "local") } } @@ -425,7 +320,7 @@ export const layer = Layer.effect( for (const file of ["opencode.json", "opencode.jsonc"]) { const source = path.join(dir, file) yield* Effect.logDebug(`loading config from ${source}`) - yield* merge(source, yield* loadFile(source, authEnv)) + yield* merge(source, yield* loadFile(source)) result.agent ??= {} result.mode ??= {} result.plugin ??= [] @@ -673,14 +568,15 @@ export const layer = Layer.effect( export const defaultLayer = layer.pipe( Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(Auth.defaultLayer), + Layer.provide(AuthWellKnown.defaultLayer), + Layer.provide(Substitution.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), Layer.provide(FetchHttpClient.layer), ) -export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient]) +export const node = LayerNode.make(defaultLayer, [FSUtil.node, Account.node, Env.node, Npm.node, httpClient]) export * as Config from "./config" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index edc7674a930..cfff93c8851 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -10,14 +10,14 @@ import { resolveHostAttentionSoundPaths } from "./tui-host-attention" import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@opencode-ai/tui/util/record" import { Global } from "@opencode-ai/core/global" -import { FSUtil } from "@opencode-ai/core/fs-util" import { CurrentWorkingDirectory } from "./tui-cwd" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Substitution } from "@opencode-ai/core/substitution" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "@opencode-ai/tui/config/keybind" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" -import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import { FormatError, FormatUnknownError } from "@/cli/error" import { TuiConfig } from "@opencode-ai/tui/config" @@ -79,7 +79,8 @@ function dropUnknownKeybinds(input: Record) { } const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { - const afs = yield* FSUtil.Service + const afs = yield* AppFileSystem.Service + const substitution = yield* Substitution.Service let appliedOrder = 0 const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => @@ -96,9 +97,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string): Effect.Effect => Effect.gen(function* () { - const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), - ) + const expanded = yield* substitution.substitute({ text, type: "path", path: configFilepath, missing: "empty" }).pipe(Effect.orDie) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json @@ -257,7 +256,11 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("TuiConfig.layer")), ) -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Npm.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Substitution.defaultLayer), +) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts deleted file mode 100644 index 52c449538fa..00000000000 --- a/packages/opencode/src/config/variable.ts +++ /dev/null @@ -1,91 +0,0 @@ -export * as ConfigVariable from "./variable" - -import path from "path" -import os from "os" -import { Filesystem } from "@/util/filesystem" -import { InvalidError } from "@opencode-ai/core/v1/config/error" - -type ParseSource = - | { - type: "path" - path: string - } - | { - type: "virtual" - source: string - dir: string - } - -type SubstituteInput = ParseSource & { - text: string - missing?: "error" | "empty" - env?: Record -} - -function source(input: ParseSource) { - return input.type === "path" ? input.path : input.source -} - -function dir(input: ParseSource) { - return input.type === "path" ? path.dirname(input.path) : input.dir -} - -/** Apply {env:VAR} and {file:path} substitutions to config text. */ -export async function substitute(input: SubstituteInput) { - const missing = input.missing ?? "error" - let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - return (input.env?.[varName] ?? process.env[varName]) || "" - }) - - const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) - if (!fileMatches.length) return text - - const configDir = dir(input) - const configSource = source(input) - let out = "" - let cursor = 0 - - for (const match of fileMatches) { - const token = match[0] - const index = match.index - out += text.slice(cursor, index) - - const lineStart = text.lastIndexOf("\n", index - 1) + 1 - const prefix = text.slice(lineStart, index).trimStart() - if (prefix.startsWith("//")) { - out += token - cursor = index + token.length - continue - } - - let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") - if (filePath.startsWith("~/")) { - filePath = path.join(os.homedir(), filePath.slice(2)) - } - - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) - const fileContent = ( - await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { - if (missing === "empty") return "" - - const errMsg = `bad file reference: "${token}"` - if (error.code === "ENOENT") { - throw new InvalidError( - { - path: configSource, - message: errMsg + ` ${resolvedPath} does not exist`, - }, - { cause: error }, - ) - } - throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) - }) - ).trim() - - out += JSON.stringify(fileContent).slice(1, -1) - cursor = index + token.length - } - - out += text.slice(cursor) - return out -} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 30dbb4c880a..ac4d91f06c6 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -2,8 +2,9 @@ import { Layer, ManagedRuntime } from "effect" import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/observability" -import { FSUtil } from "@opencode-ai/core/fs-util" import { Database } from "@opencode-ai/core/database/database" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" import { Auth } from "@/auth" import { Account } from "@/account/account" import { Config } from "@/config/config" @@ -54,8 +55,9 @@ import { EventV2Bridge } from "@/event-v2-bridge" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, - FSUtil.defaultLayer, + AppFileSystem.defaultLayer, Database.defaultLayer, + AuthWellKnown.defaultLayer, Auth.defaultLayer, Account.defaultLayer, Config.defaultLayer, diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 4c894b0a011..81c9e61cc8e 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 { FSUtil } from "@opencode-ai/core/fs-util" import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Substitution } from "@opencode-ai/core/substitution" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import path from "path" @@ -13,6 +14,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" +import { AuthWellKnownTest } from "../fake/auth-well-known" import { NpmTest } from "../fake/npm" import { ProviderTest } from "../fake/provider" import { SkillTest } from "../fake/skill" @@ -26,7 +28,9 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age const provider = ProviderTest.fake() const configLayer = Config.layer.pipe( - Layer.provide(FSUtil.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 02ace536688..6935428525b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -2,7 +2,7 @@ import { test, expect, describe, afterEach, beforeEach, spyOn } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { Cause, Effect, Exit, Layer, Option } from "effect" import { NamedError } from "@opencode-ai/core/util/error" -import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" +import { FetchHttpClient } from "effect/unstable/http" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" @@ -11,7 +11,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { InstanceRef } from "../../src/effect/instance-ref" import type { InstanceContext } from "../../src/project/instance-context" -import { Auth } from "../../src/auth" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" import { Account } from "../../src/account/account" import { AccessToken, AccountID, OrgID } from "../../src/account/schema" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -37,82 +37,43 @@ import { ProjectV2 } from "@opencode-ai/core/project" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Substitution } from "@opencode-ai/core/substitution" import { AccountTest } from "../fake/account" -import { AuthTest } from "../fake/auth" +import { AuthWellKnownTest } from "../fake/auth-well-known" import { NpmTest } from "../fake/npm" /** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ const infra = CrossSpawnSpawner.defaultLayer.pipe( Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)), ) - const testFlock = EffectFlock.defaultLayer -const unexpectedHttp = HttpClient.make((request) => - Effect.die(`unexpected http request: ${request.method} ${request.url}`), -) +const emptyAccount = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), + activeOrg: () => Effect.succeed(Option.none()), +}) -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 - remoteHtml?: string - 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 (request.url.includes("config.example.com") && (input.remote !== undefined || input.remoteHtml !== undefined)) { - input.seen.remote = request.url - input.seen.authorization = request.headers.authorization - if (input.remoteHtml !== undefined) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(input.remoteHtml, { status: 200, headers: { "content-type": "text/html; charset=utf-8" } }), - ), - ) - } - return Effect.succeed(json(request, input.remote)) - } - return Effect.succeed(json(request, {}, 404)) - }) -} +const runSubstitution = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(Substitution.defaultLayer))) const configLayer = ( options: { - auth?: Layer.Layer + authWellKnown?: Layer.Layer account?: Layer.Layer - client?: HttpClient.HttpClient } = {}, ) => Config.layer.pipe( Layer.provide(testFlock), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provideMerge(FSUtil.defaultLayer), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(options.auth ?? AuthTest.empty), + Layer.provide(options.authWellKnown ?? AuthWellKnownTest.empty), Layer.provide(options.account ?? AccountTest.empty), Layer.provideMerge(infra), Layer.provide(NpmTest.noop), - Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)), - Layer.provideMerge(FSUtil.defaultLayer), + Layer.provide(FetchHttpClient.layer), ) const layer = configLayer() @@ -122,6 +83,8 @@ const configIt = (options?: Parameters[0]) => testEffect(con const schemaConfig = (config: object) => ({ $schema: "https://opencode.ai/config.json", ...config }) +const isObjectRecord = (value: unknown): value is Record => typeof value === "object" && value !== null + const provideCurrentInstance = (effect: Effect.Effect, ctx: InstanceContext) => effect.pipe(Effect.provideService(InstanceRef, ctx)) @@ -228,18 +191,59 @@ const wellKnown = (input: { wellKnown?: unknown }) => { const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: input.wellKnown ?? { - ...(input.config !== undefined ? { config: input.config } : {}), - ...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}), - }, - remote: input.remote, - remoteHtml: input.remoteHtml, + const authUrl = (input.authUrl ?? "https://example.com").replace(/\/+$/, "") + const metadata = + isObjectRecord(input.wellKnown) + ? input.wellKnown + : { + ...(input.config !== undefined ? { config: input.config } : {}), + ...(input.remoteConfig !== undefined ? { remote_config: input.remoteConfig } : {}), + } + const substitute = (value: string) => value.replaceAll("{env:TEST_TOKEN}", "test-token") + const authWellKnown = Layer.mock(AuthWellKnown.Service, { + configs: () => + Effect.gen(function* () { + const source = `${authUrl}/.well-known/opencode` + seen.wellKnown = source + const documents: Array<{ url: string; source: string; dir: string; content: unknown }> = [] + if (isObjectRecord(metadata.config)) { + documents.push({ + url: authUrl, + source, + dir: path.dirname(source), + content: metadata.config, + }) + } + + if (isObjectRecord(metadata.remote_config) && typeof metadata.remote_config.url === "string") { + const remoteURL = substitute(metadata.remote_config.url) + seen.remote = remoteURL + if (isObjectRecord(metadata.remote_config.headers)) { + const authHeader = metadata.remote_config.headers["Authorization"] + if (typeof authHeader === "string") seen.authorization = substitute(authHeader) + } + if (input.remoteHtml) { + const error = Object.assign(new Error("Login required"), { + name: "ConfigRemoteAuthError", + data: { url: authUrl }, + }) + return yield* Effect.die(error) + } + if (!isObjectRecord(input.remote)) return yield* Effect.die(new Error("expected object")) + documents.push({ + url: remoteURL, + source: remoteURL, + dir: path.dirname(remoteURL), + content: isObjectRecord(input.remote.config) ? input.remote.config : input.remote, + }) + } + + return documents + }), }) return { seen, - it: configIt({ auth: wellKnownAuth(input.authUrl ?? "https://example.com"), client }), + it: configIt({ authWellKnown }), } } @@ -564,6 +568,30 @@ it.instance("handles file inclusion with replacement tokens", () => }), ) +test("environment variable substitution accepts an env overlay", async () => { + const originalEnv = process.env["TEST_VAR"] + delete process.env["TEST_VAR"] + + try { + expect( + await runSubstitution( + Substitution.Service.use((substitution) => + substitution.substitute({ + text: "{env:TEST_VAR}", + type: "virtual", + dir: "/tmp", + source: "test", + env: { TEST_VAR: "overlay" }, + }), + ), + ), + ).toBe("overlay") + } finally { + if (originalEnv === undefined) delete process.env["TEST_VAR"] + else process.env["TEST_VAR"] = originalEnv + } +}) + const accountTokenIt = configIt({ account: Layer.mock(Account.Service)({ active: () => @@ -1501,58 +1529,6 @@ trailingSlashWellKnown.it.instance("wellknown URL with trailing slash is normali }), ) -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( - () => - 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( - Layer.mergeAll( - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(FSUtil.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), - ), - testInstanceStoreLayer, - ), - ), - Effect.runPromise, - ) - } finally { - await server.stop(true) - } -}) - const templatedHeaderWellKnown = wellKnown({ remoteConfig: { url: "https://config.example.com/opencode.json", diff --git a/packages/opencode/test/fake/auth-well-known.ts b/packages/opencode/test/fake/auth-well-known.ts new file mode 100644 index 00000000000..5a9f13dccd9 --- /dev/null +++ b/packages/opencode/test/fake/auth-well-known.ts @@ -0,0 +1,8 @@ +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" +import { Effect, Layer } from "effect" + +export const AuthWellKnownTest = { + empty: Layer.mock(AuthWellKnown.Service, { + configs: () => Effect.succeed([]), + }), +} diff --git a/packages/opencode/test/plugin/trigger.test.ts b/packages/opencode/test/plugin/trigger.test.ts index 7bd9e33527f..f5fd8093850 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -2,8 +2,9 @@ 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 { FSUtil } from "@opencode-ai/core/fs-util" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Substitution } from "@opencode-ai/core/substitution" import path from "path" import { pathToFileURL } from "url" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -11,6 +12,7 @@ import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" +import { AuthWellKnownTest } from "../fake/auth-well-known" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -22,7 +24,9 @@ import { ModelV2 } from "@opencode-ai/core/model" const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty), diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index 30428980bbf..7584bf36efa 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -3,9 +3,11 @@ import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Database } from "@opencode-ai/core/database/database" +import { AppFileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Substitution } from "@opencode-ai/core/substitution" import path from "path" import { pathToFileURL } from "url" import { Auth } from "../../src/auth" @@ -27,10 +29,13 @@ import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +import { AuthWellKnownTest } from "../fake/auth-well-known" const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), - Layer.provide(FSUtil.defaultLayer), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty),