The rewritten cst_agent_store_info() counted a transcript line that ran past a whole 64 KiB read block a second time, when the newline ending its tail arrived: a session with one very long line (a tool that read a file back) reported one message too many -- exactly what the sidebar shows the person. - src/agent_store.c: the tail's newline ends a line that was already counted. - tests/test_store.c: test_info_scan() holds the scan down -- a 70 KiB line is one line, the first user message comes from the head of the file, and a user line past the window is not called the first one. - tests/test_tools_ext.c: a byte budget stops the session_search walk, says so in the result, and never reports a match it did not reach; the same query without a budget finds it. make check and make asan are green.
319 lines
12 KiB
C
319 lines
12 KiB
C
/* 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);
|
|
|
|
/* A store is not read to the end on a whim: a byte budget stops the walk
|
|
* and says so, and it never reports a match it did not get to. */
|
|
{
|
|
char path2[512];
|
|
EXPECT(cst_agent_store_path_for_id(g_ws, "20260101-130000-big", path2,
|
|
sizeof(path2)) == 0,
|
|
"a second session path");
|
|
cst_session_t *s2 = cst_session_open(path2);
|
|
EXPECT(s2 != NULL, "its transcript opens");
|
|
if (s2) {
|
|
cst_message_t m2;
|
|
memset(&m2, 0, sizeof(m2));
|
|
m2.role = CST_ROLE_USER;
|
|
m2.content = (char *)"the start of a long one";
|
|
EXPECT(cst_session_append(s2, &m2) == 0, "a first line goes in");
|
|
for (int i = 0; i < 600; i++) {
|
|
memset(&m2, 0, sizeof(m2));
|
|
m2.role = CST_ROLE_ASSISTANT;
|
|
m2.content = (char *)"a line of tool output that says nothing special";
|
|
if (cst_session_append(s2, &m2) != 0)
|
|
break;
|
|
}
|
|
memset(&m2, 0, sizeof(m2));
|
|
m2.role = CST_ROLE_ASSISTANT;
|
|
m2.content = (char *)"the needle is only at the very end";
|
|
EXPECT(cst_session_append(s2, &m2) == 0, "the needle goes in last");
|
|
cst_session_close(s2);
|
|
}
|
|
/* Enough lines that a byte budget is spent before the end is reached. */
|
|
c = mk("session_search", "query", "needle", "max_bytes", "4096", NULL);
|
|
EXPECT(run(ctx, c, &r) == 0 && r.out != NULL, "a bounded search runs");
|
|
EXPECT(r.out && strstr(r.out, "budget stopped the walk") != NULL,
|
|
"the byte budget stops the walk and says so");
|
|
EXPECT(r.out == NULL || strstr(r.out, "the needle is only") == NULL,
|
|
"and it does not report a match it never got to");
|
|
cst_tool_result_free(&r);
|
|
/* The same query without a budget reaches the end and finds it. */
|
|
c = mk("session_search", "query", "needle", NULL);
|
|
EXPECT(run(ctx, c, &r) == 0 && r.out &&
|
|
strstr(r.out, "the needle is only") != NULL,
|
|
"the unbounded search finds it");
|
|
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;
|
|
}
|