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
View File
@@ -7,6 +7,7 @@
"dev": "vite --host 127.0.0.1 --port 3017",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 3018",
"test": "bun test src",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
+38
View File
@@ -0,0 +1,38 @@
import { expect, test } from "bun:test"
function setup() {
const calls: Array<{ url: string; method: string; body: unknown }> = []
const win = {
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const req = input instanceof Request ? input : new Request(input, init)
calls.push({ url: req.url, method: req.method, body: await req.json() })
return new Response(JSON.stringify({ permission: { edit: { "*": "allow" } } }), {
headers: { "content-type": "application/json" },
})
},
}
Object.defineProperty(globalThis, "window", { value: win, configurable: true })
return calls
}
test("config writes include the selected directory", async () => {
const calls = setup()
const client = await import("./client")
const query = { url: "http://kilo:secret@127.0.0.1:4097", dir: "/tmp/project", scope: "project" as const }
await client.saveConfig(query, { permission: { edit: { "*": "allow" } } })
await client.unsetConfig(query, [["permission", "edit"]])
expect(calls).toHaveLength(2)
const save = calls[0]
const unset = calls[1]
expect(save.method).toBe("PATCH")
expect(new URL(save.url).searchParams.get("directory")).toBe("/tmp/project")
expect(save.body).toEqual({ scope: "project", set: { permission: { edit: { "*": "allow" } } } })
expect(unset.method).toBe("PATCH")
expect(new URL(unset.url).searchParams.get("directory")).toBe("/tmp/project")
expect(unset.body).toEqual({ scope: "project", unset: [["permission", "edit"]] })
})
+97 -17
View File
@@ -108,10 +108,16 @@ type Result<T> = {
error?: unknown
}
type Hit = {
url: string
count: number
time: number
}
const ports = Array.from({ length: 20 }, (_, index) => 4097 + index)
const day = 24 * 60 * 60 * 1000
const hidden = new Set(["global"])
const key = "kilo.config.server"
const discovery: { run?: Promise<string | undefined> } = {}
const fetcher = window.fetch.bind(window) as typeof fetch
@@ -211,6 +217,11 @@ function inside(root: string, input: string) {
return dir.startsWith(`${base}/`)
}
function visible(item: ProjectItem) {
if (item.id !== "global") return true
return norm(item.worktree) !== "/"
}
function score(item: ProjectItem, dir: string) {
return [item.worktree, ...item.sandboxes].reduce((best, root) => {
if (!inside(root, dir)) return best
@@ -241,6 +252,44 @@ async function probe(url: string) {
.finally(() => window.clearTimeout(timer))
}
function projects(input: unknown) {
if (!Array.isArray(input)) return []
return input.filter((item): item is ProjectItem => {
if (!item || typeof item !== "object") return false
const row = item as Partial<ProjectItem>
return typeof row.id === "string" && typeof row.worktree === "string" && typeof row.time?.updated === "number"
})
}
function newest(items: ProjectItem[]) {
return items.reduce((best, item) => Math.max(best, item.time.updated), 0)
}
async function inspect(url: string): Promise<Hit | undefined> {
const hit = await probe(url)
if (!hit) return undefined
const ctl = new AbortController()
const timer = window.setTimeout(() => ctl.abort(), 400)
const info = server(url)
return await fetcher(`${info.url}/project`, { headers: { Authorization: `Basic ${info.token}` }, signal: ctl.signal })
.then(async (res) => {
if (!res.ok) return { url, count: 0, time: 0 }
const rows = projects(await res.json()).filter(visible)
return { url, count: rows.length, time: newest(rows) }
})
.catch(() => ({ url, count: 0, time: 0 }))
.finally(() => window.clearTimeout(timer))
}
function port(input: string) {
return Number(new URL(input).port) || 0
}
function local(input: string) {
return new URL(input).hostname === "127.0.0.1" ? 1 : 0
}
export function loadCached() {
return window.localStorage.getItem(key) ?? ""
}
@@ -257,17 +306,32 @@ export async function healthy(url: string) {
return (await probe(url)) !== undefined
}
export async function discover() {
async function scan() {
const urls = ports.flatMap((port) => [`http://127.0.0.1:${port}`, `http://localhost:${port}`])
const hit = await Promise.any(
urls.map((url) =>
probe(url).then((value) => {
if (value) return value
throw new Error(`${url} unavailable`)
}),
),
).catch(() => undefined)
return hit
const hits = (await Promise.all(urls.map(inspect))).filter((item): item is Hit => item !== undefined)
return hits.toSorted(
(a, b) => b.count - a.count || b.time - a.time || port(b.url) - port(a.url) || local(b.url) - local(a.url),
)[0]?.url
}
export async function discover() {
if (discovery.run) return await discovery.run
const run = scan().finally(() => {
if (discovery.run === run) discovery.run = undefined
})
discovery.run = run
return await run
}
export async function resolveServer() {
const hit = await discover()
if (hit) return hit
const cached = loadCached()
if (!cached) return undefined
if (await healthy(cached)) return cached
forgetCached()
return undefined
}
export async function load(input: Query): Promise<Snapshot> {
@@ -316,7 +380,7 @@ export async function loadProjects(input: ProjectQuery): Promise<ProjectItem[]>
export async function loadVisibleProjects(input: ProjectQuery): Promise<ProjectItem[]> {
const items = await loadProjects(input)
return items.filter((item) => !hidden.has(item.id))
return items.filter(visible)
}
export async function loadRecentProjects(input: ProjectQuery): Promise<RecentProjectItem[]> {
@@ -414,10 +478,18 @@ export async function loadProjectTerminals(input: ProjectQuery, dir: string): Pr
})
}
function opened(items: ProjectPtyInfo[]) {
return new Set(items.flatMap((item) => (item.sessionID ? [item.sessionID] : [])))
}
function pending(items: Array<PermissionRequest | QuestionRequest>) {
return new Set(items.map((item) => item.sessionID))
}
function requested(items: Array<PermissionRequest | QuestionRequest>, open: Set<string>) {
return items.some((item) => open.has(item.sessionID))
}
export type ProjectLiveStatus = {
busy: boolean
attention: boolean
@@ -425,16 +497,24 @@ export type ProjectLiveStatus = {
export async function loadProjectLiveStatus(input: ProjectQuery, dir: string): Promise<ProjectLiveStatus> {
const sdk = client({ url: input.url, dir })
const [status, permissions, questions] = await Promise.all([
const [status, terminals, permissions, questions] = await Promise.all([
maybe("Session status", sdk.session.status({ directory: dir })),
maybe("Terminals", sdk.pty.list({ directory: dir })),
maybe("Pending permissions", sdk.permission.list({ directory: dir })),
maybe("Pending questions", sdk.question.list({ directory: dir })),
])
const busy = Object.values(status ?? {}).some((s) => s.type !== "idle")
const attention = (permissions ?? []).length > 0 || (questions ?? []).length > 0
const open = opened(terminals ?? [])
const busy = Object.entries(status ?? {}).some(([id, s]) => open.has(id) && s.type !== "idle")
const attention = requested(permissions ?? [], open) || requested(questions ?? [], open)
return { busy, attention }
}
export async function loadProjectOpenSessions(input: ProjectQuery, dir: string) {
const sdk = client({ url: input.url, dir })
const terminals = await maybe("Terminals", sdk.pty.list({ directory: dir }))
return opened(terminals ?? [])
}
function attention(id: string, permissions: Set<string>, questions: Set<string>) {
if (permissions.has(id)) return "permission"
if (questions.has(id)) return "question"
@@ -504,13 +584,13 @@ export function ptyWsUrl(input: Query, pty: string, cursor = 0) {
export async function saveConfig(input: Query, patch: Partial<ConfigPatch>) {
const sdk = client(input)
const result = await sdk.config.overlayUpdate({ scope: input.scope, set: patch })
const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, set: patch })
return demand("Update config", result)
}
export async function unsetConfig(input: Query, unset: ConfigUnset) {
const sdk = client(input)
const result = await sdk.config.overlayUpdate({ scope: input.scope, unset })
const result = await sdk.config.overlayUpdate({ directory: value(input.dir), scope: input.scope, unset })
return demand("Update config", result)
}
@@ -3,9 +3,9 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup,
import {
discover,
forgetCached,
healthy,
loadCached,
loadVisibleProjects,
resolveServer,
saveCached,
type ProjectItem,
type ProjectQuery,
@@ -200,18 +200,11 @@ export function OmniSearch() {
createEffect(() => {
if (!discoverable()) return
const cached = loadCached()
void Promise.resolve(cached ? healthy(cached) : false)
.then((ok) => {
if (ok) return cached
forgetCached()
return discover()
})
.then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
@@ -3,10 +3,11 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup
import {
discover,
forgetCached,
healthy,
loadRecentProjects,
loadProjectLiveStatus,
loadProjectOpenSessions,
loadCached,
resolveServer,
saveCached,
subscribeProjectEvents,
type ProjectConsoleEvent,
@@ -25,6 +26,7 @@ import {
markAttention,
clearAttention,
markUnread,
clearUnread,
clearBusy,
markBusy,
type GlobalEvent,
@@ -120,6 +122,7 @@ export function AppSidebar(props: Props) {
return { url: target, dir: "" }
})
const [items, { refetch }] = createResource(query, loadRecentProjects)
const checks = new Map<string, number>()
// project currently rendered by ProjectConsoleRoute — it owns unread tracking for its terminals
const activeProject = createMemo(() => {
@@ -148,6 +151,32 @@ export function AppSidebar(props: Props) {
}, 150)
}
function bump(type: string, project: string, session: string) {
const key = `${type}\0${project}\0${session}`
const rev = (checks.get(key) ?? 0) + 1
checks.set(key, rev)
return { key, rev }
}
function gated(
type: string,
input: ProjectQuery,
project: string,
dir: string,
session: string,
run: () => void,
clear: () => void,
) {
const check = bump(type, project, session)
void loadProjectOpenSessions(input, dir)
.then((open) => {
if (checks.get(check.key) !== check.rev) return
if (open.has(session)) run()
else clear()
})
.catch((err) => console.warn("Project open sessions:", err))
}
// ── server URL tracking ──────────────────────────────────────────────────────
createEffect(() => {
@@ -157,18 +186,11 @@ export function AppSidebar(props: Props) {
createEffect(() => {
if (!discoverable()) return
const cached = loadCached()
void Promise.resolve(cached ? healthy(cached) : false)
.then((ok) => {
if (ok) return cached
forgetCached()
return discover()
})
.then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
@@ -216,7 +238,7 @@ export function AppSidebar(props: Props) {
if (s.attention) markAttention(item.id, "__hydrated__")
else clearAttention(item.id, "__hydrated__")
})
.catch(() => {})
.catch((err) => console.warn("Project live status:", err))
}
})
@@ -240,24 +262,34 @@ export function AppSidebar(props: Props) {
const proj = projectForDir(list, ge.directory)
if (!proj) return
const sid = eventSessionId(ge) ?? "__unknown__"
const sid = eventSessionId(ge)
// session.turn.close: error | completed
if (t === "session.turn.close") {
const payload = (ge.payload as Record<string, unknown>).properties as
| { reason?: string }
| undefined
if (!sid) return
if (payload?.reason === "error") {
markError(proj.id, sid)
} else {
clearError(proj.id, sid)
if (payload?.reason === "completed") {
// When the user is on this project, ProjectConsoleRoute owns unread tracking
// with per-terminal awareness (it knows which terminal is active).
// AppSidebar only handles cross-project: projects the user is NOT currently viewing.
if (proj.id !== activeProject()) {
markUnread(proj.id, sid)
}
gated("error", current, proj.id, ge.directory, sid, () => markError(proj.id, sid), () => clearError(proj.id, sid))
return
}
bump("error", proj.id, sid)
clearError(proj.id, sid)
if (payload?.reason === "completed") {
// When the user is on this project, ProjectConsoleRoute owns unread tracking
// with per-terminal awareness (it knows which terminal is active).
// AppSidebar only handles cross-project: projects the user is NOT currently viewing.
if (proj.id !== activeProject()) {
gated(
"unread",
current,
proj.id,
ge.directory,
sid,
() => markUnread(proj.id, sid),
() => clearUnread(proj.id, sid),
)
}
}
return
@@ -265,7 +297,16 @@ export function AppSidebar(props: Props) {
// permission / question → attention
if (t === "permission.asked" || t === "question.asked") {
markAttention(proj.id, sid)
if (!sid) return
gated(
"attention",
current,
proj.id,
ge.directory,
sid,
() => markAttention(proj.id, sid),
() => clearAttention(proj.id, sid),
)
return
}
if (
@@ -274,6 +315,8 @@ export function AppSidebar(props: Props) {
t === "question.replied" ||
t === "question.rejected"
) {
if (!sid) return
bump("attention", proj.id, sid)
clearAttention(proj.id, sid)
return
}
@@ -284,9 +327,12 @@ export function AppSidebar(props: Props) {
| { status?: { type?: string } }
| undefined
const stype = sstatus?.status?.type
if (!sid) return
if (stype === "busy" || stype === "retry") {
markBusy(proj.id, sid)
gated("busy", current, proj.id, ge.directory, sid, () => markBusy(proj.id, sid), () => clearBusy(proj.id, sid))
} else if (stype === "idle") {
bump("busy", proj.id, sid)
bump("error", proj.id, sid)
clearBusy(proj.id, sid)
clearError(proj.id, sid)
}
@@ -299,6 +345,7 @@ export function AppSidebar(props: Props) {
onCleanup(() => {
if (timers.refetch) window.clearTimeout(timers.refetch)
checks.clear()
})
return (
@@ -3,10 +3,10 @@ import type { JSX } from "solid-js"
import {
discover,
forgetCached,
healthy,
load,
loadCached,
loadProjects,
resolveServer,
saveCached,
saveConfig,
saveTui,
@@ -86,18 +86,11 @@ export function ConfigProvider(props: { children?: JSX.Element }) {
createEffect(() => {
if (!discoverable()) return
const cached = loadCached()
void Promise.resolve(cached ? healthy(cached) : false)
.then((ok) => {
if (ok) return cached
forgetCached()
return discover()
})
.then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
@@ -5,7 +5,7 @@ import { IconButton } from "@kilocode/kilo-web-ui/icon-button"
import { CountTag, Tag } from "@kilocode/kilo-web-ui/tag"
import { toolName } from "../../shared/utils"
import { ConfigPage, SourceBadge } from "./ConfigPage"
import { actions, defs, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions"
import { actions, usePermissionSettings, type PermissionAction, type PermissionRule } from "./state/permissions"
function tone(action: PermissionAction) {
if (action === "allow") return "success"
@@ -26,6 +26,56 @@ function RuleMeta(props: { rule: PermissionRule }) {
)
}
function ActionSelect(props: {
label: string
value: PermissionAction
disabled?: boolean
onSelect: (value: PermissionAction) => void
}) {
const current = () => label(props.value)
function choose(value: PermissionAction, event: MouseEvent & { currentTarget: HTMLButtonElement }) {
if (props.disabled) return
props.onSelect(value)
event.currentTarget.closest("details")?.removeAttribute("open")
}
function toggle(event: Event & { currentTarget: HTMLDetailsElement }) {
if (props.disabled) {
event.currentTarget.removeAttribute("open")
return
}
if (!event.currentTarget.open) return
event.currentTarget.parentElement?.querySelectorAll(".models-select[open]").forEach((node) => {
if (node !== event.currentTarget) node.removeAttribute("open")
})
}
return (
<details class="models-select permission-select" classList={{ disabled: props.disabled }} onToggle={toggle}>
<summary aria-label={props.label} aria-disabled={props.disabled}>
{current()}
</summary>
<div class="models-select-menu" role="listbox" aria-label={props.label}>
<For each={actions}>
{(item) => (
<button
class="models-select-option"
classList={{ selected: item.value === props.value }}
type="button"
role="option"
aria-selected={item.value === props.value}
onClick={(event) => choose(item.value, event)}
>
{item.label}
</button>
)}
</For>
</div>
</details>
)
}
export function PermissionsRoute() {
const state = usePermissionSettings()
@@ -34,26 +84,33 @@ export function PermissionsRoute() {
title={
<span class="config-title-count">
Permissions
<CountTag>{state.rules().length}</CountTag>
<CountTag>{state.groups().length + state.settings().length}</CountTag>
</span>
}
description="Control what tools agents can use by default and add pattern-specific allow, ask, or deny rules."
actions={
<Button icon="plus" variant="primary" disabled={Boolean(state.ctx.saving())} onClick={() => state.open()}>
Add rule
</Button>
}
>
<div class="permissions">
<For each={state.groups()}>
{(group) => (
<section class="permission-group">
<SectionTitle
trailing={<CountTag>{group.rules.length}</CountTag>}
description={`${group.id} · ${group.description}`}
>
{group.title}
</SectionTitle>
<header class="permission-group-header">
<div class="permission-group-copy">
<h2>{group.title}</h2>
<span>{group.id}</span>
<p>{group.description}</p>
</div>
<div class="permission-section-actions">
<CountTag>{group.rules.length}</CountTag>
<Button
icon="plus"
variant="primary"
disabled={Boolean(state.ctx.saving())}
onClick={() => state.open(group.id)}
>
Add rule
</Button>
</div>
</header>
<ConfigRow
title="Default method"
@@ -64,15 +121,12 @@ export function PermissionsRoute() {
</div>
}
actions={
<select
class="permission-action-select"
aria-label={`Default method for ${group.title}`}
<ActionSelect
label={`Default method for ${group.title}`}
value={group.action}
disabled={Boolean(state.ctx.saving())}
onChange={(event) => state.setDefault(group.id, event.currentTarget.value as PermissionAction)}
>
<For each={actions}>{(item) => <option value={item.value}>{item.label}</option>}</For>
</select>
onSelect={(action) => state.setDefault(group.id, action)}
/>
}
/>
@@ -85,15 +139,13 @@ export function PermissionsRoute() {
subtitle={`${group.title} ${group.noun} rule`}
status={<RuleMeta rule={rule} />}
actions={
<Show when={state.ctx.query()?.scope === "project" && rule.overridden}>
<IconButton
icon="trash"
variant="ghost"
aria-label={`Revert ${group.title} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.revert(rule)}
/>
</Show>
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${group.title} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.remove(rule)}
/>
}
/>
)}
@@ -104,6 +156,35 @@ export function PermissionsRoute() {
)}
</For>
<section class="permission-group">
<SectionTitle trailing={<CountTag>{state.settings().length}</CountTag>} description="Default methods for additional built-in tool permissions.">
Tool Defaults
</SectionTitle>
<div class="permission-rules">
<For each={state.settings()}>
{(item) => (
<ConfigRow
title={item.title}
subtitle={`${item.id} · ${item.description}`}
status={
<div class="permission-row-meta">
<SourceBadge source={item.source} inherited={item.inherited} overridden={item.overridden} />
</div>
}
actions={
<ActionSelect
label={`Default method for ${item.title}`}
value={item.action}
disabled={Boolean(state.ctx.saving())}
onSelect={(action) => state.setDefault(item.id, action)}
/>
}
/>
)}
</For>
</div>
</section>
<Show when={state.other().length}>
<section class="permission-group">
<SectionTitle trailing={<CountTag>{state.other().length}</CountTag>} description="Additional tool permission rules from config.">
@@ -122,15 +203,13 @@ export function PermissionsRoute() {
}
status={<RuleMeta rule={rule} />}
actions={
<Show when={state.ctx.query()?.scope === "project" && rule.overridden}>
<IconButton
icon="trash"
variant="ghost"
aria-label={`Revert ${rule.tool} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.revert(rule)}
/>
</Show>
<IconButton
icon="trash"
variant="ghost"
aria-label={`Delete ${rule.tool} rule ${rule.pattern}`}
disabled={Boolean(state.ctx.saving())}
onClick={() => state.remove(rule)}
/>
}
/>
)}
@@ -145,8 +224,8 @@ export function PermissionsRoute() {
<aside class="provider-drawer permission-drawer" aria-label="Permission rule configuration">
<header class="drawer-header">
<div>
<h2>Add Permission Rule</h2>
<span>Create a pattern-specific rule for external directory, bash, read, or edit.</span>
<h2>{`Add ${state.selected().title} Rule`}</h2>
<span>{`${state.selected().id} · ${state.selected().description}`}</span>
</div>
<Button variant="ghost" aria-label="Close permission rule overlay" onClick={state.close}>
X
@@ -154,12 +233,6 @@ export function PermissionsRoute() {
</header>
<div class="provider-form permission-form">
<label class="required-field wide">
Permission type
<select value={state.kind()} onChange={(event) => state.choose(event.currentTarget.value)}>
<For each={defs}>{(def) => <option value={def.id}>{def.title}</option>}</For>
</select>
</label>
<label class="required-field wide">
{state.selected().noun === "command" ? "Command pattern" : "Path pattern"}
<input
@@ -171,12 +244,12 @@ export function PermissionsRoute() {
</label>
<label class="required-field wide">
Method
<select
<ActionSelect
label="Rule method"
value={state.action()}
onChange={(event) => state.setAction(event.currentTarget.value as PermissionAction)}
>
<For each={actions}>{(item) => <option value={item.value}>{item.label}</option>}</For>
</select>
disabled={Boolean(state.ctx.saving())}
onSelect={state.setAction}
/>
</label>
</div>
@@ -6,6 +6,16 @@ import { clean, type PermissionMap } from "../../../shared/utils"
type Resolved = Snapshot["overlay"]["collections"][string][number]
export type PermissionAction = "ask" | "allow" | "deny"
export type PermissionTool = "external_directory" | "bash" | "read" | "edit"
type PermissionDef = {
id: string
title: string
description: string
}
type RuleDef = PermissionDef & {
id: PermissionTool
noun: string
placeholder: string
}
export type PermissionRule = {
tool: string
@@ -30,19 +40,20 @@ export type PermissionGroup = {
rules: PermissionRule[]
}
export type PermissionDefault = PermissionDef & {
action: PermissionAction
source: string
inherited: boolean
overridden: boolean
}
export const actions: Array<{ value: PermissionAction; label: string }> = [
{ value: "ask", label: "Ask" },
{ value: "allow", label: "Allow" },
{ value: "deny", label: "Deny" },
]
export const defs: Array<{
id: PermissionTool
title: string
description: string
noun: string
placeholder: string
}> = [
const ruleDefs: RuleDef[] = [
{
id: "external_directory",
title: "External Directory",
@@ -73,6 +84,24 @@ export const defs: Array<{
},
]
const defaults: PermissionDef[] = [
{ id: "glob", title: "Glob", description: "Match files using glob patterns." },
{ id: "grep", title: "Grep", description: "Search file contents using regular expressions." },
{ id: "list", title: "List", description: "List files within a directory." },
{ id: "task", title: "Task", description: "Launch sub-agents." },
{ id: "skill", title: "Skill", description: "Load a skill by name." },
{ id: "lsp", title: "LSP", description: "Run language server queries." },
{ id: "todowrite", title: "Todo Write", description: "Update the todo list." },
{ id: "question", title: "Question", description: "Ask the user a question." },
{ id: "webfetch", title: "Web Fetch", description: "Fetch content from a URL." },
{ id: "websearch", title: "Web Search", description: "Search the web." },
{ id: "doom_loop", title: "Doom Loop", description: "Detect repeated tool calls with identical input." },
{ id: "agent_manager", title: "Agent Manager", description: "Manage Agent Manager operations." },
]
export const defs = [...ruleDefs, ...defaults]
const ruleIDs = new Set<string>(ruleDefs.map((item) => item.id))
const known = new Set<string>(defs.map((item) => item.id))
function act(input: unknown, fallback: PermissionAction = "ask"): PermissionAction {
@@ -139,7 +168,21 @@ function listed(tool: string, value: unknown, item?: Resolved) {
return itemRow ? [itemRow] : []
}
function group(data: Snapshot, def: (typeof defs)[number]): PermissionGroup {
function setting(data: Snapshot, def: PermissionDef): PermissionDefault {
const item = meta(data, def.id)
const value = raw(data, def.id)
const obj = record(value)
const base = fallback(data)
return {
...def,
action: act(typeof value === "string" ? value : obj["*"], base),
source: item?.source ?? "default",
inherited: item?.inherited ?? false,
overridden: item?.overridden ?? false,
}
}
function group(data: Snapshot, def: RuleDef): PermissionGroup {
const item = meta(data, def.id)
const value = raw(data, def.id)
const obj = record(value)
@@ -168,7 +211,12 @@ export function usePermissionSettings() {
const groups = createMemo(() => {
const data = snap()
if (!data) return []
return defs.map((def) => group(data, def))
return ruleDefs.map((def) => group(data, def))
})
const settings = createMemo(() => {
const data = snap()
if (!data) return []
return defaults.map((def) => setting(data, def))
})
const other = createMemo(() => {
const data = snap()
@@ -178,9 +226,9 @@ export function usePermissionSettings() {
.flatMap(([tool, value]) => listed(tool, value, meta(data, tool)))
.sort((a, b) => a.tool.localeCompare(b.tool) || a.pattern.localeCompare(b.pattern))
})
const selected = createMemo(() => defs.find((def) => def.id === kind()) ?? defs[0])
const selected = createMemo(() => ruleDefs.find((def) => def.id === kind()) ?? ruleDefs[0])
function open(tool: PermissionTool = "external_directory") {
function open(tool: PermissionTool) {
setKind(tool)
setPattern("")
setAction("ask")
@@ -191,11 +239,6 @@ export function usePermissionSettings() {
setMode("closed")
}
function choose(value: string) {
const match = defs.find((def) => def.id === value)
if (match) setKind(match.id)
}
function add() {
const data = snap()
const glob = clean(pattern())
@@ -208,12 +251,12 @@ export function usePermissionSettings() {
close()
}
function setDefault(tool: PermissionTool, action: PermissionAction) {
const permission = { [tool]: { "*": action } } as PermissionMap
function setDefault(tool: string, action: PermissionAction) {
const permission = ruleIDs.has(tool) ? ({ [tool]: { "*": action } } as PermissionMap) : ({ [tool]: action } as PermissionMap)
ctx.save({ permission })
}
function revert(rule: PermissionRule) {
function remove(rule: PermissionRule) {
ctx.unset([rule.path])
}
@@ -221,19 +264,19 @@ export function usePermissionSettings() {
ctx,
mode,
kind,
choose,
pattern,
setPattern,
action,
setAction,
rules,
groups,
settings,
other,
selected,
open,
close,
add,
setDefault,
revert,
remove,
}
}
@@ -7,7 +7,6 @@ import {
createProjectWorktree,
discover,
forgetCached,
healthy,
loadCached,
loadProjectConsole,
loadProjectDiff,
@@ -15,6 +14,7 @@ import {
removeProjectPty,
removeProjectWorktree,
resetProjectWorktree,
resolveServer,
saveCached,
subscribeProjectEvents,
viewProjectSessions,
@@ -431,18 +431,11 @@ export function ProjectConsoleRoute() {
createEffect(() => {
if (!discoverable(search())) return
const cached = loadCached()
void Promise.resolve(cached ? healthy(cached) : false)
.then((ok) => {
if (ok) return cached
forgetCached()
return discover()
})
.then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
@@ -5,9 +5,9 @@ import { SearchField } from "../../components/SearchField"
import {
discover,
forgetCached,
healthy,
loadCached,
loadVisibleProjects,
resolveServer,
saveCached,
type ProjectItem,
type ProjectQuery,
@@ -94,18 +94,11 @@ export function ProjectsRoute() {
createEffect(() => {
if (!discoverable()) return
const cached = loadCached()
void Promise.resolve(cached ? healthy(cached) : false)
.then((ok) => {
if (ok) return cached
forgetCached()
return discover()
})
.then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
void resolveServer().then((value) => {
if (!value) return
saveCached(value)
setUrl(value)
})
})
createEffect(() => {
@@ -9,6 +9,48 @@
margin: 0.75rem 0 0.25rem;
}
.kilo-console .permission-group-header {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
margin: 0.75rem 0 0.25rem;
}
.kilo-console .permission-group-copy {
display: grid;
gap: 0.25rem;
min-width: 0;
}
.kilo-console .permission-group-copy h2,
.kilo-console .permission-group-copy p {
margin: 0;
}
.kilo-console .permission-group-copy h2 {
color: var(--foreground);
font-size: 0.875rem;
font-weight: 500;
line-height: 1.35;
}
.kilo-console .permission-group-copy span {
overflow: hidden;
color: var(--muted-foreground);
font-family: var(--font-family-mono);
font-size: 0.75rem;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.kilo-console .permission-group-copy p {
color: var(--muted-foreground);
font-size: 0.75rem;
line-height: 1.45;
}
.kilo-console .permission-row-meta {
display: flex;
gap: 0.5rem;
@@ -17,11 +59,33 @@
flex-wrap: wrap;
}
.kilo-console .permission-action-select {
width: 7.5rem;
.kilo-console .permission-section-actions {
display: inline-flex;
gap: 0.5rem;
align-items: center;
flex: 0 0 auto;
}
.kilo-console .permission-section-actions [data-component="button"] {
min-height: 2rem;
border-radius: var(--radius-md);
font-size: 0.75rem;
}
.kilo-console .permission-section-actions [data-component="button"][data-variant="primary"] [data-component="icon"],
.kilo-console .permission-section-actions [data-component="button"][data-variant="primary"] [data-slot="icon-svg"] {
color: var(--primary-foreground);
}
.kilo-console .permission-select {
width: 7.5rem;
}
.kilo-console .permission-form .permission-select {
width: 100%;
}
.kilo-console .permission-select.disabled summary {
cursor: not-allowed;
opacity: 0.6;
}
.kilo-console .permission-pattern {
@@ -62,3 +126,14 @@
.kilo-console .permission-form .wide {
grid-column: 1;
}
@media (max-width: 760px) {
.kilo-console .permission-group-header {
flex-direction: column;
align-items: stretch;
}
.kilo-console .permission-section-actions {
justify-content: space-between;
}
}
+15 -4
View File
@@ -66,7 +66,7 @@ export interface Interface {
}>
}
type State = Omit<Interface, "generate">
type State = Omit<Interface, "generate"> & { version: string } // kilocode_change
export class Service extends Context.Service<Service, Interface>()("@opencode/Agent") {}
@@ -338,6 +338,7 @@ export const layer = Layer.effect(
})
return {
version: KiloAgent.cacheKey(cfg), // kilocode_change
get,
list,
defaultAgent,
@@ -345,15 +346,25 @@ export const layer = Layer.effect(
}),
)
// kilocode_change start - rebuild cached agents when permission-relevant config changes
const current = Effect.fnUntraced(function* <A>(select: (s: State) => Effect.Effect<A>) {
const cfg = yield* config.get()
const s = yield* InstanceState.get(state)
if (s.version === KiloAgent.cacheKey(cfg)) return yield* select(s)
yield* InstanceState.invalidate(state)
return yield* select(yield* InstanceState.get(state))
})
// kilocode_change end
return Service.of({
get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
return yield* current((s) => s.get(agent)) // kilocode_change
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
return yield* current((s) => s.list()) // kilocode_change
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
return yield* current((s) => s.defaultAgent()) // kilocode_change
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
+45 -3
View File
@@ -49,6 +49,7 @@ import { Npm } from "@opencode-ai/core/npm"
import { ZodOverride } from "@/util/effect-zod"
import { KilocodeConfig } from "../kilocode/config/config"
import { KilocodeDefaultPlugins } from "@/kilocode/config/default-plugins"
import { KilocodeGlobalConfigStamp } from "@/kilocode/config/global-stamp"
import {
IndexingConfig as KiloIndexingConfig,
IndexingSchema as KiloIndexingSchema,
@@ -526,8 +527,11 @@ export const layer = Layer.effect(
return yield* loadConfig(text, { path: filepath })
})
let globalStamp = "" // kilocode_change
const loadGlobal = Effect.fnUntraced(function* () {
yield* Effect.promise(() => KilocodeConfig.migrateBashPermission()) // kilocode_change
globalStamp = yield* KilocodeGlobalConfigStamp.read(fs, Global.Path.config) // kilocode_change
let result: Info = {}
result = mergeConfig(result, yield* loadFile(path.join(Global.Path.config, "config.json")))
// kilocode_change start
@@ -566,7 +570,18 @@ export const layer = Layer.effect(
Duration.infinity,
)
const refreshGlobal = Effect.fnUntraced(function* () {
// kilocode_change start - detect global config edits made by other Kilo processes
const stamp = yield* KilocodeGlobalConfigStamp.read(fs, Global.Path.config)
if (!globalStamp || stamp === globalStamp) return false
globalStamp = stamp
yield* invalidateGlobal
return true
// kilocode_change end
})
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
yield* refreshGlobal() // kilocode_change
return yield* cachedGlobal
})
@@ -985,6 +1000,9 @@ export const layer = Layer.effect(
)
const get = Effect.fn("Config.get")(function* () {
if (yield* refreshGlobal()) {
yield* InstanceState.invalidate(state).pipe(Effect.catchCause(() => Effect.void))
}
return yield* InstanceState.use(state, (s) => s.config)
})
@@ -1015,6 +1033,16 @@ export const layer = Layer.effect(
patch: (input, patch) => patchJsonc(input, patch),
writable,
})
yield* InstanceState.invalidate(state)
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: ctx.directory,
payload: {
type: Event.ConfigUpdated.type,
properties: {},
},
}),
)
// kilocode_change end
})
@@ -1056,7 +1084,7 @@ export const layer = Layer.effect(
// kilocode_change start - skip dispose when caller opts out
if (!dispose) {
yield* invalidateGlobal
yield* InstanceState.invalidate(state)
yield* InstanceState.invalidate(state).pipe(Effect.catchCause(() => Effect.void))
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
@@ -1065,12 +1093,26 @@ export const layer = Layer.effect(
properties: {},
},
}),
)
).pipe(Effect.catchCause(() => Effect.void))
return { info: next, changed }
}
// kilocode_change end
if (changed) yield* invalidate()
if (changed) {
yield* invalidate()
// kilocode_change start - hot-reload global config changes in the active instance
yield* InstanceState.invalidate(state).pipe(Effect.catchCause(() => Effect.void))
yield* Effect.sync(() =>
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Event.ConfigUpdated.type,
properties: {},
},
}),
).pipe(Effect.catchCause(() => Effect.void))
// kilocode_change end
}
return { info: next, changed }
})
@@ -233,6 +233,16 @@ export function prepare(cfg: Config.Info): KiloData {
return { mcpRules, defaultsPatch }
}
export function cacheKey(cfg: Config.Info) {
return JSON.stringify({
agent: cfg.agent,
default_agent: cfg.default_agent,
mcp: cfg.mcp,
mode: cfg.mode,
permission: cfg.permission,
})
}
// Map "build" config key to "code" for backward compatibility.
export function resolveKey(name: string): string {
return name === "build" ? "code" : name
@@ -0,0 +1,20 @@
import path from "path"
import type { AppFileSystem } from "@opencode-ai/core/filesystem"
import { Effect } from "effect"
export namespace KilocodeGlobalConfigStamp {
const files = ["config.json", "kilo.json", "kilo.jsonc", "opencode.json", "opencode.jsonc", "config"]
export const read = Effect.fnUntraced(function* (fs: Pick<AppFileSystem.Interface, "readFileStringSafe">, dir: string) {
const entries = yield* Effect.forEach(
files,
Effect.fnUntraced(function* (file) {
const source = path.join(dir, file)
const text = yield* fs.readFileStringSafe(source).pipe(Effect.catch(() => Effect.succeed(undefined)))
return [source, text ?? null] as const
}),
{ concurrency: "unbounded" },
)
return JSON.stringify(entries)
})
}
@@ -6,6 +6,7 @@ import { KilocodeConfigOverlay } from "@/kilocode/config/overlay"
import { KilocodeConfigSources } from "@/kilocode/config/sources"
import { KilocodeModelState } from "@/kilocode/config/model-state"
import { KilocodeTuiConfig } from "@/kilocode/tui/config"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
import { InstanceHttpApi } from "@/server/routes/instance/httpapi/api"
import { Effect, Option } from "effect"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -73,7 +74,15 @@ export const configConsoleHandlers = HttpApiBuilder.group(InstanceHttpApi, "conf
if (body.scope === "global") return yield* config.getGlobal()
return yield* config.get()
}
if (body.scope === "global") return (yield* config.updateGlobal(patch)).info
if (body.scope === "global") {
const result = yield* config.updateGlobal(patch)
if (result.changed) {
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe(
Effect.catchCause(() => Effect.void),
)
}
return result.info
}
yield* config.update(patch)
return yield* config.get()
})
@@ -9,6 +9,7 @@ import { jsonRequest } from "@/server/routes/instance/trace"
import { lazy } from "@/util/lazy"
import { KilocodeConfigOverlay } from "@/kilocode/config/overlay"
import { KilocodeConfigSources } from "@/kilocode/config/sources"
import { disposeAllInstancesAndEmitGlobalDisposed } from "@/server/global-lifecycle"
export const ConfigOverlayRoutes = lazy(() =>
new Hono()
@@ -98,7 +99,15 @@ export const ConfigOverlayRoutes = lazy(() =>
if (body.scope === "global") return yield* cfg.getGlobal()
return yield* cfg.get()
}
if (body.scope === "global") return yield* cfg.updateGlobal(patch)
if (body.scope === "global") {
const result = yield* cfg.updateGlobal(patch)
if (result.changed) {
yield* disposeAllInstancesAndEmitGlobalDisposed({ swallowErrors: true }).pipe(
Effect.catchCause(() => Effect.void),
)
}
return result.info
}
yield* cfg.update(patch)
return yield* cfg.get()
}),
+21 -20
View File
@@ -378,6 +378,17 @@ NOTE: At any point in time through this workflow you should feel free to ask the
return input.messages
})
// kilocode_change start - resolve permissions at ask time so active tools see config edits
const rules = Effect.fnUntraced(function* (input: { agent: Agent.Info; session: Session.Info }) {
const agent = (yield* agents.get(input.agent.name)) ?? input.agent
const session = yield* sessions.get(input.session.id).pipe(Effect.catchCause(() => Effect.succeed(input.session)))
return {
ruleset: Permission.merge(agent.permission, KiloSessionPrompt.guardPermissions({ agent, session })),
hardRuleset: KiloSessionPrompt.hardPermissions({ agent }),
}
})
// kilocode_change end
const resolveTools = Effect.fn("SessionPrompt.resolveTools")(function* (input: {
agent: Agent.Info
model: Provider.Model
@@ -415,20 +426,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}
}),
ask: (req) =>
permission
.ask({
Effect.gen(function* () {
const current = yield* rules({ agent: input.agent, session: input.session })
yield* permission.ask({
...req,
sessionID: input.session.id,
tool: { messageID: input.processor.message.id, callID: options.toolCallId },
// kilocode_change start - reapply Ask/Plan mode guards after session permissions
ruleset: Permission.merge(
input.agent.permission,
KiloSessionPrompt.guardPermissions({ agent: input.agent, session: input.session }),
),
hardRuleset: KiloSessionPrompt.hardPermissions({ agent: input.agent }),
// kilocode_change end
...current, // kilocode_change - live permission rules
})
.pipe(Effect.orDie),
}).pipe(Effect.orDie),
})
for (const item of yield* registry.tools({
@@ -657,19 +663,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
} satisfies MessageV2.ToolPart)
}),
ask: (req: any) =>
permission
.ask({
Effect.gen(function* () {
const current = yield* rules({ agent: taskAgent, session })
yield* permission.ask({
...req,
// kilocode_change start - reapply Ask/Plan subagent guards after session permissions
sessionID,
ruleset: Permission.merge(
taskAgent.permission,
KiloSessionPrompt.guardPermissions({ agent: taskAgent, session }),
),
hardRuleset: KiloSessionPrompt.hardPermissions({ agent: taskAgent }),
// kilocode_change end
...current, // kilocode_change - live permission rules
})
.pipe(Effect.orDie),
}).pipe(Effect.orDie),
})
.pipe(
Effect.catchCause((cause) => {
@@ -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* () {