diff --git a/packages/opencode/src/config/markdown.ts b/packages/opencode/src/config/markdown.ts index f20842c41a9..6698435343b 100644 --- a/packages/opencode/src/config/markdown.ts +++ b/packages/opencode/src/config/markdown.ts @@ -14,11 +14,30 @@ export namespace ConfigMarkdown { return Array.from(template.matchAll(SHELL_REGEX)) } + // Perform {env:VAR} interpolation on frontmatter data only + function interpolateEnvironmentVariables(obj: any): any { + if (typeof obj === "string") { + return obj.replace(/\{env:([^}]+)\}/g, (_, varName) => { + return process.env[varName] || "" + }) + } else if (Array.isArray(obj)) { + return obj.map(interpolateEnvironmentVariables) + } else if (obj && typeof obj === "object") { + const result: any = {} + for (const [key, value] of Object.entries(obj)) { + result[key] = interpolateEnvironmentVariables(value) + } + return result + } + return obj + } + export async function parse(filePath: string) { const template = await Bun.file(filePath).text() try { const md = matter(template) + md.data = interpolateEnvironmentVariables(md.data) return md } catch (err) { throw new FrontmatterError( diff --git a/packages/opencode/test/config/markdown.test.ts b/packages/opencode/test/config/markdown.test.ts index 392ca3911be..bd1fc88fe3c 100644 --- a/packages/opencode/test/config/markdown.test.ts +++ b/packages/opencode/test/config/markdown.test.ts @@ -87,3 +87,88 @@ test("should not match email addresses", () => { const emailMatches = ConfigMarkdown.files(emailTest) expect(emailMatches.length).toBe(0) }) + +// Helper function to reduce test code duplication +async function parseMarkdownWithEnv(markdown: string) { + const tempFile = `/tmp/test-agent-${Date.now()}.md` + await Bun.write(tempFile, markdown) + try { + return await ConfigMarkdown.parse(tempFile) + } finally { + await Bun.file(tempFile).delete() + } +} + +// Tests for {env:VAR} interpolation in frontmatter +test("should interpolate {env:VAR} in frontmatter", async () => { + process.env.TEST_MODEL = "gpt-4" + process.env.TEST_DESCRIPTION = "Test agent description" + + const markdownWithEnv = `--- +description: "{env:TEST_DESCRIPTION}" +model: "{env:TEST_MODEL}" +mode: primary +--- + +# Agent Content + +This is the agent content.` + + const result = await parseMarkdownWithEnv(markdownWithEnv) + + expect(result.data.description).toBe("Test agent description") + expect(result.data.model).toBe("gpt-4") + expect(result.data.mode).toBe("primary") + expect(result.content).toContain("Agent Content") +}) + +test("should handle missing environment variables gracefully", async () => { + delete process.env.NONEXISTENT_VAR + + const markdownWithMissingEnv = `--- +description: "Description with {env:NONEXISTENT_VAR} missing" +model: "gpt-3.5-turbo" +--- + +# Agent Content` + + const result = await parseMarkdownWithEnv(markdownWithMissingEnv) + + expect(result.data.description).toBe("Description with missing") + expect(result.data.model).toBe("gpt-3.5-turbo") +}) + +test("should interpolate multiple environment variables in same field", async () => { + process.env.PREFIX = "AI" + process.env.SUFFIX = "Assistant" + + const markdownWithMultipleEnv = `--- +description: "{env:PREFIX} {env:SUFFIX}" +model: "gpt-4" +--- + +# Agent Content` + + const result = await parseMarkdownWithEnv(markdownWithMultipleEnv) + + expect(result.data.description).toBe("AI Assistant") + expect(result.data.model).toBe("gpt-4") +}) + +test("should not interpolate {env:VAR} in markdown body content", async () => { + process.env.BODY_VAR = "should not appear" + + const markdownWithEnvInBody = `--- +description: "Test agent" +model: "gpt-4" +--- + +# Agent Content + +This should not interpolate: {env:BODY_VAR}` + + const result = await parseMarkdownWithEnv(markdownWithEnvInBody) + + expect(result.data.description).toBe("Test agent") + expect(result.content).toContain("{env:BODY_VAR}") +})