Files
CST-agent/src/plugins.c
T
WHC 7d3ec9fcb9 Extensions: skills, plugins, MCP, sub-agents; configurable keys
- 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
2026-10-05 14:55:31 +08:00

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;
}