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:
+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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user