@crewhaus/tool-bash 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +84 -0
- package/package.json +14 -11
- package/src/index.drain-fallback.test.ts +0 -119
- package/src/index.test.ts +0 -74
- package/src/index.ts +0 -97
- package/src/integration.test.ts +0 -75
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { buildTool } from "@crewhaus/tool-builder";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
/**
|
|
4
|
+
* Built-in Bash tool. Spawns the command through `sh -c` via `Bun.spawn`,
|
|
5
|
+
* captures stdout + stderr, and enforces a default 30s timeout (max 10min)
|
|
6
|
+
* by SIGKILLing the subprocess when the deadline elapses. The returned
|
|
7
|
+
* string is human-readable and encodes both streams plus the exit code.
|
|
8
|
+
*
|
|
9
|
+
* Linux note: when `sh` forks a long-running grandchild (e.g. `sleep 10`),
|
|
10
|
+
* SIGKILL on the shell does not propagate to the orphan, which keeps the
|
|
11
|
+
* pipe write-end alive and prevents `text()` from ever EOFing. After the
|
|
12
|
+
* shell exits we therefore give each stream a fixed drain grace window
|
|
13
|
+
* before falling back to an empty string — the orphan still exits on its
|
|
14
|
+
* own, but the tool call returns within `timeoutMs + DRAIN_GRACE_MS`.
|
|
15
|
+
*
|
|
16
|
+
* Layer R4. Pairs with the `target-cli` codegen contract (`bash` export).
|
|
17
|
+
*/
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
19
|
+
const MAX_TIMEOUT_MS = 600_000;
|
|
20
|
+
const DRAIN_GRACE_MS = 500;
|
|
21
|
+
const bashSchema = z.object({
|
|
22
|
+
command: z.string().min(1),
|
|
23
|
+
timeout: z.number().int().positive().max(MAX_TIMEOUT_MS).optional(),
|
|
24
|
+
});
|
|
25
|
+
function formatResult(out) {
|
|
26
|
+
const parts = [];
|
|
27
|
+
if (out.stdout.length > 0)
|
|
28
|
+
parts.push(out.stdout.replace(/\n+$/, ""));
|
|
29
|
+
if (out.stderr.length > 0) {
|
|
30
|
+
parts.push("[stderr]");
|
|
31
|
+
parts.push(out.stderr.replace(/\n+$/, ""));
|
|
32
|
+
}
|
|
33
|
+
const exitLine = out.timedOut
|
|
34
|
+
? `[exit] ${out.exitCode} (timed out after ${out.timeoutMs}ms)`
|
|
35
|
+
: `[exit] ${out.exitCode}`;
|
|
36
|
+
parts.push(exitLine);
|
|
37
|
+
return parts.join("\n");
|
|
38
|
+
}
|
|
39
|
+
export const bash = buildTool({
|
|
40
|
+
name: "Bash",
|
|
41
|
+
description: "Run a shell command via `sh -c`. Captures stdout and stderr; default timeout 30s, max 10min.",
|
|
42
|
+
inputSchema: bashSchema,
|
|
43
|
+
destructive: true,
|
|
44
|
+
// Pillar 3 sink-side: Bash spawns a host process and its command string is an
|
|
45
|
+
// exfiltration channel (curl, nc, `base64 | sh`, …). Mark it external +
|
|
46
|
+
// process so runtime-core runs classifyEgress on the command payload and the
|
|
47
|
+
// substring matcher can flag tagged secrets on the command line (#146).
|
|
48
|
+
scope: "external",
|
|
49
|
+
ioCapability: "process",
|
|
50
|
+
execute: async (input, ctx) => {
|
|
51
|
+
const timeoutMs = input.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
52
|
+
const proc = Bun.spawn(["sh", "-c", input.command], {
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "pipe",
|
|
55
|
+
// When the orchestrator aborts the turn (Ctrl-C, recovery exhaustion),
|
|
56
|
+
// Bun forwards the signal as SIGTERM to the spawned shell.
|
|
57
|
+
...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
58
|
+
});
|
|
59
|
+
let timedOut = false;
|
|
60
|
+
const timer = setTimeout(() => {
|
|
61
|
+
timedOut = true;
|
|
62
|
+
try {
|
|
63
|
+
proc.kill("SIGKILL");
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Process already exited between the timer firing and the kill call.
|
|
67
|
+
}
|
|
68
|
+
}, timeoutMs);
|
|
69
|
+
try {
|
|
70
|
+
const stdoutText = new Response(proc.stdout).text();
|
|
71
|
+
const stderrText = new Response(proc.stderr).text();
|
|
72
|
+
const exitCode = await proc.exited;
|
|
73
|
+
const drainFallback = () => new Promise((resolve) => setTimeout(() => resolve(""), DRAIN_GRACE_MS));
|
|
74
|
+
const [stdout, stderr] = await Promise.all([
|
|
75
|
+
Promise.race([stdoutText, drainFallback()]),
|
|
76
|
+
Promise.race([stderrText, drainFallback()]),
|
|
77
|
+
]);
|
|
78
|
+
return formatResult({ stdout, stderr, exitCode, timedOut, timeoutMs });
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
clearTimeout(timer);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
});
|
package/package.json
CHANGED
|
@@ -1,26 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/tool-bash",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Built-in Bash tool: spawns shell commands via Bun.spawn with a default 30s timeout",
|
|
6
|
-
"main": "
|
|
7
|
-
"types": "
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
10
13
|
},
|
|
11
14
|
"scripts": {
|
|
12
15
|
"test": "bun test src"
|
|
13
16
|
},
|
|
14
17
|
"dependencies": {
|
|
15
|
-
"@crewhaus/errors": "0.1.
|
|
16
|
-
"@crewhaus/tool-builder": "0.1.
|
|
17
|
-
"@crewhaus/tool-catalog": "0.1.
|
|
18
|
+
"@crewhaus/errors": "0.1.6",
|
|
19
|
+
"@crewhaus/tool-builder": "0.1.6",
|
|
20
|
+
"@crewhaus/tool-catalog": "0.1.6",
|
|
18
21
|
"zod": "^3.23.8"
|
|
19
22
|
},
|
|
20
23
|
"devDependencies": {
|
|
21
|
-
"@crewhaus/tool-executor": "0.1.
|
|
22
|
-
"@crewhaus/tool-permission-matcher": "0.1.
|
|
23
|
-
"@crewhaus/tool-validate": "0.1.
|
|
24
|
+
"@crewhaus/tool-executor": "0.1.6",
|
|
25
|
+
"@crewhaus/tool-permission-matcher": "0.1.6",
|
|
26
|
+
"@crewhaus/tool-validate": "0.1.6"
|
|
24
27
|
},
|
|
25
28
|
"license": "Apache-2.0",
|
|
26
29
|
"author": {
|
|
@@ -40,5 +43,5 @@
|
|
|
40
43
|
"publishConfig": {
|
|
41
44
|
"access": "public"
|
|
42
45
|
},
|
|
43
|
-
"files": ["
|
|
46
|
+
"files": ["dist", "README.md", "LICENSE", "NOTICE"]
|
|
44
47
|
}
|
|
@@ -1,119 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Isolated coverage for the stream **drain-fallback** path in the Bash tool.
|
|
3
|
-
*
|
|
4
|
-
* On Linux, when the spawned `sh` forks a long-running grandchild and is
|
|
5
|
-
* SIGKILLed, the orphan keeps the pipe's write-end open, so
|
|
6
|
-
* `new Response(proc.stdout).text()` never EOFs. The tool guards against this
|
|
7
|
-
* by racing each stream read against a fixed `DRAIN_GRACE_MS` fallback that
|
|
8
|
-
* resolves to `""`. That fallback closure (`() => resolve("")`) cannot be
|
|
9
|
-
* reached with a real, well-behaved subprocess — both pipes EOF immediately —
|
|
10
|
-
* so we exercise it here with a fully mocked `Bun.spawn` whose streams never
|
|
11
|
-
* close, plus a synchronous `setTimeout` so there is zero wall-clock delay and
|
|
12
|
-
* no leaked timer handle.
|
|
13
|
-
*
|
|
14
|
-
* Everything (spawn + timers) is mocked and restored in `afterEach`, and this
|
|
15
|
-
* lives in its own file so the stubs never leak into the real-subprocess
|
|
16
|
-
* suites in `index.test.ts` / `integration.test.ts`.
|
|
17
|
-
*/
|
|
18
|
-
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
19
|
-
import { bash } from "./index";
|
|
20
|
-
|
|
21
|
-
/** A ReadableStream that emits one chunk and then NEVER closes — modelling the
|
|
22
|
-
* orphaned-grandchild pipe that keeps the write-end alive. `Response.text()`
|
|
23
|
-
* on this stream never resolves, forcing the drain fallback to win the race. */
|
|
24
|
-
function neverClosingStream(): ReadableStream<Uint8Array> {
|
|
25
|
-
return new ReadableStream<Uint8Array>({
|
|
26
|
-
start(controller) {
|
|
27
|
-
controller.enqueue(new TextEncoder().encode("partial-output-no-eof"));
|
|
28
|
-
// Deliberately no controller.close() → the stream hangs open forever.
|
|
29
|
-
},
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const spies: Array<{ mockRestore: () => void }> = [];
|
|
34
|
-
|
|
35
|
-
afterEach(() => {
|
|
36
|
-
for (const s of spies.splice(0)) s.mockRestore();
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
describe("Bash tool — stream drain fallback (orphaned-pipe guard)", () => {
|
|
40
|
-
test("falls back to empty streams when neither pipe EOFs before the grace window", async () => {
|
|
41
|
-
const killMock = mock((_sig?: unknown) => {});
|
|
42
|
-
// Fake proc: exits cleanly, but its stdout/stderr never close.
|
|
43
|
-
spies.push(
|
|
44
|
-
spyOn(Bun, "spawn").mockImplementation(
|
|
45
|
-
((): ReturnType<typeof Bun.spawn> =>
|
|
46
|
-
({
|
|
47
|
-
stdout: neverClosingStream(),
|
|
48
|
-
stderr: neverClosingStream(),
|
|
49
|
-
exited: Promise.resolve(0),
|
|
50
|
-
kill: killMock,
|
|
51
|
-
}) as unknown as ReturnType<typeof Bun.spawn>) as unknown as typeof Bun.spawn,
|
|
52
|
-
),
|
|
53
|
-
);
|
|
54
|
-
// Fire ONLY the short DRAIN_GRACE (500ms) fallback timers synchronously —
|
|
55
|
-
// their `() => resolve("")` bodies are the target of this test. The long
|
|
56
|
-
// command-timeout timer (30s default) must NOT fire, or the run would be
|
|
57
|
-
// wrongly marked timed-out; for it we return an inert handle.
|
|
58
|
-
spies.push(
|
|
59
|
-
spyOn(globalThis, "setTimeout").mockImplementation(((
|
|
60
|
-
fn: (...a: unknown[]) => void,
|
|
61
|
-
ms?: number,
|
|
62
|
-
) => {
|
|
63
|
-
if (ms !== undefined && ms <= 500) fn();
|
|
64
|
-
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
65
|
-
}) as unknown as typeof setTimeout),
|
|
66
|
-
);
|
|
67
|
-
spies.push(
|
|
68
|
-
spyOn(globalThis, "clearTimeout").mockImplementation(
|
|
69
|
-
(() => {}) as unknown as typeof clearTimeout,
|
|
70
|
-
),
|
|
71
|
-
);
|
|
72
|
-
|
|
73
|
-
const out = await bash.execute({ command: "sleep 999" });
|
|
74
|
-
|
|
75
|
-
// Both streams drained to "" via the fallback, so the formatted result is
|
|
76
|
-
// just the exit line (no stdout/stderr sections) and the run is not marked
|
|
77
|
-
// as timed out (the process `exited` resolved on its own).
|
|
78
|
-
expect(out).toBe("[exit] 0");
|
|
79
|
-
expect(out).not.toContain("partial-output-no-eof");
|
|
80
|
-
expect(out).not.toContain("[stderr]");
|
|
81
|
-
expect(out).not.toContain("timed out");
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test("drain fallback still reports a non-zero exit code from a hung-pipe process", async () => {
|
|
85
|
-
const killMock = mock((_sig?: unknown) => {});
|
|
86
|
-
spies.push(
|
|
87
|
-
spyOn(Bun, "spawn").mockImplementation(
|
|
88
|
-
((): ReturnType<typeof Bun.spawn> =>
|
|
89
|
-
({
|
|
90
|
-
stdout: neverClosingStream(),
|
|
91
|
-
stderr: neverClosingStream(),
|
|
92
|
-
exited: Promise.resolve(42),
|
|
93
|
-
kill: killMock,
|
|
94
|
-
}) as unknown as ReturnType<typeof Bun.spawn>) as unknown as typeof Bun.spawn,
|
|
95
|
-
),
|
|
96
|
-
);
|
|
97
|
-
// Same selective firing as the first test: only the short DRAIN_GRACE
|
|
98
|
-
// (≤500ms) fallback timers run synchronously; the 30s command-timeout timer
|
|
99
|
-
// must stay inert, otherwise the run would be wrongly flagged as timed out
|
|
100
|
-
// and the exit line would read "[exit] 42 (timed out after 30000ms)".
|
|
101
|
-
spies.push(
|
|
102
|
-
spyOn(globalThis, "setTimeout").mockImplementation(((
|
|
103
|
-
fn: (...a: unknown[]) => void,
|
|
104
|
-
ms?: number,
|
|
105
|
-
) => {
|
|
106
|
-
if (ms !== undefined && ms <= 500) fn();
|
|
107
|
-
return 0 as unknown as ReturnType<typeof setTimeout>;
|
|
108
|
-
}) as unknown as typeof setTimeout),
|
|
109
|
-
);
|
|
110
|
-
spies.push(
|
|
111
|
-
spyOn(globalThis, "clearTimeout").mockImplementation(
|
|
112
|
-
(() => {}) as unknown as typeof clearTimeout,
|
|
113
|
-
),
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
const out = await bash.execute({ command: "exit 42" });
|
|
117
|
-
expect(out).toBe("[exit] 42");
|
|
118
|
-
});
|
|
119
|
-
});
|
package/src/index.test.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { bash } from "./index";
|
|
3
|
-
|
|
4
|
-
describe("Bash tool metadata", () => {
|
|
5
|
-
test("name + flags", () => {
|
|
6
|
-
expect(bash.name).toBe("Bash");
|
|
7
|
-
expect(bash.destructive).toBe(true);
|
|
8
|
-
expect(bash.readOnly).toBe(false);
|
|
9
|
-
expect(bash.concurrencySafe).toBe(false);
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
test("crosses a process boundary → external scope + process io-capability (#146)", () => {
|
|
13
|
-
expect(bash.scope).toBe("external");
|
|
14
|
-
expect(bash.ioCapability).toBe("process");
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
describe("Bash tool execution", () => {
|
|
19
|
-
test("captures stdout and exit code 0 on success", async () => {
|
|
20
|
-
const out = await bash.execute({ command: "echo hello" });
|
|
21
|
-
expect(out).toContain("hello");
|
|
22
|
-
expect(out).toContain("[exit] 0");
|
|
23
|
-
expect(out).not.toContain("timed out");
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test("reports non-zero exit code", async () => {
|
|
27
|
-
const out = await bash.execute({ command: "exit 7" });
|
|
28
|
-
expect(out).toContain("[exit] 7");
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
test("captures stderr separately from stdout", async () => {
|
|
32
|
-
const out = await bash.execute({ command: ">&2 echo oops" });
|
|
33
|
-
expect(out).toContain("[stderr]");
|
|
34
|
-
expect(out).toContain("oops");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("captures both streams and shows them in order", async () => {
|
|
38
|
-
const out = await bash.execute({
|
|
39
|
-
command: "echo out; >&2 echo err; exit 1",
|
|
40
|
-
});
|
|
41
|
-
expect(out).toContain("out");
|
|
42
|
-
expect(out).toContain("[stderr]");
|
|
43
|
-
expect(out).toContain("err");
|
|
44
|
-
expect(out).toContain("[exit] 1");
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("kills the process and reports timeout when exceeded", async () => {
|
|
48
|
-
const start = Date.now();
|
|
49
|
-
const out = await bash.execute({ command: "sleep 10", timeout: 150 });
|
|
50
|
-
const elapsed = Date.now() - start;
|
|
51
|
-
expect(out).toContain("timed out after 150ms");
|
|
52
|
-
expect(elapsed).toBeLessThan(2000);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
test("does not time out when command finishes within budget", async () => {
|
|
56
|
-
const out = await bash.execute({ command: "echo fast", timeout: 5000 });
|
|
57
|
-
expect(out).not.toContain("timed out");
|
|
58
|
-
expect(out).toContain("fast");
|
|
59
|
-
expect(out).toContain("[exit] 0");
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test("rejects timeouts above the cap via schema", () => {
|
|
63
|
-
const result = bash.inputSchema.safeParse({
|
|
64
|
-
command: "echo",
|
|
65
|
-
timeout: 999_999_999,
|
|
66
|
-
});
|
|
67
|
-
expect(result.success).toBe(false);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("rejects empty command via schema", () => {
|
|
71
|
-
const result = bash.inputSchema.safeParse({ command: "" });
|
|
72
|
-
expect(result.success).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { buildTool } from "@crewhaus/tool-builder";
|
|
2
|
-
import type { RegisteredTool } from "@crewhaus/tool-catalog";
|
|
3
|
-
import { z } from "zod";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* Built-in Bash tool. Spawns the command through `sh -c` via `Bun.spawn`,
|
|
7
|
-
* captures stdout + stderr, and enforces a default 30s timeout (max 10min)
|
|
8
|
-
* by SIGKILLing the subprocess when the deadline elapses. The returned
|
|
9
|
-
* string is human-readable and encodes both streams plus the exit code.
|
|
10
|
-
*
|
|
11
|
-
* Linux note: when `sh` forks a long-running grandchild (e.g. `sleep 10`),
|
|
12
|
-
* SIGKILL on the shell does not propagate to the orphan, which keeps the
|
|
13
|
-
* pipe write-end alive and prevents `text()` from ever EOFing. After the
|
|
14
|
-
* shell exits we therefore give each stream a fixed drain grace window
|
|
15
|
-
* before falling back to an empty string — the orphan still exits on its
|
|
16
|
-
* own, but the tool call returns within `timeoutMs + DRAIN_GRACE_MS`.
|
|
17
|
-
*
|
|
18
|
-
* Layer R4. Pairs with the `target-cli` codegen contract (`bash` export).
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
22
|
-
const MAX_TIMEOUT_MS = 600_000;
|
|
23
|
-
const DRAIN_GRACE_MS = 500;
|
|
24
|
-
|
|
25
|
-
const bashSchema = z.object({
|
|
26
|
-
command: z.string().min(1),
|
|
27
|
-
timeout: z.number().int().positive().max(MAX_TIMEOUT_MS).optional(),
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
type BashOutput = {
|
|
31
|
-
readonly stdout: string;
|
|
32
|
-
readonly stderr: string;
|
|
33
|
-
readonly exitCode: number;
|
|
34
|
-
readonly timedOut: boolean;
|
|
35
|
-
readonly timeoutMs: number;
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
function formatResult(out: BashOutput): string {
|
|
39
|
-
const parts: string[] = [];
|
|
40
|
-
if (out.stdout.length > 0) parts.push(out.stdout.replace(/\n+$/, ""));
|
|
41
|
-
if (out.stderr.length > 0) {
|
|
42
|
-
parts.push("[stderr]");
|
|
43
|
-
parts.push(out.stderr.replace(/\n+$/, ""));
|
|
44
|
-
}
|
|
45
|
-
const exitLine = out.timedOut
|
|
46
|
-
? `[exit] ${out.exitCode} (timed out after ${out.timeoutMs}ms)`
|
|
47
|
-
: `[exit] ${out.exitCode}`;
|
|
48
|
-
parts.push(exitLine);
|
|
49
|
-
return parts.join("\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export const bash: RegisteredTool = buildTool({
|
|
53
|
-
name: "Bash",
|
|
54
|
-
description:
|
|
55
|
-
"Run a shell command via `sh -c`. Captures stdout and stderr; default timeout 30s, max 10min.",
|
|
56
|
-
inputSchema: bashSchema,
|
|
57
|
-
destructive: true,
|
|
58
|
-
// Pillar 3 sink-side: Bash spawns a host process and its command string is an
|
|
59
|
-
// exfiltration channel (curl, nc, `base64 | sh`, …). Mark it external +
|
|
60
|
-
// process so runtime-core runs classifyEgress on the command payload and the
|
|
61
|
-
// substring matcher can flag tagged secrets on the command line (#146).
|
|
62
|
-
scope: "external",
|
|
63
|
-
ioCapability: "process",
|
|
64
|
-
execute: async (input, ctx) => {
|
|
65
|
-
const timeoutMs = input.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
66
|
-
const proc = Bun.spawn(["sh", "-c", input.command], {
|
|
67
|
-
stdout: "pipe",
|
|
68
|
-
stderr: "pipe",
|
|
69
|
-
// When the orchestrator aborts the turn (Ctrl-C, recovery exhaustion),
|
|
70
|
-
// Bun forwards the signal as SIGTERM to the spawned shell.
|
|
71
|
-
...(ctx?.signal !== undefined ? { signal: ctx.signal } : {}),
|
|
72
|
-
});
|
|
73
|
-
let timedOut = false;
|
|
74
|
-
const timer = setTimeout(() => {
|
|
75
|
-
timedOut = true;
|
|
76
|
-
try {
|
|
77
|
-
proc.kill("SIGKILL");
|
|
78
|
-
} catch {
|
|
79
|
-
// Process already exited between the timer firing and the kill call.
|
|
80
|
-
}
|
|
81
|
-
}, timeoutMs);
|
|
82
|
-
try {
|
|
83
|
-
const stdoutText = new Response(proc.stdout).text();
|
|
84
|
-
const stderrText = new Response(proc.stderr).text();
|
|
85
|
-
const exitCode = await proc.exited;
|
|
86
|
-
const drainFallback = (): Promise<string> =>
|
|
87
|
-
new Promise((resolve) => setTimeout(() => resolve(""), DRAIN_GRACE_MS));
|
|
88
|
-
const [stdout, stderr] = await Promise.all([
|
|
89
|
-
Promise.race([stdoutText, drainFallback()]),
|
|
90
|
-
Promise.race([stderrText, drainFallback()]),
|
|
91
|
-
]);
|
|
92
|
-
return formatResult({ stdout, stderr, exitCode, timedOut, timeoutMs });
|
|
93
|
-
} finally {
|
|
94
|
-
clearTimeout(timer);
|
|
95
|
-
}
|
|
96
|
-
},
|
|
97
|
-
});
|
package/src/integration.test.ts
DELETED
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Integration test: wire ToolCatalog + buildTool + validateToolInput +
|
|
3
|
-
* matchesPattern + executeTool around the bash tool. Confirms the timeout
|
|
4
|
-
* path surfaces a non-error result whose content includes the timeout
|
|
5
|
-
* marker, and that permission patterns gate prefix-style command rules.
|
|
6
|
-
*/
|
|
7
|
-
import { beforeEach, describe, expect, test } from "bun:test";
|
|
8
|
-
import { type RegisteredTool, ToolCatalog } from "@crewhaus/tool-catalog";
|
|
9
|
-
import { executeTool } from "@crewhaus/tool-executor";
|
|
10
|
-
import { bash } from "./index";
|
|
11
|
-
|
|
12
|
-
let catalog: ToolCatalog;
|
|
13
|
-
|
|
14
|
-
function lookup(name: string): RegisteredTool {
|
|
15
|
-
const tool = catalog.get(name);
|
|
16
|
-
if (!tool) throw new Error(`expected tool "${name}" to be registered`);
|
|
17
|
-
return tool;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
catalog = new ToolCatalog();
|
|
22
|
-
catalog.register(bash);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
describe("integration: tool-bash through executeTool", () => {
|
|
26
|
-
test("happy path returns stdout and exit code", async () => {
|
|
27
|
-
const result = await executeTool(
|
|
28
|
-
lookup("Bash"),
|
|
29
|
-
{ command: "echo integration" },
|
|
30
|
-
{ toolUseId: "b1" },
|
|
31
|
-
);
|
|
32
|
-
expect(result.isError).toBe(false);
|
|
33
|
-
expect(result.content).toContain("integration");
|
|
34
|
-
expect(result.content).toContain("[exit] 0");
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
test("non-zero exit is reported via formatted content (not isError)", async () => {
|
|
38
|
-
const result = await executeTool(lookup("Bash"), { command: "exit 3" }, { toolUseId: "b2" });
|
|
39
|
-
expect(result.isError).toBe(false);
|
|
40
|
-
expect(result.content).toContain("[exit] 3");
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
test("timeout surfaces in content", async () => {
|
|
44
|
-
const result = await executeTool(
|
|
45
|
-
lookup("Bash"),
|
|
46
|
-
{ command: "sleep 5", timeout: 120 },
|
|
47
|
-
{ toolUseId: "b3" },
|
|
48
|
-
);
|
|
49
|
-
expect(result.isError).toBe(false);
|
|
50
|
-
expect(result.content).toContain("timed out");
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
test("invalid input is caught before execute (empty command)", async () => {
|
|
54
|
-
const result = await executeTool(lookup("Bash"), { command: "" }, { toolUseId: "b4" });
|
|
55
|
-
expect(result.isError).toBe(true);
|
|
56
|
-
expect(result.content).toContain("Bash");
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test("permission pattern Bash gates the call", async () => {
|
|
60
|
-
const allowed = await executeTool(
|
|
61
|
-
lookup("Bash"),
|
|
62
|
-
{ command: "echo ok" },
|
|
63
|
-
{ toolUseId: "b5", allowedPatterns: ["Bash"] },
|
|
64
|
-
);
|
|
65
|
-
expect(allowed.isError).toBe(false);
|
|
66
|
-
|
|
67
|
-
const denied = await executeTool(
|
|
68
|
-
lookup("Bash"),
|
|
69
|
-
{ command: "echo ok" },
|
|
70
|
-
{ toolUseId: "b6", allowedPatterns: ["Read"] },
|
|
71
|
-
);
|
|
72
|
-
expect(denied.isError).toBe(true);
|
|
73
|
-
expect(denied.content).toContain("not permitted");
|
|
74
|
-
});
|
|
75
|
-
});
|