diff options
| author | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-19 23:20:41 +0900 |
| commit | a38d5b1279db6f9de5228c173019fc2ac08daec3 (patch) | |
| tree | 32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/frontend/tests | |
| parent | 0ae805b28b5160b8d9fb43635fa172961f6550cc (diff) | |
| download | dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.tar.gz dispatch-a38d5b1279db6f9de5228c173019fc2ac08daec3.zip | |
feat: Phase 2 — shell permissions, tree-sitter analysis, permission UI
Permission engine:
- Rule-based engine: wildcard matching, last-match-wins, reject cascade
- PermissionService with pending/approved state, PermissionChecker interface
- dispatch.yaml config loader with per-permission pattern rules
Shell tool:
- run_shell tool with child_process spawn, timeout, streaming output
- Tree-sitter static analysis (web-tree-sitter + tree-sitter-bash WASM)
- BashArity command normalization for 'always allow' patterns
- FILE_COMMANDS set: rm, cp, mv, mkdir, ls, find, grep, cat, etc.
Agent loop refactored:
- Removed maxSteps, manual step loop with tool execution
- Permission checks on shell commands (external_directory only)
- Permission checks on file tools outside workspace boundary
- Symlink bypass fix (realpathSync), .. false positive fix
- Shell output streaming via Promise.race + setImmediate polling
API layer:
- PermissionManager wraps PermissionService, broadcasts via WebSocket
- WebSocket handles permission-reply messages from frontend
- Config loaded from dispatch.yaml, converted to ruleset
Frontend:
- Permission prompt modal (native dialog, focus trap, ARIA)
- Always-allow confirmation flow with pattern preview
- Shell output display (live streaming + final parsed result)
- Permission log panel (fixed bottom-right overlay)
- Exit code badge (green 0, red non-zero)
134 tests, typecheck clean on all 3 packages
Diffstat (limited to 'packages/frontend/tests')
| -rw-r--r-- | packages/frontend/tests/chat-store.test.ts | 308 |
1 files changed, 305 insertions, 3 deletions
diff --git a/packages/frontend/tests/chat-store.test.ts b/packages/frontend/tests/chat-store.test.ts index 11b7deb..db1132c 100644 --- a/packages/frontend/tests/chat-store.test.ts +++ b/packages/frontend/tests/chat-store.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it } from "vitest"; -import type { AgentEvent, ContentSegment } from "../src/lib/types.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentEvent, ContentSegment, LogEntry, PermissionPrompt } from "../src/lib/types.js"; // We test the logic inline since runes require svelte compilation context. // The chat store logic is tested via a plain reimplementation of the same logic. @@ -17,10 +17,12 @@ interface ChatMessage { } // Plain JS version of the chat store logic (no runes) for unit testing -function createTestStore() { +function createTestStore(wsSend?: (data: unknown) => void) { let messages: ChatMessage[] = []; let agentStatus: "idle" | "running" | "error" = "idle"; let currentAssistantId: string | null = null; + let pendingPermissions: PermissionPrompt[] = []; + let permissionLog: LogEntry[] = []; function getCurrentAssistantMessage(): ChatMessage | null { if (!currentAssistantId) return null; @@ -142,6 +144,34 @@ function createTestStore() { agentStatus = "error"; break; } + case "permission-prompt": { + pendingPermissions = event.pending; + break; + } + case "shell-output": { + messages = messages.map((m) => { + if (m.id === currentAssistantId) { + return { + ...m, + content: m.content.map((seg, i) => { + if (seg.type === "tool-call" && i === m.content.length - 1) { + const prev = seg.shellOutput ?? { stdout: "", stderr: "" }; + return { + ...seg, + shellOutput: + event.stream === "stdout" + ? { ...prev, stdout: prev.stdout + event.data } + : { ...prev, stderr: prev.stderr + event.data }, + }; + } + return seg; + }), + }; + } + return m; + }); + break; + } } } @@ -155,6 +185,23 @@ function createTestStore() { currentAssistantId = null; } + function replyPermission(id: string, reply: "once" | "always" | "reject") { + const prompt = pendingPermissions.find((p) => p.id === id); + if (wsSend) wsSend({ type: "permission-reply", id, reply }); + pendingPermissions = pendingPermissions.filter((p) => p.id !== id); + if (prompt) { + const entry: LogEntry = { + id: generateId(), + permission: prompt.permission, + patterns: prompt.patterns, + action: reply, + timestamp: new Date().toISOString(), + description: prompt.description, + }; + permissionLog = [...permissionLog, entry]; + } + } + function clear() { messages = []; currentAssistantId = null; @@ -168,8 +215,15 @@ function createTestStore() { get agentStatus() { return agentStatus; }, + get pendingPermissions() { + return pendingPermissions; + }, + get permissionLog() { + return permissionLog; + }, handleEvent, sendMessage, + replyPermission, clear, }; } @@ -314,3 +368,251 @@ describe("chat store logic", () => { expect(store.messages[0]?.thinking).toBe("First thought. Second thought."); }); }); + +describe("permission-prompt handling", () => { + let store: ReturnType<typeof createTestStore>; + + beforeEach(() => { + store = createTestStore(); + }); + + it("permission-prompt sets pendingPermissions", () => { + const prompt: PermissionPrompt = { + id: "p1", + permission: "bash", + patterns: ["*"], + always: ["*"], + description: "Run a command", + metadata: { command: "ls" }, + }; + store.handleEvent({ type: "permission-prompt", pending: [prompt] }); + expect(store.pendingPermissions).toHaveLength(1); + expect(store.pendingPermissions[0]?.id).toBe("p1"); + }); + + it("permission-prompt replaces previous pending permissions", () => { + const p1: PermissionPrompt = { + id: "p1", + permission: "bash", + patterns: [], + always: [], + description: "First", + metadata: {}, + }; + const p2: PermissionPrompt = { + id: "p2", + permission: "read", + patterns: [], + always: [], + description: "Second", + metadata: {}, + }; + store.handleEvent({ type: "permission-prompt", pending: [p1] }); + store.handleEvent({ type: "permission-prompt", pending: [p2] }); + expect(store.pendingPermissions).toHaveLength(1); + expect(store.pendingPermissions[0]?.id).toBe("p2"); + }); + + it("replyPermission removes the permission from pending and calls wsSend", () => { + const mockSend = vi.fn(); + const storeWithSend = createTestStore(mockSend); + const prompt: PermissionPrompt = { + id: "p1", + permission: "bash", + patterns: [], + always: [], + description: "Run command", + metadata: { command: "echo hi" }, + }; + storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] }); + storeWithSend.replyPermission("p1", "once"); + expect(storeWithSend.pendingPermissions).toHaveLength(0); + expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p1", reply: "once" }); + }); + + it("replyPermission with 'always' sends correct payload", () => { + const mockSend = vi.fn(); + const storeWithSend = createTestStore(mockSend); + const prompt: PermissionPrompt = { + id: "p2", + permission: "read", + patterns: ["src/**"], + always: ["src/**"], + description: "Read a file", + metadata: { filepath: "src/foo.ts" }, + }; + storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] }); + storeWithSend.replyPermission("p2", "always"); + expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p2", reply: "always" }); + expect(storeWithSend.pendingPermissions).toHaveLength(0); + }); + + it("replyPermission with 'reject' removes the permission", () => { + const mockSend = vi.fn(); + const storeWithSend = createTestStore(mockSend); + const prompt: PermissionPrompt = { + id: "p3", + permission: "edit", + patterns: [], + always: [], + description: "Edit a file", + metadata: {}, + }; + storeWithSend.handleEvent({ type: "permission-prompt", pending: [prompt] }); + storeWithSend.replyPermission("p3", "reject"); + expect(storeWithSend.pendingPermissions).toHaveLength(0); + expect(mockSend).toHaveBeenCalledWith({ type: "permission-reply", id: "p3", reply: "reject" }); + }); +}); + +describe("permission log", () => { + let store: ReturnType<typeof createTestStore>; + + beforeEach(() => { + store = createTestStore(vi.fn()); + }); + + it("starts with empty permission log", () => { + expect(store.permissionLog).toHaveLength(0); + }); + + it("replyPermission adds an entry to permissionLog", () => { + const mockSend = vi.fn(); + const s = createTestStore(mockSend); + const prompt: PermissionPrompt = { + id: "p1", + permission: "bash", + patterns: ["*"], + always: ["*"], + description: "Run a command", + metadata: {}, + }; + s.handleEvent({ type: "permission-prompt", pending: [prompt] }); + s.replyPermission("p1", "once"); + expect(s.permissionLog).toHaveLength(1); + expect(s.permissionLog[0]?.permission).toBe("bash"); + expect(s.permissionLog[0]?.action).toBe("once"); + expect(s.permissionLog[0]?.description).toBe("Run a command"); + }); + + it("permissionLog accumulates multiple entries", () => { + const mockSend = vi.fn(); + const s = createTestStore(mockSend); + const p1: PermissionPrompt = { + id: "p1", + permission: "bash", + patterns: [], + always: [], + description: "First", + metadata: {}, + }; + const p2: PermissionPrompt = { + id: "p2", + permission: "read", + patterns: [], + always: [], + description: "Second", + metadata: {}, + }; + s.handleEvent({ type: "permission-prompt", pending: [p1, p2] }); + s.replyPermission("p1", "always"); + s.replyPermission("p2", "reject"); + expect(s.permissionLog).toHaveLength(2); + expect(s.permissionLog[0]?.action).toBe("always"); + expect(s.permissionLog[1]?.action).toBe("reject"); + }); + + it("replyPermission for unknown id does not add to log", () => { + const s = createTestStore(vi.fn()); + s.replyPermission("nonexistent", "once"); + expect(s.permissionLog).toHaveLength(0); + }); +}); + +// Shell output parsing logic (mirrors ToolCallDisplay logic) +function parseShellResult(result: string): { stdout: string; stderr: string; exitCode: number } | null { + try { + const parsed = JSON.parse(result) as unknown; + if ( + parsed !== null && + typeof parsed === "object" && + "stdout" in parsed && + "stderr" in parsed && + "exitCode" in parsed + ) { + const p = parsed as Record<string, unknown>; + return { + stdout: String(p.stdout ?? ""), + stderr: String(p.stderr ?? ""), + exitCode: Number(p.exitCode ?? 0), + }; + } + return null; + } catch { + return null; + } +} + +describe("shell output parsing", () => { + it("parses a valid shell result JSON", () => { + const result = JSON.stringify({ stdout: "hello\n", stderr: "", exitCode: 0 }); + const parsed = parseShellResult(result); + expect(parsed).not.toBeNull(); + expect(parsed?.stdout).toBe("hello\n"); + expect(parsed?.stderr).toBe(""); + expect(parsed?.exitCode).toBe(0); + }); + + it("parses non-zero exit code and stderr", () => { + const result = JSON.stringify({ stdout: "", stderr: "error: not found", exitCode: 1 }); + const parsed = parseShellResult(result); + expect(parsed?.exitCode).toBe(1); + expect(parsed?.stderr).toBe("error: not found"); + }); + + it("returns null for invalid JSON", () => { + expect(parseShellResult("not json")).toBeNull(); + }); + + it("returns null for JSON that lacks required fields", () => { + expect(parseShellResult(JSON.stringify({ stdout: "foo" }))).toBeNull(); + }); + + it("returns null for non-object JSON", () => { + expect(parseShellResult(JSON.stringify(42))).toBeNull(); + }); +}); + +describe("shell-output event handling", () => { + it("shell-output stdout appends to last tool-call shellOutput", () => { + const s = createTestStore(); + s.handleEvent({ + type: "tool-call", + toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } }, + }); + s.handleEvent({ type: "shell-output", data: "file1\n", stream: "stdout" }); + s.handleEvent({ type: "shell-output", data: "file2\n", stream: "stdout" }); + const seg = s.messages[0]?.content[0]; + if (seg?.type === "tool-call") { + expect(seg.shellOutput?.stdout).toBe("file1\nfile2\n"); + expect(seg.shellOutput?.stderr).toBe(""); + } else { + expect.fail("Expected tool-call segment"); + } + }); + + it("shell-output stderr appends to last tool-call shellOutput stderr", () => { + const s = createTestStore(); + s.handleEvent({ + type: "tool-call", + toolCall: { id: "tc1", name: "run_shell", arguments: { command: "ls" } }, + }); + s.handleEvent({ type: "shell-output", data: "err line\n", stream: "stderr" }); + const seg = s.messages[0]?.content[0]; + if (seg?.type === "tool-call") { + expect(seg.shellOutput?.stderr).toBe("err line\n"); + } else { + expect.fail("Expected tool-call segment"); + } + }); +}); |
