diff --git a/packages/core/src/auth-well-known.ts b/packages/core/src/auth-well-known.ts new file mode 100644 index 000000000000..9f7990301ff5 --- /dev/null +++ b/packages/core/src/auth-well-known.ts @@ -0,0 +1,242 @@ +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 { 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: AppFileSystem.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.Effect> = 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/substitution.ts b/packages/core/src/substitution.ts new file mode 100644 index 000000000000..b387e35d75ea --- /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 000000000000..11dee975dc53 --- /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/core/test/auth.test.ts b/packages/core/test/auth.test.ts new file mode 100644 index 000000000000..2d864718c069 --- /dev/null +++ b/packages/core/test/auth.test.ts @@ -0,0 +1,49 @@ +import { describe, expect } from "bun:test" +import { Effect, Layer } from "effect" +import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Global } from "@opencode-ai/core/global" +import { AuthV2 } from "@opencode-ai/core/auth" +import { tmpdir } from "./fixture/tmpdir" +import { testEffect } from "./lib/effect" + +const it = testEffect(Layer.empty) + +const withAuth = (dir: string, effect: Effect.Effect) => + effect.pipe( + Effect.provide(AuthV2.layer), + Effect.provide(AppFileSystem.defaultLayer), + Effect.provide(Global.layerWith({ data: dir })), + ) + +describe("AuthV2", () => { + it.live("stores api credentials", () => + Effect.gen(function* () { + const tmp = yield* Effect.acquireRelease( + Effect.promise(() => tmpdir()), + (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()), + ) + + const account = yield* withAuth( + tmp.path, + Effect.gen(function* () { + const auth = yield* AuthV2.Service + return yield* auth.create({ + serviceID: AuthV2.ServiceID.make("anthropic"), + credential: new AuthV2.ApiKeyCredential({ type: "api", key: "sk-test" }), + }) + }), + ) + + const active = yield* withAuth( + tmp.path, + Effect.gen(function* () { + const auth = yield* AuthV2.Service + return yield* auth.active(AuthV2.ServiceID.make("anthropic")) + }), + ) + + expect(active?.id).toBe(account.id) + expect(active?.credential).toEqual({ type: "api", key: "sk-test" }) + }), + ) +}) diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts index 25f1bf968c30..a8863139fa8c 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,4 +1,5 @@ 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" @@ -252,6 +253,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() @@ -259,7 +261,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) { @@ -267,7 +270,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 }> = [] @@ -316,19 +323,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") @@ -342,7 +349,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 @@ -492,10 +499,20 @@ 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") @@ -504,12 +521,15 @@ export const ProvidersLogoutCommand = effectCmd({ const database = yield* modelsDev.get() const selected = yield* Prompt.select({ message: "Select provider", - options: credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, + options: credentials.map((item, index) => ({ + label: (database[item.key]?.name || item.key) + UI.Style.TEXT_DIM + " (" + item.type + ")", + value: index, })), }) - yield* Effect.orDie(authSvc.remove(yield* promptValue(selected))) + const credential = credentials[yield* promptValue(selected)] + if (!credential) return + 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/cli/cmd/tui/config/tui.ts b/packages/opencode/src/cli/cmd/tui/config/tui.ts index 0d4be41dfc0d..2ab82a1ec59d 100644 --- a/packages/opencode/src/cli/cmd/tui/config/tui.ts +++ b/packages/opencode/src/cli/cmd/tui/config/tui.ts @@ -12,6 +12,7 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@/util/record" import { Global } from "@opencode-ai/core/global" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Substitution } from "@opencode-ai/core/substitution" import { CurrentWorkingDirectory } from "./cwd" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "./keybind" @@ -19,7 +20,6 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" import * as Log from "@opencode-ai/core/util/log" -import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import type { DeepMutable } from "@opencode-ai/core/schema" import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui" @@ -98,6 +98,7 @@ function dropUnknownKeybinds(input: Record, configFilepath: str const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* AppFileSystem.Service + const substitution = yield* Substitution.Service let appliedOrder = 0 const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => @@ -112,9 +113,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 @@ -295,7 +294,11 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("TuiConfig.layer")), ) -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(AppFileSystem.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/config.ts b/packages/opencode/src/config/config.ts index b13d3a8c8131..4cbc7d89c543 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" import { NamedError } from "@opencode-ai/core/util/error" 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" @@ -38,7 +39,6 @@ import { ConfigProvider } from "./provider" import { ConfigReference } from "./reference" import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" -import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" const log = Log.create({ service: "config" }) @@ -69,36 +69,6 @@ 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 - - const url = await ConfigVariable.substitute({ - text: input.value.url, - type: "virtual", - dir: input.dir, - source: input.source, - }) - 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, - }), - ]), - ), - ) - : 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++) { @@ -365,7 +335,8 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* AppFileSystem.Service - const authSvc = yield* Auth.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 @@ -377,11 +348,9 @@ export const layer = Layer.effect( options: { path: string } | { dir: string; source: 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 }, - ), - ) + 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(Info, normalizeLoadedConfig(parsed, source), source) if (!("path" in options)) return data @@ -471,8 +440,6 @@ 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 consoleManagedProviders = new Set() let activeOrgName: string | undefined @@ -510,46 +477,13 @@ 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(/\/+$/, "") - process.env[value.key] = value.token - log.debug("fetching remote config", { url: `${url}/.well-known/opencode` }) - const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`)) - if (!response.ok) { - throw new Error(`failed to fetch remote config from ${url}: ${response.status}`) - } - const wellknown = (yield* Effect.promise(() => response.json())) as { - config?: Record - remote_config?: unknown - } - const remote = yield* Effect.promise(() => - substituteWellKnownRemoteConfig({ - value: wellknown.remote_config, - dir: url, - source: `${url}/.well-known/opencode`, - }), - ) - const fetchedConfig = remote - ? ((yield* Effect.promise(async () => { - 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 remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info) - 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, - }) - yield* merge(source, next, "global") - log.debug("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", + ) + log.debug("loaded well-known config", { url: item.url }) } const global = yield* getGlobal() @@ -825,7 +759,8 @@ export const defaultLayer = layer.pipe( Layer.provide(EffectFlock.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), ) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts deleted file mode 100644 index e61e06d41bbe..000000000000 --- a/packages/opencode/src/config/variable.ts +++ /dev/null @@ -1,90 +0,0 @@ -export * as ConfigVariable from "./variable" - -import path from "path" -import os from "os" -import { Filesystem } from "@/util/filesystem" -import { InvalidError } from "./error" - -type ParseSource = - | { - type: "path" - path: string - } - | { - type: "virtual" - source: string - dir: string - } - -type SubstituteInput = ParseSource & { - text: string - missing?: "error" | "empty" -} - -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 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 0ce876ddc65a..a94b4d19bc4f 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -3,6 +3,7 @@ import { attach } from "./run-service" import * as Observability from "@opencode-ai/core/effect/observability" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" import { Bus } from "@/bus" import { Auth } from "@/auth" import { Account } from "@/account/account" @@ -62,6 +63,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" export const AppLayer = Layer.mergeAll( Npm.defaultLayer, AppFileSystem.defaultLayer, + AuthWellKnown.defaultLayer, Bus.defaultLayer, Auth.defaultLayer, Account.defaultLayer, diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index c437281cc6b6..bf54e929d053 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,5 +1,6 @@ import { expect } from "bun:test" import { AppFileSystem } from "@opencode-ai/core/filesystem" +import { Substitution } from "@opencode-ai/core/substitution" import { Effect, Layer } from "effect" import path from "path" import { pathToFileURL } from "url" @@ -11,6 +12,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" @@ -25,6 +27,8 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age const provider = ProviderTest.fake() const configLayer = Config.layer.pipe( 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 4d3ed45d2fc6..7aeddf534a7b 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,7 +8,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 { AppFileSystem } from "@opencode-ai/core/filesystem" @@ -31,16 +31,14 @@ import { ProjectID } from "../../src/project/schema" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" import { Npm } from "@opencode-ai/core/npm" +import { Substitution } from "@opencode-ai/core/substitution" +import { AuthWellKnownTest } from "../fake/auth-well-known" 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 testFlock = EffectFlock.defaultLayer const noopNpm = Layer.mock(Npm.Service)({ @@ -49,11 +47,15 @@ const noopNpm = Layer.mock(Npm.Service)({ which: () => Effect.succeed(Option.none()), }) +const runSubstitution = (effect: Effect.Effect) => + Effect.runPromise(effect.pipe(Effect.provide(Substitution.defaultLayer))) + const layer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), + Layer.provide(AuthWellKnownTest.empty), Layer.provide(emptyAccount), Layer.provideMerge(infra), Layer.provide(noopNpm), @@ -486,6 +488,30 @@ test("handles environment variable substitution", async () => { } }) +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 + } +}) + test("preserves env variables when adding $schema to config", async () => { const originalEnv = process.env["PRESERVE_VAR"] process.env["PRESERVE_VAR"] = "secret_value" @@ -564,8 +590,9 @@ test("resolves env templates in account config with account token", async () => const layer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), + Layer.provide(AuthWellKnownTest.empty), Layer.provide(fakeAccount), Layer.provideMerge(infra), Layer.provide(noopNpm), @@ -1075,8 +1102,9 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const testLayer = Config.layer.pipe( Layer.provide(testFlock), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(emptyAuth), + Layer.provide(AuthWellKnownTest.empty), Layer.provide(emptyAccount), Layer.provideMerge(infra), Layer.provide(noopNpm), @@ -1938,192 +1966,44 @@ test("local .opencode config can override MCP from project config", async () => }) test("project config overrides remote well-known config", async () => { - const originalFetch = globalThis.fetch - let fetchedUrl: string | undefined - globalThis.fetch = mock((url: string | URL | Request) => { - const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url - if (urlStr.includes(".well-known/opencode")) { - fetchedUrl = urlStr - return Promise.resolve( - new Response( - JSON.stringify({ - config: { - mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, - }, - }), - { status: 200 }, - ), - ) - } - return originalFetch(url) - }) as unknown as typeof fetch - - const fakeAuth = Layer.mock(Auth.Service)({ - all: () => - Effect.succeed({ - "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), - }), - }) - - const 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(fetchedUrl).toBe("https://example.com/.well-known/opencode") - expect(config.mcp?.jira?.enabled).toBe(true) - }), - ), - { - git: true, - config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } }, - }, - ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) - } finally { - globalThis.fetch = originalFetch - } -}) - -test("wellknown URL with trailing slash is normalized", async () => { - const originalFetch = globalThis.fetch - let fetchedUrl: string | undefined - globalThis.fetch = mock((url: string | URL | Request) => { - const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url - if (urlStr.includes(".well-known/opencode")) { - fetchedUrl = urlStr - return Promise.resolve( - new Response( - JSON.stringify({ - config: { - mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } }, - }, - }), - { status: 200 }, - ), - ) - } - return originalFetch(url) - }) as unknown as typeof fetch - - const fakeAuth = Layer.mock(Auth.Service)({ - all: () => - Effect.succeed({ - "https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), - }), - }) - - const 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) - } finally { - globalThis.fetch = originalFetch - } -}) - -test("wellknown remote_config supports templated env vars in headers", async () => { - const originalFetch = globalThis.fetch - const originalToken = process.env.TEST_TOKEN - let wellknownFetchedUrl: string | undefined - let remoteFetchedUrl: string | undefined - let remoteHeaders: HeadersInit | undefined - globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => { - const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url - if (urlStr.includes(".well-known/opencode")) { - wellknownFetchedUrl = urlStr - return Promise.resolve( - new Response( - JSON.stringify({ - remote_config: { - url: "https://config.example.com/opencode.json", - headers: { - Authorization: "Bearer {env:TEST_TOKEN}", - }, - }, - }), - { status: 200 }, - ), - ) - } - if (urlStr.includes("config.example.com")) { - remoteFetchedUrl = urlStr - remoteHeaders = init?.headers - return Promise.resolve( - new Response( - JSON.stringify({ - mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, - }), - { status: 200 }, - ), - ) - } - return originalFetch(url, init) - }) as unknown as typeof fetch - - const fakeAuth = Layer.mock(Auth.Service)({ - all: () => - Effect.succeed({ - "https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), - }), + const fakeAuthWellKnown = Layer.mock(AuthWellKnown.Service)({ + configs: () => + Effect.succeed([ + { + url: "https://example.com", + source: "https://example.com/.well-known/opencode", + dir: "https://example.com/.well-known", + content: { + 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(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(fakeAuth), + Layer.provide(fakeAuthWellKnown), 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(config.mcp?.confluence?.enabled).toBe(true) - }), - ), - { git: true }, - ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) - } finally { - globalThis.fetch = originalFetch - if (originalToken === undefined) delete process.env.TEST_TOKEN - else process.env.TEST_TOKEN = originalToken - } + await provideTmpdirInstance( + () => + Config.Service.use((svc) => + Effect.gen(function* () { + const config = yield* svc.get() + 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) }) describe("resolvePluginSpec", () => { 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 000000000000..5a9f13dccd9a --- /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 94642fba629c..77b8417a20f5 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.test.ts @@ -3,6 +3,7 @@ import { Effect, Layer, Option } from "effect" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Substitution } from "@opencode-ai/core/substitution" import path from "path" import { pathToFileURL } from "url" import { Account } from "../../src/account/account" @@ -16,6 +17,7 @@ import { ModelID, ProviderID } from "../../src/provider/schema" import { provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { NpmTest } from "../fake/npm" +import { AuthWellKnownTest } from "../fake/auth-well-known" const emptyAccount = Layer.mock(Account.Service)({ active: () => Effect.succeed(Option.none()), @@ -27,6 +29,8 @@ const emptyAuth = Layer.mock(Auth.Service)({ const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(emptyAuth), Layer.provide(emptyAccount), diff --git a/packages/opencode/test/plugin/workspace-adapter.test.ts b/packages/opencode/test/plugin/workspace-adapter.test.ts index b4b40fe7677b..0dfbf5375716 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -4,6 +4,7 @@ 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 { Substitution } from "@opencode-ai/core/substitution" import path from "path" import { pathToFileURL } from "url" import { Account } from "../../src/account/account" @@ -25,6 +26,7 @@ import { SyncEvent } from "../../src/sync" import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { NpmTest } from "../fake/npm" +import { AuthWellKnownTest } from "../fake/auth-well-known" const emptyAccount = Layer.mock(Account.Service)({ active: () => Effect.succeed(Option.none()), @@ -36,6 +38,8 @@ const emptyAuth = Layer.mock(Auth.Service)({ const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(emptyAuth), Layer.provide(emptyAccount),