Compare commits
1 commit
dev
...
kit/config
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18f3b31f1c |
2 changed files with 135 additions and 34 deletions
|
|
@ -2,10 +2,13 @@ export * as ConfigParse from "./parse"
|
||||||
|
|
||||||
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
|
import { type ParseError as JsoncParseError, parse as parseJsoncImpl, printParseErrorCode } from "jsonc-parser"
|
||||||
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
|
import { Cause, Exit, Schema as EffectSchema, SchemaIssue } from "effect"
|
||||||
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||||
import { InvalidError, JsonError } from "./error"
|
import { InvalidError, JsonError } from "./error"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "config.parse" })
|
||||||
|
|
||||||
type ZodSchema<T> = z.ZodType<T>
|
type ZodSchema<T> = z.ZodType<T>
|
||||||
|
|
||||||
export function jsonc(text: string, filepath: string): unknown {
|
export function jsonc(text: string, filepath: string): unknown {
|
||||||
|
|
@ -50,34 +53,70 @@ export function effectSchema<S extends EffectSchema.Decoder<unknown, never>>(
|
||||||
data: unknown,
|
data: unknown,
|
||||||
source: string,
|
source: string,
|
||||||
): DeepMutable<S["Type"]> {
|
): DeepMutable<S["Type"]> {
|
||||||
const extra = topLevelExtraKeys(schema, data)
|
// The user's config lives on disk and may legitimately be stale, hand-edited,
|
||||||
if (extra.length) {
|
// or carry leftover keys from older versions. Crashing the whole load on a
|
||||||
throw new InvalidError({
|
// single bad field would make opencode unstartable for those users (see Ben
|
||||||
path: source,
|
// Matthews / Discord, v1.14.45). Strip the malformed top-level fields and
|
||||||
issues: [
|
// keep going — log every drop so users can see what was ignored and fix it.
|
||||||
{
|
const cleaned = stripUnknownTopLevelKeys(schema, data, source)
|
||||||
code: "unrecognized_keys",
|
return decodeWithFieldTolerance(schema, cleaned, source)
|
||||||
keys: extra,
|
}
|
||||||
path: [],
|
|
||||||
message: `Unrecognized key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`,
|
|
||||||
} as z.core.$ZodIssue,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
function stripUnknownTopLevelKeys(schema: EffectSchema.Top, data: unknown, source: string): unknown {
|
||||||
|
if (typeof data !== "object" || data === null || Array.isArray(data)) return data
|
||||||
|
const extra = topLevelExtraKeys(schema, data)
|
||||||
|
if (extra.length === 0) return data
|
||||||
|
log.warn("ignoring unrecognized config keys", { source, keys: extra })
|
||||||
|
const obj = data as Record<string, unknown>
|
||||||
|
return Object.fromEntries(Object.entries(obj).filter(([key]) => !extra.includes(key)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeWithFieldTolerance<S extends EffectSchema.Decoder<unknown, never>>(
|
||||||
|
schema: S,
|
||||||
|
data: unknown,
|
||||||
|
source: string,
|
||||||
|
): DeepMutable<S["Type"]> {
|
||||||
|
// Try a clean decode first. If it succeeds we're done — common path.
|
||||||
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
|
const decoded = EffectSchema.decodeUnknownExit(schema)(data, { errors: "all", propertyOrder: "original" })
|
||||||
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
|
if (Exit.isSuccess(decoded)) return decoded.value as DeepMutable<S["Type"]>
|
||||||
const error = Cause.squash(decoded.cause)
|
const error = Cause.squash(decoded.cause)
|
||||||
|
const issues = EffectSchema.isSchemaError(error)
|
||||||
|
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
|
||||||
|
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[])
|
||||||
|
|
||||||
throw new InvalidError(
|
// Identify malformed top-level fields. Anything with a non-empty path is a
|
||||||
{
|
// field-scoped issue we can drop and retry. Issues with an empty path are
|
||||||
path: source,
|
// root-level (e.g. data is not an object at all) and can't be field-recovered.
|
||||||
issues: EffectSchema.isSchemaError(error)
|
const badFields = collectTopLevelFieldNames(issues)
|
||||||
? (SchemaIssue.makeFormatterStandardSchemaV1()(error.issue).issues as z.core.$ZodIssue[])
|
if (badFields.size === 0 || typeof data !== "object" || data === null || Array.isArray(data)) {
|
||||||
: ([{ code: "custom", message: String(error), path: [] }] as z.core.$ZodIssue[]),
|
throw new InvalidError({ path: source, issues }, { cause: error })
|
||||||
},
|
}
|
||||||
{ cause: error },
|
|
||||||
)
|
log.warn("ignoring invalid config fields", {
|
||||||
|
source,
|
||||||
|
fields: [...badFields],
|
||||||
|
summary: issues
|
||||||
|
.filter((issue) => issue.path && issue.path.length > 0)
|
||||||
|
.map((issue) => `${issue.path?.join(".")}: ${issue.message}`)
|
||||||
|
.slice(0, 8),
|
||||||
|
})
|
||||||
|
|
||||||
|
const obj = data as Record<string, unknown>
|
||||||
|
const cleaned = Object.fromEntries(Object.entries(obj).filter(([key]) => !badFields.has(key)))
|
||||||
|
// Retry without the bad fields. If THIS fails, we're past field-tolerance —
|
||||||
|
// fall back to the original strict error so the user sees the real cause.
|
||||||
|
const retry = EffectSchema.decodeUnknownExit(schema)(cleaned, { errors: "all", propertyOrder: "original" })
|
||||||
|
if (Exit.isSuccess(retry)) return retry.value as DeepMutable<S["Type"]>
|
||||||
|
throw new InvalidError({ path: source, issues }, { cause: error })
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectTopLevelFieldNames(issues: z.core.$ZodIssue[]): Set<string> {
|
||||||
|
const names = new Set<string>()
|
||||||
|
for (const issue of issues) {
|
||||||
|
const head = issue.path?.[0]
|
||||||
|
if (typeof head === "string") names.add(head)
|
||||||
|
}
|
||||||
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
|
function topLevelExtraKeys(schema: EffectSchema.Top, data: unknown) {
|
||||||
|
|
|
||||||
|
|
@ -558,20 +558,22 @@ test("handles file inclusion with replacement tokens", async () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("validates config schema and throws on invalid fields", async () => {
|
test("config loader is tolerant: drops unknown fields, keeps the rest", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await writeConfig(dir, {
|
await writeConfig(dir, {
|
||||||
$schema: "https://opencode.ai/config.json",
|
$schema: "https://opencode.ai/config.json",
|
||||||
invalid_field: "should cause error",
|
username: "kept",
|
||||||
|
invalid_field: "should be dropped, not crash the app",
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
await provideTestInstance({
|
await provideTestInstance({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
// Strict schema should throw an error for invalid fields
|
const config = await load()
|
||||||
await expect(load()).rejects.toThrow()
|
expect(config.username).toBe("kept")
|
||||||
|
expect((config as Record<string, unknown>).invalid_field).toBeUndefined()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -1681,7 +1683,70 @@ test("permission config preserves user key order", async () => {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("Effect config parser preserves permission order while rejecting unknown top-level keys", () => {
|
// Discord bug report (Ben Matthews, v1.14.45): a malformed `skills:` field
|
||||||
|
// (array instead of object) made the WHOLE config fail to load, the server
|
||||||
|
// returned 500, and the desktop app couldn't start. Per Kit:
|
||||||
|
// "for all of these things that we load from the user's computer, they
|
||||||
|
// should be kind of tolerant. ... It shouldn't break opencode."
|
||||||
|
// The contract: drop the malformed top-level field, log a warning, keep
|
||||||
|
// the rest of the config so the app starts.
|
||||||
|
test("config parser is tolerant: drops malformed top-level fields, keeps the rest", () => {
|
||||||
|
const config = ConfigParse.effectSchema(
|
||||||
|
Config.Info,
|
||||||
|
{
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
username: "ben",
|
||||||
|
// Wrong shape — schema expects { paths?, urls? }, user has an array
|
||||||
|
// (looks like the LOADED skills list got pasted into the config).
|
||||||
|
skills: [
|
||||||
|
{ name: "scss-layout-accessibility", path: ".opencode/skills/scss-layout-accessibility.md" },
|
||||||
|
{ name: "testing", path: ".opencode/skills/testing.md" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pre-fix this throws ConfigInvalidError and the user can't start opencode.
|
||||||
|
// Post-fix the bad field is dropped and the rest of the config loads.
|
||||||
|
expect(config.username).toBe("ben")
|
||||||
|
expect(config.skills).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("config parser is tolerant: drops unrecognized top-level keys instead of throwing", () => {
|
||||||
|
const config = ConfigParse.effectSchema(
|
||||||
|
Config.Info,
|
||||||
|
{
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
username: "ben",
|
||||||
|
// Typo or stale key — pre-fix this threw `unrecognized_keys`.
|
||||||
|
autoshrare: true,
|
||||||
|
},
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config.username).toBe("ben")
|
||||||
|
expect((config as Record<string, unknown>).autoshrare).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("config parser is tolerant: drops multiple bad fields in one pass", () => {
|
||||||
|
const config = ConfigParse.effectSchema(
|
||||||
|
Config.Info,
|
||||||
|
{
|
||||||
|
$schema: "https://opencode.ai/config.json",
|
||||||
|
username: "ben",
|
||||||
|
skills: ["wrong shape"],
|
||||||
|
autoshare: 42, // wrong type — schema wants string literal | undefined
|
||||||
|
not_a_real_key: "ignore me",
|
||||||
|
},
|
||||||
|
"test",
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(config.username).toBe("ben")
|
||||||
|
expect(config.skills).toBeUndefined()
|
||||||
|
expect(config.autoshare).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("Effect config parser preserves permission order while dropping unknown top-level keys", () => {
|
||||||
const config = ConfigParse.effectSchema(
|
const config = ConfigParse.effectSchema(
|
||||||
Config.Info,
|
Config.Info,
|
||||||
{
|
{
|
||||||
|
|
@ -1695,13 +1760,10 @@ test("Effect config parser preserves permission order while rejecting unknown to
|
||||||
)
|
)
|
||||||
|
|
||||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||||
try {
|
// Tolerant parser: unknown keys are stripped (with a warning log) instead
|
||||||
ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
|
// of failing the entire config load.
|
||||||
throw new Error("expected config parse to fail")
|
const stripped = ConfigParse.effectSchema(Config.Info, { invalid_field: true }, "test")
|
||||||
} catch (err) {
|
expect((stripped as Record<string, unknown>).invalid_field).toBeUndefined()
|
||||||
const error = err as { data?: { issues?: Array<{ code?: string; keys?: string[]; path?: string[] }> } }
|
|
||||||
expect(error.data?.issues?.[0]).toMatchObject({ code: "unrecognized_keys", keys: ["invalid_field"], path: [] })
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// MCP config merging tests
|
// MCP config merging tests
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue