import { describe, expect, test } from "bun:test" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { httpClient } from "@opencode-ai/core/effect/app-node-platform" import { FSUtil } from "@opencode-ai/core/fs-util" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { Npm } from "@opencode-ai/core/npm" import { Effect, Layer, Logger } from "effect" import { HttpClient } from "effect/unstable/http" import path from "path" import { Account } from "../../src/account/account" import { Auth } from "../../src/auth" import { Config } from "../../src/config/config" import { ConfigParse } from "../../src/config/parse" import { ConfigV2Compat } from "../../src/config/v2-compat" import { Env } from "../../src/env" import { AccountTest } from "../fake/account" import { AuthTest } from "../fake/auth" import { NpmTest } from "../fake/npm" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" import { snapshot } from "./snapshot" const source = "test:v2-compat" const lower = (input: unknown) => ConfigParse.schema(ConfigV1.Info, ConfigV2Compat.lower(input, source).value, source) const it = testEffect( LayerNode.compile(LayerNode.group([Config.node, FSUtil.node, Env.node, CrossSpawnSpawner.node]), [ [Auth.node, AuthTest.empty], [Account.node, AccountTest.empty], [Npm.node, NpmTest.noop], [ httpClient, Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => Effect.die(`unexpected http request: ${request.method} ${request.url}`)), ), ], ]), ) describe("V2 compatibility read fixtures", () => { const directory = path.join(import.meta.dir, "fixtures/v2-compat/read") const cases = Array.from(new Bun.Glob("*-input.jsonc").scanSync(directory)) .map((file) => file.slice(0, -"-input.jsonc".length)) .sort() if (!cases.length) throw new Error("No V2 compatibility read fixtures found") cases.forEach((name) => { test(name, async () => { const source = path.join(directory, `${name}-input.jsonc`) const input = ConfigParse.jsonc(await Bun.file(source).text(), source) const original = structuredClone(input) const result = ConfigV2Compat.lower(input, source) expect(input).toEqual(original) const config = ConfigParse.schema(ConfigV1.Info, result.value, source) await snapshot(path.join(directory, `${name}-output.json`), JSON.stringify(config, null, 2) + "\n") }) }) }) describe("ConfigV2Compat.lower", () => { test("returns structured invalid diagnostics while retaining supported siblings", () => { const result = ConfigV2Compat.lower({ mcp: { servers: { broken: { type: "local", command: "not-an-array" }, working: { type: "local", command: ["working-mcp"] }, }, }, agents: { broken: { steps: "many" } }, commands: { broken: { template: 42 } }, }) expect(result.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "invalid", path: ["mcp", "servers", "broken"] }), expect.objectContaining({ kind: "invalid", path: ["agents", "broken"] }), expect.objectContaining({ kind: "invalid", path: ["commands", "broken"] }), ]), ) expect(ConfigParse.schema(ConfigV1.Info, result.value, source).mcp).toEqual({ working: { type: "local", command: ["working-mcp"], enabled: true }, }) }) test("reports unsupported settings and lossy conversions without their values", () => { const secret = "do-not-log-credentials" const result = ConfigV2Compat.lower({ model: { providerID: "example", model: "model", variant: "high" }, plugins: [{ package: "native-plugin", options: { token: secret } }], providers: { example: { settings: { apiKey: secret } } }, websearch: secret, warming: true, experimental: { portable_shell_scanner: true }, agents: { reviewer: { request: { headers: { Authorization: secret } } } }, mcp: { servers: { remote: { type: "remote", url: `https://example.com/?token=${secret}`, oauth: { client_secret: secret }, codemode: false, timeout: { execution: 60000 }, }, }, }, lsp: { custom: { command: ["custom-lsp"] } }, }) expect(result.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "unsupported", path: ["model", "variant"] }), expect.objectContaining({ kind: "unsupported", path: ["plugins"] }), expect.objectContaining({ kind: "unsupported", path: ["providers"] }), expect.objectContaining({ kind: "unsupported", path: ["websearch"] }), expect.objectContaining({ kind: "unsupported", path: ["warming"] }), expect.objectContaining({ kind: "unsupported", path: ["experimental", "portable_shell_scanner"] }), expect.objectContaining({ kind: "unsupported", path: ["agents", "reviewer", "request", "headers"] }), expect.objectContaining({ kind: "unsupported", path: ["mcp", "servers", "remote", "codemode"] }), expect.objectContaining({ kind: "unsupported", path: ["mcp", "servers", "remote", "timeout"] }), expect.objectContaining({ kind: "unsupported", path: ["lsp", "custom"] }), ]), ) expect(JSON.stringify(result.diagnostics)).not.toContain(secret) }) test("reports conflicting forms while retaining the V1 value", () => { const result = ConfigV2Compat.lower({ snapshot: false, snapshots: true, command: { review: { template: "Legacy review" } }, commands: { review: { template: "Native review" } }, mcp: { shared: { type: "local", command: ["legacy"] }, servers: { shared: { type: "local", command: ["native"] } }, }, }) const config = ConfigParse.schema(ConfigV1.Info, result.value, source) expect(config.snapshot).toBe(false) expect(config.command?.review.template).toBe("Legacy review") expect(config.mcp?.shared).toEqual({ type: "local", command: ["legacy"] }) expect(result.diagnostics.filter((item) => item.kind === "conflict")).toHaveLength(3) }) test("does not diagnose ordinary V1 configuration or reject invalid V1 roots early", () => { expect(ConfigV2Compat.lower({ snapshot: false, mcp: { existing: { enabled: false } } }).diagnostics).toEqual([]) expect(ConfigV2Compat.lower({ snapshot: false, snapshots: false }).diagnostics).toEqual([]) const result = ConfigV2Compat.lower(null) expect(result.value).toBeNull() expect(() => ConfigParse.schema(ConfigV1.Info, result.value, source)).toThrow() expect(() => lower({ snapshot: "invalid", snapshots: true })).toThrow() }) test("keeps malformed MCP servers named servers and timeout for V1 validation", () => { expect(() => lower({ mcp: { servers: { type: "local", command: "invalid" } } })).toThrow() expect(() => lower({ mcp: { timeout: { type: "remote", url: 42 } } })).toThrow() expect(() => lower({ mcp: { servers: { type: "bogus" } } })).toThrow() expect(() => lower({ mcp: { servers: { type: 42 } } })).toThrow() expect(() => lower({ mcp: { servers: { enabled: "false" } } })).toThrow() }) test("rejects invalid V1 enablement when flat MCP entries include V2 fields", () => { expect(() => lower({ mcp: { invalid: { type: "local", command: ["legacy"], enabled: "false", codemode: false } } }), ).toThrow("ConfigInvalidError") }) test("does not repair malformed V1 containers or shadowed entries with V2 values", () => { const cases = [ { agent: null, agents: { reviewer: { system: "Native prompt" } } }, { command: [], commands: { review: { template: "Native command" } } }, { attachment: false, media: { image: { auto_resize: true } } }, { experimental: null, mcp: { timeout: { catalog: 3000, execution: 3000 } } }, { agent: { reviewer: 42 }, agents: { reviewer: { system: "Native prompt" } } }, { command: { review: 42 }, commands: { review: { template: "Native command" } } }, { mcp: { shared: 42, servers: { shared: { type: "local", command: ["native"] } } } }, ] cases.forEach((input) => expect(() => lower(input)).toThrow("ConfigInvalidError")) }) test("keeps secrets out of invalid and conflict diagnostics", () => { const secret = "secret-never-in-diagnostics" const result = ConfigV2Compat.lower({ commands: { malformed: { template: { token: secret } } }, mcp: { shared: { type: "remote", url: "https://example.com", headers: { Authorization: secret } }, servers: { shared: { type: "remote", url: "https://example.com", headers: { Authorization: `${secret}-changed` } }, malformed: { type: "remote", url: `https://example.com?token=${secret}`, disabled: secret }, }, }, }) expect(result.diagnostics).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: "conflict", path: ["mcp", "servers", "shared"] }), expect.objectContaining({ kind: "invalid", path: ["commands", "malformed"] }), expect.objectContaining({ kind: "invalid", path: ["mcp", "servers", "malformed"] }), ]), ) expect(JSON.stringify(result.diagnostics)).not.toContain(secret) }) test("rejects any native permission field, including empty and malformed values", () => { const cases = [ [{ action: "shell", resource: "*", effect: "deny" }], [ { action: "read", resource: "*", effect: "allow" }, { action: "*", resource: "*", effect: "deny" }, { action: "read", resource: "public", effect: "allow" }, ], [], null, "secret-permission-value", [{ action: "read", resource: "secret-permission-value", effect: "invalid" }], ] cases.forEach((permissions) => { expect(() => lower({ username: "keep-me", permissions })).toThrow("ConfigInvalidError") expect(() => lower({ permission: "deny", permissions })).toThrow("ConfigInvalidError") }) }) test("does not repair invalid legacy permissions with native rules", () => { expect(() => lower({ permission: { read: "invalid" }, permissions: [] })).toThrow("ConfigInvalidError") }) test("rejects native agent permissions before decoding or applying V1 precedence", () => { const cases = [ { agents: { reviewer: { system: "Review carefully", permissions: [{ action: "read" }] } } }, { agents: { reviewer: { disabled: true, permissions: [] } } }, { agent: { reviewer: { permission: "deny" } }, agents: { reviewer: { permissions: [] } } }, { agents: { reviewer: { steps: "invalid", permissions: [] } } }, { agent: { reviewer: { permissions: [] } } }, { mode: { reviewer: { permissions: [] } } }, ] cases.forEach((input) => expect(() => lower(input)).toThrow("ConfigInvalidError")) }) test("continues to support V1 permission rules", () => { const config = lower({ permission: { bash: "deny", "*": "ask", edit: "allow" }, agent: { reviewer: { permission: { edit: "deny" } } }, }) expect(config.permission).toEqual({ bash: "deny", "*": "ask", edit: "allow" }) expect(Object.keys(config.permission ?? {})).toEqual(["bash", "*", "edit"]) expect(config.agent?.reviewer?.permission).toEqual({ edit: "deny" }) }) test("does not sanitize malformed V1 fields before schema validation", () => { expect(() => lower({ model: 42 })).toThrow() expect(() => lower({ snapshot: "enabled" })).toThrow() expect(() => lower({ mcp: { broken: { type: "local", command: "not-an-array" } } })).toThrow() expect(() => lower({ experimental: { mcp_timeout: -1 } })).toThrow() }) test("does not mutate the input or nested configuration objects", () => { const input = { model: { providerID: "anthropic", model: "claude-sonnet", variant: "fast" }, snapshots: true, skills: ["./skills", "https://example.com/skills"], mcp: { existing: { type: "local", command: ["existing-mcp"], enabled: false }, servers: { native: { type: "remote", url: "https://example.com/mcp", disabled: true, oauth: { client_id: "client" }, timeout: { execution: 3000 }, }, }, }, agents: { reviewer: { model: "anthropic/claude-sonnet#thinking", disabled: true } }, experimental: { subagent_depth: 2 }, } const original = structuredClone(input) lower(input) expect(input).toEqual(original) }) }) describe("V2 configuration loading", () => { it.instance("logs compatibility diagnostics without writing the lowered projection", () => Effect.gen(function* () { const instance = yield* TestInstance const fs = yield* FSUtil.Service const file = path.join(instance.directory, "opencode.jsonc") const text = '{\n // Retain this comment\n "$schema": "https://opencode.ai/config.json",\n "plugins": ["native-only"]\n}\n' yield* fs.writeWithDirs(file, text) const messages: unknown[] = [] const config = yield* Config.use.get().pipe( Effect.provide( Logger.layer([ Logger.make((options) => { messages.push(options.message) }), ]), ), ) expect(config.plugin).toEqual([]) expect(messages).toContainEqual([ "configuration compatibility diagnostic", expect.objectContaining({ source: file, kind: "unsupported", path: ["plugins"] }), ]) expect(yield* fs.readFileString(file)).toBe(text) }), ) it.instance("loads native V2 configuration through the V1 Config service", () => Effect.gen(function* () { const instance = yield* TestInstance const fs = yield* FSUtil.Service yield* fs.writeWithDirs( path.join(instance.directory, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", model: { providerID: "anthropic", model: "claude-sonnet", variant: "fast" }, snapshots: false, skills: ["./skills", "https://example.com/skills"], mcp: { timeout: { catalog: 9000, execution: 9000 }, servers: { native: { type: "remote", url: "https://native.example.com/mcp", disabled: false }, }, }, agents: { reviewer: { model: "anthropic/claude-sonnet#thinking", system: "Review carefully.", }, }, commands: { review: { template: "Review $ARGUMENTS", model: { providerID: "anthropic", model: "claude-sonnet", variant: "thinking" }, }, }, experimental: { subagent_depth: 2 }, }), ) const config = yield* Config.use.get() expect(config.model).toBe("anthropic/claude-sonnet") expect(config.snapshot).toBe(false) expect(config.skills).toEqual({ paths: ["./skills"], urls: ["https://example.com/skills"] }) expect(config.mcp?.native).toEqual({ type: "remote", url: "https://native.example.com/mcp", enabled: true }) expect(config.experimental?.mcp_timeout).toBe(9000) expect(config.permission).toBeUndefined() expect(config.agent?.reviewer).toMatchObject({ model: "anthropic/claude-sonnet", variant: "thinking", prompt: "Review carefully.", permission: {}, }) expect(config.command?.review).toMatchObject({ template: "Review $ARGUMENTS", model: "anthropic/claude-sonnet", variant: "thinking", }) expect(config.subagent_depth).toBe(2) }), ) it.instance("keeps legacy TUI normalization when loading a mixed V1 and V2 document", () => Effect.gen(function* () { const instance = yield* TestInstance const fs = yield* FSUtil.Service yield* fs.writeWithDirs( path.join(instance.directory, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json", model: { providerID: "openai", model: "gpt-4.1" }, theme: "legacy", keybinds: { leader: "ctrl+x" }, tui: { scroll_speed: 4 }, mcp: { legacy: { enabled: false }, servers: { native: { type: "local", command: ["native-mcp"] } }, }, }), ) const config = yield* Config.use.get() expect(config.model).toBe("openai/gpt-4.1") expect(config.mcp?.legacy).toEqual({ enabled: false }) expect(config.mcp?.native).toEqual({ type: "local", command: ["native-mcp"], enabled: true }) expect(config).not.toHaveProperty("theme") expect(config).not.toHaveProperty("keybinds") expect(config).not.toHaveProperty("tui") }), ) })