refactor: hot reload permissions

This commit is contained in:
Catriel Müller
2026-05-28 21:58:00 -03:00
parent 3c96a7c8d3
commit 7e4c297abd
21 changed files with 836 additions and 201 deletions
@@ -1,9 +1,11 @@
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { GlobalBus } from "../../src/bus/global"
import { Server } from "../../src/server/server"
import { registerDisposer } from "../../src/effect/instance-registry"
import { Permission } from "../../src/permission"
import * as Log from "@opencode-ai/core/util/log"
import { resetDatabase } from "../fixture/db"
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
@@ -31,6 +33,17 @@ async function provider(target: ReturnType<typeof app>, directory: string) {
return (await response.json()).indexing?.provider as string | undefined
}
async function config(dir: string, value: unknown) {
await Bun.write(path.join(dir, "kilo.json"), JSON.stringify(value, null, 2))
}
async function edit(target: ReturnType<typeof app>, directory: string) {
const response = await target.request("/agent", { headers: { "x-kilo-directory": directory } })
expect(response.status).toBe(200)
const agents = (await response.json()) as Array<{ name: string; permission: Permission.Ruleset }>
return Permission.evaluate("edit", "*", agents.find((agent) => agent.name === "code")?.permission ?? []).action
}
afterEach(async () => {
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
;(Global.Path as { config: string }).config = root
@@ -86,5 +99,20 @@ describe("global config refresh", () => {
GlobalBus.off("event", listener)
}
})
test(`${value ? "httpapi" : "legacy"} detects external global config edits`, async () => {
await using global = await tmpdir()
await using workspace = await tmpdir({ config: { formatter: false, lsp: false } })
;(Global.Path as { config: string }).config = global.path
await config(global.path, { permission: { edit: "ask" } })
await disposeAllInstances()
const target = app(value)
expect(await edit(target, workspace.path)).toBe("ask")
await config(global.path, { permission: { edit: { "*": "allow" } } })
expect(await edit(target, workspace.path)).toBe("allow")
})
}
})
@@ -2,8 +2,10 @@ import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import * as Log from "@opencode-ai/core/util/log"
import { Global } from "@opencode-ai/core/global"
import { Flag } from "@opencode-ai/core/flag/flag"
import { Server } from "../../../src/server/server"
import { Config } from "../../../src/config/config"
import { Permission } from "../../../src/permission"
import { AppRuntime } from "../../../src/effect/app-runtime"
import { resetDatabase } from "../../fixture/db"
import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
@@ -11,15 +13,21 @@ import { disposeAllInstances, tmpdir } from "../../fixture/fixture"
void Log.init({ print: false })
const original = Global.Path.config
const experimental = Flag.KILO_EXPERIMENTAL_HTTPAPI
type Overlay = {
fields: Record<string, { source: string; inherited: boolean; overridden: boolean; value?: unknown }>
collections: Record<string, Array<{ key: string; source: string; inherited: boolean; local?: unknown }>>
targets: { project?: string; global?: string; active?: string }
}
type Agent = {
name: string
permission: Permission.Ruleset
}
afterEach(async () => {
;(Global.Path as { config: string }).config = original
Flag.KILO_EXPERIMENTAL_HTTPAPI = experimental
await AppRuntime.runPromise(Config.Service.use((svc) => svc.invalidate()))
await disposeAllInstances()
await resetDatabase()
@@ -35,6 +43,21 @@ function req(dir: string, input: string, init?: RequestInit) {
})
}
function app(value: boolean) {
Flag.KILO_EXPERIMENTAL_HTTPAPI = value
return value ? Server.Default().app : Server.Legacy().app
}
function request(target: ReturnType<typeof app>, dir: string | undefined, input: string, init?: RequestInit) {
return target.request(input, {
...init,
headers: {
...(dir ? { "x-kilo-directory": dir } : {}),
...init?.headers,
},
})
}
async function json<T>(response: Response) {
expect(response.status).toBe(200)
return (await response.json()) as T
@@ -143,4 +166,102 @@ describe("config overlay routes", () => {
}
expect(saved.mcp).toEqual({ shared: { enabled: false } })
})
test.serial("refreshes effective config after project permission update", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await config(global.path, { permission: { edit: "allow" } })
await invalidate()
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"allow",
)
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "project", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=project"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"ask",
)
expect(body.collections.permission.find((item) => item.key === "edit")).toMatchObject({
source: "project",
overridden: true,
})
})
test.serial("refreshes agent permissions after global permission update", async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await config(global.path, { permission: { edit: "allow" } })
await invalidate()
const before = await json<Agent[]>(await req(project.path, "/agent"))
expect(Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"allow",
)
await json(
await req(project.path, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "global", set: { permission: { edit: { "*": "ask" } } } }),
}),
)
const body = await json<Overlay & { effective: { permission: Record<string, string | Record<string, string>> } }>(
await req(project.path, "/config/overlay?scope=global"),
)
const edit = body.effective.permission.edit
const after = await json<Agent[]>(await req(project.path, "/agent"))
expect(typeof edit === "string" ? edit : edit["*"]).toBe("ask")
expect(Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action).toBe(
"ask",
)
})
for (const value of [false, true]) {
test.serial(
`${value ? "httpapi" : "legacy"} global overlay update refreshes existing project instances without a project directory`,
async () => {
await using global = await tmpdir()
await using project = await tmpdir()
;(Global.Path as { config: string }).config = global.path
await config(global.path, { permission: { edit: "ask" } })
await invalidate()
const target = app(value)
const before = await json<Agent[]>(await request(target, project.path, "/agent"))
expect(
Permission.evaluate("edit", "*", before.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("ask")
await json(
await request(target, undefined, "/config/overlay", {
method: "PATCH",
headers: { "content-type": "application/json" },
body: JSON.stringify({ scope: "global", set: { permission: { edit: { "*": "allow" } } } }),
}),
)
const after = await json<Agent[]>(await request(target, project.path, "/agent"))
expect(
Permission.evaluate("edit", "*", after.find((item) => item.name === "code")?.permission ?? []).action,
).toBe("allow")
},
)
}
})
@@ -396,6 +396,61 @@ it.live("loop calls LLM and returns assistant message", () =>
),
)
// kilocode_change start - active tools must re-read permissions after config changes
it.live("active tool calls use permissions changed after model streaming starts", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ dir, llm }) {
const config = yield* Config.Service
const prompt = yield* SessionPrompt.Service
const sessions = yield* Session.Service
const permission = yield* Permission.Service
const file = path.join(dir, "note.txt")
const gate = defer<void>()
yield* Effect.promise(() => Bun.write(file, "old"))
yield* llm.push(
reply()
.wait(gate.promise)
.tool("edit", { filePath: file, oldString: "old", newString: "new" }),
)
const chat = yield* sessions.create({ title: "Pinned" })
yield* prompt.prompt({
sessionID: chat.id,
agent: "build",
noReply: true,
parts: [{ type: "text", text: "edit note" }],
})
const fiber = yield* prompt.loop({ sessionID: chat.id }).pipe(Effect.forkScoped)
yield* llm.wait(1)
yield* config.update({ permission: { edit: { "*": "allow" } } } as Config.Info)
gate.resolve(undefined)
yield* waitFor(
"edit without permission prompt",
Effect.gen(function* () {
const pending = yield* permission.list()
if (pending.length) throw new Error("edit permission was requested after config allowed it")
const text = yield* Effect.promise(() => Bun.file(file).text())
if (text === "new") return text
}),
)
const exit = yield* Fiber.await(fiber)
expect(Exit.isSuccess(exit)).toBe(true)
}),
{
git: true,
config: (url) => ({
...providerCfg(url),
permission: { edit: "ask" },
}),
},
),
)
// kilocode_change end
it.live("prompt emits v2 prompted and synthetic events", () =>
provideTmpdirServer(
Effect.fnUntraced(function* () {