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:
WHC
2026-10-11 18:33:41 +08:00
parent 08008a5a4c
commit 580d533656
20 changed files with 1408 additions and 10 deletions
+72
View File
@@ -304,6 +304,78 @@ void cst_web_md_free(cst_web_md_t *s) {
free(s);
}
/* Escape the few characters a <title> cannot carry as they are. */
static void doc_escape(const char *s, size_t n, cst_buf_t *out) {
for (size_t i = 0; i < n && s[i]; i++) {
switch (s[i]) {
case '&':
cst_buf_puts(out, "&amp;");
break;
case '<':
cst_buf_puts(out, "&lt;");
break;
case '>':
cst_buf_puts(out, "&gt;");
break;
case '"':
cst_buf_puts(out, "&quot;");
break;
default:
cst_buf_append(out, s + i, 1);
}
}
}
/* The document a generated file holds: the same sanitized HTML, inside a page
* that stands on its own -- charset first (a phone that guesses is a phone
* that shows mojibake), a title, and a stylesheet small enough to read: no
* script, no font, no request to anywhere. */
char *cst_web_md_document(const char *md, size_t len, const char *title) {
char *body = cst_web_md_html(md, len);
if (!body)
return NULL;
static const char head[] =
"<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
"<meta name=\"viewport\" content=\"width=device-width, "
"initial-scale=1\">\n<title>";
static const char mid[] =
"</title>\n<style>\n"
" :root { color-scheme: light dark; }\n"
" body { margin: 0 auto; max-width: 46rem; padding: 2rem 1rem 4rem;\n"
" font: 16px/1.65 system-ui, -apple-system, \"Segoe UI\", Roboto,\n"
" \"Noto Sans CJK SC\", \"Microsoft YaHei\", sans-serif; }\n"
" h1, h2, h3 { line-height: 1.25; margin: 1.6em 0 .5em; }\n"
" h1 { font-size: 1.9rem; } h2 { font-size: 1.4rem; } "
"h3 { font-size: 1.15rem; }\n"
" pre { overflow-x: auto; padding: .75rem 1rem; border-radius: 8px;\n"
" background: rgba(127, 127, 127, .12); }\n"
" code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "
"monospace;\n"
" font-size: .92em; }\n"
" pre code { font-size: .88em; }\n"
" table { border-collapse: collapse; width: 100%; margin: 1em 0; }\n"
" th, td { border: 1px solid rgba(127, 127, 127, .35); padding: "
".35rem .6rem;\n"
" text-align: left; vertical-align: top; }\n"
" blockquote { margin: 1em 0; padding: .1em 1em; border-left: 3px "
"solid rgba(127, 127, 127, .4); }\n"
" img { max-width: 100%; height: auto; }\n"
" hr { border: 0; border-top: 1px solid rgba(127, 127, 127, .35); }\n"
"</style>\n</head>\n<body>\n";
static const char tail[] = "\n</body>\n</html>\n";
cst_buf_t b = {0};
cst_buf_puts(&b, head);
if (title && title[0])
doc_escape(title, 200, &b);
else
cst_buf_puts(&b, "Document");
cst_buf_puts(&b, mid);
cst_buf_puts(&b, body);
cst_buf_puts(&b, tail);
free(body);
return cst_buf_steal(&b);
}
char *cst_web_md_html(const char *md, size_t len) {
if (!md)
return NULL;