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
+150 -345
View File
@@ -10,6 +10,7 @@
#include <stdint.h>
#endif
#include "os_compat.h"
#include "os_internal.h"
#include <errno.h>
#include <stdio.h>
@@ -26,6 +27,7 @@
#include <process.h>
#include <windows.h>
#else
#include <dirent.h>
#include <fcntl.h>
#include <poll.h>
#include <pthread.h>
@@ -93,26 +95,10 @@ int os_path_join(char *out, size_t out_sz, const char *a, const char *b) {
return 0;
}
#ifndef _WIN32
int os_realpath(const char *path, char *out, size_t out_sz) {
if (!path || !out || out_sz < 2)
return -1;
#ifdef _WIN32
char buf[OS_PATH_MAX];
DWORD n = GetFullPathNameA(path, (DWORD)sizeof(buf), buf, NULL);
if (n == 0 || n >= sizeof(buf))
return -1;
/* Prefer forward slashes internally for sandbox compares. */
os_path_norm_slashes(buf);
if (strlen(buf) + 1 > out_sz)
return -1;
memcpy(out, buf, strlen(buf) + 1);
/* Lowercase drive letter for stable compares */
if (out[0] && out[1] == ':') {
if (out[0] >= 'A' && out[0] <= 'Z')
out[0] = (char)(out[0] - 'A' + 'a');
}
return 0;
#else
char *r = realpath(path, NULL);
if (!r)
return -1;
@@ -123,7 +109,6 @@ int os_realpath(const char *path, char *out, size_t out_sz) {
memcpy(out, r, strlen(r) + 1);
free(r);
return 0;
#endif
}
int os_path_under(const char *root, const char *path) {
@@ -132,14 +117,9 @@ int os_path_under(const char *root, const char *path) {
size_t rl = strlen(root);
if (strncmp(path, root, rl) != 0)
return 0;
if (path[rl] == '\0')
return 1;
#ifdef _WIN32
return path[rl] == '/' || path[rl] == '\\';
#else
return path[rl] == '/';
#endif
return path[rl] == '\0' || path[rl] == '/';
}
#endif
int os_mkdir(const char *path, int mode) {
if (!path)
@@ -172,16 +152,93 @@ int os_mkdir_p(const char *path, int mode) {
return os_mkdir(tmp, mode);
}
#ifndef _WIN32
struct os_dir {
DIR *d;
char path[OS_PATH_MAX];
os_dirent_t ent;
};
os_dir_t *os_dir_open(const char *path) {
if (!path)
return NULL;
os_dir_t *d = calloc(1, sizeof(*d));
if (!d)
return NULL;
snprintf(d->path, sizeof(d->path), "%s", path);
d->d = opendir(path);
if (!d->d) {
free(d);
return NULL;
}
return d;
}
const os_dirent_t *os_dir_next(os_dir_t *d) {
struct dirent *de;
while (d && (de = readdir(d->d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
continue;
d->ent.name = de->d_name;
d->ent.is_dir = d->ent.is_link = 0;
#ifdef DT_DIR
if (de->d_type != DT_UNKNOWN) {
d->ent.is_dir = de->d_type == DT_DIR;
d->ent.is_link = de->d_type == DT_LNK;
return &d->ent;
}
#endif
char p[OS_PATH_MAX];
struct stat st;
if (snprintf(p, sizeof(p), "%s/%s", d->path, de->d_name) <
(int)sizeof(p) &&
lstat(p, &st) == 0) {
d->ent.is_dir = S_ISDIR(st.st_mode);
d->ent.is_link = S_ISLNK(st.st_mode);
}
return &d->ent;
}
return NULL;
}
void os_dir_close(os_dir_t *d) {
if (!d)
return;
closedir(d->d);
free(d);
}
int os_lstat(const char *path, os_stat_t *st) {
struct stat s;
memset(st, 0, sizeof(*st));
if (!path || lstat(path, &s) != 0)
return -1;
st->is_dir = S_ISDIR(s.st_mode);
st->is_reg = S_ISREG(s.st_mode);
st->is_link = S_ISLNK(s.st_mode);
st->size = (long long)s.st_size;
st->mtime = (double)s.st_mtime;
return 0;
}
int os_rename_replace(const char *from, const char *to) {
return rename(from, to) == 0 ? 0 : -1;
}
#endif
int os_config_dir(char *out, size_t out_sz) {
if (!out || out_sz < 8)
return -1;
#ifdef _WIN32
const char *base = getenv("APPDATA");
const char *base = getenv("XDG_CONFIG_HOME");
if (!base || !os_path_is_abs(base))
base = getenv("APPDATA");
if (!base || !base[0])
base = getenv("LOCALAPPDATA");
if (!base || !base[0])
return -1;
int n = snprintf(out, out_sz, "%s\\cst-agent", base);
int n = snprintf(out, out_sz, "%s/cst-agent", base);
os_path_norm_slashes(out);
#else
const char *xdg = getenv("XDG_CONFIG_HOME");
const char *home = getenv("HOME");
@@ -389,7 +446,7 @@ void os_set_run_timeout(int seconds) { g_run_timeout_s = seconds; }
int os_get_run_timeout(void) { return g_run_timeout_s; }
static int run_timeout_s(void) {
int os_run_timeout_effective(void) {
if (g_run_timeout_s > 0)
return g_run_timeout_s;
const char *e = getenv("CST_SHELL_TIMEOUT");
@@ -414,8 +471,8 @@ double os_now_ms(void) {
}
/* Append to capture buffer, keeping at most max_out bytes. */
static int cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
const char *data, size_t n, int *truncated) {
int os_cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
const char *data, size_t n, int *truncated) {
if (*len >= max_out) {
*truncated = 1;
return 0;
@@ -440,7 +497,7 @@ static int cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
return 0;
}
static void cap_note(char **buf, size_t *len, size_t *cap, const char *note) {
void os_cap_note(char **buf, size_t *len, size_t *cap, const char *note) {
size_t n = strlen(note);
if (*len + n + 1 > *cap) {
char *nb = realloc(*buf, *len + n + 1);
@@ -453,154 +510,7 @@ static void cap_note(char **buf, size_t *len, size_t *cap, const char *note) {
*len += n;
}
#ifdef _WIN32
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
os_run_result_t *r) {
if (!argv || !argv[0] || !r)
return -1;
if (max_out == 0)
max_out = 512 * 1024;
memset(r, 0, sizeof(*r));
char cmdline[8192];
size_t pos = 0;
cmdline[0] = 0;
for (int i = 0; argv[i]; i++) {
const char *a = argv[i];
int need_q = strchr(a, ' ') || strchr(a, '\t') || strchr(a, '"');
if (i && pos + 1 < sizeof(cmdline))
cmdline[pos++] = ' ';
if (need_q) {
if (pos + 1 < sizeof(cmdline))
cmdline[pos++] = '"';
for (const char *p = a; *p && pos + 2 < sizeof(cmdline); p++) {
if (*p == '"') {
cmdline[pos++] = '\\';
cmdline[pos++] = '"';
} else {
cmdline[pos++] = *p;
}
}
if (pos + 1 < sizeof(cmdline))
cmdline[pos++] = '"';
} else {
size_t L = strlen(a);
if (pos + L >= sizeof(cmdline))
break;
memcpy(cmdline + pos, a, L);
pos += L;
}
cmdline[pos] = 0;
}
SECURITY_ATTRIBUTES sa = {0};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE rd = NULL, wr = NULL;
if (!CreatePipe(&rd, &wr, &sa, 0))
return -1;
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOA si = {0};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdOutput = wr;
si.hStdError = wr;
HANDLE nul = CreateFileA("NUL", GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE,
&sa, OPEN_EXISTING, 0, NULL);
si.hStdInput = nul != INVALID_HANDLE_VALUE ? nul : GetStdHandle(STD_INPUT_HANDLE);
PROCESS_INFORMATION pi = {0};
char *cmd_mut = _strdup(cmdline);
if (!cmd_mut) {
CloseHandle(rd);
CloseHandle(wr);
return -1;
}
BOOL ok = CreateProcessA(NULL, cmd_mut, NULL, NULL, TRUE, 0, NULL,
cwd && cwd[0] ? cwd : NULL, &si, &pi);
free(cmd_mut);
CloseHandle(wr);
if (nul != INVALID_HANDLE_VALUE)
CloseHandle(nul);
if (!ok) {
CloseHandle(rd);
return -1;
}
size_t cap = 4096, len = 0;
char *buf = malloc(cap);
if (!buf) {
CloseHandle(rd);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
return -1;
}
buf[0] = 0;
int truncated = 0, killed = 0;
double deadline = os_now_ms() + run_timeout_s() * 1000.0;
for (;;) {
DWORD avail = 0;
if (!PeekNamedPipe(rd, NULL, 0, NULL, &avail, NULL))
break; /* writer closed */
if (avail > 0) {
char tmp[4096];
DWORD n = 0;
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
break;
cap_append(&buf, &len, &cap, max_out, tmp, n, &truncated);
continue;
}
if (WaitForSingleObject(pi.hProcess, 50) == WAIT_OBJECT_0) {
/* drain what is left */
while (PeekNamedPipe(rd, NULL, 0, NULL, &avail, NULL) && avail > 0) {
char tmp[4096];
DWORD n = 0;
if (!ReadFile(rd, tmp, sizeof(tmp), &n, NULL) || n == 0)
break;
cap_append(&buf, &len, &cap, max_out, tmp, n, &truncated);
}
break;
}
if ((g_run_cancel && *g_run_cancel) || os_now_ms() > deadline) {
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
TerminateProcess(pi.hProcess, killed == 1 ? 124 : 130);
break;
}
}
CloseHandle(rd);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
if (truncated)
cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
if (killed == 1) {
char note[96];
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
run_timeout_s());
cap_note(&buf, &len, &cap, note);
code = 124;
} else if (killed == 2) {
cap_note(&buf, &len, &cap, "\n[cst: cancelled]\n");
code = 130;
}
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
buf[len] = 0;
r->out = buf;
r->out_len = len;
r->exit_code = (int)code;
return 0;
}
int os_run_capture_ex(const char *cwd, const char *const argv[],
size_t max_out, os_run_result_t *r,
os_child_hook_fn child_hook, void *ud) {
(void)child_hook;
(void)ud;
return os_run_capture(cwd, argv, max_out, r);
}
#else
#ifndef _WIN32
static void cloexec(int fd) {
int fl = fcntl(fd, F_GETFD);
if (fl >= 0)
@@ -685,7 +595,7 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
int truncated = 0, killed = 0;
int status = 0, reaped = 0, bg_killed = 0;
double reaped_at = 0, killed_at = 0;
double deadline = os_now_ms() + run_timeout_s() * 1000.0;
double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0;
for (;;) {
struct pollfd pfd = {pipefd[0], POLLIN, 0};
int pr = poll(&pfd, 1, 100);
@@ -697,7 +607,7 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
if (n == 0)
break; /* every writer closed */
if (n > 0)
cap_append(&buf, &len, &cap, max_out, tmp, (size_t)n, &truncated);
os_cap_append(&buf, &len, &cap, max_out, tmp, (size_t)n, &truncated);
else if (errno != EINTR && errno != EAGAIN)
break;
}
@@ -733,9 +643,9 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
waitpid(pid, &status, 0);
}
if (truncated)
cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
os_cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
if (bg_killed)
cap_note(&buf, &len, &cap,
os_cap_note(&buf, &len, &cap,
"\n[cst: background processes left running were stopped]\n");
r->out = buf;
r->out_len = len;
@@ -743,11 +653,11 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
if (killed == 1) {
char note[96];
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
run_timeout_s());
cap_note(&r->out, &r->out_len, &cap, note);
os_run_timeout_effective());
os_cap_note(&r->out, &r->out_len, &cap, note);
r->exit_code = 124;
} else if (killed == 2) {
cap_note(&r->out, &r->out_len, &cap, "\n[cst: cancelled]\n");
os_cap_note(&r->out, &r->out_len, &cap, "\n[cst: cancelled]\n");
r->exit_code = 130;
}
return 0;
@@ -759,40 +669,64 @@ int os_run_shell_c(const char *cwd, const char *cmd, size_t max_out,
return os_run_shell_ex(cwd, cmd, max_out, r, NULL, NULL);
}
/* busybox: busybox sh -c cmd ; /bin/sh: sh -c cmd. argv needs 5 slots and
* points into shell. */
static int shell_argv(char *shell, size_t shell_sz, const char *cmd,
const char *argv[5]) {
if (!cmd || os_resolve_shell(shell, shell_sz) != 0)
return -1;
const char *base = shell;
const char *s = strrchr(shell, '/');
#ifdef _WIN32
const char *s2 = strrchr(shell, '\\');
if (s2 && (!s || s2 > s))
s = s2;
#endif
if (s)
base = s + 1;
int i = 0;
argv[i++] = shell;
if (strncmp(base, "busybox", 7) == 0)
argv[i++] = "sh";
argv[i++] = "-c";
argv[i++] = cmd;
argv[i] = NULL;
return 0;
}
int os_run_shell_ex(const char *cwd, const char *cmd, size_t max_out,
os_run_result_t *r, os_child_hook_fn child_hook,
void *ud) {
if (!cmd || !r)
return -1;
char shell[OS_PATH_MAX];
if (os_resolve_shell(shell, sizeof(shell)) != 0)
const char *argv[5];
if (!r || shell_argv(shell, sizeof(shell), cmd, argv) != 0)
return -1;
/* busybox: busybox sh -c cmd ; /bin/sh: sh -c cmd */
int is_busybox = 0;
{
const char *base = shell;
const char *s = strrchr(shell, '/');
#ifdef _WIN32
const char *s2 = strrchr(shell, '\\');
if (s2 && (!s || s2 > s))
s = s2;
#endif
if (s)
base = s + 1;
if (strncmp(base, "busybox", 7) == 0)
is_busybox = 1;
}
if (is_busybox) {
const char *argv[] = {shell, "sh", "-c", cmd, NULL};
return os_run_capture_ex(cwd, argv, max_out, r, child_hook, ud);
} else {
const char *argv[] = {shell, "-c", cmd, NULL};
return os_run_capture_ex(cwd, argv, max_out, r, child_hook, ud);
}
return os_run_capture_ex(cwd, argv, max_out, r, child_hook, ud);
}
#ifdef _WIN32
int os_run_shell_token(const char *cwd, const char *cmd, size_t max_out,
os_run_result_t *r, void *token,
const char *const env_add[]) {
char shell[OS_PATH_MAX];
const char *argv[5];
if (!r || shell_argv(shell, sizeof(shell), cmd, argv) != 0)
return -1;
return os_run_capture_token(cwd, argv, max_out, r, token, env_add);
}
#endif
#ifndef _WIN32
int os_console_init(void) { return 0; }
char *os_output_to_utf8(const char *s, size_t n, size_t *out_len) {
(void)s;
(void)n;
(void)out_len;
return NULL;
}
#endif
void os_localtime(const time_t *t, struct tm *out) {
if (!t || !out)
return;
@@ -811,16 +745,13 @@ int os_getpid(void) {
#endif
}
#ifndef _WIN32
int os_chmod_private(const char *path) {
if (!path)
return -1;
#ifdef _WIN32
(void)path;
return 0;
#else
return chmod(path, 0600);
#endif
}
#endif
typedef struct {
os_task_fn fn;
@@ -905,133 +836,7 @@ int os_cancel_requested(void) { return g_run_cancel && *g_run_cancel; }
volatile int *os_get_cancel_flag(void) { return g_run_cancel; }
#ifdef _WIN32
static void win_quote_arg(char *out, size_t out_sz, size_t *pos,
const char *a) {
int need_q = !a[0] || strpbrk(a, " \t\"") != NULL;
if (need_q && *pos + 1 < out_sz)
out[(*pos)++] = '"';
for (const char *p = a; *p && *pos + 2 < out_sz; p++) {
if (*p == '"')
out[(*pos)++] = '\\';
out[(*pos)++] = *p;
}
if (need_q && *pos + 1 < out_sz)
out[(*pos)++] = '"';
out[*pos] = 0;
}
int os_proc_spawn(const char *cwd, const char *const argv[],
const char *const env_add[], const char *stderr_path,
os_proc_t *p) {
memset(p, 0, sizeof(*p));
if (!argv || !argv[0])
return -1;
char cmdline[16384];
size_t pos = 0;
cmdline[0] = 0;
for (int i = 0; argv[i]; i++) {
if (i && pos + 1 < sizeof(cmdline))
cmdline[pos++] = ' ';
win_quote_arg(cmdline, sizeof(cmdline), &pos, argv[i]);
}
for (int i = 0; env_add && env_add[i]; i++)
_putenv(env_add[i]); /* inherited by the child */
SECURITY_ATTRIBUTES sa = {0};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE in_r = NULL, in_w = NULL, out_r = NULL, out_w = NULL;
if (!CreatePipe(&in_r, &in_w, &sa, 0))
return -1;
if (!CreatePipe(&out_r, &out_w, &sa, 0)) {
CloseHandle(in_r);
CloseHandle(in_w);
return -1;
}
SetHandleInformation(in_w, HANDLE_FLAG_INHERIT, 0);
SetHandleInformation(out_r, HANDLE_FLAG_INHERIT, 0);
HANDLE err = CreateFileA(stderr_path ? stderr_path : "NUL", GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, &sa,
CREATE_ALWAYS, 0, NULL);
STARTUPINFOA si = {0};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES;
si.hStdInput = in_r;
si.hStdOutput = out_w;
si.hStdError = err != INVALID_HANDLE_VALUE ? err : out_w;
PROCESS_INFORMATION pi = {0};
BOOL ok = CreateProcessA(NULL, cmdline, NULL, NULL, TRUE, CREATE_NO_WINDOW,
NULL, cwd && cwd[0] ? cwd : NULL, &si, &pi);
CloseHandle(in_r);
CloseHandle(out_w);
if (err != INVALID_HANDLE_VALUE)
CloseHandle(err);
if (!ok) {
CloseHandle(in_w);
CloseHandle(out_r);
return -1;
}
CloseHandle(pi.hThread);
p->proc = pi.hProcess;
p->in_w = in_w;
p->out_r = out_r;
return 0;
}
int os_proc_write(os_proc_t *p, const char *data, size_t n) {
while (n > 0) {
DWORD w = 0;
if (!p->in_w || !WriteFile((HANDLE)p->in_w, data, (DWORD)n, &w, NULL) ||
w == 0)
return -1;
data += w;
n -= w;
}
return 0;
}
long os_proc_read(os_proc_t *p, char *buf, size_t n, int timeout_ms) {
if (!p->out_r)
return -1;
double deadline = os_now_ms() + timeout_ms;
for (;;) {
DWORD avail = 0;
if (!PeekNamedPipe((HANDLE)p->out_r, NULL, 0, NULL, &avail, NULL))
return GetLastError() == ERROR_BROKEN_PIPE ? 0 : -1;
if (avail > 0)
break;
if (os_now_ms() >= deadline)
return -2;
Sleep(10);
}
DWORD r = 0;
if (!ReadFile((HANDLE)p->out_r, buf, (DWORD)n, &r, NULL))
return GetLastError() == ERROR_BROKEN_PIPE ? 0 : -1;
return (long)r;
}
void os_proc_close_stdin(os_proc_t *p) {
if (p->in_w) {
CloseHandle((HANDLE)p->in_w);
p->in_w = NULL;
}
}
void os_proc_kill(os_proc_t *p) {
os_proc_close_stdin(p);
if (p->proc) {
if (WaitForSingleObject((HANDLE)p->proc, 500) != WAIT_OBJECT_0)
TerminateProcess((HANDLE)p->proc, 1);
WaitForSingleObject((HANDLE)p->proc, 2000);
CloseHandle((HANDLE)p->proc);
p->proc = NULL;
}
if (p->out_r) {
CloseHandle((HANDLE)p->out_r);
p->out_r = NULL;
}
}
#else
#ifndef _WIN32
int os_proc_spawn(const char *cwd, const char *const argv[],
const char *const env_add[], const char *stderr_path,
os_proc_t *p) {