20 Commits
Author SHA1 Message Date
WHC 32b4afd772 v0.7.1: allow network inside the run_shell sandbox
Landlock no longer handles TCP, the Seatbelt profile drops (deny network*),
and the seccomp filter stops refusing IPv4/IPv6 sockets. Sandboxed commands
run without asking in auto mode on every backend (Low-IL no longer asks for
network commands), and connection errors are not treated as sandbox blocks.
2026-10-08 21:05:14 +08:00
WHC 895b47274c v0.7.0: image input, model registry, prefix-cache savings
Images
- vision gate: text-only models get a note instead of image parts and no
  view_image tool; unknown models retry once without images on HTTP 400
- paste: Ctrl+V and /attach paste read the clipboard (wl-paste, xclip,
  pngpaste/osascript, Win32 PNG or CF_DIB converted to PNG); pasting image
  file paths attaches them
- MCP image content is stored in the session and sent with the tool result
- attach: rel path validation, base64 decode, /retry keeps images

Model registry
- built-in src/models_builtin.json embedded at build time, merged with
  <config>/models.json and cached GET /models metadata (context, vision,
  pricing); most specific rule wins per field
- /model labels, /status source, --list-models [provider]

Cache and cost
- stable request prefix (frozen tools, notes as messages), DeepSeek prefix
  completion, OpenAI/Anthropic cache hints, /cost and hit rate in the status
2026-10-08 20:34:09 +08:00
WHC 0226f417ff providers: add teamorouter; pick the first /models id when no default model 2026-10-08 14:30:00 +08:00
WHC 1772c26d43 ci: auto-publish Windows zip to Gitea Releases
After a successful windows-cross build on main or v* tags, upload the zip
and .sha256 via scripts/release_windows.sh (creates/updates v$VERSION).
Prefer secrets.RELEASE_TOKEN; fall back to GITHUB_TOKEN. Skip on PRs.
2026-10-07 21:43:41 +08:00
WHC 630e8f4295 ci: Windows zip only builds cst-agent; link util for replay tests
os_win.c needs cst_buf_* / cst_utf8_valid from util.c after os_compat was
added to the small replay CMake targets. Release packaging only needs
cst-agent.exe, so bootstrap_windows.sh builds that target alone.
2026-10-07 21:09:44 +08:00
WHC 94e8746091 ci: multi-mirror GitHub clone for mingw third_party
windows-cross died cloning busybox-w32 through a global gitclone.com
insteadOf (HTTP 502). Try ghfast/ghproxy/gitclone/github per repo instead
of forcing one proxy for every fetch.
2026-10-07 11:43:12 +08:00
WHC 546a4c7eeb ci: link os_compat for replay CMake tests; fix llvm-mingw mirrors
gcc -O0 keeps unused cst_test_util.h helpers that need os_*; clang GC'd
them so only the gcc CMake step failed. Prefer ghfast/ghproxy.net for the
llvm-mingw tarball (gitclone 500 / github unreachable on the runner).
2026-10-07 02:57:38 +08:00
WHC 9b2c7acfae ci: fix test_subagent strcasestr crash; llvm-mingw Windows zip
clang under strict C11 left strcasestr undeclared (returns int → pointer
truncation → SIGSEGV). Parse Content-Length portably; keep -Werror on
make check. Drop Nix for windows-cross (root /nix fails) and use pinned
llvm-mingw UCRT instead; Alpine musl gets linux-headers for audit.h.
2026-10-07 02:39:36 +08:00
WHC 420f9b242c ci: drop sanitizers job; harden test_subagent fake server
test_subagent segfaulted in the Docker runner: the fake SSE handler took
user + strlen(user) - 1 with an empty user string. Guard JSON access and
format the child report with an empty-safe last character. Remove the
sanitizers job entirely (if: false still queued it and blocked the runner).
2026-10-07 02:27:00 +08:00
WHC e5ea869681 ci: install unzip for windows-cross zip listing 2026-10-07 02:21:28 +08:00
WHC d6ae1a67c4 ci: drop macOS, skip ASan, checkout without GitHub, MinGW zip
- Replace actions/checkout (pulls from github.com) with scripts/ci_checkout.sh
  fetched from this Gitea over raw/commit.
- Remove the macOS job (no runner).
- Disable sanitizers until the Docker runner has SYS_PTRACE; it was wedging
  the only slot with AddressSanitizer:DEADLYSIGNAL.
- Add windows-cross: Nix UCRT mingw via TUNA, bootstrap_windows.sh, verify
  dist/cst-agent-*-windows-x86_64.zip. Third-party git clones go through
  gitclone.com.
2026-10-07 02:21:18 +08:00
WHC 41c7e04940 ci: cancel stuck runs; cap ASan wall time; silence path snprintf warning
The sanitizers job wedged the only runner by printing AddressSanitizer
DEADLYSIGNAL forever (Docker without SYS_PTRACE). Concurrency now cancels
in-progress jobs on a new push, and make asan is wrapped in timeout -s KILL
480 with shallow ASAN_OPTIONS.

Also copy the path with memcpy after the length check so -O1 asan builds
do not warn about snprintf truncation into OS_PATH_MAX.
2026-10-07 01:55:51 +08:00
WHC 77de812207 sashidown: keep -Iinclude when parent make overrides CFLAGS
CI passes CFLAGS=-Werror on the command line; that wiped the library's
CFLAGS ?= ... -Iinclude -Isrc, so fsm.h could not find sashidown.h.
Always append the include paths via override CPPFLAGS.
2026-10-07 01:52:42 +08:00
WHC 9ec21b7fc0 ci: use Tsinghua apt/apk mirrors on the runner
Install was pulling bookworm packages from deb.debian.org; the domestic
Gitea runner crawls that for minutes. Rewrite sources to TUNA before
apt-get/apk, with CST_CI_MIRROR to override.
2026-10-07 01:42:56 +08:00
WHC daa92a6cfb seccomp: require child memory peek in the availability probe
With Yama ptrace_scope=2 and no CAP_SYS_PTRACE, process_vm_readv and
/proc/<pid>/mem both fail, so sc_decide cannot read path strings and every
workspace write gets EACCES. The old probe only checked the USER_NOTIF
round trip, so CST_SANDBOX=seccomp and the forced-seccomp test pass looked
available and then failed on the first write.

sc_can_peek() forks a child, places a marker on its stack, and retries the
same sc_peek path. sc_probe() now needs that too, so those hosts fall back
to none (or keep landlock) instead of a silent deny-all sandbox.
2026-10-07 01:39:08 +08:00
WHC 1476f1774a Temp dir falls back when TMPDIR or /tmp is unusable (Termux); v0.6.1
os_temp_dir() used $TMPDIR or "/tmp" without checking either. On Termux
/tmp usually does not exist (or SELinux denies it) unless TMPDIR points at
$PREFIX/tmp, so plugin and MCP temp files failed and shell children that
need a temp file (heredocs) broke.

  * os_temp_dir() picks, once, the first of $TMPDIR, $PREFIX/tmp, /tmp,
    /var/tmp and ~/.cache/cst-agent/tmp where a file can really be
    created; if none works it keeps the old answer
  * os_temp_dir_export() at startup writes the choice back to TMPDIR, so
    shell children and the sandbox's writable temp dir agree
  * tests/test_tmpdir.c covers the fallbacks in fresh forks
2026-10-06 23:32:21 +08:00
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
XYZ 9862c29f58 Windows: point libcurl at a CA bundle
An OpenSSL-based libcurl keeps the CA path it was built with. MSYS2's is
"/mingw64/etc/ssl/certs/ca-bundle.crt", which resolves to nothing once the
binary runs outside an MSYS2 shell, and that build ignores CURL_CA_BUNDLE,
SSL_CERT_FILE and SSL_CERT_DIR. Every HTTPS request then failed with
CURLE_SSL_CACERT_BADFILE (77).

llm_apply_ca_bundle() sets CURLOPT_CAINFO from $CST_CA_BUNDLE, falling back
to ca-bundle.crt beside the executable. If neither exists nothing is set,
so a libcurl linked against Schannel (the Windows certificate store)
behaves exactly as before.
2026-10-06 17:56:56 +08:00
XYZ 929f7125b8 Windows: encode and decode text as UTF-8 instead of trusting the locale
setlocale(LC_ALL, "") maps to the ANSI code page on Windows -- 936 (GBK)
on a Chinese system -- so wcrtomb() produces GBK where the protocol wants
UTF-8, and mbrtowc() reads UTF-8 as GBK. Typed Chinese therefore reached
the model as mojibake, and once the buffer held correct UTF-8 the input
line rendered as mojibake instead. Windows' C runtime has no ".UTF-8"
locale to switch to, so the conversion has to be explicit.

os_compat.[ch]
  * add os_utf8_encode() and os_utf8_decode(), locale-independent, with
    range and overlong-form checks
src/tui.c
  * input_put_wc(), the command-palette filter and the ask_user buffer use
    os_utf8_encode() instead of wcrtomb()
src/textsel.c
  * cst_utf8_decode() uses os_utf8_decode() instead of mbrtowc()
2026-10-06 17:56:55 +08:00
XYZ 3db33eddac Windows: make the CMake build work with MSYS2's native toolchain 2026-10-06 16:13:45 +08:00
107 changed files with 16522 additions and 1606 deletions
+2
View File
@@ -0,0 +1,2 @@
*.cmd text eol=crlf
*.bat text eol=crlf
+89 -29
View File
@@ -2,20 +2,36 @@ name: ci
on: on:
push: push:
branches: [main]
tags: ['v*']
pull_request: pull_request:
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
linux: linux:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
cc: [gcc, clang] cc: [gcc, clang]
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install dependencies - name: Install dependencies
run: | run: |
SUDO=$(command -v sudo || true) SUDO=$(command -v sudo || true)
$SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update $SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \ $SUDO apt-get install -y --no-install-recommends \
build-essential clang cmake pkg-config python3 \ build-essential clang cmake pkg-config python3 \
@@ -23,7 +39,11 @@ jobs:
- name: Build (warnings are errors) - name: Build (warnings are errors)
run: make CC=${{ matrix.cc }} CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" -j"$(nproc)" run: make CC=${{ matrix.cc }} CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" -j"$(nproc)"
- name: Unit tests - name: Unit tests
run: make CC=${{ matrix.cc }} check run: |
# Same -Werror as the build step so undeclared libc helpers cannot
# silently return int and truncate pointers (see test_subagent).
make CC=${{ matrix.cc }} \
CFLAGS="-std=c11 -Wall -Wextra -O2 -g -Werror" check
- name: CMake build + ctest - name: CMake build + ctest
run: | run: |
cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCST_BUILD_TUI=ON cmake -B build -DCMAKE_C_COMPILER=${{ matrix.cc }} -DCST_BUILD_TUI=ON
@@ -35,41 +55,81 @@ jobs:
./cst-agent --workspace "$PWD" --list-sessions ./cst-agent --workspace "$PWD" --list-sessions
./cst-agent --demo-ctl --workspace . ./cst-agent --demo-ctl --workspace .
sanitizers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
SUDO=$(command -v sudo || true)
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
build-essential pkg-config libcurl4-openssl-dev libncurses-dev
- name: ASan + UBSan
run: make asan
musl: musl:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30
container: alpine:3.20 container: alpine:3.20
steps: steps:
- name: Install dependencies - name: Install tools + mirror
run: apk add --no-cache build-base pkgconf curl-dev ncurses-dev git nodejs bash python3 run: |
- uses: actions/checkout@v4 sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' \
/etc/apk/repositories
apk add --no-cache curl git bash
- name: Checkout
env:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: |
curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
-o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install build dependencies
run: |
# linux-headers provides linux/audit.h for the seccomp probe path
apk add --no-cache build-base pkgconf curl-dev ncurses-dev \
linux-headers git nodejs bash python3
- name: Build + unit tests - name: Build + unit tests
run: | run: |
make CFLAGS="-std=c11 -Wall -Wextra -O2 -Werror" -j"$(nproc)" make CFLAGS="-std=c11 -Wall -Wextra -O2 -Werror" -j"$(nproc)"
make check make check
# Gitea instances rarely have macOS runners; set the repository variable # MinGW-w64 UCRT cross build → dist/cst-agent-*-windows-x86_64.zip
# HAVE_MACOS_RUNNER=true once one is registered. windows-cross:
macos: runs-on: ubuntu-latest
if: ${{ vars.HAVE_MACOS_RUNNER == 'true' }} timeout-minutes: 120
runs-on: macos-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
- name: Install dependencies env:
run: brew install ncurses curl pkg-config GITHUB_SERVER_URL: ${{ github.server_url }}
- name: Build + unit tests GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_SHA: ${{ github.sha }}
run: | run: |
make -j"$(sysctl -n hw.ncpu)" curl -fsSL "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/raw/commit/$GITHUB_SHA/scripts/ci_checkout.sh" \
make check -o /tmp/ci_checkout.sh
bash /tmp/ci_checkout.sh
- name: Install host tools
run: |
SUDO=$(command -v sudo || true)
$SUDO bash scripts/ci_pkg_mirror.sh
$SUDO apt-get update
$SUDO apt-get install -y --no-install-recommends \
ca-certificates curl git cmake ninja-build make pkg-config \
python3 zip unzip perl xz-utils
- name: Cross-build Windows zip
run: bash scripts/ci_windows_cross.sh
- name: Verify zip
run: |
ls -lah dist/windows-x86_64/
ls -lah dist/cst-agent-*-windows-x86_64.zip
cat dist/cst-agent-*-windows-x86_64.zip.sha256
unzip -l dist/cst-agent-*-windows-x86_64.zip | head -20
# Auto-publish to Gitea Releases (v$CST_VERSION from include/cst_version.h).
# Prefer repo secret RELEASE_TOKEN (write:repository); fall back to the
# job token. Skipped on pull_request and when no token is available.
- name: Publish Gitea Release
if: github.event_name == 'push'
env:
RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
CST_GITEA: ${{ github.server_url }}
CST_REPO: ${{ github.repository }}
GITHUB_REF: ${{ github.ref }}
CI: "true"
run: |
export GITEA_TOKEN="${RELEASE_TOKEN:-${GITHUB_TOKEN:-}}"
if [[ -z "${GITEA_TOKEN}" ]]; then
echo "::warning::no RELEASE_TOKEN / GITHUB_TOKEN; skip auto-release"
exit 0
fi
bash scripts/release_windows.sh
+3
View File
@@ -21,6 +21,8 @@ third_party/busybox/busybox
third_party/busybox/busybox.exe third_party/busybox/busybox.exe
third_party/curl/ third_party/curl/
third_party/PDCurses/ third_party/PDCurses/
third_party/ncurses-*/
third_party/ncurses-*.tar.gz
third_party/busybox-w32/ third_party/busybox-w32/
third_party/busybox-src/ third_party/busybox-src/
third_party/prefix-mingw/ third_party/prefix-mingw/
@@ -28,3 +30,4 @@ vendor/sashidown/*.o
vendor/sashidown/src/*.o vendor/sashidown/src/*.o
vendor/sashidown/libsashidown.a vendor/sashidown/libsashidown.a
vendor/sashidown/libsashidown-mingw.a vendor/sashidown/libsashidown-mingw.a
.gen/models_builtin.inc
+111 -18
View File
@@ -8,6 +8,32 @@ option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF) option(CST_NATIVE_ARCH "Pass -march=native to sashidown" OFF)
option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON) option(CST_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
if(WIN32)
enable_language(RC)
# Cross builds link libgcc/winpthreads/curl statically; MSYS2's curl is a DLL.
if(CMAKE_CROSSCOMPILING)
set(_CST_STATIC_DEFAULT ON)
else()
set(_CST_STATIC_DEFAULT OFF)
endif()
option(CST_WIN_STATIC "Link Windows executables with -static" ${_CST_STATIC_DEFAULT})
endif()
# Windows: UTF-8 manifest (activeCodePage, longPathAware) and version resource
# on every executable, so tests run with the same process code page as the app.
function(cst_win_exe t)
if(NOT WIN32)
return()
endif()
target_sources(${t} PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}/res/windows/cst-agent.rc")
target_include_directories(${t} PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/include" "${CMAKE_CURRENT_SOURCE_DIR}/res/windows")
target_compile_definitions(${t} PRIVATE _WIN32_WINNT=0x0A00)
if(CST_WIN_STATIC)
target_link_options(${t} PRIVATE -static)
endif()
endfunction()
set(SD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sashidown") set(SD_DIR "${CMAKE_CURRENT_SOURCE_DIR}/vendor/sashidown")
if(CST_NATIVE_ARCH) if(CST_NATIVE_ARCH)
set(_SD_MAKE_EXTRA CST_NATIVE_ARCH=1) set(_SD_MAKE_EXTRA CST_NATIVE_ARCH=1)
@@ -15,25 +41,33 @@ else()
set(_SD_MAKE_EXTRA "") set(_SD_MAKE_EXTRA "")
endif() endif()
# sashidown has a plain Makefile; CMAKE_MAKE_PROGRAM may be ninja.
find_program(CST_MAKE NAMES make gmake mingw32-make REQUIRED)
# Separate archive for MinGW so Linux host builds keep libsashidown.a # Separate archive for MinGW so Linux host builds keep libsashidown.a
if(CMAKE_SYSTEM_NAME STREQUAL "Windows") if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a") set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a")
set(_MINGW_AR x86_64-w64-mingw32-ar) # Use whatever archiver CMake found for the active toolchain. Hardcoding
# "x86_64-w64-mingw32-ar" only works on cross-build hosts, where binutils
# installs prefixed names; MSYS2's mingw-w64 toolchain ships plain ar.exe
# and ranlib.exe, so the hardcoded names are not found.
set(_MINGW_AR "${CMAKE_AR}")
set(_MINGW_RANLIB "${CMAKE_RANLIB}")
add_custom_command( add_custom_command(
OUTPUT "${SASHIDOWN_A}" OUTPUT "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a
CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=x86_64-w64-mingw32-ranlib CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=${_MINGW_RANLIB}
COMMAND ${CMAKE_COMMAND} -E copy_if_different COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}" "${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean COMMAND ${CST_MAKE} -C "${SD_DIR}" clean
COMMENT "Building libsashidown for MinGW" COMMENT "Building libsashidown for MinGW"
VERBATIM) VERBATIM)
else() else()
set(SASHIDOWN_A "${SD_DIR}/libsashidown.a") set(SASHIDOWN_A "${SD_DIR}/libsashidown.a")
add_custom_command( add_custom_command(
OUTPUT "${SASHIDOWN_A}" OUTPUT "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA} COMMAND ${CST_MAKE} -C "${SD_DIR}" libsashidown.a CC=${CMAKE_C_COMPILER} ${_SD_MAKE_EXTRA}
COMMENT "Building libsashidown.a" COMMENT "Building libsashidown.a"
VERBATIM) VERBATIM)
endif() endif()
@@ -49,6 +83,7 @@ set(CST_CORE_SOURCES
src/util.c src/util.c
src/ctl_parse.c src/ctl_parse.c
src/os_compat.c src/os_compat.c
src/os_win.c
src/sandbox.c src/sandbox.c
src/sandbox_exec.c src/sandbox_exec.c
src/keymap.c src/keymap.c
@@ -57,6 +92,8 @@ set(CST_CORE_SOURCES
src/plugins.c src/plugins.c
src/mcp.c src/mcp.c
src/subagent.c src/subagent.c
src/attach.c
src/clipboard.c
src/session.c src/session.c
src/tools/tools.c src/tools/tools.c
src/tools/patch.c src/tools/patch.c
@@ -75,8 +112,26 @@ set(CST_CORE_SOURCES
src/creds.c src/creds.c
src/mm_tool.c src/mm_tool.c
src/models.c src/models.c
src/model_registry.c
src/metrics.c src/metrics.c
) )
# Built-in model data as a C byte list (included by model_registry.c).
set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX)
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}")
file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
include_directories("${CST_GEN_DIR}")
if(WIN32)
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
include_directories(vendor/tre-regex)
list(APPEND CST_CORE_SOURCES
vendor/tre-regex/regcomp.c vendor/tre-regex/regexec.c
vendor/tre-regex/regerror.c vendor/tre-regex/tre-mem.c)
endif()
set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c) set(CST_SOURCES ${CST_CORE_SOURCES} src/main.c)
if(CST_BUILD_TUI) if(CST_BUILD_TUI)
@@ -87,6 +142,7 @@ endif()
add_executable(cst-agent ${CST_SOURCES}) add_executable(cst-agent ${CST_SOURCES})
add_dependencies(cst-agent sashidown) add_dependencies(cst-agent sashidown)
cst_win_exe(cst-agent)
target_include_directories(cst-agent PRIVATE target_include_directories(cst-agent PRIVATE
include include
@@ -108,12 +164,31 @@ if(NOT WIN32)
target_link_libraries(cst-agent PRIVATE m) target_link_libraries(cst-agent PRIVATE m)
endif() endif()
if(CST_BUILD_TUI) set(CST_WIN_CURSES "ncurses" CACHE STRING "Windows TUI backend: ncurses or pdcurses")
set_property(CACHE CST_WIN_CURSES PROPERTY STRINGS ncurses pdcurses)
if(CST_BUILD_TUI AND WIN32 AND CST_WIN_CURSES STREQUAL "ncurses")
# ncursesw built by scripts/build_third_party_mingw.sh ncurses (or MSYS2's
# mingw-w64-ucrt-x86_64-ncurses); headers live in include/ncursesw.
find_path(NCURSESW_INCLUDE_DIR ncurses.h PATH_SUFFIXES ncursesw include/ncursesw)
find_library(NCURSESW_LIBRARY NAMES ncursesw)
if(NOT NCURSESW_INCLUDE_DIR OR NOT NCURSESW_LIBRARY)
message(FATAL_ERROR "CST_WIN_CURSES=ncurses needs ncursesw "
"(scripts/build_third_party_mingw.sh ncurses), or -DCST_WIN_CURSES=pdcurses")
endif()
# ncurses.h includes <ncursesw/ncurses_dll.h>, so the parent is needed too
get_filename_component(NCURSESW_PARENT_DIR ${NCURSESW_INCLUDE_DIR} DIRECTORY)
target_include_directories(cst-agent PRIVATE ${NCURSESW_INCLUDE_DIR} ${NCURSESW_PARENT_DIR})
target_link_libraries(cst-agent PRIVATE ${NCURSESW_LIBRARY})
target_compile_definitions(cst-agent PRIVATE CST_USE_NCURSES=1 NCURSES_STATIC=1 NCURSES_WIDECHAR=1)
message(STATUS "TUI: ncursesw (${NCURSESW_LIBRARY})")
elseif(CST_BUILD_TUI)
if(WIN32) if(WIN32)
# Prefer PDCurses (pdcurses / curses). Users may set PDCurses_ROOT. # PDCurses (pdcurses / curses). Users may set PDCurses_ROOT.
# PATH_SUFFIXES also covers the layout MSYS2's mingw-w64-pdcurses uses,
# which puts the headers under include/pdcurses/ rather than include/.
find_path(PDCURSES_INCLUDE_DIR curses.h find_path(PDCURSES_INCLUDE_DIR curses.h
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
PATH_SUFFIXES include) PATH_SUFFIXES include pdcurses include/pdcurses)
find_library(PDCURSES_LIBRARY find_library(PDCURSES_LIBRARY
NAMES pdcurses pdcurses.dll curses NAMES pdcurses pdcurses.dll curses
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
@@ -121,7 +196,11 @@ if(CST_BUILD_TUI)
if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY) if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY)
target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR}) target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR})
target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY}) target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY})
target_compile_definitions(cst-agent PRIVATE CST_USE_PDCURSES=1) # PDCurses suffixes its exported symbols with an ABI tag (endwin_u64_4400
# etc.). MSYS2 builds the UTF-8 wide variant, so PDC_FORCE_UTF8 has to be
# defined or curses.h maps to the w64 names and the link fails.
target_compile_definitions(cst-agent PRIVATE
CST_USE_PDCURSES=1 PDC_FORCE_UTF8=1)
message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})") message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})")
else() else()
message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)") message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)")
@@ -143,9 +222,9 @@ else()
endif() endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32) if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32)
# curl Schannel + networking # curl Schannel + networking; winmm is needed by PDCurses (PlaySoundW)
target_link_libraries(cst-agent PRIVATE target_link_libraries(cst-agent PRIVATE
ws2_32 crypt32 secur32 bcrypt advapi32) ws2_32 crypt32 secur32 bcrypt advapi32 user32 gdi32 winmm)
endif() endif()
# --- tests --- # --- tests ---
@@ -155,7 +234,11 @@ set(_CST_SMALL_TESTS test_ctl_frontier test_ctl_heredoc test_json_baseline
test_markdown) test_markdown)
foreach(t ${_CST_SMALL_TESTS}) foreach(t ${_CST_SMALL_TESTS})
if(t STREQUAL test_textsel) if(t STREQUAL test_textsel)
add_executable(${t} tests/${t}.c src/textsel.c) add_executable(${t} tests/${t}.c src/textsel.c src/os_compat.c src/os_win.c
src/util.c)
if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32)
endif()
elseif(t STREQUAL test_markdown) elseif(t STREQUAL test_markdown)
add_executable(${t} tests/${t}.c) add_executable(${t} tests/${t}.c)
target_include_directories(${t} PRIVATE "${SD_DIR}/include") target_include_directories(${t} PRIVATE "${SD_DIR}/include")
@@ -166,7 +249,13 @@ foreach(t ${_CST_SMALL_TESTS})
elseif(t STREQUAL test_json_baseline) elseif(t STREQUAL test_json_baseline)
add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c) add_executable(${t} tests/${t}.c src/json_baseline.c vendor/cjson/cJSON.c)
elseif(t STREQUAL test_replay_starvation OR t STREQUAL test_replay_real) elseif(t STREQUAL test_replay_starvation OR t STREQUAL test_replay_real)
add_executable(${t} tests/${t}.c src/ctl_parse.c src/json_baseline.c vendor/cjson/cJSON.c) # cst_test_util.h static helpers reference os_*; gcc -O0 keeps unused ones.
# On Windows os_win.c also needs util.c (cst_buf_*, cst_utf8_valid).
add_executable(${t} tests/${t}.c src/ctl_parse.c src/json_baseline.c
vendor/cjson/cJSON.c src/os_compat.c src/os_win.c src/util.c)
if(WIN32)
target_link_libraries(${t} PRIVATE advapi32 user32)
endif()
else() else()
add_executable(${t} tests/test_mm_tool.c src/mm_tool.c) add_executable(${t} tests/test_mm_tool.c src/mm_tool.c)
endif() endif()
@@ -175,15 +264,18 @@ foreach(t ${_CST_SMALL_TESTS})
if(NOT WIN32) if(NOT WIN32)
target_link_libraries(${t} PRIVATE m) target_link_libraries(${t} PRIVATE m)
endif() endif()
cst_win_exe(${t})
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
endforeach() endforeach()
# Tests that link the whole core (no TUI). # Tests that link the whole core (no TUI).
foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip foreach(t test_llm_body test_prefix_stable test_sse_decode test_apply_patch
test_session_roundtrip test_attach
test_shell test_diff test_tools_fs test_registry test_shell test_diff test_tools_fs test_registry
test_undo test_config test_models test_sandbox test_undo test_config test_models test_model_registry test_sandbox
test_keymap test_shell_compact test_skills test_keymap test_shell_compact test_skills
test_plugins test_mcp test_subagent) test_plugins test_mcp test_subagent test_lowil
test_win_utf8 test_os_dir test_tmpdir)
add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES}) add_executable(${t} tests/${t}.c ${CST_CORE_SOURCES})
target_include_directories(${t} PRIVATE include "${CJSON_DIR}") target_include_directories(${t} PRIVATE include "${CJSON_DIR}")
target_compile_definitions(${t} PRIVATE _GNU_SOURCE) target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
@@ -191,8 +283,9 @@ foreach(t test_llm_body test_sse_decode test_apply_patch test_session_roundtrip
if(NOT WIN32) if(NOT WIN32)
target_link_libraries(${t} PRIVATE m) target_link_libraries(${t} PRIVATE m)
else() else()
target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32) target_link_libraries(${t} PRIVATE ws2_32 crypt32 secur32 bcrypt advapi32 user32)
endif() endif()
cst_win_exe(${t})
add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}") add_test(NAME ${t} COMMAND ${t} WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}")
endforeach() endforeach()
+22 -8
View File
@@ -46,7 +46,7 @@ endif
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS) PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS) PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
CPPFLAGS += -Iinclude -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS) CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_EXTENDED $(PKG_CFLAGS)
LDFLAGS += -L$(SD_DIR) LDFLAGS += -L$(SD_DIR)
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS)
@@ -55,11 +55,12 @@ CORE_SRC = \
src/util.c \ src/util.c \
src/ctl_parse.c \ src/ctl_parse.c \
src/os_compat.c \ src/os_compat.c \
src/os_win.c \
src/sandbox.c \ src/sandbox.c \
src/sandbox_exec.c \ src/sandbox_exec.c \
src/keymap.c \ src/keymap.c \
src/skills.c \ src/skills.c \
src/extensions.c src/plugins.c src/mcp.c src/subagent.c \ src/extensions.c src/plugins.c src/mcp.c src/subagent.c src/attach.c src/clipboard.c \
src/session.c \ src/session.c \
src/tools/tools.c \ src/tools/tools.c \
src/tools/patch.c \ src/tools/patch.c \
@@ -77,12 +78,13 @@ CORE_SRC = \
src/json_baseline.c \ src/json_baseline.c \
src/creds.c \ src/creds.c \
src/mm_tool.c \ src/mm_tool.c \
src/models.c \ src/models.c src/model_registry.c \
src/metrics.c src/metrics.c
SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c SRC = $(CORE_SRC) src/textsel.c src/tui.c src/main.c
OBJ = $(SRC:.c=.o) OBJ = $(SRC:.c=.o)
HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h HDRS = $(wildcard include/*.h) $(wildcard src/tools/*.h) $(CJSON_DIR)/cJSON.h \
.gen/models_builtin.inc
TESTS = \ TESTS = \
tests/test_ctl_frontier \ tests/test_ctl_frontier \
@@ -92,9 +94,11 @@ TESTS = \
tests/test_replay_real \ tests/test_replay_real \
tests/test_mm_tool \ tests/test_mm_tool \
tests/test_llm_body \ tests/test_llm_body \
tests/test_prefix_stable \
tests/test_sse_decode \ tests/test_sse_decode \
tests/test_apply_patch \ tests/test_apply_patch \
tests/test_session_roundtrip \ tests/test_session_roundtrip \
tests/test_attach \
tests/test_shell \ tests/test_shell \
tests/test_diff \ tests/test_diff \
tests/test_tools_fs \ tests/test_tools_fs \
@@ -102,6 +106,7 @@ TESTS = \
tests/test_undo \ tests/test_undo \
tests/test_config \ tests/test_config \
tests/test_models \ tests/test_models \
tests/test_model_registry \
tests/test_sandbox \ tests/test_sandbox \
tests/test_keymap \ tests/test_keymap \
tests/test_textsel \ tests/test_textsel \
@@ -110,7 +115,11 @@ TESTS = \
tests/test_skills \ tests/test_skills \
tests/test_plugins \ tests/test_plugins \
tests/test_mcp \ tests/test_mcp \
tests/test_subagent tests/test_subagent \
tests/test_lowil \
tests/test_win_utf8 \
tests/test_os_dir \
tests/test_tmpdir
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS) TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS)
@@ -139,6 +148,11 @@ FORCE:
cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS) $(CC) $(CFLAGS) -o $@ $(OBJ) $(LDFLAGS) $(LDLIBS)
# Built-in model data as a C byte list (included by model_registry.c).
.gen/models_builtin.inc: src/models_builtin.json
@mkdir -p .gen
od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@
%.o: %.c $(HDRS) %.o: %.c $(HDRS)
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
@@ -163,8 +177,8 @@ tests/test_replay_real: tests/test_replay_real.c src/ctl_parse.c src/json_baseli
tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h tests/test_mm_tool: tests/test_mm_tool.c src/mm_tool.c include/mm_tool.h
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_mm_tool.c src/mm_tool.c
tests/test_textsel: tests/test_textsel.c src/textsel.c include/cst_textsel.h tests/test_textsel: tests/test_textsel.c src/textsel.c src/os_compat.c src/os_win.c src/util.c include/cst_textsel.h
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_textsel.c src/textsel.c src/os_compat.c src/os_win.c src/util.c
tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ tests/test_markdown.c $(LDFLAGS) -lsashidown -lm
@@ -185,7 +199,7 @@ demo: cst-agent
./cst-agent --demo-ctl --workspace . ./cst-agent --demo-ctl --workspace .
clean: clean:
rm -f $(OBJ) cst-agent $(TESTS) rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc
install: cst-agent install: cst-agent
install -d "$(DESTDIR)$(PREFIX)/bin" install -d "$(DESTDIR)$(PREFIX)/bin"
+33 -20
View File
@@ -27,6 +27,15 @@ cst-agent --version
cd ~/my-project && cst-agent cd ~/my-project && cst-agent
``` ```
Windows 10 1903+(PowerShell,不需要管理员):
```powershell
irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_windows.ps1 | iex
# 新开终端后:cd 到项目目录运行 cst-agent;升级用 cst-agent-update
```
详见 [docs/README-windows.md](docs/README-windows.md)。
一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装; 一键脚本可以重复运行:已有仓库会 `git pull --ff-only`,代码有更新时干净重编,然后重新安装;
同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。 同时装一个 `cst-agent-update` 命令(等同重跑脚本)。NixOS 上会自动进 `nix-shell` 编译。
@@ -39,8 +48,9 @@ cd ~/my-project && cst-agent
|---|---| |---|---|
| Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) | | Linux | [`scripts/bootstrap_linux.sh`](scripts/bootstrap_linux.sh) |
| Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) | | Termux | [`scripts/bootstrap_termux.sh`](scripts/bootstrap_termux.sh) |
| Windows 安装 / 升级 | [`scripts/install_windows.ps1`](scripts/install_windows.ps1)(从 Release 安装),之后 `cst-agent-update` |
| Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` | | Windows 交叉构建(在 Linux 上) | [`scripts/bootstrap_windows.sh`](scripts/bootstrap_windows.sh) → `dist/windows-x86_64/` |
| Windows 运行 | 拷贝 dist 后 [`scripts/run_windows.bat`](scripts/run_windows.bat) | | Windows 原生构建(MSYS2 UCRT64) | [`scripts/bootstrap_msys2.sh`](scripts/bootstrap_msys2.sh) → `dist/windows-x86_64/` |
### 迷你验收工程(论文案例) ### 迷你验收工程(论文案例)
@@ -97,14 +107,11 @@ cmake --build build-clang
# Headless only(无 ncurses) # Headless only(无 ncurses)
cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd cmake -B build-hd -DCST_BUILD_TUI=OFF && cmake --build build-hd
# Windows MinGW 交叉(需安装 mingw-w64) # Windows:用一键脚本(见下方“Windows 发行包”),会先编译静态依赖
cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ ./scripts/bootstrap_windows.sh
-DCST_BUILD_TUI=OFF
cmake --build build-win
# TUI:安装 PDCurses 后 -DCST_BUILD_TUI=ON -DPDCurses_ROOT=...
``` ```
依赖:`gcc` 或 `clang`、`libcurl`、Linux TUI 需 `ncursesw`;Windows TUI 需 **PDCurses**。 依赖:`gcc` 或 `clang`、`libcurl`、TUI 需 `ncursesw`(Windows 版用打过补丁的 ncurses 6.6,脚本自动编译)。
Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。 Sashidown(`vendor/sashidown`)与 cJSON(`vendor/cjson`,MIT)已随仓库提供。
API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。 API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(权限 0600);`/key` 更换,`--ask-key` 在命令行输入。
`--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。 `--api-key` 只用于本次进程、不落盘;`DEEPSEEK_API_KEY` 等环境变量和 `CST_API_KEY` 仅作为没有保存 key 时的兜底(方便 CI)。
@@ -117,7 +124,7 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
| WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) | | WSL2 | x86_64 | 同 Linux | yes | ncursesw | `/bin/sh`(工作区建议放在 Linux 文件系统内) |
| macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` | | macOS | arm64 / x86_64 | Apple Clang | yes | ncurses(Homebrew) | `/bin/sh` |
| Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox | | Android Termux | aarch64 / … | Clang (Termux) | yes | ncurses(Termux 包) | `$PREFIX/bin/sh`;可旁路 busybox |
| Windows | x86_64 / aarch64 | MinGW GCC/Clang | yes | PDCurses | **捆绑 busybox-w32** | | Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** |
### Termux ### Termux
@@ -137,20 +144,21 @@ make && make install # 装到 Termux 的 $PREFIX/bin,之后任意目录
``` ```
环境:`PREFIX` 自动用于找 `sh`;`CST_EXE_DIR` / `argv[0]` 用于定位旁路 busybox(`/proc/self/exe` 受限时)。 环境:`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 发行包 ### Windows 发行包
见 [docs/README-windows.md](docs/README-windows.md)。交叉构建: 见 [docs/README-windows.md](docs/README-windows.md)(安装、编码、沙箱、构建、已知限制)。
```bash ```bash
./scripts/build_third_party_mingw.sh all # curl+PDCurses+busybox-w32 ./scripts/bootstrap_windows.sh # Linux 交叉编译;Windows 上在 MSYS2 UCRT64 里用 scripts/bootstrap_msys2.sh
# 源码:https://github.com/rmyorston/busybox-w32.git
# https://github.com/mirror/busybox.git (Unix)
cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
``` ```
产物示例:`dist/windows-x86_64/`(含 `cst-agent.exe` + `busybox.exe` + `libwinpthread-1.dll`)。 产物:`dist/windows-x86_64/`(静态链接的 `cst-agent.exe` + `busybox.exe`,无其他 DLL)和
`dist/cst-agent-VERSION-windows-x86_64.zip`。CI 在 `main` / `v*` tag 推送且交叉构建成功后会自动上传到
Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_TOKEN`(`write:repository`),否则用任务自带 token。
本地:`GITEA_TOKEN=... scripts/release_windows.sh`。
环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。 环境变量:`CST_SHELL`、`CST_EXE_DIR`、`CST_AUTO_APPROVE` 等。
@@ -163,6 +171,7 @@ cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | | `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | | `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
| 用途 | 来源(优先级从高到低) | | 用途 | 来源(优先级从高到低) |
|---|---| |---|---|
@@ -268,15 +277,19 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
`ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。 `ask` / `auto` 的选择会记在 `settings.json`;`yolo` 只在本次运行有效。headless 行为不变(全部自动)。
- **沙箱**(C 实现,`src/sandbox_exec.c`): - **沙箱**(C 实现,`src/sandbox_exec.c`):
- Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`; - Linux:Landlock(内核 5.13+,直接 syscall,无需 libseccomp)。子进程只能写工作区、临时目录、`/dev/null`、`/dev/tty`;
读和执行不受限。Landlock ABI ≥ 4(内核 6.7+)时同时禁止 TCP 连接和监听。 读、执行和联网不受限。
- Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify - Linux 没有 Landlock 时(Android / Termux、WSL、老发行版;内核 5.5+,x86_64 / aarch64):seccomp user-notify
(界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给 (界面显示 `auto · seccomp`)。子进程的写类系统调用(带写标志的 open、创建 / 删除 / 改名 / 链接 / chmod 等)先交给
agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`;IPv4/IPv6 socket 一律拒绝。 agent 里的监督线程,按真实路径判断是否在工作区、临时目录或 `/dev/null` 等之内,不在就返回 `EACCES`。联网不受限。
读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。 读、执行和已打开文件的写入不检查。它防的是误操作而不是恶意程序:检查之后内核会再读一次路径,多线程程序可以趁机换掉。
探测还会确认能读直接子进程的内存;`ptrace_scope=2` 且无 `CAP_SYS_PTRACE` 时 seccomp 不会启用。
比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。 比 shell 活得久的后台进程:桌面发行版开着 Yama(`ptrace_scope` ≥ 1)时它们的受检写入会被拒;命令结束后一律失败。
- macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,禁网。 - macOS:`sandbox_init` profile,同样只放行工作区和临时目录写入,不限制联网。
- Windows、更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。 - Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` 只用指定的那种。 给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网。
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
- 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问 - 沙箱里的命令如果像是被拦了(`Permission denied` / `权限不够` / `Read-only file system` 等),会弹窗问
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。 “Rerun outside the sandbox?”:`y` 在沙箱外重跑一次 · `p` 保存命令前缀规则并重跑 · `n` 保留沙箱结果(模型会收到说明)。
- **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree` - **只读白名单**(无沙箱时 `auto` 用):`ls cat head tail wc grep rg find pwd echo which file stat du tree`
+174 -44
View File
@@ -1,53 +1,183 @@
# CST-Agent on Windows # CST-Agent on Windows
## Release layout 原生 Windows 版:一个静态链接的 `cst-agent.exe`(UCRT,内嵌 UTF-8 清单)加一个
`busybox.exe`(busybox-w32 Unicode 版)作为 shell,不依赖其他 DLL,也不需要管理员权限。
``` ## 安装
cst-agent.exe
busybox.exe # built from busybox-w32 在 PowerShell 里(Windows PowerShell 5.1 或 PowerShell 7 都行):
libwinpthread-1.dll # MinGW runtime (required)
LICENSE.busybox ```powershell
README-windows.md irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_windows.ps1 | iex
``` ```
Prebuilt tree from Linux cross-compile: `agent/dist/windows-x86_64/`. 脚本从 Gitea Release 下载 `cst-agent-VERSION-windows-x86_64.zip`,校验 `.sha256`,
装到 `%LOCALAPPDATA%\Programs\cst-agent`,并把这个目录加进**用户** PATH。装完新开一个终端:
## Build (MinGW-w64 cross from Linux)
```bash
cd agent
# 1) third-party: curl (Schannel) + PDCurses + busybox-w32
./scripts/build_third_party_mingw.sh all
# sources:
# https://github.com/rmyorston/busybox-w32.git
# https://github.com/curl/curl.git
# https://github.com/wmcbrine/PDCurses.git
# Unix busybox (optional, Linux/Termux bundle):
# ./scripts/build_third_party_mingw.sh busybox-unix
# https://github.com/mirror/busybox.git
# 2) headless
cmake -B build-win -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=OFF -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw
cmake --build build-win
# 3) TUI (PDCurses)
cmake -B build-win-tui -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=ON -DCMAKE_PREFIX_PATH=$PWD/third_party/prefix-mingw \
-DPDCurses_ROOT=$PWD/third_party/prefix-mingw
cmake --build build-win-tui
```
Copy `build-win-tui/cst-agent.exe`, `third_party/busybox/busybox.exe`, and
`libwinpthread-1.dll` into one folder.
## Run
```bat ```bat
set CST_API_KEY=... cd C:\code\my-project
cst-agent.exe --headless --workspace . --prompt "echo hi" cst-agent
cst-agent.exe --workspace .
``` ```
Shell: bundled `busybox.exe sh -c ...` unless `CST_SHELL` is set. 首次启动会让你选 provider 并输入 API key(遮罩输入,按 provider 保存)。
Credentials: `%APPDATA%\cst-agent\credentials` (or console prompt).
以后升级:
```bat
cst-agent-update :: 最新版
cst-agent-update -Version 0.6.0 :: 指定版本
```
正在运行的 `cst-agent.exe` 不会挡住升级:旧文件会先改名为 `.old`,下次升级时删掉。
安装脚本的参数(下载后用 `powershell -ExecutionPolicy Bypass -File install_windows.ps1 ...` 运行):
| 参数 | 环境变量 | 作用 |
|---|---|---|
| `-Version 0.6.0` | `CST_VERSION` | 指定版本,默认最新 |
| `-Prefix D:\tools\cst` | `CST_PREFIX` | 安装目录 |
| `-Zip FILE` | | 用本地 zip,不联网 |
| `-NoPath` | | 不改用户 PATH |
| | `CST_GITEA` / `CST_REPO` | 换 Gitea 服务器或仓库(默认 `WHC/CST-agent`) |
不想用脚本的话,把 zip 解压到任意目录直接运行 `cst-agent.exe` 也可以。
## 系统要求
- Windows 10 1903(build 18362)及以上、Windows 11、Windows Server 2022 / 2025。
更老的系统不认清单里的 `activeCodePage=UTF-8`,安装脚本会直接拒绝。
- x86_64。Windows 10 1903 起系统自带 UCRT,无需另装运行库。
- 终端:Windows Terminal 或传统控制台(conhost)都行。
## 发行包内容
```
windows-x86_64/
cst-agent.exe # 静态链接:curl(Schannel)、ncurses 6.6、winpthreads
busybox.exe # busybox-w32 Unicode 版,run_shell 用它的 sh
LICENSE.busybox
README.md # 本文件
VERSION
cst-agent-update.cmd # 调用旁边的 install_windows.ps1 升级本目录
install_windows.ps1
```
## 文件位置
| 内容 | 位置 |
|---|---|
| API key(每个 provider 一个) | `%APPDATA%\cst-agent\credentials`,只有当前用户可读,并带“禁止向上读”标签(沙箱内读不到) |
| 上次的 provider、模型、推理强度、确认模式 | `%APPDATA%\cst-agent\settings.json` |
| 沙箱打过标签的目录 | `%APPDATA%\cst-agent\sandbox-labels` |
| 会话、撤销快照、导出 | `<工作区>\.agent\` |
设置了绝对路径的 `XDG_CONFIG_HOME` 时用 `%XDG_CONFIG_HOME%\cst-agent`。
## 编码:全部 UTF-8
- exe 内嵌 `activeCodePage=UTF-8` 清单:进程的 ANSI 代码页就是 65001,命令行参数、
环境变量、文件名都以 UTF-8 进出;内部所有路径在系统调用边界转成 UTF-16(`*W` API)。
- 控制台输入输出代码页在启动时设为 65001,退出时恢复。
- 文件按字节读写,不做换行转换。`read_file` 给模型看的是去掉 `\r` 的文本;`edit_file`、
`apply_patch`、`write_file` 会保持原文件的 CRLF 换行和 UTF-8 BOM。
- 子进程输出:先按 UTF-8 解码;不是合法 UTF-8 时依次尝试控制台代码页、系统 OEM / ANSI
代码页(比如中文系统的 936),转成 UTF-8 再交给模型。
- 中文用户名、中文工作区路径(`C:\Users\测试\项目`)都已在中英文系统上测过。
## 终端界面(TUI)
用的是打过补丁的 ncurses 6.6 Windows 控制台驱动(补丁在
`scripts/patches/ncurses-6.6-win32-console.patch`):
- 中文按两列宽显示,光标位置正确;粘贴多行不会直接发送。
- 鼠标:点击、拖选复制(写入 Windows 剪贴板)、滚轮滚动;`/mouse` 关闭。
- `/copy` 和拖选复制走 Win32 剪贴板 API,不依赖终端的 OSC 52。
- 控制台是点阵字体时(显示不了中文,会变成 `?`),启动时自动换成同字号的 Consolas
(中文系统换成新宋体),退出时换回去。
- 传统控制台不做字形回退:当前字体缺 logo 用的方块字符时(比如中文系统默认的新宋体),
首页改用文字 logo。
## Shell 沙箱:Low 完整性级别
`auto` 确认模式下,`run_shell` 在 Low 完整性级别的受限令牌里运行(界面显示 `auto · lowil`):
- 子进程的令牌去掉了管理员组和特权,完整性级别为 Low(S-1-16-4096),放进 Job Object,
超时或按 Esc 时整个进程树一起结束。
- Low 进程只能写标为 Low 的位置。第一次在某个工作区跑沙箱命令时,CST-Agent 给工作区
加上可继承的 Low 标签(`S:(ML;OICI;NW;;;LW)`),并在输出里提示一次;
记录写进 `sandbox-labels`。沙箱的临时目录是 `%TEMP%\cst-agent-sandbox`(同样标为 Low),
子进程的 `TEMP` / `TMP` 指向这里。
- 不会给盘符根目录、用户目录本身、`%APPDATA%`、`%LOCALAPPDATA%`、`%TEMP%` 本身,
以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行,
界面会问是否在沙箱外重跑。请在项目目录里启动。
- `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。
- 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**:`curl`、`git pull`、
`npm install` 等联网命令在 `auto` 模式下也直接在沙箱里运行。
- 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。
- MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化
(`0xc0000142`),同样会触发上面的重跑询问。Git for Windows 的 `git.exe` 本身不受影响。
- `CST_SANDBOX=off` 关闭沙箱(`auto` 退回只读白名单规则);`CST_SANDBOX=lowil` 只用 Low IL。
它防的是误操作写坏工作区以外的文件,不是对抗恶意程序的安全边界。
## 从源码构建
两种方式产物一样:`dist/windows-x86_64/` 和 `dist/cst-agent-VERSION-windows-x86_64.zip`。
第三方依赖(curl、ncurses、busybox-w32)都按固定版本从源码编译、静态链接。
### 在 Linux 上交叉编译
```bash
git clone https://git.whcself.xyz/WHC/CST-agent.git
cd CST-agent
scripts/bootstrap_windows.sh
```
NixOS / 有 Nix 时自动进 `shell-win.nix`(`pkgsCross.ucrt64`);否则需要 UCRT 版的
`x86_64-w64-mingw32-gcc`(带 winpthreads)、cmake、ninja、git、perl。
### 在 Windows 上用 MSYS2 原生编译
装好 [MSYS2](https://www.msys2.org/) 后,打开 **MSYS2 UCRT64** 终端(不是 MINGW64:
MINGW64 链接 msvcrt,UTF-8 清单不起作用):
```bash
pacman -S --needed git
git clone https://git.whcself.xyz/WHC/CST-agent.git
cd CST-agent
scripts/bootstrap_msys2.sh
```
脚本会用 pacman 装 UCRT64 的 gcc / cmake / ninja,编译依赖和 `cst-agent.exe`,跑 ctest
(`CST_SKIP_TESTS=1` 跳过),再打包。
### 测试
`scripts/win_test.sh` 在本机 QEMU 里的 Windows Server 虚拟机(英文 / 中文系统,管理员 /
普通用户 / 中文用户名)上交叉编译后跑全部测试;虚拟机的创建和控制见 `scripts/winvm/`。
### 发布
CI:推 `main` 或 `v*` tag 且 `windows-cross` 成功后,自动把 zip / `.sha256` 发到
Gitea Release `vVERSION`(版本来自 `include/cst_version.h`;同名资源替换)。仓库 Secrets
可设 `RELEASE_TOKEN`(`write:repository`),没有则用 Actions 自带 token。
本地:
```bash
GITEA_TOKEN=... scripts/release_windows.sh --build
```
创建(或复用)`vVERSION` Release,上传 zip 和 `.sha256`。token 只从环境变量读取,经 stdin
交给 curl,不会出现在命令行参数、磁盘或输出里。发版前先 bump `cst_version.h`;推
`vX.Y.Z` tag 时必须与头文件一致。
## 已知限制
- 英文等非中文系统的传统控制台默认用 Consolas,它没有中文字形,中文会显示成方框(列宽仍然正确)。
用 Windows Terminal,或在控制台“属性 → 字体”里选新宋体等中文字体即可。
- 传统控制台里输入表情符号(BMP 以外的字符)可能显示不对;中文输入没问题。
- 通过 SSH / ConPTY 远程运行时,鼠标事件不会转发过来,键盘操作不受影响。
- 很大的工作区第一次打标签要等一会儿(标签要继承到每个文件)。
- 只有 x86_64 发行包。
+40 -18
View File
@@ -42,18 +42,20 @@ the `HAVE_MACOS_RUNNER` variable) or by hand with the checklist below.
In the default `auto` approval mode, `run_shell` runs inside an OS sandbox In the default `auto` approval mode, `run_shell` runs inside an OS sandbox
when one is available. Commands may read and execute anything but may only when one is available. Commands may read and execute anything but may only
write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`. write beneath the workspace, the temp directory, `/dev/null` and `/dev/tty`.
The network is not restricted.
| Platform | Sandbox | Notes | | Platform | Sandbox | Notes |
|---|---|---| |---|---|---|
| Linux 5.13+ | Landlock (raw syscalls, no extra library) | ABI ≥ 4 (6.7+) also blocks TCP connect/bind | | Linux 5.13+ | Landlock (raw syscalls, no extra library) | |
| Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | refuses IPv4/IPv6 sockets; see below | | Linux 5.5+ without Landlock (Android / Termux, WSL, older distros), x86_64 / aarch64 | seccomp user-notify | see below |
| macOS | `sandbox_init` profile | also denies network | | macOS | `sandbox_init` profile | |
| Older kernels, Windows | none | falls back to the read-only rules | | Windows 10 1903+ | Low integrity restricted token + Job Object | writes need a Low label (given to the workspace once); see `README-windows.md` |
| Older kernels | none | falls back to the read-only rules |
The status bar and sidebar show `auto · landlock`, `auto · seccomp`, The status bar and sidebar show `auto · landlock`, `auto · seccomp`,
`auto · seatbelt` or `auto · rules`. `CST_SANDBOX=off` disables the `auto · seatbelt`, `auto · lowil` or `auto · rules`. `CST_SANDBOX=off`
sandbox; `CST_SANDBOX=landlock` or `CST_SANDBOX=seccomp` allows only that disables the sandbox; `CST_SANDBOX=landlock`, `seccomp` or `lowil` allows
kind. When a sandboxed command looks blocked, the TUI asks whether to rerun only that kind. When a sandboxed command looks blocked, the TUI asks whether to rerun
it outside the sandbox. The policy is built in the parent; the forked child it outside the sandbox. The policy is built in the parent; the forked child
only makes a few async-signal-safe syscalls before `exec`, so the agent only makes a few async-signal-safe syscalls before `exec`, so the agent
itself is never confined. itself is never confined.
@@ -65,16 +67,19 @@ every create / remove / rename / link / chmod / chown / utimes / xattr call
stop until the supervisor has read the path from the child's memory, stop until the supervisor has read the path from the child's memory,
resolved it (cwd, dirfd, `..`, symlinks, `/proc/self`, `/dev/stdout`) and resolved it (cwd, dirfd, `..`, symlinks, `/proc/self`, `/dev/stdout`) and
answered: continue when it lands in the allowed places, `EACCES` otherwise. answered: continue when it lands in the allowed places, `EACCES` otherwise.
Reads, exec and writes to already-open files are not checked. Background Reads, exec and writes to already-open files are not checked. The probe also
jobs that outlive their shell are no longer the agent's descendants: with checks that the parent can peek a direct child's memory (`process_vm_readv`
Yama `ptrace_scope` ≥ 1 (common on desktop distros, absent on Android) the or `/proc/<pid>/mem`); with Yama `ptrace_scope=2` and no `CAP_SYS_PTRACE`
supervisor cannot read their memory and their checked writes fail with that fails, so seccomp is not selected (forced `CST_SANDBOX=seccomp` then
`EACCES`; once the run has finished they get `ENOSYS`. This keeps falls back to unavailable). Background jobs that outlive their shell are no
mistakes out of the rest of the disk but is not a security boundary: the longer the agent's descendants: with Yama `ptrace_scope` ≥ 1 (common on
kernel reads the path again after the check, so a hostile multi-threaded desktop distros, absent on Android) the supervisor cannot read their memory
program can swap it in between. The availability check runs in a and their checked writes fail with `EACCES`; once the run has finished they
throwaway child, so a platform seccomp policy that kills the caller cannot get `ENOSYS`. This keeps mistakes out of the rest of the disk but is not a
take the agent down. security boundary: the kernel reads the path again after the check, so a
hostile multi-threaded program can swap it in between. The availability
check runs in a throwaway child, so a platform seccomp policy that kills the
caller cannot take the agent down.
## Terminal notes ## Terminal notes
@@ -140,7 +145,7 @@ TUI:
- [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette - [ ] Tab toggles Build/Plan, Shift+Tab cycles ask/auto/yolo, Ctrl+P opens the palette
- [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked - [ ] `/model` lists the provider's `/models` (marked `(api)`); a wrong key is re-asked
- [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the - [ ] `auto` mode: `echo x > ~/outside` in run_shell asks "Rerun outside the
sandbox?" (Linux/Termux/macOS); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks sandbox?" (Linux/Termux/macOS/Windows); with `CST_SANDBOX=off`, `ls` runs and `touch x` asks
- [ ] Resize the terminal during streaming: layout recovers - [ ] Resize the terminal during streaming: layout recovers
- [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens - [ ] `NO_COLOR=1` and `CST_ASCII=1` render readable screens
@@ -149,6 +154,23 @@ WSL specifics:
- [ ] Workspace under `/mnt/c/...` works (slower IO is expected); `~/project` is recommended - [ ] Workspace under `/mnt/c/...` works (slower IO is expected); `~/project` is recommended
- [ ] Windows Terminal: mouse wheel scroll and OSC 52 `/copy` - [ ] Windows Terminal: mouse wheel scroll and OSC 52 `/copy`
Windows specifics (`scripts/win_test.sh` runs the tests on English and
Chinese VMs as Administrator, a standard user and a Chinese user name):
- [ ] `install_windows.ps1 -Zip dist\cst-agent-*.zip` installs, adds the user
PATH; `cst-agent-update -Zip ...` works while `cst-agent.exe` runs
- [ ] conhost with a raster font: Chinese renders (font switched), and the
old font is back after exit; Windows Terminal renders the same
- [ ] Chinese input via IME, paste, mouse click / drag-copy / wheel
- [ ] Workspace under `C:\Users\<中文名>\项目`: read/write/edit/apply_patch
keep CRLF and BOM; `run_shell` output in Chinese is readable on a
936 system
- [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside`
asks to rerun; `curl ...` runs without asking; `/sandbox reset-labels`
restores the label
- [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no
`busybox.exe` behind
macOS specifics: macOS specifics:
- [ ] Builds with Homebrew ncurses and with the system ncurses (`brew unlink ncurses`) - [ ] Builds with Homebrew ncurses and with the system ncurses (`brew unlink ncurses`)
+1
View File
@@ -13,6 +13,7 @@ CST-Agent 使用 **OpenAI Chat Completions** 协议(`POST {base}/chat/completi
| `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` | | `minimax`(默认) | `https://api.minimax.cn/v1` | `MiniMax-M2.7` | `MINIMAX_API_KEY` |
| `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` | | `deepseek` | `https://api.deepseek.com/v1` | `deepseek-flash`(另有 `deepseek-v4-pro`) | `DEEPSEEK_API_KEY` |
| `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` | | `openai` | `https://api.openai.com/v1` | `gpt-4.1-mini` | `OPENAI_API_KEY` |
| `teamorouter` | `https://api.teamorouter.com/v1` | 登录后自动取 `/models` 第一个(`/model` 可换) | `TEAMOROUTER_API_KEY` |
显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。 显式给出的 `--api-base` / `--model` / 下表中的环境变量优先于预设。
+6 -8
View File
@@ -1,11 +1,9 @@
# Windows self-test checklist # Windows self-test checklist
Run on a Windows machine (or wine) after MinGW build + busybox.exe beside the binary. Automated: `scripts/win_test.sh` cross-builds and runs every test on the
English and Chinese Windows Server VMs as Administrator, a standard user and
a Chinese user name (VM tooling in `scripts/winvm/`).
1. `cst-agent.exe --help` exits 0 Manual: the "Windows specifics" list in [compat.md](compat.md), plus the
2. `set CST_API_KEY=...` then `--headless --workspace . --prompt "run echo hello"` — tool output contains hello general CLI and TUI items there. Install and sandbox details are in
3. `write_file` under workspace succeeds; path outside workspace rejected [README-windows.md](README-windows.md).
4. Credentials saved under `%APPDATA%\cst-agent\credentials`
5. With PDCurses build: launch without `--headless`, confirm TUI draws, `/help` works, Esc cancels
Shell must resolve to bundled busybox unless `CST_SHELL` is set.
+97 -2
View File
@@ -4,6 +4,7 @@
#include "ctl.h" #include "ctl.h"
#include <stddef.h> #include <stddef.h>
#include <time.h>
#ifdef __cplusplus #ifdef __cplusplus
extern "C" { extern "C" {
@@ -11,7 +12,6 @@ extern "C" {
#define CST_AGENT_MAX_MSG 256 #define CST_AGENT_MAX_MSG 256
#define CST_AGENT_MAX_CONTENT (512 * 1024) #define CST_AGENT_MAX_CONTENT (512 * 1024)
#define CST_AGENT_CTX_WINDOW 80 /* max non-system msgs sent to LLM */
#define CST_DEFAULT_MAX_STEPS 30 #define CST_DEFAULT_MAX_STEPS 30
#define CST_DEFAULT_TOOL_OUT_CAP (16 * 1024) #define CST_DEFAULT_TOOL_OUT_CAP (16 * 1024)
@@ -47,6 +47,19 @@ int cst_protocol_is_text(cst_protocol_t p);
/* Eager protocols execute a call during the stream, at confirmation time. */ /* Eager protocols execute a call during the stream, at confirmation time. */
int cst_protocol_is_eager(cst_protocol_t p); int cst_protocol_is_eager(cst_protocol_t p);
/* One image carried by a message (see cst_attach.h). The bytes live beside
* the session; this is the metadata plus the name of the stored copy. */
typedef struct {
char id[32]; /* unique inside the session ("img-1a2b3c4d") */
char name[256]; /* display name (original file name) */
char mime[32]; /* image/png, image/jpeg, image/gif, image/webp, image/bmp */
char rel[256]; /* file name inside the session's attachment directory */
long long bytes; /* stored size */
int width, height; /* pixels, 0 when the header could not be read */
char detail[8]; /* auto | low | high (wire hint, "" = auto) */
char origin[32]; /* user | view_image | mcp (for display only) */
} cst_attachment_t;
typedef struct { typedef struct {
cst_role_t role; cst_role_t role;
char *content; char *content;
@@ -55,6 +68,10 @@ typedef struct {
char tool_name[CTL_MAX_NAME]; char tool_name[CTL_MAX_NAME];
char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */ char *tool_calls_json; /* assistant: OpenAI tool_calls array JSON, or NULL */
char *meta_json; /* optional JSON object (kind, protocol, ...) */ char *meta_json; /* optional JSON object (kind, protocol, ...) */
cst_attachment_t *attach; /* images shown with this message, or NULL */
int nattach;
char *reasoning; /* assistant: reasoning_content as streamed, or NULL */
int prefix; /* wire only: assistant prefix the model continues */
} cst_message_t; } cst_message_t;
typedef struct { typedef struct {
@@ -149,6 +166,10 @@ typedef struct {
char *out; char *out;
size_t out_len; size_t out_len;
int exit_code; int exit_code;
/* Images the tool produced for the model (view_image, image-returning MCP
* tools). cst_tool_result_free releases them. */
cst_attachment_t *attach;
int nattach;
} cst_tool_result_t; } cst_tool_result_t;
void cst_tool_result_free(cst_tool_result_t *r); void cst_tool_result_free(cst_tool_result_t *r);
@@ -200,7 +221,9 @@ typedef struct {
char finish_reason[32]; char finish_reason[32];
long prompt_tokens; long prompt_tokens;
long completion_tokens; long completion_tokens;
long cached_tokens; long cached_tokens; /* prompt tokens read from the provider's cache */
long cache_miss_tokens; /* prompt tokens billed at the full input price */
long cache_write_tokens; /* prompt tokens written to the cache (Anthropic) */
double t_first_byte_ms; /* relative to request start; -1 if none */ double t_first_byte_ms; /* relative to request start; -1 if none */
double t_end_ms; double t_end_ms;
int client_stopped; /* stream ended by stop_flag (not an error) */ int client_stopped; /* stream ended by stop_flag (not an error) */
@@ -222,8 +245,15 @@ typedef struct {
void *userdata; void *userdata;
int enable_tools; /* send tools[] schema */ int enable_tools; /* send tools[] schema */
const cst_agent_config_t *tool_cfg; /* active tool set; NULL = all */ const cst_agent_config_t *tool_cfg; /* active tool set; NULL = all */
const char *tools_json; /* tools[] sent verbatim (frozen); NULL = tool_cfg */
int tool_choice_none; /* "tool_choice":"none" */ int tool_choice_none; /* "tool_choice":"none" */
int send_reasoning; /* assistant reasoning goes back as reasoning_content */
int cache_style; /* CST_CACHE_* hints to add */
const char *cache_key; /* prompt_cache_key (CST_CACHE_OPENAI) */
const char *cache_ttl; /* cache_control ttl (CST_CACHE_ANTHROPIC); NULL = 5m */
int images_off; /* the model does not take images: send a note instead */
size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */ size_t tool_out_cap; /* wire cap for role=tool content; 0 = default */
const char *session_path; /* where stored images live; NULL = none */
volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */ volatile int *cancel_flag; /* non-zero → abort (error "cancelled") */
volatile int *stop_flag; /* non-zero → stop reading, not an error */ volatile int *stop_flag; /* non-zero → stop reading, not an error */
cst_llm_stats_t *stats; /* optional */ cst_llm_stats_t *stats; /* optional */
@@ -294,6 +324,25 @@ int cst_agent_switch_session(cst_agent_t *a, const char *id);
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz); int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
/* Bare id of the current session (file name without .jsonl). */ /* Bare id of the current session (file name without .jsonl). */
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz); void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
/* Transcript path of the current session (images are stored beside it). */
const char *cst_agent_session_path(const cst_agent_t *a);
/* Images picked for the next user message (/attach). They ride along with the
* next cst_agent_run_turn*() call and are then part of the session.
* cst_agent_attach_file copies the file into the session store; *err gets a
* short reason on failure (-1). */
int cst_agent_attach_file(cst_agent_t *a, const char *path, char *err,
size_t err_sz);
/* Same for bytes already in memory (clipboard); name is display-only. */
int cst_agent_attach_bytes(cst_agent_t *a, const char *name, const char *data,
size_t n, char *err, size_t err_sz);
/* Whether the current model is shown images: 1 yes, -1 unknown (tried; a
* 400 turns sending off), 0 no (images go as text notes). */
int cst_agent_sees_images(const cst_agent_t *a);
int cst_agent_pending_count(const cst_agent_t *a);
/* One line "<name> (<mime>, 12 KB)"; -1 when i is out of range. */
int cst_agent_pending_describe(const cst_agent_t *a, int i, char *out,
size_t out_sz);
void cst_agent_pending_clear(cst_agent_t *a);
/* Latest todo_write list of this session, or NULL. */ /* Latest todo_write list of this session, or NULL. */
const char *cst_agent_todo(const cst_agent_t *a); const char *cst_agent_todo(const cst_agent_t *a);
/* AGENTS.md (or CST.md) text injected into the system prompt, or NULL. */ /* AGENTS.md (or CST.md) text injected into the system prompt, or NULL. */
@@ -311,6 +360,19 @@ void cst_agent_undo_key(const cst_agent_t *a, char *sid, size_t sid_sz,
char *tag, size_t tag_sz); char *tag, size_t tag_sz);
/* Last prompt_tokens reported by the model (0 if unknown). */ /* Last prompt_tokens reported by the model (0 if unknown). */
long cst_agent_last_prompt_tokens(const cst_agent_t *a); long cst_agent_last_prompt_tokens(const cst_agent_t *a);
/* Token use since the session was opened, and of the latest turn. */
typedef struct {
int requests;
long prompt, cached, miss, write, out;
double cost; /* in currency; valid when priced */
int priced; /* every request had a known price */
char currency[8];
} cst_usage_t;
void cst_agent_usage(const cst_agent_t *a, cst_usage_t *session,
cst_usage_t *turn);
/* "[cst] ..." notes the agent appends when Plan mode, approval, branch,
* date or AGENTS.md change mid-session (the system prompt stays frozen). */
int cst_message_is_note(const cst_message_t *m);
const char *cst_agent_system_prompt(void); const char *cst_agent_system_prompt(void);
const char *cst_agent_system_prompt_for(cst_protocol_t p); const char *cst_agent_system_prompt_for(cst_protocol_t p);
@@ -384,6 +446,39 @@ const cst_provider_t *cst_provider_at(int i);
/* Context window of a model in tokens (CST_CTX_TOKENS overrides). */ /* Context window of a model in tokens (CST_CTX_TOKENS overrides). */
long cst_model_context_tokens(const char *model); long cst_model_context_tokens(const char *model);
/* How a provider caches prompt prefixes.
* auto : implicit prefix cache, nothing to send (DeepSeek, MiniMax...)
* openai : implicit, routed by "prompt_cache_key"
* anthropic : explicit "cache_control" breakpoints on content parts */
typedef enum {
CST_CACHE_AUTO = 0,
CST_CACHE_OPENAI,
CST_CACHE_ANTHROPIC
} cst_cache_style_t;
typedef struct {
int cache_style; /* cst_cache_style_t */
int reasoning_passback; /* history keeps reasoning_content (DeepSeek) */
int prefix_completion; /* assistant "prefix": true via <base>/beta */
int vision; /* 1 takes images, 0 text only, -1 unknown */
} cst_model_caps_t;
/* CST_NO_CACHE_HINTS=1 forces CST_CACHE_AUTO. */
void cst_model_caps(const char *provider, const char *api_base,
const char *model, cst_model_caps_t *out);
/* Prices per million tokens. CST_PRICE="miss,hit,out[,write[,currency]]"
* overrides the built-in table. */
typedef struct {
char currency[8]; /* "¥", "$" */
double miss, hit, write, out;
int offpeak_half; /* DeepSeek: half price outside Beijing peak hours */
} cst_price_t;
/* 0 when a price is known for model, -1 otherwise. */
int cst_model_price(const char *model, cst_price_t *out);
/* DeepSeek peak hours: Beijing time Mon-Fri 9:00-12:00 and 14:00-18:00. */
int cst_price_is_peak(time_t when);
double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
long out, time_t when);
/* Reasoning effort: low | medium | high | max. */ /* Reasoning effort: low | medium | high | max. */
#define CST_DEFAULT_EFFORT "medium" #define CST_DEFAULT_EFFORT "medium"
int cst_effort_valid(const char *effort); int cst_effort_valid(const char *effort);
+81
View File
@@ -0,0 +1,81 @@
#ifndef CST_ATTACH_H
#define CST_ATTACH_H
#include "cst_agent.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* --- image attachments ---------------------------------------------------
* An attachment is a validated copy of an image file, stored beside the
* session so the bytes a model is shown stay the bytes that were picked.
* Only the metadata travels in the JSONL; the bytes are encoded into the
* request when it is built. */
/* Supported: PNG, JPEG, GIF, WebP, BMP (static images). */
#define CST_ATTACH_MAX_BYTES (16 * 1024 * 1024) /* one stored image */
#define CST_ATTACH_MAX_TOTAL (32 * 1024 * 1024) /* per request */
#define CST_ATTACH_MAX_COUNT 8 /* per message */
/* Magic-byte test: 0 and a canonical mime when data holds a supported
* image, -1 otherwise (an extension alone never decides). */
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz);
/* Pixel size from the header when it is readable; 0 for unknown. */
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h);
/* A stored file name as store() makes it ("<id>.<ext>"); rejects anything
* that could leave the attachment directory. */
int cst_attach_rel_ok(const char *rel);
/* Directory holding this session's stored images (created on demand). */
int cst_attach_dir(const char *session_path, char *out, size_t out_sz);
/* Absolute path of a stored image (0 / -1). */
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz);
/* Read src_path, verify it is a supported image within limits, and copy it
* into the session's attachment directory. Returns a malloc'd entry the
* caller owns (cst_attach_free), or NULL with err filled. */
cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err,
size_t err_sz);
/* Store already-read bytes (clipboard, downloads); name is display-only. */
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data,
size_t n, char *err, size_t err_sz);
/* Validate and read back a stored image, or NULL when it is gone. */
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz);
/* "data:image/png;base64,...." for the wire; NULL when the file is gone. */
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a);
/* "1920x1080 PNG 412 KB" style one-liner for prompts and the UI. */
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz);
void cst_attach_free(cst_attachment_t *a);
/* Free a list of n entries allocated as separate blocks. */
void cst_attachments_free(cst_attachment_t *list, int n);
/* Deep copy (NULL on OOM or when n <= 0). */
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n);
/* Append src to *list (grows with realloc). 0 / -1. */
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
const cst_attachment_t *src);
/* JSON array of n attachments for the session JSONL (malloc'd), or NULL. */
char *cst_attachments_to_json(const cst_attachment_t *list, int n);
/* Parse a JSON array; caller frees with cst_attachments_free. */
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out);
/* Base64 of n bytes (malloc'd, no line breaks). */
char *cst_base64_encode(const void *data, size_t n);
/* Standard or URL-safe base64 (whitespace ignored) to bytes; malloc'd,
* NULL on a bad character. */
char *cst_base64_decode(const char *s, size_t n, size_t *out_len);
#ifdef __cplusplus
}
#endif
#endif
+35
View File
@@ -0,0 +1,35 @@
#ifndef CST_CLIPBOARD_H
#define CST_CLIPBOARD_H
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Image bytes on the system clipboard (PNG, or BMP from a Windows DIB).
* Wayland: wl-paste; X11: xclip; macOS: pngpaste or osascript; Windows: the
* clipboard API. Returns 0 with *data malloc'd, or -1 with err filled in
* ("no image on the clipboard", "install wl-clipboard", ...). */
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz);
/* Windows CF_DIB bytes (BITMAPINFOHEADER + pixels) as an image file: a PNG
* for uncompressed 24/32-bit bitmaps, else a BMP. NULL when malformed. */
char *cst_dib_to_image(const void *dib, size_t sz, size_t *n);
#define CST_PASTE_MAX_PATHS 8
#define CST_PASTE_PATH_MAX 1024
/* Split pasted text into file paths the way terminals paste dropped files:
* whitespace- or newline-separated, '...' / "..." quoting, file:// URIs
* (percent-decoded) and, outside Windows, backslash escapes. Returns the
* count, or -1 when the text is empty or holds more than max paths. Whether
* the paths exist is up to the caller. */
int cst_paste_paths(const char *text, size_t len,
char out[][CST_PASTE_PATH_MAX], int max);
#ifdef __cplusplus
}
#endif
#endif
+1
View File
@@ -64,6 +64,7 @@ typedef enum {
CST_KA_COMPACT, CST_KA_COMPACT,
CST_KA_COPY, CST_KA_COPY,
CST_KA_EXPORT, CST_KA_EXPORT,
CST_KA_PASTE_IMAGE,
/* model */ /* model */
CST_KA_MODEL, CST_KA_MODEL,
CST_KA_PROVIDER, CST_KA_PROVIDER,
+92
View File
@@ -0,0 +1,92 @@
#ifndef CST_MODELS_H
#define CST_MODELS_H
/* Model registry: what cst-agent knows about providers and models.
*
* Three layers, highest first, merged field by field:
* 1. <config>/models.json user overrides and extra providers
* 2. <config>/models-cache/<provider>.json GET /models metadata, exact id
* 3. built-in data (src/models_builtin.json, embedded at build time)
* then defaults (128000 tokens, vision unknown). Model rules match the name
* without its "vendor/" prefix, case-insensitively: a plain "match" is a
* prefix, one with '*' is a glob, one with '/' is tested against the full
* id. Within a layer the most specific rule (most literal characters) that
* sets a field wins, so table order does not matter.
*
* CST_CTX_TOKENS, CST_PRICE, CST_MODELS and CST_NO_CACHE_HINTS still
* override everything. */
#include "cst_agent.h"
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
long context; /* tokens */
int vision; /* 1 takes images, 0 text only, -1 unknown */
int cache_style; /* cst_cache_style_t */
int reasoning; /* takes OpenAI-style reasoning_effort */
int has_price;
cst_price_t price;
/* Layers that supplied anything, highest first: "user+api+built-in",
* "default", "env+built-in"... */
char source[48];
} cst_model_info_t;
/* provider may be NULL: then every provider's fetched cache is searched. */
void cst_model_info(const char *provider, const char *model,
cst_model_info_t *out);
/* Short picker label: "1M · vision", "200K · text only", "128K". */
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n);
/* One entry of GET /models, with the optional OpenRouter-style metadata. */
typedef struct {
char id[128];
long context; /* 0 = not given */
int vision; /* -1 = not given */
int has_price; /* prices in USD per 1M tokens */
double miss, hit, write, out;
} cst_model_meta_t;
/* Like cst_llm_list_models, keeping context_length,
* architecture.input_modalities (or modalities) and pricing when present. */
int cst_llm_list_models_meta(const char *api_base, const char *api_key,
cst_model_meta_t *out, int max_out, char *err,
size_t err_sz);
#define CST_MODELS_CACHE_TTL (24L * 3600)
/* Store a fetched list as <config>/models-cache/<provider>.json and use it
* from now on. 0 / -1. */
int cst_models_cache_save(const char *provider, const char *api_base,
const cst_model_meta_t *list, int n);
/* The cached list for provider: count (up to max), or -1 when there is
* none. *age_s gets the seconds since it was fetched. */
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
long *age_s);
/* The "models" a provider entry lists (built-in or user file). */
int cst_provider_models(const cst_provider_t *p, char out[][128], int max);
/* Where a provider entry came from: "built-in" or "user". */
const char *cst_provider_source(const cst_provider_t *p);
/* The user file's path (it may not exist). */
int cst_models_user_path(char *out, size_t out_sz);
/* Drop everything and read the built-in data, the user file and the caches
* again. Provider pointers stay valid but may name another provider. */
void cst_models_reload(void);
/* Tests: load these documents instead of the built-in data and the user
* file (NULL = none). Caches still come from <config>. 0, or -1 with err. */
int cst_models_load_json(const char *builtin_json, const char *user_json,
char *err, size_t err_sz);
/* The embedded built-in document. */
const char *cst_models_builtin_json(void);
/* Problems found while loading (bad user file...), or "". */
const char *cst_models_load_error(void);
#ifdef __cplusplus
}
#endif
#endif
+3
View File
@@ -26,6 +26,9 @@ void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz);
/* 1 when every segment of cmd (split on | || && ;) is a read-only command /* 1 when every segment of cmd (split on | || && ;) is a read-only command
* from a fixed whitelist, with no output redirection or substitution. */ * from a fixed whitelist, with no output redirection or substitution. */
int cst_shell_is_safe(const char *cmd); int cst_shell_is_safe(const char *cmd);
/* 1 when cmd looks like it talks to the network (curl, ssh, git push,
* npm install, ...). Errs on the side of yes. */
int cst_shell_uses_network(const char *cmd);
#ifdef __cplusplus #ifdef __cplusplus
} }
+25 -10
View File
@@ -2,16 +2,19 @@
#define CST_SANDBOX_H #define CST_SANDBOX_H
/* /*
* OS sandbox for run_shell: reads and exec stay allowed everywhere, writes * OS sandbox for run_shell: reads, exec and the network stay allowed;
* are limited to the workspace, the temp dir, /dev/null and /dev/tty, and * writes are limited to the workspace, the temp dir, /dev/null and /dev/tty.
* the network is cut where the OS can do it. * Linux Landlock
* Linux Landlock (network needs ABI 4, kernel 6.7+)
* Linux seccomp user-notify when Landlock is missing (Android/Termux, * Linux seccomp user-notify when Landlock is missing (Android/Termux,
* older kernels): a supervisor thread checks the path of every * older kernels): a supervisor thread checks the path of every
* write-type syscall and IPv4/IPv6 sockets are refused. Guards * write-type syscall. Guards
* against mistakes, not hostile code (the path is checked before * against mistakes, not hostile code (the path is checked before
* the kernel reads it again). * the kernel reads it again).
* macOS sandbox_init (Seatbelt profile) * macOS sandbox_init (Seatbelt profile)
* Windows Low integrity level: a restricted token (no admin privileges,
* Low IL) can only write objects labeled Low, so the workspace and
* a temp dir get that label; the credentials file is labeled
* no-read-up.
* other none: callers fall back to rule-based approval * other none: callers fall back to rule-based approval
*/ */
@@ -27,17 +30,18 @@ typedef enum {
CST_SANDBOX_NONE = 0, CST_SANDBOX_NONE = 0,
CST_SANDBOX_LANDLOCK, CST_SANDBOX_LANDLOCK,
CST_SANDBOX_SEATBELT, CST_SANDBOX_SEATBELT,
CST_SANDBOX_SECCOMP CST_SANDBOX_SECCOMP,
CST_SANDBOX_LOWIL
} cst_sandbox_kind_t; } cst_sandbox_kind_t;
/* What this process can use; cached. CST_SANDBOX=off|0|none forces NONE, /* What this process can use; cached. CST_SANDBOX=off|0|none forces NONE,
* CST_SANDBOX=landlock|seccomp allows only that kind. */ * CST_SANDBOX=landlock|seccomp|lowil allows only that kind. */
int cst_sandbox_probe(void); int cst_sandbox_probe(void);
/* Drops the cache and probes again (tests that change CST_SANDBOX). */ /* Drops the cache and probes again (tests that change CST_SANDBOX). */
int cst_sandbox_reprobe(void); int cst_sandbox_reprobe(void);
/* "landlock" | "seatbelt" | "seccomp" | "none" */ /* "landlock" | "seatbelt" | "seccomp" | "lowil" | "none" */
const char *cst_sandbox_name(int kind); const char *cst_sandbox_name(int kind);
/* Landlock ABI version (0 when unavailable); ≥4 also blocks TCP. */ /* Landlock ABI version (0 when unavailable). */
int cst_sandbox_landlock_abi(void); int cst_sandbox_landlock_abi(void);
typedef struct { typedef struct {
@@ -45,6 +49,9 @@ typedef struct {
int ruleset_fd; /* Landlock, built in the parent */ int ruleset_fd; /* Landlock, built in the parent */
char *profile; /* Seatbelt */ char *profile; /* Seatbelt */
void *sc; /* seccomp: filter program and supervisor thread */ void *sc; /* seccomp: filter program and supervisor thread */
void *token; /* lowil: primary token for CreateProcessAsUserW */
char *tmpdir; /* lowil: Low-labeled TEMP for the child */
char *note; /* lowil: shown once when a directory was just labeled */
} cst_sandbox_plan_t; } cst_sandbox_plan_t;
/* Parent side: build the policy. Returns 0, or -1 (plan->kind = NONE). */ /* Parent side: build the policy. Returns 0, or -1 (plan->kind = NONE). */
@@ -61,9 +68,17 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
size_t max_out, os_run_result_t *r); size_t max_out, os_run_result_t *r);
/* Output of a failed sandboxed run that looks like the sandbox got in the /* Output of a failed sandboxed run that looks like the sandbox got in the
* way (EACCES / EPERM / EROFS / blocked network). */ * way (EACCES / EPERM / EROFS). */
int cst_sandbox_blocked(const char *out, size_t n, int exit_code); int cst_sandbox_blocked(const char *out, size_t n, int exit_code);
/* lowil: directories this user's cst-agent labeled Low (recorded in the
* config dir), one per line; malloc'd, NULL when there are none. */
char *cst_sandbox_labels(void);
/* Gives every recorded directory its old label back. Returns how many were
* restored (-1 when some failed; those stay recorded); *report gets a
* malloc'd summary. */
int cst_sandbox_reset_labels(char **report);
#ifdef __cplusplus #ifdef __cplusplus
} }
#endif #endif
+21
View File
@@ -25,6 +25,27 @@ char *cst_buf_steal(cst_buf_t *b);
size_t cst_utf8_floor(const char *s, size_t n); size_t cst_utf8_floor(const char *s, size_t n);
/* Copy s[0..n) replacing invalid UTF-8 with '?'. malloc'd, NUL-terminated. */ /* Copy s[0..n) replacing invalid UTF-8 with '?'. malloc'd, NUL-terminated. */
char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len); char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len);
int cst_utf8_valid(const char *s, size_t n);
/* Text files may start with a UTF-8 BOM (Windows editors): its length (0/3),
* and an in-place strip of the NUL-terminated s (*len updated when given). */
size_t cst_bom_len(const char *s, size_t n);
void cst_strip_bom(char *s, size_t *len);
/* Line endings: "\r\n" -> "\n" in place (returns the new length); whether
* every line of s ends in "\r\n" (and at least one does); and a malloc'd
* copy of s with bare "\n" turned into "\r\n" when crlf, its BOM dropped and
* a BOM added when bom. Edits keep a Windows file's style with these. */
size_t cst_crlf_to_lf(char *s, size_t n);
int cst_text_is_crlf(const char *s, size_t n);
char *cst_text_restyle(const char *s, size_t n, int crlf, int bom,
size_t *out_len);
/* fnmatch(3) without FNM_PATHNAME ('*' also crosses '/'): * ? [..] [!..]
* [[:class:]] and backslash escapes over UTF-8 code points. Returns 0 on a
* match. Always case-insensitive on Windows. */
#define CST_FNM_PERIOD 1 /* a leading '.' must be matched literally */
#define CST_FNM_CASEFOLD 2
int cst_fnmatch(const char *pat, const char *str, int flags);
uint64_t cst_fnv1a64(uint64_t h, const void *data, size_t n); uint64_t cst_fnv1a64(uint64_t h, const void *data, size_t n);
#define CST_FNV_INIT 1469598103934665603ULL #define CST_FNV_INIT 1469598103934665603ULL
+9 -1
View File
@@ -1,6 +1,14 @@
#ifndef CST_VERSION_H #ifndef CST_VERSION_H
#define CST_VERSION_H #define CST_VERSION_H
#define CST_VERSION "0.5.0" #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 7
#define CST_VERSION_PATCH 1
#define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
#define CST_VERSION \
CST_VERSION_STR(CST_VERSION_MAJOR) \
"." CST_VERSION_STR(CST_VERSION_MINOR) "." CST_VERSION_STR(CST_VERSION_PATCH)
#endif #endif
+103 -2
View File
@@ -22,11 +22,36 @@ int os_realpath(const char *path, char *out, size_t out_sz);
/* True if path is under root (after realpath/norm). Trailing slash rules apply. */ /* True if path is under root (after realpath/norm). Trailing slash rules apply. */
int os_path_under(const char *root, const char *path); int os_path_under(const char *root, const char *path);
/* --- directory listing / file type --- */
typedef struct os_dir os_dir_t;
typedef struct {
const char *name; /* UTF-8; valid until the next os_dir_next */
int is_dir;
int is_link; /* symlink, or a junction on Windows (not followed) */
} os_dirent_t;
os_dir_t *os_dir_open(const char *path);
/* Next entry, skipping "." and ".."; NULL at the end. */
const os_dirent_t *os_dir_next(os_dir_t *d);
void os_dir_close(os_dir_t *d);
typedef struct {
int is_dir, is_reg, is_link;
long long size;
double mtime; /* seconds since the epoch */
} os_stat_t;
/* Like lstat(2): a link is reported as a link, not followed. */
int os_lstat(const char *path, os_stat_t *st);
/* Replace `to` if it exists (atomic where the OS allows). */
int os_rename_replace(const char *from, const char *to);
/* --- dirs / config --- */ /* --- dirs / config --- */
int os_mkdir(const char *path, int mode); int os_mkdir(const char *path, int mode);
int os_mkdir_p(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_config_dir(char *out, size_t out_sz);
int os_temp_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. */ /* 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); int os_mkstemp(char *out, size_t out_sz, const char *prefix);
@@ -67,6 +92,28 @@ 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, os_run_result_t *r, os_child_hook_fn child_hook,
void *ud); void *ud);
#ifdef _WIN32
/* Windows: run under `token` (a primary token, e.g. the Low-IL sandbox
* token; NULL = ours) with env_add "KEY=VALUE" entries layered on the
* inherited environment. Every child runs in a kill-on-close Job Object, so
* timeouts and cancels end the whole process tree. */
int os_run_capture_token(const char *cwd, const char *const argv[],
size_t max_out, os_run_result_t *r, void *token,
const char *const env_add[]);
/* os_run_shell_c under token, with env_add. */
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[]);
/* "Windows 10.0.26100 x86_64" (the real version, not the compat shim's). */
void os_windows_version(char *out, size_t n);
/* Sets the mandatory-label SACL, e.g. "S:(ML;OICI;NW;;;LW)"; 0 / -1. */
int os_win_set_label(const char *path, const char *sddl);
/* The explicit (not inherited) label as SDDL, "S:" when there is none. */
int os_win_get_label(const char *path, char *out, size_t n);
/* CF_UNICODE_TEXT clipboard (the console may not do OSC 52). */
int os_clipboard_set(const char *utf8, size_t n);
#endif
/* Child processes get stdin=/dev/null, their own process group, and are /* Child processes get stdin=/dev/null, their own process group, and are
* killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when * killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when
* *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */ * *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */
@@ -80,7 +127,7 @@ int os_get_run_timeout(void);
* the inherited environment (NULL-terminated, may be NULL). */ * the inherited environment (NULL-terminated, may be NULL). */
typedef struct { typedef struct {
#ifdef _WIN32 #ifdef _WIN32
void *proc, *in_w, *out_r; void *proc, *in_w, *out_r, *job;
#else #else
int pid, in_fd, out_fd; int pid, in_fd, out_fd;
#endif #endif
@@ -106,10 +153,64 @@ void os_run_parallel(os_task_fn fn, void *const args[], int n);
/* Monotonic milliseconds (arbitrary epoch). */ /* Monotonic milliseconds (arbitrary epoch). */
double os_now_ms(void); double os_now_ms(void);
/* --- text encoding ---
* cst-agent is UTF-8 throughout. On Windows the manifest makes UTF-8 the
* process code page; os_console_init() also switches the console to UTF-8
* (restored at exit), sets a UTF-8 C locale and asks Python children for
* UTF-8. Returns -1 when the process code page is not UTF-8 (pre-1903
* Windows); no-op returning 0 elsewhere. */
int os_console_init(void);
/* Child output that is not valid UTF-8, decoded from the console/OEM/ANSI
* code page: malloc'd UTF-8, or NULL when s is already UTF-8 (or not on
* Windows). */
char *os_output_to_utf8(const char *s, size_t n, size_t *out_len);
#ifdef _WIN32
#include <wchar.h>
/* malloc'd conversions; NULL on invalid input or allocation failure */
wchar_t *os_utf8_to_wide(const char *s);
char *os_wide_to_utf8(const wchar_t *w);
#endif
/* --- misc --- */ /* --- misc --- */
void os_localtime(const time_t *t, struct tm *out); void os_localtime(const time_t *t, struct tm *out);
int os_getpid(void); int os_getpid(void);
int os_chmod_private(const char *path); /* best-effort 0600; no-op/ok on Win */ /* 0600; on Windows a Medium no-read-up label, so Low-IL children (the
* shell sandbox) cannot read the file. */
int os_chmod_private(const char *path);
/* --- UTF-8 --------------------------------------------------------------
* Encode one code point as UTF-8, independent of the C locale.
*
* wcrtomb() follows setlocale(). On Linux the environment normally selects a
* UTF-8 locale, but on Windows the C runtime maps setlocale(LC_ALL, "") to the
* ANSI code page -- 936 (GBK) on a Chinese system -- so wcrtomb() hands back
* GBK bytes where the protocol requires UTF-8, and the model receives
* mojibake. Windows' C runtime has no ".UTF-8" locale to switch to.
*
* Returns the number of bytes written (1..4), or 0 if the code point cannot be
* encoded. Surrogate halves (as they arrive on 16-bit wchar_t platforms for
* non-BMP code points) return 0 and are skipped. */
int os_utf8_encode(unsigned long cp, char out[4]);
/* Decode one UTF-8 sequence from s (at most avail bytes).
* Returns the number of bytes consumed (1..4) and stores the code point in
* *cp, or 0 if the bytes are not a valid, complete, non-overlong sequence.
*
* The counterpart of the encoder above, and needed for the same reason:
* mbrtowc() also follows setlocale(), so on Windows it would decode UTF-8
* text as GBK and the screen would show mojibake. */
int os_utf8_decode(const char *s, size_t avail, unsigned long *cp);
/* --- libc gaps on Windows ------------------------------------------------
* MinGW-w64 provides neither strcasestr() (a GNU extension) nor
* setenv()/unsetenv() (POSIX): they are simply absent from its headers.
* src/tui.c uses all three. Implemented in os_compat.c for _WIN32 only,
* so the Unix builds keep using the libc versions. */
#if defined(_WIN32)
char *strcasestr(const char *haystack, const char *needle);
int setenv(const char *name, const char *value, int overwrite);
int unsetenv(const char *name);
#endif
#ifdef __cplusplus #ifdef __cplusplus
} }
+23
View File
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1"
xmlns:asmv3="urn:schemas-microsoft-com:asm.v3">
<assemblyIdentity type="win32" name="cst-agent" version="1.0.0.0"/>
<asmv3:application>
<asmv3:windowsSettings>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</asmv3:windowsSettings>
</asmv3:application>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
<security>
<requestedPrivileges>
<requestedExecutionLevel level="asInvoker" uiAccess="false"/>
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
</application>
</compatibility>
</assembly>
+29
View File
@@ -0,0 +1,29 @@
#include <winver.h>
#include "cst_version.h"
1 24 "cst-agent.manifest"
VS_VERSION_INFO VERSIONINFO
FILEVERSION CST_VERSION_MAJOR, CST_VERSION_MINOR, CST_VERSION_PATCH, 0
PRODUCTVERSION CST_VERSION_MAJOR, CST_VERSION_MINOR, CST_VERSION_PATCH, 0
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_APP
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904B0"
BEGIN
VALUE "CompanyName", "WHC"
VALUE "FileDescription", "CST agent"
VALUE "FileVersion", CST_VERSION
VALUE "InternalName", "cst-agent"
VALUE "OriginalFilename", "cst-agent.exe"
VALUE "ProductName", "CST agent"
VALUE "ProductVersion", CST_VERSION
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Native Windows build from an MSYS2 UCRT64 shell (https://www.msys2.org/).
# scripts/bootstrap_msys2.sh [repo-dir]
# Installs the UCRT64 toolchain with pacman, builds the same pinned static
# deps as the Linux cross build (curl with Schannel, ncurses with the Windows
# console patches, busybox-w32 Unicode), then a static cst-agent.exe with the
# UTF-8 manifest, runs the tests and packages dist/windows-x86_64/.
# CST_SKIP_TESTS=1 skips ctest.
set -euo pipefail
if [[ "${MSYSTEM:-}" != UCRT64 ]]; then
echo "run this from the MSYS2 UCRT64 shell: the UTF-8 manifest needs the" \
"UCRT runtime (MINGW64 links msvcrt)" >&2
exit 1
fi
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
DIR="${1:-}"
if [[ -z "$DIR" ]]; then
if [[ -f CMakeLists.txt && -d src ]]; then
DIR="$(pwd)"
else
DIR="CST-agent"
fi
fi
pacman -S --needed --noconfirm \
mingw-w64-ucrt-x86_64-gcc mingw-w64-ucrt-x86_64-cmake \
mingw-w64-ucrt-x86_64-ninja make git patch tar perl zip diffutils
if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
echo "==> git clone $REPO_URL -> $DIR"
git clone "$REPO_URL" "$DIR"
fi
cd "$DIR"
# third-party script: unprefixed native tools
export MINGW_HOST= BUILD_CC=gcc
[[ -f third_party/prefix-mingw/lib/libcurl.a ]] || scripts/build_third_party_mingw.sh curl
[[ -f third_party/prefix-mingw/lib/libncursesw.a ]] || scripts/build_third_party_mingw.sh ncurses
if [[ ! -f third_party/busybox/busybox.exe ]]; then
scripts/build_third_party_mingw.sh busybox-w32 || scripts/fetch_busybox_w32.sh
fi
echo "==> cmake (MSYS2 UCRT64, static, TUI)"
cmake -S . -B build-msys2 -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="$PWD/third_party/prefix-mingw" \
-DCST_WIN_STATIC=ON -DCST_BUILD_TUI=ON
cmake --build build-msys2
if [[ -z "${CST_SKIP_TESTS:-}" ]]; then
echo "==> ctest"
cp -f third_party/busybox/busybox.exe build-msys2/
ctest --test-dir build-msys2 --output-on-failure
fi
echo "==> package"
CST_WIN_BUILD=build-msys2 scripts/package_windows.sh
+25 -25
View File
@@ -1,11 +1,13 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# One-shot on a Linux host: cross-build Windows x86_64 release (MinGW). # Linux host: cross-build the Windows x86_64 release (mingw-w64, UCRT, static).
# Output: dist/windows-x86_64/ — copy that folder to a Windows machine. # scripts/bootstrap_windows.sh [repo-dir]
# With Nix the toolchain comes from shell-win.nix; otherwise x86_64-w64-mingw32-gcc
# (UCRT flavour, with winpthreads), cmake, ninja, git and perl must be installed.
# Output: dist/windows-x86_64/ and dist/cst-agent-VERSION-windows-x86_64.zip
set -euo pipefail set -euo pipefail
REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}" REPO_URL="${CST_AGENT_REPO:-https://git.whcself.xyz/WHC/CST-agent.git}"
DIR="${1:-}" DIR="${1:-}"
if [[ -z "$DIR" ]]; then if [[ -z "$DIR" ]]; then
if [[ -f CMakeLists.txt && -d src ]]; then if [[ -f CMakeLists.txt && -d src ]]; then
DIR="$(pwd)" DIR="$(pwd)"
@@ -13,36 +15,34 @@ if [[ -z "$DIR" ]]; then
DIR="CST-agent" DIR="CST-agent"
fi fi
fi fi
if [[ ! -f "$DIR/CMakeLists.txt" ]]; then if [[ ! -f "$DIR/CMakeLists.txt" ]]; then
echo "==> git clone $REPO_URL -> $DIR" echo "==> git clone $REPO_URL -> $DIR"
git clone "$REPO_URL" "$DIR" git clone "$REPO_URL" "$DIR"
fi fi
cd "$DIR" cd "$DIR"
need() { command -v "$1" >/dev/null || { echo "missing $1 (install mingw-w64 gcc cmake git)" >&2; exit 1; }; } if ! command -v x86_64-w64-mingw32-gcc >/dev/null && [[ -z "${IN_NIX_SHELL:-}" ]] &&
need git; need cmake; need x86_64-w64-mingw32-gcc command -v nix-shell >/dev/null; then
exec nix-shell shell-win.nix --run "$(printf '%q ' "$0" "$PWD")"
fi
echo "==> third-party (curl Schannel + PDCurses + busybox-w32)" need() { command -v "$1" >/dev/null || { echo "missing $1 (mingw-w64 UCRT gcc, cmake, ninja, git, perl)" >&2; exit 1; }; }
chmod +x scripts/*.sh 2>/dev/null || true need git; need cmake; need ninja; need perl; need x86_64-w64-mingw32-gcc
./scripts/build_third_party_mingw.sh all if ! printf '#include <stdio.h>\n' | x86_64-w64-mingw32-gcc -dM -E - | grep -qw _UCRT; then
echo "x86_64-w64-mingw32-gcc targets msvcrt; the UTF-8 manifest needs a UCRT toolchain" >&2
exit 1
fi
echo "==> cmake Windows TUI" [[ -f third_party/prefix-mingw/lib/libcurl.a ]] || scripts/build_third_party_mingw.sh curl
cmake -B build-win-tui \ [[ -f third_party/prefix-mingw/lib/libncursesw.a ]] || scripts/build_third_party_mingw.sh ncurses
[[ -f third_party/busybox/busybox.exe ]] || scripts/build_third_party_mingw.sh busybox-w32
echo "==> cmake (Windows, TUI)"
cmake -S . -B build-win-tui -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \ -DCMAKE_TOOLCHAIN_FILE=cmake/mingw-w64-x86_64.cmake \
-DCST_BUILD_TUI=ON \ -DCMAKE_BUILD_TYPE=Release -DCST_BUILD_TUI=ON
-DCMAKE_PREFIX_PATH="$PWD/third_party/prefix-mingw" \ # Package only needs the agent binary; skip host-unrunnable Win32 tests.
-DPDCurses_ROOT="$PWD/third_party/prefix-mingw" cmake --build build-win-tui --target cst-agent
cmake --build build-win-tui -j"$(nproc 2>/dev/null || echo 4)"
echo "==> package" echo "==> package"
./scripts/package_windows.sh scripts/package_windows.sh
echo
echo "OK: dist/windows-x86_64/"
echo "Copy that folder to Windows, then:"
echo " set OPENAI_API_KEY=sk-..."
echo " set OPENAI_BASE_URL=https://api.openai.com/v1"
echo " set OPENAI_MODEL=gpt-4.1-mini"
echo " cst-agent.exe --workspace ."
echo "See docs/env_openai.md and docs/README-windows.md"
+119 -107
View File
@@ -1,138 +1,150 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build third-party deps for Windows MinGW cross + optional Unix busybox. # Build pinned third-party deps for Windows x86_64 (mingw-w64, UCRT) into
# Sources: # third_party/prefix-mingw, plus busybox-w32 into third_party/busybox.
# busybox-w32: https://github.com/rmyorston/busybox-w32.git # nix-shell shell-win.nix --run 'scripts/build_third_party_mingw.sh [all|curl|pdcurses|ncurses|busybox-w32]'
# busybox: https://github.com/mirror/busybox.git # Works the same from an MSYS2 UCRT64 shell (MINGW_HOST= uses unprefixed tools).
# curl: https://github.com/curl/curl.git (Windows via Schannel)
# PDCurses: https://github.com/wmcbrine/PDCurses.git
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
TP="$ROOT/third_party" TP="$ROOT/third_party"
HOST="${MINGW_HOST:-x86_64-w64-mingw32}" PREFIX="$TP/prefix-mingw"
HOST="${MINGW_HOST-x86_64-w64-mingw32}"
T="${HOST:+$HOST-}"
JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}" JOBS="${JOBS:-$(nproc 2>/dev/null || echo 4)}"
mkdir -p "$TP"
CURL_TAG=curl-8_22_0
PDCURSES_TAG=3.9
NCURSES_VER=6.6
BUSYBOX_W32_REV=9b50a1e
mkdir -p "$TP" "$PREFIX"
need_cmd() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; } need_cmd() { command -v "$1" >/dev/null || { echo "missing: $1" >&2; exit 1; }; }
need_cmd git need_cmd git
need_cmd make need_cmd make
need_cmd "${HOST}-gcc" need_cmd "${T}gcc"
clone_or_update() { # Clone a GitHub repo with CN-friendly mirrors; fall back if one proxy 502s.
local url="$1" dir="$2" github_mirrors() { # https://github.com/org/repo[.git] -> print candidate clone URLs
if [[ -d "$dir/.git" ]]; then local url="$1" path
git -C "$dir" fetch --depth 1 origin 2>/dev/null || true path="${url#https://github.com/}"
git -C "$dir" pull --ff-only 2>/dev/null || true path="${path%.git}"
else printf '%s\n' \
git clone --depth 1 "$url" "$dir" "https://ghfast.top/https://github.com/${path}.git" \
fi "https://ghproxy.net/https://github.com/${path}.git" \
"https://gitclone.com/github.com/${path}.git" \
"https://github.com/${path}.git"
} }
# --- curl (static, Schannel) --- checkout() { # url dir rev
build_curl_mingw() { local url="$1" dir="$2" rev="$3" m
clone_or_update https://github.com/curl/curl.git "$TP/curl" if [[ ! -d "$dir/.git" ]]; then
cd "$TP/curl" local ok=0
if [[ ! -f configure ]]; then while IFS= read -r m; do
autoreconf -fi 2>/dev/null || true echo " clone $m"
rm -rf "$dir"
if git clone -q --filter=blob:none "$m" "$dir"; then
ok=1
break
fi fi
# Prefer cmake for curl modern tree done < <(github_mirrors "$url")
rm -rf build-mingw [[ $ok -eq 1 ]] || { echo "clone failed: $url" >&2; exit 1; }
cmake -B build-mingw -G "Unix Makefiles" \ fi
-DCMAKE_SYSTEM_NAME=Windows \ if ! git -C "$dir" rev-parse -q --verify "$rev^{commit}" >/dev/null; then
-DCMAKE_C_COMPILER="${HOST}-gcc" \ git -C "$dir" fetch -q --tags origin || true
-DCMAKE_RC_COMPILER="${HOST}-windres" \ git -C "$dir" fetch -q origin "$rev" || true
-DCMAKE_INSTALL_PREFIX="$TP/prefix-mingw" \ fi
-DBUILD_SHARED_LIBS=OFF \ git -C "$dir" checkout -q --detach "$rev"
-DCURL_USE_SCHANNEL=ON \
-DCURL_USE_OPENSSL=OFF \
-DCURL_DISABLE_LDAP=ON \
-DCURL_USE_LIBPSL=OFF \
-DCURL_ZLIB=OFF \
-DCURL_BROTLI=OFF \
-DCURL_ZSTD=OFF \
-DUSE_NGHTTP2=OFF \
-DBUILD_CURL_EXE=OFF \
-DBUILD_TESTING=OFF \
-DHTTP_ONLY=OFF
cmake --build build-mingw -j"$JOBS"
cmake --install build-mingw
echo "curl installed -> $TP/prefix-mingw"
} }
# --- PDCurses --- build_curl() {
build_pdcurses_mingw() { checkout https://github.com/curl/curl.git "$TP/curl" "$CURL_TAG"
clone_or_update https://github.com/wmcbrine/PDCurses.git "$TP/PDCurses" rm -rf "$TP/curl/build-mingw"
local tc=()
[[ -n "$HOST" ]] && tc=(-DCMAKE_SYSTEM_NAME=Windows -DCMAKE_C_COMPILER="${T}gcc" -DCMAKE_RC_COMPILER="${T}windres")
cmake -S "$TP/curl" -B "$TP/curl/build-mingw" -G Ninja "${tc[@]}" \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX="$PREFIX" \
-DBUILD_SHARED_LIBS=OFF -DBUILD_STATIC_LIBS=ON \
-DCURL_USE_SCHANNEL=ON -DCURL_USE_OPENSSL=OFF \
-DCURL_WINDOWS_SSPI=ON -DENABLE_UNICODE=ON \
-DCURL_DISABLE_LDAP=ON -DCURL_USE_LIBPSL=OFF -DCURL_USE_LIBSSH2=OFF \
-DUSE_LIBIDN2=OFF -DUSE_NGHTTP2=OFF \
-DCURL_ZLIB=OFF -DCURL_BROTLI=OFF -DCURL_ZSTD=OFF \
-DBUILD_CURL_EXE=OFF -DBUILD_TESTING=OFF -DBUILD_EXAMPLES=OFF \
-DBUILD_LIBCURL_DOCS=OFF -DBUILD_MISC_DOCS=OFF -DENABLE_CURL_MANUAL=OFF
cmake --build "$TP/curl/build-mingw" -j"$JOBS"
cmake --install "$TP/curl/build-mingw"
echo "curl $CURL_TAG -> $PREFIX"
}
build_pdcurses() {
checkout https://github.com/wmcbrine/PDCurses.git "$TP/PDCurses" "$PDCURSES_TAG"
cd "$TP/PDCurses/wincon" cd "$TP/PDCurses/wincon"
make -f Makefile clean 2>/dev/null || true make clean >/dev/null 2>&1 || true
make -f Makefile \ make -j"$JOBS" CC="${T}gcc" AR="${T}ar" STRIP="${T}strip" WIDE=Y UTF8=Y pdcurses.a
CC="${HOST}-gcc" \ mkdir -p "$PREFIX/include/pdcurses" "$PREFIX/lib"
LINK="${HOST}-gcc" \ cp -f ../curses.h ../panel.h "$PREFIX/include/pdcurses/"
CFLAGS="-O2 -I.. -DPDC_WIDE -DPDC_FORCE_UTF8" \ cp -f pdcurses.a "$PREFIX/lib/libpdcurses.a"
LIBEXE="${HOST}-ar" \ echo "PDCurses $PDCURSES_TAG -> $PREFIX"
LIBFLAGS=rcv \ }
pdcurses.a
mkdir -p "$TP/prefix-mingw/include" "$TP/prefix-mingw/lib" build_ncurses() {
cp -f ../curses.h "$TP/prefix-mingw/include/" local src="$TP/ncurses-$NCURSES_VER" tgz="$TP/ncurses-$NCURSES_VER.tar.gz"
[[ -f ../panel.h ]] && cp -f ../panel.h "$TP/prefix-mingw/include/" || true [[ -f "$tgz" ]] || curl -fL -o "$tgz" \
cp -f pdcurses.a "$TP/prefix-mingw/lib/libpdcurses.a" "https://invisible-island.net/archives/ncurses/ncurses-$NCURSES_VER.tar.gz"
# Also install as curses for find_library [[ -d "$src" ]] || tar -C "$TP" -xzf "$tgz"
cp -f pdcurses.a "$TP/prefix-mingw/lib/libcurses.a" local pf
echo "PDCurses -> $TP/prefix-mingw" for pf in "$ROOT"/scripts/patches/ncurses-"$NCURSES_VER"-*.patch; do
[[ -e "$pf" ]] || continue
patch -d "$src" -p1 -R --dry-run -s -f <"$pf" >/dev/null 2>&1 && continue
if ! patch -d "$src" -p1 --dry-run -s -f <"$pf" >/dev/null 2>&1; then
echo "ncurses: $src has another version of $(basename "$pf"); re-extracting"
rm -rf "$src"
tar -C "$TP" -xzf "$tgz"
fi
patch -d "$src" -p1 -s <"$pf"
done
rm -rf "$src/build-mingw"
mkdir -p "$src/build-mingw"
cd "$src/build-mingw"
../configure --host="${HOST:-x86_64-w64-mingw32}" --prefix="$PREFIX" \
--without-ada --without-cxx --without-cxx-binding --without-manpages \
--without-progs --without-tests --without-debug --without-shared \
--enable-widec --enable-sp-funcs --enable-term-driver \
--enable-interop --disable-home-terminfo --disable-db-install \
--with-fallbacks=ms-terminal,xterm-256color --without-pkg-config \
--disable-pc-files --with-build-cc="${BUILD_CC:-cc}" >/dev/null
make -j"$JOBS" libs >/dev/null
make install.libs >/dev/null
echo "ncurses $NCURSES_VER -> $PREFIX"
} }
# --- busybox-w32 ---
build_busybox_w32() { build_busybox_w32() {
clone_or_update https://github.com/rmyorston/busybox-w32.git "$TP/busybox-w32" checkout https://github.com/rmyorston/busybox-w32.git "$TP/busybox-w32" "$BUSYBOX_W32_REV"
cd "$TP/busybox-w32" cd "$TP/busybox-w32"
# configs: try mingw64 defconfig make -s mingw64u_defconfig >/dev/null
if [[ -f configs/mingw64_defconfig ]]; then make -s -j"$JOBS" CROSS_COMPILE="$T" </dev/null
make mingw64_defconfig
elif [[ -f configs/mingw_defconfig ]]; then
make mingw_defconfig
else
make defconfig
fi
# Cross compile
make -j"$JOBS" CROSS_COMPILE="${HOST}-" \
CONFIG_EXTRA_CFLAGS="-O2" \
busybox.exe 2>&1 || make -j"$JOBS" CROSS_COMPILE="${HOST}-"
mkdir -p "$TP/busybox" mkdir -p "$TP/busybox"
if [[ -f busybox.exe ]]; then
cp -f busybox.exe "$TP/busybox/busybox.exe" cp -f busybox.exe "$TP/busybox/busybox.exe"
else cat >"$TP/busybox/LICENSE.busybox" <<EOF
find . -name 'busybox.exe' | head -1 | xargs -I{} cp -f {} "$TP/busybox/busybox.exe" BusyBox is GPLv2. This busybox.exe is busybox-w32 ($BUSYBOX_W32_REV, Unicode build):
fi https://github.com/rmyorston/busybox-w32
cat > "$TP/busybox/LICENSE.busybox" << 'EOF' Upstream BusyBox: https://busybox.net/
BusyBox is GPLv2. Source: https://github.com/rmyorston/busybox-w32
Upstream BusyBox: https://github.com/mirror/busybox
EOF EOF
ls -la "$TP/busybox/busybox.exe" ls -la "$TP/busybox/busybox.exe"
} }
# --- Unix busybox (optional, for Linux/Termux side-by-side bundle) --- case "${1:-all}" in
build_busybox_unix() { curl) build_curl ;;
clone_or_update https://github.com/mirror/busybox.git "$TP/busybox-src" pdcurses) build_pdcurses ;;
cd "$TP/busybox-src" ncurses) build_ncurses ;;
make defconfig busybox-w32) build_busybox_w32 ;;
# Keep applets reasonable; enable ash + diff all)
make -j"$JOBS" busybox build_curl
mkdir -p "$TP/busybox" build_ncurses
cp -f busybox "$TP/busybox/busybox"
echo "unix busybox -> $TP/busybox/busybox"
}
TARGETS="${1:-all}"
case "$TARGETS" in
curl) build_curl_mingw ;;
pdcurses) build_pdcurses_mingw ;;
busybox-w32) build_busybox_w32 ;;
busybox-unix) build_busybox_unix ;;
all)
build_curl_mingw
build_pdcurses_mingw
build_busybox_w32 build_busybox_w32
;; ;;
*) *)
echo "usage: $0 [all|curl|pdcurses|busybox-w32|busybox-unix]" echo "usage: $0 [all|curl|pdcurses|ncurses|busybox-w32]"
exit 1 exit 1
;; ;;
esac esac
+33
View File
@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Checkout this repo without downloading actions/checkout from GitHub.
# act_runner may already have placed sources in the workspace; otherwise
# clone from the same Gitea that started the job.
set -euo pipefail
SERVER="${GITHUB_SERVER_URL:-${GITEA_SERVER_URL:-}}"
REPO="${GITHUB_REPOSITORY:-${GITEA_REPOSITORY:-}}"
SHA="${GITHUB_SHA:-${GITEA_SHA:-}}"
if [[ -f CMakeLists.txt && -d src ]]; then
echo "==> workspace already has sources"
git rev-parse --short HEAD 2>/dev/null || true
exit 0
fi
if [[ -z "$SERVER" || -z "$REPO" ]]; then
echo "missing GITHUB_SERVER_URL / GITHUB_REPOSITORY" >&2
exit 1
fi
echo "==> git clone $SERVER/$REPO"
git clone --filter=blob:none "$SERVER/$REPO.git" _ci_src
if [[ -n "$SHA" ]]; then
git -C _ci_src fetch --depth 1 origin "$SHA" || git -C _ci_src fetch origin "$SHA"
git -C _ci_src checkout --force "$SHA"
fi
# Move into the workspace root (may already contain act_runner metadata).
shopt -s dotglob nullglob
mv _ci_src/* .
rmdir _ci_src 2>/dev/null || rm -rf _ci_src
test -f CMakeLists.txt
git rev-parse --short HEAD
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
# Point package managers at a China mirror before apt/apk install.
# Gitea runners in CN are far too slow against deb.debian.org /
# archive.ubuntu.com / dl-cdn.alpinelinux.org.
#
# CST_CI_MIRROR=https://mirrors.tuna.tsinghua.edu.cn scripts/ci_pkg_mirror.sh
# Default mirror: Tsinghua TUNA. Override with CST_CI_MIRROR if needed.
set -euo pipefail
MIRROR="${CST_CI_MIRROR:-https://mirrors.tuna.tsinghua.edu.cn}"
MIRROR="${MIRROR%/}"
if [[ -f /etc/alpine-release ]]; then
echo "==> apk mirror -> $MIRROR/alpine"
if [[ -f /etc/apk/repositories ]]; then
sed -i \
-e "s|https\\?://dl-cdn.alpinelinux.org/alpine|${MIRROR}/alpine|g" \
-e "s|https\\?://[^/[:space:]]*/alpine|${MIRROR}/alpine|g" \
/etc/apk/repositories
fi
exit 0
fi
if ! command -v apt-get >/dev/null; then
echo "==> no apt/apk; skip mirror"
exit 0
fi
# Debian vs Ubuntu: different path under the mirror root.
id_like=""
. /etc/os-release 2>/dev/null || true
case "${ID:-}:${ID_LIKE:-}" in
ubuntu:*|*ubuntu*)
dist=ubuntu
;;
debian:*|*debian*|*)
dist=debian
;;
esac
echo "==> apt mirror -> $MIRROR/$dist (and ${dist}-security)"
rewrite() {
local f="$1"
[[ -f "$f" ]] || return 0
# Classic one-line sources
sed -i \
-e "s|https\\?://deb.debian.org/debian-security|${MIRROR}/debian-security|g" \
-e "s|https\\?://deb.debian.org/debian|${MIRROR}/debian|g" \
-e "s|https\\?://security.debian.org/debian-security|${MIRROR}/debian-security|g" \
-e "s|https\\?://security.debian.org|${MIRROR}/debian-security|g" \
-e "s|https\\?://archive.ubuntu.com/ubuntu|${MIRROR}/ubuntu|g" \
-e "s|https\\?://security.ubuntu.com/ubuntu|${MIRROR}/ubuntu|g" \
-e "s|https\\?://ports.ubuntu.com/ubuntu-ports|${MIRROR}/ubuntu-ports|g" \
"$f"
# DEB822 (.sources): URIs: http://deb.debian.org/debian
sed -i \
-e "s|^URIs: *https\\?://deb.debian.org/debian-security|URIs: ${MIRROR}/debian-security|g" \
-e "s|^URIs: *https\\?://deb.debian.org/debian|URIs: ${MIRROR}/debian|g" \
-e "s|^URIs: *https\\?://security.debian.org/debian-security|URIs: ${MIRROR}/debian-security|g" \
-e "s|^URIs: *https\\?://security.debian.org|URIs: ${MIRROR}/debian-security|g" \
-e "s|^URIs: *https\\?://archive.ubuntu.com/ubuntu|URIs: ${MIRROR}/ubuntu|g" \
-e "s|^URIs: *https\\?://security.ubuntu.com/ubuntu|URIs: ${MIRROR}/ubuntu|g" \
"$f"
}
rewrite /etc/apt/sources.list
shopt -s nullglob
for f in /etc/apt/sources.list.d/*.list /etc/apt/sources.list.d/*.sources; do
rewrite "$f"
done
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# CI helper: MinGW-w64 UCRT toolchain via llvm-mingw (no Nix / no /nix).
# Downloads a pinned release, puts it on PATH, then runs bootstrap_windows.sh.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
VER="${CST_LLVM_MINGW_VER:-20241030}"
NAME="llvm-mingw-${VER}-ucrt-ubuntu-20.04-x86_64"
TGZ="/tmp/${NAME}.tar.xz"
# Prefer /opt when writable (root CI); else unpack under $HOME.
if [[ -w /opt || "$(id -u)" -eq 0 ]]; then
DIR="/opt/${NAME}"
PARENT=/opt
else
DIR="${HOME}/${NAME}"
PARENT="$HOME"
fi
if [[ ! -x "$DIR/bin/x86_64-w64-mingw32-gcc" ]]; then
echo "==> download $NAME"
# Prefer CN-reachable GitHub proxies; raw github.com is often unreachable.
urls=(
"https://ghfast.top/https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
"https://ghproxy.net/https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
"https://gitclone.com/github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
"https://github.com/mstorsjo/llvm-mingw/releases/download/${VER}/${NAME}.tar.xz"
)
ok=0
for u in "${urls[@]}"; do
echo " try $u"
if curl -fL --retry 2 --connect-timeout 15 --max-time 600 \
--retry-delay 2 -o "$TGZ" "$u"; then
# Reject tiny error pages that some proxies return with HTTP 200.
sz=$(wc -c <"$TGZ")
if [[ "$sz" -gt 10000000 ]]; then
ok=1
break
fi
echo " too small ($sz bytes), next mirror"
fi
done
[[ $ok -eq 1 ]] || { echo "failed to download llvm-mingw" >&2; exit 1; }
rm -rf "$DIR"
mkdir -p "$PARENT"
tar -C "$PARENT" -xf "$TGZ"
fi
export PATH="$DIR/bin:$PATH"
export MINGW_HOST=x86_64-w64-mingw32
export BUILD_CC="${BUILD_CC:-cc}"
need() { command -v "$1" >/dev/null || { echo "missing $1" >&2; exit 1; }; }
need x86_64-w64-mingw32-gcc
need x86_64-w64-mingw32-windres
need cmake; need ninja; need perl; need zip; need git; need make
if ! printf '#include <stdio.h>\n' | x86_64-w64-mingw32-gcc -dM -E - | grep -qw _UCRT; then
echo "toolchain is not UCRT" >&2
exit 1
fi
# Do not set a global insteadOf: a single broken proxy (e.g. gitclone 502)
# would take down every third_party clone. build_third_party_mingw.sh tries
# several mirrors per repo instead.
echo "==> bootstrap_windows.sh"
exec bash scripts/bootstrap_windows.sh "$ROOT"
+106
View File
@@ -0,0 +1,106 @@
# Install or update CST-Agent on Windows from a Gitea release.
#
# irm https://git.whcself.xyz/WHC/CST-agent/raw/branch/main/scripts/install_windows.ps1 | iex
# powershell -ExecutionPolicy Bypass -File install_windows.ps1 [-Version 0.6.0] [-Prefix DIR] [-Zip FILE] [-NoPath]
#
# Installs into %LOCALAPPDATA%\Programs\cst-agent (no admin rights needed),
# adds it to the user PATH and leaves cst-agent-update.cmd beside the exe,
# which reruns this script for that folder. Environment overrides:
# CST_VERSION, CST_PREFIX, CST_GITEA (server URL), CST_REPO (owner/name).
# Needs Windows 10 1903+ / Server 2022+ (UTF-8 process code page).
# Keep this file ASCII: Windows PowerShell 5.1 reads BOM-less scripts in the
# ANSI code page.
param(
[string]$Version = $env:CST_VERSION,
[string]$Prefix = $env:CST_PREFIX,
[string]$Zip = "",
[switch]$NoPath
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$Server = if ($env:CST_GITEA) { $env:CST_GITEA.TrimEnd("/") } else { "https://git.whcself.xyz" }
$Repo = if ($env:CST_REPO) { $env:CST_REPO } else { "WHC/CST-agent" }
if (-not $Prefix) { $Prefix = Join-Path $env:LOCALAPPDATA "Programs\cst-agent" }
$Prefix = [IO.Path]::GetFullPath($Prefix)
$build = [Environment]::OSVersion.Version.Build
if ($build -lt 18362) {
throw "CST-Agent needs Windows 10 1903 (build 18362) or newer; this is build $build."
}
$work = Join-Path ([IO.Path]::GetTempPath()) ("cst-agent-install-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force $work | Out-Null
try {
if (-not $Zip) {
$api = "$Server/api/v1/repos/$Repo/releases"
$url = if (-not $Version -or $Version -eq "latest") { "$api/latest" } else { "$api/tags/v$($Version.TrimStart('v'))" }
Write-Host "==> $url"
try {
$rel = Invoke-RestMethod -Uri $url -UseBasicParsing
} catch {
throw "no release found at $url ($($_.Exception.Message))"
}
$asset = $rel.assets | Where-Object { $_.name -like "*-windows-x86_64.zip" } | Select-Object -First 1
if (-not $asset) { throw "release $($rel.tag_name) has no *-windows-x86_64.zip" }
$Zip = Join-Path $work $asset.name
Write-Host "==> download $($asset.name) ($($rel.tag_name))"
Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $Zip -UseBasicParsing
$sum = $rel.assets | Where-Object { $_.name -eq "$($asset.name).sha256" } | Select-Object -First 1
if ($sum) {
$sumFile = Join-Path $work $sum.name
Invoke-WebRequest -Uri $sum.browser_download_url -OutFile $sumFile -UseBasicParsing
$want = ((Get-Content $sumFile -Raw).Trim() -split "\s+")[0].ToLower()
$got = (Get-FileHash -Algorithm SHA256 $Zip).Hash.ToLower()
if ($want -ne $got) { throw "sha256 mismatch for $($asset.name): expected $want, got $got" }
Write-Host "==> sha256 ok"
}
}
Expand-Archive -Path $Zip -DestinationPath $work -Force
$src = Join-Path $work "windows-x86_64"
if (-not (Test-Path (Join-Path $src "cst-agent.exe"))) { throw "$Zip has no windows-x86_64\cst-agent.exe" }
New-Item -ItemType Directory -Force $Prefix | Out-Null
# A running exe cannot be overwritten but can be renamed out of the way.
foreach ($name in @("cst-agent.exe", "busybox.exe")) {
$dst = Join-Path $Prefix $name
$old = "$dst.old"
if (Test-Path $old) { Remove-Item -Force $old -ErrorAction SilentlyContinue }
if (Test-Path $dst) {
try { Remove-Item -Force $dst } catch { Rename-Item -Force $dst "$name.old" }
}
}
Copy-Item -Force -Recurse (Join-Path $src "*") $Prefix
$update = Join-Path $Prefix "cst-agent-update.cmd"
if (-not (Test-Path $update)) {
Set-Content -Encoding Ascii -Path $update -Value @(
"@echo off",
"rem Update CST-Agent in this folder from the latest release.",
"powershell -NoProfile -ExecutionPolicy Bypass -File `"%~dp0install_windows.ps1`" -Prefix `"%~dp0.`" %*"
)
}
if (-not (Test-Path (Join-Path $Prefix "install_windows.ps1")) -and $PSCommandPath) {
Copy-Item -Force $PSCommandPath (Join-Path $Prefix "install_windows.ps1")
}
if (-not $NoPath) {
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$parts = @($userPath -split ";" | Where-Object { $_ })
if (-not ($parts | Where-Object { $_.TrimEnd("\") -ieq $Prefix.TrimEnd("\") })) {
[Environment]::SetEnvironmentVariable("Path", (($parts + $Prefix) -join ";"), "User")
Write-Host "==> added $Prefix to the user PATH (open a new terminal to use it)"
}
if (-not (($env:Path -split ";") | Where-Object { $_.TrimEnd("\") -ieq $Prefix.TrimEnd("\") })) {
$env:Path = "$env:Path;$Prefix"
}
}
$ver = (& (Join-Path $Prefix "cst-agent.exe") --version) 2>&1 | Select-Object -First 1
Write-Host "==> installed $ver in $Prefix"
Write-Host "Run 'cst-agent' inside a project folder; update later with 'cst-agent-update'."
} finally {
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
}
+27 -7
View File
@@ -1,18 +1,38 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Assemble dist/windows-x86_64 from MinGW TUI build + busybox. # Assemble dist/windows-x86_64/ and dist/cst-agent-VERSION-windows-x86_64.zip
# from the cross build (build-win-tui, else build-win; CST_WIN_BUILD=DIR
# picks another, e.g. build-msys2) and busybox-w32.
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REL="$ROOT/dist/windows-x86_64" REL="$ROOT/dist/windows-x86_64"
EXE="$ROOT/build-win-tui/cst-agent.exe" if [[ -n "${CST_WIN_BUILD:-}" ]]; then
[[ -f "$EXE" ]] || EXE="$ROOT/build-win/cst-agent.exe" EXE="$ROOT/$CST_WIN_BUILD/cst-agent.exe"
else
EXE="$ROOT/build-win-tui/cst-agent.exe"
[[ -f "$EXE" ]] || EXE="$ROOT/build-win/cst-agent.exe"
fi
BB="$ROOT/third_party/busybox/busybox.exe" BB="$ROOT/third_party/busybox/busybox.exe"
DLL="/usr/x86_64-w64-mingw32/bin/libwinpthread-1.dll" [[ -f "$EXE" ]] || { echo "no cst-agent.exe; build first" >&2; exit 1; }
[[ -f "$BB" ]] || { echo "no busybox.exe; scripts/build_third_party_mingw.sh busybox-w32" >&2; exit 1; }
ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' "$ROOT/include/cst_version.h" | paste -sd.)"
strip="${MINGW_HOST-x86_64-w64-mingw32}"
strip="${strip:+$strip-}strip"
command -v "$strip" >/dev/null || strip=strip # MSYS2: unprefixed
rm -rf "$REL"
mkdir -p "$REL" mkdir -p "$REL"
cp -f "$EXE" "$REL/cst-agent.exe" cp -f "$EXE" "$REL/cst-agent.exe"
"$strip" "$REL/cst-agent.exe"
cp -f "$BB" "$REL/busybox.exe" cp -f "$BB" "$REL/busybox.exe"
cp -f "$ROOT/third_party/busybox/LICENSE.busybox" "$REL/" cp -f "$ROOT/third_party/busybox/LICENSE.busybox" "$REL/"
cp -f "$ROOT/docs/README-windows.md" "$REL/" cp -f "$ROOT/docs/README-windows.md" "$REL/README.md"
[[ -f "$DLL" ]] && cp -f "$DLL" "$REL/" cp -f "$ROOT/scripts/windows/cst-agent-update.cmd" "$ROOT/scripts/install_windows.ps1" "$REL/"
echo "$ver" >"$REL/VERSION"
zip="$ROOT/dist/cst-agent-$ver-windows-x86_64.zip"
rm -f "$zip"
(cd "$ROOT/dist" && zip -qr "$zip" windows-x86_64)
(cd "$ROOT/dist" && sha256sum "$(basename "$zip")" >"$(basename "$zip").sha256")
ls -la "$REL" ls -la "$REL"
file "$REL/cst-agent.exe" echo "$zip"
@@ -0,0 +1,350 @@
--- a/ncurses/curses.priv.h
+++ b/ncurses/curses.priv.h
@@ -2294,7 +2294,10 @@
#if defined(_NC_WINDOWS_NATIVE) && !defined(_NC_MSC) && !USE_NAMED_PIPES
/*
* MinGW has wide-character functions, but they do not work correctly.
+ * Declare the originals first so a later <wchar.h> is not macro-expanded.
*/
+#include <stdlib.h>
+#include <wchar.h>
extern int __MINGW_NOTHROW _nc_wctomb(char *, wchar_t);
#define wctomb(s,wc) _nc_wctomb(s,wc)
#define wcrtomb(s,wc,n) _nc_wctomb(s,wc)
--- a/ncurses/tinfo/lib_win32con.c
+++ b/ncurses/tinfo/lib_win32con.c
@@ -653,6 +653,21 @@
return result;
}
+/* Whether handle_mouse() turns this record into an event: wheel, a button
+ * change (press or release), or a drag. */
+static bool
+mouse_wanted(const SCREEN *sp, const MOUSE_EVENT_RECORD *mer)
+{
+ int held = (int) (mer->dwButtonState & BUTTON_MASK);
+
+ if (mer->dwEventFlags & MOUSE_WHEELED)
+ return TRUE;
+ if (held != sp->_drv_mouse_new_buttons)
+ return TRUE;
+ return (mer->dwEventFlags & MOUSE_MOVED) && held
+ && (sp->_mouse_mask & REPORT_MOUSE_POSITION);
+}
+
#define AdjustY() (WINCONSOLE.buffered \
? 0 \
: (int) WINCONSOLE.SBI.srWindow.Top)
@@ -661,36 +676,47 @@
handle_mouse(SCREEN *sp, MOUSE_EVENT_RECORD mer)
{
MEVENT work;
- bool result = FALSE;
+ int old_buttons;
+ int new_buttons;
assert(sp);
- sp->_drv_mouse_old_buttons = sp->_drv_mouse_new_buttons;
- sp->_drv_mouse_new_buttons = mer.dwButtonState & BUTTON_MASK;
+ if (sp->_drv_mouse_tail >= FIFO_SIZE)
+ return FALSE;
- /*
- * We're only interested if the button is pressed or released.
- * FIXME: implement continuous event-tracking.
- */
- if (sp->_drv_mouse_new_buttons != sp->_drv_mouse_old_buttons) {
- memset(&work, 0, sizeof(work));
+ memset(&work, 0, sizeof(work));
+ work.x = mer.dwMousePosition.X;
+ work.y = mer.dwMousePosition.Y - AdjustY();
+
+ /* the wheel reports like xterm: button 4 up, button 5 down */
+ if (mer.dwEventFlags & MOUSE_WHEELED) {
+ work.bstate = ((SHORT) HIWORD(mer.dwButtonState) > 0)
+ ? BUTTON4_PRESSED
+ : BUTTON5_PRESSED;
+ sp->_drv_mouse_fifo[sp->_drv_mouse_tail++] = work;
+ return TRUE;
+ }
- if (sp->_drv_mouse_new_buttons) {
- work.bstate |= decode_mouse(sp, sp->_drv_mouse_new_buttons);
+ old_buttons = sp->_drv_mouse_new_buttons;
+ new_buttons = (int) (mer.dwButtonState & BUTTON_MASK);
+ sp->_drv_mouse_old_buttons = old_buttons;
+ sp->_drv_mouse_new_buttons = new_buttons;
+
+ if (new_buttons != old_buttons) {
+ if (new_buttons & ~old_buttons) {
+ work.bstate = decode_mouse(sp, new_buttons & ~old_buttons);
} else {
/* cf: BUTTON_PRESSED, BUTTON_RELEASED */
- work.bstate |= (decode_mouse(sp, sp->_drv_mouse_old_buttons)
- >> 1);
- result = TRUE;
+ work.bstate = decode_mouse(sp, old_buttons & ~new_buttons) >> 1;
}
-
- work.x = mer.dwMousePosition.X;
- work.y = mer.dwMousePosition.Y - AdjustY();
-
- sp->_drv_mouse_fifo[sp->_drv_mouse_tail] = work;
- sp->_drv_mouse_tail += 1;
+ } else if ((mer.dwEventFlags & MOUSE_MOVED) && new_buttons
+ && (sp->_mouse_mask & REPORT_MOUSE_POSITION)) {
+ work.bstate = REPORT_MOUSE_POSITION; /* drag */
+ } else {
+ return FALSE;
}
- return result;
+ sp->_drv_mouse_fifo[sp->_drv_mouse_tail++] = work;
+ return TRUE;
}
static int
@@ -813,6 +839,13 @@
returnCode(found);
}
+#if USE_WIDEC_SUPPORT
+/* UTF-8 bytes of the last non-ASCII key event not yet read */
+static int utf8_pend[4];
+static int utf8_len, utf8_pos;
+static unsigned utf8_high;
+#endif
+
NCURSES_EXPORT(int)
_nc_console_twait(
const SCREEN *sp,
@@ -845,6 +878,14 @@
TR(TRACE_IEVENT, ("start twait: hdl=%p, %d milliseconds, mode: %d",
hdl, milliseconds, mode));
+#if USE_WIDEC_SUPPORT
+ if ((mode & TW_INPUT) && utf8_pos < utf8_len) {
+ if (timeleft)
+ *timeleft = milliseconds;
+ return TW_INPUT;
+ }
+#endif
+
if (milliseconds < 0)
milliseconds = NC_INFINITY;
@@ -940,9 +981,7 @@
continue;
case MOUSE_EVENT:
T(("twait:event MOUSE_EVENT"));
- if (decode_mouse(sp,
- (inp_rec.Event.MouseEvent.dwButtonState
- & BUTTON_MASK)) == 0) {
+ if (!mouse_wanted(sp, &inp_rec.Event.MouseEvent)) {
CONSUME();
} else if (mode & TW_MOUSE) {
code = TW_MOUSE;
@@ -1024,6 +1063,16 @@
T((T_CALLED("lib_win32con::_nc_console_read(%p)"), sp));
+#if USE_WIDEC_SUPPORT
+ /* wget_wch() decodes the input as a byte stream in the locale's
+ * multibyte encoding (UTF-8), so non-ASCII UTF-16 key events are
+ * handed over one UTF-8 byte per call. */
+ if (utf8_pos < utf8_len) {
+ *buf = utf8_pend[utf8_pos++];
+ returnCode(1);
+ }
+#endif
+
while ((b = read_keycode(hdl, &inp_rec, 1, &nRead))) {
if (b && nRead > 0) {
if (rc < 0)
@@ -1034,6 +1083,36 @@
continue;
*buf = (int) inp_rec.Event.KeyEventChar;
vk = inp_rec.Event.KeyEvent.wVirtualKeyCode;
+#if USE_WIDEC_SUPPORT
+ if (*buf >= 0x80) {
+ unsigned cp = (unsigned) *buf;
+ if (cp >= 0xD800 && cp < 0xDC00) {
+ utf8_high = cp;
+ continue;
+ }
+ if (cp >= 0xDC00 && cp < 0xE000) {
+ if (!utf8_high)
+ continue;
+ cp = 0x10000 + ((utf8_high - 0xD800) << 10) + (cp - 0xDC00);
+ }
+ utf8_high = 0;
+ utf8_len = 0;
+ if (cp < 0x800) {
+ utf8_pend[utf8_len++] = (int) (0xC0 | (cp >> 6));
+ } else if (cp < 0x10000) {
+ utf8_pend[utf8_len++] = (int) (0xE0 | (cp >> 12));
+ utf8_pend[utf8_len++] = (int) (0x80 | ((cp >> 6) & 0x3F));
+ } else {
+ utf8_pend[utf8_len++] = (int) (0xF0 | (cp >> 18));
+ utf8_pend[utf8_len++] = (int) (0x80 | ((cp >> 12) & 0x3F));
+ utf8_pend[utf8_len++] = (int) (0x80 | ((cp >> 6) & 0x3F));
+ }
+ utf8_pend[utf8_len++] = (int) (0x80 | (cp & 0x3F));
+ *buf = utf8_pend[0];
+ utf8_pos = 1;
+ break;
+ }
+#endif
/*
* There are 24 virtual function-keys, and typically
* 12 function-keys on a keyboard. Use the shift-modifier
--- a/ncurses/widechar/widechars.c
+++ b/ncurses/widechar/widechars.c
@@ -97,20 +97,10 @@
wchar_t temp;
if (s != NULL && n != 0) {
+ /* _nc_mbtowc() returns the bytes used, not a character count */
count = _nc_mbtowc(&temp, s, n);
- if (count == 1) {
- int check = WideCharToMultiByte(CP_UTF8,
- 0,
- &temp,
- 1,
- NULL,
- 0, /* compute length only */
- NULL,
- NULL);
- TR(TRACE_BITS, ("\tcheck ->%d\n", check));
- if (check > 0 && (size_t) check <= n) {
- result = check;
- }
+ if (count > 0 && (size_t) count <= n) {
+ result = count;
}
} else {
result = 0;
--- a/ncurses/win32con/win32_driver.c
+++ b/ncurses/win32con/win32_driver.c
@@ -87,8 +87,13 @@
if (ch & A_BOLD)
res |= FOREGROUND_INTENSITY;
- if (ch & A_DIM)
- res |= BACKGROUND_INTENSITY;
+ /* dim lowers the foreground: bright -> normal, light gray -> dark gray */
+ if ((ch & A_DIM) && !(ch & A_BOLD)) {
+ if (res & FOREGROUND_INTENSITY)
+ res &= ~FOREGROUND_INTENSITY;
+ else if ((res & 7) == 7)
+ res = (res & ~7) | FOREGROUND_INTENSITY;
+ }
return res;
}
@@ -159,7 +164,6 @@
int y, int x, cchar_t *str, int limit)
{
int actual = 0;
- MakeArray(ci, CHAR_INFO, limit);
COORD loc, siz;
SMALL_RECT rec;
int i;
@@ -169,39 +173,71 @@
AssertTCB();
SetSP();
- for (i = actual = 0; i < limit; i++) {
- ch = str[i];
- if (isWidecExt(ch))
- continue;
- ci[actual].CharInfoChar = CharOf(ch);
- ci[actual].Attributes = MapAttr(WINCONSOLE.SBI.wAttributes,
- AttrOf(ch));
- if (AttrOf(ch) & A_ALTCHARSET) {
- if (_nc_wacs) {
- int which = CharOf(ch);
- if (which > 0
- && which < ACS_LEN
- && CharOf(_nc_wacs[which]) != 0) {
- ci[actual].CharInfoChar = CharOf(_nc_wacs[which]);
+ /*
+ * The console stores a double-width character as two cells flagged
+ * leading/trailing byte. Never start or end a write inside one, and
+ * emit both halves so the columns after it stay put.
+ */
+ if (limit > 0 && x > 0 && isWidecExt(str[0])) {
+ --str;
+ --x;
+ ++limit;
+ }
+ if (limit > 0 && x + limit < screen_columns(sp) && isWidecExt(str[limit]))
+ ++limit;
+
+ {
+ MakeArray(ci, CHAR_INFO, limit);
+
+ for (i = actual = 0; i < limit; i++) {
+ ch = str[i];
+ if (isWidecExt(ch)) {
+ if (actual > 0
+ && (ci[actual - 1].Attributes & COMMON_LVB_LEADING_BYTE)) {
+ ci[actual] = ci[actual - 1];
+ ci[actual].Attributes = (WORD)
+ ((ci[actual].Attributes & ~COMMON_LVB_LEADING_BYTE)
+ | COMMON_LVB_TRAILING_BYTE);
} else {
ci[actual].CharInfoChar = ' ';
+ ci[actual].Attributes = MapAttr(WINCONSOLE.SBI.wAttributes,
+ AttrOf(ch));
}
+ ++actual;
+ continue;
}
+ ci[actual].CharInfoChar = CharOf(ch);
+ ci[actual].Attributes = MapAttr(WINCONSOLE.SBI.wAttributes,
+ AttrOf(ch));
+ if (AttrOf(ch) & A_ALTCHARSET) {
+ if (_nc_wacs) {
+ int which = CharOf(ch);
+ if (which > 0
+ && which < ACS_LEN
+ && CharOf(_nc_wacs[which]) != 0) {
+ ci[actual].CharInfoChar = CharOf(_nc_wacs[which]);
+ } else {
+ ci[actual].CharInfoChar = ' ';
+ }
+ }
+ }
+ if (i + 1 < limit && isWidecExt(str[i + 1]))
+ ci[actual].Attributes |= COMMON_LVB_LEADING_BYTE;
+ ++actual;
}
- ++actual;
- }
- loc.X = (SHORT) 0;
- loc.Y = (SHORT) 0;
- siz.X = (SHORT) actual;
- siz.Y = 1;
-
- rec.Left = (SHORT) x;
- rec.Top = (SHORT) (y + AdjustY());
- rec.Right = (SHORT) (x + limit - 1);
- rec.Bottom = rec.Top;
+ loc.X = (SHORT) 0;
+ loc.Y = (SHORT) 0;
+ siz.X = (SHORT) actual;
+ siz.Y = 1;
+
+ rec.Left = (SHORT) x;
+ rec.Top = (SHORT) (y + AdjustY());
+ rec.Right = (SHORT) (x + actual - 1);
+ rec.Bottom = rec.Top;
- return write_screen(WINCONSOLE.hdl, ci, siz, loc, &rec);
+ return write_screen(WINCONSOLE.hdl, ci, siz, loc, &rec);
+ }
}
#define con_write(tcb, y, x, str, n) con_write16(tcb, y, x, str, n)
#else
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Publish dist/cst-agent-VERSION-windows-x86_64.zip (+ .sha256) to the Gitea
# release vVERSION, creating the release (and its tag at HEAD) when missing.
# GITEA_TOKEN=... scripts/release_windows.sh [--build]
# --build runs scripts/bootstrap_windows.sh first. The token is only read from
# the environment and handed to curl on stdin: it never appears in argv, on
# disk or in the output. Assets of the same name are replaced.
#
# CI: set GITEA_TOKEN (or GITHUB_TOKEN), CST_GITEA, CST_REPO. The remote-branch
# check is skipped under GITEA_ACTIONS / GITHUB_ACTIONS / CI.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
SERVER="${CST_GITEA:-${GITHUB_SERVER_URL:-https://git.whcself.xyz}}"
REPO="${CST_REPO:-${GITHUB_REPOSITORY:-WHC/CST-agent}}"
API="$SERVER/api/v1/repos/$REPO"
TOKEN="${GITEA_TOKEN:-${GITHUB_TOKEN:-}}"
[[ -n "$TOKEN" ]] || {
echo "set GITEA_TOKEN (a Gitea token with write:repository)" >&2
exit 1
}
command -v python3 >/dev/null || { echo "missing python3" >&2; exit 1; }
cd "$ROOT"
[[ "${1:-}" == --build ]] && scripts/bootstrap_windows.sh "$ROOT"
ver="$(sed -n 's/^#define CST_VERSION_\(MAJOR\|MINOR\|PATCH\) \([0-9]*\)$/\2/p' include/cst_version.h | paste -sd.)"
tag="v$ver"
zip="dist/cst-agent-$ver-windows-x86_64.zip"
for f in "$zip" "$zip.sha256"; do
[[ -f "$f" ]] || { echo "missing $f (scripts/package_windows.sh, or --build)" >&2; exit 1; }
done
# If this job was triggered by a version tag, it must match cst_version.h.
if [[ "${GITHUB_REF:-}" == refs/tags/v* ]]; then
ref_tag="${GITHUB_REF#refs/tags/}"
if [[ "$ref_tag" != "$tag" ]]; then
echo "pushed tag $ref_tag != cst_version.h $tag; bump include/cst_version.h" >&2
exit 1
fi
fi
api() { # METHOD PATH [curl args...]
local m="$1" p="$2"
shift 2
printf 'Authorization: token %s\n' "$TOKEN" |
curl -fsS -X "$m" -H @- "$API$p" "$@"
}
json() { # CODE [ARGS...]: d = the JSON on stdin, ARGS in sys.argv[1:]
local code="$1"
shift
python3 -c "import json,sys; d=json.load(sys.stdin); $code" "$@"
}
if rel="$(api GET "/releases/tags/$tag" 2>/dev/null)"; then
echo "==> release $tag exists"
else
head="$(git rev-parse HEAD)"
if [[ -z "${CI:-}${GITEA_ACTIONS:-}${GITHUB_ACTIONS:-}" ]]; then
if ! git branch -r --contains "$head" 2>/dev/null | grep -q .; then
echo "HEAD $head is not on any remote branch; push it first" >&2
exit 1
fi
fi
echo "==> creating release $tag at $head"
body="$(python3 -c 'import json,sys; print(json.dumps({"tag_name": sys.argv[1], "target_commitish": sys.argv[2], "name": "CST-Agent " + sys.argv[1], "body": "Windows x86_64: unzip, or install with install_windows.ps1 from the zip.\\n\\nArtifacts: `cst-agent-VERSION-windows-x86_64.zip` + `.sha256`."}))' "$tag" "$head")"
rel="$(api POST /releases -H 'Content-Type: application/json' -d "$body")"
fi
id="$(json 'print(d["id"])' <<<"$rel")"
for f in "$zip" "$zip.sha256"; do
name="$(basename "$f")"
old="$(json "print(' '.join(str(a['id']) for a in d.get('assets') or [] if a['name'] == sys.argv[1]))" "$name" <<<"$rel")"
for aid in $old; do
echo "==> replacing $name"
api DELETE "/releases/$id/assets/$aid" >/dev/null
done
echo "==> upload $name"
api POST "/releases/$id/assets?name=$name" -F "attachment=@$f" >/dev/null
done
echo "$SERVER/$REPO/releases/tag/$tag"
-27
View File
@@ -1,27 +0,0 @@
@echo off
REM Run ON Windows after copying dist/windows-x86_64 (or a full git checkout + MSYS2 build).
REM This script only documents env + launch; prefer bootstrap_windows.sh on Linux for cross-build.
setlocal
if "%OPENAI_API_KEY%"=="" if "%CST_API_KEY%"=="" (
echo Set OPENAI_API_KEY or CST_API_KEY first. See docs\env_openai.md
echo Example:
echo set OPENAI_API_KEY=sk-...
echo set OPENAI_BASE_URL=https://api.openai.com/v1
echo set OPENAI_MODEL=gpt-4.1-mini
exit /b 1
)
if not exist cst-agent.exe (
echo cst-agent.exe not found in %CD%
echo Copy dist\windows-x86_64\* here, or build with MSYS2/MinGW.
exit /b 1
)
if not exist busybox.exe (
echo WARNING: busybox.exe missing — run_shell may fail. Place busybox.exe beside cst-agent.exe
)
echo Launching CST-Agent...
cst-agent.exe --workspace .
endlocal
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Run the cross-built Windows tests inside the test VMs (scripts/winvm/vm.sh).
#
# scripts/win_test.sh [-b BUILD_DIR] [-v "en zh"] [-u "Administrator tester 测试"] [-f FILTER]
#
# BUILD_DIR (default build-win) must contain test_*.exe from the MinGW build;
# busybox.exe is taken from third_party/busybox/. VMs that are not running are
# started and stopped again afterwards. Exit status is the number of failed
# variant/user runs.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
VM="$ROOT/scripts/winvm/vm.sh"
BUILD="$ROOT/build-win"
VARIANTS="en zh"
USERS_LIST="Administrator tester 测试"
FILTER="*"
while getopts "b:v:u:f:" o; do
case "$o" in
b) BUILD="$(cd "$OPTARG" && pwd)" ;;
v) VARIANTS="$OPTARG" ;;
u) USERS_LIST="$OPTARG" ;;
f) FILTER="$OPTARG" ;;
*) sed -n '2,8p' "$0"; exit 2 ;;
esac
done
shopt -s nullglob
exes=("$BUILD"/test_*.exe)
[[ ${#exes[@]} -gt 0 ]] || { echo "no test_*.exe in $BUILD" >&2; exit 1; }
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/bundle/bin" "$stage/bundle/data/tests"
cp "${exes[@]}" "$stage/bundle/bin/"
for f in "$BUILD"/*.dll "$ROOT/third_party/busybox/busybox.exe"; do
[[ -f "$f" ]] && cp "$f" "$stage/bundle/bin/"
done
cp -r "$ROOT/tests/fixtures" "$stage/bundle/data/tests/"
cp "$ROOT/scripts/winvm/run_tests.ps1" "$stage/bundle/"
tar -C "$stage" -cf "$stage/bundle.tar" bundle
total_fail=0
summary=()
for v in $VARIANTS; do
started=0
if [[ "$("$VM" status "$v")" != running ]]; then
"$VM" start "$v"
started=1
fi
"$VM" ssh "$v" 'Remove-Item -Recurse -Force C:\cst-test\bundle -ErrorAction SilentlyContinue; New-Item -ItemType Directory -Force C:\cst-test | Out-Null' >/dev/null
"$VM" put "$v" "$stage/bundle.tar" "C:/cst-test/bundle.tar"
"$VM" ssh "$v" 'tar -xf C:\cst-test\bundle.tar -C C:\cst-test; Remove-Item C:\cst-test\bundle.tar' >/dev/null
for u in $USERS_LIST; do
echo "===== $v / $u"
rc=0
"$VM" ps "$v" "$u" "$stage/bundle/run_tests.ps1" -Bundle 'C:\cst-test\bundle' -Filter "'$FILTER'" || rc=$?
summary+=("$v/$u: $([[ $rc == 0 ]] && echo ok || echo FAIL)")
[[ $rc == 0 ]] || total_fail=$((total_fail + 1))
done
[[ $started == 1 ]] && "$VM" stop "$v"
done
echo
printf '%s\n' "${summary[@]}"
exit "$total_fail"
+4
View File
@@ -0,0 +1,4 @@
@echo off
rem Update CST-Agent in this folder from the latest release.
rem cst-agent-update [-Version 0.6.0]
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0install_windows.ps1" -Prefix "%~dp0." %*
+98
View File
@@ -0,0 +1,98 @@
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
<settings pass="windowsPE">
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<SetupUILanguage>
<UILanguage>en-US</UILanguage>
</SetupUILanguage>
<InputLocale>en-US</InputLocale>
<SystemLocale>en-US</SystemLocale>
<UILanguage>en-US</UILanguage>
<UserLocale>en-US</UserLocale>
</component>
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<DiskConfiguration>
<Disk wcm:action="add">
<DiskID>0</DiskID>
<WillWipeDisk>true</WillWipeDisk>
<CreatePartitions>
<CreatePartition wcm:action="add">
<Order>1</Order>
<Type>Primary</Type>
<Extend>true</Extend>
</CreatePartition>
</CreatePartitions>
<ModifyPartitions>
<ModifyPartition wcm:action="add">
<Order>1</Order>
<PartitionID>1</PartitionID>
<Active>true</Active>
<Format>NTFS</Format>
<Label>Windows</Label>
<Letter>C</Letter>
</ModifyPartition>
</ModifyPartitions>
</Disk>
</DiskConfiguration>
<ImageInstall>
<OSImage>
<InstallFrom>
<MetaData wcm:action="add">
<Key>/IMAGE/NAME</Key>
<Value>@IMAGE_NAME@</Value>
</MetaData>
</InstallFrom>
<InstallTo>
<DiskID>0</DiskID>
<PartitionID>1</PartitionID>
</InstallTo>
</OSImage>
</ImageInstall>
<UserData>
<AcceptEula>true</AcceptEula>
<FullName>CST</FullName>
<Organization>CST</Organization>
</UserData>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<ComputerName>cst-win</ComputerName>
<TimeZone>China Standard Time</TimeZone>
</component>
</settings>
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideOnlineAccountScreens>true</HideOnlineAccountScreens>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<ProtectYourPC>3</ProtectYourPC>
<NetworkLocation>Work</NetworkLocation>
</OOBE>
<UserAccounts>
<AdministratorPassword>
<Value>@ADMIN_PASSWORD@</Value>
<PlainText>true</PlainText>
</AdministratorPassword>
</UserAccounts>
<AutoLogon>
<Enabled>true</Enabled>
<Username>Administrator</Username>
<Password>
<Value>@ADMIN_PASSWORD@</Value>
<PlainText>true</PlainText>
</Password>
<LogonCount>1</LogonCount>
</AutoLogon>
<FirstLogonCommands>
<SynchronousCommand wcm:action="add">
<Order>1</Order>
<CommandLine>powershell -NoProfile -ExecutionPolicy Bypass -Command "foreach ($d in Get-PSDrive -PSProvider FileSystem) { $p = Join-Path $d.Root 'setup.ps1'; if (Test-Path $p) { &amp; $p; break } }"</CommandLine>
<Description>Enable OpenSSH</Description>
<RequiresUserInput>false</RequiresUserInput>
</SynchronousCommand>
</FirstLogonCommands>
</component>
</settings>
</unattend>
+28
View File
@@ -0,0 +1,28 @@
# Idempotent VM setup after install: test users, sshd access, console defaults.
# Users come from C:\Windows\Temp\cst-users.txt ("name<TAB>password", UTF-8), deleted afterwards.
$ErrorActionPreference = 'Stop'
$list = 'C:\Windows\Temp\cst-users.txt'
if (Test-Path $list) {
foreach ($line in Get-Content -Path $list -Encoding UTF8) {
$name, $pw = $line -split "`t", 2
if (-not $name) { continue }
$sec = ConvertTo-SecureString $pw -AsPlainText -Force
if (Get-LocalUser -Name $name -ErrorAction SilentlyContinue) {
Set-LocalUser -Name $name -Password $sec
} else {
New-LocalUser -Name $name -Password $sec -PasswordNeverExpires -AccountNeverExpires | Out-Null
}
foreach ($g in 'Users', 'OpenSSH Users') {
Add-LocalGroupMember -Group $g -Member $name -ErrorAction SilentlyContinue
}
"user ok: $name"
}
Remove-Item -Force $list
}
# the built-in rule only covers the Private profile; QEMU user networking is Public
Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -Profile Any -Enabled True
Set-SConfig -AutoLaunch $false
New-Item -ItemType Directory -Force C:\cst-test | Out-Null
"provision ok"
+51
View File
@@ -0,0 +1,51 @@
# Run cross-built test_*.exe inside the VM as the current user.
# run_tests.ps1 BUNDLE [FILTER]
# BUNDLE holds bin\ (exes, busybox.exe) and data\ (repo-relative fixtures).
# Each run copies data\ into a per-user work dir and uses it as the cwd.
param([Parameter(Mandatory = $true)][string]$Bundle, [string]$Filter = '*')
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)
$OutputEncoding = [Console]::OutputEncoding
$work = Join-Path $env:TEMP ('cst-test-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
New-Item -ItemType Directory -Force $work | Out-Null
Copy-Item -Recurse -Force (Join-Path $Bundle 'data\*') $work
$env:PATH = (Join-Path $Bundle 'bin') + ';' + $env:PATH
$acp = [Text.Encoding]::Default.CodePage
"== user=$env:USERNAME acp=$acp work=$work"
$fail = 0
$pass = 0
foreach ($exe in Get-ChildItem (Join-Path $Bundle 'bin') -Filter "test_$Filter.exe" | Sort-Object Name) {
$name = $exe.BaseName
$out = Join-Path $work "$name.out"
$err = Join-Path $work "$name.err"
$sw = [Diagnostics.Stopwatch]::StartNew()
$p = Start-Process -FilePath $exe.FullName -WorkingDirectory $work -NoNewWindow -PassThru `
-RedirectStandardOutput $out -RedirectStandardError $err
$null = $p.Handle
if (-not $p.WaitForExit(300000)) {
& taskkill.exe /T /F /PID $p.Id | Out-Null
$code = 'timeout'
} else {
$p.WaitForExit()
$code = $p.ExitCode
}
$secs = [math]::Round($sw.Elapsed.TotalSeconds, 1)
if ($code -eq 0) {
$pass++
"PASS $name (${secs}s)"
} else {
$fail++
"FAIL $name (exit $code, ${secs}s)"
foreach ($f in $out, $err) {
if (Test-Path $f) {
Get-Content -Path $f -Encoding UTF8 -Tail 25 | ForEach-Object { " $_" }
}
}
}
}
"== $pass passed, $fail failed"
Set-Location $env:TEMP
Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue
exit $fail
+28
View File
@@ -0,0 +1,28 @@
Start-Transcript -Path C:\setup.log -Append
$ErrorActionPreference = 'Continue'
if (-not (Get-Service sshd -ErrorAction SilentlyContinue)) {
Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
}
Set-Service -Name sshd -StartupType Automatic
Start-Service sshd
$keys = 'C:\ProgramData\ssh\administrators_authorized_keys'
Get-Content (Join-Path $PSScriptRoot 'vm_key.pub') | Set-Content -Path $keys -Encoding ascii
icacls $keys /inheritance:r /grant 'Administrators:F' /grant 'SYSTEM:F'
New-ItemProperty -Path HKLM:\SOFTWARE\OpenSSH -Name DefaultShell -PropertyType String -Force `
-Value 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe'
if (Get-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -ErrorAction SilentlyContinue) {
# the built-in rule only covers the Private profile; QEMU user networking is Public
Set-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -Profile Any -Enabled True
} else {
New-NetFirewallRule -Name 'OpenSSH-Server-In-TCP' -DisplayName 'OpenSSH Server (sshd)' `
-Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 -Profile Any
}
powercfg /change monitor-timeout-ac 0
powercfg /change standby-timeout-ac 0
New-Item -Path C:\setup.done -ItemType File -Force
Stop-Transcript
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""Type text into the VM console via QMP.
type.py QMP_SOCKET TEXT (\\n = Enter)
type.py QMP_SOCKET @FILE the file's first line, then Enter (secrets stay
off the command line)
type.py QMP_SOCKET --cad Ctrl+Alt+Del
"""
import json
import os
import socket
import sys
import time
SHIFTED = {
'!': '1', '@': '2', '#': '3', '$': '4', '%': '5', '^': '6', '&': '7',
'*': '8', '(': '9', ')': '0', '_': 'minus', '+': 'equal', '{': 'bracket_left',
'}': 'bracket_right', '|': 'backslash', ':': 'semicolon', '"': 'apostrophe',
'<': 'comma', '>': 'dot', '?': 'slash', '~': 'grave_accent',
}
PLAIN = {
' ': 'spc', '\n': 'ret', '\t': 'tab', '-': 'minus', '=': 'equal',
'[': 'bracket_left', ']': 'bracket_right', '\\': 'backslash', ';': 'semicolon',
"'": 'apostrophe', ',': 'comma', '.': 'dot', '/': 'slash', '`': 'grave_accent',
}
def keys_for(ch):
if ch.isascii() and ch.isalpha():
return (['shift'] if ch.isupper() else []) + [ch.lower()]
if ch.isdigit():
return [ch]
if ch in PLAIN:
return [PLAIN[ch]]
if ch in SHIFTED:
return ['shift', SHIFTED[ch]]
raise SystemExit(f'unsupported char {ch!r}')
def main():
arg = sys.argv[2]
if arg == '--cad':
combos = [['ctrl', 'alt', 'delete']]
else:
if arg.startswith('@'):
with open(arg[1:], encoding='utf-8') as fp:
text = fp.readline().rstrip('\r\n') + '\n'
else:
text = arg.encode().decode('unicode_escape')
combos = [keys_for(ch) for ch in text]
s = socket.socket(socket.AF_UNIX)
s.connect(sys.argv[1])
f = s.makefile('rw')
f.readline()
f.write(json.dumps({'execute': 'qmp_capabilities'}) + '\n')
f.flush()
f.readline()
for combo in combos:
keys = [{'type': 'qcode', 'data': k} for k in combo]
f.write(json.dumps({'execute': 'send-key', 'arguments': {'keys': keys}}) + '\n')
f.flush()
f.readline()
time.sleep(0.03)
main()
+288
View File
@@ -0,0 +1,288 @@
#!/usr/bin/env bash
# Headless Windows Server 2025 Core test VMs (QEMU/KVM).
#
# vm.sh create download the evaluation ISO, install base.qcow2
# vm.sh variants en.qcow2 / zh.qcow2 overlays on top of base.qcow2
# (zh: zh-CN system locale, ANSI code page 936)
# vm.sh start|stop|status|wait VARIANT
# vm.sh ssh VARIANT [USER] [command...] USER: Administrator (default), tester, 测试
# vm.sh put VARIANT SRC... DEST scp as Administrator
# vm.sh ps VARIANT [USER] FILE.ps1 [args...] run a script (uploaded with a UTF-8 BOM)
# vm.sh shot VARIANT [out.png] console screenshot
# vm.sh type VARIANT TEXT type into the console (\n = Enter)
# vm.sh login VARIANT unlock the console as Administrator
#
# State (ISO, disks, keys, passwords) lives in $CST_WINVM_DIR, default ~/winvm.
# Keys and passwords are created there with mode 0600 and never printed.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
D="${CST_WINVM_DIR:-$HOME/winvm}"
ISO_URL="https://aka.ms/WinServ2025iso-enus"
IMAGE_NAME="Windows Server 2025 SERVERSTANDARDCORE"
USERS=(tester 测试)
if ! command -v qemu-system-x86_64 >/dev/null || ! command -v sshpass >/dev/null ||
! command -v socat >/dev/null || ! command -v xorriso >/dev/null; then
if command -v nix-shell >/dev/null && [[ -z "${CST_WINVM_NIX:-}" ]]; then
export CST_WINVM_NIX=1
exec nix-shell -p qemu sshpass socat xorriso imagemagick python3 \
--run "$(printf '%q ' "$0" "$@")"
fi
echo "need qemu-system-x86_64, sshpass, socat, xorriso" >&2
exit 1
fi
die() { echo "vm.sh: $*" >&2; exit 1; }
port_of() {
case "$1" in
base) echo 2221 ;;
en) echo 2222 ;;
zh) echo 2223 ;;
*) die "unknown variant '$1' (base, en, zh)" ;;
esac
}
pw_file() {
case "$1" in
Administrator) echo "$D/admin-password" ;;
tester) echo "$D/tester-password" ;;
测试) echo "$D/ceshi-password" ;;
*) die "unknown user '$1'" ;;
esac
}
new_password() { # prefix -> file, mixed classes for Windows complexity rules
local f="$2"
[[ -s "$f" ]] && return 0
(umask 077; printf '%s%s9a' "$1" "$(head -c 256 /dev/urandom | tr -dc 'A-Za-z0-9' | head -c 16)" >"$f")
}
running() {
local pid
pid="$(cat "$D/$1.pid" 2>/dev/null)" || return 1
[[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null
}
ssh_opts() {
echo -p "$(port_of "$1")" -o UserKnownHostsFile="$D/known_hosts" \
-o StrictHostKeyChecking=accept-new -o LogLevel=ERROR -o ConnectTimeout=10
}
vm_ssh() { # variant user command...
local v="$1" u="$2"
shift 2
# shellcheck disable=SC2046
if [[ "$u" == Administrator ]]; then
ssh -i "$D/vm_key" -o BatchMode=yes $(ssh_opts "$v") "Administrator@127.0.0.1" "$@"
else
SSHPASS="$(cat "$(pw_file "$u")")" sshpass -e ssh -o PubkeyAuthentication=no \
-o PreferredAuthentications=password $(ssh_opts "$v") "$u@127.0.0.1" "$@"
fi
}
vm_put() { # variant src... dest
local v="$1"
shift
local dest="${!#}"
local srcs=("${@:1:$#-1}")
# shellcheck disable=SC2046
scp -q -r -i "$D/vm_key" -o BatchMode=yes $(ssh_opts "$v" | sed 's/^-p/-P/') \
"${srcs[@]}" "Administrator@127.0.0.1:$dest"
}
qemu_start() { # variant disk [extra args...]
local v="$1" disk="$2"
shift 2
running "$v" && return 0
[[ -f "$disk" ]] || die "missing $disk"
qemu-system-x86_64 \
-name "cst-win-$v" -machine q35,accel=kvm \
-cpu host,hv_relaxed,hv_spinlocks=0x1fff,hv_vapic,hv_time \
-smp 4 -m 6G -rtc base=localtime \
-device ahci,id=ahci \
-drive file="$disk",if=none,id=d0,cache=writeback,discard=unmap \
-device ide-hd,drive=d0,bus=ahci.0 \
-netdev user,id=n0,hostfwd=tcp:127.0.0.1:"$(port_of "$v")"-:22 \
-device e1000e,netdev=n0 \
-device qemu-xhci,id=xhci -device usb-tablet,bus=xhci.0 \
-display none -qmp unix:"$D/$v.qmp",server,nowait \
-pidfile "$D/$v.pid" -daemonize "$@"
}
disk_of() {
case "$1" in
base) echo "$D/base.qcow2" ;;
*) echo "$D/$1.qcow2" ;;
esac
}
vm_wait() { # variant [timeout_s]
local v="$1" t="${2:-900}" i
for ((i = 0; i < t; i += 5)); do
if vm_ssh "$v" Administrator 'Test-Path C:\setup.done' 2>/dev/null | grep -q True; then
return 0
fi
running "$v" || die "$v is not running"
sleep 5
done
die "$v: no SSH after ${t}s"
}
vm_stop() {
local v="$1" i
running "$v" || return 0
vm_ssh "$v" Administrator 'Stop-Computer -Force' >/dev/null 2>&1 || true
for ((i = 0; i < 120; i++)); do
running "$v" || { rm -f "$D/$v.pid" "$D/$v.qmp"; return 0; }
sleep 1
done
printf '%s\n' '{"execute":"qmp_capabilities"}' '{"execute":"quit"}' |
socat - UNIX-CONNECT:"$D/$v.qmp" >/dev/null || true
rm -f "$D/$v.pid" "$D/$v.qmp"
}
with_bom() { # file -> temp copy with a UTF-8 BOM (Windows PowerShell 5.1 reads ANSI otherwise)
local out
out="$(mktemp --suffix=.ps1)"
printf '\xef\xbb\xbf' >"$out"
sed '1s/^\xef\xbb\xbf//' "$1" >>"$out"
echo "$out"
}
vm_ps() { # variant user file args...
local v="$1" u="$2" f="$3"
shift 3
local tmp name
tmp="$(with_bom "$f")"
name="cst-$(basename "$f" .ps1)-$$.ps1"
vm_put "$v" "$tmp" "C:/Windows/Temp/$name"
rm -f "$tmp"
vm_ssh "$v" Administrator "icacls C:\\Windows\\Temp\\$name /grant 'Users:RX' | Out-Null" >/dev/null
local rc=0
vm_ssh "$v" "$u" "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\Windows\\Temp\\$name $*" || rc=$?
vm_ssh "$v" Administrator "Remove-Item -Force C:\\Windows\\Temp\\$name" >/dev/null 2>&1 || true
return $rc
}
provision() { # variant: users, sshd, console; idempotent
local v="$1" u tmp
tmp="$(mktemp)"
chmod 600 "$tmp"
for u in "${USERS[@]}"; do
new_password "Tst!" "$(pw_file "$u")"
printf '%s\t%s\n' "$u" "$(cat "$(pw_file "$u")")" >>"$tmp"
done
vm_put "$v" "$tmp" "C:/Windows/Temp/cst-users.txt"
rm -f "$tmp"
vm_ps "$v" Administrator "$HERE/provision.ps1"
}
cmd_create() {
mkdir -p "$D"
chmod 700 "$D"
[[ -f "$D/base.qcow2" ]] && die "$D/base.qcow2 exists"
if [[ ! -s "$D/server2025.iso" ]]; then
echo "==> downloading the Windows Server 2025 evaluation ISO (~8 GB)"
curl -fL -C - -o "$D/server2025.iso" "$ISO_URL"
fi
[[ -f "$D/vm_key" ]] || ssh-keygen -q -t ed25519 -N "" -C cst-winvm -f "$D/vm_key"
new_password "Cst!" "$D/admin-password"
local u
u="$(mktemp -d)"
chmod 700 "$u"
cp "$HERE/setup.ps1" "$D/vm_key.pub" "$u/"
(umask 077
sed -e "s|@IMAGE_NAME@|$IMAGE_NAME|" -e "s|@ADMIN_PASSWORD@|$(cat "$D/admin-password")|g" \
"$HERE/autounattend.xml.in" >"$u/autounattend.xml"
xorriso -as mkisofs -quiet -J -r -V UNATTEND -o "$D/unattend.iso" "$u/" 2>/dev/null)
rm -rf "$u"
qemu-img create -q -f qcow2 "$D/base.qcow2" 60G
echo "==> unattended install (20-30 minutes)"
qemu_start base "$D/base.qcow2" \
-drive file="$D/server2025.iso",media=cdrom,if=none,id=cd0,readonly=on \
-device ide-cd,drive=cd0,bus=ahci.1 \
-drive file="$D/unattend.iso",media=cdrom,if=none,id=cd1,readonly=on \
-device ide-cd,drive=cd1,bus=ahci.2 \
-boot order=c,once=d
vm_wait base 3600
provision base
vm_stop base
echo "OK: $D/base.qcow2; next: vm.sh variants"
}
cmd_variants() {
[[ -f "$D/base.qcow2" ]] || die "no base.qcow2; run vm.sh create"
running base && die "stop the base VM first"
local v
for v in en zh; do
running "$v" && vm_stop "$v"
rm -f "$D/$v.qcow2"
qemu-img create -q -f qcow2 -b "$D/base.qcow2" -F qcow2 "$D/$v.qcow2"
done
qemu_start zh "$D/zh.qcow2"
vm_wait zh
vm_ssh zh Administrator 'Set-WinSystemLocale -SystemLocale zh-CN; Set-Culture zh-CN; Set-WinHomeLocation -GeoId 45; Restart-Computer -Force' || true
sleep 20
vm_wait zh
echo "zh ANSI code page: $(vm_ssh zh Administrator '[Text.Encoding]::Default.CodePage')"
vm_stop zh
echo "OK: en.qcow2 zh.qcow2"
}
cmd="${1:-}"
shift || true
case "$cmd" in
create) cmd_create ;;
variants) cmd_variants ;;
provision) provision "${1:?variant}" ;;
start)
qemu_start "${1:?variant}" "$(disk_of "$1")"
vm_wait "$1"
;;
stop) vm_stop "${1:?variant}" ;;
status) running "${1:?variant}" && echo running || echo stopped ;;
wait) vm_wait "${1:?variant}" "${2:-900}" ;;
ssh)
v="${1:?variant}"
shift
u=Administrator
if [[ $# -gt 0 ]] && [[ "$1" == Administrator || "$1" == tester || "$1" == 测试 ]]; then
u="$1"
shift
fi
vm_ssh "$v" "$u" "$@"
;;
put) vm_put "$@" ;;
ps)
v="${1:?variant}"
shift
u=Administrator
if [[ "$1" == Administrator || "$1" == tester || "$1" == 测试 ]]; then
u="$1"
shift
fi
vm_ps "$v" "$u" "$@"
;;
shot)
v="${1:?variant}"
out="${2:-$D/$v.png}"
printf '%s\n' '{"execute":"qmp_capabilities"}' \
'{"execute":"screendump","arguments":{"filename":"'"$D/$v.ppm"'"}}' |
socat - UNIX-CONNECT:"$D/$v.qmp" >/dev/null
sleep 1
magick "$D/$v.ppm" -resize '1024x1024>' "$out"
rm -f "$D/$v.ppm"
echo "$out"
;;
type) python3 "$HERE/type.py" "$D/${1:?variant}.qmp" "${2:?text}" ;;
login)
python3 "$HERE/type.py" "$D/${1:?variant}.qmp" --cad
sleep 3
python3 "$HERE/type.py" "$D/$1.qmp" "@$D/admin-password"
;;
*) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 2 ;;
esac
+24
View File
@@ -0,0 +1,24 @@
# Windows x86_64 cross toolchain (mingw-w64, UCRT runtime, static winpthreads).
# nix-shell shell-win.nix --run 'scripts/bootstrap_windows.sh'
{ pkgs ? import <nixpkgs> { } }:
let
win = pkgs.pkgsCross.ucrt64;
in
win.mkShell {
nativeBuildInputs = with pkgs; [
cmake
ninja
gnumake
git
perl
python3
pkg-config
zip
curl
];
buildInputs = [ win.windows.pthreads ];
# mkShell exports CC/AR for the target; the build scripts call the prefixed tools directly.
shellHook = ''
export MINGW_HOST=x86_64-w64-mingw32
'';
}
+955 -230
View File
File diff suppressed because it is too large Load Diff
+24 -22
View File
@@ -6,7 +6,6 @@
#include "cst_agent.h" #include "cst_agent.h"
#include "os_compat.h" #include "os_compat.h"
#include <dirent.h>
#include <errno.h> #include <errno.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -45,7 +44,7 @@ int cst_agent_store_ensure(const char *workspace) {
char gi[OS_PATH_MAX]; char gi[OS_PATH_MAX];
if (os_path_join(gi, sizeof(gi), base, ".gitignore") == 0 && if (os_path_join(gi, sizeof(gi), base, ".gitignore") == 0 &&
OS_ACCESS(gi, OS_F_OK) != 0) { OS_ACCESS(gi, OS_F_OK) != 0) {
FILE *fp = fopen(gi, "w"); FILE *fp = fopen(gi, "wb");
if (fp) { if (fp) {
fputs("*\n", fp); fputs("*\n", fp);
fclose(fp); fclose(fp);
@@ -97,7 +96,7 @@ int cst_agent_store_set_current(const char *workspace, const char *id) {
return -1; return -1;
if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0) if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
return -1; return -1;
FILE *fp = fopen(path, "w"); FILE *fp = fopen(path, "wb");
if (!fp) if (!fp)
return -1; return -1;
fprintf(fp, "%s\n", id); fprintf(fp, "%s\n", id);
@@ -113,7 +112,7 @@ int cst_agent_store_get_current(const char *workspace, char *id_out,
return -1; return -1;
if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0) if (os_path_join(path, sizeof(path), dir, "CURRENT") != 0)
return -1; return -1;
FILE *fp = fopen(path, "r"); FILE *fp = fopen(path, "rb");
if (!fp) if (!fp)
return -1; return -1;
if (!fgets(id_out, (int)id_sz, fp)) { if (!fgets(id_out, (int)id_sz, fp)) {
@@ -159,7 +158,7 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
char dir[OS_PATH_MAX]; char dir[OS_PATH_MAX];
if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0) if (cst_agent_store_dir(workspace, dir, sizeof(dir)) != 0)
return 0; return 0;
DIR *d = opendir(dir); os_dir_t *d = os_dir_open(dir);
if (!d) if (!d)
return 0; return 0;
typedef struct { typedef struct {
@@ -168,23 +167,23 @@ int cst_agent_store_list(const char *workspace, char ids[][128], int max_ids) {
} ent_t; } ent_t;
ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t)); ent_t *ents = calloc((size_t)max_ids * 4, sizeof(ent_t));
int n = 0, cap = max_ids * 4; int n = 0, cap = max_ids * 4;
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
size_t len = strlen(de->d_name); size_t len = strlen(de->name);
if (len < 7 || strcmp(de->d_name + len - 6, ".jsonl") != 0) if (len < 7 || strcmp(de->name + len - 6, ".jsonl") != 0)
continue; continue;
if (n >= cap) if (n >= cap)
break; break;
snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6), snprintf(ents[n].id, sizeof(ents[n].id), "%.*s", (int)(len - 6),
de->d_name); de->name);
char full[OS_PATH_MAX]; char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, de->d_name) != 0) if (os_path_join(full, sizeof(full), dir, de->name) != 0)
continue; continue;
struct stat st; struct stat st;
ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0; ents[n].mtime = (stat(full, &st) == 0) ? st.st_mtime : 0;
n++; n++;
} }
closedir(d); os_dir_close(d);
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) { for (int j = i + 1; j < n; j++) {
if (ents[j].mtime > ents[i].mtime) { if (ents[j].mtime > ents[i].mtime) {
@@ -218,7 +217,7 @@ int cst_agent_store_title_set(const char *workspace, const char *id,
return -1; return -1;
if (!title || !title[0]) if (!title || !title[0])
return remove(p) == 0 || errno == ENOENT ? 0 : -1; return remove(p) == 0 || errno == ENOENT ? 0 : -1;
FILE *fp = fopen(p, "w"); FILE *fp = fopen(p, "wb");
if (!fp) if (!fp)
return -1; return -1;
for (const char *c = title; *c; c++) for (const char *c = title; *c; c++)
@@ -255,7 +254,7 @@ int cst_agent_store_info(const char *workspace, const char *id,
info->mtime = (long long)st.st_mtime; info->mtime = (long long)st.st_mtime;
char tp[OS_PATH_MAX]; char tp[OS_PATH_MAX];
if (side_path(workspace, id, ".title", tp, sizeof(tp)) == 0) { if (side_path(workspace, id, ".title", tp, sizeof(tp)) == 0) {
FILE *tf = fopen(tp, "r"); FILE *tf = fopen(tp, "rb");
if (tf) { if (tf) {
if (fgets(info->title, sizeof(info->title), tf)) { if (fgets(info->title, sizeof(info->title), tf)) {
size_t n = strlen(info->title); size_t n = strlen(info->title);
@@ -265,7 +264,7 @@ int cst_agent_store_info(const char *workspace, const char *id,
fclose(tf); fclose(tf);
} }
} }
FILE *fp = fopen(p, "r"); FILE *fp = fopen(p, "rb");
if (!fp) if (!fp)
return 0; return 0;
size_t cap = 1 << 16; size_t cap = 1 << 16;
@@ -289,7 +288,10 @@ int cst_agent_store_info(const char *workspace, const char *id,
cJSON *o = cJSON_Parse(line); cJSON *o = cJSON_Parse(line);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role"); const cJSON *r = cJSON_GetObjectItemCaseSensitive(o, "role");
const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content"); const cJSON *c = cJSON_GetObjectItemCaseSensitive(o, "content");
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && const cJSON *k = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(o, "meta"), "kind");
int note = cJSON_IsString(k) && strcmp(k->valuestring, "note") == 0;
if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 && !note &&
cJSON_IsString(c) && c->valuestring[0]) { cJSON_IsString(c) && c->valuestring[0]) {
clip_line(info->first_user, sizeof(info->first_user), c->valuestring); clip_line(info->first_user, sizeof(info->first_user), c->valuestring);
have_first = 1; have_first = 1;
@@ -311,16 +313,16 @@ int cst_agent_store_delete(const char *workspace, const char *id) {
remove(p); remove(p);
/* sub-agent transcripts live in <id>/ */ /* sub-agent transcripts live in <id>/ */
if (side_path(workspace, id, "", p, sizeof(p)) == 0) { if (side_path(workspace, id, "", p, sizeof(p)) == 0) {
DIR *d = opendir(p); os_dir_t *d = os_dir_open(p);
if (d) { if (d) {
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
char f[OS_PATH_MAX]; char f[OS_PATH_MAX];
if (strncmp(de->d_name, "sub-", 4) == 0 && if (strncmp(de->name, "sub-", 4) == 0 &&
os_path_join(f, sizeof(f), p, de->d_name) == 0) os_path_join(f, sizeof(f), p, de->name) == 0)
remove(f); remove(f);
} }
closedir(d); os_dir_close(d);
rmdir(p); rmdir(p);
} }
} }
+646
View File
@@ -0,0 +1,646 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cJSON.h"
#include "cst_attach.h"
#include "cst_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* --- base64 -------------------------------------------------------------- */
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
char *cst_base64_encode(const void *data, size_t n) {
const unsigned char *p = data;
size_t olen = ((n + 2) / 3) * 4;
char *out = malloc(olen + 1);
if (!out)
return NULL;
size_t o = 0;
for (size_t i = 0; i < n; i += 3) {
unsigned v = (unsigned)p[i] << 16;
if (i + 1 < n)
v |= (unsigned)p[i + 1] << 8;
if (i + 2 < n)
v |= (unsigned)p[i + 2];
out[o++] = B64[(v >> 18) & 63];
out[o++] = B64[(v >> 12) & 63];
out[o++] = i + 1 < n ? B64[(v >> 6) & 63] : '=';
out[o++] = i + 2 < n ? B64[v & 63] : '=';
}
out[o] = 0;
return out;
}
static int b64_val(unsigned char c) {
if (c >= 'A' && c <= 'Z')
return c - 'A';
if (c >= 'a' && c <= 'z')
return c - 'a' + 26;
if (c >= '0' && c <= '9')
return c - '0' + 52;
if (c == '+' || c == '-')
return 62;
if (c == '/' || c == '_')
return 63;
return -1;
}
char *cst_base64_decode(const char *s, size_t n, size_t *out_len) {
if (out_len)
*out_len = 0;
if (!s)
return NULL;
char *out = malloc(n / 4 * 3 + 4);
if (!out)
return NULL;
size_t o = 0;
unsigned acc = 0;
int bits = 0;
for (size_t i = 0; i < n; i++) {
unsigned char c = (unsigned char)s[i];
if (c == '=')
break;
if (c == ' ' || c == '\t' || c == '\r' || c == '\n')
continue;
int v = b64_val(c);
if (v < 0) {
free(out);
return NULL;
}
acc = (acc << 6) | (unsigned)v;
bits += 6;
if (bits >= 8) {
bits -= 8;
out[o++] = (char)((acc >> bits) & 0xFF);
}
}
if (out_len)
*out_len = o;
return out;
}
/* --- format sniffing ----------------------------------------------------- */
static int be32(const unsigned char *p) {
return (p[0] << 24) | (p[1] << 16) | (p[2] << 8) | p[3];
}
static int le32(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8) |
((unsigned)p[2] << 16) | ((unsigned)p[3] << 24));
}
static int le16(const unsigned char *p) {
return (int)((unsigned)p[0] | ((unsigned)p[1] << 8));
}
static const char *mime_of_ext(const char *mime) {
if (strcmp(mime, "image/png") == 0)
return "png";
if (strcmp(mime, "image/jpeg") == 0)
return "jpg";
if (strcmp(mime, "image/gif") == 0)
return "gif";
if (strcmp(mime, "image/webp") == 0)
return "webp";
if (strcmp(mime, "image/bmp") == 0)
return "bmp";
return "bin";
}
/* A JPEG SOF marker carries the frame size; skip it when scanning. */
static int jpeg_sof(int marker) {
switch (marker) {
case 0xC0:
case 0xC1:
case 0xC2:
case 0xC3:
case 0xC5:
case 0xC6:
case 0xC7:
case 0xC9:
case 0xCA:
case 0xCB:
case 0xCD:
case 0xCE:
case 0xCF:
return 1;
default:
return 0;
}
}
int cst_attach_sniff(const void *data, size_t n, char *mime, size_t mime_sz) {
const unsigned char *p = data;
const char *m = NULL;
if (n >= 8 && memcmp(p, "\x89PNG\r\n\x1a\n", 8) == 0)
m = "image/png";
else if (n >= 3 && p[0] == 0xFF && p[1] == 0xD8 && p[2] == 0xFF)
m = "image/jpeg";
else if (n >= 6 && memcmp(p, "GIF87a", 6) == 0)
m = "image/gif";
else if (n >= 6 && memcmp(p, "GIF89a", 6) == 0)
m = "image/gif";
else if (n >= 12 && memcmp(p, "RIFF", 4) == 0 && memcmp(p + 8, "WEBP", 4) == 0)
m = "image/webp";
else if (n >= 2 && p[0] == 'B' && p[1] == 'M')
m = "image/bmp";
if (!m)
return -1;
snprintf(mime, mime_sz, "%s", m);
return 0;
}
/* PNG / GIF / BMP are direct; JPEG needs a marker walk; WebP has three
* container flavours. */
int cst_attach_dimensions(const void *data, size_t n, int *w, int *h) {
const unsigned char *p = data;
*w = *h = 0;
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0)
return -1;
if (strcmp(mime, "image/png") == 0) {
/* 8 signature + 4 length + "IHDR" + w + h */
if (n < 24 || memcmp(p + 12, "IHDR", 4) != 0)
return -1;
*w = be32(p + 16);
*h = be32(p + 20);
return 0;
}
if (strcmp(mime, "image/gif") == 0) {
if (n < 10)
return -1;
*w = le16(p + 6);
*h = le16(p + 8);
return 0;
}
if (strcmp(mime, "image/bmp") == 0) {
if (n < 26)
return -1;
int wi = le32(p + 18);
int hi = le32(p + 22);
if (wi < 0)
wi = -wi; /* bottom-up rows are stored with a negative height */
if (hi < 0)
hi = -hi;
*w = wi;
*h = hi;
return 0;
}
if (strcmp(mime, "image/webp") == 0) {
if (n < 30)
return -1;
const unsigned char *c = p + 12;
if (memcmp(c, "VP8 ", 4) == 0) {
/* 4 tag + 3 start code, then 16-bit width / height (14 bits each) */
if (n < 30 || !(c[7] == 0x9D && c[8] == 0x01 && c[9] == 0x2A))
return -1;
*w = le16(c + 10) & 0x3FFF;
*h = le16(c + 12) & 0x3FFF;
return 0;
}
if (memcmp(c, "VP8L", 4) == 0) {
if (n < 25 || c[8] != 0x2F)
return -1;
unsigned b = (unsigned)c[9] | ((unsigned)c[10] << 8) |
((unsigned)c[11] << 16) | ((unsigned)c[12] << 24);
*w = (int)(b & 0x3FFF) + 1;
*h = (int)((b >> 14) & 0x3FFF) + 1;
return 0;
}
if (memcmp(c, "VP8X", 4) == 0) {
if (n < 30)
return -1;
*w = (int)((unsigned)c[12] | ((unsigned)c[13] << 8) |
((unsigned)c[14] << 16)) + 1;
*h = (int)((unsigned)c[15] | ((unsigned)c[16] << 8) |
((unsigned)c[17] << 16)) + 1;
return 0;
}
return -1;
}
if (strcmp(mime, "image/jpeg") == 0) {
size_t i = 2;
while (i + 3 < n) {
if (p[i] != 0xFF)
return -1; /* lost sync */
unsigned marker = p[i + 1];
i += 2;
if (marker == 0xD8 || (marker >= 0xD0 && marker <= 0xD7))
continue; /* standalone markers: no payload */
if (marker == 0xD9 || marker == 0x01)
continue;
if (i + 1 >= n)
return -1;
size_t seg = (size_t)((p[i] << 8) | p[i + 1]);
if (seg < 2 || i + seg > n)
return -1;
if (jpeg_sof((int)marker) && seg >= 7) {
*h = (p[i + 3] << 8) | p[i + 4];
*w = (p[i + 5] << 8) | p[i + 6];
return 0;
}
i += seg;
}
}
return -1;
}
/* --- storage ------------------------------------------------------------- */
/* <session dir>/attachments/<session stem> — beside the JSONL so moving the
* workspace keeps the two together. */
int cst_attach_dir(const char *session_path, char *out, size_t out_sz) {
if (!session_path || !session_path[0])
return -1;
char dir[OS_PATH_MAX];
snprintf(dir, sizeof(dir), "%s", session_path);
char *slash = strrchr(dir, '/');
#ifdef _WIN32
char *bslash = strrchr(dir, '\\');
if (bslash && (!slash || bslash > slash))
slash = bslash;
#endif
char leaf[256];
const char *base = slash ? slash + 1 : dir;
size_t bl = strlen(base);
if (bl >= sizeof(leaf))
bl = sizeof(leaf) - 1;
memcpy(leaf, base, bl);
leaf[bl] = 0;
if (slash)
*(slash + 1) = 0;
else {
dir[0] = '.';
dir[1] = 0;
}
char *dot = strstr(leaf, ".jsonl");
if (dot)
*dot = 0;
char rel[320];
snprintf(rel, sizeof(rel), "attachments/%s", leaf[0] ? leaf : "session");
char full[OS_PATH_MAX];
if (os_path_join(full, sizeof(full), dir, rel) != 0)
return -1;
if (os_mkdir_p(full, 0700) != 0)
return -1;
snprintf(out, out_sz, "%s", full);
return 0;
}
/* Stored names are "<id>.<ext>" made by store(); anything else in a JSONL
* (hand-edited or crafted) could point outside the attachment directory. */
int cst_attach_rel_ok(const char *rel) {
if (!rel || !rel[0] || rel[0] == '.')
return 0;
for (const char *p = rel; *p; p++) {
char c = *p;
if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.'))
return 0;
}
return strstr(rel, "..") == NULL;
}
int cst_attach_full_path(const char *session_path, const cst_attachment_t *a,
char *out, size_t out_sz) {
if (!a || !cst_attach_rel_ok(a->rel))
return -1;
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0)
return -1;
return os_path_join(out, out_sz, dir, a->rel);
}
static char *read_whole(const char *path, size_t *out_len, size_t max) {
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
cst_buf_t b = {0};
char chunk[16384];
size_t n;
while ((n = fread(chunk, 1, sizeof(chunk), f)) > 0) {
if (b.len + n > max || cst_buf_append(&b, chunk, n) != 0) {
cst_buf_free(&b);
fclose(f);
return NULL;
}
}
fclose(f);
*out_len = b.len;
return cst_buf_steal(&b);
}
/* FNV over the bytes, the name and the clock: short, stable, unique enough
* inside one session. */
static void make_id(const char *name, const char *data, size_t n, char *out,
size_t out_sz) {
uint64_t h = cst_fnv1a64(CST_FNV_INIT, data, n > 4096 ? 4096 : n);
h = cst_fnv1a64(h, name, strlen(name));
h = cst_fnv1a64(h, &n, sizeof(n));
double now = os_now_ms();
h = cst_fnv1a64(h, &now, sizeof(now));
h = cst_fnv1a64(h, &(int){os_getpid()}, sizeof(int));
snprintf(out, out_sz, "img-%08x", (unsigned)(h & 0xFFFFFFFFu));
}
static cst_attachment_t *store(const char *session_path, const char *name,
const char *data, size_t n, const char *origin,
char *err, size_t err_sz) {
if (!name || !name[0])
name = "image";
char mime[32];
if (cst_attach_sniff(data, n, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz,
"not a supported image (PNG, JPEG, GIF, WebP or BMP; "
"checked by content, not by extension)");
return NULL;
}
if (n > CST_ATTACH_MAX_BYTES) {
snprintf(err, err_sz, "image is %.1f MB; the limit is %d MB",
(double)n / (1024 * 1024), (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
char dir[OS_PATH_MAX];
if (cst_attach_dir(session_path, dir, sizeof(dir)) != 0) {
snprintf(err, err_sz, "cannot create the attachment directory");
return NULL;
}
cst_attachment_t *a = calloc(1, sizeof(*a));
if (!a) {
snprintf(err, err_sz, "out of memory");
return NULL;
}
make_id(name, data, n, a->id, sizeof(a->id));
snprintf(a->rel, sizeof(a->rel), "%s.%s", a->id, mime_of_ext(mime));
snprintf(a->name, sizeof(a->name), "%s", name && name[0] ? name : "image");
snprintf(a->mime, sizeof(a->mime), "%s", mime);
snprintf(a->origin, sizeof(a->origin), "%s", origin ? origin : "user");
snprintf(a->detail, sizeof(a->detail), "auto");
a->bytes = (long long)n;
cst_attach_dimensions(data, n, &a->width, &a->height);
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment path is too long");
free(a);
return NULL;
}
FILE *f = fopen(full, "wb");
if (!f) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
size_t w = fwrite(data, 1, n, f);
if (fclose(f) != 0 || w != n) {
snprintf(err, err_sz, "cannot write %.200s", full);
free(a);
return NULL;
}
return a;
}
cst_attachment_t *cst_attach_import_bytes(const char *session_path,
const char *name, const char *data,
size_t n, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!session_path || !session_path[0]) {
snprintf(err, err_sz, "no session to store the image in");
return NULL;
}
if (!data || !n) {
snprintf(err, err_sz, "image is empty");
return NULL;
}
return store(session_path, name, data, n, "user", err, err_sz);
}
cst_attachment_t *cst_attach_import_file(const char *session_path,
const char *src_path, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
size_t n = 0;
char *data = read_whole(src_path, &n, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz, "cannot read %.300s (or it is larger than %d MB)",
src_path, (int)(CST_ATTACH_MAX_BYTES >> 20));
return NULL;
}
const char *leaf = src_path;
for (const char *p = src_path; *p; p++)
if (*p == '/' || *p == '\\')
leaf = p + 1;
cst_attachment_t *a = store(session_path, leaf, data, n, "user", err, err_sz);
free(data);
return a;
}
char *cst_attach_read(const char *session_path, const cst_attachment_t *a,
size_t *out_len, char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
char full[OS_PATH_MAX];
if (cst_attach_full_path(session_path, a, full, sizeof(full)) != 0) {
snprintf(err, err_sz, "attachment has no stored file");
return NULL;
}
char *data = read_whole(full, out_len, CST_ATTACH_MAX_BYTES);
if (!data) {
snprintf(err, err_sz,
"%.200s is missing; the image was deleted from the session "
"store",
a->name);
return NULL;
}
char mime[32];
if (cst_attach_sniff(data, *out_len, mime, sizeof(mime)) != 0) {
snprintf(err, err_sz, "%.200s in the session store is no longer an image",
a->name);
free(data);
return NULL;
}
return data;
}
char *cst_attach_data_url(const char *session_path, const cst_attachment_t *a) {
char err[256];
size_t n = 0;
char *data = cst_attach_read(session_path, a, &n, err, sizeof(err));
if (!data)
return NULL;
char mime[32];
cst_attach_sniff(data, n, mime, sizeof(mime)); /* the bytes decide */
char *b64 = cst_base64_encode(data, n);
free(data);
if (!b64)
return NULL;
cst_buf_t b = {0};
cst_buf_printf(&b, "data:%s;base64,", mime);
cst_buf_puts(&b, b64);
free(b64);
return cst_buf_steal(&b);
}
void cst_attach_describe(const cst_attachment_t *a, char *out, size_t out_sz) {
if (!a) {
snprintf(out, out_sz, "(none)");
return;
}
const char *kind = strncmp(a->mime, "image/", 6) == 0 ? a->mime + 6 : a->mime;
char dim[32] = "";
if (a->width > 0 && a->height > 0)
snprintf(dim, sizeof(dim), "%dx%d ", a->width, a->height);
out[0] = 0;
if (a->bytes >= 1024 * 1024)
snprintf(out, out_sz, "%s%s %.1f MB", dim, kind,
(double)a->bytes / (1024 * 1024));
else
snprintf(out, out_sz, "%s%s %.0f KB", dim, kind,
(double)a->bytes / 1024);
}
/* --- lifetime ------------------------------------------------------------ */
void cst_attach_free(cst_attachment_t *a) { free(a); }
/* Entries always live in one array (a 1-element array for a single import),
* so freeing the block releases them all. */
void cst_attachments_free(cst_attachment_t *list, int n) {
(void)n;
free(list);
}
cst_attachment_t *cst_attachments_dup(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cst_attachment_t *out = calloc((size_t)n, sizeof(*out));
if (!out)
return NULL;
for (int i = 0; i < n; i++)
out[i] = list[i];
return out;
}
int cst_attachments_push(cst_attachment_t **list, int *n, int *cap,
const cst_attachment_t *src) {
if (!list || !n || !cap || !src)
return -1;
if (*n == *cap) {
int nc = *cap ? *cap * 2 : 4;
cst_attachment_t *nv = realloc(*list, (size_t)nc * sizeof(**list));
if (!nv)
return -1;
*list = nv;
*cap = nc;
}
(*list)[(*n)++] = *src;
return 0;
}
/* --- JSONL --------------------------------------------------------------- */
char *cst_attachments_to_json(const cst_attachment_t *list, int n) {
if (!list || n <= 0)
return NULL;
cJSON *arr = cJSON_CreateArray();
if (!arr)
return NULL;
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &list[i];
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "id", a->id);
cJSON_AddStringToObject(o, "name", a->name);
cJSON_AddStringToObject(o, "mime", a->mime);
cJSON_AddStringToObject(o, "rel", a->rel);
cJSON_AddNumberToObject(o, "bytes", (double)a->bytes);
if (a->width > 0 && a->height > 0) {
cJSON_AddNumberToObject(o, "width", a->width);
cJSON_AddNumberToObject(o, "height", a->height);
}
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(o, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(o, "origin", a->origin);
cJSON_AddItemToArray(arr, o);
}
char *s = cJSON_PrintUnformatted(arr);
cJSON_Delete(arr);
return s;
}
cst_attachment_t *cst_attachments_from_json(const void *array_json, int *n_out) {
if (n_out)
*n_out = 0;
const cJSON *arr = array_json;
if (!cJSON_IsArray(arr))
return NULL;
int n = cJSON_GetArraySize(arr);
if (n <= 0)
return NULL;
cst_attachment_t *list = calloc((size_t)n, sizeof(*list));
if (!list)
return NULL;
int k = 0;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, arr) {
if (!cJSON_IsObject(it))
continue;
const cJSON *jid = cJSON_GetObjectItemCaseSensitive(it, "id");
const cJSON *jrel = cJSON_GetObjectItemCaseSensitive(it, "rel");
if (!cJSON_IsString(jid) || !cJSON_IsString(jrel) ||
!cst_attach_rel_ok(jrel->valuestring))
continue;
cst_attachment_t *a = &list[k];
snprintf(a->id, sizeof(a->id), "%s", jid->valuestring);
snprintf(a->rel, sizeof(a->rel), "%s", jrel->valuestring);
const cJSON *v = cJSON_GetObjectItemCaseSensitive(it, "name");
snprintf(a->name, sizeof(a->name), "%s",
cJSON_IsString(v) ? v->valuestring : a->id);
v = cJSON_GetObjectItemCaseSensitive(it, "mime");
snprintf(a->mime, sizeof(a->mime), "%s",
cJSON_IsString(v) ? v->valuestring : "image/*");
v = cJSON_GetObjectItemCaseSensitive(it, "bytes");
a->bytes = cJSON_IsNumber(v) ? (long long)v->valuedouble : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "width");
a->width = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "height");
a->height = cJSON_IsNumber(v) ? v->valueint : 0;
v = cJSON_GetObjectItemCaseSensitive(it, "detail");
snprintf(a->detail, sizeof(a->detail), "%s",
cJSON_IsString(v) ? v->valuestring : "auto");
v = cJSON_GetObjectItemCaseSensitive(it, "origin");
snprintf(a->origin, sizeof(a->origin), "%s",
cJSON_IsString(v) ? v->valuestring : "");
k++;
}
if (k == 0) {
free(list);
return NULL;
}
if (n_out)
*n_out = k;
return list;
}
+482
View File
@@ -0,0 +1,482 @@
#include "cst_clipboard.h"
#include "cst_attach.h"
#include "os_compat.h"
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#include <windows.h>
#else
#include <unistd.h>
#endif
static void set_err(char *err, size_t err_sz, const char *msg) {
if (err && err_sz)
snprintf(err, err_sz, "%s", msg);
}
static void put_le32(unsigned char *p, unsigned long v) {
p[0] = (unsigned char)(v & 0xff);
p[1] = (unsigned char)((v >> 8) & 0xff);
p[2] = (unsigned char)((v >> 16) & 0xff);
p[3] = (unsigned char)((v >> 24) & 0xff);
}
static void put_be32(unsigned char *p, unsigned long v) {
p[0] = (unsigned char)((v >> 24) & 0xff);
p[1] = (unsigned char)((v >> 16) & 0xff);
p[2] = (unsigned char)((v >> 8) & 0xff);
p[3] = (unsigned char)(v & 0xff);
}
static unsigned long get_le32(const unsigned char *p) {
return (unsigned long)p[0] | (unsigned long)p[1] << 8 |
(unsigned long)p[2] << 16 | (unsigned long)p[3] << 24;
}
static unsigned long crc32_update(unsigned long crc, const unsigned char *p,
size_t n) {
static unsigned long table[256];
static int ready;
if (!ready) {
for (unsigned long i = 0; i < 256; i++) {
unsigned long c = i;
for (int k = 0; k < 8; k++)
c = c & 1 ? 0xedb88320UL ^ (c >> 1) : c >> 1;
table[i] = c;
}
ready = 1;
}
crc ^= 0xffffffffUL;
for (size_t i = 0; i < n; i++)
crc = table[(crc ^ p[i]) & 0xff] ^ (crc >> 8);
return crc ^ 0xffffffffUL;
}
static unsigned char *png_chunk(unsigned char *o, const char *type,
const unsigned char *data, size_t n) {
put_be32(o, (unsigned long)n);
memcpy(o + 4, type, 4);
if (n)
memcpy(o + 8, data, n);
put_be32(o + 8 + n, crc32_update(0, o + 4, n + 4));
return o + 12 + n;
}
/* RGB rows (filter byte 0 each) in stored deflate blocks: no compressor in
* the tree, and clipboard screenshots stay well under the attach cap. */
static char *png_from_raw(const unsigned char *raw, size_t raw_n, long w,
long h, size_t *n) {
size_t blocks = raw_n / 65535 + 1;
size_t z_n = 2 + raw_n + blocks * 5 + 4;
size_t total = 8 + (12 + 13) + (12 + z_n) + 12;
unsigned char *out = malloc(total);
unsigned char *z = malloc(z_n);
if (!out || !z) {
free(out);
free(z);
return NULL;
}
unsigned char *q = z;
*q++ = 0x78;
*q++ = 0x01;
unsigned long a = 1, b = 0;
size_t left = raw_n, pos = 0;
do {
size_t len = left > 65535 ? 65535 : left;
*q++ = left == len ? 1 : 0;
q[0] = (unsigned char)(len & 0xff);
q[1] = (unsigned char)(len >> 8);
q[2] = (unsigned char)(~len & 0xff);
q[3] = (unsigned char)((~len >> 8) & 0xff);
q += 4;
memcpy(q, raw + pos, len);
for (size_t i = 0; i < len; i++) {
a = (a + raw[pos + i]) % 65521;
b = (b + a) % 65521;
}
q += len;
pos += len;
left -= len;
} while (left);
put_be32(q, b << 16 | a);
q += 4;
unsigned char ihdr[13];
put_be32(ihdr, (unsigned long)w);
put_be32(ihdr + 4, (unsigned long)h);
ihdr[8] = 8; /* bit depth */
ihdr[9] = 2; /* RGB */
ihdr[10] = ihdr[11] = ihdr[12] = 0;
unsigned char *o = out;
memcpy(o, "\x89PNG\r\n\x1a\n", 8);
o = png_chunk(o + 8, "IHDR", ihdr, sizeof(ihdr));
o = png_chunk(o, "IDAT", z, (size_t)(q - z));
o = png_chunk(o, "IEND", NULL, 0);
free(z);
*n = (size_t)(o - out);
return (char *)out;
}
/* CF_DIB is a BMP without its 14-byte file header; the pixel offset depends
* on the header size, BI_BITFIELDS masks and the color table. */
char *cst_dib_to_image(const void *dib_v, size_t sz, size_t *n) {
const unsigned char *dib = dib_v;
if (!dib || sz < 40)
return NULL;
unsigned long hsize = get_le32(dib);
long w = (long)(int)get_le32(dib + 4);
long hh = (long)(int)get_le32(dib + 8);
unsigned bits = (unsigned)(dib[14] | dib[15] << 8);
unsigned long comp = get_le32(dib + 16);
unsigned long used = get_le32(dib + 32);
if (hsize < 40 || hsize > sz)
return NULL;
size_t off = hsize;
if (hsize == 40 && comp == 3 /* BI_BITFIELDS */)
off += 12;
size_t colors = used;
if (!colors && bits <= 8)
colors = (size_t)1 << bits;
if (colors > 256)
return NULL;
off += colors * 4;
if (off > sz)
return NULL;
long h = hh < 0 ? -hh : hh;
if ((bits == 24 || bits == 32) && (comp == 0 || comp == 3) && w > 0 &&
h > 0 && w <= 32768 && h <= 32768) {
size_t stride = ((size_t)w * bits / 8 + 3) & ~(size_t)3;
size_t raw_n = (size_t)h * (1 + (size_t)w * 3);
if (stride * (size_t)h <= sz - off && raw_n < CST_ATTACH_MAX_BYTES) {
unsigned char *raw = malloc(raw_n);
if (!raw)
return NULL;
size_t bpp = bits / 8;
for (long y = 0; y < h; y++) {
long src_y = hh > 0 ? h - 1 - y : y; /* positive height = bottom-up */
const unsigned char *s = dib + off + (size_t)src_y * stride;
unsigned char *d = raw + (size_t)y * (1 + (size_t)w * 3);
*d++ = 0;
for (long x = 0; x < w; x++, s += bpp) {
*d++ = s[2];
*d++ = s[1];
*d++ = s[0];
}
}
char *png = png_from_raw(raw, raw_n, w, h, n);
free(raw);
return png;
}
}
char *out = malloc(14 + sz);
if (!out)
return NULL;
unsigned char *hd = (unsigned char *)out;
hd[0] = 'B';
hd[1] = 'M';
put_le32(hd + 2, (unsigned long)(14 + sz));
put_le32(hd + 6, 0);
put_le32(hd + 10, (unsigned long)(14 + off));
memcpy(out + 14, dib, sz);
*n = 14 + sz;
return out;
}
#ifdef _WIN32
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
UINT png = RegisterClipboardFormatA("PNG");
if (!OpenClipboard(NULL)) {
set_err(err, err_sz, "could not open the clipboard");
return -1;
}
HANDLE h = NULL;
int is_png = 0;
if (png && IsClipboardFormatAvailable(png)) {
h = GetClipboardData(png);
is_png = 1;
} else if (IsClipboardFormatAvailable(CF_DIB)) {
h = GetClipboardData(CF_DIB);
}
const unsigned char *p = h ? GlobalLock(h) : NULL;
if (!p) {
CloseClipboard();
set_err(err, err_sz, "no image on the clipboard");
return -1;
}
size_t sz = (size_t)GlobalSize(h);
char *out = NULL;
if (sz > CST_ATTACH_MAX_BYTES) {
set_err(err, err_sz, "clipboard image is too large");
} else if (is_png) {
out = malloc(sz ? sz : 1);
if (out) {
memcpy(out, p, sz);
*n = sz;
}
} else {
out = cst_dib_to_image(p, sz, n);
if (!out)
set_err(err, err_sz, "clipboard bitmap is malformed");
}
GlobalUnlock(h);
CloseClipboard();
if (!out)
return -1;
*data = out;
return 0;
}
#else
/* Runs cmd (stderr already redirected by the caller); 127 = not installed. */
static int run_bytes(const char *cmd, char **data, size_t *n) {
os_run_result_t r;
memset(&r, 0, sizeof(r));
if (os_run_shell_c(NULL, cmd, CST_ATTACH_MAX_BYTES + 1, &r) != 0) {
os_run_result_free(&r);
return -1;
}
if (r.exit_code != 0 || r.out_len == 0) {
int code = r.exit_code == 127 ? 127 : -1;
os_run_result_free(&r);
return code;
}
*data = r.out;
*n = r.out_len;
return 0;
}
static int type_ok(const char *s, size_t n) {
if (n < 7 || n > 63 || strncmp(s, "image/", 6) != 0)
return 0;
for (size_t i = 0; i < n; i++)
if (!isalnum((unsigned char)s[i]) && !strchr("/.+-", s[i]))
return 0;
return 1;
}
/* Best image MIME type from a newline-separated list: PNG first. */
static int pick_type(const char *list, size_t len, char *out, size_t out_sz) {
out[0] = 0;
size_t i = 0;
while (i < len) {
size_t j = i;
while (j < len && list[j] != '\n' && list[j] != '\r')
j++;
if (type_ok(list + i, j - i)) {
int is_png = j - i == 9 && strncmp(list + i, "image/png", 9) == 0;
if (is_png || !out[0])
snprintf(out, out_sz, "%.*s", (int)(j - i), list + i);
if (is_png)
break;
}
i = j + 1;
}
return out[0] ? 0 : -1;
}
static int via_lister(const char *list_cmd, const char *get_fmt,
const char *tool, char **data, size_t *n, char *err,
size_t err_sz) {
char *types = NULL;
size_t tn = 0;
int rc = run_bytes(list_cmd, &types, &tn);
if (rc == 127) {
char msg[96];
snprintf(msg, sizeof(msg), "reading the clipboard needs %s", tool);
set_err(err, err_sz, msg);
return -1;
}
char type[64];
int ok = rc == 0 && pick_type(types, tn, type, sizeof(type)) == 0;
free(types);
if (!ok) {
set_err(err, err_sz, "no image on the clipboard");
return -1;
}
char cmd[256];
snprintf(cmd, sizeof(cmd), get_fmt, type);
if (run_bytes(cmd, data, n) != 0) {
set_err(err, err_sz, "could not read the clipboard image");
return -1;
}
return 0;
}
#ifdef __APPLE__
static int mac_image(char **data, size_t *n, char *err, size_t err_sz) {
int rc = run_bytes("pngpaste - 2>/dev/null", data, n);
if (rc == 0)
return 0;
char path[1024];
int fd = os_mkstemp(path, sizeof(path), "cst-clip");
if (fd < 0) {
set_err(err, err_sz, "could not create a temp file");
return -1;
}
close(fd);
char cmd[1600];
snprintf(cmd, sizeof(cmd),
"osascript -e 'set f to open for access (POSIX file \"%s\") with "
"write permission' -e 'set eof f to 0' -e 'write (the clipboard as "
"\xc2\xabclass PNGf\xc2\xbb) to f' -e 'close access f' "
">/dev/null 2>&1",
path);
os_run_result_t r;
memset(&r, 0, sizeof(r));
int ran = os_run_shell_c(NULL, cmd, 4096, &r);
int code = r.exit_code;
os_run_result_free(&r);
char *buf = NULL;
size_t len = 0;
if (ran == 0 && code == 0) {
FILE *f = fopen(path, "rb");
if (f) {
char chunk[65536];
size_t got;
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0 &&
len <= CST_ATTACH_MAX_BYTES) {
char *nb = realloc(buf, len + got);
if (!nb)
break;
buf = nb;
memcpy(buf + len, chunk, got);
len += got;
}
fclose(f);
}
}
remove(path);
if (!buf || !len) {
free(buf);
set_err(err, err_sz, "no image on the clipboard");
return -1;
}
*data = buf;
*n = len;
return 0;
}
#endif
int cst_clipboard_image(char **data, size_t *n, char *err, size_t err_sz) {
*data = NULL;
*n = 0;
#ifdef __APPLE__
return mac_image(data, n, err, err_sz);
#else
const char *wl = getenv("WAYLAND_DISPLAY");
if (wl && *wl)
return via_lister("wl-paste --list-types 2>/dev/null",
"wl-paste --no-newline --type '%s' 2>/dev/null",
"wl-clipboard (wl-paste)", data, n, err, err_sz);
const char *x = getenv("DISPLAY");
if (x && *x)
return via_lister("xclip -selection clipboard -t TARGETS -o 2>/dev/null",
"xclip -selection clipboard -t '%s' -o 2>/dev/null",
"xclip", data, n, err, err_sz);
set_err(err, err_sz, "no clipboard here (no Wayland or X11 display)");
return -1;
#endif
}
#endif
static int hexval(int c) {
if (c >= '0' && c <= '9')
return c - '0';
if (c >= 'a' && c <= 'f')
return c - 'a' + 10;
if (c >= 'A' && c <= 'F')
return c - 'A' + 10;
return -1;
}
/* file://[host]/path -> /path, percent-decoded; 0 on success. */
static int from_file_uri(char *s) {
const char *p = s + 7;
const char *slash = strchr(p, '/');
if (!slash)
return -1;
if (slash != p && !(slash - p == 9 && strncmp(p, "localhost", 9) == 0))
return -1;
char *o = s;
for (const char *q = slash; *q; q++) {
if (*q == '%' && hexval(q[1]) >= 0 && hexval(q[2]) >= 0) {
*o++ = (char)(hexval(q[1]) * 16 + hexval(q[2]));
q += 2;
} else {
*o++ = *q;
}
}
*o = 0;
#ifdef _WIN32
if (s[0] == '/' && isalpha((unsigned char)s[1]) && s[2] == ':')
memmove(s, s + 1, strlen(s));
#endif
return s[0] ? 0 : -1;
}
static int is_space(char c) {
return c == ' ' || c == '\t' || c == '\r' || c == '\n';
}
int cst_paste_paths(const char *text, size_t len,
char out[][CST_PASTE_PATH_MAX], int max) {
int n = 0;
size_t i = 0;
while (i < len) {
while (i < len && is_space(text[i]))
i++;
if (i >= len)
break;
if (n >= max)
return -1;
char *tok = out[n];
size_t k = 0;
char q = 0;
while (i < len) {
char c = text[i];
if (!c)
return -1;
if (q) {
if (c == q) {
q = 0;
i++;
continue;
}
} else {
if (c == '\'' || c == '"') {
q = c;
i++;
continue;
}
if (is_space(c))
break;
#ifndef _WIN32
if (c == '\\' && i + 1 < len)
c = text[++i];
#endif
}
if (k + 1 >= CST_PASTE_PATH_MAX)
return -1;
tok[k++] = c;
i++;
}
if (q || !k)
return -1;
tok[k] = 0;
if (strncmp(tok, "file://", 7) == 0 && from_file_uri(tok) != 0)
return -1;
n++;
}
return n > 0 ? n : -1;
}
+18 -10
View File
@@ -12,6 +12,7 @@
#ifdef _WIN32 #ifdef _WIN32
#include <io.h> #include <io.h>
#include <windows.h>
#else #else
#include <fcntl.h> #include <fcntl.h>
#include <termios.h> #include <termios.h>
@@ -51,7 +52,7 @@ int cst_creds_load(const char *provider, char *out, size_t out_sz) {
char path[OS_PATH_MAX]; char path[OS_PATH_MAX];
if (credentials_path(path, sizeof(path)) != 0) if (credentials_path(path, sizeof(path)) != 0)
return -1; return -1;
FILE *fp = fopen(path, "r"); FILE *fp = fopen(path, "rb");
if (!fp) if (!fp)
return -1; return -1;
char want[64]; char want[64];
@@ -104,7 +105,9 @@ int cst_creds_save(const char *provider, const char *key) {
size_t wl = strlen(want); size_t wl = strlen(want);
#ifdef _WIN32 #ifdef _WIN32
FILE *out = fopen(tmp, "w"); FILE *out = fopen(tmp, "wb");
if (out)
(void)os_chmod_private(tmp); /* label before the key is written */
#else #else
/* never exists world-readable, even briefly */ /* never exists world-readable, even briefly */
int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600); int fd = open(tmp, O_WRONLY | O_CREAT | O_TRUNC, 0600);
@@ -114,7 +117,7 @@ int cst_creds_save(const char *provider, const char *key) {
#endif #endif
if (!out) if (!out)
return -1; return -1;
FILE *in = fopen(path, "r"); FILE *in = fopen(path, "rb");
if (in) { if (in) {
char line[1024]; char line[1024];
while (fgets(line, sizeof(line), in)) { while (fgets(line, sizeof(line), in)) {
@@ -128,10 +131,7 @@ int cst_creds_save(const char *provider, const char *key) {
remove(tmp); remove(tmp);
return -1; return -1;
} }
#ifdef _WIN32 if (os_rename_replace(tmp, path) != 0) {
remove(path);
#endif
if (rename(tmp, path) != 0) {
remove(tmp); remove(tmp);
return -1; return -1;
} }
@@ -167,7 +167,13 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz) {
fprintf(stderr, "API key for %s (input hidden, saved to the config dir): ", fprintf(stderr, "API key for %s (input hidden, saved to the config dir): ",
provider ? provider : "?"); provider ? provider : "?");
fflush(stderr); fflush(stderr);
#ifndef _WIN32 #ifdef _WIN32
HANDLE hin = GetStdHandle(STD_INPUT_HANDLE);
DWORD old_mode = 0;
int tty = GetConsoleMode(hin, &old_mode) != 0;
if (tty)
SetConsoleMode(hin, old_mode & ~(DWORD)ENABLE_ECHO_INPUT);
#else
struct termios old, quiet; struct termios old, quiet;
int tty = isatty(STDIN_FILENO) && tcgetattr(STDIN_FILENO, &old) == 0; int tty = isatty(STDIN_FILENO) && tcgetattr(STDIN_FILENO, &old) == 0;
if (tty) { if (tty) {
@@ -177,12 +183,14 @@ int cst_creds_prompt_console(const char *provider, char *out, size_t out_sz) {
} }
#endif #endif
char *got = fgets(out, (int)out_sz, stdin); char *got = fgets(out, (int)out_sz, stdin);
#ifndef _WIN32
if (tty) { if (tty) {
#ifdef _WIN32
SetConsoleMode(hin, old_mode);
#else
tcsetattr(STDIN_FILENO, TCSAFLUSH, &old); tcsetattr(STDIN_FILENO, TCSAFLUSH, &old);
#endif
fputc('\n', stderr); fputc('\n', stderr);
} }
#endif
if (!got) { if (!got) {
out[0] = 0; out[0] = 0;
return -1; return -1;
+5 -1
View File
@@ -1,6 +1,7 @@
#include "cst_keymap.h" #include "cst_keymap.h"
#include "cJSON.h" #include "cJSON.h"
#include "cst_util.h"
#include "os_compat.h" #include "os_compat.h"
#include <ctype.h> #include <ctype.h>
@@ -47,6 +48,8 @@ static const cst_key_action_info_t INFO[CST_KA_COUNT] = {
[CST_KA_COPY] = {"copy", "Session", "copy the last reply", "/copy"}, [CST_KA_COPY] = {"copy", "Session", "copy the last reply", "/copy"},
[CST_KA_EXPORT] = {"export", "Session", "export the transcript", [CST_KA_EXPORT] = {"export", "Session", "export the transcript",
"/export"}, "/export"},
[CST_KA_PASTE_IMAGE] = {"paste_image", "Session",
"attach the clipboard image", "/attach paste"},
[CST_KA_MODEL] = {"model", "Model", "pick the model", "/model"}, [CST_KA_MODEL] = {"model", "Model", "pick the model", "/model"},
[CST_KA_PROVIDER] = {"provider", "Model", "switch provider", "/provider"}, [CST_KA_PROVIDER] = {"provider", "Model", "switch provider", "/provider"},
[CST_KA_EFFORT] = {"effort", "Model", "reasoning effort", "/effort"}, [CST_KA_EFFORT] = {"effort", "Model", "reasoning effort", "/effort"},
@@ -88,6 +91,7 @@ static const struct {
{CST_KA_COMPACT, "<leader> c"}, {CST_KA_COMPACT, "<leader> c"},
{CST_KA_COPY, "<leader> y"}, {CST_KA_COPY, "<leader> y"},
{CST_KA_EXPORT, "<leader> x"}, {CST_KA_EXPORT, "<leader> x"},
{CST_KA_PASTE_IMAGE, "ctrl+v"},
{CST_KA_MODEL, "<leader> m"}, {CST_KA_MODEL, "<leader> m"},
{CST_KA_PROVIDER, "<leader> p"}, {CST_KA_PROVIDER, "<leader> p"},
{CST_KA_EFFORT, "<leader> e"}, {CST_KA_EFFORT, "<leader> e"},
@@ -491,7 +495,7 @@ int cst_keymap_load(cst_keymap_t *km, char *err, size_t err_sz) {
size_t n = fread(buf, 1, sizeof(buf) - 1, fp); size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp); fclose(fp);
buf[n] = 0; buf[n] = 0;
return cst_keymap_apply_json(km, buf, err, err_sz); return cst_keymap_apply_json(km, buf + cst_bom_len(buf, n), err, err_sz);
} }
int cst_keymap_lookup(const cst_keymap_t *km, const int *seq, int n, int cst_keymap_lookup(const cst_keymap_t *km, const int *seq, int n,
+471 -12
View File
@@ -22,6 +22,48 @@
#define ERRBODY_MAX 4096 #define ERRBODY_MAX 4096
#define LLM_MAX_RETRIES 3 #define LLM_MAX_RETRIES 3
/* Point libcurl at a CA bundle on Windows.
*
* An OpenSSL-based libcurl keeps the path it was built with -- MSYS2's is
* "/mingw64/etc/ssl/certs/ca-bundle.crt", which resolves to nothing once the
* binary runs outside an MSYS2 shell, and that build ignores CURL_CA_BUNDLE.
* Every HTTPS request then dies with CURLE_SSL_CACERT_BADFILE (77).
*
* Order: $CST_CA_BUNDLE, then ca-bundle.crt beside the executable. If neither
* exists nothing is set, so a libcurl linked against Schannel (the Windows
* certificate store) behaves exactly as before. */
static void llm_apply_ca_bundle(CURL *curl) {
#if defined(_WIN32)
static char ca[OS_PATH_MAX];
static int checked;
if (!checked) {
checked = 1;
const char *env = getenv("CST_CA_BUNDLE");
if (env && env[0]) {
snprintf(ca, sizeof(ca), "%s", env);
} else {
char dir[OS_PATH_MAX];
if (os_exe_dir(dir, sizeof(dir)) == 0)
os_path_join(ca, sizeof(ca), dir, "ca-bundle.crt");
}
if (ca[0]) {
FILE *f = fopen(ca, "rb");
if (f)
fclose(f);
else
ca[0] = '\0';
}
}
if (ca[0])
curl_easy_setopt(curl, CURLOPT_CAINFO, ca);
#else
(void)curl;
#endif
}
#include "cst_attach.h"
#include "cst_models.h"
/* --- wire body ----------------------------------------------------------- */ /* --- wire body ----------------------------------------------------------- */
static size_t wire_tool_cap(const cst_llm_request_t *req) { static size_t wire_tool_cap(const cst_llm_request_t *req) {
@@ -77,6 +119,210 @@ static cJSON *clean_string(const char *s, size_t cap) {
return j; return j;
} }
/* --- multimodal content --------------------------------------------------
* OpenAI-compatible servers take a parts array where a plain string is also
* accepted, so text-only messages keep the string form and only those with
* images switch to parts. Images live in the session store and are read (and
* base64'd) here, once per request. */
/* The message's "content" as a parts array, converting a string in place. */
static cJSON *content_parts(cJSON *msg) {
cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
if (cJSON_IsArray(c))
return c;
cJSON *arr = cJSON_CreateArray();
if (cJSON_IsString(c)) {
cJSON *t = cJSON_CreateObject();
cJSON_AddStringToObject(t, "type", "text");
cJSON_AddStringToObject(t, "text", c->valuestring);
cJSON_AddItemToArray(arr, t);
}
cJSON_ReplaceItemInObjectCaseSensitive(msg, "content", arr);
return arr;
}
/* Append text, joining it onto a trailing text part when there is one.
* Takes ownership of txt. */
static void parts_add_text(cJSON *parts, cJSON *txt) {
if (!txt)
txt = cJSON_CreateString("");
cJSON *last = cJSON_GetArrayItem(parts, cJSON_GetArraySize(parts) - 1);
const cJSON *lt = last ? cJSON_GetObjectItemCaseSensitive(last, "type") : NULL;
if (cJSON_IsString(lt) && strcmp(lt->valuestring, "text") == 0) {
const cJSON *old = cJSON_GetObjectItemCaseSensitive(last, "text");
cst_buf_t b = {0};
if (cJSON_IsString(old))
cst_buf_puts(&b, old->valuestring);
if (b.len)
cst_buf_puts(&b, "\n\n");
cst_buf_puts(&b, txt->valuestring ? txt->valuestring : "");
cJSON_ReplaceItemInObjectCaseSensitive(
last, "text", cJSON_CreateString(b.buf ? b.buf : ""));
cst_buf_free(&b);
cJSON_Delete(txt);
return;
}
cJSON_AddItemToArray(parts, txt);
}
/* One stored image as an image_url part, or NULL with why filled in. */
static cJSON *image_part(const cst_llm_request_t *req,
const cst_attachment_t *a, size_t *budget, char *why,
size_t why_sz) {
why[0] = 0;
if (!req->session_path || !req->session_path[0]) {
snprintf(why, why_sz, "there is no session store to read it from");
return NULL;
}
char err[256];
size_t n = 0;
char *data = cst_attach_read(req->session_path, a, &n, err, sizeof(err));
if (!data) {
snprintf(why, why_sz, "%s", err);
return NULL;
}
if (n > *budget) {
snprintf(why, why_sz, "%d KB left in the %d MB image budget",
(int)(*budget / 1024), (int)(CST_ATTACH_MAX_TOTAL >> 20));
free(data);
return NULL;
}
*budget -= n;
char mime[32];
cst_attach_sniff(data, n, mime, sizeof(mime)); /* read verified it */
char *b64 = cst_base64_encode(data, n);
free(data);
if (!b64) {
snprintf(why, why_sz, "out of memory");
return NULL;
}
cst_buf_t b = {0};
cst_buf_puts(&b, "data:");
cst_buf_puts(&b, mime);
cst_buf_puts(&b, ";base64,");
cst_buf_puts(&b, b64);
free(b64);
if (!b.buf) {
snprintf(why, why_sz, "out of memory");
return NULL;
}
cJSON *p = cJSON_CreateObject();
cJSON_AddStringToObject(p, "type", "image_url");
cJSON *iu = cJSON_AddObjectToObject(p, "image_url");
cJSON_AddStringToObject(iu, "url", b.buf);
if (a->detail[0] && strcmp(a->detail, "auto") != 0)
cJSON_AddStringToObject(iu, "detail", a->detail);
cst_buf_free(&b);
return p;
}
/* Append every attachment of m as image parts (or a note saying why not). */
static void parts_add_images(const cst_llm_request_t *req, cJSON *parts,
const cst_message_t *m, size_t *budget) {
int n = m->nattach;
if (n > CST_ATTACH_MAX_COUNT) {
char note[160];
snprintf(note, sizeof(note), "[%d of %d images sent]", CST_ATTACH_MAX_COUNT,
n);
parts_add_text(parts, cJSON_CreateString(note));
n = CST_ATTACH_MAX_COUNT;
}
for (int i = 0; i < n; i++) {
const cst_attachment_t *a = &m->attach[i];
char why[256];
cJSON *p = image_part(req, a, budget, why, sizeof(why));
if (p) {
cJSON_AddItemToArray(parts, p);
continue;
}
char note[512];
snprintf(note, sizeof(note), "[image \"%.120s\" not sent: %.200s]",
a->name, why);
parts_add_text(parts, cJSON_CreateString(note));
}
}
/* Images a tool result produced are deferred: an assistant tool_call must be
* answered by tool messages only, so they ride in one extra user message
* after the run of tool messages ends. *pending collects it. */
static void defer_images(const cst_llm_request_t *req, const cst_message_t *m,
cJSON **pending, size_t *budget) {
cJSON *p = *pending;
if (!p) {
p = cJSON_CreateObject();
cJSON_AddStringToObject(p, "role", "user");
cJSON_AddArrayToObject(p, "content");
*pending = p;
}
parts_add_images(req, cJSON_GetObjectItemCaseSensitive(p, "content"), m,
budget);
}
/* m's text plus one line per image it would have carried (malloc'd). */
static char *image_notes(const cst_message_t *m, const char *why) {
cst_buf_t b = {0};
cst_buf_puts(&b, m->content ? m->content : "");
for (int i = 0; i < m->nattach; i++) {
char d[128];
cst_attach_describe(&m->attach[i], d, sizeof(d));
cst_buf_printf(&b, "%s[image \"%.120s\" (%s) not sent: %s]",
b.len ? "\n" : "", m->attach[i].name, d, why);
}
return cst_buf_steal(&b);
}
/* --- cache breakpoints (Anthropic-style) ----------------------------------
* At most 4 per request. The system prompt one also covers tools[]; the
* newest message one moves forward every step; the newest real user message
* keeps a hit when a long tool run pushes the previous one out of the
* provider's lookback. */
static void mark_message(cJSON *msg, const char *ttl) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
if (cJSON_IsString(c) ? !c->valuestring[0] : !cJSON_IsArray(c))
return;
cJSON *parts = content_parts(msg);
cJSON *last = cJSON_GetArrayItem(parts, cJSON_GetArraySize(parts) - 1);
if (!cJSON_IsObject(last) ||
cJSON_GetObjectItemCaseSensitive(last, "cache_control"))
return;
cJSON *cc = cJSON_AddObjectToObject(last, "cache_control");
cJSON_AddStringToObject(cc, "type", "ephemeral");
if (ttl && ttl[0])
cJSON_AddStringToObject(cc, "ttl", ttl);
}
static int msg_role_is(const cJSON *msg, const char *role) {
const cJSON *r = cJSON_GetObjectItemCaseSensitive(msg, "role");
return cJSON_IsString(r) && strcmp(r->valuestring, role) == 0;
}
static int msg_has_content(const cJSON *msg) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(msg, "content");
return cJSON_IsString(c) ? c->valuestring[0] != 0 : cJSON_IsArray(c);
}
static void cache_marks(cJSON *msgs, const char *ttl) {
int n = cJSON_GetArraySize(msgs);
cJSON *first = cJSON_GetArrayItem(msgs, 0);
if (first && msg_role_is(first, "system"))
mark_message(first, ttl);
int last = -1;
for (int i = n - 1; i > 0 && last < 0; i--)
if (msg_has_content(cJSON_GetArrayItem(msgs, i)))
last = i;
if (last < 0)
return;
mark_message(cJSON_GetArrayItem(msgs, last), ttl);
for (int i = last - 1; i > 0; i--) {
cJSON *m = cJSON_GetArrayItem(msgs, i);
if (msg_role_is(m, "user") && msg_has_content(m)) {
mark_message(m, ttl);
break;
}
}
}
static const char *role_api(cst_role_t r) { static const char *role_api(cst_role_t r) {
switch (r) { switch (r) {
case CST_ROLE_SYSTEM: case CST_ROLE_SYSTEM:
@@ -107,19 +353,48 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON *msgs = cJSON_AddArrayToObject(root, "messages"); cJSON *msgs = cJSON_AddArrayToObject(root, "messages");
size_t tcap = wire_tool_cap(req); size_t tcap = wire_tool_cap(req);
cJSON *prev_user = NULL; /* merge consecutive user messages */ cJSON *prev_user = NULL; /* merge consecutive user messages */
cJSON *pending = NULL; /* images from tool results, waiting for a break */
size_t img_budget = CST_ATTACH_MAX_TOTAL;
const char *noimg = getenv("CST_NO_IMAGES");
const char *img_off =
noimg && noimg[0] && noimg[0] != '0'
? "image sending is disabled (CST_NO_IMAGES)"
: req->images_off ? "this model does not take images"
: NULL;
for (int i = 0; i < req->nmsgs; i++) { for (int i = 0; i < req->nmsgs; i++) {
const cst_message_t *m = &req->msgs[i]; cst_message_t mm = req->msgs[i];
const cst_message_t *m = &mm;
cst_role_t role = m->role; cst_role_t role = m->role;
if (role == CST_ROLE_TOOL && !m->tool_call_id[0]) if (role == CST_ROLE_TOOL && !m->tool_call_id[0])
role = CST_ROLE_USER; role = CST_ROLE_USER;
int has_img = m->attach && m->nattach > 0;
char *noted = NULL;
if (has_img && img_off) {
/* plain text: text-only APIs may refuse content arrays */
noted = image_notes(m, img_off);
if (noted)
mm.content = noted;
has_img = 0;
}
/* Every tool_call must be answered by tool messages, so tool-produced
* images only leave the queue when the run of tool results is over. */
if (role != CST_ROLE_TOOL && pending) {
cJSON_AddItemToArray(msgs, pending);
prev_user = pending;
pending = NULL;
}
if (role == CST_ROLE_USER) { if (role == CST_ROLE_USER) {
/* Text-protocol result frames are capped by the agent before framing /* Text-protocol result frames are capped by the agent before framing
* (their byte counts must stay exact), so never cap user text here. */ * (their byte counts must stay exact), so never cap user text here. */
cJSON *pc = prev_user
? cJSON_GetObjectItemCaseSensitive(prev_user, "content")
: NULL;
if (!has_img && !cJSON_IsArray(pc)) {
cJSON *c = clean_string(m->content, 0); cJSON *c = clean_string(m->content, 0);
if (prev_user) { if (prev_user) {
cJSON *pc = cJSON_GetObjectItemCaseSensitive(prev_user, "content");
cst_buf_t b = {0}; cst_buf_t b = {0};
cst_buf_puts(&b, pc->valuestring); cst_buf_puts(&b, pc->valuestring);
cst_buf_puts(&b, "\n\n"); cst_buf_puts(&b, "\n\n");
@@ -128,6 +403,7 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_CreateString(b.buf)); cJSON_CreateString(b.buf));
cst_buf_free(&b); cst_buf_free(&b);
cJSON_Delete(c); cJSON_Delete(c);
free(noted);
continue; continue;
} }
cJSON *o = cJSON_CreateObject(); cJSON *o = cJSON_CreateObject();
@@ -135,6 +411,22 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
cJSON_AddItemToObject(o, "content", c); cJSON_AddItemToObject(o, "content", c);
cJSON_AddItemToArray(msgs, o); cJSON_AddItemToArray(msgs, o);
prev_user = o; prev_user = o;
free(noted);
continue;
}
cJSON *o = prev_user;
if (!o) {
o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "role", "user");
cJSON_AddArrayToObject(o, "content");
cJSON_AddItemToArray(msgs, o);
prev_user = o;
}
cJSON *parts = content_parts(o);
parts_add_text(parts, clean_string(m->content, 0));
if (has_img)
parts_add_images(req, parts, m, &img_budget);
free(noted);
continue; continue;
} }
prev_user = NULL; prev_user = NULL;
@@ -159,17 +451,39 @@ char *cst_llm_build_body(const cst_llm_request_t *req) {
} else { } else {
cJSON_AddItemToObject(o, "content", clean_string(m->content, 0)); cJSON_AddItemToObject(o, "content", clean_string(m->content, 0));
} }
cJSON_AddItemToArray(msgs, o); if (role == CST_ROLE_ASSISTANT) {
if (req->send_reasoning && m->reasoning && m->reasoning[0])
cJSON_AddItemToObject(o, "reasoning_content",
clean_string(m->reasoning, 0));
if (m->prefix)
cJSON_AddTrueToObject(o, "prefix");
} }
cJSON_AddItemToArray(msgs, o);
if (role == CST_ROLE_TOOL && has_img)
defer_images(req, m, &pending, &img_budget);
free(noted);
}
if (pending)
cJSON_AddItemToArray(msgs, pending);
if (req->enable_tools) { if (req->enable_tools) {
cJSON *tools = cst_tools_schema_cfg(req->tool_cfg); cJSON *tools = req->tools_json ? cJSON_Parse(req->tools_json)
: cst_tools_schema_cfg(req->tool_cfg);
if (tools) { if (tools) {
cJSON_AddItemToObject(root, "tools", tools); cJSON_AddItemToObject(root, "tools", tools);
cJSON_AddStringToObject(root, "tool_choice", cJSON_AddStringToObject(root, "tool_choice",
req->tool_choice_none ? "none" : "auto"); req->tool_choice_none ? "none" : "auto");
} }
} }
if (req->cache_style == CST_CACHE_OPENAI && req->cache_key &&
req->cache_key[0]) {
cJSON_AddStringToObject(root, "prompt_cache_key", req->cache_key);
const char *ret = getenv("CST_CACHE_RETENTION");
if (ret && ret[0])
cJSON_AddStringToObject(root, "prompt_cache_retention", ret);
} else if (req->cache_style == CST_CACHE_ANTHROPIC) {
cache_marks(msgs, req->cache_ttl);
}
char *out = cJSON_PrintUnformatted(root); char *out = cJSON_PrintUnformatted(root);
cJSON_Delete(root); cJSON_Delete(root);
return out; return out;
@@ -332,11 +646,22 @@ static void handle_payload(sse_t *s, const char *payload) {
const cJSON *pt = cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens"); const cJSON *pt = cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens");
const cJSON *ct = const cJSON *ct =
cJSON_GetObjectItemCaseSensitive(usage, "completion_tokens"); cJSON_GetObjectItemCaseSensitive(usage, "completion_tokens");
/* DeepSeek: prompt_cache_hit/miss_tokens. OpenAI and routers:
* prompt_tokens_details.cached_tokens (+ cache_write_tokens).
* Anthropic fields passed through: cache_read/creation_input_tokens. */
const cJSON *hit = const cJSON *hit =
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_hit_tokens"); cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_hit_tokens");
const cJSON *miss =
cJSON_GetObjectItemCaseSensitive(usage, "prompt_cache_miss_tokens");
const cJSON *det = const cJSON *det =
cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens_details"); cJSON_GetObjectItemCaseSensitive(usage, "prompt_tokens_details");
const cJSON *cached = cJSON_GetObjectItemCaseSensitive(det, "cached_tokens"); const cJSON *cached = cJSON_GetObjectItemCaseSensitive(det, "cached_tokens");
const cJSON *dwrite =
cJSON_GetObjectItemCaseSensitive(det, "cache_write_tokens");
const cJSON *aread =
cJSON_GetObjectItemCaseSensitive(usage, "cache_read_input_tokens");
const cJSON *awrite =
cJSON_GetObjectItemCaseSensitive(usage, "cache_creation_input_tokens");
if (cJSON_IsNumber(pt)) if (cJSON_IsNumber(pt))
s->st->prompt_tokens = (long)pt->valuedouble; s->st->prompt_tokens = (long)pt->valuedouble;
if (cJSON_IsNumber(ct)) if (cJSON_IsNumber(ct))
@@ -345,6 +670,19 @@ static void handle_payload(sse_t *s, const char *payload) {
s->st->cached_tokens = (long)hit->valuedouble; s->st->cached_tokens = (long)hit->valuedouble;
else if (cJSON_IsNumber(cached)) else if (cJSON_IsNumber(cached))
s->st->cached_tokens = (long)cached->valuedouble; s->st->cached_tokens = (long)cached->valuedouble;
else if (cJSON_IsNumber(aread))
s->st->cached_tokens = (long)aread->valuedouble;
if (cJSON_IsNumber(dwrite))
s->st->cache_write_tokens = (long)dwrite->valuedouble;
else if (cJSON_IsNumber(awrite))
s->st->cache_write_tokens = (long)awrite->valuedouble;
if (cJSON_IsNumber(miss)) {
s->st->cache_miss_tokens = (long)miss->valuedouble;
} else {
long m = s->st->prompt_tokens - s->st->cached_tokens -
s->st->cache_write_tokens;
s->st->cache_miss_tokens = m > 0 ? m : 0;
}
} }
const cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices"); const cJSON *choices = cJSON_GetObjectItemCaseSensitive(root, "choices");
const cJSON *ch = cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0) const cJSON *ch = cJSON_IsArray(choices) ? cJSON_GetArrayItem(choices, 0)
@@ -487,15 +825,37 @@ static void trace_path(const char *dir, int seq, const char *ext, char *out,
snprintf(out, n, "%s/%04d.%s", dir, seq, ext); snprintf(out, n, "%s/%04d.%s", dir, seq, ext);
} }
static void format_http_error(sse_t *s, long http, char *err, size_t err_sz);
/* NNNN.sse is fed as the stream; with an NNNN.status other than 200 it is
* the error body of that HTTP status instead. */
static int replay_run(sse_t *s, const char *dir, int seq, char *err, static int replay_run(sse_t *s, const char *dir, int seq, char *err,
size_t err_sz) { size_t err_sz) {
char path[OS_PATH_MAX]; char path[OS_PATH_MAX];
trace_path(dir, seq, "status", path, sizeof(path));
FILE *sf = fopen(path, "rb");
long status = 200;
if (sf) {
if (fscanf(sf, "%ld", &status) != 1)
status = 200;
fclose(sf);
}
trace_path(dir, seq, "sse", path, sizeof(path)); trace_path(dir, seq, "sse", path, sizeof(path));
FILE *fp = fopen(path, "rb"); FILE *fp = fopen(path, "rb");
if (!fp) { if (!fp) {
snprintf(err, err_sz, "replay: missing %s", path); snprintf(err, err_sz, "replay: missing %s", path);
return -1; return -1;
} }
if (status != 200) {
char eb[4096];
size_t got = fread(eb, 1, sizeof(eb), fp);
fclose(fp);
cst_buf_append(&s->errbody, eb, got);
if (s->st)
s->st->http_status = status;
format_http_error(s, status, err, err_sz);
return -1;
}
size_t chunk = 16; size_t chunk = 16;
const char *e = getenv("CST_REPLAY_CHUNK"); const char *e = getenv("CST_REPLAY_CHUNK");
if (e && atoi(e) > 0) if (e && atoi(e) > 0)
@@ -621,6 +981,7 @@ int cst_llm_chat_stream(const cst_llm_request_t *req, char *err,
ret = -1; ret = -1;
break; break;
} }
llm_apply_ca_bundle(curl);
sse_reset(&s); sse_reset(&s);
s.curl = curl; s.curl = curl;
if (rec_dir && rec_dir[0]) { if (rec_dir && rec_dir[0]) {
@@ -702,7 +1063,7 @@ finish:
/* --- model listing -------------------------------------------------------- */ /* --- model listing -------------------------------------------------------- */
#define MODELS_BODY_MAX (2 * 1024 * 1024) #define MODELS_BODY_MAX (8 * 1024 * 1024)
static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) { static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
cst_buf_t *b = ud; cst_buf_t *b = ud;
@@ -712,18 +1073,97 @@ static size_t models_write_cb(char *ptr, size_t size, size_t nmemb, void *ud) {
return n; return n;
} }
static int add_model(char out[][128], int n, int max_out, const char *id) { /* A number, or a numeric string (OpenRouter prices are strings). */
if (!id || !id[0] || n >= max_out || strlen(id) >= 128) static int json_num(const cJSON *v, double *out) {
if (cJSON_IsNumber(v)) {
*out = v->valuedouble;
return 0;
}
if (cJSON_IsString(v) && v->valuestring[0]) {
char *end;
double d = strtod(v->valuestring, &end);
if (end != v->valuestring && !*end) {
*out = d;
return 0;
}
}
return -1;
}
static int has_image(const cJSON *arr) {
const cJSON *x;
cJSON_ArrayForEach(x, arr) {
if (cJSON_IsString(x) && strcmp(x->valuestring, "image") == 0)
return 1;
}
return 0;
}
/* Optional OpenRouter-style metadata: context_length (or context_window /
* max_model_len), architecture.input_modalities, modalities, and pricing in
* USD per token. */
static void model_meta(const cJSON *it, cst_model_meta_t *m) {
static const char *const ctx_keys[] = {"context_length", "context_window",
"max_model_len"};
for (size_t k = 0; k < sizeof(ctx_keys) / sizeof(ctx_keys[0]); k++) {
double d;
if (json_num(cJSON_GetObjectItemCaseSensitive(it, ctx_keys[k]), &d) == 0 &&
d > 0) {
m->context = (long)d;
break;
}
}
const cJSON *arch = cJSON_GetObjectItemCaseSensitive(it, "architecture");
const cJSON *in = cJSON_GetObjectItemCaseSensitive(arch, "input_modalities");
const cJSON *mods = cJSON_GetObjectItemCaseSensitive(it, "modalities");
if (cJSON_IsObject(mods))
mods = cJSON_GetObjectItemCaseSensitive(mods, "input");
const cJSON *modality = cJSON_GetObjectItemCaseSensitive(arch, "modality");
if (cJSON_IsArray(in))
m->vision = has_image(in);
else if (cJSON_IsArray(mods))
m->vision = has_image(mods);
else if (cJSON_IsString(modality)) {
const char *arrow = strstr(modality->valuestring, "->");
size_t l = arrow ? (size_t)(arrow - modality->valuestring)
: strlen(modality->valuestring);
char left[64];
snprintf(left, sizeof(left), "%.*s", (int)l, modality->valuestring);
m->vision = strstr(left, "image") != NULL;
}
const cJSON *pr = cJSON_GetObjectItemCaseSensitive(it, "pricing");
double miss, out;
if (json_num(cJSON_GetObjectItemCaseSensitive(pr, "prompt"), &miss) == 0 &&
json_num(cJSON_GetObjectItemCaseSensitive(pr, "completion"), &out) == 0 &&
miss >= 0 && out >= 0) {
double hit = miss, write = miss;
json_num(cJSON_GetObjectItemCaseSensitive(pr, "input_cache_read"), &hit);
json_num(cJSON_GetObjectItemCaseSensitive(pr, "input_cache_write"), &write);
m->has_price = 1;
m->miss = miss * 1e6;
m->out = out * 1e6;
m->hit = (hit >= 0 ? hit : miss) * 1e6;
m->write = (write >= 0 ? write : miss) * 1e6;
}
}
static int add_model(cst_model_meta_t *out, int n, int max_out,
const char *id, const cJSON *it) {
if (!id || !id[0] || n >= max_out || strlen(id) >= sizeof(out->id))
return n; return n;
for (int i = 0; i < n; i++) for (int i = 0; i < n; i++)
if (strcmp(out[i], id) == 0) if (strcmp(out[i].id, id) == 0)
return n; return n;
snprintf(out[n], 128, "%s", id); memset(&out[n], 0, sizeof(out[n]));
out[n].vision = -1;
snprintf(out[n].id, sizeof(out[n].id), "%s", id);
if (cJSON_IsObject(it))
model_meta(it, &out[n]);
return n + 1; return n + 1;
} }
/* {"data":[{"id":..}]}, {"models":[{"id"|"name":..}]} or a bare array. */ /* {"data":[{"id":..}]}, {"models":[{"id"|"name":..}]} or a bare array. */
static int parse_models(const char *json, char out[][128], int max_out) { static int parse_models(const char *json, cst_model_meta_t *out, int max_out) {
cJSON *root = cJSON_Parse(json); cJSON *root = cJSON_Parse(json);
if (!root) if (!root)
return -1; return -1;
@@ -741,9 +1181,9 @@ static int parse_models(const char *json, char out[][128], int max_out) {
if (!cJSON_IsString(id)) if (!cJSON_IsString(id))
id = cJSON_GetObjectItemCaseSensitive(it, "name"); id = cJSON_GetObjectItemCaseSensitive(it, "name");
if (cJSON_IsString(id)) if (cJSON_IsString(id))
n = add_model(out, n, max_out, id->valuestring); n = add_model(out, n, max_out, id->valuestring, it);
else if (cJSON_IsString(it)) else if (cJSON_IsString(it))
n = add_model(out, n, max_out, it->valuestring); n = add_model(out, n, max_out, it->valuestring, NULL);
} }
} }
cJSON_Delete(root); cJSON_Delete(root);
@@ -753,6 +1193,24 @@ static int parse_models(const char *json, char out[][128], int max_out) {
int cst_llm_list_models(const char *api_base, const char *api_key, int cst_llm_list_models(const char *api_base, const char *api_key,
char out[][128], int max_out, char *err, char out[][128], int max_out, char *err,
size_t err_sz) { size_t err_sz) {
if (!out || max_out <= 0)
return cst_llm_list_models_meta(api_base, api_key, NULL, 0, err, err_sz);
cst_model_meta_t *m = calloc((size_t)max_out, sizeof(*m));
if (!m) {
if (err && err_sz)
snprintf(err, err_sz, "out of memory");
return -1;
}
int n = cst_llm_list_models_meta(api_base, api_key, m, max_out, err, err_sz);
for (int i = 0; i < n; i++)
snprintf(out[i], 128, "%s", m[i].id);
free(m);
return n;
}
int cst_llm_list_models_meta(const char *api_base, const char *api_key,
cst_model_meta_t *out, int max_out, char *err,
size_t err_sz) {
char dummy[8]; char dummy[8];
if (!err || !err_sz) { if (!err || !err_sz) {
err = dummy; err = dummy;
@@ -768,6 +1226,7 @@ int cst_llm_list_models(const char *api_base, const char *api_key,
snprintf(err, err_sz, "curl init failed"); snprintf(err, err_sz, "curl init failed");
return -1; return -1;
} }
llm_apply_ca_bundle(curl);
char url[1024]; char url[1024];
size_t bl = strlen(api_base); size_t bl = strlen(api_base);
while (bl && api_base[bl - 1] == '/') while (bl && api_base[bl - 1] == '/')
+98 -1
View File
@@ -1,5 +1,6 @@
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_ext.h" #include "cst_ext.h"
#include "cst_models.h"
#include "cst_tools.h" #include "cst_tools.h"
#include "cst_version.h" #include "cst_version.h"
#include "os_compat.h" #include "os_compat.h"
@@ -20,7 +21,8 @@
static void usage(const char *argv0) { static void usage(const char *argv0) {
fprintf(stderr, fprintf(stderr,
"Usage: %s [options] (works in the current directory)\n" "Usage: %s [options] (works in the current directory)\n"
" -P, --provider NAME minimax | deepseek | openai (last used)\n" " -P, --provider NAME minimax | deepseek | openai | teamorouter\n"
" (last used)\n"
" -m, --model NAME model (last used for the provider)\n" " -m, --model NAME model (last used for the provider)\n"
" -e, --effort LEVEL low | medium | high | max (last used)\n" " -e, --effort LEVEL low | medium | high | max (last used)\n"
" -w, --workspace DIR project directory (default: current)\n" " -w, --workspace DIR project directory (default: current)\n"
@@ -28,6 +30,8 @@ static void usage(const char *argv0) {
" -N, --new start a new session\n" " -N, --new start a new session\n"
" -R, --resume ID open a session by id or unique prefix\n" " -R, --resume ID open a session by id or unique prefix\n"
" -L, --list-sessions list sessions and exit\n" " -L, --list-sessions list sessions and exit\n"
" --list-models [P] what is known about each provider's models\n"
" (built-in, <config>/models.json, GET /models)\n"
" -A, --approval MODE ask | auto (default: edits run, shell in\n" " -A, --approval MODE ask | auto (default: edits run, shell in\n"
" the sandbox or by safe rules) | yolo\n" " the sandbox or by safe rules) | yolo\n"
" -y, --yolo same as --approval yolo (this run only)\n" " -y, --yolo same as --approval yolo (this run only)\n"
@@ -80,6 +84,62 @@ static int parse_confirm(const char *s, cst_agent_config_t *cfg) {
return 0; return 0;
} }
static void list_one_model(const char *provider, const char *id,
const char *where) {
cst_model_info_t mi;
cst_model_info(provider, id, &mi);
char tag[48], price[128] = "";
cst_model_info_label(&mi, tag, sizeof(tag));
if (mi.has_price)
snprintf(price, sizeof(price), " %s%g/%g/%g per 1M (miss/hit/out)",
mi.price.currency, mi.price.miss, mi.price.hit, mi.price.out);
printf(" %-40s %-18s %-8s%s [%s]\n", id, tag, where, price, mi.source);
}
/* --list-models [provider]: the merged registry, to check models.json. */
static int list_models(const char *only) {
char user[1100] = "";
cst_models_user_path(user, sizeof(user));
FILE *uf = user[0] ? fopen(user, "rb") : NULL;
printf("user file: %s%s\n", user[0] ? user : "(no config dir)",
uf ? "" : " (none)");
if (uf)
fclose(uf);
if (cst_models_load_error()[0])
printf("warning: %s\n", cst_models_load_error());
int shown = 0;
static cst_model_meta_t cached[1024];
for (int i = 0; i < cst_provider_count(); i++) {
const cst_provider_t *p = cst_provider_at(i);
if (only && only[0] && strcmp(p->name, only) != 0)
continue;
shown++;
printf("\n%s %s (%s; key %s)\n", p->name, p->api_base,
cst_provider_source(p), p->key_env[0] ? p->key_env : "-");
char listed[16][128];
int nl = cst_provider_models(p, listed, 16);
for (int k = 0; k < nl; k++)
list_one_model(p->name, listed[k],
strcmp(listed[k], p->model) == 0 ? "default" : "listed");
long age = 0;
int nc = cst_models_cache_load(p->name, cached, 1024, &age);
if (nc > 0) {
printf(" -- %d from GET /models, fetched %ldh ago%s\n", nc,
age / 3600, age >= CST_MODELS_CACHE_TTL ? " (stale)" : "");
for (int k = 0; k < nc; k++)
list_one_model(p->name, cached[k].id, "api");
} else if (!nl) {
printf(" (no models listed; the first one GET /models returns is "
"used)\n");
}
}
if (only && only[0] && !shown) {
fprintf(stderr, "unknown provider: %s\n", only);
return 1;
}
return 0;
}
static int list_sessions(const char *ws) { static int list_sessions(const char *ws) {
static char ids[200][128]; static char ids[200][128];
int n = cst_agent_store_list(ws, ids, 200); int n = cst_agent_store_list(ws, ids, 200);
@@ -188,7 +248,11 @@ static int demo_ctl_impl(const char *workspace) {
} }
int main(int argc, char **argv) { int main(int argc, char **argv) {
if (os_console_init() != 0)
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_set_prog_path(argv[0]);
os_temp_dir_export();
curl_global_init(CURL_GLOBAL_DEFAULT); curl_global_init(CURL_GLOBAL_DEFAULT);
cst_agent_config_t cfg = {0}; cst_agent_config_t cfg = {0};
@@ -281,6 +345,7 @@ int main(int argc, char **argv) {
{"effort", required_argument, 0, 'e'}, {"effort", required_argument, 0, 'e'},
{"help", no_argument, 0, 'h'}, {"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'}, {"version", no_argument, 0, 'V'},
{"list-models", optional_argument, 0, 0x100},
{0, 0, 0, 0}}; {0, 0, 0, 0}};
int c; int c;
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) != while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
@@ -380,6 +445,14 @@ int main(int argc, char **argv) {
usage(argv[0]); usage(argv[0]);
curl_global_cleanup(); curl_global_cleanup();
return 0; return 0;
case 0x100: {
const char *only = optarg;
if (!only && optind < argc && argv[optind][0] != '-')
only = argv[optind++];
int rc = list_models(only);
curl_global_cleanup();
return rc;
}
case 'V': case 'V':
printf("cst-agent %s\n", CST_VERSION); printf("cst-agent %s\n", CST_VERSION);
curl_global_cleanup(); curl_global_cleanup();
@@ -535,6 +608,30 @@ int main(int argc, char **argv) {
curl_global_cleanup(); curl_global_cleanup();
return 1; return 1;
} }
/* Router presets have no fixed model: take the first one the API lists
* (the TUI does the same after its /models probe). */
if (!cfg.model[0] && cfg.headless) {
static cst_model_meta_t list[512];
char err[256] = "";
long age = 0;
int n = cst_models_cache_load(cfg.provider, list, 1, &age);
if (n <= 0 || age >= CST_MODELS_CACHE_TTL) {
int r = cst_llm_list_models_meta(cfg.api_base, cfg.api_key, list, 512,
err, sizeof(err));
if (r >= 0)
cst_models_cache_save(cfg.provider, cfg.api_base, list, r);
if (r >= 0 || n <= 0)
n = r;
}
if (n > 0) {
snprintf(cfg.model, sizeof(cfg.model), "%s", list[0].id);
} else {
fprintf(stderr, "no model for %s: pass --model (%s)\n", cfg.provider,
err[0] ? err : "the API listed none");
curl_global_cleanup();
return 1;
}
}
fprintf(stderr, fprintf(stderr,
"using %s model=%s effort=%s protocol=%s tools=%s max_steps=%d\n", "using %s model=%s effort=%s protocol=%s tools=%s max_steps=%d\n",
+62 -3
View File
@@ -6,6 +6,8 @@
#endif #endif
#include "cst_mcp.h" #include "cst_mcp.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include <pthread.h> #include <pthread.h>
#include <stdatomic.h> #include <stdatomic.h>
@@ -268,6 +270,7 @@ static char *slurp(const char *path, size_t max) {
fclose(fp); fclose(fp);
if (!b.buf) if (!b.buf)
cst_buf_puts(&b, ""); cst_buf_puts(&b, "");
cst_strip_bom(b.buf, &b.len);
return cst_buf_steal(&b); return cst_buf_steal(&b);
} }
@@ -601,7 +604,56 @@ static int auto_approved(const mcp_srv_t *s, const char *tool) {
return 0; return 0;
} }
static void put_content(cst_buf_t *b, const cJSON *item) { /* Images a tool call returned, stored beside the session. */
typedef struct {
const char *session;
const char *server;
cst_attachment_t *list;
int n, cap;
} mcp_imgs_t;
/* An image content item goes into the session store and rides along with the
* tool result; the text keeps a one-line description. 0 when stored. */
static int put_image(cst_buf_t *b, const cJSON *item, mcp_imgs_t *im) {
const cJSON *d = cJSON_GetObjectItemCaseSensitive(item, "data");
if (!im || !cJSON_IsString(d))
return -1;
char err[256] = "no session to store the image in";
if (im->n >= CST_ATTACH_MAX_COUNT) {
snprintf(err, sizeof(err), "more than %d images", CST_ATTACH_MAX_COUNT);
} else if (im->session && im->session[0]) {
size_t n = 0;
char *raw = cst_base64_decode(d->valuestring, strlen(d->valuestring), &n);
char name[96];
snprintf(name, sizeof(name), "%s-%d", im->server, im->n + 1);
cst_attachment_t *a =
raw ? cst_attach_import_bytes(im->session, name, raw, n, err,
sizeof(err))
: NULL;
if (!raw)
snprintf(err, sizeof(err), "bad base64");
free(raw);
if (a) {
snprintf(a->origin, sizeof(a->origin), "mcp");
int pushed = cst_attachments_push(&im->list, &im->n, &im->cap, a) == 0;
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
cst_attach_free(a);
if (pushed) {
cst_buf_printf(b, "[image %s (shown to you as an image)]", desc);
return 0;
}
snprintf(err, sizeof(err), "out of memory");
}
}
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType");
cst_buf_printf(b, "[image %s, %zu bytes base64, not shown: %s]",
cJSON_IsString(mt) ? mt->valuestring : "",
strlen(d->valuestring), err);
return -1;
}
static void put_content(cst_buf_t *b, const cJSON *item, mcp_imgs_t *im) {
const cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type"); const cJSON *type = cJSON_GetObjectItemCaseSensitive(item, "type");
const char *t = cJSON_IsString(type) ? type->valuestring : ""; const char *t = cJSON_IsString(type) ? type->valuestring : "";
if (b->len && b->buf[b->len - 1] != '\n') if (b->len && b->buf[b->len - 1] != '\n')
@@ -610,6 +662,9 @@ static void put_content(cst_buf_t *b, const cJSON *item) {
const cJSON *x = cJSON_GetObjectItemCaseSensitive(item, "text"); const cJSON *x = cJSON_GetObjectItemCaseSensitive(item, "text");
if (cJSON_IsString(x)) if (cJSON_IsString(x))
cst_buf_puts(b, x->valuestring); cst_buf_puts(b, x->valuestring);
} else if (strcmp(t, "image") == 0 &&
cJSON_IsString(cJSON_GetObjectItemCaseSensitive(item, "data"))) {
put_image(b, item, im);
} else if (strcmp(t, "image") == 0 || strcmp(t, "audio") == 0) { } else if (strcmp(t, "image") == 0 || strcmp(t, "audio") == 0) {
const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType"); const cJSON *mt = cJSON_GetObjectItemCaseSensitive(item, "mimeType");
const cJSON *d = cJSON_GetObjectItemCaseSensitive(item, "data"); const cJSON *d = cJSON_GetObjectItemCaseSensitive(item, "data");
@@ -659,7 +714,6 @@ static int set_out(cst_tool_result_t *r, int code, const char *fmt, ...) {
static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r, void *ud) { cst_tool_result_t *r, void *ud) {
(void)ctx;
mtool_t *mt = ud; mtool_t *mt = ud;
mcp_srv_t *s = mt->srv; mcp_srv_t *s = mt->srv;
pthread_mutex_lock(&s->mu); pthread_mutex_lock(&s->mu);
@@ -689,9 +743,12 @@ static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
const cJSON *res = cJSON_GetObjectItemCaseSensitive(resp, "result"); const cJSON *res = cJSON_GetObjectItemCaseSensitive(resp, "result");
const cJSON *content = cJSON_GetObjectItemCaseSensitive(res, "content"); const cJSON *content = cJSON_GetObjectItemCaseSensitive(res, "content");
cst_buf_t b = {0}; cst_buf_t b = {0};
mcp_imgs_t im = {0};
im.session = ctx && ctx->agent ? cst_agent_session_path(ctx->agent) : NULL;
im.server = s->name;
const cJSON *it = NULL; const cJSON *it = NULL;
cJSON_ArrayForEach(it, content) { cJSON_ArrayForEach(it, content) {
put_content(&b, it); put_content(&b, it, &im);
if (b.len > MCP_OUT_MAX) if (b.len > MCP_OUT_MAX)
break; break;
} }
@@ -715,6 +772,8 @@ static int mcp_tool_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cJSON_Delete(resp); cJSON_Delete(resp);
r->out_len = b.len; r->out_len = b.len;
r->out = cst_buf_steal(&b); r->out = cst_buf_steal(&b);
r->attach = im.list; /* one block, freed by cst_tool_result_free */
r->nattach = im.n;
return 0; return 0;
} }
+1 -1
View File
@@ -47,7 +47,7 @@ cst_metrics_t *cst_metrics_open(const char *workspace, const char *session_path)
snprintf(file, sizeof(file), "%s.jsonl", leaf); snprintf(file, sizeof(file), "%s.jsonl", leaf);
os_path_join(m->path, sizeof(m->path), dir, file); os_path_join(m->path, sizeof(m->path), dir, file);
} }
m->fp = fopen(m->path, "a"); m->fp = fopen(m->path, "ab");
if (!m->fp) { if (!m->fp) {
free(m); free(m);
return NULL; return NULL;
+818
View File
@@ -0,0 +1,818 @@
#include "cst_models.h"
#include "cJSON.h"
#include "os_compat.h"
#include <ctype.h>
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static const char BUILTIN[] = {
#include "models_builtin.inc"
0};
const char *cst_models_builtin_json(void) { return BUILTIN; }
#define MAX_PROV 32
#define MAX_PMODELS 16
#define MAX_RULES 256
typedef struct {
cst_provider_t pub; /* points into the buffers below */
char name[64];
char api_base[256];
char model[128];
char key_env[64];
int passback, prefix;
char effort[16]; /* "", "passthrough", "openai" */
char models[MAX_PMODELS][128];
int nmodels;
const char *source;
} prov_t;
enum { F_CONTEXT = 1, F_VISION = 2, F_CACHE = 4, F_REASONING = 8, F_PRICE = 16 };
typedef struct {
char match[128]; /* lowercase */
int spec; /* literal characters: higher is more specific */
unsigned has; /* F_* set by this rule */
long context;
int vision, cache, reasoning;
cst_price_t price;
} rule_t;
typedef struct {
char provider[64];
long fetched_at;
cst_model_meta_t m;
} cached_t;
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
static struct {
int loaded;
prov_t prov[MAX_PROV];
int nprov;
rule_t builtin[MAX_RULES];
int nbuiltin;
rule_t user[MAX_RULES];
int nuser;
cached_t *cache;
int ncache;
char err[1400];
} R;
/* --- parsing ------------------------------------------------------------- */
static void lower_copy(char *out, size_t n, const char *s) {
size_t i = 0;
for (; s && s[i] && i + 1 < n; i++)
out[i] = (char)tolower((unsigned char)s[i]);
out[i] = 0;
}
static const char *jstr(const cJSON *o, const char *k) {
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, k);
return cJSON_IsString(v) ? v->valuestring : NULL;
}
static void fix_pub(prov_t *p) {
p->pub.name = p->name;
p->pub.api_base = p->api_base;
p->pub.model = p->model;
p->pub.key_env = p->key_env;
}
static prov_t *prov_by_name(const char *name) {
for (int i = 0; i < R.nprov; i++)
if (strcmp(R.prov[i].name, name) == 0)
return &R.prov[i];
return NULL;
}
static void parse_providers(const cJSON *obj, const char *source) {
const cJSON *it = NULL;
cJSON_ArrayForEach(it, obj) {
if (!cJSON_IsObject(it) || !it->string || !it->string[0])
continue;
prov_t *p = prov_by_name(it->string);
if (!p) {
if (R.nprov >= MAX_PROV)
continue;
p = &R.prov[R.nprov++];
memset(p, 0, sizeof(*p));
snprintf(p->name, sizeof(p->name), "%s", it->string);
fix_pub(p);
}
p->source = source;
const char *s;
if ((s = jstr(it, "api_base")))
snprintf(p->api_base, sizeof(p->api_base), "%s", s);
if ((s = jstr(it, "key_env")))
snprintf(p->key_env, sizeof(p->key_env), "%s", s);
if ((s = jstr(it, "default_model")))
snprintf(p->model, sizeof(p->model), "%s", s);
if ((s = jstr(it, "effort")))
snprintf(p->effort, sizeof(p->effort), "%s", s);
const cJSON *b = cJSON_GetObjectItemCaseSensitive(it, "reasoning_passback");
if (cJSON_IsBool(b))
p->passback = cJSON_IsTrue(b);
b = cJSON_GetObjectItemCaseSensitive(it, "prefix_completion");
if (cJSON_IsBool(b))
p->prefix = cJSON_IsTrue(b);
const cJSON *ms = cJSON_GetObjectItemCaseSensitive(it, "models");
if (cJSON_IsArray(ms)) {
p->nmodels = 0;
const cJSON *m = NULL;
cJSON_ArrayForEach(m, ms) {
if (cJSON_IsString(m) && m->valuestring[0] && p->nmodels < MAX_PMODELS)
snprintf(p->models[p->nmodels++], 128, "%s", m->valuestring);
}
}
}
}
static int parse_price(const cJSON *o, cst_price_t *pr) {
const cJSON *miss = cJSON_GetObjectItemCaseSensitive(o, "miss");
const cJSON *out = cJSON_GetObjectItemCaseSensitive(o, "out");
if (!cJSON_IsNumber(miss) || !cJSON_IsNumber(out))
return -1;
memset(pr, 0, sizeof(*pr));
pr->miss = miss->valuedouble;
pr->out = out->valuedouble;
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "hit");
pr->hit = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
v = cJSON_GetObjectItemCaseSensitive(o, "write");
pr->write = cJSON_IsNumber(v) ? v->valuedouble : pr->miss;
const char *c = jstr(o, "currency");
snprintf(pr->currency, sizeof(pr->currency), "%s", c ? c : "$");
pr->offpeak_half =
cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(o, "offpeak_half"));
return 0;
}
static int parse_rule(const cJSON *o, rule_t *r) {
const char *m = jstr(o, "match");
if (!m || !m[0])
return -1;
memset(r, 0, sizeof(*r));
lower_copy(r->match, sizeof(r->match), m);
for (const char *q = r->match; *q; q++)
if (*q != '*')
r->spec++;
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
if (cJSON_IsNumber(v) && v->valuedouble > 0) {
r->context = (long)v->valuedouble;
r->has |= F_CONTEXT;
}
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
if (cJSON_IsBool(v)) {
r->vision = cJSON_IsTrue(v);
r->has |= F_VISION;
}
v = cJSON_GetObjectItemCaseSensitive(o, "reasoning");
if (cJSON_IsBool(v)) {
r->reasoning = cJSON_IsTrue(v);
r->has |= F_REASONING;
}
const char *c = jstr(o, "cache");
if (c) {
if (strcmp(c, "openai") == 0)
r->cache = CST_CACHE_OPENAI;
else if (strcmp(c, "anthropic") == 0)
r->cache = CST_CACHE_ANTHROPIC;
else
r->cache = CST_CACHE_AUTO;
r->has |= F_CACHE;
}
v = cJSON_GetObjectItemCaseSensitive(o, "price");
if (cJSON_IsObject(v) && parse_price(v, &r->price) == 0)
r->has |= F_PRICE;
return 0;
}
static int parse_doc(const char *json, int user, char *err, size_t err_sz) {
cJSON *doc = cJSON_Parse(json);
if (!cJSON_IsObject(doc)) {
cJSON_Delete(doc);
snprintf(err, err_sz, "not a JSON object");
return -1;
}
parse_providers(cJSON_GetObjectItemCaseSensitive(doc, "providers"),
user ? "user" : "built-in");
rule_t *rules = user ? R.user : R.builtin;
int *n = user ? &R.nuser : &R.nbuiltin;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
if (*n < MAX_RULES && parse_rule(it, &rules[*n]) == 0)
(*n)++;
}
cJSON_Delete(doc);
return 0;
}
static char *read_file(const char *path) {
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
char *buf = NULL;
size_t len = 0;
char chunk[8192];
size_t got;
while ((got = fread(chunk, 1, sizeof(chunk), f)) > 0 && len < (8u << 20)) {
char *nb = realloc(buf, len + got + 1);
if (!nb)
break;
buf = nb;
memcpy(buf + len, chunk, got);
len += got;
}
fclose(f);
if (buf)
buf[len] = 0;
return buf;
}
static int cache_path(const char *provider, char *out, size_t n) {
char dir[1024];
if (!provider || !provider[0] || os_config_dir(dir, sizeof(dir)) != 0)
return -1;
for (const char *q = provider; *q; q++)
if (!isalnum((unsigned char)*q) && !strchr("._-", *q))
return -1;
int w = snprintf(out, n, "%s/models-cache/%s.json", dir, provider);
return w > 0 && (size_t)w < n ? 0 : -1;
}
static void meta_from_json(const cJSON *o, cst_model_meta_t *m) {
memset(m, 0, sizeof(*m));
m->vision = -1;
const char *id = jstr(o, "id");
snprintf(m->id, sizeof(m->id), "%s", id ? id : "");
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, "context");
if (cJSON_IsNumber(v) && v->valuedouble > 0)
m->context = (long)v->valuedouble;
v = cJSON_GetObjectItemCaseSensitive(o, "vision");
if (cJSON_IsBool(v))
m->vision = cJSON_IsTrue(v);
v = cJSON_GetObjectItemCaseSensitive(o, "price");
cst_price_t pr;
if (cJSON_IsObject(v) && parse_price(v, &pr) == 0) {
m->has_price = 1;
m->miss = pr.miss;
m->hit = pr.hit;
m->write = pr.write;
m->out = pr.out;
}
}
/* Appends provider's cache file to R.cache (caller holds the lock). */
static void load_cache(const char *provider) {
char path[1100];
if (cache_path(provider, path, sizeof(path)) != 0)
return;
char *txt = read_file(path);
cJSON *doc = txt ? cJSON_Parse(txt) : NULL;
free(txt);
const cJSON *fa = cJSON_GetObjectItemCaseSensitive(doc, "fetched_at");
long at = cJSON_IsNumber(fa) ? (long)fa->valuedouble : 0;
const cJSON *it = NULL;
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(doc, "models")) {
cst_model_meta_t m;
meta_from_json(it, &m);
if (!m.id[0])
continue;
cached_t *nc = realloc(R.cache, (size_t)(R.ncache + 1) * sizeof(*nc));
if (!nc)
break;
R.cache = nc;
cached_t *c = &R.cache[R.ncache++];
snprintf(c->provider, sizeof(c->provider), "%s", provider);
c->fetched_at = at;
c->m = m;
}
cJSON_Delete(doc);
}
static void drop_cache(const char *provider) {
int w = 0;
for (int i = 0; i < R.ncache; i++)
if (provider && strcmp(R.cache[i].provider, provider) != 0)
R.cache[w++] = R.cache[i];
R.ncache = w;
}
static void reset_locked(void) {
R.nprov = 0;
R.nbuiltin = 0;
R.nuser = 0;
drop_cache(NULL);
R.err[0] = 0;
}
static void load_caches_locked(void) {
for (int i = 0; i < R.nprov; i++)
load_cache(R.prov[i].name);
}
int cst_models_user_path(char *out, size_t out_sz) {
char dir[1024];
if (os_config_dir(dir, sizeof(dir)) != 0)
return -1;
int w = snprintf(out, out_sz, "%s/models.json", dir);
return w > 0 && (size_t)w < out_sz ? 0 : -1;
}
static void load_default_locked(void) {
reset_locked();
char e[128];
if (parse_doc(BUILTIN, 0, e, sizeof(e)) != 0)
snprintf(R.err, sizeof(R.err), "built-in model data: %s", e);
char path[1100];
char *user = cst_models_user_path(path, sizeof(path)) == 0
? read_file(path)
: NULL;
if (user && parse_doc(user, 1, e, sizeof(e)) != 0)
snprintf(R.err, sizeof(R.err), "%s: %s", path, e);
free(user);
load_caches_locked();
R.loaded = 1;
}
static void ensure_locked(void) {
if (!R.loaded)
load_default_locked();
}
void cst_models_reload(void) {
pthread_mutex_lock(&g_mu);
load_default_locked();
pthread_mutex_unlock(&g_mu);
}
int cst_models_load_json(const char *builtin_json, const char *user_json,
char *err, size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
pthread_mutex_lock(&g_mu);
reset_locked();
int rc = 0;
if (builtin_json && parse_doc(builtin_json, 0, err, err_sz) != 0)
rc = -1;
if (rc == 0 && user_json && parse_doc(user_json, 1, err, err_sz) != 0)
rc = -1;
load_caches_locked();
R.loaded = 1;
pthread_mutex_unlock(&g_mu);
return rc;
}
const char *cst_models_load_error(void) {
pthread_mutex_lock(&g_mu);
ensure_locked();
pthread_mutex_unlock(&g_mu);
return R.err;
}
/* --- providers ----------------------------------------------------------- */
const cst_provider_t *cst_provider_find(const char *name) {
if (!name || !name[0])
return NULL;
pthread_mutex_lock(&g_mu);
ensure_locked();
prov_t *p = prov_by_name(name);
pthread_mutex_unlock(&g_mu);
return p ? &p->pub : NULL;
}
const cst_provider_t *cst_provider_default(void) {
const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER"));
return p ? p : cst_provider_at(0);
}
int cst_provider_count(void) {
pthread_mutex_lock(&g_mu);
ensure_locked();
int n = R.nprov;
pthread_mutex_unlock(&g_mu);
return n;
}
const cst_provider_t *cst_provider_at(int i) {
pthread_mutex_lock(&g_mu);
ensure_locked();
const cst_provider_t *p = i >= 0 && i < R.nprov ? &R.prov[i].pub : NULL;
pthread_mutex_unlock(&g_mu);
return p;
}
const char *cst_provider_source(const cst_provider_t *p) {
for (int i = 0; p && i < MAX_PROV; i++)
if (p == &R.prov[i].pub)
return R.prov[i].source ? R.prov[i].source : "built-in";
return "";
}
int cst_provider_models(const cst_provider_t *p, char out[][128], int max) {
int n = 0;
pthread_mutex_lock(&g_mu);
for (int i = 0; p && i < R.nprov; i++) {
if (p != &R.prov[i].pub)
continue;
for (int k = 0; k < R.prov[i].nmodels && n < max; k++)
snprintf(out[n++], 128, "%s", R.prov[i].models[k]);
}
pthread_mutex_unlock(&g_mu);
return n;
}
static int push_name(char out[][128], int *n, int max, const char *name) {
if (!name || !name[0] || *n >= max)
return 0;
for (int i = 0; i < *n; i++)
if (strcmp(out[i], name) == 0)
return 0;
snprintf(out[(*n)++], 128, "%s", name);
return 1;
}
int cst_models_list(const char *current, char out[][128], int max_out) {
if (!out || max_out <= 0)
return 0;
int n = 0;
const char *env = getenv("CST_MODELS");
if (env && env[0]) {
char buf[1024];
snprintf(buf, sizeof(buf), "%s", env);
for (char *tok = buf; *tok;) {
while (*tok == ':' || *tok == ',' || *tok == ' ')
tok++;
char *end = tok;
while (*end && *end != ':' && *end != ',')
end++;
char save = *end;
*end = 0;
char *t = end;
while (t > tok && (t[-1] == ' ' || t[-1] == '\t'))
*--t = 0;
push_name(out, &n, max_out, tok);
*end = save;
tok = *end ? end + 1 : end;
}
} else {
pthread_mutex_lock(&g_mu);
ensure_locked();
for (int i = 0; i < R.nprov; i++)
for (int k = 0; k < R.prov[i].nmodels; k++)
push_name(out, &n, max_out, R.prov[i].models[k]);
pthread_mutex_unlock(&g_mu);
}
push_name(out, &n, max_out, current);
return n;
}
/* --- model lookup -------------------------------------------------------- */
/* '*' matches any run; without one the pattern is a prefix. */
static int glob(const char *p, const char *s) {
if (!strchr(p, '*'))
return strncmp(s, p, strlen(p)) == 0;
while (*p) {
if (*p == '*') {
while (*p == '*')
p++;
if (!*p)
return 1;
for (; *s; s++)
if (glob(p, s))
return 1;
return 0;
}
if (*p != *s)
return 0;
p++;
s++;
}
return !*s;
}
static void set_field(cst_model_info_t *o, unsigned f, const rule_t *r) {
switch (f) {
case F_CONTEXT:
o->context = r->context;
break;
case F_VISION:
o->vision = r->vision;
break;
case F_CACHE:
o->cache_style = r->cache;
break;
case F_REASONING:
o->reasoning = r->reasoning;
break;
case F_PRICE:
o->price = r->price;
o->has_price = 1;
break;
}
}
static void add_source(cst_model_info_t *o, const char *src) {
if (strstr(o->source, src))
return;
size_t l = strlen(o->source);
snprintf(o->source + l, sizeof(o->source) - l, "%s%s", l ? "+" : "", src);
}
static void apply_rules(const rule_t *rules, int n, const char *full,
const char *bare, unsigned *have, cst_model_info_t *o,
const char *src) {
for (unsigned f = 1; f <= F_PRICE; f <<= 1) {
if (*have & f)
continue;
const rule_t *best = NULL;
for (int i = 0; i < n; i++) {
const rule_t *r = &rules[i];
if (!(r->has & f) || (best && r->spec <= best->spec))
continue;
if (glob(r->match, strchr(r->match, '/') ? full : bare))
best = r;
}
if (best) {
set_field(o, f, best);
*have |= f;
add_source(o, src);
}
}
}
static void apply_cache(const char *provider, const char *model,
unsigned *have, cst_model_info_t *o) {
for (int i = 0; i < R.ncache; i++) {
const cached_t *c = &R.cache[i];
if ((provider && strcmp(c->provider, provider) != 0) ||
strcmp(c->m.id, model) != 0)
continue;
unsigned before = *have;
if (!(*have & F_CONTEXT) && c->m.context > 0) {
o->context = c->m.context;
*have |= F_CONTEXT;
}
if (!(*have & F_VISION) && c->m.vision >= 0) {
o->vision = c->m.vision;
*have |= F_VISION;
}
if (!(*have & F_PRICE) && c->m.has_price) {
memset(&o->price, 0, sizeof(o->price));
snprintf(o->price.currency, sizeof(o->price.currency), "$");
o->price.miss = c->m.miss;
o->price.hit = c->m.hit;
o->price.write = c->m.write;
o->price.out = c->m.out;
o->has_price = 1;
*have |= F_PRICE;
}
if (*have != before)
add_source(o, "api");
return;
}
}
static int env_price(cst_price_t *out) {
const char *e = getenv("CST_PRICE");
if (!e || !e[0])
return -1;
memset(out, 0, sizeof(*out));
char cur[8] = "";
int n = sscanf(e, "%lf,%lf,%lf,%lf,%7s", &out->miss, &out->hit, &out->out,
&out->write, cur);
if (n < 3)
return -1;
if (n < 4)
out->write = out->miss;
snprintf(out->currency, sizeof(out->currency), "%s", cur);
return 0;
}
void cst_model_info(const char *provider, const char *model,
cst_model_info_t *out) {
memset(out, 0, sizeof(*out));
out->vision = -1;
unsigned have = 0;
const char *e = getenv("CST_CTX_TOKENS");
if (e && atol(e) > 0) {
out->context = atol(e);
have |= F_CONTEXT;
add_source(out, "env");
}
if (env_price(&out->price) == 0) {
out->has_price = 1;
have |= F_PRICE;
add_source(out, "env");
}
if (model && model[0]) {
char full[256], bare[256];
lower_copy(full, sizeof(full), model);
const char *slash = strrchr(full, '/');
snprintf(bare, sizeof(bare), "%s", slash && slash[1] ? slash + 1 : full);
pthread_mutex_lock(&g_mu);
ensure_locked();
apply_rules(R.user, R.nuser, full, bare, &have, out, "user");
if (provider && !prov_by_name(provider))
provider = NULL;
apply_cache(provider, model, &have, out);
apply_rules(R.builtin, R.nbuiltin, full, bare, &have, out, "built-in");
pthread_mutex_unlock(&g_mu);
}
if (!(have & F_CONTEXT)) {
out->context = 128000;
add_source(out, "default");
}
}
void cst_model_info_label(const cst_model_info_t *mi, char *out, size_t n) {
char ctx[32];
if (mi->context >= 1000000)
snprintf(ctx, sizeof(ctx), "%.0fM", (double)mi->context / 1e6);
else
snprintf(ctx, sizeof(ctx), "%ldK", (mi->context + 500) / 1000);
snprintf(out, n, "%s%s", ctx,
mi->vision == 1 ? " · vision"
: mi->vision == 0 ? " · text only"
: "");
}
/* --- wrappers kept for the rest of the code ------------------------------ */
long cst_model_context_tokens(const char *model) {
cst_model_info_t i;
cst_model_info(NULL, model, &i);
return i.context;
}
int cst_model_price(const char *model, cst_price_t *out) {
cst_model_info_t i;
cst_model_info(NULL, model, &i);
*out = i.price;
if (!i.has_price)
memset(out, 0, sizeof(*out));
return i.has_price ? 0 : -1;
}
/* "https://api.deepseek.com/v1" -> "api.deepseek.com" */
static void host_of(const char *url, char *out, size_t n) {
const char *s = strstr(url, "://");
s = s ? s + 3 : url;
size_t i = 0;
for (; s[i] && s[i] != '/' && i + 1 < n; i++)
out[i] = s[i];
out[i] = 0;
}
void cst_model_caps(const char *provider, const char *api_base,
const char *model, cst_model_caps_t *out) {
memset(out, 0, sizeof(*out));
pthread_mutex_lock(&g_mu);
ensure_locked();
/* provider extensions follow the provider, or its host behind any name */
for (int i = 0; i < R.nprov; i++) {
const prov_t *p = &R.prov[i];
if (!p->passback && !p->prefix)
continue;
char host[256];
host_of(p->api_base, host, sizeof(host));
if ((provider && strcmp(provider, p->name) == 0) ||
(api_base && host[0] && strstr(api_base, host))) {
out->reasoning_passback |= p->passback;
out->prefix_completion |= p->prefix;
}
}
pthread_mutex_unlock(&g_mu);
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
out->vision = mi.vision;
const char *e = getenv("CST_NO_CACHE_HINTS");
if ((e && e[0] == '1') || !model)
return;
out->cache_style = mi.cache_style;
}
const char *cst_effort_wire(const char *provider, const char *model,
const char *effort) {
if (!cst_effort_valid(effort) || !provider || !model)
return NULL;
char mode[16] = "";
pthread_mutex_lock(&g_mu);
ensure_locked();
const prov_t *p = prov_by_name(provider);
if (p)
snprintf(mode, sizeof(mode), "%s", p->effort);
pthread_mutex_unlock(&g_mu);
if (strcmp(mode, "passthrough") == 0)
return effort;
if (strcmp(mode, "openai") == 0) {
cst_model_info_t mi;
cst_model_info(provider, model, &mi);
if (mi.reasoning)
return strcmp(effort, "max") == 0 ? "high" : effort;
}
return NULL;
}
/* --- fetched cache ------------------------------------------------------- */
int cst_models_cache_save(const char *provider, const char *api_base,
const cst_model_meta_t *list, int n) {
char path[1100];
if (!list || n < 0 || cache_path(provider, path, sizeof(path)) != 0)
return -1;
char dir[1100];
snprintf(dir, sizeof(dir), "%s", path);
char *sl = strrchr(dir, '/');
if (sl)
*sl = 0;
os_mkdir_p(dir, 0700);
long now = (long)time(NULL);
cJSON *doc = cJSON_CreateObject();
cJSON_AddNumberToObject(doc, "fetched_at", (double)now);
if (api_base)
cJSON_AddStringToObject(doc, "api_base", api_base);
cJSON *arr = cJSON_AddArrayToObject(doc, "models");
for (int i = 0; i < n; i++) {
const cst_model_meta_t *m = &list[i];
cJSON *o = cJSON_CreateObject();
cJSON_AddStringToObject(o, "id", m->id);
if (m->context > 0)
cJSON_AddNumberToObject(o, "context", (double)m->context);
if (m->vision >= 0)
cJSON_AddBoolToObject(o, "vision", m->vision);
if (m->has_price) {
cJSON *p = cJSON_AddObjectToObject(o, "price");
cJSON_AddStringToObject(p, "currency", "$");
cJSON_AddNumberToObject(p, "miss", m->miss);
cJSON_AddNumberToObject(p, "hit", m->hit);
cJSON_AddNumberToObject(p, "write", m->write);
cJSON_AddNumberToObject(p, "out", m->out);
}
cJSON_AddItemToArray(arr, o);
}
char *txt = cJSON_Print(doc);
cJSON_Delete(doc);
if (!txt)
return -1;
char tmp[1200];
snprintf(tmp, sizeof(tmp), "%s.tmp", path);
FILE *f = fopen(tmp, "wb");
int rc = -1;
if (f) {
size_t len = strlen(txt);
rc = fwrite(txt, 1, len, f) == len ? 0 : -1;
if (fclose(f) != 0)
rc = -1;
if (rc == 0)
rc = os_rename_replace(tmp, path);
if (rc != 0)
remove(tmp);
}
free(txt);
if (rc == 0) {
pthread_mutex_lock(&g_mu);
ensure_locked();
drop_cache(provider);
load_cache(provider);
pthread_mutex_unlock(&g_mu);
}
return rc;
}
int cst_models_cache_load(const char *provider, cst_model_meta_t *out, int max,
long *age_s) {
if (age_s)
*age_s = 0;
if (!provider)
return -1;
pthread_mutex_lock(&g_mu);
ensure_locked();
int n = 0, found = 0;
for (int i = 0; i < R.ncache; i++) {
const cached_t *c = &R.cache[i];
if (strcmp(c->provider, provider) != 0)
continue;
if (!found && age_s)
*age_s = (long)time(NULL) - c->fetched_at;
found = 1;
if (out && n < max)
out[n++] = c->m;
}
pthread_mutex_unlock(&g_mu);
return found ? n : -1;
}
+20 -123
View File
@@ -1,113 +1,32 @@
#include "cst_agent.h" #include "cst_agent.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#define MODEL_MAX_LEN 128 /* Provider and model data live in model_registry.c (cst_models.h). */
static const cst_provider_t PROVIDERS[] = { int cst_price_is_peak(time_t when) {
{"minimax", "https://api.minimax.cn/v1", "MiniMax-M2.7", "MINIMAX_API_KEY"}, time_t bj = when + 8 * 3600;
{"deepseek", "https://api.deepseek.com/v1", "deepseek-flash", struct tm tmv;
"DEEPSEEK_API_KEY"}, #ifdef _WIN32
{"openai", "https://api.openai.com/v1", "gpt-4.1-mini", "OPENAI_API_KEY"}, gmtime_s(&tmv, &bj);
}; #else
gmtime_r(&bj, &tmv);
const cst_provider_t *cst_provider_find(const char *name) { #endif
if (!name || !name[0]) if (tmv.tm_wday == 0 || tmv.tm_wday == 6)
return NULL;
for (size_t i = 0; i < sizeof(PROVIDERS) / sizeof(PROVIDERS[0]); i++) {
if (strcmp(PROVIDERS[i].name, name) == 0)
return &PROVIDERS[i];
}
return NULL;
}
const cst_provider_t *cst_provider_default(void) {
const cst_provider_t *p = cst_provider_find(getenv("CST_PROVIDER"));
return p ? p : &PROVIDERS[0];
}
static int already(char out[][MODEL_MAX_LEN], int n, const char *name) {
for (int i = 0; i < n; i++) {
if (strcmp(out[i], name) == 0)
return 1;
}
return 0; return 0;
return (tmv.tm_hour >= 9 && tmv.tm_hour < 12) ||
(tmv.tm_hour >= 14 && tmv.tm_hour < 18);
} }
static int push(char out[][MODEL_MAX_LEN], int *n, int max, const char *name) { double cst_price_cost(const cst_price_t *p, long miss, long hit, long write,
if (!name || !name[0] || *n >= max) long out, time_t when) {
if (!p)
return 0; return 0;
if (already(out, *n, name)) double c = (miss * p->miss + hit * p->hit + write * p->write +
return 0; out * p->out) / 1e6;
snprintf(out[*n], MODEL_MAX_LEN, "%s", name); if (p->offpeak_half && !cst_price_is_peak(when))
(*n)++; c /= 2;
return 1; return c;
}
int cst_models_list(const char *current, char out[][128], int max_out) {
if (!out || max_out <= 0)
return 0;
int n = 0;
const char *env = getenv("CST_MODELS");
if (env && env[0]) {
char buf[1024];
snprintf(buf, sizeof(buf), "%s", env);
char *p = buf;
while (*p && n < max_out) {
while (*p == ':' || *p == ' ' || *p == ',')
p++;
if (!*p)
break;
char *start = p;
while (*p && *p != ':' && *p != ',')
p++;
char save = *p;
*p = 0;
char *end = start + strlen(start);
while (end > start && (end[-1] == ' ' || end[-1] == '\t'))
*--end = 0;
push(out, &n, max_out, start);
*p = save;
if (*p)
p++;
}
} else {
push(out, &n, max_out, "deepseek-flash");
push(out, &n, max_out, "deepseek-v4-pro");
push(out, &n, max_out, "MiniMax-M2.7");
push(out, &n, max_out, "MiniMax-M2.5");
push(out, &n, max_out, "gpt-4.1-mini");
push(out, &n, max_out, "gpt-4o");
}
if (current && current[0])
push(out, &n, max_out, current);
return n;
}
/* Context window in tokens (CST_CTX_TOKENS overrides). Prefix match. */
long cst_model_context_tokens(const char *model) {
const char *e = getenv("CST_CTX_TOKENS");
if (e && atol(e) > 0)
return atol(e);
static const struct {
const char *prefix;
long tokens;
} T[] = {{"deepseek", 128000}, {"MiniMax", 196608}, {"minimax", 196608},
{"gpt-4.1", 1047576}, {"gpt-4o", 128000}, {"gpt-5", 400000},
{"o3", 200000}, {"o4", 200000}, {"claude", 200000},
{"qwen", 131072}, {"glm", 128000}, {"kimi", 131072}};
if (model) {
for (size_t i = 0; i < sizeof(T) / sizeof(T[0]); i++) {
if (strncmp(model, T[i].prefix, strlen(T[i].prefix)) == 0)
return T[i].tokens;
}
}
return 128000;
} }
int cst_effort_valid(const char *effort) { int cst_effort_valid(const char *effort) {
@@ -118,25 +37,3 @@ int cst_effort_valid(const char *effort) {
} }
return 0; return 0;
} }
const char *cst_effort_wire(const char *provider, const char *model,
const char *effort) {
if (!cst_effort_valid(effort) || !provider || !model)
return NULL;
if (strcmp(provider, "deepseek") == 0)
return effort;
/* OpenAI reasoning models take low | medium | high. */
if (strcmp(provider, "openai") == 0 &&
(strncmp(model, "o1", 2) == 0 || strncmp(model, "o3", 2) == 0 ||
strncmp(model, "o4", 2) == 0 || strncmp(model, "gpt-5", 5) == 0))
return strcmp(effort, "max") == 0 ? "high" : effort;
return NULL;
}
int cst_provider_count(void) {
return (int)(sizeof(PROVIDERS) / sizeof(PROVIDERS[0]));
}
const cst_provider_t *cst_provider_at(int i) {
return i >= 0 && i < cst_provider_count() ? &PROVIDERS[i] : NULL;
}
+70
View File
@@ -0,0 +1,70 @@
{
"providers": {
"minimax": {
"api_base": "https://api.minimax.cn/v1",
"key_env": "MINIMAX_API_KEY",
"default_model": "MiniMax-M2.7",
"models": ["MiniMax-M2.7", "MiniMax-M2.5"]
},
"deepseek": {
"api_base": "https://api.deepseek.com/v1",
"key_env": "DEEPSEEK_API_KEY",
"default_model": "deepseek-flash",
"models": ["deepseek-flash", "deepseek-v4-pro"],
"reasoning_passback": true,
"prefix_completion": true,
"effort": "passthrough"
},
"openai": {
"api_base": "https://api.openai.com/v1",
"key_env": "OPENAI_API_KEY",
"default_model": "gpt-4.1-mini",
"models": ["gpt-4.1-mini", "gpt-4o"],
"effort": "openai"
},
"teamorouter": {
"api_base": "https://api.teamorouter.com/v1",
"key_env": "TEAMOROUTER_API_KEY",
"default_model": ""
}
},
"models": [
{"match": "deepseek", "context": 1000000, "vision": false,
"price": {"currency": "¥", "miss": 2, "hit": 0.04, "out": 8, "offpeak_half": true}},
{"match": "deepseek-v4-pro",
"price": {"currency": "¥", "miss": 9, "hit": 0.3, "out": 27, "offpeak_half": true}},
{"match": "deepseek-vl", "vision": true},
{"match": "minimax", "context": 196608, "vision": false},
{"match": "gpt-", "cache": "openai"},
{"match": "gpt-4.1", "context": 1047576, "vision": true,
"price": {"currency": "$", "miss": 2, "hit": 0.5, "out": 8}},
{"match": "gpt-4.1-mini",
"price": {"currency": "$", "miss": 0.4, "hit": 0.1, "out": 1.6}},
{"match": "gpt-4o", "context": 128000, "vision": true,
"price": {"currency": "$", "miss": 2.5, "hit": 1.25, "out": 10}},
{"match": "gpt-4o-mini",
"price": {"currency": "$", "miss": 0.15, "hit": 0.075, "out": 0.6}},
{"match": "gpt-5", "context": 400000, "vision": true, "reasoning": true},
{"match": "o1", "vision": true, "cache": "openai", "reasoning": true},
{"match": "o3", "context": 200000, "vision": true, "cache": "openai", "reasoning": true},
{"match": "o4", "context": 200000, "vision": true, "cache": "openai", "reasoning": true},
{"match": "anthropic/", "cache": "anthropic"},
{"match": "claude", "context": 200000, "vision": true, "cache": "anthropic"},
{"match": "claude-sonnet",
"price": {"currency": "$", "miss": 3, "hit": 0.3, "write": 3.75, "out": 15}},
{"match": "gemini", "context": 1048576, "vision": true},
{"match": "grok", "context": 256000},
{"match": "grok-4", "vision": true},
{"match": "qwen", "context": 131072},
{"match": "glm", "context": 128000},
{"match": "glm-*v*", "vision": true},
{"match": "kimi", "context": 131072},
{"match": "pixtral", "vision": true},
{"match": "llava", "vision": true},
{"match": "*-vl*", "vision": true},
{"match": "*vision*", "vision": true}
]
}
+331 -342
View File
@@ -10,6 +10,7 @@
#include <stdint.h> #include <stdint.h>
#endif #endif
#include "os_compat.h" #include "os_compat.h"
#include "os_internal.h"
#include <errno.h> #include <errno.h>
#include <stdio.h> #include <stdio.h>
@@ -26,6 +27,7 @@
#include <process.h> #include <process.h>
#include <windows.h> #include <windows.h>
#else #else
#include <dirent.h>
#include <fcntl.h> #include <fcntl.h>
#include <poll.h> #include <poll.h>
#include <pthread.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; return 0;
} }
#ifndef _WIN32
int os_realpath(const char *path, char *out, size_t out_sz) { int os_realpath(const char *path, char *out, size_t out_sz) {
if (!path || !out || out_sz < 2) if (!path || !out || out_sz < 2)
return -1; 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); char *r = realpath(path, NULL);
if (!r) if (!r)
return -1; return -1;
@@ -123,7 +109,6 @@ int os_realpath(const char *path, char *out, size_t out_sz) {
memcpy(out, r, strlen(r) + 1); memcpy(out, r, strlen(r) + 1);
free(r); free(r);
return 0; return 0;
#endif
} }
int os_path_under(const char *root, const char *path) { 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); size_t rl = strlen(root);
if (strncmp(path, root, rl) != 0) if (strncmp(path, root, rl) != 0)
return 0; return 0;
if (path[rl] == '\0') return path[rl] == '\0' || path[rl] == '/';
return 1;
#ifdef _WIN32
return path[rl] == '/' || path[rl] == '\\';
#else
return path[rl] == '/';
#endif
} }
#endif
int os_mkdir(const char *path, int mode) { int os_mkdir(const char *path, int mode) {
if (!path) if (!path)
@@ -172,16 +152,93 @@ int os_mkdir_p(const char *path, int mode) {
return os_mkdir(tmp, 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) { int os_config_dir(char *out, size_t out_sz) {
if (!out || out_sz < 8) if (!out || out_sz < 8)
return -1; return -1;
#ifdef _WIN32 #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]) if (!base || !base[0])
base = getenv("LOCALAPPDATA"); base = getenv("LOCALAPPDATA");
if (!base || !base[0]) if (!base || !base[0])
return -1; 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 #else
const char *xdg = getenv("XDG_CONFIG_HOME"); const char *xdg = getenv("XDG_CONFIG_HOME");
const char *home = getenv("HOME"); const char *home = getenv("HOME");
@@ -198,6 +255,66 @@ int os_config_dir(char *out, size_t out_sz) {
return 0; 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) { int os_temp_dir(char *out, size_t out_sz) {
if (!out || out_sz < 2) if (!out || out_sz < 2)
return -1; return -1;
@@ -211,11 +328,18 @@ int os_temp_dir(char *out, size_t out_sz) {
out[--L] = 0; out[--L] = 0;
return 0; return 0;
#else #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"); const char *t = getenv("TMPDIR");
if (!t || !t[0]) if (os_temp_dir(d, sizeof(d)) == 0 && (!t || strcmp(t, d) != 0))
t = "/tmp"; setenv("TMPDIR", d, 1);
snprintf(out, out_sz, "%s", t);
return 0;
#endif #endif
} }
@@ -389,7 +513,7 @@ void os_set_run_timeout(int seconds) { g_run_timeout_s = seconds; }
int os_get_run_timeout(void) { return g_run_timeout_s; } 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) if (g_run_timeout_s > 0)
return g_run_timeout_s; return g_run_timeout_s;
const char *e = getenv("CST_SHELL_TIMEOUT"); const char *e = getenv("CST_SHELL_TIMEOUT");
@@ -414,7 +538,7 @@ double os_now_ms(void) {
} }
/* Append to capture buffer, keeping at most max_out bytes. */ /* 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, int os_cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
const char *data, size_t n, int *truncated) { const char *data, size_t n, int *truncated) {
if (*len >= max_out) { if (*len >= max_out) {
*truncated = 1; *truncated = 1;
@@ -440,7 +564,7 @@ static int cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
return 0; 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); size_t n = strlen(note);
if (*len + n + 1 > *cap) { if (*len + n + 1 > *cap) {
char *nb = realloc(*buf, *len + n + 1); char *nb = realloc(*buf, *len + n + 1);
@@ -453,154 +577,7 @@ static void cap_note(char **buf, size_t *len, size_t *cap, const char *note) {
*len += n; *len += n;
} }
#ifdef _WIN32 #ifndef _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
static void cloexec(int fd) { static void cloexec(int fd) {
int fl = fcntl(fd, F_GETFD); int fl = fcntl(fd, F_GETFD);
if (fl >= 0) if (fl >= 0)
@@ -685,7 +662,7 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
int truncated = 0, killed = 0; int truncated = 0, killed = 0;
int status = 0, reaped = 0, bg_killed = 0; int status = 0, reaped = 0, bg_killed = 0;
double reaped_at = 0, killed_at = 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 (;;) { for (;;) {
struct pollfd pfd = {pipefd[0], POLLIN, 0}; struct pollfd pfd = {pipefd[0], POLLIN, 0};
int pr = poll(&pfd, 1, 100); int pr = poll(&pfd, 1, 100);
@@ -697,7 +674,7 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
if (n == 0) if (n == 0)
break; /* every writer closed */ break; /* every writer closed */
if (n > 0) 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) else if (errno != EINTR && errno != EAGAIN)
break; break;
} }
@@ -733,9 +710,9 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
waitpid(pid, &status, 0); waitpid(pid, &status, 0);
} }
if (truncated) if (truncated)
cap_note(&buf, &len, &cap, "\n...(output truncated)\n"); os_cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
if (bg_killed) if (bg_killed)
cap_note(&buf, &len, &cap, os_cap_note(&buf, &len, &cap,
"\n[cst: background processes left running were stopped]\n"); "\n[cst: background processes left running were stopped]\n");
r->out = buf; r->out = buf;
r->out_len = len; r->out_len = len;
@@ -743,11 +720,11 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
if (killed == 1) { if (killed == 1) {
char note[96]; char note[96];
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n", snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
run_timeout_s()); os_run_timeout_effective());
cap_note(&r->out, &r->out_len, &cap, note); os_cap_note(&r->out, &r->out_len, &cap, note);
r->exit_code = 124; r->exit_code = 124;
} else if (killed == 2) { } 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; r->exit_code = 130;
} }
return 0; return 0;
@@ -759,18 +736,12 @@ 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); return os_run_shell_ex(cwd, cmd, max_out, r, NULL, NULL);
} }
int os_run_shell_ex(const char *cwd, const char *cmd, size_t max_out, /* busybox: busybox sh -c cmd ; /bin/sh: sh -c cmd. argv needs 5 slots and
os_run_result_t *r, os_child_hook_fn child_hook, * points into shell. */
void *ud) { static int shell_argv(char *shell, size_t shell_sz, const char *cmd,
if (!cmd || !r) const char *argv[5]) {
if (!cmd || os_resolve_shell(shell, shell_sz) != 0)
return -1; return -1;
char shell[OS_PATH_MAX];
if (os_resolve_shell(shell, sizeof(shell)) != 0)
return -1;
/* busybox: busybox sh -c cmd ; /bin/sh: sh -c cmd */
int is_busybox = 0;
{
const char *base = shell; const char *base = shell;
const char *s = strrchr(shell, '/'); const char *s = strrchr(shell, '/');
#ifdef _WIN32 #ifdef _WIN32
@@ -780,19 +751,49 @@ int os_run_shell_ex(const char *cwd, const char *cmd, size_t max_out,
#endif #endif
if (s) if (s)
base = s + 1; base = s + 1;
int i = 0;
argv[i++] = shell;
if (strncmp(base, "busybox", 7) == 0) if (strncmp(base, "busybox", 7) == 0)
is_busybox = 1; argv[i++] = "sh";
} argv[i++] = "-c";
argv[i++] = cmd;
if (is_busybox) { argv[i] = NULL;
const char *argv[] = {shell, "sh", "-c", cmd, NULL}; return 0;
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);
}
} }
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) {
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_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) { void os_localtime(const time_t *t, struct tm *out) {
if (!t || !out) if (!t || !out)
return; return;
@@ -811,16 +812,13 @@ int os_getpid(void) {
#endif #endif
} }
#ifndef _WIN32
int os_chmod_private(const char *path) { int os_chmod_private(const char *path) {
if (!path) if (!path)
return -1; return -1;
#ifdef _WIN32
(void)path;
return 0;
#else
return chmod(path, 0600); return chmod(path, 0600);
#endif
} }
#endif
typedef struct { typedef struct {
os_task_fn fn; os_task_fn fn;
@@ -905,133 +903,7 @@ int os_cancel_requested(void) { return g_run_cancel && *g_run_cancel; }
volatile int *os_get_cancel_flag(void) { return g_run_cancel; } volatile int *os_get_cancel_flag(void) { return g_run_cancel; }
#ifdef _WIN32 #ifndef _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
int os_proc_spawn(const char *cwd, const char *const argv[], int os_proc_spawn(const char *cwd, const char *const argv[],
const char *const env_add[], const char *stderr_path, const char *const env_add[], const char *stderr_path,
os_proc_t *p) { os_proc_t *p) {
@@ -1168,3 +1040,120 @@ void os_proc_kill(os_proc_t *p) {
} }
} }
#endif #endif
/* --- UTF-8 ---------------------------------------------------------------
* See the declaration in os_compat.h for why wcrtomb() is not used. */
int os_utf8_encode(unsigned long cp, char out[4]) {
if (cp < 0x80) {
out[0] = (char)cp;
return 1;
}
if (cp < 0x800) {
out[0] = (char)(0xC0 | (cp >> 6));
out[1] = (char)(0x80 | (cp & 0x3F));
return 2;
}
if (cp >= 0xD800 && cp <= 0xDFFF)
return 0; /* lone surrogate half from a 16-bit wchar_t */
if (cp < 0x10000) {
out[0] = (char)(0xE0 | (cp >> 12));
out[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[2] = (char)(0x80 | (cp & 0x3F));
return 3;
}
if (cp <= 0x10FFFF) {
out[0] = (char)(0xF0 | (cp >> 18));
out[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
out[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
out[3] = (char)(0x80 | (cp & 0x3F));
return 4;
}
return 0;
}
/* See the declaration in os_compat.h for why mbrtowc() is not used. */
int os_utf8_decode(const char *s, size_t avail, unsigned long *cp) {
const unsigned char *p = (const unsigned char *)s;
int need, i;
unsigned long v;
static const unsigned long MINV[4] = {0, 0x80, 0x800, 0x10000};
if (!s || !cp || avail == 0)
return 0;
if (p[0] < 0x80) {
*cp = p[0];
return 1;
}
if ((p[0] & 0xE0) == 0xC0) {
need = 1;
v = p[0] & 0x1Fu;
} else if ((p[0] & 0xF0) == 0xE0) {
need = 2;
v = p[0] & 0x0Fu;
} else if ((p[0] & 0xF8) == 0xF0) {
need = 3;
v = p[0] & 0x07u;
} else {
return 0; /* not a UTF-8 lead byte */
}
if (avail < (size_t)need + 1)
return 0; /* truncated */
for (i = 1; i <= need; i++) {
if ((p[i] & 0xC0) != 0x80)
return 0; /* bad continuation byte */
v = (v << 6) | (p[i] & 0x3Fu);
}
if (v < MINV[need] || (v >= 0xD800 && v <= 0xDFFF) || v > 0x10FFFF)
return 0; /* overlong, surrogate, or out of range */
*cp = v;
return need + 1;
}
/* --- libc gaps on Windows -----------------------------------------------
* strcasestr() is a GNU extension and setenv()/unsetenv() are POSIX; glibc
* has all three, MinGW-w64 has none of them. src/tui.c needs them (command
* filtering and ESCDELAY), so the Windows build supplies its own.
*
* The case folding here is ASCII-only, unlike glibc's locale-aware version.
* That is deliberate: these are used to match command and setting names,
* which are ASCII.
*/
#if defined(_WIN32)
static int ascii_lower(int c) {
return (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c;
}
char *strcasestr(const char *haystack, const char *needle) {
if (!haystack || !needle)
return NULL;
if (!*needle)
return (char *)haystack;
for (; *haystack; haystack++) {
const char *h = haystack;
const char *n = needle;
while (*h && *n && ascii_lower((unsigned char)*h) == ascii_lower((unsigned char)*n)) {
h++;
n++;
}
if (!*n)
return (char *)haystack;
}
return NULL;
}
int setenv(const char *name, const char *value, int overwrite) {
if (!name || !*name || strchr(name, '='))
return -1;
if (!overwrite && getenv(name))
return 0;
return _putenv_s(name, value ? value : "");
}
int unsetenv(const char *name) {
if (!name || !*name || strchr(name, '='))
return -1;
return _putenv_s(name, "");
}
#endif /* _WIN32 */
+14
View File
@@ -0,0 +1,14 @@
/* Shared between os_compat.c and os_win.c; not part of the public API. */
#ifndef CST_OS_INTERNAL_H
#define CST_OS_INTERNAL_H
#include <stddef.h>
/* CST_SHELL_TIMEOUT / os_set_run_timeout / 120 s */
int os_run_timeout_effective(void);
/* Append to a capture buffer, keeping at most max_out bytes. */
int os_cap_append(char **buf, size_t *len, size_t *cap, size_t max_out,
const char *data, size_t n, int *truncated);
void os_cap_note(char **buf, size_t *len, size_t *cap, const char *note);
#endif
+934
View File
@@ -0,0 +1,934 @@
/* Windows side of os_compat: every string crossing the OS boundary is UTF-8
* inside cst-agent and UTF-16 at the Win32 W APIs. The embedded manifest also
* sets the process code page to UTF-8, so CRT calls (fopen, getenv, argv)
* see UTF-8 too. */
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include "os_compat.h"
#include "cst_util.h"
#include "os_internal.h"
#include <locale.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <windows.h>
#include <aclapi.h>
#include <sddl.h>
wchar_t *os_utf8_to_wide(const char *s) {
if (!s)
return NULL;
int n = MultiByteToWideChar(CP_UTF8, 0, s, -1, NULL, 0);
if (n <= 0)
return NULL;
wchar_t *w = malloc((size_t)n * sizeof(wchar_t));
if (w && MultiByteToWideChar(CP_UTF8, 0, s, -1, w, n) <= 0) {
free(w);
return NULL;
}
return w;
}
char *os_wide_to_utf8(const wchar_t *w) {
if (!w)
return NULL;
int n = WideCharToMultiByte(CP_UTF8, 0, w, -1, NULL, 0, NULL, NULL);
if (n <= 0)
return NULL;
char *s = malloc((size_t)n);
if (s && WideCharToMultiByte(CP_UTF8, 0, w, -1, s, n, NULL, NULL) <= 0) {
free(s);
return NULL;
}
return s;
}
static UINT g_old_cp, g_old_out_cp;
static void console_restore(void) {
if (g_old_cp)
SetConsoleCP(g_old_cp);
if (g_old_out_cp)
SetConsoleOutputCP(g_old_out_cp);
}
int os_console_init(void) {
static int done;
if (done)
return GetACP() == CP_UTF8 ? 0 : -1;
done = 1;
g_old_cp = GetConsoleCP();
g_old_out_cp = GetConsoleOutputCP();
if (g_old_cp || g_old_out_cp) {
SetConsoleCP(CP_UTF8);
SetConsoleOutputCP(CP_UTF8);
atexit(console_restore);
}
setlocale(LC_ALL, ".UTF-8");
/* children inherit these: Python otherwise writes the ANSI code page to pipes */
if (!getenv("PYTHONUTF8"))
_putenv_s("PYTHONUTF8", "1");
if (!getenv("PYTHONIOENCODING"))
_putenv_s("PYTHONIOENCODING", "utf-8");
return GetACP() == CP_UTF8 ? 0 : -1;
}
/* The system locale's code page. GetOEMCP()/GetACP() say 65001 once the
* manifest makes this process UTF-8, but children without one (cmd, most
* console tools) still write the system's OEM code page, e.g. 936. */
static UINT system_cp(LCTYPE which) {
wchar_t b[8];
if (!GetLocaleInfoW(LOCALE_SYSTEM_DEFAULT, which, b, 8))
return 0;
return (UINT)wcstoul(b, NULL, 10);
}
char *os_output_to_utf8(const char *s, size_t n, size_t *out_len) {
if (!s || cst_utf8_valid(s, n))
return NULL;
UINT cps[] = {GetConsoleOutputCP(), system_cp(LOCALE_IDEFAULTCODEPAGE),
system_cp(LOCALE_IDEFAULTANSICODEPAGE), GetOEMCP(), GetACP()};
for (size_t i = 0; i < sizeof(cps) / sizeof(cps[0]); i++) {
UINT cp = cps[i];
if (!cp || cp == CP_UTF8)
continue;
int wn = MultiByteToWideChar(cp, MB_ERR_INVALID_CHARS, s, (int)n, NULL, 0);
if (wn <= 0)
continue;
wchar_t *w = malloc((size_t)wn * sizeof(wchar_t));
if (!w)
return NULL;
MultiByteToWideChar(cp, 0, s, (int)n, w, wn);
int un = WideCharToMultiByte(CP_UTF8, 0, w, wn, NULL, 0, NULL, NULL);
char *u = un > 0 ? malloc((size_t)un + 1) : NULL;
if (u) {
WideCharToMultiByte(CP_UTF8, 0, w, wn, u, un, NULL, NULL);
u[un] = 0;
if (out_len)
*out_len = (size_t)un;
}
free(w);
return u;
}
return NULL;
}
/* --- paths --------------------------------------------------------------- */
/* UTF-8 path -> wide, with the \\?\ prefix once it gets near MAX_PATH so
* deep trees work without the LongPathsEnabled policy. */
static wchar_t *wpath(const char *p) {
wchar_t *w = os_utf8_to_wide(p);
if (!w || wcslen(w) < MAX_PATH - 12)
return w;
DWORD n = GetFullPathNameW(w, 0, NULL, NULL);
wchar_t *f = n ? malloc(n * sizeof(wchar_t)) : NULL;
wchar_t *out = n ? malloc((n + 8) * sizeof(wchar_t)) : NULL;
if (!f || !out || GetFullPathNameW(w, n, f, NULL) == 0) {
free(f);
free(out);
return w;
}
free(w);
if (wcsncmp(f, L"\\\\?\\", 4) == 0) {
wcscpy(out, f);
} else if (f[0] == L'\\' && f[1] == L'\\') { /* \\srv\share -> \\?\UNC\srv\share */
wcscpy(out, L"\\\\?\\UNC");
wcscat(out, f + 1);
} else {
wcscpy(out, L"\\\\?\\");
wcscat(out, f);
}
free(f);
return out;
}
/* Wide path from the OS -> UTF-8 with '/' separators, no \\?\ prefix and a
* lowercase drive letter (stable for prefix compares). */
static int path_out(const wchar_t *w, char *out, size_t out_sz) {
const char *lead = "";
if (wcsncmp(w, L"\\\\?\\UNC\\", 8) == 0) {
lead = "\\\\"; /* \\?\UNC\srv\share -> \\srv\share */
w += 8;
} else if (wcsncmp(w, L"\\\\?\\", 4) == 0) {
w += 4;
}
char *u = os_wide_to_utf8(w);
if (!u)
return -1;
int n = snprintf(out, out_sz, "%s%s", lead, u);
free(u);
if (n < 0 || (size_t)n >= out_sz)
return -1;
os_path_norm_slashes(out);
if (out[0] >= 'A' && out[0] <= 'Z' && out[1] == ':')
out[0] = (char)(out[0] - 'A' + 'a');
return 0;
}
int os_realpath(const char *path, char *out, size_t out_sz) {
if (!path || !path[0] || !out || out_sz < 2)
return -1;
wchar_t *w = wpath(path);
if (!w)
return -1;
HANDLE h = CreateFileW(w, 0,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
NULL, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, NULL);
free(w);
if (h == INVALID_HANDLE_VALUE)
return -1;
wchar_t stackbuf[MAX_PATH + 16], *buf = stackbuf;
DWORD n = GetFinalPathNameByHandleW(h, buf, MAX_PATH + 16,
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS);
if (n >= MAX_PATH + 16) {
buf = malloc((n + 1) * sizeof(wchar_t));
n = buf ? GetFinalPathNameByHandleW(h, buf, n + 1,
FILE_NAME_NORMALIZED | VOLUME_NAME_DOS)
: 0;
}
CloseHandle(h);
int rc = n ? path_out(buf, out, out_sz) : -1;
if (buf != stackbuf)
free(buf);
return rc;
}
/* NTFS names are case-insensitive: compare the prefix the way the OS does. */
int os_path_under(const char *root, const char *path) {
if (!root || !path)
return 0;
wchar_t *wr = os_utf8_to_wide(root), *wp = os_utf8_to_wide(path);
int ok = 0;
if (wr && wp) {
size_t rl = wcslen(wr);
if (wcslen(wp) >= rl &&
(rl == 0 || CompareStringOrdinal(wr, (int)rl, wp, (int)rl, TRUE) ==
CSTR_EQUAL)) {
wchar_t c = wp[rl];
ok = c == 0 || c == L'/' || c == L'\\' ||
(rl && (wr[rl - 1] == L'/' || wr[rl - 1] == L'\\'));
}
}
free(wr);
free(wp);
return ok;
}
static int reparse_is_link(DWORD attrs, DWORD tag) {
return (attrs & FILE_ATTRIBUTE_REPARSE_POINT) &&
(tag == IO_REPARSE_TAG_SYMLINK || tag == IO_REPARSE_TAG_MOUNT_POINT);
}
/* --- directories ------------------------------------------------------- */
struct os_dir {
HANDLE h;
int pending; /* fd holds an entry not yet returned */
WIN32_FIND_DATAW fd;
char name[MAX_PATH * 4];
os_dirent_t ent;
};
os_dir_t *os_dir_open(const char *path) {
if (!path || !path[0])
return NULL;
size_t L = strlen(path);
char *pat = malloc(L + 3);
if (!pat)
return NULL;
memcpy(pat, path, L);
if (path[L - 1] != '/' && path[L - 1] != '\\')
pat[L++] = '\\';
pat[L++] = '*';
pat[L] = 0;
wchar_t *w = wpath(pat);
free(pat);
os_dir_t *d = w ? calloc(1, sizeof(*d)) : NULL;
if (!d) {
free(w);
return NULL;
}
d->h = FindFirstFileExW(w, FindExInfoBasic, &d->fd, FindExSearchNameMatch,
NULL, FIND_FIRST_EX_LARGE_FETCH);
free(w);
if (d->h == INVALID_HANDLE_VALUE) {
DWORD e = GetLastError();
if (e != ERROR_FILE_NOT_FOUND) { /* an empty drive root has no entries */
free(d);
return NULL;
}
} else {
d->pending = 1;
}
return d;
}
const os_dirent_t *os_dir_next(os_dir_t *d) {
if (!d || d->h == INVALID_HANDLE_VALUE)
return NULL;
for (;;) {
if (!d->pending && !FindNextFileW(d->h, &d->fd))
return NULL;
d->pending = 0;
const wchar_t *n = d->fd.cFileName;
if (wcscmp(n, L".") == 0 || wcscmp(n, L"..") == 0)
continue;
if (WideCharToMultiByte(CP_UTF8, 0, n, -1, d->name, (int)sizeof(d->name),
NULL, NULL) <= 0)
continue;
DWORD a = d->fd.dwFileAttributes;
d->ent.name = d->name;
d->ent.is_link = reparse_is_link(a, d->fd.dwReserved0);
d->ent.is_dir = !d->ent.is_link && (a & FILE_ATTRIBUTE_DIRECTORY);
return &d->ent;
}
}
void os_dir_close(os_dir_t *d) {
if (!d)
return;
if (d->h != INVALID_HANDLE_VALUE)
FindClose(d->h);
free(d);
}
static double filetime_secs(FILETIME ft) {
ULONGLONG t = ((ULONGLONG)ft.dwHighDateTime << 32) | ft.dwLowDateTime;
return (double)t / 1e7 - 11644473600.0;
}
int os_lstat(const char *path, os_stat_t *st) {
memset(st, 0, sizeof(*st));
wchar_t *w = path ? wpath(path) : NULL;
if (!w)
return -1;
WIN32_FILE_ATTRIBUTE_DATA fa;
if (!GetFileAttributesExW(w, GetFileExInfoStandard, &fa)) {
free(w);
return -1;
}
DWORD a = fa.dwFileAttributes;
if (a & FILE_ATTRIBUTE_REPARSE_POINT) {
WIN32_FIND_DATAW fd;
HANDLE h = FindFirstFileW(w, &fd);
if (h != INVALID_HANDLE_VALUE) {
st->is_link = reparse_is_link(a, fd.dwReserved0);
FindClose(h);
}
}
free(w);
st->is_dir = !st->is_link && (a & FILE_ATTRIBUTE_DIRECTORY);
st->is_reg = !st->is_link && !(a & FILE_ATTRIBUTE_DIRECTORY);
st->size = (long long)(((ULONGLONG)fa.nFileSizeHigh << 32) | fa.nFileSizeLow);
st->mtime = filetime_secs(fa.ftLastWriteTime);
return 0;
}
int os_rename_replace(const char *from, const char *to) {
wchar_t *wf = wpath(from), *wt = wpath(to);
int ok = wf && wt &&
MoveFileExW(wf, wt, MOVEFILE_REPLACE_EXISTING | MOVEFILE_COPY_ALLOWED);
free(wf);
free(wt);
return ok ? 0 : -1;
}
/* --- processes ----------------------------------------------------------- */
/* One argument the way CommandLineToArgvW and the CRT split it again. */
static void cmdline_arg(cst_buf_t *b, const char *a) {
if (a[0] && !strpbrk(a, " \t\n\v\"")) {
cst_buf_puts(b, a);
return;
}
cst_buf_puts(b, "\"");
for (const char *p = a;; p++) {
size_t bs = 0;
while (*p == '\\') {
bs++;
p++;
}
if (!*p) {
for (size_t i = 0; i < bs * 2; i++)
cst_buf_puts(b, "\\");
break;
}
for (size_t i = 0; i < (*p == '"' ? bs * 2 + 1 : bs); i++)
cst_buf_puts(b, "\\");
cst_buf_append(b, p, 1);
}
cst_buf_puts(b, "\"");
}
static wchar_t *build_cmdline(const char *const argv[]) {
cst_buf_t b = {0};
for (int i = 0; argv[i]; i++) {
if (i)
cst_buf_puts(&b, " ");
cmdline_arg(&b, argv[i]);
}
wchar_t *w = b.buf ? os_utf8_to_wide(b.buf) : NULL;
cst_buf_free(&b);
return w;
}
static size_t env_name_len(const wchar_t *e) {
const wchar_t *eq = wcschr(e + 1, L'='); /* "=C:=C:\x" hides a leading '=' */
return eq ? (size_t)(eq - e) : wcslen(e);
}
static int env_cmp(const void *a, const void *b) {
const wchar_t *x = *(const wchar_t *const *)a, *y = *(const wchar_t *const *)b;
return CompareStringOrdinal(x, (int)env_name_len(x), y, (int)env_name_len(y),
TRUE) -
CSTR_EQUAL;
}
/* The inherited environment with env_add ("KEY=VALUE") layered on top, as a
* sorted CREATE_UNICODE_ENVIRONMENT block; NULL = inherit unchanged. */
static wchar_t *env_block(const char *const env_add[]) {
if (!env_add || !env_add[0])
return NULL;
size_t nadd = 0, ncur = 0;
while (env_add[nadd])
nadd++;
wchar_t *cur = GetEnvironmentStringsW();
for (const wchar_t *e = cur; e && *e; e += wcslen(e) + 1)
ncur++;
wchar_t **add = calloc(nadd, sizeof(*add));
wchar_t **v = calloc(ncur + nadd, sizeof(*v));
wchar_t *blk = NULL;
size_t n = 0, total = 1;
if (!add || !v)
goto out;
for (size_t i = 0; i < nadd; i++)
if (!(add[i] = os_utf8_to_wide(env_add[i])))
goto out;
for (wchar_t *e = cur; e && *e; e += wcslen(e) + 1) {
int shadowed = 0;
for (size_t i = 0; i < nadd && !shadowed; i++)
shadowed = env_cmp(&e, &add[i]) == 0;
if (!shadowed)
v[n++] = e;
}
for (size_t i = 0; i < nadd; i++) {
int dup = 0; /* a later entry for the same name wins */
for (size_t k = i + 1; k < nadd && !dup; k++)
dup = env_cmp(&add[i], &add[k]) == 0;
if (!dup)
v[n++] = add[i];
}
qsort(v, n, sizeof(*v), env_cmp);
for (size_t i = 0; i < n; i++)
total += wcslen(v[i]) + 1;
blk = malloc(total * sizeof(wchar_t));
if (blk) {
wchar_t *o = blk;
for (size_t i = 0; i < n; i++) {
size_t l = wcslen(v[i]) + 1;
memcpy(o, v[i], l * sizeof(wchar_t));
o += l;
}
*o = 0;
}
out:
for (size_t i = 0; add && i < nadd; i++)
free(add[i]);
free(add);
free(v);
if (cur)
FreeEnvironmentStringsW(cur);
return blk;
}
static HANDLE new_job(void) {
HANDLE job = CreateJobObjectW(NULL, NULL);
if (!job)
return NULL;
JOBOBJECT_EXTENDED_LIMIT_INFORMATION li;
memset(&li, 0, sizeof(li));
li.BasicLimitInformation.LimitFlags =
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE |
JOB_OBJECT_LIMIT_DIE_ON_UNHANDLED_EXCEPTION;
SetInformationJobObject(job, JobObjectExtendedLimitInformation, &li,
sizeof(li));
return job;
}
static DWORD job_active(HANDLE job) {
JOBOBJECT_BASIC_ACCOUNTING_INFORMATION ai;
if (!job || !QueryInformationJobObject(job,
JobObjectBasicAccountingInformation,
&ai, sizeof(ai), NULL))
return 0;
return ai.ActiveProcesses;
}
/* Children still running after the shell exited. A child started without
* our console (the sandbox) has its own conhost.exe, which leaves the job a
* moment after its client, so give the job a short while to drain. */
static int job_lingers(HANDLE job) {
for (int i = 0; i < 50 && job_active(job) > 0; i++)
Sleep(10);
return job_active(job) > 0;
}
/* Start argv suspended, put it in a fresh kill-on-close job, then resume.
* Only in/out/err are inherited (PROC_THREAD_ATTRIBUTE_HANDLE_LIST), so a
* concurrent spawn on another thread never leaks into this child. */
static int spawn(const char *cwd, const char *const argv[],
const char *const env_add[], HANDLE token, HANDLE in,
HANDLE out, HANDLE err, HANDLE *proc, HANDLE *job,
DWORD *error) {
*proc = *job = NULL;
*error = ERROR_OUTOFMEMORY;
wchar_t *cmd = build_cmdline(argv);
wchar_t *wcwd = cwd && cwd[0] ? os_utf8_to_wide(cwd) : NULL;
wchar_t *env = env_block(env_add);
HANDLE inherit[3];
int ninh = 0;
HANDLE std3[3] = {in, out, err};
for (int i = 0; i < 3; i++) {
int seen = !std3[i] || std3[i] == INVALID_HANDLE_VALUE;
for (int k = 0; k < ninh && !seen; k++)
seen = inherit[k] == std3[i];
if (!seen)
inherit[ninh++] = std3[i];
}
SIZE_T asz = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &asz);
LPPROC_THREAD_ATTRIBUTE_LIST attrs = malloc(asz);
int ok = 0;
if (cmd && (!cwd || !cwd[0] || wcwd) && (!env_add || !env_add[0] || env) &&
attrs && InitializeProcThreadAttributeList(attrs, 1, 0, &asz)) {
if (UpdateProcThreadAttribute(attrs, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST,
inherit, (SIZE_T)ninh * sizeof(HANDLE), NULL,
NULL)) {
STARTUPINFOEXW si;
memset(&si, 0, sizeof(si));
si.StartupInfo.cb = sizeof(si);
si.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
si.StartupInfo.hStdInput = in;
si.StartupInfo.hStdOutput = out;
si.StartupInfo.hStdError = err;
si.lpAttributeList = attrs;
/* Share our console when there is one (children then see its UTF-8
* code page); the sandbox token cannot attach to it. */
DWORD flags = CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT |
CREATE_NEW_PROCESS_GROUP | EXTENDED_STARTUPINFO_PRESENT;
if (token || !GetConsoleWindow())
flags |= CREATE_NO_WINDOW;
PROCESS_INFORMATION pi;
memset(&pi, 0, sizeof(pi));
ok = token ? CreateProcessAsUserW(token, NULL, cmd, NULL, NULL, TRUE,
flags, env, wcwd, &si.StartupInfo, &pi)
: CreateProcessW(NULL, cmd, NULL, NULL, TRUE, flags, env, wcwd,
&si.StartupInfo, &pi);
if (ok) {
*job = new_job();
if (*job && !AssignProcessToJobObject(*job, pi.hProcess)) {
CloseHandle(*job);
*job = NULL;
}
ResumeThread(pi.hThread);
CloseHandle(pi.hThread);
*proc = pi.hProcess;
} else {
*error = GetLastError();
}
}
DeleteProcThreadAttributeList(attrs);
}
free(attrs);
free(cmd);
free(wcwd);
free(env);
return ok ? 0 : -1;
}
int os_run_capture_token(const char *cwd, const char *const argv[],
size_t max_out, os_run_result_t *r, void *token,
const char *const env_add[]) {
if (!argv || !argv[0] || !r)
return -1;
if (max_out == 0)
max_out = 512 * 1024;
memset(r, 0, sizeof(*r));
SECURITY_ATTRIBUTES sa = {sizeof(sa), NULL, TRUE};
HANDLE rd = NULL, wr = NULL;
if (!CreatePipe(&rd, &wr, &sa, 64 * 1024))
return -1;
SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0);
HANDLE nul = CreateFileW(L"NUL", GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE, &sa,
OPEN_EXISTING, 0, NULL);
HANDLE proc, job;
DWORD err;
int rc = spawn(cwd, argv, env_add, (HANDLE)token, nul, wr, wr, &proc, &job,
&err);
CloseHandle(wr);
if (nul != INVALID_HANDLE_VALUE)
CloseHandle(nul);
if (rc != 0) {
CloseHandle(rd);
if (err != ERROR_FILE_NOT_FOUND && err != ERROR_PATH_NOT_FOUND)
return -1;
cst_buf_t b = {0};
cst_buf_printf(&b, "%s: command not found\n", argv[0]);
r->out_len = b.len;
r->out = cst_buf_steal(&b);
r->exit_code = 127;
return 0;
}
size_t cap = 4096, len = 0;
char *buf = malloc(cap);
if (!buf) {
TerminateJobObject(job, 1);
TerminateProcess(proc, 1);
CloseHandle(rd);
CloseHandle(proc);
if (job)
CloseHandle(job);
return -1;
}
buf[0] = 0;
int truncated = 0, killed = 0, reaped = 0, bg_killed = 0;
double reaped_at = 0, killed_at = 0;
double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0;
int idle = 0; /* empty polls in a row: wait longer once output stalls */
for (;;) {
DWORD avail = 0;
if (!PeekNamedPipe(rd, NULL, 0, NULL, &avail, NULL))
break; /* every writer closed */
if (avail) {
char tmp[65536];
DWORD n = 0;
if (!ReadFile(rd, tmp, avail < sizeof(tmp) ? avail : sizeof(tmp), &n,
NULL) ||
n == 0)
break;
os_cap_append(&buf, &len, &cap, max_out, tmp, n, &truncated);
idle = 0;
} else if (reaped) {
Sleep(idle++ < 50 ? 1 : 20);
}
DWORD wait = avail ? 0 : idle++ < 50 ? 1 : 20;
if (!reaped && WaitForSingleObject(proc, wait) == WAIT_OBJECT_0) {
reaped = 1;
reaped_at = os_now_ms();
}
double now = os_now_ms();
/* The shell exited but a background child still holds the pipe
* (`server &`): give it a moment, then end the tree. */
if (reaped && !killed && !bg_killed && now - reaped_at > 1000) {
TerminateJobObject(job, 1);
bg_killed = 1;
killed_at = now;
}
if (!reaped && !killed && (os_cancel_requested() || now > deadline)) {
killed = os_cancel_requested() ? 2 : 1;
killed_at = now;
TerminateJobObject(job, killed == 1 ? 124 : 130);
if (!job)
TerminateProcess(proc, killed == 1 ? 124 : 130);
}
/* a writer outside the job can keep the pipe open */
if ((killed || bg_killed) && now - killed_at > 3000)
break;
}
CloseHandle(rd);
WaitForSingleObject(proc, killed ? 5000 : INFINITE);
DWORD code = 1;
GetExitCodeProcess(proc, &code);
if (!bg_killed && !killed && job_lingers(job))
bg_killed = 1; /* closing the job below ends them */
CloseHandle(proc);
if (job)
CloseHandle(job);
size_t ulen = 0;
char *u = os_output_to_utf8(buf, len, &ulen);
if (u) {
free(buf);
buf = u;
len = ulen;
cap = ulen + 1;
}
if (truncated)
os_cap_note(&buf, &len, &cap, "\n...(output truncated)\n");
if (bg_killed && !killed)
os_cap_note(&buf, &len, &cap,
"\n[cst: background processes left running were stopped]\n");
if (killed == 1) {
char note[96];
snprintf(note, sizeof(note), "\n[cst: killed after %ds timeout]\n",
os_run_timeout_effective());
os_cap_note(&buf, &len, &cap, note);
code = 124;
} else if (killed == 2) {
os_cap_note(&buf, &len, &cap, "\n[cst: cancelled]\n");
code = 130;
}
r->out = buf;
r->out_len = len;
r->exit_code = (int)code;
return 0;
}
int os_run_capture(const char *cwd, const char *const argv[], size_t max_out,
os_run_result_t *r) {
return os_run_capture_token(cwd, argv, max_out, r, NULL, NULL);
}
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_token(cwd, argv, max_out, r, NULL, NULL);
}
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;
SECURITY_ATTRIBUTES sa = {sizeof(sa), NULL, TRUE};
HANDLE in_r = NULL, in_w = NULL, out_r = NULL, out_w = NULL;
if (!CreatePipe(&in_r, &in_w, &sa, 64 * 1024))
return -1;
if (!CreatePipe(&out_r, &out_w, &sa, 64 * 1024)) {
CloseHandle(in_r);
CloseHandle(in_w);
return -1;
}
SetHandleInformation(in_w, HANDLE_FLAG_INHERIT, 0);
SetHandleInformation(out_r, HANDLE_FLAG_INHERIT, 0);
wchar_t *werr = stderr_path ? wpath(stderr_path) : NULL;
HANDLE err = CreateFileW(werr ? werr : L"NUL", GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE, &sa,
werr ? CREATE_ALWAYS : OPEN_EXISTING, 0, NULL);
free(werr);
HANDLE proc, job;
DWORD e;
int rc = spawn(cwd, argv, env_add, NULL, in_r, out_w,
err != INVALID_HANDLE_VALUE ? err : out_w, &proc, &job, &e);
CloseHandle(in_r);
CloseHandle(out_w);
if (err != INVALID_HANDLE_VALUE)
CloseHandle(err);
if (rc != 0) {
CloseHandle(in_w);
CloseHandle(out_r);
return -1;
}
p->proc = proc;
p->job = job;
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;
DWORD avail = 0;
for (;;) {
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(5);
}
DWORD r = 0;
if (!ReadFile((HANDLE)p->out_r, buf, avail < n ? avail : (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) {
/* polite first: most servers exit when stdin closes */
if (WaitForSingleObject((HANDLE)p->proc, 500) != WAIT_OBJECT_0) {
if (p->job)
TerminateJobObject((HANDLE)p->job, 1);
else
TerminateProcess((HANDLE)p->proc, 1);
}
WaitForSingleObject((HANDLE)p->proc, 2000);
CloseHandle((HANDLE)p->proc);
p->proc = NULL;
}
if (p->job) {
CloseHandle((HANDLE)p->job); /* ends anything the server left behind */
p->job = NULL;
}
if (p->out_r) {
CloseHandle((HANDLE)p->out_r);
p->out_r = NULL;
}
}
void os_windows_version(char *out, size_t n) {
typedef LONG(WINAPI * rtl_ver_fn)(OSVERSIONINFOW *);
OSVERSIONINFOW vi;
memset(&vi, 0, sizeof(vi));
vi.dwOSVersionInfoSize = sizeof(vi);
HMODULE nt = GetModuleHandleW(L"ntdll.dll");
rtl_ver_fn fn = nt ? (rtl_ver_fn)(void (*)(void))GetProcAddress(nt, "RtlGetVersion")
: NULL;
SYSTEM_INFO si;
GetNativeSystemInfo(&si);
const char *arch = si.wProcessorArchitecture == PROCESSOR_ARCHITECTURE_ARM64
? "arm64"
: "x86_64";
if (fn && fn(&vi) == 0)
snprintf(out, n, "Windows %lu.%lu.%lu %s", vi.dwMajorVersion,
vi.dwMinorVersion, vi.dwBuildNumber, arch);
else
snprintf(out, n, "Windows %s", arch);
}
/* Mandatory label from an SDDL SACL such as "S:(ML;;NWNR;;;ME)". */
int os_win_set_label(const char *path, const char *sddl) {
wchar_t *wp = wpath(path), *ws = os_utf8_to_wide(sddl);
PSECURITY_DESCRIPTOR sd = NULL;
int rc = -1;
if (wp && ws &&
ConvertStringSecurityDescriptorToSecurityDescriptorW(ws, SDDL_REVISION_1,
&sd, NULL)) {
PACL sacl = NULL;
BOOL present = FALSE, defaulted = FALSE;
if (GetSecurityDescriptorSacl(sd, &present, &sacl, &defaulted) && present &&
SetNamedSecurityInfoW(wp, SE_FILE_OBJECT, LABEL_SECURITY_INFORMATION,
NULL, NULL, NULL, sacl) == ERROR_SUCCESS)
rc = 0;
LocalFree(sd);
}
free(wp);
free(ws);
return rc;
}
int os_win_get_label(const char *path, char *out, size_t n) {
wchar_t *wp = wpath(path);
PSECURITY_DESCRIPTOR sd = NULL;
wchar_t *ws = NULL;
int rc = -1;
if (wp && n > 2 &&
GetNamedSecurityInfoW(wp, SE_FILE_OBJECT, LABEL_SECURITY_INFORMATION,
NULL, NULL, NULL, NULL, &sd) == ERROR_SUCCESS &&
ConvertSecurityDescriptorToStringSecurityDescriptorW(
sd, SDDL_REVISION_1, LABEL_SECURITY_INFORMATION, &ws, NULL)) {
char *s = os_wide_to_utf8(ws);
if (s) {
/* keep "S:" and the explicit ACEs; inherited ones carry the ID flag */
size_t o = 0;
out[o++] = 'S';
out[o++] = ':';
rc = 0;
for (const char *p = strchr(s, '('); p; p = strchr(p + 1, '(')) {
const char *e = strchr(p, ')');
if (!e)
break;
/* (type;flags;rights;...): flags are two-letter codes */
const char *f = strchr(p, ';');
int inherited = 0;
for (f = f && f < e ? f + 1 : e; f + 1 < e && *f != ';'; f += 2)
inherited |= f[0] == 'I' && f[1] == 'D';
size_t len = (size_t)(e - p) + 1;
if (inherited)
continue;
if (o + len >= n) {
rc = -1;
break;
}
memcpy(out + o, p, len);
o += len;
}
out[o] = 0;
free(s);
}
}
if (ws)
LocalFree(ws);
if (sd)
LocalFree(sd);
free(wp);
return rc;
}
int os_chmod_private(const char *path) {
if (!path)
return -1;
return os_win_set_label(path, "S:(ML;;NWNRNX;;;ME)");
}
/* --- clipboard ----------------------------------------------------------- */
int os_clipboard_set(const char *utf8, size_t n) {
cst_buf_t b = {0}; /* CRLF is what Windows text consumers expect */
for (size_t i = 0; i < n; i++) {
if (utf8[i] == '\n' && (i == 0 || utf8[i - 1] != '\r'))
cst_buf_puts(&b, "\r");
cst_buf_append(&b, utf8 + i, 1);
}
if (!b.buf)
cst_buf_puts(&b, "");
int wn = MultiByteToWideChar(CP_UTF8, 0, b.buf, (int)b.len, NULL, 0);
HGLOBAL h = GlobalAlloc(GMEM_MOVEABLE, ((size_t)wn + 1) * sizeof(wchar_t));
wchar_t *w = h ? GlobalLock(h) : NULL;
if (!w) {
if (h)
GlobalFree(h);
cst_buf_free(&b);
return -1;
}
MultiByteToWideChar(CP_UTF8, 0, b.buf, (int)b.len, w, wn);
w[wn] = 0;
GlobalUnlock(h);
cst_buf_free(&b);
if (!OpenClipboard(NULL)) {
GlobalFree(h);
return -1;
}
EmptyClipboard();
int ok = SetClipboardData(CF_UNICODETEXT, h) != NULL;
if (!ok)
GlobalFree(h);
CloseClipboard();
return ok ? 0 : -1;
}
#endif /* _WIN32 */
+69 -1
View File
@@ -7,6 +7,7 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <strings.h>
#include "cJSON.h" #include "cJSON.h"
#include "cst_perms.h" #include "cst_perms.h"
@@ -33,7 +34,7 @@ static void load(cst_perms_t *p) {
while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0 && b.len < 256 * 1024) while ((n = fread(tmp, 1, sizeof(tmp), fp)) > 0 && b.len < 256 * 1024)
cst_buf_append(&b, tmp, n); cst_buf_append(&b, tmp, n);
fclose(fp); fclose(fp);
cJSON *root = b.buf ? cJSON_Parse(b.buf) : NULL; cJSON *root = b.buf ? cJSON_Parse(b.buf + cst_bom_len(b.buf, b.len)) : NULL;
cst_buf_free(&b); cst_buf_free(&b);
const cJSON *it = NULL; const cJSON *it = NULL;
cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(root, "tools")) { cJSON_ArrayForEach(it, cJSON_GetObjectItemCaseSensitive(root, "tools")) {
@@ -222,6 +223,73 @@ void cst_perms_suggest_prefix(const char *cmd, char *out, size_t out_sz) {
out[0] = 0; out[0] = 0;
} }
/* --- network commands ------------------------------------------------------ */
static int net_word_eq(const char *w, size_t n, const char *name) {
size_t k = strlen(name);
if (n == k + 4 && strncasecmp(w + k, ".exe", 4) == 0)
n = k;
return n == k && strncasecmp(w, name, k) == 0;
}
int cst_shell_uses_network(const char *cmd) {
static const char *const TOOLS[] = {
"curl", "wget", "ssh", "scp", "sftp", "rsync",
"ftp", "tftp", "telnet", "nc", "ncat", "netcat",
"socat", "npx", "invoke-webrequest", "iwr", "invoke-restmethod",
"irm", "start-bitstransfer", "bitsadmin", "certutil"};
/* tool + sub-command that downloads or uploads */
static const char *const PAIRS[][2] = {
{"git", "clone"}, {"git", "fetch"}, {"git", "pull"},
{"git", "push"}, {"git", "ls-remote"}, {"git", "submodule"},
{"npm", "install"}, {"npm", "i"}, {"npm", "ci"},
{"npm", "update"}, {"npm", "publish"}, {"npm", "exec"},
{"pnpm", "install"}, {"pnpm", "add"}, {"pnpm", "dlx"},
{"yarn", "install"}, {"yarn", "add"}, {"bun", "install"},
{"bun", "add"}, {"pip", "install"}, {"pip", "download"},
{"pip3", "install"}, {"uv", "pip"}, {"uv", "add"},
{"uv", "sync"}, {"cargo", "install"}, {"cargo", "fetch"},
{"cargo", "update"}, {"cargo", "publish"}, {"go", "get"},
{"go", "install"}, {"go", "mod"}, {"gem", "install"},
{"dotnet", "restore"}, {"dotnet", "add"}, {"winget", "install"},
{"choco", "install"}, {"scoop", "install"}, {"apt", "install"},
{"apt-get", "install"}, {"pkg", "install"}, {"brew", "install"}};
if (!cmd)
return 0;
const char *prev = NULL;
size_t prev_n = 0;
for (const char *p = cmd; *p;) {
while (*p && strchr(" \t\r\n;|&()<>`'\"$=", *p))
p++;
const char *w = p;
while (*p && !strchr(" \t\r\n;|&()<>`'\"$=", *p))
p++;
size_t n = (size_t)(p - w);
if (!n)
break;
/* /usr/bin/curl, C:\tools\curl.exe */
for (size_t i = n; i > 0; i--) {
if (w[i - 1] == '/' || w[i - 1] == '\\') {
n -= i;
w += i;
break;
}
}
for (size_t i = 0; i < sizeof(TOOLS) / sizeof(TOOLS[0]); i++)
if (net_word_eq(w, n, TOOLS[i]))
return 1;
for (size_t i = 0; prev && i < sizeof(PAIRS) / sizeof(PAIRS[0]); i++)
if (net_word_eq(prev, prev_n, PAIRS[i][0]) &&
net_word_eq(w, n, PAIRS[i][1]))
return 1;
if (n && w[0] != '-') {
prev = w;
prev_n = n;
}
}
return 0;
}
/* --- read-only command rules ---------------------------------------------- */ /* --- read-only command rules ---------------------------------------------- */
#define SAFE_MAX_WORDS 64 #define SAFE_MAX_WORDS 64
+13 -33
View File
@@ -11,10 +11,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#ifndef _WIN32
#include <dirent.h>
#include <unistd.h> #include <unistd.h>
#endif
#include "cJSON.h" #include "cJSON.h"
#include "cst_mcp.h" #include "cst_mcp.h"
@@ -60,6 +57,7 @@ static char *slurp(const char *path, size_t max) {
fclose(fp); fclose(fp);
if (!b.buf) if (!b.buf)
cst_buf_puts(&b, ""); cst_buf_puts(&b, "");
cst_strip_bom(b.buf, &b.len);
return cst_buf_steal(&b); return cst_buf_steal(&b);
} }
@@ -101,14 +99,6 @@ static int plugin_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r, void *ud) { cst_tool_result_t *r, void *ud) {
const ptool_t *pt = ud; const ptool_t *pt = ud;
r->exit_code = 1; r->exit_code = 1;
#ifdef _WIN32
(void)ctx;
(void)c;
(void)pt;
r->out = strdup("error: plugin tools are not supported on Windows yet");
r->out_len = r->out ? strlen(r->out) : 0;
return 0;
#else
const cst_tool_def_t *def = cst_tool_find(pt->tool); const cst_tool_def_t *def = cst_tool_find(pt->tool);
char *args = cst_tool_args_json(def, c); char *args = cst_tool_args_json(def, c);
char tmp[OS_PATH_MAX]; char tmp[OS_PATH_MAX];
@@ -180,7 +170,6 @@ static int plugin_run(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
r->out_len = out.len; r->out_len = out.len;
r->out = cst_buf_steal(&out); r->out = cst_buf_steal(&out);
return 0; return 0;
#endif
} }
/* argv array -> quoted command line; relative entries found in the plugin /* argv array -> quoted command line; relative entries found in the plugin
@@ -339,21 +328,20 @@ static void load_plugin(const char *dir, const char *dirname,
static int scan(const char *root, const char *source) { static int scan(const char *root, const char *source) {
int n = 0; int n = 0;
#ifndef _WIN32 os_dir_t *d = os_dir_open(root);
DIR *d = opendir(root);
if (!d) if (!d)
return 0; return 0;
char *names[CST_PLUGINS_MAX * 2]; char *names[CST_PLUGINS_MAX * 2];
int nn = 0; int nn = 0;
struct dirent *de; const os_dirent_t *de;
while (nn < CST_PLUGINS_MAX * 2 && (de = readdir(d)) != NULL) { while (nn < CST_PLUGINS_MAX * 2 && (de = os_dir_next(d)) != NULL) {
if (de->d_name[0] == '.') if (de->name[0] == '.')
continue; continue;
names[nn] = strdup(de->d_name); names[nn] = strdup(de->name);
if (names[nn]) if (names[nn])
nn++; nn++;
} }
closedir(d); os_dir_close(d);
for (int i = 1; i < nn; i++) for (int i = 1; i < nn; i++)
for (int j = i; j > 0 && strcmp(names[j - 1], names[j]) > 0; j--) { for (int j = i; j > 0 && strcmp(names[j - 1], names[j]) > 0; j--) {
char *t = names[j]; char *t = names[j];
@@ -371,32 +359,24 @@ static int scan(const char *root, const char *source) {
} }
free(names[i]); free(names[i]);
} }
#else
(void)root;
(void)source;
#endif
return n; return n;
} }
static int count_plugin_dirs(const char *root) { static int count_plugin_dirs(const char *root) {
int n = 0; int n = 0;
#ifndef _WIN32 os_dir_t *d = os_dir_open(root);
DIR *d = opendir(root);
if (!d) if (!d)
return 0; return 0;
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
char pj[OS_PATH_MAX]; char pj[OS_PATH_MAX];
if (de->d_name[0] != '.' && if (de->name[0] != '.' &&
snprintf(pj, sizeof(pj), "%s/%s/plugin.json", root, de->d_name) < snprintf(pj, sizeof(pj), "%s/%s/plugin.json", root, de->name) <
(int)sizeof(pj) && (int)sizeof(pj) &&
is_file(pj)) is_file(pj))
n++; n++;
} }
closedir(d); os_dir_close(d);
#else
(void)root;
#endif
return n; return n;
} }
+427 -22
View File
@@ -19,6 +19,9 @@
#elif defined(__APPLE__) #elif defined(__APPLE__)
#define CST_HAVE_SEATBELT 1 #define CST_HAVE_SEATBELT 1
#include <stdint.h> #include <stdint.h>
#elif defined(_WIN32)
#include <windows.h>
#include <sddl.h>
#endif #endif
#if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__)) #if defined(__linux__) && (defined(__x86_64__) || defined(__aarch64__))
@@ -83,8 +86,6 @@ static int g_abi;
#define LL_FS_MAKE_SYM (1ULL << 12) #define LL_FS_MAKE_SYM (1ULL << 12)
#define LL_FS_REFER (1ULL << 13) /* ABI 2 */ #define LL_FS_REFER (1ULL << 13) /* ABI 2 */
#define LL_FS_TRUNCATE (1ULL << 14) /* ABI 3 */ #define LL_FS_TRUNCATE (1ULL << 14) /* ABI 3 */
#define LL_NET_BIND_TCP (1ULL << 0) /* ABI 4 */
#define LL_NET_CONNECT_TCP (1ULL << 1) /* ABI 4 */
struct ll_ruleset_attr { struct ll_ruleset_attr {
uint64_t handled_access_fs; uint64_t handled_access_fs;
@@ -134,9 +135,7 @@ static int ll_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
int abi = g_abi; int abi = g_abi;
struct ll_ruleset_attr attr; struct ll_ruleset_attr attr;
memset(&attr, 0, sizeof(attr)); memset(&attr, 0, sizeof(attr));
attr.handled_access_fs = ll_write_rights(abi); attr.handled_access_fs = ll_write_rights(abi); /* network stays open */
if (abi >= 4)
attr.handled_access_net = LL_NET_BIND_TCP | LL_NET_CONNECT_TCP;
int fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0); int fd = (int)syscall(__NR_landlock_create_ruleset, &attr, sizeof(attr), 0);
if (fd < 0) if (fd < 0)
return -1; return -1;
@@ -190,8 +189,7 @@ static int sb_prepare(const char *ws, const char *tmp, cst_sandbox_plan_t *p) {
"(version 1)(allow default)(deny file-write*)" "(version 1)(allow default)(deny file-write*)"
"(allow file-write* (subpath \"%s\") (subpath \"%s\") " "(allow file-write* (subpath \"%s\") (subpath \"%s\") "
"(literal \"/dev/null\") (literal \"/dev/tty\") " "(literal \"/dev/null\") (literal \"/dev/tty\") "
"(literal \"/dev/dtracehelper\"))" "(literal \"/dev/dtracehelper\"))",
"(deny network*)",
qws, qtmp); qws, qtmp);
return 0; return 0;
} }
@@ -287,9 +285,8 @@ static const int SC_NOTIFY[] = {
#endif #endif
}; };
/* Foreign ABIs are refused, IPv4/IPv6 sockets fail with EACCES, opens /* Foreign ABIs are refused, opens without write flags pass untouched,
* without write flags pass untouched, write-type calls go to the * write-type calls go to the supervisor. */
* supervisor. */
static void sc_build(sc_prog_t *p) { static void sc_build(sc_prog_t *p) {
p->n = 0; p->n = 0;
SC_LD(offsetof(struct seccomp_data, arch)); SC_LD(offsetof(struct seccomp_data, arch));
@@ -300,12 +297,6 @@ static void sc_build(sc_prog_t *p) {
sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */ sc_emit(p, BPF_JMP | BPF_JGE | BPF_K, 0, 1, 0x40000000U); /* x32 */
SC_RET(SECCOMP_RET_ERRNO | EPERM); SC_RET(SECCOMP_RET_ERRNO | EPERM);
#endif #endif
SC_JEQ(__NR_socket, 0, 5);
SC_LD(SC_ARG(0));
SC_JEQ(AF_INET, 2, 0);
SC_JEQ(AF_INET6, 1, 0);
SC_RET(SECCOMP_RET_ALLOW);
SC_RET(SECCOMP_RET_ERRNO | EACCES);
SC_JEQ(__NR_io_uring_setup, 0, 1); SC_JEQ(__NR_io_uring_setup, 0, 1);
SC_RET(SECCOMP_RET_ERRNO | EPERM); SC_RET(SECCOMP_RET_ERRNO | EPERM);
SC_JEQ(__NR_openat, 0, 4); SC_JEQ(__NR_openat, 0, 4);
@@ -388,6 +379,47 @@ static ssize_t sc_peek(int pid, uint64_t addr, void *out, size_t n) {
return k; return k;
} }
/* Path decisions need sc_peek on the sandboxed child. Yama
* kernel.yama.ptrace_scope=2 (and no CAP_SYS_PTRACE) denies that, so a
* listener-only probe would report seccomp available while every write
* fails with EACCES. */
static int sc_can_peek(void) {
static const char want[] = "cst-peek-ok";
int sp[2];
if (pipe2(sp, O_CLOEXEC) != 0)
return 0;
pid_t pid = fork();
if (pid == 0) {
char marker[] = "cst-peek-ok";
uint64_t addr = (uint64_t)(uintptr_t)marker;
close(sp[0]);
if (write(sp[1], &addr, sizeof(addr)) != (ssize_t)sizeof(addr))
_exit(1);
close(sp[1]);
pause();
_exit(0);
}
close(sp[1]);
if (pid < 0) {
close(sp[0]);
return 0;
}
uint64_t addr = 0;
int ok = 0;
if (read(sp[0], &addr, sizeof(addr)) == (ssize_t)sizeof(addr) && addr) {
char got[sizeof(want)];
if (sc_peek((int)pid, addr, got, sizeof(got)) == (ssize_t)sizeof(got) &&
memcmp(got, want, sizeof(got)) == 0)
ok = 1;
}
close(sp[0]);
kill(pid, SIGKILL);
int st = 0;
while (waitpid(pid, &st, 0) < 0 && errno == EINTR)
;
return ok;
}
static int sc_read_mem(int pid, uint64_t addr, void *out, size_t n) { static int sc_read_mem(int pid, uint64_t addr, void *out, size_t n) {
size_t got = 0; size_t got = 0;
while (got < n) { while (got < n) {
@@ -522,9 +554,10 @@ static void sc_rewrite(int pid, const char *in, char *out, size_t n) {
static int sc_abs_ok(const sc_state_t *s, const char *full, int follow, static int sc_abs_ok(const sc_state_t *s, const char *full, int follow,
int strict) { int strict) {
char path[OS_PATH_MAX], real[OS_PATH_MAX], loc[2 * OS_PATH_MAX + 2]; char path[OS_PATH_MAX], real[OS_PATH_MAX], loc[2 * OS_PATH_MAX + 2];
if (strlen(full) >= sizeof(path)) size_t flen = strlen(full);
if (flen >= sizeof(path))
return 0; return 0;
snprintf(path, sizeof(path), "%s", full); memcpy(path, full, flen + 1);
size_t n = strlen(path); size_t n = strlen(path);
while (n > 1 && path[n - 1] == '/') while (n > 1 && path[n - 1] == '/')
path[--n] = 0; path[--n] = 0;
@@ -859,11 +892,346 @@ static int sc_probe(void) {
int st = 0; int st = 0;
while (waitpid(pid, &st, 0) < 0 && errno == EINTR) while (waitpid(pid, &st, 0) < 0 && errno == EINTR)
; ;
return ok && WIFEXITED(st) && WEXITSTATUS(st) == 0; return ok && WIFEXITED(st) && WEXITSTATUS(st) == 0 && sc_can_peek();
} }
#endif /* CST_HAVE_SECCOMP */ #endif /* CST_HAVE_SECCOMP */
/* --- Windows: Low integrity level ----------------------------------------- */
#ifdef _WIN32
#define LW_LABEL "S:(ML;OICI;NW;;;LW)"
#define LW_RECORD "sandbox-labels"
/* Same user, no admin privileges, Low IL. No LUA_TOKEN: a deny-only
* Administrators group cannot open an admin's (SSH) window station and the
* child fails in DLL init; the integrity check does not need it. */
static HANDLE lw_token(void) {
HANDLE self = NULL, tok = NULL;
if (!OpenProcessToken(GetCurrentProcess(),
TOKEN_DUPLICATE | TOKEN_QUERY | TOKEN_ADJUST_DEFAULT |
TOKEN_ASSIGN_PRIMARY,
&self))
return NULL;
BOOL ok = CreateRestrictedToken(self, DISABLE_MAX_PRIVILEGE, 0, NULL, 0,
NULL, 0, NULL, &tok);
CloseHandle(self);
if (!ok)
return NULL;
PSID low = NULL;
if (!ConvertStringSidToSidW(L"S-1-16-4096", &low)) {
CloseHandle(tok);
return NULL;
}
TOKEN_MANDATORY_LABEL tml;
memset(&tml, 0, sizeof(tml));
tml.Label.Sid = low;
tml.Label.Attributes = SE_GROUP_INTEGRITY;
ok = SetTokenInformation(tok, TokenIntegrityLevel, &tml,
(DWORD)(sizeof(tml) + GetLengthSid(low)));
LocalFree(low);
if (!ok) {
CloseHandle(tok);
return NULL;
}
return tok;
}
static int lw_record_path(char *out, size_t n) {
char dir[OS_PATH_MAX];
if (os_config_dir(dir, sizeof(dir)) != 0)
return -1;
return snprintf(out, n, "%s/%s", dir, LW_RECORD) < (int)n ? 0 : -1;
}
static char *lw_read_record(void) {
char path[OS_PATH_MAX];
if (lw_record_path(path, sizeof(path)) != 0)
return NULL;
FILE *f = fopen(path, "rb");
if (!f)
return NULL;
size_t cap = 4096, len = 0;
char *s = malloc(cap);
size_t k;
while (s && (k = fread(s + len, 1, cap - len - 1, f)) > 0) {
len += k;
if (cap - len < 2) {
char *g = realloc(s, cap * 2);
if (!g) {
free(s);
s = NULL;
break;
}
s = g;
cap *= 2;
}
}
fclose(f);
if (s)
s[len] = 0;
return s;
}
/* dir is a recorded directory or inside one (the label is inherited). */
static int lw_recorded(const char *dir) {
char *s = lw_read_record();
int found = 0;
for (char *line = s; line && *line && !found;) {
char *nl = strchr(line, '\n');
if (nl)
*nl = 0;
char *tab = strchr(line, '\t');
if (tab)
*tab = 0;
found = line[0] && os_path_under(line, dir);
line = nl ? nl + 1 : NULL;
}
free(s);
return found;
}
static int lw_record_add(const char *dir, const char *old_sddl) {
char path[OS_PATH_MAX];
if (lw_record_path(path, sizeof(path)) != 0)
return -1;
FILE *f = fopen(path, "ab");
if (!f)
return -1;
fprintf(f, "%s\t%s\n", dir, old_sddl);
return fclose(f) == 0 ? 0 : -1;
}
static int lw_same_dir(const char *a, const char *b) {
return a && b && a[0] && b[0] && os_path_under(a, b) && os_path_under(b, a);
}
/* An environment directory in the form os_realpath gives the workspace
* (long names, '/'), or NULL. */
static const char *lw_env_dir(const char *name, char *buf, size_t n) {
const char *v = getenv(name);
if (!v || !v[0] || os_realpath(v, buf, n) != 0)
return NULL;
os_path_norm_slashes(buf);
return buf;
}
/* Labeling a whole drive, the profile or system folders would open far more
* than a project to every sandboxed command. */
static int lw_refused(const char *dir) {
size_t n = strlen(dir);
while (n > 0 && (dir[n - 1] == '/' || dir[n - 1] == '\\'))
n--;
if (n <= 2)
return 1;
static const char *const SELF[] = {"USERPROFILE", "APPDATA",
"LOCALAPPDATA", "TEMP"};
static const char *const TREE[] = {"SystemRoot", "ProgramFiles",
"ProgramFiles(x86)", "ProgramData"};
char buf[OS_PATH_MAX];
for (size_t i = 0; i < sizeof(SELF) / sizeof(SELF[0]); i++)
if (lw_same_dir(lw_env_dir(SELF[i], buf, sizeof(buf)), dir))
return 1;
for (size_t i = 0; i < sizeof(TREE) / sizeof(TREE[0]); i++) {
const char *v = lw_env_dir(TREE[i], buf, sizeof(buf));
if (v && os_path_under(v, dir))
return 1;
}
return 0;
}
static int lw_prepare(const char *ws, cst_sandbox_plan_t *p) {
static int creds_done, tmp_done;
char buf[OS_PATH_MAX + 256];
if (!ws || !ws[0])
return -1;
if (!creds_done) {
/* files saved by older versions have no no-read-up label yet */
char dir[OS_PATH_MAX];
if (os_config_dir(dir, sizeof(dir)) == 0 &&
snprintf(buf, sizeof(buf), "%s/credentials", dir) < (int)sizeof(buf)) {
os_stat_t st;
if (os_lstat(buf, &st) != 0 || os_chmod_private(buf) == 0)
creds_done = 1;
}
}
char tmp[OS_PATH_MAX];
if (os_temp_dir(tmp, sizeof(tmp)) != 0 ||
snprintf(buf, sizeof(buf), "%s/cst-agent-sandbox", tmp) >=
(int)sizeof(tmp))
return -1;
memcpy(tmp, buf, strlen(buf) + 1);
if (!tmp_done) {
os_mkdir_p(tmp, 0700);
if (os_win_set_label(tmp, LW_LABEL) != 0)
return -1;
tmp_done = 1;
}
if (!lw_recorded(ws)) {
char old[512];
if (lw_refused(ws) || os_win_get_label(ws, old, sizeof(old)) != 0 ||
os_win_set_label(ws, LW_LABEL) != 0)
return -1;
lw_record_add(ws, old);
snprintf(buf, sizeof(buf),
"[cst: sandbox: labeled %s Low integrity so sandboxed commands "
"can write there; /sandbox reset-labels undoes it]\n",
ws);
p->note = strdup(buf);
}
HANDLE tok = lw_token();
if (!tok) {
free(p->note);
p->note = NULL;
return -1;
}
p->token = tok;
for (char *c = tmp; *c; c++)
if (*c == '/')
*c = '\\';
p->tmpdir = strdup(tmp);
return p->tmpdir ? 0 : -1;
}
static int lw_run(const char *ws, const char *cmd, size_t max_out,
const cst_sandbox_plan_t *p, os_run_result_t *r) {
size_t tl = strlen(p->tmpdir) + 8;
char *te = malloc(tl), *tm = malloc(tl);
int rc = -1;
if (te && tm) {
snprintf(te, tl, "TEMP=%s", p->tmpdir);
snprintf(tm, tl, "TMP=%s", p->tmpdir);
const char *env[] = {te, tm, NULL};
rc = os_run_shell_token(ws, cmd, max_out, r, p->token, env);
}
free(te);
free(tm);
if (rc == 0 && p->note) {
size_t nl = strlen(p->note);
char *o = malloc(nl + r->out_len + 1);
if (o) {
memcpy(o, p->note, nl);
if (r->out)
memcpy(o + nl, r->out, r->out_len);
o[nl + r->out_len] = 0;
free(r->out);
r->out = o;
r->out_len += nl;
}
}
return rc;
}
#endif /* _WIN32 */
char *cst_sandbox_labels(void) {
#ifdef _WIN32
char *s = lw_read_record();
if (!s)
return NULL;
size_t o = 0;
for (char *line = s; *line;) {
char *nl = strchr(line, '\n');
size_t len = nl ? (size_t)(nl - line) : strlen(line);
size_t path = strcspn(line, "\t\n");
if (path) {
memmove(s + o, line, path);
o += path;
s[o++] = '\n';
}
line += len + (nl ? 1 : 0);
}
s[o] = 0;
if (!o) {
free(s);
return NULL;
}
return s;
#else
return NULL;
#endif
}
int cst_sandbox_reset_labels(char **report) {
if (report)
*report = NULL;
#ifdef _WIN32
char *s = lw_read_record();
size_t cap = 256, len = 0;
char *rep = malloc(cap), *keep = NULL;
size_t kcap = 0, klen = 0;
int ok = 0, bad = 0;
if (rep)
rep[0] = 0;
for (char *line = s; line && *line;) {
char *nl = strchr(line, '\n');
if (nl)
*nl = 0;
char *tab = strchr(line, '\t');
const char *old = tab ? tab + 1 : "S:";
if (tab)
*tab = 0;
if (line[0]) {
os_stat_t st;
int gone = os_lstat(line, &st) != 0;
int done = gone || os_win_set_label(line, old[0] ? old : "S:") == 0;
char msg[OS_PATH_MAX + 64];
int m = snprintf(msg, sizeof(msg), "%s %s\n",
gone ? "gone " : done ? "restored" : "FAILED ",
line);
if (m > 0 && rep) {
while (len + (size_t)m + 1 > cap) {
char *g = realloc(rep, cap *= 2);
if (!g)
break;
rep = g;
}
if (len + (size_t)m + 1 <= cap) {
memcpy(rep + len, msg, (size_t)m + 1);
len += (size_t)m;
}
}
if (done) {
ok++;
} else {
bad++;
size_t need = strlen(line) + strlen(old) + 3;
if (klen + need + 1 > kcap) {
char *g = realloc(keep, kcap = (klen + need + 1) * 2);
if (g)
keep = g;
}
if (keep && klen + need + 1 <= kcap)
klen += (size_t)snprintf(keep + klen, kcap - klen, "%s\t%s\n",
line, old);
}
}
line = nl ? nl + 1 : NULL;
}
free(s);
char path[OS_PATH_MAX];
if (lw_record_path(path, sizeof(path)) == 0) {
if (klen) {
FILE *f = fopen(path, "wb");
if (f) {
fwrite(keep, 1, klen, f);
fclose(f);
}
} else {
remove(path);
}
}
free(keep);
if (report)
*report = rep;
else
free(rep);
return bad ? -1 : ok;
#else
return 0;
#endif
}
/* --- common --------------------------------------------------------------- */ /* --- common --------------------------------------------------------------- */
int cst_sandbox_probe(void) { int cst_sandbox_probe(void) {
@@ -891,6 +1259,15 @@ int cst_sandbox_probe(void) {
#endif #endif
#ifdef CST_HAVE_SEATBELT #ifdef CST_HAVE_SEATBELT
kind = CST_SANDBOX_SEATBELT; kind = CST_SANDBOX_SEATBELT;
#endif
#ifdef _WIN32
if (!e || strcmp(e, "lowil") == 0) {
HANDLE tok = lw_token();
if (tok) {
CloseHandle(tok);
kind = CST_SANDBOX_LOWIL;
}
}
#endif #endif
(void)want_ll; (void)want_ll;
(void)want_sc; (void)want_sc;
@@ -917,6 +1294,8 @@ const char *cst_sandbox_name(int kind) {
return "seatbelt"; return "seatbelt";
case CST_SANDBOX_SECCOMP: case CST_SANDBOX_SECCOMP:
return "seccomp"; return "seccomp";
case CST_SANDBOX_LOWIL:
return "lowil";
default: default:
return "none"; return "none";
} }
@@ -939,6 +1318,10 @@ int cst_sandbox_prepare(const char *workspace, const char *tmpdir,
#ifdef CST_HAVE_SECCOMP #ifdef CST_HAVE_SECCOMP
if (kind == CST_SANDBOX_SECCOMP) if (kind == CST_SANDBOX_SECCOMP)
rc = sc_prepare(workspace, tmpdir, plan); rc = sc_prepare(workspace, tmpdir, plan);
#endif
#ifdef _WIN32
if (kind == CST_SANDBOX_LOWIL && (rc = lw_prepare(workspace, plan)) != 0)
cst_sandbox_release(plan);
#endif #endif
(void)workspace; (void)workspace;
(void)tmpdir; (void)tmpdir;
@@ -988,6 +1371,15 @@ void cst_sandbox_release(cst_sandbox_plan_t *plan) {
#ifdef CST_HAVE_SECCOMP #ifdef CST_HAVE_SECCOMP
sc_free(plan->sc); sc_free(plan->sc);
#endif #endif
#ifdef _WIN32
if (plan->token)
CloseHandle(plan->token);
#endif
plan->token = NULL;
free(plan->tmpdir);
plan->tmpdir = NULL;
free(plan->note);
plan->note = NULL;
plan->sc = NULL; plan->sc = NULL;
free(plan->profile); free(plan->profile);
plan->profile = NULL; plan->profile = NULL;
@@ -1004,6 +1396,12 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
if (cst_sandbox_prepare(workspace, tmp, &plan) != 0) { if (cst_sandbox_prepare(workspace, tmp, &plan) != 0) {
memset(r, 0, sizeof(*r)); memset(r, 0, sizeof(*r));
const char *msg = "[cst: sandbox unavailable; command not run]\n"; const char *msg = "[cst: sandbox unavailable; command not run]\n";
#ifdef _WIN32
if (workspace && !lw_recorded(workspace) && lw_refused(workspace))
msg = "[cst: sandbox: will not label a drive root, the profile or a "
"system folder writable; command not run. Start cst-agent in a "
"project folder]\n";
#endif
r->out = malloc(strlen(msg) + 1); r->out = malloc(strlen(msg) + 1);
if (r->out) { if (r->out) {
memcpy(r->out, msg, strlen(msg) + 1); memcpy(r->out, msg, strlen(msg) + 1);
@@ -1012,8 +1410,12 @@ int cst_sandbox_run_shell(const char *workspace, const char *cmd,
r->exit_code = 126; r->exit_code = 126;
return 0; return 0;
} }
#ifdef _WIN32
int rc = lw_run(workspace, cmd, max_out, &plan, r);
#else
int rc = os_run_shell_ex(workspace, cmd, max_out, r, cst_sandbox_enter, int rc = os_run_shell_ex(workspace, cmd, max_out, r, cst_sandbox_enter,
&plan); &plan);
#endif
cst_sandbox_release(&plan); cst_sandbox_release(&plan);
return rc; return rc;
} }
@@ -1032,13 +1434,16 @@ int cst_sandbox_blocked(const char *out, size_t n, int exit_code) {
static const char *const SIGNS[] = { static const char *const SIGNS[] = {
"Permission denied", "Read-only file system", "Operation not permitted", "Permission denied", "Read-only file system", "Operation not permitted",
"EACCES", "EPERM", "EROFS", "EACCES", "EPERM", "EROFS",
"[cst: sandbox", "Network is unreachable", "[cst: sandbox",
/* curl when the socket itself was refused (seccomp) */
"Could not connect to server", "Failed to connect to",
/* localized strerror(EACCES/EPERM/EROFS) */ /* localized strerror(EACCES/EPERM/EROFS) */
"权限不够", "不允许的操作", "只读文件系统", "權限不足", "权限不够", "不允许的操作", "只读文件系统", "權限不足",
"拒絕不符權限的操作", "許可がありません", "Keine Berechtigung", "拒絕不符權限的操作", "許可がありません", "Keine Berechtigung",
"Permission non accord", "Permiso denegado", "Отказано в доступе", "Permission non accord", "Permiso denegado", "Отказано в доступе",
/* Windows ERROR_ACCESS_DENIED from cmd, PowerShell and Win32 tools */
"Access is denied", "Access to the path", "UnauthorizedAccess",
"拒绝访问", "存取被拒", "Zugriff verweigert", "Accès refusé",
/* Cygwin/MSYS2 programs cannot initialize at Low IL */
"DLL initialization routine failed", "0xc0000142",
}; };
for (size_t i = 0; i < sizeof(SIGNS) / sizeof(SIGNS[0]); i++) for (size_t i = 0; i < sizeof(SIGNS) / sizeof(SIGNS[0]); i++)
if (has(out, n, SIGNS[i])) if (has(out, n, SIGNS[i]))
+57 -4
View File
@@ -7,6 +7,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h"
#include <pthread.h> #include <pthread.h>
#include <stdio.h> #include <stdio.h>
@@ -97,7 +98,11 @@ static void msg_free(cst_message_t *m) {
free(m->content); free(m->content);
free(m->tool_calls_json); free(m->tool_calls_json);
free(m->meta_json); free(m->meta_json);
m->content = m->tool_calls_json = m->meta_json = NULL; free(m->reasoning);
cst_attachments_free(m->attach, m->nattach);
m->content = m->tool_calls_json = m->meta_json = m->reasoning = NULL;
m->attach = NULL;
m->nattach = 0;
} }
static int session_grow(cst_session_t *s) { static int session_grow(cst_session_t *s) {
@@ -127,6 +132,16 @@ static int session_push_mem(cst_session_t *s, const cst_message_t *src) {
d->tool_calls_json = xstrdup(src->tool_calls_json); d->tool_calls_json = xstrdup(src->tool_calls_json);
if (src->meta_json && src->meta_json[0]) if (src->meta_json && src->meta_json[0])
d->meta_json = xstrdup(src->meta_json); d->meta_json = xstrdup(src->meta_json);
if (src->reasoning && src->reasoning[0])
d->reasoning = xstrdup(src->reasoning);
if (src->attach && src->nattach > 0) {
d->attach = cst_attachments_dup(src->attach, src->nattach);
if (!d->attach) {
msg_free(d);
return -1;
}
d->nattach = src->nattach;
}
s->nmsgs++; s->nmsgs++;
return 0; return 0;
} }
@@ -137,7 +152,7 @@ static const char *jstr(const cJSON *o, const char *key) {
} }
static void load_existing(cst_session_t *s) { static void load_existing(cst_session_t *s) {
FILE *rp = fopen(s->path, "r"); FILE *rp = fopen(s->path, "rb");
if (!rp) if (!rp)
return; return;
char *line = NULL; char *line = NULL;
@@ -174,9 +189,14 @@ static void load_existing(cst_session_t *s) {
if (cJSON_IsObject(mo)) if (cJSON_IsObject(mo))
meta = cJSON_PrintUnformatted(mo); meta = cJSON_PrintUnformatted(mo);
m.meta_json = meta; m.meta_json = meta;
m.reasoning = (char *)jstr(o, "reasoning");
const cJSON *at = cJSON_GetObjectItemCaseSensitive(o, "attachments");
if (cJSON_IsArray(at))
m.attach = cst_attachments_from_json(at, &m.nattach);
session_push_mem(s, &m); session_push_mem(s, &m);
free(tcj); free(tcj);
free(meta); free(meta);
cst_attachments_free(m.attach, m.nattach);
cJSON_Delete(o); cJSON_Delete(o);
} }
free(line); free(line);
@@ -196,7 +216,7 @@ cst_session_t *cst_session_open(const char *path) {
pthread_mutex_init(&s->mu, NULL); pthread_mutex_init(&s->mu, NULL);
snprintf(s->path, sizeof(s->path), "%s", path ? path : "session.jsonl"); snprintf(s->path, sizeof(s->path), "%s", path ? path : "session.jsonl");
load_existing(s); load_existing(s);
s->fp = fopen(s->path, "a+"); s->fp = fopen(s->path, "a+b");
if (!s->fp || session_grow(s) != 0) { if (!s->fp || session_grow(s) != 0) {
if (s->fp) if (s->fp)
fclose(s->fp); fclose(s->fp);
@@ -249,6 +269,8 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
cJSON_AddStringToObject(o, "tool_calls_json", msg->tool_calls_json); cJSON_AddStringToObject(o, "tool_calls_json", msg->tool_calls_json);
} }
} }
if (msg->reasoning && msg->reasoning[0])
cJSON_AddStringToObject(o, "reasoning", msg->reasoning);
if (msg->meta_json && msg->meta_json[0]) { if (msg->meta_json && msg->meta_json[0]) {
cJSON *mo = cJSON_Parse(msg->meta_json); cJSON *mo = cJSON_Parse(msg->meta_json);
if (cJSON_IsObject(mo)) if (cJSON_IsObject(mo))
@@ -256,6 +278,28 @@ static int write_line(FILE *fp, const cst_message_t *msg) {
else else
cJSON_Delete(mo); cJSON_Delete(mo);
} }
if (msg->attach && msg->nattach > 0) {
cJSON *arr = cJSON_CreateArray();
for (int i = 0; i < msg->nattach; i++) {
const cst_attachment_t *a = &msg->attach[i];
cJSON *it = cJSON_CreateObject();
cJSON_AddStringToObject(it, "id", a->id);
cJSON_AddStringToObject(it, "name", a->name);
cJSON_AddStringToObject(it, "mime", a->mime);
cJSON_AddStringToObject(it, "rel", a->rel);
cJSON_AddNumberToObject(it, "bytes", (double)a->bytes);
if (a->width > 0 && a->height > 0) {
cJSON_AddNumberToObject(it, "width", a->width);
cJSON_AddNumberToObject(it, "height", a->height);
}
if (a->detail[0])
cJSON_AddStringToObject(it, "detail", a->detail);
if (a->origin[0])
cJSON_AddStringToObject(it, "origin", a->origin);
cJSON_AddItemToArray(arr, it);
}
cJSON_AddItemToObject(o, "attachments", arr);
}
char *txt = cJSON_PrintUnformatted(o); char *txt = cJSON_PrintUnformatted(o);
cJSON_Delete(o); cJSON_Delete(o);
if (!txt) if (!txt)
@@ -295,7 +339,7 @@ static int reopen_truncate(cst_session_t *s) {
fclose(s->fp); fclose(s->fp);
s->fp = NULL; s->fp = NULL;
} }
s->fp = fopen(s->path, "w+"); s->fp = fopen(s->path, "w+b");
return s->fp ? 0 : -1; return s->fp ? 0 : -1;
} }
@@ -310,6 +354,7 @@ int cst_session_clear(cst_session_t *s, int keep_system) {
if (s->msgs[i].role == CST_ROLE_SYSTEM) { if (s->msgs[i].role == CST_ROLE_SYSTEM) {
sys.role = CST_ROLE_SYSTEM; sys.role = CST_ROLE_SYSTEM;
sys.content = xstrdup(s->msgs[i].content ? s->msgs[i].content : ""); sys.content = xstrdup(s->msgs[i].content ? s->msgs[i].content : "");
sys.meta_json = xstrdup(s->msgs[i].meta_json);
have_sys = sys.content != NULL; have_sys = sys.content != NULL;
break; break;
} }
@@ -322,6 +367,7 @@ int cst_session_clear(cst_session_t *s, int keep_system) {
write_line(s->fp, &sys); write_line(s->fp, &sys);
} }
free(sys.content); free(sys.content);
free(sys.meta_json);
pthread_mutex_unlock(&s->mu); pthread_mutex_unlock(&s->mu);
return rc; return rc;
} }
@@ -339,6 +385,11 @@ int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) {
copy[i].tool_calls_json = copy[i].tool_calls_json =
msgs[i].tool_calls_json ? strdup(msgs[i].tool_calls_json) : NULL; msgs[i].tool_calls_json ? strdup(msgs[i].tool_calls_json) : NULL;
copy[i].meta_json = msgs[i].meta_json ? strdup(msgs[i].meta_json) : NULL; copy[i].meta_json = msgs[i].meta_json ? strdup(msgs[i].meta_json) : NULL;
copy[i].reasoning = msgs[i].reasoning ? strdup(msgs[i].reasoning) : NULL;
copy[i].attach = msgs[i].attach && msgs[i].nattach > 0
? cst_attachments_dup(msgs[i].attach, msgs[i].nattach)
: NULL;
copy[i].nattach = copy[i].attach ? msgs[i].nattach : 0;
} }
pthread_mutex_lock(&s->mu); pthread_mutex_lock(&s->mu);
free_all(s); free_all(s);
@@ -352,6 +403,8 @@ int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) {
free(copy[i].content); free(copy[i].content);
free(copy[i].tool_calls_json); free(copy[i].tool_calls_json);
free(copy[i].meta_json); free(copy[i].meta_json);
free(copy[i].reasoning);
cst_attachments_free(copy[i].attach, copy[i].nattach);
} }
free(copy); free(copy);
return rc; return rc;
+2 -1
View File
@@ -1,4 +1,5 @@
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_util.h"
#include "os_compat.h" #include "os_compat.h"
#include "cJSON.h" #include "cJSON.h"
@@ -31,7 +32,7 @@ int cst_settings_load(cst_settings_t *s) {
size_t n = fread(buf, 1, sizeof(buf) - 1, fp); size_t n = fread(buf, 1, sizeof(buf) - 1, fp);
fclose(fp); fclose(fp);
buf[n] = 0; buf[n] = 0;
cJSON *root = cJSON_Parse(buf); cJSON *root = cJSON_Parse(buf + cst_bom_len(buf, n));
if (!cJSON_IsObject(root)) { if (!cJSON_IsObject(root)) {
cJSON_Delete(root); cJSON_Delete(root);
return -1; return -1;
+14 -36
View File
@@ -13,10 +13,7 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#ifndef _WIN32
#include <dirent.h>
#include <pthread.h> #include <pthread.h>
#endif
#include "cst_tools.h" #include "cst_tools.h"
#include "cst_util.h" #include "cst_util.h"
@@ -166,14 +163,9 @@ static struct {
char source[48]; char source[48];
} g_roots[EXTRA_ROOTS]; } g_roots[EXTRA_ROOTS];
static int g_nroots; static int g_nroots;
#ifndef _WIN32
static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_mu = PTHREAD_MUTEX_INITIALIZER;
#define LOCK() pthread_mutex_lock(&g_mu) #define LOCK() pthread_mutex_lock(&g_mu)
#define UNLOCK() pthread_mutex_unlock(&g_mu) #define UNLOCK() pthread_mutex_unlock(&g_mu)
#else
#define LOCK() ((void)0)
#define UNLOCK() ((void)0)
#endif
void cst_skills_add_root(const char *dir, const char *source) { void cst_skills_add_root(const char *dir, const char *source) {
if (!dir || !dir[0]) if (!dir || !dir[0])
@@ -225,6 +217,7 @@ static char *slurp(const char *path, size_t max, size_t *len) {
} }
if (!b.buf) if (!b.buf)
cst_buf_puts(&b, ""); cst_buf_puts(&b, "");
cst_strip_bom(b.buf, &b.len);
if (len) if (len)
*len = b.len; *len = b.len;
return cst_buf_steal(&b); return cst_buf_steal(&b);
@@ -243,16 +236,15 @@ static int have(const cst_skill_t *v, int n, const char *name) {
static int scan_root(const char *root, const char *source, cst_skill_t *out, static int scan_root(const char *root, const char *source, cst_skill_t *out,
int n, int max) { int n, int max) {
#ifndef _WIN32 os_dir_t *d = os_dir_open(root);
DIR *d = opendir(root);
if (!d) if (!d)
return n; return n;
struct dirent *de; const os_dirent_t *de;
while (n < max && (de = readdir(d)) != NULL) { while (n < max && (de = os_dir_next(d)) != NULL) {
if (de->d_name[0] == '.') if (de->name[0] == '.')
continue; continue;
char dir[OS_PATH_MAX], md[OS_PATH_MAX + 16]; char dir[OS_PATH_MAX], md[OS_PATH_MAX + 16];
if (snprintf(dir, sizeof(dir), "%s/%s", root, de->d_name) >= if (snprintf(dir, sizeof(dir), "%s/%s", root, de->name) >=
(int)sizeof(dir)) (int)sizeof(dir))
continue; continue;
snprintf(md, sizeof(md), "%s/SKILL.md", dir); snprintf(md, sizeof(md), "%s/SKILL.md", dir);
@@ -262,7 +254,7 @@ static int scan_root(const char *root, const char *source, cst_skill_t *out,
cst_skill_t s; cst_skill_t s;
memset(&s, 0, sizeof(s)); memset(&s, 0, sizeof(s));
if (!cst_fm_get(text, "name", s.name, sizeof(s.name)) || !s.name[0]) if (!cst_fm_get(text, "name", s.name, sizeof(s.name)) || !s.name[0])
snprintf(s.name, sizeof(s.name), "%.63s", de->d_name); snprintf(s.name, sizeof(s.name), "%.63s", de->name);
cst_fm_get(text, "description", s.desc, sizeof(s.desc)); cst_fm_get(text, "description", s.desc, sizeof(s.desc));
if (!s.desc[0]) { if (!s.desc[0]) {
/* first prose line of the body */ /* first prose line of the body */
@@ -282,13 +274,7 @@ static int scan_root(const char *root, const char *source, cst_skill_t *out,
if (!have(out, n, s.name)) if (!have(out, n, s.name))
out[n++] = s; out[n++] = s;
} }
closedir(d); os_dir_close(d);
#else
(void)root;
(void)source;
(void)out;
(void)max;
#endif
return n; return n;
} }
@@ -371,25 +357,24 @@ static int find_skill(const char *ws, const char *name, cst_skill_t *out) {
static void list_files(const char *dir, const char *rel, int depth, static void list_files(const char *dir, const char *rel, int depth,
cst_buf_t *b, int *count) { cst_buf_t *b, int *count) {
#ifndef _WIN32
char full[OS_PATH_MAX]; char full[OS_PATH_MAX];
if (snprintf(full, sizeof(full), "%s%s%s", dir, rel[0] ? "/" : "", rel) >= if (snprintf(full, sizeof(full), "%s%s%s", dir, rel[0] ? "/" : "", rel) >=
(int)sizeof(full)) (int)sizeof(full))
return; return;
DIR *d = opendir(full); os_dir_t *d = os_dir_open(full);
if (!d) if (!d)
return; return;
char *names[256]; char *names[256];
int nn = 0; int nn = 0;
struct dirent *de; const os_dirent_t *de;
while (nn < 256 && (de = readdir(d)) != NULL) { while (nn < 256 && (de = os_dir_next(d)) != NULL) {
if (de->d_name[0] == '.') if (de->name[0] == '.')
continue; continue;
names[nn] = strdup(de->d_name); names[nn] = strdup(de->name);
if (names[nn]) if (names[nn])
nn++; nn++;
} }
closedir(d); os_dir_close(d);
for (int i = 1; i < nn; i++) { for (int i = 1; i < nn; i++) {
for (int j = i; j > 0 && strcmp(names[j - 1], names[j]) > 0; j--) { for (int j = i; j > 0 && strcmp(names[j - 1], names[j]) > 0; j--) {
char *tmp = names[j]; char *tmp = names[j];
@@ -416,13 +401,6 @@ static void list_files(const char *dir, const char *rel, int depth,
} }
for (int i = 0; i < nn; i++) for (int i = 0; i < nn; i++)
free(names[i]); free(names[i]);
#else
(void)dir;
(void)rel;
(void)depth;
(void)b;
(void)count;
#endif
} }
static char *errf(int *code, const char *fmt, const char *a, const char *b) { static char *errf(int *code, const char *fmt, const char *a, const char *b) {
+23 -40
View File
@@ -14,9 +14,6 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <time.h> #include <time.h>
#ifndef _WIN32
#include <dirent.h>
#endif
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
@@ -79,6 +76,7 @@ static char *slurp(const char *path, size_t max) {
fclose(fp); fclose(fp);
if (!b.buf) if (!b.buf)
cst_buf_puts(&b, ""); cst_buf_puts(&b, "");
cst_strip_bom(b.buf, &b.len);
return cst_buf_steal(&b); return cst_buf_steal(&b);
} }
@@ -118,25 +116,24 @@ static int truthy(const char *v) {
static void load_dir(const char *dir, const char *source, cst_agent_role_t *v, static void load_dir(const char *dir, const char *source, cst_agent_role_t *v,
int *n, int max) { int *n, int max) {
#ifndef _WIN32 os_dir_t *d = os_dir_open(dir);
DIR *d = opendir(dir);
if (!d) if (!d)
return; return;
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL && *n < max) { while ((de = os_dir_next(d)) != NULL && *n < max) {
size_t len = strlen(de->d_name); size_t len = strlen(de->name);
if (de->d_name[0] == '.' || len < 4 || if (de->name[0] == '.' || len < 4 ||
strcmp(de->d_name + len - 3, ".md") != 0) strcmp(de->name + len - 3, ".md") != 0)
continue; continue;
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
if (snprintf(p, sizeof(p), "%s/%s", dir, de->d_name) >= (int)sizeof(p)) if (snprintf(p, sizeof(p), "%s/%s", dir, de->name) >= (int)sizeof(p))
continue; continue;
char *text = slurp(p, ROLE_FILE_MAX); char *text = slurp(p, ROLE_FILE_MAX);
if (!text) if (!text)
continue; continue;
char name[64], val[1024]; char name[64], val[1024];
if (!cst_fm_get(text, "name", name, sizeof(name)) || !name[0]) if (!cst_fm_get(text, "name", name, sizeof(name)) || !name[0])
snprintf(name, sizeof(name), "%.*s", (int)(len - 3), de->d_name); snprintf(name, sizeof(name), "%.*s", (int)(len - 3), de->name);
char clean[64]; char clean[64];
cst_tool_sanitize_name(name, clean, sizeof(clean)); cst_tool_sanitize_name(name, clean, sizeof(clean));
int at = find_role(v, *n, clean); int at = find_role(v, *n, clean);
@@ -166,14 +163,7 @@ static void load_dir(const char *dir, const char *source, cst_agent_role_t *v,
r->prompt = strdup(body); r->prompt = strdup(body);
free(text); free(text);
} }
closedir(d); os_dir_close(d);
#else
(void)dir;
(void)source;
(void)v;
(void)n;
(void)max;
#endif
} }
int cst_roles_list(const char *ws, cst_agent_role_t *out, int max) { int cst_roles_list(const char *ws, cst_agent_role_t *out, int max) {
@@ -255,18 +245,16 @@ int cst_subagent_path(const char *ws, const char *parent_id, const char *file,
static int next_file(const char *dir, char *file, size_t fsz) { static int next_file(const char *dir, char *file, size_t fsz) {
pthread_mutex_lock(&g_seq_mu); pthread_mutex_lock(&g_seq_mu);
int best = 0; int best = 0;
#ifndef _WIN32 os_dir_t *d = os_dir_open(dir);
DIR *d = opendir(dir);
if (d) { if (d) {
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
int k = 0; int k = 0;
if (sscanf(de->d_name, "sub-%d.jsonl", &k) == 1 && k > best) if (sscanf(de->name, "sub-%d.jsonl", &k) == 1 && k > best)
best = k; best = k;
} }
closedir(d); os_dir_close(d);
} }
#endif
int rc = -1; int rc = -1;
for (int k = best + 1; k < best + 100; k++) { for (int k = best + 1; k < best + 100; k++) {
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
@@ -276,7 +264,7 @@ static int next_file(const char *dir, char *file, size_t fsz) {
struct stat st; struct stat st;
if (stat(p, &st) == 0) if (stat(p, &st) == 0)
continue; continue;
FILE *fp = fopen(p, "a"); FILE *fp = fopen(p, "ab");
if (fp) { if (fp) {
fclose(fp); fclose(fp);
rc = 0; rc = 0;
@@ -302,7 +290,7 @@ static void write_meta(const char *dir, const char *file, const char *role,
cJSON_AddNumberToObject(o, "ms", ms); cJSON_AddNumberToObject(o, "ms", ms);
char *txt = cJSON_PrintUnformatted(o); char *txt = cJSON_PrintUnformatted(o);
cJSON_Delete(o); cJSON_Delete(o);
FILE *fp = txt ? fopen(p, "w") : NULL; FILE *fp = txt ? fopen(p, "wb") : NULL;
if (fp) { if (fp) {
fputs(txt, fp); fputs(txt, fp);
fputc('\n', fp); fputc('\n', fp);
@@ -317,19 +305,18 @@ int cst_subagent_list(const char *ws, const char *parent_id,
if (sub_dir(ws, parent_id, dir, sizeof(dir)) != 0) if (sub_dir(ws, parent_id, dir, sizeof(dir)) != 0)
return 0; return 0;
int n = 0; int n = 0;
#ifndef _WIN32 os_dir_t *d = os_dir_open(dir);
DIR *d = opendir(dir);
if (!d) if (!d)
return 0; return 0;
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL && n < max) { while ((de = os_dir_next(d)) != NULL && n < max) {
int k = 0; int k = 0;
char tail[8] = ""; char tail[8] = "";
if (sscanf(de->d_name, "sub-%d.%7s", &k, tail) != 2 || if (sscanf(de->name, "sub-%d.%7s", &k, tail) != 2 ||
strcmp(tail, "json") != 0) strcmp(tail, "json") != 0)
continue; continue;
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
if (snprintf(p, sizeof(p), "%s/%s", dir, de->d_name) >= (int)sizeof(p)) if (snprintf(p, sizeof(p), "%s/%s", dir, de->name) >= (int)sizeof(p))
continue; continue;
char *txt = slurp(p, 64 * 1024); char *txt = slurp(p, 64 * 1024);
cJSON *o = txt ? cJSON_Parse(txt) : NULL; cJSON *o = txt ? cJSON_Parse(txt) : NULL;
@@ -358,11 +345,7 @@ int cst_subagent_list(const char *ws, const char *parent_id,
r->mtime = (long long)st.st_mtime; r->mtime = (long long)st.st_mtime;
n++; n++;
} }
closedir(d); os_dir_close(d);
#else
(void)out;
(void)max;
#endif
/* newest first: higher number */ /* newest first: higher number */
for (int i = 1; i < n; i++) for (int i = 1; i < n; i++)
for (int j = i; j > 0 && atoi(out[j - 1].file + 4) < atoi(out[j].file + 4); for (int j = i; j > 0 && atoi(out[j - 1].file + 4) < atoi(out[j].file + 4);
+12 -6
View File
@@ -3,23 +3,29 @@
#endif #endif
#include "cst_textsel.h" #include "cst_textsel.h"
#include "os_compat.h"
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <wctype.h> #include <wctype.h>
int cst_utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) { int cst_utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) {
unsigned long cp = 0;
int n;
if (avail == 0) if (avail == 0)
return -1; return -1;
mbstate_t st; /* os_utf8_decode(), not mbrtowc(): the latter follows setlocale(), which on
memset(&st, 0, sizeof(st)); * Windows means the ANSI code page, so UTF-8 text would be decoded as GBK
size_t n = mbrtowc(wc, s, avail, &st); * and rendered as mojibake. */
if (n == (size_t)-1 || n == (size_t)-2 || n == 0) { n = os_utf8_decode(s, avail, &cp);
*wc = (unsigned char)s[0]; if (n <= 0) {
/* Not a valid sequence: fall back to one raw byte, as before. */
*wc = (wchar_t)(unsigned char)s[0];
*eaten = 1; *eaten = 1;
return 0; return 0;
} }
*eaten = n; *wc = (wchar_t)cp;
*eaten = (size_t)n;
return 0; return 0;
} }
+89 -76
View File
@@ -12,13 +12,9 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#ifndef _WIN32
#include <dirent.h>
#include <fnmatch.h>
#include <regex.h> #include <regex.h>
#include <unistd.h>
#endif
#include "cst_attach.h"
#include "cst_tools.h" #include "cst_tools.h"
#include "cst_util.h" #include "cst_util.h"
#include "os_compat.h" #include "os_compat.h"
@@ -154,6 +150,9 @@ int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
FILE *fp = fopen(full, "rb"); FILE *fp = fopen(full, "rb");
if (!fp) if (!fp)
return tool_set_resultf(r, 1, "error: cannot open file: %.200s", path); 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 offset = tool_arg_long(c, "offset", 0);
long limit = tool_arg_long(c, "limit", 0); long limit = tool_arg_long(c, "limit", 0);
@@ -178,6 +177,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)", return tool_set_resultf(r, 1, "error: %.200s is a binary file (%zu bytes)",
path, n); 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); cst_tool_result_free(r);
r->out = buf; r->out = buf;
r->out_len = n; r->out_len = n;
@@ -217,6 +218,9 @@ int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
} }
if (numbers) if (numbers)
cst_buf_printf(&b, "%6ld|", lineno); 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); cst_buf_append(&b, line.buf, line.len);
shown++; shown++;
} }
@@ -238,6 +242,47 @@ int cst_tool_read_file2(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
return tool_take_buf(r, &b, 0); return tool_take_buf(r, &b, 0);
} }
/* --- view_image ---------------------------------------------------------- */
/* Show an image to the model: the bytes are copied into the session's
* attachment store and travel as a multimodal content part of the result. */
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r) {
const char *path = tool_arg(c, "path");
if (!path || !path[0])
return tool_set_result(r, "error: missing path", 1);
const char *session = ctx->agent ? cst_agent_session_path(ctx->agent) : NULL;
if (!session || !session[0])
return tool_set_result(r, "error: no session to store the image in", 1);
char full[OS_PATH_MAX];
if (resolve(ctx, path, full, sizeof(full)) != 0)
return tool_set_resultf(r, 1,
"error: path outside workspace or missing parent: "
"%.200s",
path);
if (is_dir(full))
return tool_set_resultf(r, 1, "error: %.200s is a directory", path);
char err[256];
cst_attachment_t *a = cst_attach_import_file(session, full, err, sizeof(err));
if (!a)
return tool_set_resultf(r, 1, "error: cannot show %.200s: %s", path, err);
snprintf(a->origin, sizeof(a->origin), "view_image");
const char *d = tool_arg(c, "detail");
if (d && (strcmp(d, "low") == 0 || strcmp(d, "high") == 0))
snprintf(a->detail, sizeof(a->detail), "%s", d);
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
cst_buf_t out = {0};
cst_buf_printf(&out, "view_image %.200s: %s\n(shown to you as an image)\n",
path, desc);
int rc = tool_take_buf(r, &out, 0);
r->attach = a; /* one entry; freed as a block by cst_tool_result_free */
r->nattach = 1;
return rc;
}
/* --- edit_file ----------------------------------------------------------- */ /* --- edit_file ----------------------------------------------------------- */
static size_t count_occ(const char *hay, size_t hn, const char *needle, static size_t count_occ(const char *hay, size_t hn, const char *needle,
@@ -442,6 +487,14 @@ static int plan_edit(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
nwn = n2n; 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; int rc = 0;
if (cnt == 0) { if (cnt == 0) {
@@ -539,7 +592,6 @@ int cst_preview_edit_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
/* --- delete_file / move_file --------------------------------------------- */ /* --- delete_file / move_file --------------------------------------------- */
static int reject_symlink(const cst_tool_ctx_t *ctx, const char *path) { static int reject_symlink(const cst_tool_ctx_t *ctx, const char *path) {
#ifndef _WIN32
char root[OS_PATH_MAX], joined[OS_PATH_MAX]; char root[OS_PATH_MAX], joined[OS_PATH_MAX];
if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0) if (os_realpath(ctx->workspace_root, root, sizeof(root)) != 0)
return 1; return 1;
@@ -547,13 +599,9 @@ static int reject_symlink(const cst_tool_ctx_t *ctx, const char *path) {
snprintf(joined, sizeof(joined), "%s", path); snprintf(joined, sizeof(joined), "%s", path);
else if (os_path_join(joined, sizeof(joined), root, path) != 0) else if (os_path_join(joined, sizeof(joined), root, path) != 0)
return 1; return 1;
struct stat st; os_stat_t st;
if (lstat(joined, &st) == 0 && S_ISLNK(st.st_mode)) if (os_lstat(joined, &st) == 0 && st.is_link)
return 1; return 1;
#else
(void)ctx;
(void)path;
#endif
return 0; return 0;
} }
@@ -672,8 +720,6 @@ int cst_tool_move_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
/* --- directory walking (list_dir / glob / grep) -------------------------- */ /* --- directory walking (list_dir / glob / grep) -------------------------- */
#ifndef _WIN32
static const char *const SKIP_DIRS[] = { static const char *const SKIP_DIRS[] = {
".git", ".hg", ".svn", "node_modules", ".agent", "__pycache__", ".git", ".hg", ".svn", "node_modules", ".agent", "__pycache__",
".venv", ".cache", ".idea", ".vscode", ".mypy_cache", ".pytest_cache"}; ".venv", ".cache", ".idea", ".vscode", ".mypy_cache", ".pytest_cache"};
@@ -703,7 +749,7 @@ static void ignore_load(const char *root, ignore_t *ig) {
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
if (os_path_join(p, sizeof(p), root, ".gitignore") != 0) if (os_path_join(p, sizeof(p), root, ".gitignore") != 0)
return; return;
FILE *fp = fopen(p, "r"); FILE *fp = fopen(p, "rb");
if (!fp) if (!fp)
return; return;
char line[512]; char line[512];
@@ -749,7 +795,7 @@ static int ignored(const ignore_t *ig, const char *rel, const char *name,
const ign_rule_t *r = &ig->v[i]; const ign_rule_t *r = &ig->v[i];
if (r->dir_only && !isdir) if (r->dir_only && !isdir)
continue; 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 1;
} }
return 0; return 0;
@@ -758,7 +804,7 @@ static int ignored(const ignore_t *ig, const char *rel, const char *name,
typedef struct walker walker_t; typedef struct walker walker_t;
/* Return non-zero to stop the walk. */ /* Return non-zero to stop the walk. */
typedef int (*walk_fn)(walker_t *w, const char *rel, const char *full, 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 { struct walker {
const char *root; /* workspace realpath */ const char *root; /* workspace realpath */
@@ -781,15 +827,13 @@ static void walk_dir(walker_t *w, const char *rel, int depth) {
snprintf(dirfull, sizeof(dirfull), "%s/%s", w->root, rel); snprintf(dirfull, sizeof(dirfull), "%s/%s", w->root, rel);
else else
snprintf(dirfull, sizeof(dirfull), "%s", w->root); snprintf(dirfull, sizeof(dirfull), "%s", w->root);
DIR *d = opendir(dirfull); os_dir_t *d = os_dir_open(dirfull);
if (!d) if (!d)
return; return;
char **names = NULL; char **names = NULL;
int n = 0, cap = 0; int n = 0, cap = 0;
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0)
continue;
if (n == cap) { if (n == cap) {
cap = cap ? cap * 2 : 32; cap = cap ? cap * 2 : 32;
char **nv = realloc(names, (size_t)cap * sizeof(char *)); char **nv = realloc(names, (size_t)cap * sizeof(char *));
@@ -797,11 +841,11 @@ static void walk_dir(walker_t *w, const char *rel, int depth) {
break; break;
names = nv; names = nv;
} }
names[n] = strdup(de->d_name); names[n] = strdup(de->name);
if (names[n]) if (names[n])
n++; n++;
} }
closedir(d); os_dir_close(d);
qsort(names, (size_t)n, sizeof(char *), name_cmp); qsort(names, (size_t)n, sizeof(char *), name_cmp);
for (int i = 0; i < n && !w->stop; i++) { for (int i = 0; i < n && !w->stop; i++) {
char crel[OS_PATH_MAX], cfull[OS_PATH_MAX]; char crel[OS_PATH_MAX], cfull[OS_PATH_MAX];
@@ -810,10 +854,10 @@ static void walk_dir(walker_t *w, const char *rel, int depth) {
int k2 = snprintf(cfull, sizeof(cfull), "%s/%s", w->root, crel); int k2 = snprintf(cfull, sizeof(cfull), "%s/%s", w->root, crel);
if (k1 >= (int)sizeof(crel) || k2 >= (int)sizeof(cfull)) if (k1 >= (int)sizeof(crel) || k2 >= (int)sizeof(cfull))
continue; /* path too long */ continue; /* path too long */
struct stat st; os_stat_t st;
if (lstat(cfull, &st) != 0) if (os_lstat(cfull, &st) != 0)
continue; continue;
int isdir = S_ISDIR(st.st_mode); int isdir = st.is_dir;
if (ignored(&w->ig, crel, names[i], isdir)) if (ignored(&w->ig, crel, names[i], isdir))
continue; continue;
if (w->fn(w, crel, cfull, &st, depth)) if (w->fn(w, crel, cfull, &st, depth))
@@ -861,21 +905,18 @@ typedef struct {
/* Entries directly inside a directory (for the depth boundary). */ /* Entries directly inside a directory (for the depth boundary). */
static int dir_entries(const char *full) { static int dir_entries(const char *full) {
DIR *d = opendir(full); os_dir_t *d = os_dir_open(full);
if (!d) if (!d)
return -1; return -1;
int n = 0; int n = 0;
struct dirent *de; while (os_dir_next(d))
while ((de = readdir(d)) != NULL) {
if (strcmp(de->d_name, ".") != 0 && strcmp(de->d_name, "..") != 0)
n++; n++;
} os_dir_close(d);
closedir(d);
return n; return n;
} }
static int list_cb(walker_t *w, const char *rel, const char *full, 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; list_ud_t *u = w->ud;
if (u->count >= LIST_MAX) { if (u->count >= LIST_MAX) {
if (++u->more >= LIST_COUNT_MAX) { if (++u->more >= LIST_COUNT_MAX) {
@@ -888,7 +929,7 @@ static int list_cb(walker_t *w, const char *rel, const char *full,
name = name ? name + 1 : rel; name = name ? name + 1 : rel;
for (int i = 0; i < depth; i++) for (int i = 0; i < depth; i++)
cst_buf_puts(&u->b, " "); cst_buf_puts(&u->b, " ");
if (S_ISDIR(st->st_mode)) { if (st->is_dir) {
int k = -1; int k = -1;
if (skip_dir_name(name)) if (skip_dir_name(name))
cst_buf_printf(&u->b, "%s/ (skipped)\n", name); cst_buf_printf(&u->b, "%s/ (skipped)\n", name);
@@ -897,10 +938,10 @@ static int list_cb(walker_t *w, const char *rel, const char *full,
k == 1 ? "y" : "ies"); k == 1 ? "y" : "ies");
else else
cst_buf_printf(&u->b, "%s/%s\n", name, k == 0 ? " (empty)" : ""); 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); cst_buf_printf(&u->b, "%s@\n", name);
else 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++; u->count++;
return 0; return 0;
} }
@@ -974,7 +1015,7 @@ static int seg_match(const char *pat, const char *path) {
pseg[pl] = 0; pseg[pl] = 0;
memcpy(sseg, path, sl); memcpy(sseg, path, sl);
sseg[sl] = 0; sseg[sl] = 0;
if (fnmatch(pseg, sseg, FNM_PERIOD) != 0) if (cst_fnmatch(pseg, sseg, CST_FNM_PERIOD) != 0)
return 0; return 0;
if (!ps) if (!ps)
return !ss; return !ss;
@@ -994,11 +1035,11 @@ typedef struct {
} glob_ud_t; } glob_ud_t;
static int glob_cb(walker_t *w, const char *rel, const char *full, 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)full;
(void)depth; (void)depth;
glob_ud_t *u = w->ud; glob_ud_t *u = w->ud;
if (!S_ISREG(st->st_mode)) if (!st->is_reg)
return 0; return 0;
const char *sub = rel; const char *sub = rel;
size_t bl = strlen(u->base); size_t bl = strlen(u->base);
@@ -1009,7 +1050,7 @@ static int glob_cb(walker_t *w, const char *rel, const char *full,
} }
const char *name = strrchr(sub, '/'); const char *name = strrchr(sub, '/');
name = name ? name + 1 : 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); : seg_match(u->pattern, sub);
if (!ok) if (!ok)
return 0; return 0;
@@ -1025,7 +1066,7 @@ static int glob_cb(walker_t *w, const char *rel, const char *full,
return 1; return 1;
} }
u->hits[u->n] = strdup(rel); 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]) if (u->hits[u->n])
u->n++; u->n++;
if (u->n >= GLOB_MAX * 20) { if (u->n >= GLOB_MAX * 20) {
@@ -1109,11 +1150,11 @@ typedef struct {
} files_ud_t; } files_ud_t;
static int files_cb(walker_t *w, const char *rel, const char *full, 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)full;
(void)depth; (void)depth;
files_ud_t *u = w->ud; files_ud_t *u = w->ud;
if (!S_ISREG(st->st_mode)) if (!st->is_reg)
return 0; return 0;
if (u->n >= u->max) if (u->n >= u->max)
return 1; return 1;
@@ -1275,16 +1316,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, 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; (void)depth;
grep_ud_t *u = w->ud; 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; return 0;
if (u->glob) { if (u->glob) {
const char *name = strrchr(rel, '/'); const char *name = strrchr(rel, '/');
name = name ? name + 1 : rel; name = name ? name + 1 : rel;
int ok = strchr(u->glob, '/') ? fnmatch(u->glob, rel, 0) == 0 int ok = strchr(u->glob, '/') ? cst_fnmatch(u->glob, rel, 0) == 0
: fnmatch(u->glob, name, 0) == 0; : cst_fnmatch(u->glob, name, 0) == 0;
if (!ok) if (!ok)
return 0; return 0;
} }
@@ -1391,32 +1432,4 @@ int cst_tool_grep(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
return tool_take_buf(r, &out, 0); 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_agent.h"
#include "cst_util.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.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; return -1;
} }
int cst_patch_apply_buffer(const char *orig, size_t olen, const char *hunks, static int patch_core(const char *orig, size_t olen, const char *hunks,
size_t hlen, char **out, size_t *out_len, size_t hlen, char **out, size_t *out_len, int *nhunks,
int *nhunks, char *err, size_t err_sz) { char *err, size_t err_sz) {
lines_t file = {0}, hl = {0}; lines_t file = {0}, hl = {0};
lines_t old_seq = {0}, new_seq = {0}; lines_t old_seq = {0}, new_seq = {0};
int rc = -1; int rc = -1;
@@ -315,3 +316,47 @@ done:
free(new_seq.v); free(new_seq.v);
return rc; 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;
}
+42 -11
View File
@@ -6,9 +6,7 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#ifndef _WIN32
#include <pthread.h> #include <pthread.h>
#endif
#include "cJSON.h" #include "cJSON.h"
#include "cst_tools.h" #include "cst_tools.h"
@@ -58,6 +56,15 @@ static const cst_tool_def_t TOOLS[] = {
{"limit", I, 0, "maximum number of lines"}, {"limit", I, 0, "maximum number of lines"},
{"line_numbers", B, 0, "`N|` line prefixes (default true)"}}, {"line_numbers", B, 0, "`N|` line prefixes (default true)"}},
CST_TP_READ, 1, run_read_file, NULL, NO_DYN}, CST_TP_READ, 1, run_read_file, NULL, NO_DYN},
{"view_image",
"Show a local image to yourself: PNG, JPEG, GIF, WebP or BMP, checked by "
"content. The file is copied into the session store and comes back as an "
"image you can actually see (not as text). Use it for screenshots, "
"diagrams and UI checks; path resolves like read_file's.",
NULL,
{{"path", S, 1, "image file path relative to the workspace"},
{"detail", S, 0, "low or high (provider hint; default auto)"}},
CST_TP_READ, 0, cst_tool_view_image, NULL, NO_DYN},
{"run_shell", {"run_shell",
"Run a non-interactive shell command in the workspace root for builds, " "Run a non-interactive shell command in the workspace root for builds, "
"tests, git and other CLIs (stdin is empty; long runs are killed). " "tests, git and other CLIs (stdin is empty; long runs are killed). "
@@ -182,14 +189,9 @@ const char *cst_tool_profile_name(int p) {
#define DYN_MAX 256 #define DYN_MAX 256
static cst_tool_def_t *g_dyn[DYN_MAX]; static cst_tool_def_t *g_dyn[DYN_MAX];
static atomic_int g_ndyn; static atomic_int g_ndyn;
#ifndef _WIN32
static pthread_mutex_t g_dyn_mu = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_dyn_mu = PTHREAD_MUTEX_INITIALIZER;
#define DYN_LOCK() pthread_mutex_lock(&g_dyn_mu) #define DYN_LOCK() pthread_mutex_lock(&g_dyn_mu)
#define DYN_UNLOCK() pthread_mutex_unlock(&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); } int cst_tool_count(void) { return NTOOLS + atomic_load(&g_ndyn); }
@@ -340,8 +342,8 @@ int cst_tool_active(const cst_agent_config_t *cfg, const cst_tool_def_t *t) {
return 1; return 1;
if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal) if (cfg->tool_profile == CST_TOOLS_MINIMAL && !t->minimal)
return 0; return 0;
if ((cfg->plan_mode || cfg->read_only) && /* plan_mode is enforced when a call runs, so tools[] stays the same */
(t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL)) if (cfg->read_only && (t->perm == CST_TP_WRITE || t->perm == CST_TP_SHELL))
return 0; return 0;
if (cfg->depth > 0 && strcmp(t->name, "task") == 0) if (cfg->depth > 0 && strcmp(t->name, "task") == 0)
return 0; return 0;
@@ -420,16 +422,44 @@ static int is_minimal(const cst_agent_config_t *cfg) {
return cfg && cfg->tool_profile == CST_TOOLS_MINIMAL; return cfg && cfg->tool_profile == CST_TOOLS_MINIMAL;
} }
static int is_external(const cst_tool_def_t *t) {
return t->owner && strncmp(t->owner, "builtin:", 8) != 0;
}
static int by_name(const void *x, const void *y) {
const cst_tool_def_t *a = *(const cst_tool_def_t *const *)x;
const cst_tool_def_t *b = *(const cst_tool_def_t *const *)y;
return strcmp(a->name, b->name);
}
void *cst_tools_schema_cfg(const cst_agent_config_t *cfg) { void *cst_tools_schema_cfg(const cst_agent_config_t *cfg) {
cJSON *arr = cJSON_CreateArray(); cJSON *arr = cJSON_CreateArray();
if (!arr) if (!arr)
return NULL; return NULL;
int legacy = is_minimal(cfg); int legacy = is_minimal(cfg);
int n = cst_tool_count(); int n = cst_tool_count();
/* Built-ins in registry order, then plugin/MCP tools by name: servers
* finish starting in any order, and tools[] is part of the cached prefix. */
const cst_tool_def_t **order = calloc((size_t)(n ? n : 1), sizeof(*order));
if (!order) {
cJSON_Delete(arr);
return NULL;
}
int nb = 0, no = 0;
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
const cst_tool_def_t *t = cst_tool_at(i); const cst_tool_def_t *t = cst_tool_at(i);
if (!t || !cst_tool_active(cfg, t)) if (t && cst_tool_active(cfg, t) && !is_external(t))
continue; order[no++] = t;
}
nb = no;
for (int i = 0; i < n; i++) {
const cst_tool_def_t *t = cst_tool_at(i);
if (t && cst_tool_active(cfg, t) && is_external(t))
order[no++] = t;
}
qsort(order + nb, (size_t)(no - nb), sizeof(*order), by_name);
for (int i = 0; i < no; i++) {
const cst_tool_def_t *t = order[i];
cJSON *tool = cJSON_CreateObject(); cJSON *tool = cJSON_CreateObject();
cJSON_AddStringToObject(tool, "type", "function"); cJSON_AddStringToObject(tool, "type", "function");
cJSON *fn = cJSON_AddObjectToObject(tool, "function"); cJSON *fn = cJSON_AddObjectToObject(tool, "function");
@@ -464,6 +494,7 @@ void *cst_tools_schema_cfg(const cst_agent_config_t *cfg) {
} }
cJSON_AddItemToArray(arr, tool); cJSON_AddItemToArray(arr, tool);
} }
free(order);
return arr; return arr;
} }
+35
View File
@@ -10,6 +10,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h"
#include "cst_sandbox.h" #include "cst_sandbox.h"
#include "cst_util.h" #include "cst_util.h"
#include "os_compat.h" #include "os_compat.h"
@@ -26,6 +27,9 @@ void cst_tool_result_free(cst_tool_result_t *r) {
free(r->out); free(r->out);
r->out = NULL; r->out = NULL;
r->out_len = 0; r->out_len = 0;
cst_attachments_free(r->attach, r->nattach);
r->attach = NULL;
r->nattach = 0;
} }
void cst_tool_call_clear(ctl_tool_call_t *c) { void cst_tool_call_clear(ctl_tool_call_t *c) {
@@ -238,6 +242,22 @@ static int write_content(const ctl_tool_call_t *call, const char **content,
return *content ? 0 : -1; 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, int cst_preview_write_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
char **out) { char **out) {
const char *path = tool_arg(c, "path"); const char *path = tool_arg(c, "path");
@@ -252,11 +272,18 @@ int cst_preview_write_file(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
char *oldc = NULL; char *oldc = NULL;
if (cst_sandbox_resolve(ctx->workspace_root, path, full, sizeof(full)) == 0) if (cst_sandbox_resolve(ctx->workspace_root, path, full, sizeof(full)) == 0)
oldc = tool_slurp(full, &olen, 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_t b = {0};
cst_buf_printf(&b, "%s %s (%zu bytes)\n", oldc ? "overwrite" : "create", cst_buf_printf(&b, "%s %s (%zu bytes)\n", oldc ? "overwrite" : "create",
path, n); path, n);
tool_append_diff(&b, path, oldc, olen, content, n); tool_append_diff(&b, path, oldc, olen, content, n);
free(oldc); free(oldc);
free(styled);
*out = cst_buf_steal(&b); *out = cst_buf_steal(&b);
return 0; return 0;
} }
@@ -276,14 +303,22 @@ int cst_tool_write_file(const char *workspace_root, const ctl_tool_call_t *call,
size_t olen = 0; size_t olen = 0;
char *oldc = tool_slurp(full, &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) { if (tool_write_whole(full, content, n) != 0) {
free(oldc); free(oldc);
free(styled);
return tool_set_resultf(r, 1, "error: cannot write file: %.200s", path); return tool_set_resultf(r, 1, "error: cannot write file: %.200s", path);
} }
cst_buf_t b = {0}; cst_buf_t b = {0};
cst_buf_printf(&b, "wrote %s (%zu bytes)\n", path, n); cst_buf_printf(&b, "wrote %s (%zu bytes)\n", path, n);
tool_append_diff(&b, path, oldc, olen, content, n); tool_append_diff(&b, path, oldc, olen, content, n);
free(oldc); free(oldc);
free(styled);
return tool_take_buf(r, &b, 0); return tool_take_buf(r, &b, 0);
} }
+2
View File
@@ -48,6 +48,8 @@ int cst_tool_ask_user(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r); cst_tool_result_t *r);
int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c, int cst_tool_web_fetch(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r); cst_tool_result_t *r);
int cst_tool_view_image(const cst_tool_ctx_t *ctx, const ctl_tool_call_t *c,
cst_tool_result_t *r);
int cst_preview_write_file(const cst_tool_ctx_t *ctx, int cst_preview_write_file(const cst_tool_ctx_t *ctx,
const ctl_tool_call_t *c, char **out); const ctl_tool_call_t *c, char **out);
+578 -67
View File
File diff suppressed because it is too large Load Diff
+24 -42
View File
@@ -9,10 +9,7 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#ifndef _WIN32
#include <dirent.h>
#include <unistd.h> #include <unistd.h>
#endif
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_undo.h" #include "cst_undo.h"
@@ -75,7 +72,7 @@ static int put(const char *p, const char *d, size_t n) {
} }
static int manifest_has(const char *mf, const char *rel) { static int manifest_has(const char *mf, const char *rel) {
FILE *fp = fopen(mf, "r"); FILE *fp = fopen(mf, "rb");
if (!fp) if (!fp)
return 0; return 0;
char line[1400]; char line[1400];
@@ -120,7 +117,7 @@ int cst_undo_snapshot(const char *ws, const char *sid, const char *tag,
return 0; return 0;
int idx = 0; int idx = 0;
{ {
FILE *fp = fopen(mf, "r"); FILE *fp = fopen(mf, "rb");
if (fp) { if (fp) {
int ch; int ch;
while ((ch = fgetc(fp)) != EOF) while ((ch = fgetc(fp)) != EOF)
@@ -144,7 +141,7 @@ int cst_undo_snapshot(const char *ws, const char *sid, const char *tag,
free(data); free(data);
} }
} }
FILE *fp = fopen(mf, "a"); FILE *fp = fopen(mf, "ab");
if (!fp) if (!fp)
return -1; return -1;
fprintf(fp, "%d\t%s\t%s\n", idx, state, rel); fprintf(fp, "%d\t%s\t%s\n", idx, state, rel);
@@ -152,35 +149,34 @@ int cst_undo_snapshot(const char *ws, const char *sid, const char *tag,
return 0; return 0;
} }
#ifndef _WIN32
static int latest_tag(const char *root, char *tag, size_t n) { static int latest_tag(const char *root, char *tag, size_t n) {
DIR *d = opendir(root); os_dir_t *d = os_dir_open(root);
if (!d) if (!d)
return -1; return -1;
struct dirent *de; const os_dirent_t *de;
tag[0] = 0; tag[0] = 0;
while ((de = readdir(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
if (de->d_name[0] == '.') if (de->name[0] == '.')
continue; continue;
if (strcmp(de->d_name, tag) > 0) if (strcmp(de->name, tag) > 0)
snprintf(tag, n, "%s", de->d_name); snprintf(tag, n, "%s", de->name);
} }
closedir(d); os_dir_close(d);
return tag[0] ? 0 : -1; return tag[0] ? 0 : -1;
} }
static void rm_dir(const char *dir) { static void rm_dir(const char *dir) {
DIR *d = opendir(dir); os_dir_t *d = os_dir_open(dir);
if (d) { if (d) {
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL) { while ((de = os_dir_next(d)) != NULL) {
if (strcmp(de->d_name, ".") == 0 || strcmp(de->d_name, "..") == 0) if (strcmp(de->name, ".") == 0 || strcmp(de->name, "..") == 0)
continue; continue;
char p[OS_PATH_MAX]; char p[OS_PATH_MAX];
if (snprintf(p, sizeof(p), "%s/%s", dir, de->d_name) < (int)sizeof(p)) if (snprintf(p, sizeof(p), "%s/%s", dir, de->name) < (int)sizeof(p))
unlink(p); unlink(p);
} }
closedir(d); os_dir_close(d);
} }
rmdir(dir); rmdir(dir);
} }
@@ -189,14 +185,14 @@ int cst_undo_count(const char *ws, const char *sid) {
char root[OS_PATH_MAX]; char root[OS_PATH_MAX];
if (undo_root(ws, sid, root, sizeof(root)) != 0) if (undo_root(ws, sid, root, sizeof(root)) != 0)
return 0; return 0;
DIR *d = opendir(root); os_dir_t *d = os_dir_open(root);
if (!d) if (!d)
return 0; return 0;
int n = 0; int n = 0;
struct dirent *de; const os_dirent_t *de;
while ((de = readdir(d)) != NULL) while ((de = os_dir_next(d)) != NULL)
n += de->d_name[0] != '.'; n += de->name[0] != '.';
closedir(d); os_dir_close(d);
return n; return n;
} }
@@ -214,7 +210,7 @@ int cst_undo_restore_last(const char *ws, const char *sid, char *report,
snprintf(report, report_sz, "undo path too long"); snprintf(report, report_sz, "undo path too long");
return -1; return -1;
} }
FILE *fp = fopen(mf, "r"); FILE *fp = fopen(mf, "rb");
cst_buf_t rep = {0}; cst_buf_t rep = {0};
int restored = 0, failed = 0; int restored = 0, failed = 0;
if (fp) { if (fp) {
@@ -231,10 +227,10 @@ int cst_undo_restore_last(const char *ws, const char *sid, char *report,
*t3++ = 0; *t3++ = 0;
const char *rel = t3, *state = t2; const char *rel = t3, *state = t2;
char full[OS_PATH_MAX]; char full[OS_PATH_MAX];
struct stat lst; os_stat_t lst;
if (!rel_ok(rel) || os_path_join(full, sizeof(full), ws, rel) != 0) if (!rel_ok(rel) || os_path_join(full, sizeof(full), ws, rel) != 0)
continue; continue;
if (lstat(full, &lst) == 0 && S_ISLNK(lst.st_mode)) { if (os_lstat(full, &lst) == 0 && lst.is_link) {
cst_buf_printf(&rep, "skipped %s (now a symlink)\n", rel); cst_buf_printf(&rep, "skipped %s (now a symlink)\n", rel);
failed++; failed++;
continue; continue;
@@ -280,17 +276,3 @@ int cst_undo_restore_last(const char *ws, const char *sid, char *report,
cst_buf_free(&rep); cst_buf_free(&rep);
return failed && !restored ? -1 : 0; return failed && !restored ? -1 : 0;
} }
#else
int cst_undo_count(const char *ws, const char *sid) {
(void)ws;
(void)sid;
return 0;
}
int cst_undo_restore_last(const char *ws, const char *sid, char *report,
size_t report_sz) {
(void)ws;
(void)sid;
snprintf(report, report_sz, "undo is not available on Windows");
return -1;
}
#endif
+209
View File
@@ -4,6 +4,8 @@
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <wchar.h>
#include <wctype.h>
static int buf_reserve(cst_buf_t *b, size_t extra) { static int buf_reserve(cst_buf_t *b, size_t extra) {
if (b->len + extra + 1 <= b->cap) if (b->len + extra + 1 <= b->cap)
@@ -153,6 +155,213 @@ char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len) {
return out; return out;
} }
int cst_utf8_valid(const char *s, size_t n) {
const unsigned char *p = (const unsigned char *)s;
for (size_t i = 0; i < n;) {
size_t k = utf8_seq_ok(p + i, n - i);
if (k == 0)
return 0;
i += k;
}
return 1;
}
size_t cst_bom_len(const char *s, size_t n) {
return n >= 3 && memcmp(s, "\xEF\xBB\xBF", 3) == 0 ? 3 : 0;
}
void cst_strip_bom(char *s, size_t *len) {
if (!s)
return;
size_t n = len ? *len : strlen(s);
if (!cst_bom_len(s, n))
return;
memmove(s, s + 3, n - 3 + 1);
if (len)
*len = n - 3;
}
size_t cst_crlf_to_lf(char *s, size_t n) {
size_t o = 0;
for (size_t i = 0; i < n; i++)
if (!(s[i] == '\r' && i + 1 < n && s[i + 1] == '\n'))
s[o++] = s[i];
if (o < n)
s[o] = 0;
return o;
}
int cst_text_is_crlf(const char *s, size_t n) {
int crlf = 0;
for (size_t i = 0; s && i < n; i++) {
if (s[i] != '\n')
continue;
if (i == 0 || s[i - 1] != '\r')
return 0;
crlf = 1;
}
return crlf;
}
char *cst_text_restyle(const char *s, size_t n, int crlf, int bom,
size_t *out_len) {
size_t skip = cst_bom_len(s, n), extra = bom ? 3 : 0;
for (size_t i = skip; crlf && i < n; i++)
extra += s[i] == '\n' && (i == skip || s[i - 1] != '\r');
char *o = malloc(n - skip + extra + 1);
if (!o)
return NULL;
size_t k = 0;
if (bom) {
memcpy(o, "\xEF\xBB\xBF", 3);
k = 3;
}
for (size_t i = skip; i < n; i++) {
if (crlf && s[i] == '\n' && (i == skip || s[i - 1] != '\r'))
o[k++] = '\r';
o[k++] = s[i];
}
o[k] = 0;
if (out_len)
*out_len = k;
return o;
}
/* Next code point of a UTF-8 string; a stray byte stands for itself. */
static int utf8_next(const char **ps) {
const unsigned char *p = (const unsigned char *)*ps;
size_t left = 0;
while (left < 4 && p[left])
left++;
size_t k = utf8_seq_ok(p, left);
if (k <= 1) {
*ps += 1;
return p[0];
}
int c = p[0] & (0x7F >> k);
for (size_t i = 1; i < k; i++)
c = (c << 6) | (p[i] & 0x3F);
*ps += k;
return c;
}
static int fold_cp(int c) {
if (c >= 'A' && c <= 'Z')
return c + 32;
if (c > 0x7F && c <= 0xFFFF)
return (int)towlower((wint_t)c);
return c;
}
/* p points just past '['. 1/0 = (no) match with *pp moved past ']';
* -1 = no closing ']' (the '[' is then a literal). */
static int fnm_bracket(const char **pp, int c, int fold) {
const char *p = *pp;
int neg = 0, hit = 0;
if (*p == '!' || *p == '^') {
neg = 1;
p++;
}
int fc = fold ? fold_cp(c) : c;
for (int first = 1; *p && (*p != ']' || first); first = 0) {
if (p[0] == '[' && p[1] == ':') {
const char *e = strstr(p + 2, ":]");
if (e) {
char name[16];
size_t nl = (size_t)(e - (p + 2));
if (nl < sizeof(name)) {
memcpy(name, p + 2, nl);
name[nl] = 0;
wctype_t t = wctype(name);
if (t && c <= 0xFFFF && iswctype((wint_t)c, t))
hit = 1;
}
p = e + 2;
continue;
}
}
if (*p == '\\' && p[1])
p++;
int lo = utf8_next(&p), hi = lo;
if (p[0] == '-' && p[1] && p[1] != ']') {
p++;
if (*p == '\\' && p[1])
p++;
hi = utf8_next(&p);
}
if ((c >= lo && c <= hi) ||
(fold && fc >= fold_cp(lo) && fc <= fold_cp(hi)))
hit = 1;
}
if (*p != ']')
return -1;
*pp = p + 1;
return hit != neg;
}
int cst_fnmatch(const char *pat, const char *str, int flags) {
#ifdef _WIN32
flags |= CST_FNM_CASEFOLD;
#endif
int fold = (flags & CST_FNM_CASEFOLD) != 0;
const char *p = pat, *s = str;
const char *star_p = NULL, *star_s = NULL;
if ((flags & CST_FNM_PERIOD) && s[0] == '.' && p[0] != '.' &&
!(p[0] == '\\' && p[1] == '.'))
return 1;
while (*s) {
if (*p == '*') {
while (*p == '*')
p++;
if (!*p)
return 0;
star_p = p;
star_s = s;
continue;
}
const char *sn = s;
int c = utf8_next(&sn);
if (*p == '?') {
p++;
s = sn;
continue;
}
if (*p == '[') {
const char *q = p + 1;
int r = fnm_bracket(&q, c, fold);
if (r == 1) {
p = q;
s = sn;
continue;
}
if (r == 0)
goto backtrack;
}
{
const char *q = p;
if (*q == '\\' && q[1])
q++;
if (*q) {
int pc = utf8_next(&q);
if (pc == c || (fold && fold_cp(pc) == fold_cp(c))) {
p = q;
s = sn;
continue;
}
}
}
backtrack:
if (!star_p)
return 1;
utf8_next(&star_s);
s = star_s;
p = star_p;
}
while (*p == '*')
p++;
return *p ? 1 : 0;
}
uint64_t cst_fnv1a64(uint64_t h, const void *data, size_t n) { uint64_t cst_fnv1a64(uint64_t h, const void *data, size_t n) {
const unsigned char *p = data; const unsigned char *p = data;
for (size_t i = 0; i < n; i++) { for (size_t i = 0; i < n; i++) {
+208
View File
@@ -0,0 +1,208 @@
/* Portable helpers for the test programs: scratch dirs, env, recursive
* delete, symlinks and (with CST_TEST_SOCKETS) a loopback socket layer. */
#ifndef CST_TEST_UTIL_H
#define CST_TEST_UTIL_H
#include "os_compat.h"
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#ifdef CST_TEST_SOCKETS
#include <winsock2.h> /* before windows.h */
#include <ws2tcpip.h>
#endif
#include <direct.h>
#include <io.h>
#include <windows.h>
#else
#include <sys/stat.h>
#include <unistd.h>
#endif
#if defined(__GNUC__)
#define CST_TEST_UNUSED __attribute__((unused))
#else
#define CST_TEST_UNUSED
#endif
/* mkdir(path, mode) that also builds on Windows. */
#define test_mkdir(p, mode) os_mkdir((p), (mode))
/* Fresh empty directory "<tmp>/<tag>-<pid>-<n>"; 0 on success. */
static CST_TEST_UNUSED int test_tmpdir(char *out, size_t n, const char *tag) {
static int seq;
char base[1024];
if (os_temp_dir(base, sizeof(base)) != 0)
snprintf(base, sizeof(base), ".");
for (int i = 0; i < 100; i++) {
int k = snprintf(out, n, "%s/%s-%d-%d", base, tag, os_getpid(), seq++);
if (k < 0 || (size_t)k >= n)
return -1;
#ifdef _WIN32
int rc = _mkdir(out);
#else
int rc = mkdir(out, 0755);
#endif
if (rc == 0)
return 0;
if (errno != EEXIST)
return -1;
}
return -1;
}
/* rm -rf without a shell; links are removed, never followed. */
static CST_TEST_UNUSED int test_rmrf(const char *path) {
os_stat_t st;
if (os_lstat(path, &st) != 0)
return 0;
if (st.is_link) {
#ifdef _WIN32
if (RemoveDirectoryA(path) || DeleteFileA(path))
return 0;
return -1;
#else
return unlink(path);
#endif
}
if (!st.is_dir) {
#ifdef _WIN32
SetFileAttributesA(path, FILE_ATTRIBUTE_NORMAL);
#endif
return remove(path);
}
os_dir_t *d = os_dir_open(path);
if (d) {
const os_dirent_t *e;
while ((e = os_dir_next(d))) {
if (!strcmp(e->name, ".") || !strcmp(e->name, ".."))
continue;
size_t L = strlen(path) + strlen(e->name) + 2;
char *c = malloc(L);
if (!c)
break;
snprintf(c, L, "%s/%s", path, e->name);
test_rmrf(c);
free(c);
}
os_dir_close(d);
}
#ifdef _WIN32
return _rmdir(path);
#else
return rmdir(path);
#endif
}
/* 0 on success; -1 when the platform or account cannot make links (Windows
* without Developer Mode or admin rights): callers skip those checks. */
static CST_TEST_UNUSED int test_symlink(const char *target, const char *link) {
#ifdef _WIN32
wchar_t *wt = os_utf8_to_wide(target), *wl = os_utf8_to_wide(link);
int ok = 0;
if (wt && wl) {
for (wchar_t *p = wt; *p; p++)
if (*p == L'/')
*p = L'\\';
DWORD attr = GetFileAttributesW(wt);
if (attr == INVALID_FILE_ATTRIBUTES) {
/* relative targets resolve against the link's directory */
wchar_t *slash = wcsrchr(wl, L'/');
if (!slash)
slash = wcsrchr(wl, L'\\');
if (slash && wt[0] != L'\\' && !(wt[0] && wt[1] == L':')) {
size_t dl = (size_t)(slash - wl) + 1, tl = wcslen(wt);
wchar_t *full = malloc((dl + tl + 1) * sizeof(wchar_t));
if (full) {
memcpy(full, wl, dl * sizeof(wchar_t));
memcpy(full + dl, wt, (tl + 1) * sizeof(wchar_t));
attr = GetFileAttributesW(full);
free(full);
}
}
}
DWORD flags = 0x2; /* SYMBOLIC_LINK_FLAG_ALLOW_UNPRIVILEGED_CREATE */
if (attr != INVALID_FILE_ATTRIBUTES && (attr & FILE_ATTRIBUTE_DIRECTORY))
flags |= SYMBOLIC_LINK_FLAG_DIRECTORY;
ok = CreateSymbolicLinkW(wl, wt, flags) != 0;
}
free(wt);
free(wl);
return ok ? 0 : -1;
#else
return symlink(target, link);
#endif
}
/* rand_r replacement (same sequence everywhere). */
static CST_TEST_UNUSED int test_rand(unsigned *seed) {
*seed = *seed * 1103515245u + 12345u;
return (int)((*seed >> 16) & 0x7fff);
}
/* getline replacement: -1 at EOF. */
static CST_TEST_UNUSED long test_getline(char **line, size_t *cap, FILE *fp) {
if (!*line || *cap == 0) {
*cap = 256;
*line = malloc(*cap);
if (!*line)
return -1;
}
size_t n = 0;
int c;
while ((c = fgetc(fp)) != EOF) {
if (n + 2 > *cap) {
size_t nc = *cap * 2;
char *nl = realloc(*line, nc);
if (!nl)
return -1;
*line = nl;
*cap = nc;
}
(*line)[n++] = (char)c;
if (c == '\n')
break;
}
if (n == 0)
return -1;
(*line)[n] = 0;
return (long)n;
}
static CST_TEST_UNUSED void test_sleep_ms(int ms) {
#ifdef _WIN32
Sleep((DWORD)ms);
#else
usleep((useconds_t)ms * 1000);
#endif
}
#ifdef CST_TEST_SOCKETS
#ifdef _WIN32
typedef SOCKET test_sock_t;
#define TEST_BAD_SOCK INVALID_SOCKET
#define test_sock_close closesocket
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
static CST_TEST_UNUSED void test_sock_init(void) {
WSADATA w;
WSAStartup(MAKEWORD(2, 2), &w);
}
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <signal.h>
#include <sys/socket.h>
typedef int test_sock_t;
#define TEST_BAD_SOCK (-1)
#define test_sock_close close
static CST_TEST_UNUSED void test_sock_init(void) { signal(SIGPIPE, SIG_IGN); }
#endif
#endif /* CST_TEST_SOCKETS */
#endif
+6 -5
View File
@@ -7,6 +7,7 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
static int nfail; static int nfail;
@@ -180,12 +181,12 @@ static void test_tool_multi_file(void) {
int main(void) { int main(void) {
test_buffer_basic(); test_buffer_basic();
test_buffer_edge(); test_buffer_edge();
snprintf(g_ws, sizeof(g_ws), "/tmp/cst-patch-test-%d", (int)getpid()); if (test_tmpdir(g_ws, sizeof(g_ws), "cst-patch-test") != 0) {
mkdir(g_ws, 0755); perror("tmpdir");
return 1;
}
test_tool_multi_file(); test_tool_multi_file();
char cmd[300]; if (test_rmrf(g_ws) != 0)
snprintf(cmd, sizeof(cmd), "rm -rf %s /tmp/escape.c", g_ws);
if (system(cmd) != 0)
fprintf(stderr, "warning: cleanup failed\n"); fprintf(stderr, "warning: cleanup failed\n");
if (nfail) { if (nfail) {
fprintf(stderr, "test_apply_patch: %d failed\n", nfail); fprintf(stderr, "test_apply_patch: %d failed\n", nfail);
+543
View File
@@ -0,0 +1,543 @@
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_attach.h"
#include "cst_agent.h"
#include "cst_clipboard.h"
#include "cst_test_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.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 void put_be32(unsigned char *p, unsigned v) {
p[0] = (unsigned char)(v >> 24);
p[1] = (unsigned char)(v >> 16);
p[2] = (unsigned char)(v >> 8);
p[3] = (unsigned char)v;
}
static void put_le16(unsigned char *p, unsigned v) {
p[0] = (unsigned char)(v & 0xFF);
p[1] = (unsigned char)((v >> 8) & 0xFF);
}
/* 8 signature + 4 length + "IHDR" + w + h (what cst_attach_dimensions reads). */
static size_t png_hdr(unsigned char *b, unsigned w, unsigned h) {
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A, '\n'};
memcpy(b, sig, 8);
b[8] = b[9] = b[10] = 0;
b[11] = 13;
memcpy(b + 12, "IHDR", 4);
put_be32(b + 16, w);
put_be32(b + 20, h);
return 32;
}
static size_t gif_hdr(unsigned char *b, unsigned w, unsigned h) {
memcpy(b, "GIF89a", 6);
put_le16(b + 6, w);
put_le16(b + 8, h);
return 16;
}
static void put_le32(unsigned char *p, unsigned v) {
p[0] = (unsigned char)(v & 0xFF);
p[1] = (unsigned char)((v >> 8) & 0xFF);
p[2] = (unsigned char)((v >> 16) & 0xFF);
p[3] = (unsigned char)((v >> 24) & 0xFF);
}
static size_t bmp_hdr(unsigned char *b, int w, int h) {
memset(b, 0, 64);
b[0] = 'B';
b[1] = 'M';
put_le32(b + 18, (unsigned)w);
put_le32(b + 22, (unsigned)h);
return 64;
}
/* FFD8 + a filler APP0 segment + SOF0 carrying the frame size. */
static size_t jpeg_hdr(unsigned char *b, unsigned w, unsigned h) {
size_t i = 0;
b[i++] = 0xFF;
b[i++] = 0xD8;
b[i++] = 0xFF;
b[i++] = 0xE0;
b[i++] = 0x00;
b[i++] = 0x04;
b[i++] = 0x00;
b[i++] = 0x00;
b[i++] = 0xFF;
b[i++] = 0xC0;
b[i++] = 0x00;
b[i++] = 0x0B;
b[i++] = 0x08;
b[i++] = (unsigned char)(h >> 8);
b[i++] = (unsigned char)h;
b[i++] = (unsigned char)(w >> 8);
b[i++] = (unsigned char)w;
b[i++] = 0x01; /* one component */
b[i++] = 0x01; /* component id */
b[i++] = 0x11; /* sampling */
b[i++] = 0x00; /* quant table */
return i;
}
static void test_sniff(void) {
unsigned char b[64];
char mime[32];
size_t n;
n = png_hdr(b, 32, 24);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/png") == 0, "PNG sniffed");
n = jpeg_hdr(b, 20, 10);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/jpeg") == 0, "JPEG sniffed");
n = gif_hdr(b, 3, 5);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/gif") == 0, "GIF sniffed");
memcpy(b, "RIFF\0\0\0\0WEBPVP8 ", 16);
EXPECT(cst_attach_sniff(b, 30, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/webp") == 0, "WebP sniffed");
n = bmp_hdr(b, 7, 9);
EXPECT(cst_attach_sniff(b, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/bmp") == 0, "BMP sniffed");
/* an extension never decides: text and a PDF are not images */
EXPECT(cst_attach_sniff("hello, world", 12, mime, sizeof(mime)) != 0,
"text rejected");
EXPECT(cst_attach_sniff("%PDF-1.7\n", 9, mime, sizeof(mime)) != 0,
"PDF rejected");
EXPECT(cst_attach_sniff("\x89PNG", 4, mime, sizeof(mime)) != 0,
"truncated PNG rejected");
EXPECT(cst_attach_sniff("", 0, mime, sizeof(mime)) != 0, "empty rejected");
}
static void test_dimensions(void) {
unsigned char b[64];
int w = 0, h = 0;
size_t n;
n = png_hdr(b, 1280, 720);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 1280 && h == 720,
"PNG dimensions");
n = gif_hdr(b, 16, 40);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 16 && h == 40,
"GIF dimensions");
n = bmp_hdr(b, 7, 9);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 7 && h == 9,
"BMP dimensions");
n = jpeg_hdr(b, 640, 480);
EXPECT(cst_attach_dimensions(b, n, &w, &h) == 0 && w == 640 && h == 480,
"JPEG dimensions");
w = h = -1;
EXPECT(cst_attach_dimensions("nope", 4, &w, &h) != 0 && w == 0 && h == 0,
"unknown format gives 0x0");
}
static void test_base64(void) {
char *s = cst_base64_encode("Man", 3);
EXPECT(s && strcmp(s, "TWFu") == 0, "base64 <Man>");
free(s);
s = cst_base64_encode("Ma", 2);
EXPECT(s && strcmp(s, "TWE=") == 0, "base64 padding 1");
free(s);
s = cst_base64_encode("M", 1);
EXPECT(s && strcmp(s, "TQ==") == 0, "base64 padding 2");
free(s);
s = cst_base64_encode("\x00\xff\xfe", 3);
EXPECT(s && strcmp(s, "AP/+") == 0, "base64 binary");
free(s);
size_t n = 0;
char *d = cst_base64_decode("TWFu", 4, &n);
EXPECT(d && n == 3 && memcmp(d, "Man", 3) == 0, "decode <Man>");
free(d);
d = cst_base64_decode("TQ==", 4, &n);
EXPECT(d && n == 1 && d[0] == 'M', "decode with padding");
free(d);
d = cst_base64_decode("AP/+\n", 5, &n);
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0,
"decode binary, newline ignored");
free(d);
d = cst_base64_decode("AP_-", 4, &n);
EXPECT(d && n == 3 && memcmp(d, "\x00\xff\xfe", 3) == 0, "URL-safe alphabet");
free(d);
EXPECT(cst_base64_decode("A*B=", 4, &n) == NULL, "bad character refused");
unsigned char raw[200];
for (int i = 0; i < 200; i++)
raw[i] = (unsigned char)(i * 37);
for (size_t len = 0; len < 7; len++) {
char *e = cst_base64_encode(raw, 190 + len);
d = e ? cst_base64_decode(e, strlen(e), &n) : NULL;
EXPECT(d && n == 190 + len && memcmp(d, raw, n) == 0, "round trip");
free(e);
free(d);
}
}
static void test_rel_ok(const char *root) {
EXPECT(cst_attach_rel_ok("img-0a1b2c3d.png"), "normal name accepted");
EXPECT(!cst_attach_rel_ok("../x.png"), "parent dir refused");
EXPECT(!cst_attach_rel_ok("a/../../b.png"), "slash refused");
EXPECT(!cst_attach_rel_ok("/etc/passwd"), "absolute refused");
EXPECT(!cst_attach_rel_ok("a\\b.png"), "backslash refused");
EXPECT(!cst_attach_rel_ok(".hidden"), "dot file refused");
EXPECT(!cst_attach_rel_ok(""), "empty refused");
cJSON *arr = cJSON_Parse("[{\"id\":\"x\",\"rel\":\"../../secret.png\"},"
"{\"id\":\"y\",\"rel\":\"img-1.png\"}]");
int n = 0;
cst_attachment_t *back = cst_attachments_from_json(arr, &n);
EXPECT(n == 1 && back && strcmp(back[0].id, "y") == 0,
"escaping rel dropped on load");
cst_attachments_free(back, n);
cJSON_Delete(arr);
/* a stored file swapped for non-image bytes is not sent */
char session[1024], full[1024], err[256];
snprintf(session, sizeof(session), "%.900s/sessions/r.jsonl", root);
unsigned char b[64];
size_t bn = png_hdr(b, 4, 4);
cst_attachment_t *a =
cst_attach_import_bytes(session, "x.png", (const char *)b, bn, err,
sizeof(err));
EXPECT(a != NULL, "stored");
if (!a)
return;
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0, "path");
FILE *f = fopen(full, "wb");
if (f) {
fputs("plain text now", f);
fclose(f);
}
size_t rn = 0;
EXPECT(cst_attach_read(session, a, &rn, err, sizeof(err)) == NULL &&
strstr(err, "no longer an image"),
"read re-checks the bytes");
EXPECT(cst_attach_data_url(session, a) == NULL, "no data url for it");
snprintf(a->rel, sizeof(a->rel), "../r.jsonl");
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) != 0,
"full path refuses an escaping rel");
cst_attach_free(a);
}
static void test_store(const char *root) {
char session[1024], dir[1024];
snprintf(session, sizeof(session), "%.900s/sessions/a.jsonl", root);
unsigned char b[64];
size_t n = png_hdr(b, 32, 24);
char err[256];
cst_attachment_t *a = cst_attach_import_bytes(session, "shot.png",
(const char *)b, n, err,
sizeof(err));
EXPECT(a != NULL, "import stored");
if (!a) {
fprintf(stderr, " err: %s\n", err);
return;
}
EXPECT(strcmp(a->mime, "image/png") == 0, "stored mime");
EXPECT(a->width == 32 && a->height == 24, "stored dimensions");
EXPECT(a->bytes == (long long)n, "stored size");
EXPECT(strcmp(a->name, "shot.png") == 0, "stored name");
EXPECT(strcmp(a->origin, "user") == 0, "origin=user");
EXPECT(strcmp(a->rel, "img-") != 0 && strstr(a->rel, ".png") != NULL,
"rel has a .png extension");
EXPECT(cst_attach_dir(session, dir, sizeof(dir)) == 0, "attach dir");
char full[1024];
EXPECT(cst_attach_full_path(session, a, full, sizeof(full)) == 0 &&
strstr(full, "attachments/") != NULL,
"full path inside attachments/");
FILE *f = fopen(full, "rb");
EXPECT(f != NULL, "stored file exists");
if (f)
fclose(f);
char desc[128];
cst_attach_describe(a, desc, sizeof(desc));
EXPECT(strstr(desc, "32x24") && strstr(desc, "png"), "describe has size");
size_t rn = 0;
char *data = cst_attach_read(session, a, &rn, err, sizeof(err));
EXPECT(data && rn == n && memcmp(data, b, n) == 0, "read back exact bytes");
free(data);
char *url = cst_attach_data_url(session, a);
EXPECT(url && strncmp(url, "data:image/png;base64,", 22) == 0,
"data url prefix");
if (url)
EXPECT(strlen(url) == 22 + ((n + 2) / 3) * 4, "data url length");
free(url);
cst_attach_free(a);
/* a text file named .png is still refused */
EXPECT(cst_attach_import_bytes(session, "fake.png", "not an image", 12, err,
sizeof(err)) == NULL,
"content decides, not the name");
EXPECT(strstr(err, "not a supported image") != NULL, "err explains why");
EXPECT(cst_attach_import_bytes(session, "empty.png", "", 0, err,
sizeof(err)) == NULL,
"empty data refused");
}
static void test_import_file(const char *root) {
char session[1024], src[1024];
snprintf(session, sizeof(session), "%.900s/sessions/b.jsonl", root);
snprintf(src, sizeof(src), "%.900s/logo.gif", root);
unsigned char b[64];
size_t n = gif_hdr(b, 8, 6);
FILE *f = fopen(src, "wb");
EXPECT(f != NULL, "write source image");
if (f) {
fwrite(b, 1, n, f);
fclose(f);
}
char err[256];
cst_attachment_t *a =
cst_attach_import_file(session, src, err, sizeof(err));
EXPECT(a != NULL, "file imported");
if (a) {
EXPECT(strcmp(a->mime, "image/gif") == 0, "imported mime");
EXPECT(strcmp(a->name, "logo.gif") == 0, "imported keeps the leaf name");
EXPECT(a->width == 8 && a->height == 6, "imported dimensions");
cst_attach_free(a);
}
EXPECT(cst_attach_import_file(session, "/nonexistent/x.png", err,
sizeof(err)) == NULL,
"missing file refused");
}
static void test_json_roundtrip(void) {
cst_attachment_t list[3];
memset(list, 0, sizeof(list));
snprintf(list[0].id, sizeof(list[0].id), "img-1");
snprintf(list[0].name, sizeof(list[0].name), "a.png");
snprintf(list[0].mime, sizeof(list[0].mime), "image/png");
snprintf(list[0].rel, sizeof(list[0].rel), "img-1.png");
list[0].bytes = 12345;
list[0].width = 640;
list[0].height = 480;
snprintf(list[0].origin, sizeof(list[0].origin), "user");
snprintf(list[1].id, sizeof(list[1].id), "img-2");
snprintf(list[1].name, sizeof(list[1].name), "b.jpg");
snprintf(list[1].mime, sizeof(list[1].mime), "image/jpeg");
snprintf(list[1].rel, sizeof(list[1].rel), "img-2.jpg");
list[1].bytes = 4096;
snprintf(list[1].detail, sizeof(list[1].detail), "high");
snprintf(list[1].origin, sizeof(list[1].origin), "view_image");
snprintf(list[2].id, sizeof(list[2].id), "img-3");
snprintf(list[2].name, sizeof(list[2].name), "c.webp");
snprintf(list[2].mime, sizeof(list[2].mime), "image/webp");
snprintf(list[2].rel, sizeof(list[2].rel), "img-3.webp");
list[2].bytes = 7;
char *json = cst_attachments_to_json(list, 3);
EXPECT(json != NULL, "to_json");
int n = 0;
cJSON *parsed = cJSON_Parse(json);
cst_attachment_t *back = cst_attachments_from_json(parsed, &n);
cJSON_Delete(parsed);
EXPECT(back != NULL && n == 3, "from_json count");
if (back && n == 3) {
EXPECT(strcmp(back[0].id, "img-1") == 0 && back[0].bytes == 12345 &&
back[0].width == 640 && back[0].height == 480,
"first entry survives");
EXPECT(strcmp(back[1].detail, "high") == 0 &&
strcmp(back[1].origin, "view_image") == 0,
"detail and origin survive");
EXPECT(strcmp(back[2].mime, "image/webp") == 0 && back[2].width == 0,
"entry without dimensions");
EXPECT(strcmp(back[0].detail, "auto") == 0, "missing detail defaults");
cst_attachments_free(back, n);
}
free(json);
/* entries without rel/id are dropped; a non-array is NULL */
cJSON *arr = cJSON_Parse("[{\"id\":\"x\"},{\"id\":\"y\",\"rel\":\"y.png\"}]");
back = cst_attachments_from_json(arr, &n);
EXPECT(back != NULL && n == 1 && strcmp(back[0].id, "y") == 0,
"incomplete entries skipped");
cst_attachments_free(back, n);
cJSON_Delete(arr);
cJSON *obj = cJSON_Parse("{}");
n = 7;
EXPECT(cst_attachments_from_json(obj, &n) == NULL && n == 0,
"object is not an attachment array");
cJSON_Delete(obj);
EXPECT(cst_attachments_to_json(NULL, 0) == NULL, "empty list is NULL");
}
static void test_lists(void) {
cst_attachment_t src;
memset(&src, 0, sizeof(src));
snprintf(src.id, sizeof(src.id), "img-x");
snprintf(src.mime, sizeof(src.mime), "image/png");
cst_attachment_t *list = NULL;
int n = 0, cap = 0;
for (int i = 0; i < 10; i++)
EXPECT(cst_attachments_push(&list, &n, &cap, &src) == 0, "push");
EXPECT(n == 10 && cap >= 10, "list grew");
EXPECT(strcmp(list[9].id, "img-x") == 0, "last entry copied");
cst_attachment_t *dup = cst_attachments_dup(list, n);
EXPECT(dup && strcmp(dup[3].mime, "image/png") == 0, "dup copies fields");
dup[3].id[0] = 'Z';
EXPECT(list[3].id[0] == 'i', "dup is a deep copy");
cst_attachments_free(dup, n);
EXPECT(cst_attachments_dup(NULL, 0) == NULL, "dup of nothing is NULL");
cst_attachments_free(list, n);
}
static void test_agent_pending(const char *root) {
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/sessions/c.jsonl",
root);
cst_agent_t *a = cst_agent_create(&cfg);
EXPECT(a != NULL, "agent created");
if (!a)
return;
char err[256], line[128];
EXPECT(cst_agent_pending_count(a) == 0, "no pending at start");
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == -1,
"describe out of range");
unsigned char b[32];
size_t n = png_hdr(b, 4, 2);
char src[1024];
snprintf(src, sizeof(src), "%.900s/pic.png", root);
FILE *f = fopen(src, "wb");
EXPECT(f != NULL, "write the source png");
if (f) {
fwrite(b, 1, n, f);
fclose(f);
}
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0,
"attach through the agent");
EXPECT(cst_agent_pending_count(a) == 1, "one pending");
EXPECT(cst_agent_pending_describe(a, 0, line, sizeof(line)) == 0 &&
strstr(line, "4x2") != NULL,
"pending describe shows the size");
EXPECT(cst_agent_attach_file(a, "/nonexistent/x.png", err, sizeof(err)) == -1,
"missing file refused");
EXPECT(cst_agent_pending_count(a) == 1, "a failure keeps the queue");
for (int i = 1; i < CST_ATTACH_MAX_COUNT; i++)
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == 0, "attach more");
EXPECT(cst_agent_pending_count(a) == CST_ATTACH_MAX_COUNT, "queue full");
EXPECT(cst_agent_attach_file(a, src, err, sizeof(err)) == -1 &&
strstr(err, "at most") != NULL,
"one over the limit is refused");
cst_agent_pending_clear(a);
EXPECT(cst_agent_pending_count(a) == 0, "cleared");
cst_agent_destroy(a);
}
static void test_paste_paths(void) {
static char p[CST_PASTE_MAX_PATHS][CST_PASTE_PATH_MAX];
const char *s = "/tmp/a.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
strcmp(p[0], "/tmp/a.png") == 0,
"single path");
s = " '/home/u/my shot.png' \"/x/y z.jpg\"\n/plain.gif\r\n";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 3, "quoted paths count");
EXPECT(strcmp(p[0], "/home/u/my shot.png") == 0, "single quotes");
EXPECT(strcmp(p[1], "/x/y z.jpg") == 0, "double quotes");
EXPECT(strcmp(p[2], "/plain.gif") == 0, "newline separated");
s = "file:///home/u/a%20b.png file://localhost/c.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 2, "file uris");
EXPECT(strcmp(p[0], "/home/u/a b.png") == 0, "percent decoded");
EXPECT(strcmp(p[1], "/c.png") == 0, "localhost host");
s = "file://evil.example/c.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "remote host refused");
#ifndef _WIN32
s = "/home/u/my\\ shot.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == 1 &&
strcmp(p[0], "/home/u/my shot.png") == 0,
"backslash escape");
#endif
s = "'/unterminated.png";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "unbalanced quote");
s = " \n ";
EXPECT(cst_paste_paths(s, strlen(s), p, 8) == -1, "blank");
s = "a b c";
EXPECT(cst_paste_paths(s, strlen(s), p, 2) == -1, "too many");
}
static void test_dib(void) {
/* 3x2, 24-bit, bottom-up: stride 12 (9 bytes + pad). */
unsigned char dib[40 + 24];
memset(dib, 0, sizeof(dib));
put_le32(dib, 40);
put_le32(dib + 4, 3);
put_le32(dib + 8, 2);
put_le16(dib + 12, 1);
put_le16(dib + 14, 24);
dib[40] = 0xff; /* bottom-left pixel blue (BGR) */
size_t n = 0;
char *img = cst_dib_to_image(dib, sizeof(dib), &n);
char mime[32];
int w = 0, h = 0;
EXPECT(img && cst_attach_sniff(img, n, mime, sizeof(mime)) == 0 &&
strcmp(mime, "image/png") == 0,
"24-bit DIB becomes a PNG");
EXPECT(img && cst_attach_dimensions(img, n, &w, &h) == 0 && w == 3 && h == 2,
"PNG keeps the size");
free(img);
put_le16(dib + 14, 8); /* palette needs 256 entries: past the end */
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "short DIB refused");
put_le32(dib, 4);
EXPECT(cst_dib_to_image(dib, sizeof(dib), &n) == NULL, "bad header size");
}
int main(void) {
char root[1024];
if (test_tmpdir(root, sizeof(root), "cst-attach") != 0) {
fprintf(stderr, "cannot create a scratch dir\n");
return 1;
}
test_sniff();
test_dimensions();
test_base64();
test_rel_ok(root);
test_paste_paths();
test_dib();
test_store(root);
test_import_file(root);
test_json_roundtrip();
test_lists();
test_agent_pending(root);
test_rmrf(root);
if (nfail) {
fprintf(stderr, "test_attach: %d failed\n", nfail);
return 1;
}
printf("test_attach: OK\n");
return 0;
}
+26 -7
View File
@@ -9,6 +9,11 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
#ifdef _WIN32
#include <aclapi.h>
#include <sddl.h>
#endif
#define CHECK(c) \ #define CHECK(c) \
do { \ do { \
@@ -23,7 +28,7 @@ static char g_dir[256];
static void write_file(const char *name, const char *text) { static void write_file(const char *name, const char *text) {
char path[512]; char path[512];
snprintf(path, sizeof(path), "%s/cst-agent/%s", g_dir, name); snprintf(path, sizeof(path), "%s/cst-agent/%s", g_dir, name);
FILE *fp = fopen(path, "w"); FILE *fp = fopen(path, "wb");
CHECK(fp); CHECK(fp);
fputs(text, fp); fputs(text, fp);
fclose(fp); fclose(fp);
@@ -57,9 +62,26 @@ static void test_creds(void) {
char path[512]; char path[512];
snprintf(path, sizeof(path), "%s/cst-agent/credentials", g_dir); snprintf(path, sizeof(path), "%s/cst-agent/credentials", g_dir);
#ifdef _WIN32
{
wchar_t *wp = os_utf8_to_wide(path);
PSECURITY_DESCRIPTOR sd = NULL;
LPWSTR s = NULL;
CHECK(wp && GetNamedSecurityInfoW(wp, SE_FILE_OBJECT,
LABEL_SECURITY_INFORMATION, NULL, NULL,
NULL, NULL, &sd) == ERROR_SUCCESS);
CHECK(ConvertSecurityDescriptorToStringSecurityDescriptorW(
sd, SDDL_REVISION_1, LABEL_SECURITY_INFORMATION, &s, NULL));
CHECK(wcsstr(s, L"NR") && wcsstr(s, L";ME)")); /* Low-IL cannot read */
LocalFree(s);
LocalFree(sd);
free(wp);
}
#else
struct stat st; struct stat st;
CHECK(stat(path, &st) == 0); CHECK(stat(path, &st) == 0);
CHECK((st.st_mode & 0077) == 0); CHECK((st.st_mode & 0077) == 0);
#endif
CHECK(cst_creds_resolve("deepseek", key, sizeof(key), &from) == 0); CHECK(cst_creds_resolve("deepseek", key, sizeof(key), &from) == 0);
CHECK(strcmp(from, "saved") == 0); CHECK(strcmp(from, "saved") == 0);
@@ -124,23 +146,20 @@ static void test_effort(void) {
} }
int main(void) { int main(void) {
snprintf(g_dir, sizeof(g_dir), "/tmp/cst-config-XXXXXX"); CHECK(test_tmpdir(g_dir, sizeof(g_dir), "cst-config") == 0);
CHECK(mkdtemp(g_dir));
setenv("XDG_CONFIG_HOME", g_dir, 1); setenv("XDG_CONFIG_HOME", g_dir, 1);
unsetenv("DEEPSEEK_API_KEY"); unsetenv("DEEPSEEK_API_KEY");
unsetenv("MINIMAX_API_KEY"); unsetenv("MINIMAX_API_KEY");
unsetenv("OPENAI_API_KEY"); unsetenv("OPENAI_API_KEY");
char sub[300]; char sub[300];
snprintf(sub, sizeof(sub), "%s/cst-agent", g_dir); snprintf(sub, sizeof(sub), "%s/cst-agent", g_dir);
CHECK(mkdir(sub, 0700) == 0); CHECK(test_mkdir(sub, 0755) == 0);
test_creds(); test_creds();
test_settings(); test_settings();
test_effort(); test_effort();
char cmd[400]; CHECK(test_rmrf(g_dir) == 0);
snprintf(cmd, sizeof(cmd), "rm -rf '%s'", g_dir);
CHECK(system(cmd) == 0);
printf("test_config: OK\n"); printf("test_config: OK\n");
return 0; return 0;
} }
+14 -7
View File
@@ -9,6 +9,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
static int nfail; static int nfail;
@@ -30,6 +31,11 @@ static void put(const char *p, const char *s) {
/* GNU diff output, or NULL when diff is not installed. */ /* GNU diff output, or NULL when diff is not installed. */
static char *gnu_diff(const char *a, const char *b) { static char *gnu_diff(const char *a, const char *b) {
#ifdef _WIN32
(void)a;
(void)b;
return NULL; /* no GNU diff to compare against */
#else
char pa[] = "/tmp/cstdiffaXXXXXX", pb[] = "/tmp/cstdiffbXXXXXX"; char pa[] = "/tmp/cstdiffaXXXXXX", pb[] = "/tmp/cstdiffbXXXXXX";
int fa = mkstemp(pa), fb = mkstemp(pb); int fa = mkstemp(pa), fb = mkstemp(pb);
if (fa < 0 || fb < 0) if (fa < 0 || fb < 0)
@@ -67,6 +73,7 @@ static char *gnu_diff(const char *a, const char *b) {
unlink(pa); unlink(pa);
unlink(pb); unlink(pb);
return out; return out;
#endif
} }
static int have_gnu_diff(void) { static int have_gnu_diff(void) {
@@ -130,11 +137,11 @@ static char *random_text(unsigned *seed, int lines) {
size_t cap = (size_t)lines * 16 + 2, n = 0; size_t cap = (size_t)lines * 16 + 2, n = 0;
char *s = malloc(cap); char *s = malloc(cap);
for (int i = 0; i < lines; i++) { for (int i = 0; i < lines; i++) {
const char *w = words[rand_r(seed) % 8]; const char *w = words[test_rand(seed) % 8];
n += (size_t)snprintf(s + n, cap - n, "%s%d\n", w, rand_r(seed) % 3); n += (size_t)snprintf(s + n, cap - n, "%s%d\n", w, test_rand(seed) % 3);
} }
s[n] = 0; s[n] = 0;
if (n && rand_r(seed) % 5 == 0) if (n && test_rand(seed) % 5 == 0)
s[n - 1] = 0; /* no newline at end of file */ s[n - 1] = 0; /* no newline at end of file */
return s; return s;
} }
@@ -146,15 +153,15 @@ static char *mutate(unsigned *seed, const char *a) {
while (*p) { while (*p) {
const char *e = strchr(p, '\n'); const char *e = strchr(p, '\n');
size_t len = e ? (size_t)(e - p) + 1 : strlen(p); size_t len = e ? (size_t)(e - p) + 1 : strlen(p);
int r = rand_r(seed) % 10; int r = test_rand(seed) % 10;
if (r == 0) { if (r == 0) {
/* drop the line */ /* drop the line */
} else if (r == 1) { } else if (r == 1) {
o += (size_t)snprintf(b + o, cap - o, "inserted%d\n", rand_r(seed) % 9); o += (size_t)snprintf(b + o, cap - o, "inserted%d\n", test_rand(seed) % 9);
memcpy(b + o, p, len); memcpy(b + o, p, len);
o += len; o += len;
} else if (r == 2) { } else if (r == 2) {
o += (size_t)snprintf(b + o, cap - o, "changed%d\n", rand_r(seed) % 9); o += (size_t)snprintf(b + o, cap - o, "changed%d\n", test_rand(seed) % 9);
} else { } else {
memcpy(b + o, p, len); memcpy(b + o, p, len);
o += len; o += len;
@@ -198,7 +205,7 @@ int main(void) {
unsigned seed = 12345; unsigned seed = 12345;
int ok = 0, total = 400; int ok = 0, total = 400;
for (int i = 0; i < total; i++) { for (int i = 0; i < total; i++) {
char *a = random_text(&seed, 1 + (int)(rand_r(&seed) % 60)); char *a = random_text(&seed, 1 + (int)(test_rand(&seed) % 60));
char *b = mutate(&seed, a); char *b = mutate(&seed, a);
ok += round_trip(a, b); ok += round_trip(a, b);
free(a); free(a);
+170
View File
@@ -159,7 +159,177 @@ static void test_tool_cap_head_tail(void) {
free(big); free(big);
} }
static cJSON *build_req(const cst_llm_request_t *r) {
char *body = cst_llm_build_body(r);
EXPECT(body != NULL, "body built");
cJSON *j = body ? cJSON_Parse(body) : NULL;
EXPECT(j != NULL, "body is valid JSON");
free(body);
return j;
}
static const cJSON *msg_at(const cJSON *root, int i) {
return cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(root, "messages"),
i);
}
static const cJSON *cache_mark(const cJSON *m) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (!cJSON_IsArray(c))
return NULL;
const cJSON *last = cJSON_GetArrayItem(c, cJSON_GetArraySize(c) - 1);
return cJSON_GetObjectItemCaseSensitive(last, "cache_control");
}
static void test_reasoning_and_prefix(void) {
cst_message_t m[4];
m[0] = msg(CST_ROLE_SYSTEM, "sys");
m[1] = msg(CST_ROLE_USER, "go");
m[2] = msg(CST_ROLE_ASSISTANT, "partial answer");
m[2].reasoning = "thinking it over";
m[3] = msg(CST_ROLE_ASSISTANT, "cut off here");
m[3].prefix = 1;
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 4;
cJSON *j = build_req(&r);
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content"),
"reasoning only when asked for");
const cJSON *p = cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "prefix");
EXPECT(cJSON_IsTrue(p), "prefix:true on the continued message");
EXPECT(!cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "prefix"),
"no prefix on history");
cJSON_Delete(j);
r.send_reasoning = 1;
j = build_req(&r);
const cJSON *rc =
cJSON_GetObjectItemCaseSensitive(msg_at(j, 2), "reasoning_content");
EXPECT(cJSON_IsString(rc) && strcmp(rc->valuestring, "thinking it over") == 0,
"reasoning_content passed back");
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
"no cache key without a cache style");
cJSON_Delete(j);
}
static void test_cache_hints(void) {
cst_message_t m[4];
m[0] = msg(CST_ROLE_SYSTEM, "sys");
m[1] = msg(CST_ROLE_USER, "first");
m[2] = msg(CST_ROLE_ASSISTANT, "reply");
m[3] = msg(CST_ROLE_USER, "second");
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "gpt-4.1";
r.msgs = m;
r.nmsgs = 4;
r.cache_style = CST_CACHE_OPENAI;
r.cache_key = "sess-1";
cJSON *j = build_req(&r);
const cJSON *k = cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key");
EXPECT(cJSON_IsString(k) && strcmp(k->valuestring, "sess-1") == 0,
"prompt_cache_key");
EXPECT(content_at(j, 3) && !cache_mark(msg_at(j, 3)),
"OpenAI style leaves content as strings");
cJSON_Delete(j);
r.model = "anthropic/claude-sonnet-4";
r.cache_style = CST_CACHE_ANTHROPIC;
r.cache_ttl = "1h";
j = build_req(&r);
EXPECT(!cJSON_GetObjectItemCaseSensitive(j, "prompt_cache_key"),
"no prompt_cache_key for Anthropic");
const cJSON *cs = cache_mark(msg_at(j, 0));
const cJSON *cl = cache_mark(msg_at(j, 3));
const cJSON *cu = cache_mark(msg_at(j, 1));
EXPECT(cs && cl && cu, "breakpoints on system, newest, previous user");
EXPECT(!cache_mark(msg_at(j, 2)), "no breakpoint on the assistant reply");
const cJSON *ttl = cJSON_GetObjectItemCaseSensitive(cl, "ttl");
EXPECT(cJSON_IsString(ttl) && strcmp(ttl->valuestring, "1h") == 0, "ttl");
const cJSON *parts =
cJSON_GetObjectItemCaseSensitive(msg_at(j, 3), "content");
const cJSON *txt =
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(parts, 0), "text");
EXPECT(cJSON_IsString(txt) && strcmp(txt->valuestring, "second") == 0,
"marked content keeps its text");
cJSON_Delete(j);
}
static void test_tools_snapshot(void) {
cst_message_t m[1] = {msg(CST_ROLE_USER, "go")};
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "test-model";
r.msgs = m;
r.nmsgs = 1;
r.enable_tools = 1;
r.tools_json = "[{\"type\":\"function\",\"function\":{\"name\":\"only_me\","
"\"parameters\":{\"type\":\"object\"}}}]";
cJSON *j = build_req(&r);
const cJSON *tools = cJSON_GetObjectItemCaseSensitive(j, "tools");
EXPECT(cJSON_IsArray(tools) && cJSON_GetArraySize(tools) == 1,
"tools[] taken from the snapshot");
const cJSON *nm = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(tools, 0),
"function"),
"name");
EXPECT(cJSON_IsString(nm) && strcmp(nm->valuestring, "only_me") == 0,
"snapshot tool name");
cJSON_Delete(j);
}
static void test_images_off(void) {
cst_attachment_t img;
memset(&img, 0, sizeof(img));
snprintf(img.id, sizeof(img.id), "img-1");
snprintf(img.rel, sizeof(img.rel), "img-1.png");
snprintf(img.name, sizeof(img.name), "shot.png");
snprintf(img.mime, sizeof(img.mime), "image/png");
img.width = 640;
img.height = 480;
img.bytes = 2048;
cst_message_t m[4];
m[0] = msg(CST_ROLE_USER, "what is this");
m[0].attach = &img;
m[0].nattach = 1;
m[1] = msg(CST_ROLE_ASSISTANT, "");
m[1].tool_calls_json = "[{\"id\":\"c1\",\"type\":\"function\",\"function\":"
"{\"name\":\"view_image\",\"arguments\":\"{}\"}}]";
m[2] = msg(CST_ROLE_TOOL, "view_image shot.png");
snprintf(m[2].tool_call_id, sizeof(m[2].tool_call_id), "c1");
m[2].attach = &img;
m[2].nattach = 1;
m[3] = msg(CST_ROLE_USER, "and now?");
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 4;
r.images_off = 1;
r.session_path = "/nonexistent/s.jsonl";
cJSON *j = build_req(&r);
char *s = cJSON_PrintUnformatted(j);
EXPECT(s && !strstr(s, "image_url"), "no image parts when images are off");
free(s);
const char *c0 = content_at(j, 0);
EXPECT(c0 && strstr(c0, "what is this") &&
strstr(c0, "[image \"shot.png\" (640x480") &&
strstr(c0, "not sent: this model does not take images"),
"user image becomes a plain-text note");
const char *c2 = content_at(j, 2);
EXPECT(c2 && strstr(c2, "view_image shot.png") && strstr(c2, "not sent"),
"tool image noted inside the tool result");
EXPECT(nmsgs(j) == 4, "no extra user message for deferred tool images");
cJSON_Delete(j);
}
int main(void) { int main(void) {
test_images_off();
test_reasoning_and_prefix();
test_cache_hints();
test_tools_snapshot();
test_escaping(); test_escaping();
test_invalid_utf8(); test_invalid_utf8();
test_tool_calls_and_null_content(); test_tool_calls_and_null_content();
+184
View File
@@ -0,0 +1,184 @@
/* Windows Low-IL shell sandbox: writes only in the workspace and the
* sandbox TEMP, the credentials file is unreadable, timeouts end the whole
* process tree, and /sandbox reset-labels restores the workspace label.
* Elsewhere it only checks that the kind is never reported. */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cst_sandbox.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.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)
#ifdef _WIN32
static int run(const char *ws, const char *cmd, os_run_result_t *r) {
memset(r, 0, sizeof(*r));
if (cst_sandbox_run_shell(ws, cmd, 0, r) != 0)
return -1;
return r->exit_code;
}
static int has(const os_run_result_t *r, const char *s) {
return r->out && strstr(r->out, s);
}
static void write_file(const char *path, const char *text) {
FILE *f = fopen(path, "wb");
if (f) {
fputs(text, f);
fclose(f);
}
}
static void confined(void) {
char root[1024], cfg[1100], ws[1100], out[1100], cred[1300], cmd[4096];
char wsr[1100];
if (test_tmpdir(root, sizeof(root), "cst-lowil") != 0) {
EXPECT(0, "tmpdir");
return;
}
snprintf(cfg, sizeof(cfg), "%s/cfg", root);
snprintf(ws, sizeof(ws), "%s/项目 ws", root);
snprintf(out, sizeof(out), "%s/outside", root);
test_mkdir(cfg, 0700);
test_mkdir(ws, 0755);
test_mkdir(out, 0755);
setenv("XDG_CONFIG_HOME", cfg, 1);
char cdir[1200];
EXPECT(os_config_dir(cdir, sizeof(cdir)) == 0, "config dir");
os_mkdir_p(cdir, 0700);
snprintf(cred, sizeof(cred), "%s/credentials", cdir);
write_file(cred, "deepseek=sk-test-secret\n");
EXPECT(os_chmod_private(cred) == 0, "label credentials");
EXPECT(os_realpath(ws, wsr, sizeof(wsr)) == 0, "realpath ws");
os_path_norm_slashes(wsr);
os_run_result_t r;
EXPECT(run(wsr, "echo hi > in.txt && cat in.txt", &r) == 0,
"write inside the workspace");
EXPECT(has(&r, "hi"), "output captured");
EXPECT(has(&r, "labeled"), "first run says the workspace was labeled");
os_run_result_free(&r);
EXPECT(run(wsr, "echo again > in.txt && cat in.txt", &r) == 0,
"second run");
EXPECT(!has(&r, "labeled"), "labeled only once");
EXPECT(!has(&r, "background"), "conhost is not a background process");
os_run_result_free(&r);
char *labels = cst_sandbox_labels();
EXPECT(labels && strstr(labels, wsr), "workspace recorded");
free(labels);
snprintf(cmd, sizeof(cmd), "echo x > '%s/o.txt'", out);
int rc = run(wsr, cmd, &r);
EXPECT(rc != 0, "write outside the workspace fails");
EXPECT(cst_sandbox_blocked(r.out, r.out_len, rc), "outside write blocked");
os_run_result_free(&r);
snprintf(cmd, sizeof(cmd), "%s/o.txt", out);
os_stat_t st;
EXPECT(os_lstat(cmd, &st) != 0, "nothing written outside");
snprintf(cmd, sizeof(cmd), "cat '%s'", cred);
rc = run(wsr, cmd, &r);
EXPECT(rc != 0 && !has(&r, "sk-test-secret"), "credentials unreadable");
EXPECT(cst_sandbox_blocked(r.out, r.out_len, rc), "credential read blocked");
os_run_result_free(&r);
EXPECT(run(wsr, "echo t > \"$TEMP/t.txt\" && cat \"$TEMP/t.txt\"", &r) == 0,
"TEMP is writable");
EXPECT(has(&r, "t"), "TEMP read back");
os_run_result_free(&r);
/* System32's whoami: busybox has an applet and MSYS2's cannot start at
* Low IL */
char sys[MAX_PATH];
UINT sl = GetSystemDirectoryA(sys, sizeof(sys));
for (UINT i = 0; i < sl; i++)
if (sys[i] == '\\')
sys[i] = '/';
snprintf(cmd, sizeof(cmd), "'%s/whoami.exe' /groups | grep S-1-16-4096",
sys);
rc = run(wsr, cmd, &r);
EXPECT(rc == 0, "child runs at Low integrity");
if (rc != 0)
fprintf(stderr, "whoami: exit %d: %.*s\n", rc, (int)r.out_len,
r.out ? r.out : "");
os_run_result_free(&r);
/* timeout ends background children too */
os_set_run_timeout(2);
double t0 = os_now_ms();
rc = run(wsr, "sleep 30 & sleep 30; echo late", &r);
double ms = os_now_ms() - t0;
EXPECT(rc == 124, "timeout exit code");
EXPECT(ms < 10000, "process tree killed promptly");
EXPECT(!has(&r, "late"), "command did not finish");
os_run_result_free(&r);
os_set_run_timeout(0);
const char *prof = getenv("USERPROFILE");
char profr[1100];
if (prof && os_realpath(prof, profr, sizeof(profr)) == 0) {
os_path_norm_slashes(profr);
rc = run(profr, "echo no", &r);
EXPECT(rc == 126 && has(&r, "will not label"), "profile is not labeled");
EXPECT(cst_sandbox_blocked(r.out, r.out_len, rc), "offers a rerun");
os_run_result_free(&r);
}
rc = run("C:/", "echo no", &r);
EXPECT(rc == 126 && has(&r, "will not label"), "drive root is not labeled");
os_run_result_free(&r);
char *rep = NULL;
EXPECT(cst_sandbox_reset_labels(&rep) >= 1, "reset-labels restored");
EXPECT(rep && strstr(rep, "restored"), "reset report");
free(rep);
labels = cst_sandbox_labels();
EXPECT(!labels, "record cleared");
free(labels);
char sddl[256];
EXPECT(os_win_get_label(wsr, sddl, sizeof(sddl)) == 0 &&
strcmp(sddl, "S:") == 0,
"workspace has no explicit label again");
unsetenv("XDG_CONFIG_HOME");
test_rmrf(root);
}
#endif
int main(void) {
int kind = cst_sandbox_probe();
printf("test_lowil: sandbox %s\n", cst_sandbox_name(kind));
EXPECT(strcmp(cst_sandbox_name(CST_SANDBOX_LOWIL), "lowil") == 0, "name");
#ifdef _WIN32
EXPECT(kind == CST_SANDBOX_LOWIL, "lowil available");
if (kind == CST_SANDBOX_LOWIL)
confined();
#else
EXPECT(kind != CST_SANDBOX_LOWIL, "lowil is Windows only");
char *rep = NULL;
EXPECT(cst_sandbox_reset_labels(&rep) == 0 && !rep, "nothing to reset");
EXPECT(!cst_sandbox_labels(), "no labels");
#endif
if (nfail) {
fprintf(stderr, "test_lowil: %d failure(s)\n", nfail);
return 1;
}
puts("test_lowil: OK");
return 0;
}
+62 -5
View File
@@ -5,6 +5,8 @@
#define _POSIX_C_SOURCE 200809L #define _POSIX_C_SOURCE 200809L
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h"
#include "cst_attach.h"
#include "cst_mcp.h" #include "cst_mcp.h"
#include "cst_tools.h" #include "cst_tools.h"
#include "os_compat.h" #include "os_compat.h"
@@ -14,7 +16,11 @@
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
static int nfail; static int nfail;
static char g_tmp[OS_PATH_MAX];
static cst_agent_t *g_agent;
#define CHECK(cond, msg) \ #define CHECK(cond, msg) \
do { \ do { \
@@ -96,6 +102,7 @@ static int server(void) {
cJSON_AddItemToArray(tools, tool("mixed", "Mixed content.")); cJSON_AddItemToArray(tools, tool("mixed", "Mixed content."));
cJSON_AddItemToArray(tools, tool("hang", "Never answers.")); cJSON_AddItemToArray(tools, tool("hang", "Never answers."));
cJSON_AddItemToArray(tools, tool("crash", "Exits.")); cJSON_AddItemToArray(tools, tool("crash", "Exits."));
cJSON_AddItemToArray(tools, tool("pic", "Returns images."));
} }
reply(id, r); reply(id, r);
} else if (strcmp(mt, "tools/call") == 0) { } else if (strcmp(mt, "tools/call") == 0) {
@@ -133,6 +140,21 @@ static int server(void) {
cJSON_AddStringToObject(rr, "text", "resource text"); cJSON_AddStringToObject(rr, "text", "resource text");
cJSON_AddItemToArray(c, res); cJSON_AddItemToArray(c, res);
reply(id, r); reply(id, r);
} else if (strcmp(name, "pic") == 0) {
cJSON *r = text_result("a chart", 0);
cJSON *c = cJSON_GetObjectItemCaseSensitive(r, "content");
const char *datas[] = {
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAC"
"hwGA60e6kgAAAABJRU5ErkJggg==",
"AAAA"};
for (int i = 0; i < 2; i++) {
cJSON *img = cJSON_CreateObject();
cJSON_AddStringToObject(img, "type", "image");
cJSON_AddStringToObject(img, "mimeType", "image/png");
cJSON_AddStringToObject(img, "data", datas[i]);
cJSON_AddItemToArray(c, img);
}
reply(id, r);
} else if (strcmp(name, "crash") == 0) { } else if (strcmp(name, "crash") == 0) {
fprintf(stderr, "fatal: fake crash\n"); fprintf(stderr, "fatal: fake crash\n");
exit(3); exit(3);
@@ -159,7 +181,8 @@ static int call(const char *tool, const char *k, const char *v,
} }
cst_tool_ctx_t ctx; cst_tool_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx)); memset(&ctx, 0, sizeof(ctx));
ctx.workspace_root = "/tmp"; ctx.workspace_root = g_tmp;
ctx.agent = g_agent;
memset(r, 0, sizeof(*r)); memset(r, 0, sizeof(*r));
return cst_tool_run(&ctx, &c, r); return cst_tool_run(&ctx, &c, r);
} }
@@ -181,6 +204,8 @@ int main(int argc, char **argv) {
fprintf(stderr, "cannot resolve %s\n", argv[0]); fprintf(stderr, "cannot resolve %s\n", argv[0]);
return 1; return 1;
} }
if (os_temp_dir(g_tmp, sizeof(g_tmp)) != 0)
snprintf(g_tmp, sizeof(g_tmp), ".");
setenv("CST_TEST_SECRET", "s3cret", 1); setenv("CST_TEST_SECRET", "s3cret", 1);
char cfg[OS_PATH_MAX + 128]; char cfg[OS_PATH_MAX + 128];
snprintf(cfg, sizeof(cfg), snprintf(cfg, sizeof(cfg),
@@ -206,14 +231,14 @@ int main(int argc, char **argv) {
cst_mcp_counts(&ready, &total); cst_mcp_counts(&ready, &total);
CHECK(ready == 0 && total == 3, "counts before start (disabled excluded)"); CHECK(ready == 0 && total == 3, "counts before start (disabled excluded)");
cst_mcp_start_all("/tmp"); cst_mcp_start_all(g_tmp);
cst_mcp_wait_ready(10000); cst_mcp_wait_ready(10000);
cst_mcp_info_t mi[CST_MCP_MAX]; cst_mcp_info_t mi[CST_MCP_MAX];
int n = cst_mcp_info(mi, CST_MCP_MAX); int n = cst_mcp_info(mi, CST_MCP_MAX);
const cst_mcp_info_t *f = info_of("fake", mi, n); const cst_mcp_info_t *f = info_of("fake", mi, n);
CHECK(f && f->state == CST_MCP_READY && f->ntools == 6, CHECK(f && f->state == CST_MCP_READY && f->ntools == 7,
"fake ready with 6 tools over two pages"); "fake ready with 7 tools over two pages");
const cst_mcp_info_t *rm = info_of("remote", mi, n); const cst_mcp_info_t *rm = info_of("remote", mi, n);
CHECK(rm && rm->state == CST_MCP_FAILED && strstr(rm->error, "stdio"), CHECK(rm && rm->state == CST_MCP_FAILED && strstr(rm->error, "stdio"),
"url servers unsupported"); "url servers unsupported");
@@ -250,8 +275,40 @@ int main(int argc, char **argv) {
CHECK(r.exit_code == 0 && r.out && strstr(r.out, "first\n[image image/png") && CHECK(r.exit_code == 0 && r.out && strstr(r.out, "first\n[image image/png") &&
strstr(r.out, "resource text"), strstr(r.out, "resource text"),
"content items mapped to text"); "content items mapped to text");
CHECK(r.nattach == 0, "no session: images stay text");
cst_tool_result_free(&r); cst_tool_result_free(&r);
cst_agent_config_t acfg;
memset(&acfg, 0, sizeof(acfg));
snprintf(acfg.workspace_root, sizeof(acfg.workspace_root), "%s", g_tmp);
char sdir[1024];
snprintf(sdir, sizeof(sdir), "%.900s/cst-mcp-test-%d", g_tmp, (int)getpid());
os_mkdir_p(sdir, 0755);
snprintf(acfg.session_path, sizeof(acfg.session_path), "%s/s.jsonl", sdir);
g_agent = cst_agent_create(&acfg);
CHECK(g_agent != NULL, "agent for the session store");
call("mcp__fake__pic", NULL, NULL, &r);
CHECK(r.exit_code == 0 && r.nattach == 1 && r.attach &&
strcmp(r.attach[0].mime, "image/png") == 0 &&
strcmp(r.attach[0].origin, "mcp") == 0 &&
r.attach[0].width == 1 && r.attach[0].height == 1,
"image content stored and attached");
CHECK(r.out && strstr(r.out, "a chart\n[image ") &&
strstr(r.out, "shown to you as an image") &&
strstr(r.out, "not shown:"),
"one line per image, bad data falls back to text");
if (r.nattach == 1) {
char full[OS_PATH_MAX];
CHECK(cst_attach_full_path(acfg.session_path, &r.attach[0], full,
sizeof(full)) == 0 &&
access(full, R_OK) == 0,
"stored file exists");
}
cst_tool_result_free(&r);
cst_agent_destroy(g_agent);
g_agent = NULL;
test_rmrf(sdir);
volatile int cancel = 1; volatile int cancel = 1;
os_set_cancel_flag(&cancel); os_set_cancel_flag(&cancel);
double t0 = os_now_ms(); double t0 = os_now_ms();
@@ -281,7 +338,7 @@ int main(int argc, char **argv) {
/* a live server stops cleanly too */ /* a live server stops cleanly too */
CHECK(cst_mcp_add_config_json("fake2", cfg, NULL, "user") == 0, "re-add"); CHECK(cst_mcp_add_config_json("fake2", cfg, NULL, "user") == 0, "re-add");
cst_mcp_start_all("/tmp"); cst_mcp_start_all(g_tmp);
cst_mcp_wait_ready(10000); cst_mcp_wait_ready(10000);
CHECK(cst_tool_find("mcp__fake2__echo"), "second server ready"); CHECK(cst_tool_find("mcp__fake2__echo"), "second server ready");
t0 = os_now_ms(); t0 = os_now_ms();
+223
View File
@@ -0,0 +1,223 @@
/* Model registry: built-in data, longest/most specific match, the user file,
* the fetched cache and the env overrides. */
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_models.h"
#include "cst_test_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.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 void test_builtin(void) {
cJSON *doc = cJSON_Parse(cst_models_builtin_json());
EXPECT(cJSON_IsObject(doc), "embedded built-in JSON parses");
cJSON_Delete(doc);
EXPECT(cst_models_load_error()[0] == 0, "no load error");
EXPECT(cst_provider_count() >= 4, "built-in providers");
const cst_provider_t *p = cst_provider_at(0);
EXPECT(p && strcmp(p->name, "minimax") == 0, "file order kept");
p = cst_provider_find("deepseek");
EXPECT(p && strcmp(p->model, "deepseek-flash") == 0 &&
strcmp(p->key_env, "DEEPSEEK_API_KEY") == 0 &&
strcmp(cst_provider_source(p), "built-in") == 0,
"deepseek preset");
cst_model_info_t mi;
/* most specific rule per field: price from deepseek-v4-pro, context from
* deepseek */
cst_model_info("deepseek", "deepseek-v4-pro", &mi);
EXPECT(mi.context == 1000000 && mi.vision == 0, "context inherited");
EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half,
"longer match wins the price");
EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in");
cst_model_info(NULL, "gpt-4o-mini-2024", &mi);
EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1,
"gpt-4o-mini over gpt-4o");
cst_model_info(NULL, "Qwen/Qwen2.5-VL-72B-Instruct", &mi);
EXPECT(mi.vision == 1 && mi.context == 131072, "glob *-vl* + qwen prefix");
cst_model_info(NULL, "zhipu/glm-4.5v", &mi);
EXPECT(mi.vision == 1 && mi.context == 128000, "glm-*v*");
cst_model_info(NULL, "glm-4-plus", &mi);
EXPECT(mi.vision == -1, "glm without v: unknown");
cst_model_info(NULL, "anthropic/some-new-model", &mi);
EXPECT(mi.cache_style == CST_CACHE_ANTHROPIC, "vendor rule on the full id");
cst_model_info(NULL, "totally-unknown", &mi);
EXPECT(mi.context == 128000 && mi.vision == -1 && !mi.has_price &&
strcmp(mi.source, "default") == 0,
"defaults");
char tag[48];
cst_model_info(NULL, "gpt-4.1", &mi);
cst_model_info_label(&mi, tag, sizeof(tag));
EXPECT(strcmp(tag, "1M · vision") == 0, "label");
}
static const char *USER_JSON =
"{\"providers\": {"
" \"deepseek\": {\"api_base\": \"https://proxy.example/v1\"},"
" \"local\": {\"api_base\": \"http://127.0.0.1:8000/v1\","
" \"key_env\": \"LOCAL_KEY\", \"default_model\": \"qwen3-32b\","
" \"models\": [\"qwen3-32b\", \"my-vl\"]}"
"},"
"\"models\": ["
" {\"match\": \"qwen3\", \"context\": 32768},"
" {\"match\": \"my-vl\", \"vision\": true, \"price\": {\"miss\": 1,"
" \"out\": 2, \"currency\": \"¥\"}}"
"]}";
static void test_user(void) {
char err[256];
EXPECT(cst_models_load_json(cst_models_builtin_json(), USER_JSON, err,
sizeof(err)) == 0,
"load with user file");
const cst_provider_t *ds = cst_provider_find("deepseek");
EXPECT(ds && strcmp(ds->api_base, "https://proxy.example/v1") == 0 &&
strcmp(ds->model, "deepseek-flash") == 0 &&
strcmp(cst_provider_source(ds), "user") == 0,
"user overrides one field, keeps the rest");
const cst_provider_t *lp = cst_provider_find("local");
EXPECT(lp && strcmp(lp->model, "qwen3-32b") == 0 &&
strcmp(lp->key_env, "LOCAL_KEY") == 0,
"user adds a provider");
EXPECT(cst_provider_at(cst_provider_count() - 1) == lp, "appended last");
cst_model_info_t mi;
cst_model_info("local", "qwen3-32b", &mi);
EXPECT(mi.context == 32768 && strcmp(mi.source, "user") == 0,
"user rule beats built-in qwen");
cst_model_info("local", "my-vl", &mi);
EXPECT(mi.vision == 1 && mi.has_price && mi.price.hit == 1 &&
mi.price.write == 1 && strcmp(mi.price.currency, "¥") == 0,
"user price, hit/write default to miss");
/* passback follows the provider name even behind a proxy base */
cst_model_caps_t caps;
cst_model_caps("deepseek", "https://proxy.example/v1", "deepseek-flash",
&caps);
EXPECT(caps.reasoning_passback && caps.prefix_completion, "caps by name");
char names[32][128];
int n = cst_models_list(NULL, names, 32);
int seen = 0;
for (int i = 0; i < n; i++)
seen += strcmp(names[i], "my-vl") == 0;
EXPECT(seen == 1, "picker list includes user models");
EXPECT(cst_models_load_json(cst_models_builtin_json(), "{not json", err,
sizeof(err)) != 0 &&
err[0],
"bad user file reported");
}
static void test_cache(void) {
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
cst_model_meta_t m[3];
memset(m, 0, sizeof(m));
snprintf(m[0].id, sizeof(m[0].id), "anthropic/claude-sonnet-4");
m[0].context = 1000000;
m[0].vision = -1;
snprintf(m[1].id, sizeof(m[1].id), "deepseek-flash");
m[1].vision = 1; /* API claims vision */
m[1].has_price = 1;
m[1].miss = 0.5;
m[1].hit = 0.1;
m[1].write = 0.5;
m[1].out = 1;
snprintf(m[2].id, sizeof(m[2].id), "brand-new");
m[2].vision = -1;
EXPECT(cst_models_cache_save("teamorouter", "https://x/v1", m, 3) == 0,
"cache saved");
cst_model_info_t mi;
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
EXPECT(mi.context == 1000000, "API context beats built-in");
EXPECT(mi.vision == 1 && mi.cache_style == CST_CACHE_ANTHROPIC,
"missing API fields fall through to built-in");
EXPECT(strcmp(mi.source, "api+built-in") == 0, "source lists both");
cst_model_info("teamorouter", "deepseek-flash", &mi);
EXPECT(mi.vision == 1 && mi.price.miss == 0.5 &&
strcmp(mi.price.currency, "$") == 0,
"API vision and USD price");
cst_model_info("deepseek", "deepseek-flash", &mi);
EXPECT(mi.vision == 0 && mi.price.miss == 2.0,
"another provider's cache is not used");
EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000,
"no provider: any cache, exact id");
cst_model_meta_t back[8];
long age = -1;
int n = cst_models_cache_load("teamorouter", back, 8, &age);
EXPECT(n == 3 && age >= 0 && age < 60 &&
strcmp(back[2].id, "brand-new") == 0,
"cache reads back");
EXPECT(cst_models_cache_load("openai", back, 8, &age) == -1, "no cache");
EXPECT(cst_models_cache_save("../evil", NULL, m, 1) != 0,
"provider name cannot leave the cache dir");
/* a fresh process (reload) sees the file */
cst_models_reload();
cst_model_info("teamorouter", "anthropic/claude-sonnet-4", &mi);
EXPECT(mi.context == 1000000, "cache survives a reload");
}
static void test_env(void) {
cst_models_load_json(cst_models_builtin_json(), NULL, NULL, 0);
setenv("CST_CTX_TOKENS", "4242", 1);
setenv("CST_PRICE", "1,0.5,3", 1);
cst_model_info_t mi;
cst_model_info(NULL, "deepseek-flash", &mi);
EXPECT(mi.context == 4242 && mi.price.miss == 1 && mi.price.write == 1 &&
strstr(mi.source, "env"),
"env overrides");
unsetenv("CST_CTX_TOKENS");
unsetenv("CST_PRICE");
setenv("CST_NO_CACHE_HINTS", "1", 1);
cst_model_caps_t caps;
cst_model_caps("openai", NULL, "gpt-4.1", &caps);
EXPECT(caps.cache_style == CST_CACHE_AUTO && caps.vision == 1,
"no cache hints, vision kept");
unsetenv("CST_NO_CACHE_HINTS");
setenv("CST_MODELS", "a, b:c", 1);
char names[8][128];
int n = cst_models_list("cur", names, 8);
EXPECT(n == 4 && strcmp(names[1], "b") == 0 && strcmp(names[3], "cur") == 0,
"CST_MODELS list");
unsetenv("CST_MODELS");
}
int main(void) {
char root[1024];
if (test_tmpdir(root, sizeof(root), "cst-models") != 0) {
fprintf(stderr, "cannot create a scratch dir\n");
return 1;
}
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
unsetenv("CST_CTX_TOKENS");
unsetenv("CST_PRICE");
unsetenv("CST_MODELS");
unsetenv("CST_NO_CACHE_HINTS");
test_builtin();
test_user();
test_cache();
test_env();
test_rmrf(root);
if (nfail) {
fprintf(stderr, "test_model_registry: %d failed\n", nfail);
return 1;
}
printf("test_model_registry: OK\n");
return 0;
}
+89 -21
View File
@@ -3,23 +3,17 @@
#endif #endif
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_models.h"
#include "os_compat.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#ifdef _WIN32 #define CST_TEST_SOCKETS
int main(void) { #include "cst_test_util.h"
puts("test_models: skipped on Windows");
return 0;
}
#else
#include <arpa/inet.h>
#include <netinet/in.h>
#include <pthread.h> #include <pthread.h>
#include <sys/socket.h>
#include <unistd.h>
#define CHECK(c) \ #define CHECK(c) \
do { \ do { \
@@ -32,35 +26,38 @@ int main(void) {
/* One canned HTTP response per accepted connection. */ /* One canned HTTP response per accepted connection. */
static const char *g_responses[8]; static const char *g_responses[8];
static int g_nresp; static int g_nresp;
static int g_listen = -1; static test_sock_t g_listen = TEST_BAD_SOCK;
static char g_request[4096]; static char g_request[4096];
static void *serve(void *arg) { static void *serve(void *arg) {
(void)arg; (void)arg;
for (int i = 0; i < g_nresp; i++) { for (int i = 0; i < g_nresp; i++) {
int c = accept(g_listen, NULL, NULL); test_sock_t c = accept(g_listen, NULL, NULL);
if (c < 0) if (c == TEST_BAD_SOCK)
return NULL; return NULL;
ssize_t n = recv(c, g_request, sizeof(g_request) - 1, 0); long n = recv(c, g_request, sizeof(g_request) - 1, 0);
g_request[n > 0 ? n : 0] = 0; g_request[n > 0 ? n : 0] = 0;
const char *r = g_responses[i]; const char *r = g_responses[i];
ssize_t off = 0, len = (ssize_t)strlen(r); long off = 0, len = (long)strlen(r);
while (off < len) { while (off < len) {
ssize_t w = send(c, r + off, (size_t)(len - off), 0); long w = send(c, r + off, (int)(len - off), 0);
if (w <= 0) if (w <= 0)
break; break;
off += w; off += w;
} }
close(c); test_sock_close(c);
} }
return NULL; return NULL;
} }
static int start_server(void) { static int start_server(void) {
test_sock_init();
g_listen = socket(AF_INET, SOCK_STREAM, 0); g_listen = socket(AF_INET, SOCK_STREAM, 0);
CHECK(g_listen >= 0); CHECK(g_listen != TEST_BAD_SOCK);
#ifndef _WIN32
int one = 1; int one = 1;
setsockopt(g_listen, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); setsockopt(g_listen, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
#endif
struct sockaddr_in a; struct sockaddr_in a;
memset(&a, 0, sizeof(a)); memset(&a, 0, sizeof(a));
a.sin_family = AF_INET; a.sin_family = AF_INET;
@@ -77,6 +74,52 @@ static int start_server(void) {
"Connection: close\r\n\r\n" body "Connection: close\r\n\r\n" body
int main(void) { int main(void) {
char cfgdir[1024];
CHECK(test_tmpdir(cfgdir, sizeof(cfgdir), "cst-models-cfg") == 0);
setenv("XDG_CONFIG_HOME", cfgdir, 1); /* ignore a real models-cache */
const cst_provider_t *tr = cst_provider_find("teamorouter");
CHECK(tr && strcmp(tr->api_base, "https://api.teamorouter.com/v1") == 0);
CHECK(tr->model[0] == 0); /* picked from GET /models */
CHECK(cst_model_context_tokens("anthropic/claude-sonnet-4") == 200000);
CHECK(cst_model_context_tokens("deepseek-flash") == 1000000);
CHECK(cst_model_context_tokens("deepseek/deepseek-v4-pro") == 1000000);
CHECK(cst_model_context_tokens("google/gemini-2.5-pro") == 1048576);
CHECK(cst_model_context_tokens("unknown-model") == 128000);
cst_model_caps_t caps;
cst_model_caps("deepseek", "https://api.deepseek.com/v1", "deepseek-flash",
&caps);
CHECK(caps.reasoning_passback && caps.prefix_completion);
CHECK(caps.cache_style == CST_CACHE_AUTO);
cst_model_caps("teamorouter", "https://api.teamorouter.com/v1",
"deepseek-flash", &caps);
CHECK(!caps.reasoning_passback && !caps.prefix_completion);
cst_model_caps("teamorouter", NULL, "anthropic/claude-sonnet-4", &caps);
CHECK(caps.cache_style == CST_CACHE_ANTHROPIC);
cst_model_caps("openai", NULL, "gpt-4.1", &caps);
CHECK(caps.cache_style == CST_CACHE_OPENAI);
cst_model_caps("openai", NULL, "o3-mini", &caps);
CHECK(caps.cache_style == CST_CACHE_OPENAI);
cst_model_caps("minimax", NULL, "MiniMax-M2", &caps);
CHECK(caps.cache_style == CST_CACHE_AUTO);
/* Beijing time: Thu 10:00 peak, Thu 13:00 and Sat 10:00 off-peak */
CHECK(cst_price_is_peak((time_t)1791424800));
CHECK(!cst_price_is_peak((time_t)1791435600));
CHECK(!cst_price_is_peak((time_t)1791597600));
cst_price_t pr;
CHECK(cst_model_price("deepseek-flash", &pr) == 0);
CHECK(pr.offpeak_half && pr.hit < pr.miss);
double peak = cst_price_cost(&pr, 1000000, 1000000, 0, 1000000,
(time_t)1791424800);
CHECK(peak > 9.99 && peak < 10.05); /* 2 + 0.04 + 8 */
double off = cst_price_cost(&pr, 1000000, 1000000, 0, 1000000,
(time_t)1791435600);
CHECK(off > peak / 2 - 0.001 && off < peak / 2 + 0.001);
CHECK(cst_model_price("anthropic/claude-sonnet-4", &pr) == 0);
CHECK(pr.write > pr.miss && strcmp(pr.currency, "$") == 0);
CHECK(cst_model_price("MiniMax-M2", &pr) != 0);
g_responses[0] = RESP("200 OK", "{\"object\":\"list\",\"data\":[" g_responses[0] = RESP("200 OK", "{\"object\":\"list\",\"data\":["
"{\"id\":\"deepseek-flash\"}," "{\"id\":\"deepseek-flash\"},"
"{\"id\":\"deepseek-v4-pro\"}," "{\"id\":\"deepseek-v4-pro\"},"
@@ -86,7 +129,21 @@ int main(void) {
g_responses[3] = RESP("200 OK", "{\"models\":[{\"name\":\"m1\"}," g_responses[3] = RESP("200 OK", "{\"models\":[{\"name\":\"m1\"},"
"{\"name\":\"m2\"}]}"); "{\"name\":\"m2\"}]}");
g_responses[4] = RESP("500 Internal Server Error", "{}"); g_responses[4] = RESP("500 Internal Server Error", "{}");
g_nresp = 5; /* OpenRouter-style metadata; prices are USD per token, as strings */
g_responses[5] = RESP(
"200 OK",
"{\"data\":[{\"id\":\"anthropic/claude-sonnet-4\","
"\"context_length\":1000000,"
"\"architecture\":{\"input_modalities\":[\"text\",\"image\"]},"
"\"pricing\":{\"prompt\":\"0.000003\",\"completion\":\"0.000015\","
"\"input_cache_read\":\"0.0000003\"}},"
"{\"id\":\"old/text-model\","
"\"architecture\":{\"modality\":\"text->text\"},"
"\"pricing\":{\"prompt\":\"-1\",\"completion\":\"-1\"}},"
"{\"id\":\"vllm-model\",\"max_model_len\":32768,"
"\"modalities\":{\"input\":[\"text\"]}},"
"{\"id\":\"plain\"}]}");
g_nresp = 6;
int port = start_server(); int port = start_server();
pthread_t th; pthread_t th;
CHECK(pthread_create(&th, NULL, serve, NULL) == 0); CHECK(pthread_create(&th, NULL, serve, NULL) == 0);
@@ -117,14 +174,25 @@ int main(void) {
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err)); n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == -1 && strstr(err, "500") != NULL); CHECK(n == -1 && strstr(err, "500") != NULL);
cst_model_meta_t meta[8];
n = cst_llm_list_models_meta(base, "sk-test", meta, 8, err, sizeof(err));
CHECK(n == 4);
CHECK(meta[0].context == 1000000 && meta[0].vision == 1);
CHECK(meta[0].has_price && meta[0].miss > 2.99 && meta[0].miss < 3.01 &&
meta[0].out > 14.99 && meta[0].out < 15.01 && meta[0].hit < 0.31 &&
meta[0].write == meta[0].miss);
CHECK(meta[1].vision == 0 && !meta[1].has_price && meta[1].context == 0);
CHECK(meta[2].context == 32768 && meta[2].vision == 0);
CHECK(meta[3].vision == -1 && meta[3].context == 0 && !meta[3].has_price);
pthread_join(th, NULL); pthread_join(th, NULL);
close(g_listen); test_sock_close(g_listen);
/* nothing listening: network error, not an auth failure */ /* nothing listening: network error, not an auth failure */
n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err)); n = cst_llm_list_models(base, "sk-test", ids, 8, err, sizeof(err));
CHECK(n == -1); CHECK(n == -1);
test_rmrf(cfgdir);
puts("test_models: ok"); puts("test_models: ok");
return 0; return 0;
} }
#endif
+147
View File
@@ -0,0 +1,147 @@
/* cst_fnmatch and the os_dir / os_lstat layer that replaced the POSIX-only
* directory code; runs on every platform. */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cst_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.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 void fnm(const char *pat, const char *s, int flags, int want) {
int got = cst_fnmatch(pat, s, flags) == 0;
if (got != want) {
fprintf(stderr, "FAIL fnmatch(\"%s\", \"%s\", %d) = %d\n", pat, s, flags,
got);
nfail++;
}
}
static void test_fnmatch(void) {
fnm("*.c", "main.c", 0, 1);
fnm("*.c", "main.h", 0, 0);
fnm("*.c", "src/main.c", 0, 1); /* '*' crosses '/' */
fnm("?.txt", "a.txt", 0, 1);
fnm("?.txt", "ab.txt", 0, 0);
fnm("?.txt", "中.txt", 0, 1); /* one code point */
fnm("[abc]x", "bx", 0, 1);
fnm("[!abc]x", "bx", 0, 0);
fnm("[a-c]x", "cx", 0, 1);
fnm("[[:digit:]]*", "7up", 0, 1);
fnm("[[:digit:]]*", "up", 0, 0);
fnm("\\*", "*", 0, 1);
fnm("\\*", "a", 0, 0);
fnm("*", ".hidden", CST_FNM_PERIOD, 0);
fnm(".*", ".hidden", CST_FNM_PERIOD, 1);
fnm("*", ".hidden", 0, 1);
fnm("*文件*", "我的文件.txt", 0, 1);
fnm("README*", "readme.md", CST_FNM_CASEFOLD, 1);
#ifdef _WIN32
fnm("README*", "readme.md", 0, 1); /* Windows names are case-insensitive */
#else
fnm("README*", "readme.md", 0, 0);
#endif
fnm("", "", 0, 1);
fnm("a", "", 0, 0);
}
static void touch(const char *dir, const char *name) {
char p[1200];
snprintf(p, sizeof(p), "%s/%s", dir, name);
FILE *f = fopen(p, "wb");
if (f) {
fputs("x", f);
fclose(f);
}
}
static void test_dir(void) {
char root[1024], sub[1100], p[1200];
if (test_tmpdir(root, sizeof(root), "cst-osdir") != 0) {
EXPECT(0, "tmpdir");
return;
}
snprintf(sub, sizeof(sub), "%s/子目录", root);
EXPECT(test_mkdir(sub, 0755) == 0, "mkdir with a Chinese name");
touch(root, "a.txt");
touch(root, "文件 一.md");
touch(root, ".hidden");
int files = 0, dirs = 0, seen_cn = 0, seen_hidden = 0, dots = 0;
os_dir_t *d = os_dir_open(root);
EXPECT(d != NULL, "os_dir_open");
const os_dirent_t *e;
while (d && (e = os_dir_next(d)) != NULL) {
if (!strcmp(e->name, ".") || !strcmp(e->name, ".."))
dots++;
if (e->is_dir)
dirs++;
else
files++;
seen_cn |= strcmp(e->name, "文件 一.md") == 0 && !e->is_dir;
seen_hidden |= strcmp(e->name, ".hidden") == 0;
if (!strcmp(e->name, "子目录"))
EXPECT(e->is_dir && !e->is_link, "subdir flags");
}
os_dir_close(d);
EXPECT(dots == 0, "no . or .. entries");
EXPECT(files == 3 && dirs == 1, "entry counts");
EXPECT(seen_cn, "UTF-8 file name listed");
EXPECT(seen_hidden, "dot files listed");
d = os_dir_open(sub);
EXPECT(d != NULL && os_dir_next(d) == NULL, "empty Chinese subdir");
if (d)
os_dir_close(d);
os_stat_t st;
snprintf(p, sizeof(p), "%s/文件 一.md", root);
EXPECT(os_lstat(p, &st) == 0 && st.is_reg && !st.is_dir && st.size == 1,
"os_lstat on a UTF-8 file");
EXPECT(os_lstat(sub, &st) == 0 && st.is_dir, "os_lstat on a dir");
snprintf(p, sizeof(p), "%s/missing", root);
EXPECT(os_lstat(p, &st) != 0, "os_lstat on a missing path");
EXPECT(os_dir_open(p) == NULL, "os_dir_open on a missing path");
char real[1200], rr[1200];
EXPECT(os_realpath(sub, real, sizeof(real)) == 0 &&
os_realpath(root, rr, sizeof(rr)) == 0,
"os_realpath");
EXPECT(os_path_under(rr, real), "realpath of subdir is under root");
EXPECT(!os_path_under(real, rr), "root is not under subdir");
snprintf(p, sizeof(p), "%s/link", root);
int lk = test_symlink(sub, p);
if (lk == 0) {
EXPECT(os_lstat(p, &st) == 0 && st.is_link, "symlink is reported as link");
} else {
printf("test_os_dir: symlinks not permitted, link checks skipped\n");
}
test_rmrf(root);
EXPECT(os_lstat(root, &st) != 0, "test_rmrf removed everything");
}
int main(void) {
os_console_init();
test_fnmatch();
test_dir();
if (nfail) {
fprintf(stderr, "test_os_dir: %d failure(s)\n", nfail);
return 1;
}
puts("test_os_dir: OK");
return 0;
}
+8 -7
View File
@@ -14,6 +14,7 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
static int nfail; static int nfail;
@@ -33,11 +34,11 @@ static void put(const char *rel, const char *text) {
for (char *q = p + strlen(g_root) + 1; *q; q++) { for (char *q = p + strlen(g_root) + 1; *q; q++) {
if (*q == '/') { if (*q == '/') {
*q = 0; *q = 0;
mkdir(p, 0755); test_mkdir(p, 0755);
*q = '/'; *q = '/';
} }
} }
FILE *fp = fopen(p, "w"); FILE *fp = fopen(p, "wb");
if (fp) { if (fp) {
fputs(text, fp); fputs(text, fp);
fclose(fp); fclose(fp);
@@ -70,8 +71,10 @@ static void run(const char *ws, const char *tool, const char *k1,
} }
int main(void) { int main(void) {
snprintf(g_root, sizeof(g_root), "/tmp/cst-plugins-%d", (int)getpid()); if (test_tmpdir(g_root, sizeof(g_root), "cst-plugins") != 0) {
mkdir(g_root, 0755); perror("tmpdir");
return 1;
}
char ws[300], cfgdir[300]; char ws[300], cfgdir[300];
snprintf(ws, sizeof(ws), "%s/ws", g_root); snprintf(ws, sizeof(ws), "%s/ws", g_root);
snprintf(cfgdir, sizeof(cfgdir), "%s/cfg", g_root); snprintf(cfgdir, sizeof(cfgdir), "%s/cfg", g_root);
@@ -206,9 +209,7 @@ int main(void) {
CHECK(!cst_tool_find("skill") && !cst_tool_find("echo_args"), CHECK(!cst_tool_find("skill") && !cst_tool_find("echo_args"),
"extensions unload"); "extensions unload");
char cmd[400]; if (test_rmrf(g_root) != 0)
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_root);
if (system(cmd) != 0)
fprintf(stderr, "note: cleanup failed\n"); fprintf(stderr, "note: cleanup failed\n");
if (nfail) { if (nfail) {
fprintf(stderr, "test_plugins: %d failure(s)\n", nfail); fprintf(stderr, "test_plugins: %d failure(s)\n", nfail);
+392
View File
@@ -0,0 +1,392 @@
#define _POSIX_C_SOURCE 200809L
#include "cJSON.h"
#include "cst_agent.h"
#include "cst_util.h"
#include "os_compat.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "cst_test_util.h"
/* Runs a few replayed turns (tool call, Plan mode on, approval change, a
* reply cut off by max tokens) and checks the recorded request bodies: every
* request must extend the previous one byte for byte, so the provider's
* prefix cache keeps hitting. */
static int nfail;
#define EXPECT(cond, msg) \
do { \
if (!(cond)) { \
fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, msg); \
nfail++; \
} \
} while (0)
#define USAGE \
"data: {\"choices\":[],\"usage\":{\"prompt_tokens\":1000," \
"\"completion_tokens\":20,\"prompt_cache_hit_tokens\":900," \
"\"prompt_cache_miss_tokens\":100}}\n\n" \
"data: [DONE]\n\n"
#define TOOL(id, name, args) \
"data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0," \
"\"id\":\"" id "\",\"type\":\"function\",\"function\":{\"name\":\"" name \
"\",\"arguments\":\"" args "\"}}]},\"finish_reason\":null}]}\n\n" \
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":" \
"\"tool_calls\"}]}\n\n" USAGE
#undef TEXT /* <windows.h> has one */
#define TEXT(s, finish) \
"data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"" s "\"}," \
"\"finish_reason\":null}]}\n\n" \
"data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"" finish \
"\"}]}\n\n" USAGE
#define THINK(s) \
"data: {\"choices\":[{\"index\":0,\"delta\":{\"reasoning_content\":\"" s \
"\"},\"finish_reason\":null}]}\n\n"
static const char *FIXTURES[] = {
/* turn 1 */
THINK("look at the file") TOOL("c1", "read_file", "{\\\"path\\\":\\\"a.txt\\\"}"),
TEXT("done one", "stop"),
/* turn 2, Plan mode */
TOOL("c2", "write_file",
"{\\\"path\\\":\\\"b.txt\\\",\\\"content\\\":\\\"x\\\"}"),
TEXT("plan reply", "stop"),
/* turn 3, Build mode + yolo; cut off, then continued */
TEXT("part one", "length"),
TEXT(" part two", "stop"),
/* turn 4 */
TEXT("bye", "stop"),
};
#define NFIX ((int)(sizeof(FIXTURES) / sizeof(FIXTURES[0])))
static void write_file(const char *dir, const char *name, const char *body) {
char p[2048];
snprintf(p, sizeof(p), "%s/%s", dir, name);
FILE *fp = fopen(p, "wb");
EXPECT(fp != NULL, "write fixture");
if (fp) {
fputs(body, fp);
fclose(fp);
}
}
static cJSON *load_req(const char *dir, int seq) {
char p[2048];
snprintf(p, sizeof(p), "%s/%04d.req.json", dir, seq);
FILE *fp = fopen(p, "rb");
if (!fp)
return NULL;
cst_buf_t b = {0};
char buf[4096];
size_t n;
while ((n = fread(buf, 1, sizeof(buf), fp)) > 0)
cst_buf_append(&b, buf, n);
fclose(fp);
cJSON *j = b.buf ? cJSON_Parse(b.buf) : NULL;
cst_buf_free(&b);
return j;
}
static const cJSON *msgs_of(const cJSON *req) {
return cJSON_GetObjectItemCaseSensitive(req, "messages");
}
static int is_prefix_msg(const cJSON *m) {
return cJSON_IsTrue(cJSON_GetObjectItemCaseSensitive(m, "prefix"));
}
static const char *str_of(const cJSON *o, const char *key) {
const cJSON *v = cJSON_GetObjectItemCaseSensitive(o, key);
return cJSON_IsString(v) ? v->valuestring : NULL;
}
static char *print(const cJSON *j) { return j ? cJSON_PrintUnformatted(j) : NULL; }
/* messages of `a` (minus a request-only prefix message) start `b` */
static int extends(const cJSON *a, const cJSON *b) {
const cJSON *ma = msgs_of(a), *mb = msgs_of(b);
int na = cJSON_GetArraySize(ma), nb = cJSON_GetArraySize(mb);
if (na > 0 && is_prefix_msg(cJSON_GetArrayItem(ma, na - 1)))
na--;
if (na > nb)
return 0;
for (int i = 0; i < na; i++) {
char *x = print(cJSON_GetArrayItem(ma, i));
char *y = print(cJSON_GetArrayItem(mb, i));
int same = x && y && strcmp(x, y) == 0;
if (!same)
fprintf(stderr, " message %d differs:\n %.300s\n %.300s\n", i,
x ? x : "(null)", y ? y : "(null)");
free(x);
free(y);
if (!same)
return 0;
}
return 1;
}
static const cJSON *last_msg(const cJSON *req) {
const cJSON *m = msgs_of(req);
return cJSON_GetArrayItem(m, cJSON_GetArraySize(m) - 1);
}
static int any_msg_has(const cJSON *req, const char *key, const char *needle) {
const cJSON *m;
cJSON_ArrayForEach(m, msgs_of(req)) {
const char *s = str_of(m, key);
if (s && strstr(s, needle))
return 1;
}
return 0;
}
static int run(cst_agent_t *a, const char *text) {
char err[512];
int rc = cst_agent_run_turn(a, text, err, sizeof(err));
if (rc != 0)
fprintf(stderr, " turn \"%s\": %s\n", text, err);
return rc;
}
/* 8-byte PNG signature + IHDR header: enough for the attachment sniffer. */
static size_t tiny_png(unsigned char *b) {
static const unsigned char sig[8] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
'\n'};
memset(b, 0, 32);
memcpy(b, sig, 8);
b[11] = 13;
memcpy(b + 12, "IHDR", 4);
b[19] = 2; /* 2 x 2 */
b[23] = 2;
return 32;
}
static int tools_have(const cJSON *req, const char *name) {
char *t = print(cJSON_GetObjectItemCaseSensitive(req, "tools"));
int has = t && strstr(t, name) != NULL;
free(t);
return has;
}
static int has_image_url(const cJSON *req) {
char *t = print(msgs_of(req));
int has = t && strstr(t, "image_url") != NULL;
free(t);
return has;
}
/* A text-only model never sees images or view_image; a model of unknown
* vision that answers 400 to an image is retried with notes, and stays
* that way so later requests extend the retried one. */
static void test_vision(const char *root) {
char dir[1100], sse[1200], rec[1200];
snprintf(dir, sizeof(dir), "%s/vision", root);
snprintf(sse, sizeof(sse), "%s/sse", dir);
snprintf(rec, sizeof(rec), "%s/rec", dir);
os_mkdir_p(sse, 0755);
write_file(sse, "0000.sse", TEXT("blind reply", "stop"));
write_file(sse, "0001.sse", "{\"error\":{\"message\":\"image input is not "
"supported\"}}");
write_file(sse, "0001.status", "400\n");
write_file(sse, "0002.sse", TEXT("noted reply", "stop"));
write_file(sse, "0003.sse", TEXT("later reply", "stop"));
setenv("CST_REPLAY_SSE", sse, 1);
setenv("CST_RECORD_SSE", rec, 1);
cst_llm_trace_reset();
unsigned char png[32];
size_t pn = tiny_png(png);
char err[256];
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/blind.jsonl", dir);
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = 4;
cfg.headless = 1;
cfg.approval = CST_APPROVAL_AUTO;
cst_agent_t *a = cst_agent_create(&cfg);
EXPECT(a != NULL, "blind agent");
if (!a)
return;
EXPECT(cst_agent_sees_images(a) == 0, "deepseek-flash is text only");
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
sizeof(err)) == 0,
"attach to a blind model");
EXPECT(run(a, "what is in the picture?") == 0, "blind turn");
cst_agent_destroy(a);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/unknown.jsonl",
dir);
snprintf(cfg.model, sizeof(cfg.model), "mystery-model-9");
snprintf(cfg.provider, sizeof(cfg.provider), "teamorouter");
a = cst_agent_create(&cfg);
EXPECT(a != NULL, "unknown-vision agent");
if (!a)
return;
EXPECT(cst_agent_sees_images(a) != 0, "unknown vision: images are tried");
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
sizeof(err)) == 0,
"attach to an unknown model");
EXPECT(run(a, "describe it") == 0, "400 retried without images");
EXPECT(run(a, "and now?") == 0, "next turn");
cst_agent_destroy(a);
cJSON *r[4];
for (int i = 0; i < 4; i++) {
r[i] = load_req(rec, i);
EXPECT(r[i] != NULL, "vision request recorded");
}
if (r[0] && r[1] && r[2] && r[3]) {
EXPECT(!tools_have(r[0], "view_image"), "blind: no view_image tool");
EXPECT(!has_image_url(r[0]), "blind: no image part");
const char *c = str_of(last_msg(r[0]), "content");
EXPECT(c && strstr(c, "what is in the picture?") &&
strstr(c, "not sent: this model does not take images"),
"blind: the image becomes a note");
EXPECT(tools_have(r[1], "view_image") && has_image_url(r[1]),
"unknown: first try sends the image");
EXPECT(!tools_have(r[2], "view_image") && !has_image_url(r[2]),
"retry: no image, no view_image");
c = str_of(last_msg(r[2]), "content");
EXPECT(c && strstr(c, "not sent"), "retry: note instead");
EXPECT(!has_image_url(r[3]) && extends(r[2], r[3]),
"images stay off; the next request extends the retry");
}
for (int i = 0; i < 4; i++)
cJSON_Delete(r[i]);
}
int main(void) {
char root[1024], sse[1100], rec[1100];
if (test_tmpdir(root, sizeof(root), "cst-prefix") != 0) {
fprintf(stderr, "cannot create a scratch dir\n");
return 1;
}
snprintf(sse, sizeof(sse), "%s/sse", root);
snprintf(rec, sizeof(rec), "%s/rec", root);
os_mkdir_p(sse, 0755);
for (int i = 0; i < NFIX; i++) {
char name[32];
snprintf(name, sizeof(name), "%04d.sse", i);
write_file(sse, name, FIXTURES[i]);
}
write_file(root, "a.txt", "hello\n");
setenv("CST_REPLAY_SSE", sse, 1);
setenv("CST_RECORD_SSE", rec, 1);
setenv("CST_MCP_WAIT_MS", "0", 1);
setenv("XDG_CONFIG_HOME", root, 1); /* no real models.json or caches */
unsetenv("CST_CTX_WINDOW");
unsetenv("CST_NO_CACHE_HINTS");
unsetenv("CST_PRICE");
cst_llm_trace_reset();
cst_agent_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/s.jsonl", root);
snprintf(cfg.api_base, sizeof(cfg.api_base), "http://replay/v1");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash");
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = 8;
cfg.headless = 1;
cfg.approval = CST_APPROVAL_AUTO;
cst_agent_t *a = cst_agent_create(&cfg);
EXPECT(a != NULL, "agent created");
if (!a)
return 1;
EXPECT(run(a, "read a.txt") == 0, "turn 1");
cst_agent_config_mut(a)->plan_mode = 1;
EXPECT(run(a, "now write b.txt") == 0, "turn 2 (plan)");
cst_agent_config_mut(a)->plan_mode = 0;
cst_agent_config_mut(a)->approval = CST_APPROVAL_YOLO;
EXPECT(run(a, "a long answer please") == 0, "turn 3 (continued)");
EXPECT(run(a, "bye") == 0, "turn 4");
char bpath[1100];
snprintf(bpath, sizeof(bpath), "%s/b.txt", root);
EXPECT(access(bpath, F_OK) != 0, "Plan mode refused write_file");
cJSON *req[NFIX];
for (int i = 0; i < NFIX; i++) {
req[i] = load_req(rec, i);
EXPECT(req[i] != NULL, "request recorded");
}
if (req[NFIX - 1]) {
char *tools0 = print(cJSON_GetObjectItemCaseSensitive(req[0], "tools"));
char *sys0 = print(cJSON_GetArrayItem(msgs_of(req[0]), 0));
EXPECT(tools0 && strstr(tools0, "write_file"),
"write tools stay in the schema");
for (int i = 0; i < NFIX; i++) {
char *tools = print(cJSON_GetObjectItemCaseSensitive(req[i], "tools"));
char *sys = print(cJSON_GetArrayItem(msgs_of(req[i]), 0));
EXPECT(tools && tools0 && strcmp(tools, tools0) == 0,
"tools[] identical in every request");
EXPECT(sys && sys0 && strcmp(sys, sys0) == 0,
"system prompt identical in every request");
const char *tc = str_of(req[i], "tool_choice");
EXPECT(tc && strcmp(tc, "auto") == 0, "tool_choice stays auto");
free(tools);
free(sys);
if (i + 1 < NFIX && !extends(req[i], req[i + 1])) {
fprintf(stderr, " request %d does not extend request %d\n", i + 1, i);
nfail++;
}
}
free(tools0);
free(sys0);
EXPECT(any_msg_has(req[1], "reasoning_content", "look at the file"),
"DeepSeek reasoning passed back");
EXPECT(strstr(str_of(last_msg(req[2]), "content"), "Plan mode is now ON"),
"Plan mode announced by a note");
EXPECT(any_msg_has(req[3], "content", "Plan mode is on"),
"write_file denied with a Plan mode error");
const char *t3 = str_of(last_msg(req[4]), "content");
EXPECT(t3 && strstr(t3, "Plan mode is now OFF") &&
strstr(t3, "Approval mode is now yolo"),
"Build mode and approval change announced");
const cJSON *pm = last_msg(req[5]);
EXPECT(is_prefix_msg(pm) && strcmp(str_of(pm, "role"), "assistant") == 0 &&
strcmp(str_of(pm, "content"), "part one") == 0,
"cut-off reply continued through an assistant prefix");
EXPECT(any_msg_has(req[6], "content", "part one part two"),
"continued reply stored as one message");
}
for (int i = 0; i < NFIX; i++)
cJSON_Delete(req[i]);
cst_usage_t s, turn;
cst_agent_usage(a, &s, &turn);
EXPECT(s.requests == NFIX, "every response counted");
EXPECT(s.prompt == 1000L * NFIX && s.cached == 900L * NFIX &&
s.miss == 100L * NFIX,
"hit and miss tokens summed");
EXPECT(s.priced && s.cost > 0 && strcmp(s.currency, "¥") == 0,
"DeepSeek cost estimated");
EXPECT(turn.requests == 1, "last turn usage");
cst_agent_destroy(a);
test_vision(root);
test_rmrf(root);
if (nfail) {
fprintf(stderr, "test_prefix_stable: %d failed\n", nfail);
return 1;
}
printf("test_prefix_stable: OK\n");
return 0;
}
+2 -1
View File
@@ -8,6 +8,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
/* /*
* Truncation experiment on recorded model streams (tests/fixtures/sse). * Truncation experiment on recorded model streams (tests/fixtures/sse).
@@ -79,7 +80,7 @@ static int load_trace(const char *path, trace_t *t) {
char *line = NULL; char *line = NULL;
size_t cap = 0; size_t cap = 0;
ssize_t n; ssize_t n;
while ((n = getline(&line, &cap, fp)) > 0) { while ((n = test_getline(&line, &cap, fp)) > 0) {
if (strncmp(line, "data:", 5) != 0) if (strncmp(line, "data:", 5) != 0)
continue; continue;
cJSON *j = cJSON_Parse(line + 5); cJSON *j = cJSON_Parse(line + 5);
+54 -9
View File
@@ -72,7 +72,44 @@ static void test_blocked(void) {
CHECK(cst_sandbox_blocked(c, strlen(c), 1)); CHECK(cst_sandbox_blocked(c, strlen(c), 1));
const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 " const char *d = "curl: (7) Failed to connect to example.com port 443 after 9 "
"ms: Could not connect to server\n"; "ms: Could not connect to server\n";
CHECK(cst_sandbox_blocked(d, strlen(d), 7)); CHECK(!cst_sandbox_blocked(d, strlen(d), 7)); /* network is not sandboxed */
const char *e = "Access is denied.\r\n";
CHECK(cst_sandbox_blocked(e, strlen(e), 1));
const char *f = "拒绝访问。\r\n";
CHECK(cst_sandbox_blocked(f, strlen(f), 1));
const char *g = "sh: C:/msys64/usr/bin/ls.exe: A dynamic link library (DLL) "
"initialization routine failed. Error 0xc0000142\n";
CHECK(cst_sandbox_blocked(g, strlen(g), 1));
}
static void test_network_rules(void) {
static const char *const NET[] = {
"curl -fsSL https://example.com",
"cd x && wget http://a/b",
"/usr/bin/ssh host ls",
"C:\\tools\\curl.exe -O x",
"git push origin main",
"git --no-pager fetch",
"npm install",
"python -m pip install requests",
"go get example.com/x",
"cargo install ripgrep",
"Invoke-WebRequest -Uri x",
};
static const char *const LOCAL[] = {
"ls -la", "git status", "git commit -m push", "npm run build",
"make -j8", "pip list", "echo curly", "cat curl.txt",
};
for (size_t i = 0; i < sizeof(NET) / sizeof(NET[0]); i++) {
if (!cst_shell_uses_network(NET[i]))
fprintf(stderr, "expected network: %s\n", NET[i]);
CHECK(cst_shell_uses_network(NET[i]));
}
for (size_t i = 0; i < sizeof(LOCAL) / sizeof(LOCAL[0]); i++) {
if (cst_shell_uses_network(LOCAL[i]))
fprintf(stderr, "expected local: %s\n", LOCAL[i]);
CHECK(!cst_shell_uses_network(LOCAL[i]));
}
} }
#if !defined(_WIN32) #if !defined(_WIN32)
@@ -95,7 +132,7 @@ static int run(const char *ws, const char *cmd, os_run_result_t *r) {
return r->exit_code; return r->exit_code;
} }
/* Child: enter the sandbox, then try a TCP connect to a local listener. */ /* Child: enter the sandbox, then a TCP connect to a local listener works. */
static void test_network(const char *ws) { static void test_network(const char *ws) {
int ls = socket(AF_INET, SOCK_STREAM, 0); int ls = socket(AF_INET, SOCK_STREAM, 0);
CHECK(ls >= 0); CHECK(ls >= 0);
@@ -115,12 +152,11 @@ static void test_network(const char *ws) {
if (pid == 0) { if (pid == 0) {
if (cst_sandbox_enter(&plan) != 0) if (cst_sandbox_enter(&plan) != 0)
_exit(3); _exit(3);
/* Landlock refuses the connect, seccomp already the socket */
int s = socket(AF_INET, SOCK_STREAM, 0); int s = socket(AF_INET, SOCK_STREAM, 0);
if (s < 0) if (s < 0)
_exit(errno == EACCES ? 0 : 2); _exit(errno == EACCES ? 1 : 2);
int rc = connect(s, (struct sockaddr *)&a, sizeof(a)); int rc = connect(s, (struct sockaddr *)&a, sizeof(a));
_exit(rc == 0 ? 1 : errno == EACCES ? 0 : 2); _exit(rc == 0 ? 0 : errno == EACCES ? 1 : 2);
} }
int st = 0; int st = 0;
waitpid(pid, &st, 0); waitpid(pid, &st, 0);
@@ -300,11 +336,7 @@ static void test_confined(int kind) {
CHECK(fp); CHECK(fp);
fclose(fp); fclose(fp);
if (kind == CST_SANDBOX_SECCOMP || cst_sandbox_landlock_abi() >= 4)
test_network(ws); test_network(ws);
else
printf("test_sandbox: landlock ABI %d < 4, network rule not tested\n",
cst_sandbox_landlock_abi());
snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", ws, out); snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", ws, out);
CHECK(system(cmd) == 0); CHECK(system(cmd) == 0);
@@ -314,6 +346,7 @@ static void test_confined(int kind) {
int main(void) { int main(void) {
test_rules(); test_rules();
test_blocked(); test_blocked();
test_network_rules();
int kind = cst_sandbox_probe(); int kind = cst_sandbox_probe();
printf("test_sandbox: sandbox %s\n", cst_sandbox_name(kind)); printf("test_sandbox: sandbox %s\n", cst_sandbox_name(kind));
#if !defined(_WIN32) #if !defined(_WIN32)
@@ -329,6 +362,18 @@ int main(void) {
printf("test_sandbox: forced seccomp\n"); printf("test_sandbox: forced seccomp\n");
test_confined(CST_SANDBOX_SECCOMP); test_confined(CST_SANDBOX_SECCOMP);
} else { } else {
FILE *yf = fopen("/proc/sys/kernel/yama/ptrace_scope", "r");
int yama = -1;
if (yf) {
if (fscanf(yf, "%d", &yama) != 1)
yama = -1;
fclose(yf);
}
if (yama >= 2)
printf("test_sandbox: seccomp not available here "
"(yama.ptrace_scope=%d; cannot peek child memory)\n",
yama);
else
printf("test_sandbox: seccomp not available here\n"); printf("test_sandbox: seccomp not available here\n");
} }
if (was) if (was)
+7 -3
View File
@@ -2,6 +2,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
#include "os_compat.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -76,8 +77,11 @@ static void check_messages(cst_session_t *s) {
} }
int main(void) { int main(void) {
char path[256]; char path[1024], tmp[900];
snprintf(path, sizeof(path), "/tmp/cst-session-test-%d.jsonl", (int)getpid()); if (os_temp_dir(tmp, sizeof(tmp)) != 0)
snprintf(tmp, sizeof(tmp), ".");
snprintf(path, sizeof(path), "%s/cst-session-test-%d.jsonl", tmp,
os_getpid());
remove(path); remove(path);
cst_session_t *s = cst_session_open(path); cst_session_t *s = cst_session_open(path);
@@ -129,7 +133,7 @@ int main(void) {
cst_session_close(s); cst_session_close(s);
/* legacy format: tool_calls_json stored as a string */ /* legacy format: tool_calls_json stored as a string */
fp = fopen(path, "w"); fp = fopen(path, "wb");
fprintf(fp, "{\"role\":\"assistant\",\"content\":\"\",\"tool_calls_json\":" fprintf(fp, "{\"role\":\"assistant\",\"content\":\"\",\"tool_calls_json\":"
"\"[{\\\"id\\\":\\\"c9\\\",\\\"type\\\":\\\"function\\\"," "\"[{\\\"id\\\":\\\"c9\\\",\\\"type\\\":\\\"function\\\","
"\\\"function\\\":{\\\"name\\\":\\\"run_shell\\\"," "\\\"function\\\":{\\\"name\\\":\\\"run_shell\\\","
+6 -5
View File
@@ -8,6 +8,7 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
/* run_shell must never hang the agent: timeout kills the whole process /* run_shell must never hang the agent: timeout kills the whole process
* group, stdin is empty, huge output is capped. */ * group, stdin is empty, huge output is capped. */
@@ -41,8 +42,10 @@ static int sh(const char *cmd, cst_tool_result_t *r, double *ms) {
} }
int main(void) { int main(void) {
snprintf(g_ws, sizeof(g_ws), "/tmp/cst-shell-test-%d", (int)getpid()); if (test_tmpdir(g_ws, sizeof(g_ws), "cst-shell-test") != 0) {
mkdir(g_ws, 0755); perror("tmpdir");
return 1;
}
cst_tool_result_t r; cst_tool_result_t r;
double ms; double ms;
@@ -81,9 +84,7 @@ int main(void) {
EXPECT(ms < 20000, "large output does not hang"); EXPECT(ms < 20000, "large output does not hang");
cst_tool_result_free(&r); cst_tool_result_free(&r);
char cmd[300]; if (test_rmrf(g_ws) != 0)
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_ws);
if (system(cmd) != 0)
fprintf(stderr, "warning: cleanup failed\n"); fprintf(stderr, "warning: cleanup failed\n");
if (nfail) { if (nfail) {
fprintf(stderr, "test_shell: %d failed\n", nfail); fprintf(stderr, "test_shell: %d failed\n", nfail);
+10 -9
View File
@@ -11,6 +11,7 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
static int nfail; static int nfail;
@@ -30,11 +31,11 @@ static void put(const char *rel, const char *text) {
for (char *q = p + strlen(g_root) + 1; *q; q++) { for (char *q = p + strlen(g_root) + 1; *q; q++) {
if (*q == '/') { if (*q == '/') {
*q = 0; *q = 0;
mkdir(p, 0755); test_mkdir(p, 0755);
*q = '/'; *q = '/';
} }
} }
FILE *fp = fopen(p, "w"); FILE *fp = fopen(p, "wb");
if (fp) { if (fp) {
fputs(text, fp); fputs(text, fp);
fclose(fp); fclose(fp);
@@ -69,8 +70,10 @@ static void frontmatter(void) {
int main(void) { int main(void) {
frontmatter(); frontmatter();
snprintf(g_root, sizeof(g_root), "/tmp/cst-skills-%d", (int)getpid()); if (test_tmpdir(g_root, sizeof(g_root), "cst-skills") != 0) {
mkdir(g_root, 0755); perror("tmpdir");
return 1;
}
char ws[300], cfgdir[300]; char ws[300], cfgdir[300];
snprintf(ws, sizeof(ws), "%s/ws", g_root); snprintf(ws, sizeof(ws), "%s/ws", g_root);
snprintf(cfgdir, sizeof(cfgdir), "%s/cfg", g_root); snprintf(cfgdir, sizeof(cfgdir), "%s/cfg", g_root);
@@ -94,8 +97,8 @@ int main(void) {
char link[512], target[512]; char link[512], target[512];
snprintf(link, sizeof(link), "%s/ws/.agent/skills/forms/leak", g_root); snprintf(link, sizeof(link), "%s/ws/.agent/skills/forms/leak", g_root);
snprintf(target, sizeof(target), "%s/ws/secret.txt", g_root); snprintf(target, sizeof(target), "%s/ws/secret.txt", g_root);
if (symlink(target, link) != 0) if (test_symlink(target, link) != 0)
perror("symlink"); fprintf(stderr, "note: no symlinks here, escape check is partial\n");
} }
cst_skill_t v[CST_SKILLS_MAX]; cst_skill_t v[CST_SKILLS_MAX];
@@ -180,9 +183,7 @@ int main(void) {
CHECK(sp && !strstr(sp, "Skills ("), "minimal prompt unchanged"); CHECK(sp && !strstr(sp, "Skills ("), "minimal prompt unchanged");
free(sp); free(sp);
char cmd[400]; if (test_rmrf(g_root) != 0)
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_root);
if (system(cmd) != 0)
fprintf(stderr, "note: cleanup failed\n"); fprintf(stderr, "note: cleanup failed\n");
if (nfail) { if (nfail) {
fprintf(stderr, "test_skills: %d failure(s)\n", nfail); fprintf(stderr, "test_skills: %d failure(s)\n", nfail);
+3 -6
View File
@@ -7,6 +7,7 @@
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
/* Drives cst_llm_chat_stream through CST_REPLAY_SSE with synthetic and /* Drives cst_llm_chat_stream through CST_REPLAY_SSE with synthetic and
* recorded (tests/fixtures/sse) streams, at several network chunk sizes. */ * recorded (tests/fixtures/sse) streams, at several network chunk sizes. */
@@ -279,18 +280,14 @@ static void test_recorded(void) {
} }
int main(void) { int main(void) {
snprintf(g_dir, sizeof(g_dir), "/tmp/cst-sse-test-%d", (int)getpid()); if (test_tmpdir(g_dir, sizeof(g_dir), "cst-sse-test") != 0)
char cmd[300];
snprintf(cmd, sizeof(cmd), "mkdir -p %s", g_dir);
if (system(cmd) != 0)
return 1; return 1;
test_text_reasoning_usage(); test_text_reasoning_usage();
test_parallel_calls(); test_parallel_calls();
test_same_index_new_id(); test_same_index_new_id();
test_invalid_args_and_error(); test_invalid_args_and_error();
test_recorded(); test_recorded();
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir); if (test_rmrf(g_dir) != 0)
if (system(cmd) != 0)
fprintf(stderr, "warning: cleanup failed\n"); fprintf(stderr, "warning: cleanup failed\n");
if (nfail) { if (nfail) {
fprintf(stderr, "test_sse_decode: %d failed\n", nfail); fprintf(stderr, "test_sse_decode: %d failed\n", nfail);
+68 -45
View File
@@ -15,19 +15,17 @@
#include "cst_util.h" #include "cst_util.h"
#include "os_compat.h" #include "os_compat.h"
#include <arpa/inet.h> #define CST_TEST_SOCKETS
#include "cst_test_util.h"
#include <ctype.h>
#include <curl/curl.h> #include <curl/curl.h>
#include <netinet/in.h>
#include <pthread.h> #include <pthread.h>
#include <signal.h>
#include <stdatomic.h> #include <stdatomic.h>
#include <stdint.h> #include <stdint.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <sys/socket.h>
#include <sys/stat.h>
#include <unistd.h>
static int nfail; static int nfail;
@@ -45,9 +43,9 @@ static atomic_int g_child_active, g_child_max, g_child_reqs;
static atomic_int g_child_saw_task, g_child_saw_write, g_child_role_prompt; static atomic_int g_child_saw_task, g_child_saw_write, g_child_role_prompt;
static atomic_int g_parent_saw_task; static atomic_int g_parent_saw_task;
static void send_all(int fd, const char *s, size_t n) { static void send_all(test_sock_t fd, const char *s, size_t n) {
while (n > 0) { while (n > 0) {
ssize_t w = send(fd, s, n, MSG_NOSIGNAL); long w = send(fd, s, (int)n, MSG_NOSIGNAL);
if (w <= 0) if (w <= 0)
return; return;
s += w; s += w;
@@ -55,7 +53,21 @@ static void send_all(int fd, const char *s, size_t n) {
} }
} }
static void sse(int fd, cJSON *delta, const char *finish) { /* Portable Content-Length lookup (avoid GNU strcasestr; undeclared under
* strict C11 it is assumed to return int and the pointer truncates). */
static const char *find_content_length(const char *hdrs) {
static const char key[] = "content-length:";
for (const char *p = hdrs; *p; p++) {
size_t i = 0;
while (key[i] && tolower((unsigned char)p[i]) == (unsigned char)key[i])
i++;
if (key[i] == '\0')
return p + (sizeof(key) - 1);
}
return NULL;
}
static void sse(test_sock_t fd, cJSON *delta, const char *finish) {
cJSON *o = cJSON_CreateObject(); cJSON *o = cJSON_CreateObject();
cJSON *ch = cJSON_AddArrayToObject(o, "choices"); cJSON *ch = cJSON_AddArrayToObject(o, "choices");
cJSON *c0 = cJSON_CreateObject(); cJSON *c0 = cJSON_CreateObject();
@@ -80,7 +92,7 @@ static void sse(int fd, cJSON *delta, const char *finish) {
cJSON_Delete(o); cJSON_Delete(o);
} }
static void reply_text(int fd, const char *text) { static void reply_text(test_sock_t fd, const char *text) {
cJSON *d = cJSON_CreateObject(); cJSON *d = cJSON_CreateObject();
cJSON_AddStringToObject(d, "role", "assistant"); cJSON_AddStringToObject(d, "role", "assistant");
cJSON_AddStringToObject(d, "content", text); cJSON_AddStringToObject(d, "content", text);
@@ -90,13 +102,13 @@ static void reply_text(int fd, const char *text) {
} }
/* calls: {name, args_json} pairs */ /* calls: {name, args_json} pairs */
static void reply_calls(int fd, const char *const *calls, int n) { static void reply_calls(test_sock_t fd, const char *const *calls, int n) {
for (int i = 0; i < n; i++) { for (int i = 0; i < n; i++) {
cJSON *d = cJSON_CreateObject(); cJSON *d = cJSON_CreateObject();
cJSON *tc = cJSON_AddArrayToObject(d, "tool_calls"); cJSON *tc = cJSON_AddArrayToObject(d, "tool_calls");
cJSON *c = cJSON_CreateObject(); cJSON *c = cJSON_CreateObject();
cJSON_AddNumberToObject(c, "index", i); cJSON_AddNumberToObject(c, "index", i);
char id[16]; char id[32];
snprintf(id, sizeof(id), "call_%d", i); snprintf(id, sizeof(id), "call_%d", i);
cJSON_AddStringToObject(c, "id", id); cJSON_AddStringToObject(c, "id", id);
cJSON_AddStringToObject(c, "type", "function"); cJSON_AddStringToObject(c, "type", "function");
@@ -121,21 +133,32 @@ static int has_tool(const cJSON *body, const char *name) {
return 0; return 0;
} }
static void handle(int fd, const char *body_txt) { static void handle(test_sock_t fd, const char *body_txt) {
cJSON *body = cJSON_Parse(body_txt); cJSON *body = cJSON_Parse(body_txt);
if (!body) {
const char *err = "HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n";
send_all(fd, err, strlen(err));
return;
}
const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(body, "messages"); const cJSON *msgs = cJSON_GetObjectItemCaseSensitive(body, "messages");
int nm = cJSON_GetArraySize(msgs); int nm = cJSON_IsArray(msgs) ? cJSON_GetArraySize(msgs) : 0;
const cJSON *sys = cJSON_GetObjectItemCaseSensitive( const cJSON *sys = NULL;
cJSON_GetArrayItem(msgs, 0), "content"); const char *last_role = "";
const cJSON *last = cJSON_GetArrayItem(msgs, nm - 1);
const char *last_role =
cJSON_GetObjectItemCaseSensitive(last, "role")->valuestring;
const char *user = ""; const char *user = "";
if (nm > 0) {
sys = cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(msgs, 0),
"content");
const cJSON *last = cJSON_GetArrayItem(msgs, nm - 1);
const cJSON *lr = cJSON_GetObjectItemCaseSensitive(last, "role");
if (cJSON_IsString(lr))
last_role = lr->valuestring;
}
for (int i = 0; i < nm; i++) { for (int i = 0; i < nm; i++) {
const cJSON *m = cJSON_GetArrayItem(msgs, i); const cJSON *m = cJSON_GetArrayItem(msgs, i);
const cJSON *r = cJSON_GetObjectItemCaseSensitive(m, "role"); const cJSON *r = cJSON_GetObjectItemCaseSensitive(m, "role");
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content"); const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (strcmp(r->valuestring, "user") == 0 && cJSON_IsString(c)) if (cJSON_IsString(r) && strcmp(r->valuestring, "user") == 0 &&
cJSON_IsString(c))
user = c->valuestring; user = c->valuestring;
} }
const char *hdr = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n" const char *hdr = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n"
@@ -155,16 +178,18 @@ static void handle(int fd, const char *body_txt) {
if (strstr(sys->valuestring, "Role instructions:\nYou only read.")) if (strstr(sys->valuestring, "Role instructions:\nYou only read."))
atomic_store(&g_child_role_prompt, 1); atomic_store(&g_child_role_prompt, 1);
if (strstr(user, "Slow")) { if (strstr(user, "Slow")) {
usleep(4000 * 1000); /* cancelled before this ends */ test_sleep_ms(4000); /* cancelled before this ends */
} else { } else {
usleep(300 * 1000); test_sleep_ms(300);
} }
if (strcmp(last_role, "user") == 0) { if (strcmp(last_role, "user") == 0) {
const char *calls[] = {"read_file", "{\"path\":\"a.txt\"}"}; const char *calls[] = {"read_file", "{\"path\":\"a.txt\"}"};
reply_calls(fd, calls, 1); reply_calls(fd, calls, 1);
} else { } else {
char out[256]; /* last char of the user prompt (e.g. "Find A" -> "A"); empty-safe */
snprintf(out, sizeof(out), "child report %s", user + strlen(user) - 1); char tag = user[0] ? user[strlen(user) - 1] : '?';
char out[64];
snprintf(out, sizeof(out), "child report %c", tag);
reply_text(fd, out); reply_text(fd, out);
} }
atomic_fetch_sub(&g_child_active, 1); atomic_fetch_sub(&g_child_active, 1);
@@ -195,7 +220,7 @@ static void handle(int fd, const char *body_txt) {
const char *rep = cJSON_IsString(c) ? strstr(c->valuestring, const char *rep = cJSON_IsString(c) ? strstr(c->valuestring,
"child report ") "child report ")
: NULL; : NULL;
if (strcmp(r->valuestring, "tool") == 0 && rep) if (cJSON_IsString(r) && strcmp(r->valuestring, "tool") == 0 && rep)
cst_buf_printf(&b, " [%.14s]", rep); cst_buf_printf(&b, " [%.14s]", rep);
} }
reply_text(fd, b.buf); reply_text(fd, b.buf);
@@ -206,13 +231,13 @@ static void handle(int fd, const char *body_txt) {
} }
static void *conn_main(void *p) { static void *conn_main(void *p) {
int fd = (int)(intptr_t)p; test_sock_t fd = (test_sock_t)(intptr_t)p;
cst_buf_t req = {0}; cst_buf_t req = {0};
char tmp[8192]; char tmp[8192];
long need = -1; long need = -1;
size_t hdr_end = 0; size_t hdr_end = 0;
for (;;) { for (;;) {
ssize_t n = recv(fd, tmp, sizeof(tmp), 0); long n = recv(fd, tmp, sizeof(tmp), 0);
if (n <= 0) if (n <= 0)
break; break;
cst_buf_append(&req, tmp, (size_t)n); cst_buf_append(&req, tmp, (size_t)n);
@@ -220,8 +245,8 @@ static void *conn_main(void *p) {
char *e = strstr(req.buf, "\r\n\r\n"); char *e = strstr(req.buf, "\r\n\r\n");
if (e) { if (e) {
hdr_end = (size_t)(e - req.buf) + 4; hdr_end = (size_t)(e - req.buf) + 4;
char *cl = strcasestr(req.buf, "Content-Length:"); const char *cl = find_content_length(req.buf);
need = cl ? atol(cl + 15) : 0; need = cl ? atol(cl) : 0;
} }
} }
if (hdr_end && (long)(req.len - hdr_end) >= need) if (hdr_end && (long)(req.len - hdr_end) >= need)
@@ -230,23 +255,23 @@ static void *conn_main(void *p) {
if (hdr_end) if (hdr_end)
handle(fd, req.buf + hdr_end); handle(fd, req.buf + hdr_end);
cst_buf_free(&req); cst_buf_free(&req);
close(fd); test_sock_close(fd);
return NULL; return NULL;
} }
static int g_lfd; static test_sock_t g_lfd;
static void *server_main(void *p) { static void *server_main(void *p) {
(void)p; (void)p;
for (;;) { for (;;) {
int fd = accept(g_lfd, NULL, NULL); test_sock_t fd = accept(g_lfd, NULL, NULL);
if (fd < 0) if (fd == TEST_BAD_SOCK)
return NULL; return NULL;
pthread_t th; pthread_t th;
if (pthread_create(&th, NULL, conn_main, (void *)(intptr_t)fd) == 0) if (pthread_create(&th, NULL, conn_main, (void *)(intptr_t)fd) == 0)
pthread_detach(th); pthread_detach(th);
else else
close(fd); test_sock_close(fd);
} }
} }
@@ -282,10 +307,10 @@ static void put(const char *root, const char *rel, const char *text) {
for (char *q = p + strlen(root) + 1; *q; q++) for (char *q = p + strlen(root) + 1; *q; q++)
if (*q == '/') { if (*q == '/') {
*q = 0; *q = 0;
mkdir(p, 0755); test_mkdir(p, 0755);
*q = '/'; *q = '/';
} }
FILE *fp = fopen(p, "w"); FILE *fp = fopen(p, "wb");
if (fp) { if (fp) {
fputs(text, fp); fputs(text, fp);
fclose(fp); fclose(fp);
@@ -293,13 +318,13 @@ static void put(const char *root, const char *rel, const char *text) {
} }
static void *cancel_later(void *p) { static void *cancel_later(void *p) {
usleep(800 * 1000); test_sleep_ms(800);
cst_agent_request_cancel(p); cst_agent_request_cancel(p);
return NULL; return NULL;
} }
int main(void) { int main(void) {
signal(SIGPIPE, SIG_IGN); test_sock_init();
curl_global_init(CURL_GLOBAL_DEFAULT); curl_global_init(CURL_GLOBAL_DEFAULT);
g_lfd = socket(AF_INET, SOCK_STREAM, 0); g_lfd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr; struct sockaddr_in addr;
@@ -316,10 +341,10 @@ int main(void) {
pthread_t srv; pthread_t srv;
pthread_create(&srv, NULL, server_main, NULL); pthread_create(&srv, NULL, server_main, NULL);
char root[256], ws[300]; char root[600], ws[700];
snprintf(root, sizeof(root), "/tmp/cst-sub-%d", (int)getpid()); if (test_tmpdir(root, sizeof(root), "cst-sub") != 0)
return 1;
snprintf(ws, sizeof(ws), "%s/ws", root); snprintf(ws, sizeof(ws), "%s/ws", root);
mkdir(root, 0755);
setenv("XDG_CONFIG_HOME", root, 1); setenv("XDG_CONFIG_HOME", root, 1);
put(root, "ws/a.txt", "alpha\n"); put(root, "ws/a.txt", "alpha\n");
put(root, "ws/.agent/agents/reviewer.md", put(root, "ws/.agent/agents/reviewer.md",
@@ -470,11 +495,9 @@ int main(void) {
CHECK(cst_subagent_list(ws, "parent1", recs, 8) == 0, CHECK(cst_subagent_list(ws, "parent1", recs, 8) == 0,
"delete removes sub-agent transcripts"); "delete removes sub-agent transcripts");
char cmd[400]; if (test_rmrf(root) != 0)
snprintf(cmd, sizeof(cmd), "rm -rf %s", root);
if (system(cmd) != 0)
fprintf(stderr, "note: cleanup failed\n"); fprintf(stderr, "note: cleanup failed\n");
close(g_lfd); test_sock_close(g_lfd);
if (nfail) { if (nfail) {
fprintf(stderr, "test_subagent: %d failure(s)\n", nfail); fprintf(stderr, "test_subagent: %d failure(s)\n", nfail);
return 1; return 1;
+116
View File
@@ -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 <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifndef _WIN32
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
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
+12 -10
View File
@@ -12,6 +12,8 @@
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
static int nfail; static int nfail;
#define EXPECT(cond, msg) \ #define EXPECT(cond, msg) \
@@ -98,17 +100,19 @@ static int run(cst_tool_ctx_t *ctx, ctl_tool_call_t c, cst_tool_result_t *r) {
static void reset(cst_tool_result_t *r) { cst_tool_result_free(r); } static void reset(cst_tool_result_t *r) { cst_tool_result_free(r); }
int main(void) { int main(void) {
snprintf(g_ws, sizeof(g_ws), "/tmp/cst-tools-test-%d", (int)getpid()); if (test_tmpdir(g_ws, sizeof(g_ws), "cst-tools-test") != 0) {
mkdir(g_ws, 0755); perror("tmpdir");
return 1;
}
char p[512]; char p[512];
snprintf(p, sizeof(p), "%s/src", g_ws); snprintf(p, sizeof(p), "%s/src", g_ws);
mkdir(p, 0755); test_mkdir(p, 0755);
snprintf(p, sizeof(p), "%s/src/deep", g_ws); snprintf(p, sizeof(p), "%s/src/deep", g_ws);
mkdir(p, 0755); test_mkdir(p, 0755);
snprintf(p, sizeof(p), "%s/node_modules", g_ws); snprintf(p, sizeof(p), "%s/node_modules", g_ws);
mkdir(p, 0755); test_mkdir(p, 0755);
snprintf(p, sizeof(p), "%s/build", g_ws); snprintf(p, sizeof(p), "%s/build", g_ws);
mkdir(p, 0755); test_mkdir(p, 0755);
wfile("src/main.c", "#include <stdio.h>\nint main(void) {\n int x = 42;\n" wfile("src/main.c", "#include <stdio.h>\nint main(void) {\n int x = 42;\n"
" printf(\"%d\\n\", x);\n return 0;\n}\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" wfile("src/deep/util.c", "int add(int a, int b) { return a + b; }\n"
@@ -119,7 +123,7 @@ int main(void) {
wfile(".gitignore", "build/\n*.log\n"); wfile(".gitignore", "build/\n*.log\n");
wfile("run.log", "int add in log\n"); wfile("run.log", "int add in log\n");
{ {
FILE *fp = fopen(strcat(strcpy(p, g_ws), "/lines.txt"), "w"); FILE *fp = fopen(strcat(strcpy(p, g_ws), "/lines.txt"), "wb");
for (int i = 1; i <= 100; i++) for (int i = 1; i <= 100; i++)
fprintf(fp, "line %d\n", i); fprintf(fp, "line %d\n", i);
fclose(fp); fclose(fp);
@@ -410,9 +414,7 @@ int main(void) {
"patch targets"); "patch targets");
} }
char cmd[400]; if (test_rmrf(g_ws) != 0)
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_ws);
if (system(cmd) != 0)
fprintf(stderr, "note: cleanup failed\n"); fprintf(stderr, "note: cleanup failed\n");
if (nfail) { if (nfail) {
fprintf(stderr, "test_tools_fs: %d failure(s)\n", nfail); fprintf(stderr, "test_tools_fs: %d failure(s)\n", nfail);
+6 -8
View File
@@ -10,6 +10,7 @@
#include <string.h> #include <string.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#include "cst_test_util.h"
static int nfail; static int nfail;
@@ -81,9 +82,9 @@ static void test_undo(void) {
EXPECT(cst_undo_snapshot(g_ws, sid, "t3", "d/e/f.txt") == 0, "snap nested"); EXPECT(cst_undo_snapshot(g_ws, sid, "t3", "d/e/f.txt") == 0, "snap nested");
char p[512]; char p[512];
snprintf(p, sizeof(p), "%s/d", g_ws); snprintf(p, sizeof(p), "%s/d", g_ws);
mkdir(p, 0755); test_mkdir(p, 0755);
snprintf(p, sizeof(p), "%s/d/e", g_ws); snprintf(p, sizeof(p), "%s/d/e", g_ws);
mkdir(p, 0755); test_mkdir(p, 0755);
wfile("d/e/f.txt", "x"); wfile("d/e/f.txt", "x");
EXPECT(cst_undo_restore_last(g_ws, sid, rep, sizeof(rep)) == 0, "undo t3"); EXPECT(cst_undo_restore_last(g_ws, sid, rep, sizeof(rep)) == 0, "undo t3");
EXPECT(rfile_eq("d/e/f.txt", NULL), "nested file removed"); EXPECT(rfile_eq("d/e/f.txt", NULL), "nested file removed");
@@ -146,7 +147,7 @@ static void test_store(void) {
EXPECT(cst_agent_store_path_for_id(g_ws, "20260101-000000-abcd", path, EXPECT(cst_agent_store_path_for_id(g_ws, "20260101-000000-abcd", path,
sizeof(path)) == 0, sizeof(path)) == 0,
"path for id"); "path for id");
FILE *fp = fopen(path, "w"); FILE *fp = fopen(path, "wb");
EXPECT(fp != NULL, "create session file"); EXPECT(fp != NULL, "create session file");
if (fp) { if (fp) {
fputs("{\"role\":\"system\",\"content\":\"sys\"}\n", fp); fputs("{\"role\":\"system\",\"content\":\"sys\"}\n", fp);
@@ -174,17 +175,14 @@ static void test_store(void) {
} }
int main(void) { int main(void) {
snprintf(g_ws, sizeof(g_ws), "/tmp/cst_test_undo_XXXXXX"); if (test_tmpdir(g_ws, sizeof(g_ws), "cst_test_undo") != 0) {
if (!mkdtemp(g_ws)) {
perror("mkdtemp"); perror("mkdtemp");
return 1; return 1;
} }
test_undo(); test_undo();
test_perms(); test_perms();
test_store(); test_store();
char cmd[512]; if (test_rmrf(g_ws) != 0)
snprintf(cmd, sizeof(cmd), "rm -rf '%s'", g_ws);
if (system(cmd) != 0)
fprintf(stderr, "warning: cleanup failed\n"); fprintf(stderr, "warning: cleanup failed\n");
if (nfail) { if (nfail) {
fprintf(stderr, "test_undo: %d failure(s)\n", nfail); fprintf(stderr, "test_undo: %d failure(s)\n", nfail);
+238
View File
@@ -0,0 +1,238 @@
/* UTF-8 everywhere: Chinese workspace and file names through the file
* tools and the shell, CRLF and BOM files edited by apply_patch, write_file
* and edit_file without losing their style, and (Windows) child output in
* the OEM code page turned into UTF-8. */
#ifndef _GNU_SOURCE
#define _GNU_SOURCE
#endif
#include "cst_agent.h"
#include "cst_util.h"
#include "os_compat.h"
#include <stdarg.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.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[1100];
/* tool(name, key, value, key, value, ..., NULL) */
static int tool(cst_tool_result_t *r, 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 *);
snprintf(c.args[c.nargs].key, sizeof(c.args[c.nargs].key), "%s", k);
c.args[c.nargs].value = (char *)v;
c.args[c.nargs].value_len = strlen(v);
c.nargs++;
}
va_end(ap);
memset(r, 0, sizeof(*r));
cst_tool_dispatch(g_ws, &c, r);
return r->exit_code;
}
static char *slurp(const char *rel, size_t *n) {
char p[1200];
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
FILE *f = fopen(p, "rb");
if (!f)
return NULL;
char *b = malloc(1 << 16);
*n = b ? fread(b, 1, (1 << 16) - 1, f) : 0;
if (b)
b[*n] = 0;
fclose(f);
return b;
}
static void spit(const char *rel, const char *s) {
char p[1200];
snprintf(p, sizeof(p), "%s/%s", g_ws, rel);
FILE *f = fopen(p, "wb");
if (f) {
fputs(s, f);
fclose(f);
}
}
static int file_is(const char *rel, const char *want) {
size_t n = 0;
char *b = slurp(rel, &n);
int ok = b && n == strlen(want) && memcmp(b, want, n) == 0;
if (!ok)
fprintf(stderr, " %s is \"%s\"\n", rel, b ? b : "(missing)");
free(b);
return ok;
}
static void test_patch_buffer(void) {
char *out = NULL, err[256];
size_t n = 0;
int nh = 0;
const char *crlf = "a\r\nb\r\nc\r\n";
const char *hunk = "@@ -1,3 +1,3 @@\n a\n-b\n+B\n c\n";
EXPECT(cst_patch_apply_buffer(crlf, strlen(crlf), hunk, strlen(hunk), &out,
&n, &nh, err, sizeof(err)) == 0,
"LF hunk applies to a CRLF file");
EXPECT(out && strcmp(out, "a\r\nB\r\nc\r\n") == 0, "CRLF kept");
free(out);
const char *bom = "\xEF\xBB\xBF" "first\nsecond\n";
const char *h2 = "@@ -1,2 +1,2 @@\n-first\n+FIRST\n second\n";
EXPECT(cst_patch_apply_buffer(bom, strlen(bom), h2, strlen(h2), &out, &n,
&nh, err, sizeof(err)) == 0,
"first line matches past the BOM");
EXPECT(out && strcmp(out, "\xEF\xBB\xBF" "FIRST\nsecond\n") == 0,
"BOM kept");
free(out);
const char *lf = "x\ny\n";
const char *h3 = "@@ -1,2 +1,3 @@\n x\n+中文\n y\n";
EXPECT(cst_patch_apply_buffer(lf, strlen(lf), h3, strlen(h3), &out, &n, &nh,
err, sizeof(err)) == 0 &&
out && strcmp(out, "x\n中文\ny\n") == 0,
"LF file stays LF");
free(out);
EXPECT(cst_text_is_crlf("a\r\nb\r\n", 6), "is_crlf");
EXPECT(!cst_text_is_crlf("a\r\nb\n", 5), "mixed is not crlf");
EXPECT(!cst_text_is_crlf("ab", 2), "no newline is not crlf");
}
static void test_tools(void) {
cst_tool_result_t r;
/* Chinese names through write/read/list/glob/grep */
EXPECT(tool(&r, "write_file", "path", "目录/文件.txt", "content",
"你好,世界\n第二行\n", NULL) == 0,
"write_file with a Chinese path");
cst_tool_result_free(&r);
EXPECT(tool(&r, "read_file", "path", "目录/文件.txt", NULL) == 0 && r.out &&
strstr(r.out, "你好,世界"),
"read_file with a Chinese path");
cst_tool_result_free(&r);
EXPECT(tool(&r, "list_dir", "path", "目录", NULL) == 0 && r.out &&
strstr(r.out, "文件.txt"),
"list_dir shows the Chinese name");
cst_tool_result_free(&r);
EXPECT(tool(&r, "glob", "pattern", "**/*.txt", NULL) == 0 && r.out &&
strstr(r.out, "目录/文件.txt"),
"glob finds the Chinese name");
cst_tool_result_free(&r);
EXPECT(tool(&r, "grep", "pattern", "世界", NULL) == 0 && r.out &&
strstr(r.out, "文件.txt"),
"grep matches Chinese text");
cst_tool_result_free(&r);
/* CRLF + BOM file */
spit("win.txt", "\xEF\xBB\xBFone\r\ntwo\r\nthree\r\n");
EXPECT(tool(&r, "read_file", "path", "win.txt", NULL) == 0 && r.out &&
strcmp(r.out, "one\ntwo\nthree\n") == 0,
"read_file hides BOM and CR");
cst_tool_result_free(&r);
EXPECT(tool(&r, "edit_file", "path", "win.txt", "old_string", "two",
"new_string", "二\n2", NULL) == 0,
"edit_file single-line match");
cst_tool_result_free(&r);
EXPECT(file_is("win.txt", "\xEF\xBB\xBFone\r\n二\r\n2\r\nthree\r\n"),
"edit_file inserts CRLF");
EXPECT(tool(&r, "edit_file", "path", "win.txt", "old_string", "one\n二",
"new_string", "壹\n贰", NULL) == 0,
"edit_file multi-line LF match");
cst_tool_result_free(&r);
EXPECT(file_is("win.txt", "\xEF\xBB\xBF壹\r\n贰\r\n2\r\nthree\r\n"),
"edit_file keeps CRLF and BOM");
EXPECT(tool(&r, "apply_patch", "patch",
"--- a/win.txt\n+++ b/win.txt\n@@ -3,2 +3,2 @@\n 2\n-three\n"
"+叁\n",
NULL) == 0,
"apply_patch on a CRLF file");
if (r.exit_code)
fprintf(stderr, " apply_patch: %s\n", r.out ? r.out : "");
cst_tool_result_free(&r);
EXPECT(file_is("win.txt", "\xEF\xBB\xBF壹\r\n贰\r\n2\r\n叁\r\n"),
"apply_patch keeps CRLF and BOM");
EXPECT(tool(&r, "write_file", "path", "win.txt", "content", "new\ntext\n",
NULL) == 0,
"write_file over a CRLF file");
cst_tool_result_free(&r);
EXPECT(file_is("win.txt", "\xEF\xBB\xBFnew\r\ntext\r\n"),
"write_file keeps CRLF and BOM");
EXPECT(tool(&r, "write_file", "path", "new.txt", "content", "lf\n", NULL) ==
0 &&
file_is("new.txt", "lf\n"),
"new files are LF without BOM");
cst_tool_result_free(&r);
/* the shell sees and prints UTF-8 */
EXPECT(tool(&r, "run_shell", "cmd", "cat 目录/文件.txt && echo 完成", NULL) ==
0 &&
r.out && strstr(r.out, "你好,世界") && strstr(r.out, "完成"),
"run_shell with Chinese arguments and output");
if (!r.out || !strstr(r.out, "完成"))
fprintf(stderr, " run_shell: %s\n", r.out ? r.out : "");
cst_tool_result_free(&r);
}
#ifdef _WIN32
static void test_oem_output(void) {
/* "中文" in GBK */
const char gbk[] = "\xD6\xD0\xCE\xC4\r\n";
size_t n = 0;
char *u = os_output_to_utf8(gbk, sizeof(gbk) - 1, &n);
wchar_t b[8] = L"0";
GetLocaleInfoW(LOCALE_SYSTEM_DEFAULT, LOCALE_IDEFAULTCODEPAGE, b, 8);
unsigned oem = (unsigned)wcstoul(b, NULL, 10);
if (oem == 936)
EXPECT(u && strstr(u, "中文"), "GBK output decoded on a 936 system");
else
EXPECT(u != NULL, "non-UTF-8 output is decoded");
free(u);
EXPECT(os_output_to_utf8("中文", strlen("中文"), &n) == NULL,
"UTF-8 output left alone");
printf("test_win_utf8: ACP %u, OEM %u, system OEM %u, console %u\n",
GetACP(), GetOEMCP(), oem, GetConsoleOutputCP());
EXPECT(GetACP() == CP_UTF8, "manifest makes UTF-8 the process code page");
}
#endif
int main(void) {
os_console_init();
char base[1024];
if (test_tmpdir(base, sizeof(base), "cst-utf8") != 0) {
perror("tmpdir");
return 1;
}
snprintf(g_ws, sizeof(g_ws), "%s/项目 空格", base);
test_mkdir(g_ws, 0755);
test_patch_buffer();
test_tools();
#ifdef _WIN32
test_oem_output();
#endif
test_rmrf(base);
if (nfail) {
fprintf(stderr, "test_win_utf8: %d failure(s)\n", nfail);
return 1;
}
puts("test_win_utf8: OK");
return 0;
}
+3 -2
View File
@@ -1,2 +1,3 @@
BusyBox is GPLv2. Source: https://github.com/rmyorston/busybox-w32 BusyBox is GPLv2. This busybox.exe is busybox-w32 (9b50a1e, Unicode build):
Upstream BusyBox: https://github.com/mirror/busybox https://github.com/rmyorston/busybox-w32
Upstream BusyBox: https://busybox.net/
+5 -2
View File
@@ -1,5 +1,8 @@
CC ?= gcc CC ?= gcc
CFLAGS ?= -std=c11 -Wall -Wextra -O2 -Iinclude -Isrc CFLAGS ?= -std=c11 -Wall -Wextra -O2
# Keep include paths when a parent make passes CFLAGS=... on the command
# line (CI uses -Werror that way); plain CFLAGS ?= would be overridden.
override CPPFLAGS += -Iinclude -Isrc
SRCS = src/fsm.c src/table_stats.c src/line_index.c src/block_scan.c \ SRCS = src/fsm.c src/table_stats.c src/line_index.c src/block_scan.c \
src/ansi_style.c src/render.c src/render_ansi.c src/render_json.c \ src/ansi_style.c src/render.c src/render_ansi.c src/render_json.c \
src/sashidown.c src/compat_cmark.c src/stream.c src/sashidown.c src/compat_cmark.c src/stream.c
@@ -11,6 +14,6 @@ all: $(LIB)
$(LIB): $(OBJS) $(LIB): $(OBJS)
$(AR) rcs $@ $(OBJS) $(AR) rcs $@ $(OBJS)
src/%.o: src/%.c src/%.o: src/%.c
$(CC) $(CFLAGS) -c -o $@ $< $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
clean: clean:
rm -f $(OBJS) $(LIB) rm -f $(OBJS) $(LIB)

Some files were not shown because too many files have changed in this diff Show More