feat: add well-known auth service
This commit is contained in:
@@ -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<string, unknown>, 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<Info> =>
|
||||
@@ -112,9 +113,7 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
|
||||
const load = (text: string, configFilepath: string): Effect.Effect<Info> =>
|
||||
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)
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { Global } from "@opencode-ai/core/global"
|
||||
import fsNode from "fs/promises"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import { Env } from "../env"
|
||||
import { applyEdits, modify } from "jsonc-parser"
|
||||
import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
@@ -38,7 +39,6 @@ import { ConfigProvider } from "./provider"
|
||||
import { ConfigReference } from "./reference"
|
||||
import { ConfigServer } from "./server"
|
||||
import { ConfigSkills } from "./skills"
|
||||
import { ConfigVariable } from "./variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -69,36 +69,6 @@ function normalizeLoadedConfig(data: unknown, source: string) {
|
||||
return copy
|
||||
}
|
||||
|
||||
async function substituteWellKnownRemoteConfig(input: { value: unknown; dir: string; source: string }) {
|
||||
if (!isRecord(input.value) || typeof input.value.url !== "string") return
|
||||
|
||||
const url = await ConfigVariable.substitute({
|
||||
text: input.value.url,
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
})
|
||||
const headers = isRecord(input.value.headers)
|
||||
? Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(input.value.headers)
|
||||
.filter((entry): entry is [string, string] => typeof entry[1] === "string")
|
||||
.map(async ([key, value]) => [
|
||||
key,
|
||||
await ConfigVariable.substitute({
|
||||
text: value,
|
||||
type: "virtual",
|
||||
dir: input.dir,
|
||||
source: input.source,
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
: undefined
|
||||
|
||||
return { url, headers }
|
||||
}
|
||||
|
||||
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(config: T, filepath: string) {
|
||||
if (!config.plugin) return config
|
||||
for (let i = 0; i < config.plugin.length; i++) {
|
||||
@@ -365,7 +335,8 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
const authWellKnown = yield* AuthWellKnown.Service
|
||||
const substitution = yield* Substitution.Service
|
||||
const accountSvc = yield* Account.Service
|
||||
const env = yield* Env.Service
|
||||
const npmSvc = yield* Npm.Service
|
||||
@@ -377,11 +348,9 @@ export const layer = Layer.effect(
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
) {
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const expanded = yield* Effect.promise(() =>
|
||||
ConfigVariable.substitute(
|
||||
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
|
||||
),
|
||||
)
|
||||
const expanded = yield* substitution.substitute(
|
||||
"path" in options ? { text, type: "path", path: options.path } : { text, type: "virtual", ...options },
|
||||
).pipe(Effect.orDie)
|
||||
const parsed = ConfigParse.jsonc(expanded, source)
|
||||
const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source)
|
||||
if (!("path" in options)) return data
|
||||
@@ -471,8 +440,6 @@ export const layer = Layer.effect(
|
||||
|
||||
const loadInstanceState = Effect.fn("Config.loadInstanceState")(
|
||||
function* (ctx: InstanceContext) {
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
let result: Info = {}
|
||||
const consoleManagedProviders = new Set<string>()
|
||||
let activeOrgName: string | undefined
|
||||
@@ -510,46 +477,13 @@ export const layer = Layer.effect(
|
||||
return mergePluginOrigins(source, next.plugin, kind)
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as {
|
||||
config?: Record<string, unknown>
|
||||
remote_config?: unknown
|
||||
}
|
||||
const remote = yield* Effect.promise(() =>
|
||||
substituteWellKnownRemoteConfig({
|
||||
value: wellknown.remote_config,
|
||||
dir: url,
|
||||
source: `${url}/.well-known/opencode`,
|
||||
}),
|
||||
)
|
||||
const fetchedConfig = remote
|
||||
? ((yield* Effect.promise(async () => {
|
||||
log.debug("fetching remote config", { url: remote.url })
|
||||
const response = await fetch(remote.url, { headers: remote.headers })
|
||||
if (!response.ok)
|
||||
throw new Error(`failed to fetch remote config from ${remote.url}: ${response.status}`)
|
||||
const data = await response.json()
|
||||
return isRecord(data) && isRecord(data.config) ? data.config : data
|
||||
})) as Record<string, unknown>)
|
||||
: {}
|
||||
const remoteConfig = mergeConfig(wellknown.config ?? {}, fetchedConfig as Info)
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
const source = `${url}/.well-known/opencode`
|
||||
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(source),
|
||||
source,
|
||||
})
|
||||
yield* merge(source, next, "global")
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
for (const item of yield* authWellKnown.configs().pipe(Effect.orDie)) {
|
||||
yield* merge(
|
||||
item.source,
|
||||
yield* loadConfig(JSON.stringify(item.content), { dir: item.dir, source: item.source }),
|
||||
"global",
|
||||
)
|
||||
log.debug("loaded well-known config", { url: item.url })
|
||||
}
|
||||
|
||||
const global = yield* getGlobal()
|
||||
@@ -825,7 +759,8 @@ export const defaultLayer = layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(Auth.defaultLayer),
|
||||
Layer.provide(AuthWellKnown.defaultLayer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
Layer.provide(Npm.defaultLayer),
|
||||
)
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
export * as ConfigVariable from "./variable"
|
||||
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { InvalidError } from "./error"
|
||||
|
||||
type ParseSource =
|
||||
| {
|
||||
type: "path"
|
||||
path: string
|
||||
}
|
||||
| {
|
||||
type: "virtual"
|
||||
source: string
|
||||
dir: string
|
||||
}
|
||||
|
||||
type SubstituteInput = ParseSource & {
|
||||
text: string
|
||||
missing?: "error" | "empty"
|
||||
}
|
||||
|
||||
function source(input: ParseSource) {
|
||||
return input.type === "path" ? input.path : input.source
|
||||
}
|
||||
|
||||
function dir(input: ParseSource) {
|
||||
return input.type === "path" ? path.dirname(input.path) : input.dir
|
||||
}
|
||||
|
||||
/** Apply {env:VAR} and {file:path} substitutions to config text. */
|
||||
export async function substitute(input: SubstituteInput) {
|
||||
const missing = input.missing ?? "error"
|
||||
let text = input.text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
|
||||
return process.env[varName] || ""
|
||||
})
|
||||
|
||||
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
|
||||
if (!fileMatches.length) return text
|
||||
|
||||
const configDir = dir(input)
|
||||
const configSource = source(input)
|
||||
let out = ""
|
||||
let cursor = 0
|
||||
|
||||
for (const match of fileMatches) {
|
||||
const token = match[0]
|
||||
const index = match.index!
|
||||
out += text.slice(cursor, index)
|
||||
|
||||
const lineStart = text.lastIndexOf("\n", index - 1) + 1
|
||||
const prefix = text.slice(lineStart, index).trimStart()
|
||||
if (prefix.startsWith("//")) {
|
||||
out += token
|
||||
cursor = index + token.length
|
||||
continue
|
||||
}
|
||||
|
||||
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
|
||||
if (filePath.startsWith("~/")) {
|
||||
filePath = path.join(os.homedir(), filePath.slice(2))
|
||||
}
|
||||
|
||||
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
|
||||
const fileContent = (
|
||||
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
|
||||
if (missing === "empty") return ""
|
||||
|
||||
const errMsg = `bad file reference: "${token}"`
|
||||
if (error.code === "ENOENT") {
|
||||
throw new InvalidError(
|
||||
{
|
||||
path: configSource,
|
||||
message: errMsg + ` ${resolvedPath} does not exist`,
|
||||
},
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
|
||||
})
|
||||
).trim()
|
||||
|
||||
out += JSON.stringify(fileContent).slice(1, -1)
|
||||
cursor = index + token.length
|
||||
}
|
||||
|
||||
out += text.slice(cursor)
|
||||
return out
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -23,8 +25,13 @@ import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
|
||||
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
|
||||
|
||||
const provider = ProviderTest.fake()
|
||||
const emptyAuthWellKnown = Layer.mock(AuthWellKnown.Service)({
|
||||
configs: () => Effect.succeed([]),
|
||||
})
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(emptyAuthWellKnown),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import type { InstanceContext } from "../../src/project/instance-context"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { AccessToken, AccountID, OrgID } from "../../src/account/schema"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
@@ -31,14 +31,15 @@ import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
const emptyAuthWellKnown = Layer.mock(AuthWellKnown.Service)({
|
||||
configs: () => Effect.succeed([]),
|
||||
})
|
||||
|
||||
const testFlock = EffectFlock.defaultLayer
|
||||
@@ -49,11 +50,15 @@ const noopNpm = Layer.mock(Npm.Service)({
|
||||
which: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
|
||||
const runSubstitution = <A, E>(effect: Effect.Effect<A, E, Substitution.Service>) =>
|
||||
Effect.runPromise(effect.pipe(Effect.provide(Substitution.defaultLayer)))
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAuthWellKnown),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
@@ -486,6 +491,30 @@ test("handles environment variable substitution", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("environment variable substitution accepts an env overlay", async () => {
|
||||
const originalEnv = process.env["TEST_VAR"]
|
||||
delete process.env["TEST_VAR"]
|
||||
|
||||
try {
|
||||
expect(
|
||||
await runSubstitution(
|
||||
Substitution.Service.use((substitution) =>
|
||||
substitution.substitute({
|
||||
text: "{env:TEST_VAR}",
|
||||
type: "virtual",
|
||||
dir: "/tmp",
|
||||
source: "test",
|
||||
env: { TEST_VAR: "overlay" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
).toBe("overlay")
|
||||
} finally {
|
||||
if (originalEnv === undefined) delete process.env["TEST_VAR"]
|
||||
else process.env["TEST_VAR"] = originalEnv
|
||||
}
|
||||
})
|
||||
|
||||
test("preserves env variables when adding $schema to config", async () => {
|
||||
const originalEnv = process.env["PRESERVE_VAR"]
|
||||
process.env["PRESERVE_VAR"] = "secret_value"
|
||||
@@ -564,8 +593,9 @@ test("resolves env templates in account config with account token", async () =>
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAuthWellKnown),
|
||||
Layer.provide(fakeAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
@@ -1075,8 +1105,9 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
||||
const testLayer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAuthWellKnown),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
@@ -1938,192 +1969,44 @@ test("local .opencode config can override MCP from project config", async () =>
|
||||
})
|
||||
|
||||
test("project config overrides remote well-known config", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let fetchedUrl: string | undefined
|
||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
fetchedUrl = urlStr
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
config: {
|
||||
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
}
|
||||
return originalFetch(url)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
const fakeAuthWellKnown = Layer.mock(AuthWellKnown.Service)({
|
||||
configs: () =>
|
||||
Effect.succeed([
|
||||
{
|
||||
url: "https://example.com",
|
||||
source: "https://example.com/.well-known/opencode",
|
||||
dir: "https://example.com/.well-known",
|
||||
content: {
|
||||
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
|
||||
},
|
||||
},
|
||||
]),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(fakeAuthWellKnown),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
|
||||
},
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("wellknown URL with trailing slash is normalized", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let fetchedUrl: string | undefined
|
||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
fetchedUrl = urlStr
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
config: {
|
||||
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
}
|
||||
return originalFetch(url)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
yield* svc.get()
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test("wellknown remote_config supports templated env vars in headers", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalToken = process.env.TEST_TOKEN
|
||||
let wellknownFetchedUrl: string | undefined
|
||||
let remoteFetchedUrl: string | undefined
|
||||
let remoteHeaders: HeadersInit | undefined
|
||||
globalThis.fetch = mock((url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlStr = url instanceof Request ? url.url : url instanceof URL ? url.href : url
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
wellknownFetchedUrl = urlStr
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
remote_config: {
|
||||
url: "https://config.example.com/opencode.json",
|
||||
headers: {
|
||||
Authorization: "Bearer {env:TEST_TOKEN}",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
}
|
||||
if (urlStr.includes("config.example.com")) {
|
||||
remoteFetchedUrl = urlStr
|
||||
remoteHeaders = init?.headers
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
mcp: { confluence: { type: "remote", url: "https://confluence.example.com/mcp", enabled: true } },
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
)
|
||||
}
|
||||
return originalFetch(url, init)
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(testFlock),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
Layer.provide(noopNpm),
|
||||
)
|
||||
|
||||
try {
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(wellknownFetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
expect(remoteFetchedUrl).toBe("https://config.example.com/opencode.json")
|
||||
expect(remoteHeaders).toEqual({ Authorization: "Bearer test-token" })
|
||||
expect(config.mcp?.confluence?.enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
if (originalToken === undefined) delete process.env.TEST_TOKEN
|
||||
else process.env.TEST_TOKEN = originalToken
|
||||
}
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||
}),
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
|
||||
},
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
})
|
||||
|
||||
describe("resolvePluginSpec", () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Effect, Layer, Option } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Account } from "../../src/account/account"
|
||||
@@ -24,9 +26,14 @@ const emptyAccount = Layer.mock(Account.Service)({
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
const emptyAuthWellKnown = Layer.mock(AuthWellKnown.Service)({
|
||||
configs: () => Effect.succeed([]),
|
||||
})
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(emptyAuthWellKnown),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
|
||||
@@ -4,6 +4,8 @@ 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 { AuthWellKnown } from "@opencode-ai/core/auth-well-known"
|
||||
import { Substitution } from "@opencode-ai/core/substitution"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Account } from "../../src/account/account"
|
||||
@@ -33,9 +35,14 @@ const emptyAccount = Layer.mock(Account.Service)({
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
const emptyAuthWellKnown = Layer.mock(AuthWellKnown.Service)({
|
||||
configs: () => Effect.succeed([]),
|
||||
})
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(emptyAuthWellKnown),
|
||||
Layer.provide(Substitution.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
|
||||
Reference in New Issue
Block a user