148 lines
4.5 KiB
TypeScript
148 lines
4.5 KiB
TypeScript
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
import { exec } from "node:child_process";
|
|
import path from "node:path";
|
|
import { promisify } from "node:util";
|
|
|
|
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
|
import { DroneTool } from "../tool.ts";
|
|
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
|
|
|
|
const execAsync = promisify(exec);
|
|
const DEFAULT_TIMEOUT = 30_000;
|
|
const MAX_TIMEOUT = 120_000;
|
|
|
|
export class ShellExecTool extends DroneTool {
|
|
get name(): string {
|
|
return "shell_exec";
|
|
}
|
|
|
|
get category(): string {
|
|
return "system";
|
|
}
|
|
|
|
public definition: IToolDefinition = {
|
|
type: "function",
|
|
function: {
|
|
name: "shell_exec",
|
|
description: "Execute a shell command in the project directory using Bash. Returns stdout, stderr, and exit code. For temporary/scratch work, create and use a tmp directory inside the .gadget workspace directory.",
|
|
parameters: {
|
|
type: "object",
|
|
properties: {
|
|
command: {
|
|
type: "string",
|
|
description: "The shell command to execute with Bash.",
|
|
},
|
|
cwd: {
|
|
type: "string",
|
|
description: "Working directory for the command, relative to the project root. Defaults to the project root.",
|
|
},
|
|
timeout: {
|
|
type: "number",
|
|
description: `Timeout in milliseconds. Default: ${DEFAULT_TIMEOUT / 1000}s, max: ${MAX_TIMEOUT / 1000}s.`,
|
|
},
|
|
},
|
|
required: ["command"],
|
|
},
|
|
},
|
|
};
|
|
|
|
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
|
const command = args.command;
|
|
if (typeof command !== "string" || command.trim().length === 0) {
|
|
return toolError({
|
|
code: "MISSING_PARAMETER",
|
|
message: "Command must not be empty.",
|
|
parameter: "command",
|
|
recoveryHint: "Provide a valid shell command to execute with Bash.",
|
|
});
|
|
}
|
|
|
|
const projectRoot = getProjectRoot(this.toolbox);
|
|
if (!projectRoot) {
|
|
return toolError({
|
|
code: "OPERATION_NOT_ALLOWED",
|
|
message: "No active project workspace is configured for shell execution.",
|
|
recoveryHint: "Run this tool during an active Agent work order.",
|
|
});
|
|
}
|
|
|
|
let resolvedCwd: string;
|
|
if (args.cwd !== undefined && typeof args.cwd === "string") {
|
|
const resolved = resolveProjectPath(this.toolbox, args.cwd);
|
|
if (typeof resolved === "string") return resolved;
|
|
resolvedCwd = resolved.absolutePath;
|
|
} else {
|
|
resolvedCwd = path.resolve(projectRoot);
|
|
}
|
|
|
|
const timeout = Math.min(
|
|
typeof args.timeout === "number" ? args.timeout : DEFAULT_TIMEOUT,
|
|
MAX_TIMEOUT,
|
|
);
|
|
|
|
try {
|
|
const { stdout, stderr } = await execAsync(command, {
|
|
cwd: resolvedCwd,
|
|
shell: "/bin/bash",
|
|
timeout,
|
|
maxBuffer: 1024 * 1024 * 10,
|
|
});
|
|
|
|
return this.formatOutput(command, stdout, stderr, 0);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
const nodeError = error as NodeJS.ErrnoException & {
|
|
code?: string;
|
|
stdout?: string;
|
|
stderr?: string;
|
|
};
|
|
|
|
if (nodeError.code === "ETIMEDOUT" || nodeError.message.includes("timeout")) {
|
|
return toolError({
|
|
code: "TIMEOUT",
|
|
message: `Command timed out after ${timeout / 1000}s: ${command}`,
|
|
recoveryHint: `Increase the timeout parameter (max ${MAX_TIMEOUT / 1000}s) or break the command into smaller steps.`,
|
|
});
|
|
}
|
|
|
|
const stdout = nodeError.stdout ?? "";
|
|
const stderr = nodeError.stderr ?? nodeError.message;
|
|
const exitCode = typeof nodeError.code === "string"
|
|
? parseInt(nodeError.code.replace("ERR_", ""), 10)
|
|
: 1;
|
|
|
|
return this.formatOutput(
|
|
command,
|
|
stdout,
|
|
stderr,
|
|
isNaN(exitCode) ? 1 : exitCode,
|
|
);
|
|
}
|
|
|
|
logger.error("failed to execute command", { command, error: String(error) });
|
|
return toolError({
|
|
code: "OPERATION_FAILED",
|
|
message: `Failed to execute command: ${String(error)}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
private formatOutput(
|
|
command: string,
|
|
stdout: string,
|
|
stderr: string,
|
|
exitCode: number,
|
|
): string {
|
|
return [
|
|
`COMMAND: ${command}`,
|
|
`EXIT CODE: ${exitCode}`,
|
|
"---[stdout]",
|
|
stdout || "N/A",
|
|
"---[stderr]",
|
|
stderr || "N/A",
|
|
].join("\n");
|
|
}
|
|
}
|