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/core/tests/tools | |
| 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/core/tests/tools')
| -rw-r--r-- | packages/core/tests/tools/bash-arity.test.ts | 36 | ||||
| -rw-r--r-- | packages/core/tests/tools/run-shell.test.ts | 78 |
2 files changed, 114 insertions, 0 deletions
diff --git a/packages/core/tests/tools/bash-arity.test.ts b/packages/core/tests/tools/bash-arity.test.ts new file mode 100644 index 0000000..a01a6a5 --- /dev/null +++ b/packages/core/tests/tools/bash-arity.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { prefix } from "../../src/tools/bash-arity.js"; + +describe("BashArity.prefix", () => { + it("returns arity-2 prefix for known command 'git'", () => { + expect(prefix(["git", "checkout", "main"])).toEqual(["git", "checkout"]); + }); + + it("returns arity-3 prefix for npm", () => { + expect(prefix(["npm", "run", "dev"])).toEqual(["npm", "run", "dev"]); + }); + + it("returns arity-2 prefix for bun", () => { + expect(prefix(["bun", "install", "--frozen-lockfile"])).toEqual(["bun", "install"]); + }); + + it("returns just the command for unknown command", () => { + expect(prefix(["unknowncmd", "arg1", "arg2"])).toEqual(["unknowncmd"]); + }); + + it("returns empty array for empty tokens", () => { + expect(prefix([])).toEqual([]); + }); + + it("handles single token for unknown command", () => { + expect(prefix(["ls"])).toEqual(["ls"]); + }); + + it("handles git with fewer tokens than arity", () => { + expect(prefix(["git"])).toEqual(["git"]); + }); + + it("handles case-insensitive matching", () => { + expect(prefix(["GIT", "checkout", "main"])).toEqual(["GIT", "checkout"]); + }); +}); diff --git a/packages/core/tests/tools/run-shell.test.ts b/packages/core/tests/tools/run-shell.test.ts new file mode 100644 index 0000000..cb66d1c --- /dev/null +++ b/packages/core/tests/tools/run-shell.test.ts @@ -0,0 +1,78 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createRunShellTool } from "../../src/tools/run-shell.js"; + +describe("run_shell tool", () => { + let workDir: string; + + beforeEach(async () => { + workDir = await mkdtemp(join(tmpdir(), "dispatch-test-")); + }); + + afterEach(async () => { + await rm(workDir, { recursive: true, force: true }); + }); + + it("executes a simple echo command", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "echo hello" }); + const result = JSON.parse(raw); + expect(result.stdout.trim()).toBe("hello"); + expect(result.exitCode).toBe(0); + }); + + it("returns non-zero exit code on failure", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "exit 42" }); + const result = JSON.parse(raw); + expect(result.exitCode).toBe(42); + }); + + it("captures stderr", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "echo errormsg >&2" }); + const result = JSON.parse(raw); + expect(result.stderr.trim()).toBe("errormsg"); + }); + + it("handles timeout", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "sleep 10", timeout: 100 }); + const result = JSON.parse(raw); + // Either times out (non-zero exit) or returns an error + expect(result.exitCode !== 0 || result.error !== undefined).toBe(true); + }, 5000); + + it("executes in the working directory", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "pwd" }); + const result = JSON.parse(raw); + // On macOS /tmp is symlinked; use includes check + expect(result.stdout.trim()).toContain(workDir.replace(/^\/private/, "")); + }); + + it("calls onOutput callback with stdout chunks", async () => { + const tool = createRunShellTool(workDir); + const onOutput = vi.fn(); + const raw = await tool.execute({ command: "echo streaming" }, { onOutput }); + const result = JSON.parse(raw); + expect(result.stdout.trim()).toBe("streaming"); + expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("streaming"), "stdout"); + }); + + it("calls onOutput callback with stderr chunks", async () => { + const tool = createRunShellTool(workDir); + const onOutput = vi.fn(); + await tool.execute({ command: "echo errdata >&2" }, { onOutput }); + expect(onOutput).toHaveBeenCalledWith(expect.stringContaining("errdata"), "stderr"); + }); + + it("works without context (backward compatible)", async () => { + const tool = createRunShellTool(workDir); + const raw = await tool.execute({ command: "echo nocontext" }); + const result = JSON.parse(raw); + expect(result.stdout.trim()).toBe("nocontext"); + }); +}); |
