forked from WHC/Hermit
v0.9.6: documents, downloads, PDFs and the phone's own files
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:
+2
-1
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 个)。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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).",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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, "&");
|
||||
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[] =
|
||||
"</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;
|
||||
|
||||
@@ -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 <b> & "c" ../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;
|
||||
}
|
||||
Reference in New Issue
Block a user