// SPDX-License-Identifier: ISC /* * shell_exec builtin — run a shell command via uv_spawn ($SHELL -c ). * * 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 #include #include #include #include #include #include #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 ). */ 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 void shell_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 void shell_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 void shell_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 void shell_on_close(uv_handle_t *handle) { struct shell_state *s = handle->data; if (--s->handles == 0) shell_finish(s); } static void shell_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 void shell_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 void shell_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 void shell_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 void tool_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); } } int clm_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); }