Skip to content
Merged
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
5 changes: 4 additions & 1 deletion packages/core/src/messaging/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,10 @@ export function presetList(config?: ConfigMessaging.Info): ReadonlyArray<Preset>

/** Constant value with config overrides applied; custom variables are resolved by the caller. */
export function constant(name: string, config?: ConfigMessaging.Info) {
return config?.vars?.[name] ?? constants[name]?.value
// Own-property lookups only (#793): plain indexing reads through the prototype chain, so
// "constructor" / "__proto__" resolve to inherited non-string values when any vars exist.
const configured = config?.vars && Object.hasOwn(config.vars, name) ? config.vars[name] : undefined
return configured ?? (Object.hasOwn(constants, name) ? constants[name]?.value : undefined)
}

export interface Classification {
Expand Down
41 changes: 41 additions & 0 deletions packages/core/test/messaging-template.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect } from "effect"
import { MessagingTemplate } from "@opencode/core/messaging/template"
import { ConfigMessaging } from "@opencode/schema/config/messaging"

const failure = async (template: string, vars: Record<string, string>) => {
const exit = await Effect.runPromiseExit(
Expand Down Expand Up @@ -99,3 +100,43 @@ describe("MessagingTemplate.parseTime bare epochs", () => {
expect(MessagingTemplate.parseTime("2h", now)).toBe(now - 2 * 3_600_000)
})
})

describe("MessagingTemplate.constant own-property lookups (#793)", () => {
const config = new ConfigMessaging.Info({ vars: { custom: "configured" } })

test("prototype names resolve to undefined, not inherited values", () => {
expect(MessagingTemplate.constant("constructor", config)).toBeUndefined()
expect(MessagingTemplate.constant("__proto__", config)).toBeUndefined()
expect(MessagingTemplate.constant("toString", config)).toBeUndefined()
expect(MessagingTemplate.constant("hasOwnProperty", config)).toBeUndefined()
expect(MessagingTemplate.constant("constructor")).toBeUndefined()
expect(MessagingTemplate.constant("__proto__")).toBeUndefined()
})

test("configured vars and built-in constants still resolve, config first", () => {
expect(MessagingTemplate.constant("custom", config)).toBe("configured")
expect(MessagingTemplate.constant("separator", config)).toBe("/")
expect(MessagingTemplate.constant("separator", new ConfigMessaging.Info({ vars: { separator: ":" } }))).toBe(":")
expect(MessagingTemplate.constant("unknown", config)).toBeUndefined()
})

test("rendering a prototype name with vars configured reports an unknown variable, not a TypeError", async () => {
// The resolver mirrors MessagingVars.resolve: constant() is the configured/built-in lookup.
const exit = await Effect.runPromiseExit(
MessagingTemplate.render(
"{constructor}",
(name) => {
const value = MessagingTemplate.constant(name, config)
return Effect.succeed(value === undefined ? undefined : { value, template: true })
},
{ strict: true },
),
)
expect(exit._tag).toBe("Failure")
if (exit._tag !== "Failure") return
const error = Cause.squash(exit.cause)
expect(error).toBeInstanceOf(MessagingTemplate.TemplateError)
expect(error instanceof Error ? error.message : String(error)).toContain("Unknown variable")
expect(error instanceof Error ? error.message : String(error)).toContain("{constructor}")
})
})
Loading