diff --git a/crates/agent-gateway/web/src/pages/settings/types.ts b/crates/agent-gateway/web/src/pages/settings/types.ts index 4fb988dda..0b774bcb8 100644 --- a/crates/agent-gateway/web/src/pages/settings/types.ts +++ b/crates/agent-gateway/web/src/pages/settings/types.ts @@ -10,6 +10,7 @@ export type SectionId = | "skills" | "mcp" | "systemTools" + | "permissions" | "stt" | "providers" | "agents" diff --git a/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts b/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts index 3bb6bf117..1c44fb46d 100644 --- a/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts +++ b/crates/agent-gui/src/agent-ui-adapters/kbrainSettings.ts @@ -3,6 +3,8 @@ import type { KBrainComputerStatus, KBrainSettingsAdapter, KBrainSettingsDocument, + PermissionRule, + PermissionRuleList, } from "@liveagent/ui/pages/settings/kbrainSettingsAdapter"; import { getKBrainRuntimeConnection } from "../lib/kbrain/runtimeConnection"; @@ -27,6 +29,48 @@ async function request(path: string, init?: RequestInit): Promise { return (await (await send(path, init)).json()) as T; } +// The rules API answers errors as {"error": "..."}; show the message, not the JSON. +async function rulesRequest(path: string, init?: RequestInit): Promise { + try { + return await request(path, init); + } catch (error) { + const text = error instanceof Error ? error.message : String(error); + let message = text; + try { + const parsed = JSON.parse(text) as { error?: unknown }; + if (typeof parsed.error === "string" && parsed.error) message = parsed.error; + } catch {} + throw new Error(message); + } +} + +type WirePermissionRule = { + id: string; + rule: string; + effect: PermissionRule["effect"]; + scope: PermissionRule["scope"]; + created_at?: string; + source_session?: string; +}; + +function permissionRuleFromWire(rule: WirePermissionRule): PermissionRule { + return { + id: rule.id, + rule: rule.rule, + effect: rule.effect, + scope: rule.scope, + ...(rule.created_at ? { createdAt: rule.created_at } : {}), + ...(rule.source_session ? { sourceSession: rule.source_session } : {}), + }; +} + +function rulesQuery(values: Record): string { + const query = new URLSearchParams(); + for (const [key, value] of Object.entries(values)) if (value) query.set(key, value); + const text = query.toString(); + return text ? `?${text}` : ""; +} + export function getKBrainHealth(): Promise { return send("/v1/health").then(readKBrainHealthResponse); } @@ -44,6 +88,28 @@ export const kbrainSettingsAdapter: KBrainSettingsAdapter = { method: "PUT", body: JSON.stringify(update), }), + listPermissionRules: async ({ workspace }) => { + const list = await rulesRequest<{ project_root?: string; rules?: WirePermissionRule[] }>( + `/v1/permissions/rules${rulesQuery({ workspace })}`, + ); + return { + ...(list.project_root ? { projectRoot: list.project_root } : {}), + rules: (list.rules ?? []).map(permissionRuleFromWire), + } satisfies PermissionRuleList; + }, + addPermissionRule: async (input) => + permissionRuleFromWire( + await rulesRequest("/v1/permissions/rules", { + method: "POST", + body: JSON.stringify(input), + }), + ), + deletePermissionRule: async ({ id, scope, workspace }) => { + await rulesRequest( + `/v1/permissions/rules/${encodeURIComponent(id)}${rulesQuery({ scope, workspace })}`, + { method: "DELETE" }, + ); + }, }; export const settingsHostAdapter = { diff --git a/crates/agent-gui/src/pages/settings/types.ts b/crates/agent-gui/src/pages/settings/types.ts index abc4061e1..a89c9fa83 100644 --- a/crates/agent-gui/src/pages/settings/types.ts +++ b/crates/agent-gui/src/pages/settings/types.ts @@ -13,6 +13,7 @@ export type SectionId = | "skills" | "mcp" | "systemTools" + | "permissions" | "stt" | "providers" | "agents" diff --git a/crates/agent-gui/test/settings/permission-rules-registration.test.mjs b/crates/agent-gui/test/settings/permission-rules-registration.test.mjs new file mode 100644 index 000000000..89c749bc7 --- /dev/null +++ b/crates/agent-gui/test/settings/permission-rules-registration.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-3a: Settings lists 权限 only when the host can reach the K-brain rules API (the desktop +// adapter today; the WebUI gets it in LA-3b). Both locales carry the page's text. + +const settings = { + stt: { provider: "tencent_cloud" }, + system: { workspaceProjects: [], hiddenWorkspaceProjectPaths: [], archivedWorkspaceProjectPaths: [] }, + customProviders: [], + customSettings: { fontScale: { sidebar: 1, chat: 1, rightDock: 1, settings: 1 } }, +}; + +async function sectionIds(kbrain) { + const shared = (name) => fileURLToPath(new URL(`../../../agent-ui/src/pages/settings/${name}`, import.meta.url)); + let sections = []; + const env = await createDomTestEnv({ + mocks: { + [shared("SettingsShell.tsx")]: { SettingsShell: ({ registry }) => { sections = registry.settingsSections; return null; } }, + [shared("PermissionRulesSection.tsx")]: { PermissionRulesSection: () => null }, + [shared("ProvidersSection.tsx")]: { ProvidersSection: () => null }, + [shared("SystemSettingsForm.tsx")]: { SystemSettingsForm: () => null }, + [shared("AgentsSection.tsx")]: { AgentsSection: () => null }, + [shared("ResourceHubSection.tsx")]: { ResourceHubSection: () => null }, + [shared("SystemToolsSection.tsx")]: { SystemToolsSection: () => null }, + [shared("CuaDriverSection.tsx")]: { CuaDriverSection: () => null }, + [shared("RemoteSection.tsx")]: { RemoteSection: () => null }, + [shared("SshSection.tsx")]: { SshSection: () => null }, + [shared("SttSection.tsx")]: { SttSection: () => null }, + [shared("HooksSection.tsx")]: { HooksSection: () => null }, + [shared("CronSection.tsx")]: { CronSection: () => null }, + [shared("AIApprovalSection.tsx")]: { AIApprovalSection: () => null }, + [shared("CalendarSection.tsx")]: { CalendarSection: () => null }, + [shared("memory/MemoryPanel.tsx")]: { MemoryPanel: () => null }, + "@liveagent/ui/i18n/index": { useLocale: () => ({ t: (key) => key }) }, + "@liveagent/adapters/settingsExtension": { + createSettingsExtension: () => ({ surface: "desktop", iconClassName: "", slots: {}, sections: [] }), + }, + "@liveagent/adapters/providerSettings": { ProviderSettingsExtension: () => null, ProviderCopyConfigButton: () => null }, + "@liveagent/ui/components/IconSet": new Proxy({}, { get: () => () => null }), + "@liveagent/ui/components/ui/button": { Button: (props) => env.React.createElement("button", props) }, + "@liveagent/ui/components/ui/input": { Input: (props) => env.React.createElement("input", props) }, + }, + }); + const { SettingsPage } = env.loadModule("@liveagent/ui/pages/settings/SettingsPage.tsx"); + const host = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(host); + try { + await env.act(async () => root.render(env.React.createElement(SettingsPage, { + settings, setSettings: () => {}, saveState: { status: "saved" }, onBack: () => {}, + initialSection: "system", sttSettingsService: {}, + settingsHost: { isKbrain: true, kbrain }, + }))); + return sections.map((section) => `${section.id}:${section.groupKey}:${section.labelKey}`); + } finally { + await env.act(async () => root.unmount()); + host.remove(); + env.cleanup(); + } +} + +const base = { isKbrain: true, getConnection: () => null, getSettings: async () => ({}), updateSettings: async () => ({}) }; + +test("the permissions page is listed only for hosts with the rules API", async () => { + const withRules = await sectionIds({ ...base, listPermissionRules: async () => ({ rules: [] }), addPermissionRule: async () => ({}), deletePermissionRule: async () => {} }); + assert.equal(withRules.includes("permissions:settings.groupIntelligence:settings.navPermissions"), true); + const without = await sectionIds(base); + assert.equal(without.some((id) => id.startsWith("permissions:")), false); +}); + +test("both locales carry the same permission page keys", () => { + const loader = createTsModuleLoader(); + const zh = loader.loadModule("../agent-ui/src/i18n/translations/zhCNSettings.ts").ZH_CN_SETTINGS_TRANSLATIONS; + const en = loader.loadModule("../agent-ui/src/i18n/translations/enUSSettings.ts").EN_US_SETTINGS_TRANSLATIONS; + const keys = (table) => Object.keys(table).filter((key) => key === "settings.navPermissions" || key.startsWith("settings.permissions.")).sort(); + assert.deepEqual(keys(zh), keys(en)); + assert.equal(keys(zh).length >= 20, true); + assert.equal(zh["settings.navPermissions"], "权限"); +}); diff --git a/crates/agent-gui/test/settings/permission-rules.test.mjs b/crates/agent-gui/test/settings/permission-rules.test.mjs new file mode 100644 index 000000000..bab4ac0ce --- /dev/null +++ b/crates/agent-gui/test/settings/permission-rules.test.mjs @@ -0,0 +1,224 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; +import { createTsModuleLoader } from "../helpers/load-ts-module.mjs"; + +// LA-3a: Settings → 权限 lists the rules "allow and remember" stored (all projects and the +// chosen project), deletes them, and adds rules by hand. Assertions compare strings and +// booleans only: a failing assert on a DOM node would print the whole jsdom tree. + +const uiPath = (relative) => + fileURLToPath(new URL(`../../../agent-ui/src/${relative}`, import.meta.url)); + +const projects = [ + { id: "a", name: "LiveAgent", path: "D:\\work\\LiveAgent" }, + { id: "b", name: "K-brain", path: "D:\\work\\K-brain" }, + { id: "c", name: "Hidden", path: "D:\\work\\Hidden" }, +]; + +function settingsWith(list = projects) { + return { + system: { + workspaceProjects: list, + hiddenWorkspaceProjectPaths: ["D:\\work\\Hidden"], + archivedWorkspaceProjectPaths: [], + }, + }; +} + +const userRule = { id: "u1", rule: "Bash(npm test:*)", effect: "allow", scope: "user", createdAt: "2026-11-01T08:00:00Z", sourceSession: "cd5cf421aa" }; +const projectRule = { id: "p1", rule: "Bash(rm:*)", effect: "deny", scope: "project", createdAt: "2026-11-01T09:00:00Z" }; + +async function fixture(task, options = {}) { + const env = await createDomTestEnv({ mocks: { + "@liveagent/ui/components/IconSet": new Proxy({}, { get: () => () => null }), + "@liveagent/ui/components/ui/button": { Button: ({ children, size, variant, ...props }) => env.React.createElement("button", props, children) }, + "@liveagent/ui/components/settings/SettingsToggleGroup": { + SettingsToggleGroup: ({ children, value, onValueChange, disabled, ...props }) => env.React.createElement("div", props, + env.React.Children.map(children, (child) => env.React.cloneElement(child, { disabled: disabled || child.props.disabled, pressed: value.includes(child.props.value), onSelect: () => onValueChange([child.props.value]) }))), + SettingsToggleGroupItem: ({ children, value, pressed, onSelect, disabled, ...props }) => env.React.createElement("button", { ...props, disabled, "data-toggle": value, "aria-pressed": pressed, onClick: onSelect }, children), + }, + [uiPath("components/ui/select.tsx")]: { + Select: ({ value, onValueChange, disabled, children }) => env.React.createElement("select", { value, disabled, "data-project-select": "", onChange: (event) => onValueChange(event.target.value) }, children), + SelectItem: ({ value, children }) => env.React.createElement("option", { value }, children), + SelectValue: () => null, + }, + [uiPath("components/settings/SettingsSelect.tsx")]: { + SettingsSelectTrigger: () => null, + SettingsSelectContent: ({ children }) => env.React.createElement(env.React.Fragment, null, children), + }, + [uiPath("components/ui/confirm-action-popover.tsx")]: { + ConfirmDeletePopover: ({ children, onConfirm }) => children(onConfirm), + }, + } }); + const { PermissionRulesSection } = env.loadModule("@liveagent/ui/pages/settings/PermissionRulesSection.tsx"); + const calls = []; + let rules = { user: [userRule], project: { "D:\\work\\LiveAgent": [projectRule] } }; + let list = async ({ workspace }) => ({ + ...(workspace ? { projectRoot: workspace } : {}), + rules: [...rules.user, ...(workspace ? rules.project[workspace] ?? [] : [])], + }); + let add = async (input) => ({ id: `n${calls.length}`, rule: input.rule, effect: input.effect, scope: input.scope }); + const kbrain = { + getConnection: () => ({ baseUrl: "http://fixture", token: "t" }), + ...(options.capabilities ? { getCapabilities: async () => options.capabilities } : {}), + listPermissionRules: (query) => { calls.push(["list", query]); return list(query); }, + addPermissionRule: (input) => { calls.push(["add", input]); return add(input); }, + deletePermissionRule: async (ref) => { calls.push(["delete", ref]); }, + }; + const host = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(host); + const settle = () => env.act(async () => { for (let i = 0; i < 5; i++) await Promise.resolve(); }); + const click = async (element) => { await env.act(async () => { element.click(); }); await settle(); }; + const button = (text) => [...host.querySelectorAll("button")].find((b) => b.textContent.trim() === text); + const rowsOf = (scope) => [...host.querySelectorAll(`[data-permission-rules="${scope}"] [data-permission-rule]`)].map((row) => row.getAttribute("data-permission-rule")); + const type = (value) => env.act(async () => { + const input = host.querySelector("input[data-permission-rule-input]"); + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set.call(input, value); + input.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + const pickProject = async (path) => { + await env.act(async () => { + const select = host.querySelector("select[data-project-select]"); + select.value = path; + select.dispatchEvent(new window.Event("change", { bubbles: true })); + }); + await settle(); + }; + try { + await env.act(async () => root.render(env.React.createElement(PermissionRulesSection, { kbrain, settings: options.settings ?? settingsWith() }))); + await settle(); + await task({ env, host, calls, button, click, rowsOf, type, pickProject, settle, + setRules: (next) => { rules = next; }, setList: (next) => { list = next; }, setAdd: (next) => { add = next; } }); + } finally { + await env.act(async () => root.unmount()); + host.remove(); + env.cleanup(); + } +} + +test("lists all-project rules and the first visible project's rules; switching project reloads", async () => { + await fixture(async ({ host, calls, rowsOf, pickProject }) => { + assert.deepEqual(calls[0], ["list", { workspace: "D:\\work\\LiveAgent" }]); + assert.deepEqual(rowsOf("user"), ["Bash(npm test:*)"]); + assert.deepEqual(rowsOf("project"), ["Bash(rm:*)"]); + const options = [...host.querySelectorAll("select[data-project-select] option")].map((o) => o.value); + assert.deepEqual(options, ["D:\\work\\LiveAgent", "D:\\work\\K-brain"], "hidden projects are not offered"); + assert.match(host.textContent, /允许/); + assert.match(host.textContent, /拒绝/); + assert.match(host.textContent, /cd5cf421/, "the source session is shown"); + await pickProject("D:\\work\\K-brain"); + assert.deepEqual(calls.at(-1), ["list", { workspace: "D:\\work\\K-brain" }]); + assert.deepEqual(rowsOf("project"), []); + assert.match(host.querySelector('[data-permission-rules="project"]').textContent, /还没有规则/); + }); +}); + +test("deleting a rule names its scope (and project) and reloads", async () => { + await fixture(async ({ host, calls, click, rowsOf, setRules }) => { + setRules({ user: [userRule], project: {} }); + await click(host.querySelector('[data-permission-rule="Bash(rm:*)"] button[data-delete-rule]')); + assert.deepEqual(calls.find((call) => call[0] === "delete"), ["delete", { id: "p1", scope: "project", workspace: "D:\\work\\LiveAgent" }]); + assert.deepEqual(rowsOf("project"), [], "the list is reloaded after deleting"); + await click(host.querySelector('[data-permission-rule="Bash(npm test:*)"] button[data-delete-rule]')); + assert.deepEqual(calls.filter((call) => call[0] === "delete").at(-1), ["delete", { id: "u1", scope: "user" }]); + }); +}); + +test("adds a deny rule for all projects by default, or for the chosen project", async () => { + await fixture(async ({ host, calls, button, click, type }) => { + assert.equal(button("添加").disabled, true, "nothing to add yet"); + await type("Bash(curl:*)"); + await click(button("添加")); + assert.deepEqual(calls.find((call) => call[0] === "add"), ["add", { rule: "Bash(curl:*)", effect: "deny", scope: "user" }]); + assert.equal(host.querySelector("input[data-permission-rule-input]").value, "", "the field clears after adding"); + await type("Edit(src/**)"); + await click(host.querySelector('[data-toggle="allow"]')); + await click(host.querySelector('[data-toggle="project"]')); + await click(button("添加")); + assert.deepEqual(calls.filter((call) => call[0] === "add").at(-1), ["add", { rule: "Edit(src/**)", effect: "allow", scope: "project", workspace: "D:\\work\\LiveAgent" }]); + }); +}); + +test("a rule K-brain refuses keeps the text and shows the reason", async () => { + await fixture(async ({ host, button, click, type, setAdd }) => { + setAdd(async () => { throw new Error('permission rule "Bash(": missing closing parenthesis'); }); + await type("Bash("); + await click(button("添加")); + assert.match(host.querySelector('[role="alert"]')?.textContent ?? "", /missing closing parenthesis/); + assert.equal(host.querySelector("input[data-permission-rule-input]").value, "Bash("); + }); +}); + +test("a backend without permission_rules shows an upgrade note and lists nothing", async () => { + await fixture(async ({ host, calls }) => { + assert.equal(calls.length, 0); + assert.match(host.textContent, /不支持权限规则/); + }, { capabilities: { memory_switch: 1 } }); +}); + +test("only the latest project's answer is shown when answers arrive out of order", async () => { + await fixture(async ({ calls, rowsOf, pickProject, setList, settle }) => { + let releaseSlow; + setList(async ({ workspace }) => { + if (workspace === "D:\\work\\K-brain") { + await new Promise((resolve) => { releaseSlow = resolve; }); + return { rules: [{ ...projectRule, id: "slow", rule: "Bash(slow:*)" }] }; + } + return { rules: [{ ...projectRule, id: "fast", rule: "Bash(fast:*)" }] }; + }); + await pickProject("D:\\work\\K-brain"); + await pickProject("D:\\work\\LiveAgent"); + releaseSlow(); + await settle(); + assert.deepEqual(rowsOf("project"), ["Bash(fast:*)"]); + assert.equal(calls.filter((call) => call[0] === "list").length >= 3, true); + }); +}); + +test("without projects the project list says so and project scope cannot be chosen", async () => { + await fixture(async ({ host, calls }) => { + assert.deepEqual(calls[0], ["list", {}]); + assert.match(host.querySelector('[data-permission-rules="project"]').textContent, /没有可选的项目/); + assert.equal(host.querySelector('[data-toggle="project"]').disabled, true); + }, { settings: settingsWith([]) }); +}); + +// The desktop adapter talks to K-brain directly. +test("the desktop adapter calls the rules endpoints and reports K-brain's error message", async () => { + const loader = createTsModuleLoader(); + const { setKBrainRuntimeConnection, clearKBrainRuntimeConnection } = loader.loadModule("src/lib/kbrain/runtimeConnection.ts"); + const { kbrainSettingsAdapter } = loader.loadModule("src/agent-ui-adapters/kbrainSettings.ts"); + const requests = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, init = {}) => { + requests.push([init.method ?? "GET", String(url), init.body ?? null]); + const path = new URL(String(url)).pathname; + if (init.method === "POST" && JSON.parse(init.body).rule === "Bash(") { + return new Response(JSON.stringify({ error: 'permission rule "Bash(": missing closing parenthesis' }), { status: 400 }); + } + if (init.method === "POST") return new Response(JSON.stringify({ id: "r9", rule: "Bash(curl:*)", effect: "deny", scope: "user", created_at: "2026-11-01T00:00:00Z" }), { status: 201 }); + if (init.method === "DELETE") return new Response(JSON.stringify({ ok: true }), { status: 200 }); + if (path === "/v1/permissions/rules") return new Response(JSON.stringify({ project_root: "D:\\w", rules: [{ id: "a", rule: "Bash(ls:*)", effect: "allow", scope: "project", created_at: "2026-11-01T00:00:00Z", source_session: "s1" }] }), { status: 200 }); + return new Response("{}", { status: 404 }); + }; + setKBrainRuntimeConnection({ baseUrl: "http://kb", token: "tok", protocolVersion: "kbrain.agent.v1" }); + try { + const listed = await kbrainSettingsAdapter.listPermissionRules({ workspace: "D:\\w" }); + assert.deepEqual(listed, { projectRoot: "D:\\w", rules: [{ id: "a", rule: "Bash(ls:*)", effect: "allow", scope: "project", createdAt: "2026-11-01T00:00:00Z", sourceSession: "s1" }] }); + assert.deepEqual(requests.at(-1), ["GET", "http://kb/v1/permissions/rules?workspace=D%3A%5Cw", null]); + const added = await kbrainSettingsAdapter.addPermissionRule({ rule: "Bash(curl:*)", effect: "deny", scope: "user" }); + assert.equal(added.id, "r9"); + assert.deepEqual(JSON.parse(requests.at(-1)[2]), { rule: "Bash(curl:*)", effect: "deny", scope: "user" }); + await kbrainSettingsAdapter.deletePermissionRule({ id: "a b", scope: "project", workspace: "D:\\w" }); + assert.deepEqual(requests.at(-1).slice(0, 2), ["DELETE", "http://kb/v1/permissions/rules/a%20b?scope=project&workspace=D%3A%5Cw"]); + await assert.rejects( + kbrainSettingsAdapter.addPermissionRule({ rule: "Bash(", effect: "deny", scope: "user" }), + (error) => error.message === 'permission rule "Bash(": missing closing parenthesis', + ); + } finally { + globalThis.fetch = originalFetch; + clearKBrainRuntimeConnection(); + } +}); diff --git a/crates/agent-gui/test/settings/review-la3a-permission-rules-r2.test.mjs b/crates/agent-gui/test/settings/review-la3a-permission-rules-r2.test.mjs new file mode 100644 index 000000000..7a7b698c6 --- /dev/null +++ b/crates/agent-gui/test/settings/review-la3a-permission-rules-r2.test.mjs @@ -0,0 +1,289 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// LA-3a adversarial review, round 2 (after binding the list to its project, reload(target) +// and isImeKeyboardEvent). The fake K-brain below keeps rules per project the way +// K-brain does (one file per project root, random rule ids) and answers a DELETE for an +// id that is not in the named project's file with 404 {"error":"permission rule not found"} +// (internal/backend/permission_rules.go, permissions.RemoveRule). Assertions compare +// strings, numbers and booleans only: never pass a DOM node to assert. + +const uiPath = (relative) => + fileURLToPath(new URL(`../../../agent-ui/src/${relative}`, import.meta.url)); + +const LIVEAGENT = "D:\\work\\LiveAgent"; +const KBRAIN = "D:\\work\\K-brain"; +const DOCS = "D:\\work\\Docs"; + +const settings = { + system: { + workspaceProjects: [ + { id: "a", name: "LiveAgent", path: LIVEAGENT }, + { id: "b", name: "K-brain", path: KBRAIN }, + { id: "c", name: "Docs", path: DOCS }, + ], + hiddenWorkspaceProjectPaths: [], + archivedWorkspaceProjectPaths: [], + }, +}; + +function fakeKBrain() { + const store = { + user: [{ id: "r_user01", rule: "Bash(npm test:*)", effect: "allow", scope: "user" }], + project: { + [LIVEAGENT]: [{ id: "r_la0001", rule: "Bash(rm:*)", effect: "deny", scope: "project" }], + [KBRAIN]: [{ id: "r_kb0001", rule: "Bash(go test:*)", effect: "allow", scope: "project" }], + [DOCS]: [{ id: "r_dc0001", rule: "Edit(docs/**)", effect: "allow", scope: "project" }], + }, + }; + const calls = []; + const gates = { list: null, add: null, delete: null }; + const wait = (name, arg) => (gates[name] ? gates[name](arg) : Promise.resolve()); + const kbrain = { + isKbrain: true, + getConnection: () => ({ baseUrl: "http://fixture", token: "t" }), + listPermissionRules: async (query) => { + calls.push(JSON.stringify(["list", query.workspace ?? ""])); + await wait("list", query); + return { + ...(query.workspace ? { projectRoot: query.workspace } : {}), + rules: [...store.user, ...(query.workspace ? (store.project[query.workspace] ?? []) : [])], + }; + }, + addPermissionRule: async (input) => { + calls.push(JSON.stringify(["add", input])); + await wait("add", input); + const rule = { id: `r_new${calls.length}`, rule: input.rule, effect: input.effect, scope: input.scope }; + if (input.scope === "user") store.user.push(rule); + else (store.project[input.workspace] ??= []).push(rule); + return rule; + }, + deletePermissionRule: async (ref) => { + calls.push(JSON.stringify(["delete", ref])); + await wait("delete", ref); + const file = ref.scope === "user" ? store.user : (store.project[ref.workspace] ?? []); + const index = file.findIndex((rule) => rule.id === ref.id); + if (index < 0) throw new Error("permission rule not found"); + file.splice(index, 1); + }, + }; + return { kbrain, store, calls, gates }; +} + +async function fixture(task) { + const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": new Proxy({}, { get: () => () => null }), + "@liveagent/ui/components/ui/button": { + Button: ({ children, size, variant, ...props }) => env.React.createElement("button", props, children), + }, + "@liveagent/ui/components/settings/SettingsToggleGroup": { + SettingsToggleGroup: ({ children, value, onValueChange, disabled, ...props }) => + env.React.createElement( + "div", + props, + env.React.Children.map(children, (child) => + env.React.cloneElement(child, { + disabled: disabled || child.props.disabled, + pressed: value.includes(child.props.value), + onSelect: () => onValueChange([child.props.value]), + }), + ), + ), + SettingsToggleGroupItem: ({ children, value, pressed, onSelect, disabled, ...props }) => + env.React.createElement("button", { ...props, disabled, "data-toggle": value, "aria-pressed": pressed, onClick: onSelect }, children), + }, + [uiPath("components/ui/select.tsx")]: { + Select: ({ value, onValueChange, disabled, children }) => + env.React.createElement("select", { value, disabled, "data-project-select": "", onChange: (event) => onValueChange(event.target.value) }, children), + SelectItem: ({ value, children }) => env.React.createElement("option", { value }, children), + SelectValue: () => null, + }, + [uiPath("components/settings/SettingsSelect.tsx")]: { + SettingsSelectTrigger: () => null, + SettingsSelectContent: ({ children }) => env.React.createElement(env.React.Fragment, null, children), + }, + [uiPath("components/ui/confirm-action-popover.tsx")]: { + ConfirmDeletePopover: ({ children, onConfirm }) => children(onConfirm), + }, + }, + }); + const { PermissionRulesSection } = env.loadModule("@liveagent/ui/pages/settings/PermissionRulesSection.tsx"); + const fake = fakeKBrain(); + const host = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(host); + const settle = () => env.act(async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }); + const click = async (element) => { await env.act(async () => { element.click(); }); await settle(); }; + const button = (text) => [...host.querySelectorAll("button")].find((b) => b.textContent.trim() === text); + const rowsOf = (scope) => + [...host.querySelectorAll(`[data-permission-rules="${scope}"] [data-permission-rule]`)].map((row) => row.getAttribute("data-permission-rule")); + const projectHeaderPath = () => + [...host.querySelectorAll('[data-permission-rules="project"] p')].map((p) => p.textContent).find((text) => text.startsWith("D:\\")) ?? ""; + const alerts = () => [...host.querySelectorAll('[role="alert"]')].map((node) => node.textContent).join(" | "); + const type = (value) => env.act(async () => { + const input = host.querySelector("input[data-permission-rule-input]"); + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set.call(input, value); + input.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + const pickProject = async (path) => { + await env.act(async () => { + const select = host.querySelector("select[data-project-select]"); + select.value = path; + select.dispatchEvent(new window.Event("change", { bubbles: true })); + }); + await settle(); + }; + try { + await env.act(async () => root.render(env.React.createElement(PermissionRulesSection, { kbrain: fake.kbrain, settings }))); + await settle(); + await task({ env, host, fake, settle, click, button, rowsOf, projectHeaderPath, alerts, type, pickProject }); + } finally { + await env.act(async () => root.unmount()); + host.remove(); + env.cleanup(); + } +} + +function gate() { + let release; + const promise = new Promise((resolve) => { release = resolve; }); + return { promise, release }; +} + +// Risk 1/2: run() awaits the action and then calls the reload captured when the click +// happened. If the project changes while the action is in flight, that stale reload +// (old workspace) takes a newer ticket, drops the new project's answer and leaves the + +const deleteButtons = (host, scope) => + [...host.querySelectorAll(`[data-permission-rules="${scope}"] button[data-delete-rule]`)].length; +const rowButton = (host, rule) => + host.querySelector(`[data-permission-rule="${rule}"] button[data-delete-rule]`); +const stored = (fake, path) => (fake.store.project[path] ?? []).map((rule) => rule.rule); + +// R2-1: while the chosen project loads, its section shows no other project's rows (and no +// delete buttons); all-project rules stay; the answer then shows the chosen project. +test("r2: switching to a slow project hides the previous project's rows until it answers", async () => { + await fixture(async ({ host, fake, settle, rowsOf, projectHeaderPath, pickProject }) => { + const pending = gate(); + fake.gates.list = (query) => (query.workspace === KBRAIN ? pending.promise : Promise.resolve()); + await pickProject(KBRAIN); + assert.deepEqual(rowsOf("project"), [], "no LiveAgent rows under K-brain while it loads"); + assert.equal(deleteButtons(host, "project"), 0); + assert.deepEqual(rowsOf("user"), ["Bash(npm test:*)"], "all-project rules stay"); + pending.release(); + await settle(); + assert.equal(projectHeaderPath(), KBRAIN); + assert.deepEqual(rowsOf("project"), ["Bash(go test:*)"]); + }); +}); + +// R2-2: A -> B (slow) -> A: A's list is shown again and a delete names A. +test("r2: switching away and back before the slow answer deletes in the shown project", async () => { + await fixture(async ({ host, fake, settle, click, rowsOf, alerts, pickProject }) => { + const pending = gate(); + fake.gates.list = (query) => (query.workspace === KBRAIN ? pending.promise : Promise.resolve()); + await pickProject(KBRAIN); + await pickProject(LIVEAGENT); + assert.deepEqual(rowsOf("project"), ["Bash(rm:*)"]); + await click(rowButton(host, "Bash(rm:*)")); + pending.release(); + await settle(); + assert.equal(alerts(), ""); + assert.deepEqual(stored(fake, LIVEAGENT), [], "LiveAgent's rule is deleted"); + assert.deepEqual(rowsOf("project"), [], `slow K-brain answer must not replace LiveAgent: ${rowsOf("project").join(",")}`); + }); +}); + +// R2-3: a delete in flight while the user switches project names the rule's project and +// the reload afterwards shows the project chosen now. +test("r2: switching project while a delete is in flight deletes in the rule's project", async () => { + await fixture(async ({ host, fake, settle, click, rowsOf, projectHeaderPath, alerts, pickProject }) => { + const pending = gate(); + fake.gates.delete = () => pending.promise; + await click(rowButton(host, "Bash(rm:*)")); + await pickProject(KBRAIN); + pending.release(); + await settle(); + const deletes = fake.calls.filter((c) => c.startsWith('["delete"')); + assert.equal(deletes.at(-1), JSON.stringify(["delete", { id: "r_la0001", scope: "project", workspace: LIVEAGENT }])); + assert.equal(alerts(), ""); + assert.deepEqual(stored(fake, LIVEAGENT), []); + assert.equal(projectHeaderPath(), KBRAIN); + assert.deepEqual(rowsOf("project"), ["Bash(go test:*)"]); + }); +}); + +// R2-4: a switch during the reload that follows an add wins over that reload. +test("r2: switching project during the post-add reload shows the last chosen project", async () => { + await fixture(async ({ fake, settle, click, button, rowsOf, projectHeaderPath, type, pickProject }) => { + const pendingAdd = gate(); + const pendingReload = gate(); + let adds = 0; + fake.gates.add = () => { adds++; return pendingAdd.promise; }; + fake.gates.list = (query) => (adds > 0 && query.workspace === KBRAIN ? pendingReload.promise : Promise.resolve()); + await type("Bash(curl:*)"); + await click(button("添加")); + await pickProject(KBRAIN); + pendingAdd.release(); + await settle(); + await pickProject(DOCS); + pendingReload.release(); + await settle(); + assert.equal(projectHeaderPath(), DOCS); + assert.deepEqual(rowsOf("project"), ["Edit(docs/**)"]); + assert.deepEqual(rowsOf("user"), ["Bash(npm test:*)", "Bash(curl:*)"]); + }); +}); + +// R2-5: a project-scope add stores the rule in the project chosen when Add was pressed, +// even if the user switches while it runs. +test("r2: a project-scope add keeps the project chosen at the click", async () => { + await fixture(async ({ host, fake, settle, click, button, rowsOf, type, pickProject }) => { + const pending = gate(); + fake.gates.add = () => pending.promise; + await type("Bash(make:*)"); + await click(host.querySelector('[data-toggle="project"]')); + await click(button("添加")); + await pickProject(KBRAIN); + pending.release(); + await settle(); + assert.deepEqual(stored(fake, LIVEAGENT), ["Bash(rm:*)", "Bash(make:*)"]); + assert.deepEqual(stored(fake, KBRAIN), ["Bash(go test:*)"]); + assert.deepEqual(rowsOf("project"), ["Bash(go test:*)"]); + }); +}); + +// R2-6: a failed load for the chosen project shows no rows and no delete buttons; picking +// a project that loads recovers. +test("r2: a failed project load shows the error and no stale rows; another project recovers", async () => { + await fixture(async ({ host, fake, rowsOf, alerts, pickProject }) => { + fake.gates.list = (query) => (query.workspace === KBRAIN ? Promise.reject(new Error("project root must be absolute")) : Promise.resolve()); + await pickProject(KBRAIN); + assert.match(alerts(), /project root must be absolute/); + assert.deepEqual(rowsOf("project"), []); + assert.equal(deleteButtons(host, "project") + deleteButtons(host, "user"), 0); + await pickProject(DOCS); + assert.equal(alerts(), ""); + assert.deepEqual(rowsOf("project"), ["Edit(docs/**)"]); + }); +}); + +// R2-7: positive guard for the IME fix: isImeKeyboardEvent must not swallow a real Enter +// (keyCode 13). The keyCode 229 commit case is round 1's IME test. +test("r2: a plain Enter (keyCode 13) in the rule field still adds", async () => { + await fixture(async ({ env, host, fake, settle, type }) => { + const press = (init, keyCode) => env.act(async () => { + const input = host.querySelector("input[data-permission-rule-input]"); + const event = new window.KeyboardEvent("keydown", { bubbles: true, cancelable: true, ...init }); + Object.defineProperty(event, "keyCode", { value: keyCode }); + Object.defineProperty(event, "which", { value: keyCode }); + input.dispatchEvent(event); + }); + await type("Bash(curl:*)"); + await press({ key: "Enter" }, 13); + await settle(); + assert.deepEqual(fake.calls.filter((c) => c.startsWith('["add"')), [JSON.stringify(["add", { rule: "Bash(curl:*)", effect: "deny", scope: "user" }])]); + }); +}); diff --git a/crates/agent-gui/test/settings/review-la3a-permission-rules.test.mjs b/crates/agent-gui/test/settings/review-la3a-permission-rules.test.mjs new file mode 100644 index 000000000..753cd9542 --- /dev/null +++ b/crates/agent-gui/test/settings/review-la3a-permission-rules.test.mjs @@ -0,0 +1,244 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createDomTestEnv } from "../helpers/dom-test-env.mjs"; + +// LA-3a adversarial review. The fake K-brain below keeps rules per project the way +// K-brain does (one file per project root, random rule ids) and answers a DELETE for an +// id that is not in the named project's file with 404 {"error":"permission rule not found"} +// (internal/backend/permission_rules.go, permissions.RemoveRule). Assertions compare +// strings, numbers and booleans only: never pass a DOM node to assert. + +const uiPath = (relative) => + fileURLToPath(new URL(`../../../agent-ui/src/${relative}`, import.meta.url)); + +const LIVEAGENT = "D:\\work\\LiveAgent"; +const KBRAIN = "D:\\work\\K-brain"; + +const settings = { + system: { + workspaceProjects: [ + { id: "a", name: "LiveAgent", path: LIVEAGENT }, + { id: "b", name: "K-brain", path: KBRAIN }, + ], + hiddenWorkspaceProjectPaths: [], + archivedWorkspaceProjectPaths: [], + }, +}; + +function fakeKBrain() { + const store = { + user: [{ id: "r_user01", rule: "Bash(npm test:*)", effect: "allow", scope: "user" }], + project: { + [LIVEAGENT]: [{ id: "r_la0001", rule: "Bash(rm:*)", effect: "deny", scope: "project" }], + [KBRAIN]: [{ id: "r_kb0001", rule: "Bash(go test:*)", effect: "allow", scope: "project" }], + }, + }; + const calls = []; + const gates = { list: null, add: null }; + const wait = (name, arg) => (gates[name] ? gates[name](arg) : Promise.resolve()); + const kbrain = { + isKbrain: true, + getConnection: () => ({ baseUrl: "http://fixture", token: "t" }), + listPermissionRules: async (query) => { + calls.push(JSON.stringify(["list", query.workspace ?? ""])); + await wait("list", query); + return { + ...(query.workspace ? { projectRoot: query.workspace } : {}), + rules: [...store.user, ...(query.workspace ? (store.project[query.workspace] ?? []) : [])], + }; + }, + addPermissionRule: async (input) => { + calls.push(JSON.stringify(["add", input])); + await wait("add", input); + const rule = { id: `r_new${calls.length}`, rule: input.rule, effect: input.effect, scope: input.scope }; + if (input.scope === "user") store.user.push(rule); + else (store.project[input.workspace] ??= []).push(rule); + return rule; + }, + deletePermissionRule: async (ref) => { + calls.push(JSON.stringify(["delete", ref])); + const file = ref.scope === "user" ? store.user : (store.project[ref.workspace] ?? []); + const index = file.findIndex((rule) => rule.id === ref.id); + if (index < 0) throw new Error("permission rule not found"); + file.splice(index, 1); + }, + }; + return { kbrain, store, calls, gates }; +} + +async function fixture(task) { + const env = await createDomTestEnv({ + mocks: { + "@liveagent/ui/components/IconSet": new Proxy({}, { get: () => () => null }), + "@liveagent/ui/components/ui/button": { + Button: ({ children, size, variant, ...props }) => env.React.createElement("button", props, children), + }, + "@liveagent/ui/components/settings/SettingsToggleGroup": { + SettingsToggleGroup: ({ children, value, onValueChange, disabled, ...props }) => + env.React.createElement( + "div", + props, + env.React.Children.map(children, (child) => + env.React.cloneElement(child, { + disabled: disabled || child.props.disabled, + pressed: value.includes(child.props.value), + onSelect: () => onValueChange([child.props.value]), + }), + ), + ), + SettingsToggleGroupItem: ({ children, value, pressed, onSelect, disabled, ...props }) => + env.React.createElement("button", { ...props, disabled, "data-toggle": value, "aria-pressed": pressed, onClick: onSelect }, children), + }, + [uiPath("components/ui/select.tsx")]: { + Select: ({ value, onValueChange, disabled, children }) => + env.React.createElement("select", { value, disabled, "data-project-select": "", onChange: (event) => onValueChange(event.target.value) }, children), + SelectItem: ({ value, children }) => env.React.createElement("option", { value }, children), + SelectValue: () => null, + }, + [uiPath("components/settings/SettingsSelect.tsx")]: { + SettingsSelectTrigger: () => null, + SettingsSelectContent: ({ children }) => env.React.createElement(env.React.Fragment, null, children), + }, + [uiPath("components/ui/confirm-action-popover.tsx")]: { + ConfirmDeletePopover: ({ children, onConfirm }) => children(onConfirm), + }, + }, + }); + const { PermissionRulesSection } = env.loadModule("@liveagent/ui/pages/settings/PermissionRulesSection.tsx"); + const fake = fakeKBrain(); + const host = document.body.appendChild(document.createElement("div")); + const root = env.createRoot(host); + const settle = () => env.act(async () => { for (let i = 0; i < 8; i++) await Promise.resolve(); }); + const click = async (element) => { await env.act(async () => { element.click(); }); await settle(); }; + const button = (text) => [...host.querySelectorAll("button")].find((b) => b.textContent.trim() === text); + const rowsOf = (scope) => + [...host.querySelectorAll(`[data-permission-rules="${scope}"] [data-permission-rule]`)].map((row) => row.getAttribute("data-permission-rule")); + const projectHeaderPath = () => + [...host.querySelectorAll('[data-permission-rules="project"] p')].map((p) => p.textContent).find((text) => text.startsWith("D:\\")) ?? ""; + const alerts = () => [...host.querySelectorAll('[role="alert"]')].map((node) => node.textContent).join(" | "); + const type = (value) => env.act(async () => { + const input = host.querySelector("input[data-permission-rule-input]"); + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set.call(input, value); + input.dispatchEvent(new window.Event("input", { bubbles: true })); + }); + const pickProject = async (path) => { + await env.act(async () => { + const select = host.querySelector("select[data-project-select]"); + select.value = path; + select.dispatchEvent(new window.Event("change", { bubbles: true })); + }); + await settle(); + }; + try { + await env.act(async () => root.render(env.React.createElement(PermissionRulesSection, { kbrain: fake.kbrain, settings }))); + await settle(); + await task({ env, host, fake, settle, click, button, rowsOf, projectHeaderPath, alerts, type, pickProject }); + } finally { + await env.act(async () => root.unmount()); + host.remove(); + env.cleanup(); + } +} + +function gate() { + let release; + const promise = new Promise((resolve) => { release = resolve; }); + return { promise, release }; +} + +// Risk 1/2: run() awaits the action and then calls the reload captured when the click +// happened. If the project changes while the action is in flight, that stale reload +// (old workspace) takes a newer ticket, drops the new project's answer and leaves the +// old project's rules under the new project's header. +test("switching project while an add is in flight shows the new project's rules", async () => { + await fixture(async ({ fake, settle, click, button, rowsOf, projectHeaderPath, type, pickProject }) => { + assert.deepEqual(rowsOf("project"), ["Bash(rm:*)"]); + const pending = gate(); + fake.gates.add = () => pending.promise; + await type("Bash(curl:*)"); + await click(button("添加")); + await pickProject(KBRAIN); + assert.equal(projectHeaderPath(), KBRAIN, "the header follows the chosen project"); + pending.release(); + await settle(); + assert.equal(projectHeaderPath(), KBRAIN); + assert.deepEqual( + rowsOf("project"), + ["Bash(go test:*)"], + `the "${KBRAIN}" header lists another project's rules; list calls: ${fake.calls.filter((c) => c.startsWith('["list"')).join(" ")}`, + ); + }); +}); + +// Follow-on of the case above, end to end against the per-project fake: the row shown +// belongs to LiveAgent but the delete names the chosen project, so K-brain answers 404 and +// the rule the user meant to delete stays. +test("after switching project mid-add, deleting a shown project rule removes that rule", async () => { + await fixture(async ({ host, fake, settle, click, button, rowsOf, alerts, type, pickProject }) => { + const pending = gate(); + fake.gates.add = () => pending.promise; + await type("Bash(curl:*)"); + await click(button("添加")); + await pickProject(KBRAIN); + pending.release(); + await settle(); + const shown = rowsOf("project"); + const target = shown[0]; + const row = [...host.querySelectorAll('[data-permission-rules="project"] [data-permission-rule]')].find( + (node) => node.getAttribute("data-permission-rule") === target, + ); + await click(row.querySelector("button[data-delete-rule]")); + const deletes = fake.calls.filter((c) => c.startsWith('["delete"')); + assert.equal(alerts().includes("permission rule not found"), false, `delete of "${target}" failed: ${deletes.join(" ")}`); + const remaining = [...fake.store.project[LIVEAGENT], ...fake.store.project[KBRAIN]].map((rule) => rule.rule); + assert.equal(remaining.includes(target), false, `"${target}" is still stored after deleting it: ${deletes.join(" ")}`); + }); +}); + +// Risk 1: while the new project's list is loading, the old list stays on screen under +// the new header with its delete buttons enabled (only `busy` disables them); a delete +// then names the new project. +test("a project rule deleted while the next project's list is loading names its own project", async () => { + await fixture(async ({ host, fake, settle, click, rowsOf, alerts, pickProject }) => { + const pending = gate(); + fake.gates.list = (query) => (query.workspace === KBRAIN ? pending.promise : Promise.resolve()); + await pickProject(KBRAIN); + const row = host.querySelector('[data-permission-rules="project"] [data-permission-rule="Bash(rm:*)"]'); + const deletable = Boolean(row) && row.querySelector("button[data-delete-rule]")?.disabled === false; + if (deletable) { + await click(row.querySelector("button[data-delete-rule]")); + const deletes = fake.calls.filter((c) => c.startsWith('["delete"')); + assert.equal( + deletes.at(-1), + JSON.stringify(["delete", { id: "r_la0001", scope: "project", workspace: LIVEAGENT }]), + `LiveAgent's rule was deleted under another project; alerts: ${alerts()}`, + ); + } + pending.release(); + await settle(); + }); +}); + +// Risk 6: WebKit (Tauri on macOS = WKWebView, Safari) fires compositionend before the +// committing keydown, which arrives as Enter with keyCode 229 and isComposing=false. The +// rule field only checks isComposing; ToolApprovalBar and MentionComposer use +// isImeKeyboardEvent (MentionComposerInputUtils.ts), which also treats keyCode 229 as IME. +test("committing an IME candidate with Enter in the rule field does not add the rule (WebKit order)", async () => { + await fixture(async ({ env, host, fake }) => { + const input = host.querySelector("input[data-permission-rule-input]"); + await env.act(async () => { + input.dispatchEvent(new window.CompositionEvent("compositionstart", { bubbles: true })); + Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value").set.call(input, "Edit(文档"); + input.dispatchEvent(new window.Event("input", { bubbles: true })); + input.dispatchEvent(new window.CompositionEvent("compositionend", { bubbles: true, data: "文档" })); + const enter = new window.KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }); + Object.defineProperty(enter, "keyCode", { value: 229 }); + Object.defineProperty(enter, "which", { value: 229 }); + input.dispatchEvent(enter); + for (let i = 0; i < 5; i++) await Promise.resolve(); + }); + const adds = fake.calls.filter((c) => c.startsWith('["add"')); + assert.deepEqual(adds, [], `the IME commit Enter added a half-typed rule: ${adds.join(" ")}`); + }); +}); diff --git a/crates/agent-ui/src/i18n/translations/enUSSettings.ts b/crates/agent-ui/src/i18n/translations/enUSSettings.ts index 1a00af291..aeb0624fd 100644 --- a/crates/agent-ui/src/i18n/translations/enUSSettings.ts +++ b/crates/agent-ui/src/i18n/translations/enUSSettings.ts @@ -70,6 +70,29 @@ export const EN_US_SETTINGS_TRANSLATIONS = { "settings.showSidebarShortcut": "Show {name} in sidebar", "settings.navSystem": "System", "settings.navSystemTools": "System Tools", + "settings.navPermissions": "Permissions", + "settings.permissions.title": "Permission rules", + "settings.permissions.description": + 'Rules saved with "Allow and remember" on an approval, and rules added here. Deny rules win over allow rules, and a deleted rule stops applying at once. Rules for a single session stay with that session and are not listed here.', + "settings.permissions.userScope": "All projects", + "settings.permissions.projectScope": "This project", + "settings.permissions.project": "Choose a project", + "settings.permissions.noProject": "No project to choose", + "settings.permissions.empty": "No rules yet", + "settings.permissions.allow": "Allow", + "settings.permissions.deny": "Deny", + "settings.permissions.addedAt": "Added {time}", + "settings.permissions.fromSession": "From session {id}", + "settings.permissions.delete": "Delete rule", + "settings.permissions.addTitle": "Add a rule", + "settings.permissions.rule": "Rule", + "settings.permissions.rulePlaceholder": "For example Bash(rm:*), Edit(src/**), Read", + "settings.permissions.effect": "Allow or deny", + "settings.permissions.scope": "Applies to", + "settings.permissions.add": "Add", + "settings.permissions.upgrade": + "This K-brain version has no permission rules. Upgrade the backend to use them.", + "settings.permissions.connectionChanged": "The K-brain connection changed. Reopen this page.", "settings.navStt": "Speech to Text", "settings.toolPermissionsOverridden": "{count} customized", "settings.toolPolicy.allow": "Allow", diff --git a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts index dc8698a08..4fa210671 100644 --- a/crates/agent-ui/src/i18n/translations/zhCNSettings.ts +++ b/crates/agent-ui/src/i18n/translations/zhCNSettings.ts @@ -63,6 +63,28 @@ export const ZH_CN_SETTINGS_TRANSLATIONS = { "settings.showSidebarShortcut": "在侧栏显示 {name}", "settings.navSystem": "系统设置", "settings.navSystemTools": "系统工具", + "settings.navPermissions": "权限", + "settings.permissions.title": "权限规则", + "settings.permissions.description": + "审批时选「允许并记住」存下的规则,以及在这里手动添加的规则。拒绝规则优先于允许规则,删除后立即生效。本会话的规则只在那个会话里有效,不在这里列出。", + "settings.permissions.userScope": "所有项目", + "settings.permissions.projectScope": "本项目", + "settings.permissions.project": "选择项目", + "settings.permissions.noProject": "没有可选的项目", + "settings.permissions.empty": "还没有规则", + "settings.permissions.allow": "允许", + "settings.permissions.deny": "拒绝", + "settings.permissions.addedAt": "添加于 {time}", + "settings.permissions.fromSession": "来自会话 {id}", + "settings.permissions.delete": "删除规则", + "settings.permissions.addTitle": "添加规则", + "settings.permissions.rule": "规则", + "settings.permissions.rulePlaceholder": "例如 Bash(rm:*)、Edit(src/**)、Read", + "settings.permissions.effect": "允许还是拒绝", + "settings.permissions.scope": "生效范围", + "settings.permissions.add": "添加", + "settings.permissions.upgrade": "当前 K-brain 版本不支持权限规则,升级后端后可用。", + "settings.permissions.connectionChanged": "K-brain 连接已变化,请重新打开这一页。", "settings.navStt": "语音输入", "settings.toolPermissionsOverridden": "{count} 项已自定义", "settings.toolPolicy.allow": "允许", diff --git a/crates/agent-ui/src/pages/settings/PermissionRulesSection.tsx b/crates/agent-ui/src/pages/settings/PermissionRulesSection.tsx new file mode 100644 index 000000000..87f6ff1f9 --- /dev/null +++ b/crates/agent-ui/src/pages/settings/PermissionRulesSection.tsx @@ -0,0 +1,401 @@ +import { workspaceProjectPathKey } from "@liveagent/app/lib/settings"; +import type { SettingsSectionProps } from "@liveagent/app/pages/settings/types"; +import { Trash2 } from "@liveagent/ui/components/IconSet"; +import { SettingsSection } from "@liveagent/ui/components/settings/SettingsLayout"; +import { + SettingsToggleGroup, + SettingsToggleGroupItem, +} from "@liveagent/ui/components/settings/SettingsToggleGroup"; +import { Button } from "@liveagent/ui/components/ui/button"; +import { useLocale } from "@liveagent/ui/i18n"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { isImeKeyboardEvent } from "../../components/chat/MentionComposerInputUtils"; +import { + SettingsSelectContent, + SettingsSelectTrigger, +} from "../../components/settings/SettingsSelect"; +import { ConfirmDeletePopover } from "../../components/ui/confirm-action-popover"; +import { Select, SelectItem, SelectValue } from "../../components/ui/select"; +import type { + KBrainSettingsAdapter, + PermissionRule, + PermissionRuleEffect, + PermissionRuleInput, + PermissionRuleList, +} from "./kbrainSettingsAdapter"; + +const EFFECTS: readonly PermissionRuleEffect[] = ["deny", "allow"]; +const ADD_SCOPES: readonly PermissionRuleInput["scope"][] = ["user", "project"]; + +function connectionScope(kbrain: KBrainSettingsAdapter): string { + const connection = kbrain.getConnection(); + return JSON.stringify(connection ? [connection.baseUrl, connection.token] : null); +} + +function messageOf(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); +} + +function formatTime(value: string | undefined): string { + if (!value) return ""; + const time = new Date(value); + return Number.isNaN(time.getTime()) + ? value + : time.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" }); +} + +/** + * Settings → 权限: the rules "allow and remember" stored, for all projects and for the + * chosen project, with delete and a form to add allow or deny rules by hand. Session rules + * stay with their session and are not listed here. + */ +export function PermissionRulesSection({ + kbrain, + settings, +}: { + kbrain: KBrainSettingsAdapter; + settings: SettingsSectionProps["settings"]; +}) { + const { t } = useLocale(); + // Archived and hidden projects are not offered, the same as in scheduled tasks. + const projects = useMemo(() => { + const excluded = new Set( + [ + ...settings.system.archivedWorkspaceProjectPaths, + ...settings.system.hiddenWorkspaceProjectPaths, + ].map(workspaceProjectPathKey), + ); + return settings.system.workspaceProjects + .filter((project) => !excluded.has(workspaceProjectPathKey(project.path))) + .map((project) => ({ path: project.path, name: project.name || project.path })); + }, [settings]); + const [chosen, setChosen] = useState(""); + const workspace = projects.some((project) => project.path === chosen) + ? chosen + : (projects[0]?.path ?? ""); + + const [supported, setSupported] = useState(kbrain.getCapabilities ? null : true); + // The list remembers the project it was loaded for: deleting a project rule names that + // project, and another project's rules are not shown while the chosen one loads. + const [loaded, setLoaded] = useState<{ workspace: string; list: PermissionRuleList } | null>( + null, + ); + const workspaceRef = useRef(workspace); + workspaceRef.current = workspace; + const [loading, setLoading] = useState(false); + const [loadError, setLoadError] = useState(""); + const [draft, setDraft] = useState(""); + const [effect, setEffect] = useState("deny"); + const [scope, setScope] = useState("user"); + const [busy, setBusy] = useState(false); + const [actionError, setActionError] = useState(""); + const epoch = useRef(0); + + useEffect(() => { + if (!kbrain.getCapabilities) return; + let active = true; + void kbrain + .getCapabilities() + .then((capabilities) => { + if (active) setSupported(Number(capabilities.permission_rules) >= 1); + }) + .catch((reason) => { + if (!active) return; + setSupported(true); + setLoadError(messageOf(reason)); + }); + return () => { + active = false; + }; + }, [kbrain]); + + // Each load gets a ticket; an answer for an older project or connection is dropped. + const reload = useCallback( + async (target: string) => { + if (!kbrain.listPermissionRules) return; + const ticket = ++epoch.current; + const connection = connectionScope(kbrain); + setLoading(true); + setLoadError(""); + try { + const next = await kbrain.listPermissionRules(target ? { workspace: target } : {}); + if (ticket !== epoch.current) return; + if (connection !== connectionScope(kbrain)) + throw new Error(t("settings.permissions.connectionChanged")); + setLoaded({ workspace: target, list: next }); + } catch (reason) { + if (ticket !== epoch.current) return; + setLoaded(null); + setLoadError(messageOf(reason)); + } finally { + if (ticket === epoch.current) setLoading(false); + } + }, + [kbrain, t], + ); + + useEffect(() => { + if (supported) void reload(workspace); + return () => { + ++epoch.current; + }; + }, [reload, supported, workspace]); + + const addScope = workspace ? scope : "user"; + + async function run(action: () => Promise) { + setBusy(true); + setActionError(""); + try { + await action(); + // The project chosen now: the user may have switched while the action ran. + await reload(workspaceRef.current); + return true; + } catch (reason) { + setActionError(messageOf(reason)); + return false; + } finally { + setBusy(false); + } + } + + async function add() { + const rule = draft.trim(); + if (!rule || busy || !kbrain.addPermissionRule) return; + const addRule = kbrain.addPermissionRule; + const input: PermissionRuleInput = { + rule, + effect, + scope: addScope, + ...(addScope === "project" ? { workspace } : {}), + }; + if (await run(() => addRule(input))) setDraft(""); + } + + function remove(rule: PermissionRule, ruleWorkspace: string) { + const deleteRule = kbrain.deletePermissionRule; + if (!deleteRule) return; + void run(() => + deleteRule({ + id: rule.id, + scope: rule.scope, + ...(rule.scope === "project" ? { workspace: ruleWorkspace } : {}), + }), + ); + } + + if (supported === null) return null; + + const list = loaded?.list ?? null; + const userRules = list?.rules.filter((rule) => rule.scope === "user") ?? []; + const projectRules = list?.rules.filter((rule) => rule.scope === "project") ?? []; + const projectLoaded = !!loaded && loaded.workspace === workspace; + const loadedWorkspace = loaded?.workspace ?? ""; + + const renderRules = (rules: PermissionRule[]) => + rules.length === 0 ? ( +

{t("settings.permissions.empty")}

+ ) : ( +
    + {rules.map((rule) => ( +
  • +
    + {rule.rule} +

    + {[ + rule.createdAt + ? t("settings.permissions.addedAt").replace( + "{time}", + formatTime(rule.createdAt), + ) + : "", + rule.sourceSession + ? t("settings.permissions.fromSession").replace( + "{id}", + rule.sourceSession.slice(0, 8), + ) + : "", + ] + .filter(Boolean) + .join(" · ")} +

    +
    + + {t(`settings.permissions.${rule.effect}`)} + + remove(rule, loadedWorkspace)}> + {(open) => ( + + )} + +
  • + ))} +
+ ); + + return ( + + {!supported ? ( +

+ {t("settings.permissions.upgrade")} +

+ ) : ( +
+ {loadError ? ( +

+ {loadError} +

+ ) : null} + +
+

+ {t("settings.permissions.userScope")} +

+ {list ? renderRules(userRules) : null} +
+ +
+
+

+ {t("settings.permissions.projectScope")} +

+ {projects.length > 0 ? ( + + ) : null} +
+ {projects.length === 0 ? ( +

+ {t("settings.permissions.noProject")} +

+ ) : projectLoaded ? ( + <> +

{workspace}

+ {renderRules(projectRules)} + + ) : null} +
+ +
+

+ {t("settings.permissions.addTitle")} +

+ setDraft(event.target.value)} + onKeyDown={(event) => { + // The Enter that commits an IME candidate (keyCode 229 in WebKit) picks + // a word; it does not add the rule. + if (event.key === "Enter" && !isImeKeyboardEvent(event)) { + event.preventDefault(); + void add(); + } + }} + className="h-8 w-full min-w-0 rounded-md border border-border bg-background px-2 font-mono text-xs text-foreground outline-none focus-visible:ring-2 focus-visible:ring-primary/35" + /> +
+ { + const next = values[0] as PermissionRuleEffect | undefined; + if (next && EFFECTS.includes(next)) setEffect(next); + }} + > + {EFFECTS.map((value) => ( + + {t(`settings.permissions.${value}`)} + + ))} + + { + const next = values[0] as PermissionRuleInput["scope"] | undefined; + if (next && ADD_SCOPES.includes(next)) setScope(next); + }} + > + {ADD_SCOPES.map((value) => ( + + {t( + value === "user" + ? "settings.permissions.userScope" + : "settings.permissions.projectScope", + )} + + ))} + + +
+ {actionError ? ( +

+ {actionError} +

+ ) : null} +
+
+ )} +
+ ); +} diff --git a/crates/agent-ui/src/pages/settings/SettingsPage.tsx b/crates/agent-ui/src/pages/settings/SettingsPage.tsx index 9a6dc136c..a6d281b60 100644 --- a/crates/agent-ui/src/pages/settings/SettingsPage.tsx +++ b/crates/agent-ui/src/pages/settings/SettingsPage.tsx @@ -13,6 +13,7 @@ import { Key, Mic, Settings2, + ShieldCheck, Wrench, Zap, } from "@liveagent/ui/components/IconSet"; @@ -24,6 +25,7 @@ import { CalendarSection } from "./CalendarSection"; import { CronSection } from "./CronSection"; import { HooksSection } from "./HooksSection"; import { MemoryPanel } from "./memory/MemoryPanel"; +import { PermissionRulesSection } from "./PermissionRulesSection"; import { ProvidersSection } from "./ProvidersSection"; import { RemoteSection } from "./RemoteSection"; import { ResourceHubSection } from "./ResourceHubSection"; @@ -177,6 +179,23 @@ export function SettingsPage(props: SettingsPageProps) { ), }, + // Only hosts that can reach the K-brain rules API get the page. + ...(props.settingsHost?.kbrain?.listPermissionRules + ? [ + { + id: "permissions", + groupKey: "settings.groupIntelligence", + groupOrder: 25, + order: 25, + labelKey: "settings.navPermissions", + icon: , + render: () => + props.settingsHost?.kbrain ? ( + + ) : null, + } satisfies SettingsSectionDefinition, + ] + : []), { id: "stt", groupKey: "settings.groupIntelligence", diff --git a/crates/agent-ui/src/pages/settings/kbrainSettingsAdapter.ts b/crates/agent-ui/src/pages/settings/kbrainSettingsAdapter.ts index af97faea8..e159fa16c 100644 --- a/crates/agent-ui/src/pages/settings/kbrainSettingsAdapter.ts +++ b/crates/agent-ui/src/pages/settings/kbrainSettingsAdapter.ts @@ -70,6 +70,31 @@ export type KBrainRuntimeConnection = { token: string; }; +export type PermissionRuleScope = "session" | "project" | "user"; +export type PermissionRuleEffect = "allow" | "deny"; + +/** One remembered rule from K-brain `/v1/permissions/rules`. */ +export type PermissionRule = { + id: string; + rule: string; + effect: PermissionRuleEffect; + scope: PermissionRuleScope; + createdAt?: string; + sourceSession?: string; +}; + +export type PermissionRuleList = { projectRoot?: string; rules: PermissionRule[] }; + +/** A rule to add; `workspace` names the project for project scope. */ +export type PermissionRuleInput = { + rule: string; + effect: PermissionRuleEffect; + scope: "project" | "user"; + workspace?: string; +}; + +export type PermissionRuleRef = { id: string; scope: PermissionRuleScope; workspace?: string }; + export type KBrainSettingsAdapter = { isKbrain: true; runtimeConnection: () => KBrainRuntimeConnection | null; @@ -81,6 +106,14 @@ export type KBrainSettingsAdapter = { getCapabilities?: () => Promise>; /** Parsed `/v1/health` handshake for diagnostics; only hosts talking to K-brain directly provide it. */ getHealth?: () => Promise; + /** + * K-brain permission rules (`capabilities.permission_rules`). Lists all-project rules and, + * with `workspace`, that project's rules. Hosts that cannot reach the rules API leave + * these out and Settings hides the page. + */ + listPermissionRules?: (query: { workspace?: string }) => Promise; + addPermissionRule?: (input: PermissionRuleInput) => Promise; + deletePermissionRule?: (ref: PermissionRuleRef) => Promise; }; export type SettingsHostAdapter = {