mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:32:08 +08:00
refactor: hot reload permissions
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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"]] })
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
Reference in New Issue
Block a user