Merge branch 'main' into feature/am-pr-view

This commit is contained in:
cosi-conda
2026-08-10 11:14:46 -07:00
committed by GitHub
7 changed files with 672 additions and 16 deletions
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Prevent project MCP configs from resolving variable-backed headers or inheriting trusted headers when changing endpoints, while preserving unaffected servers.
+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
@@ -550,7 +567,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)
@@ -670,8 +687,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)
@@ -723,7 +740,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)
@@ -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
}
@@ -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")
})