diff --git a/CMakeLists.txt b/CMakeLists.txt index abdb70e..b3834a7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,7 +256,7 @@ foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip test_undo test_config test_models test_sandbox test_keymap test_shell_compact test_skills test_plugins test_mcp test_subagent test_lowil - test_win_utf8 test_os_dir) + test_win_utf8 test_os_dir test_tmpdir) add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES}) target_include_directories(${t} PRIVATE include "${CJSON_DIR}") target_compile_definitions(${t} PRIVATE _GNU_SOURCE) diff --git a/Makefile b/Makefile index 43bd510..8da5385 100644 --- a/Makefile +++ b/Makefile @@ -114,7 +114,8 @@ TESTS = \ tests/test_subagent \ tests/test_lowil \ tests/test_win_utf8 \ - tests/test_os_dir + tests/test_os_dir \ + tests/test_tmpdir TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS) diff --git a/README.md b/README.md index b766e4a..d004e79 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录 ``` 环境:`PREFIX` 自动用于找 `sh`;`CST_EXE_DIR` / `argv[0]` 用于定位旁路 busybox(`/proc/self/exe` 受限时)。 +临时目录:依次试 `$TMPDIR`、`$PREFIX/tmp`、`/tmp`、`/var/tmp`、`~/.cache/cst-agent/tmp`,用第一个真能建文件的, +并写回 `TMPDIR` 给 shell 子进程和沙箱用(Termux 的 `/tmp` 常常不存在或被 SELinux 拒绝)。 ### Windows 发行包 diff --git a/include/cst_version.h b/include/cst_version.h index ec6ac5e..09ba168 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 6 -#define CST_VERSION_PATCH 0 +#define CST_VERSION_PATCH 1 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/include/os_compat.h b/include/os_compat.h index c70b351..e0add6d 100644 --- a/include/os_compat.h +++ b/include/os_compat.h @@ -49,6 +49,9 @@ int os_mkdir(const char *path, int mode); int os_mkdir_p(const char *path, int mode); int os_config_dir(char *out, size_t out_sz); int os_temp_dir(char *out, size_t out_sz); +/* Point TMPDIR at os_temp_dir() so child processes use the same writable + * directory. Call once at startup, before any threads. */ +void os_temp_dir_export(void); /* Create unique temp file; writes path into out; returns fd or -1. Caller closes. */ int os_mkstemp(char *out, size_t out_sz, const char *prefix); diff --git a/src/main.c b/src/main.c index 5c59578..39c15d3 100644 --- a/src/main.c +++ b/src/main.c @@ -192,6 +192,7 @@ int main(int argc, char **argv) { fprintf(stderr, "warning: the process code page is not UTF-8 (Windows 10 " "1903 or later is required); non-ASCII text may break\n"); os_set_prog_path(argv[0]); + os_temp_dir_export(); curl_global_init(CURL_GLOBAL_DEFAULT); cst_agent_config_t cfg = {0}; diff --git a/src/os_compat.c b/src/os_compat.c index b1e944e..e2a0720 100644 --- a/src/os_compat.c +++ b/src/os_compat.c @@ -255,6 +255,66 @@ int os_config_dir(char *out, size_t out_sz) { return 0; } +#ifndef _WIN32 +/* $TMPDIR is not always usable: Termux has no /tmp unless TMPDIR points at + * $PREFIX/tmp, and Android may deny /tmp or /data/local/tmp outright. Take + * the first candidate where a file can actually be created. */ +static char g_tmp[OS_PATH_MAX]; +static pthread_once_t g_tmp_once = PTHREAD_ONCE_INIT; + +static int tmp_writable(const char *dir) { + char probe[OS_PATH_MAX]; + struct stat st; + if (!dir || dir[0] != '/' || stat(dir, &st) != 0 || !S_ISDIR(st.st_mode)) + return 0; + int n = snprintf(probe, sizeof(probe), "%s/.cst-probe-XXXXXX", dir); + if (n < 0 || (size_t)n >= sizeof(probe)) + return 0; + int fd = mkstemp(probe); + if (fd < 0) + return 0; + close(fd); + unlink(probe); + return 1; +} + +static void tmp_trim(char *s) { + size_t n = strlen(s); + while (n > 1 && s[n - 1] == '/') + s[--n] = 0; +} + +static void tmp_pick(void) { + char cand[5][OS_PATH_MAX]; + int nc = 0; + const char *env = getenv("TMPDIR"); + const char *prefix = getenv("PREFIX"); + const char *home = getenv("HOME"); + const char *cache = getenv("XDG_CACHE_HOME"); + if (env && env[0]) + snprintf(cand[nc++], OS_PATH_MAX, "%s", env); + if (prefix && prefix[0] == '/') + snprintf(cand[nc++], OS_PATH_MAX, "%s/tmp", prefix); + snprintf(cand[nc++], OS_PATH_MAX, "/tmp"); + snprintf(cand[nc++], OS_PATH_MAX, "/var/tmp"); + if (cache && cache[0] == '/') + snprintf(cand[nc++], OS_PATH_MAX, "%s/cst-agent/tmp", cache); + else if (home && home[0] == '/') + snprintf(cand[nc++], OS_PATH_MAX, "%s/.cache/cst-agent/tmp", home); + for (int i = 0; i < nc; i++) { + tmp_trim(cand[i]); + if (i == nc - 1) + os_mkdir_p(cand[i], 0700); + if (tmp_writable(cand[i])) { + memcpy(g_tmp, cand[i], strlen(cand[i]) + 1); + return; + } + } + /* nothing works: keep the old answer so callers fail as before */ + memcpy(g_tmp, cand[0], strlen(cand[0]) + 1); +} +#endif + int os_temp_dir(char *out, size_t out_sz) { if (!out || out_sz < 2) return -1; @@ -268,11 +328,18 @@ int os_temp_dir(char *out, size_t out_sz) { out[--L] = 0; return 0; #else + pthread_once(&g_tmp_once, tmp_pick); + int n = snprintf(out, out_sz, "%s", g_tmp); + return n < 0 || (size_t)n >= out_sz ? -1 : 0; +#endif +} + +void os_temp_dir_export(void) { +#ifndef _WIN32 + char d[OS_PATH_MAX]; const char *t = getenv("TMPDIR"); - if (!t || !t[0]) - t = "/tmp"; - snprintf(out, out_sz, "%s", t); - return 0; + if (os_temp_dir(d, sizeof(d)) == 0 && (!t || strcmp(t, d) != 0)) + setenv("TMPDIR", d, 1); #endif } diff --git a/tests/test_tmpdir.c b/tests/test_tmpdir.c new file mode 100644 index 0000000..9b348ad --- /dev/null +++ b/tests/test_tmpdir.c @@ -0,0 +1,116 @@ +/* os_temp_dir() falls back when $TMPDIR is missing or read-only (Termux, + * Android) and os_temp_dir_export() hands the choice to child processes. + * Each case runs in a fresh fork because the choice is made once. */ +#ifndef _GNU_SOURCE +#define _GNU_SOURCE +#endif + +#include "os_compat.h" + +#include +#include +#include + +#ifndef _WIN32 +#include +#include +#include + +static int nfail; + +#define EXPECT(cond, msg) \ + do { \ + if (!(cond)) { \ + fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \ + nfail++; \ + } \ + } while (0) + +/* Runs in a child: set the environment, then compare os_temp_dir(). */ +static int pick_is(const char *tmpdir, const char *prefix, const char *want) { + pid_t pid = fork(); + if (pid < 0) + return 0; + if (pid == 0) { + if (tmpdir) + setenv("TMPDIR", tmpdir, 1); + else + unsetenv("TMPDIR"); + if (prefix) + setenv("PREFIX", prefix, 1); + else + unsetenv("PREFIX"); + char got[4096]; + if (os_temp_dir(got, sizeof(got)) != 0 || strcmp(got, want) != 0) { + fprintf(stderr, " TMPDIR=%s PREFIX=%s: got %s, want %s\n", + tmpdir ? tmpdir : "(unset)", prefix ? prefix : "(unset)", got, + want); + _exit(1); + } + os_temp_dir_export(); + const char *t = getenv("TMPDIR"); + if (!t || strcmp(t, want) != 0) + _exit(2); + char f[4096]; + int fd = os_mkstemp(f, sizeof(f), "cst-t"); + if (fd < 0 || strncmp(f, want, strlen(want)) != 0) + _exit(3); + close(fd); + unlink(f); + _exit(0); + } + int st = 0; + waitpid(pid, &st, 0); + return WIFEXITED(st) && WEXITSTATUS(st) == 0; +} + +int main(void) { + const char *base = getenv("TMPDIR"); + char root[4096]; + snprintf(root, sizeof(root), "%s/cst-tmpdir-XXXXXX", + base && base[0] ? base : "/tmp"); + if (!mkdtemp(root)) { + puts("test_tmpdir: skipped (no writable temp dir to set up in)"); + return 0; + } + char ok[4200], prefix[4200], ptmp[4300], ro[4200], slash[4300]; + snprintf(ok, sizeof(ok), "%s/ok", root); + snprintf(prefix, sizeof(prefix), "%s/usr", root); + snprintf(ptmp, sizeof(ptmp), "%s/tmp", prefix); + snprintf(ro, sizeof(ro), "%s/ro", root); + snprintf(slash, sizeof(slash), "%s//", ok); + mkdir(ok, 0700); + mkdir(prefix, 0700); + mkdir(ptmp, 0700); + mkdir(ro, 0500); + + EXPECT(pick_is(ok, NULL, ok), "usable TMPDIR wins"); + EXPECT(pick_is(slash, NULL, ok), "trailing slashes trimmed"); + EXPECT(pick_is("/nonexistent/cst", prefix, ptmp), + "missing TMPDIR falls back to $PREFIX/tmp"); + EXPECT(pick_is(NULL, prefix, ptmp), "unset TMPDIR uses $PREFIX/tmp"); + if (geteuid() != 0) { + EXPECT(pick_is(ro, prefix, ptmp), "read-only TMPDIR is skipped"); + char tmp_ok[64] = "/tmp"; + if (access("/tmp", W_OK) == 0) + EXPECT(pick_is(ro, NULL, tmp_ok), "read-only TMPDIR falls to /tmp"); + } + + rmdir(ro); + rmdir(ptmp); + rmdir(prefix); + rmdir(ok); + rmdir(root); + if (nfail) { + fprintf(stderr, "test_tmpdir: %d failure(s)\n", nfail); + return 1; + } + puts("test_tmpdir: OK"); + return 0; +} +#else +int main(void) { + puts("test_tmpdir: skipped on Windows"); + return 0; +} +#endif