import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; const cleanupPaths = new Set(); afterEach(async () => { await Promise.all( [...cleanupPaths].map(async (filepath) => { await fs.rm(filepath, { recursive: true, force: true }); cleanupPaths.delete(filepath); }), ); }); async function makeConfigHome(initialConfig?: Record) { const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-")); cleanupPaths.add(root); const configDir = path.join(root, "opencode"); await fs.mkdir(configDir, { recursive: true }); if (initialConfig) { await fs.writeFile( path.join(configDir, "opencode.json"), `${JSON.stringify(initialConfig, null, 2)}\n`, "utf8", ); } return root; } describe("prepareOpenCodeRuntimeConfig", () => { it("injects an external_directory allow rule by default", async () => { const configHome = await makeConfigHome({ permission: { read: "allow", }, theme: "system", }); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); expect(prepared.env.XDG_CONFIG_HOME).not.toBe(configHome); const runtimeConfig = JSON.parse( await fs.readFile( path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8", ), ) as Record; expect(runtimeConfig).toMatchObject({ theme: "system", permission: { read: "allow", external_directory: "allow", }, }); await prepared.cleanup(); cleanupPaths.delete(prepared.env.XDG_CONFIG_HOME); await expect(fs.access(prepared.env.XDG_CONFIG_HOME)).rejects.toThrow(); }); it("merges custom providers from PAPERCLIP_OPENCODE_PROVIDERS into the config", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const providers = { bifrost: { npm: "@ai-sdk/openai-compatible", name: "Bifrost EU", options: { baseURL: "http://gateway.example.svc.cluster.local:8080/v1", apiKey: "{env:ANTHROPIC_API_KEY}", }, models: { "example/model-a": { name: "Model A" } }, }, }; const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers), }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; expect(runtimeConfig).toMatchObject({ permission: { read: "allow", external_directory: "allow" }, provider: providers, }); expect(prepared.notes.some((n) => n.includes("bifrost"))).toBe(true); await prepared.cleanup(); }); it("reads PAPERCLIP_OPENCODE_PROVIDERS from process.env when absent from the run env", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const providers = { bifrost: { npm: "@ai-sdk/openai-compatible", models: { "example/model-a": {} } } }; process.env.PAPERCLIP_OPENCODE_PROVIDERS = JSON.stringify(providers); try { const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; expect(runtimeConfig).toMatchObject({ provider: providers }); await prepared.cleanup(); } finally { delete process.env.PAPERCLIP_OPENCODE_PROVIDERS; } }); it("expands {env:VAR} placeholders in custom providers using the run/process env (bakes the literal vk)", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const providers = { bifrost: { npm: "@ai-sdk/openai-compatible", options: { baseURL: "http://bifrost/v1", apiKey: "{env:ANTHROPIC_API_KEY}" }, models: { "example/model-a": {} }, }, }; const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers), ANTHROPIC_API_KEY: "sk-bf-REALVK" }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as { provider: { bifrost: { options: { apiKey: string } } } }; // The {env:...} placeholder must be replaced with the literal value, so OpenCode // does not depend on its sandboxed process env carrying the key. expect(runtimeConfig.provider.bifrost.options.apiKey).toBe("sk-bf-REALVK"); await prepared.cleanup(); }); it("leaves an unresolvable {env:VAR} placeholder intact", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const providers = { bifrost: { options: { apiKey: "{env:DEFINITELY_UNSET_VAR_XYZ}" }, models: { "x/y": {} } } }; const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify(providers) }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as { provider: { bifrost: { options: { apiKey: string } } } }; expect(runtimeConfig.provider.bifrost.options.apiKey).toBe("{env:DEFINITELY_UNSET_VAR_XYZ}"); await prepared.cleanup(); }); it("pins small_model from PAPERCLIP_OPENCODE_SMALL_MODEL", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_SMALL_MODEL: "example/model-a" }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as { small_model?: string }; expect(runtimeConfig.small_model).toBe("example/model-a"); await prepared.cleanup(); }); it("ignores malformed PAPERCLIP_OPENCODE_PROVIDERS without writing a provider block and surfaces a note", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "not json" }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; expect(runtimeConfig.provider).toBeUndefined(); expect(prepared.notes).toContain( "PAPERCLIP_OPENCODE_PROVIDERS contains invalid JSON; custom providers ignored.", ); await prepared.cleanup(); }); it("surfaces a note when PAPERCLIP_OPENCODE_PROVIDERS is valid JSON but not an object", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: "[1,2,3]" }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; expect(runtimeConfig.provider).toBeUndefined(); expect(prepared.notes).toContain( "PAPERCLIP_OPENCODE_PROVIDERS is set but is not a JSON object; custom providers ignored.", ); await prepared.cleanup(); }); it("surfaces skipped provider entries with non-object values and keeps the usable ones", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify({ bifrost: "http://gateway.example/v1", usable: { options: { baseURL: "http://gateway.example/v1" } }, }), }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as { provider?: Record }; expect(runtimeConfig.provider?.usable).toBeDefined(); expect(runtimeConfig.provider?.bifrost).toBeUndefined(); expect(prepared.notes).toContain( "PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: bifrost.", ); await prepared.cleanup(); }); it("surfaces skipped provider entries when no usable entries remain", async () => { const configHome = await makeConfigHome({ permission: { read: "allow" } }); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome, PAPERCLIP_OPENCODE_PROVIDERS: JSON.stringify({ bifrost: "http://gateway.example/v1" }), }, config: {}, }); cleanupPaths.add(prepared.env.XDG_CONFIG_HOME); const runtimeConfig = JSON.parse( await fs.readFile(path.join(prepared.env.XDG_CONFIG_HOME, "opencode", "opencode.json"), "utf8"), ) as Record; expect(runtimeConfig.provider).toBeUndefined(); expect(prepared.notes).toContain( "PAPERCLIP_OPENCODE_PROVIDERS: skipped provider(s) with non-object values: bifrost.", ); await prepared.cleanup(); }); it("respects explicit opt-out", async () => { const configHome = await makeConfigHome(); const prepared = await prepareOpenCodeRuntimeConfig({ env: { XDG_CONFIG_HOME: configHome }, config: { dangerouslySkipPermissions: false }, }); expect(prepared.env).toEqual({ XDG_CONFIG_HOME: configHome }); expect(prepared.notes).toEqual([]); await prepared.cleanup(); }); });