Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
fb6265c
fix(config): catch parse errors gracefully during startup
HaleTom May 25, 2026
de8dc4a
fix(config): convert tests to it.instance pattern matching upstream
HaleTom May 25, 2026
85ed2e4
fix(config): narrow catchCause to parse/schema only, strengthen tests
HaleTom May 25, 2026
109a6eb
fix(config): catch plugin resolution errors per-file to preserve mult…
HaleTom May 25, 2026
e426b11
feat(tui): make prompt size responsive and configurable (#28255)
bjschafer May 25, 2026
054ca3a
chore: generate
opencode-agent[bot] May 25, 2026
24a0fe7
perf: use redis/upstash for ip rate limits (#28694)
vimtor May 25, 2026
68d1833
chore: generate
opencode-agent[bot] May 25, 2026
d5b6345
test(acp): add compatibility baseline (#29222)
nexxeln May 25, 2026
658b938
chore: generate
opencode-agent[bot] May 25, 2026
5477b4e
chore: update nix node_modules hashes
opencode-agent[bot] May 25, 2026
ca9d857
feat(acp): add initial acp-next skeleton behind runtime flag (#29226)
nexxeln May 25, 2026
416f01d
feat(acp-next): add content conversion helpers (#29231)
nexxeln May 25, 2026
ed010b7
test(acp-next): add config option helpers (#29234)
nexxeln May 25, 2026
ae594f9
feat(acp-next): add pure tool conversion helpers (#29232)
nexxeln May 25, 2026
72c1693
fix(acp-next): map typed errors to request errors (#29233)
nexxeln May 25, 2026
ddddceb
chore: generate
opencode-agent[bot] May 25, 2026
cb2006f
feat(acp-next): add directory snapshot service (#29241)
nexxeln May 25, 2026
fbfa968
feat(acp-next): add session state service (#29240)
nexxeln May 25, 2026
292b626
chore: generate
opencode-agent[bot] May 25, 2026
8fefc50
feat(acp-next): add usage service (#29249)
nexxeln May 25, 2026
6515990
chore: generate
opencode-agent[bot] May 25, 2026
45456fa
fix(config): skip auto-injection on parse failure
HaleTom May 25, 2026
7068379
Merge remote-tracking branch 'upstream/dev' into json-errors
HaleTom May 25, 2026
3922a82
fix(config): sanitize log.error to avoid leaking config content
HaleTom May 25, 2026
6378082
fix(config): use Cause.pretty for safe config error logging
HaleTom May 25, 2026
702fd0e
fix(config): address review comments on PR #29208
HaleTom May 25, 2026
b11570a
fix(config): address copilot review comments
HaleTom May 25, 2026
a72cb35
fix(config): warn for each unknown key stripped from config
HaleTom May 25, 2026
b8eedcf
fix(config): add infoKeys sync comment, drop unused cause param
HaleTom May 25, 2026
998034f
fix(config): complete infoKeys allowlist, add safe error detail to logs
HaleTom May 25, 2026
828e255
fix(config): derive config keys dynamically from Info schema AST
HaleTom May 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 44 additions & 7 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<string, unknown> = {}
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 })
Comment on lines +76 to +82
}
return result
}

async function substituteWellKnownRemoteConfig(input: {
value: unknown
dir: string
Expand Down Expand Up @@ -117,9 +129,11 @@ const WellKnownConfig = Schema.Struct({
async function resolveLoadedPlugins<T extends { plugin?: ConfigPlugin.Spec[] }>(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
}
Expand Down Expand Up @@ -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(
Comment thread
HaleTom marked this conversation as resolved.
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
}),
),
Comment on lines +445 to +454
)
Comment thread
HaleTom marked this conversation as resolved.
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))
Expand Down
63 changes: 57 additions & 6 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -580,24 +580,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)
}),
Comment thread
HaleTom marked this conversation as resolved.
)

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")
Comment on lines +642 to +651
}),
)

Expand Down
Loading