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:
@@ -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