diff --git a/packages/core/src/auth-well-known.ts b/packages/core/src/auth-well-known.ts new file mode 100644 index 0000000000..9f7990301f --- /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 0000000000..b387e35d75 --- /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 0000000000..11dee975dc --- /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 560781ef0e..1ba711503b 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 0d4be41dfc..2ab82a1ec5 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 349b7e6a07..153fb35fcf 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -8,7 +8,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" @@ -19,7 +20,7 @@ import type { ConsoleState } from "./console-state" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { InstanceState } from "@/effect/instance-state" import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" +import { FetchHttpClient } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { NonNegativeInt, PositiveInt, type DeepMutable } from "@opencode-ai/core/schema" @@ -40,9 +41,7 @@ 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" -import { withTransientReadRetry } from "@/util/effect-http-client" const log = Log.create({ service: "config" }) @@ -72,48 +71,6 @@ function normalizeLoadedConfig(data: unknown, source: string) { 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 } -} - -const WellKnownConfig = Schema.Struct({ - config: Schema.optional(Schema.Json), - remote_config: Schema.optional(Schema.Json), -}) - async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -382,44 +339,26 @@ 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 - const http = yield* HttpClient.HttpClient - const readConfigFile = (filepath: string) => fs.readFileStringSafe(filepath).pipe(Effect.orDie) - const fetchRemoteJson = Effect.fnUntraced(function* ( - url: string, - headers: Record | undefined, - schema: S, - ) { - const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http)) - .execute( - HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})), - ) - .pipe( - Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))), - ) - return yield* HttpClientResponse.schemaBodyJson(schema)(response).pipe( - Effect.catch((error) => Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), - ) - }) - const loadConfig = Effect.fnUntraced(function* ( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, ) { const source = "path" in options ? options.path : options.source - const expanded = yield* Effect.promise(() => - ConfigVariable.substitute( + const expanded = yield* substitution + .substitute( "path" in options ? { text, type: "path", path: options.path, env } : { text, type: "virtual", ...options, env }, - ), - ) + ) + .pipe(Effect.orDie) const parsed = ConfigParse.jsonc(expanded, source) const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) if (!("path" in options)) return data @@ -509,8 +448,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 authEnv: Record = {} const consoleManagedProviders = new Set() @@ -549,46 +486,16 @@ 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` - log.debug("fetching remote config", { url: wellknownURL }) - const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, WellKnownConfig) - const remote = yield* Effect.promise(() => - substituteWellKnownRemoteConfig({ - value: wellknown.remote_config, - dir: url, - source: wellknownURL, - env: authEnv, - }), - ) - const fetchedConfig = remote - ? yield* Effect.gen(function* () { - log.debug("fetching remote config", { url: remote.url }) - const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json) - if (isRecord(data) && isRecord(data.config)) return data.config - 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") - log.debug("loaded remote config from well-known", { url }) - } + for (const value of Object.values(yield* authWellKnown.all().pipe(Effect.orDie))) { + authEnv[value.key] = value.token + } + 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 }, authEnv), + "global", + ) + log.debug("loaded well-known config", { url: item.url }) } const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal() @@ -868,7 +775,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), Layer.provide(FetchHttpClient.layer), diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts deleted file mode 100644 index 44c985c991..0000000000 --- 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 "./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 2bef35ed07..d0f066fb70 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 d79e01c788..27f55262b9 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 { FetchHttpClient } from "effect/unstable/http" import path from "path" @@ -12,6 +13,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,6 +28,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 04dcde32e1..7aeddf534a 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1,6 +1,5 @@ -import { test, expect, describe, afterEach, beforeEach } from "bun:test" -import { Effect, Exit, Layer, Option } from "effect" -import { FetchHttpClient, HttpClient, HttpClientResponse } from "effect/unstable/http" +import { test, expect, describe, mock, afterEach, beforeEach } from "bun:test" +import { Effect, Layer, Option } from "effect" import { NodeFileSystem, NodePath } from "@effect/platform-node" import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" @@ -9,19 +8,13 @@ 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" import { Env } from "../../src/env" -import { - provideTestInstance, - provideTmpdirInstance, - TestInstance, - tmpdir, - tmpdirScoped, - withTestInstance, -} from "../fixture/fixture" +import { provideTestInstance, provideTmpdirInstance, withTestInstance } from "../fixture/fixture" +import { tmpdir } from "../fixture/fixture" import { InstanceRuntime } from "@/project/instance-runtime" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { testEffect } from "../lib/effect" @@ -37,72 +30,37 @@ import { Global } from "@opencode-ai/core/global" import { ProjectID } from "../../src/project/schema" import { Filesystem } from "@/util/filesystem" import { ConfigPlugin } from "@/config/plugin" -import { AccountTest } from "../fake/account" -import { AuthTest } from "../fake/auth" -import { NpmTest } from "../fake/npm" +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 testFlock = EffectFlock.defaultLayer -const unexpectedHttp = HttpClient.make((request) => - Effect.die(`unexpected http request: ${request.method} ${request.url}`), +const noopNpm = Layer.mock(Npm.Service)({ + install: () => Effect.void, + add: () => Effect.die("not implemented"), + 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(AuthWellKnownTest.empty), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), ) -const json = (request: Parameters[0], body: unknown, status = 200) => - HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify(body), { - status, - headers: { "content-type": "application/json" }, - }), - ) - -const wellKnownAuth = (url: string) => - Layer.mock(Auth.Service)({ - all: () => - Effect.succeed({ - [url]: new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }), - }), - }) - -function remoteConfigClient(input: { - wellKnown: unknown - remote?: unknown - seen: { wellKnown?: string; remote?: string; authorization?: string } -}) { - return HttpClient.make((request) => { - if (request.url.includes(".well-known/opencode")) { - input.seen.wellKnown = request.url - return Effect.succeed(json(request, input.wellKnown)) - } - if (input.remote !== undefined && request.url.includes("config.example.com")) { - input.seen.remote = request.url - input.seen.authorization = request.headers.authorization - return Effect.succeed(json(request, input.remote)) - } - return Effect.succeed(json(request, {}, 404)) - }) -} - -const configLayer = ( - options: { - auth?: Layer.Layer - account?: Layer.Layer - client?: HttpClient.HttpClient - } = {}, -) => - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(options.auth ?? AuthTest.empty), - Layer.provide(options.account ?? AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(Layer.succeed(HttpClient.HttpClient, options.client ?? unexpectedHttp)), - ) - -const layer = configLayer() - const it = testEffect(layer) const provideCurrentInstance = (effect: Effect.Effect, ctx: InstanceContext) => @@ -112,16 +70,23 @@ const load = (ctx: InstanceContext) => Effect.runPromise( Config.Service.use((svc) => provideCurrentInstance(svc.get(), ctx)).pipe(Effect.scoped, Effect.provide(layer)), ) +const save = (config: Config.Info, ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.update(config), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) const saveGlobal = (config: Config.Info) => Effect.runPromise( - Config.use.updateGlobal(config).pipe( + Config.Service.use((svc) => svc.updateGlobal(config)).pipe( Effect.map((result) => result.info), Effect.scoped, Effect.provide(layer), ), ) const clear = async (wait = false) => { - await Effect.runPromise(Config.use.invalidate().pipe(Effect.scoped, Effect.provide(layer))) + await Effect.runPromise(Config.Service.use((svc) => svc.invalidate()).pipe(Effect.scoped, Effect.provide(layer))) if (wait) await InstanceRuntime.disposeAllInstances() } const listDirs = (ctx: InstanceContext) => @@ -131,9 +96,16 @@ const listDirs = (ctx: InstanceContext) => Effect.provide(layer), ), ) +const ready = (ctx: InstanceContext) => + Effect.runPromise( + Config.Service.use((svc) => provideCurrentInstance(svc.waitForDependencies(), ctx)).pipe( + Effect.scoped, + Effect.provide(layer), + ), + ) + // Get managed config directory from environment (set in preload.ts) const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR! -const originalTestToken = process.env.TEST_TOKEN beforeEach(async () => { await clear(true) @@ -141,8 +113,6 @@ beforeEach(async () => { afterEach(async () => { await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {}) - if (originalTestToken === undefined) delete process.env.TEST_TOKEN - else process.env.TEST_TOKEN = originalTestToken await clear(true) }) @@ -151,44 +121,10 @@ async function writeManagedSettings(settings: object, filename = "opencode.json" await Filesystem.write(path.join(managedConfigDir, filename), JSON.stringify(settings)) } -const writeManagedSettingsEffect = (settings: object, filename?: string) => - Effect.promise(() => writeManagedSettings(settings, filename)) - async function writeConfig(dir: string, config: object, name = "opencode.json") { await Filesystem.write(path.join(dir, name), JSON.stringify(config)) } -const writeConfigEffect = (dir: string, config: object, name = "opencode.json") => - Effect.promise(() => writeConfig(dir, config, name)) -const mkdirEffect = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true })) -const writeTextEffect = (file: string, content: string) => Effect.promise(() => Filesystem.write(file, content)) - -function withProcessEnv(key: string, value: string | undefined, effect: Effect.Effect) { - return withProcessEnvs({ [key]: value }, effect) -} - -function withProcessEnvs(entries: Record, effect: Effect.Effect) { - return Effect.acquireUseRelease( - Effect.sync(() => { - const originals: Record = {} - for (const [key, value] of Object.entries(entries)) { - originals[key] = process.env[key] - if (value === undefined) delete process.env[key] - else process.env[key] = value - } - return originals - }), - () => effect, - (originals) => - Effect.sync(() => { - for (const [key, original] of Object.entries(originals)) { - if (original !== undefined) process.env[key] = original - else delete process.env[key] - } - }), - ) -} - async function check(map: (dir: string) => string) { if (process.platform !== "win32") return await using globalTmp = await tmpdir() @@ -217,12 +153,16 @@ async function check(map: (dir: string) => string) { } } -it.instance("loads config with defaults when no files exist", () => - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.username).toBeDefined() - }), -) +test("loads config with defaults when no files exist", async () => { + await using tmp = await tmpdir() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.username).toBeDefined() + }, + }) +}) test("creates global jsonc config with schema when no global configs exist", async () => { await using tmp = await tmpdir() @@ -272,42 +212,67 @@ test("does not create global config when OPENCODE_CONFIG_DIR is set", async () = } }) -it.instance( - "loads JSON config file", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.model).toBe("test/model") - expect(config.username).toBe("testuser") - }), - { config: { model: "test/model", username: "testuser" } }, -) +test("loads JSON config file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "test/model", + username: "testuser", + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.model).toBe("test/model") + expect(config.username).toBe("testuser") + }, + }) +}) -it.instance( - "loads shell config field", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.shell).toBe("bash") - }), - { config: { shell: "bash" } }, -) +test("loads shell config field", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + shell: "bash", + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.shell).toBe("bash") + }, + }) +}) -it.instance("updates config and preserves empty shell sentinel", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect( - test.directory, - { $schema: "https://opencode.ai/config.json", shell: "bash" }, - "config.json", - ) +test("updates config and preserves empty shell sentinel", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + shell: "bash", + }, + "config.json", + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + await save({ shell: "" }, ctx) - yield* Config.Service.use((svc) => svc.update(ConfigParse.schema(Config.Info, { shell: "" }, "test:config"))) - - const writtenConfig = yield* Effect.promise(() => - Filesystem.readJson<{ shell?: string }>(path.join(test.directory, "config.json")), - ) - expect(writtenConfig.shell).toBe("") - }), -) + const writtenConfig = await Filesystem.readJson<{ shell?: string }>(path.join(tmp.path, "config.json")) + expect(writtenConfig.shell).toBe("") + }, + }) +}) test("updates global config and omits empty shell key in json", async () => { await using tmp = await tmpdir({ @@ -367,23 +332,41 @@ test("updates global config and omits empty shell key in jsonc", async () => { } }) -it.instance( - "loads formatter boolean config", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.formatter).toBe(true) - }), - { config: { formatter: true } }, -) +test("loads formatter boolean config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + formatter: true, + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.formatter).toBe(true) + }, + }) +}) -it.instance( - "loads lsp boolean config", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.lsp).toBe(true) - }), - { config: { lsp: true } }, -) +test("loads lsp boolean config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + lsp: true, + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.lsp).toBe(true) + }, + }) +}) test("loads project config from Git Bash and MSYS2 paths on Windows", async () => { // Git Bash and MSYS2 both use //... paths on Windows. @@ -402,135 +385,170 @@ test("loads project config from Cygwin paths on Windows", async () => { }) }) -it.instance("ignores legacy tui keys in opencode config", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - model: "test/model", - theme: "legacy", - tui: { scroll_speed: 4 }, - }) +test("ignores legacy tui keys in opencode config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "test/model", + theme: "legacy", + tui: { scroll_speed: 4 }, + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.model).toBe("test/model") + expect((config as Record).theme).toBeUndefined() + expect((config as Record).tui).toBeUndefined() + }, + }) +}) - const config = yield* Config.use.get() - expect(config.model).toBe("test/model") - expect((config as Record).theme).toBeUndefined() - expect((config as Record).tui).toBeUndefined() - }), -) - -it.instance("loads JSONC config file", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* Effect.promise(() => - Filesystem.write( - path.join(test.directory, "opencode.jsonc"), +test("loads JSONC config file", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.jsonc"), `{ // This is a comment "$schema": "https://opencode.ai/config.json", "model": "test/model", "username": "testuser" }`, - ), - ) - const config = yield* Config.use.get() - expect(config.model).toBe("test/model") - expect(config.username).toBe("testuser") - }), -) + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.model).toBe("test/model") + expect(config.username).toBe("testuser") + }, + }) +}) -it.instance("jsonc overrides json in the same directory", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect( - test.directory, - { +test("jsonc overrides json in the same directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig( + dir, + { + $schema: "https://opencode.ai/config.json", + model: "base", + username: "base", + }, + "opencode.jsonc", + ) + await writeConfig(dir, { $schema: "https://opencode.ai/config.json", - model: "base", - username: "base", - }, - "opencode.jsonc", - ) - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - model: "override", - }) - const config = yield* Config.use.get() - expect(config.model).toBe("base") - expect(config.username).toBe("base") - }), -) - -it.instance("handles environment variable substitution", () => - withProcessEnv( - "TEST_VAR", - "test-user", - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - username: "{env:TEST_VAR}", + model: "override", }) - const config = yield* Config.use.get() - expect(config.username).toBe("test-user") - }), - ), -) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.model).toBe("base") + expect(config.username).toBe("base") + }, + }) +}) -it.instance("preserves env variables when adding $schema to config", () => - withProcessEnv( - "PRESERVE_VAR", - "secret_value", - Effect.gen(function* () { - const test = yield* TestInstance - // Config without $schema - should trigger auto-add - yield* Effect.promise(() => - Filesystem.write( - path.join(test.directory, "opencode.json"), +test("handles environment variable substitution", async () => { + const originalEnv = process.env["TEST_VAR"] + process.env["TEST_VAR"] = "test-user" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + username: "{env:TEST_VAR}", + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.username).toBe("test-user") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["TEST_VAR"] = originalEnv + } else { + delete process.env["TEST_VAR"] + } + } +}) + +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" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Config without $schema - should trigger auto-add + await Filesystem.write( + path.join(dir, "opencode.json"), JSON.stringify({ username: "{env:PRESERVE_VAR}", }), - ), - ) - const config = yield* Config.use.get() - expect(config.username).toBe("secret_value") - - // Read the file to verify the env variable was preserved - const content = yield* Effect.promise(() => Filesystem.readText(path.join(test.directory, "opencode.json"))) - expect(content).toContain("{env:PRESERVE_VAR}") - expect(content).not.toContain("secret_value") - expect(content).toContain("$schema") - }), - ), -) - -it.instance("handles file inclusion substitution", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "included.txt"), "test-user")) - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - username: "{file:included.txt}", + ) + }, }) - const config = yield* Config.use.get() - expect(config.username).toBe("test-user") - }), -) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.username).toBe("secret_value") -it.instance("handles file inclusion with replacement tokens", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* Effect.promise(() => - Filesystem.write(path.join(test.directory, "included.md"), "const out = await Bun.$`echo hi`"), - ) - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - username: "{file:included.md}", + // Read the file to verify the env variable was preserved + const content = await Filesystem.readText(path.join(tmp.path, "opencode.json")) + expect(content).toContain("{env:PRESERVE_VAR}") + expect(content).not.toContain("secret_value") + expect(content).toContain("$schema") + }, }) - const config = yield* Config.use.get() - expect(config.username).toBe("const out = await Bun.$`echo hi`") - }), -) + } finally { + if (originalEnv !== undefined) { + process.env["PRESERVE_VAR"] = originalEnv + } else { + delete process.env["PRESERVE_VAR"] + } + } +}) test("resolves env templates in account config with account token", async () => { const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"] @@ -569,7 +587,16 @@ test("resolves env templates in account config with account token", async () => token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))), }) - const layer = configLayer({ account: fakeAccount }) + const layer = Config.layer.pipe( + Layer.provide(testFlock), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Substitution.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(fakeAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), + ) try { await provideTmpdirInstance(() => @@ -589,304 +616,443 @@ test("resolves env templates in account config with account token", async () => } }) -it.instance("validates config schema and throws on invalid fields", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - invalid_field: "should cause error", - }) - const exit = yield* Config.use.get().pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - }), -) +test("handles file inclusion substitution", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "included.txt"), "test-user") + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + username: "{file:included.txt}", + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.username).toBe("test-user") + }, + }) +}) -it.instance("throws error for invalid JSON", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* Effect.promise(() => Filesystem.write(path.join(test.directory, "opencode.json"), "{ invalid json }")) - const exit = yield* Config.use.get().pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) - }), -) +test("handles file inclusion with replacement tokens", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`") + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + username: "{file:included.md}", + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.username).toBe("const out = await Bun.$`echo hi`") + }, + }) +}) -it.instance("handles agent configuration", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { - test_agent: { +test("validates config schema and throws on invalid fields", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + invalid_field: "should cause error", + }) + }, + }) + await provideTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + // Strict schema should throw an error for invalid fields + await expect(load(ctx)).rejects.toThrow() + }, + }) +}) + +test("throws error for invalid JSON", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "opencode.json"), "{ invalid json }") + }, + }) + await provideTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + await expect(load(ctx)).rejects.toThrow() + }, + }) +}) + +test("handles agent configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { + model: "test/model", + temperature: 0.7, + description: "test agent", + }, + }, + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test_agent"]).toEqual( + expect.objectContaining({ model: "test/model", temperature: 0.7, description: "test agent", + }), + ) + }, + }) +}) + +test("treats agent variant as model-scoped setting (not provider option)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + agent: { + test_agent: { + model: "openai/gpt-5.2", + variant: "xhigh", + max_tokens: 123, + }, }, - }, - }) - const config = yield* Config.use.get() - expect(config.agent?.["test_agent"]).toEqual( - expect.objectContaining({ + }) + }, + }) + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + const agent = config.agent?.["test_agent"] + + expect(agent?.variant).toBe("xhigh") + expect(agent?.options).toMatchObject({ + max_tokens: 123, + }) + expect(agent?.options).not.toHaveProperty("variant") + }, + }) +}) + +test("handles command configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + command: { + test_command: { + template: "test template", + description: "test command", + agent: "test_agent", + }, + }, + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.command?.["test_command"]).toEqual({ + template: "test template", + description: "test command", + agent: "test_agent", + }) + }, + }) +}) + +test("migrates autoshare to share field", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + autoshare: true, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.share).toBe("auto") + expect(config.autoshare).toBe(true) + }, + }) +}) + +test("migrates mode field to agent field", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mode: { + test_mode: { + model: "test/model", + temperature: 0.5, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test_mode"]).toEqual({ model: "test/model", - temperature: 0.7, - description: "test agent", - }), - ) - }), -) + temperature: 0.5, + mode: "primary", + options: {}, + permission: {}, + }) + }, + }) +}) -it.instance("treats agent variant as model-scoped setting (not provider option)", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { - test_agent: { - model: "openai/gpt-5.2", - variant: "xhigh", - max_tokens: 123, - }, - }, - }) - const config = yield* Config.use.get() - const agent = config.agent?.["test_agent"] +test("loads config from .opencode directory", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const agentDir = path.join(opencodeDir, "agent") + await fs.mkdir(agentDir, { recursive: true }) - expect(agent?.variant).toBe("xhigh") - expect(agent?.options).toMatchObject({ - max_tokens: 123, - }) - expect(agent?.options).not.toHaveProperty("variant") - }), -) - -it.instance("handles command configuration", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - command: { - test_command: { - template: "test template", - description: "test command", - agent: "test_agent", - }, - }, - }) - const config = yield* Config.use.get() - expect(config.command?.["test_command"]).toEqual({ - template: "test template", - description: "test command", - agent: "test_agent", - }) - }), -) - -it.instance("migrates autoshare to share field", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - autoshare: true, - }) - const config = yield* Config.use.get() - expect(config.share).toBe("auto") - expect(config.autoshare).toBe(true) - }), -) - -it.instance("migrates mode field to agent field", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - mode: { - test_mode: { - model: "test/model", - temperature: 0.5, - }, - }, - }) - const config = yield* Config.use.get() - expect(config.agent?.["test_mode"]).toEqual({ - model: "test/model", - temperature: 0.5, - mode: "primary", - options: {}, - permission: {}, - }) - }), -) - -it.instance("loads config from .opencode directory", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agent")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "agent", "test.md"), - `--- + await Filesystem.write( + path.join(agentDir, "test.md"), + `--- model: test/model --- Test agent prompt`, - ) + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]).toEqual( + expect.objectContaining({ + name: "test", + model: "test/model", + prompt: "Test agent prompt", + }), + ) + }, + }) +}) - const config = yield* Config.use.get() - expect(config.agent?.["test"]).toEqual( - expect.objectContaining({ - name: "test", - model: "test/model", - prompt: "Test agent prompt", - }), - ) - }), -) +test("agent markdown permission config preserves user key order", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const agentDir = path.join(dir, ".opencode", "agent") + await fs.mkdir(agentDir, { recursive: true }) -it.instance("agent markdown permission config preserves user key order", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agent")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "agent", "ordered.md"), - `--- + await Filesystem.write( + path.join(agentDir, "ordered.md"), + `--- permission: bash: allow "*": deny edit: ask --- Ordered permissions`, - ) + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"]) + }, + }) +}) - const config = yield* Config.use.get() - expect(Object.keys(config.agent?.ordered?.permission ?? {})).toEqual(["bash", "*", "edit"]) - }), -) +test("loads agents from .opencode/agents (plural)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) -it.instance("loads agents from .opencode/agents (plural)", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agents", "nested")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "agents", "helper.md"), - `--- + const agentsDir = path.join(opencodeDir, "agents") + await fs.mkdir(path.join(agentsDir, "nested"), { recursive: true }) + + await Filesystem.write( + path.join(agentsDir, "helper.md"), + `--- model: test/model mode: subagent --- Helper agent prompt`, - ) + ) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "agents", "nested", "child.md"), - `--- + await Filesystem.write( + path.join(agentsDir, "nested", "child.md"), + `--- model: test/model mode: subagent --- Nested agent prompt`, - ) + ) + }, + }) - const config = yield* Config.use.get() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) - expect(config.agent?.["helper"]).toMatchObject({ - name: "helper", - model: "test/model", - mode: "subagent", - prompt: "Helper agent prompt", - }) + expect(config.agent?.["helper"]).toMatchObject({ + name: "helper", + model: "test/model", + mode: "subagent", + prompt: "Helper agent prompt", + }) - expect(config.agent?.["nested/child"]).toMatchObject({ - name: "nested/child", - model: "test/model", - mode: "subagent", - prompt: "Nested agent prompt", - }) - }), -) + expect(config.agent?.["nested/child"]).toMatchObject({ + name: "nested/child", + model: "test/model", + mode: "subagent", + prompt: "Nested agent prompt", + }) + }, + }) +}) -it.instance("loads commands from .opencode/command (singular)", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "command", "nested")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "command", "hello.md"), - `--- +test("loads commands from .opencode/command (singular)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + + const commandDir = path.join(opencodeDir, "command") + await fs.mkdir(path.join(commandDir, "nested"), { recursive: true }) + + await Filesystem.write( + path.join(commandDir, "hello.md"), + `--- description: Test command --- Hello from singular command`, - ) + ) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "command", "nested", "child.md"), - `--- + await Filesystem.write( + path.join(commandDir, "nested", "child.md"), + `--- description: Nested command --- Nested command template`, - ) + ) + }, + }) - const config = yield* Config.use.get() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) - expect(config.command?.["hello"]).toEqual({ - description: "Test command", - template: "Hello from singular command", - }) + expect(config.command?.["hello"]).toEqual({ + description: "Test command", + template: "Hello from singular command", + }) - expect(config.command?.["nested/child"]).toEqual({ - description: "Nested command", - template: "Nested command template", - }) - }), -) + expect(config.command?.["nested/child"]).toEqual({ + description: "Nested command", + template: "Nested command template", + }) + }, + }) +}) -it.instance("loads commands from .opencode/commands (plural)", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "commands", "nested")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "commands", "hello.md"), - `--- +test("loads commands from .opencode/commands (plural)", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + + const commandsDir = path.join(opencodeDir, "commands") + await fs.mkdir(path.join(commandsDir, "nested"), { recursive: true }) + + await Filesystem.write( + path.join(commandsDir, "hello.md"), + `--- description: Test command --- Hello from plural commands`, - ) + ) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "commands", "nested", "child.md"), - `--- + await Filesystem.write( + path.join(commandsDir, "nested", "child.md"), + `--- description: Nested command --- Nested command template`, - ) + ) + }, + }) - const config = yield* Config.use.get() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) - expect(config.command?.["hello"]).toEqual({ - description: "Test command", - template: "Hello from plural commands", - }) + expect(config.command?.["hello"]).toEqual({ + description: "Test command", + template: "Hello from plural commands", + }) - expect(config.command?.["nested/child"]).toEqual({ - description: "Nested command", - template: "Nested command template", - }) - }), -) + expect(config.command?.["nested/child"]).toEqual({ + description: "Nested command", + template: "Nested command template", + }) + }, + }) +}) -it.instance("updates config and writes to file", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* Config.Service.use((svc) => - svc.update(ConfigParse.schema(Config.Info, { model: "updated/model" }, "test:config")), - ) +test("updates config and writes to file", async () => { + await using tmp = await tmpdir() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const newConfig = { model: "updated/model" } + await save(newConfig as any, ctx) - const writtenConfig = yield* Effect.promise(() => - Filesystem.readJson<{ model: string }>(path.join(test.directory, "config.json")), - ) - expect(writtenConfig.model).toBe("updated/model") - }), -) + const writtenConfig = await Filesystem.readJson<{ model: string }>(path.join(tmp.path, "config.json")) + expect(writtenConfig.model).toBe("updated/model") + }, + }) +}) -it.instance("gets config directories", () => - Effect.gen(function* () { - const dirs = yield* Config.use.directories() - expect(dirs.length).toBeGreaterThanOrEqual(1) - }), -) +test("gets config directories", async () => { + await using tmp = await tmpdir() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const dirs = await listDirs(ctx) + expect(dirs.length).toBeGreaterThanOrEqual(1) + }, + }) +}) test("does not try to install dependencies in read-only OPENCODE_CONFIG_DIR", async () => { if (process.platform === "win32") return @@ -933,7 +1099,16 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { const prev = process.env.OPENCODE_CONFIG_DIR process.env.OPENCODE_CONFIG_DIR = tmp.extra - const testLayer = configLayer() + const testLayer = Config.layer.pipe( + Layer.provide(testFlock), + Layer.provide(AppFileSystem.defaultLayer), + Layer.provide(Substitution.defaultLayer), + Layer.provide(Env.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), + ) try { await withTestInstance({ @@ -954,6 +1129,9 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { }, }) + // TODO: this is a hack to wait for backgruounded gitignore + await new Promise((resolve) => setTimeout(resolve, 1000)) + expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true) expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json") } finally { @@ -966,35 +1144,49 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => { // core Npm.Service (via EffectFlock). Those behaviors are tested in the core // package's npm tests, not here. -it.instance("resolves scoped npm plugins in config", () => - Effect.gen(function* () { - const test = yield* TestInstance - const pluginDir = path.join(test.directory, "node_modules", "@scope", "plugin") - yield* mkdirEffect(pluginDir) - yield* writeTextEffect( - path.join(test.directory, "package.json"), - JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), - ) - yield* writeTextEffect( - path.join(pluginDir, "package.json"), - JSON.stringify( - { - name: "@scope/plugin", - version: "1.0.0", - type: "module", - main: "./index.js", - }, - null, - 2, - ), - ) - yield* writeTextEffect(path.join(pluginDir, "index.js"), "export default {}\n") - yield* writeConfigEffect(test.directory, { plugin: ["@scope/plugin"] }) +test("resolves scoped npm plugins in config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const pluginDir = path.join(dir, "node_modules", "@scope", "plugin") + await fs.mkdir(pluginDir, { recursive: true }) - const config = yield* Config.use.get() - expect(config.plugin ?? []).toContain("@scope/plugin") - }), -) + await Filesystem.write( + path.join(dir, "package.json"), + JSON.stringify({ name: "config-fixture", version: "1.0.0", type: "module" }, null, 2), + ) + + await Filesystem.write( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@scope/plugin", + version: "1.0.0", + type: "module", + main: "./index.js", + }, + null, + 2, + ), + ) + + await Filesystem.write(path.join(pluginDir, "index.js"), "export default {}\n") + + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ $schema: "https://opencode.ai/config.json", plugin: ["@scope/plugin"] }, null, 2), + ) + }, + }) + + await provideTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + const pluginEntries = config.plugin ?? [] + expect(pluginEntries).toContain("@scope/plugin") + }, + }) +}) test("merges plugin arrays from global and local configs", async () => { await using tmp = await tmpdir({ @@ -1042,28 +1234,37 @@ test("merges plugin arrays from global and local configs", async () => { }) }) -it.instance("does not error when only custom agent is a subagent", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "agent")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "agent", "helper.md"), - `--- +test("does not error when only custom agent is a subagent", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + const agentDir = path.join(opencodeDir, "agent") + await fs.mkdir(agentDir, { recursive: true }) + + await Filesystem.write( + path.join(agentDir, "helper.md"), + `--- model: test/model mode: subagent --- Helper subagent prompt`, - ) - - const config = yield* Config.use.get() - expect(config.agent?.["helper"]).toMatchObject({ - name: "helper", - model: "test/model", - mode: "subagent", - prompt: "Helper subagent prompt", - }) - }), -) + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["helper"]).toMatchObject({ + name: "helper", + model: "test/model", + mode: "subagent", + prompt: "Helper subagent prompt", + }) + }, + }) +}) test("merges instructions arrays from global and local configs", async () => { await using tmp = await tmpdir({ @@ -1245,192 +1446,345 @@ test("keeps plugin origins aligned with merged plugin list", async () => { // Legacy tools migration tests -it.instance("migrates legacy tools config to permissions - allow", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { test: { tools: { bash: true, read: true } } }, - }) +test("migrates legacy tools config to permissions - allow", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + bash: true, + read: true, + }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]?.permission).toEqual({ + bash: "allow", + read: "allow", + }) + }, + }) +}) - const config = yield* Config.use.get() - expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", - read: "allow", - }) - }), -) +test("migrates legacy tools config to permissions - deny", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + bash: false, + webfetch: false, + }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]?.permission).toEqual({ + bash: "deny", + webfetch: "deny", + }) + }, + }) +}) -it.instance("migrates legacy tools config to permissions - deny", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { test: { tools: { bash: false, webfetch: false } } }, - }) - - const config = yield* Config.use.get() - expect(config.agent?.["test"]?.permission).toEqual({ - bash: "deny", - webfetch: "deny", - }) - }), -) - -it.instance("migrates legacy write tool to edit permission", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { test: { tools: { write: true } } }, - }) - - const config = yield* Config.use.get() - expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" }) - }), -) +test("migrates legacy write tool to edit permission", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + write: true, + }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "allow", + }) + }, + }) +}) // Managed settings tests // Note: preload.ts sets OPENCODE_TEST_MANAGED_CONFIG which Global.Path.managedConfig uses -it.instance( - "managed settings override user settings", - Effect.gen(function* () { - yield* writeManagedSettingsEffect({ - $schema: "https://opencode.ai/config.json", - model: "managed/model", - share: "disabled", - }) +test("managed settings override user settings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "user/model", + share: "auto", + username: "testuser", + }) + }, + }) - const config = yield* Config.use.get() - expect(config.model).toBe("managed/model") - expect(config.share).toBe("disabled") - expect(config.username).toBe("testuser") - }), - { config: { model: "user/model", share: "auto", username: "testuser" } }, -) + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + model: "managed/model", + share: "disabled", + }) -it.instance( - "managed settings override project settings", - Effect.gen(function* () { - yield* writeManagedSettingsEffect({ - $schema: "https://opencode.ai/config.json", - autoupdate: false, - disabled_providers: ["openai"], - }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.model).toBe("managed/model") + expect(config.share).toBe("disabled") + expect(config.username).toBe("testuser") + }, + }) +}) - const config = yield* Config.use.get() - expect(config.autoupdate).toBe(false) - expect(config.disabled_providers).toEqual(["openai"]) - }), - { config: { autoupdate: true, disabled_providers: [] } }, -) +test("managed settings override project settings", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + autoupdate: true, + disabled_providers: [], + }) + }, + }) -it.instance( - "missing managed settings file is not an error", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.model).toBe("user/model") - }), - { config: { model: "user/model" } }, -) + await writeManagedSettings({ + $schema: "https://opencode.ai/config.json", + autoupdate: false, + disabled_providers: ["openai"], + }) -it.instance("migrates legacy edit tool to edit permission", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { test: { tools: { edit: false } } }, - }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.autoupdate).toBe(false) + expect(config.disabled_providers).toEqual(["openai"]) + }, + }) +}) - const config = yield* Config.use.get() - expect(config.agent?.["test"]?.permission).toEqual({ edit: "deny" }) - }), -) +test("missing managed settings file is not an error", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + model: "user/model", + }) + }, + }) -it.instance("migrates legacy patch tool to edit permission", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { test: { tools: { patch: true } } }, - }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.model).toBe("user/model") + }, + }) +}) - const config = yield* Config.use.get() - expect(config.agent?.["test"]?.permission).toEqual({ edit: "allow" }) - }), -) +test("migrates legacy edit tool to edit permission", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + edit: false, + }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "deny", + }) + }, + }) +}) -it.instance("migrates mixed legacy tools config", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { test: { tools: { bash: true, write: true, read: false, webfetch: true } } }, - }) +test("migrates legacy patch tool to edit permission", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + patch: true, + }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]?.permission).toEqual({ + edit: "allow", + }) + }, + }) +}) - const config = yield* Config.use.get() - expect(config.agent?.["test"]?.permission).toEqual({ - bash: "allow", - edit: "allow", - read: "deny", - webfetch: "allow", - }) - }), -) +test("migrates mixed legacy tools config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + tools: { + bash: true, + write: true, + read: false, + webfetch: true, + }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]?.permission).toEqual({ + bash: "allow", + edit: "allow", + read: "deny", + webfetch: "allow", + }) + }, + }) +}) -it.instance("merges legacy tools with existing permission config", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - agent: { test: { permission: { glob: "allow" }, tools: { bash: true } } }, - }) +test("merges legacy tools with existing permission config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + agent: { + test: { + permission: { + glob: "allow", + }, + tools: { + bash: true, + }, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.agent?.["test"]?.permission).toEqual({ + glob: "allow", + bash: "allow", + }) + }, + }) +}) - const config = yield* Config.use.get() - expect(config.agent?.["test"]?.permission).toEqual({ - glob: "allow", - bash: "allow", - }) - }), -) - -it.instance("permission config preserves user key order", () => +test("permission config preserves user key order", async () => { // Permission precedence follows the order users write in config, so parsing // must not canonicalise known keys ahead of wildcard or custom keys. - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - permission: { - "*": "deny", - edit: "ask", - write: "ask", - external_directory: "ask", - read: "allow", - todowrite: "allow", - "thoughts_*": "allow", - "reasoning_model_*": "allow", - "tools_*": "allow", - "pr_comments_*": "allow", - }, - }) - - const config = yield* Config.use.get() - expect(Object.keys(config.permission!)).toEqual([ - "*", - "edit", - "write", - "external_directory", - "read", - "todowrite", - "thoughts_*", - "reasoning_model_*", - "tools_*", - "pr_comments_*", - ]) - }), -) + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + permission: { + "*": "deny", + edit: "ask", + write: "ask", + external_directory: "ask", + read: "allow", + todowrite: "allow", + "thoughts_*": "allow", + "reasoning_model_*": "allow", + "tools_*": "allow", + "pr_comments_*": "allow", + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(Object.keys(config.permission!)).toEqual([ + "*", + "edit", + "write", + "external_directory", + "read", + "todowrite", + "thoughts_*", + "reasoning_model_*", + "tools_*", + "pr_comments_*", + ]) + }, + }) +}) test("config parser preserves permission order while rejecting unknown top-level keys", () => { const config = ConfigParse.schema( @@ -1457,149 +1811,191 @@ test("config parser preserves permission order while rejecting unknown top-level // MCP config merging tests -it.instance("project config can override MCP server enabled status", () => - Effect.gen(function* () { - const test = yield* TestInstance - // Simulates a base config (like from remote .well-known) with disabled MCP. - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - mcp: { - jira: { - type: "remote", - url: "https://jira.example.com/mcp", - enabled: false, - }, - wiki: { - type: "remote", - url: "https://wiki.example.com/mcp", - enabled: false, - }, - }, - }) - // Project config enables just jira. - yield* writeConfigEffect( - test.directory, - { - $schema: "https://opencode.ai/config.json", - mcp: { - jira: { - type: "remote", - url: "https://jira.example.com/mcp", - enabled: true, +test("project config can override MCP server enabled status", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Simulates a base config (like from remote .well-known) with disabled MCP + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + jira: { + type: "remote", + url: "https://jira.example.com/mcp", + enabled: false, + }, + wiki: { + type: "remote", + url: "https://wiki.example.com/mcp", + enabled: false, + }, }, - }, - }, - "opencode.jsonc", - ) - - const config = yield* Config.use.get() - expect(config.mcp?.jira).toEqual({ - type: "remote", - url: "https://jira.example.com/mcp", - enabled: true, - }) - expect(config.mcp?.wiki).toEqual({ - type: "remote", - url: "https://wiki.example.com/mcp", - enabled: false, - }) - }), -) - -it.instance("MCP config deep merges preserving base config properties", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - mcp: { - myserver: { - type: "remote", - url: "https://myserver.example.com/mcp", - enabled: false, - headers: { - "X-Custom-Header": "value", + }), + ) + // Project config enables just jira + await Filesystem.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + jira: { + type: "remote", + url: "https://jira.example.com/mcp", + enabled: true, + }, }, - }, - }, - }) - yield* writeConfigEffect( - test.directory, - { - $schema: "https://opencode.ai/config.json", - mcp: { - myserver: { - type: "remote", - url: "https://myserver.example.com/mcp", - enabled: true, - }, - }, - }, - "opencode.jsonc", - ) - - const config = yield* Config.use.get() - expect(config.mcp?.myserver).toEqual({ - type: "remote", - url: "https://myserver.example.com/mcp", - enabled: true, - headers: { - "X-Custom-Header": "value", - }, - }) - }), -) - -it.instance("local .opencode config can override MCP from project config", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeConfigEffect(test.directory, { - $schema: "https://opencode.ai/config.json", - mcp: { - docs: { - type: "remote", - url: "https://docs.example.com/mcp", - enabled: false, - }, - }, - }) - yield* mkdirEffect(path.join(test.directory, ".opencode")) - yield* writeConfigEffect( - path.join(test.directory, ".opencode"), - { - $schema: "https://opencode.ai/config.json", - mcp: { - docs: { - type: "remote", - url: "https://docs.example.com/mcp", - enabled: true, - }, - }, - }, - "opencode.json", - ) - - const config = yield* Config.use.get() - expect(config.mcp?.docs?.enabled).toBe(true) - }), -) - -test("project config overrides remote well-known config", async () => { - const seen: { wellKnown?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - config: { - mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } }, - }, + }), + ) }, }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + // jira should be enabled (overridden by project config) + expect(config.mcp?.jira).toEqual({ + type: "remote", + url: "https://jira.example.com/mcp", + enabled: true, + }) + // wiki should still be disabled (not overridden) + expect(config.mcp?.wiki).toEqual({ + type: "remote", + url: "https://wiki.example.com/mcp", + enabled: false, + }) + }, + }) +}) + +test("MCP config deep merges preserving base config properties", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Base config with full MCP definition + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + myserver: { + type: "remote", + url: "https://myserver.example.com/mcp", + enabled: false, + headers: { + "X-Custom-Header": "value", + }, + }, + }, + }), + ) + // Override just enables it, should preserve other properties + await Filesystem.write( + path.join(dir, "opencode.jsonc"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + myserver: { + type: "remote", + url: "https://myserver.example.com/mcp", + enabled: true, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.mcp?.myserver).toEqual({ + type: "remote", + url: "https://myserver.example.com/mcp", + enabled: true, + headers: { + "X-Custom-Header": "value", + }, + }) + }, + }) +}) + +test("local .opencode config can override MCP from project config", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + // Project config with disabled MCP + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: false, + }, + }, + }), + ) + // Local .opencode directory config enables it + const opencodeDir = path.join(dir, ".opencode") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write( + path.join(opencodeDir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + mcp: { + docs: { + type: "remote", + url: "https://docs.example.com/mcp", + enabled: true, + }, + }, + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.mcp?.docs?.enabled).toBe(true) + }, + }) +}) + +test("project config overrides remote well-known config", async () => { + 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(fakeAuthWellKnown), + Layer.provide(emptyAccount), + Layer.provideMerge(infra), + Layer.provide(noopNpm), + ) await provideTmpdirInstance( () => Config.Service.use((svc) => Effect.gen(function* () { const config = yield* svc.get() - expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode") expect(config.mcp?.jira?.enabled).toBe(true) }), ), @@ -1607,223 +2003,7 @@ test("project config overrides remote well-known config", async () => { git: true, config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } }, }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) -}) - -test("wellknown URL with trailing slash is normalized", async () => { - const seen: { wellKnown?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - config: { - mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } }, - }, - }, - }) - - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - yield* svc.get() - expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode") - }), - ), - { git: true }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com/"), client })), - Effect.runPromise, - ) -}) - -test("remote well-known config can use FetchHttpClient layer", async () => { - let fetchedUrl: string | undefined - const server = Bun.serve({ - port: 0, - fetch: (request) => { - fetchedUrl = request.url - return new Response( - JSON.stringify({ - config: { - mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } }, - }, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ) - }, - }) - - try { - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(fetchedUrl).toBe(`${server.url.origin}/.well-known/opencode`) - expect(config.mcp?.jira?.enabled).toBe(true) - }), - ), - { git: true }, - ).pipe( - Effect.scoped, - Effect.provide( - Config.layer.pipe( - Layer.provide(testFlock), - Layer.provide(AppFileSystem.defaultLayer), - Layer.provide(Env.defaultLayer), - Layer.provide(wellKnownAuth(server.url.origin)), - Layer.provide(AccountTest.empty), - Layer.provideMerge(infra), - Layer.provide(NpmTest.noop), - Layer.provide(FetchHttpClient.layer), - ), - ), - Effect.runPromise, - ) - } finally { - await server.stop(true) - } -}) - -test("wellknown remote_config supports templated env vars in headers", async () => { - const originalToken = process.env.TEST_TOKEN - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - remote_config: { - url: "https://config.example.com/opencode.json", - headers: { - Authorization: "Bearer {env:TEST_TOKEN}", - }, - }, - }, - remote: { - mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, - }, - }) - - try { - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(seen.wellKnown).toBe("https://example.com/.well-known/opencode") - expect(seen.remote).toBe("https://config.example.com/opencode.json") - expect(seen.authorization).toBe("Bearer test-token") - expect(config.mcp?.confluence?.enabled).toBe(true) - }), - ), - { git: true }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) - } finally { - if (originalToken === undefined) delete process.env.TEST_TOKEN - else process.env.TEST_TOKEN = originalToken - } -}) - -test("wellknown token env substitution does not mutate process env", async () => { - const originalToken = process.env.TEST_TOKEN - process.env.TEST_TOKEN = "preexisting-token" - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - remote_config: { - url: "https://config.example.com/opencode.json", - headers: { - Authorization: "Bearer {env:TEST_TOKEN}", - }, - }, - }, - remote: { - mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, - }, - }) - - try { - const config = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()), { - git: true, - config: { username: "{env:TEST_TOKEN}" }, - }).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) - - expect(seen.authorization).toBe("Bearer test-token") - expect(config.username).toBe("test-token") - expect(process.env.TEST_TOKEN).toBe("preexisting-token") - } finally { - if (originalToken === undefined) delete process.env.TEST_TOKEN - else process.env.TEST_TOKEN = originalToken - } -}) - -test("wellknown config null is treated as absent", async () => { - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - config: null, - remote_config: { - url: "https://config.example.com/opencode.json", - }, - }, - remote: { - mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } }, - }, - }) - - await provideTmpdirInstance( - () => - Config.Service.use((svc) => - Effect.gen(function* () { - const config = yield* svc.get() - expect(seen.remote).toBe("https://config.example.com/opencode.json") - expect(config.mcp?.confluence?.enabled).toBe(true) - }), - ), - { git: true }, - ).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) -}) - -test("wellknown remote_config rejects non-object config responses", async () => { - const seen: { wellKnown?: string; remote?: string; authorization?: string } = {} - const client = remoteConfigClient({ - seen, - wellKnown: { - remote_config: { - url: "https://config.example.com/opencode.json", - }, - }, - remote: "not an object", - }) - - const exit = await provideTmpdirInstance(() => Config.Service.use((svc) => svc.get()).pipe(Effect.exit), { - git: true, - }).pipe( - Effect.scoped, - Effect.provide(configLayer({ auth: wellKnownAuth("https://example.com"), client })), - Effect.runPromise, - ) - - expect(seen.remote).toBe("https://config.example.com/opencode.json") - expect(Exit.isFailure(exit)).toBe(true) + ).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise) }) describe("resolvePluginSpec", () => { @@ -1976,136 +2156,265 @@ describe("deduplicatePluginOrigins", () => { }) describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { - it.instance( - "skips project config files when flag is set", - () => - withProcessEnv( - "OPENCODE_DISABLE_PROJECT_CONFIG", - "true", - Effect.gen(function* () { - const config = yield* Config.use.get() + test("skips project config files when flag is set", async () => { + const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a project config that would normally be loaded + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "project/model", + username: "project-user", + }), + ) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + // Project config should NOT be loaded - model should be default, not "project/model" expect(config.model).not.toBe("project/model") expect(config.username).not.toBe("project-user") - }), - ), - { config: { model: "project/model", username: "project-user" } }, - ) + }, + }) + } finally { + if (originalEnv === undefined) { + delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv + } + } + }) - it.instance("skips project .opencode/ directories when flag is set", () => - withProcessEnv( - "OPENCODE_DISABLE_PROJECT_CONFIG", - "true", - Effect.gen(function* () { - const test = yield* TestInstance - yield* mkdirEffect(path.join(test.directory, ".opencode", "command")) - yield* writeTextEffect( - path.join(test.directory, ".opencode", "command", "test-cmd.md"), - "# Test Command\nThis is a test command.", - ) - const directories = yield* Config.use.directories() - expect(directories.some((d) => d.startsWith(test.directory))).toBe(false) - }), - ), - ) + test("skips project .opencode/ directories when flag is set", async () => { + const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" - it.instance("still loads global config when flag is set", () => - withProcessEnv( - "OPENCODE_DISABLE_PROJECT_CONFIG", - "true", - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config).toBeDefined() - expect(config.username).toBeDefined() - }), - ), - ) + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a .opencode directory with a command + const opencodeDir = path.join(dir, ".opencode", "command") + await fs.mkdir(opencodeDir, { recursive: true }) + await Filesystem.write(path.join(opencodeDir, "test-cmd.md"), "# Test Command\nThis is a test command.") + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const directories = await listDirs(ctx) + // Project .opencode should NOT be in directories list + const hasProjectOpencode = directories.some((d) => d.startsWith(tmp.path)) + expect(hasProjectOpencode).toBe(false) + }, + }) + } finally { + if (originalEnv === undefined) { + delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv + } + } + }) - it.instance( - "skips relative instructions with warning when flag is set but no config dir", - () => - withProcessEnvs( - { OPENCODE_CONFIG_DIR: undefined, OPENCODE_DISABLE_PROJECT_CONFIG: "true" }, - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "CUSTOM.md"), "# Custom Instructions") - // The relative instruction should be skipped without error - const config = yield* Config.use.get() + test("still loads global config when flag is set", async () => { + const originalEnv = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" + + try { + await using tmp = await tmpdir() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + // Should still get default config (from global or defaults) + const config = await load(ctx) expect(config).toBeDefined() - }), - ), - { config: { instructions: ["./CUSTOM.md"] } }, - ) + expect(config.username).toBeDefined() + }, + }) + } finally { + if (originalEnv === undefined) { + delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalEnv + } + } + }) - it.instance( - "OPENCODE_CONFIG_DIR still works when flag is set", - () => - Effect.gen(function* () { - const configDir = yield* tmpdirScoped({ config: { model: "configdir/model" } }) - yield* withProcessEnvs( - { OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_CONFIG_DIR: configDir }, - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.model).toBe("configdir/model") - }), - ) - }), - { config: { model: "project/model" } }, - ) -}) + test("skips relative instructions with warning when flag is set but no config dir", async () => { + const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] -// Regression for #28206: malformed OPENCODE_PERMISSION JSON used to crash -// the app on startup with an unhandled SyntaxError. Loading the config with -// an invalid JSON value in this env var should not throw. -describe("OPENCODE_PERMISSION env var", () => { - it.instance("does not crash when OPENCODE_PERMISSION contains invalid JSON", () => - withProcessEnv( - "OPENCODE_PERMISSION", - "{invalid", - Effect.gen(function* () { - const config = yield* Config.use.get() - // Regression: load() used to throw before returning anything. - expect(config).toBeDefined() - }), - ), - ) + try { + // Ensure no config dir is set + delete process.env["OPENCODE_CONFIG_DIR"] + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" + + await using tmp = await tmpdir({ + init: async (dir) => { + // Create a config with relative instruction path + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + instructions: ["./CUSTOM.md"], + }), + ) + // Create the instruction file (should be skipped) + await Filesystem.write(path.join(dir, "CUSTOM.md"), "# Custom Instructions") + }, + }) + + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + // The relative instruction should be skipped without error + // We're mainly verifying this doesn't throw and the config loads + const config = await load(ctx) + expect(config).toBeDefined() + // The instruction should have been skipped (warning logged) + // We can't easily test the warning was logged, but we verify + // the relative path didn't cause an error + }, + }) + } finally { + if (originalDisable === undefined) { + delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable + } + if (originalConfigDir === undefined) { + delete process.env["OPENCODE_CONFIG_DIR"] + } else { + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + } + } + }) + + test("OPENCODE_CONFIG_DIR still works when flag is set", async () => { + const originalDisable = process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + const originalConfigDir = process.env["OPENCODE_CONFIG_DIR"] + + try { + await using configDirTmp = await tmpdir({ + init: async (dir) => { + // Create config in the custom config dir + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "configdir/model", + }), + ) + }, + }) + + await using projectTmp = await tmpdir({ + init: async (dir) => { + // Create config in project (should be ignored) + await Filesystem.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "project/model", + }), + ) + }, + }) + + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = "true" + process.env["OPENCODE_CONFIG_DIR"] = configDirTmp.path + + await withTestInstance({ + directory: projectTmp.path, + fn: async (ctx) => { + const config = await load(ctx) + // Should load from OPENCODE_CONFIG_DIR, not project + expect(config.model).toBe("configdir/model") + }, + }) + } finally { + if (originalDisable === undefined) { + delete process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] + } else { + process.env["OPENCODE_DISABLE_PROJECT_CONFIG"] = originalDisable + } + if (originalConfigDir === undefined) { + delete process.env["OPENCODE_CONFIG_DIR"] + } else { + process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir + } + } + }) }) describe("OPENCODE_CONFIG_CONTENT token substitution", () => { - it.instance("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", () => - withProcessEnv( - "TEST_CONFIG_VAR", - "test_api_key_12345", - withProcessEnv( - "OPENCODE_CONFIG_CONTENT", - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - username: "{env:TEST_CONFIG_VAR}", - }), - Effect.gen(function* () { - const config = yield* Config.use.get() - expect(config.username).toBe("test_api_key_12345") - }), - ), - ), - ) + test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => { + const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"] + const originalTestVar = process.env["TEST_CONFIG_VAR"] + process.env["TEST_CONFIG_VAR"] = "test_api_key_12345" + process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + username: "{env:TEST_CONFIG_VAR}", + }) - it.instance("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", () => - Effect.gen(function* () { - const test = yield* TestInstance - yield* writeTextEffect(path.join(test.directory, "api_key.txt"), "secret_key_from_file") - yield* withProcessEnv( - "OPENCODE_CONFIG_CONTENT", - JSON.stringify({ - $schema: "https://opencode.ai/config.json", - username: "{file:./api_key.txt}", - }), - Effect.gen(function* () { - const config = yield* Config.use.get() + try { + await using tmp = await tmpdir() + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) + expect(config.username).toBe("test_api_key_12345") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv + } else { + delete process.env["OPENCODE_CONFIG_CONTENT"] + } + if (originalTestVar !== undefined) { + process.env["TEST_CONFIG_VAR"] = originalTestVar + } else { + delete process.env["TEST_CONFIG_VAR"] + } + } + }) + + test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => { + const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"] + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file") + process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + username: "{file:./api_key.txt}", + }) + }, + }) + await withTestInstance({ + directory: tmp.path, + fn: async (ctx) => { + const config = await load(ctx) expect(config.username).toBe("secret_key_from_file") - }), - ) - }), - ) + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv + } else { + delete process.env["OPENCODE_CONFIG_CONTENT"] + } + } + }) }) // parseManagedPlist unit tests — pure function, no OS interaction 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 0000000000..5a9f13dccd --- /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 3716bc3aca..b8bc91671f 100644 --- a/packages/opencode/test/plugin/trigger.test.ts +++ b/packages/opencode/test/plugin/trigger.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 { Bus } from "../../src/bus" @@ -17,10 +18,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(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 79964d3dee..fb6afaea04 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 { Auth } from "../../src/auth" @@ -26,10 +27,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(AppFileSystem.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty),