summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
committerAdam Malczewski <[email protected]>2026-05-19 23:20:41 +0900
commita38d5b1279db6f9de5228c173019fc2ac08daec3 (patch)
tree32c3a535d0b74872ef952b4a44d4d5ba2ec9d638 /packages/core/tests
parent0ae805b28b5160b8d9fb43635fa172961f6550cc (diff)
downloaddispatch-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')
-rw-r--r--packages/core/tests/agent/agent.test.ts130
-rw-r--r--packages/core/tests/config/loader.test.ts166
-rw-r--r--packages/core/tests/permission/evaluate.test.ts68
-rw-r--r--packages/core/tests/permission/service.test.ts95
-rw-r--r--packages/core/tests/permission/wildcard.test.ts50
-rw-r--r--packages/core/tests/tools/bash-arity.test.ts36
-rw-r--r--packages/core/tests/tools/run-shell.test.ts78
7 files changed, 555 insertions, 68 deletions
diff --git a/packages/core/tests/agent/agent.test.ts b/packages/core/tests/agent/agent.test.ts
index 92df90e..5be210a 100644
--- a/packages/core/tests/agent/agent.test.ts
+++ b/packages/core/tests/agent/agent.test.ts
@@ -40,15 +40,9 @@ async function* makeFullStream(
}
}
-interface MockStreamOptions {
- events: Array<{ type: string; [key: string]: unknown }>;
- steps?: Array<{ toolResults: Array<{ toolCallId: string; result: unknown }> }>;
-}
-
-function makeMockStreamResult(opts: MockStreamOptions) {
+function makeMockStreamResult(events: Array<{ type: string; [key: string]: unknown }>) {
return {
- fullStream: makeFullStream(opts.events),
- steps: Promise.resolve(opts.steps ?? []),
+ fullStream: makeFullStream(events),
} as ReturnType<typeof import("ai").streamText>;
}
@@ -66,18 +60,16 @@ describe("Agent", () => {
it("yields running then idle status events around a simple message", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Hello!" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Hello!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -97,19 +89,17 @@ describe("Agent", () => {
it("yields text-delta events", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Hello" },
- { type: "text-delta", textDelta: " world" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Hello" },
+ { type: "text-delta", textDelta: " world" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -127,18 +117,16 @@ describe("Agent", () => {
it("adds user message and assistant message to history", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Response" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Response" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -160,18 +148,16 @@ describe("Agent", () => {
it("yields done event with final message", async () => {
const { streamText } = await import("ai");
vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
- { type: "text-delta", textDelta: "Done!" },
- {
- type: "finish",
- finishReason: "stop",
- usage: {},
- providerMetadata: undefined,
- response: {},
- },
- ],
- }),
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Done!" },
+ {
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
+ },
+ ]),
);
const agent = new Agent(makeConfig());
@@ -190,31 +176,39 @@ describe("Agent", () => {
it("yields tool-call and tool-result events", async () => {
const { streamText } = await import("ai");
- vi.mocked(streamText).mockReturnValue(
- makeMockStreamResult({
- events: [
+
+ // First call: LLM emits a tool-call
+ // Second call (after tool execution): LLM emits text response with no tool calls
+ vi.mocked(streamText)
+ .mockReturnValueOnce(
+ makeMockStreamResult([
{
type: "tool-call",
toolCallId: "tc1",
toolName: "read_file",
args: { path: "hello.txt" },
},
- { type: "text-delta", textDelta: "Here is the file." },
{
type: "finish",
- finishReason: "stop",
+ finishReason: "tool-calls",
usage: {},
providerMetadata: undefined,
response: {},
},
- ],
- steps: [
+ ]),
+ )
+ .mockReturnValueOnce(
+ makeMockStreamResult([
+ { type: "text-delta", textDelta: "Here is the file." },
{
- toolResults: [{ toolCallId: "tc1", result: "file contents" }],
+ type: "finish",
+ finishReason: "stop",
+ usage: {},
+ providerMetadata: undefined,
+ response: {},
},
- ],
- }),
- );
+ ]),
+ );
const toolDef = {
name: "read_file",
diff --git a/packages/core/tests/config/loader.test.ts b/packages/core/tests/config/loader.test.ts
new file mode 100644
index 0000000..9a358fe
--- /dev/null
+++ b/packages/core/tests/config/loader.test.ts
@@ -0,0 +1,166 @@
+import { homedir } from "node:os";
+import { join, sep } from "node:path";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { writeFileSync, mkdirSync, rmSync } from "node:fs";
+import { configToRuleset, loadConfig } from "../../src/config/loader.js";
+
+const TMP = join("/tmp/opencode", "dispatch-config-test");
+
+beforeEach(() => {
+ mkdirSync(TMP, { recursive: true });
+});
+
+afterEach(() => {
+ rmSync(TMP, { recursive: true, force: true });
+});
+
+function writeYaml(content: string): void {
+ writeFileSync(join(TMP, "dispatch.yaml"), content, "utf-8");
+}
+
+describe("loadConfig", () => {
+ it("returns empty permissions when dispatch.yaml is missing", () => {
+ const config = loadConfig(TMP);
+ expect(config.permissions).toEqual({});
+ });
+
+ it("parses simple string permissions", () => {
+ writeYaml(`permissions:\n read: allow\n edit: deny\n`);
+ const config = loadConfig(TMP);
+ expect(config.permissions["read"]).toBe("allow");
+ expect(config.permissions["edit"]).toBe("deny");
+ });
+
+ it("parses nested pattern permissions", () => {
+ writeYaml(`permissions:\n bash:\n "npm test": allow\n "*": ask\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["npm test"]).toBe("allow");
+ expect(bash["*"]).toBe("ask");
+ });
+
+ it("ignores comment lines", () => {
+ writeYaml(`# this is a comment\npermissions:\n # another comment\n read: allow\n`);
+ const config = loadConfig(TMP);
+ expect(config.permissions["read"]).toBe("allow");
+ });
+
+ it("handles ~ expansion in nested keys", () => {
+ const home = homedir();
+ writeYaml(`permissions:\n read:\n "~/projects/*": allow\n`);
+ const config = loadConfig(TMP);
+ const read = config.permissions["read"] as Record<string, string>;
+ expect(read[`${home}/projects/*`]).toBe("allow");
+ });
+
+ it("handles $HOME expansion in nested keys", () => {
+ const home = homedir();
+ writeYaml(`permissions:\n read:\n "$HOME/docs/*": allow\n`);
+ const config = loadConfig(TMP);
+ const read = config.permissions["read"] as Record<string, string>;
+ expect(read[`${home}/docs/*`]).toBe("allow");
+ });
+
+ it("parses quoted keys", () => {
+ writeYaml(`permissions:\n bash:\n "git commit *": allow\n "rm *": deny\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["git commit *"]).toBe("allow");
+ expect(bash["rm *"]).toBe("deny");
+ });
+
+ it("handles multiple permission groups", () => {
+ writeYaml(
+ `permissions:\n read: allow\n edit:\n "*": ask\n "src/**": allow\n bash:\n "npm test": allow\n "*": ask\n`,
+ );
+ const config = loadConfig(TMP);
+ expect(config.permissions["read"]).toBe("allow");
+ const edit = config.permissions["edit"] as Record<string, string>;
+ expect(edit["*"]).toBe("ask");
+ expect(edit["src/**"]).toBe("allow");
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["npm test"]).toBe("allow");
+ expect(bash["*"]).toBe("ask");
+ });
+
+ it("preserves # inside quoted string values", () => {
+ writeYaml(`permissions:\n bash:\n '"file#1"': allow\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash['"file#1"']).toBe("allow");
+ });
+
+ it("strips inline comments on nested map keys (empty value after comment)", () => {
+ writeYaml(`permissions:\n bash: # scripts\n "*": allow\n`);
+ const config = loadConfig(TMP);
+ const bash = config.permissions["bash"] as Record<string, string>;
+ expect(bash["*"]).toBe("allow");
+ });
+
+ it("expands ~ with platform path separator", () => {
+ const home = homedir();
+ // Simulate a path using the OS separator
+ const pattern = `~${sep}projects${sep}*`;
+ writeYaml(`permissions:\n read:\n "${pattern}": allow\n`);
+ const config = loadConfig(TMP);
+ const read = config.permissions["read"] as Record<string, string>;
+ expect(read[`${home}${sep}projects${sep}*`]).toBe("allow");
+ });
+});
+
+describe("configToRuleset — new validations", () => {
+ it("falls back to ask and warns for invalid action in string value", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const rules = configToRuleset({ permissions: { read: "allw" } });
+ expect(rules[0]?.action).toBe("ask");
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("allw"));
+ warn.mockRestore();
+ });
+
+ it("falls back to ask and warns for invalid action in nested pattern", () => {
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
+ const rules = configToRuleset({ permissions: { bash: { "*": "INVALID" } } });
+ expect(rules[0]?.action).toBe("ask");
+ expect(warn).toHaveBeenCalledWith(expect.stringContaining("INVALID"));
+ warn.mockRestore();
+ });
+
+ it("expands ~ with backslash separator in patterns", () => {
+ const home = homedir();
+ // Force backslash path even on Linux to test the regex
+ const rules = configToRuleset({ permissions: { read: { "~\\foo\\*": "allow" } } });
+ expect(rules[0]?.pattern).toBe(`${home}\\foo\\*`);
+ });
+});
+
+describe("configToRuleset", () => {
+ it("produces a rule with pattern * for string value", () => {
+ const rules = configToRuleset({ permissions: { read: "allow" } });
+ expect(rules).toEqual([{ permission: "read", pattern: "*", action: "allow" }]);
+ });
+
+ it("produces rules for each pattern in an object value", () => {
+ const rules = configToRuleset({
+ permissions: { bash: { "npm test": "allow", "*": "ask" } },
+ });
+ expect(rules).toContainEqual({ permission: "bash", pattern: "npm test", action: "allow" });
+ expect(rules).toContainEqual({ permission: "bash", pattern: "*", action: "ask" });
+ });
+
+ it("expands ~ in patterns", () => {
+ const home = homedir();
+ const rules = configToRuleset({ permissions: { read: { "~/foo/*": "allow" } } });
+ expect(rules[0]?.pattern).toBe(`${home}/foo/*`);
+ });
+
+ it("expands $HOME in patterns", () => {
+ const home = homedir();
+ const rules = configToRuleset({ permissions: { read: { "$HOME/bar/*": "deny" } } });
+ expect(rules[0]?.pattern).toBe(`${home}/bar/*`);
+ });
+
+ it("handles empty permissions", () => {
+ const rules = configToRuleset({ permissions: {} });
+ expect(rules).toEqual([]);
+ });
+});
diff --git a/packages/core/tests/permission/evaluate.test.ts b/packages/core/tests/permission/evaluate.test.ts
new file mode 100644
index 0000000..c8f4541
--- /dev/null
+++ b/packages/core/tests/permission/evaluate.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it } from "vitest";
+import { evaluate } from "../../src/permission/evaluate.js";
+import type { Ruleset } from "../../src/permission/index.js";
+
+describe("evaluate", () => {
+ it("returns default ask when no rules match", () => {
+ const result = evaluate("bash", "ls -la");
+ expect(result.action).toBe("ask");
+ expect(result.permission).toBe("bash");
+ expect(result.pattern).toBe("ls -la");
+ });
+
+ it("returns allow when matching rule is allow", () => {
+ const rules: Ruleset = [{ permission: "bash", pattern: "ls *", action: "allow" }];
+ const result = evaluate("bash", "ls -la", rules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("returns deny when matching rule is deny", () => {
+ const rules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
+ const result = evaluate("bash", "rm -rf /", rules);
+ expect(result.action).toBe("deny");
+ });
+
+ it("last-match-wins: later deny overrides earlier allow", () => {
+ const rules: Ruleset = [
+ { permission: "bash", pattern: "*", action: "allow" },
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ ];
+ const result = evaluate("bash", "rm -rf /", rules);
+ expect(result.action).toBe("deny");
+ });
+
+ it("last-match-wins: later allow overrides earlier deny", () => {
+ const rules: Ruleset = [
+ { permission: "bash", pattern: "rm *", action: "deny" },
+ { permission: "bash", pattern: "*", action: "allow" },
+ ];
+ const result = evaluate("bash", "rm -rf /", rules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("matches permission wildcard", () => {
+ const rules: Ruleset = [{ permission: "*", pattern: "*", action: "allow" }];
+ const result = evaluate("read", "anything", rules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("multiple rulesets are concatenated, last match wins across rulesets", () => {
+ const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "ask" }];
+ const overrideRules: Ruleset = [{ permission: "bash", pattern: "git *", action: "allow" }];
+ const result = evaluate("bash", "git status", baseRules, overrideRules);
+ expect(result.action).toBe("allow");
+ });
+
+ it("second ruleset can deny what first ruleset allows", () => {
+ const baseRules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
+ const overrideRules: Ruleset = [{ permission: "bash", pattern: "rm *", action: "deny" }];
+ const result = evaluate("bash", "rm -rf /", baseRules, overrideRules);
+ expect(result.action).toBe("deny");
+ });
+
+ it("non-matching permission returns default ask", () => {
+ const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
+ const result = evaluate("read", "/some/path", rules);
+ expect(result.action).toBe("ask");
+ });
+});
diff --git a/packages/core/tests/permission/service.test.ts b/packages/core/tests/permission/service.test.ts
new file mode 100644
index 0000000..d1b39d9
--- /dev/null
+++ b/packages/core/tests/permission/service.test.ts
@@ -0,0 +1,95 @@
+import { describe, expect, it } from "vitest";
+import type { PermissionRequest, Ruleset } from "../../src/permission/index.js";
+import { PermissionService } from "../../src/permission/service.js";
+
+function makeRequest(overrides: Partial<PermissionRequest> = {}): PermissionRequest {
+ return {
+ permission: "bash",
+ patterns: ["git *"],
+ always: ["git status"],
+ description: "Run git status",
+ metadata: {},
+ ...overrides,
+ };
+}
+
+describe("PermissionService", () => {
+ it("resolves immediately with 'once' when rule is allow", async () => {
+ const svc = new PermissionService();
+ const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "allow" }];
+ const reply = await svc.ask(makeRequest(), [rules]);
+ expect(reply).toBe("once");
+ });
+
+ it("rejects immediately when rule is deny", async () => {
+ const svc = new PermissionService();
+ const rules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
+ await expect(svc.ask(makeRequest(), [rules])).rejects.toThrow("Permission denied");
+ });
+
+ it("creates pending request when rule is ask", () => {
+ const svc = new PermissionService();
+ svc.ask(makeRequest(), []);
+ expect(svc.getPending()).toHaveLength(1);
+ });
+
+ it("reply 'once' resolves the specific pending request", async () => {
+ const svc = new PermissionService();
+ const promise = svc.ask(makeRequest(), []);
+ const pending = svc.getPending();
+ expect(pending).toHaveLength(1);
+ svc.reply(pending[0].id, "once");
+ const result = await promise;
+ expect(result).toBe("once");
+ expect(svc.getPending()).toHaveLength(0);
+ });
+
+ it("reply 'always' adds approved rules and resolves", async () => {
+ const svc = new PermissionService();
+ const promise = svc.ask(makeRequest({ patterns: ["git *"] }), []);
+ const pending = svc.getPending();
+ svc.reply(pending[0].id, "always");
+ const result = await promise;
+ expect(result).toBe("always");
+
+ // Now the same permission should be immediately allowed
+ const reply2 = await svc.ask(makeRequest({ always: ["git commit"] }), []);
+ expect(reply2).toBe("once");
+ });
+
+ it("reply 'reject' rejects all pending requests (cascade)", async () => {
+ const svc = new PermissionService();
+ const p1 = svc.ask(makeRequest(), []);
+ const p2 = svc.ask(makeRequest({ permission: "read" }), []);
+
+ const pending = svc.getPending();
+ expect(pending).toHaveLength(2);
+
+ // Reject using the first id — should cascade to all
+ svc.reply(pending[0].id, "reject");
+
+ await expect(p1).rejects.toThrow("Permission rejected");
+ await expect(p2).rejects.toThrow("Permission rejected");
+ expect(svc.getPending()).toHaveLength(0);
+ });
+
+ it("approved rules override config rulesets", async () => {
+ const svc = new PermissionService();
+ svc.approve([{ permission: "bash", pattern: "git *", action: "allow" }]);
+
+ // Config says deny, but approved says allow — approved wins (last)
+ const configRules: Ruleset = [{ permission: "bash", pattern: "*", action: "deny" }];
+ const reply = await svc.ask(makeRequest({ always: ["git status"] }), [configRules]);
+ expect(reply).toBe("once");
+ });
+
+ it("getPending returns all pending requests with id and request", () => {
+ const svc = new PermissionService();
+ const req = makeRequest();
+ svc.ask(req, []);
+ const pending = svc.getPending();
+ expect(pending).toHaveLength(1);
+ expect(pending[0].id).toBeDefined();
+ expect(pending[0].request.permission).toBe("bash");
+ });
+});
diff --git a/packages/core/tests/permission/wildcard.test.ts b/packages/core/tests/permission/wildcard.test.ts
new file mode 100644
index 0000000..8fa30a7
--- /dev/null
+++ b/packages/core/tests/permission/wildcard.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import { Wildcard } from "../../src/permission/wildcard.js";
+
+describe("Wildcard.match", () => {
+ it("matches exact string", () => {
+ expect(Wildcard.match("bash", "bash")).toBe(true);
+ expect(Wildcard.match("bash", "read")).toBe(false);
+ });
+
+ it("matches * wildcard (any characters)", () => {
+ expect(Wildcard.match("*", "bash")).toBe(true);
+ expect(Wildcard.match("*", "anything")).toBe(true);
+ expect(Wildcard.match("ba*", "bash")).toBe(true);
+ expect(Wildcard.match("ba*", "ba")).toBe(true);
+ expect(Wildcard.match("ba*", "read")).toBe(false);
+ });
+
+ it("matches ? wildcard (single character)", () => {
+ expect(Wildcard.match("ba?h", "bash")).toBe(true);
+ expect(Wildcard.match("ba?h", "bath")).toBe(true);
+ expect(Wildcard.match("ba?h", "baXXh")).toBe(false);
+ expect(Wildcard.match("?", "a")).toBe(true);
+ expect(Wildcard.match("?", "ab")).toBe(false);
+ });
+
+ it("matches nested * patterns with path-like strings", () => {
+ expect(Wildcard.match("/home/*", "/home/user")).toBe(true);
+ expect(Wildcard.match("/home/*/file.txt", "/home/user/file.txt")).toBe(true);
+ expect(Wildcard.match("/home/*/file.txt", "/home/user/subdir/file.txt")).toBe(true);
+ expect(Wildcard.match("/home/*/file.txt", "/tmp/user/file.txt")).toBe(false);
+ });
+
+ it("escapes regex special characters in pattern", () => {
+ expect(Wildcard.match("git add .", "git add .")).toBe(true);
+ expect(Wildcard.match("git add .", "git add X")).toBe(false);
+ expect(Wildcard.match("foo(bar)", "foo(bar)")).toBe(true);
+ expect(Wildcard.match("foo(bar)", "fooXbar")).toBe(false);
+ });
+
+ it("is case-sensitive", () => {
+ expect(Wildcard.match("Bash", "bash")).toBe(false);
+ expect(Wildcard.match("BASH", "BASH")).toBe(true);
+ });
+
+ it("handles empty pattern and value", () => {
+ expect(Wildcard.match("", "")).toBe(true);
+ expect(Wildcard.match("", "x")).toBe(false);
+ expect(Wildcard.match("*", "")).toBe(true);
+ });
+});
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");
+ });
+});