- Skills (SKILL.md discovery, skill tool, /skills /skill) - plugin.json exec tools with permission prompts, timeouts and workspace trust (/plugins) - MCP stdio client: JSON-RPC initialize/tools/list/tools/call, mcp__server__tool, /mcp and sidebar status - task tool with general/explore/plan and .agent/agents/*.md roles, parallel read-only runs, cancel propagation, /agents - Keymap with Ctrl+X leader, keybinds.json overrides and /keys panel - Collapsed thinking, throttled streaming Markdown, leaner tool output, parallel read-only tools - Spawn: close-on-exec pipes and a process-wide fork lock - Tests for keymap, skills, plugins, MCP, sub-agents, shell compaction; docs/plugins.md
445 lines
13 KiB
C
445 lines
13 KiB
C
#ifndef _DEFAULT_SOURCE
|
|
#define _DEFAULT_SOURCE
|
|
#endif
|
|
#ifndef _XOPEN_SOURCE
|
|
#define _XOPEN_SOURCE 700
|
|
#endif
|
|
|
|
#include "cst_plugins.h"
|
|
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <sys/stat.h>
|
|
#ifndef _WIN32
|
|
#include <dirent.h>
|
|
#include <unistd.h>
|
|
#endif
|
|
|
|
#include "cJSON.h"
|
|
#include "cst_mcp.h"
|
|
#include "cst_sandbox.h"
|
|
#include "cst_skills.h"
|
|
#include "cst_tools.h"
|
|
#include "cst_util.h"
|
|
|
|
#define PLUGIN_JSON_MAX (256 * 1024)
|
|
#define PLUGIN_OUT_MAX (512 * 1024)
|
|
#define PLUGIN_TIMEOUT_DEF 60
|
|
#define PLUGIN_TIMEOUT_MAX 600
|
|
|
|
/* What a registered plugin tool runs. */
|
|
typedef struct ptool {
|
|
char tool[CTL_MAX_NAME];
|
|
char plugin[64];
|
|
char dir[OS_PATH_MAX];
|
|
char *shell; /* complete command line (argv quoted, or the raw string) */
|
|
int timeout;
|
|
struct ptool *next;
|
|
} ptool_t;
|
|
|
|
static ptool_t *g_tools;
|
|
static cst_plugin_info_t g_info[CST_PLUGINS_MAX];
|
|
static int g_ninfo;
|
|
|
|
static char *slurp(const char *path, size_t max) {
|
|
FILE *fp = fopen(path, "rb");
|
|
if (!fp)
|
|
return NULL;
|
|
cst_buf_t b = {0};
|
|
char tmp[8192];
|
|
size_t n;
|
|
while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0) {
|
|
if (b.len + n > max) {
|
|
cst_buf_free(&b);
|
|
fclose(fp);
|
|
return NULL;
|
|
}
|
|
cst_buf_append(&b, tmp, n);
|
|
}
|
|
fclose(fp);
|
|
if (!b.buf)
|
|
cst_buf_puts(&b, "");
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
/* POSIX sh single-quoting. */
|
|
static void sh_quote(cst_buf_t *b, const char *s) {
|
|
cst_buf_puts(b, "'");
|
|
for (; *s; s++) {
|
|
if (*s == '\'')
|
|
cst_buf_puts(b, "'\\''");
|
|
else
|
|
cst_buf_append(b, s, 1);
|
|
}
|
|
cst_buf_puts(b, "'");
|
|
}
|
|
|
|
static int is_file(const char *p) {
|
|
struct stat st;
|
|
return stat(p, &st) == 0 && S_ISREG(st.st_mode);
|
|
}
|
|
|
|
static int is_dir(const char *p) {
|
|
struct stat st;
|
|
return stat(p, &st) == 0 && S_ISDIR(st.st_mode);
|
|
}
|
|
|
|
static cst_tool_perm_t parse_perm(const char *s) {
|
|
if (!s)
|
|
return CST_TP_SHELL;
|
|
if (strcmp(s, "read") == 0)
|
|
return CST_TP_READ;
|
|
if (strcmp(s, "write") == 0)
|
|
return CST_TP_WRITE;
|
|
if (strcmp(s, "net") == 0)
|
|
return CST_TP_NET;
|
|
return CST_TP_SHELL;
|
|
}
|
|
|
|
static int plugin_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
|
|
cst_tool_result_t *r, void *ud) {
|
|
const ptool_t *pt = ud;
|
|
r->exit_code = 1;
|
|
#ifdef _WIN32
|
|
(void)ctx;
|
|
(void)c;
|
|
(void)pt;
|
|
r->out = strdup("error: plugin tools are not supported on Windows yet");
|
|
r->out_len = r->out ? strlen(r->out) : 0;
|
|
return 0;
|
|
#else
|
|
const cst_tool_def_t *def = cst_tool_find(pt->tool);
|
|
char *args = cst_tool_args_json(def, c);
|
|
char tmp[OS_PATH_MAX];
|
|
int fd = os_mkstemp(tmp, sizeof(tmp), "cst-plugin-");
|
|
if (fd < 0 || !args) {
|
|
if (fd >= 0) {
|
|
close(fd);
|
|
unlink(tmp);
|
|
}
|
|
free(args);
|
|
r->out = strdup("error: cannot create the argument file");
|
|
r->out_len = r->out ? strlen(r->out) : 0;
|
|
return 0;
|
|
}
|
|
size_t al = strlen(args);
|
|
int wok = write(fd, args, al) == (ssize_t)al;
|
|
close(fd);
|
|
free(args);
|
|
if (!wok) {
|
|
unlink(tmp);
|
|
r->out = strdup("error: cannot write the argument file");
|
|
r->out_len = r->out ? strlen(r->out) : 0;
|
|
return 0;
|
|
}
|
|
char root[OS_PATH_MAX];
|
|
if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0)
|
|
snprintf(root, sizeof(root), "%s", ctx->workspace_root);
|
|
cst_buf_t cmd = {0};
|
|
cst_buf_puts(&cmd, "CST_PLUGIN_DIR=");
|
|
sh_quote(&cmd, pt->dir);
|
|
cst_buf_puts(&cmd, " CST_WORKSPACE=");
|
|
sh_quote(&cmd, root);
|
|
cst_buf_puts(&cmd, " CST_TOOL=");
|
|
sh_quote(&cmd, pt->tool);
|
|
cst_buf_puts(&cmd, "; export CST_PLUGIN_DIR CST_WORKSPACE CST_TOOL; ");
|
|
cst_buf_puts(&cmd, pt->shell);
|
|
cst_buf_puts(&cmd, " < ");
|
|
sh_quote(&cmd, tmp);
|
|
|
|
int old = os_get_run_timeout();
|
|
os_set_run_timeout(pt->timeout);
|
|
os_run_result_t rr = {0};
|
|
int rc = ctx->sandbox
|
|
? cst_sandbox_run_shell(root, cmd.buf, PLUGIN_OUT_MAX, &rr)
|
|
: os_run_shell_c(root, cmd.buf, PLUGIN_OUT_MAX, &rr);
|
|
os_set_run_timeout(old);
|
|
cst_buf_free(&cmd);
|
|
unlink(tmp);
|
|
if (rc != 0) {
|
|
os_run_result_free(&rr);
|
|
r->out = strdup("error: could not start the plugin command");
|
|
r->out_len = r->out ? strlen(r->out) : 0;
|
|
return 0;
|
|
}
|
|
cst_buf_t out = {0};
|
|
if (rr.out)
|
|
cst_buf_append(&out, rr.out, rr.out_len);
|
|
if (rr.exit_code == 124)
|
|
cst_buf_printf(&out, "\n[plugin %s: timed out after %d s]", pt->plugin,
|
|
pt->timeout);
|
|
else if (rr.exit_code == 127)
|
|
cst_buf_printf(&out, "\n[plugin %s: command not found — check \"command\" "
|
|
"in %s/plugin.json]",
|
|
pt->plugin, pt->dir);
|
|
if (!out.buf)
|
|
cst_buf_puts(&out, "");
|
|
r->exit_code = rr.exit_code;
|
|
os_run_result_free(&rr);
|
|
r->out_len = out.len;
|
|
r->out = cst_buf_steal(&out);
|
|
return 0;
|
|
#endif
|
|
}
|
|
|
|
/* argv array -> quoted command line; relative entries found in the plugin
|
|
* directory become absolute. String commands are used as they are. */
|
|
static char *build_shell(const cJSON *command, const char *dir) {
|
|
if (cJSON_IsString(command) && command->valuestring[0]) {
|
|
/* a group, so the stdin redirect covers pipelines too */
|
|
cst_buf_t b = {0};
|
|
cst_buf_printf(&b, "{ %s\n}", command->valuestring);
|
|
return cst_buf_steal(&b);
|
|
}
|
|
if (!cJSON_IsArray(command) || cJSON_GetArraySize(command) == 0)
|
|
return NULL;
|
|
cst_buf_t b = {0};
|
|
cst_buf_puts(&b, "exec");
|
|
const cJSON *it = NULL;
|
|
cJSON_ArrayForEach(it, command) {
|
|
if (!cJSON_IsString(it)) {
|
|
cst_buf_free(&b);
|
|
return NULL;
|
|
}
|
|
const char *a = it->valuestring;
|
|
char abs[OS_PATH_MAX * 2];
|
|
if (a[0] && a[0] != '/' && a[0] != '-' &&
|
|
snprintf(abs, sizeof(abs), "%s/%s", dir, a) < (int)sizeof(abs) &&
|
|
is_file(abs))
|
|
a = abs;
|
|
cst_buf_puts(&b, " ");
|
|
sh_quote(&b, a);
|
|
}
|
|
return cst_buf_steal(&b);
|
|
}
|
|
|
|
static void load_plugin(const char *dir, const char *dirname,
|
|
const char *source) {
|
|
if (g_ninfo >= CST_PLUGINS_MAX)
|
|
return;
|
|
cst_plugin_info_t *info = &g_info[g_ninfo];
|
|
memset(info, 0, sizeof(*info));
|
|
snprintf(info->dir, sizeof(info->dir), "%s", dir);
|
|
snprintf(info->source, sizeof(info->source), "%s", source);
|
|
cst_tool_sanitize_name(dirname, info->name, sizeof(info->name));
|
|
char pj[OS_PATH_MAX + 16];
|
|
snprintf(pj, sizeof(pj), "%s/plugin.json", dir);
|
|
char *text = slurp(pj, PLUGIN_JSON_MAX);
|
|
if (!text)
|
|
return; /* not a plugin directory */
|
|
g_ninfo++;
|
|
cJSON *root = cJSON_Parse(text);
|
|
free(text);
|
|
if (!cJSON_IsObject(root)) {
|
|
snprintf(info->error, sizeof(info->error), "plugin.json is not a JSON "
|
|
"object");
|
|
cJSON_Delete(root);
|
|
return;
|
|
}
|
|
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(root, "name");
|
|
if (cJSON_IsString(nm) && nm->valuestring[0])
|
|
cst_tool_sanitize_name(nm->valuestring, info->name, sizeof(info->name));
|
|
const cJSON *ds = cJSON_GetObjectItemCaseSensitive(root, "description");
|
|
if (cJSON_IsString(ds))
|
|
snprintf(info->desc, sizeof(info->desc), "%s", ds->valuestring);
|
|
char owner[96];
|
|
snprintf(owner, sizeof(owner), "plugin:%.63s", info->name);
|
|
|
|
const cJSON *tools = cJSON_GetObjectItemCaseSensitive(root, "tools");
|
|
const cJSON *t = NULL;
|
|
cJSON_ArrayForEach(t, tools) {
|
|
const cJSON *tn = cJSON_GetObjectItemCaseSensitive(t, "name");
|
|
if (!cJSON_IsString(tn) || !tn->valuestring[0]) {
|
|
snprintf(info->error, sizeof(info->error), "a tool has no name");
|
|
continue;
|
|
}
|
|
char tname[CTL_MAX_NAME];
|
|
cst_tool_sanitize_name(tn->valuestring, tname, sizeof(tname));
|
|
char *shell = build_shell(cJSON_GetObjectItemCaseSensitive(t, "command"),
|
|
dir);
|
|
if (!shell) {
|
|
snprintf(info->error, sizeof(info->error),
|
|
"tool %s: \"command\" must be a string or an array of strings",
|
|
tname);
|
|
continue;
|
|
}
|
|
const cJSON *params = cJSON_GetObjectItemCaseSensitive(t, "parameters");
|
|
if (!params)
|
|
params = cJSON_GetObjectItemCaseSensitive(t, "inputSchema");
|
|
char *schema = cJSON_IsObject(params)
|
|
? cJSON_PrintUnformatted(params)
|
|
: strdup("{\"type\":\"object\",\"properties\":{}}");
|
|
const cJSON *pm = cJSON_GetObjectItemCaseSensitive(t, "perm");
|
|
const cJSON *to = cJSON_GetObjectItemCaseSensitive(t, "timeout");
|
|
const cJSON *td = cJSON_GetObjectItemCaseSensitive(t, "description");
|
|
ptool_t *pt = calloc(1, sizeof(*pt));
|
|
if (!pt || !schema) {
|
|
free(pt);
|
|
free(schema);
|
|
free(shell);
|
|
continue;
|
|
}
|
|
snprintf(pt->tool, sizeof(pt->tool), "%s", tname);
|
|
snprintf(pt->plugin, sizeof(pt->plugin), "%s", info->name);
|
|
snprintf(pt->dir, sizeof(pt->dir), "%s", dir);
|
|
pt->shell = shell;
|
|
pt->timeout = cJSON_IsNumber(to) && to->valueint > 0
|
|
? (to->valueint > PLUGIN_TIMEOUT_MAX ? PLUGIN_TIMEOUT_MAX
|
|
: to->valueint)
|
|
: PLUGIN_TIMEOUT_DEF;
|
|
cst_tool_def_t d;
|
|
memset(&d, 0, sizeof(d));
|
|
d.name = tname;
|
|
d.desc = cJSON_IsString(td) ? td->valuestring : "";
|
|
d.schema_json = schema;
|
|
d.perm = parse_perm(cJSON_IsString(pm) ? pm->valuestring : NULL);
|
|
d.run_ex = plugin_run;
|
|
d.ud = pt;
|
|
d.owner = owner;
|
|
if (cst_tool_register(&d) != 0) {
|
|
snprintf(info->error, sizeof(info->error),
|
|
"tool %s: name already taken", tname);
|
|
free(pt->shell);
|
|
free(pt);
|
|
} else {
|
|
pt->next = g_tools;
|
|
g_tools = pt;
|
|
info->ntools++;
|
|
}
|
|
free(schema);
|
|
}
|
|
|
|
const cJSON *sk = cJSON_GetObjectItemCaseSensitive(root, "skills");
|
|
char sdir[OS_PATH_MAX * 2];
|
|
snprintf(sdir, sizeof(sdir), "%s/%s", dir,
|
|
cJSON_IsString(sk) ? sk->valuestring : "skills");
|
|
if (is_dir(sdir)) {
|
|
cst_skills_add_root(sdir, owner);
|
|
info->nskills_root = 1;
|
|
}
|
|
|
|
const cJSON *servers = cJSON_GetObjectItemCaseSensitive(root, "mcpServers");
|
|
if (!servers) {
|
|
const cJSON *mcp = cJSON_GetObjectItemCaseSensitive(root, "mcp");
|
|
servers = cJSON_GetObjectItemCaseSensitive(mcp, "mcpServers");
|
|
if (!servers)
|
|
servers = cJSON_GetObjectItemCaseSensitive(mcp, "servers");
|
|
}
|
|
const cJSON *sv = NULL;
|
|
cJSON_ArrayForEach(sv, servers) {
|
|
char *sj = cJSON_PrintUnformatted(sv);
|
|
if (sj && sv->string &&
|
|
cst_mcp_add_config_json(sv->string, sj, dir, owner) == 0)
|
|
info->nmcp++;
|
|
free(sj);
|
|
}
|
|
cJSON_Delete(root);
|
|
}
|
|
|
|
static int scan(const char *root, const char *source) {
|
|
int n = 0;
|
|
#ifndef _WIN32
|
|
DIR *d = opendir(root);
|
|
if (!d)
|
|
return 0;
|
|
char *names[CST_PLUGINS_MAX * 2];
|
|
int nn = 0;
|
|
struct dirent *de;
|
|
while (nn < CST_PLUGINS_MAX * 2 && (de = readdir(d)) != NULL) {
|
|
if (de->d_name[0] == '.')
|
|
continue;
|
|
names[nn] = strdup(de->d_name);
|
|
if (names[nn])
|
|
nn++;
|
|
}
|
|
closedir(d);
|
|
for (int i = 1; i < nn; i++)
|
|
for (int j = i; j > 0 && strcmp(names[j - 1], names[j]) > 0; j--) {
|
|
char *t = names[j];
|
|
names[j] = names[j - 1];
|
|
names[j - 1] = t;
|
|
}
|
|
for (int i = 0; i < nn; i++) {
|
|
char dir[OS_PATH_MAX];
|
|
if (snprintf(dir, sizeof(dir), "%s/%s", root, names[i]) <
|
|
(int)sizeof(dir) &&
|
|
is_dir(dir)) {
|
|
int before = g_ninfo;
|
|
load_plugin(dir, names[i], source);
|
|
n += g_ninfo > before;
|
|
}
|
|
free(names[i]);
|
|
}
|
|
#else
|
|
(void)root;
|
|
(void)source;
|
|
#endif
|
|
return n;
|
|
}
|
|
|
|
static int count_plugin_dirs(const char *root) {
|
|
int n = 0;
|
|
#ifndef _WIN32
|
|
DIR *d = opendir(root);
|
|
if (!d)
|
|
return 0;
|
|
struct dirent *de;
|
|
while ((de = readdir(d)) != NULL) {
|
|
char pj[OS_PATH_MAX];
|
|
if (de->d_name[0] != '.' &&
|
|
snprintf(pj, sizeof(pj), "%s/%s/plugin.json", root, de->d_name) <
|
|
(int)sizeof(pj) &&
|
|
is_file(pj))
|
|
n++;
|
|
}
|
|
closedir(d);
|
|
#else
|
|
(void)root;
|
|
#endif
|
|
return n;
|
|
}
|
|
|
|
int cst_plugins_load(const char *ws, int ws_trusted, int *untrusted) {
|
|
if (untrusted)
|
|
*untrusted = 0;
|
|
int n = 0;
|
|
char p[OS_PATH_MAX];
|
|
if (ws && ws[0]) {
|
|
snprintf(p, sizeof(p), "%s/.agent/plugins", ws);
|
|
if (ws_trusted)
|
|
n += scan(p, "workspace");
|
|
else if (untrusted)
|
|
*untrusted = count_plugin_dirs(p);
|
|
}
|
|
char cfg[OS_PATH_MAX];
|
|
if (os_config_dir(cfg, sizeof(cfg)) == 0) {
|
|
snprintf(p, sizeof(p), "%.4000s/plugins", cfg);
|
|
n += scan(p, "user");
|
|
}
|
|
return n;
|
|
}
|
|
|
|
void cst_plugins_unload(void) {
|
|
for (int i = 0; i < g_ninfo; i++) {
|
|
char owner[96];
|
|
snprintf(owner, sizeof(owner), "plugin:%.63s", g_info[i].name);
|
|
cst_tool_unregister_owner(owner);
|
|
}
|
|
cst_skills_clear_roots("plugin:");
|
|
while (g_tools) {
|
|
ptool_t *n = g_tools->next;
|
|
free(g_tools->shell);
|
|
free(g_tools);
|
|
g_tools = n;
|
|
}
|
|
g_ninfo = 0;
|
|
}
|
|
|
|
int cst_plugins_info(cst_plugin_info_t *out, int max) {
|
|
int n = g_ninfo < max ? g_ninfo : max;
|
|
for (int i = 0; i < n; i++)
|
|
out[i] = g_info[i];
|
|
return n;
|
|
}
|