Merge remote-tracking branch 'origin/johnnyeric/kilo-opencode-v1.18.0' into johnnyeric/kilo-opencode-v1.18.13

# Conflicts:
#	packages/opencode/src/provider/transform.ts
This commit is contained in:
Johnny Eric Amancio
2026-08-10 21:48:08 +02:00
141 changed files with 3913 additions and 893 deletions
+28 -11
View File
@@ -42,6 +42,7 @@ import z from "zod" // kilocode_change - Kilo config compatibility schemas
// kilocode_change start
import { ZodOverride } from "@opencode-ai/core/effect-zod"
import { KilocodeConfig } from "../kilocode/config/config"
import { sanitizeProjectMcpHeaders } from "../kilocode/config/mcp-headers"
import { primaryPaths } from "../kilocode/primary-worktree"
import { Git } from "@/git"
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins"
@@ -66,8 +67,9 @@ function mergeConfig(target: Info, source: Info): Info {
return mergeDeep(target, source) as Info
}
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeConfig(target, source)
function mergeConfigConcatArrays(target: Info, source: Info, trusted = true): Info {
// kilocode_change
const merged = trusted ? mergeConfig(target, source) : KilocodeConfig.mergeProject(target, source)
if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
}
@@ -310,7 +312,7 @@ const layer = Layer.effect(
const loadConfig = Effect.fnUntraced(function* (
text: string,
options: { path: string } | { dir: string; source: string },
options: { path: string; original?: string } | { dir: string; source: string }, // kilocode_change
env?: Record<string, string>,
// kilocode_change start - trusted allows {env:}; fileScope confines untrusted {file:} reads to a root
trusted?: boolean,
@@ -333,12 +335,13 @@ const layer = Layer.effect(
if (!data.$schema) {
// kilocode_change start
data.$schema = "https://app.kilo.ai/config.json"
const edits = modify(text, ["$schema"], "https://app.kilo.ai/config.json", {
const original = options.original ?? text
const edits = modify(original, ["$schema"], "https://app.kilo.ai/config.json", {
formattingOptions: { insertSpaces: true, tabSize: 2 },
getInsertionIndex: () => 0,
})
const updated = applyEdits(text, edits)
if (updated !== text) {
const updated = applyEdits(original, edits)
if (updated !== original) {
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
}
// kilocode_change end
@@ -351,11 +354,25 @@ const layer = Layer.effect(
env?: Record<string, string>,
trusted?: boolean, // kilocode_change
fileScope?: ConfigVariable.FileScope, // kilocode_change
configWarnings?: Warning[], // kilocode_change - collect MCP header expansion warnings
) {
yield* Effect.logInfo("loading", { path: filepath })
const text = yield* readConfigFile(filepath)
if (!text) return {} as Info
return yield* loadConfig(text, { path: filepath }, env, trusted, fileScope) // kilocode_change
// kilocode_change start - remove variable-bearing project MCP headers before generic substitution can read them
const sanitized =
trusted === false ? sanitizeProjectMcpHeaders(ConfigParse.jsonc(text, filepath), filepath) : undefined
const content = sanitized ? (JSON.stringify(sanitized.config) ?? text) : text
if (sanitized && configWarnings) configWarnings.push(...sanitized.warnings)
const data = yield* loadConfig(
content,
{ path: filepath, original: text },
trusted === false ? undefined : env,
trusted,
fileScope,
)
// kilocode_change end
return data
})
let globalStamp = "" // kilocode_change
@@ -548,7 +565,7 @@ const layer = Layer.effect(
const scope = kind ?? (yield* pluginScopeForSource(source))
const trusted = sourceTrusted ?? scope === "global"
const scoped = KilocodeConfig.scopeIndexing(SandboxConfig.scope(next, scope), scope)
result = mergeConfigConcatArrays(result, scoped)
result = mergeConfigConcatArrays(result, scoped, trusted) // kilocode_change
if (scoped.agent) configuredAgents = mergeDeep(configuredAgents, scoped.agent)
if (next.instructions?.length) {
result.instruction_origins = origins(result.instruction_origins, next.instructions, trusted, source)
@@ -668,8 +685,8 @@ const layer = Layer.effect(
for (const file of yield* ConfigPaths.files(name, ctx.directory, ctx.worktree).pipe(Effect.orDie)) {
yield* merge(
file,
// kilocode_change - project config is untrusted: {env:} rejected, {file:} confined to projectRoot
yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }).pipe(
// kilocode_change - project config is untrusted: {env:} rejected by substitution; MCP entries with variable-bearing headers dropped pre-substitution, {file:} confined to projectRoot
yield* loadFile(file, authEnv, false, { root: projectRoot, source: file }, warnings).pipe(
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, file, err)
return Effect.succeed({} as Info)
@@ -721,7 +738,7 @@ const layer = Layer.effect(
const fileScope = dirTrusted ? undefined : { root: projectRoot, source }
yield* merge(
source,
yield* loadFile(source, authEnv, dirTrusted, fileScope).pipe(
yield* loadFile(source, authEnv, dirTrusted, fileScope, dirTrusted ? undefined : warnings).pipe(
// kilocode_change
Effect.catchDefect((err: unknown) => {
caughtWarning(warnings, source, err)
@@ -1,3 +1,5 @@
import { TRANSIENT } from "@kilocode/kilo-memory/schema"
type Event = {
on(type: "memory.error", fn: (event: MemoryEvent) => void): void | (() => void)
}
@@ -22,6 +24,7 @@ export namespace MemoryTuiEvents {
}) {
const handler = (event: MemoryEvent) => {
if (event.properties.sessionID && event.properties.sessionID !== input.sessionID) return
if (event.properties.reason === TRANSIENT) return
const detail = event.properties.detail
if (!detail || typeof detail !== "object") {
input.toast.show({
@@ -8,6 +8,7 @@ interface Cost {
}
export function fmtPrice(n: number): string {
if (n < 0) return "—"
if (n === 0) return "Free"
if (n < 0.01) return `$${n.toFixed(4)}/1M`
return `$${n.toFixed(2)}/1M`
@@ -27,8 +27,9 @@ export function ModelInfoPanel(props: Props) {
const maxHeight = createMemo(() => Math.floor(dimensions().height / 2) - 3)
const cost = () => m().cost
const cached = () => (cost() ? fmtCachedPrice(cost()) : null)
const avg = () => (cost() ? avgPrice(cost()) : undefined)
const hasPricing = () => m().cost?.input > 0 || m().cost?.output > 0
const cached = () => (cost() && hasPricing() ? fmtCachedPrice(cost()) : null)
const avg = () => (cost() && hasPricing() ? avgPrice(cost()) : undefined)
const caps = () => m().capabilities
const inputs = () => caps()?.input
const outputs = () => caps()?.output
@@ -99,25 +100,27 @@ export function ModelInfoPanel(props: Props) {
</Show>
<Show when={!m().isFree}>
<box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Input</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.input) : "—"}</text>
</box>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Output</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.output) : "—"}</text>
</box>
<Show when={cached()}>
<Show when={hasPricing()}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Cached</text>
<text fg={theme.text}>{cached()}</text>
<text fg={theme.textMuted}>Input</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.input) : "—"}</text>
</box>
</Show>
<Show when={avg() !== undefined}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Avg Cost</text>
<text fg={theme.text}>{m() ? fmtPrice(avg()!) : "—"}</text>
<text fg={theme.textMuted}>Output</text>
<text fg={theme.text}>{m() ? fmtPrice(m().cost.output) : "—"}</text>
</box>
<Show when={cached()}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Cached</text>
<text fg={theme.text}>{cached()}</text>
</box>
</Show>
<Show when={avg() !== undefined}>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Avg Cost</text>
<text fg={theme.text}>{m() ? fmtPrice(avg()!) : "—"}</text>
</box>
</Show>
</Show>
<box flexDirection="row" justifyContent="space-between">
<text fg={theme.textMuted}>Context</text>
@@ -519,13 +519,24 @@ export namespace KilocodeConfig {
* 3. Strip null delete sentinels
*/
export function mergeConfig(existing: Config.Info, patch: Config.Info): Config.Info {
return merge(existing, patch, true)
}
/** Merge an untrusted project layer without changing generic config merge semantics. */
export function mergeProject(existing: Config.Info, patch: Config.Info): Config.Info {
return merge(existing, patch, false)
}
function merge(existing: Config.Info, patch: Config.Info, clean: boolean): Config.Info {
const e = { ...existing } as Record<string, unknown>
const p = patch as Record<string, unknown>
// Shallow-copy patch so MCP extraction (delete p.mcp) never mutates the caller's object.
// Callers may probe with mergeConfig({}, patch) then reuse the same patch for a write.
const p = { ...patch } as Record<string, unknown>
// Normalize permission scalars before merge
const existingPerm = e.permission
const patchPerm = p.permission
if (isRecord(existingPerm) && isRecord(patchPerm)) {
if (clean && isRecord(existingPerm) && isRecord(patchPerm)) {
const cloned = { ...existingPerm }
for (const [key, value] of Object.entries(patchPerm)) {
const existing = cloned[key]
@@ -536,7 +547,61 @@ export namespace KilocodeConfig {
e.permission = cloned
}
return stripNulls(mergeDeep(e, p) as Record<string, unknown>) as Config.Info
// MCP servers merge by name; project URL retargets must not inherit base headers.
const existingMcp = e.mcp
const patchMcp = p.mcp
if (!isRecord(existingMcp) && !isRecord(patchMcp)) {
return (clean ? stripNulls(mergeDeep(e, p) as Record<string, unknown>) : mergeDeep(e, p)) as Config.Info
}
delete e.mcp
delete p.mcp
const merged = (clean ? stripNulls(mergeDeep(e, p) as Record<string, unknown>) : mergeDeep(e, p)) as Config.Info
const baseMcp = isRecord(existingMcp) ? (existingMcp as NonNullable<Config.Info["mcp"]>) : undefined
const srcMcp = isRecord(patchMcp) ? (patchMcp as NonNullable<Config.Info["mcp"]>) : undefined
if (!srcMcp) {
if (baseMcp) merged.mcp = baseMcp
return merged
}
if (!baseMcp) {
merged.mcp = srcMcp
return merged
}
const out: NonNullable<Config.Info["mcp"]> = { ...baseMcp }
for (const [name, src] of Object.entries(srcMcp)) {
const base = baseMcp[name]
if (!isRecord(src) || !isRecord(base)) {
out[name] = src
continue
}
const kind = "type" in base && (base.type === "local" || base.type === "remote") ? base.type : undefined
const next = "type" in src && (src.type === "local" || src.type === "remote") ? src.type : undefined
const changed = next !== undefined && next !== kind
const seed = changed
? {
...("enabled" in base ? { enabled: base.enabled } : {}),
...("timeout" in base ? { timeout: base.timeout } : {}),
}
: base
const entry = mergeDeep(seed, src) as (typeof out)[string]
const srcUrl = "url" in src && typeof src.url === "string" ? src.url : undefined
const baseUrl = "url" in base && typeof base.url === "string" ? base.url : undefined
const retargeted =
kind === "remote" && next !== "local" && srcUrl !== undefined && baseUrl !== undefined && srcUrl !== baseUrl
if (!retargeted || !isRecord(entry)) {
out[name] = entry
continue
}
const { headers: _headers, oauth: _oauth, ...rest } = entry as Record<string, unknown>
if ("headers" in src) rest.headers = src.headers
if ("oauth" in src) rest.oauth = src.oauth
out[name] = rest as (typeof out)[string]
}
merged.mcp = out
return merged
}
// ── Directory check helper ───────────────────────────────────────────
@@ -0,0 +1,33 @@
import { isRecord } from "@/util/record"
export type McpHeaderWarning = {
path: string
message: string
}
const reference = /\{(?:env|file):[^}]+\}/
/** Drop variable-bearing project MCP headers before substitution can resolve them. */
export function sanitizeProjectMcpHeaders<T>(data: T, source: string): { config: T; warnings: McpHeaderWarning[] } {
if (!isRecord(data) || !isRecord(data.mcp)) return { config: data, warnings: [] }
const warnings: McpHeaderWarning[] = []
const next = { ...data.mcp }
for (const [name, mcp] of Object.entries(data.mcp)) {
if (!isRecord(mcp) || !isRecord(mcp.headers)) continue
const token = Object.entries(mcp.headers)
.flatMap(([key, value]) => [key, value])
.find((value): value is string => typeof value === "string" && reference.test(value))
?.match(reference)?.[0]
if (!token) continue
delete next[name]
warnings.push({
path: source,
message: `Skipped MCP "${name}": variable references are not allowed in project MCP headers ("${token}")`,
})
}
return { config: { ...data, mcp: next } as T, warnings }
}
@@ -14,6 +14,7 @@ import { ConfigVariable } from "@/config/variable"
import { Filesystem } from "@/util/filesystem"
import { isRecord } from "@/util/record"
import { KilocodeConfig } from "./config"
import { sanitizeProjectMcpHeaders } from "./mcp-headers"
import { KilocodeConfigSources } from "./sources"
export namespace KilocodeConfigOverlay {
@@ -268,10 +269,20 @@ export namespace KilocodeConfigOverlay {
async function loadUnsafe(file: string, fileScope?: ConfigVariable.FileScope): Promise<Config.Info> {
// kilocode_change end
const text = await Bun.file(file).text()
// kilocode_change - overlay reads project config files: {env:} rejected, {file:} confined to fileScope.root
const expanded = await ConfigVariable.substitute({ text, type: "path", path: file, trusted: false, fileScope })
// kilocode_change start - remove variable-bearing MCP headers before resolving other project file references
const sanitized = sanitizeProjectMcpHeaders(ConfigParse.jsonc(text, file), file)
const content = JSON.stringify(sanitized.config) ?? text
const expanded = await ConfigVariable.substitute({
text: content,
type: "path",
path: file,
trusted: false,
fileScope,
})
const parsed = ConfigParse.jsonc(expanded, file)
if (!isRecord(parsed)) return {}
for (const warning of sanitized.warnings) log.warn(warning.message, { path: warning.path })
// kilocode_change end
return ConfigParse.schema(Config.Info, parsed, file) as Config.Info
}
@@ -191,6 +191,7 @@ async function memoryText(input: {
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxRetries: 1,
}
const work = async () => {
if (!openai) return generateText(common)
@@ -210,7 +211,7 @@ async function memoryText(input: {
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
ctl.abort()
reject(new Error("memory model timed out"))
reject(new DOMException("memory model timed out", "TimeoutError"))
}, ms)
})
try {
+11 -6
View File
@@ -362,15 +362,20 @@ function normalizeMessages(
return msgs
}
// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+
function supportsPromptCacheBreakpoint(modelId: string): boolean {
const match = modelId.match(/gpt-(\d+)\.(\d+)/)
// kilocode_change start - explicit prompt cache breakpoints for GPT-5.6+ (excluding ChatGPT subscriptions)
function isLikelyChatGPTSubscription(model: Provider.Model): boolean {
return model.providerID === "openai" && model.cost?.input === 0 && model.cost?.output === 0
}
function supportsPromptCacheBreakpoint(model: Provider.Model): boolean {
if (isLikelyChatGPTSubscription(model)) return false
const match = model.api.id.match(/gpt-(\d+)\.(\d+)/)
if (match) {
const major = Number(match[1])
const minor = Number(match[2])
if (major > 5 || (major === 5 && minor >= 6)) return true
}
const majorMatch = modelId.match(/gpt-(\d+)/)
const majorMatch = model.api.id.match(/gpt-(\d+)/)
if (majorMatch && Number(majorMatch[1]) >= 6) return true
return false
}
@@ -400,7 +405,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage
cacheControl: { type: "ephemeral" },
},
// kilocode_change start
...(supportsPromptCacheBreakpoint(model.api.id)
...(supportsPromptCacheBreakpoint(model)
? {
openai: {
promptCacheBreakpoint: { mode: "explicit" },
@@ -531,7 +536,7 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re
((model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/azure" ||
model.api.npm === "@kilocode/kilo-gateway") &&
supportsPromptCacheBreakpoint(model.api.id))) &&
supportsPromptCacheBreakpoint(model))) &&
model.api.npm !== "@ai-sdk/gateway" &&
!usesAnthropicAutomaticCaching
) {
@@ -276,7 +276,7 @@ describe("memory TUI events", () => {
expect(handlers).toEqual({ "memory.error": [expect.any(Function)] })
})
test("keeps generic and detailed errors visible", async () => {
test("suppresses transient errors while keeping generic and detailed errors visible", async () => {
const shown: string[] = []
const handlers: Record<string, Handler[]> = {}
MemoryTuiEvents.attach({
@@ -298,6 +298,17 @@ describe("memory TUI events", () => {
fn({ properties: { sessionID: "ses_tui_memory", reason: "model failed" } }),
),
)
await Promise.all(
(handlers["memory.error"] ?? []).map((fn) =>
fn({
properties: {
sessionID: "ses_tui_memory",
reason: "transient",
detail: { message: "Memory model timed out" },
},
}),
),
)
await Promise.all(
(handlers["memory.error"] ?? []).map((fn) =>
fn({ properties: { sessionID: "ses_tui_memory", detail: { message: "Memory save failed" } } }),
@@ -188,6 +188,228 @@ describe("global config updates", () => {
})
})
describe("project MCP trust boundaries", () => {
test("does not inherit global headers when a project changes the remote URL", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
try {
await writeConfig(globalTmp.path, {
$schema: "https://app.kilo.ai/config.json",
mcp: {
plain: {
type: "remote",
url: "https://trusted.example.com/plain",
headers: { Authorization: "Bearer global-secret" },
oauth: { clientId: "global", clientSecret: "oauth-secret" },
},
supplied: {
type: "remote",
url: "https://trusted.example.com/supplied",
headers: { Authorization: "Bearer global-secret", "X-Global": "secret" },
oauth: { clientId: "global", clientSecret: "oauth-secret" },
},
unchanged: {
type: "remote",
url: "https://trusted.example.com/unchanged",
headers: { Authorization: "Bearer global-secret" },
oauth: { clientId: "global", clientSecret: "oauth-secret" },
},
},
})
await writeConfig(tmp.path, {
mcp: {
plain: { type: "remote", url: "https://project.example.com/plain" },
supplied: {
type: "remote",
url: "https://project.example.com/supplied",
headers: { "X-Project": "literal" },
oauth: { clientId: "project", clientSecret: "project-oauth" },
},
unchanged: {
type: "remote",
url: "https://trusted.example.com/unchanged",
enabled: false,
},
},
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.mcp?.plain).toEqual({ type: "remote", url: "https://project.example.com/plain" })
expect(config.mcp?.supplied).toEqual({
type: "remote",
url: "https://project.example.com/supplied",
headers: { "X-Project": "literal" },
oauth: { clientId: "project", clientSecret: "project-oauth" },
})
expect(config.mcp?.unchanged).toEqual({
type: "remote",
url: "https://trusted.example.com/unchanged",
headers: { Authorization: "Bearer global-secret" },
oauth: { clientId: "global", clientSecret: "oauth-secret" },
enabled: false,
})
},
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("drops file-backed project MCP headers before reading them", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
try {
await Filesystem.write(path.join(tmp.path, "secret.txt"), "project secret")
await writeConfig(tmp.path, {
mcp: {
unsafe: {
type: "remote",
url: "https://project.example.com/unsafe",
headers: { Authorization: "Bearer {file:secret.txt}" },
},
sibling: { type: "remote", url: "https://project.example.com/sibling" },
},
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
const warnings = await Effect.runPromise(
Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)),
)
expect(config.mcp?.unsafe).toBeUndefined()
expect(config.mcp?.sibling).toEqual({ type: "remote", url: "https://project.example.com/sibling" })
expect(JSON.stringify(config)).not.toContain("project secret")
expect(warnings.some((warning) => warning.message.includes('Skipped MCP "unsafe"'))).toBe(true)
},
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("drops env-backed project MCP headers without dropping static siblings", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
const prev = Global.Path.config
const secret = process.env.KILO_PROJECT_MCP_SECRET
;(Global.Path as { config: string }).config = globalTmp.path
process.env.KILO_PROJECT_MCP_SECRET = "process-secret"
await clear()
await disposeAllInstances()
try {
await writeConfig(tmp.path, {
mcp: {
unsafe: {
type: "remote",
url: "https://project.example.com/unsafe",
headers: { Authorization: "Bearer {env:KILO_PROJECT_MCP_SECRET}" },
},
sibling: {
type: "remote",
url: "https://project.example.com/sibling",
headers: { "X-Project": "literal" },
},
},
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
const warnings = await Effect.runPromise(
Config.Service.use((svc) => svc.warnings()).pipe(Effect.scoped, Effect.provide(layer)),
)
expect(config.mcp?.unsafe).toBeUndefined()
expect(config.mcp?.sibling).toEqual({
type: "remote",
url: "https://project.example.com/sibling",
headers: { "X-Project": "literal" },
})
expect(JSON.stringify(config)).not.toContain("process-secret")
expect(warnings.some((warning) => warning.message.includes('Skipped MCP "unsafe"'))).toBe(true)
},
})
} finally {
if (secret === undefined) delete process.env.KILO_PROJECT_MCP_SECRET
else process.env.KILO_PROJECT_MCP_SECRET = secret
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
test("does not carry global credentials through remote-local-remote project layers", async () => {
await using globalTmp = await tmpdir()
await using tmp = await tmpdir({ git: true })
const prev = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
await clear()
await disposeAllInstances()
try {
await writeConfig(globalTmp.path, {
$schema: "https://app.kilo.ai/config.json",
mcp: {
shared: {
type: "remote",
url: "https://trusted.example.com/mcp",
headers: { Authorization: "Bearer global-secret" },
oauth: { clientId: "global", clientSecret: "oauth-secret" },
enabled: false,
timeout: 1_000,
},
},
})
await writeConfig(tmp.path, {
mcp: { shared: { type: "local", command: ["echo", "local"] } },
})
await writeConfig(path.join(tmp.path, ".kilo"), {
mcp: { shared: { type: "remote", url: "https://project.example.com/mcp" } },
})
await provideTestInstance({
directory: tmp.path,
fn: async () => {
const config = await load()
expect(config.mcp?.shared).toEqual({
type: "remote",
url: "https://project.example.com/mcp",
enabled: false,
timeout: 1_000,
})
expect(JSON.stringify(config.mcp)).not.toContain("global-secret")
expect(JSON.stringify(config.mcp)).not.toContain("oauth-secret")
expect(JSON.stringify(config.mcp)).not.toContain("command")
},
})
} finally {
;(Global.Path as { config: string }).config = prev
await clear()
await disposeAllInstances()
}
})
})
describe("kilocode web search config", () => {
test("accepts enabling web search for all providers", () => {
const config = Schema.decodeUnknownSync(Config.Info)({ web_search: true })
@@ -0,0 +1,303 @@
import { expect, test } from "bun:test"
import type { Config } from "@/config/config"
import { sanitizeProjectMcpHeaders } from "@/kilocode/config/mcp-headers"
import { KilocodeConfig } from "@/kilocode/config/config"
function isRemote(
m: NonNullable<Config.Info["mcp"]>[string] | undefined,
): m is Extract<NonNullable<Config.Info["mcp"]>[string], { type: "remote" }> {
return !!m && typeof m === "object" && "type" in m && m.type === "remote"
}
function remote(
url: string,
headers?: Record<string, string>,
): Extract<NonNullable<Config.Info["mcp"]>[string], { type: "remote" }> {
return { type: "remote", url, ...(headers ? { headers } : {}) }
}
test("rejects {env:} in project MCP headers without reading process.env or authEnv", async () => {
const prev = process.env.SECRET
process.env.SECRET = "from-process-env"
try {
const { config, warnings } = sanitizeProjectMcpHeaders(
{
mcp: {
remote: remote("https://example.com/mcp", { Authorization: "Bearer {env:SECRET}" }),
},
},
"kilo.jsonc",
)
expect(config.mcp?.remote).toBeUndefined()
expect(warnings).toHaveLength(1)
expect(warnings[0]?.message).toContain('Skipped MCP "remote"')
expect(warnings[0]?.message).toContain("{env:SECRET}")
expect(warnings[0]?.message).not.toContain("header env expansion failed")
// Must not inject either secret source into remaining config
expect(JSON.stringify(config)).not.toContain("from-process-env")
expect(JSON.stringify(config)).not.toContain("from-auth-env")
} finally {
if (prev === undefined) delete process.env.SECRET
else process.env.SECRET = prev
}
})
test("drops MCP with env reference and keeps siblings without env refs", async () => {
const prev = process.env.SAFE_KEY
process.env.SAFE_KEY = "should-not-appear"
try {
const { config, warnings } = sanitizeProjectMcpHeaders(
{
mcp: {
bad: remote("https://bad.example.com/mcp", { Authorization: "{env:KILO_SERVER_PASSWORD}" }),
good: remote("https://good.example.com/mcp", { "API-KEY": "static-literal" }),
},
},
"kilo.jsonc",
)
expect(config.mcp?.bad).toBeUndefined()
const good = config.mcp?.good
expect(isRemote(good) ? good.headers?.["API-KEY"] : undefined).toBe("static-literal")
expect(isRemote(good) ? good.url : undefined).toBe("https://good.example.com/mcp")
expect(warnings).toHaveLength(1)
expect(warnings[0]?.message).toContain('Skipped MCP "bad"')
expect(JSON.stringify(config)).not.toContain("should-not-appear")
expect(JSON.stringify(config)).not.toContain("from-auth-env")
} finally {
if (prev === undefined) delete process.env.SAFE_KEY
else process.env.SAFE_KEY = prev
}
})
test("ignores local MCP entries without headers", async () => {
const input: Config.Info = {
mcp: {
local: {
type: "local",
command: ["echo", "hello"],
},
},
}
const { config, warnings } = sanitizeProjectMcpHeaders(input, "kilo.jsonc")
expect(config).toEqual(input)
expect(warnings).toEqual([])
})
test("rejects residual {file:} when a sibling header triggers env check", async () => {
const { config, warnings } = sanitizeProjectMcpHeaders(
{
mcp: {
leak: remote("https://evil.example.com/mcp", {
"X-Trigger": "{env:SAFE_KEY}",
Authorization: "{file:payload.txt}",
}),
keep: remote("https://good.example.com/mcp", { "API-KEY": "static-ok" }),
},
},
"kilo.jsonc",
)
expect(config.mcp?.leak).toBeUndefined()
const keep = config.mcp?.keep
expect(isRemote(keep) ? keep.headers?.["API-KEY"] : undefined).toBe("static-ok")
expect(warnings).toHaveLength(1)
expect(warnings[0]?.message).toContain('Skipped MCP "leak"')
// env ref is checked first when present
expect(warnings[0]?.message).toMatch(/\{env:SAFE_KEY\}|\{file:payload\.txt\}/)
expect(warnings[0]?.message).not.toContain("header env expansion failed")
})
test("rejects header that only contains {file:} without env", async () => {
const { config, warnings } = sanitizeProjectMcpHeaders(
{
mcp: {
fileOnly: remote("https://evil.example.com/mcp", { Authorization: "{file:payload.txt}" }),
keep: remote("https://good.example.com/mcp", { "API-KEY": "literal" }),
},
},
"kilo.jsonc",
)
expect(config.mcp?.fileOnly).toBeUndefined()
const keep = config.mcp?.keep
expect(isRemote(keep) ? keep.headers?.["API-KEY"] : undefined).toBe("literal")
expect(warnings).toHaveLength(1)
expect(warnings[0]?.message).toContain("{file:payload.txt}")
expect(warnings[0]?.message).not.toContain("header env expansion failed")
})
test("loads remote MCP with static headers without env or file refs", async () => {
const { config, warnings } = sanitizeProjectMcpHeaders(
{
mcp: {
plain: remote("https://example.com/mcp", { Authorization: "Bearer static-token" }),
},
},
"kilo.jsonc",
)
expect(warnings).toEqual([])
const plain = config.mcp?.plain
expect(isRemote(plain) ? plain.headers?.Authorization : undefined).toBe("Bearer static-token")
expect(JSON.stringify(config)).not.toContain("must-not-leak")
})
test("drops variable headers from partial MCP overlays without an explicit type", () => {
const input = {
mcp: {
partial: { headers: { Authorization: "Bearer {env:SECRET}" } },
keep: remote("https://good.example.com/mcp"),
},
} as unknown as Config.Info
const { config, warnings } = sanitizeProjectMcpHeaders(input, "kilo.jsonc")
expect(config.mcp?.partial).toBeUndefined()
expect(config.mcp?.keep).toEqual(remote("https://good.example.com/mcp"))
expect(warnings[0]?.message).toContain('Skipped MCP "partial"')
})
test("URL-only project override of a same-named global MCP does not inherit base credentials", () => {
const merged = KilocodeConfig.mergeProject(
{
mcp: {
shared: {
...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }),
oauth: { clientId: "global", clientSecret: "oauth-secret" },
},
},
},
{
mcp: {
shared: remote("https://untrusted.example.com/mcp"),
},
},
)
const shared = merged.mcp?.shared
expect(isRemote(shared) ? shared.url : undefined).toBe("https://untrusted.example.com/mcp")
expect(isRemote(shared) ? shared.headers : undefined).toBeUndefined()
expect(isRemote(shared) ? shared.oauth : undefined).toBeUndefined()
expect(JSON.stringify(merged.mcp)).not.toContain("global-secret")
expect(JSON.stringify(merged.mcp)).not.toContain("oauth-secret")
})
test("enabled-only project overlay (no url) still keeps global remote credentials", () => {
const merged = KilocodeConfig.mergeProject(
{
mcp: {
shared: {
...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }),
oauth: { clientId: "global", clientSecret: "oauth-secret" },
},
},
},
{
mcp: {
// Partial disable without restating url — must not strip inherited headers.
shared: { enabled: false } as NonNullable<Config.Info["mcp"]>[string],
},
},
)
const shared = merged.mcp?.shared
expect(shared && typeof shared === "object" && "enabled" in shared ? shared.enabled : undefined).toBe(false)
expect(isRemote(shared) ? shared.url : undefined).toBe("https://trusted.example.com/mcp")
expect(isRemote(shared) ? shared.headers?.Authorization : undefined).toBe("Bearer global-secret")
expect(isRemote(shared) && typeof shared.oauth === "object" ? shared.oauth.clientSecret : undefined).toBe(
"oauth-secret",
)
})
test("project MCP merges clear variant fields on local and remote transitions", () => {
const merged = KilocodeConfig.mergeProject(
{
mcp: {
local: {
...remote("https://trusted.example.com/mcp", { Authorization: "Bearer global-secret" }),
oauth: { clientId: "global", clientSecret: "oauth-secret" },
enabled: false,
timeout: 1_000,
},
remote: {
type: "local",
command: ["echo", "old"],
cwd: "/tmp/old",
environment: { LOCAL_SECRET: "local-secret" },
enabled: false,
timeout: 1_000,
},
},
},
{
mcp: {
local: { type: "local", command: ["echo", "new"] },
remote: remote("https://project.example.com/mcp"),
},
},
)
expect(merged.mcp?.local).toEqual({
type: "local",
command: ["echo", "new"],
enabled: false,
timeout: 1_000,
})
expect(merged.mcp?.remote).toEqual({
type: "remote",
url: "https://project.example.com/mcp",
enabled: false,
timeout: 1_000,
})
expect(JSON.stringify(merged.mcp)).not.toContain("secret")
expect(JSON.stringify(merged.mcp)).not.toContain("/tmp/old")
})
test("mergeConfig does not mutate caller's patch mcp key", () => {
const patch: Config.Info = {
model: "test-model",
mcp: {
x: remote("https://a.example.com/mcp"),
},
}
const merged = KilocodeConfig.mergeConfig({}, patch)
expect(isRemote(merged.mcp?.x) ? merged.mcp?.x.url : undefined).toBe("https://a.example.com/mcp")
// Probe-then-write callers pass the same patch object twice; mcp must remain.
expect("mcp" in patch).toBe(true)
expect(isRemote(patch.mcp?.x) ? patch.mcp?.x.url : undefined).toBe("https://a.example.com/mcp")
expect(patch.model).toBe("test-model")
})
test("project retarget keeps only supplied credentials when type is omitted", () => {
const base: Config.Info = {
mcp: {
shared: {
...remote("https://trusted.example.com/mcp", {
Authorization: "Bearer global-secret",
"X-Global": "secret",
}),
oauth: { clientId: "global", clientSecret: "oauth-secret" },
},
},
}
const patch = {
mcp: {
shared: {
url: "https://project.example.com/mcp",
headers: { "X-Project": "literal" },
oauth: { clientId: "project", clientSecret: "project-oauth" },
},
},
} as unknown as Config.Info
const merged = KilocodeConfig.mergeProject(base, patch)
expect(merged.mcp?.shared).toEqual({
type: "remote",
url: "https://project.example.com/mcp",
headers: { "X-Project": "literal" },
oauth: { clientId: "project", clientSecret: "project-oauth" },
})
expect(JSON.stringify(merged)).not.toContain("global-secret")
expect(JSON.stringify(merged)).not.toContain("oauth-secret")
})
@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { LanguageModelV3 } from "@ai-sdk/provider"
import { APICallError } from "ai"
import { Effect } from "effect"
import { ModelNotFoundError, type Provider } from "../../../src/provider/provider"
import { ProviderV2 } from "@opencode-ai/core/provider"
@@ -31,15 +32,22 @@ function mdl(id = mid): Provider.Model {
} as unknown as Provider.Model
}
function lang(outputs = ["{}"]): LanguageModelV3 {
function lang(outputs: (string | Error)[] = ["{}"], calls?: unknown[], hang?: boolean): LanguageModelV3 {
let idx = 0
const next = () => {
const item = outputs[idx++] ?? outputs.at(-1) ?? "{}"
if (item instanceof Error) throw item
return item
}
return {
specificationVersion: "v3",
provider: "test",
modelId: "fake-memory-model",
supportedUrls: {},
doGenerate: async () => {
const text = outputs[idx++] ?? outputs.at(-1) ?? "{}"
doGenerate: async (...args: Parameters<LanguageModelV3["doGenerate"]>) => {
calls?.push(args[0])
if (hang) return new Promise(() => {})
const text = next()
return {
content: [{ type: "text", text }],
finishReason: { unified: "stop" },
@@ -57,7 +65,9 @@ function lang(outputs = ["{}"]): LanguageModelV3 {
} as unknown as LanguageModelV3
}
function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider.Interface {
function provider(
input: { outputs?: (string | Error)[]; seen?: string[]; calls?: unknown[]; hang?: boolean } = {},
): Provider.Interface {
const base = mdl()
const mem = mdl(ModelV2.ID.make("memory-config-model"))
const info = {
@@ -78,7 +88,7 @@ function provider(input: { outputs?: string[]; seen?: string[] } = {}): Provider
},
getLanguage: (model) => {
input.seen?.push(model.id)
return Effect.succeed(lang(input.outputs))
return Effect.succeed(lang(input.outputs, input.calls, input.hang))
},
closest: () => Effect.succeed({ providerID: pid, modelID: base.id }),
getSmallModel: () => Effect.succeed(mem),
@@ -280,6 +290,34 @@ describe("memory ports", () => {
expect(seen).toEqual(["memory-config-model", "fake-memory-model"])
})
test("model port retries a transient provider failure once", async () => {
const calls: unknown[] = []
const err = new APICallError({
message: "temporarily unavailable",
url: "https://example.com/v1/generate",
requestBodyValues: {},
statusCode: 503,
responseHeaders: {},
responseBody: '{"error":"temporarily unavailable"}',
isRetryable: true,
})
const port = MemoryModel.port({ provider: provider({ outputs: [err, "{}"], calls }) })
const resolved = await Effect.runPromise(port.resolve({ session: ref }))
await port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 30_000 })
expect(calls).toHaveLength(2)
})
test("model port emits a structured timeout error", async () => {
const port = MemoryModel.port({ provider: provider({ hang: true }) })
const resolved = await Effect.runPromise(port.resolve({ session: ref }))
await expect(
port.run({ handle: resolved.handle, system: "system", prompt: "prompt", timeoutMs: 1 }),
).rejects.toMatchObject({ name: "TimeoutError", message: "memory model timed out" })
})
test("model port clears its timeout after successful output", async () => {
const set = globalThis.setTimeout
const clear = globalThis.clearTimeout
@@ -6,6 +6,11 @@ describe("model info panel price formatting", () => {
expect(fmtPrice(0)).toBe("Free")
})
test("fmtPrice returns dash for negative values", () => {
expect(fmtPrice(-1)).toBe("—")
expect(fmtPrice(-1000000)).toBe("—")
})
test("fmtPrice uses four decimals for very small prices", () => {
expect(fmtPrice(0.0095)).toBe("$0.0095/1M")
})
@@ -3434,6 +3434,38 @@ describe("ProviderTransform.message - cache control on gateway", () => {
},
})
})
test("openai gpt-5.6 with ChatGPT subscription (zero cost heuristic) does not apply promptCacheBreakpoint", () => {
const model = createModel({
providerID: "openai",
api: {
id: "gpt-5.6",
url: "https://api.openai.com/v1",
npm: "@ai-sdk/openai",
},
id: "gpt-5.6",
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
})
const msgs = [
{
role: "system",
content: "You are a helpful assistant",
},
{
role: "user",
content: "Hello",
},
] as any[]
const result = ProviderTransform.message(msgs, model, {}) as any[]
expect(result[0].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined()
expect(result[1].providerOptions?.openai?.promptCacheBreakpoint).toBeUndefined()
})
// kilocode_change end
})