cloudaxe-opencode/packages/opencode/test/config/v2-compat.test.ts

400 lines
17 KiB
TypeScript

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<unknown, void>((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")
}),
)
})