Compare commits

...

1 Commits

Author SHA1 Message Date
Kit Langton 1d2e75ef49 fix(config): guard env-var JSON parsing against invalid input 2026-05-18 20:42:31 -04:00
7 changed files with 258 additions and 9 deletions
@@ -1,6 +1,8 @@
import { createStore, reconcile } from "solid-js/store"
import { Option, Schema } from "effect"
import { createSimpleContext } from "./helper"
import type { PromptInfo } from "../component/prompt/history"
import { tryJsonConfig } from "@/util/json"
export type HomeRoute = {
type: "home"
@@ -21,17 +23,25 @@ export type PluginRoute = {
export type Route = HomeRoute | SessionRoute | PluginRoute
const HOME_ROUTE: Route = { type: "home" }
// Schema covers only the env-fed shape — `prompt` is set programmatically and
// not expected from OPENCODE_ROUTE, so we keep it out of validation.
export const RouteSchema = Schema.Union([
Schema.Struct({ type: Schema.Literal("home") }),
Schema.Struct({ type: Schema.Literal("session"), sessionID: Schema.String }),
Schema.Struct({
type: Schema.Literal("plugin"),
id: Schema.String,
data: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
}),
])
export const { use: useRoute, provider: RouteProvider } = createSimpleContext({
name: "Route",
init: (props: { initialRoute?: Route }) => {
const [store, setStore] = createStore<Route>(
props.initialRoute ??
(process.env["OPENCODE_ROUTE"]
? JSON.parse(process.env["OPENCODE_ROUTE"])
: {
type: "home",
}),
)
const fromEnv = tryJsonConfig(process.env["OPENCODE_ROUTE"], RouteSchema, "OPENCODE_ROUTE")
const [store, setStore] = createStore<Route>(props.initialRoute ?? Option.getOrElse(fromEnv, () => HOME_ROUTE))
return {
get data() {
+15
View File
@@ -106,6 +106,21 @@ export function FormatError(input: unknown): string | undefined {
].join("\n")
}
// InvalidConfigError: malformed env-var JSON at a safety boundary
if (isTaggedError(input, "InvalidConfigError")) {
const source = stringField(input, "source") ?? ""
const value = stringField(input, "value") ?? ""
const reason = stringField(input, "reason") ?? ""
return [
`Error: ${source} is invalid and cannot be applied.`,
` Value: '${value}'`,
` Reason: ${reason}`,
"",
`opencode will not start with a malformed ${source}. Set ${source}`,
`correctly or unset it to use defaults.`,
].join("\n")
}
// UICancelledError: user cancelled an interactive CLI prompt
if (isTaggedError(input, "UICancelledError") || NamedError.hasName(input, "UICancelledError")) {
return ""
+3 -1
View File
@@ -14,6 +14,7 @@ import { InstallationLocal, InstallationVersion } from "@opencode-ai/core/instal
import { existsSync } from "fs"
import { Account } from "@/account/account"
import { isRecord } from "@/util/record"
import { requireJsonConfig } from "@/util/json"
import type { ConsoleState } from "./console-state"
import { AppFileSystem } from "@opencode-ai/core/filesystem"
import { InstanceState } from "@/effect/instance-state"
@@ -703,7 +704,8 @@ export const layer = Layer.effect(
}
if (Flag.OPENCODE_PERMISSION) {
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
const decoded = requireJsonConfig(Flag.OPENCODE_PERMISSION, ConfigPermission.Info, "OPENCODE_PERMISSION")
result.permission = mergeDeep(result.permission ?? {}, decoded)
}
if (result.tools) {
+49
View File
@@ -0,0 +1,49 @@
import { Option, Result, Schema } from "effect"
import * as Log from "@opencode-ai/core/util/log"
const log = Log.create({ service: "config" })
const decodeJsonUnknown = Schema.decodeUnknownResult(Schema.UnknownFromJsonString)
export class InvalidConfigError extends Schema.TaggedErrorClass<InvalidConfigError>()("InvalidConfigError", {
source: Schema.String,
value: Schema.String,
reason: Schema.String,
}) {}
// Decode JSON config at a safety boundary. On malformed JSON or schema
// mismatch, throws `InvalidConfigError`. On success, returns the typed value.
// Use when defaulting silently is dangerous (e.g. permissions).
export function requireJsonConfig<S extends Schema.Decoder<unknown>>(raw: string, schema: S, source: string): S["Type"] {
const decoded = decode(raw, schema)
if (Result.isFailure(decoded)) {
throw new InvalidConfigError({ source, value: raw, reason: decoded.failure })
}
return decoded.success
}
// Decode JSON config at a UX-preference boundary. Returns `None` for absent,
// empty, malformed, or schema-mismatched input, logging a warn so the user
// can debug. Use when defaulting silently is benign (e.g. theme, route).
export function tryJsonConfig<S extends Schema.Decoder<unknown>>(
raw: string | undefined,
schema: S,
source: string,
): Option.Option<S["Type"]> {
if (!raw) return Option.none()
const decoded = decode(raw, schema)
if (Result.isFailure(decoded)) {
log.warn(`ignoring invalid ${source}`, { value: raw, reason: decoded.failure })
return Option.none()
}
return Option.some(decoded.success)
}
function decode<S extends Schema.Decoder<unknown>>(raw: string, schema: S): Result.Result<S["Type"], string> {
const decodeSchema = Schema.decodeUnknownResult(schema)
const parsed = decodeJsonUnknown(raw)
if (Result.isFailure(parsed)) return Result.fail(parsed.failure.toString())
const decoded = decodeSchema(parsed.success)
if (Result.isFailure(decoded)) return Result.fail(decoded.failure.toString())
return Result.succeed(decoded.success)
}
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { Option } from "effect"
import { RouteSchema } from "@/cli/cmd/tui/context/route"
import { tryJsonConfig } from "@/util/json"
const parse = (raw: string | undefined) => tryJsonConfig(raw, RouteSchema, "OPENCODE_ROUTE")
describe("tryJsonConfig with RouteSchema", () => {
test("returns None for undefined input", () => {
expect(Option.isNone(parse(undefined))).toBe(true)
})
test("returns None for empty string", () => {
expect(Option.isNone(parse(""))).toBe(true)
})
test("returns None for malformed JSON", () => {
expect(Option.isNone(parse("{not json"))).toBe(true)
})
test("returns None when JSON does not match route schema", () => {
expect(Option.isNone(parse(`{"type":"unknown"}`))).toBe(true)
})
test("returns Some for valid home route", () => {
expect(Option.getOrThrow(parse(`{"type":"home"}`))).toEqual({ type: "home" })
})
test("returns Some for valid session route", () => {
expect(Option.getOrThrow(parse(`{"type":"session","sessionID":"abc123"}`))).toEqual({
type: "session",
sessionID: "abc123",
})
})
test("returns Some for valid plugin route", () => {
expect(Option.getOrThrow(parse(`{"type":"plugin","id":"foo","data":{"x":1}}`))).toEqual({
type: "plugin",
id: "foo",
data: { x: 1 },
})
})
})
@@ -0,0 +1,54 @@
import { describe, expect, test } from "bun:test"
import { ConfigPermission } from "@/config/permission"
import { InvalidConfigError, requireJsonConfig } from "@/util/json"
const decode = (raw: string) => requireJsonConfig(raw, ConfigPermission.Info, "OPENCODE_PERMISSION")
describe("requireJsonConfig with ConfigPermission.Info", () => {
test("throws InvalidConfigError on malformed JSON", () => {
let caught: unknown
try {
decode("{not json")
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const data = caught as InvalidConfigError
expect(data.source).toBe("OPENCODE_PERMISSION")
expect(data.value).toBe("{not json")
expect(typeof data.reason).toBe("string")
expect(data.reason.length).toBeGreaterThan(0)
})
test("throws InvalidConfigError on shape mismatch", () => {
let caught: unknown
try {
// `banana` is not a valid Action ("ask" | "allow" | "deny")
decode(`{"bash":"banana"}`)
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const data = caught as InvalidConfigError
expect(data.source).toBe("OPENCODE_PERMISSION")
expect(data.value).toBe(`{"bash":"banana"}`)
expect(data.reason.length).toBeGreaterThan(0)
})
test("returns decoded value for Action shorthand", () => {
expect(decode(`"ask"`)).toEqual({ "*": "ask" })
})
test("returns decoded value for object of per-target rules", () => {
expect(decode(`{"bash":"ask","edit":"allow"}`)).toEqual({
bash: "ask",
edit: "allow",
})
})
test("returns decoded value for nested Object rule", () => {
expect(decode(`{"bash":{"rm *":"deny","ls":"allow"}}`)).toEqual({
bash: { "rm *": "deny", ls: "allow" },
})
})
})
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, test } from "bun:test"
import { Option, Schema } from "effect"
import { InvalidConfigError, requireJsonConfig, tryJsonConfig } from "@/util/json"
const PointSchema = Schema.Struct({
x: Schema.Number,
y: Schema.Number,
})
describe("requireJsonConfig", () => {
test("returns decoded value on success", () => {
expect(requireJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")).toEqual({ x: 1, y: 2 })
})
test("throws InvalidConfigError on malformed JSON", () => {
let caught: unknown
try {
requireJsonConfig("not json", PointSchema, "TEST")
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const err = caught as InvalidConfigError
expect(err.source).toBe("TEST")
expect(err.value).toBe("not json")
expect(err.reason.length).toBeGreaterThan(0)
})
test("throws InvalidConfigError on schema mismatch", () => {
let caught: unknown
try {
requireJsonConfig(`{"x":"oops"}`, PointSchema, "TEST")
} catch (error) {
caught = error
}
expect(caught).toBeInstanceOf(InvalidConfigError)
const err = caught as InvalidConfigError
expect(err.source).toBe("TEST")
expect(err.value).toBe(`{"x":"oops"}`)
expect(err.reason.length).toBeGreaterThan(0)
})
test("InvalidConfigError carries _tag for downstream matching", () => {
let caught: unknown
try {
requireJsonConfig("not json", PointSchema, "TEST")
} catch (error) {
caught = error
}
expect((caught as { _tag: string })._tag).toBe("InvalidConfigError")
})
})
describe("tryJsonConfig", () => {
test("returns Some on success", () => {
const decoded = tryJsonConfig(`{"x":1,"y":2}`, PointSchema, "TEST")
expect(Option.isSome(decoded)).toBe(true)
expect(Option.getOrThrow(decoded)).toEqual({ x: 1, y: 2 })
})
test("returns None for undefined input", () => {
expect(Option.isNone(tryJsonConfig(undefined, PointSchema, "TEST"))).toBe(true)
})
test("returns None for empty string", () => {
expect(Option.isNone(tryJsonConfig("", PointSchema, "TEST"))).toBe(true)
})
test("returns None for malformed JSON", () => {
expect(Option.isNone(tryJsonConfig("not json", PointSchema, "TEST"))).toBe(true)
})
test("returns None on schema mismatch", () => {
expect(Option.isNone(tryJsonConfig(`{"x":"oops"}`, PointSchema, "TEST"))).toBe(true)
})
})