v0.9.6: documents, downloads, PDFs and the phone's own files
ci / tcc-check (push) Successful in 1m9s
ci / linux (aarch64, 0) (push) Failing after 1h0m2s
ci / linux (x86, 1) (push) Failing after 1h0m2s
ci / linux (x86_64, 1) (push) Failing after 1h0m2s
ci / windows (push) Failing after 10m4s

Four tools and a document writer, plus the storage half of the phone: until
now the picker's answer (a content:// URI) reached the engine as a path, and
it said "cannot read" -- the app had no way to hand a phone's file over.

- md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one
  self-contained HTML document out -- doctype, utf-8, viewport, escaped
  title, a small stylesheet; no script, no external request. It is written
  into the workspace and delivered as a file card (for_user, so it never
  enters a request), the way deliver_file does. The default name comes from
  the source file's stem or, for pasted markdown, from the title with what a
  path cannot carry turned into '-'. It has a preview, so an approval prompt
  says "render x -> y.html" before anything is written.
- download (src/tools/web.c): an http(s) URL into a file in the workspace,
  streamed with a 128 MB ceiling; a redirect chain, the same CA handling and
  the same refusals as web_fetch, but the bytes stay on disk (a half file is
  removed on any failure, including HTTP >= 400). The name comes from the URL
  when the caller names only a directory.
- read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF,
  which read_file refuses because the bytes are binary -- and a PDF is
  exactly what arrives from a phone. Same extractor an attachment uses, its
  own 4 MiB input ceiling, 256 KiB of text by default with a note when cut.
- session_search (new, src/tools/session_tools.c): every stored session --
  this one and all the ones before it -- searched for a substring, reporting
  the session's id, message count and title, then the matching lines with
  their number and role. Case-insensitive by default, capped at 20 matches.
- device_info (same file, perm_name "device"): version, system/arch/CPU
  count, workspace, sandbox, model and permissions, config/temp/program
  directories, pid -- so the model stops guessing where it runs.
- Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files):
  the document is a whole page, a script tag and a javascript: link do not
  survive, a hostile title stays text and cannot write outside the workspace,
  the preview names the file, read_pdf/download refuse what they must (no
  network in a test), session_search finds a fixture session and says so when
  nothing matches, device_info names the workspace. README's tool table grew
  five rows.

- Android storage: the picker (SAF) answers with a content:// URI, and the
  engine opens files by path, so the app now copies what it points at into
  its own directory first -- app/android/src/xyz/whcself/cstagent/
  AttachFile.java, called through a new app::materializeAttachment()
  (ToolProvider.h: one API for every platform; a desktop strips file:// and
  refuses content:// with a reason). The copy needs no permission at all: the
  grant travels with the URI.
- "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what
  lets the engine read a path in shared storage that a task names rather than
  picks. Android has no runtime dialog for it, so main.cpp opens the one
  Settings screen once (AttachFile.java's requestAllFilesAccess, with the
  all-apps screen as a fallback) and never insists: without it everything
  works, one picked file at a time. On the NX789J the screen came up, the
  app-op reads back "allow", and a relaunch no longer asks.
- Composer.qml hands a content:// URI through untouched (it is not a path,
  and decoding it would corrupt it); AgentCore::attachFile() turns whatever
  the picker gave into a path first.

Desktop: build clean, make check 36/36 (the new test included, also under
ASan), --uitest all green, --selftest and cst-app-selftest OK.
The APK builds and installs (86 MB, release-signed) with the new Java class
in its dex.

Version 0.9.6.
This commit is contained in:
WHC
2026-10-11 18:33:41 +08:00
parent 08008a5a4c
commit 580d533656
20 changed files with 1408 additions and 10 deletions
+2 -1
View File
@@ -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
+2
View File
@@ -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 \
+5
View File
@@ -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__<server>__<tool>`)在运行时注册,和内置工具走同一套审批与 `/tools` 开关。
同一步里的多个只读调用(读文件、搜索、只读子 agent)会并行执行(最多 4 个)。
+14 -5
View File
@@ -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. -->
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="xyz.whcself.cstagent"
android:installLocation="auto"
@@ -38,6 +41,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- "All files access": without it the engine can only read the app's own
directory, so a task that names a file in shared storage cannot be
answered. Android 11+ has no runtime dialog for it, only the Settings
screen (AttachFile.java opens it once). Files the user picks work
either way: their content:// grant is copied into the app first. -->
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<supports-screens
android:anyDensity="true"
@@ -0,0 +1,189 @@
package xyz.whcself.cstagent;
import android.content.ActivityNotFoundException;
import android.content.ContentResolver;
import android.content.Context;
import android.content.Intent;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Environment;
import android.provider.OpenableColumns;
import android.provider.Settings;
import java.io.File;
import java.io.FileOutputStream;
import java.io.InputStream;
import java.io.OutputStream;
/* What the user picks in the system file chooser comes back as a content://
* URI, and the engine opens files by path -- so the bytes are copied into the
* app's own directory first. That copy needs no permission at all (the grant
* travels with the URI), which is what makes picking work on a phone that
* grants nothing else.
*
* The "all files access" below is a different thing: it is what lets the
* engine read /sdcard by path afterwards (a model that opens a file the user
* names rather than picks). Android 11+ has no runtime dialog for it -- only
* the Settings screen -- so it is a request, not a demand: everything works
* without it, one file at a time.
*
* Qt swallows a Java exception (it comes back as a null object), so every
* method catches its own and answers with a reason instead.
*/
public final class AttachFile {
private AttachFile() {}
private static final long MAX_BYTES = 256L * 1024 * 1024;
public static boolean isContent(String s) {
return s != null && s.startsWith("content:");
}
/* content://<uri> -> 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;
}
}
+6
View File
@@ -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. */
+10 -2
View File
@@ -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();
}
+16
View File
@@ -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 */
+20
View File
@@ -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);
+84
View File
@@ -27,6 +27,7 @@
#include <QGuiApplication>
#include <QJniObject>
#include <QMetaObject>
#include <QStandardPaths>
#include <QString>
#include <QStringList>
#include <QThread>
@@ -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<jobject>(), QJniObject::fromString(pathOrUri).object<jstring>(),
QJniObject::fromString(dest).object<jstring>());
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<jboolean>(
"xyz/whcself/cstagent/AttachFile", "hasAllFilesAccess",
"(Landroid/content/Context;)Z", ctx.object<jobject>());
});
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<jobject>());
if (out.isValid()) {
const QString s = out.toString();
if (!s.isEmpty())
err = s;
}
});
return err;
}
int platformRegisterAndroid(AgentCore *core) {
if (!core)
return 0;
+21 -1
View File
@@ -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"},
+1 -1
View File
@@ -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)
+4
View File
@@ -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);
+55
View File
@@ -15,6 +15,7 @@
#include <regex.h>
#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);
}
+47
View File
@@ -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).",
+245
View File
@@ -0,0 +1,245 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#ifndef _XOPEN_SOURCE
#define _XOPEN_SOURCE 700
#endif
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#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 <sys/utsname.h>
#include <unistd.h>
#else
#include <windows.h>
#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);
}
+12
View File
@@ -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,
+329
View File
@@ -6,8 +6,12 @@
#include <curl/curl.h>
#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);
}
+72
View File
@@ -304,6 +304,78 @@ void cst_web_md_free(cst_web_md_t *s) {
free(s);
}
/* Escape the few characters a <title> 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, "&amp;");
break;
case '<':
cst_buf_puts(out, "&lt;");
break;
case '>':
cst_buf_puts(out, "&gt;");
break;
case '"':
cst_buf_puts(out, "&quot;");
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[] =
"</title>\n<style>\n"
" :root { color-scheme: light dark; }\n"
" body { margin: 0 auto; max-width: 46rem; padding: 2rem 1rem 4rem;\n"
" font: 16px/1.65 system-ui, -apple-system, \"Segoe UI\", Roboto,\n"
" \"Noto Sans CJK SC\", \"Microsoft YaHei\", sans-serif; }\n"
" h1, h2, h3 { line-height: 1.25; margin: 1.6em 0 .5em; }\n"
" h1 { font-size: 1.9rem; } h2 { font-size: 1.4rem; } "
"h3 { font-size: 1.15rem; }\n"
" pre { overflow-x: auto; padding: .75rem 1rem; border-radius: 8px;\n"
" background: rgba(127, 127, 127, .12); }\n"
" code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "
"monospace;\n"
" font-size: .92em; }\n"
" pre code { font-size: .88em; }\n"
" table { border-collapse: collapse; width: 100%; margin: 1em 0; }\n"
" th, td { border: 1px solid rgba(127, 127, 127, .35); padding: "
".35rem .6rem;\n"
" text-align: left; vertical-align: top; }\n"
" blockquote { margin: 1em 0; padding: .1em 1em; border-left: 3px "
"solid rgba(127, 127, 127, .4); }\n"
" img { max-width: 100%; height: auto; }\n"
" hr { border: 0; border-top: 1px solid rgba(127, 127, 127, .35); }\n"
"</style>\n</head>\n<body>\n";
static const char tail[] = "\n</body>\n</html>\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;
+274
View File
@@ -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 <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#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<script>alert(1)</script>\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, "<!doctype html>", 15) == 0, "it is a whole page");
EXPECT(strstr(html, "charset=\"utf-8\"") != NULL, "with a charset");
EXPECT(strstr(html, "<h1>Report</h1>") != NULL, "the heading rendered");
EXPECT(strstr(html, "<li>one</li>") != NULL, "and the list");
EXPECT(strstr(html, "<script") == NULL, "no script tag survives");
EXPECT(strstr(html, "javascript:") == NULL, "no javascript: link survives");
}
cst_tool_result_free(&r);
/* a title that is markup stays text in the <title>, and a title with a
* path separator in it does not escape the directory */
c = mk("md_to_html", "markdown", "body\n", "title",
"a <b> & \"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, "<title>a &lt;b&gt; &amp; &quot;c&quot; ../d"
"</title>") != 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;
}