diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b55bce..7dd7b5b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,6 +127,7 @@ set(CST_CORE_SOURCES src/tools/fs_tools.c src/tools/agent_tools.c src/tools/web.c + src/tools/session_tools.c src/tools/diff.c src/undo.c src/permissions.c @@ -372,7 +373,7 @@ endforeach() set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode test_apply_patch test_session_roundtrip test_store test_attach test_artifacts - test_shell test_diff test_tools_fs test_registry + test_shell test_diff test_tools_fs test_tools_ext test_registry test_undo test_config test_models test_model_registry test_sandbox test_keymap test_shell_compact test_skills test_plugins test_mcp test_subagent test_lowil diff --git a/Makefile b/Makefile index 5f37b56..7860b84 100644 --- a/Makefile +++ b/Makefile @@ -104,6 +104,7 @@ CORE_SRC = \ src/tools/fs_tools.c \ src/tools/agent_tools.c \ src/tools/web.c \ + src/tools/session_tools.c \ src/tools/diff.c \ src/undo.c \ src/permissions.c \ @@ -146,6 +147,7 @@ TESTS = \ tests/test_shell \ tests/test_diff \ tests/test_tools_fs \ + tests/test_tools_ext \ tests/test_registry \ tests/test_tool_meta \ tests/test_embed \ diff --git a/README.md b/README.md index b2e2166..a009f1c 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,8 @@ cst-agent --headless --prompt 'List files' | `delete_file` / `move_file` | 写 | 删除 / 移动(重命名)文件 | | `run_shell` | shell | 非交互命令,超时整组进程被杀,Esc 可中断 | | `web_fetch` | 网络 | 抓取 URL,HTML 转纯文本 | +| `download` | 网络 | 把 URL 抓成工作区里的文件(≤128 MB,超限删掉残file);字节落在磁盘上,不进模型上下文 | +| `read_pdf` | 读 | 读 PDF 的文本层(`read_file` 因二进制拒收的缺口),带页数,文本默认截到 256 KiB | | `clipboard_read` | 读 | 读系统剪贴板里的文本(`max_bytes` 默认 64 KiB,超出截断并说明) | | `clipboard_write` | 写 | 把文本放进系统剪贴板(按写类权限确认;本机走 pbcopy / wl-copy / xclip / Win32 API) | | `todo_write` | 元 | 维护任务清单(TUI 右侧面板,Ctrl+T 开关) | @@ -307,6 +309,9 @@ cst-agent --headless --prompt 'List files' | `skill` | 元 | 加载一个 skill 的说明(`SKILL.md`)或其中的文件;有 skill 时才出现 | | `task` | 子 agent | 把独立任务交给子 agent(`general` / `explore` / `plan` / 自定义角色),只返回最终报告 | | `deliver_file` | 交付 | 把工作区里的成品文件复制进会话库并交给用户(任意字节,≤64 MB):前端画成文件卡,可打开 / 分享;带 `for_user` 标记,永不进模型上下文 | +| `md_to_html` | 文档 | 把 markdown(文本或工作区 `.md`)渲染成一份自包含 HTML 文档写进工作区并交付成文件卡:utf-8、标题、内联样式,无脚本无外链 | +| `session_search` | 读 | 在过去和当前的所有会话转录里搜子串,给会话 id、条数、标题和匹配行(行号 + 角色 + 上下文) | +| `device_info` | 读 | 报告运行环境:系统 / 架构 / CPU 数、工作区、沙箱、模型与审批模式、config / temp / 程序目录、pid(审批策略名 `device`) | 插件工具和 MCP 工具(`mcp____`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。 同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。 diff --git a/app/android/AndroidManifest.xml b/app/android/AndroidManifest.xml index 0026e6f..5486486 100644 --- a/app/android/AndroidManifest.xml +++ b/app/android/AndroidManifest.xml @@ -18,12 +18,15 @@ Permissions: INTERNET for the providers the user configures, POST_NOTIFICATIONS for the app's notification_send tool (Android 13+ - asks at runtime; the tool stays hidden until it is granted), and the + asks at runtime; the tool stays hidden until it is granted), the foreground-service permissions with WAKE_LOCK for the service that keeps - a turn going while the phone is in a pocket (CstService.java). No - storage permission: the file picker is the system one (SAF) and its - input is copied into the app's directory. No cleartext traffic: every - provider URL is https. --> + a turn going while the phone is in a pocket (CstService.java), and + MANAGE_EXTERNAL_STORAGE ("all files access", Android 11+) so the engine + can read a path in shared storage that a task names rather than picks: + Android only offers the Settings screen for it, so main.cpp opens that + once and everything still works without it: what the system picker (SAF) + hands over is copied into the app's own directory (AttachFile.java). No + cleartext traffic: every provider URL is https. --> + + -> the path of a copy inside destDir; "" when the string + * is already a path; "error: ..." when the copy failed. */ + public static String materialize(Context ctx, String pathOrUri, String destDir) { + if (pathOrUri == null || pathOrUri.isEmpty()) + return "error: no file was picked"; + if (!isContent(pathOrUri)) + return ""; + try { + if (ctx == null) + return "error: no Android context"; + final Uri uri = Uri.parse(pathOrUri); + String name = displayName(ctx, uri); + if (name == null || name.trim().isEmpty()) + name = uri.getLastPathSegment(); + name = sanitize(name); + File dir = new File(destDir); + if (!dir.isDirectory() && !dir.mkdirs()) + return "error: cannot use " + destDir; + File out = unique(dir, name); + final ContentResolver cr = ctx.getContentResolver(); + final InputStream in = cr.openInputStream(uri); + if (in == null) + return "error: the picker gave no data for that file"; + long total = 0; + try { + final OutputStream os = new FileOutputStream(out); + try { + final byte[] buf = new byte[64 * 1024]; + int n; + while ((n = in.read(buf)) > 0) { + total += n; + if (total > MAX_BYTES) { + os.close(); + in.close(); + out.delete(); + return "error: that file is larger than 256 MB"; + } + os.write(buf, 0, n); + } + } finally { + os.close(); + } + } finally { + in.close(); + } + if (total == 0) { + out.delete(); + return "error: that file is empty"; + } + return out.getAbsolutePath(); + } catch (Throwable t) { + return "error: cannot read the picked file: " + t; + } + } + + /* Android 11+ keeps "all files access" in a special app-op; older systems + * have no such gate. */ + public static boolean hasAllFilesAccess(Context ctx) { + try { + if (Build.VERSION.SDK_INT < 30) + return true; + return Environment.isExternalStorageManager(); + } catch (Throwable t) { + return false; + } + } + + /* The Settings screen Android requires for it. "" when it was opened. */ + public static String requestAllFilesAccess(Context ctx) { + try { + if (Build.VERSION.SDK_INT < 30 || ctx == null) + return ""; + if (Environment.isExternalStorageManager()) + return ""; + final Intent intent = new Intent( + Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION, + Uri.parse("package:" + ctx.getPackageName())); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + ctx.startActivity(intent); + } catch (ActivityNotFoundException notFound) { + final Intent all = new Intent( + Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION); + all.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + ctx.startActivity(all); + } + return ""; + } catch (Throwable t) { + return "cannot open the storage settings: " + t; + } + } + + private static String displayName(Context ctx, Uri uri) { + Cursor c = null; + try { + c = ctx.getContentResolver().query(uri, + new String[] { OpenableColumns.DISPLAY_NAME }, null, null, + null); + if (c != null && c.moveToFirst()) { + final int i = c.getColumnIndex(OpenableColumns.DISPLAY_NAME); + if (i >= 0) + return c.getString(i); + } + } catch (Throwable ignored) { + /* fall back to the last path segment */ + } finally { + if (c != null) + c.close(); + } + return null; + } + + /* A plain file name: no separators, no traversal, no control bytes. */ + private static String sanitize(String name) { + if (name == null || name.isEmpty()) + return "picked.bin"; + final StringBuilder b = new StringBuilder(); + for (int i = 0; i < name.length() && b.length() < 120; i++) { + final char ch = name.charAt(i); + if (ch == '/' || ch == '\\' || ch < 0x20) + b.append('_'); + else + b.append(ch); + } + String s = b.toString(); + while (s.startsWith(".")) + s = s.substring(1); + if (s.isEmpty()) + return "picked.bin"; + return s; + } + + private static File unique(File dir, String name) { + File f = new File(dir, name); + if (!f.exists()) + return f; + final int dot = name.lastIndexOf('.'); + final String stem = dot > 0 ? name.substring(0, dot) : name; + final String ext = dot > 0 ? name.substring(dot) : ""; + for (int i = 2; i < 1000; i++) { + f = new File(dir, stem + "-" + i + ext); + if (!f.exists()) + return f; + } + return f; + } +} diff --git a/app/qml/Composer.qml b/app/qml/Composer.qml index 31adf5e..83cbdfe 100644 --- a/app/qml/Composer.qml +++ b/app/qml/Composer.qml @@ -31,6 +31,12 @@ Rectangle { /* A file: from the picker, or a URL dropped on the window. */ function localPath(url) { let s = String(url) + // A phone's picker (SAF) hands over content:// URIs: they are not + // paths and must not be decoded -- the app copies what they point at + // into its own directory before the engine sees it (see + // app::materializeAttachment). + if (s.startsWith("content://")) + return s if (s.startsWith("file://")) s = s.substring(7) /* Windows keeps its drive letter after the slash. */ diff --git a/app/src/core/AgentCore.cpp b/app/src/core/AgentCore.cpp index 29aef3a..129f691 100644 --- a/app/src/core/AgentCore.cpp +++ b/app/src/core/AgentCore.cpp @@ -1034,11 +1034,19 @@ QString AgentCore::attachFile(const QString &path) { return QStringLiteral("no engine"); if (path.isEmpty()) return QStringLiteral("no file"); + /* What a picker hands over is not always a path: on a phone it is a SAF + * content:// URI, and the app copies that into its own directory first (the + * engine opens files by path). A desktop passes the path through. */ + QString err2; + const QString local = app::materializeAttachment(path, &err2); + if (local.isEmpty()) + return err2.isEmpty() ? QStringLiteral("that file cannot be attached") + : err2; char err[256] = ""; - if (cst_agent_attach_file(m_agent, path.toUtf8().constData(), err, + if (cst_agent_attach_file(m_agent, local.toUtf8().constData(), err, sizeof(err)) != 0) return utf8(err[0] ? err : "that file cannot be attached"); - m_attachNames << QFileInfo(path).fileName(); + m_attachNames << QFileInfo(local).fileName(); emit pendingChanged(); return QString(); } diff --git a/app/src/core/ToolProvider.h b/app/src/core/ToolProvider.h index 83a2c5f..ab2f23d 100644 --- a/app/src/core/ToolProvider.h +++ b/app/src/core/ToolProvider.h @@ -78,6 +78,22 @@ QString holdForeground(const QString &text); /* Drop it again, at the end of the turn (a phone: the notification goes). */ void releaseForeground(); +/* Turn what a file chooser handed over into a path the engine can open. A + * desktop picker gives a path and nothing happens; a phone's is a SAF + * content:// URI, which the app copies into its own directory first (the + * grant travels with the URI, so no permission is involved -- and without + * the copy the engine, which opens files by path, would fail with "cannot + * read"). Returns the path to read, or "" with *err filled. */ +QString materializeAttachment(const QString &pathOrUri, QString *err); +/* Does this app have "all files access" (Android 11+)? A desktop always + * does. Without it the app still attaches whatever the picker handed it; what + * it cannot do is read shared storage by path. */ +bool hasAllFilesAccess(); +/* Ask for it, through the one screen Android offers (there is no runtime + * dialog for it). A desktop, a phone that already has it or one too old to + * know about it returns "" and nothing opens. */ +QString requestAllFilesAccess(); + } // namespace app #endif /* CST_APP_TOOLPROVIDER_H */ diff --git a/app/src/main.cpp b/app/src/main.cpp index 32cd968..44b532c 100644 --- a/app/src/main.cpp +++ b/app/src/main.cpp @@ -257,6 +257,26 @@ int main(int argc, char **argv) { QtAndroidPrivate::requestPermission( QStringLiteral("android.permission.POST_NOTIFICATIONS")); } + /* "All files access" is the other half of storage: it is what lets the + * engine read a path in shared storage (a file the user names, not one the + * picker hands over -- that one is copied into the app either way, see + * AttachFile.java). Android has no runtime dialog for it, only the Settings + * screen, so it is opened once (a marker file remembers) and never + * insisted on: without it everything still works, one picked file at a + * time. The line below is what logcat shows when a device behaves oddly. */ + const bool allFiles = app::hasAllFilesAccess(); + qInfo("storage: all files access = %s", allFiles ? "yes" : "no"); + if (!allFiles) { + const QString marker = androidRoot() + QStringLiteral("/.all-files-asked"); + if (!QFileInfo::exists(marker)) { + const QString why = app::requestAllFilesAccess(); + if (!why.isEmpty()) + qWarning("all files access: %s", qUtf8Printable(why)); + QFile asked(marker); + if (asked.open(QIODevice::WriteOnly)) + asked.write("1\n"); + } + } #endif if (parser.isSet(planOpt)) core.setPlanMode(true); diff --git a/app/src/platform/android/AndroidTools.cpp b/app/src/platform/android/AndroidTools.cpp index b9f01c7..716fafa 100644 --- a/app/src/platform/android/AndroidTools.cpp +++ b/app/src/platform/android/AndroidTools.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -662,6 +663,89 @@ void releaseForeground() { }); } +/* What the picker gave us -> a path the engine can open. A SAF URI is copied + * into the app's own cache (the URI's grant covers the read; no permission is + * involved), a plain path is left alone. */ +QString materializeAttachment(const QString &pathOrUri, QString *err) { + if (err) + err->clear(); + if (pathOrUri.isEmpty()) + return QString(); + if (!pathOrUri.startsWith(QLatin1String("content://"))) { + QString p = pathOrUri; + if (p.startsWith(QLatin1String("file://"))) + p = p.mid(7); + return p; + } + const QString dest = QStandardPaths::writableLocation( + QStandardPaths::CacheLocation) + + QStringLiteral("/picked"); + QString result, failure; + onGuiThread([&] { + QJniObject ctx = appContext(); + if (!ctx.isValid()) { + failure = QStringLiteral("no Android context to copy the file with"); + return; + } + QJniObject out = QJniObject::callStaticObjectMethod( + "xyz/whcself/cstagent/AttachFile", "materialize", + "(Landroid/content/Context;Ljava/lang/String;Ljava/lang/String;)" + "Ljava/lang/String;", + ctx.object(), QJniObject::fromString(pathOrUri).object(), + QJniObject::fromString(dest).object()); + if (!out.isValid()) { + failure = QStringLiteral("the phone could not hand that file over"); + return; + } + const QString s = out.toString(); + if (s.startsWith(QLatin1String("error: "))) + failure = s.mid(7); + else if (!s.isEmpty()) + result = s; + /* "" means it was a path all along; the caller keeps what it had */ + }); + if (!failure.isEmpty()) { + if (err) + *err = failure; + return QString(); + } + return result.isEmpty() ? pathOrUri : result; +} + +bool hasAllFilesAccess() { + bool granted = true; + onGuiThread([&] { + QJniObject ctx = appContext(); + if (!ctx.isValid()) + return; + granted = QJniObject::callStaticMethod( + "xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess", + "(Landroid/content/Context;)Z", ctx.object()); + }); + return granted; +} + +QString requestAllFilesAccess() { + if (qEnvironmentVariableIsSet("CST_APP_NO_PERM")) + return QString(); /* a test run touches no settings */ + QString err; + onGuiThread([&] { + QJniObject ctx = appContext(); + if (!ctx.isValid()) + return; + QJniObject out = QJniObject::callStaticObjectMethod( + "xyz/whcself/cstagent/AttachFile", "requestAllFilesAccess", + "(Landroid/content/Context;)Ljava/lang/String;", + ctx.object()); + if (out.isValid()) { + const QString s = out.toString(); + if (!s.isEmpty()) + err = s; + } + }); + return err; +} + int platformRegisterAndroid(AgentCore *core) { if (!core) return 0; diff --git a/app/src/platform/linux/NotifyTool.cpp b/app/src/platform/linux/NotifyTool.cpp index 787c775..b920c92 100644 --- a/app/src/platform/linux/NotifyTool.cpp +++ b/app/src/platform/linux/NotifyTool.cpp @@ -134,9 +134,29 @@ QString holdForeground(const QString &text) { (void)text; return QString(); } - void releaseForeground() {} +/* A desktop picker hands over a path, so there is nothing to turn into one: + * a file:// URL loses its scheme and a content:// URI (a portal that never + * gave us a path) is refused with what the user should do instead. */ +QString materializeAttachment(const QString &pathOrUri, QString *err) { + QString s = pathOrUri; + if (s.startsWith(QLatin1String("file://"))) + s = s.mid(7); + if (s.startsWith(QLatin1String("content://"))) { + if (err) + *err = QStringLiteral("that picker handed over a content:// URI this " + "desktop cannot open; pick the file by path " + "instead"); + return QString(); + } + return s; +} + +/* Every desktop process reads the files its user can. */ +bool hasAllFilesAccess() { return true; } +QString requestAllFilesAccess() { return QString(); } + int platformRegisterLinux(AgentCore *core) { static const cst_tool_param_t params[CST_TOOL_MAX_PARAMS] = { {"message", "string", 1, "text of the notification"}, diff --git a/include/cst_version.h b/include/cst_version.h index 8a1fc75..780f391 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 9 -#define CST_VERSION_PATCH 5 +#define CST_VERSION_PATCH 6 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/include/cst_web_md.h b/include/cst_web_md.h index 6698995..1a1edaa 100644 --- a/include/cst_web_md.h +++ b/include/cst_web_md.h @@ -38,6 +38,10 @@ void cst_web_md_free(cst_web_md_t *s); /* One-shot: markdown -> sanitized HTML (malloc'd, never NULL on success). */ char *cst_web_md_html(const char *md, size_t len); +/* One-shot: markdown -> a whole HTML document (doctype, charset, title, a + * small stylesheet; no script and no external request), for a file handed to + * the user. title is optional; NULL or empty means "Document". */ +char *cst_web_md_document(const char *md, size_t len, const char *title); /* The sanitizer alone (tests, and any HTML that did not come from here). */ char *cst_web_md_sanitize(const char *html, size_t len); diff --git a/src/tools/fs_tools.c b/src/tools/fs_tools.c index 5fe42eb..3e67a9f 100644 --- a/src/tools/fs_tools.c +++ b/src/tools/fs_tools.c @@ -15,6 +15,7 @@ #include #include "cst_attach.h" +#include "cst_pdf.h" #include "cst_tools.h" #include "cst_util.h" #include "os_compat.h" @@ -1490,4 +1491,58 @@ int cst_tool_grep(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, return tool_take_buf(r, &out, 0); } +/* --- read_pdf ------------------------------------------------------------ */ + +/* A PDF is not text, so read_file refuses it (it sniffs NUL bytes) -- and a + * PDF is exactly the file a phone gets handed. This reads its text layer the + * way an attachment does, with the extractor's own 4 MiB ceiling. */ +#define PDF_MAX_IN (4 * 1024 * 1024 + 1024) +#define PDF_DEFAULT_OUT (256 * 1024) + +int cst_tool_read_pdf(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + const char *path = tool_arg(c, "path"); + if (!path || !path[0]) + return tool_set_result(r, "error: missing path", 1); + char full[OS_PATH_MAX]; + if (resolve(ctx, path, full, sizeof(full)) != 0) + return tool_set_resultf(r, 1, + "error: path outside workspace or missing parent: " + "%.200s", + path); + if (is_dir(full)) + return tool_set_resultf(r, 1, "error: %.200s is a directory", path); + size_t n = 0; + char *data = tool_slurp(full, &n, PDF_MAX_IN); + if (!data) + return tool_set_resultf(r, 1, + "error: cannot read %.200s (or it is larger than " + "%d MB)", + path, PDF_MAX_IN >> 20); + int pages = 0; + char err[256] = ""; + char *text = cst_pdf_text(data, n, &pages, err, sizeof(err)); + free(data); + if (!text) + return tool_set_resultf(r, 1, "error: %.200s is not a readable PDF: %.150s", + path, err); + long max_out = tool_arg_long(c, "max_bytes", PDF_DEFAULT_OUT); + if (max_out <= 0) + max_out = PDF_DEFAULT_OUT; + size_t tlen = strlen(text); + size_t keep = cst_utf8_floor(text, (size_t)max_out); + if (keep > tlen) + keep = tlen; + cst_buf_t out = {0}; + cst_buf_printf(&out, "%s · %d page%s · %zu bytes of text\n\n", path, pages, + pages == 1 ? "" : "s", tlen); + cst_buf_append(&out, text, keep); + if (keep < tlen) + cst_buf_printf(&out, "\n...(text cut at %zu of %zu bytes; a PDF's own " + "layout is not reproduced)\n", + keep, tlen); + free(text); + return tool_take_buf(r, &out, 0); +} + diff --git a/src/tools/registry.c b/src/tools/registry.c index be4148a..527c1e5 100644 --- a/src/tools/registry.c +++ b/src/tools/registry.c @@ -166,6 +166,53 @@ static const cst_tool_def_t TOOLS[] = { {{"url", S, 1, "http:// or https:// URL"}, {"max_bytes", I, 0, "maximum bytes to return (default 65536)"}}, CST_TP_NET, 0, cst_tool_web_fetch, NULL, NO_DYN, NO_META}, + {"download", + "Fetch an http(s) URL into the workspace as a file (up to 128 MB) — a " + "release, a dataset, an image, a PDF. The bytes stay on disk: this " + "answer only says what was saved. Use web_fetch when you want to read " + "the content yourself.", + "Download an http(s) URL to a file in the workspace.", + {{"url", S, 1, "http:// or https:// URL"}, + {"path", S, 0, "file path (default: the URL's own file name)"}, + {"max_bytes", I, 0, "give up above this size (default 128 MB)"}}, + CST_TP_NET, 0, cst_tool_download, NULL, NO_DYN, NO_META}, + {"read_pdf", + "Read a PDF's text layer, page count and all — what read_file refuses " + "because the bytes are binary. Layout, tables and images are not " + "reproduced.", + "Read the text of a PDF file in the workspace.", + {{"path", S, 1, "PDF file path relative to the workspace"}, + {"max_bytes", I, 0, "cap on the returned text (default 256 KiB)"}}, + CST_TP_READ, 0, cst_tool_read_pdf, NULL, NO_DYN, NO_META}, + {"md_to_html", + "Write a document: markdown in, one self-contained HTML file out " + "(the engine's own renderer — no script, safe to open anywhere), saved " + "in the workspace and handed to the person as a card they can open or " + "share. Use it when the answer should be a document to keep.", + "Render markdown into a standalone HTML document and hand it over.", + {{"markdown", S, 0, "markdown text to render"}, + {"file", S, 0, "workspace .md file to render instead"}, + {"out", S, 0, "output path (default: the source's name + .html)"}, + {"title", S, 0, "document title (default: the file name)"}}, + CST_TP_WRITE, 0, cst_tool_md_to_html, cst_preview_md_to_html, NO_DYN, + NO_META}, + {"session_search", + "Search the workspace's stored sessions (this one and every earlier " + "conversation) for a substring and get the matching lines with their " + "session, message number and context. Answers \"what did we decide " + "before\" without a shell.", + "Search earlier and current sessions for text.", + {{"query", S, 1, "text to look for"}, + {"max_matches", I, 0, "stop after this many matches (default 20)"}, + {"ignore_case", B, 0, "case-insensitive (default true)"}}, + CST_TP_READ, 0, cst_tool_session_search, NULL, NO_DYN, NO_META}, + {"device_info", + "Where this agent is running: system and CPU count, workspace, sandbox, " + "model and permissions, config/temp/program directories, pid. Call it " + "instead of guessing the environment.", + "Report the machine and session the agent runs on.", + {{"section", S, 0, "unused; the whole report is always returned"}}, + CST_TP_READ, 0, cst_tool_device_info, NULL, NO_DYN, "device", NULL, NULL}, {"ask_user", "Ask the user and wait for the answer. Only for decisions that are " "truly theirs (preferences, credentials, destructive choices).", diff --git a/src/tools/session_tools.c b/src/tools/session_tools.c new file mode 100644 index 0000000..220999a --- /dev/null +++ b/src/tools/session_tools.c @@ -0,0 +1,245 @@ +#ifndef _DEFAULT_SOURCE +#define _DEFAULT_SOURCE +#endif +#ifndef _XOPEN_SOURCE +#define _XOPEN_SOURCE 700 +#endif + +#include +#include +#include +#include + +#include "cJSON.h" +#include "cst_agent.h" +#include "cst_sandbox.h" +#include "cst_tools.h" +#include "cst_util.h" +#include "cst_version.h" +#include "os_compat.h" +#include "tools_internal.h" + +#ifndef _WIN32 +#include +#include +#else +#include +#endif + +/* A search that does not care about case: the model asks "where did we talk + * about the store", and nobody remembers how it was capitalised. */ +static const char *find_in(const char *hay, const char *needle, int icase) { + size_t nn = strlen(needle); + if (!nn) + return NULL; + for (const char *p = hay; *p; p++) { + if (!icase) { + if (strncmp(p, needle, nn) == 0) + return p; + continue; + } + size_t i = 0; + while (i < nn && p[i] && + tolower((unsigned char)p[i]) == tolower((unsigned char)needle[i])) + i++; + if (i == nn) + return p; + } + return NULL; +} + +/* A line of context around a match: control characters become spaces (one + * transcript line is one JSON object anyway) and the tail is marked. */ +static void say_context(cst_buf_t *b, const char *from, size_t max) { + size_t i = 0; + while (from[i] && i < max) { + char c = from[i]; + if (c == '\n' || c == '\r' || c == '\t') + c = ' '; + cst_buf_append(b, &c, 1); + i++; + } + if (from[i]) + cst_buf_puts(b, " ..."); +} + +/* --- session_search ------------------------------------------------------ */ + +/* What was said before: every stored session's transcript, line by line. The + * store is the same one the CLI, the TUI, the WebUI and the app use, so this + * is how "we solved this last week" is answered without a shell. */ +int cst_tool_session_search(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + const char *query = tool_arg(c, "query"); + if (!query || !query[0]) + return tool_set_result(r, "error: missing query", 1); + while (*query == ' ') + query++; + if (!query[0]) + return tool_set_result(r, "error: missing query", 1); + long want = tool_arg_long(c, "max_matches", 20); + if (want <= 0) + want = 20; + if (want > 100) + want = 100; + int icase = tool_arg_bool(c, "ignore_case", 1); + + static char ids[200][128]; + int n = cst_agent_store_list(ctx->workspace_root, ids, 200); + cst_buf_t hits = {0}; + int matches = 0, in_sessions = 0, scanned_sessions = 0; + for (int i = 0; i < n && matches < (int)want; i++) { + char path[OS_PATH_MAX]; + if (cst_agent_store_path_for_id(ctx->workspace_root, ids[i], path, + sizeof(path)) != 0) + continue; + FILE *fp = fopen(path, "rb"); + if (!fp) + continue; + scanned_sessions++; + cst_session_info_t in; + int have_info = cst_agent_store_info(ctx->workspace_root, ids[i], &in) == 0; + int in_this = 0, lineno = 0; + char *line = NULL; + size_t cap = 0; + cst_buf_t one = {0}; + ssize_t got; + while ((got = getline(&line, &cap, fp)) > 0) { + lineno++; + if (got <= 1) + continue; + cJSON *o = cJSON_Parse(line); + if (!o) + continue; + const cJSON *role = cJSON_GetObjectItemCaseSensitive(o, "role"); + const cJSON *content = cJSON_GetObjectItemCaseSensitive(o, "content"); + const char *txt = cJSON_IsString(content) ? content->valuestring : NULL; + const char *at = txt ? find_in(txt, query, icase) : NULL; + int stop = 0; + if (at) { + if (!in_this) { + in_this = 1; + in_sessions++; + cst_buf_printf(&one, "\n%s", ids[i]); + if (have_info && in.messages) + cst_buf_printf(&one, " · %d msgs", in.messages); + if (have_info && in.title[0]) + cst_buf_printf(&one, " · %s", in.title); + else if (have_info && in.first_user[0]) + cst_buf_printf(&one, " · %s", in.first_user); + cst_buf_puts(&one, "\n"); + } + const char *who = cJSON_IsString(role) ? role->valuestring : "?"; + const char *meta = NULL; + const cJSON *kind = + cJSON_GetObjectItemCaseSensitive( + cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind"); + if (cJSON_IsString(kind)) + meta = kind->valuestring; + /* a little before the match, a line's worth after it */ + const char *start = at; + for (int back = 0; start > txt && back < 60; back++) + start--; + cst_buf_printf(&one, " #%d %s%s: ", lineno, who, + meta && strcmp(meta, "note") == 0 ? " (note)" : ""); + say_context(&one, start, (size_t)(at - start) + strlen(query) + 160); + cst_buf_puts(&one, "\n"); + matches++; + stop = matches >= (int)want; + } + cJSON_Delete(o); + if (stop) + break; + } + cst_buf_append(&hits, one.buf, one.buf ? one.len : 0); + cst_buf_free(&one); + free(line); + fclose(fp); + } + if (matches == 0) { + cst_buf_free(&hits); + return tool_set_resultf(r, 0, + "no match for \"%.80s\" in %d stored session%s " + "(the store holds %d)\n", + query, scanned_sessions, + scanned_sessions == 1 ? "" : "s", n); + } + cst_buf_t out = {0}; + cst_buf_printf(&out, "%d match%s for \"%.80s\" in %d of %d stored session%s\n", + matches, matches == 1 ? "" : "es", query, in_sessions, + scanned_sessions, scanned_sessions == 1 ? "" : "s"); + cst_buf_append(&out, hits.buf, hits.len); + if (matches >= (int)want) + cst_buf_puts(&out, "\n(more may match; raise max_matches or narrow the " + "query)\n"); + cst_buf_free(&hits); + return tool_take_buf(r, &out, 0); +} + +/* --- device_info --------------------------------------------------------- */ + +/* Where this agent is running: enough for the model to stop guessing (a + * phone in a pocket and a desktop in a terminal answer differently), and no + * more than that -- no serial numbers, no accounts. */ +int cst_tool_device_info(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + (void)c; + cst_buf_t b = {0}; + cst_buf_printf(&b, "cst-agent %s\n", CST_VERSION); +#if defined(__ANDROID__) + const char *plat = "android"; +#elif defined(_WIN32) + const char *plat = "windows"; +#elif defined(__APPLE__) + const char *plat = "macos"; +#elif defined(__linux__) + const char *plat = "linux"; +#else + const char *plat = "unknown"; +#endif +#ifdef _WIN32 + char host[160] = ""; + os_windows_version(host, sizeof(host)); + SYSTEM_INFO si; + memset(&si, 0, sizeof(si)); + GetSystemInfo(&si); + cst_buf_printf(&b, "system: %s (%d-bit, %lu CPUs)\n", + host[0] ? host : plat, (int)(sizeof(void *) * 8), + (unsigned long)si.dwNumberOfProcessors); +#else + struct utsname u; + long cpus = sysconf(_SC_NPROCESSORS_ONLN); + if (uname(&u) == 0) + cst_buf_printf(&b, "system: %s %.80s %.60s (%d-bit, %ld CPUs)\n", + u.sysname, u.release, u.machine, (int)(sizeof(void *) * 8), + cpus > 0 ? cpus : 0L); + else + cst_buf_printf(&b, "system: %s (%d-bit)\n", plat, + (int)(sizeof(void *) * 8)); +#endif + cst_buf_printf(&b, "workspace: %s\n", + ctx->workspace_root ? ctx->workspace_root : "?"); + cst_buf_printf(&b, "sandbox: %s\n", cst_sandbox_name(cst_sandbox_probe())); + const cst_agent_config_t *cfg = ctx->agent ? cst_agent_config(ctx->agent) : NULL; + if (cfg) { + char sid[128] = ""; + cst_agent_session_id(ctx->agent, sid, sizeof(sid)); + cst_buf_printf(&b, "session: %.120s\n", sid[0] ? sid : "(none)"); + cst_buf_printf(&b, "model: %.60s · %.30s · effort %.12s · mode %.12s\n", + cfg->model, cfg->provider, cfg->effort, cfg->mode); + cst_buf_printf(&b, "permissions: approval %s%s%s\n", + cst_approval_name(cfg->approval), + cfg->plan_mode ? ", plan mode (read-only)" : "", + cfg->auto_approve ? ", unattended" : ""); + cst_buf_printf(&b, "steps: %d model calls per turn max\n", cfg->max_steps); + } + char dir[OS_PATH_MAX]; + if (os_config_dir(dir, sizeof(dir)) == 0) + cst_buf_printf(&b, "config: %s\n", dir); + if (os_temp_dir(dir, sizeof(dir)) == 0) + cst_buf_printf(&b, "temp: %s\n", dir); + if (os_exe_dir(dir, sizeof(dir)) == 0) + cst_buf_printf(&b, "program: %s\n", dir); + cst_buf_printf(&b, "process: pid %d\n", (int)os_getpid()); + return tool_take_buf(r, &b, 0); +} diff --git a/src/tools/tools_internal.h b/src/tools/tools_internal.h index ec07112..ee9ac94 100644 --- a/src/tools/tools_internal.h +++ b/src/tools/tools_internal.h @@ -55,6 +55,18 @@ int cst_preview_clipboard_write(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, char **out); int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); +int cst_tool_download(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r); +int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r); +int cst_preview_md_to_html(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *c, char **out); +int cst_tool_read_pdf(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r); +int cst_tool_session_search(const cst_tool_ctx_t *ctx, + const ctl_tool_call_t *c, cst_tool_result_t *r); +int cst_tool_device_info(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r); int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_tool_result_t *r); int cst_tool_deliver_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, diff --git a/src/tools/web.c b/src/tools/web.c index e8cfa1c..ea92bbf 100644 --- a/src/tools/web.c +++ b/src/tools/web.c @@ -6,8 +6,12 @@ #include +#include "cst_agent.h" +#include "cst_attach.h" #include "cst_tools.h" #include "cst_util.h" +#include "cst_web_md.h" +#include "os_compat.h" #include "tools_internal.h" #define FETCH_DOWNLOAD_MAX (5 * 1024 * 1024) @@ -284,3 +288,328 @@ int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, cst_buf_free(&f.body); return tool_take_buf(r, &b, code); } + +/* --- md_to_html ---------------------------------------------------------- */ + +#define MD_TO_HTML_MAX (8 * 1024 * 1024) + +/* The file name a URL ends in; "" when it names nothing (a bare host, a + * directory). Kept to plain characters: it becomes a file name. */ +static void name_from_url(const char *url, char *out, size_t n) { + const char *p = strstr(url, "://"); + p = p ? p + 3 : url; + const char *q = strpbrk(p, "?#"); + const char *end = q ? q : p + strlen(p); + const char *slash = NULL; + for (const char *s = p; s < end; s++) + if (*s == '/') + slash = s; + const char *leaf = slash ? slash + 1 : p; + size_t len = (size_t)(end - leaf); + for (size_t i = 0; i < len; i++) { + unsigned char ch = (unsigned char)leaf[i]; + if (!(isalnum(ch) || ch == '.' || ch == '-' || ch == '_' || ch == '+')) { + len = i; /* cut at the first byte that is not a file name's */ + break; + } + } + while (len && leaf[len - 1] == '.') + len--; + if (!len) + snprintf(out, n, "download.bin"); + else + snprintf(out, n, "%.*s", (int)len, leaf); +} + +/* "docs/notes.md" -> "notes" (the title a document falls back to). */ +static void stem_of(const char *rel, char *out, size_t n) { + char leaf[128]; + snprintf(leaf, sizeof(leaf), "%.127s", rel && rel[0] ? rel : ""); + char *slash = NULL; + for (char *p = leaf; *p; p++) + if (*p == '/' || *p == '\\') + slash = p; + char *base = slash ? slash + 1 : leaf; + char *dot = strrchr(base, '.'); + if (dot && dot != base) + *dot = 0; + snprintf(out, n, "%s", base[0] ? base : "document"); +} + +/* A title turned into a file name: what a path cannot carry becomes '-', + * control bytes go, and a name that is only dots or spaces is refused. */ +static void stem_of_title(const char *title, char *out, size_t n) { + size_t i = 0; + for (const char *p = title; *p && i + 1 < n && i < 80; p++) { + unsigned char ch = (unsigned char)*p; + if (ch == '/' || ch == '\\' || ch == ':' || ch == '*' || ch == '?' || + ch == '"' || ch == '<' || ch == '>' || ch == '|') + ch = '-'; + else if (ch < 0x20) + continue; + out[i++] = (char)ch; + } + while (i && (out[i - 1] == ' ' || out[i - 1] == '.')) + i--; + size_t start = 0; + while (start < i && (out[start] == ' ' || out[start] == '.')) + start++; + i -= start; + if (i) + memmove(out, out + start, i); + out[i] = 0; + if (!i) + snprintf(out, n, "document"); +} + +/* The file md_to_html writes: `out` when given (a directory takes the + * source's name inside it), otherwise the source file's stem, or the title. */ +static void md_out_name(const ctl_tool_call_t *c, char *out_name, size_t n) { + const char *file = tool_arg(c, "file"); + const char *out_arg = tool_arg(c, "out"); + const char *title = tool_arg(c, "title"); + char stem[200]; + if (file && file[0]) + stem_of(file, stem, sizeof(stem)); + else if (title && title[0]) + stem_of_title(title, stem, sizeof(stem)); + else + snprintf(stem, sizeof(stem), "document"); + if (out_arg && out_arg[0]) { + snprintf(out_name, n, "%.300s", out_arg); + size_t ol = strlen(out_name); + if (ol && (out_name[ol - 1] == '/' || out_name[ol - 1] == '\\') && + ol + strlen(stem) + 6 < n) + snprintf(out_name + ol, n - ol, "%s.html", stem); + } else { + snprintf(out_name, n, "%s.html", stem); + } +} + +/* What the window shows before the write is approved. */ +int cst_preview_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + char **out) { + (void)ctx; + const char *md = tool_arg(c, "markdown"); + const char *file = tool_arg(c, "file"); + if ((!md || !md[0]) && (!file || !file[0])) { + *out = strdup("error: give markdown, or a .md file to render"); + return -1; + } + char out_name[400]; + md_out_name(c, out_name, sizeof(out_name)); + cst_buf_t b = {0}; + if (file && file[0]) + cst_buf_printf(&b, "render %.200s → %.200s\n", file, out_name); + else + cst_buf_printf(&b, "render %zu bytes of markdown → %.200s\n", + md ? strlen(md) : 0, out_name); + *out = cst_buf_steal(&b); + return 0; +} + +int cst_tool_md_to_html(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + const char *md = tool_arg(c, "markdown"); + const char *file = tool_arg(c, "file"); + if ((!md || !md[0]) && (!file || !file[0])) + return tool_set_result(r, "error: give markdown, or a .md file to render", + 1); + const char *text = md; + size_t len = md ? strlen(md) : 0; + char *owned = NULL; + if (file && file[0]) { + char src[OS_PATH_MAX]; + if (cst_sandbox_resolve(ctx->workspace_root, file, src, sizeof(src)) != 0) + return tool_set_resultf(r, 1, + "error: path outside workspace or missing " + "parent: %.200s", + file); + size_t n = 0; + owned = tool_slurp(src, &n, MD_TO_HTML_MAX); + if (!owned) + return tool_set_resultf(r, 1, + "error: cannot read %.200s (or it is larger than " + "%d MB)", + file, MD_TO_HTML_MAX >> 20); + text = owned; + len = n; + } + + char title[256], out_name[400]; + const char *given = tool_arg(c, "title"); + snprintf(title, sizeof(title), "%.200s", given && given[0] ? given : "Document"); + md_out_name(c, out_name, sizeof(out_name)); + + char *html = cst_web_md_document(text, len, title); + free(owned); + if (!html) + return tool_set_result(r, "error: cannot render the markdown", 1); + tool_ensure_parent_dirs(ctx->workspace_root, out_name); + char full[OS_PATH_MAX]; + if (cst_sandbox_resolve(ctx->workspace_root, out_name, full, sizeof(full)) != + 0) { + free(html); + return tool_set_resultf(r, 1, "error: path outside workspace: %.200s", + out_name); + } + size_t bytes = strlen(html); + int wrote = tool_write_whole(full, html, bytes); + free(html); + if (wrote != 0) + return tool_set_resultf(r, 1, "error: cannot write %.200s", out_name); + + const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : NULL; + cst_attachment_t *a = NULL; + char err[256] = ""; + if (session && session[0]) + a = cst_attach_import_artifact(session, full, NULL, err, sizeof(err)); + + cst_buf_t out = {0}; + cst_buf_printf(&out, + "wrote %.200s (%zu bytes): one HTML file with everything " + "inside it, ready to open in a browser\n", + out_name, bytes); + if (a) + cst_buf_puts(&out, + "(the user sees it as a file card they can open or share; " + "you will not be shown its bytes again)\n"); + else if (err[0]) + cst_buf_printf(&out, "(the file is in the workspace; handing it over " + "failed: %.150s)\n", + err); + int rc = tool_take_buf(r, &out, 0); + if (a) { + r->attach = a; + r->nattach = 1; + } + return rc; +} + +/* --- download ------------------------------------------------------------ */ + +#define DOWNLOAD_MAX (128 * 1024 * 1024) +#define DOWNLOAD_TIMEOUT 300L + +typedef struct { + FILE *fp; + size_t written; + size_t cap; + int overflow; + int failed; +} dl_t; + +static size_t on_file(char *p, size_t sz, size_t n, void *ud) { + dl_t *d = ud; + size_t len = sz * n; + if (d->written + len > d->cap) { + d->overflow = 1; + return 0; /* abort: the cap is what keeps a wrong URL from filling a disk */ + } + if (fwrite(p, 1, len, d->fp) != len) { + d->failed = 1; + return 0; + } + d->written += len; + return len; +} + +int cst_tool_download(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, + cst_tool_result_t *r) { + const char *url = tool_arg(c, "url"); + if (!url || !url[0]) + return tool_set_result(r, "error: missing url", 1); + while (*url == ' ') + url++; + if (strncasecmp(url, "http://", 7) != 0 && + strncasecmp(url, "https://", 8) != 0) + return tool_set_result(r, "error: only http:// and https:// URLs", 1); + long cap = tool_arg_long(c, "max_bytes", DOWNLOAD_MAX); + if (cap <= 0 || cap > DOWNLOAD_MAX) + cap = DOWNLOAD_MAX; + + const char *path = tool_arg(c, "path"); + char rel[400]; + if (path && path[0]) + snprintf(rel, sizeof(rel), "%s", path); + else + name_from_url(url, rel, sizeof(rel)); + size_t rl = strlen(rel); + if (rl && (rel[rl - 1] == '/' || rel[rl - 1] == '\\')) { + /* the caller named the directory: the URL names the file */ + char name[200]; + name_from_url(url, name, sizeof(name)); + snprintf(rel + rl, sizeof(rel) - rl, "%s", name); + } + tool_ensure_parent_dirs(ctx->workspace_root, rel); + char full[OS_PATH_MAX]; + if (cst_sandbox_resolve(ctx->workspace_root, rel, full, sizeof(full)) != 0) + return tool_set_resultf(r, 1, + "error: path outside workspace or missing parent: " + "%.200s", + rel); + FILE *fp = fopen(full, "wb"); + if (!fp) + return tool_set_resultf(r, 1, "error: cannot write %.200s", rel); + + CURL *curl = curl_easy_init(); + if (!curl) { + fclose(fp); + remove(full); + return tool_set_result(r, "error: curl init failed", 1); + } + dl_t d; + memset(&d, 0, sizeof(d)); + d.fp = fp; + d.cap = (size_t)cap; + char errbuf[CURL_ERROR_SIZE]; + errbuf[0] = 0; + cst_curl_apply_ca(curl); + curl_easy_setopt(curl, CURLOPT_URL, url); + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); + curl_easy_setopt(curl, CURLOPT_MAXREDIRS, 5L); +#if LIBCURL_VERSION_NUM >= 0x075500 + curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https"); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS_STR, "http,https"); +#else + curl_easy_setopt(curl, CURLOPT_PROTOCOLS, + (long)(CURLPROTO_HTTP | CURLPROTO_HTTPS)); + curl_easy_setopt(curl, CURLOPT_REDIR_PROTOCOLS, + (long)(CURLPROTO_HTTP | CURLPROTO_HTTPS)); +#endif + curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 15L); + curl_easy_setopt(curl, CURLOPT_TIMEOUT, DOWNLOAD_TIMEOUT); + curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); + curl_easy_setopt(curl, CURLOPT_USERAGENT, "cst-agent/1.0 (+download)"); + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, on_file); + curl_easy_setopt(curl, CURLOPT_WRITEDATA, &d); + curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf); + CURLcode rc = curl_easy_perform(curl); + long http = 0; + char *ctype = NULL; + char *final_url = NULL; + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http); + curl_easy_getinfo(curl, CURLINFO_CONTENT_TYPE, &ctype); + curl_easy_getinfo(curl, CURLINFO_EFFECTIVE_URL, &final_url); + int closed = fclose(fp) != 0; + curl_easy_cleanup(curl); + if (rc != CURLE_OK || d.failed || closed || http >= 400) { + remove(full); /* half a file is worse than none */ + if (d.overflow) + return tool_set_resultf(r, 1, + "error: %.200s is larger than %ld bytes and " + "nothing was saved (raise max_bytes, up to %d " + "MB)", + url, cap, DOWNLOAD_MAX >> 20); + return tool_set_resultf(r, 1, "error: download failed: HTTP %ld %.200s", + http, errbuf[0] ? errbuf : curl_easy_strerror(rc)); + } + cst_buf_t b = {0}; + cst_buf_printf(&b, "saved %.200s (%s, %zu bytes) from HTTP %ld %.200s\n", rel, + ctype ? ctype : "application/octet-stream", d.written, http, + final_url ? final_url : url); + cst_buf_puts(&b, + "(the bytes are on disk, not in this answer; read_file or " + "read_pdf it when you need to look inside)\n"); + return tool_take_buf(r, &b, 0); +} diff --git a/src/web_md.c b/src/web_md.c index 8ceebc5..0f81064 100644 --- a/src/web_md.c +++ b/src/web_md.c @@ -304,6 +304,78 @@ void cst_web_md_free(cst_web_md_t *s) { free(s); } +/* Escape the few characters a cannot carry as they are. */ +static void doc_escape(const char *s, size_t n, cst_buf_t *out) { + for (size_t i = 0; i < n && s[i]; i++) { + switch (s[i]) { + case '&': + cst_buf_puts(out, "&"); + break; + case '<': + cst_buf_puts(out, "<"); + break; + case '>': + cst_buf_puts(out, ">"); + break; + case '"': + cst_buf_puts(out, """); + break; + default: + cst_buf_append(out, s + i, 1); + } + } +} + +/* The document a generated file holds: the same sanitized HTML, inside a page + * that stands on its own -- charset first (a phone that guesses is a phone + * that shows mojibake), a title, and a stylesheet small enough to read: no + * script, no font, no request to anywhere. */ +char *cst_web_md_document(const char *md, size_t len, const char *title) { + char *body = cst_web_md_html(md, len); + if (!body) + return NULL; + static const char head[] = + "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n" + "<meta name=\"viewport\" content=\"width=device-width, " + "initial-scale=1\">\n<title>"; + static const char mid[] = + "\n\n\n\n"; + static const char tail[] = "\n\n\n"; + cst_buf_t b = {0}; + cst_buf_puts(&b, head); + if (title && title[0]) + doc_escape(title, 200, &b); + else + cst_buf_puts(&b, "Document"); + cst_buf_puts(&b, mid); + cst_buf_puts(&b, body); + cst_buf_puts(&b, tail); + free(body); + return cst_buf_steal(&b); +} + char *cst_web_md_html(const char *md, size_t len) { if (!md) return NULL; diff --git a/tests/test_tools_ext.c b/tests/test_tools_ext.c new file mode 100644 index 0000000..5b93b9b --- /dev/null +++ b/tests/test_tools_ext.c @@ -0,0 +1,274 @@ +/* The tools that answer "make me a document", "read this PDF", "get that URL" + * and "where are we": md_to_html, read_pdf, download, session_search and + * device_info. + * + * Nothing here touches the network: download is checked on the arguments and + * the file-name rules it applies before any request is made, which is where + * its mistakes would cost a disk. */ +#define _POSIX_C_SOURCE 200809L + +#include "cst_agent.h" +#include "cst_tools.h" + +#include +#include +#include +#include +#include +#include + +#include "cst_test_util.h" + +static int nfail; + +#define EXPECT(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \ + nfail++; \ + } \ + } while (0) + +static char g_ws[256]; + +/* name, then key/value pairs, NULL-terminated. */ +static ctl_tool_call_t mk(const char *name, ...) { + ctl_tool_call_t c; + memset(&c, 0, sizeof(c)); + snprintf(c.id, sizeof(c.id), "t"); + snprintf(c.name, sizeof(c.name), "%s", name); + va_list ap; + va_start(ap, name); + const char *k; + while ((k = va_arg(ap, const char *)) != NULL) { + const char *v = va_arg(ap, const char *); + ctl_arg_t *a = &c.args[c.nargs++]; + snprintf(a->key, sizeof(a->key), "%s", k); + a->value = (char *)v; + a->value_len = strlen(v); + } + va_end(ap); + return c; +} + +static int run(cst_tool_ctx_t *ctx, ctl_tool_call_t c, cst_tool_result_t *r) { + memset(r, 0, sizeof(*r)); + cst_tool_run(ctx, &c, r); + return r->exit_code; +} + +static char *rfile(const char *rel) { + char p[512]; + snprintf(p, sizeof(p), "%s/%s", g_ws, rel); + FILE *fp = fopen(p, "rb"); + if (!fp) + return NULL; + static char buf[262144]; + size_t n = fread(buf, 1, sizeof(buf) - 1, fp); + buf[n] = 0; + fclose(fp); + return buf; +} + +static void wfile(const char *rel, const char *s) { + char p[512]; + snprintf(p, sizeof(p), "%s/%s", g_ws, rel); + FILE *fp = fopen(p, "wb"); + if (fp) { + fputs(s, fp); + fclose(fp); + } +} + +static int exists(const char *rel) { + char p[512]; + struct stat st; + snprintf(p, sizeof(p), "%s/%s", g_ws, rel); + return stat(p, &st) == 0; +} + +/* --- md_to_html: one file that stands on its own ------------------------- */ +static void test_md_to_html(cst_tool_ctx_t *ctx) { + cst_tool_result_t r; + ctl_tool_call_t c = + mk("md_to_html", "markdown", + "# Report\n\n- one\n- two\n\n\n\n" + "[x](javascript:alert(2))\n", + "title", "Report", NULL); + EXPECT(run(ctx, c, &r) == 0, "markdown renders to a document"); + EXPECT(r.out && strstr(r.out, "Report.html") != NULL, + "the answer names the file it wrote"); + char *html = rfile("Report.html"); + EXPECT(html != NULL, "and the file is there"); + if (html) { + EXPECT(strncmp(html, "", 15) == 0, "it is a whole page"); + EXPECT(strstr(html, "charset=\"utf-8\"") != NULL, "with a charset"); + EXPECT(strstr(html, "

Report

") != NULL, "the heading rendered"); + EXPECT(strstr(html, "
  • one
  • ") != NULL, "and the list"); + EXPECT(strstr(html, ", and a title with a + * path separator in it does not escape the directory */ + c = mk("md_to_html", "markdown", "body\n", "title", + "a & \"c\" ../d", "out", "hostile.html", NULL); + EXPECT(run(ctx, c, &r) == 0 && rfile("hostile.html") != NULL, + "a hostile title still writes where it was told"); + cst_tool_result_free(&r); + html = rfile("hostile.html"); + EXPECT(html && strstr(html, "a <b> & "c" ../d" + "") != NULL, + "the title is escaped"); + EXPECT(!exists("../d.html"), "and nothing lands outside the workspace"); + + /* a .md file, and an output directory */ + char dir[512]; + snprintf(dir, sizeof(dir), "%s/notes", g_ws); + mkdir(dir, 0755); + wfile("notes/source.md", "## From a file\n\nbody\n"); + c = mk("md_to_html", "file", "notes/source.md", "out", "notes/", NULL); + EXPECT(run(ctx, c, &r) == 0, "a workspace file renders"); + EXPECT(r.out && strstr(r.out, "notes/source.html") != NULL, + "the directory gets the source's name inside it"); + EXPECT(rfile("notes/source.html") != NULL, "the html is there"); + cst_tool_result_free(&r); + + /* the preview the window shows says what will be written */ + char *prev = NULL; + c = mk("md_to_html", "markdown", "hello", "out", "hello.html", NULL); + EXPECT(cst_tool_preview(ctx, &c, &prev) == 0 && prev && + strstr(prev, "hello.html") != NULL, + "the preview names the file"); + free(prev); + c = mk("md_to_html", NULL); + prev = NULL; + EXPECT(cst_tool_preview(ctx, &c, &prev) != 0 && prev && + strstr(prev, "markdown") != NULL, + "an empty call is refused in the preview too"); + free(prev); + EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "error") != NULL, + "and by the tool"); + cst_tool_result_free(&r); +} + +/* --- read_pdf: the text layer, and a plain refusal for anything else ---- */ +static void test_read_pdf(cst_tool_ctx_t *ctx) { + cst_tool_result_t r; + wfile("notreally.pdf", "this is not a pdf at all\n"); + ctl_tool_call_t c = mk("read_pdf", "path", "notreally.pdf", NULL); + EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "PDF") != NULL, + "a file that is not a PDF is refused"); + cst_tool_result_free(&r); + + c = mk("read_pdf", "path", "missing.pdf", NULL); + EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "error") != NULL, + "and a missing one"); + cst_tool_result_free(&r); + + c = mk("read_pdf", NULL); + EXPECT(run(ctx, c, &r) == 1, "a call without a path fails"); + cst_tool_result_free(&r); +} + +/* --- download: the file name it picks, before it asks the network ------- */ +static void test_download_args(cst_tool_ctx_t *ctx) { + cst_tool_result_t r; + ctl_tool_call_t c = mk("download", NULL); + EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "url") != NULL, + "download without a url fails"); + cst_tool_result_free(&r); + + c = mk("download", "url", "ftp://example.com/x.bin", NULL); + EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "http") != NULL, + "only http(s) URLs are accepted"); + cst_tool_result_free(&r); + + c = mk("download", "url", "https://example.invalid/x.bin", "path", + "../escape.bin", NULL); + EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "outside") != NULL, + "a path that leaves the workspace is refused"); + cst_tool_result_free(&r); + EXPECT(!exists("escape.bin"), "and nothing was written"); +} + +/* --- session_search: what was said before ------------------------------- */ +static void test_session_search(cst_tool_ctx_t *ctx) { + EXPECT(cst_agent_store_ensure(g_ws) == 0, "the store exists"); + char path[512]; + EXPECT(cst_agent_store_path_for_id(g_ws, "20260101-120000-fix", path, + sizeof(path)) == 0, + "a fixture session path"); + cst_session_t *s = cst_session_open(path); + EXPECT(s != NULL, "its transcript opens"); + cst_message_t m; + memset(&m, 0, sizeof(m)); + m.role = CST_ROLE_USER; + m.content = (char *)"how does the Session Store name a session?"; + EXPECT(cst_session_append(s, &m) == 0, "a message goes in"); + memset(&m, 0, sizeof(m)); + m.role = CST_ROLE_ASSISTANT; + m.content = (char *)"one directory per session, named by a uuid"; + EXPECT(cst_session_append(s, &m) == 0, "and an answer"); + cst_session_close(s); + + cst_tool_result_t r; + ctl_tool_call_t c = mk("session_search", "query", "uuid", NULL); + EXPECT(run(ctx, c, &r) == 0, "the search runs"); + EXPECT(r.out && strstr(r.out, "20260101-120000-fix") != NULL && + strstr(r.out, "uuid") != NULL, + "the match names its session and shows the line"); + EXPECT(r.out && strstr(r.out, "#2 assistant") != NULL, + "with the message number and role"); + cst_tool_result_free(&r); + + c = mk("session_search", "query", "UUID", "ignore_case", "true", NULL); + EXPECT(run(ctx, c, &r) == 0 && r.out && strstr(r.out, "1 match") != NULL, + "case-insensitive by default"); + cst_tool_result_free(&r); + + c = mk("session_search", "query", "zzz-nothing-says-this", NULL); + EXPECT(run(ctx, c, &r) == 0 && r.out && strstr(r.out, "no match") != NULL, + "a query nothing matches says so (and is not an error)"); + cst_tool_result_free(&r); + + c = mk("session_search", NULL); + EXPECT(run(ctx, c, &r) == 1, "a query is required"); + cst_tool_result_free(&r); +} + +/* --- device_info: the environment, without a shell ---------------------- */ +static void test_device_info(cst_tool_ctx_t *ctx) { + cst_tool_result_t r; + ctl_tool_call_t c = mk("device_info", NULL); + EXPECT(run(ctx, c, &r) == 0, "device_info answers"); + EXPECT(r.out && strstr(r.out, "cst-agent") != NULL, "with the version"); + EXPECT(r.out && strstr(r.out, "system: ") != NULL, "the system it runs on"); + EXPECT(r.out && strstr(r.out, g_ws) != NULL, "the workspace in use"); + cst_tool_result_free(&r); +} + +int main(void) { + if (test_tmpdir(g_ws, sizeof(g_ws), "cst-tools-2") != 0) { + perror("tmpdir"); + return 1; + } + cst_tool_ctx_t ctx; + memset(&ctx, 0, sizeof(ctx)); + ctx.workspace_root = g_ws; + + test_md_to_html(&ctx); + test_read_pdf(&ctx); + test_download_args(&ctx); + test_session_search(&ctx); + test_device_info(&ctx); + + test_rmrf(g_ws); + if (nfail) { + fprintf(stderr, "%d check(s) failed\n", nfail); + return 1; + } + printf("test_tools_ext: OK\n"); + return 0; +}