summaryrefslogtreecommitdiffhomepage
path: root/packages/tool-shell/src
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
committerAdam Malczewski <[email protected]>2026-06-27 01:09:39 +0900
commit61e45e60d699ed1ca46f94a8f181c92a940317c6 (patch)
tree2892d9773c5a8e367e1e58cdb1e88d9c6ad3fe6d /packages/tool-shell/src
parent63c7e64532e85e0bbdd6d9ac6825d8f86be98e7a (diff)
parent727c98c9dae516a2070eb950410314380a20c974 (diff)
downloaddispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.tar.gz
dispatch-61e45e60d699ed1ca46f94a8f181c92a940317c6.zip
Merge branch 'feature/indent-change' into dev
Diffstat (limited to 'packages/tool-shell/src')
-rw-r--r--packages/tool-shell/src/extension.ts32
-rw-r--r--packages/tool-shell/src/shell.test.ts816
-rw-r--r--packages/tool-shell/src/shell.ts298
3 files changed, 573 insertions, 573 deletions
diff --git a/packages/tool-shell/src/extension.ts b/packages/tool-shell/src/extension.ts
index 984263e..74e0ac9 100644
--- a/packages/tool-shell/src/extension.ts
+++ b/packages/tool-shell/src/extension.ts
@@ -3,20 +3,20 @@ import type { Extension } from "@dispatch/kernel";
import { createRunShellTool } from "./shell.js";
export const extension: Extension = {
- manifest: {
- id: "tool-shell",
- name: "Shell Tool",
- version: "0.0.0",
- apiVersion: "^0.1.0",
- trust: "bundled",
- activation: "eager",
- capabilities: { shell: true },
- contributes: { tools: ["run_shell"] },
- // Host activates exec-backend first → host.getService at activation is safe.
- dependsOn: ["exec-backend"],
- },
- activate(host) {
- const resolveBackend = host.getService(execBackendHandle);
- host.defineTool(createRunShellTool({ workdir: process.cwd(), resolveBackend }));
- },
+ manifest: {
+ id: "tool-shell",
+ name: "Shell Tool",
+ version: "0.0.0",
+ apiVersion: "^0.1.0",
+ trust: "bundled",
+ activation: "eager",
+ capabilities: { shell: true },
+ contributes: { tools: ["run_shell"] },
+ // Host activates exec-backend first → host.getService at activation is safe.
+ dependsOn: ["exec-backend"],
+ },
+ activate(host) {
+ const resolveBackend = host.getService(execBackendHandle);
+ host.defineTool(createRunShellTool({ workdir: process.cwd(), resolveBackend }));
+ },
};
diff --git a/packages/tool-shell/src/shell.test.ts b/packages/tool-shell/src/shell.test.ts
index 4a579b0..42127e6 100644
--- a/packages/tool-shell/src/shell.test.ts
+++ b/packages/tool-shell/src/shell.test.ts
@@ -4,436 +4,436 @@ import { describe, expect, it } from "vitest";
import { buildResult, createRunShellTool, truncateOutput, validateArgs } from "./shell.js";
function stubCtx(overrides?: Partial<ToolExecuteContext>): ToolExecuteContext {
- return {
- toolCallId: "test-call-1",
- onOutput: () => {},
- signal: AbortSignal.timeout(5000),
- log: createLogger(
- { extensionId: "test" },
- { emit: () => {} },
- { now: () => 0, newId: () => "id" },
- ),
- ...overrides,
- };
+ return {
+ toolCallId: "test-call-1",
+ onOutput: () => {},
+ signal: AbortSignal.timeout(5000),
+ log: createLogger(
+ { extensionId: "test" },
+ { emit: () => {} },
+ { now: () => 0, newId: () => "id" },
+ ),
+ ...overrides,
+ };
}
/** A fake backend whose `spawn` resolves a fixed result (no real I/O). */
function fakeBackend(result: {
- exitCode: number | null;
- timedOut: boolean;
- aborted?: boolean;
+ exitCode: number | null;
+ timedOut: boolean;
+ aborted?: boolean;
}): ExecBackend {
- return {
- ...localExecBackend,
- spawn: async () => ({ aborted: false, ...result }),
- };
+ return {
+ ...localExecBackend,
+ spawn: async () => ({ aborted: false, ...result }),
+ };
}
/** Wrap a fake backend in the resolver the factory expects (computerId-agnostic). */
function resolverFor(backend: ExecBackend) {
- return () => backend;
+ return () => backend;
}
describe("validateArgs", () => {
- it("returns validated args for valid input", () => {
- const result = validateArgs({ command: "echo hello" });
- expect(result).toEqual({ command: "echo hello", timeout: 120_000 });
- });
-
- it("parses custom timeout", () => {
- const result = validateArgs({ command: "echo hello", timeout: 5000 });
- expect(result).toEqual({ command: "echo hello", timeout: 5000 });
- });
-
- it("floors fractional timeout", () => {
- const result = validateArgs({ command: "echo hello", timeout: 5000.7 });
- expect(result).toEqual({ command: "echo hello", timeout: 5000 });
- });
-
- it("returns error for null args", () => {
- const result = validateArgs(null);
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for non-object args", () => {
- const result = validateArgs("string");
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for missing command", () => {
- const result = validateArgs({});
- expect(result).toHaveProperty("error");
- });
-
- it("rejects missing or empty command", () => {
- const empty = validateArgs({ command: "" });
- expect(empty).toHaveProperty("error");
- const whitespace = validateArgs({ command: " " });
- expect(whitespace).toHaveProperty("error");
- const missing = validateArgs({ timeout: 5000 });
- expect(missing).toHaveProperty("error");
- });
-
- it("returns error for non-string command", () => {
- const result = validateArgs({ command: 123 });
- expect(result).toHaveProperty("error");
- });
-
- it("returns error for invalid timeout", () => {
- const negative = validateArgs({ command: "echo", timeout: -1 });
- expect(negative).toHaveProperty("error");
- const zero = validateArgs({ command: "echo", timeout: 0 });
- expect(zero).toHaveProperty("error");
- const nan = validateArgs({ command: "echo", timeout: Number.NaN });
- expect(nan).toHaveProperty("error");
- });
+ it("returns validated args for valid input", () => {
+ const result = validateArgs({ command: "echo hello" });
+ expect(result).toEqual({ command: "echo hello", timeout: 120_000 });
+ });
+
+ it("parses custom timeout", () => {
+ const result = validateArgs({ command: "echo hello", timeout: 5000 });
+ expect(result).toEqual({ command: "echo hello", timeout: 5000 });
+ });
+
+ it("floors fractional timeout", () => {
+ const result = validateArgs({ command: "echo hello", timeout: 5000.7 });
+ expect(result).toEqual({ command: "echo hello", timeout: 5000 });
+ });
+
+ it("returns error for null args", () => {
+ const result = validateArgs(null);
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for non-object args", () => {
+ const result = validateArgs("string");
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for missing command", () => {
+ const result = validateArgs({});
+ expect(result).toHaveProperty("error");
+ });
+
+ it("rejects missing or empty command", () => {
+ const empty = validateArgs({ command: "" });
+ expect(empty).toHaveProperty("error");
+ const whitespace = validateArgs({ command: " " });
+ expect(whitespace).toHaveProperty("error");
+ const missing = validateArgs({ timeout: 5000 });
+ expect(missing).toHaveProperty("error");
+ });
+
+ it("returns error for non-string command", () => {
+ const result = validateArgs({ command: 123 });
+ expect(result).toHaveProperty("error");
+ });
+
+ it("returns error for invalid timeout", () => {
+ const negative = validateArgs({ command: "echo", timeout: -1 });
+ expect(negative).toHaveProperty("error");
+ const zero = validateArgs({ command: "echo", timeout: 0 });
+ expect(zero).toHaveProperty("error");
+ const nan = validateArgs({ command: "echo", timeout: Number.NaN });
+ expect(nan).toHaveProperty("error");
+ });
});
describe("truncateOutput", () => {
- it("returns output unchanged when under cap", () => {
- const output = "short output";
- expect(truncateOutput(output, 100)).toBe("short output");
- });
-
- it("returns output unchanged when exactly at cap", () => {
- const output = "exact";
- expect(truncateOutput(output, 5)).toBe("exact");
- });
-
- it("truncates output beyond the cap and appends a notice", () => {
- const output = "a".repeat(100);
- const result = truncateOutput(output, 50);
- expect(result).toContain("a".repeat(50));
- expect(result).toContain("[Output truncated: exceeded 50 characters]");
- expect(result.length).toBeLessThan(output.length + 100);
- });
+ it("returns output unchanged when under cap", () => {
+ const output = "short output";
+ expect(truncateOutput(output, 100)).toBe("short output");
+ });
+
+ it("returns output unchanged when exactly at cap", () => {
+ const output = "exact";
+ expect(truncateOutput(output, 5)).toBe("exact");
+ });
+
+ it("truncates output beyond the cap and appends a notice", () => {
+ const output = "a".repeat(100);
+ const result = truncateOutput(output, 50);
+ expect(result).toContain("a".repeat(50));
+ expect(result).toContain("[Output truncated: exceeded 50 characters]");
+ expect(result.length).toBeLessThan(output.length + 100);
+ });
});
describe("buildResult", () => {
- it("maps a zero exit code to a success result", () => {
- const result = buildResult({
- exitCode: 0,
- timedOut: false,
- aborted: false,
- output: "all good",
- cap: 50_000,
- });
- expect(result.content).toBe("all good");
- expect(result.isError).toBeUndefined();
- });
-
- it("maps a non-zero exit code to an isError result", () => {
- const result = buildResult({
- exitCode: 1,
- timedOut: false,
- aborted: false,
- output: "some error",
- cap: 50_000,
- });
- expect(result.content).toBe("some error");
- expect(result.isError).toBe(true);
- });
-
- it("reports a timeout as an isError result", () => {
- const result = buildResult({
- exitCode: null,
- timedOut: true,
- aborted: false,
- output: "partial",
- cap: 50_000,
- });
- expect(result.content).toContain("partial");
- expect(result.content).toContain("[Command timed out]");
- expect(result.isError).toBe(true);
- });
-
- it("reports abort as an isError result", () => {
- const result = buildResult({
- exitCode: null,
- timedOut: false,
- aborted: true,
- output: "interrupted",
- cap: 50_000,
- });
- expect(result.content).toBe("interrupted");
- expect(result.isError).toBe(true);
- });
-
- it("truncates output in result when over cap", () => {
- const output = "x".repeat(60_000);
- const result = buildResult({
- exitCode: 0,
- timedOut: false,
- aborted: false,
- output,
- cap: 50_000,
- });
- expect(result.content).toContain("[Output truncated");
- expect(result.content.length).toBeLessThan(60_000);
- });
+ it("maps a zero exit code to a success result", () => {
+ const result = buildResult({
+ exitCode: 0,
+ timedOut: false,
+ aborted: false,
+ output: "all good",
+ cap: 50_000,
+ });
+ expect(result.content).toBe("all good");
+ expect(result.isError).toBeUndefined();
+ });
+
+ it("maps a non-zero exit code to an isError result", () => {
+ const result = buildResult({
+ exitCode: 1,
+ timedOut: false,
+ aborted: false,
+ output: "some error",
+ cap: 50_000,
+ });
+ expect(result.content).toBe("some error");
+ expect(result.isError).toBe(true);
+ });
+
+ it("reports a timeout as an isError result", () => {
+ const result = buildResult({
+ exitCode: null,
+ timedOut: true,
+ aborted: false,
+ output: "partial",
+ cap: 50_000,
+ });
+ expect(result.content).toContain("partial");
+ expect(result.content).toContain("[Command timed out]");
+ expect(result.isError).toBe(true);
+ });
+
+ it("reports abort as an isError result", () => {
+ const result = buildResult({
+ exitCode: null,
+ timedOut: false,
+ aborted: true,
+ output: "interrupted",
+ cap: 50_000,
+ });
+ expect(result.content).toBe("interrupted");
+ expect(result.isError).toBe(true);
+ });
+
+ it("truncates output in result when over cap", () => {
+ const output = "x".repeat(60_000);
+ const result = buildResult({
+ exitCode: 0,
+ timedOut: false,
+ aborted: false,
+ output,
+ cap: 50_000,
+ });
+ expect(result.content).toContain("[Output truncated");
+ expect(result.content.length).toBeLessThan(60_000);
+ });
});
describe("createRunShellTool", () => {
- it("has correct name and parameters shape", () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
- });
- expect(tool.name).toBe("run_shell");
- expect(tool.parameters.type).toBe("object");
- expect(tool.parameters.required).toEqual(["command"]);
- expect(tool.parameters.properties?.command?.type).toBe("string");
- expect(tool.parameters.properties?.timeout?.type).toBe("number");
- });
-
- it("concurrencySafe is false", () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
- });
- expect(tool.concurrencySafe).toBe(false);
- });
-
- it("rejects missing or empty command", async () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
- });
- const result = await tool.execute({}, stubCtx());
- expect(result.isError).toBe(true);
- expect(result.content).toContain("Missing or empty");
- });
-
- it("maps a zero exit code to a success result", async () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- params.onOutput("hello\n", "stdout");
- return { exitCode: 0, timedOut: false, aborted: false };
- },
- }),
- });
- const result = await tool.execute({ command: "echo hello" }, stubCtx());
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("hello");
- });
-
- it("maps a non-zero exit code to an isError result", async () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- params.onOutput("error output\n", "stderr");
- return { exitCode: 1, timedOut: false, aborted: false };
- },
- }),
- });
- const result = await tool.execute({ command: "false" }, stubCtx());
- expect(result.isError).toBe(true);
- expect(result.content).toContain("error output");
- });
-
- it("reports a timeout as an isError result", async () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- params.onOutput("partial\n", "stdout");
- return { exitCode: null, timedOut: true, aborted: false };
- },
- }),
- });
- const result = await tool.execute({ command: "sleep 999" }, stubCtx());
- expect(result.isError).toBe(true);
- expect(result.content).toContain("[Command timed out]");
- });
-
- it("truncates output beyond the cap and appends a notice", async () => {
- const cap = 100;
- const tool = createRunShellTool({
- workdir: "/tmp",
- outputCap: cap,
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- params.onOutput("a".repeat(200), "stdout");
- return { exitCode: 0, timedOut: false, aborted: false };
- },
- }),
- });
- const result = await tool.execute({ command: "gen" }, stubCtx());
- expect(result.content).toContain("[Output truncated");
- expect(result.content.length).toBeLessThan(200);
- });
-
- it("streams output to ctx.onOutput", async () => {
- const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- params.onOutput("line1\n", "stdout");
- params.onOutput("err1\n", "stderr");
- params.onOutput("line2\n", "stdout");
- return { exitCode: 0, timedOut: false, aborted: false };
- },
- }),
- });
- await tool.execute(
- { command: "test" },
- stubCtx({
- onOutput: (data, stream) => chunks.push({ data, stream }),
- }),
- );
- expect(chunks).toEqual([
- { data: "line1\n", stream: "stdout" },
- { data: "err1\n", stream: "stderr" },
- { data: "line2\n", stream: "stdout" },
- ]);
- });
-
- it("uses ctx.cwd when present over baked workdir", async () => {
- let receivedCwd = "";
- const tool = createRunShellTool({
- workdir: "/baked",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- receivedCwd = params.cwd;
- return { exitCode: 0, timedOut: false, aborted: false };
- },
- }),
- });
- await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" }));
- expect(receivedCwd).toBe("/custom");
- });
-
- it("falls back to baked workdir when ctx.cwd is omitted", async () => {
- let receivedCwd = "";
- const tool = createRunShellTool({
- workdir: "/baked",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- receivedCwd = params.cwd;
- return { exitCode: 0, timedOut: false, aborted: false };
- },
- }),
- });
- await tool.execute({ command: "pwd" }, stubCtx());
- expect(receivedCwd).toBe("/baked");
- });
-
- it("returns error for spawn failure", async () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async () => {
- throw new Error("spawn failed");
- },
- }),
- });
- const result = await tool.execute({ command: "bad" }, stubCtx());
- expect(result.isError).toBe(true);
- expect(result.content).toContain("Error spawning command");
- });
-
- it("reports abort as isError when signal fires before spawn completes", async () => {
- const controller = new AbortController();
- controller.abort();
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
- });
- const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal }));
- expect(result.isError).toBe(true);
- });
-
- it("passes timeout to spawn", async () => {
- let receivedTimeout = 0;
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: resolverFor({
- ...localExecBackend,
- spawn: async (params) => {
- receivedTimeout = params.timeout;
- return { exitCode: 0, timedOut: false, aborted: false };
- },
- }),
- });
- await tool.execute({ command: "test", timeout: 5000 }, stubCtx());
- expect(receivedTimeout).toBe(5000);
- });
-
- it("resolves the backend per-call from ctx.computerId (passes it to the resolver)", async () => {
- let receivedComputerId: string | undefined = "__unset__";
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: (computerId) => {
- receivedComputerId = computerId;
- return fakeBackend({ exitCode: 0, timedOut: false });
- },
- });
- await tool.execute({ command: "test" }, stubCtx({ computerId: "my-host" }));
- expect(receivedComputerId).toBe("my-host");
- });
-
- it("resolves the local backend when ctx.computerId is undefined", async () => {
- let receivedComputerId: string | undefined = "__unset__";
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: (computerId) => {
- receivedComputerId = computerId;
- return fakeBackend({ exitCode: 0, timedOut: false });
- },
- });
- await tool.execute({ command: "test" }, stubCtx());
- expect(receivedComputerId).toBeUndefined();
- });
+ it("has correct name and parameters shape", () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
+ });
+ expect(tool.name).toBe("run_shell");
+ expect(tool.parameters.type).toBe("object");
+ expect(tool.parameters.required).toEqual(["command"]);
+ expect(tool.parameters.properties?.command?.type).toBe("string");
+ expect(tool.parameters.properties?.timeout?.type).toBe("number");
+ });
+
+ it("concurrencySafe is false", () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
+ });
+ expect(tool.concurrencySafe).toBe(false);
+ });
+
+ it("rejects missing or empty command", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
+ });
+ const result = await tool.execute({}, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Missing or empty");
+ });
+
+ it("maps a zero exit code to a success result", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("hello\n", "stdout");
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
+ });
+ const result = await tool.execute({ command: "echo hello" }, stubCtx());
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("hello");
+ });
+
+ it("maps a non-zero exit code to an isError result", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("error output\n", "stderr");
+ return { exitCode: 1, timedOut: false, aborted: false };
+ },
+ }),
+ });
+ const result = await tool.execute({ command: "false" }, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("error output");
+ });
+
+ it("reports a timeout as an isError result", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("partial\n", "stdout");
+ return { exitCode: null, timedOut: true, aborted: false };
+ },
+ }),
+ });
+ const result = await tool.execute({ command: "sleep 999" }, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("[Command timed out]");
+ });
+
+ it("truncates output beyond the cap and appends a notice", async () => {
+ const cap = 100;
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ outputCap: cap,
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("a".repeat(200), "stdout");
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
+ });
+ const result = await tool.execute({ command: "gen" }, stubCtx());
+ expect(result.content).toContain("[Output truncated");
+ expect(result.content.length).toBeLessThan(200);
+ });
+
+ it("streams output to ctx.onOutput", async () => {
+ const chunks: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ params.onOutput("line1\n", "stdout");
+ params.onOutput("err1\n", "stderr");
+ params.onOutput("line2\n", "stdout");
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
+ });
+ await tool.execute(
+ { command: "test" },
+ stubCtx({
+ onOutput: (data, stream) => chunks.push({ data, stream }),
+ }),
+ );
+ expect(chunks).toEqual([
+ { data: "line1\n", stream: "stdout" },
+ { data: "err1\n", stream: "stderr" },
+ { data: "line2\n", stream: "stdout" },
+ ]);
+ });
+
+ it("uses ctx.cwd when present over baked workdir", async () => {
+ let receivedCwd = "";
+ const tool = createRunShellTool({
+ workdir: "/baked",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ receivedCwd = params.cwd;
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
+ });
+ await tool.execute({ command: "pwd" }, stubCtx({ cwd: "/custom" }));
+ expect(receivedCwd).toBe("/custom");
+ });
+
+ it("falls back to baked workdir when ctx.cwd is omitted", async () => {
+ let receivedCwd = "";
+ const tool = createRunShellTool({
+ workdir: "/baked",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ receivedCwd = params.cwd;
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
+ });
+ await tool.execute({ command: "pwd" }, stubCtx());
+ expect(receivedCwd).toBe("/baked");
+ });
+
+ it("returns error for spawn failure", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async () => {
+ throw new Error("spawn failed");
+ },
+ }),
+ });
+ const result = await tool.execute({ command: "bad" }, stubCtx());
+ expect(result.isError).toBe(true);
+ expect(result.content).toContain("Error spawning command");
+ });
+
+ it("reports abort as isError when signal fires before spawn completes", async () => {
+ const controller = new AbortController();
+ controller.abort();
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor(fakeBackend({ exitCode: 0, timedOut: false })),
+ });
+ const result = await tool.execute({ command: "test" }, stubCtx({ signal: controller.signal }));
+ expect(result.isError).toBe(true);
+ });
+
+ it("passes timeout to spawn", async () => {
+ let receivedTimeout = 0;
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: resolverFor({
+ ...localExecBackend,
+ spawn: async (params) => {
+ receivedTimeout = params.timeout;
+ return { exitCode: 0, timedOut: false, aborted: false };
+ },
+ }),
+ });
+ await tool.execute({ command: "test", timeout: 5000 }, stubCtx());
+ expect(receivedTimeout).toBe(5000);
+ });
+
+ it("resolves the backend per-call from ctx.computerId (passes it to the resolver)", async () => {
+ let receivedComputerId: string | undefined = "__unset__";
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: (computerId) => {
+ receivedComputerId = computerId;
+ return fakeBackend({ exitCode: 0, timedOut: false });
+ },
+ });
+ await tool.execute({ command: "test" }, stubCtx({ computerId: "my-host" }));
+ expect(receivedComputerId).toBe("my-host");
+ });
+
+ it("resolves the local backend when ctx.computerId is undefined", async () => {
+ let receivedComputerId: string | undefined = "__unset__";
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: (computerId) => {
+ receivedComputerId = computerId;
+ return fakeBackend({ exitCode: 0, timedOut: false });
+ },
+ });
+ await tool.execute({ command: "test" }, stubCtx());
+ expect(receivedComputerId).toBeUndefined();
+ });
});
describe("createRunShellTool (integration)", () => {
- it("runs a real echo command through the local backend and captures stdout + cwd", async () => {
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: () => localExecBackend,
- });
- let streamed = "";
- const result = await tool.execute(
- { command: "echo hello-from-shell" },
- stubCtx({
- onOutput: (data) => {
- streamed += data;
- },
- }),
- );
- expect(result.isError).toBeUndefined();
- expect(result.content).toContain("hello-from-shell");
- expect(streamed).toContain("hello-from-shell");
- });
-
- it("aborts a real long-running command through the local backend and resolves with aborted", async () => {
- const controller = new AbortController();
- const tool = createRunShellTool({
- workdir: "/tmp",
- resolveBackend: () => localExecBackend,
- });
- const promise = tool.execute({ command: "sleep 30" }, stubCtx({ signal: controller.signal }));
- // Let the sleep actually start.
- await new Promise((r) => setTimeout(r, 300));
- controller.abort();
- const result = await promise;
- expect(result.isError).toBe(true);
- // The detailed process-group-kill semantics (grandchild holding the pipes,
- // prompt resolution) are owned by @dispatch/exec-backend's local.test.ts —
- // realSpawn was ported there byte-for-byte. Here we only confirm the tool
- // wires the backend's aborted result through to an isError result.
- await new Promise((r) => setTimeout(r, 200));
- });
+ it("runs a real echo command through the local backend and captures stdout + cwd", async () => {
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: () => localExecBackend,
+ });
+ let streamed = "";
+ const result = await tool.execute(
+ { command: "echo hello-from-shell" },
+ stubCtx({
+ onOutput: (data) => {
+ streamed += data;
+ },
+ }),
+ );
+ expect(result.isError).toBeUndefined();
+ expect(result.content).toContain("hello-from-shell");
+ expect(streamed).toContain("hello-from-shell");
+ });
+
+ it("aborts a real long-running command through the local backend and resolves with aborted", async () => {
+ const controller = new AbortController();
+ const tool = createRunShellTool({
+ workdir: "/tmp",
+ resolveBackend: () => localExecBackend,
+ });
+ const promise = tool.execute({ command: "sleep 30" }, stubCtx({ signal: controller.signal }));
+ // Let the sleep actually start.
+ await new Promise((r) => setTimeout(r, 300));
+ controller.abort();
+ const result = await promise;
+ expect(result.isError).toBe(true);
+ // The detailed process-group-kill semantics (grandchild holding the pipes,
+ // prompt resolution) are owned by @dispatch/exec-backend's local.test.ts —
+ // realSpawn was ported there byte-for-byte. Here we only confirm the tool
+ // wires the backend's aborted result through to an isError result.
+ await new Promise((r) => setTimeout(r, 200));
+ });
});
diff --git a/packages/tool-shell/src/shell.ts b/packages/tool-shell/src/shell.ts
index dac7fab..e9b676a 100644
--- a/packages/tool-shell/src/shell.ts
+++ b/packages/tool-shell/src/shell.ts
@@ -6,170 +6,170 @@ const DEFAULT_TIMEOUT = 120_000;
const OUTPUT_CAP = 50_000;
export interface ValidatedArgs {
- readonly command: string;
- readonly timeout: number;
+ readonly command: string;
+ readonly timeout: number;
}
export interface SpawnResult {
- readonly exitCode: number | null;
- readonly timedOut: boolean;
- readonly aborted: boolean;
+ readonly exitCode: number | null;
+ readonly timedOut: boolean;
+ readonly aborted: boolean;
}
export function validateArgs(args: unknown): ValidatedArgs | { readonly error: string } {
- if (args === null || args === undefined || typeof args !== "object") {
- return { error: "Error: Arguments must be an object." };
- }
- const obj = args as Record<string, unknown>;
-
- const rawCommand = obj.command;
- if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) {
- return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' };
- }
-
- let timeout = DEFAULT_TIMEOUT;
- if (obj.timeout !== undefined) {
- const n = Number(obj.timeout);
- if (!Number.isFinite(n) || n < 1) {
- return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' };
- }
- timeout = Math.floor(n);
- }
-
- return { command: rawCommand, timeout };
+ if (args === null || args === undefined || typeof args !== "object") {
+ return { error: "Error: Arguments must be an object." };
+ }
+ const obj = args as Record<string, unknown>;
+
+ const rawCommand = obj.command;
+ if (typeof rawCommand !== "string" || rawCommand.trim().length === 0) {
+ return { error: 'Error: Missing or empty "command" parameter (must be a non-empty string).' };
+ }
+
+ let timeout = DEFAULT_TIMEOUT;
+ if (obj.timeout !== undefined) {
+ const n = Number(obj.timeout);
+ if (!Number.isFinite(n) || n < 1) {
+ return { error: 'Error: Invalid "timeout" parameter (must be a positive number).' };
+ }
+ timeout = Math.floor(n);
+ }
+
+ return { command: rawCommand, timeout };
}
export function truncateOutput(output: string, cap: number): string {
- if (output.length <= cap) {
- return output;
- }
- const truncated = output.slice(0, cap);
- return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`;
+ if (output.length <= cap) {
+ return output;
+ }
+ const truncated = output.slice(0, cap);
+ return `${truncated}\n\n[Output truncated: exceeded ${cap} characters]`;
}
export function buildResult(params: {
- readonly exitCode: number | null;
- readonly timedOut: boolean;
- readonly aborted: boolean;
- readonly output: string;
- readonly cap: number;
+ readonly exitCode: number | null;
+ readonly timedOut: boolean;
+ readonly aborted: boolean;
+ readonly output: string;
+ readonly cap: number;
}): ToolResult {
- if (params.aborted) {
- return {
- content: truncateOutput(params.output, params.cap),
- isError: true,
- };
- }
- if (params.timedOut) {
- const content = truncateOutput(params.output, params.cap);
- return {
- content: `${content}\n\n[Command timed out]`,
- isError: true,
- };
- }
- const exitCode = params.exitCode;
- if (exitCode !== null && exitCode !== 0) {
- return {
- content: truncateOutput(params.output, params.cap),
- isError: true,
- };
- }
- return {
- content: truncateOutput(params.output, params.cap),
- };
+ if (params.aborted) {
+ return {
+ content: truncateOutput(params.output, params.cap),
+ isError: true,
+ };
+ }
+ if (params.timedOut) {
+ const content = truncateOutput(params.output, params.cap);
+ return {
+ content: `${content}\n\n[Command timed out]`,
+ isError: true,
+ };
+ }
+ const exitCode = params.exitCode;
+ if (exitCode !== null && exitCode !== 0) {
+ return {
+ content: truncateOutput(params.output, params.cap),
+ isError: true,
+ };
+ }
+ return {
+ content: truncateOutput(params.output, params.cap),
+ };
}
export function createRunShellTool(deps: {
- readonly workdir: string;
- readonly resolveBackend: ExecBackendResolver;
- readonly outputCap?: number;
+ readonly workdir: string;
+ readonly resolveBackend: ExecBackendResolver;
+ readonly outputCap?: number;
}): ToolContract {
- const workdir = resolve(deps.workdir);
- const cap = deps.outputCap ?? OUTPUT_CAP;
-
- return {
- name: "run_shell",
- description:
- "Execute a shell command and return its output. " +
- "Use for running CLI tools, scripts, or system commands.",
- parameters: {
- type: "object",
- properties: {
- command: {
- type: "string",
- description: "The shell command to execute.",
- },
- timeout: {
- type: "number",
- description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`,
- default: DEFAULT_TIMEOUT,
- },
- },
- required: ["command"],
- },
- concurrencySafe: false,
- async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
- const validated = validateArgs(args);
- if ("error" in validated) {
- return { content: validated.error, isError: true };
- }
-
- const { command, timeout } = validated;
- const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir;
-
- if (ctx.signal.aborted) {
- return buildResult({
- exitCode: null,
- timedOut: false,
- aborted: true,
- output: "",
- cap,
- });
- }
-
- let output = "";
- const appendOutput = (data: string, _stream: "stdout" | "stderr") => {
- output += data;
- };
-
- const backend = deps.resolveBackend(ctx.computerId);
-
- let spawnResult: SpawnResult;
-
- try {
- spawnResult = await backend.spawn({
- command,
- cwd: effectiveCwd,
- signal: ctx.signal,
- timeout,
- onOutput: (data, stream) => {
- ctx.onOutput(data, stream);
- appendOutput(data, stream);
- },
- });
- } catch (err: unknown) {
- if (ctx.signal.aborted) {
- return buildResult({
- exitCode: null,
- timedOut: false,
- aborted: true,
- output,
- cap,
- });
- }
- return {
- content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`,
- isError: true,
- };
- }
-
- return buildResult({
- exitCode: spawnResult.exitCode,
- timedOut: spawnResult.timedOut,
- aborted: spawnResult.aborted,
- output,
- cap,
- });
- },
- };
+ const workdir = resolve(deps.workdir);
+ const cap = deps.outputCap ?? OUTPUT_CAP;
+
+ return {
+ name: "run_shell",
+ description:
+ "Execute a shell command and return its output. " +
+ "Use for running CLI tools, scripts, or system commands.",
+ parameters: {
+ type: "object",
+ properties: {
+ command: {
+ type: "string",
+ description: "The shell command to execute.",
+ },
+ timeout: {
+ type: "number",
+ description: `Timeout in milliseconds (default: ${DEFAULT_TIMEOUT}).`,
+ default: DEFAULT_TIMEOUT,
+ },
+ },
+ required: ["command"],
+ },
+ concurrencySafe: false,
+ async execute(args: unknown, ctx: ToolExecuteContext): Promise<ToolResult> {
+ const validated = validateArgs(args);
+ if ("error" in validated) {
+ return { content: validated.error, isError: true };
+ }
+
+ const { command, timeout } = validated;
+ const effectiveCwd = ctx.cwd ? resolve(ctx.cwd) : workdir;
+
+ if (ctx.signal.aborted) {
+ return buildResult({
+ exitCode: null,
+ timedOut: false,
+ aborted: true,
+ output: "",
+ cap,
+ });
+ }
+
+ let output = "";
+ const appendOutput = (data: string, _stream: "stdout" | "stderr") => {
+ output += data;
+ };
+
+ const backend = deps.resolveBackend(ctx.computerId);
+
+ let spawnResult: SpawnResult;
+
+ try {
+ spawnResult = await backend.spawn({
+ command,
+ cwd: effectiveCwd,
+ signal: ctx.signal,
+ timeout,
+ onOutput: (data, stream) => {
+ ctx.onOutput(data, stream);
+ appendOutput(data, stream);
+ },
+ });
+ } catch (err: unknown) {
+ if (ctx.signal.aborted) {
+ return buildResult({
+ exitCode: null,
+ timedOut: false,
+ aborted: true,
+ output,
+ cap,
+ });
+ }
+ return {
+ content: `Error spawning command: ${err instanceof Error ? err.message : String(err)}`,
+ isError: true,
+ };
+ }
+
+ return buildResult({
+ exitCode: spawnResult.exitCode,
+ timedOut: spawnResult.timedOut,
+ aborted: spawnResult.aborted,
+ output,
+ cap,
+ });
+ },
+ };
}