diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 456d6c3ee317..4c67309556a1 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1,3 +1,4 @@ +import * as Cause from "effect/Cause" import * as Log from "@opencode-ai/core/util/log" import { serviceUse } from "@opencode-ai/core/effect/service-use" import path from "path" @@ -72,6 +73,17 @@ function normalizeLoadedConfig(data: unknown, source: string) { return copy } +function stripUnknownKeys(data: unknown): unknown { + if (typeof data !== "object" || data === null || Array.isArray(data)) return data + const known = new Set(Info.ast.propertySignatures.map((p) => String(p.name))) + const result: Record = {} + for (const [key, value] of Object.entries(data)) { + if (known.has(key)) result[key] = value + else log.warn("config key is not recognized and will be ignored", { key }) + } + return result +} + async function substituteWellKnownRemoteConfig(input: { value: unknown dir: string @@ -117,9 +129,11 @@ const WellKnownConfig = Schema.Struct({ async function resolveLoadedPlugins(config: T, filepath: string) { if (!config.plugin) return config for (let i = 0; i < config.plugin.length; i++) { - // Normalize path-like plugin specs while we still know which config file declared them. - // This prevents `./plugin.ts` from being reinterpreted relative to some later merge location. - config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath) + try { + config.plugin[i] = await ConfigPlugin.resolvePluginSpec(config.plugin[i], filepath) + } catch (e) { + log.error("plugin resolution failed", { spec: config.plugin[i], path: filepath, error: String(e) }) + } } return config } @@ -420,12 +434,35 @@ export const layer = Layer.effect( : { text, type: "virtual", ...options, env }, ), ) - const parsed = ConfigParse.jsonc(expanded, source) - const data = ConfigParse.schema(Info, normalizeLoadedConfig(parsed, source), source) + let parsedOk = false + const data = yield* Effect.sync(() => { + const parsed = ConfigParse.jsonc(expanded, source) + const cleaned = stripUnknownKeys(normalizeLoadedConfig(parsed, source)) + const result = ConfigParse.schema(Info, cleaned, source) + parsedOk = true + return result + }).pipe( + Effect.catchCause((cause) => + Effect.sync(() => { + const errors = cause.reasons + .filter(Cause.isDieReason) + .map((r) => (r.defect instanceof Error ? r.defect.name : "UnknownError")) + .join(", ") + log.error("invalid config: config file could not be parsed", { path: source, error: errors }) + return {} as Info + }), + ), + ) if (!("path" in options)) return data - yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)) - if (!data.$schema) { + yield* Effect.promise(() => resolveLoadedPlugins(data, options.path)).pipe( + Effect.catchCause(() => + Effect.sync(() => { + log.error("plugin resolution failed", { path: source }) + }), + ), + ) + if (parsedOk && !data.$schema) { data.$schema = "https://opencode.ai/config.json" const updated = text.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void)) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 4d5aaf6fe17e..6b6fdb017407 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -595,24 +595,75 @@ accountTokenIt.instance("resolves env templates in account config with account t }), ) -it.instance("validates config schema and throws on invalid fields", () => +it.instance("handles invalid schema gracefully without crashing", () => Effect.gen(function* () { const test = yield* TestInstance yield* writeConfigEffect(test.directory, { $schema: "https://opencode.ai/config.json", + model: "test/model", invalid_field: "should cause error", }) - const exit = yield* Config.use.get().pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) + const config = yield* Config.use.get() + expect(config.model).toBe("test/model") + expect("invalid_field" in config).toBe(false) }), ) -it.instance("throws error for invalid JSON", () => +it.instance("handles invalid JSON gracefully without crashing", () => Effect.gen(function* () { const test = yield* TestInstance yield* AppFileSystem.use.writeWithDirs(path.join(test.directory, "opencode.json"), "{ invalid json }") - const exit = yield* Config.use.get().pipe(Effect.exit) - expect(Exit.isFailure(exit)).toBe(true) + const config = yield* Config.use.get() + expect(config.username).toBeDefined() + }), +) + +it.instance("handles invalid JSONC syntax gracefully without crashing", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, "opencode.jsonc"), + `{ + // comment + "model": "test/model", + "username": "testuser",`, + ) + const config = yield* Config.use.get() + expect(config.username).toBeDefined() + }), +) + +it.instance("skips bad config file but merges others", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + model: "global/model", + username: "globaluser", + }), + ) + yield* AppFileSystem.use.writeWithDirs( + path.join(test.directory, "opencode.jsonc"), + "{ invalid json }", + ) + const config = yield* Config.use.get() + expect(config.model).toBe("global/model") + expect(config.username).toBe("globaluser") + }), +) + +it.instance("handles plugin resolution failure gracefully", () => + Effect.gen(function* () { + const test = yield* TestInstance + yield* writeConfigEffect(test.directory, { + $schema: "https://opencode.ai/config.json", + model: "has-plugin", + plugin: ["./non-existent-plugin.ts"], + }) + const config = yield* Config.use.get() + expect(config.model).toBe("has-plugin") }), )