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
This commit is contained in:
WHC
2026-10-06 23:08:14 +08:00
parent 9862c29f58
commit 2a43ae6622
84 changed files with 9784 additions and 1083 deletions
+48 -77
View File
@@ -12,12 +12,7 @@
#include <string.h>
#include <sys/stat.h>
#ifndef _WIN32
#include <dirent.h>
#include <fnmatch.h>
#include <regex.h>
#include <unistd.h>
#endif
#include "cst_tools.h"
#include "cst_util.h"
@@ -154,6 +149,9 @@ int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
FILE *fp = fopen(full, "rb");
if (!fp)
return tool_set_resultf(r, 1, "error: cannot open file: %.200s", path);
char bom[3];
if (fread(bom, 1, 3, fp) != 3 || !cst_bom_len(bom, 3))
rewind(fp);
long offset = tool_arg_long(c, "offset", 0);
long limit = tool_arg_long(c, "limit", 0);
@@ -178,6 +176,8 @@ int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
return tool_set_resultf(r, 1, "error: %.200s is a binary file (%zu bytes)",
path, n);
}
cst_strip_bom(buf, &n);
n = cst_crlf_to_lf(buf, n); /* edits match LF text and keep CRLF */
cst_tool_result_free(r);
r->out = buf;
r->out_len = n;
@@ -217,6 +217,9 @@ int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
}
if (numbers)
cst_buf_printf(&b, "%6ld|", lineno);
if (line.len >= 2 && line.buf[line.len - 2] == '\r' &&
line.buf[line.len - 1] == '\n')
line.buf[--line.len - 1] = '\n';
cst_buf_append(&b, line.buf, line.len);
shown++;
}
@@ -442,6 +445,14 @@ static int plan_edit(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
nwn = n2n;
}
}
} else if (cnt && memchr(nw, '\n', nwn) &&
cst_text_is_crlf(pl->oldc, pl->olen)) {
size_t n2n;
n2 = to_crlf(nw, nwn, &n2n);
if (n2) {
nw = n2;
nwn = n2n;
}
}
int rc = 0;
if (cnt == 0) {
@@ -539,7 +550,6 @@ int cst_preview_edit_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
/* --- delete_file / move_file --------------------------------------------- */
static int reject_symlink(const cst_tool_ctx_t *ctx, const char *path) {
#ifndef _WIN32
char root[OS_PATH_MAX], joined[OS_PATH_MAX];
if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0)
return 1;
@@ -547,13 +557,9 @@ static int reject_symlink(const cst_tool_ctx_t *ctx, const char *path) {
snprintf(joined, sizeof(joined), "%s", path);
else if (os_path_join(joined, sizeof(joined), root, path) != 0)
return 1;
struct stat st;
if (lstat(joined, &st) == 0 && S_ISLNK(st.st_mode))
os_stat_t st;
if (os_lstat(joined, &st) == 0 && st.is_link)
return 1;
#else
(void)ctx;
(void)path;
#endif
return 0;
}
@@ -672,8 +678,6 @@ int cst_tool_move_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
/* --- directory walking (list_dir / glob / grep) -------------------------- */
#ifndef _WIN32
static const char *const SKIP_DIRS[] = {
".git", ".hg", ".svn", "node_modules", ".agent", "__pycache__",
".venv", ".cache", ".idea", ".vscode", ".mypy_cache", ".pytest_cache"};
@@ -703,7 +707,7 @@ static void ignore_load(const char *root, ignore_t *ig) {
char p[OS_PATH_MAX];
if (os_path_join(p, sizeof(p), root, ".gitignore") != 0)
return;
FILE *fp = fopen(p, "r");
FILE *fp = fopen(p, "rb");
if (!fp)
return;
char line[512];
@@ -749,7 +753,7 @@ static int ignored(const ignore_t *ig, const char *rel, const char *name,
const ign_rule_t *r = &ig->v[i];
if (r->dir_only && !isdir)
continue;
if (fnmatch(r->pat, r->anchored ? rel : name, 0) == 0)
if (cst_fnmatch(r->pat, r->anchored ? rel : name, 0) == 0)
return 1;
}
return 0;
@@ -758,7 +762,7 @@ static int ignored(const ignore_t *ig, const char *rel, const char *name,
typedef struct walker walker_t;
/* Return non-zero to stop the walk. */
typedef int (*walk_fn)(walker_t *w, const char *rel, const char *full,
const struct stat *st, int depth);
const os_stat_t *st, int depth);
struct walker {
const char *root; /* workspace realpath */
@@ -781,15 +785,13 @@ static void walk_dir(walker_t *w, const char *rel, int depth) {
snprintf(dirfull, sizeof(dirfull), "%s/%s", w->root, rel);
else
snprintf(dirfull, sizeof(dirfull), "%s", w->root);
DIR *d = opendir(dirfull);
os_dir_t *d = os_dir_open(dirfull);
if (!d)
return;
char **names = NULL;
int n = 0, cap = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
continue;
const os_dirent_t *de;
while ((de = os_dir_next(d)) != NULL) {
if (n == cap) {
cap = cap ? cap * 2 : 32;
char **nv = realloc(names, (size_t)cap * sizeof(char *));
@@ -797,11 +799,11 @@ static void walk_dir(walker_t *w, const char *rel, int depth) {
break;
names = nv;
}
names[n] = strdup(de->d_name);
names[n] = strdup(de->name);
if (names[n])
n++;
}
closedir(d);
os_dir_close(d);
qsort(names, (size_t)n, sizeof(char *), name_cmp);
for (int i = 0; i < n && !w->stop; i++) {
char crel[OS_PATH_MAX], cfull[OS_PATH_MAX];
@@ -810,10 +812,10 @@ static void walk_dir(walker_t *w, const char *rel, int depth) {
int k2 = snprintf(cfull, sizeof(cfull), "%s/%s", w->root, crel);
if (k1 >= (int)sizeof(crel) || k2 >= (int)sizeof(cfull))
continue; /* path too long */
struct stat st;
if (lstat(cfull, &st) != 0)
os_stat_t st;
if (os_lstat(cfull, &st) != 0)
continue;
int isdir = S_ISDIR(st.st_mode);
int isdir = st.is_dir;
if (ignored(&w->ig, crel, names[i], isdir))
continue;
if (w->fn(w, crel, cfull, &st, depth))
@@ -861,21 +863,18 @@ typedef struct {
/* Entries directly inside a directory (for the depth boundary). */
static int dir_entries(const char *full) {
DIR *d = opendir(full);
os_dir_t *d = os_dir_open(full);
if (!d)
return -1;
int n = 0;
struct dirent *de;
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") != 0 && strcmp(de->d_name, "..") != 0)
n++;
}
closedir(d);
while (os_dir_next(d))
n++;
os_dir_close(d);
return n;
}
static int list_cb(walker_t *w, const char *rel, const char *full,
const struct stat *st, int depth) {
const os_stat_t *st, int depth) {
list_ud_t *u = w->ud;
if (u->count >= LIST_MAX) {
if (++u->more >= LIST_COUNT_MAX) {
@@ -888,7 +887,7 @@ static int list_cb(walker_t *w, const char *rel, const char *full,
name = name ? name + 1 : rel;
for (int i = 0; i < depth; i++)
cst_buf_puts(&u->b, " ");
if (S_ISDIR(st->st_mode)) {
if (st->is_dir) {
int k = -1;
if (skip_dir_name(name))
cst_buf_printf(&u->b, "%s/ (skipped)\n", name);
@@ -897,10 +896,10 @@ static int list_cb(walker_t *w, const char *rel, const char *full,
k == 1 ? "y" : "ies");
else
cst_buf_printf(&u->b, "%s/%s\n", name, k == 0 ? " (empty)" : "");
} else if (S_ISLNK(st->st_mode))
} else if (st->is_link)
cst_buf_printf(&u->b, "%s@\n", name);
else
cst_buf_printf(&u->b, "%s (%lld B)\n", name, (long long)st->st_size);
cst_buf_printf(&u->b, "%s (%lld B)\n", name, st->size);
u->count++;
return 0;
}
@@ -974,7 +973,7 @@ static int seg_match(const char *pat, const char *path) {
pseg[pl] = 0;
memcpy(sseg, path, sl);
sseg[sl] = 0;
if (fnmatch(pseg, sseg, FNM_PERIOD) != 0)
if (cst_fnmatch(pseg, sseg, CST_FNM_PERIOD) != 0)
return 0;
if (!ps)
return !ss;
@@ -994,11 +993,11 @@ typedef struct {
} glob_ud_t;
static int glob_cb(walker_t *w, const char *rel, const char *full,
const struct stat *st, int depth) {
const os_stat_t *st, int depth) {
(void)full;
(void)depth;
glob_ud_t *u = w->ud;
if (!S_ISREG(st->st_mode))
if (!st->is_reg)
return 0;
const char *sub = rel;
size_t bl = strlen(u->base);
@@ -1009,7 +1008,7 @@ static int glob_cb(walker_t *w, const char *rel, const char *full,
}
const char *name = strrchr(sub, '/');
name = name ? name + 1 : sub;
int ok = u->basename_only ? fnmatch(u->pattern, name, FNM_PERIOD) == 0
int ok = u->basename_only ? cst_fnmatch(u->pattern, name, CST_FNM_PERIOD) == 0
: seg_match(u->pattern, sub);
if (!ok)
return 0;
@@ -1025,7 +1024,7 @@ static int glob_cb(walker_t *w, const char *rel, const char *full,
return 1;
}
u->hits[u->n] = strdup(rel);
u->mtimes[u->n] = (double)st->st_mtime;
u->mtimes[u->n] = st->mtime;
if (u->hits[u->n])
u->n++;
if (u->n >= GLOB_MAX * 20) {
@@ -1109,11 +1108,11 @@ typedef struct {
} files_ud_t;
static int files_cb(walker_t *w, const char *rel, const char *full,
const struct stat *st, int depth) {
const os_stat_t *st, int depth) {
(void)full;
(void)depth;
files_ud_t *u = w->ud;
if (!S_ISREG(st->st_mode))
if (!st->is_reg)
return 0;
if (u->n >= u->max)
return 1;
@@ -1275,16 +1274,16 @@ static void grep_file(grep_ud_t *u, const char *rel, const char *full) {
}
static int grep_cb(walker_t *w, const char *rel, const char *full,
const struct stat *st, int depth) {
const os_stat_t *st, int depth) {
(void)depth;
grep_ud_t *u = w->ud;
if (!S_ISREG(st->st_mode) || st->st_size > READ_FILE_MAX)
if (!st->is_reg || st->size > READ_FILE_MAX)
return 0;
if (u->glob) {
const char *name = strrchr(rel, '/');
name = name ? name + 1 : rel;
int ok = strchr(u->glob, '/') ? fnmatch(u->glob, rel, 0) == 0
: fnmatch(u->glob, name, 0) == 0;
int ok = strchr(u->glob, '/') ? cst_fnmatch(u->glob, rel, 0) == 0
: cst_fnmatch(u->glob, name, 0) == 0;
if (!ok)
return 0;
}
@@ -1391,32 +1390,4 @@ int cst_tool_grep(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
return tool_take_buf(r, &out, 0);
}
#else /* _WIN32: directory tools are not implemented natively */
int cst_tool_list_dir(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
(void)ctx;
(void)c;
return tool_set_result(r, "error: list_dir is not available on Windows; "
"use run_shell", 1);
}
int cst_workspace_files(const char *ws, char ***out, int max) {
(void)ws;
(void)max;
*out = NULL;
return 0;
}
int cst_tool_glob(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
(void)ctx;
(void)c;
return tool_set_result(r, "error: glob is not available on Windows", 1);
}
int cst_tool_grep(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
(void)ctx;
(void)c;
return tool_set_result(r, "error: grep is not available on Windows", 1);
}
#endif
+48 -3
View File
@@ -1,4 +1,5 @@
#include "cst_agent.h"
#include "cst_util.h"
#include <stdio.h>
#include <stdlib.h>
@@ -129,9 +130,9 @@ static int parse_hunk_header(const char *p, size_t n, long *a, long *b,
return -1;
}
int cst_patch_apply_buffer(const char *orig, size_t olen, const char *hunks,
size_t hlen, char **out, size_t *out_len,
int *nhunks, char *err, size_t err_sz) {
static int patch_core(const char *orig, size_t olen, const char *hunks,
size_t hlen, char **out, size_t *out_len, int *nhunks,
char *err, size_t err_sz) {
lines_t file = {0}, hl = {0};
lines_t old_seq = {0}, new_seq = {0};
int rc = -1;
@@ -315,3 +316,47 @@ done:
free(new_seq.v);
return rc;
}
/* A CRLF file is patched as LF (the model's hunks usually are) and written
* back as CRLF; a BOM is kept out of the first line's context. */
int cst_patch_apply_buffer(const char *orig, size_t olen, const char *hunks,
size_t hlen, char **out, size_t *out_len,
int *nhunks, char *err, size_t err_sz) {
size_t bom = orig ? cst_bom_len(orig, olen) : 0;
int crlf = orig && cst_text_is_crlf(orig, olen);
if (!bom && !crlf)
return patch_core(orig, olen, hunks, hlen, out, out_len, nhunks, err,
err_sz);
if (out)
*out = NULL;
char *o = malloc(olen - bom + 1), *h = malloc(hlen + 1), *res = NULL;
size_t rlen = 0;
int rc = -1;
if (!o || !h) {
snprintf(err, err_sz, "oom");
goto done;
}
memcpy(o, orig + bom, olen - bom);
o[olen - bom] = 0;
size_t on = olen - bom, hn = hlen;
if (hlen)
memcpy(h, hunks, hlen);
h[hlen] = 0;
if (crlf) {
on = cst_crlf_to_lf(o, on);
hn = cst_crlf_to_lf(h, hn);
}
rc = patch_core(o, on, h, hn, &res, &rlen, nhunks, err, err_sz);
if (rc == 0) {
*out = cst_text_restyle(res, rlen, crlf, bom != 0, out_len);
if (!*out) {
snprintf(err, err_sz, "oom");
rc = -1;
}
}
done:
free(res);
free(o);
free(h);
return rc;
}
-7
View File
@@ -6,9 +6,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <pthread.h>
#endif
#include "cJSON.h"
#include "cst_tools.h"
@@ -182,14 +180,9 @@ const char *cst_tool_profile_name(int p) {
#define DYN_MAX 256
static cst_tool_def_t *g_dyn[DYN_MAX];
static atomic_int g_ndyn;
#ifndef _WIN32
static pthread_mutex_t g_dyn_mu = PTHREAD_MUTEX_INITIALIZER;
#define DYN_LOCK() pthread_mutex_lock(&g_dyn_mu)
#define DYN_UNLOCK() pthread_mutex_unlock(&g_dyn_mu)
#else
#define DYN_LOCK() ((void)0)
#define DYN_UNLOCK() ((void)0)
#endif
int cst_tool_count(void) { return NTOOLS + atomic_load(&g_ndyn); }
+31
View File
@@ -238,6 +238,22 @@ static int write_content(const ctl_tool_call_t *call, const char **content,
return *content ? 0 : -1;
}
/* Overwriting a CRLF and/or BOM file with plain LF text keeps the file's
* style: malloc'd restyled copy, or NULL to write content as given. */
static char *keep_text_style(const char *oldc, size_t olen, const char *s,
size_t n, size_t *out_len) {
if (!oldc)
return NULL;
int has_crlf = 0;
for (size_t i = 1; i < n && !has_crlf; i++)
has_crlf = s[i] == '\n' && s[i - 1] == '\r';
int crlf = !has_crlf && cst_text_is_crlf(oldc, olen);
int bom = cst_bom_len(oldc, olen) && !cst_bom_len(s, n);
if (!crlf && !bom)
return NULL;
return cst_text_restyle(s, n, crlf, 1, out_len);
}
int cst_preview_write_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
char **out) {
const char *path = tool_arg(c, "path");
@@ -252,11 +268,18 @@ int cst_preview_write_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
char *oldc = NULL;
if (cst_sandbox_resolve(ctx->workspace_root, path, full, sizeof(full)) == 0)
oldc = tool_slurp(full, &olen, 0);
size_t sn = 0;
char *styled = keep_text_style(oldc, olen, content, n, &sn);
if (styled) {
content = styled;
n = sn;
}
cst_buf_t b = {0};
cst_buf_printf(&b, "%s %s (%zu bytes)\n", oldc ? "overwrite" : "create",
path, n);
tool_append_diff(&b, path, oldc, olen, content, n);
free(oldc);
free(styled);
*out = cst_buf_steal(&b);
return 0;
}
@@ -276,14 +299,22 @@ int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
size_t olen = 0;
char *oldc = tool_slurp(full, &olen, 0);
size_t sn = 0;
char *styled = keep_text_style(oldc, olen, content, n, &sn);
if (styled) {
content = styled;
n = sn;
}
if (tool_write_whole(full, content, n) != 0) {
free(oldc);
free(styled);
return tool_set_resultf(r, 1, "error: cannot write file: %.200s", path);
}
cst_buf_t b = {0};
cst_buf_printf(&b, "wrote %s (%zu bytes)\n", path, n);
tool_append_diff(&b, path, oldc, olen, content, n);
free(oldc);
free(styled);
return tool_take_buf(r, &b, 0);
}