Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 109 additions & 31 deletions packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -850,48 +850,122 @@ export namespace Config {
return load(text, filepath)
}

async function load(text: string, configFilepath: string) {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
async function expandTemplates(value: unknown, configDir: string, configFilepath: string): Promise<void> {
// First pass: collect all file references
const fileRefs = new Map<string, string>() // match -> resolvedPath
collectFileReferences(value, configDir, fileRefs)

// Read all files in parallel
const fileContents = new Map<string, string>()
await Promise.all(
Array.from(fileRefs.entries()).map(async ([match, resolvedPath]) => {
const content = await Bun.file(resolvedPath)
.text()
.catch((error) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configFilepath,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
})
fileContents.set(match, content.trim())
}),
)

const fileMatches = text.match(/\{file:[^}]+\}/g)
if (fileMatches) {
const configDir = path.dirname(configFilepath)
const lines = text.split("\n")
// Second pass: expand templates in-place
replaceTemplates(value, fileContents)
}

for (const match of fileMatches) {
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
continue // Skip if line is commented
function collectFileReferences(value: unknown, configDir: string, fileRefs: Map<string, string>): void {
if (Array.isArray(value)) {
for (const item of value) {
if (typeof item === "string") {
extractFileReferences(item, configDir, fileRefs)
} else {
collectFileReferences(item, configDir, fileRefs)
}
}
return
}

if (value && typeof value === "object") {
const obj = value as Record<string, unknown>
for (const key in obj) {
const val = obj[key]
if (typeof val === "string") {
extractFileReferences(val, configDir, fileRefs)
} else {
collectFileReferences(val, configDir, fileRefs)
}
}
return
}
}

function extractFileReferences(str: string, configDir: string, fileRefs: Map<string, string>): void {
const fileMatches = str.match(/\{file:[^}]+\}/g)
if (fileMatches) {
for (const match of fileMatches) {
if (fileRefs.has(match)) continue

let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Bun.file(resolvedPath)
.text()
.catch((error) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configFilepath,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configFilepath, message: errMsg }, { cause: error })
})
).trim()
// escape newlines/quotes, strip outer quotes
text = text.replace(match, JSON.stringify(fileContent).slice(1, -1))
fileRefs.set(match, resolvedPath)
}
}
}

function replaceTemplates(value: unknown, fileContents: Map<string, string>): void {
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
if (typeof value[i] === "string") {
value[i] = expandString(value[i], fileContents)
} else {
replaceTemplates(value[i], fileContents)
}
}
return
}

if (value && typeof value === "object") {
const obj = value as Record<string, unknown>
for (const key in obj) {
const val = obj[key]
if (typeof val === "string") {
obj[key] = expandString(val, fileContents)
} else {
replaceTemplates(val, fileContents)
}
}
return
}
}

function expandString(str: string, fileContents: Map<string, string>): string {
// Expand {env:VAR} templates
str = str.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})

// Expand {file:path} templates
for (const [match, content] of fileContents) {
str = str.replaceAll(match, content)
}

return str
}

async function load(text: string, configFilepath: string) {
// Parse JSONC first, letting the parser handle comments correctly
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
Expand All @@ -916,6 +990,10 @@ export namespace Config {
})
}

// Expand templates in the parsed object tree
const configDir = path.dirname(configFilepath)
await expandTemplates(data, configDir, configFilepath)

const parsed = Info.safeParse(data)
if (parsed.success) {
if (!parsed.data.$schema) {
Expand Down
99 changes: 99 additions & 0 deletions packages/opencode/test/config/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,105 @@ test("handles file inclusion substitution", async () => {
})
})

test("reproduces shadowing bug: file reference fails when same path appears in comment", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const promptsDir = path.join(dir, "prompts")
await fs.mkdir(promptsDir, { recursive: true })
await Bun.write(path.join(promptsDir, "precise.md"), "This is the precise prompt.")

// Write JSONC with comment containing same file reference
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
"$schema": "https://opencode.ai/config.json",
"agent": {
// "experimental": {
// "prompt": "{file:./prompts/precise.md}"
// },
"precise": {
"prompt": "{file:./prompts/precise.md}"
}
}
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
// BUG: The prompt remains as "{file:./prompts/precise.md}" instead of being expanded
// because findIndex finds the commented line first
expect(config.agent?.["precise"]?.prompt).toBe("This is the precise prompt.")
},
})
})

test("handles multiple agents with different file references", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const promptsDir = path.join(dir, "prompts")
await fs.mkdir(promptsDir, { recursive: true })
await Bun.write(path.join(promptsDir, "prompt-a.md"), "Prompt A content")
await Bun.write(path.join(promptsDir, "prompt-b.md"), "Prompt B content")
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
agent_a: {
prompt: "{file:./prompts/prompt-a.md}",
},
agent_b: {
prompt: "{file:./prompts/prompt-b.md}",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.agent?.["agent_a"]?.prompt).toBe("Prompt A content")
expect(config.agent?.["agent_b"]?.prompt).toBe("Prompt B content")
},
})
})

test("throws error when file inclusion reference fails", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
agent: {
test: {
prompt: "{file:./prompts/precise.md}",
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
try {
await Config.get()
expect.unreachable()
} catch (error: any) {
expect(error.name).toBe("ConfigInvalidError")
expect(error.data.message).toMatch(/bad file reference/)
expect(error.data.message).toMatch(/precise\.md does not exist/)
}
},
})
})

test("validates config schema and throws on invalid fields", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
Expand Down