normalization.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509
  1. import { describe, expect, test } from "bun:test"
  2. import { Duration, Schema } from "effect"
  3. import { FastCheck } from "effect/testing"
  4. import { ConfigNormalize } from "@opencode-ai/core/config/normalize"
  5. import { Info } from "@opencode-ai/schema/config"
  6. const options = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
  7. function normalized(input: unknown) {
  8. const result = ConfigNormalize.normalize(input)
  9. expect(result.type).toBe("normalized")
  10. if (result.type !== "normalized") throw new Error("expected normalized config")
  11. return result
  12. }
  13. function decoded(input: unknown) {
  14. return Schema.decodeUnknownSync(Info, options)(normalized(input).encoded)
  15. }
  16. function withoutEmptyCompatibilityContainers(input: Record<string, unknown>) {
  17. const result = structuredClone(input)
  18. if (typeof result.mcp === "object" && result.mcp !== null && !Array.isArray(result.mcp)) {
  19. const mcp = result.mcp as Record<string, unknown>
  20. const originallyEmpty = !Object.keys(mcp).length
  21. for (const key of ["servers", "timeout"]) {
  22. if (
  23. typeof mcp[key] === "object" &&
  24. mcp[key] !== null &&
  25. !Array.isArray(mcp[key]) &&
  26. !Object.keys(mcp[key]).length
  27. )
  28. delete mcp[key]
  29. }
  30. if (!originallyEmpty && !Object.keys(mcp).length) delete result.mcp
  31. }
  32. if (typeof result.compaction === "object" && result.compaction !== null && !Array.isArray(result.compaction)) {
  33. const compaction = result.compaction as Record<string, unknown>
  34. const originallyEmpty = !Object.keys(compaction).length
  35. if (
  36. typeof compaction.keep === "object" &&
  37. compaction.keep !== null &&
  38. !Array.isArray(compaction.keep) &&
  39. !Object.keys(compaction.keep).length
  40. )
  41. delete compaction.keep
  42. if (!originallyEmpty && !Object.keys(compaction).length) delete result.compaction
  43. }
  44. return result
  45. }
  46. describe("ConfigNormalize", () => {
  47. test("rejects every non-object root with one root diagnostic", () => {
  48. for (const input of [null, [], "config", true, 1]) {
  49. expect(ConfigNormalize.normalize(input)).toEqual({
  50. type: "rejected",
  51. diagnostics: [
  52. {
  53. kind: "invalid",
  54. path: ["$"],
  55. message: "rejected configuration because its root is not an object",
  56. },
  57. ],
  58. })
  59. }
  60. })
  61. test("keeps unrelated native fields when a legacy field is present", () => {
  62. const result = decoded({ snapshot: false, agents: { reviewer: { system: "Use V2" } } })
  63. expect(result.snapshots).toBe(false)
  64. expect(result.agents?.reviewer?.system).toBe("Use V2")
  65. })
  66. test("canonicalizes transformed native values through decode then encode", () => {
  67. const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
  68. expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
  69. const info = Schema.decodeUnknownSync(Info)(result.encoded)
  70. if (typeof info.warming === "boolean" || info.warming === undefined) throw new Error("expected warming info")
  71. expect(Duration.toMillis(info.warming.interval ?? Duration.zero)).toBe(240_000)
  72. expect(Duration.toMillis(info.warming.duration ?? Duration.zero)).toBe(1_800_000)
  73. })
  74. test("preserves arbitrary JSON-round-tripped native configuration", () => {
  75. FastCheck.assert(
  76. FastCheck.property(Schema.toArbitrary(Info), (info) => {
  77. const source = JSON.parse(JSON.stringify(Schema.encodeSync(Info)(info)))
  78. const result = normalized(source)
  79. expect(Schema.decodeUnknownSync(Info)(result.encoded)).toEqual(
  80. Schema.decodeUnknownSync(Info)(withoutEmptyCompatibilityContainers(source)),
  81. )
  82. }),
  83. { numRuns: 100 },
  84. )
  85. })
  86. test("merges named maps by entry and gives valid native entries precedence", () => {
  87. const result = normalized({
  88. reference: { legacy: { path: "../legacy" }, duplicate: { path: "../old" } },
  89. references: { native: { path: "../native" }, duplicate: { path: "../new" } },
  90. command: { legacy: { template: "legacy" }, duplicate: { template: "old" } },
  91. commands: { native: { template: "native" }, duplicate: { template: "new" } },
  92. })
  93. expect(result.encoded.references).toEqual({
  94. legacy: { path: "../legacy" },
  95. native: { path: "../native" },
  96. duplicate: { path: "../new" },
  97. })
  98. expect(result.encoded.commands).toEqual({
  99. legacy: { template: "legacy" },
  100. native: { template: "native" },
  101. duplicate: { template: "new" },
  102. })
  103. expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
  104. ["references", "duplicate"],
  105. ["commands", "duplicate"],
  106. ])
  107. })
  108. test("does not report canonical-equal duplicates as conflicts", () => {
  109. const result = normalized({
  110. snapshot: false,
  111. snapshots: false,
  112. reference: { docs: { path: "../docs" } },
  113. references: { docs: { path: "../docs" } },
  114. agent: { reviewer: { prompt: "same" } },
  115. agents: { reviewer: { system: "same" } },
  116. provider: { custom: { name: "same" } },
  117. providers: { custom: { name: "same" } },
  118. compaction: { preserve_recent_tokens: 1000, keep: { tokens: 1000 } },
  119. })
  120. expect(result.diagnostics.filter((item) => item.kind === "conflict")).toEqual([])
  121. })
  122. test("uses agent then mode then native agent precedence", () => {
  123. const result = normalized({
  124. agent: { reviewer: { prompt: "agent" }, agentOnly: { prompt: "agent-only" } },
  125. mode: { reviewer: { prompt: "mode" }, modeOnly: { prompt: "mode-only" } },
  126. agents: { reviewer: { system: "native" }, nativeOnly: { system: "native-only" } },
  127. })
  128. expect(result.encoded.agents).toEqual({
  129. reviewer: { system: "native" },
  130. agentOnly: { system: "agent-only" },
  131. modeOnly: { system: "mode-only", mode: "primary" },
  132. nativeOnly: { system: "native-only" },
  133. })
  134. expect(result.diagnostics.filter((item) => item.kind === "conflict").map((item) => item.path)).toEqual([
  135. ["agents", "reviewer"],
  136. ["agents", "reviewer"],
  137. ])
  138. expect(() => Schema.decodeUnknownSync(Info)(result.encoded)).not.toThrow()
  139. })
  140. test("migrates the legacy small model to the title agent", () => {
  141. const result = normalized({
  142. small_model: "anthropic/claude-haiku-4-5",
  143. agent: { title: { prompt: "Custom title prompt" } },
  144. })
  145. expect(result.encoded.agents).toEqual({
  146. title: {
  147. model: { providerID: "anthropic", model: "claude-haiku-4-5" },
  148. system: "Custom title prompt",
  149. },
  150. })
  151. expect(result.diagnostics).toEqual([])
  152. })
  153. test("omits an invalid legacy small model without exposing its value", () => {
  154. const secret = "do-not-log-this-value"
  155. const result = normalized({ small_model: secret })
  156. expect(result.encoded.agents).toBeUndefined()
  157. expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([["unsupported", ["small_model"]]])
  158. expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
  159. })
  160. test("recovers malformed named entries and retains a valid legacy collision", () => {
  161. const result = normalized({
  162. command: { fallback: { template: "legacy" } },
  163. commands: {
  164. fallback: { template: 1 },
  165. valid: { template: "native" },
  166. invalid: { template: false },
  167. },
  168. providers: {
  169. valid: { name: "Valid" },
  170. invalid: { env: [1] },
  171. },
  172. })
  173. expect(result.encoded.commands).toEqual({ fallback: { template: "legacy" }, valid: { template: "native" } })
  174. expect(result.encoded.providers).toEqual({ valid: { name: "Valid" } })
  175. expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
  176. ["commands", "fallback"],
  177. ["commands", "invalid"],
  178. ["providers", "invalid"],
  179. ])
  180. })
  181. test("uses a valid retired provider alias when the canonical legacy entry is malformed", () => {
  182. const result = normalized({
  183. provider: {
  184. "azure-cognitive-services": { models: { deployment: {} } },
  185. azure: { env: [1] },
  186. },
  187. })
  188. expect(result.encoded.providers).toHaveProperty("azure.models.deployment")
  189. expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toContainEqual([
  190. "provider",
  191. "azure",
  192. ])
  193. })
  194. test("preserves permission source order and appends native rules", () => {
  195. expect(
  196. normalized({
  197. tools: { bash: true, write: false },
  198. permission: { read: "allow", custom: { first: "deny", second: "ask" }, task: "allow" },
  199. permissions: [{ action: "native", resource: "*", effect: "deny" }],
  200. }).encoded.permissions,
  201. ).toEqual([
  202. { action: "shell", resource: "*", effect: "allow" },
  203. { action: "edit", resource: "*", effect: "deny" },
  204. { action: "read", resource: "*", effect: "allow" },
  205. { action: "custom", resource: "first", effect: "deny" },
  206. { action: "custom", resource: "second", effect: "ask" },
  207. { action: "subagent", resource: "*", effect: "allow" },
  208. { action: "native", resource: "*", effect: "deny" },
  209. ])
  210. })
  211. test("redacts permission resource keys from invalid diagnostics", () => {
  212. const result = normalized({
  213. permission: { bash: { "curl -H Authorization:Bearer TOPSECRET *": "bogus" } },
  214. })
  215. expect(result.diagnostics).toEqual([
  216. {
  217. kind: "invalid",
  218. path: ["permission", "bash", "0"],
  219. message: "skipped malformed recognized value",
  220. },
  221. ])
  222. expect(JSON.stringify(result.diagnostics)).not.toContain("TOPSECRET")
  223. })
  224. test("recovers list items for skills, plugins, instructions, and permissions", () => {
  225. const result = normalized({
  226. skills: { paths: ["./skills", 1], urls: [false, "https://example.com/skills"] },
  227. plugin: ["legacy", ["tuple", {}], [1, {}]],
  228. plugins: ["native", { package: "object" }, { package: 1 }],
  229. instructions: ["one", 2, "three"],
  230. permissions: [
  231. { action: "read", resource: "*", effect: "allow" },
  232. { action: "read", resource: "*", effect: "invalid" },
  233. ],
  234. })
  235. expect(result.encoded.skills).toEqual(["./skills", "https://example.com/skills"])
  236. expect(result.encoded.plugins).toEqual([
  237. "legacy",
  238. { package: "tuple", options: {} },
  239. "native",
  240. { package: "object" },
  241. ])
  242. expect(result.encoded.instructions).toEqual(["one", "three"])
  243. expect(result.encoded.permissions).toEqual([{ action: "read", resource: "*", effect: "allow" }])
  244. expect(result.diagnostics.filter((item) => item.kind === "invalid")).toHaveLength(6)
  245. })
  246. test("omits malformed collection roots instead of synthesizing empty values", () => {
  247. const result = normalized({
  248. commands: [],
  249. providers: "invalid",
  250. references: false,
  251. agents: 1,
  252. plugins: {},
  253. permissions: {},
  254. instructions: {},
  255. })
  256. expect(result.encoded).toEqual({})
  257. expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
  258. ["references"],
  259. ["commands"],
  260. ["agents"],
  261. ["providers"],
  262. ["permissions"],
  263. ["plugins"],
  264. ["instructions"],
  265. ])
  266. })
  267. test("omits all-invalid formatter and LSP maps while preserving explicit empty maps", () => {
  268. const invalid = normalized({
  269. formatter: { prettier: { command: [1] } },
  270. lsp: { typescript: { command: [1] } },
  271. })
  272. expect(invalid.encoded).not.toHaveProperty("formatter")
  273. expect(invalid.encoded).not.toHaveProperty("lsp")
  274. expect(invalid.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
  275. ["formatter", "prettier"],
  276. ["lsp", "typescript"],
  277. ])
  278. expect(normalized({ formatter: {}, lsp: {} }).encoded).toMatchObject({ formatter: {}, lsp: {} })
  279. })
  280. test("combines legacy and native MCP servers and merges timeout leaves", () => {
  281. const result = normalized({
  282. experimental: { mcp_timeout: 5000 },
  283. mcp: {
  284. legacy: { type: "local", command: ["legacy"] },
  285. duplicate: { type: "remote", url: "https://legacy.example.com" },
  286. servers: {
  287. native: { type: "local", command: ["native"] },
  288. duplicate: { type: "remote", url: "https://native.example.com" },
  289. invalid: { type: "local", command: [1] },
  290. },
  291. timeout: { startup: 1000, catalog: 6000 },
  292. },
  293. })
  294. expect(result.encoded.mcp).toEqual({
  295. timeout: { catalog: 6000, execution: 5000, startup: 1000 },
  296. servers: {
  297. legacy: { type: "local", command: ["legacy"], disabled: undefined, timeout: undefined },
  298. duplicate: { type: "remote", url: "https://native.example.com" },
  299. native: { type: "local", command: ["native"] },
  300. },
  301. })
  302. expect(
  303. result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.servers.duplicate"),
  304. ).toBe(true)
  305. expect(
  306. result.diagnostics.some((item) => item.kind === "conflict" && item.path.join(".") === "mcp.timeout.catalog"),
  307. ).toBe(true)
  308. expect(
  309. result.diagnostics.some((item) => item.kind === "invalid" && item.path.join(".") === "mcp.servers.invalid"),
  310. ).toBe(true)
  311. })
  312. test("uses raw MCP discriminators for reserved server names", () => {
  313. const result = normalized({
  314. mcp: {
  315. servers: { type: "local", command: ["reserved-servers"] },
  316. timeout: { type: "remote", url: "https://reserved.example.com" },
  317. },
  318. })
  319. expect((result.encoded.mcp as { servers: Record<string, unknown> }).servers).toEqual({
  320. servers: { type: "local", command: ["reserved-servers"], disabled: undefined, timeout: undefined },
  321. timeout: { type: "remote", url: "https://reserved.example.com", disabled: undefined, timeout: undefined },
  322. })
  323. const enabledOnly = normalized({ mcp: { servers: { enabled: true }, timeout: { enabled: false } } })
  324. expect(enabledOnly.encoded.mcp).toBeUndefined()
  325. expect(enabledOnly.diagnostics.map((item) => [item.kind, item.path])).toEqual([
  326. ["unsupported", ["mcp", "servers"]],
  327. ["unsupported", ["mcp", "timeout"]],
  328. ])
  329. })
  330. test("merges bounded compaction leaves and omits unsupported leaves", () => {
  331. const result = normalized({
  332. compaction: {
  333. auto: false,
  334. preserve_recent_tokens: 1000,
  335. keep: { tokens: 2000 },
  336. reserved: 3000,
  337. buffer: 4000,
  338. tail_turns: 2,
  339. prune: true,
  340. },
  341. })
  342. expect(result.encoded.compaction).toEqual({ auto: false, keep: { tokens: 2000 }, buffer: 4000 })
  343. expect(result.diagnostics.map((item) => [item.kind, item.path])).toEqual([
  344. ["unsupported", ["compaction", "tail_turns"]],
  345. ["unsupported", ["compaction", "prune"]],
  346. ["conflict", ["compaction", "keep", "tokens"]],
  347. ["conflict", ["compaction", "buffer"]],
  348. ])
  349. })
  350. test("distinguishes empty, mixed, and wholly malformed enabled provider lists", () => {
  351. expect(normalized({ enabled_providers: [] }).encoded.experimental).toEqual({
  352. policies: [{ action: "provider.use", resource: "*", effect: "deny" }],
  353. })
  354. expect(normalized({ enabled_providers: [1, "anthropic", false] }).encoded.experimental).toEqual({
  355. policies: [
  356. { action: "provider.use", resource: "*", effect: "deny" },
  357. { action: "provider.use", resource: "anthropic", effect: "allow" },
  358. ],
  359. })
  360. expect(normalized({ enabled_providers: [1, false] }).encoded.experimental).toBeUndefined()
  361. expect(normalized({ enabled_providers: "anthropic" }).encoded.experimental).toBeUndefined()
  362. })
  363. test("appends native policies after migrated provider policies", () => {
  364. expect(
  365. normalized({
  366. enabled_providers: ["anthropic"],
  367. disabled_providers: ["openai"],
  368. experimental: {
  369. subagent_depth: 0,
  370. policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
  371. },
  372. }).encoded.experimental,
  373. ).toEqual({
  374. subagent_depth: 0,
  375. policies: [
  376. { action: "provider.use", resource: "*", effect: "deny" },
  377. { action: "provider.use", resource: "anthropic", effect: "allow" },
  378. { action: "provider.use", resource: "openai", effect: "deny" },
  379. { action: "provider.use", resource: "custom", effect: "allow" },
  380. ],
  381. })
  382. })
  383. test("reports unsupported legacy settings without including their values", () => {
  384. const secret = "do-not-log-this-value"
  385. const result = normalized({
  386. logLevel: "DEBUG",
  387. agent: { reviewer: { name: secret, prompt: "review" } },
  388. provider: {
  389. custom: {
  390. id: secret,
  391. whitelist: ["model"],
  392. models: {
  393. model: {
  394. release_date: secret,
  395. status: "active",
  396. interleaved: true,
  397. },
  398. },
  399. },
  400. },
  401. experimental: { openTelemetry: true },
  402. })
  403. expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
  404. ["logLevel"],
  405. ["agent", "reviewer", "name"],
  406. ["provider", "custom", "id"],
  407. ["provider", "custom", "whitelist"],
  408. ["provider", "custom", "models", "model", "release_date"],
  409. ["provider", "custom", "models", "model", "status"],
  410. ["provider", "custom", "models", "model", "interleaved"],
  411. ["experimental", "openTelemetry"],
  412. ])
  413. expect(JSON.stringify(result.diagnostics)).not.toContain(secret)
  414. })
  415. test("diagnoses unsupported legacy model selections without dropping their entries", () => {
  416. const result = normalized({
  417. command: {
  418. invalidModel: { template: "one", model: "invalid" },
  419. invalidVariant: { template: "two", model: "anthropic/model", variant: "bad#variant" },
  420. missingModel: { template: "three", variant: "high" },
  421. },
  422. agent: { invalid: { prompt: "agent", model: "invalid", variant: "" } },
  423. })
  424. expect(Object.keys(result.encoded.commands as Record<string, unknown>)).toEqual([
  425. "invalidModel",
  426. "invalidVariant",
  427. "missingModel",
  428. ])
  429. expect(Object.keys(result.encoded.agents as Record<string, unknown>)).toEqual(["invalid"])
  430. expect(result.diagnostics.filter((item) => item.kind === "unsupported").map((item) => item.path)).toEqual([
  431. ["command", "invalidModel", "model"],
  432. ["command", "invalidVariant", "variant"],
  433. ["command", "missingModel", "variant"],
  434. ["agent", "invalid", "model"],
  435. ["agent", "invalid", "variant"],
  436. ])
  437. })
  438. test("invalid legacy provider overlays skip only that provider", () => {
  439. const result = normalized({
  440. provider: {
  441. headers: { options: { headers: { valid: "yes", invalid: 1 } } },
  442. body: { options: { body: "not-an-object" } },
  443. valid: { options: { headers: { valid: "yes" }, body: { trace: true } } },
  444. },
  445. })
  446. expect(result.encoded.providers).toEqual({
  447. valid: { settings: {}, headers: { valid: "yes" }, body: { trace: true } },
  448. })
  449. expect(result.diagnostics.filter((item) => item.kind === "invalid").map((item) => item.path)).toEqual([
  450. ["provider", "headers", "options", "headers"],
  451. ["provider", "body", "options", "body"],
  452. ])
  453. })
  454. test("preserves explicit false, zero, empty list, and empty map presence", () => {
  455. const result = normalized({
  456. snapshot: false,
  457. autoshare: false,
  458. references: {},
  459. commands: {},
  460. agents: {},
  461. providers: {},
  462. plugins: [],
  463. instructions: [],
  464. experimental: { subagent_depth: 0 },
  465. })
  466. expect(result.encoded).toMatchObject({
  467. snapshots: false,
  468. references: {},
  469. commands: {},
  470. agents: {},
  471. providers: {},
  472. plugins: [],
  473. instructions: [],
  474. experimental: { subagent_depth: 0 },
  475. })
  476. expect(result.encoded.share).toBeUndefined()
  477. })
  478. })