diff --git a/packages/opencode/src/cli/cmd/tui/context/route.tsx b/packages/opencode/src/cli/cmd/tui/context/route.tsx index 35be17801b..ea43c680fb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/route.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/route.tsx @@ -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( - 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(props.initialRoute ?? Option.getOrElse(fromEnv, () => HOME_ROUTE)) return { get data() { diff --git a/packages/opencode/src/cli/error.ts b/packages/opencode/src/cli/error.ts index ef724bbbef..490094b53c 100644 --- a/packages/opencode/src/cli/error.ts +++ b/packages/opencode/src/cli/error.ts @@ -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 "" diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index b13d3a8c81..39f9bf35b3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -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) { diff --git a/packages/opencode/src/util/json.ts b/packages/opencode/src/util/json.ts new file mode 100644 index 0000000000..081d308e95 --- /dev/null +++ b/packages/opencode/src/util/json.ts @@ -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", { + 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>(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>( + raw: string | undefined, + schema: S, + source: string, +): Option.Option { + 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>(raw: string, schema: S): Result.Result { + 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) +} diff --git a/packages/opencode/test/cli/tui/route-env.test.ts b/packages/opencode/test/cli/tui/route-env.test.ts new file mode 100644 index 0000000000..6773bd10a8 --- /dev/null +++ b/packages/opencode/test/cli/tui/route-env.test.ts @@ -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 }, + }) + }) +}) diff --git a/packages/opencode/test/config/permission-env.test.ts b/packages/opencode/test/config/permission-env.test.ts new file mode 100644 index 0000000000..3ea0af54e5 --- /dev/null +++ b/packages/opencode/test/config/permission-env.test.ts @@ -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" }, + }) + }) +}) diff --git a/packages/opencode/test/util/json.test.ts b/packages/opencode/test/util/json.test.ts new file mode 100644 index 0000000000..ccc7c2afaf --- /dev/null +++ b/packages/opencode/test/util/json.test.ts @@ -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) + }) +})