Compare commits

...
9 changed files with 332 additions and 87 deletions

No files matched your search

+43 -13
View File
@@ -42,6 +42,8 @@ export interface Interface {
readonly changes: () => Stream.Stream<Watcher.Update>
/** Updates supported global config fields while preserving unrelated JSONC content. */
readonly update?: (patch: Patch) => Effect.Effect<void, FSUtil.Error>
/** Unreadable {file:} references per current document; each referencing value was removed from that document. */
readonly unresolved?: () => Effect.Effect<ReadonlyMap<Document, readonly ConfigVariable.Unresolved[]>>
}
export const Options = Schema.Struct({
@@ -137,13 +139,27 @@ export const layer = (options?: Options) =>
})
})
// Keyed by Document identity so a reload that keeps equal entries keeps their record too.
const unresolved = new WeakMap<Document, readonly ConfigVariable.Unresolved[]>()
const parseDocument = Effect.fnUntraced(function* (
substituted: ConfigVariable.Result,
source: string,
documentPath?: AbsolutePath,
) {
const info = yield* parseInfo(substituted.text, source)
if (!info) return
const document = new Document(
documentPath ? { type: "document", path: documentPath, info } : { type: "document", info },
)
if (substituted.unresolved.length) unresolved.set(document, substituted.unresolved)
return document
})
const loadFile = Effect.fnUntraced(function* (filepath: string) {
const text = yield* fs.readFileStringSafe(filepath)
if (text === undefined) return
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath)
if (!info) return
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
return yield* parseDocument(substituted, filepath, AbsolutePath.make(filepath))
})
const loadWellknownEntry = Effect.fnUntraced(function* (entry: WellKnown.Entry) {
@@ -168,10 +184,7 @@ export const layer = (options?: Options) =>
dir: entry.origin,
text: JSON.stringify(config),
env: variables,
}).pipe(
Effect.flatMap((text) => parseInfo(text, entry.origin)),
Effect.map((info) => (info ? new Document({ type: "document", info }) : undefined)),
),
}).pipe(Effect.flatMap((substituted) => parseDocument(substituted, entry.origin))),
).pipe(Effect.map((documents) => documents.filter((document) => document !== undefined)))
})
@@ -196,14 +209,14 @@ export const layer = (options?: Options) =>
yield* Effect.logWarning("failed to convert managed preferences", { source: plist })
return []
}
const text = yield* ConfigVariable.substitute({
const substituted = yield* ConfigVariable.substitute({
type: "virtual",
source: plist,
dir: path.dirname(plist),
text: ConfigDiscovery.stripPreferenceMetadata(result.value.stdout.toString()),
})
const info = yield* parseInfo(text, plist)
return info ? [new Document({ type: "document", path: plist, info })] : []
const document = yield* parseDocument(substituted, plist, plist)
return document ? [document] : []
})
const loadDirectory = Effect.fnUntraced(function* (directory: AbsolutePath) {
@@ -235,8 +248,8 @@ export const layer = (options?: Options) =>
dir: location.directory,
text: options.content,
}).pipe(
Effect.flatMap((text) => parseInfo(text, "OPENCODE_CONFIG_CONTENT")),
Effect.map((info) => (info ? [new Document({ type: "document", info })] : [])),
Effect.flatMap((substituted) => parseDocument(substituted, "OPENCODE_CONFIG_CONTENT")),
Effect.map((document) => (document ? [document] : [])),
Effect.orDie,
)
: []
@@ -393,6 +406,14 @@ export const layer = (options?: Options) =>
}),
changes: () => Stream.fromPubSub(updates),
update,
unresolved: Effect.fnUntraced(function* () {
return new Map(
configs.flatMap((entry) => {
const references = entry.type === "document" ? unresolved.get(entry) : undefined
return entry.type === "document" && references ? [[entry, references] as const] : []
}),
)
}),
})
}),
)
@@ -401,7 +422,16 @@ export function configured(options?: Options) {
return makeLocationNode({
service: Service,
layer: layer(options),
deps: [Watcher.node, Bus.node, FSUtil.node, Global.node, Location.node, Credential.node, WellKnown.node, AppProcess.node],
deps: [
Watcher.node,
Bus.node,
FSUtil.node,
Global.node,
Location.node,
Credential.node,
WellKnown.node,
AppProcess.node,
],
})
}
+24 -7
View File
@@ -5,6 +5,7 @@ import { Document, type Entry } from "@opencode/schema/config"
import type { ServerConfig } from "@opencode/schema/mcp"
import { Effect, Stream } from "effect"
import { Config } from "../../config.js"
import type { ConfigVariable } from "../variable.js"
import { Mcp } from "../../mcp/index.js"
export const Plugin = define({
@@ -19,13 +20,15 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
) {
const config = yield* Config.Service
const mcp = yield* Mcp.Service
const loaded = { entries: [] as Entry[] }
const none: ReadonlyMap<Document, readonly ConfigVariable.Unresolved[]> = new Map()
const load = Effect.all({ entries: config.entries(), unresolved: config.unresolved?.() ?? Effect.succeed(none) })
const loaded = { entries: [] as Entry[], unresolved: none }
yield* events.pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() =>
config.entries().pipe(
Effect.tap((entries) => Effect.sync(() => (loaded.entries = entries))),
load.pipe(
Effect.tap((next) => Effect.sync(() => Object.assign(loaded, next))),
Effect.andThen(mcp.reload()),
Effect.catchCause((cause) => Effect.logError("failed to reload MCP config", { cause })),
),
@@ -35,7 +38,7 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
)
// Subscribe before the initial load so updates racing it trigger a rebuild.
loaded.entries = yield* config.entries()
Object.assign(loaded, yield* load)
yield* mcp.transform((editor) => {
const documents = loaded.entries.filter((entry): entry is Document => entry.type === "document")
// Global timeout defaults merge in config order; each server can override them.
@@ -43,15 +46,29 @@ export const register = Effect.fn("ConfigMCPPlugin.register")(function* (
{},
...documents.flatMap((entry) => (entry.info.mcp?.timeout ? [entry.info.mcp.timeout] : [])),
)
const servers = new Map<string, ServerConfig>()
const servers = new Map<string, { server: ServerConfig; document: Document }>()
for (const document of documents) {
for (const [name, server] of Object.entries(document.info.mcp?.servers ?? {})) {
servers.set(name, server)
servers.set(name, { server, document })
}
}
for (const [name, server] of servers) {
for (const [name, { server, document }] of servers) {
if (editor.get(name)) continue
editor.set(name, { ...server, timeout: { ...timeout, ...server.timeout } })
const reference = loaded.unresolved.get(document)?.find((item) => serverName(item.path) === name)
if (reference) editor.fail(name, describe(reference))
}
})
})
// Native servers live under mcp.servers.<name>; legacy documents list them directly under mcp.<name>.
function serverName(path: ConfigVariable.Unresolved["path"]) {
if (path[0] !== "mcp") return
if (path[1] === "servers") return path[2]
if (path[1] !== "timeout") return path[1]
}
function describe(reference: ConfigVariable.Unresolved) {
const problem = reference.reason === "NotFound" ? "does not exist" : `could not be read (${reference.reason})`
return `bad file reference "${reference.reference}": ${reference.file} ${problem}`
}
+59 -21
View File
@@ -2,9 +2,18 @@ export * as ConfigVariable from "./variable.js"
import os from "os"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { Effect } from "effect"
import {
applyEdits,
findNodeAtOffset,
getNodePath,
modify,
parseTree,
type JSONPath,
type ParseError,
} from "jsonc-parser"
import { FSUtil } from "@opencode/util/fs-util"
import { InvalidError } from "../v1/config/error.js"
type ParseSource =
| {
@@ -19,17 +28,30 @@ type ParseSource =
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
/** A {file:} reference that could not be read; the JSON value holding it was removed. */
export interface Unresolved {
/** JSON path of the removed value in the source document. */
readonly path: JSONPath
readonly reference: string
readonly file: string
readonly reason: string
}
export interface Result {
readonly text: string
readonly unresolved: readonly Unresolved[]
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
export const substitute = Effect.fn("ConfigVariable.substitute")(function* (input: SubstituteInput) {
const text = input.text.replace(
/\{env:([^}]+)\}/g,
(_, varName: string) => (input.env?.[varName] ?? process.env[varName]) || "",
)
if (!text.includes("{file:")) return text
if (!text.includes("{file:")) return { text, unresolved: [] } satisfies Result
return yield* substituteFiles(input, text)
})
@@ -37,6 +59,9 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
const fs = yield* FSUtil.Service
const configDir = input.type === "path" ? path.dirname(input.path) : input.dir
const configSource = input.type === "path" ? input.path : input.source
const errors: ParseError[] = []
const tree = parseTree(text, errors, { allowTrailingComma: true })
const unresolved: Unresolved[] = []
let out = ""
let cursor = 0
@@ -56,29 +81,42 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
const filePath = token.slice("{file:".length, -1)
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
// An unreadable file removes only the value that references it, so one stale reference
// cannot take down the rest of the Location's config or leave a half-filled value behind.
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((error) => {
if (input.missing === "empty") return Effect.succeed("")
const message = `bad file reference: "${token}"`
return Effect.fail(
new InvalidError(
{
path: configSource,
message:
error._tag === "PlatformError" && error.reason._tag === "NotFound"
? `${message} ${resolvedPath} does not exist`
: message,
},
{ cause: error },
),
)
}),
Effect.catch((error) =>
Effect.gen(function* () {
const valuePath = errors.length === 0 && tree ? stringValuePath(findNodeAtOffset(tree, index)) : undefined
yield* Effect.logWarning("configuration file reference unavailable", {
source: configSource,
path: valuePath ? `$.${valuePath.join(".")}` : "$",
reference: token,
file: resolvedPath,
reason: error.reason._tag,
action: valuePath ? "removed value" : "substituted empty value",
})
if (valuePath && !unresolved.some((item) => isDeepStrictEqual(item.path, valuePath)))
unresolved.push({ path: valuePath, reference: token, file: resolvedPath, reason: error.reason._tag })
return ""
}),
),
)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
cursor = index + token.length
}
return out + text.slice(cursor)
// Remove in reverse document order so earlier array indexes stay valid.
return {
text: unresolved
.toReversed()
.reduce((text, item) => applyEdits(text, modify(text, item.path, undefined, {})), out + text.slice(cursor)),
unresolved,
} satisfies Result
})
function stringValuePath(node: ReturnType<typeof findNodeAtOffset>) {
if (node?.type !== "string" || !node.parent) return
if (node.parent.type === "property" && node.parent.children?.[0] === node) return
return getNodePath(node)
}
+39 -10
View File
@@ -77,6 +77,9 @@ const unavailable = (server: ServerName, status: Status) => {
type ServerEntry = {
readonly config: Mcp.ServerConfig
// A configuration problem, such as an unreadable {file:} reference. The server reports it as
// failed without connecting until its configuration changes.
readonly configError?: string
status: Status
readonly startup: Latch.Latch
scope?: Scope.Closeable
@@ -98,14 +101,18 @@ const endpointLoads = KeyedMutex.makeUnsafe<string>()
type Data = {
servers: Map<ServerName, Types.DeepMutable<Mcp.ServerConfig>>
removed: Set<ServerName>
failed: Map<ServerName, string>
}
export type Editor = {
list: () => readonly [ServerName, Types.DeepMutable<Mcp.ServerConfig>][]
get: (server: ServerName | string) => Types.DeepMutable<Mcp.ServerConfig> | undefined
/** Replaces a server's configuration and clears any failure recorded for it. */
set: (server: ServerName | string, config: Mcp.ServerConfig) => void
update: (server: ServerName | string, update: (config: Types.DeepMutable<Mcp.ServerConfig>) => void) => void
remove: (server: ServerName | string) => void
/** Marks a configured server failed with a configuration error instead of connecting it. */
fail: (server: ServerName | string, error: string) => void
}
const cloneConfig = (config: Mcp.ServerConfig) => structuredClone(config) as Types.DeepMutable<Mcp.ServerConfig>
@@ -426,6 +433,12 @@ export const layer = (options?: Options) =>
const startServer = (name: ServerName, entry: ServerEntry) =>
Effect.gen(function* () {
if (entry.configError) {
entry.status = { status: "failed", error: entry.configError }
yield* Effect.logWarning("mcp connect failed", { server: name, status: entry.status })
yield* bus.publish(McpEvent.StatusChanged, { server: name })
return
}
// Announce the handshake so connect() and credential reconnects don't show a stale
// disabled/failed status for the duration of the connection attempt.
entry.status = { status: "pending" }
@@ -499,11 +512,16 @@ export const layer = (options?: Options) =>
if (entry.registration) yield* entry.registration.dispose
})
const replaceServer = Effect.fnUntraced(function* (name: ServerName, serverConfig: Mcp.ServerConfig) {
const replaceServer = Effect.fnUntraced(function* (
name: ServerName,
serverConfig: Mcp.ServerConfig,
configError: string | undefined,
) {
const previous = entries.get(name)
if (previous) yield* disposeServer(name, previous)
const entry: ServerEntry = {
config: serverConfig,
configError,
status: { status: "pending" },
startup: Latch.makeUnsafe(),
}
@@ -531,21 +549,22 @@ export const layer = (options?: Options) =>
yield* bus.publish(McpEvent.StatusChanged, { server: name })
})
let applied: Map<ServerName, Mcp.ServerConfig> | undefined
let applied: Pick<Data, "servers" | "failed"> | undefined
const overrides = new Map<ServerName, Mcp.ServerConfig | false>()
const reconcileLock = Semaphore.makeUnsafe(1)
const reconcile = Effect.fnUntraced(function* () {
const servers = state.get().servers
const { servers, failed } = state.get()
if (!applied && entries.size === 0) {
for (const [name, server] of servers) {
entries.set(name, {
config: server,
configError: failed.get(name),
status: { status: "pending" },
startup: Latch.makeUnsafe(),
})
}
yield* Effect.forEach(entries, ([name, entry]) => register(name, entry), { discard: true })
applied = servers
applied = { servers, failed }
// Initial connections stay asynchronous so one slow server does not block Location startup.
for (const [name, entry] of entries) {
@@ -560,18 +579,18 @@ export const layer = (options?: Options) =>
return
}
const names = new Set([...(applied?.keys() ?? []), ...servers.keys()])
const names = new Set([...(applied?.servers.keys() ?? []), ...servers.keys()])
for (const name of names) {
const previous = applied?.get(name)
const previous = applied?.servers.get(name)
const updated = servers.get(name)
if (isDeepStrictEqual(previous, updated)) continue
if (isDeepStrictEqual(previous, updated) && applied?.failed.get(name) === failed.get(name)) continue
if (!updated) {
yield* removeServer(name).pipe(locks.withLock(name))
continue
}
yield* replaceServer(name, updated).pipe(locks.withLock(name))
yield* replaceServer(name, updated, failed.get(name)).pipe(locks.withLock(name))
}
applied = servers
applied = { servers, failed }
})
// Bring a server online (or back to needs_auth) when its integration's credential changes, so an
@@ -605,6 +624,7 @@ export const layer = (options?: Options) =>
),
),
removed: new Set(Array.from(overrides).flatMap(([name, config]) => (config === false ? [name] : []))),
failed: new Map(),
}),
editor: (editor) => ({
list: () => Array.from(editor.servers),
@@ -613,13 +633,22 @@ export const layer = (options?: Options) =>
const name = ServerName.make(server)
if (editor.removed.has(name)) return
editor.servers.set(name, cloneConfig(serverConfig))
editor.failed.delete(name)
},
update: (server, update) => {
const current = editor.servers.get(ServerName.make(server))
if (!current) return
update(current)
},
remove: (server) => editor.servers.delete(ServerName.make(server)),
remove: (server) => {
const name = ServerName.make(server)
editor.servers.delete(name)
editor.failed.delete(name)
},
fail: (server, error) => {
const name = ServerName.make(server)
if (editor.servers.has(name)) editor.failed.set(name, error)
},
}),
notify: () => State.reconcile(root, fork, () => reconcileLock.withPermit(reconcile())),
})
+96
View File
@@ -1341,6 +1341,102 @@ describe("Config", () => {
),
)
it.live("removes values with unreadable file references and restores them when config reloads", () => {
const output: Array<Record<string, unknown>> = []
const logger = Logger.map(Logger.formatStructured, (entry) => {
if (!Array.isArray(entry.message) || entry.message[0] !== "configuration file reference unavailable") return
const details = entry.message[1]
if (typeof details === "object" && details !== null) output.push(details as Record<string, unknown>)
})
return Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const file = path.join(global, "opencode.json")
// Outside every config root, like ~/.secrets.
const secret = path.join(tmp.path, "secrets", "firecrawl-key")
yield* Effect.promise(async () => {
await fs.mkdir(global, { recursive: true })
await fs.mkdir(project, { recursive: true })
await fs.writeFile(
file,
JSON.stringify({
username: "kept",
instructions: ["first.md", "{file:../secrets/firecrawl-key}", "last.md"],
mcp: {
servers: {
firecrawl: {
type: "remote",
url: "https://mcp.firecrawl.dev/v2/mcp",
headers: { Authorization: "Bearer {file:../secrets/firecrawl-key}", "X-Client": "opencode" },
},
},
},
}),
)
})
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const bus = yield* Bus.Service
const watcher = yield* Watcher.Test
const headers = Effect.gen(function* () {
const server = Config.latest(yield* config.entries(), "mcp")?.servers?.firecrawl
return server?.type === "remote" ? server.headers : undefined
})
const entries = yield* config.entries()
expect(Config.latest(entries, "username")).toBe("kept")
expect(Config.latest(entries, "instructions")).toEqual(["first.md", "last.md"])
expect(yield* headers).toEqual({ "X-Client": "opencode" })
const unresolved = yield* config.unresolved!()
expect(Array.from(unresolved.keys())).toEqual(entries.filter((entry) => entry.type === "document"))
expect(Array.from(unresolved.values())).toEqual([
[
{
path: ["instructions", 1],
reference: "{file:../secrets/firecrawl-key}",
file: secret,
reason: "NotFound",
},
{
path: ["mcp", "servers", "firecrawl", "headers", "Authorization"],
reference: "{file:../secrets/firecrawl-key}",
file: secret,
reason: "NotFound",
},
],
])
expect(output).toContainEqual({
source: file,
path: "$.mcp.servers.firecrawl.headers.Authorization",
reference: "{file:../secrets/firecrawl-key}",
file: secret,
reason: "NotFound",
action: "removed value",
})
const changed = yield* bus
.subscribe(Event.Updated)
.pipe(Stream.take(1), Stream.runCollect, Effect.forkScoped)
yield* Effect.sleep("10 millis")
yield* Effect.promise(async () => {
await fs.mkdir(path.dirname(secret), { recursive: true })
await fs.writeFile(secret, "fc-key\n")
})
// Referenced files are not watched; a config change picks the restored file up.
yield* watcher.emit({ type: "update", path: file })
expect(yield* Fiber.join(changed)).toHaveLength(1)
expect(yield* headers).toEqual({ Authorization: "Bearer fc-key", "X-Client": "opencode" })
expect((yield* config.unresolved!()).size).toBe(0)
}).pipe(Effect.provide(testLayer(project, global, project, undefined, Watcher.testLayer)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.provide(Logger.layer([logger])))
})
it.live("does not load legacy config.json files", () =>
Effect.acquireDisposable(Effect.promise(() => tmpdir())).pipe(
Effect.flatMap((tmp) =>
+12 -27
View File
@@ -5,7 +5,6 @@ import { describe, expect } from "bun:test"
import { Config } from "@opencode/schema/config"
import { Money } from "@opencode/schema/money"
import {
Cause,
DateTime,
Deferred,
Duration,
@@ -79,10 +78,10 @@ const itWithActivity = testEffect(
)
describe("LocationServiceMap", () => {
for (const failure of ["file", "permissions", "config reference"] as const) {
for (const failure of ["file", "permissions", "config file"] as const) {
for (const invalidate of [false, true]) {
// The file-path fixture boots on Windows rather than failing during
// discovery. The config-reference case covers repair on every OS.
// discovery. The config-file case covers repair on every OS.
// Windows does not enforce POSIX directory modes, and root bypasses them.
const test =
(failure === "file" && process.platform === "win32") ||
@@ -102,20 +101,11 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.mkdir(directory, { mode: 0o000 }))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(directory, 0o755)))
}
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(directory))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
if (failure === "config file") {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "opencode.json"), { recursive: true }))
}
const first = yield* Effect.exit(load)
expect(Exit.isFailure(first)).toBe(true)
if (failure === "config reference" && Exit.isFailure(first)) {
expect(Cause.squash(first.cause)).toMatchObject({
name: "ConfigInvalidError",
data: { message: expect.stringContaining('bad file reference: "{file:username.txt}"') },
})
}
if (!invalidate) expect(yield* locations.contextEffectOption(ref).pipe(Effect.scoped)).toEqual(Option.none())
if (failure === "file") {
@@ -123,8 +113,8 @@ describe("LocationServiceMap", () => {
yield* Effect.promise(() => fs.mkdir(directory))
}
if (failure === "permissions") yield* Effect.promise(() => fs.chmod(directory, 0o755))
if (failure === "config reference") {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
if (failure === "config file") {
yield* Effect.promise(() => fs.rmdir(path.join(directory, "opencode.json")))
}
expect((yield* Effect.promise(() => fs.stat(directory))).isDirectory()).toBe(true)
if (invalidate) yield* locations.invalidate(ref)
@@ -136,8 +126,8 @@ describe("LocationServiceMap", () => {
}
}
for (const failure of ["file", "missing", "config reference"] as const) {
// A file-path Location boots on Windows; use the missing config reference there.
for (const failure of ["file", "missing", "config file"] as const) {
// A file-path Location boots on Windows; use the unreadable config file there.
const test = failure === "file" && process.platform === "win32" ? it.live.skip : it.live
test(`keeps the repaired graph after concurrent ${failure} failures release`, () =>
Effect.gen(function* () {
@@ -146,11 +136,8 @@ describe("LocationServiceMap", () => {
const ref = Location.Ref.make({ directory: AbsolutePath.make(directory) })
const locations = yield* LocationServiceMap.Service
if (failure === "file") yield* Effect.promise(() => fs.writeFile(directory, "file"))
if (failure === "config reference") {
yield* Effect.promise(() => fs.mkdir(directory))
yield* Effect.promise(() =>
fs.writeFile(path.join(directory, "opencode.json"), JSON.stringify({ username: "{file:username.txt}" })),
)
if (failure === "config file") {
yield* Effect.promise(() => fs.mkdir(path.join(directory, "opencode.json"), { recursive: true }))
}
const scopes = yield* Effect.forEach(Array.from({ length: 8 }), () =>
@@ -163,10 +150,8 @@ describe("LocationServiceMap", () => {
)
expect(failures.every(Exit.isFailure)).toBe(true)
if (failure === "file") yield* Effect.promise(() => fs.rm(directory))
if (failure !== "config reference") yield* Effect.promise(() => fs.mkdir(directory))
if (failure === "config reference") {
yield* Effect.promise(() => fs.writeFile(path.join(directory, "username.txt"), "test-user"))
}
if (failure !== "config file") yield* Effect.promise(() => fs.mkdir(directory))
if (failure === "config file") yield* Effect.promise(() => fs.rmdir(path.join(directory, "opencode.json")))
const repaired = yield* locations.contextEffect(ref)
yield* Effect.forEach(scopes, (scope) => Scope.close(scope, Exit.void))
+55
View File
@@ -275,6 +275,7 @@ function resourceMcpLayer(
managed?: ManagedPolicy.Interface
policies?: readonly ConfigPolicy.Info[]
entries?: Config.Interface["entries"]
unresolved?: Config.Interface["unresolved"]
subscribe?: Bus.Interface["subscribe"]
environment?: Layer.Layer<Environment.Service>
published?: string[]
@@ -304,6 +305,7 @@ function resourceMcpLayer(
Config.Service,
Config.Service.of({
entries: overrides.entries,
unresolved: overrides.unresolved,
changes: () => Stream.never,
}),
)
@@ -1714,6 +1716,59 @@ testEffect(Layer.empty).live(
}),
)
it.live("fails only the servers whose winning config has an unreadable file reference", () => {
const remote = (headers?: Record<string, string>, disabled?: boolean) =>
new ConfigMCP.Remote({ type: "remote", url: "https://unused.example", oauth: false, headers, disabled })
const base = new Document({
type: "document",
info: new Info({
mcp: new ConfigMCP.Info({
servers: { resources: remote({}), legacy: remote(), overridden: remote({}) },
}),
}),
})
const override = new Document({
type: "document",
info: new Info({ mcp: new ConfigMCP.Info({ servers: { overridden: remote({ Authorization: "x" }, true) } }) }),
})
const reference = (path: readonly (string | number)[]) => ({
path: [...path],
reference: "{file:~/.secrets/key}",
file: "/home/user/.secrets/key",
reason: "NotFound",
})
const error = 'bad file reference "{file:~/.secrets/key}": /home/user/.secrets/key does not exist'
return Effect.gen(function* () {
const service = yield* Mcp.Service
expect(yield* settled(service)).toEqual({ status: "failed", error })
expect(yield* settled(service, "legacy")).toEqual({ status: "failed", error })
expect(yield* settled(service, "overridden")).toEqual({ status: "disabled" })
// Retrying cannot recover until configuration changes.
yield* service.connect("resources")
expect(yield* settled(service)).toEqual({ status: "failed", error })
}).pipe(
Effect.provide(
resourceMcpLayer("https://unused.example", undefined, undefined, {
entries: () => Effect.succeed([base, override]),
unresolved: () =>
Effect.succeed(
new Map([
[
base,
[
reference(["mcp", "servers", "resources", "headers", "Authorization"]),
reference(["mcp", "legacy", "headers", "Authorization"]),
reference(["mcp", "servers", "overridden", "headers", "Authorization"]),
],
],
]),
),
}),
),
)
})
testEffect(resourceMcpLayer(new ConfigMCP.Local({ type: "local", command: ["unused"], disabled: true }))).live(
"manages live MCP servers entirely through scoped transforms",
() =>
@@ -709,6 +709,7 @@ describe("OpencodePlugin", () => {
set: (name, config) => configured.set(name, config),
update: () => {},
remove: (name) => configured.delete(name),
fail: () => {},
}),
)
return Object.fromEntries(configured)
+3 -9
View File
@@ -213,10 +213,7 @@ describe("Session.move", () => {
const source = AbsolutePath.make(path.join(tmp.path, "source"))
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source), mkdir(destination)]))
if (broken)
yield* Effect.promise(() =>
Bun.write(path.join(source, "opencode.json"), JSON.stringify({ instructions: ["{file:./missing.txt}"] })),
)
if (broken) yield* Effect.promise(() => mkdir(path.join(source, "opencode.json")))
const session = yield* Session.Service
const execution = yield* SessionExecution.Service
const created = yield* session.create({ location: Location.Ref.make({ directory: source }) })
@@ -271,8 +268,7 @@ describe("Session.move", () => {
const source = Location.Ref.make({ directory: AbsolutePath.make(path.join(tmp.path, "source")) })
const destination = AbsolutePath.make(path.join(tmp.path, "destination"))
yield* Effect.promise(() => Promise.all([mkdir(source.directory), mkdir(destination)]))
const config = JSON.stringify({ instructions: ["{file:./missing.txt}"] })
if (!broken) yield* Effect.promise(() => Bun.write(path.join(source.directory, "opencode.json"), config))
if (!broken) yield* Effect.promise(() => mkdir(path.join(source.directory, "opencode.json")))
const selectedID = Session.ID.create()
const replacements: LayerNode.Replacements = [
Global.node.replace(tempGlobalLayer),
@@ -294,9 +290,7 @@ describe("Session.move", () => {
...bindings,
...(broken
? [
Config.node.replace(
Config.configured({ project: false, global: false, content: config }),
),
Config.node.replace(Layer.effect(Config.Service, Effect.die("broken config"))),
]
: []),
],