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

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

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

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

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

Version 0.9.6.
This commit is contained in:
WHC
2026-10-11 18:33:41 +08:00
parent 08008a5a4c
commit 580d533656
20 changed files with 1408 additions and 10 deletions
+274
View File
@@ -0,0 +1,274 @@
/* The tools that answer "make me a document", "read this PDF", "get that URL"
* and "where are we": md_to_html, read_pdf, download, session_search and
* device_info.
*
* Nothing here touches the network: download is checked on the arguments and
* the file-name rules it applies before any request is made, which is where
* its mistakes would cost a disk. */
#define _POSIX_C_SOURCE 200809L
#include "cst_agent.h"
#include "cst_tools.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include "cst_test_util.h"
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
static char g_ws[256];
/* name, then key/value pairs, NULL-terminated. */
static ctl_tool_call_t mk(const char *name, ...) {
ctl_tool_call_t c;
memset(&c, 0, sizeof(c));
snprintf(c.id, sizeof(c.id), "t");
snprintf(c.name, sizeof(c.name), "%s", name);
va_list ap;
va_start(ap, name);
const char *k;
while ((k = va_arg(ap, const char *)) != NULL) {
const char *v = va_arg(ap, const char *);
ctl_arg_t *a = &c.args[c.nargs++];
snprintf(a->key, sizeof(a->key), "%s", k);
a->value = (char *)v;
a->value_len = strlen(v);
}
va_end(ap);
return c;
}
static int run(cst_tool_ctx_t *ctx, ctl_tool_call_t c, cst_tool_result_t *r) {
memset(r, 0, sizeof(*r));
cst_tool_run(ctx, &c, r);
return r->exit_code;
}
static char *rfile(const char *rel) {
char p[512];
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
FILE *fp = fopen(p, "rb");
if (!fp)
return NULL;
static char buf[262144];
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
buf[n] = 0;
fclose(fp);
return buf;
}
static void wfile(const char *rel, const char *s) {
char p[512];
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
FILE *fp = fopen(p, "wb");
if (fp) {
fputs(s, fp);
fclose(fp);
}
}
static int exists(const char *rel) {
char p[512];
struct stat st;
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
return stat(p, &st) == 0;
}
/* --- md_to_html: one file that stands on its own ------------------------- */
static void test_md_to_html(cst_tool_ctx_t *ctx) {
cst_tool_result_t r;
ctl_tool_call_t c =
mk("md_to_html", "markdown",
"# Report\n\n- one\n- two\n\n<script>alert(1)</script>\n\n"
"[x](javascript:alert(2))\n",
"title", "Report", NULL);
EXPECT(run(ctx, c, &r) == 0, "markdown renders to a document");
EXPECT(r.out && strstr(r.out, "Report.html") != NULL,
"the answer names the file it wrote");
char *html = rfile("Report.html");
EXPECT(html != NULL, "and the file is there");
if (html) {
EXPECT(strncmp(html, "<!doctype html>", 15) == 0, "it is a whole page");
EXPECT(strstr(html, "charset=\"utf-8\"") != NULL, "with a charset");
EXPECT(strstr(html, "<h1>Report</h1>") != NULL, "the heading rendered");
EXPECT(strstr(html, "<li>one</li>") != NULL, "and the list");
EXPECT(strstr(html, "<script") == NULL, "no script tag survives");
EXPECT(strstr(html, "javascript:") == NULL, "no javascript: link survives");
}
cst_tool_result_free(&r);
/* a title that is markup stays text in the <title>, and a title with a
* path separator in it does not escape the directory */
c = mk("md_to_html", "markdown", "body\n", "title",
"a <b> & \"c\" ../d", "out", "hostile.html", NULL);
EXPECT(run(ctx, c, &r) == 0 && rfile("hostile.html") != NULL,
"a hostile title still writes where it was told");
cst_tool_result_free(&r);
html = rfile("hostile.html");
EXPECT(html && strstr(html, "<title>a &lt;b&gt; &amp; &quot;c&quot; ../d"
"</title>") != NULL,
"the title is escaped");
EXPECT(!exists("../d.html"), "and nothing lands outside the workspace");
/* a .md file, and an output directory */
char dir[512];
snprintf(dir, sizeof(dir), "%s/notes", g_ws);
mkdir(dir, 0755);
wfile("notes/source.md", "## From a file\n\nbody\n");
c = mk("md_to_html", "file", "notes/source.md", "out", "notes/", NULL);
EXPECT(run(ctx, c, &r) == 0, "a workspace file renders");
EXPECT(r.out && strstr(r.out, "notes/source.html") != NULL,
"the directory gets the source's name inside it");
EXPECT(rfile("notes/source.html") != NULL, "the html is there");
cst_tool_result_free(&r);
/* the preview the window shows says what will be written */
char *prev = NULL;
c = mk("md_to_html", "markdown", "hello", "out", "hello.html", NULL);
EXPECT(cst_tool_preview(ctx, &c, &prev) == 0 && prev &&
strstr(prev, "hello.html") != NULL,
"the preview names the file");
free(prev);
c = mk("md_to_html", NULL);
prev = NULL;
EXPECT(cst_tool_preview(ctx, &c, &prev) != 0 && prev &&
strstr(prev, "markdown") != NULL,
"an empty call is refused in the preview too");
free(prev);
EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "error") != NULL,
"and by the tool");
cst_tool_result_free(&r);
}
/* --- read_pdf: the text layer, and a plain refusal for anything else ---- */
static void test_read_pdf(cst_tool_ctx_t *ctx) {
cst_tool_result_t r;
wfile("notreally.pdf", "this is not a pdf at all\n");
ctl_tool_call_t c = mk("read_pdf", "path", "notreally.pdf", NULL);
EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "PDF") != NULL,
"a file that is not a PDF is refused");
cst_tool_result_free(&r);
c = mk("read_pdf", "path", "missing.pdf", NULL);
EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "error") != NULL,
"and a missing one");
cst_tool_result_free(&r);
c = mk("read_pdf", NULL);
EXPECT(run(ctx, c, &r) == 1, "a call without a path fails");
cst_tool_result_free(&r);
}
/* --- download: the file name it picks, before it asks the network ------- */
static void test_download_args(cst_tool_ctx_t *ctx) {
cst_tool_result_t r;
ctl_tool_call_t c = mk("download", NULL);
EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "url") != NULL,
"download without a url fails");
cst_tool_result_free(&r);
c = mk("download", "url", "ftp://example.com/x.bin", NULL);
EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "http") != NULL,
"only http(s) URLs are accepted");
cst_tool_result_free(&r);
c = mk("download", "url", "https://example.invalid/x.bin", "path",
"../escape.bin", NULL);
EXPECT(run(ctx, c, &r) == 1 && r.out && strstr(r.out, "outside") != NULL,
"a path that leaves the workspace is refused");
cst_tool_result_free(&r);
EXPECT(!exists("escape.bin"), "and nothing was written");
}
/* --- session_search: what was said before ------------------------------- */
static void test_session_search(cst_tool_ctx_t *ctx) {
EXPECT(cst_agent_store_ensure(g_ws) == 0, "the store exists");
char path[512];
EXPECT(cst_agent_store_path_for_id(g_ws, "20260101-120000-fix", path,
sizeof(path)) == 0,
"a fixture session path");
cst_session_t *s = cst_session_open(path);
EXPECT(s != NULL, "its transcript opens");
cst_message_t m;
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_USER;
m.content = (char *)"how does the Session Store name a session?";
EXPECT(cst_session_append(s, &m) == 0, "a message goes in");
memset(&m, 0, sizeof(m));
m.role = CST_ROLE_ASSISTANT;
m.content = (char *)"one directory per session, named by a uuid";
EXPECT(cst_session_append(s, &m) == 0, "and an answer");
cst_session_close(s);
cst_tool_result_t r;
ctl_tool_call_t c = mk("session_search", "query", "uuid", NULL);
EXPECT(run(ctx, c, &r) == 0, "the search runs");
EXPECT(r.out && strstr(r.out, "20260101-120000-fix") != NULL &&
strstr(r.out, "uuid") != NULL,
"the match names its session and shows the line");
EXPECT(r.out && strstr(r.out, "#2 assistant") != NULL,
"with the message number and role");
cst_tool_result_free(&r);
c = mk("session_search", "query", "UUID", "ignore_case", "true", NULL);
EXPECT(run(ctx, c, &r) == 0 && r.out && strstr(r.out, "1 match") != NULL,
"case-insensitive by default");
cst_tool_result_free(&r);
c = mk("session_search", "query", "zzz-nothing-says-this", NULL);
EXPECT(run(ctx, c, &r) == 0 && r.out && strstr(r.out, "no match") != NULL,
"a query nothing matches says so (and is not an error)");
cst_tool_result_free(&r);
c = mk("session_search", NULL);
EXPECT(run(ctx, c, &r) == 1, "a query is required");
cst_tool_result_free(&r);
}
/* --- device_info: the environment, without a shell ---------------------- */
static void test_device_info(cst_tool_ctx_t *ctx) {
cst_tool_result_t r;
ctl_tool_call_t c = mk("device_info", NULL);
EXPECT(run(ctx, c, &r) == 0, "device_info answers");
EXPECT(r.out && strstr(r.out, "cst-agent") != NULL, "with the version");
EXPECT(r.out && strstr(r.out, "system: ") != NULL, "the system it runs on");
EXPECT(r.out && strstr(r.out, g_ws) != NULL, "the workspace in use");
cst_tool_result_free(&r);
}
int main(void) {
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-tools-2") != 0) {
perror("tmpdir");
return 1;
}
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = g_ws;
test_md_to_html(&ctx);
test_read_pdf(&ctx);
test_download_args(&ctx);
test_session_search(&ctx);
test_device_info(&ctx);
test_rmrf(g_ws);
if (nfail) {
fprintf(stderr, "%d check(s) failed\n", nfail);
return 1;
}
printf("test_tools_ext: OK\n");
return 0;
}