diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index c5b0aa71e..f379a4485 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -7,6 +7,8 @@ import { bootstrapDirectory, hydratePendingExternalResults, mergeSessionStatusSnapshot, + surfaceConfigErrors, + type ConfigLoadError, } from "./bootstrap" import { loadSessionsQuery } from "../global-sync" import type { State, VcsCache } from "./types" @@ -131,7 +133,7 @@ describe("bootstrapDirectory", () => { }) as typeof console.warn const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async () => ({ data: undefined }), @@ -192,7 +194,7 @@ describe("bootstrapDirectory", () => { }) as typeof console.warn const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async () => ({ data: undefined }), @@ -262,7 +264,7 @@ describe("bootstrapDirectory", () => { const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async () => ({ data: undefined }), @@ -347,7 +349,7 @@ describe("bootstrapDirectory", () => { const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => { throw new Error("status failed") @@ -419,7 +421,7 @@ describe("bootstrapDirectory", () => { } const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async ({ sessionID }: { sessionID: string }) => { @@ -493,7 +495,7 @@ describe("bootstrapDirectory", () => { const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => { statusStarted = true @@ -553,7 +555,7 @@ describe("bootstrapDirectory", () => { const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async () => ({ data: undefined }), @@ -603,7 +605,7 @@ describe("bootstrapDirectory", () => { const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async () => ({ data: undefined }), @@ -663,7 +665,7 @@ describe("bootstrapDirectory", () => { const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async () => ({ data: undefined }), @@ -737,7 +739,7 @@ describe("bootstrapDirectory", () => { const sdk = { app: { agents: async () => ({ data: [] }) }, - config: { get: async () => ({ data: {} as Config }) }, + config: { get: async () => ({ data: {} as Config }), errors: async () => ({ data: [] }) }, session: { status: async () => ({ data: {} }), get: async () => ({ data: undefined }), @@ -790,6 +792,56 @@ describe("bootstrapDirectory", () => { }) }) +describe("surfaceConfigErrors", () => { + const translate = (key: string) => key + + test("emits one toast per distinct config error", () => { + const emitted: Array<{ title: string; description: string }> = [] + const errors: ConfigLoadError[] = [ + { name: "ConfigJsonError", data: { path: "/repo/one/pawwork.json" } }, + { name: "ConfigInvalidError", data: { path: "/repo/one/opencode.json", message: "bad field" } }, + ] + surfaceConfigErrors("/repo/one", errors, translate, (toast) => emitted.push(toast)) + expect(emitted.length).toBe(2) + expect(emitted[0].title).toBe("toast.config.invalid.title") + expect(emitted[0].description).toContain("toast.config.invalid.hint") + }) + + test("does not re-toast the same unresolved error across repeated bootstraps", () => { + const emitted: Array<{ title: string }> = [] + const error: ConfigLoadError = { name: "ConfigJsonError", data: { path: "/repo/two/pawwork.json" } } + surfaceConfigErrors("/repo/two", [error], translate, (toast) => emitted.push(toast)) + surfaceConfigErrors("/repo/two", [error], translate, (toast) => emitted.push(toast)) + expect(emitted.length).toBe(1) + }) + + test("toasts again when the same file fails a different way", () => { + const emitted: Array<{ title: string }> = [] + surfaceConfigErrors( + "/repo/three", + [{ name: "ConfigJsonError", data: { path: "/repo/three/pawwork.json" } }], + translate, + (toast) => emitted.push(toast), + ) + surfaceConfigErrors( + "/repo/three", + [{ name: "ConfigInvalidError", data: { path: "/repo/three/pawwork.json", message: "now a schema error" } }], + translate, + (toast) => emitted.push(toast), + ) + expect(emitted.length).toBe(2) + }) + + test("clears a directory's memory on a clean load and re-toasts if the error reappears", () => { + const emitted: Array<{ title: string }> = [] + const error: ConfigLoadError = { name: "ConfigJsonError", data: { path: "/repo/four/pawwork.json" } } + surfaceConfigErrors("/repo/four", [error], translate, (toast) => emitted.push(toast)) + surfaceConfigErrors("/repo/four", [], translate, (toast) => emitted.push(toast)) // fixed → drop the entry + surfaceConfigErrors("/repo/four", [error], translate, (toast) => emitted.push(toast)) // regressed → toast again + expect(emitted.length).toBe(2) + }) +}) + describe("hydratePendingExternalResults", () => { const directory = "/tmp/project" const parentSession = { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index b796eb4bc..b04b0b3dc 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -60,6 +60,43 @@ export function clearProviderRev(directory: string) { providerRev.delete(directory) } +// A config file that fails to load (bad JSON or schema) is skipped by the engine so the rest of the config +// still works, and reported once via /config/errors. Re-bootstrap runs on every project reload, so without a +// guard the same unresolved error would toast repeatedly — the spam we are fixing, just moved to the UI. Track, +// per directory, which (file, message, issues) we have already surfaced and only toast the ones we have not +// seen. Each pass replaces the directory's set with the errors still present, so a fixed config (no errors) +// drops its entry instead of holding memory for the app's lifetime, and an error that reappears after being +// fixed is surfaced again. +const seenConfigErrorsByDirectory = new Map>() + +export type ConfigLoadError = { + name: string + data: { path?: string | null; message?: string | null; issues?: unknown } +} + +// `emit` is injectable so the dedup behavior can be tested without a live toast host; production passes showToast. +export function surfaceConfigErrors( + directory: string, + errors: ConfigLoadError[], + translate: (key: string, vars?: Record) => string, + emit: (toast: { variant: "error"; title: string; description: string }) => void = showToast, +) { + const previous = seenConfigErrorsByDirectory.get(directory) ?? new Set() + const current = new Set() + for (const error of errors) { + const key = [error.data?.path ?? "", error.data?.message ?? "", JSON.stringify(error.data?.issues ?? "")].join(" | ") + current.add(key) + if (previous.has(key)) continue + emit({ + variant: "error", + title: translate("toast.config.invalid.title"), + description: `${formatServerError(error, translate)}\n${translate("toast.config.invalid.hint")}`, + }) + } + if (current.size) seenConfigErrorsByDirectory.set(directory, current) + else seenConfigErrorsByDirectory.delete(directory) +} + function runAll(list: Array<() => Promise>) { return Promise.allSettled(list.map((item) => item())) } @@ -436,6 +473,14 @@ export async function bootstrapDirectory(input: { ), }), () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + // Best-effort surface of config files the engine had to skip (bad JSON/schema). Never let it fail the + // bootstrap — a broken config already degrades gracefully; this only turns the recorded errors into one + // readable toast per unseen problem. + () => + input.sdk.config + .errors() + .then((x) => surfaceConfigErrors(input.directory, x.data ?? [], input.translate)) + .catch((err) => console.error("Failed to load config errors", err)), () => retry(() => input.sdk.session.status().then((x) => { diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c67df04c5..1279d61a3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -513,6 +513,9 @@ export const dict = { "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", + "toast.config.invalid.title": "Some settings couldn't be loaded", + "toast.config.invalid.hint": "Your other settings still work. Fix the file, then reopen the project to apply it.", + "toast.update.title": "Update available", "toast.update.description": "A new version of PawWork ({{version}}) is now available to install.", "toast.update.action.installRestart": "Install and restart", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 7517e2363..d79d0c143 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1169,6 +1169,8 @@ export const dict = { "dialog.releaseNotes.action.hideFuture": "不再显示", "dialog.releaseNotes.media.alt": "发布预览", "toast.project.reloadFailed.title": "无法重新加载 {{project}}", + "toast.config.invalid.title": "部分设置未能加载", + "toast.config.invalid.hint": "其余设置仍然生效。修正该文件后重新打开项目即可应用。", "error.server.invalidConfiguration": "配置无效", "common.moreCountSuffix": " (还有 {{count}} 个)", "common.time.justNow": "刚刚", diff --git a/packages/app/src/utils/server-errors.test.ts b/packages/app/src/utils/server-errors.test.ts index 1f53bb8cf..4f4c275aa 100644 --- a/packages/app/src/utils/server-errors.test.ts +++ b/packages/app/src/utils/server-errors.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { ConfigInvalidError, ProviderModelNotFoundError } from "./server-errors" +import type { ConfigInvalidError, ConfigJsonError, ProviderModelNotFoundError } from "./server-errors" import { formatServerError, parseReadableConfigInvalidError } from "./server-errors" function fill(text: string, vars?: Record) { @@ -16,6 +16,7 @@ function useLanguageMock() { "error.chain.unknown": "Erro desconhecido", "error.chain.configInvalid": "Arquivo de config em {{path}} invalido", "error.chain.configInvalidWithMessage": "Arquivo de config em {{path}} invalido: {{message}}", + "error.chain.configJsonInvalid": "Arquivo de config em {{path}} nao e JSON valido", "error.chain.modelNotFound": "Modelo nao encontrado: {{provider}}/{{model}}", "error.chain.didYouMean": "Voce quis dizer: {{suggestions}}", "error.chain.checkConfig": "Revise provider/model no config", @@ -80,6 +81,19 @@ describe("formatServerError", () => { expect(result).toBe("Arquivo de config em config invalido: Missing host") }) + test("formats config JSON errors by file, ignoring the noisy dump", () => { + const error = { + name: "ConfigJsonError", + data: { + path: "pawwork.json", + message: "\n--- JSONC Input ---\n{ bad }\n--- Errors ---\nPropertyNameExpected at line 1", + }, + } satisfies ConfigJsonError + + // Only the file path surfaces; the multi-line parser dump never reaches the toast. + expect(formatServerError(error, language.t)).toBe("Arquivo de config em pawwork.json nao e JSON valido") + }) + test("returns error messages", () => { expect(formatServerError(new Error("Request failed with status 503"), language.t)).toBe( "Request failed with status 503", diff --git a/packages/app/src/utils/server-errors.ts b/packages/app/src/utils/server-errors.ts index 14a98721a..90c42a76c 100644 --- a/packages/app/src/utils/server-errors.ts +++ b/packages/app/src/utils/server-errors.ts @@ -9,6 +9,14 @@ export type ConfigInvalidError = { } } +export type ConfigJsonError = { + name: "ConfigJsonError" + data: { + path?: string + message?: string + } +} + export type ProviderModelNotFoundError = { name: "ProviderModelNotFoundError" data: { @@ -29,6 +37,7 @@ function tr(translator: Translator | undefined, key: string, text: string, vars? export function formatServerError(error: unknown, translate?: Translator, fallback?: string) { if (isConfigInvalidErrorLike(error)) return parseReadableConfigInvalidError(error, translate) + if (isConfigJsonErrorLike(error)) return parseReadableConfigJsonError(error, translate) if (isProviderModelNotFoundErrorLike(error)) return parseReadableProviderModelNotFoundError(error, translate) // Server / assistant error payloads ({ name, data }) are neither Error // instances nor strings; share the session card's decoder so a toast surfaces @@ -48,6 +57,12 @@ function isConfigInvalidErrorLike(error: unknown): error is ConfigInvalidError { return o.name === "ConfigInvalidError" && typeof o.data === "object" && o.data !== null } +function isConfigJsonErrorLike(error: unknown): error is ConfigJsonError { + if (typeof error !== "object" || error === null) return false + const o = error as Record + return o.name === "ConfigJsonError" && typeof o.data === "object" && o.data !== null +} + function isProviderModelNotFoundErrorLike(error: unknown): error is ProviderModelNotFoundError { if (typeof error !== "object" || error === null) return false const o = error as Record @@ -72,6 +87,12 @@ export function parseReadableConfigInvalidError(errorInput: ConfigInvalidError, }) } +export function parseReadableConfigJsonError(errorInput: ConfigJsonError, translator?: Translator) { + const file = errorInput.data.path && errorInput.data.path !== "config" ? errorInput.data.path : "config" + // The backend message is a full multi-line JSONC dump — too noisy for a toast, so surface only the file. + return tr(translator, "error.chain.configJsonInvalid", `Config file at ${file} is not valid JSON(C)`, { path: file }) +} + function parseReadableProviderModelNotFoundError(errorInput: ProviderModelNotFoundError, translator?: Translator) { const p = errorInput.data.providerID.trim() const m = errorInput.data.modelID.trim() diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 4a273571a..33cda296d 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -43,7 +43,7 @@ import { ConfigProvider } from "./provider" import { ConfigServer } from "./server" import { ConfigSkills } from "./skills" import { ConfigVariable } from "./variable" -import { RemoteAuthError } from "./error" +import { RemoteAuthError, JsonError, InvalidError } from "./error" import { Npm } from "@opencode-ai/core/npm" import { Filesystem } from "@/util/filesystem" import { Installation } from "@/installation" @@ -363,15 +363,103 @@ export type Info = DeepMutable> & { plugin_origins?: ConfigPlugin.Origin[] } +// A single config file that failed to load, kept in a shape the app can format directly. The fields mirror +// ConfigJsonError / ConfigInvalidError so the existing frontend error formatter renders it without new types. +export const ConfigLoadError = Schema.Struct({ + name: Schema.String, + data: Schema.Struct({ + path: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + issues: Schema.optional( + Schema.Array( + Schema.Struct({ + message: Schema.String, + path: Schema.Array(Schema.Union([Schema.String, Schema.Number])), + }), + ), + ), + }), +}) +export type ConfigLoadError = Schema.Schema.Type + +// ConfigParse builds a rich CLI debug message that echoes the raw config text twice: once as a whole between +// "--- JSONC Input ---" and "--- Errors ---", and again per error as a "Line N: " context line under +// each caret. That raw text can hold secrets (API keys, PATs) from a half-edited config, and this error is now +// surfaced to the frontend via /config/errors, so keep only the " at line N, column M" summary lines and +// drop every echoed source line. Messages without a parser summary and no dump wrapper (e.g. a plain "file does +// not exist") pass through unchanged; a wrapped message we can't summarize is dropped rather than leaked. +function sanitizeConfigLoadMessage(message: string | undefined): string | undefined { + if (!message) return message + const block = message.match(/--- Errors ---\n([\s\S]*?)\n--- End ---/) + const details = block ? block[1] : message + // Keep only whole " at line N, column M" summary lines. Anchoring to the full line (not just the suffix) + // and explicitly dropping the "Line N: " echo means a config line that itself ends with a fake + // "at line N, column M" suffix cannot masquerade as a summary and leak through. + const summary = details + .split("\n") + .map((line) => line.trim()) + .filter((line) => !/^Line \d+:/.test(line) && /^[A-Za-z]+ at line \d+, column \d+$/.test(line)) + .join("\n") + if (summary) return summary + return block ? undefined : message +} + +function toConfigLoadError(defect: unknown): ConfigLoadError | undefined { + if (!JsonError.isInstance(defect) && !InvalidError.isInstance(defect)) return undefined + const object = defect.toObject() as { + name: string + data: { path?: string; message?: string; issues?: ReadonlyArray<{ message: string; path: ReadonlyArray }> } + } + return { + name: object.name, + data: { + path: object.data.path, + message: sanitizeConfigLoadMessage(object.data.message), + // Zod issues carry fields beyond the declared { message, path } shape (issue code, expected/received or + // raw input values, nested union errors) — some echo the config value that failed. Map to only the two + // fields the schema and frontend use so no upstream detail leaks via /config/errors, state, or dedup key. + issues: object.data.issues?.map((issue) => ({ message: issue.message, path: issue.path })), + }, + } +} + +// A malformed config file (bad JSONC or a schema violation) is raised as a defect by ConfigParse. Left +// unhandled it dies the whole config load; because Config.get() runs on nearly every operation the same +// failure then resurfaces on every call — the reported error spam that also bricks the desktop app. In the +// PawWork runtime we catch only these config-parse defects, hand each to `onError` so the app can surface one +// readable message, and fall back to an empty config for that single file so every other valid config source +// still loads. The plain opencode CLI keeps its fail-fast contract (a broken config throws), and any non +// config-parse defect is always re-raised unchanged. +function keepValidConfig( + effect: Effect.Effect, + onError: (error: ConfigLoadError) => void, +): Effect.Effect { + if (!Runtime.isPawWork()) return effect + return effect.pipe( + Effect.catchDefect((defect) => { + const error = toConfigLoadError(defect) + if (!error) return Effect.die(defect) + onError(error) + log.error("skipping invalid config file, keeping other config", { + path: error.data.path, + error: error.name, + }) + return Effect.succeed({} as Info) + }), + ) +} + type State = { config: Info directories: string[] deps: Fiber.Fiber[] consoleState: ConsoleState + errors: ConfigLoadError[] } export interface Interface { readonly get: () => Effect.Effect + readonly getErrors: () => Effect.Effect readonly getGlobal: () => Effect.Effect readonly getConsoleState: () => Effect.Effect readonly update: (config: Info) => Effect.Effect @@ -782,13 +870,17 @@ const rawLayer = Layer.effect( const loadGlobal = Effect.fnUntraced(function* () { let result: Info = {} + const errors: ConfigLoadError[] = [] const globalFiles = globalConfigFilesToLoad() for (const filepath of globalFiles) { const dir = path.dirname(filepath) const allowWrite = !Runtime.isPawWork() || PawWorkHome.isPrimary(dir) const text = yield* readConfigFile(filepath) if (!text) continue - result = pipe(result, mergeDeep(yield* loadConfig(text, { path: filepath, allowWrite }))) + const next = yield* keepValidConfig(loadConfig(text, { path: filepath, allowWrite }), (error) => + errors.push(error), + ) + result = pipe(result, mergeDeep(next)) } const legacy = path.join(Global.Path.config, "config") @@ -827,7 +919,7 @@ const rawLayer = Layer.effect( }) } - return result + return { config: result, errors } }) const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL( @@ -835,13 +927,13 @@ const rawLayer = Layer.effect( Effect.tapError((error) => Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })), ), - Effect.orElseSucceed((): Info => ({})), + Effect.orElseSucceed((): { config: Info; errors: ConfigLoadError[] } => ({ config: {}, errors: [] })), ), Duration.infinity, ) const getGlobal = Effect.fn("Config.getGlobal")(function* () { - return yield* cachedGlobal + return (yield* cachedGlobal).config }) const ensureGitignore = Effect.fn("Config.ensureGitignore")(function* (dir: string) { @@ -867,6 +959,8 @@ const rawLayer = Layer.effect( const auth = yield* authSvc.all().pipe(Effect.orDie) let result: Info = {} + const errors: ConfigLoadError[] = [] + const collectError = (error: ConfigLoadError) => errors.push(error) const consoleManagedProviders = new Set() let activeOrgName: string | undefined @@ -926,24 +1020,28 @@ const rawLayer = Layer.effect( const remoteConfig = wellknown.config ?? {} if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" const source = remote - const next = yield* loadConfig(JSON.stringify(remoteConfig), { - dir: path.dirname(source), - source, - }) + const next = yield* keepValidConfig( + loadConfig(JSON.stringify(remoteConfig), { + dir: path.dirname(source), + source, + }), + collectError, + ) yield* merge(source, next, "global") log.debug("loaded remote config from well-known", { url }) } } - const global = yield* getGlobal() + const globalState = yield* cachedGlobal yield* merge( globalConfigSource() ?? (Runtime.isPawWork() ? PawWorkHome.primary() : Global.Path.config), - global, + globalState.config, "global", ) + errors.push(...globalState.errors) if (Flag.OPENCODE_CONFIG) { - yield* merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG)) + yield* merge(Flag.OPENCODE_CONFIG, yield* keepValidConfig(loadFile(Flag.OPENCODE_CONFIG), collectError)) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) } @@ -951,7 +1049,7 @@ const rawLayer = Layer.effect( for (const file of yield* ConfigPaths.files(projectConfigNames(), ctx.directory, ctx.worktree).pipe( Effect.orDie, )) { - yield* merge(file, yield* loadFile(file), "local") + yield* merge(file, yield* keepValidConfig(loadFile(file), collectError), "local") } } @@ -978,7 +1076,7 @@ const rawLayer = Layer.effect( for (const file of configFiles) { const source = path.join(dir, file) log.debug(`loading config from ${source}`) - yield* merge(source, yield* loadFile(source)) + yield* merge(source, yield* keepValidConfig(loadFile(source), collectError)) result.agent ??= {} result.mode ??= {} result.plugin ??= [] @@ -1011,10 +1109,13 @@ const rawLayer = Layer.effect( if (process.env.OPENCODE_CONFIG_CONTENT) { const source = "OPENCODE_CONFIG_CONTENT" - const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { - dir: ctx.directory, - source, - }) + const next = yield* keepValidConfig( + loadConfig(process.env.OPENCODE_CONFIG_CONTENT, { + dir: ctx.directory, + source, + }), + collectError, + ) yield* merge(source, next, "local") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } @@ -1066,7 +1167,7 @@ const rawLayer = Layer.effect( if (existsSync(managedDir)) { for (const file of Runtime.isPawWork() ? globalConfigFiles() : OPENCODE_PROJECT_CONFIG_FILES) { const source = path.join(managedDir, file) - yield* merge(source, yield* loadFile(source), "global") + yield* merge(source, yield* keepValidConfig(loadFile(source), collectError), "global") } } @@ -1075,10 +1176,13 @@ const rawLayer = Layer.effect( if (managed) { result = mergeConfigConcatArrays( result, - yield* loadConfig(managed.text, { - dir: path.dirname(managed.source), - source: managed.source, - }), + yield* keepValidConfig( + loadConfig(managed.text, { + dir: path.dirname(managed.source), + source: managed.source, + }), + collectError, + ), ) } @@ -1136,6 +1240,7 @@ const rawLayer = Layer.effect( config: result, directories, deps, + errors, consoleState: { consoleManagedProviders: Array.from(consoleManagedProviders), activeOrgName, @@ -1156,6 +1261,10 @@ const rawLayer = Layer.effect( return yield* InstanceState.use(state, (s) => s.config) }) + const getErrors = Effect.fn("Config.getErrors")(function* () { + return yield* InstanceState.use(state, (s) => s.errors) + }) + const directories = Effect.fn("Config.directories")(function* () { return yield* InstanceState.use(state, (s) => s.directories) }) @@ -1377,6 +1486,7 @@ const rawLayer = Layer.effect( return Service.of({ get, + getErrors, getGlobal, getConsoleState, update, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts index 7addefaec..512c152ad 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/config.ts @@ -1,5 +1,6 @@ -import { Info as ConfigInfo } from "@/config/config" +import { ConfigLoadError, Info as ConfigInfo } from "@/config/config" import { ConfigProvidersResult } from "@/provider/provider" +import { Schema } from "effect" import { HttpApi, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" import { BadRequestError, WorkspaceRoutingQuery } from "./common" @@ -42,6 +43,17 @@ export const ConfigApi = HttpApi.make("config") description: "Get a list of all configured AI providers and their default models.", }), ), + HttpApiEndpoint.get("errors", `${root}/errors`, { + query: WorkspaceRoutingQuery, + success: Schema.Array(ConfigLoadError), + }).annotateMerge( + OpenApi.annotations({ + identifier: "config.errors", + summary: "List config load errors", + description: + "List config files that failed to load (invalid JSON or schema). The rest of the config still loads; this lets the app surface one readable message instead of failing repeatedly.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts index 82646e0d1..e7728fcbc 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/config.ts @@ -10,6 +10,10 @@ const getWith = Effect.fn("ConfigHttpApi.get")(function* (config: Config.Interfa return HttpServerResponse.jsonUnsafe(yield* config.get()) }) +const errorsWith = Effect.fn("ConfigHttpApi.errors")(function* (config: Config.Interface) { + return HttpServerResponse.jsonUnsafe(yield* config.getErrors()) +}) + const providersWith = Effect.fn("ConfigHttpApi.providers")(function* (provider: Provider.Interface) { const providers = yield* provider.list() return HttpServerResponse.jsonUnsafe({ @@ -50,5 +54,6 @@ export const configHandlers = HttpApiBuilder.group(ConfigApi, "config", (handler }), ) .handle("providers", () => providersWith(provider)) + .handle("errors", () => errorsWith(config)) }), ) diff --git a/packages/opencode/test/config/pawwork-global-config.test.ts b/packages/opencode/test/config/pawwork-global-config.test.ts index c50bc6348..0989d5694 100644 --- a/packages/opencode/test/config/pawwork-global-config.test.ts +++ b/packages/opencode/test/config/pawwork-global-config.test.ts @@ -41,6 +41,8 @@ const layer = Config.layer.pipe( ) const load = () => Effect.runPromise(Config.Service.use((svc) => svc.get()).pipe(Effect.scoped, Effect.provide(layer))) +const loadErrors = () => + Effect.runPromise(Config.Service.use((svc) => svc.getErrors()).pipe(Effect.scoped, Effect.provide(layer))) const save = (config: Config.Info) => Effect.runPromise(Config.Service.use((svc) => svc.update(config)).pipe(Effect.scoped, Effect.provide(layer))) const saveGlobal = (config: Config.Info) => @@ -1346,3 +1348,125 @@ home command`, } }) }) + +describe("PawWork config load resilience", () => { + // #1485: a non-technical user hand-edits pawwork.json to add an MCP server, gets the schema wrong, and the + // desktop app spams the same config error on every operation until it is unusable. In the PawWork runtime a + // broken config file must be skipped (with the error recorded for one readable message) while every other + // valid config source still loads. The plain opencode CLI keeps failing fast — covered in config.test.ts. + test("keeps valid config and records the error when a project config file has invalid JSON", async () => { + await using project = await tmpdir({ git: true }) + const configDir = path.join(project.path, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + await Filesystem.write(path.join(configDir, "opencode.json"), JSON.stringify({ model: "pawwork/valid-model" })) + await Filesystem.write(path.join(configDir, "pawwork.json"), "{ invalid json }") + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("pawwork/valid-model") + const errors = await loadErrors() + expect(errors.length).toBe(1) + expect(errors[0].name).toBe("ConfigJsonError") + expect(errors[0].data.path).toContain("pawwork.json") + }, + }) + }) + + test("keeps valid config and records the error when an mcp entry violates the schema", async () => { + await using project = await tmpdir({ git: true }) + const configDir = path.join(project.path, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + await Filesystem.write(path.join(configDir, "opencode.json"), JSON.stringify({ model: "pawwork/valid-model" })) + // Structurally-valid JSON whose mcp entry matches no transport (local/remote) — the reported #1485 shape. + await Filesystem.write(path.join(configDir, "pawwork.json"), JSON.stringify({ mcp: { broken: { type: "nonsense" } } })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("pawwork/valid-model") + const errors = await loadErrors() + expect(errors.length).toBe(1) + expect(errors[0].name).toBe("ConfigInvalidError") + expect(errors[0].data.path).toContain("pawwork.json") + expect(errors[0].data.issues?.length ?? 0).toBeGreaterThan(0) + }, + }) + }) + + // The JSONC parser embeds the raw file text in its error message for local debugging. That message is now + // surfaced to the frontend via /config/errors, so a half-edited config's secret must never ride along. + test("does not leak raw config file contents into the recorded error message", async () => { + await using project = await tmpdir({ git: true }) + const configDir = path.join(project.path, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + const secret = "sk-pawwork-super-secret-DEADBEEF" + await Filesystem.write(path.join(configDir, "pawwork.json"), `{ "apiKey": "${secret}" broken }`) + + await Instance.provide({ + directory: project.path, + fn: async () => { + await load() + const errors = await loadErrors() + expect(errors.length).toBe(1) + expect(errors[0].name).toBe("ConfigJsonError") + expect(JSON.stringify(errors[0])).not.toContain(secret) + }, + }) + }) + + // Edge of the sanitizer: the parser echoes the offending line as "Line N: ". If that source line + // itself ends with " at line N, column M", a naive "ends-with-position" filter would mistake the echoed + // secret for a summary line and keep it. The sanitizer must drop the "Line N:" context line regardless. + test("does not leak a config line that ends with a parser-position-like suffix", async () => { + await using project = await tmpdir({ git: true }) + const configDir = path.join(project.path, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + const secret = "sk-leak-SECRET" + // Line 1 is valid JSON; the trailing line is a syntax error whose source ends with a fake position suffix. + await Filesystem.write(path.join(configDir, "pawwork.json"), `{ "a": 1 }\n${secret} at line 2, column 3`) + + await Instance.provide({ + directory: project.path, + fn: async () => { + await load() + const errors = await loadErrors() + expect(errors.length).toBe(1) + expect(errors[0].name).toBe("ConfigJsonError") + expect(JSON.stringify(errors[0])).not.toContain(secret) + }, + }) + }) + + // Managed/MDM-deployed config is loaded last and overrides everything, so a broken managed file must degrade + // just like user config — otherwise enterprise deployments still hit the #1485 "one bad file bricks the app". + test("keeps valid config and records the error when a managed config file is broken", async () => { + await using managed = await tmpdir() + await using project = await tmpdir({ git: true }) + const previous = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = managed.path + + try { + const managedConfig = path.join(managed.path, "pawwork.json") + await Filesystem.write(managedConfig, "{ managed invalid json }") + const configDir = path.join(project.path, ".opencode") + await fs.mkdir(configDir, { recursive: true }) + await Filesystem.write(path.join(configDir, "pawwork.json"), JSON.stringify({ model: "pawwork/valid-model" })) + + await Instance.provide({ + directory: project.path, + fn: async () => { + const config = await load() + expect(config.model).toBe("pawwork/valid-model") + const errors = await loadErrors() + expect(errors.some((error) => error.name === "ConfigJsonError" && error.data.path === managedConfig)).toBeTrue() + }, + }) + } finally { + if (previous === undefined) delete process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR + else process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR = previous + } + }) +}) diff --git a/packages/opencode/test/server/config-routes.test.ts b/packages/opencode/test/server/config-routes.test.ts index aa5e7e925..799250d48 100644 --- a/packages/opencode/test/server/config-routes.test.ts +++ b/packages/opencode/test/server/config-routes.test.ts @@ -105,14 +105,17 @@ describe("config routes", () => { expect(spec.paths).toHaveProperty("/config") expect(spec.paths).toHaveProperty("/config/providers") + expect(spec.paths).toHaveProperty("/config/errors") expect(spec.paths["/config"]).toHaveProperty("get") expect(spec.paths["/config"]).toHaveProperty("patch") expect(spec.paths["/config/providers"]).toHaveProperty("get") + expect(spec.paths["/config/errors"]).toHaveProperty("get") for (const operation of [ spec.paths["/config"]?.get, spec.paths["/config"]?.patch, spec.paths["/config/providers"]?.get, + spec.paths["/config/errors"]?.get, ]) { expect(operation?.parameters).toEqual([ { name: "directory", in: "query", required: false, schema: { type: "string" } }, @@ -272,6 +275,7 @@ describe("config routes", () => { services: { config: { get: () => Effect.succeed(config), + getErrors: () => Effect.succeed([]), getGlobal: () => Effect.succeed({}), getConsoleState: () => Effect.succeed({} as never), update: () => Effect.void, @@ -293,6 +297,33 @@ describe("config routes", () => { }) }) + test("returns recorded config load errors through the HttpApi errors handler", async () => { + const configErrors = [ + { name: "ConfigInvalidError", data: { path: "/tmp/pawwork.json", issues: [{ message: "Invalid input", path: ["mcp", "broken"] }] } }, + ] + const response = await requestConfigHttpApi("/config/errors", { + services: { + config: { + get: () => Effect.succeed({} as Config.Info), + getErrors: () => Effect.succeed(configErrors as never), + getGlobal: () => Effect.succeed({}), + getConsoleState: () => Effect.succeed({} as never), + update: () => Effect.void, + updateGlobal: (next) => Effect.succeed(next), + invalidate: () => Effect.void, + directories: () => Effect.succeed([]), + waitForDependencies: () => Effect.void, + installDependencies: () => Effect.succeed(true), + }, + provider: unusedProvider(), + }, + }) + const body = await response.json() + + expect(response.status).toBe(200) + expect(body).toEqual(configErrors) + }) + test("rejects unknown config fields through the HttpApi config handlers", async () => { await using tmp = await tmpdir({ git: true }) await Instance.provide({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index f2aaf8529..ae53a8595 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -34,6 +34,7 @@ import type { AutomationUpdateResponses, CommandListResponses, Config as Config3, + ConfigErrorsResponses, ConfigGetResponses, ConfigProvidersResponses, ConfigUpdateErrors, @@ -1637,6 +1638,36 @@ export class Config2 extends HeyApiClient { ...params, }) } + + /** + * List config load errors + * + * List config files that failed to load (invalid JSON or schema). The rest of the config still loads; this lets the app surface one readable message instead of failing repeatedly. + */ + public errors( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).get({ + url: "/config/errors", + ...options, + ...params, + }) + } } export class Tool extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 43680b836..09e80ea67 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -3907,6 +3907,35 @@ export type ConfigProvidersResponses = { export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses] +export type ConfigErrorsData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/config/errors" +} + +export type ConfigErrorsResponses = { + /** + * Success + */ + 200: Array<{ + name: string + data: { + path?: string | null + message?: string | null + issues?: Array<{ + message: string + path: Array + }> | null + } + }> +} + +export type ConfigErrorsResponse = ConfigErrorsResponses[keyof ConfigErrorsResponses] + export type ExperimentalCapabilitiesGetData = { body?: never path?: never diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 60bb13e2f..63954f0f9 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3763,6 +3763,162 @@ ] } }, + "/config/errors": { + "get": { + "tags": [ + "config" + ], + "operationId": "config.errors", + "parameters": [ + { + "name": "directory", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + }, + { + "name": "workspace", + "in": "query", + "schema": { + "type": "string" + }, + "required": false + } + ], + "security": [], + "responses": { + "200": { + "description": "Success", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "issues": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "message": { + "type": "string" + }, + "path": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string" + }, + { + "anyOf": [ + { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "NaN" + ] + }, + { + "type": "string", + "enum": [ + "Infinity" + ] + }, + { + "type": "string", + "enum": [ + "-Infinity" + ] + } + ] + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + ] + } + } + }, + "required": [ + "message", + "path" + ], + "additionalProperties": false + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "required": [ + "name", + "data" + ], + "additionalProperties": false + } + } + } + } + } + }, + "description": "List config files that failed to load (invalid JSON or schema). The rest of the config still loads; this lets the app surface one readable message instead of failing repeatedly.", + "summary": "List config load errors", + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\"\n\nconst client = createOpencodeClient()\nawait client.config.errors({\n ...\n})" + } + ] + } + }, "/experimental/capabilities": { "get": { "tags": [