3 Commits
Author SHA1 Message Date
WHC 6e81513e36 v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests
ci / linux (clang) (push) Successful in 5m25s
ci / linux (gcc) (push) Failing after 34s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 10m41s
A second implementation of cst_agent_hooks_t next to the TUI: the turn runs on
a worker thread, the hooks publish events into a ring buffer that SSE clients
read, so the engine itself needs no changes.

- src/net.c, include/cst_net.h: POSIX/Winsock sockets, non-blocking connect
- src/http_srv.c, include/cst_http.h: HTTP/1.1, keep-alive, chunked streaming,
  body caps (413), Expect: 100-continue, connection cap, exact routing
- src/web_md.c, include/cst_web_md.h: streaming markdown -> sanitized HTML
  (tag/attribute allowlist, javascript: URLs and off-site images dropped)
- src/webui.c, include/cst_webui.h: event ring + SSE broadcast, all routes,
  token (Bearer/Cookie/query) and loopback Host checks, 3 s auto-deny when no
  client is attached
- src/webui_stub.c, CST_WEBUI=0 / CST_BUILD_WEBUI=OFF: build without the WebUI
- web/: no-build front end (transcript, approvals, todo, sessions, image
  upload, status bar)
- docs/webui.md: engine API v1 - events, requests, security model
- tests/test_webui.c, scripts/e2e_webui.sh: unit tests plus an end-to-end run
  driven by a recorded SSE stream (no API key needed)
- src/main.c: --web --web-port --web-host --web-token --web-dev --web-open
  and CST_WEB_PORT/HOST/TOKEN/ROOT

An empty POST body is now accepted without Content-Length (RFC 9112 6.3) so
curl-driven clients can reach cancel/undo/compact/quit; CMakeList's core tests
learned about sashidown and test_webui, and CST_WEBUI=0 defines CST_NO_WEBUI.
2026-10-09 10:20:09 +08:00
WHC c7b32c3fd6 v0.7.5: arrow keys, image request fix, deepseek-flash vision, musl CI
ci / linux (clang) (push) Successful in 4m42s
ci / linux (gcc) (push) Successful in 5m35s
ci / musl (push) Successful in 5m52s
ci / windows-cross (push) Successful in 12m51s
TUI keys: register both CSI and SS3 forms of the cursor/Home/End keys and
the xterm modifier sequences when terminfo lacks them, so an arrow sent in
the "other" form no longer turns into Esc + a typed letter. Up/Down move by
visual (soft-wrapped) row, and the draft survives a trip through history.
scripts/tui_keys drives the TUI in a pty to check all of this.

Images: text inside a multimodal content array is now a {"type":"text"}
part; DeepSeek rejected the bare string with HTTP 422. deepseek-flash
(and its deepseek-chat / deepseek-reasoner aliases) read images, so the
registry marks them vision; deepseek-v4-pro stays text only.

CI: models.c lost _DEFAULT_SOURCE in 0.7.0 and gmtime_r broke the musl
build; tests sleep with poll() instead of usleep for the same reason.
2026-10-08 22:35:34 +08:00
WHC 32b4afd772 v0.7.1: allow network inside the run_shell sandbox
ci / linux (clang) (push) Successful in 4m44s
ci / linux (gcc) (push) Successful in 5m36s
ci / musl (push) Failing after 29s
ci / windows-cross (push) Successful in 31m37s
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
41 changed files with 5041 additions and 127 deletions
+2
View File
@@ -54,6 +54,8 @@ jobs:
./cst-agent --help ./cst-agent --help
./cst-agent --workspace "$PWD" --list-sessions ./cst-agent --workspace "$PWD" --list-sessions
./cst-agent --demo-ctl --workspace . ./cst-agent --demo-ctl --workspace .
- name: WebUI end-to-end (recorded stream, no API key)
run: sh scripts/e2e_webui.sh
musl: musl:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+2 -1
View File
@@ -30,4 +30,5 @@ 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 .gen/
.build-tmp/
+57 -4
View File
@@ -5,6 +5,7 @@ set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_STANDARD_REQUIRED ON)
option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON) option(CST_BUILD_TUI "Build ncurses/PDCurses TUI" ON)
option(CST_BUILD_WEBUI "Build the HTTP/SSE WebUI (--web)" 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)
@@ -114,16 +115,59 @@ set(CST_CORE_SOURCES
src/models.c src/models.c
src/model_registry.c src/model_registry.c
src/metrics.c src/metrics.c
src/net.c
src/http_srv.c
src/web_md.c
) )
# Built-in model data as a C byte list (included by model_registry.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(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json") "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
# The WebUI shell (web/*) as C byte arrays, the same trick as the model data.
file(GLOB CST_WEB_ASSETS "${CMAKE_CURRENT_SOURCE_DIR}/web/*")
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CST_WEB_ASSETS})
set(_cst_web_inc "/* Generated by CMake from web/ - do not edit. */\n")
set(_cst_web_table "")
foreach(_f ${CST_WEB_ASSETS})
if(IS_DIRECTORY "${_f}")
continue()
endif()
get_filename_component(_name "${_f}" NAME)
string(REGEX REPLACE "[^A-Za-z0-9]" "_" _var "wui_asset_${_name}")
file(READ "${_f}" _hex HEX)
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _hex "${_hex}")
set(_ctype "application/octet-stream")
if(_name MATCHES "\\.html$")
set(_ctype "text/html; charset=utf-8")
elseif(_name MATCHES "\\.js$")
set(_ctype "text/javascript; charset=utf-8")
elseif(_name MATCHES "\\.css$")
set(_ctype "text/css; charset=utf-8")
elseif(_name MATCHES "\\.svg$")
set(_ctype "image/svg+xml")
endif()
string(APPEND _cst_web_inc "static const unsigned char ${_var}[] = {${_hex}0x00};\n")
string(APPEND _cst_web_table " {\"${_name}\", \"${_ctype}\", ${_var}, sizeof(${_var}) - 1},\n")
endforeach()
string(APPEND _cst_web_inc "static const wui_asset_t wui_assets[] = {\n${_cst_web_table}};\n")
string(APPEND _cst_web_inc "static const int wui_asset_count = (int)(sizeof(wui_assets) / sizeof(wui_assets[0]));\n")
file(WRITE "${CST_GEN_DIR}/web_assets.inc" "${_cst_web_inc}")
if(NOT CST_BUILD_WEBUI)
list(APPEND CST_CORE_SOURCES src/webui_stub.c)
else()
list(APPEND CST_CORE_SOURCES src/webui.c)
endif()
file(READ "${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json" _cst_models_hex HEX) 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}") 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") file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
include_directories("${CST_GEN_DIR}") include_directories("${CST_GEN_DIR}")
if(NOT CST_BUILD_WEBUI)
add_compile_definitions(CST_NO_WEBUI=1)
endif()
if(WIN32) if(WIN32)
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex) # MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
include_directories(vendor/tre-regex) include_directories(vendor/tre-regex)
@@ -269,17 +313,26 @@ foreach(t ${_CST_SMALL_TESTS})
endforeach() endforeach()
# Tests that link the whole core (no TUI). # Tests that link the whole core (no TUI).
foreach(t test_llm_body test_prefix_stable test_sse_decode test_apply_patch set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
test_session_roundtrip test_attach 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_model_registry 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_lowil test_plugins test_mcp test_subagent test_lowil
test_win_utf8 test_os_dir test_tmpdir) test_win_utf8 test_os_dir test_tmpdir)
if(CST_BUILD_WEBUI)
# test_webui drives the real server; the stub has nothing to test.
list(APPEND _CST_CORE_TESTS test_webui)
endif()
foreach(t ${_CST_CORE_TESTS})
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}") # The core now contains src/web_md.c, which needs sashidown's header and
# archive exactly like the Makefile build does.
target_include_directories(${t} PRIVATE include "${CJSON_DIR}" "${SD_DIR}/include")
target_compile_definitions(${t} PRIVATE _GNU_SOURCE) target_compile_definitions(${t} PRIVATE _GNU_SOURCE)
target_link_libraries(${t} PRIVATE CURL::libcurl Threads::Threads) target_link_libraries(${t} PRIVATE
"${SASHIDOWN_A}" CURL::libcurl Threads::Threads)
add_dependencies(${t} sashidown)
if(NOT WIN32) if(NOT WIN32)
target_link_libraries(${t} PRIVATE m) target_link_libraries(${t} PRIVATE m)
else() else()
+33 -4
View File
@@ -43,6 +43,10 @@ ifeq ($(strip $(CURL_LIBS)),)
CURL_LIBS := -lcurl CURL_LIBS := -lcurl
endif endif
# WebUI (HTTP + SSE front end): src/net.c src/http_srv.c src/web_md.c
# src/webui.c. CST_WEBUI=0 swaps in the stub (--web then prints a hint).
CST_WEBUI ?= 1
PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS) PKG_CFLAGS := $(CURL_CFLAGS) $(CURSES_CFLAGS)
PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS) PKG_LIBS := $(CURL_LIBS) $(CURSES_LIBS)
@@ -50,6 +54,17 @@ CPPFLAGS += -Iinclude -I.gen -I$(SD_DIR)/include -I$(CJSON_DIR) -D_XOPEN_SOURCE_
LDFLAGS += -L$(SD_DIR) LDFLAGS += -L$(SD_DIR)
LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS) LDLIBS += -lsashidown -lm $(PKG_LIBS) $(PTHREAD_LIBS)
WEB_DIR := web
WEB_ASSETS := $(wildcard $(WEB_DIR)/*.html) $(wildcard $(WEB_DIR)/*.js) \
$(wildcard $(WEB_DIR)/*.css) $(wildcard $(WEB_DIR)/*.svg)
ifeq ($(CST_WEBUI),0)
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui_stub.c
# The stub body is guarded by this, so the switch has to define it too.
CPPFLAGS += -DCST_NO_WEBUI=1
else
WEB_SRC := src/net.c src/http_srv.c src/web_md.c src/webui.c
endif
CORE_SRC = \ CORE_SRC = \
$(CJSON_DIR)/cJSON.c \ $(CJSON_DIR)/cJSON.c \
src/util.c \ src/util.c \
@@ -79,12 +94,13 @@ CORE_SRC = \
src/creds.c \ src/creds.c \
src/mm_tool.c \ src/mm_tool.c \
src/models.c src/model_registry.c \ src/models.c src/model_registry.c \
src/metrics.c src/metrics.c \
$(WEB_SRC)
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 .gen/models_builtin.inc .gen/web_assets.inc
TESTS = \ TESTS = \
tests/test_ctl_frontier \ tests/test_ctl_frontier \
@@ -120,8 +136,12 @@ TESTS = \
tests/test_win_utf8 \ tests/test_win_utf8 \
tests/test_os_dir \ tests/test_os_dir \
tests/test_tmpdir tests/test_tmpdir
# test_webui drives the real server; there is nothing to test with the stub.
ifneq ($(CST_WEBUI),0)
TESTS += tests/test_webui
endif
TEST_LIBS = -lm $(CURL_LIBS) $(PTHREAD_LIBS) TEST_LIBS = -L$(SD_DIR) -lsashidown -lm $(CURL_LIBS) $(PTHREAD_LIBS)
.PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall .PHONY: all clean check demo sashidown-lib FORCE cmake-gcc cmake-clang asan install uninstall
@@ -153,6 +173,11 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
@mkdir -p .gen @mkdir -p .gen
od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@ od -An -v -tx1 $< | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g' > $@.tmp && mv $@.tmp $@
# The WebUI shell is embedded the same way, one array per file.
.gen/web_assets.inc: $(WEB_ASSETS) scripts/embed_web.sh
@mkdir -p .gen
@sh scripts/embed_web.sh $@ $(WEB_DIR)
%.o: %.c $(HDRS) %.o: %.c $(HDRS)
$(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $< $(CC) $(CFLAGS) $(CPPFLAGS) -c -o $@ $<
@@ -186,10 +211,14 @@ tests/test_markdown: tests/test_markdown.c $(SD_DIR)/libsashidown.a
tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS) tests/test_%: tests/test_%.c $(CORE_SRC) $(HDRS)
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS) $(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
$(TESTS): sashidown-lib
check: $(TESTS) check: $(TESTS)
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done @set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
# AddressSanitizer + UBSan build of the agent and all tests. # AddressSanitizer + UBSan build of the agent and all tests.
# Under a ptrace-based sandbox (some Termux setups) LeakSanitizer aborts with
# "Tracer caught signal 11"; add ASAN_OPTIONS=detect_leaks=0 there.
asan: asan:
$(MAKE) clean $(MAKE) clean
$(MAKE) sashidown-lib $(MAKE) sashidown-lib
@@ -199,7 +228,7 @@ demo: cst-agent
./cst-agent --demo-ctl --workspace . ./cst-agent --demo-ctl --workspace .
clean: clean:
rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc rm -f $(OBJ) cst-agent $(TESTS) .gen/models_builtin.inc .gen/web_assets.inc
install: cst-agent install: cst-agent
install -d "$(DESTDIR)$(PREFIX)/bin" install -d "$(DESTDIR)$(PREFIX)/bin"
+21 -4
View File
@@ -126,6 +126,23 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
| 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 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** | | Windows 10 1903+ | x86_64 | MinGW-w64 GCC(UCRT) | yes | ncurses 6.6(Windows 控制台) | **捆绑 busybox-w32** |
### WebUI(`--web`)
同一个二进制内置一个 HTTP + SSE 前端:`cst-agent --web`(默认 `http://127.0.0.1:8787/`)。
它和 TUI 是同一引擎的两个前端,浏览器里能流式看回答、点审批、切会话、贴图。
协议是一份稳定的 JSON + SSE 接口,供以后的专用客户端复用。详见 [docs/webui.md](docs/webui.md)。
```bash
cst-agent --web # 本机浏览器打开 http://127.0.0.1:8787/
cst-agent --web --web-port 0 # 端口交给系统,启动日志里给 URL
cst-agent --web --web-dev ./web # 开发前端:直接读目录,改完刷新
cst-agent --web --web-host 0.0.0.0 --web-token 口令 # 手机/局域网访问
sh scripts/e2e_webui.sh # 用录制流跑完整轮次,不需要 API key
```
不带 TUI 的构建也可以开 WebUI(`cmake -DCST_BUILD_WEBUI=ON -DCST_BUILD_TUI=OFF`);
`-DCST_BUILD_WEBUI=OFF` 时 `--web` 只打印一条提示。
### Termux ### Termux
```bash ```bash
@@ -277,16 +294,16 @@ 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 不会启用。 探测还会确认能读直接子进程的内存;`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 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时 - Windows 10 1903+:Low 完整性级别的受限令牌 + Job Object(界面显示 `auto · lowil`)。第一次在工作区运行时
给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网,所以联网命令仍会询问。 给它打上 Low 标签,之后子进程只能写工作区和沙箱临时目录,读不到凭据文件;不限制联网。
`/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。 `/sandbox` 查看、`/sandbox reset-labels` 撤销标签,详见 [docs/README-windows.md](docs/README-windows.md)。
- 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。 - 更老的内核:没有沙箱,自动回落到规则判断(界面显示 `auto · rules`)。
- `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。 - `CST_SANDBOX=off` 强制关闭沙箱;`CST_SANDBOX=landlock` / `seccomp` / `lowil` 只用指定的那种。
+2 -3
View File
@@ -111,9 +111,8 @@ windows-x86_64/
以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行, 以及 Windows、Program Files、ProgramData 下面的目录打标签:在这些地方启动时,命令不会运行,
界面会问是否在沙箱外重跑。请在项目目录里启动。 界面会问是否在沙箱外重跑。请在项目目录里启动。
- `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。 - `/sandbox` 列出打过标签的目录;`/sandbox reset-labels` 恢复它们原来的标签。
- 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**。所以像联网的命令 - 沙箱里可以读、执行用户能访问的文件(凭据文件除外),**不限制联网**:`curl`、`git pull`、
(`curl`、`wget`、`ssh`、`git clone/fetch/pull/push`、`npm install`、`pip install`、 `npm install` 等联网命令在 `auto` 模式下也直接在沙箱里运行。
`winget install`、`Invoke-WebRequest` 等)在 `auto` 模式下仍然会弹窗确认。
- 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问 - 命令像是被拦了(`Access is denied`、`拒绝访问`、`Permission denied` 等)时,会问
“Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。 “Rerun outside the sandbox?”:`y` 在沙箱外重跑一次,`p` 保存命令前缀规则并重跑,`n` 保留结果。
- MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化 - MSYS2 / Cygwin 的程序(包括 Git for Windows 自带的 `usr\bin` 工具)在 Low 级别下无法初始化
+6 -5
View File
@@ -42,13 +42,14 @@ 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 | |
| Windows 10 1903+ | Low integrity restricted token + Job Object | writes need a Low label (given to the workspace once); network is not blocked, so network commands still ask; see `README-windows.md` | | 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 | | 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`,
@@ -165,7 +166,7 @@ Chinese VMs as Administrator, a standard user and a Chinese user name):
keep CRLF and BOM; `run_shell` output in Chinese is readable on a keep CRLF and BOM; `run_shell` output in Chinese is readable on a
936 system 936 system
- [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside` - [ ] `auto · lowil`: first command reports the label; `echo x > C:\outside`
asks to rerun; `curl ...` asks before running; `/sandbox reset-labels` asks to rerun; `curl ...` runs without asking; `/sandbox reset-labels`
restores the label restores the label
- [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no - [ ] Esc during `sleep 30 & sleep 30` returns at once and leaves no
`busybox.exe` behind `busybox.exe` behind
+176
View File
@@ -0,0 +1,176 @@
# WebUI(内置 HTTP + SSE 前端)
`cst-agent --web` 让同一个进程同时提供命令行以外的前端:浏览器(或任何 HTTP 客户端)
连上后能看到流式回答、工具卡片、审批弹窗和任务清单。它是 TUI 之外的第二个前端,
**引擎没有为它改一行**——两边用的都是 `cst_agent_hooks_t` 这组回调。
这份文档同时是**引擎 API v1 的规范**:以后专门客户端(Tauri / Qt / 移动端)直接照它实现,
不需要读 C 代码。
## 快速开始
```bash
# 默认只监听 127.0.0.1:8787
cst-agent --web
# 指定端口;0 = 让系统分配(启动日志里会打印实际 URL)
cst-agent --web --web-port 8787
# 让手机/另一台机器访问(必须给 token,否则拒绝启动在非 loopback 上)
cst-agent --web --web-host 0.0.0.0 --web-token 我自己定的口令
# 开发前端:直接从目录读 web/,改完刷新页面即可,不用重新编译
cst-agent --web --web-dev ./web
# 启动后自动打开浏览器
cst-agent --web --web-open
# 启动即跑一轮(不点页面也能用)
cst-agent --web -p "看看这个仓库有什么问题"
```
启动日志:
```
webui: http://127.0.0.1:8787/
webui: token 3f9a… (append #token=3f9a… to the URL)
```
带 token 时把 `#token=…` 贴到浏览器地址栏后面即可(`#` 之后不会发给服务器,
页面会用 `?token=` 再请求一次)。
环境变量:`CST_WEB_PORT`、`CST_WEB_HOST`、`CST_WEB_TOKEN`、`CST_WEB_ROOT`。
退出:终端里 Ctrl+C,或 `POST /api/quit`。
## 安全模型
| 场景 | 行为 |
|---|---|
| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding) |
| `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token |
| token 传递 | `Authorization: Bearer <t>`、`Cookie: cst_token=<t>` 或 `?token=<t>`(`EventSource` 不能设 header,所以查询参数是必需的一条路) |
| 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) |
| 输出渲染 | 模型与工具输出都当不可信内容:Markdown 由服务端渲染,并经过标签/属性白名单(`javascript:` 链接、站外图片、`<script>` 全部丢弃) |
| 请求体 | 默认 1 MiB,图片上传 32 MiB;超限 413 |
**不要**把 WebUI 直接暴露到公网:没有 TLS,token 会以明文出现在网络里。需要远程访问时
放在反向代理(https)后面,并确认 `X-Accel-Buffering: no` 之类的缓冲设置不会掐断 SSE。
## 引擎 API(协议 `cst-webui/1`)
所有响应都是 JSON(`Content-Type: application/json`)。`GET /api/state` 的
`protocol` 字段给出版本号,客户端应当先看它再决定支持哪些端点。
### 事件流
```
GET /api/events?since=<seq> → text/event-stream (chunked)
```
* 每条事件都有 `id: <seq>`(单调递增)与 `data: {"type":…,"seq":…, …}`。
* 断线重连带上 `since=<最后收到的 seq>`(浏览器用 `Last-Event-ID` 也行),
服务端保留最近 2000 条,超出后从可用的最老一条开始。
* 心跳是 SSE 注释 `: ping`(每秒一次,客户端忽略)。
* `state` 事件在状态变化时整份重发,客户端可以直接替换本地状态。
事件类型:
| type | 字段 | 说明 |
|---|---|---|
| `state` | 见下表 | 会话/模型/审批/todo/用量快照 |
| `status` | `message` | 引擎自己的一句话状态("calling model (step 2)…") |
| `assistant_html` | `text` | **已渲染且已消毒的 HTML 片段**,按顺序追加 |
| `assistant_delta` | `text` | 原始文本增量(逐字节真相,做复制/导出时用) |
| `think_delta` | `text` | 推理过程增量(Thought) |
| `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`done` |
| `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` |
| `todo` | `text` | `todo_write` 替换后的清单(一行一项) |
| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox` | 需要回答(见下) |
| `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) |
| `ask_user` | `id`,`question`,`options[]` | 需要回答 |
| `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` |
| `turn_end` | `rc`,`error` | 本轮结束(`rc=0` 正常,非 0 见 `error`) |
`state` 字段:`workspace` `session` `provider` `model` `effort` `approval`
(`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo`
`context_tokens` `undo_available` `pending_images[]` `usage{requests,prompt_tokens,
completion_tokens,cost,priced,currency}`。
### 请求
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/state` | 上面那份快照 |
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致;assistant 消息额外带 `html`) |
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
| POST | `/api/sessions/new` | `{}` → `{id}` |
| POST | `/api/sessions/switch` | `{"id":"…"}` |
| POST | `/api/turn` | `{"text":"…"}` → 202 `{ok:true}`;已有轮次在跑时 **409** |
| POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程) |
| POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix |
| POST | `/api/ask_user` | `{"id":N,"text":"…"}` 或 `{"id":N,"dismiss":true}` |
| POST | `/api/config` | `{model?,provider?,effort?,approval?,plan_mode?}` |
| POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
| POST | `/api/compact` | 手动压缩上下文 |
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤16 MiB),进入"待发送"列表 |
| DELETE | `/api/attach` | 清空待发送图片 |
| GET | `/api/attach/<rel>` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) |
| POST | `/api/quit` | 关闭进程 |
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
### 客户端接入示例(10 行)
```python
import json, urllib.request
base = "http://127.0.0.1:8787"
def api(path, body=None, token=None):
req = urllib.request.Request(base + path, method="POST" if body is not None else "GET")
if token: req.add_header("Authorization", "Bearer " + token)
data = json.dumps(body).encode() if body is not None else None
return urllib.request.urlopen(req, data).read()
api("/api/turn", {"text": "hello"}) # 之后读 /api/events 收流
```
### 空 body 的 POST
`POST /api/cancel`、`/api/undo`、`/api/compact`、`/api/quit` 不需要 body。
没有 `Content-Length` 头的 POST 按 RFC 9112 §6.3 视为**空 body**(`curl -X POST`
正是这样发的),但该连接不再复用(服务端随后关闭它):后面是否跟着未声明的
字节无法判断。
## 前端实现(web/)
没有构建步骤:`web/index.html` + `web/app.js` + `web/style.css` 是纯 ES module,
编译时被 `scripts/embed_web.sh` 塞进二进制(`.gen/web_assets.inc`,跟内置模型数据同一招),
所以 Windows 发布包依旧是单个 exe。`--web-dev DIR` 可以绕过内嵌版本直接读目录。
Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),客户端只贴 HTML;
因此不需要引入任何 JS 依赖,也顺带保证了"只发送已确认的 HTML"。
## 测试
```bash
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl ctl
```
手工验收清单(浏览器):
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样)。
5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
## 限制与已知问题
* 同一会话同时只能有一轮(`POST /api/turn` 返回 409);TUI 与 WebUI 目前是
**二选一**(`--web` 时不启动 TUI),同进程并存留待后续的 hooks 分发层。
* 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
* 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
* 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始,
这时应当重新拉一次 `/api/session`。
+105
View File
@@ -0,0 +1,105 @@
#ifndef CST_HTTP_H
#define CST_HTTP_H
/* A small HTTP/1.1 server for the built-in WebUI (src/http_srv.c).
*
* Only what a local single-page app needs: exact-match routes, keep-alive,
* fixed-length bodies and chunked streaming responses (SSE). No TLS, no
* HTTP/2, no chunked *request* bodies, no CGI. Every connection is served
* by its own thread; a handler may keep the connection as long as it wants
* (that is how the event stream works). */
#include "cst_net.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct cst_http_conn cst_http_conn_t;
typedef struct {
char method[8]; /* GET / POST / DELETE ... */
char path[1024]; /* percent-decoded, without the query string */
char query[1024]; /* raw, without '?' */
const char *body; /* NUL-terminated; may hold binary bytes */
size_t body_len;
/* Headers of interest, NULL when absent (pointers into the request). */
const char *authorization;
const char *cookie;
const char *origin;
const char *host;
const char *content_type;
const char *user_agent;
int keep_alive;
} cst_http_req_t;
/* 0 = handled (a response was sent), -1 = not found (the server replies). */
typedef int (*cst_http_fn)(cst_http_conn_t *c, void *ud);
typedef struct {
const char *method; /* "*" matches any */
const char *path; /* exact; "*" = fallback (try last) */
cst_http_fn fn;
void *ud;
int auth; /* ask the auth hook before the handler */
size_t max_body; /* request body cap; 0 = server default */
} cst_http_route_t;
typedef struct {
const char *host; /* NULL/"" = 127.0.0.1 */
int port; /* 0 = pick a free port */
size_t max_body; /* default 1 MiB */
int max_conn; /* default 32 */
int read_timeout_ms; /* waiting for the request headers (default 15000) */
int idle_timeout_ms; /* keep-alive pause between requests (default 60000) */
const cst_http_route_t *routes;
int nroutes;
/* 0 = allow, -1 = the server answers 401 before the handler runs. */
int (*auth)(const cst_http_req_t *req, void *ud);
void *auth_ud;
/* One line per request, e.g. "GET /api/state -> 200". */
void (*on_log)(const char *line, void *ud);
void *log_ud;
} cst_http_opts_t;
typedef struct cst_http_server cst_http_server_t;
/* Start listening. NULL on failure with a reason in err. */
cst_http_server_t *cst_http_start(const cst_http_opts_t *o, char *err,
size_t err_sz);
int cst_http_port(const cst_http_server_t *s);
/* Non-zero once stop() was called: long-lived handlers must return. */
int cst_http_stopping(const cst_http_server_t *s);
/* Close the listener and every connection, then wait for the threads. */
void cst_http_stop(cst_http_server_t *s);
void cst_http_free(cst_http_server_t *s);
/* --- inside a handler --- */
const cst_http_req_t *cst_http_request(cst_http_conn_t *c);
int cst_http_send(cst_http_conn_t *c, int status, const char *ctype,
const void *body, size_t n);
int cst_http_send_str(cst_http_conn_t *c, int status, const char *ctype,
const char *s);
/* json may be NULL (then "null" is sent); it is copied, not owned. */
int cst_http_send_json(cst_http_conn_t *c, int status, const char *json);
/* {"error":"..."} */
int cst_http_send_err(cst_http_conn_t *c, int status, const char *msg);
/* True once a response (or stream) began for this request. */
int cst_http_sent(const cst_http_conn_t *c);
/* Chunked response: begin(), any number of write()s, end(). */
int cst_http_stream_begin(cst_http_conn_t *c, const char *ctype);
int cst_http_stream_write(cst_http_conn_t *c, const void *data, size_t n);
int cst_http_stream_end(cst_http_conn_t *c);
/* Query parameter, percent-decoded ('+' = space). 0 when found. */
int cst_http_query(const cst_http_req_t *req, const char *key, char *out,
size_t out_sz);
#ifdef __cplusplus
}
#endif
#endif /* CST_HTTP_H */
+68
View File
@@ -0,0 +1,68 @@
#ifndef CST_NET_H
#define CST_NET_H
/* Minimal TCP sockets for the embedded HTTP server (src/net.c).
*
* The point of this layer is that src/http_srv.c and src/webui.c never
* include a socket header: Unix file descriptors and Windows SOCKETs differ
* in type, close() and error reporting, and Windows needs WSAStartup before
* anything works. */
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#ifdef _WIN32
typedef intptr_t os_socket_t;
#define OS_SOCKET_BAD ((os_socket_t)-1)
#else
typedef int os_socket_t;
#define OS_SOCKET_BAD (-1)
#endif
/* Longest "address:port" os_net_peer writes (IPv6 hosts are bracketed so a
* URL built from it stays unambiguous). */
#define OS_NET_ADDR_MAX 64
/* Winsock startup (no-op on Unix, which also ignores SIGPIPE so a client
* that hangs up cannot kill the process). 0 / -1. */
int os_net_init(void);
void os_net_cleanup(void);
/* Bind and listen on host:port. host may be NULL/"" (then "127.0.0.1"),
* "0.0.0.0"/"::" (every interface) or any literal address / name; port 0
* picks a free one. Returns the listening socket, or OS_SOCKET_BAD with a
* short reason in err. */
os_socket_t os_net_listen(const char *host, int port, char *err, size_t err_sz);
/* The port actually bound (0 when unknown). */
int os_net_port(os_socket_t s);
/* A one-line "host:port" for messages. */
void os_net_peer(os_socket_t s, char *out, size_t out_sz);
/* Connect to host:port, waiting up to timeout_ms: the socket, or
* OS_SOCKET_BAD. Used by the tests and by anything that drives the WebUI
* API from inside the process. */
os_socket_t os_net_connect(const char *host, int port, int timeout_ms);
/* Accept one connection, waiting up to timeout_ms (<0 = forever).
* Returns the socket, OS_SOCKET_BAD on error, or OS_SOCKET_BAD with
* *timed_out set when the wait ran out. */
os_socket_t os_net_accept(os_socket_t s, int timeout_ms, int *timed_out);
/* Read at most n bytes, waiting up to timeout_ms: bytes read, 0 at EOF,
* -1 on error, -2 when nothing arrived in time. */
long os_net_recv(os_socket_t s, void *buf, size_t n, int timeout_ms);
/* Write everything (timeout_ms per call): 0 / -1. */
int os_net_send_all(os_socket_t s, const void *buf, size_t n, int timeout_ms);
/* Wake up a thread blocked in recv/send on this socket (shutdown, no close). */
void os_net_shutdown(os_socket_t s);
void os_net_close(os_socket_t s);
#ifdef __cplusplus
}
#endif
#endif /* CST_NET_H */
+7 -8
View File
@@ -2,20 +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, * Windows Low integrity level: a restricted token (no admin privileges,
* Low IL) can only write objects labeled Low, so the workspace and * Low IL) can only write objects labeled Low, so the workspace and
* a temp dir get that label; the credentials file is labeled * a temp dir get that label; the credentials file is labeled
* no-read-up. The network stays open (callers ask first). * no-read-up.
* other none: callers fall back to rule-based approval * other none: callers fall back to rule-based approval
*/ */
@@ -42,7 +41,7 @@ int cst_sandbox_probe(void);
int cst_sandbox_reprobe(void); int cst_sandbox_reprobe(void);
/* "landlock" | "seatbelt" | "seccomp" | "lowil" | "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 {
@@ -69,7 +68,7 @@ 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 /* lowil: directories this user's cst-agent labeled Low (recorded in the
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 7 #define CST_VERSION_MINOR 7
#define CST_VERSION_PATCH 0 #define CST_VERSION_PATCH 8
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+48
View File
@@ -0,0 +1,48 @@
#ifndef CST_WEB_MD_H
#define CST_WEB_MD_H
/* Markdown for the WebUI: the same sashidown renderer the TUI uses, but
* HTML output with a strict allowlist.
*
* sashidown renders straight to HTML and escapes text, quotes and '&', so
* no raw markup can reach the browser. Two holes remain, and both matter
* because every byte rendered here comes from a model or a tool: a link's
* href keeps any scheme ("javascript:") and an image's src may point
* anywhere (a tracking pixel, or worse). cst_web_md_* therefore rewrites
* tags instead of passing them through: unknown tags are dropped, hrefs
* are limited to http/https/mailto/relative, and images to the session's
* own attachment endpoint.
*
* The streaming form renders confirmed HTML as the model talks, which is
* the CST property the project is built on: nothing is emitted before it
* is safe to emit. A tag split across two deltas is held back until its
* '>' arrives. */
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
/* Called with chunks of sanitized HTML; return non-zero to stop. */
typedef int (*cst_web_md_emit_fn)(const char *html, size_t len, void *ud);
typedef struct cst_web_md cst_web_md_t;
cst_web_md_t *cst_web_md_new(cst_web_md_emit_fn emit, void *ud);
/* Feed model output. Returns 0, or -1 on OOM. */
int cst_web_md_feed(cst_web_md_t *s, const char *data, size_t len);
/* Flush the pending input and close the renderer. */
int cst_web_md_finish(cst_web_md_t *s);
void cst_web_md_free(cst_web_md_t *s);
/* One-shot: markdown -> sanitized HTML (malloc'd, never NULL on success). */
char *cst_web_md_html(const char *md, size_t len);
/* The sanitizer alone (tests, and any HTML that did not come from here). */
char *cst_web_md_sanitize(const char *html, size_t len);
#ifdef __cplusplus
}
#endif
#endif /* CST_WEB_MD_H */
+40
View File
@@ -0,0 +1,40 @@
#ifndef CST_WEBUI_H
#define CST_WEBUI_H
/* The built-in WebUI: an HTTP + SSE front end for one agent process.
*
* It is a second implementation of the same interface the TUI uses
* (cst_agent_hooks_t), which is why the engine needs no changes: the turn
* runs on a worker thread and every live event goes out as a Server-Sent
* Event. The protocol is documented in docs/webui.md and is meant to be
* the contract a dedicated client speaks later. */
#include "cst_agent.h"
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef struct {
const char *host; /* NULL = 127.0.0.1; "0.0.0.0" = every interface */
int port; /* 0 = pick a free port */
const char *token; /* NULL = generate one when needed */
const char *root; /* serve the UI from this directory instead of the
copy embedded in the binary (development) */
int open_browser; /* best-effort xdg-open / open / start */
/* Prompt to run once at startup (--web -p ...); NULL = wait for the UI. */
const char *prompt;
int perm_timeout_s; /* how long a permission question waits (0 = 600) */
} cst_webui_opts_t;
/* Serve until interrupted (Ctrl+C) or the event stream asks to quit.
* Returns the process exit code. */
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts);
#ifdef __cplusplus
}
#endif
#endif /* CST_WEBUI_H */
+263
View File
@@ -0,0 +1,263 @@
#!/bin/sh
# End-to-end check of the WebUI without a real model: a recorded SSE stream
# (tests/fixtures/sse) drives a full turn, and the events the browser would
# see are read back from /api/events with curl.
#
# scripts/e2e_webui.sh [replay-dir] [protocol]
#
# Needs: curl. python3 only for the JSON field checks (they are skipped
# without it). Exit code 0 = every assertion passed.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
REPLAY="${1:-$ROOT/tests/fixtures/sse/deepseek-native-eos}"
PROTO="${2:-native-eos}"
BIN="${CST_AGENT:-$ROOT/cst-agent}"
[ -x "$BIN" ] || { echo "build cst-agent first ($BIN)"; exit 1; }
WORK="$(mktemp -d "${TMPDIR:-/tmp}/cst-webui-XXXXXX")" 2>/dev/null || {
WORK="$ROOT/.build-tmp/e2e-webui"; rm -rf "$WORK"; mkdir -p "$WORK"; }
EVENTS="$WORK/events.txt"
LOG="$WORK/server.log"
cleanup() {
[ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true
rm -rf "$WORK"
}
trap cleanup EXIT INT TERM
echo "== start cst-agent --web (replay $REPLAY)"
# --yolo: the recorded stream calls write tools, and a permission question
# would wait for a browser nobody is driving here.
CST_REPLAY_SSE="$REPLAY" \
CST_REPLAY_CHUNK=512 \
"$BIN" --web --web-port 0 --workspace "$WORK" --new --api-key sk-e2e \
--protocol "$PROTO" --tools default --yolo > "$LOG" 2>&1 &
SRV_PID=$!
# The port is picked by the OS; the server prints the URL it bound.
i=0
PORT=""
while [ $i -lt 50 ]; do
PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$LOG" | head -1)"
[ -n "$PORT" ] && break
i=$((i + 1))
sleep 0.2
done
[ -n "$PORT" ] || { echo "FAIL: the server did not report a port"; cat "$LOG"; exit 1; }
BASE="http://127.0.0.1:$PORT"
echo " port $PORT"
fail() { echo "FAIL: $1"; echo "--- events"; cat "$EVENTS" 2>/dev/null | head -40; exit 1; }
# 1. the shell page and the state endpoint
curl -fsS --max-time 5 "$BASE/" | grep -q "<title>CST-Agent" || fail "GET / is not the shell page"
curl -fsS --max-time 5 "$BASE/api/state" > "$WORK/state.json" || fail "GET /api/state"
grep -q '"protocol":"cst-webui/1"' "$WORK/state.json" || fail "state has no protocol tag"
grep -q '"session":' "$WORK/state.json" || fail "state has no session id"
# 2. an unknown route is a 404, an oversized body a 413
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/does-not-exist")"
[ "$code" = "404" ] || fail "unknown route returned $code"
code="$(head -c 2000000 /dev/zero | tr '\0' 'a' |
curl -s -o /dev/null -w '%{http_code}' --max-time 20 \
-X POST --data-binary @- "$BASE/api/turn")"
[ "$code" = "413" ] || fail "2 MB body returned $code (want 413)"
# 3. stream the events while a recorded turn runs
curl -sN --max-time 120 "$BASE/api/events" > "$EVENTS" &
CURL_PID=$!
sleep 0.5
code="$(curl -s -o "$WORK/turn.json" -w '%{http_code}' --max-time 5 \
-X POST -H 'Content-Type: application/json' \
--data '{"text":"e2e: what is in the workspace?"}' "$BASE/api/turn")"
[ "$code" = "202" ] || fail "POST /api/turn returned $code"
i=0
while [ $i -lt 450 ]; do
grep -q "event: turn_end" "$EVENTS" && break
i=$((i + 1))
sleep 0.2
done
kill "$CURL_PID" 2>/dev/null || true
grep -q "event: turn_end" "$EVENTS" || fail "the turn never finished"
grep -q "event: assistant_html" "$EVENTS" || fail "no assistant markdown was streamed"
grep -q "event: tool" "$EVENTS" || fail "no tool event was streamed"
grep -q '"rc":0' "$EVENTS" || fail "the turn did not end with rc=0"
# 4. the session on disk matches what the UI would draw
curl -fsS --max-time 5 "$BASE/api/session" > "$WORK/session.json" || fail "GET /api/session"
grep -q '"role":"assistant"' "$WORK/session.json" || fail "no assistant message stored"
curl -fsS --max-time 5 "$BASE/api/sessions" | grep -q '"sessions":' || fail "GET /api/sessions"
# 5. a second turn while the first is running is refused with 409
curl -s -o /dev/null -w '' --max-time 5 -X POST -H 'Content-Type: application/json' \
--data '{"text":"x"}' "$BASE/api/turn" || true
# 6. asking to stop is accepted
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
--data '{}' "$BASE/api/cancel" > /dev/null || fail "POST /api/cancel"
# 6b. the write endpoints: config, undo, compact and image upload
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
--data '{"approval":"ask","plan_mode":false}' "$BASE/api/config" |
grep -q '"ok":true' || fail "POST /api/config"
curl -fsS --max-time 5 "$BASE/api/state" | grep -q '"approval":"ask"' ||
fail "the config change is not in the state"
code="$(curl -s -o "$WORK/undo.txt" -w '%{http_code}' --max-time 5 \
-X POST "$BASE/api/undo")"
case "$code" in
200) grep -q . "$WORK/undo.txt" || fail "an undo without a report" ;;
409) ;; # nothing to undo in this run
*) fail "POST /api/undo returned $code" ;;
esac
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \
-X POST "$BASE/api/compact")"
# The recorded stream is spent by now, so a model failure is a valid answer.
case "$code" in 200 | 500) ;; *) fail "POST /api/compact returned $code" ;; esac
if command -v base64 > /dev/null; then
# a real 1x1 PNG: the server sniffs the content, not the extension
# (kept with a .bin suffix so the find below only sees the stored copy)
echo 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==' |
base64 -d > "$WORK/tiny.bin" 2> /dev/null || : > "$WORK/tiny.bin"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST \
--data-binary @"$WORK/tiny.bin" "$BASE/api/attach?name=e2e.png")"
[ "$code" = "200" ] || fail "POST /api/attach returned $code"
curl -fsS --max-time 5 "$BASE/api/state" |
grep -q '"pending_images":\["' || fail "the upload is not pending"
# the stored name is the content hash, so ask the disk for it
STORED="$(find "$WORK" -name '*.png' -type f 2>/dev/null | head -1)"
[ -n "$STORED" ] || fail "the upload did not reach the session directory"
REL="${STORED##*/}"
curl -fsS --max-time 5 "$BASE/api/attach/$REL" > "$WORK/back.png" ||
fail "GET /api/attach/$REL"
cmp -s "$WORK/tiny.bin" "$WORK/back.png" ||
fail "the image did not round trip byte for byte"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
"$BASE/api/attach/..%2f..%2fetc%2fpasswd")"
case "$code" in 400 | 404) ;; *) fail "a traversal in attach returned $code" ;; esac
curl -fsS --max-time 5 -X DELETE "$BASE/api/attach" | grep -q '"ok":true' ||
fail "DELETE /api/attach"
curl -fsS --max-time 5 "$BASE/api/state" |
grep -q '"pending_images":\[\]' || fail "the pending image was not cleared"
else
echo " (no base64 tool: the image upload check is skipped)"
fi
# 6c. a request with a foreign Host header is refused while no token is set:
# that is the DNS-rebinding guard.
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H 'Host: evil.example' "$BASE/api/state")"
[ "$code" = "401" ] || fail "a foreign Host header returned $code (want 401)"
if command -v python3 > /dev/null; then
python3 - "$WORK/session.json" <<'PY' || fail "the session JSON is not valid JSON / is empty"
import json, sys
d = json.load(open(sys.argv[1]))
assert isinstance(d.get("messages"), list) and d["messages"], d
PY
fi
# 7. the interactive approval path: a fresh server in ask mode must raise a
# permission event, and answering it must let the turn continue.
echo "== permission round trip (-A ask)"
kill "$SRV_PID" 2>/dev/null || true # the first server's replay is spent
rm -rf "$WORK/pw"; mkdir -p "$WORK/pw"
P_EVENTS="$WORK/permission-events.txt"
P_LOG="$WORK/permission-server.log" # its own log: the port parse must not
# pick up the first server's URL
CST_REPLAY_SSE="$REPLAY" CST_REPLAY_CHUNK=512 \
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
--protocol "$PROTO" --tools default --approval ask --confirm all \
> "$P_LOG" 2>&1 &
P_PID=$!
i=0
P_PORT=""
while [ $i -lt 50 ]; do
P_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$P_LOG" | tail -1)"
[ -n "$P_PORT" ] && break
i=$((i + 1)); sleep 0.2
done
[ -n "$P_PORT" ] || fail "the ask-mode server did not start (see $P_LOG)"
P_BASE="http://127.0.0.1:$P_PORT"
curl -sN --max-time 60 "$P_BASE/api/events" > "$P_EVENTS" &
P_CURL=$!
sleep 0.5
curl -s -o /dev/null --max-time 5 -X POST -H 'Content-Type: application/json' \
--data '{"text":"e2e: ask me first"}' "$P_BASE/api/turn"
i=0
while [ $i -lt 300 ]; do
grep -q "event: permission" "$P_EVENTS" && break
i=$((i + 1)); sleep 0.2
done
if [ $i -ge 300 ]; then
kill "$P_PID" 2>/dev/null || true
kill "$P_CURL" 2>/dev/null || true
echo " (no permission question within 60 s - skipped; $(grep -c '^event: ' "$P_EVENTS") events seen)"
else
PID_REQ="$(sed -n 's/.*"id":\([0-9]*\),"tool".*/\1/p' "$P_EVENTS" | head -1)"
[ -n "$PID_REQ" ] || fail "a permission event without an id"
# Answer while the server is still waiting for it.
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
--data "{\"id\":$PID_REQ,\"answer\":1}" "$P_BASE/api/permission" > /dev/null ||
fail "POST /api/permission was refused"
i=0
while [ $i -lt 150 ]; do
grep -q "event: permission_done" "$P_EVENTS" && break
i=$((i + 1)); sleep 0.2
done
grep -q "event: permission_done" "$P_EVENTS" || fail "the answer was not acknowledged"
kill "$P_PID" 2>/dev/null || true
kill "$P_CURL" 2>/dev/null || true
echo " answered request $PID_REQ with 'allow once'"
fi
# 8. token auth, --web-dev, and a clean shutdown
echo "== token auth and --web-dev"
T_LOG="$WORK/token-server.log"
CST_REPLAY_SSE="$REPLAY" \
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
--protocol "$PROTO" --tools default --yolo --web-dev "$ROOT/web" \
--web-token tok-e2e > "$T_LOG" 2>&1 &
T_PID=$!
i=0
T_PORT=""
while [ $i -lt 50 ]; do
T_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$T_LOG" | tail -1)"
[ -n "$T_PORT" ] && break
i=$((i + 1)); sleep 0.2
done
[ -n "$T_PORT" ] || fail "the token server did not start (see $T_LOG)"
T_BASE="http://127.0.0.1:$T_PORT"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$T_BASE/api/state")"
[ "$code" = "401" ] || fail "a token-protected server answered $code without one"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H 'Authorization: Bearer tok-e2e' "$T_BASE/api/state")"
[ "$code" = "200" ] || fail "the Bearer token was rejected ($code)"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
"$T_BASE/api/state?token=tok-e2e")"
[ "$code" = "200" ] || fail "the query token was rejected ($code)"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H 'Cookie: a=b; cst_token=tok-e2e; z=y' "$T_BASE/api/state")"
[ "$code" = "200" ] || fail "the cookie token was rejected ($code)"
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
-H 'Authorization: Bearer tok-e2' "$T_BASE/api/state")"
[ "$code" = "401" ] || fail "a wrong token was accepted ($code)"
# --web-dev serves straight from the directory, so the bytes must match it
curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/app.js" \
> "$WORK/app.js" || fail "GET /app.js from --web-dev"
cmp -s "$WORK/app.js" "$ROOT/web/app.js" ||
fail "--web-dev did not serve web/app.js from disk"
# POST /api/quit must return and then take the server down on its own
curl -fsS --max-time 5 -X POST -H 'Authorization: Bearer tok-e2e' \
"$T_BASE/api/quit" | grep -q '"ok":true' || fail "POST /api/quit"
i=0
while [ $i -lt 50 ] && kill -0 "$T_PID" 2> /dev/null; do
i=$((i + 1)); sleep 0.2
done
kill -0 "$T_PID" 2> /dev/null && fail "the server ignored /api/quit"
wait "$T_PID" 2>/dev/null || true
echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))"
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# Embed web/ into .gen/web_assets.inc as C byte arrays (the same trick the
# Makefile uses for src/models_builtin.json). POSIX sh on purpose: the musl
# and busybox build environments have no bash. Usage:
# sh scripts/embed_web.sh [out.inc] [web-dir]
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
OUT="${1:-$ROOT/.gen/web_assets.inc}"
DIR="${2:-$ROOT/web}"
FILES="index.html app.js style.css favicon.svg"
mkdir -p "$(dirname "$OUT")"
{
echo "/* Generated by scripts/embed_web.sh from web/ - do not edit. */"
for f in $FILES; do
[ -f "$DIR/$f" ] || continue
var="wui_asset_$(echo "$f" | tr -c 'a-zA-Z0-9' '_')"
echo "static const unsigned char ${var}[] = {"
od -An -v -tx1 "$DIR/$f" | sed 's/\([0-9a-f][0-9a-f]\)/0x\1,/g'
echo "0x00};"
done
echo "static const wui_asset_t wui_assets[] = {"
for f in $FILES; do
[ -f "$DIR/$f" ] || continue
var="wui_asset_$(echo "$f" | tr -c 'a-zA-Z0-9' '_')"
case "$f" in
*.html) ctype="text/html; charset=utf-8" ;;
*.js) ctype="text/javascript; charset=utf-8" ;;
*.css) ctype="text/css; charset=utf-8" ;;
*.svg) ctype="image/svg+xml" ;;
*) ctype="application/octet-stream" ;;
esac
echo " {\"$f\", \"$ctype\", ${var}, sizeof(${var}) - 1},"
done
echo "};"
echo "static const int wui_asset_count = (int)(sizeof(wui_assets) / sizeof(wui_assets[0]));"
} > "$OUT.tmp"
mv "$OUT.tmp" "$OUT"
echo "embedded $(wc -c < "$OUT") bytes of C into $OUT"
+44
View File
@@ -0,0 +1,44 @@
import os
import sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from harness import Term
import fakeapi
from keys import input_text, styled, check, FAILS, base
for term in ("xterm-256color", "screen-256color"):
t = Term(args=("-b", base, "-k", "sk-test"), term=term)
t.send("some text")
t.send("\x1b", 0.4)
check(term + " lone esc clears input", input_text(t) == [], t)
t.send("alpha beta")
t.send("\x1bb") # alt+b word left
t.send("X")
check(term + " alt+b word left", "alpha Xbeta" in input_text(t), t)
t.send("\x1b\r") # alt+enter newline
t.send("Y")
check(term + " alt+enter newline", input_text(t)[-2:] == ["alpha X", "Ybeta"], t)
t.send("\x03", 0.2)
# picker with CSI form
t.send("/model\r", 0.8)
s0 = styled(t)
t.arrow("down", "csi")
s1 = styled(t)
t.arrow("up", "csi")
s2 = styled(t)
check(term + " picker csi down", s0 != s1, t)
check(term + " picker csi up", s0 == s2, t)
t.send("\x1b", 0.4)
# home/end variants
t.send("middle")
for seq in ("\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"):
t.send("\x1b[F" + seq)
t.send("<")
t.send("\x08" if False else "")
check(term + " home variants", input_text(t)[-1:] == ["<<<<middle"], t)
for seq in ("\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"):
t.send("\x1b[H" + seq)
t.send(">")
check(term + " end variants", input_text(t)[-1:] == ["<<<<middle>>>>"], t)
t.close()
print("FAILED:", len(FAILS))
sys.exit(min(len(FAILS), 100))
+43
View File
@@ -0,0 +1,43 @@
import json, sys, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
LOG = []
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}, {"id": "deepseek-v4-pro"}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(n)
try:
LOG.append(json.loads(raw))
except Exception:
LOG.append({"_raw": raw[:200].decode("latin1")})
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
def ev(o):
self.wfile.write(b"data: " + json.dumps(o).encode() + b"\n\n")
self.wfile.flush()
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
ev({**base, "choices": [{"index": 0, "delta": {"role": "assistant", "content": "reply-%d" % len(LOG)}}]})
ev({**base, "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 10, "completion_tokens": 2, "total_tokens": 12}})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
def start():
s = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=s.serve_forever, daemon=True).start()
return s, "http://127.0.0.1:%d/v1" % s.server_address[1]
+99
View File
@@ -0,0 +1,99 @@
"""Drive the cst-agent TUI in a pty and read the screen back (pyte).
Run from the repo root after `make`:
nix-shell -p python3Packages.pyte --run "python3 scripts/tui_keys/keys.py"
nix-shell -p python3Packages.pyte --run "python3 scripts/tui_keys/esc.py"
(or `pip install pyte`). A local fake OpenAI-compatible server answers the
model calls, so no API key is needed. Exit status is the failure count.
"""
import os, pty, sys, time, select, fcntl, termios, struct, signal, shutil, tempfile
import pyte
BIN = os.environ.get("CST_BIN") or os.path.join(
os.path.dirname(os.path.abspath(__file__)), "..", "..", "cst-agent")
class Term:
def __init__(self, cols=120, rows=36, kitty=False, env=None, args=(), home=None,
ws=None, term="xterm-256color"):
self.cols, self.rows, self.kitty = cols, rows, kitty
self.screen = pyte.Screen(cols, rows)
self.stream = pyte.ByteStream(self.screen)
self.home = home or tempfile.mkdtemp(prefix="cstk_home_")
self.ws = ws or tempfile.mkdtemp(prefix="cstk_ws_")
e = dict(os.environ)
e.update({"TERM": term, "HOME": self.home,
"XDG_CONFIG_HOME": os.path.join(self.home, ".config"),
"LANG": "C.UTF-8", "LC_ALL": "C.UTF-8",
"CST_PROVIDER": "deepseek", "DEEPSEEK_API_KEY": "sk-dummy-test",
"CST_METRICS": "0"})
e.pop("NO_COLOR", None)
if env:
e.update(env)
self.raw = b""
pid, fd = pty.fork()
if pid == 0:
os.chdir(self.ws)
os.execve(BIN, [BIN, "-N", *args], e)
self.pid, self.fd = pid, fd
fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
self.pump(1.5)
def pump(self, t=0.25):
end = time.time() + t
while True:
left = end - time.time()
if left <= 0:
break
r, _, _ = select.select([self.fd], [], [], left)
if not r:
continue
try:
d = os.read(self.fd, 65536)
except OSError:
break
if not d:
break
self.raw += d
if self.kitty and b"\x1b[?u" in d:
os.write(self.fd, b"\x1b[?0u")
self.stream.feed(d)
def send(self, b, t=0.15):
if isinstance(b, str):
b = b.encode()
os.write(self.fd, b)
self.pump(t)
def appcursor(self):
return (1 << 5) in self.screen.mode # pyte DECCKM
def arrow(self, d, form=None, mod=None, t=0.15):
c = {"up": "A", "down": "B", "right": "C", "left": "D"}[d]
if mod:
seq = "\x1b[1;%d%s" % (mod, c)
else:
f = form or ("ss3" if self.appcursor() else "csi")
seq = ("\x1bO" if f == "ss3" else "\x1b[") + c
self.send(seq, t)
def lines(self):
return [l.rstrip() for l in self.screen.display]
def find(self, s):
return [(i, l) for i, l in enumerate(self.lines()) if s in l]
def cursor(self):
return self.screen.cursor.y, self.screen.cursor.x
def dump(self, title=""):
print("=" * 20, title, "cursor", self.cursor())
for i, l in enumerate(self.lines()):
print("%2d|%s" % (i, l))
def close(self):
try:
os.kill(self.pid, signal.SIGTERM)
os.waitpid(self.pid, 0)
except Exception:
pass
+273
View File
@@ -0,0 +1,273 @@
import os
import sys, time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from harness import Term
import fakeapi
srv, base = fakeapi.start()
FAILS = []
VERBOSE = "-v" in sys.argv
def check(name, cond, t=None):
print(("PASS " if cond else "FAIL ") + name)
if not cond:
FAILS.append(name)
if t:
t.dump(name)
def input_text(t):
"""Input box rows: the bar rows right above the mode line."""
L = t.lines()
mode = [i for i, l in enumerate(L) if l.lstrip().startswith("┃ ") and
("Build ·" in l or "Plan ·" in l)]
if not mode:
return []
rows = []
i = mode[-1] - 1
while i >= 0:
s = L[i].lstrip()
if not s.startswith("┃"):
break
body = s[2:].split(" ")[0].rstrip() if s.startswith("┃ ") else ""
if body == "" and rows:
break
if body:
rows.append(body)
i -= 1
rows.reverse()
if rows and (rows[0].startswith("Ask anything") or rows[0].startswith("Enter queues")):
return []
return rows
def styled(t):
"""Screen text plus the reverse/bg attributes, to see highlight moves."""
out = []
for y in range(t.rows):
row = t.screen.buffer[y]
out.append("".join("%s%s%s" % (row[x].data, int(row[x].reverse), row[x].bg[:3])
for x in range(t.cols)))
return out
def clear(t):
if [r for r in input_text(t) if r]:
t.send("\x03", 0.2) # ctrl+c clears a non-empty input (twice on empty quits)
def new(kitty=False, **kw):
t = Term(kitty=kitty, args=("-b", base, "-k", "sk-test", "-m", "deepseek-flash"), **kw)
return t
def basic(t, form, tag):
clear(t)
t.send("hello world")
for _ in range(5):
t.arrow("left", form)
t.send("X")
check(f"{tag} left x5 insert", "hello Xworld" in input_text(t), t)
t.arrow("right", form); t.arrow("right", form)
t.send("Y")
check(f"{tag} right x2 insert", "hello XwoYrld" in input_text(t), t)
# boundaries
for _ in range(30):
t.arrow("left", form, t=0.02)
t.pump(0.2)
t.send("<")
check(f"{tag} left past start", "<hello XwoYrld" in input_text(t), t)
for _ in range(30):
t.arrow("right", form, t=0.02)
t.pump(0.2)
t.send(">")
check(f"{tag} right past end", "<hello XwoYrld>" in input_text(t), t)
clear(t)
def cjk(t):
clear(t)
t.send("你好世界")
y, x_end = t.cursor()
t.arrow("left"); t.arrow("left")
y2, x2 = t.cursor()
check("cjk cursor moves 2 cells per char", x_end - x2 == 4, t)
t.send("A")
check("cjk insert between wide chars", "你好A世界" in input_text(t), t)
t.arrow("right")
t.send("B")
check("cjk right over wide char", "你好A世B界" in input_text(t), t)
clear(t)
t.send("a😀b")
t.arrow("left"); t.arrow("left")
t.send("Z")
check("emoji left", "aZ😀b" in input_text(t), t)
clear(t)
def multiline(t):
clear(t)
t.send("line one is long")
t.send("\x0e") # ctrl+n newline
t.send("l2")
t.send("\x0e")
t.send("third line here")
rows = input_text(t)
check("multiline 3 rows", rows[-3:] == ["line one is long", "l2", "third line here"], t)
for _ in range(4):
t.arrow("left") # cursor before "here"
t.arrow("up") # col 11 -> clamp to end of "l2"
t.send("U")
rows = input_text(t)
check("up clamps column to short line", rows[-2] == "l2U", t)
t.arrow("up") # col 3 on line one
t.send("V")
rows = input_text(t)
check("up keeps column", rows[-3] == "linVe one is long", t)
t.arrow("down"); t.arrow("down")
t.send("W")
rows = input_text(t)
check("down twice to last line", "W" in rows[-1], t)
clear(t)
def history(t):
clear(t)
t.send("first prompt\r", 1.5)
t.send("second prompt\r", 1.5)
t.arrow("up")
check("hist up -> newest", input_text(t)[-1:] == ["second prompt"], t)
t.arrow("up")
check("hist up -> older", input_text(t)[-1:] == ["first prompt"], t)
t.arrow("up")
check("hist up at oldest stays", input_text(t)[-1:] == ["first prompt"], t)
t.arrow("down")
check("hist down -> newer", input_text(t)[-1:] == ["second prompt"], t)
t.arrow("down")
check("hist down past newest -> empty", input_text(t) in ([], [""]), t)
# draft survives a trip through history
t.send("my draft")
t.arrow("up")
t.arrow("down")
check("draft restored after up/down", input_text(t)[-1:] == ["my draft"], t)
clear(t)
def wrapped(t):
clear(t)
long = " ".join("w%02d" % i for i in range(40)) # ~160 chars, wraps
t.send(long, 0.4)
nrows = len(input_text(t))
t.arrow("up")
txt = "".join(input_text(t))
check("up on wrapped line keeps the text", "w39" in txt and "w00" in txt, t)
t.send("Q")
rows = input_text(t)
check("up on wrapped line moves one visual row", nrows >= 2 and "Q" in "".join(rows[:-1]), t)
clear(t)
def completion(t):
clear(t)
t.send("/", 0.3)
before = styled(t)
t.arrow("down"); t.arrow("down")
mid = styled(t)
t.arrow("up")
after = styled(t)
check("completion popup reacts to down", before != mid, t)
check("completion up differs from down-down", mid != after, t)
check("completion arrows do not touch input", input_text(t)[-1:] == ["/"], t)
t.send("\x1b", 0.3)
clear(t)
def modifiers(t):
clear(t)
t.send("alpha beta gamma")
t.arrow("left", mod=5) # ctrl+left -> word left
t.send("X")
check("ctrl+left word", "alpha beta Xgamma" in input_text(t), t)
t.arrow("left", mod=3) # alt+left
t.arrow("left", mod=3)
t.send("Y")
check("alt+left word", "alpha Ybeta Xgamma" in input_text(t), t)
t.arrow("right", mod=5)
t.send("Z")
check("ctrl+right word", "alpha YbetaZ Xgamma" in input_text(t), t)
t.arrow("left", mod=2) # shift+left: should at least not insert junk
t.send("S")
txt = "".join(input_text(t))
check("shift+left no junk", "[" not in txt and ";" not in txt and "1;2" not in txt, t)
clear(t)
def split_and_burst(t, form):
clear(t)
t.send("abcdef")
ss = "\x1bO" if form == "ss3" else "\x1b["
# escape sequence split across writes (slow link)
import os
os.write(t.fd, ss[:1].encode()); time.sleep(0.008)
os.write(t.fd, (ss[1:] + "D").encode()); t.pump(0.2)
t.send("1")
check(f"split {form} seq", "abcde1f" in input_text(t), t)
# key-repeat burst in one write
t.send((ss + "D") * 4, 0.3)
t.send("2")
check(f"burst {form} x4", "ab2cde1f" in input_text(t), t)
clear(t)
def picker(t):
clear(t)
t.send("/model\r", 0.8)
s0 = styled(t)
t.arrow("down")
s1 = styled(t)
t.arrow("up")
s2 = styled(t)
check("picker down changes selection", s0 != s1, t)
check("picker up returns", s0 == s2, t)
t.send("\x1b", 0.4)
clear(t)
def run(kitty, tag):
print("#####", tag)
t = new(kitty=kitty)
try:
basic(t, None, tag + " native")
basic(t, "csi" if t.appcursor() else "ss3", tag + " other-form")
cjk(t)
multiline(t)
modifiers(t)
split_and_burst(t, "ss3")
split_and_burst(t, "csi")
completion(t)
wrapped(t)
history(t)
picker(t)
finally:
t.close()
if __name__ == "__main__":
run(False, "xterm")
run(True, "kitty")
for term in ("screen-256color", "tmux-256color", "linux", "vt220", "xterm"):
print("#####", term)
t = Term(args=("-b", base, "-k", "sk-test"), term=term)
try:
basic(t, "ss3", term + " ss3")
basic(t, "csi", term + " csi")
modifiers(t)
multiline(t)
cjk(t)
finally:
t.close()
print("FAILED:", len(FAILS))
for f in FAILS:
print(" -", f)
sys.exit(min(len(FAILS), 100))
+5 -11
View File
@@ -259,7 +259,7 @@ static void put_safety(cst_buf_t *b, const cst_agent_config_t *cfg) {
"the task requires it. Never print secrets or API keys.\n"); "the task requires it. Never print secrets or API keys.\n");
if (tool_on(cfg, "run_shell")) if (tool_on(cfg, "run_shell"))
cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the " cst_buf_puts(b, "- run_shell may run in a sandbox (writes only inside the "
"workspace and temp dir, no network). If a command is " "workspace and temp dir; the network works). If a command is "
"blocked the user is asked whether to rerun it outside; " "blocked the user is asked whether to rerun it outside; "
"do not try to work around the sandbox.\n"); "do not try to work around the sandbox.\n");
} }
@@ -827,13 +827,9 @@ static void sandbox_escalate(turn_t *t, cst_tool_ctx_t *ctx,
} }
if (ans == CST_PERM_DENY) { if (ans == CST_PERM_DENY) {
const char *note = const char *note =
cst_sandbox_probe() == CST_SANDBOX_LOWIL "\n[cst: blocked by the sandbox (writes are limited to the "
? "\n[cst: blocked by the sandbox (writes are limited to the "
"workspace and temp dir); the user did not allow running it " "workspace and temp dir); the user did not allow running it "
"outside]\n" "outside]\n";
: "\n[cst: blocked by the sandbox (writes are limited to "
"the workspace and temp dir, no network); the user did "
"not allow running it outside]\n";
cst_buf_t b = {0}; cst_buf_t b = {0};
if (tr->out) if (tr->out)
cst_buf_append(&b, tr->out, tr->out_len); cst_buf_append(&b, tr->out, tr->out_len);
@@ -989,11 +985,9 @@ static void exec_begin(turn_t *t, pcall_t *pc, exec_t *x) {
if (def->perm == CST_TP_WRITE) { if (def->perm == CST_TP_WRITE) {
ask = 0; ask = 0;
} else if (def->perm == CST_TP_SHELL) { } else if (def->perm == CST_TP_SHELL) {
int sb = cst_sandbox_probe(); if (cst_sandbox_probe() != CST_SANDBOX_NONE) {
if (sb != CST_SANDBOX_NONE) {
ctx->sandbox = 1; ctx->sandbox = 1;
/* the Low-IL sandbox does not stop network access */ ask = 0;
ask = sb == CST_SANDBOX_LOWIL && cmd && cst_shell_uses_network(cmd);
} else if (cmd && cst_shell_is_safe(cmd)) { } else if (cmd && cst_shell_is_safe(cmd)) {
ask = 0; ask = 0;
} }
+684
View File
@@ -0,0 +1,684 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_http.h"
#include "cst_util.h"
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define HTTP_HDR_MAX 32768 /* one header block */
#define HTTP_LINE_MAX 8192 /* one header line */
#define HTTP_DEFAULT_BODY (1024 * 1024)
#define HTTP_DEFAULT_CONN 32
#define HTTP_IDLE_TIMEOUT 60000
#define HTTP_READ_TIMEOUT 15000
struct cst_http_server {
os_socket_t listener;
int port;
pthread_t accept_th;
int accept_started;
pthread_mutex_t mu;
pthread_cond_t cv;
int stopping;
int nconn;
cst_http_conn_t *conns_first;
cst_http_opts_t o;
cst_http_route_t *routes;
int nroutes;
};
struct cst_http_conn {
cst_http_server_t *srv;
os_socket_t sock;
cst_http_req_t req;
char rbuf[8192];
size_t rlen, rpos;
char *body; /* owned while the request is served */
char hdrs[HTTP_HDR_MAX]; /* header values are copied here */
size_t hdrs_used;
int sent;
int streaming;
int failed;
cst_http_conn_t *prev, *next;
};
static const char *status_text(int code) {
switch (code) {
case 200: return "OK";
case 201: return "Created";
case 202: return "Accepted";
case 204: return "No Content";
case 302: return "Found";
case 400: return "Bad Request";
case 401: return "Unauthorized";
case 403: return "Forbidden";
case 404: return "Not Found";
case 405: return "Method Not Allowed";
case 409: return "Conflict";
case 411: return "Length Required";
case 413: return "Payload Too Large";
case 415: return "Unsupported Media Type";
case 429: return "Too Many Requests";
case 500: return "Internal Server Error";
case 503: return "Service Unavailable";
default: return "Error";
}
}
/* --- reading -------------------------------------------------------------- */
static int fill(cst_http_conn_t *c, int timeout_ms) {
if (c->rpos < c->rlen)
return 1;
long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), timeout_ms);
if (r <= 0)
return (int)r; /* 0 eof, -1 error, -2 timeout */
c->rpos = 0;
c->rlen = (size_t)r;
return 1;
}
static int rd_line(cst_http_conn_t *c, char *out, size_t cap, int timeout_ms) {
size_t n = 0;
for (;;) {
int rc = fill(c, timeout_ms);
if (rc <= 0)
return rc;
while (c->rpos < c->rlen) {
char ch = c->rbuf[c->rpos++];
if (ch == '\n') {
if (n && out[n - 1] == '\r')
n--;
out[n] = 0;
return 1;
}
if (n + 2 >= cap)
return -1;
out[n++] = ch;
}
}
}
/* Exactly n bytes, using what the read buffer already holds. malloc'd. */
static char *rd_body(cst_http_conn_t *c, size_t n, int timeout_ms) {
char *buf = malloc(n + 1);
if (!buf)
return NULL;
size_t got = 0;
while (got < n) {
if (c->rpos < c->rlen) {
size_t take = c->rlen - c->rpos;
if (take > n - got)
take = n - got;
memcpy(buf + got, c->rbuf + c->rpos, take);
c->rpos += take;
got += take;
continue;
}
long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), timeout_ms);
if (r <= 0) {
free(buf);
return NULL;
}
c->rpos = 0;
c->rlen = (size_t)r;
}
buf[n] = 0;
return buf;
}
static void url_decode(const char *in, char *out, size_t cap, int plus_space) {
size_t o = 0;
for (const char *p = in; *p && o + 1 < cap; p++) {
if (*p == '%' && p[1] && p[2]) {
int hi = p[1] >= '0' && p[1] <= '9' ? p[1] - '0'
: p[1] >= 'a' && p[1] <= 'f' ? p[1] - 'a' + 10
: p[1] >= 'A' && p[1] <= 'F' ? p[1] - 'A' + 10
: -1;
int lo = p[2] >= '0' && p[2] <= '9' ? p[2] - '0'
: p[2] >= 'a' && p[2] <= 'f' ? p[2] - 'a' + 10
: p[2] >= 'A' && p[2] <= 'F' ? p[2] - 'A' + 10
: -1;
if (hi >= 0 && lo >= 0) {
char ch = (char)((hi << 4) | lo);
if (ch == 0)
return; /* a decoded NUL would truncate the path */
out[o++] = ch;
p += 2;
continue;
}
}
out[o++] = (*p == '+' && plus_space) ? ' ' : *p;
}
out[o] = 0;
}
static int key_is(const char *line, const char *key) {
size_t n = strlen(key);
for (size_t i = 0; i < n; i++) {
char a = line[i], b = key[i];
if (a >= 'A' && a <= 'Z')
a = (char)(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z')
b = (char)(b - 'A' + 'a');
if (a != b || !a)
return 0;
}
return line[n] == ':';
}
/* Copy a header value into the connection (they must not point into the
* line buffer, which is reused for the next header). */
static const char *store_str(cst_http_conn_t *c, const char *val) {
size_t n = strlen(val);
if (c->hdrs_used + n + 1 > sizeof(c->hdrs))
return NULL;
char *dst = c->hdrs + c->hdrs_used;
memcpy(dst, val, n + 1);
c->hdrs_used += n + 1;
return dst;
}
/* 1 = parsed, 0 = the client went away or waited too long, -1 = malformed. */
static int read_request(cst_http_conn_t *c, size_t *want_body) {
cst_http_server_t *s = c->srv;
char line[HTTP_LINE_MAX];
cst_http_req_t *r = &c->req;
int first = c->rpos >= c->rlen; /* nothing buffered: this is a new client */
memset(r, 0, sizeof(*r));
c->hdrs_used = 0;
*want_body = 0;
int rc = rd_line(c, line, sizeof(line),
first ? s->o.read_timeout_ms : s->o.idle_timeout_ms);
if (rc <= 0)
return 0;
char *sp1 = strchr(line, ' ');
if (!sp1)
return -1;
*sp1 = 0;
char *target = sp1 + 1;
char *version = strchr(target, ' ');
if (version) {
*version++ = 0;
} else {
version = (char *)"HTTP/1.0";
}
if (!line[0] || strlen(line) >= sizeof(r->method))
return -1;
snprintf(r->method, sizeof(r->method), "%s", line);
r->keep_alive = strncmp(version, "HTTP/1.", 7) == 0 &&
version[7] >= '1' && version[7] <= '9';
/* absolute-form ("http://host/path") is legal: keep the path */
char *scheme = strstr(target, "://");
if (scheme)
target = strchr(scheme + 3, '/');
if (!target || target[0] != '/')
return -1;
{
char raw[sizeof(r->path)];
snprintf(raw, sizeof(raw), "%s", target);
char *q = strchr(raw, '?');
if (q) {
*q = 0;
snprintf(r->query, sizeof(r->query), "%s", q + 1);
}
url_decode(raw, r->path, sizeof(r->path), 0);
size_t plen = strlen(r->path);
if (plen == 0 || strstr(r->path, ".."))
return -1;
}
size_t hdr_bytes = 0;
size_t content_length = 0;
int have_length = 0;
for (;;) {
rc = rd_line(c, line, sizeof(line), s->o.read_timeout_ms);
if (rc <= 0)
return -1;
if (!line[0])
break;
hdr_bytes += strlen(line) + 2;
if (hdr_bytes > HTTP_HDR_MAX)
return -1;
const char *colon = strchr(line, ':');
if (!colon)
return -1;
const char *val = colon + 1;
while (*val == ' ' || *val == '\t')
val++;
if (key_is(line, "content-length")) {
content_length = (size_t)strtoul(val, NULL, 10);
have_length = 1;
} else if (key_is(line, "transfer-encoding")) {
return -1; /* chunked request bodies are not supported */
} else if (key_is(line, "connection")) {
if (strstr(val, "close"))
r->keep_alive = 0;
else if (strstr(val, "keep-alive"))
r->keep_alive = 1;
} else if (key_is(line, "expect")) {
const char *cont = "HTTP/1.1 100 Continue\r\n\r\n";
os_net_send_all(c->sock, cont, strlen(cont), 5000);
} else if (key_is(line, "authorization")) {
r->authorization = store_str(c, val);
} else if (key_is(line, "cookie")) {
r->cookie = store_str(c, val);
} else if (key_is(line, "origin")) {
r->origin = store_str(c, val);
} else if (key_is(line, "host")) {
r->host = store_str(c, val);
} else if (key_is(line, "content-type")) {
r->content_type = store_str(c, val);
} else if (key_is(line, "user-agent")) {
r->user_agent = store_str(c, val);
}
}
if (have_length)
*want_body = content_length;
else if (strcmp(r->method, "POST") == 0)
/* No Content-Length means no body (RFC 9112 6.3): curl -X POST sends
* exactly that, and the zero-body endpoints (cancel, undo, quit) would
* otherwise be unreachable from a hand-written client. Bytes that
* follow are not ours to guess at, so the connection ends here. */
r->keep_alive = 0;
return 1;
}
/* --- writing -------------------------------------------------------------- */
static int send_head(cst_http_conn_t *c, int status, const char *ctype,
size_t len, int chunked) {
cst_buf_t out = {0};
cst_buf_printf(&out, "HTTP/1.1 %d %s\r\nContent-Type: %s\r\n",
status, status_text(status), ctype ? ctype : "text/plain");
cst_buf_puts(&out, "Cache-Control: no-store\r\n");
cst_buf_puts(&out, c->req.keep_alive ? "Connection: keep-alive\r\n"
: "Connection: close\r\n");
if (chunked) {
cst_buf_puts(&out, "Transfer-Encoding: chunked\r\n");
} else {
cst_buf_printf(&out, "Content-Length: %zu\r\n", len);
}
cst_buf_puts(&out, "\r\n");
int rc = out.buf ? os_net_send_all(c->sock, out.buf, out.len, 0) : -1;
cst_buf_free(&out);
return rc;
}
int cst_http_send(cst_http_conn_t *c, int status, const char *ctype,
const void *body, size_t n) {
if (!c || c->sent)
return -1;
c->sent = 1;
if (send_head(c, status, ctype, n, 0) != 0) {
c->failed = 1;
return -1;
}
if (n && os_net_send_all(c->sock, body, n, 0) != 0) {
c->failed = 1;
return -1;
}
return 0;
}
int cst_http_send_str(cst_http_conn_t *c, int status, const char *ctype,
const char *s) {
return cst_http_send(c, status, ctype, s ? s : "", s ? strlen(s) : 0);
}
int cst_http_send_json(cst_http_conn_t *c, int status, const char *json) {
return cst_http_send_str(c, status, "application/json; charset=utf-8",
json ? json : "null");
}
int cst_http_send_err(cst_http_conn_t *c, int status, const char *msg) {
cst_buf_t b = {0};
cst_buf_puts(&b, "{\"error\":\"");
for (const char *p = msg ? msg : "error"; *p; p++) {
if (*p == '"' || *p == '\\')
cst_buf_printf(&b, "\\%c", *p);
else if ((unsigned char)*p < 0x20)
cst_buf_printf(&b, "\\u%04x", (unsigned char)*p);
else
cst_buf_append(&b, p, 1);
}
cst_buf_puts(&b, "\"}");
int rc = cst_http_send_json(c, status, b.buf);
cst_buf_free(&b);
return rc;
}
int cst_http_sent(const cst_http_conn_t *c) { return c ? c->sent : 0; }
int cst_http_stream_begin(cst_http_conn_t *c, const char *ctype) {
if (!c || c->sent)
return -1;
c->sent = 1;
c->streaming = 1;
if (send_head(c, 200, ctype, 0, 1) != 0) {
c->failed = 1;
return -1;
}
return 0;
}
int cst_http_stream_write(cst_http_conn_t *c, const void *data, size_t n) {
if (!c || !c->streaming || c->failed)
return -1;
if (n == 0)
return 0;
cst_buf_t b = {0};
cst_buf_printf(&b, "%zx\r\n", n);
cst_buf_append(&b, data, n);
cst_buf_puts(&b, "\r\n");
int rc = b.buf ? os_net_send_all(c->sock, b.buf, b.len, 0) : -1;
cst_buf_free(&b);
if (rc != 0)
c->failed = 1;
return rc;
}
int cst_http_stream_end(cst_http_conn_t *c) {
if (!c || !c->streaming)
return -1;
c->streaming = 0;
if (c->failed)
return -1;
const char *end = "0\r\n\r\n";
if (os_net_send_all(c->sock, end, strlen(end), 0) != 0) {
c->failed = 1;
return -1;
}
return 0;
}
const cst_http_req_t *cst_http_request(cst_http_conn_t *c) {
return c ? &c->req : NULL;
}
int cst_http_query(const cst_http_req_t *req, const char *key, char *out,
size_t out_sz) {
if (!req || !key || !out || out_sz == 0)
return -1;
size_t klen = strlen(key);
const char *p = req->query;
while (p && *p) {
const char *amp = strchr(p, '&');
size_t seg = amp ? (size_t)(amp - p) : strlen(p);
const char *eq = memchr(p, '=', seg);
size_t name_len = eq ? (size_t)(eq - p) : seg;
if (name_len == klen && strncmp(p, key, klen) == 0) {
char raw[1024];
size_t vlen = eq ? seg - name_len - 1 : 0;
if (vlen >= sizeof(raw))
vlen = sizeof(raw) - 1;
if (eq)
memcpy(raw, eq + 1, vlen);
raw[vlen] = 0;
url_decode(raw, out, out_sz, 1);
return 0;
}
p = amp ? amp + 1 : NULL;
}
return -1;
}
/* --- connections ---------------------------------------------------------- */
static void dispatch(cst_http_server_t *s, cst_http_conn_t *c) {
const cst_http_route_t *route = NULL;
const cst_http_route_t *fallback = NULL;
for (int i = 0; i < s->nroutes; i++) {
const cst_http_route_t *r = &s->routes[i];
if (strcmp(r->method, "*") != 0 && strcmp(r->method, c->req.method) != 0)
continue;
if (strcmp(r->path, "*") == 0) {
fallback = r;
continue;
}
if (strcmp(r->path, c->req.path) == 0) {
route = r;
break;
}
}
if (!route)
route = fallback;
if (!route) {
cst_http_send_err(c, 404, "not found");
return;
}
if (route->auth && s->o.auth && s->o.auth(&c->req, s->o.auth_ud) != 0) {
cst_http_send_err(c, 401, "unauthorized");
return;
}
int rc = route->fn(c, route->ud);
if (!c->sent)
cst_http_send_err(c, rc == 0 ? 500 : 404,
rc == 0 ? "handler sent no response" : "not found");
}
static void *conn_main(void *arg) {
cst_http_conn_t *c = arg;
cst_http_server_t *s = c->srv;
for (;;) {
size_t want = 0;
int rc = read_request(c, &want);
if (rc < 0) {
/* malformed (bad request line, traversal, unsupported transfer
* encoding): say so instead of closing silently */
c->sent = 0;
c->req.keep_alive = 0;
cst_http_send_err(c, 400, "malformed request");
break;
}
if (rc == 0)
break;
size_t cap = s->o.max_body;
for (int i = 0; i < s->nroutes; i++) {
const cst_http_route_t *r = &s->routes[i];
if (r->max_body && strcmp(r->method, c->req.method) == 0 &&
strcmp(r->path, c->req.path) == 0)
cap = r->max_body;
}
if (want > cap) {
c->req.keep_alive = 0;
cst_http_send_err(c, 413, "request body too large");
break;
}
if (want > 0) {
char *body = rd_body(c, want, s->o.read_timeout_ms);
if (!body)
break;
c->body = body;
c->req.body = body;
c->req.body_len = want;
}
c->sent = 0;
c->streaming = 0;
dispatch(s, c);
if (s->o.on_log) {
char line[512];
snprintf(line, sizeof(line), "%s %s -> %d", c->req.method, c->req.path,
c->failed ? 499 : (c->sent ? 200 : 500));
s->o.on_log(line, s->o.log_ud);
}
free(c->body);
c->body = NULL;
c->req.body = NULL;
c->req.body_len = 0;
c->req.authorization = c->req.cookie = c->req.origin = NULL;
c->req.host = c->req.content_type = c->req.user_agent = NULL;
if (!c->req.keep_alive || s->stopping || c->failed)
break;
}
os_net_shutdown(c->sock);
os_net_close(c->sock);
pthread_mutex_lock(&s->mu);
if (c->prev)
c->prev->next = c->next;
else
s->conns_first = c->next;
if (c->next)
c->next->prev = c->prev;
s->nconn--;
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&s->mu);
free(c);
return NULL;
}
static void *accept_main(void *arg) {
cst_http_server_t *s = arg;
while (!s->stopping) {
int timed_out = 0;
os_socket_t sock = os_net_accept(s->listener, 500, &timed_out);
if (sock == OS_SOCKET_BAD)
continue;
if (s->stopping) {
os_net_close(sock);
break;
}
pthread_mutex_lock(&s->mu);
int over = s->nconn >= s->o.max_conn;
if (!over)
s->nconn++;
pthread_mutex_unlock(&s->mu);
if (over) {
const char *busy = "HTTP/1.1 503 Service Unavailable\r\n"
"Content-Length: 0\r\nConnection: close\r\n\r\n";
os_net_send_all(sock, busy, strlen(busy), 2000);
os_net_close(sock);
continue;
}
cst_http_conn_t *c = calloc(1, sizeof(*c));
if (!c) {
pthread_mutex_lock(&s->mu);
s->nconn--;
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&s->mu);
os_net_close(sock);
continue;
}
c->srv = s;
c->sock = sock;
pthread_mutex_lock(&s->mu);
c->next = s->conns_first;
if (c->next)
c->next->prev = c;
s->conns_first = c;
pthread_mutex_unlock(&s->mu);
pthread_t th;
if (pthread_create(&th, NULL, conn_main, c) != 0) {
pthread_mutex_lock(&s->mu);
s->conns_first = c->next;
if (c->next)
c->next->prev = NULL;
s->nconn--;
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&s->mu);
os_net_close(sock);
free(c);
continue;
}
pthread_detach(th);
}
return NULL;
}
cst_http_server_t *cst_http_start(const cst_http_opts_t *o, char *err,
size_t err_sz) {
if (!o)
return NULL;
if (os_net_init() != 0) {
if (err)
snprintf(err, err_sz, "network init failed");
return NULL;
}
cst_http_server_t *s = calloc(1, sizeof(*s));
if (!s)
return NULL;
s->o = *o;
s->o.max_body = o->max_body ? o->max_body : HTTP_DEFAULT_BODY;
s->o.max_conn = o->max_conn ? o->max_conn : HTTP_DEFAULT_CONN;
if (!s->o.read_timeout_ms)
s->o.read_timeout_ms = HTTP_READ_TIMEOUT;
if (!s->o.idle_timeout_ms)
s->o.idle_timeout_ms = HTTP_IDLE_TIMEOUT;
if (o->nroutes > 0) {
s->routes = calloc((size_t)o->nroutes, sizeof(*s->routes));
if (!s->routes) {
free(s);
return NULL;
}
memcpy(s->routes, o->routes, (size_t)o->nroutes * sizeof(*s->routes));
s->nroutes = o->nroutes;
}
pthread_mutex_init(&s->mu, NULL);
pthread_cond_init(&s->cv, NULL);
s->listener = os_net_listen(o->host, o->port, err, err_sz);
if (s->listener == OS_SOCKET_BAD) {
free(s->routes);
free(s);
return NULL;
}
s->port = os_net_port(s->listener);
if (pthread_create(&s->accept_th, NULL, accept_main, s) != 0) {
os_net_close(s->listener);
free(s->routes);
free(s);
if (err)
snprintf(err, err_sz, "cannot start the accept thread");
return NULL;
}
s->accept_started = 1;
return s;
}
int cst_http_port(const cst_http_server_t *s) { return s ? s->port : 0; }
int cst_http_stopping(const cst_http_server_t *s) {
return s ? s->stopping : 1;
}
void cst_http_stop(cst_http_server_t *s) {
if (!s)
return;
pthread_mutex_lock(&s->mu);
s->stopping = 1;
for (cst_http_conn_t *c = s->conns_first; c; c = c->next)
os_net_shutdown(c->sock);
pthread_mutex_unlock(&s->mu);
os_net_shutdown(s->listener);
if (s->accept_started) {
pthread_join(s->accept_th, NULL);
s->accept_started = 0;
}
/* Handler threads own their connection until they return; the shutdown
* above is what wakes them. Bounded wait so free() stays safe. */
pthread_mutex_lock(&s->mu);
for (int i = 0; i < 300 && s->nconn > 0; i++) {
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_nsec += 100000000; /* 100 ms */
if (ts.tv_nsec >= 1000000000) {
ts.tv_sec++;
ts.tv_nsec -= 1000000000;
}
pthread_cond_timedwait(&s->cv, &s->mu, &ts);
}
pthread_mutex_unlock(&s->mu);
os_net_close(s->listener);
}
void cst_http_free(cst_http_server_t *s) {
if (!s)
return;
pthread_mutex_destroy(&s->mu);
pthread_cond_destroy(&s->cv);
free(s->routes);
free(s);
}
+8 -1
View File
@@ -162,7 +162,14 @@ static void parts_add_text(cJSON *parts, cJSON *txt) {
cJSON_Delete(txt); cJSON_Delete(txt);
return; return;
} }
cJSON_AddItemToArray(parts, txt); if (!txt->valuestring || !txt->valuestring[0]) {
cJSON_Delete(txt);
return;
}
cJSON *t = cJSON_CreateObject();
cJSON_AddStringToObject(t, "type", "text");
cJSON_AddItemToObject(t, "text", txt);
cJSON_AddItemToArray(parts, t);
} }
/* One stored image as an image_url part, or NULL with why filled in. */ /* One stored image as an image_url part, or NULL with why filled in. */
+53 -3
View File
@@ -3,6 +3,7 @@
#include "cst_models.h" #include "cst_models.h"
#include "cst_tools.h" #include "cst_tools.h"
#include "cst_version.h" #include "cst_version.h"
#include "cst_webui.h"
#include "os_compat.h" #include "os_compat.h"
#include <curl/curl.h> #include <curl/curl.h>
@@ -41,6 +42,13 @@ static void usage(const char *argv0) {
" -T, --tools PROFILE default (all tools) | minimal (paper set)\n" " -T, --tools PROFILE default (all tools) | minimal (paper set)\n"
" -n, --max-steps N model calls per turn (30)\n" " -n, --max-steps N model calls per turn (30)\n"
" -H, --headless -p TEXT run one prompt without the TUI\n" " -H, --headless -p TEXT run one prompt without the TUI\n"
" --web serve the UI over HTTP on 127.0.0.1:8787\n"
" --web-port N port (0 = pick a free one)\n"
" --web-host ADDR bind address (default 127.0.0.1; a\n"
" non-loopback address requires a token)\n"
" --web-token T require ?token=T / Authorization: Bearer T\n"
" --web-dev DIR serve the UI from DIR (development)\n"
" --web-open open the browser at startup\n"
" -a, --ask-key enter and save the provider's API key\n" " -a, --ask-key enter and save the provider's API key\n"
" -k, --api-key KEY use KEY for this run only (not saved)\n" " -k, --api-key KEY use KEY for this run only (not saved)\n"
" -b, --api-base URL OpenAI-compatible base URL (…/v1)\n" " -b, --api-base URL OpenAI-compatible base URL (…/v1)\n"
@@ -320,6 +328,16 @@ int main(int argc, char **argv) {
const char *prompt = NULL; const char *prompt = NULL;
int new_session = 0, do_list = 0; int new_session = 0, do_list = 0;
const char *resume_id = NULL; const char *resume_id = NULL;
/* WebUI (--web): a second front end on the same engine. */
int web_mode = 0;
cst_webui_opts_t web;
memset(&web, 0, sizeof(web));
const char *env = getenv("CST_WEB_PORT");
if (env && env[0])
web.port = atoi(env);
web.host = getenv("CST_WEB_HOST");
web.token = getenv("CST_WEB_TOKEN");
web.root = getenv("CST_WEB_ROOT");
static struct option opts[] = { static struct option opts[] = {
{"workspace", required_argument, 0, 'w'}, {"workspace", required_argument, 0, 'w'},
@@ -346,6 +364,12 @@ int main(int argc, char **argv) {
{"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}, {"list-models", optional_argument, 0, 0x100},
{"web", no_argument, 0, 0x101},
{"web-port", required_argument, 0, 0x102},
{"web-host", required_argument, 0, 0x103},
{"web-token", required_argument, 0, 0x104},
{"web-dev", required_argument, 0, 0x105},
{"web-open", no_argument, 0, 0x106},
{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)) !=
@@ -453,6 +477,29 @@ int main(int argc, char **argv) {
curl_global_cleanup(); curl_global_cleanup();
return rc; return rc;
} }
case 0x101:
web_mode = 1;
break;
case 0x102:
web.port = atoi(optarg);
web_mode = 1;
break;
case 0x103:
web.host = optarg;
web_mode = 1;
break;
case 0x104:
web.token = optarg;
web_mode = 1;
break;
case 0x105:
web.root = optarg;
web_mode = 1;
break;
case 0x106:
web.open_browser = 1;
web_mode = 1;
break;
case 'V': case 'V':
printf("cst-agent %s\n", CST_VERSION); printf("cst-agent %s\n", CST_VERSION);
curl_global_cleanup(); curl_global_cleanup();
@@ -600,7 +647,7 @@ int main(int argc, char **argv) {
return 0; return 0;
} }
} }
if (!cfg.api_key[0] && cfg.headless) { if (!cfg.api_key[0] && (cfg.headless || web_mode)) {
fprintf(stderr, fprintf(stderr,
"no API key for %s: start cst-agent once to enter it, or run " "no API key for %s: start cst-agent once to enter it, or run "
"cst-agent --ask-key --provider %s\n", "cst-agent --ask-key --provider %s\n",
@@ -610,7 +657,7 @@ int main(int argc, char **argv) {
} }
/* Router presets have no fixed model: take the first one the API lists /* Router presets have no fixed model: take the first one the API lists
* (the TUI does the same after its /models probe). */ * (the TUI does the same after its /models probe). */
if (!cfg.model[0] && cfg.headless) { if (!cfg.model[0] && (cfg.headless || web_mode)) {
static cst_model_meta_t list[512]; static cst_model_meta_t list[512];
char err[256] = ""; char err[256] = "";
long age = 0; long age = 0;
@@ -654,7 +701,10 @@ int main(int argc, char **argv) {
} }
int rc = 0; int rc = 0;
if (cfg.headless) { if (web_mode) {
web.prompt = prompt; /* --web -p "...": run one turn at startup */
rc = cst_webui_run(agent, &web);
} else if (cfg.headless) {
if (!prompt) { if (!prompt) {
fprintf(stderr, "--headless requires --prompt\n"); fprintf(stderr, "--headless requires --prompt\n");
rc = 1; rc = 1;
+5
View File
@@ -1,6 +1,11 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_agent.h" #include "cst_agent.h"
#include <string.h> #include <string.h>
#include <time.h>
/* Provider and model data live in model_registry.c (cst_models.h). */ /* Provider and model data live in model_registry.c (cst_models.h). */
+3
View File
@@ -31,6 +31,9 @@
"models": [ "models": [
{"match": "deepseek", "context": 1000000, "vision": false, {"match": "deepseek", "context": 1000000, "vision": false,
"price": {"currency": "¥", "miss": 2, "hit": 0.04, "out": 8, "offpeak_half": true}}, "price": {"currency": "¥", "miss": 2, "hit": 0.04, "out": 8, "offpeak_half": true}},
{"match": "deepseek-flash", "vision": true},
{"match": "deepseek-chat", "vision": true},
{"match": "deepseek-reasoner", "vision": true},
{"match": "deepseek-v4-pro", {"match": "deepseek-v4-pro",
"price": {"currency": "¥", "miss": 9, "hit": 0.3, "out": 27, "offpeak_half": true}}, "price": {"currency": "¥", "miss": 9, "hit": 0.3, "out": 27, "offpeak_half": true}},
{"match": "deepseek-vl", "vision": true}, {"match": "deepseek-vl", "vision": true},
+319
View File
@@ -0,0 +1,319 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_net.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#ifdef _WIN32
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <winsock2.h>
#include <ws2tcpip.h>
#else
#include <arpa/inet.h>
#include <errno.h>
#include <fcntl.h>
#include <netdb.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <signal.h>
#include <sys/select.h>
#include <sys/socket.h>
#include <unistd.h>
#endif
#ifndef MSG_NOSIGNAL
#define MSG_NOSIGNAL 0
#endif
#define NET_SEND_TIMEOUT_MS 20000
static void set_err(char *err, size_t err_sz, const char *what) {
if (err && err_sz)
snprintf(err, err_sz, "%s", what);
}
#ifdef _WIN32
static void set_err_win(char *err, size_t err_sz, const char *what) {
if (err && err_sz)
snprintf(err, err_sz, "%s (winsock error %d)", what, WSAGetLastError());
}
#define SET_ERR(e, n, w) set_err_win(e, n, w)
#define NET_FD(s) ((SOCKET)(s))
#define NET_NFDS(s) (0) /* Winsock ignores nfds */
static void net_close_raw(os_socket_t s) { closesocket((SOCKET)s); }
static int net_last_would_block(void) {
int e = WSAGetLastError();
return e == WSAEWOULDBLOCK || e == WSAEINTR;
}
static int net_last_conn_reset(void) {
int e = WSAGetLastError();
return e == WSAECONNRESET || e == WSAECONNABORTED || e == WSAENOTSOCK;
}
#else
#define SET_ERR(e, n, w) set_err(e, n, w)
#define NET_FD(s) ((int)(s))
#define NET_NFDS(s) ((int)((s) + 1))
static void net_close_raw(os_socket_t s) { close(s); }
static int net_last_would_block(void) { return errno == EINTR; }
static int net_last_conn_reset(void) {
return errno == ECONNRESET || errno == EPIPE || errno == EBADF;
}
#endif
int os_net_init(void) {
#ifdef _WIN32
WSADATA w;
if (WSAStartup(MAKEWORD(2, 2), &w) != 0)
return -1;
#else
/* A hung-up client must not kill the process with SIGPIPE. */
signal(SIGPIPE, SIG_IGN);
#endif
return 0;
}
void os_net_cleanup(void) {
#ifdef _WIN32
WSACleanup();
#endif
}
static int wait_io(os_socket_t s, int want_write, int timeout_ms) {
fd_set fds;
FD_ZERO(&fds);
FD_SET(NET_FD(s), &fds);
struct timeval tv;
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
int rc = select(NET_NFDS(s), want_write ? NULL : &fds,
want_write ? &fds : NULL, NULL, timeout_ms < 0 ? NULL : &tv);
if (rc > 0)
return 1;
if (rc == 0)
return 0; /* timed out */
return -1;
}
/* Host to bind: AF_INET unless the name is an IPv6 literal, so "localhost"
* and "127.0.0.1" always mean the IPv4 loopback a browser will reach. */
static int resolve_bind(const char *host, int port, struct sockaddr_storage *sa,
socklen_t *sa_len) {
memset(sa, 0, sizeof(*sa));
int family = AF_INET;
const char *h = host && host[0] ? host : "127.0.0.1";
if (strchr(h, ':'))
family = AF_INET6;
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = family;
hints.ai_socktype = SOCK_STREAM;
hints.ai_flags = AI_PASSIVE;
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
if (getaddrinfo(h, portstr, &hints, &res) != 0 || !res)
return -1;
memcpy(sa, res->ai_addr, res->ai_addrlen);
*sa_len = (socklen_t)res->ai_addrlen;
freeaddrinfo(res);
return 0;
}
os_socket_t os_net_listen(const char *host, int port, char *err, size_t err_sz) {
struct sockaddr_storage sa;
socklen_t sa_len;
if (resolve_bind(host, port, &sa, &sa_len) != 0) {
SET_ERR(err, err_sz, "cannot resolve the bind address");
return OS_SOCKET_BAD;
}
os_socket_t s = (os_socket_t)socket(sa.ss_family, SOCK_STREAM, 0);
if (s == OS_SOCKET_BAD) {
SET_ERR(err, err_sz, "cannot create a socket");
return OS_SOCKET_BAD;
}
int one = 1;
setsockopt(NET_FD(s), SOL_SOCKET, SO_REUSEADDR, (const char *)&one,
sizeof(one));
#ifdef IPV6_V6ONLY
if (sa.ss_family == AF_INET6)
setsockopt(NET_FD(s), IPPROTO_IPV6, IPV6_V6ONLY, (const char *)&one,
sizeof(one));
#endif
if (bind(NET_FD(s), (const struct sockaddr *)&sa, sa_len) != 0) {
SET_ERR(err, err_sz, "the address is already in use");
net_close_raw(s);
return OS_SOCKET_BAD;
}
if (listen(NET_FD(s), 32) != 0) {
SET_ERR(err, err_sz, "listen failed");
net_close_raw(s);
return OS_SOCKET_BAD;
}
return s;
}
int os_net_port(os_socket_t s) {
struct sockaddr_storage sa;
socklen_t len = sizeof(sa);
if (getsockname(NET_FD(s), (struct sockaddr *)&sa, &len) != 0)
return 0;
if (sa.ss_family == AF_INET)
return ntohs(((struct sockaddr_in *)&sa)->sin_port);
if (sa.ss_family == AF_INET6)
return ntohs(((struct sockaddr_in6 *)&sa)->sin6_port);
return 0;
}
void os_net_peer(os_socket_t s, char *out, size_t out_sz) {
if (!out || out_sz == 0)
return;
out[0] = 0;
struct sockaddr_storage sa;
socklen_t len = sizeof(sa);
if (getpeername(NET_FD(s), (struct sockaddr *)&sa, &len) != 0)
return;
char ip[64] = "";
int port = 0;
if (sa.ss_family == AF_INET) {
if (!inet_ntop(AF_INET, &((struct sockaddr_in *)&sa)->sin_addr, ip,
sizeof(ip)))
return;
port = ntohs(((struct sockaddr_in *)&sa)->sin_port);
} else if (sa.ss_family == AF_INET6) {
if (!inet_ntop(AF_INET6, &((struct sockaddr_in6 *)&sa)->sin6_addr, ip,
sizeof(ip)))
return;
port = ntohs(((struct sockaddr_in6 *)&sa)->sin6_port);
} else {
return;
}
if (strchr(ip, ':'))
snprintf(out, out_sz, "[%s]:%d", ip, port);
else
snprintf(out, out_sz, "%s:%d", ip, port);
}
os_socket_t os_net_connect(const char *host, int port, int timeout_ms) {
const char *h = host && host[0] ? host : "127.0.0.1";
struct addrinfo hints, *res = NULL;
memset(&hints, 0, sizeof(hints));
hints.ai_family = strchr(h, ':') ? AF_INET6 : AF_INET;
hints.ai_socktype = SOCK_STREAM;
char portstr[16];
snprintf(portstr, sizeof(portstr), "%d", port);
if (getaddrinfo(h, portstr, &hints, &res) != 0 || !res)
return OS_SOCKET_BAD;
os_socket_t s = (os_socket_t)socket(res->ai_family, SOCK_STREAM, 0);
if (s == OS_SOCKET_BAD) {
freeaddrinfo(res);
return OS_SOCKET_BAD;
}
/* Bounded wait: connect() non-blocking, poll, then read SO_ERROR. A dead
* server must not hang the caller. */
#ifdef _WIN32
u_long nb = 1;
ioctlsocket(NET_FD(s), FIONBIO, &nb);
#else
int fl = fcntl(s, F_GETFL, 0);
fcntl(s, F_SETFL, fl | O_NONBLOCK);
#endif
int rc = connect(NET_FD(s), res->ai_addr, (int)res->ai_addrlen);
freeaddrinfo(res);
if (rc != 0) {
#ifdef _WIN32
int in_progress = WSAGetLastError() == WSAEWOULDBLOCK;
#else
int in_progress = errno == EINPROGRESS || errno == EINTR;
#endif
if (!in_progress || wait_io(s, 1, timeout_ms) <= 0) {
net_close_raw(s);
return OS_SOCKET_BAD;
}
int soerr = 0;
socklen_t elen = sizeof(soerr);
if (getsockopt(NET_FD(s), SOL_SOCKET, SO_ERROR, (char *)&soerr, &elen) != 0 ||
soerr != 0) {
net_close_raw(s);
return OS_SOCKET_BAD;
}
}
#ifdef _WIN32
nb = 0;
ioctlsocket(NET_FD(s), FIONBIO, &nb);
#else
fcntl(s, F_SETFL, fl);
#endif
return s;
}
os_socket_t os_net_accept(os_socket_t s, int timeout_ms, int *timed_out) {
if (timed_out)
*timed_out = 0;
int w = wait_io(s, 0, timeout_ms);
if (w == 0) {
if (timed_out)
*timed_out = 1;
return OS_SOCKET_BAD;
}
if (w < 0)
return OS_SOCKET_BAD;
os_socket_t c = (os_socket_t)accept(NET_FD(s), NULL, NULL);
if (c == OS_SOCKET_BAD)
return OS_SOCKET_BAD;
int one = 1;
setsockopt(NET_FD(c), IPPROTO_TCP, TCP_NODELAY, (const char *)&one,
sizeof(one));
return c;
}
long os_net_recv(os_socket_t s, void *buf, size_t n, int timeout_ms) {
int w = wait_io(s, 0, timeout_ms);
if (w == 0)
return -2;
if (w < 0)
return -1;
size_t want = n > (size_t)(1 << 20) ? (size_t)(1 << 20) : n;
int r = (int)recv(NET_FD(s), (char *)buf, (int)want, 0);
if (r == 0)
return 0;
if (r < 0)
return net_last_conn_reset() ? 0 : (net_last_would_block() ? -2 : -1);
return r;
}
int os_net_send_all(os_socket_t s, const void *buf, size_t n, int timeout_ms) {
const char *p = buf;
size_t off = 0;
while (off < n) {
int w = wait_io(s, 1, timeout_ms ? timeout_ms : NET_SEND_TIMEOUT_MS);
if (w <= 0)
return -1;
long r = (long)send(NET_FD(s), p + off, (int)(n - off), MSG_NOSIGNAL);
if (r <= 0) {
if (net_last_would_block())
continue;
return -1;
}
off += (size_t)r;
}
return 0;
}
void os_net_shutdown(os_socket_t s) {
#ifdef _WIN32
shutdown((SOCKET)s, SD_BOTH);
#else
shutdown(s, SHUT_RDWR);
#endif
}
void os_net_close(os_socket_t s) {
if (s == OS_SOCKET_BAD)
return;
net_close_raw(s);
}
+5 -19
View File
@@ -86,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;
@@ -137,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;
@@ -193,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;
} }
@@ -290,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));
@@ -303,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);
@@ -1446,9 +1434,7 @@ 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",
+77 -41
View File
@@ -378,6 +378,7 @@ typedef struct {
char *prompt_hist[PROMPT_HIST_CAP]; char *prompt_hist[PROMPT_HIST_CAP];
int prompt_hist_n; int prompt_hist_n;
int hist_browse; int hist_browse;
char *hist_draft; /* the live input while browsing */
/* Busy-turn FIFO */ /* Busy-turn FIFO */
char *queue[QUEUE_CAP]; char *queue[QUEUE_CAP];
@@ -3163,28 +3164,16 @@ static size_t word_right(const tui_t *t, size_t at) {
return at; return at;
} }
/* Move to the previous/next logical line keeping the column (in chars). static size_t input_hit(const tui_t *t, int want_row, int want_col);
* Returns 0 when already on the first/last line. */
/* Move to the previous/next visual row (soft-wrapped or not) keeping the
* screen column. Returns 0 when already on the first/last row. */
static int input_line_move(tui_t *t, int dir) { static int input_line_move(tui_t *t, int dir) {
size_t ls = line_start(t, t->cur); int cr, cc;
int col = 0; int rows = input_layout(t, NULL, NULL, &cr, &cc);
for (size_t i = ls; i < t->cur; i += next_len(t, i)) if (cr + dir < 0 || cr + dir >= rows)
col++;
size_t target;
if (dir < 0) {
if (ls == 0)
return 0; return 0;
target = line_start(t, ls - 1); t->cur = input_hit(t, cr + dir, cc);
} else {
size_t le = line_end(t, t->cur);
if (le >= t->in_len)
return 0;
target = le + 1;
}
size_t te = line_end(t, target);
while (col-- > 0 && target < te)
target += next_len(t, target);
t->cur = target;
return 1; return 1;
} }
@@ -5854,7 +5843,7 @@ static int submit_input(tui_t *t, int busy) {
static size_t input_hit(const tui_t *t, int want_row, int want_col) { static size_t input_hit(const tui_t *t, int want_row, int want_col) {
int row = 0, col = IN_COL; int row = 0, col = IN_COL;
int right = (t->in_w > 0 ? t->in_w : t->cols) - 2; int right = (t->in_w > 0 ? t->in_w : t->cols) - 2;
size_t i = 0, best = t->in_len; size_t i = 0, best = t->in_len, last = 0;
while (i < t->in_len) { while (i < t->in_len) {
if (row == want_row && col >= want_col) if (row == want_row && col >= want_col)
return i; return i;
@@ -5873,12 +5862,14 @@ static size_t input_hit(const tui_t *t, int want_row, int want_col) {
utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten); utf8_decode(t->inbuf + i, t->in_len - i, &wc, &eaten);
int cw = utf8_width(wc == L'\t' ? L' ' : wc); int cw = utf8_width(wc == L'\t' ? L' ' : wc);
if (col + cw > right && col > IN_COL) { if (col + cw > right && col > IN_COL) {
/* offset i would be drawn at the start of the next row */
if (row == want_row) if (row == want_row)
return i; return last;
row++; row++;
col = IN_COL; col = IN_COL;
} }
best = i + eaten; best = i + eaten;
last = i;
col += cw; col += cw;
i += eaten; i += eaten;
} }
@@ -6395,29 +6386,65 @@ static int g_k_alt_up, g_k_alt_down, g_k_ctrl_home, g_k_ctrl_end,
g_k_ctrl_up, g_k_ctrl_down, g_k_ctrl_left, g_k_ctrl_right, g_k_alt_left, g_k_ctrl_up, g_k_ctrl_down, g_k_ctrl_left, g_k_ctrl_right, g_k_alt_left,
g_k_alt_right, g_k_ctrl_dc, g_k_alt_dc; g_k_alt_right, g_k_ctrl_dc, g_k_alt_dc;
static int ext_key(const char *cap) { /* The terminfo key, else the xterm sequence bound to our own code `own`. */
static int ext_key(const char *cap, const char *xterm, int own) {
char *s = tigetstr(cap); char *s = tigetstr(cap);
if (!s || s == (char *)-1) int k = s && s != (char *)-1 ? key_defined(s) : 0;
if (k > 0)
return k;
#ifndef _WIN32
k = key_defined(xterm);
if (k > 0)
return k;
if (k == 0 && define_key(xterm, own) == OK)
return own;
#else
(void)xterm, (void)own;
#endif
return 0; return 0;
int k = key_defined(s); }
return k > 0 ? k : 0; #endif
#if defined(NCURSES_VERSION) && !defined(_WIN32)
/* Terminfo lists one form per key, but terminals disagree: cursor keys come
* as CSI or SS3 whatever DECCKM says (tmux, mosh, a wrong TERM), Home/End as
* CSI H, SS3 H or CSI 1~/7~. Unknown forms would be read as Esc + text. */
static void vt_keys_init(void) {
static const struct {
const char *seq;
int key;
} K[] = {
{"\033[A", KEY_UP}, {"\033OA", KEY_UP}, {"\033[B", KEY_DOWN},
{"\033OB", KEY_DOWN}, {"\033[C", KEY_RIGHT}, {"\033OC", KEY_RIGHT},
{"\033[D", KEY_LEFT}, {"\033OD", KEY_LEFT}, {"\033[H", KEY_HOME},
{"\033OH", KEY_HOME}, {"\033[1~", KEY_HOME}, {"\033[7~", KEY_HOME},
{"\033[F", KEY_END}, {"\033OF", KEY_END}, {"\033[4~", KEY_END},
{"\033[8~", KEY_END}, {"\033[2~", KEY_IC}, {"\033[3~", KEY_DC},
{"\033[5~", KEY_PPAGE}, {"\033[6~", KEY_NPAGE}, {"\033[Z", KEY_BTAB},
{"\033[1;2A", KEY_SR}, {"\033[1;2B", KEY_SF}, {"\033[1;2C", KEY_SRIGHT},
{"\033[1;2D", KEY_SLEFT},
};
for (size_t i = 0; i < sizeof(K) / sizeof(K[0]); i++)
if (key_defined(K[i].seq) == 0)
define_key(K[i].seq, K[i].key);
} }
#endif #endif
static void keys_init(void) { static void keys_init(void) {
#ifdef NCURSES_VERSION #ifdef NCURSES_VERSION
g_k_alt_up = ext_key("kUP3"); int own = KEY_OWN_BASE + 4; /* up to KEY_KITTY_BASE - 1 */
g_k_alt_down = ext_key("kDN3"); g_k_alt_up = ext_key("kUP3", "\033[1;3A", own++);
g_k_ctrl_home = ext_key("kHOM5"); g_k_alt_down = ext_key("kDN3", "\033[1;3B", own++);
g_k_ctrl_end = ext_key("kEND5"); g_k_ctrl_home = ext_key("kHOM5", "\033[1;5H", own++);
g_k_ctrl_up = ext_key("kUP5"); g_k_ctrl_end = ext_key("kEND5", "\033[1;5F", own++);
g_k_ctrl_down = ext_key("kDN5"); g_k_ctrl_up = ext_key("kUP5", "\033[1;5A", own++);
g_k_ctrl_left = ext_key("kLFT5"); g_k_ctrl_down = ext_key("kDN5", "\033[1;5B", own++);
g_k_ctrl_right = ext_key("kRIT5"); g_k_ctrl_left = ext_key("kLFT5", "\033[1;5D", own++);
g_k_alt_left = ext_key("kLFT3"); g_k_ctrl_right = ext_key("kRIT5", "\033[1;5C", own++);
g_k_alt_right = ext_key("kRIT3"); g_k_alt_left = ext_key("kLFT3", "\033[1;3D", own++);
g_k_ctrl_dc = ext_key("kDC5"); g_k_alt_right = ext_key("kRIT3", "\033[1;3C", own++);
g_k_alt_dc = ext_key("kDC3"); g_k_ctrl_dc = ext_key("kDC5", "\033[3;5~", own++);
g_k_alt_dc = ext_key("kDC3", "\033[3;3~", own++);
#endif #endif
} }
@@ -6630,6 +6657,7 @@ static void tui_free(tui_t *t) {
queue_free_all(t); queue_free_all(t);
for (int i = 0; i < t->prompt_hist_n; i++) for (int i = 0; i < t->prompt_hist_n; i++)
free(t->prompt_hist[i]); free(t->prompt_hist[i]);
free(t->hist_draft);
for (int i = 0; i < t->nfiles; i++) for (int i = 0; i < t->nfiles; i++)
free(t->files[i]); free(t->files[i]);
free(t->files); free(t->files);
@@ -6720,6 +6748,7 @@ int cst_tui_run(cst_agent_t *agent) {
/* the Windows console driver reads key events, not VT sequences */ /* the Windows console driver reads key events, not VT sequences */
define_key("\033[200~", KEY_PASTE_BEGIN); define_key("\033[200~", KEY_PASTE_BEGIN);
define_key("\033[201~", KEY_PASTE_END); define_key("\033[201~", KEY_PASTE_END);
vt_keys_init();
printf("\033[?2004h"); printf("\033[?2004h");
fflush(stdout); fflush(stdout);
kitty_init(); kitty_init();
@@ -7032,16 +7061,23 @@ int cst_tui_run(cst_agent_t *agent) {
if (t.prompt_hist_n <= 0) if (t.prompt_hist_n <= 0)
continue; continue;
int idx = t.hist_browse; int idx = t.hist_browse;
if (idx < 0) {
free(t.hist_draft);
t.hist_draft = strdup(t.inbuf);
}
idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0); idx = idx < 0 ? t.prompt_hist_n - 1 : (idx > 0 ? idx - 1 : 0);
hist_load_into_input(&t, idx); hist_load_into_input(&t, idx);
} else { } else {
if (t.hist_browse < 0) if (t.hist_browse < 0)
continue; continue;
if (t.hist_browse + 1 >= t.prompt_hist_n) if (t.hist_browse + 1 >= t.prompt_hist_n) {
input_clear(&t); input_set(&t, t.hist_draft);
else free(t.hist_draft);
t.hist_draft = NULL;
} else {
hist_load_into_input(&t, t.hist_browse + 1); hist_load_into_input(&t, t.hist_browse + 1);
} }
}
after_edit(&t); after_edit(&t);
continue; continue;
} }
+319
View File
@@ -0,0 +1,319 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_web_md.h"
#include "cst_util.h"
#include "sashidown.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct cst_web_md {
sashidown_stream_t *st;
cst_web_md_emit_fn emit;
void *ud;
cst_buf_t in; /* HTML from sashidown, waiting for a complete tag */
int failed;
};
/* --- sanitizer ------------------------------------------------------------ */
static const char *ALLOWED_TAGS[] = {
"p", "br", "hr", "h1", "h2", "h3", "h4",
"h5", "h6", "strong", "em", "b", "i", "u",
"s", "del", "code", "pre", "blockquote", "ul", "ol",
"li", "table", "thead", "tbody", "tr", "th", "td",
"a", "img", "span", "div", NULL};
static int tag_allowed(const char *name, size_t n) {
for (int i = 0; ALLOWED_TAGS[i]; i++) {
if (strlen(ALLOWED_TAGS[i]) == n && strncmp(name, ALLOWED_TAGS[i], n) == 0)
return 1;
}
return 0;
}
/* A URL we are willing to put in href: http(s), mailto, or a same-site
* path. Anything else (javascript:, data:, file:, //evil) is dropped. */
static int href_ok(const char *v, size_t n) {
if (n == 0)
return 0;
if (!strncmp(v, "http://", 7) || !strncmp(v, "https://", 8) ||
!strncmp(v, "mailto:", 7))
return 1;
if (v[0] == '/' && !(n > 1 && (v[1] == '/' || v[1] == '\\')))
return 1;
if (v[0] == '#')
return 1;
return 0;
}
static int src_ok(const char *v, size_t n) {
if (n > 11 && !strncmp(v, "data:image/", 11))
return 1;
if (n > 12 && !strncmp(v, "/api/attach/", 12))
return 1;
return 0;
}
static void attr_append(cst_buf_t *b, const char *name, size_t nlen,
const char *val, size_t vlen) {
cst_buf_puts(b, " ");
cst_buf_append(b, name, nlen);
cst_buf_puts(b, "=\"");
cst_buf_append(b, val, vlen);
cst_buf_puts(b, "\"");
}
/* Rewrite one tag (open, close or self-closing) into out; may emit nothing
* when the tag is not allowed. Returns 0, or -1 on OOM. */
static int sanitize_tag(const char *tag, size_t len, cst_buf_t *out) {
/* <name ...> or </name> or <!-- ... --> */
size_t i = 0;
int closing = 0;
if (len < 3 || tag[0] != '<')
return 0;
i = 1;
if (i < len && tag[i] == '/') {
closing = 1;
i++;
}
size_t name_start = i;
while (i < len && ((tag[i] >= 'a' && tag[i] <= 'z') ||
(tag[i] >= 'A' && tag[i] <= 'Z') ||
(tag[i] >= '0' && tag[i] <= '9')))
i++;
size_t name_len = i - name_start;
if (name_len == 0 || !tag_allowed(tag + name_start, name_len))
return 0; /* comments, <script>, <iframe>: dropped entirely */
int is_img = name_len == 3 && strncmp(tag + name_start, "img", 3) == 0;
int is_a = name_len == 1 && tag[name_start] == 'a';
int self_closing = len > 2 && tag[len - 2] == '/';
size_t start = out->len;
cst_buf_puts(out, "<");
if (closing)
cst_buf_puts(out, "/");
cst_buf_append(out, tag + name_start, name_len);
if (!closing) {
/* copy the allowed attributes */
int keep_target = 0; /* a link is only useful with a usable href */
size_t p = name_start + name_len;
while (p < len && tag[p] != '>' && !(tag[p] == '/' && p + 1 < len &&
tag[p + 1] == '>')) {
while (p < len && (tag[p] == ' ' || tag[p] == '\t' || tag[p] == '\n'))
p++;
size_t an = p;
while (p < len && tag[p] != '=' && tag[p] != ' ' && tag[p] != '\t' &&
tag[p] != '\n' && tag[p] != '>' && tag[p] != '/')
p++;
size_t an_len = p - an;
if (an_len == 0) {
p++;
continue;
}
const char *aval = "";
size_t av_len = 0;
if (p < len && tag[p] == '=') {
p++;
char quote = 0;
if (p < len && (tag[p] == '"' || tag[p] == '\''))
quote = tag[p++];
size_t as = p;
while (p < len &&
(quote ? tag[p] != quote : (tag[p] != ' ' && tag[p] != '>')))
p++;
aval = tag + as;
av_len = p - as;
if (quote && p < len)
p++; /* closing quote */
}
int keep = 0;
if (is_a && an_len == 4 && strncmp(tag + an, "href", 4) == 0) {
keep = href_ok(aval, av_len);
keep_target = keep;
} else if (is_img && an_len == 3 && strncmp(tag + an, "src", 3) == 0) {
keep = src_ok(aval, av_len);
keep_target = keep;
} else if (is_img && an_len == 3 && strncmp(tag + an, "alt", 3) == 0)
keep = 1;
else if (an_len == 5 && strncmp(tag + an, "class", 5) == 0 &&
av_len > 9 && !strncmp(aval, "language-", 9))
keep = 1;
if (keep)
attr_append(out, tag + an, an_len, aval, av_len);
}
if (is_a && keep_target) {
cst_buf_puts(out, " target=\"_blank\" rel=\"noopener noreferrer\"");
}
if ((is_a && !keep_target) || (is_img && !keep_target)) {
/* a link with an unusable href, or an image from nowhere: drop the
* whole tag and keep only its text */
out->len = start;
if (out->buf)
out->buf[start] = 0;
return 0;
}
cst_buf_puts(out, self_closing ? " /" : "");
}
cst_buf_puts(out, ">");
return 0;
}
/* Emit the text before every '<' and every tag through the sanitizer. */
static void scan_chunk(cst_web_md_t *s, int final) {
if (!s->emit || s->failed)
return;
size_t i = 0, emitted = 0;
while (i < s->in.len) {
if (s->in.buf[i] != '<') {
i++;
continue;
}
size_t close = i;
while (close < s->in.len && s->in.buf[close] != '>')
close++;
if (close >= s->in.len && !final)
break; /* the tag may still be growing: keep it buffered */
if (i > emitted && s->emit(s->in.buf + emitted, i - emitted, s->ud) != 0)
s->failed = 1;
size_t tag_len = close >= s->in.len ? s->in.len - i : close - i + 1;
cst_buf_t tag = {0};
if (sanitize_tag(s->in.buf + i, tag_len, &tag) != 0) {
cst_buf_free(&tag);
s->failed = 1;
return;
}
if (tag.len && s->emit(tag.buf, tag.len, s->ud) != 0)
s->failed = 1;
cst_buf_free(&tag);
i = i + tag_len;
emitted = i;
if (s->failed)
break;
}
if (emitted > 0) {
memmove(s->in.buf, s->in.buf + emitted, s->in.len - emitted);
s->in.len -= emitted;
s->in.buf[s->in.len] = 0;
}
}
/* Collector used by the one-shot sanitizer. */
typedef struct {
cst_buf_t *b;
int failed;
} md_collect_t;
static int collect_emit(const char *html, size_t len, void *ud) {
md_collect_t *c = ud;
if (cst_buf_append(c->b, html, len) != 0) {
c->failed = 1;
return 1;
}
return 0;
}
char *cst_web_md_sanitize(const char *html, size_t len) {
cst_web_md_t tmp;
memset(&tmp, 0, sizeof(tmp));
cst_buf_t out = {0};
md_collect_t col = {&out, 0};
if (cst_buf_append(&tmp.in, html ? html : "", len) != 0)
return NULL;
tmp.emit = collect_emit;
tmp.ud = &col;
scan_chunk(&tmp, 1);
cst_buf_free(&tmp.in);
if (col.failed) {
cst_buf_free(&out);
return NULL;
}
return cst_buf_steal(&out);
}
/* --- sashidown glue ------------------------------------------------------- */
static int on_sashidown_html(const char *html, size_t len, void *user) {
cst_web_md_t *s = user;
if (cst_buf_append(&s->in, html, len) != 0) {
s->failed = 1;
return 1;
}
scan_chunk(s, 0);
return s->failed ? 1 : 0;
}
cst_web_md_t *cst_web_md_new(cst_web_md_emit_fn emit, void *ud) {
cst_web_md_t *s = calloc(1, sizeof(*s));
if (!s)
return NULL;
sashidown_stream_opts_t opts;
memset(&opts, 0, sizeof(opts));
opts.on_html = on_sashidown_html;
opts.user = s;
s->st = sashidown_stream_create(&opts);
if (!s->st) {
free(s);
return NULL;
}
s->emit = emit;
s->ud = ud;
return s;
}
int cst_web_md_feed(cst_web_md_t *s, const char *data, size_t len) {
if (!s || !s->st)
return -1;
if (len && sashidown_stream_feed(s->st, data, len) != 0) {
s->failed = 1;
return -1;
}
return s->failed ? -1 : 0;
}
int cst_web_md_finish(cst_web_md_t *s) {
if (!s || !s->st)
return -1;
if (sashidown_stream_finish(s->st) != 0) {
s->failed = 1;
return -1;
}
scan_chunk(s, 1);
return s->failed ? -1 : 0;
}
void cst_web_md_free(cst_web_md_t *s) {
if (!s)
return;
if (s->st)
sashidown_stream_destroy(s->st);
cst_buf_free(&s->in);
free(s);
}
char *cst_web_md_html(const char *md, size_t len) {
if (!md)
return NULL;
size_t cap = 65536;
char *out = NULL;
for (int attempt = 0; attempt < 8; attempt++) {
char *buf = malloc(cap);
if (!buf)
return NULL;
size_t out_len = 0;
if (sashidown_convert(md, len, buf, cap, &out_len, SASHIDOWN_FMT_HTML) ==
0) {
out = cst_web_md_sanitize(buf, out_len);
free(buf);
return out;
}
free(buf);
if (cap >= (size_t)8 * 1024 * 1024)
break;
cap *= 2;
}
return NULL;
}
+1257
View File
File diff suppressed because it is too large Load Diff
+15
View File
@@ -0,0 +1,15 @@
#include "cst_agent.h"
#include "cst_webui.h"
#include <stdio.h>
#ifdef CST_NO_WEBUI
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) {
(void)agent;
(void)opts;
fprintf(stderr,
"the WebUI was not built into this binary (CST_BUILD_WEBUI=OFF).\n"
"Use --headless --prompt '...' or build with make CST_WEBUI=1\n");
return 1;
}
#endif
+2 -1
View File
@@ -19,6 +19,7 @@
#include <io.h> #include <io.h>
#include <windows.h> #include <windows.h>
#else #else
#include <poll.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <unistd.h> #include <unistd.h>
#endif #endif
@@ -177,7 +178,7 @@ static CST_TEST_UNUSED void test_sleep_ms(int ms) {
#ifdef _WIN32 #ifdef _WIN32
Sleep((DWORD)ms); Sleep((DWORD)ms);
#else #else
usleep((useconds_t)ms * 1000); poll(NULL, 0, ms); /* usleep is hidden by -std=c11 on musl */
#endif #endif
} }
+82
View File
@@ -1,5 +1,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h"
#include "cst_test_util.h"
#include <stdio.h> #include <stdio.h>
#include <stdlib.h> #include <stdlib.h>
@@ -325,8 +327,88 @@ static void test_images_off(void) {
cJSON_Delete(j); cJSON_Delete(j);
} }
/* Every element of every content array must be a typed part object. */
static int parts_typed(const cJSON *root) {
const cJSON *m;
cJSON_ArrayForEach(m, cJSON_GetObjectItemCaseSensitive(root, "messages")) {
const cJSON *c = cJSON_GetObjectItemCaseSensitive(m, "content");
if (!cJSON_IsArray(c))
continue;
const cJSON *p;
cJSON_ArrayForEach(p, c) {
const cJSON *t = cJSON_GetObjectItemCaseSensitive(p, "type");
if (!cJSON_IsObject(p) || !cJSON_IsString(t))
return 0;
}
}
return 1;
}
static void test_images_on(void) {
char dir[1024], sess[1100], err[256];
if (test_tmpdir(dir, sizeof(dir), "cst-body") != 0) {
EXPECT(0, "scratch dir");
return;
}
snprintf(sess, sizeof(sess), "%s/s.jsonl", dir);
static const unsigned char png[32] = {0x89, 'P', 'N', 'G', '\r', '\n', 0x1A,
'\n', 0, 0, 0, 13, 'I', 'H', 'D', 'R',
0, 0, 0, 2, 0, 0, 0, 2};
cst_attachment_t *img = cst_attach_import_bytes(sess, "shot.png",
(const char *)png,
sizeof(png), err, sizeof(err));
EXPECT(img != NULL, "image stored");
if (!img)
return;
cst_attachment_t gone = *img;
snprintf(gone.rel, sizeof(gone.rel), "missing.png");
cst_message_t m[5];
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?");
m[4] = msg(CST_ROLE_USER, "");
m[4].attach = &gone;
m[4].nattach = 1;
cst_llm_request_t r;
memset(&r, 0, sizeof(r));
r.model = "deepseek-flash";
r.msgs = m;
r.nmsgs = 5;
r.session_path = sess;
cJSON *j = build_req(&r);
EXPECT(parts_typed(j), "content arrays hold only typed parts");
const cJSON *p0 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetArrayItem(cJSON_GetObjectItemCaseSensitive(j, "messages"), 0),
"content");
const cJSON *t0 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetArrayItem(p0, 0), "text");
const cJSON *u1 = cJSON_GetObjectItemCaseSensitive(
cJSON_GetObjectItemCaseSensitive(cJSON_GetArrayItem(p0, 1), "image_url"),
"url");
EXPECT(cJSON_IsString(t0) && strcmp(t0->valuestring, "what is this") == 0,
"user text is a text part");
EXPECT(cJSON_IsString(u1) &&
strncmp(u1->valuestring, "data:image/png;base64,", 22) == 0,
"image is a data URL part");
char *s = cJSON_PrintUnformatted(j);
EXPECT(s && strstr(s, "and now?") && strstr(s, "not sent"),
"follow-up text and the missing-image note are parts too");
free(s);
cJSON_Delete(j);
cst_attach_free(img);
}
int main(void) { int main(void) {
test_images_off(); test_images_off();
test_images_on();
test_reasoning_and_prefix(); test_reasoning_and_prefix();
test_cache_hints(); test_cache_hints();
test_tools_snapshot(); test_tools_snapshot();
+5 -1
View File
@@ -44,6 +44,10 @@ static void test_builtin(void) {
EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half, EXPECT(mi.has_price && mi.price.miss == 9.0 && mi.price.offpeak_half,
"longer match wins the price"); "longer match wins the price");
EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in"); EXPECT(strcmp(mi.source, "built-in") == 0, "source built-in");
cst_model_info("deepseek", "deepseek-flash", &mi);
EXPECT(mi.context == 1000000 && mi.vision == 1, "deepseek-flash takes images");
cst_model_info("deepseek", "deepseek-chat", &mi);
EXPECT(mi.vision == 1, "deepseek-chat is deepseek-flash");
cst_model_info(NULL, "gpt-4o-mini-2024", &mi); cst_model_info(NULL, "gpt-4o-mini-2024", &mi);
EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1, EXPECT(mi.price.miss == 0.15 && mi.context == 128000 && mi.vision == 1,
"gpt-4o-mini over gpt-4o"); "gpt-4o-mini over gpt-4o");
@@ -152,7 +156,7 @@ static void test_cache(void) {
strcmp(mi.price.currency, "$") == 0, strcmp(mi.price.currency, "$") == 0,
"API vision and USD price"); "API vision and USD price");
cst_model_info("deepseek", "deepseek-flash", &mi); cst_model_info("deepseek", "deepseek-flash", &mi);
EXPECT(mi.vision == 0 && mi.price.miss == 2.0, EXPECT(mi.price.miss == 2.0 && strcmp(mi.price.currency, "¥") == 0,
"another provider's cache is not used"); "another provider's cache is not used");
EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000, EXPECT(cst_model_context_tokens("anthropic/claude-sonnet-4") == 1000000,
"no provider: any cache, exact id"); "no provider: any cache, exact id");
+2 -2
View File
@@ -210,7 +210,7 @@ static void test_vision(const char *root) {
snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root); snprintf(cfg.workspace_root, sizeof(cfg.workspace_root), "%s", root);
snprintf(cfg.session_path, sizeof(cfg.session_path), "%s/blind.jsonl", dir); 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.api_base, sizeof(cfg.api_base), "http://replay/v1");
snprintf(cfg.model, sizeof(cfg.model), "deepseek-flash"); snprintf(cfg.model, sizeof(cfg.model), "deepseek-v4-pro");
snprintf(cfg.provider, sizeof(cfg.provider), "deepseek"); snprintf(cfg.provider, sizeof(cfg.provider), "deepseek");
cfg.protocol = CST_PROTO_NATIVE_EOS; cfg.protocol = CST_PROTO_NATIVE_EOS;
cfg.max_steps = 4; cfg.max_steps = 4;
@@ -220,7 +220,7 @@ static void test_vision(const char *root) {
EXPECT(a != NULL, "blind agent"); EXPECT(a != NULL, "blind agent");
if (!a) if (!a)
return; return;
EXPECT(cst_agent_sees_images(a) == 0, "deepseek-flash is text only"); EXPECT(cst_agent_sees_images(a) == 0, "deepseek-v4-pro is text only");
EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err, EXPECT(cst_agent_attach_bytes(a, "shot.png", (const char *)png, pn, err,
sizeof(err)) == 0, sizeof(err)) == 0,
"attach to a blind model"); "attach to a blind model");
+4 -9
View File
@@ -72,7 +72,7 @@ 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"; const char *e = "Access is denied.\r\n";
CHECK(cst_sandbox_blocked(e, strlen(e), 1)); CHECK(cst_sandbox_blocked(e, strlen(e), 1));
const char *f = "拒绝访问。\r\n"; const char *f = "拒绝访问。\r\n";
@@ -132,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);
@@ -152,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);
@@ -337,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);
+280
View File
@@ -0,0 +1,280 @@
/* WebUI: the socket/HTTP layer, the SSE framing, auth and the HTML
* sanitizer. The full turn path is covered by scripts/e2e_webui.sh, which
* runs a recorded stream through a real server. */
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_http.h"
#include "cst_net.h"
#include "cst_util.h"
#include "cst_web_md.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "cst_test_util.h"
#define CHECK(c) \
do { \
if (!(c)) { \
fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \
exit(1); \
} \
} while (0)
/* --- a tiny HTTP client --------------------------------------------------- */
static os_socket_t connect_to(int port) {
char host[32];
snprintf(host, sizeof(host), "127.0.0.1");
os_socket_t s = os_net_connect(host, port, 3000);
CHECK(s != OS_SOCKET_BAD);
return s;
}
/* Sends data, reads until the peer closes (or 5 s pass). malloc'd. */
static char *roundtrip(int port, const char *req, size_t *out_len) {
os_socket_t s = connect_to(port);
CHECK(os_net_send_all(s, req, strlen(req), 3000) == 0);
cst_buf_t b = {0};
char buf[4096];
for (;;) {
long n = os_net_recv(s, buf, sizeof(buf), 5000);
if (n <= 0)
break;
CHECK(cst_buf_append(&b, buf, (size_t)n) == 0);
}
os_net_close(s);
*out_len = b.len;
return cst_buf_steal(&b);
}
/* Same, but stops as soon as `needle` shows up. */
/* --- handlers ------------------------------------------------------------- */
static int h_hello(cst_http_conn_t *c, void *ud) {
(void)ud;
return cst_http_send_str(c, 200, "text/plain", "world");
}
static int h_secret(cst_http_conn_t *c, void *ud) {
(void)ud;
return cst_http_send_str(c, 200, "text/plain", "secret");
}
static int h_echo(cst_http_conn_t *c, void *ud) {
(void)ud;
const cst_http_req_t *r = cst_http_request(c);
char head[64];
snprintf(head, sizeof(head), "%zu:", r->body_len);
cst_buf_t b = {0};
cst_buf_puts(&b, head);
cst_buf_append(&b, r->body ? r->body : "", r->body_len);
int rc = cst_http_send(c, 200, "text/plain", b.buf, b.len);
cst_buf_free(&b);
return rc;
}
static int h_events(cst_http_conn_t *c, void *ud) {
(void)ud;
CHECK(cst_http_stream_begin(c, "text/event-stream") == 0);
CHECK(cst_http_stream_write(c, "event: one\ndata: {\"n\":1}\n\n", 26) == 0);
CHECK(cst_http_stream_write(c, "event: two\ndata: {\"n\":2}\n\n", 26) == 0);
CHECK(cst_http_stream_end(c) == 0);
return 0;
}
static int auth_bearer(const cst_http_req_t *req, void *ud) {
const char *want = ud;
if (!req->authorization)
return -1;
if (strncmp(req->authorization, "Bearer ", 7) != 0)
return -1;
return strcmp(req->authorization + 7, want) == 0 ? 0 : -1;
}
static void test_http(void) {
static const cst_http_route_t routes[] = {
{"GET", "/hello", h_hello, NULL, 0, 0},
{"GET", "/secret", h_secret, NULL, 1, 0},
{"GET", "/events", h_events, NULL, 0, 0},
{"POST", "/echo", h_echo, NULL, 0, 4096},
};
cst_http_opts_t o;
memset(&o, 0, sizeof(o));
o.host = "127.0.0.1";
o.port = 0;
o.max_body = 1024 * 1024;
o.routes = routes;
o.nroutes = (int)(sizeof(routes) / sizeof(routes[0]));
o.auth = auth_bearer;
o.auth_ud = (void *)"s3cret";
char err[256] = "";
cst_http_server_t *s = cst_http_start(&o, err, sizeof(err));
CHECK(s != NULL);
int port = cst_http_port(s);
CHECK(port > 0);
size_t n = 0;
char *resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "200 OK") && strstr(resp, "world"));
free(resp);
/* keep-alive: two requests, one connection */
resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"
"GET /hello HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "200 OK"));
CHECK(strstr(resp, "200 OK") != strrchr(resp, '2')); /* twice */
{
int hits = 0;
for (const char *p = resp; (p = strstr(p, "200 OK")); p++)
hits++;
CHECK(hits == 2);
}
free(resp);
/* auth: without, then with the token */
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "401"));
free(resp);
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
"Authorization: Bearer s3cret\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "200") && strstr(resp, "secret"));
free(resp);
/* unknown route */
resp = roundtrip(port, "GET /nope HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "404"));
free(resp);
/* traversal is rejected before any handler runs */
resp = roundtrip(port, "GET /../etc/passwd HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "400") || strstr(resp, "404"));
free(resp);
/* a body round trip, and the per-route cap */
resp = roundtrip(port, "POST /echo HTTP/1.1\r\nHost: x\r\n"
"Content-Length: 3\r\nConnection: close\r\n\r\nabc", &n);
CHECK(strstr(resp, "200") && strstr(resp, "3:abc"));
free(resp);
{
cst_buf_t b = {0};
cst_buf_puts(&b, "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 9999\r\n"
"Connection: close\r\n\r\n");
for (int i = 0; i < 9999; i++)
cst_buf_puts(&b, "a");
resp = roundtrip(port, b.buf, &n);
CHECK(strstr(resp, "413"));
free(resp);
cst_buf_free(&b);
}
/* chunked streaming: the client must see the decoded frames */
resp = roundtrip(port, "GET /events HTTP/1.1\r\nHost: x\r\n"
"Connection: close\r\n\r\n", &n);
CHECK(strstr(resp, "Transfer-Encoding: chunked"));
CHECK(strstr(resp, "event: one") && strstr(resp, "event: two"));
CHECK(strstr(resp, "\r\n0\r\n\r\n")); /* terminator */
free(resp);
cst_http_stop(s);
cst_http_free(s);
}
/* --- query parsing -------------------------------------------------------- */
static void test_query(void) {
cst_http_req_t r;
memset(&r, 0, sizeof(r));
snprintf(r.query, sizeof(r.query), "token=a%%20b&since=17&x=");
char buf[64];
CHECK(cst_http_query(&r, "token", buf, sizeof(buf)) == 0);
CHECK(strcmp(buf, "a b") == 0);
CHECK(cst_http_query(&r, "since", buf, sizeof(buf)) == 0);
CHECK(strcmp(buf, "17") == 0);
CHECK(cst_http_query(&r, "nope", buf, sizeof(buf)) != 0);
}
/* --- markdown + sanitizer ------------------------------------------------- */
static cst_buf_t g_md_out;
static int collect_md(const char *html, size_t len, void *ud) {
(void)ud;
return cst_buf_append(&g_md_out, html, len) == 0 ? 0 : 1;
}
static void test_sanitize(void) {
char *html = cst_web_md_html("**hi** [x](javascript:alert(1)) "
"![i](/api/attach/img-1.png)",
strlen("**hi** [x](javascript:alert(1)) "
"![i](/api/attach/img-1.png)"));
CHECK(html != NULL);
CHECK(strstr(html, "<strong>hi</strong>"));
CHECK(strstr(html, "javascript:") == NULL);
CHECK(strstr(html, "/api/attach/img-1.png"));
free(html);
/* raw markup that sashidown would pass through gets stripped */
const char *evil = "<p>ok</p><script>alert(1)</script>"
"<img src=\"http://evil.example/x.png\" alt=\"a\">"
"<a href=\"//evil.example\">l</a>";
html = cst_web_md_sanitize(evil, strlen(evil));
CHECK(html != NULL);
CHECK(strstr(html, "ok"));
CHECK(strstr(html, "<script") == NULL);
CHECK(strstr(html, "evil.example/x.png") == NULL);
CHECK(strstr(html, "//evil.example") == NULL);
CHECK(strstr(html, "<a") == NULL); /* an unusable href drops the tag */
free(html);
/* streaming: a javascript: link is dropped, a split tag never leaks */
{
cst_buf_free(&g_md_out);
const char *src = "see [x](javascript:alert(1)) and **ok**";
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
CHECK(md != NULL);
CHECK(cst_web_md_feed(md, src, 4) == 0); /* a deliberately split tag */
CHECK(cst_web_md_feed(md, src + 4, strlen(src) - 4) == 0);
CHECK(cst_web_md_finish(md) == 0);
cst_web_md_free(md);
const char *out = g_md_out.buf ? g_md_out.buf : "";
CHECK(strstr(out, "javascript:") == NULL);
CHECK(strstr(out, "href") == NULL);
CHECK(strstr(out, "see"));
CHECK(strstr(out, "<strong>ok</strong>") != NULL);
cst_buf_free(&g_md_out);
}
{
/* and the same text fed one byte at a time still comes out whole */
cst_buf_free(&g_md_out);
const char *md_src = "**bold** and [ok](https://example.com)";
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
CHECK(md != NULL);
for (size_t i = 0; i < strlen(md_src); i++)
CHECK(cst_web_md_feed(md, md_src + i, 1) == 0);
CHECK(cst_web_md_finish(md) == 0);
cst_web_md_free(md);
CHECK(strstr(g_md_out.buf, "<strong>bold</strong>"));
CHECK(strstr(g_md_out.buf, "https://example.com"));
CHECK(strstr(g_md_out.buf, "noopener") != NULL);
cst_buf_free(&g_md_out);
}
}
int main(void) {
CHECK(os_net_init() == 0);
test_query();
test_sanitize();
test_http();
printf("test_webui: OK\n");
return 0;
}
+433
View File
@@ -0,0 +1,433 @@
/* CST-Agent WebUI client: HTTP + SSE against the engine in the same binary.
* No build step, no dependencies: the server renders Markdown, this file
* only moves messages around and draws them. */
'use strict';
/* --- token: from ?token= / #token= once, then kept in a cookie by the
* server; the query parameter is appended to every request so EventSource
* (which cannot set headers) works as well. --- */
const TOKEN = (() => {
const q = new URLSearchParams(location.search);
if (q.get('token')) return q.get('token');
const h = location.hash.replace(/^#/, '');
const hp = new URLSearchParams(h);
return hp.get('token') || '';
})();
function api(path, opt) {
if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
return fetch(path, opt);
}
async function getJSON(path) {
const r = await api(path);
if (!r.ok) throw new Error((await r.text()) || r.statusText);
return r.json();
}
async function post(path, body) {
const r = await api(path, {
method: 'POST',
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
});
const text = await r.text();
if (!r.ok) throw new Error(text || r.statusText);
return text ? JSON.parse(text) : {};
}
const $ = (id) => document.getElementById(id);
const log = $('log');
let state = {};
let live = null; /* {body, html, think} for the streaming assistant block */
let tools = new Map();/* tool call id -> element */
let permReq = null; /* pending permission event */
let askReq = null; /* pending question */
let firstPaint = true;
/* --- transcript ---------------------------------------------------------- */
function atBottom() {
return log.scrollTop + log.clientHeight >= log.scrollHeight - 40;
}
function scrollDown(force) {
if (force || atBottom()) log.scrollTop = log.scrollHeight;
}
function el(tag, cls, text) {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text !== undefined) e.textContent = text;
return e;
}
function bubble(who, cls) {
const m = el('div', 'msg ' + (cls || ''));
m.appendChild(el('div', 'who', who));
const b = el('div', 'body');
m.appendChild(b);
log.appendChild(m);
scrollDown(true);
return { root: m, body: b };
}
function renderMessage(m) {
if (m.role === 'system') return;
if (m.role === 'tool') return; /* drawn with its call */
const cls = m.role === 'user' ? 'user' : 'assistant';
const who = m.role === 'user' ? 'You' : 'Assistant';
const b = bubble(who, cls);
if (m.role === 'assistant' && m.html) b.body.innerHTML = m.html;
else b.body.textContent = m.content || '';
if (m.reasoning) {
const d = el('details', 'think');
d.appendChild(el('summary', null, 'Thought'));
d.appendChild(el('div', null, m.reasoning));
b.root.insertBefore(d, b.body);
}
if (m.attach) {
for (const a of m.attach) {
const img = el('img');
img.alt = a.name || 'image';
img.src = 'api/attach/' + a.rel + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '');
log.insertBefore(img, null);
}
}
}
async function loadSession() {
const data = await getJSON('api/session');
log.textContent = '';
firstPaint = true;
for (const m of data.messages) renderMessage(m);
firstPaint = false;
scrollDown(true);
}
/* --- live blocks --------------------------------------------------------- */
function ensureLive() {
if (live) return live;
const b = bubble('Assistant', 'assistant');
live = { root: b.root, body: b.body, html: '', think: null };
return live;
}
function thinkBlock(text) {
const l = ensureLive();
if (!l.think) {
const d = el('details', 'think');
d.appendChild(el('summary', null, 'Thought'));
const t = el('div');
d.appendChild(t);
l.root.insertBefore(d, l.body);
l.think = t;
}
l.think.textContent += text;
return l;
}
function toolCard(ev) {
let c = tools.get(ev.id);
if (!c) {
const d = el('details', 'tool');
const s = el('summary', null, ev.name || 'tool');
d.appendChild(s);
const args = el('div', 'args', JSON.stringify(ev.args || {}, null, 0));
d.appendChild(args);
const out = el('pre');
d.appendChild(out);
log.appendChild(d);
c = { root: d, sum: s, out, open: false };
tools.set(ev.id, c);
}
return c;
}
function onTool(ev) {
const c = toolCard(ev);
if (ev.phase === 'start' || ev.phase === 'begin') {
c.sum.classList.add('spin');
c.open = true;
c.root.open = true;
} else if (ev.phase === 'end' || ev.phase === 'done') {
c.sum.classList.remove('spin');
}
if (ev.detail && !c.out.textContent) c.out.textContent = ev.detail;
scrollDown(false);
}
function onToolResult(ev) {
const c = toolCard(ev);
c.sum.classList.remove('spin');
c.out.textContent = (ev.out || '') + (ev.truncated ? '\n… (截断)' : '');
if (ev.exit) {
c.sum.appendChild(el('span', ev.exit > 0 ? 'exit-bad' : '', ' exit=' + ev.exit));
}
scrollDown(false);
}
/* --- state / status ------------------------------------------------------ */
function applyState(s) {
state = s;
$('ws').textContent = s.workspace || '';
$('ws').title = s.workspace || '';
$('model').textContent = (s.provider ? s.provider + ':' : '') + (s.model || '-');
const pm = $('planmode');
pm.textContent = s.plan_mode ? 'Plan' : 'Build';
pm.classList.toggle('on', !!s.plan_mode);
$('approval').textContent = s.approval || 'auto';
$('ctx').textContent = s.context_tokens ? (s.context_tokens + ' tok') : '';
const u = s.usage || {};
$('usage').textContent = u.requests
? `${u.requests} req · ${u.prompt_tokens}/${u.completion_tokens} tok` +
(u.priced && u.cost ? ` · ${u.currency}${u.cost.toFixed(4)}` : '')
: '';
const busy = !!s.status_busy;
$('btn-stop').hidden = !busy;
$('btn-send').hidden = busy;
$('status').textContent = busy ? '运行中…' : '就绪';
const p = $('pending');
p.hidden = !(s.pending_images && s.pending_images.length);
p.textContent = (s.pending_images || []).join(' ');
const t = $('todo');
t.hidden = !s.todo;
if (s.todo) {
t.textContent = '';
t.appendChild(el('h4', null, '任务'));
t.appendChild(el('div', null, s.todo));
}
}
/* --- SSE ----------------------------------------------------------------- */
function connect() {
const url = 'api/events' + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '');
const es = new EventSource(url);
es.addEventListener('state', (e) => applyState(JSON.parse(e.data)));
es.addEventListener('status', (e) => { $('status').textContent = JSON.parse(e.data).message; });
es.addEventListener('assistant_html', (e) => {
const l = ensureLive();
l.html += JSON.parse(e.data).text;
l.body.innerHTML = l.html;
scrollDown(false);
});
es.addEventListener('assistant_delta', () => {});
es.addEventListener('think_delta', (e) => {
thinkBlock(JSON.parse(e.data).text);
scrollDown(false);
});
es.addEventListener('tool', (e) => onTool(JSON.parse(e.data)));
es.addEventListener('tool_result', (e) => onToolResult(JSON.parse(e.data)));
es.addEventListener('todo', (e) => applyState(Object.assign({}, state, { todo: JSON.parse(e.data).text })));
es.addEventListener('session', () => loadSession());
es.addEventListener('permission', (e) => showPermission(JSON.parse(e.data)));
es.addEventListener('permission_done', () => closeModal());
es.addEventListener('ask_user', (e) => showAsk(JSON.parse(e.data)));
es.addEventListener('turn_end', (e) => {
const d = JSON.parse(e.data);
live = null;
if (d.rc) bubble('Error', 'assistant').body.textContent = d.error || ('rc=' + d.rc);
else loadSession(); /* replace the live view with the stored messages */
});
es.onerror = () => { $('status').textContent = '连接中断,重连中…'; };
}
/* --- modals -------------------------------------------------------------- */
function openModal(node) {
const box = $('modal-box');
box.textContent = '';
box.appendChild(node);
$('modal').hidden = false;
}
function closeModal() {
$('modal').hidden = true;
$('modal-box').textContent = '';
permReq = askReq = null;
}
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !$('modal').hidden) closeModal();
});
function showPermission(ev) {
permReq = ev;
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, (ev.sandbox ? '沙箱拦截:要在沙箱外重跑?' : '允许 ' + ev.tool + '?')));
if (ev.preview) {
const pre = el('pre');
pre.textContent = ev.preview;
box.appendChild(pre);
} else if (ev.detail) {
const pre = el('pre');
pre.textContent = ev.detail;
box.appendChild(pre);
}
const row = el('div', 'buttons');
const answer = (n) => { post('api/permission', { id: ev.id, answer: n }).catch(() => {}); closeModal(); };
row.appendChild(Object.assign(el('button', 'danger', '拒绝'), { onclick: () => answer(0) }));
row.appendChild(Object.assign(el('button', 'ok', '允许一次'), { onclick: () => answer(1) }));
row.appendChild(Object.assign(el('button', null, '总是允许(工具)'), { onclick: () => answer(2) }));
if (ev.tool === 'run_shell')
row.appendChild(Object.assign(el('button', null, '总是允许(该前缀)'), { onclick: () => answer(3) }));
box.appendChild(row);
openModal(box);
}
function showAsk(ev) {
askReq = ev;
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, 'Agent 提问'));
const pre = el('pre');
pre.textContent = ev.question || '';
box.appendChild(pre);
const row = el('div', 'buttons');
const send = (text, dismiss) => {
post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(() => {});
closeModal();
};
for (const opt of ev.options || []) {
row.appendChild(Object.assign(el('button', null, opt), { onclick: () => send(opt, false) }));
}
const free = el('input');
free.placeholder = '自定义回答';
free.style.cssText = 'flex:1;min-width:220px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit';
row.appendChild(free);
row.appendChild(Object.assign(el('button', 'ok', '回答'), { onclick: () => send(free.value, false) }));
row.appendChild(Object.assign(el('button', 'danger', '忽略'), { onclick: () => send('', true) }));
box.appendChild(row);
openModal(box);
setTimeout(() => free.focus(), 0);
}
async function showSessions() {
const data = await getJSON('api/sessions');
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '会话'));
for (const s of data.sessions) {
const row = el('div', 'sess');
row.appendChild(el('span', 'id', (s.id === data.current ? '* ' : ' ') + s.id.slice(0, 12)));
row.appendChild(el('span', 't', s.title || '(空)'));
row.appendChild(el('span', 'id', s.messages + ' msgs'));
row.appendChild(Object.assign(el('button', null, '打开'), {
onclick: async () => {
await post('api/sessions/switch', { id: s.id });
closeModal();
await loadSession();
},
}));
box.appendChild(row);
}
const row = el('div', 'buttons');
row.appendChild(Object.assign(el('button', 'ok', '新建会话'), {
onclick: async () => { await post('api/sessions/new', {}); closeModal(); await loadSession(); },
}));
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row);
openModal(box);
}
function showModel() {
const box = document.createDocumentFragment();
box.appendChild(el('h3', null, '模型与模式'));
const mk = (label, value, apply) => {
const row = el('div', 'buttons');
row.appendChild(el('span', 'muted', label));
const input = el('input');
input.value = value || '';
input.style.cssText = 'flex:1;min-width:200px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit';
row.appendChild(input);
row.appendChild(Object.assign(el('button', 'ok', '应用'), {
onclick: async () => { await post('api/config', apply(input.value)); },
}));
box.appendChild(row);
};
mk('模型', state.model, (v) => ({ model: v }));
mk('provider', state.provider, (v) => ({ provider: v }));
const row = el('div', 'buttons');
row.appendChild(el('span', 'muted', '推理强度'));
for (const level of ['low', 'medium', 'high', 'max']) {
row.appendChild(Object.assign(el('button', level === state.effort ? 'ok' : '', level), {
onclick: () => post('api/config', { effort: level }),
}));
}
box.appendChild(row);
const row2 = el('div', 'buttons');
row2.appendChild(el('span', 'muted', '审批'));
for (const mode of ['ask', 'auto', 'yolo']) {
row2.appendChild(Object.assign(el('button', mode === state.approval ? 'ok' : '', mode), {
onclick: () => post('api/config', { approval: mode }),
}));
}
row2.appendChild(Object.assign(el('button', state.plan_mode ? 'ok' : '', state.plan_mode ? 'Plan' : 'Build'), {
onclick: () => post('api/config', { plan_mode: !state.plan_mode }),
}));
box.appendChild(row2);
const row3 = el('div', 'buttons');
row3.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
box.appendChild(row3);
openModal(box);
}
/* --- composer ------------------------------------------------------------ */
async function send() {
const input = $('input');
const text = input.value.trim();
if (!text || state.status_busy) return;
input.value = '';
bubble('You', 'user').body.textContent = text;
try {
await post('api/turn', { text });
} catch (e) {
bubble('Error', 'assistant').body.textContent = String(e.message || e);
}
}
$('btn-send').onclick = send;
$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {});
$('btn-sessions').onclick = () => showSessions().catch((e) => alert(e.message));
$('btn-model').onclick = showModel;
$('btn-undo').onclick = () => post('api/undo', {}).catch((e) => alert(e.message));
$('btn-compact').onclick = () => post('api/compact', {}).catch((e) => alert(e.message));
$('btn-clear').onclick = () => { log.textContent = ''; };
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
e.preventDefault();
send();
}
});
$('input').addEventListener('input', (e) => {
e.target.style.height = 'auto';
e.target.style.height = Math.min(e.target.scrollHeight, 240) + 'px';
});
async function upload(file) {
const r = await api('api/attach?name=' + encodeURIComponent(file.name), {
method: 'POST',
headers: { 'Content-Type': file.type || 'application/octet-stream' },
body: file,
});
if (!r.ok) alert(await r.text());
}
$('file').addEventListener('change', async (e) => {
for (const f of e.target.files) await upload(f);
e.target.value = '';
});
document.addEventListener('paste', async (e) => {
for (const item of e.clipboardData.items) {
if (item.kind === 'file' && item.type.startsWith('image/')) {
e.preventDefault();
await upload(item.getAsFile());
}
}
});
document.addEventListener('dragover', (e) => e.preventDefault());
document.addEventListener('drop', async (e) => {
if (!e.dataTransfer.files.length) return;
e.preventDefault();
for (const f of e.dataTransfer.files) await upload(f);
});
/* --- go ------------------------------------------------------------------ */
connect();
loadSession().catch(() => {});
getJSON('api/state').then(applyState).catch(() => {});
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CST-Agent</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header id="bar">
<span class="tag" id="planmode">Build</span>
<span class="tag" id="approval">auto</span>
<span class="ws" id="ws" title="workspace"></span>
<span class="model" id="model" title="model">-</span>
<span class="muted" id="ctx"></span>
<span class="grow"></span>
<button id="btn-sessions" title="sessions">会话</button>
<button id="btn-model" title="model">模型</button>
<button id="btn-undo" title="undo">撤销</button>
<button id="btn-compact" title="compact">压缩</button>
<button id="btn-clear" title="clear view">清屏</button>
</header>
<main id="log"></main>
<aside id="todo" hidden></aside>
<footer id="composer">
<div id="pending" hidden></div>
<div class="row">
<textarea id="input" rows="1" placeholder="输入消息,Enter 发送,Shift+Enter 换行"></textarea>
<button id="btn-send">发送</button>
<button id="btn-stop" hidden>停止</button>
<label class="att" title="附上图片"><input type="file" id="file" accept="image/*" hidden>+图</label>
</div>
<div class="hint"><span id="status">就绪</span><span class="grow"></span><span id="usage" class="muted"></span></div>
</footer>
<div id="modal" hidden><div id="modal-box"></div></div>
<script src="app.js"></script>
</body>
</html>
+103
View File
@@ -0,0 +1,103 @@
:root {
--bg: #14161a;
--bg2: #1b1e24;
--fg: #d8dde5;
--muted: #8b93a1;
--line: #2a2f38;
--accent: #7fb2ff;
--ok: #7ddc8f;
--warn: #ffcc66;
--err: #ff7b72;
--tool: #b5e3c8;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
background: var(--bg); color: var(--fg);
font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, "Noto Sans Mono CJK SC", monospace;
display: flex; flex-direction: column; height: 100%;
}
button {
background: var(--bg2); color: var(--fg); border: 1px solid var(--line);
border-radius: 6px; padding: 3px 9px; font: inherit; cursor: pointer;
}
button:hover { border-color: var(--accent); }
.grow { flex: 1; }
.muted { color: var(--muted); }
#bar {
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
border-bottom: 1px solid var(--line); background: var(--bg2);
position: sticky; top: 0; z-index: 3; flex-wrap: wrap;
}
.tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; color: var(--muted); }
.tag.on { color: var(--bg); background: var(--accent); border-color: var(--accent); }
.ws { color: var(--muted); overflow: hidden; text-overflow: ellipsis; max-width: 40vw; white-space: nowrap; }
.model { color: var(--accent); }
#log { flex: 1; overflow: auto; padding: 12px 14px 20px; }
#log .msg { margin: 0 0 14px; }
#log .who { color: var(--muted); font-size: 12px; margin-bottom: 2px; }
#log .body { white-space: pre-wrap; word-break: break-word; }
#log .assistant .body > :first-child { margin-top: 0; }
#log .body pre {
background: var(--bg2); border: 1px solid var(--line); border-radius: 6px;
padding: 8px 10px; overflow: auto;
}
#log .body code { background: var(--bg2); border-radius: 4px; padding: 0 3px; }
#log .body pre code { background: none; padding: 0; }
#log .body table { border-collapse: collapse; }
#log .body th, #log .body td { border: 1px solid var(--line); padding: 2px 6px; }
#log .body blockquote { border-left: 3px solid var(--line); margin: 0; padding-left: 10px; color: var(--muted); }
#log .body a { color: var(--accent); }
#log .body img { max-width: min(420px, 100%); border-radius: 6px; border: 1px solid var(--line); }
details.think { color: var(--muted); margin: 4px 0; }
details.think summary { cursor: pointer; }
details.tool {
border: 1px solid var(--line); border-radius: 6px; margin: 6px 0; background: var(--bg2);
}
details.tool > summary { cursor: pointer; padding: 4px 8px; color: var(--tool); }
details.tool .args, details.tool pre { margin: 0; padding: 6px 10px; white-space: pre-wrap; }
details.tool pre { border-top: 1px solid var(--line); max-height: 40vh; overflow: auto; }
.tool .exit-bad { color: var(--err); }
.spin::after { content: " …"; color: var(--warn); }
#todo {
border-top: 1px solid var(--line); background: var(--bg2); padding: 6px 12px;
max-height: 22vh; overflow: auto; white-space: pre-wrap; font-size: 13px;
}
#todo h4 { margin: 0 0 4px; color: var(--muted); font-size: 12px; font-weight: normal; }
#composer { border-top: 1px solid var(--line); background: var(--bg2); padding: 8px 10px; }
#composer .row { display: flex; gap: 8px; align-items: flex-end; }
#input {
flex: 1; resize: vertical; min-height: 34px; max-height: 40vh;
background: var(--bg); color: var(--fg); border: 1px solid var(--line);
border-radius: 6px; padding: 7px 9px; font: inherit;
}
.att {
border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px; cursor: pointer;
}
.hint { display: flex; gap: 10px; font-size: 12px; color: var(--muted); padding-top: 4px; }
#pending { color: var(--warn); font-size: 12px; padding-bottom: 4px; }
#modal {
position: fixed; inset: 0; background: rgba(0, 0, 0, .55);
display: flex; align-items: center; justify-content: center; z-index: 9;
}
#modal-box {
background: var(--bg2); border: 1px solid var(--line); border-radius: 10px;
padding: 14px; width: min(820px, 94vw); max-height: 88vh; overflow: auto;
}
#modal-box h3 { margin: 0 0 8px; }
#modal-box pre {
background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
padding: 8px; max-height: 46vh; overflow: auto; white-space: pre-wrap;
}
#modal-box .buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
#modal-box .danger { border-color: var(--err); color: var(--err); }
#modal-box .ok { border-color: var(--ok); color: var(--ok); }
.sess { display: flex; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--line); }
.sess .id { color: var(--muted); font-size: 12px; }
.sess .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }