Files
CST-agent/tests/test_tools_fs.c
T
WHC 2a43ae6622 Windows: official port -- UCRT static build, UTF-8 throughout, Low IL sandbox, ncurses TUI, installer; v0.6.0
Build
  * cross build switches to pkgsCross.ucrt64 (shell-win.nix); curl (Schannel),
    winpthreads and ncurses 6.6 are linked statically, so the release is
    cst-agent.exe + busybox.exe (busybox-w32 Unicode) with no extra DLLs
  * windres embeds a manifest with activeCodePage=UTF-8 and a version resource
  * ncurses 6.6 Windows console driver patches (mouse button state, A_DIM,
    wide-char declarations): scripts/patches/
  * scripts/bootstrap_msys2.sh builds the same release natively in MSYS2
    UCRT64; PR #1's MINGW64 notes now point there
  * vendor/tre-regex (musl's TRE) provides <regex.h> on MinGW

UTF-8
  * paths cross the system call boundary as UTF-16 (*W APIs); console code
    pages set to 65001 and restored on exit; files are read and written in
    binary mode
  * child output that is not valid UTF-8 is decoded from the console, OEM or
    ANSI system code page
  * read_file hides CR; edit_file, apply_patch and write_file keep CRLF line
    endings and a UTF-8 BOM

OS layer and processes
  * os_dir_* and cst_fnmatch replace the POSIX-only directory walks in
    subagent, plugins, skills, undo and fs_tools; realpath / path_under and
    symlink rejection work on Windows
  * every child goes through CreateProcessW with its own environment block
    inside a Job Object, so timeouts and Esc end the whole process tree

Sandbox (auto mode, "auto · lowil")
  * run_shell runs with a restricted Low integrity token; the workspace gets
    an inheritable Low label once, the sandbox TEMP is labeled Low, and the
    credentials file gets a no-read-up label
  * drive roots, the profile and system folders are never labeled
  * network commands still ask, since Low IL does not block the network;
    Access denied and MSYS2's 0xc0000142 offer a rerun outside the sandbox
  * /sandbox lists labeled folders, /sandbox reset-labels restores them

TUI
  * raster console fonts are switched to Consolas / NSimSun while running
  * the home logo falls back to text when the console font lacks U+2580
  * mouse drag copy goes to the Windows clipboard

Distribution
  * scripts/install_windows.ps1 installs a Gitea release into
    %LOCALAPPDATA%\Programs\cst-agent and adds it to the user PATH;
    cst-agent-update.cmd reruns it; scripts/release_windows.sh uploads the
    zip with the token taken from the environment
  * scripts/run_windows.bat is gone; docs/README-windows.md is rewritten

Tests
  * new test_lowil, test_win_utf8 and test_os_dir; tests made portable
    (cst_test_util.h)
  * scripts/win_test.sh and scripts/winvm/ run the suite on English and
    Chinese Windows Server VMs as Administrator, a standard user and a
    Chinese user name: 29/29 on all six
2026-10-06 23:08:14 +08:00

426 lines
16 KiB
C

/* Built-in workspace tools: read ranges, list_dir, glob, grep, edit_file,
* delete/move, todo_write, ask_user, previews and the sandbox. */
#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];
static char g_todo[512];
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 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[65536];
size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
buf[n] = 0;
fclose(fp);
return buf;
}
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;
}
static void set_todo(const char *text, void *ud) {
(void)ud;
snprintf(g_todo, sizeof(g_todo), "%s", text);
}
static int ask_yes(const char *q, const char *o, char *ans, size_t n,
void *ud) {
(void)q;
(void)o;
(void)ud;
snprintf(ans, n, "yes please");
return 0;
}
/* 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 void reset(cst_tool_result_t *r) { cst_tool_result_free(r); }
int main(void) {
if (test_tmpdir(g_ws, sizeof(g_ws), "cst-tools-test") != 0) {
perror("tmpdir");
return 1;
}
char p[512];
snprintf(p, sizeof(p), "%s/src", g_ws);
test_mkdir(p, 0755);
snprintf(p, sizeof(p), "%s/src/deep", g_ws);
test_mkdir(p, 0755);
snprintf(p, sizeof(p), "%s/node_modules", g_ws);
test_mkdir(p, 0755);
snprintf(p, sizeof(p), "%s/build", g_ws);
test_mkdir(p, 0755);
wfile("src/main.c", "#include <stdio.h>\nint main(void) {\n int x = 42;\n"
" printf(\"%d\\n\", x);\n return 0;\n}\n");
wfile("src/deep/util.c", "int add(int a, int b) { return a + b; }\n"
"int sub(int a, int b) { return a - b; }\n");
wfile("src/notes.txt", "TODO: x\n");
wfile("node_modules/junk.c", "int add(int a, int b);\n");
wfile("build/out.c", "int add_generated;\n");
wfile(".gitignore", "build/\n*.log\n");
wfile("run.log", "int add in log\n");
{
FILE *fp = fopen(strcat(strcpy(p, g_ws), "/lines.txt"), "wb");
for (int i = 1; i <= 100; i++)
fprintf(fp, "line %d\n", i);
fclose(fp);
}
cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = g_ws;
ctx.set_todo = set_todo;
cst_tool_result_t r;
/* read_file: whole, slices, numbers */
EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", NULL), &r) == 0 &&
strcmp(r.out, "TODO: x\n") == 0,
"read whole file unchanged");
reset(&r);
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "10",
"limit", "3", "line_numbers", "true", NULL),
&r) == 0,
"read slice ok");
EXPECT(r.out && strstr(r.out, " 10|line 10\n 11|line 11\n 12|line 12\n"),
"slice numbered lines");
EXPECT(r.out && strstr(r.out, "next offset=13"), "slice continuation hint");
EXPECT(r.out && !strstr(r.out, "line 13\n"), "slice limit respected");
reset(&r);
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "\"99\"",
NULL),
&r) == 0 &&
strstr(r.out, "line 100\n") && !strstr(r.out, "next offset"),
"quoted offset (ctl style) reads to the end");
reset(&r);
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "abc", NULL),
&r) == 1 && strstr(r.out, "integer"),
"non-integer offset rejected");
reset(&r);
EXPECT(run(&ctx, mk("read_file", "path", "src", NULL), &r) == 1 &&
strstr(r.out, "list_dir"),
"directory read suggests list_dir");
reset(&r);
EXPECT(run(&ctx, mk("read_file", "path", "../../etc/passwd", NULL), &r) == 1,
"read outside workspace denied");
reset(&r);
EXPECT(run(&ctx, mk("read_file", NULL), &r) == 1 &&
strstr(r.out, "needs argument \"path\""),
"missing required arg");
reset(&r);
/* compact read_file (default profile) */
ctx.compact = 1;
EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", NULL), &r) == 0 &&
strcmp(r.out, "src/notes.txt · 1 line\n1|TODO: x\n") == 0,
"compact read: header + numbers");
reset(&r);
EXPECT(run(&ctx, mk("read_file", "path", "lines.txt", "offset", "10",
"limit", "3", NULL),
&r) == 0 &&
strstr(r.out, "lines.txt · lines 10-12 of 100\n10|line 10\n") ==
r.out &&
strstr(r.out, "… 88 more lines; continue with offset=13"),
"compact slice header and continuation");
reset(&r);
EXPECT(run(&ctx, mk("read_file", "path", "src/notes.txt", "line_numbers",
"false", NULL),
&r) == 0 &&
strstr(r.out, "\nTODO: x\n") && !strstr(r.out, "1|"),
"compact read without numbers");
reset(&r);
{
char big[5000];
memset(big, 'a', sizeof(big) - 2);
big[sizeof(big) - 2] = '\n';
big[sizeof(big) - 1] = 0;
wfile("long.txt", big);
EXPECT(run(&ctx, mk("read_file", "path", "long.txt", NULL), &r) == 0 &&
strstr(r.out, "[line cut, +2998 bytes]") &&
strstr(r.out, "1 long line cut"),
"compact read cuts long lines");
reset(&r);
}
wfile("empty.txt", "");
EXPECT(run(&ctx, mk("read_file", "path", "empty.txt", NULL), &r) == 0 &&
strstr(r.out, "empty file"),
"compact read of an empty file");
reset(&r);
ctx.compact = 0;
/* list_dir */
EXPECT(run(&ctx, mk("list_dir", NULL), &r) == 0, "list_dir ok");
EXPECT(r.out && strstr(r.out, "src/") && strstr(r.out, " main.c"),
"list_dir tree");
EXPECT(r.out && strstr(r.out, "node_modules/ (skipped)"),
"node_modules shown but not walked");
EXPECT(r.out && !strstr(r.out, "junk.c"), "skipped dir contents hidden");
EXPECT(r.out && !strstr(r.out, "build/") && !strstr(r.out, "run.log"),
".gitignore respected");
reset(&r);
EXPECT(run(&ctx, mk("list_dir", "path", "src", "depth", "1", NULL), &r) == 0 &&
strstr(r.out, "deep/") && !strstr(r.out, "util.c"),
"list_dir depth");
EXPECT(r.out && strstr(r.out, "deep/ (1 entry)"),
"list_dir counts entries at the depth limit");
reset(&r);
/* glob */
EXPECT(run(&ctx, mk("glob", "pattern", "*.c", NULL), &r) == 0, "glob ok");
EXPECT(r.out && strstr(r.out, "src/main.c") &&
strstr(r.out, "src/deep/util.c"),
"basename glob matches at any depth");
EXPECT(r.out && !strstr(r.out, "junk.c") && !strstr(r.out, "out.c"),
"glob skips node_modules and ignored dirs");
reset(&r);
EXPECT(run(&ctx, mk("glob", "pattern", "src/*.c", NULL), &r) == 0 &&
strstr(r.out, "src/main.c") && !strstr(r.out, "util.c"),
"single-segment glob");
reset(&r);
EXPECT(run(&ctx, mk("glob", "pattern", "src/**/*.c", NULL), &r) == 0 &&
strstr(r.out, "src/main.c") && strstr(r.out, "util.c"),
"** glob");
reset(&r);
EXPECT(run(&ctx, mk("glob", "pattern", "*.rs", NULL), &r) == 0 &&
strstr(r.out, "no files match"),
"glob no match");
reset(&r);
/* grep */
EXPECT(run(&ctx, mk("grep", "pattern", "int add", NULL), &r) == 0, "grep ok");
EXPECT(r.out && strstr(r.out, "src/deep/util.c\n1:int add"), "grep hit grouped");
EXPECT(r.out && strncmp(r.out, "1 match in 1 file\n", 18) == 0,
"grep summary first");
EXPECT(r.out && !strstr(r.out, "junk.c") && !strstr(r.out, "run.log"),
"grep skips node_modules and ignored files");
reset(&r);
EXPECT(run(&ctx, mk("grep", "pattern", "x = \\d+", "glob", "*.c", NULL),
&r) == 0 &&
strstr(r.out, "src/main.c\n3:"),
"grep translates \\d");
reset(&r);
EXPECT(run(&ctx, mk("grep", "pattern", "RETURN", "ignore_case", "true",
"path", "src/main.c", "context", "1", NULL),
&r) == 0 &&
strstr(r.out, "\n5: return 0;") && strstr(r.out, "\n4-") &&
strstr(r.out, "\n6-"),
"grep single file, icase, context");
reset(&r);
EXPECT(run(&ctx, mk("grep", "pattern", "(", NULL), &r) == 1 &&
strstr(r.out, "bad regex"),
"grep bad regex");
reset(&r);
/* edit_file */
EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string",
"int x = 42;", "new_string", "int x = 7;", NULL),
&r) == 0,
"edit ok");
EXPECT(r.out && strstr(r.out, "- int x = 42;") && strstr(r.out, "+ int x = 7;"),
"edit diff");
EXPECT(strstr(rfile("src/main.c"), "int x = 7;") != NULL, "edit written");
reset(&r);
EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string",
"nothing like this", "new_string", "y", NULL),
&r) == 1 &&
strstr(r.out, "not found"),
"edit not found");
reset(&r);
EXPECT(run(&ctx, mk("edit_file", "path", "src/main.c", "old_string",
"int main(void) {\n int x = 7;", "new_string", "y",
NULL),
&r) == 1 &&
strstr(r.out, "appears (whitespace aside) at line 2"),
"edit not found points at the near line");
reset(&r);
EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string",
"int a, int b", "new_string", "long a, long b", NULL),
&r) == 1 &&
strstr(r.out, "matches 2 places"),
"edit ambiguous");
EXPECT(r.out && strstr(r.out, "(lines 1, 2)"), "edit ambiguous lines");
reset(&r);
EXPECT(run(&ctx, mk("edit_file", "path", "src/deep/util.c", "old_string",
"int a, int b", "new_string", "long a, long b",
"replace_all", "true", NULL),
&r) == 0 &&
strstr(r.out, "2 replacements"),
"edit replace_all");
reset(&r);
wfile("crlf.txt", "one\r\ntwo\r\nthree\r\n");
EXPECT(run(&ctx, mk("edit_file", "path", "crlf.txt", "old_string",
"one\ntwo\n", "new_string", "1\n2\n", NULL),
&r) == 0 &&
strcmp(rfile("crlf.txt"), "1\r\n2\r\nthree\r\n") == 0,
"edit keeps CRLF line endings");
reset(&r);
/* previews never touch the disk */
{
ctl_tool_call_t c = mk("edit_file", "path", "src/main.c", "old_string",
"return 0;", "new_string", "return 1;", NULL);
char *pv = NULL;
EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv &&
strstr(pv, "+ return 1;"),
"edit preview diff");
EXPECT(strstr(rfile("src/main.c"), "return 0;") != NULL,
"preview did not write");
free(pv);
c = mk("write_file", "path", "new/dir/x.txt", "content", "hi\n", NULL);
EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv &&
strstr(pv, "create new/dir/x.txt") && strstr(pv, "+hi"),
"write preview for a new file");
EXPECT(!exists("new"), "write preview did not mkdir");
free(pv);
c = mk("apply_patch", "patch",
"--- a/src/notes.txt\n+++ b/src/notes.txt\n@@ -1 +1 @@\n-TODO: x\n"
"+DONE: x\n",
NULL);
EXPECT(cst_tool_preview(&ctx, &c, &pv) == 0 && pv &&
strstr(pv, "+DONE: x"),
"patch preview");
EXPECT(strcmp(rfile("src/notes.txt"), "TODO: x\n") == 0,
"patch preview did not write");
free(pv);
}
/* move / delete */
EXPECT(run(&ctx, mk("move_file", "from", "src/notes.txt", "to",
"docs/notes.md", NULL),
&r) == 0 &&
exists("docs/notes.md") && !exists("src/notes.txt"),
"move creates parent and renames");
reset(&r);
wfile("other.txt", "o\n");
EXPECT(run(&ctx, mk("move_file", "from", "other.txt", "to", "docs/notes.md",
NULL),
&r) == 1 &&
strstr(r.out, "exists"),
"move refuses to overwrite");
reset(&r);
EXPECT(run(&ctx, mk("move_file", "from", "other.txt", "to", "../escape.txt",
NULL),
&r) == 1,
"move outside workspace denied");
reset(&r);
EXPECT(run(&ctx, mk("delete_file", "path", "other.txt", NULL), &r) == 0 &&
!exists("other.txt") && strstr(r.out, "-o"),
"delete with diff");
reset(&r);
EXPECT(run(&ctx, mk("delete_file", "path", "src", NULL), &r) == 1 &&
strstr(r.out, "directory"),
"delete refuses directories");
reset(&r);
/* todo_write / ask_user */
EXPECT(run(&ctx, mk("todo_write", "todos",
"- [x] read code\n[~] write fix\nrun tests\n", NULL),
&r) == 0 &&
strstr(r.out, "1/3 done"),
"todo_write counts");
EXPECT(strcmp(g_todo, "[x] read code\n[~] write fix\n[ ] run tests\n") == 0,
"todo normalized and handed to the agent");
reset(&r);
EXPECT(run(&ctx, mk("ask_user", "question", "Proceed?", NULL), &r) == 1 &&
strstr(r.out, "no interactive user"),
"ask_user headless");
reset(&r);
ctx.ask = ask_yes;
EXPECT(run(&ctx, mk("ask_user", "question", "Proceed?", NULL), &r) == 0 &&
strstr(r.out, "yes please"),
"ask_user answered");
reset(&r);
/* web_fetch refuses non-http schemes without touching the network */
EXPECT(run(&ctx, mk("web_fetch", "url", "file:///etc/passwd", NULL), &r) == 1,
"web_fetch rejects file://");
reset(&r);
/* undo targets */
{
char paths[CST_TOOL_MAX_TARGETS][1024];
ctl_tool_call_t c = mk("move_file", "from", "a.txt", "to", "b.txt", NULL);
EXPECT(cst_tool_targets(&c, paths, CST_TOOL_MAX_TARGETS) == 2 &&
strcmp(paths[0], "a.txt") == 0 && strcmp(paths[1], "b.txt") == 0,
"move targets");
c = mk("apply_patch", "patch",
"--- a/x.c\n+++ b/x.c\n@@ -1 +1 @@\n-a\n+b\n--- /dev/null\n+++ "
"b/y.c\n@@ -0,0 +1 @@\n+y\n",
NULL);
EXPECT(cst_tool_targets(&c, paths, CST_TOOL_MAX_TARGETS) == 2 &&
strcmp(paths[0], "x.c") == 0 && strcmp(paths[1], "y.c") == 0,
"patch targets");
}
if (test_rmrf(g_ws) != 0)
fprintf(stderr, "note: cleanup failed\n");
if (nfail) {
fprintf(stderr, "test_tools_fs: %d failure(s)\n", nfail);
return 1;
}
printf("test_tools_fs: OK\n");
return 0;
}