mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-17 01:40:19 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f283e3c20 | ||
|
|
a09f9a2dce | ||
|
|
ebaf31a7ce | ||
|
|
d88046fb1c |
No files matched your search
+43
-13
@@ -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,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())),
|
||||
})
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"))),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user