322 lines · 9.1 KB · C
RawDownload
// SPDX-License-Identifier: ISC/* * shell_exec builtin — run a shell command via uv_spawn ($SHELL -c <command>). * * This is part of the desktop uv/curl layer (libclmuv), NOT the portable core: * a subprocess needs libuv, so keeping it here is what lets libclm itself stay * free of libuv. It talks to the core through the public tool API only * (clm_tool_* accessors), so it links against libclm without reaching into any * core internals. Register it with clm_tools_register_shell() after * clm_tools_register_builtins(). */#include <fcntl.h>#include <signal.h>#include <stdlib.h>#include <string.h>#include <unistd.h> #include <cjson/cJSON.h>#include <uv.h> #include "clm/clm.h"#include "clm/tools.h"#include "clm/host_uv.h"#include "clm/cleanup.h"#include "banned.h" #define CLM_SHELL_DEFAULT_TIMEOUT_MS 30000u /* Local copy of the core's arg_string helper (kept private so libclmuv depends * only on libclm's public API). */static char *sh_arg_string(cJSON *args, const char *key){	cJSON *v = NULL;	if (!(v = cJSON_GetObjectItemCaseSensitive(args, key)))		return NULL;	if (!cJSON_IsString(v))		return NULL;	return strdup(v->valuestring);} /* Shell exec via uv_spawn ($SHELL -c <command>). */struct shell_state {	struct clm_tool_invocation *inv;	uv_process_t proc;	uv_pipe_t in;         /* present only when stdin is supplied */	uv_pipe_t out;	uv_pipe_t err;	uv_write_t wreq;	char *in_buf;         /* stdin blob, kept alive across the write */	bool has_stdin;	char *buf;	size_t len;	size_t bufcap;	int handles;          /* uv handles still open (proc + pipes) */	int64_t exit_status;	int term_signal;	char *spawn_err;}; static voidshell_append(struct shell_state *s, const char *data, size_t n){	size_t cap = clm_tool_invocation_output_cap(s->inv);	size_t room, take; 	if (s->len >= cap)		return; /* full; drain and discard the rest */	room = cap - s->len;	take = n < room ? n : room; 	if (s->len + take + 1 > s->bufcap) {		size_t nc = s->bufcap ? s->bufcap * 2 : 4096;		char *p;		while (nc < s->len + take + 1)			nc *= 2;		if (nc > cap + 1)			nc = cap + 1;		p = realloc(s->buf, nc);		if (p == NULL)			return;		s->buf = p;		s->bufcap = nc;	}	memcpy(s->buf + s->len, data, take);	s->len += take;	s->buf[s->len] = '\0';} static voidshell_alloc(uv_handle_t *handle, size_t suggested, uv_buf_t *buf){	(void)handle;	buf->base = malloc(suggested);	buf->len = buf->base ? suggested : 0;} static voidshell_finish(struct shell_state *s){	struct clm_tool_invocation *inv = s->inv; 	if (s->spawn_err != NULL) {		clm_tool_fail(inv, s->spawn_err);	} else if (s->exit_status != 0 || s->term_signal != 0) {		size_t mlen = s->len + 128;		autofree char *msg = malloc(mlen);		if (msg != NULL) {			/* A signal-terminated process has no real exit status --			 * uv_process_t's exit callback reports exit_status as 0			 * in that case, not the process's own value (there isn't			 * one; it never called exit()). Printing "exit status 0"			 * alongside "killed by signal" was actively misleading			 * (reads as "succeeded, but also killed?"), so show the			 * actual signal instead of the meaningless exit status			 * when term_signal is set, rather than both at once. */			if (s->term_signal != 0) {				const char *signame = strsignal(s->term_signal);				(void)snprintf(msg, mlen, "%s%s(killed by signal %d: %s)",				    s->len ? s->buf : "", s->len ? "\n" : "",				    s->term_signal, signame != NULL ? signame : "unknown");			} else {				(void)snprintf(msg, mlen, "%s%s(exit status %lld)",				    s->len ? s->buf : "", s->len ? "\n" : "",				    (long long)s->exit_status);			}			clm_tool_fail(inv, msg);		} else {			clm_tool_fail(inv, "command failed");		}	} else {		clm_tool_complete(inv, s->len ? s->buf : "(command produced no output)");	} 	free(s->spawn_err);	free(s->in_buf);	free(s->buf);	free(s);} static voidshell_on_close(uv_handle_t *handle){	struct shell_state *s = handle->data;	if (--s->handles == 0)		shell_finish(s);} static voidshell_read(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf){	struct shell_state *s = stream->data;	if (nread > 0)		shell_append(s, buf->base, (size_t)nread);	else if (nread < 0)		uv_close((uv_handle_t *)stream, shell_on_close);	free(buf->base);} static voidshell_on_exit(uv_process_t *proc, int64_t exit_status, int term_signal){	struct shell_state *s = proc->data;	s->exit_status = exit_status;	s->term_signal = term_signal;	uv_close((uv_handle_t *)proc, shell_on_close);} static voidshell_cancel(struct clm_tool_invocation *inv, void *user){	struct shell_state *s = user;	(void)inv;	/*	 * Signal the whole process group, not just the immediate $SHELL -c	 * child: a command that backgrounds a long-running job ("cmd &")	 * leaves that job behind as a sibling in the same group once the	 * shell itself exits. uv_process_kill() only reaches s->proc's own	 * pid, so a killed shell whose backgrounded grandchild still holds	 * the stdout/stderr pipes open never delivers EOF -- shell_read()	 * never sees nread < 0, the pipes never close, shell_finish() never	 * runs, and the tool call hangs forever even after cancel reports	 * success. opt.flags | UV_PROCESS_DETACHED (see tool_shell_exec)	 * puts the child in its own new process group via setsid(), so	 * killing -pid here reaches that job too.	 */	kill(-(pid_t)s->proc.pid, SIGTERM);} /* stdin blob written: close the pipe so the child sees EOF. */static voidshell_on_stdin_written(uv_write_t *req, int status){	struct shell_state *s = req->data;	(void)status;	uv_close((uv_handle_t *)&s->in, shell_on_close);} static voidtool_shell_exec(struct clm_tool_invocation *inv, void *user){	json_cleanup cJSON *args =	    cJSON_Parse(clm_tool_invocation_args(inv));	autofree char *command = NULL;	autoclose int devnull = -1;	struct shell_state *s;	uv_loop_t *loop = clm_tool_invocation_loop(inv);	uv_stdio_container_t stdio[3];	uv_process_options_t opt;	const char *shell;	char *argv[4];	int r; 	(void)user;	if (args == NULL || !cJSON_IsObject(args)) {		clm_tool_fail(inv, "invalid arguments");		return;	}	command = sh_arg_string(args, "command");	if (command == NULL) {		clm_tool_fail(inv, "missing required string argument 'command'");		return;	} 	s = calloc(1, sizeof(*s));	if (s == NULL) {		clm_tool_fail(inv, "out of memory");		return;	}	s->inv = inv;	s->proc.data = s;	uv_pipe_init(loop, &s->out, 0);	s->out.data = s;	uv_pipe_init(loop, &s->err, 0);	s->err.data = s; 	/* Optional stdin blob: feed it through a pipe, else use /dev/null. */	s->in_buf = sh_arg_string(args, "stdin");	s->has_stdin = (s->in_buf != NULL);	if (s->has_stdin) {		uv_pipe_init(loop, &s->in, 0);		s->in.data = s;		stdio[0].flags = UV_CREATE_PIPE | UV_READABLE_PIPE;		stdio[0].data.stream = (uv_stream_t *)&s->in;	} else {		devnull = open("/dev/null", O_RDONLY | O_CLOEXEC);		if (devnull >= 0) {			stdio[0].flags = UV_INHERIT_FD;			stdio[0].data.fd = devnull;		} else {			stdio[0].flags = UV_IGNORE;		}	} 	shell = getenv("SHELL");	if (shell == NULL || shell[0] == '\0')		shell = "/bin/sh";	argv[0] = (char *)shell;	argv[1] = "-c";	argv[2] = command;	argv[3] = NULL; 	memset(&opt, 0, sizeof(opt));	opt.file = shell;	opt.args = argv;	opt.exit_cb = shell_on_exit;	/* New process group (via setsid()) so shell_cancel()'s group kill	 * also reaches anything the command backgrounded with "&", not just	 * the $SHELL -c process itself. */	opt.flags = UV_PROCESS_DETACHED;	stdio[1].flags = UV_CREATE_PIPE | UV_WRITABLE_PIPE;	stdio[1].data.stream = (uv_stream_t *)&s->out;	stdio[2].flags = UV_CREATE_PIPE | UV_WRITABLE_PIPE;	stdio[2].data.stream = (uv_stream_t *)&s->err;	opt.stdio = stdio;	opt.stdio_count = 3; 	s->handles = s->has_stdin ? 4 : 3;	r = uv_spawn(loop, &s->proc, &opt);	if (r < 0) {		s->spawn_err = strdup(uv_strerror(r));		uv_close((uv_handle_t *)&s->proc, shell_on_close);		uv_close((uv_handle_t *)&s->out, shell_on_close);		uv_close((uv_handle_t *)&s->err, shell_on_close);		if (s->has_stdin)			uv_close((uv_handle_t *)&s->in, shell_on_close);		return;	} 	clm_tool_invocation_set_cancel(inv, shell_cancel, s);	uv_read_start((uv_stream_t *)&s->out, shell_alloc, shell_read);	uv_read_start((uv_stream_t *)&s->err, shell_alloc, shell_read); 	if (s->has_stdin) {		uv_buf_t b = uv_buf_init(s->in_buf, (unsigned)strlen(s->in_buf));		s->wreq.data = s;		if (uv_write(&s->wreq, (uv_stream_t *)&s->in, &b, 1,		    shell_on_stdin_written) < 0)			uv_close((uv_handle_t *)&s->in, shell_on_close);	}} intclm_tools_register_shell(struct clm_agent *agent){	const struct clm_tool_def shell_def = {		.name = "shell_exec",		.description = "execute a shell command and return its output",		.params_schema =		    "{\"type\":\"object\","		    "\"properties\":{"		    "\"command\":{\"type\":\"string\","		    "\"description\":\"the shell command to execute\"},"		    "\"stdin\":{\"type\":\"string\","		    "\"description\":\"optional: data to write to the command's standard input\"}},"		    "\"required\":[\"command\"]}",		.invoke = tool_shell_exec,		.timeout_ms = CLM_SHELL_DEFAULT_TIMEOUT_MS,		.flags = CLM_TOOL_TIMEOUT_OVERRIDABLE | CLM_TOOL_OUTPUT_CAP_OVERRIDABLE,	};	return clm_tool_add(agent, &shell_def);}