diff --git a/packages/core/src/auth-well-known.ts b/packages/core/src/auth-well-known.ts new file mode 100644 index 0000000000..34ac0e34db --- /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 { FSUtil } from "./fs-util" +import { Global } from "./global" +import { Substitution } from "./substitution" + +export class Entry extends Schema.Class("AuthWellKnown.Entry")({ + key: Schema.String, + token: Schema.String, +}) {} + +export class FileWriteError extends Schema.TaggedErrorClass()("AuthWellKnown.FileWriteError", { + operation: Schema.Union([Schema.Literal("migrate"), Schema.Literal("write")]), + cause: Schema.Defect, +}) {} + +export class RemoteConfigError extends Schema.TaggedErrorClass()("AuthWellKnown.RemoteConfigError", { + url: Schema.String, + status: Schema.Number.pipe(Schema.optional), + cause: Schema.Defect.pipe(Schema.optional), +}) {} + +export type Error = FileWriteError | RemoteConfigError + +const RemoteConfig = Schema.Struct({ + url: Schema.String, + headers: Schema.Record(Schema.String, Schema.String).pipe(Schema.optional), +}) + +export class Metadata extends Schema.Class("AuthWellKnown.Metadata")({ + auth: Schema.Struct({ + command: Schema.Array(Schema.String), + env: Schema.String, + }).pipe(Schema.optional), + config: Schema.Record(Schema.String, Schema.Unknown).pipe(Schema.optional), + remote_config: RemoteConfig.pipe(Schema.optional), +}) {} + +export type ConfigDocument = { + url: string + source: string + dir: string + content: unknown +} + +export interface Interface { + readonly all: () => Effect.Effect, Error> + readonly get: (url: string) => Effect.Effect + readonly set: (url: string, entry: Entry) => Effect.Effect + readonly remove: (url: string) => Effect.Effect + readonly metadata: (url: string) => Effect.Effect + readonly configs: () => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/v2/AuthWellKnown") {} +const decodeMetadata = Schema.decodeUnknownEffect(Metadata) +const decodeRemoteConfig = Schema.decodeUnknownEffect(RemoteConfig) + +function loadLegacyAuth(input: { + fsys: FSUtil.Interface + dataDir: string + write: (data: Record) => Effect.Effect +}) { + return Effect.gen(function* () { + const decodeLegacy = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown)) + const decodeLegacyCredential = Schema.decodeUnknownOption( + Schema.Struct({ + type: Schema.Literal("wellknown"), + key: Schema.String, + token: Schema.String, + }), + ) + const legacy = Object.fromEntries( + Object.entries( + Option.getOrElse( + decodeLegacy( + yield* input.fsys.readJson(path.join(input.dataDir, "auth.json")).pipe(Effect.orElseSucceed(() => null)), + ), + () => ({}), + ), + ).flatMap(([url, value]) => { + const decoded = Option.getOrUndefined(decodeLegacyCredential(value)) + return decoded ? [[url.replace(/\/+$/, ""), new Entry({ key: decoded.key, token: decoded.token })]] : [] + }), + ) + if (Object.keys(legacy).length > 0) yield* input.write(legacy).pipe(Effect.ignore) + return legacy + }) +} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const fsys = yield* FSUtil.Service + const global = yield* Global.Service + const http = yield* HttpClient.HttpClient + const substitution = yield* Substitution.Service + const file = path.join(global.data, "well-known.json") + const decodeEntries = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry)) + const normalizeUrl = (url: string) => url.replace(/\/+$/, "") + + const write = (operation: "migrate" | "write", data: Record) => + fsys.writeJson(file, data, 0o600).pipe(Effect.mapError((cause) => new FileWriteError({ operation, cause }))) + + const load = Effect.fnUntraced(function* () { + const current = yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => null)) + if (current && typeof current === "object") + return Option.getOrElse(decodeEntries(current), () => ({}) as Record) + return yield* loadLegacyAuth({ fsys, dataDir: global.data, write: (data) => write("migrate", data) }) + }) + + const state = SynchronizedRef.makeUnsafe>(yield* load()) + + const metadata = Effect.fn("AuthWellKnown.metadata")(function* (url: string) { + const normalized = normalizeUrl(url) + const source = `${normalized}/.well-known/opencode` + const response = yield* HttpClientRequest.get(source).pipe( + HttpClientRequest.acceptJson, + http.execute, + Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new RemoteConfigError({ url: source, status: response.status }) + } + const metadata = yield* response.json.pipe( + Effect.flatMap(decodeMetadata), + Effect.mapError((cause) => new RemoteConfigError({ url: source, cause })), + ) + return { url: normalized, source, dir: path.dirname(source), metadata } + }) + + const remote = Effect.fn("AuthWellKnown.remote")(function* (input: { url: string; headers?: Record }) { + const response = yield* HttpClientRequest.get(input.url).pipe( + HttpClientRequest.acceptJson, + input.headers ? HttpClientRequest.setHeaders(input.headers) : (request) => request, + http.execute, + Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause })), + ) + if (response.status < 200 || response.status >= 300) { + return yield* new RemoteConfigError({ url: input.url, status: response.status }) + } + return yield* response.json.pipe(Effect.mapError((cause) => new RemoteConfigError({ url: input.url, cause }))) + }) + + return Service.of({ + all: Effect.fn("AuthWellKnown.all")(function* () { + return yield* SynchronizedRef.get(state) + }), + + get: Effect.fn("AuthWellKnown.get")(function* (url) { + return (yield* SynchronizedRef.get(state))[normalizeUrl(url)] + }), + + set: Effect.fn("AuthWellKnown.set")(function* (url, entry) { + yield* SynchronizedRef.updateEffect( + state, + Effect.fnUntraced(function* (data) { + const next = { ...data, [normalizeUrl(url)]: entry } + yield* write("write", next) + return next + }), + ) + }), + + remove: Effect.fn("AuthWellKnown.remove")(function* (url) { + yield* SynchronizedRef.updateEffect( + state, + Effect.fnUntraced(function* (data) { + const next = { ...data } + delete next[url] + delete next[normalizeUrl(url)] + yield* write("write", next) + return next + }), + ) + }), + + metadata: Effect.fn("AuthWellKnown.metadata.public")(function* (url) { + return (yield* metadata(url)).metadata + }), + + configs: Effect.fn("AuthWellKnown.configs")(function* () { + const documents = yield* Effect.all( + Object.entries(yield* SynchronizedRef.get(state)).map(([url, entry]) => + Effect.gen(function* () { + const configs: ConfigDocument[] = [] + const response = yield* metadata(url) + const env = { [entry.key]: entry.token } + if (response.metadata.config) { + configs.push({ + url: response.url, + source: response.source, + dir: response.dir, + content: response.metadata.config, + }) + } + if (response.metadata.remote_config) { + const remoteConfig = yield* substitution + .substitute({ + text: JSON.stringify(response.metadata.remote_config), + type: "virtual", + dir: response.url, + source: response.source, + env, + }) + .pipe( + Effect.flatMap((text) => + Effect.try({ + try: () => JSON.parse(text) as unknown, + catch: (cause) => new RemoteConfigError({ url: response.source, cause }), + }), + ), + Effect.flatMap(decodeRemoteConfig), + Effect.mapError((cause) => new RemoteConfigError({ url: response.source, cause })), + ) + configs.push({ + url: remoteConfig.url, + source: remoteConfig.url, + dir: path.dirname(remoteConfig.url), + content: yield* remote({ url: remoteConfig.url, headers: remoteConfig.headers }), + }) + } + return configs + }), + ), + { concurrency: "unbounded" }, + ) + return documents.flat() + }), + }) + }), +) + +export const defaultLayer = layer.pipe( + Layer.provide(FSUtil.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..db1f846d01 --- /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 { FSUtil } from "./fs-util" + +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* FSUtil.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(FSUtil.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..9f991b8d03 --- /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 { FSUtil } from "@opencode-ai/core/fs-util" +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(FSUtil.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 3775123d83..6f493d7327 100644 --- a/packages/opencode/src/cli/cmd/providers.ts +++ b/packages/opencode/src/cli/cmd/providers.ts @@ -1,5 +1,6 @@ import type { Argv } from "yargs" import { Auth } from "../../auth" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" import { cmd } from "./cmd" import { CliError, effectCmd, fail } from "../effect-cmd" import { UI } from "../ui" @@ -253,6 +254,7 @@ export const ProvidersListCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.list")(function* (_args) { const authSvc = yield* Auth.Service + const authWellKnown = yield* AuthWellKnown.Service const modelsDev = yield* ModelsDev.Service UI.empty() @@ -260,7 +262,8 @@ export const ProvidersListCommand = effectCmd({ const homedir = os.homedir() const displayPath = authPath.startsWith(homedir) ? authPath.replace(homedir, "~") : authPath yield* Prompt.intro(`Credentials ${UI.Style.TEXT_DIM}${displayPath}`) - const results = Object.entries(yield* Effect.orDie(authSvc.all())) + const results = Object.entries(yield* Effect.orDie(authSvc.all())).filter(([, result]) => result.type !== "wellknown") + const wellKnownResults = Object.entries(yield* Effect.orDie(authWellKnown.all())) const database = yield* modelsDev.get() for (const [providerID, result] of results) { @@ -268,7 +271,11 @@ export const ProvidersListCommand = effectCmd({ yield* Prompt.log.info(`${name} ${UI.Style.TEXT_DIM}${result.type}`) } - yield* Prompt.outro(`${results.length} credentials`) + for (const [url] of wellKnownResults) { + yield* Prompt.log.info(`${url} ${UI.Style.TEXT_DIM}wellknown`) + } + + yield* Prompt.outro(`${results.length + wellKnownResults.length} credentials`) const activeEnvVars: Array<{ provider: string; envVar: string }> = [] @@ -319,19 +326,19 @@ export const ProvidersLoginCommand = effectCmd({ }), handler: Effect.fn("Cli.providers.login")(function* (args) { const authSvc = yield* Auth.Service + const authWellKnown = yield* AuthWellKnown.Service UI.empty() yield* Prompt.intro("Add credential") if (args.url) { const url = args.url.replace(/\/+$/, "") - const wellknown = (yield* cliTry(`Failed to load auth provider metadata from ${url}: `, () => - fetch(`${url}/.well-known/opencode`).then((x) => x.json()), - )) as { - auth: { command: string[]; env: string } - } + const wellknown = yield* authWellKnown.metadata(url).pipe( + Effect.mapError((error) => new CliError({ message: `Failed to load auth provider metadata from ${url}: ${errorMessage(error)}` })), + ) + if (!wellknown.auth) return yield* fail(`Auth provider metadata from ${url} is missing auth configuration`) yield* Prompt.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const abort = new AbortController() - const proc = Process.spawn(wellknown.auth.command, { stdout: "pipe", stderr: "inherit", abort: abort.signal }) + const proc = Process.spawn([...wellknown.auth.command], { stdout: "pipe", stderr: "inherit", abort: abort.signal }) if (!proc.stdout) { yield* Prompt.log.error("Failed") yield* Prompt.outro("Done") @@ -345,7 +352,7 @@ export const ProvidersLoginCommand = effectCmd({ yield* Prompt.outro("Done") return } - yield* Effect.orDie(authSvc.set(url, { type: "wellknown", key: wellknown.auth.env, token: token.trim() })) + yield* Effect.orDie(authWellKnown.set(url, new AuthWellKnown.Entry({ key: wellknown.auth.env, token: token.trim() }))) yield* Prompt.log.success("Logged into " + url) yield* Prompt.outro("Done") return @@ -500,19 +507,29 @@ export const ProvidersLogoutCommand = effectCmd({ instance: false, handler: Effect.fn("Cli.providers.logout")(function* (args) { const authSvc = yield* Auth.Service + const authWellKnown = yield* AuthWellKnown.Service const modelsDev = yield* ModelsDev.Service UI.empty() - const credentials: Array<[string, Auth.Info]> = Object.entries(yield* Effect.orDie(authSvc.all())) + const credentials = [ + ...Object.entries(yield* Effect.orDie(authSvc.all())) + .filter(([, value]) => value.type !== "wellknown") + .map(([key, value]) => ({ key, type: value.type, auth: "provider" as const })), + ...Object.keys(yield* Effect.orDie(authWellKnown.all())).map((key) => ({ + key, + type: "wellknown" as const, + auth: "wellknown" as const, + })), + ] yield* Prompt.intro("Remove credential") if (credentials.length === 0) { yield* Prompt.log.error("No credentials found") return } const database = yield* modelsDev.get() - const options = credentials.map(([key, value]) => ({ - label: (database[key]?.name || key) + UI.Style.TEXT_DIM + " (" + value.type + ")", - value: key, + const options = credentials.map((item) => ({ + label: (database[item.key]?.name || item.key) + UI.Style.TEXT_DIM + " (" + item.type + ")", + value: item.key, })) const provider = args.provider ? options.find( @@ -528,7 +545,10 @@ export const ProvidersLogoutCommand = effectCmd({ }), ) if (!provider) return yield* fail(`Unknown configured provider "${args.provider}"`) - yield* Effect.orDie(authSvc.remove(provider)) + const credential = credentials.find((item) => item.key === provider) + if (!credential) return yield* fail(`Unknown configured provider "${args.provider}"`) + if (credential.auth === "wellknown") yield* Effect.orDie(authWellKnown.remove(credential.key)) + else yield* Effect.orDie(authSvc.remove(credential.key)) yield* Prompt.outro("Logout successful") }), }) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 7f568f4920..23dc6cb0e3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,5 +1,4 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { httpClient } from "@opencode-ai/core/effect/layer-node-platform" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" import { pathToFileURL } from "url" @@ -8,7 +7,8 @@ import { mergeDeep } from "remeda" import { Global } from "@opencode-ai/core/global" import fsNode from "fs/promises" import { Flag } from "@opencode-ai/core/flag/flag" -import { Auth } from "../auth" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" +import { Substitution } from "@opencode-ai/core/substitution" import { Env } from "../env" import { applyEdits, modify } from "jsonc-parser" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" @@ -18,12 +18,11 @@ import { isRecord } from "@/util/record" import type { ConsoleState } from "@opencode-ai/core/v1/config/console-state" import { FSUtil } from "@opencode-ai/core/fs-util" import { InstanceState } from "@/effect/instance-state" -import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Schema } from "effect" -import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http" +import { Context, Duration, Effect, Exit, Fiber, Layer, Option, Scope } from "effect" +import { FetchHttpClient } from "effect/unstable/http" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" import { containsPath, type InstanceContext } from "../project/instance-context" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" -import { RemoteAuthError } from "@opencode-ai/core/v1/config/error" import { ConfigPermissionV1 } from "@opencode-ai/core/v1/config/permission" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { ConfigAgent } from "./agent" @@ -32,9 +31,7 @@ import { ConfigManaged } from "./managed" import { ConfigParse } from "./parse" import { ConfigPaths } from "./paths" import { ConfigPlugin } from "./plugin" -import { ConfigVariable } from "./variable" import { Npm } from "@opencode-ai/core/npm" -import { withTransientReadRetry } from "@/util/effect-http-client" // Custom merge function that concatenates array fields instead of replacing them // Keep remeda's deep conditional merge type out of hot config-loading paths; TS profiling showed it dominates here. @@ -61,43 +58,6 @@ function normalizeLoadedConfig(data: unknown) { return copy } -async function substituteWellKnownRemoteConfig(input: { - value: unknown - dir: string - source: string - env: Record -}) { - if (!isRecord(input.value) || typeof input.value.url !== "string") return undefined - - const url = await ConfigVariable.substitute({ - text: input.value.url, - type: "virtual", - dir: input.dir, - source: input.source, - env: input.env, - }) - const headers = isRecord(input.value.headers) - ? Object.fromEntries( - await Promise.all( - Object.entries(input.value.headers) - .filter((entry): entry is [string, string] => typeof entry[1] === "string") - .map(async ([key, value]) => [ - key, - await ConfigVariable.substitute({ - text: value, - type: "virtual", - dir: input.dir, - source: input.source, - env: input.env, - }), - ]), - ), - ) - : undefined - - return { url, headers } -} - async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { @@ -176,53 +136,25 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.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, - loginOrigin: string, - ) { - const response = yield* HttpClient.filterStatusOk(withTransientReadRetry(http)) - .execute( - HttpClientRequest.get(url).pipe(HttpClientRequest.acceptJson, HttpClientRequest.setHeaders(headers ?? {})), - ) - .pipe( - Effect.catch((error) => Effect.die(new Error(`failed to fetch remote config from ${url}: ${String(error)}`))), - ) - const body = yield* response.text.pipe( - Effect.catch((error) => Effect.die(new Error(`failed to read remote config from ${url}: ${String(error)}`))), - ) - // An auth proxy can answer with an HTML login page at HTTP 200 (passes filterStatusOk); treat it as a re-auth error, not a decode failure. - const contentType = (response.headers["content-type"] ?? "").toLowerCase() - if (contentType.includes("html") || /^\s* Effect.die(new Error(`failed to decode remote config from ${url}: ${String(error)}`))), - ) - }) - const loadConfig = Effect.fnUntraced(function* ( text: string, options: { path: string } | { dir: string; source: string }, env?: Record, ) { const source = "path" in options ? options.path : options.source - const expanded = yield* Effect.promise(() => - ConfigVariable.substitute( - "path" in options - ? { text, type: "path", path: options.path, env } - : { text, type: "virtual", ...options, env }, - ), - ) + const expanded = yield* substitution.substitute( + "path" in options + ? { text, type: "path", path: options.path, env } + : { text, type: "virtual", ...options, env }, + ).pipe(Effect.orDie) const parsed = ConfigParse.jsonc(expanded, source) const data = ConfigParse.schema(ConfigV1.Info, normalizeLoadedConfig(parsed), source) if (!("path" in options)) return data @@ -310,10 +242,8 @@ export const layer = Layer.effect( } }) - const loadInstanceState = Effect.fn("Config.loadInstanceState")( - function* (ctx: InstanceContext) { - const auth = yield* authSvc.all().pipe(Effect.orDie) - + const loadInstanceState = (ctx: InstanceContext) => + Effect.gen(function* () { let result: Info = {} const authEnv: Record = {} const consoleManagedProviders = new Set() @@ -352,46 +282,13 @@ export const layer = Layer.effect( return mergePluginOrigins(source, next.plugin, kind) } - for (const [key, value] of Object.entries(auth)) { - if (value.type === "wellknown") { - const url = key.replace(/\/+$/, "") - authEnv[value.key] = value.token - const wellknownURL = `${url}/.well-known/opencode` - yield* Effect.logDebug("fetching remote config", { url: wellknownURL }) - const wellknown = yield* fetchRemoteJson(wellknownURL, undefined, ConfigV1.WellKnown, url) - const remote = yield* Effect.promise(() => - substituteWellKnownRemoteConfig({ - value: wellknown.remote_config, - dir: url, - source: wellknownURL, - env: authEnv, - }), - ) - const fetchedConfig = remote - ? yield* Effect.gen(function* () { - yield* Effect.logDebug("fetching remote config", { url: remote.url }) - const data = yield* fetchRemoteJson(remote.url, remote.headers, Schema.Json, url) - if (isRecord(data) && isRecord(data.config)) return data.config - if (isRecord(data)) return data - return yield* Effect.die( - new Error(`failed to decode remote config from ${remote.url}: expected object`), - ) - }) - : {} - const remoteConfig = mergeConfig(isRecord(wellknown.config) ? wellknown.config : {}, fetchedConfig) - if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" - const source = wellknownURL - const next = yield* loadConfig( - JSON.stringify(remoteConfig), - { - dir: path.dirname(source), - source, - }, - authEnv, - ) - yield* merge(source, next, "global") - yield* Effect.logDebug("loaded remote config from well-known", { url }) - } + for (const item of yield* authWellKnown.configs().pipe(Effect.orDie)) { + yield* merge( + item.source, + yield* loadConfig(JSON.stringify(item.content), { dir: item.dir, source: item.source }, authEnv), + "global", + ) + yield* Effect.logDebug("loaded well-known config", { url: item.url }) } const global = Object.keys(authEnv).length ? yield* loadGlobal(authEnv) : yield* getGlobal() @@ -592,14 +489,10 @@ export const layer = Layer.effect( switchableOrgCount: 0, }, } - }, - Effect.provideService(FSUtil.Service, fs), - ) + }) - const state = yield* InstanceState.make( - Effect.fn("Config.state")(function* (ctx) { - return yield* loadInstanceState(ctx).pipe(Effect.orDie) - }), + const state = yield* InstanceState.make((ctx) => + loadInstanceState(ctx).pipe(Effect.orDie) as unknown as Effect.Effect, ) const get = Effect.fn("Config.get")(function* () { @@ -675,12 +568,13 @@ export const defaultLayer = layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer), Layer.provide(Env.defaultLayer), - Layer.provide(Auth.defaultLayer), + Layer.provide(AuthWellKnown.defaultLayer), + Layer.provide(Substitution.defaultLayer), Layer.provide(Account.defaultLayer), Layer.provide(Npm.defaultLayer), Layer.provide(FetchHttpClient.layer), ) -export const node = LayerNode.make(layer, [FSUtil.node, Auth.node, Account.node, Env.node, Npm.node, httpClient]) +export const node = LayerNode.make(defaultLayer, []) export * as Config from "./config" diff --git a/packages/opencode/src/config/tui.ts b/packages/opencode/src/config/tui.ts index edc7674a93..9b013c6c75 100644 --- a/packages/opencode/src/config/tui.ts +++ b/packages/opencode/src/config/tui.ts @@ -11,13 +11,13 @@ import { Flag } from "@opencode-ai/core/flag/flag" import { isRecord } from "@opencode-ai/tui/util/record" import { Global } from "@opencode-ai/core/global" import { FSUtil } from "@opencode-ai/core/fs-util" +import { Substitution } from "@opencode-ai/core/substitution" import { CurrentWorkingDirectory } from "./tui-cwd" import { ConfigPlugin } from "@/config/plugin" import { TuiKeybind } from "@opencode-ai/tui/config/keybind" import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version" import { makeRuntime } from "@opencode-ai/core/effect/runtime" import { Filesystem } from "@/util/filesystem" -import { ConfigVariable } from "@/config/variable" import { Npm } from "@opencode-ai/core/npm" import { FormatError, FormatUnknownError } from "@/cli/error" import { TuiConfig } from "@opencode-ai/tui/config" @@ -80,6 +80,7 @@ function dropUnknownKeybinds(input: Record) { const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: string }) { const afs = yield* FSUtil.Service + const substitution = yield* Substitution.Service let appliedOrder = 0 const resolvePlugins = (config: Info, configFilepath: string): Effect.Effect => @@ -96,9 +97,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory: const load = (text: string, configFilepath: string): Effect.Effect => Effect.gen(function* () { - const expanded = yield* Effect.promise(() => - ConfigVariable.substitute({ text, type: "path", path: configFilepath, missing: "empty" }), - ) + const expanded = yield* substitution.substitute({ text, type: "path", path: configFilepath, missing: "empty" }).pipe(Effect.orDie) const data = ConfigParse.jsonc(expanded, configFilepath) if (!isRecord(data)) return {} as Info // Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json @@ -257,7 +256,11 @@ export const layer = Layer.effect( }).pipe(Effect.withSpan("TuiConfig.layer")), ) -export const defaultLayer = layer.pipe(Layer.provide(Npm.defaultLayer), Layer.provide(FSUtil.defaultLayer)) +export const defaultLayer = layer.pipe( + Layer.provide(Npm.defaultLayer), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Substitution.defaultLayer), +) const { runPromise } = makeRuntime(Service, defaultLayer) diff --git a/packages/opencode/src/config/variable.ts b/packages/opencode/src/config/variable.ts deleted file mode 100644 index 52c449538f..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 "@opencode-ai/core/v1/config/error" - -type ParseSource = - | { - type: "path" - path: string - } - | { - type: "virtual" - source: string - dir: string - } - -type SubstituteInput = ParseSource & { - text: string - missing?: "error" | "empty" - env?: Record -} - -function source(input: ParseSource) { - return input.type === "path" ? input.path : input.source -} - -function dir(input: ParseSource) { - return input.type === "path" ? path.dirname(input.path) : input.dir -} - -/** Apply {env:VAR} and {file:path} substitutions to config text. */ -export async function substitute(input: SubstituteInput) { - const missing = input.missing ?? "error" - let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => { - return (input.env?.[varName] ?? process.env[varName]) || "" - }) - - const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g)) - if (!fileMatches.length) return text - - const configDir = dir(input) - const configSource = source(input) - let out = "" - let cursor = 0 - - for (const match of fileMatches) { - const token = match[0] - const index = match.index - out += text.slice(cursor, index) - - const lineStart = text.lastIndexOf("\n", index - 1) + 1 - const prefix = text.slice(lineStart, index).trimStart() - if (prefix.startsWith("//")) { - out += token - cursor = index + token.length - continue - } - - let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "") - if (filePath.startsWith("~/")) { - filePath = path.join(os.homedir(), filePath.slice(2)) - } - - const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath) - const fileContent = ( - await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => { - if (missing === "empty") return "" - - const errMsg = `bad file reference: "${token}"` - if (error.code === "ENOENT") { - throw new InvalidError( - { - path: configSource, - message: errMsg + ` ${resolvedPath} does not exist`, - }, - { cause: error }, - ) - } - throw new InvalidError({ path: configSource, message: errMsg }, { cause: error }) - }) - ).trim() - - out += JSON.stringify(fileContent).slice(1, -1) - cursor = index + token.length - } - - out += text.slice(cursor) - return out -} diff --git a/packages/opencode/src/effect/app-runtime.ts b/packages/opencode/src/effect/app-runtime.ts index 30dbb4c880..4cee5d151a 100644 --- a/packages/opencode/src/effect/app-runtime.ts +++ b/packages/opencode/src/effect/app-runtime.ts @@ -4,6 +4,7 @@ import * as Observability from "@opencode-ai/core/observability" import { FSUtil } from "@opencode-ai/core/fs-util" import { Database } from "@opencode-ai/core/database/database" +import { AuthWellKnown } from "@opencode-ai/core/auth-well-known" import { Auth } from "@/auth" import { Account } from "@/account/account" import { Config } from "@/config/config" @@ -56,6 +57,7 @@ export const AppLayer = Layer.mergeAll( Npm.defaultLayer, FSUtil.defaultLayer, Database.defaultLayer, + AuthWellKnown.defaultLayer, Auth.defaultLayer, Account.defaultLayer, Config.defaultLayer, diff --git a/packages/opencode/test/agent/plugin-agent-regression.test.ts b/packages/opencode/test/agent/plugin-agent-regression.test.ts index 4c894b0a01..9593f166a2 100644 --- a/packages/opencode/test/agent/plugin-agent-regression.test.ts +++ b/packages/opencode/test/agent/plugin-agent-regression.test.ts @@ -1,6 +1,7 @@ import { expect } from "bun:test" -import { FSUtil } from "@opencode-ai/core/fs-util" import { LocationServiceMap } from "@opencode-ai/core/location-layer" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { Substitution } from "@opencode-ai/core/substitution" import { Effect, Layer } from "effect" import { FetchHttpClient } from "effect/unstable/http" import path from "path" @@ -13,6 +14,7 @@ import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" +import { AuthWellKnownTest } from "../fake/auth-well-known" import { NpmTest } from "../fake/npm" import { ProviderTest } from "../fake/provider" import { SkillTest } from "../fake/skill" @@ -27,6 +29,8 @@ const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "age const provider = ProviderTest.fake() const configLayer = Config.layer.pipe( Layer.provide(FSUtil.defaultLayer), + Layer.provide(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 02ace53668..0b9df96fe9 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -8,6 +8,7 @@ import { Config } from "@/config/config" import { ConfigManaged } from "@/config/managed" import { ConfigParse } from "../../src/config/parse" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Substitution } from "@opencode-ai/core/substitution" import { InstanceRef } from "../../src/effect/instance-ref" import type { InstanceContext } from "../../src/project/instance-context" @@ -40,6 +41,7 @@ import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +import { AuthWellKnownTest } from "../fake/auth-well-known" /** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */ const infra = CrossSpawnSpawner.defaultLayer.pipe( @@ -106,6 +108,9 @@ const configLayer = ( ) => Config.layer.pipe( Layer.provide(testFlock), + Layer.provide(FSUtil.defaultLayer), + Layer.provide(Substitution.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), Layer.provide(Env.defaultLayer), Layer.provide(options.auth ?? AuthTest.empty), Layer.provide(options.account ?? AccountTest.empty), @@ -1536,6 +1541,8 @@ test("remote well-known config can use FetchHttpClient layer", async () => { Config.layer.pipe( Layer.provide(testFlock), Layer.provide(FSUtil.defaultLayer), + Layer.provide(Substitution.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), Layer.provide(Env.defaultLayer), Layer.provide(wellKnownAuth(server.url.origin)), Layer.provide(AccountTest.empty), 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 7bd9e33527..334cbf4168 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 { FSUtil } from "@opencode-ai/core/fs-util" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Substitution } from "@opencode-ai/core/substitution" import path from "path" import { pathToFileURL } from "url" import { EventV2Bridge } from "../../src/event-v2-bridge" @@ -11,6 +12,7 @@ import { Config } from "../../src/config/config" import { Env } from "../../src/env" import { RuntimeFlags } from "../../src/effect/runtime-flags" import { Plugin } from "../../src/plugin/index" +import { AuthWellKnownTest } from "../fake/auth-well-known" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" @@ -23,6 +25,8 @@ import { ModelV2 } from "@opencode-ai/core/model" const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.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 30428980bb..5a24cf4305 100644 --- a/packages/opencode/test/plugin/workspace-adapter.test.ts +++ b/packages/opencode/test/plugin/workspace-adapter.test.ts @@ -6,6 +6,7 @@ import { Database } from "@opencode-ai/core/database/database" import { FSUtil } from "@opencode-ai/core/fs-util" import { Ripgrep } from "@opencode-ai/core/ripgrep" import { EffectFlock } from "@opencode-ai/core/util/effect-flock" +import { Substitution } from "@opencode-ai/core/substitution" import path from "path" import { pathToFileURL } from "url" import { Auth } from "../../src/auth" @@ -27,10 +28,13 @@ import { testEffect } from "../lib/effect" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" +import { AuthWellKnownTest } from "../fake/auth-well-known" const configLayer = Config.layer.pipe( Layer.provide(EffectFlock.defaultLayer), Layer.provide(FSUtil.defaultLayer), + Layer.provide(AuthWellKnownTest.empty), + Layer.provide(Substitution.defaultLayer), Layer.provide(Env.defaultLayer), Layer.provide(AuthTest.empty), Layer.provide(AccountTest.empty),