mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-21 05:52:35 +08:00
refactor: hot reload permissions
This commit is contained in:
@@ -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* () {
|
||||
|
||||
Reference in New Issue
Block a user