Windows: official port -- UCRT static build, UTF-8 throughout, Low IL sandbox, ncurses TUI, installer; v0.6.0
ci / linux (clang) (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 0s
ci / sanitizers (push) Canceled after 0s
ci / musl (push) Canceled after 0s
ci / macos (push) Canceled after 0s

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
+225 -28
View File
@@ -41,11 +41,12 @@
static int g_nocolor; /* NO_COLOR */
static int g_ascii; /* non-UTF-8 locale or CST_ASCII=1 */
static int g_logo_text; /* console font lacks the logo's block glyphs */
static int g_utf8 = 1; /* locale can show non-ASCII text */
#define HAS_COLOR() (!g_nocolor && has_colors())
/* Curses: ncursesw on Unix, PDCurses (wide) on Windows. */
#if defined(CST_USE_PDCURSES) || defined(_WIN32)
/* Curses: ncursesw (Unix and Windows), or PDCurses (wide) on Windows. */
#if defined(CST_USE_PDCURSES)
#ifndef PDC_WIDE
#define PDC_WIDE 1
#endif
@@ -62,10 +63,23 @@ static int g_utf8 = 1; /* locale can show non-ASCII text */
#ifndef KEY_RESIZE
#define KEY_RESIZE 0632
#endif
#define wcwidth cst_wcwidth /* MinGW often lacks wcwidth */
#else
#include <ncurses.h>
#endif
#ifdef _WIN32
#define wcwidth cst_wcwidth /* MinGW often lacks wcwidth */
#endif
/* The Windows console (conhost/ConPTY) loses wide characters on insert or
* delete-character, so curses must repaint cells instead. */
static WINDOW *tui_newwin(int rows, int cols, int y, int x) {
WINDOW *w = newwin(rows, cols, y, x);
#ifdef _WIN32
if (w)
idcok(w, FALSE);
#endif
return w;
}
#ifdef _WIN32
static void cst_usleep(unsigned usec) {
@@ -1876,10 +1890,10 @@ static void git_branch(const char *dir, char *out, size_t sz) {
FILE *f = NULL;
for (int depth = 0; depth < 64 && d[0]; depth++) {
snprintf(p, sizeof(p), "%s/.git/HEAD", d);
if ((f = fopen(p, "r")) != NULL)
if ((f = fopen(p, "rb")) != NULL)
break;
snprintf(p, sizeof(p), "%s/.git", d);
FILE *g = fopen(p, "r");
FILE *g = fopen(p, "rb");
if (g) {
int ok = fgets(line, sizeof(line), g) && strncmp(line, "gitdir: ", 8) == 0;
fclose(g);
@@ -1889,7 +1903,7 @@ static void git_branch(const char *dir, char *out, size_t sz) {
? snprintf(p, sizeof(p), "%s/HEAD", line + 8)
: snprintf(p, sizeof(p), "%s/%s/HEAD", d, line + 8);
if (n > 0 && (size_t)n < sizeof(p))
f = fopen(p, "r");
f = fopen(p, "rb");
break;
}
}
@@ -2322,7 +2336,7 @@ static void draw_home(tui_t *t) {
getmaxyx(t->logw, h, w);
int box_top = t->in_y - t->log_y;
int box_bot = box_top + t->in_h;
if (g_ascii || !g_utf8) {
if (g_ascii || !g_utf8 || g_logo_text) {
int x = (w - 8) / 2, y = box_top - 2;
if (y >= 0) {
draw_ansi_at(t->logw, y, x, 3, "cst", CP_LOGO_DIM, 1, 0);
@@ -2805,12 +2819,107 @@ static int input_visual_rows(const tui_t *t) {
return rows;
}
#ifdef _WIN32
static DWORD g_con_in_mode;
static int g_con_in_saved;
/* QuickEdit makes the console select text itself and swallow the clicks;
* it is off while the TUI owns the mouse and restored on exit. */
static void win_console_mouse(int on) {
HANDLE h = GetStdHandle(STD_INPUT_HANDLE);
DWORD m;
if (!GetConsoleMode(h, &m))
return;
if (!g_con_in_saved) {
g_con_in_mode = m;
g_con_in_saved = 1;
}
if (on)
m = (m & ~(DWORD)ENABLE_QUICK_EDIT_MODE) | ENABLE_MOUSE_INPUT |
ENABLE_EXTENDED_FLAGS;
else
m = g_con_in_mode | ENABLE_EXTENDED_FLAGS;
SetConsoleMode(h, m);
}
static CONSOLE_FONT_INFOEX g_con_font;
static int g_con_font_saved;
/* conhost has no glyph fallback: NSimSun, the default on Chinese systems,
* draws U+2580 as an empty box. */
static int win_font_has(const WCHAR *face, WCHAR ch) {
HDC dc = CreateCompatibleDC(NULL);
if (!dc)
return 1;
HFONT font = CreateFontW(16, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET,
0, 0, 0, FF_MODERN, face);
WORD idx = 0;
int ok = 1;
if (font) {
HGDIOBJ old = SelectObject(dc, font);
if (GetGlyphIndicesW(dc, &ch, 1, &idx, GGI_MARK_NONEXISTING_GLYPHS) == 1)
ok = idx != 0xffff;
SelectObject(dc, old);
DeleteObject(font);
}
DeleteDC(dc);
return ok;
}
/* Raster fonts cannot draw UTF-8 box drawing or CJK; switch the console to
* a TrueType font of the same height while the TUI runs. */
static void win_console_font(int on) {
HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE);
if (!on) {
if (g_con_font_saved)
SetCurrentConsoleFontEx(h, FALSE, &g_con_font);
g_con_font_saved = 0;
return;
}
CONSOLE_FONT_INFOEX f = {.cbSize = sizeof f};
if (!GetCurrentConsoleFontEx(h, FALSE, &f))
return;
if (!(f.FontFamily & TMPF_TRUETYPE)) {
g_con_font = f;
CONSOLE_FONT_INFOEX tt = {.cbSize = sizeof tt};
tt.dwFontSize.Y = f.dwFontSize.Y > 0 ? f.dwFontSize.Y : 16;
tt.FontFamily = FF_MODERN | TMPF_TRUETYPE;
tt.FontWeight = FW_NORMAL;
wcscpy(tt.FaceName, GetACP() == 936 ? L"NSimSun" : L"Consolas");
if (!SetCurrentConsoleFontEx(h, FALSE, &tt))
return;
g_con_font_saved = 1;
f = tt;
}
if (!getenv("WT_SESSION") && !win_font_has(f.FaceName, 0x2580))
g_logo_text = 1;
}
#endif
#define MOUSE_RUN_MAX 16
/* getmouse() hands out a run of queued events newest first (the Windows
* console driver queues press, motion and release together); returns them
* oldest first. */
static int get_mouse_run(MEVENT *out, int max) {
int n = 0;
while (n < max && getmouse(&out[n]) == OK)
n++;
for (int i = 0; i < n / 2; i++) {
MEVENT tmp = out[i];
out[i] = out[n - 1 - i];
out[n - 1 - i] = tmp;
}
return n;
}
static void apply_mouse(const tui_t *t) {
mousemask(t->mouse_on ? (mmask_t)(ALL_MOUSE_EVENTS | REPORT_MOUSE_POSITION)
: 0,
NULL);
mouseinterval(0);
#ifdef NCURSES_VERSION
#ifdef _WIN32
win_console_mouse(t->mouse_on);
#elif defined(NCURSES_VERSION)
/* ncurses only asks for presses and releases (1000). 1002 also reports
* motion while a button is held, for drag selection; terminals treat it as
* replacing 1000, so it goes after mousemask and is turned off explicitly. */
@@ -2869,8 +2978,8 @@ static void layout(tui_t *t) {
int top = (t->rows - 1 - block) / 2;
t->in_y = clampi(top + LOGO_H + 1, 0, t->rows - 1 - t->in_h);
t->log_x = t->log_y = 0;
t->logw = newwin(t->rows - 1, t->cols, 0, 0);
t->status = newwin(1, t->cols - 2, t->rows - 1, 1);
t->logw = tui_newwin(t->rows - 1, t->cols, 0, 0);
t->status = tui_newwin(1, t->cols - 2, t->rows - 1, 1);
} else {
int mw = t->cols - t->side_w;
t->in_y = t->rows - 1 - t->in_h;
@@ -2885,14 +2994,14 @@ static void layout(tui_t *t) {
t->todo_rows = 0;
log_h = t->in_y - t->log_y > 0 ? t->in_y - t->log_y : 1;
}
t->logw = newwin(log_h, mw - 2, t->log_y, 1);
t->logw = tui_newwin(log_h, mw - 2, t->log_y, 1);
if (t->todo_rows)
t->todow = newwin(t->todo_rows, mw - 2, t->log_y + log_h, 1);
t->status = newwin(1, mw - 2, t->rows - 1, 1);
t->todow = tui_newwin(t->todo_rows, mw - 2, t->log_y + log_h, 1);
t->status = tui_newwin(1, mw - 2, t->rows - 1, 1);
if (t->side_w)
t->side = newwin(t->rows, t->side_w, 0, mw);
t->side = tui_newwin(t->rows, t->side_w, 0, mw);
}
t->input = newwin(t->in_h, t->in_w, t->in_y, t->in_x);
t->input = tui_newwin(t->in_h, t->in_w, t->in_y, t->in_x);
if (HAS_COLOR()) {
wbkgdset(t->logw, COLOR_PAIR(CP_PANEL));
wbkgdset(t->status, COLOR_PAIR(CP_PANEL));
@@ -3495,7 +3604,7 @@ static int picker_run(tui_t *t, const char *title, char items[][128], int n,
redraw_all(t); /* uncover what a larger dialog hid */
last_dh = dh;
last_dw = dw;
dlg = newwin(dh, dw, dy, dx);
dlg = tui_newwin(dh, dw, dy, dx);
if (!dlg)
break;
keypad(dlg, TRUE);
@@ -3549,9 +3658,14 @@ static int picker_run(tui_t *t, const char *title, char items[][128], int n,
layout(t);
redraw_all(t);
} else if (wch == KEY_MOUSE) {
MEVENT ev;
if (getmouse(&ev) != OK)
MEVENT evs[MOUSE_RUN_MAX], ev;
int ne = get_mouse_run(evs, MOUSE_RUN_MAX), k0 = ne - 1;
if (ne == 0)
continue;
for (int i = 0; i < ne; i++)
if (evs[i].bstate & (BUTTON1_PRESSED | BUTTON1_CLICKED))
k0 = i; /* a click anywhere in the run wins over its release */
ev = evs[k0];
if (ev.bstate & BUTTON4_PRESSED) {
if (sel > 0)
sel--;
@@ -4301,6 +4415,34 @@ static void cmd_tools(tui_t *t, char *arg, int *quit) {
}
}
static void cmd_sandbox(tui_t *t, char *arg, int *quit) {
(void)quit;
if (arg && strcmp(arg, "reset-labels") == 0) {
char *rep = NULL;
int n = cst_sandbox_reset_labels(&rep);
if (rep && rep[0])
push_lines(t, " ", rep);
push_system_line(t, n < 0 ? "(some labels could not be restored; "
"they stay listed)"
: n == 0 ? "(no labeled directories)"
: "(labels restored)");
free(rep);
return;
}
int sk = cst_sandbox_probe();
push_system_line(t, "Shell sandbox: %s",
sk == CST_SANDBOX_NONE ? "none (rule-based approval)"
: cst_sandbox_name(sk));
char *l = cst_sandbox_labels();
if (l) {
push_system_line(t, "Labeled Low integrity (writable by sandboxed "
"commands):");
push_lines(t, " ", l);
push_system_line(t, "/sandbox reset-labels restores their old labels");
free(l);
}
}
static void cmd_permissions(tui_t *t, char *arg, int *quit) {
(void)quit;
cst_perms_t *p = cst_agent_perms(t->agent);
@@ -4537,7 +4679,7 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
*sl = 0;
os_mkdir_p(dir, 0755);
}
FILE *fp = fopen(path, "w");
FILE *fp = fopen(path, "wb");
if (!fp) {
push_system_line(t, "Error: cannot write %s", path);
return;
@@ -4569,9 +4711,19 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(exported to %s)", path);
}
#ifdef _WIN32
#define COPY_VIA "clipboard"
#else
#define COPY_VIA "OSC 52"
#endif
/* Puts text on the terminal's clipboard; returns the bytes sent, 0 on
* failure. */
static size_t osc52_copy(const char *text, size_t n) {
#ifdef _WIN32
if (os_clipboard_set(text, n) == 0)
return n; /* conhost has no OSC 52 */
#endif
static const char b64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
if (n > 75000)
@@ -4614,8 +4766,8 @@ static void cmd_copy(tui_t *t, char *arg, int *quit) {
}
size_t n = osc52_copy(text, strlen(text));
if (n)
push_system_line(t, "(copied %zu bytes of the last reply via OSC 52 — the "
"terminal must allow clipboard access)", n);
push_system_line(t, "(copied %zu bytes of the last reply via " COPY_VIA
" — the terminal must allow clipboard access)", n);
}
static void cmd_init(tui_t *t, char *arg, int *quit) {
@@ -4945,6 +5097,8 @@ static const slash_cmd_t SLASH[] = {
cmd_tools},
{"permissions", "perms", "[reset]", "show saved always-allow rules", 1,
cmd_permissions},
{"sandbox", "", "[reset-labels]", "shell sandbox and labeled folders", 1,
cmd_sandbox},
{"approval", "", "[ask|auto|yolo]", "how tools get approved (Shift+Tab)",
1, cmd_approval},
{"yolo", "", "", "toggle approving everything", 1, cmd_yolo},
@@ -5530,7 +5684,7 @@ static void sel_copy(tui_t *t) {
for (size_t i = 0; i < b.n; i++)
chars += ((unsigned char)b.s[i] & 0xC0) != 0x80;
char msg[96];
snprintf(msg, sizeof(msg), "Copied %zu characters (OSC 52)", chars);
snprintf(msg, sizeof(msg), "Copied %zu characters (" COPY_VIA ")", chars);
set_status(t, msg);
draw_status(t);
}
@@ -6187,6 +6341,21 @@ static void insert_newline(tui_t *t) {
}
/* Paste mode: keep newlines/tabs literally; redraw once at the end. */
#ifdef _WIN32
static int win_key_pending(void) {
INPUT_RECORD rec[32];
DWORD n = 0;
HANDLE h = GetStdHandle(STD_INPUT_HANDLE);
if (!PeekConsoleInputW(h, rec, 32, &n))
return 0;
for (DWORD i = 0; i < n; i++)
if (rec[i].EventType == KEY_EVENT && rec[i].Event.KeyEvent.bKeyDown &&
rec[i].Event.KeyEvent.uChar.UnicodeChar)
return 1;
return 0;
}
#endif
static void paste_char(tui_t *t, wint_t wch) {
if (wch == 13) {
input_put_byte(t, '\n');
@@ -6207,20 +6376,31 @@ static void paste_char(tui_t *t, wint_t wch) {
}
int cst_tui_run(cst_agent_t *agent) {
#ifdef _WIN32
setlocale(LC_ALL, ".UTF-8");
#else
if (!setlocale(LC_ALL, ""))
setlocale(LC_ALL, "C.UTF-8");
#endif
#ifndef _WIN32
if (!getenv("ESCDELAY"))
setenv("ESCDELAY", "25", 0);
#endif
#ifdef _WIN32
win_console_font(1);
#endif
initscr();
#ifdef _WIN32
idcok(stdscr, FALSE);
#endif
raw(); /* Ctrl+C/Ctrl+Z reach us as keys instead of signals */
noecho();
keypad(stdscr, TRUE);
curs_set(1);
theme_init();
#ifdef NCURSES_VERSION
#if defined(NCURSES_VERSION) && !defined(_WIN32)
/* the Windows console driver reads key events, not VT sequences */
define_key("\033[200~", KEY_PASTE_BEGIN);
define_key("\033[201~", KEY_PASTE_END);
printf("\033[?2004h");
@@ -6375,6 +6555,18 @@ int cst_tui_run(cst_agent_t *agent) {
if (t.sel_state == SEL_SHOWN &&
!(rc == KEY_CODE_YES && (wch == KEY_MOUSE || wch == KEY_RESIZE)))
sel_clear(&t);
#ifdef _WIN32
/* The console has no bracketed paste: an Enter with more keys already
* queued is a pasted line break, not a send. */
if (rc == OK && !t.in_paste && (wch == 10 || wch == 13) &&
win_key_pending()) {
paste_char(&t, wch);
input_changed(&t);
after_edit(&t);
draw_input(&t);
continue;
}
#endif
if (!t.in_paste &&
!(rc == KEY_CODE_YES &&
@@ -6453,12 +6645,13 @@ int cst_tui_run(cst_agent_t *agent) {
continue;
}
if (wch == KEY_MOUSE) {
MEVENT ev;
if (getmouse(&ev) == OK) {
if (on_mouse(&t, &ev, busy) == -2)
MEVENT evs[MOUSE_RUN_MAX];
int ne = get_mouse_run(evs, MOUSE_RUN_MAX);
for (int i = 0; i < ne; i++)
if (on_mouse(&t, &evs[i], busy) == -2)
goto out;
if (ne)
draw_input(&t); /* keep the terminal cursor in the box */
}
continue;
}
int wdir;
@@ -6667,7 +6860,11 @@ out:
usleep(100000); /* let a /models probe finish before curl cleanup */
tui_free(&t);
#ifdef NCURSES_VERSION
#ifdef _WIN32
if (g_con_in_saved)
win_console_mouse(0);
win_console_font(0);
#elif defined(NCURSES_VERSION)
kitty_restore();
printf("\033[?1002l\033[?2004l");
fflush(stdout);