6 Commits
Author SHA1 Message Date
WHC 7e1dbd9afa v0.8.4: WebUI sessions, diff, settings, slash commands and extensions
ci / linux (clang) (push) Successful in 6m13s
ci / musl (push) Canceled after 0s
ci / windows-cross (push) Canceled after 0s
ci / linux (gcc) (push) Canceled after 6m33s
Sessions: rename, delete and search; /api/retry regenerates the last
answer, editing a prompt resends from there; /api/export downloads the
transcript as Markdown (cst_session_export_md). Session messages carry
note/tool_result markers, so a reload rebuilds the tool steps with
their arguments, output and exit status; subagent progress is shown.

Diff and settings: /api/diff shows the real diff; /api/settings covers
protocol, max_steps, tool switches and confirm; /api/permissions edits
the rules; sandbox status is reported. Session rename no longer reads
past a short title.

Commands: slash commands with completion in the composer (/model,
/provider, /new, /retry, /undo, /compact, /export, /diff, /init,
/skill and more), a Ctrl+P palette, Shift+Tab cycles the approval
mode, F1 or ? shows the keys. "Thinking expanded" is saved in the
settings (show_thinking), "expand all tool output" in the browser.

Extensions: GET /api/extensions lists MCP servers, plugins, skills,
agent roles and subagent runs; POST /api/extensions/reload and
/api/extensions/trust; a workspace switch reloads them. /api/init and
/api/skill run on the server (cst_init_prompt shared with the TUI).

Errors, statuses and [cst] notes are shown in Chinese with a readable
reason instead of rc=; a failed turn's error survives the reload.

Tests: scripts/e2e_webui_sessions.py, e2e_webui_workspace.py and
e2e_webui_ext.py, run from e2e_webui.sh.
2026-10-10 13:57:17 +08:00
WHC 2d1052fd5f v0.8.3: WebUI models, providers and keys; garbled thinking fixed
ci / linux (clang) (push) Successful in 5m56s
ci / linux (gcc) (push) Successful in 8m38s
ci / musl (push) Successful in 7m22s
ci / windows-cross (push) Successful in 9m21s
Streaming: Chinese reasoning showed up garbled in the WebUI. The agent
loop now feeds visible/thinking text in runs instead of byte by byte,
and the WebUI holds an incomplete UTF-8 tail until the next chunk
(cst_utf8_hold_feed), so every think_delta/assistant_delta is whole.

src/provider_switch.c: model list fetch/cache, key verify-and-save and
provider switching moved out of tui.c and shared by TUI and WebUI.

WebUI API:
- GET /api/providers, GET /api/models[?refresh=1] (401 on a rejected
  key, still listing built-ins), POST /api/provider, POST /api/key
  (key checked against /v1/models, saved to credentials 0600, never
  echoed in replies, events or logs)
- /api/config validates everything before applying, persists model,
  effort and approval (not yolo), warns when effort does not apply;
  plan_mode no longer forces read-only
- state: turn_usage, api_base, key_set/key_from, model_info,
  effort_applies, sees_images, max_steps, tool_profile, perm_timeout;
  permission events carry their timeout

Frontend: searchable model picker with refresh and manual entry,
provider picker and key dialog, effort/vision hints, toasts instead of
silent failures, Chinese error text, real protocol in the settings and
usage panels, approval countdown from the server's timeout.

Tests: utf8 hold unit test, scripts/e2e_webui_settings.py (fake
/v1/models, wrong key, switching, persistence, Chinese thinking) run
from e2e_webui.sh, scripts/tui_keys/provider.py.
2026-10-09 15:54:53 +08:00
WHC e238f5f37d v0.8.2: build fixes, WebUI security and correctness
ci / linux (clang) (push) Successful in 5m45s
ci / linux (gcc) (push) Successful in 6m46s
ci / musl (push) Successful in 7m9s
ci / windows-cross (push) Successful in 11m52s
Build: <fcntl.h> in tui.c, format-truncation in http_srv.c and /web.

WebUI:
- refuse cross-site requests (Origin must be the page itself) in the
  default no-token mode: any page could POST approval=yolo and a prompt
- session/workspace/undo/compact/attach/model changes are 409 while a
  turn runs (use-after-free, messages landing in the wrong session)
- ask_user returned "dismissed" at once instead of waiting
- cancel and shutdown wake a turn parked on a permission or question;
  SIGTERM mid-turn no longer hangs or crashes
- a page opened while a question is pending still gets it;
  Last-Event-ID resumes the stream; event seq matches the SSE id
- token mode: page assets load without the token, images via cookie
- --web-open works, --web-port/--web-token are validated
- frontend: gate keys ignored while typing, clear-images button,
  reasoning no longer wiped while streaming

HTTP server: auth and size cap before reading the body, strict
Content-Length, NUL in url_decode, 413 on keep-alive, idle timeout,
absolute-form only without a leading '/', case-insensitive Connection,
header deadline, 400 for long request lines, unlink before close,
poll() instead of select(), SO_EXCLUSIVEADDRUSE on Windows.

Markdown sanitizer escapes quotes in attribute values.

/web: passes this session's provider/model/base/effort/approval (key
via the environment), keeps output off the TUI, shows the real URL and
keeps CURRENT on the TUI's session.

Tests: scripts/e2e_webui_ask.py, scripts/e2e_webui_http.py (run from
e2e_webui.sh), sanitizer quote test.
2026-10-09 13:51:34 +08:00
WHC adf3e9f024 v0.8.0: WebUI redesign, /web, workspace switch, 75% budget, no shell timeout
ci / linux (clang) (push) Failing after 31s
ci / linux (gcc) (push) Failing after 32s
ci / musl (push) Failing after 39s
ci / windows-cross (push) Canceled after 2m32s
The front end is rebuilt on the design tokens the desktop app ships (surfaces,
color-mix borders, alpha hovers, accent): a 30-icon inline SVG sprite, dark and
light themes, layout switches as deep links (?side=0, ?panel=0, ?theme=light)
resolved before the first paint. The empty page is one line and leaves with the
first turn; the right rail keeps three cards - todo, file changes (+N -M) and
context (tokens, prompt cache hit rate). Both rails animate in and out, and a
collapsed rail widens its grid track to 0 instead of being removed, so the
transcript never slides into a zero-width column.

- src/webui.c: POST /api/workspace - re-root the agent at another directory
  (permission rules, metrics, AGENTS.md follow) and start a session there, as
  sessions live per directory; state also reports cached/cache_miss tokens
- src/agent_loop.c: cst_agent_set_workspace(), rebuilt prompt and re-measured
  budget; the auto-compact threshold is 75% of the window (was 60%)
- src/tui.c: /web forks a WebUI next to the TUI - separate process and a new
  session, because cst_webui_run() blocks and two writers corrupt one session
- src/os_compat.c, src/os_win.c: run_shell runs without a time limit unless
  CST_SHELL_TIMEOUT asks for one, so builds and long clones finish
- src/main.c, include/cst_webui.h: one CST_WEBUI_DEFAULT_PORT for --help, the
  docs and /web; Termux opens the browser through termux-open-url
- README.md, docs/webui.md, docs/env_openai.md: /web, /api/workspace, the new
  state fields, the 75% threshold and the layout deep links

tests/e2e_webui.sh green (permission round-trip, token paths, --web-dev);
make check green.
2026-10-09 11:50:13 +08:00
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
56 changed files with 11742 additions and 212 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/
+63 -9
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)
@@ -110,20 +111,64 @@ set(CST_CORE_SOURCES
src/agent_store.c src/agent_store.c
src/json_baseline.c src/json_baseline.c
src/creds.c src/creds.c
src/provider_switch.c
src/mm_tool.c src/mm_tool.c
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 +314,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()
+34 -5
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 \
@@ -76,15 +91,16 @@ CORE_SRC = \
src/agent_loop.c \ src/agent_loop.c \
src/agent_store.c \ src/agent_store.c \
src/json_baseline.c \ src/json_baseline.c \
src/creds.c \ src/creds.c src/provider_switch.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 -3
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
@@ -195,10 +212,10 @@ Gitea Release `vVERSION`(同名资源会被替换);仓库可设 `RELEASE_T
| `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) | | `CST_MAX_STEPS` / `--max-steps` | 每个用户回合最多调用模型几次(30) |
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) | | `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(16384,保留头 60% + 尾 40%) |
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) | | `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(80) |
| `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 80% 时自动 `/compact`) | | `CST_CTX_TOKENS` / `CST_AUTO_COMPACT=0` | 覆盖模型上下文窗口大小 / 关闭自动压缩(用到 75% 时自动 `/compact`) |
| `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt | | `CST_NO_PROJECT_NOTES=1` | 不把工作区的 `AGENTS.md` 注入 system prompt |
| `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) | | `NO_COLOR` / `CST_ASCII=1` | 关闭颜色 / 强制 ASCII 图形(非 UTF-8 终端自动退化) |
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(120),超时整组进程被杀,exit=124 | | `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限),设了才在超时后整组杀进程,exit=124 |
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL(默认 `.agent/metrics/<session>.jsonl`) |
| `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) | | `CST_RECORD_SSE=DIR` / `CST_REPLAY_SSE=DIR` | 录制 / 回放原始 SSE 流(`NNNN.sse` + `NNNN.req.json`) |
| `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 | | `CST_APPROVAL=ask\|auto\|yolo` / `CST_SANDBOX=off\|landlock\|seccomp` | 审批模式 / 关闭沙箱(回落到只读白名单规则)或只用指定沙箱 |
@@ -302,7 +319,7 @@ TUI 中 `/tools off NAME`、`/tools on NAME` 可临时开关单个工具。
`sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。 `sudo`、`rm`、解释器(`python3 -c …`)等危险前缀不会被建议成规则。
- **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。 - **撤销**:每轮写文件前自动快照到 `.agent/undo/`,`/undo` 恢复上一轮改动的全部文件(新建的文件会被删除)。
- **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。 - **AGENTS.md**:工作区根目录的 `AGENTS.md` 会注入 system prompt;`/init` 让 agent 自己生成一份。
- **自动压缩**:上一轮 prompt 用到模型上下文 80% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。 - **自动压缩**:上一轮 prompt 用到模型上下文 75% 时,下一轮开始前自动总结历史(`CST_AUTO_COMPACT=0` 关闭)。
### 扩展:Skills、插件、MCP、子 agent ### 扩展:Skills、插件、MCP、子 agent
@@ -391,6 +408,7 @@ Thought(推理过程)默认折叠成一行“Thought · 3.2s”,生成中
| `/diff [path]` * | `git status` + `git diff` | | `/diff [path]` * | `git status` + `git diff` |
| `/compact` | 手动压缩上下文 | | `/compact` | 手动压缩上下文 |
| `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) | | `/export [file]` *、`/copy` * | 导出 Markdown 记录;复制上一条回复(OSC 52,需终端支持) |
| `/web` * | 后台起一个 WebUI 进程(同 workspace、新会话,日志写到 `.agent/webui.log`) |
| `/init` | 让 agent 写 `AGENTS.md` | | `/init` | 让 agent 写 `AGENTS.md` |
| `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 | | `/details` *、`/thinking [on\|off]`、`/keys` * | 展开全部 tool 输出;Thought 默认展开/折叠;快捷键列表 |
| `/skills [reload\|list]`、`/skill <name> [任务]` | 浏览 skills;带着某个 skill 开始一轮 | | `/skills [reload\|list]`、`/skill <name> [任务]` | 浏览 skills;带着某个 skill 开始一轮 |
+1 -1
View File
@@ -73,7 +73,7 @@ export OPENAI_MODEL='your-model'
| `CST_MAX_STEPS` | 每个用户回合最多调用模型几次(默认 30) | | `CST_MAX_STEPS` | 每个用户回合最多调用模型几次(默认 30) |
| `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(默认 16384) | | `CST_TOOL_OUT_CAP` | 单个工具输出发给模型的字节上限(默认 16384) |
| `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(默认 80) | | `CST_CTX_WINDOW` | 发给模型的最近非 system 消息条数(默认 80) |
| `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认 120) | | `CST_SHELL_TIMEOUT` | `run_shell` 超时秒数(默认不限,Ctrl+C / 停止按钮仍可随时中断) |
| `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL | | `CST_METRICS=0` / `CST_METRICS_PATH` | 关闭 / 指定计时埋点 JSONL |
| `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 | | `CST_RECORD_SSE` / `CST_REPLAY_SSE` | 录制 / 回放原始 SSE 流目录;`CST_REPLAY_CHUNK` 控制回放分块字节数 |
| `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 | | `CST_MODELS` | `/model` 可选列表,`:` 或 `,` 分隔 |
+275
View File
@@ -0,0 +1,275 @@
# 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);带 `Origin` 的请求必须来自本页面自己(`http://<Host>`),否则 401 —— 防止任意网页跨站 POST 驱动 agent |
| 静态资源 | 页面本身与 `app.js`/`style.css` 不需要 token;`/api/attach/<rel>` 图片仍要(页面会把 token 写进 `SameSite=Strict` cookie) |
| 请求解析 | 鉴权与大小检查在读请求体**之前**完成;`Content-Length` 必须是纯数字且只有一个值;请求头整体 15 s 内收完 |
| `--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 条,超出后从可用的最老一条开始;比当前 seq 还大(服务端重启过)
就当作新连接。
* 新连接(不带 `since`)只收之后的事件;但若有尚未回答的 `permission`/`ask_user`,
会从那条事件开始补发,刷新页面不会丢掉正在等的问题。
* 心跳是 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`/`progress`/`done`;`progress` 是子代理的最近几步(`detail` 形如 `explore · grep foo · read_file x`),可以出现多次 |
| `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` |
| `todo` | `text` | `todo_write` 替换后的清单(一行一项) |
| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox`,`timeout` | 需要回答(见下);`timeout` 秒后按拒绝处理 |
| `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,cached_tokens,cache_miss_tokens,cost,priced,currency}`
`turn_usage{…}`(上一轮,字段同 `usage`)`api_base` `key_set` `key_from`
(`saved` / 环境变量名 / `session` / 空,**从不含 key 本身**)
`model_info{label,context,vision,reasoning,source,price?}` `effort_applies`
`sees_images`(1 / 0 / -1 未知)`max_steps` `tool_profile` `perm_timeout`
`show_thinking`(思考过程默认展开,存在 `settings.json`,和 TUI 的 `/thinking` 是同一个开关)。
`cached_tokens` / `cache_miss_tokens` 来自上游的 prompt cache 统计,前端用它算命中率。
`think_delta` / `assistant_delta` 的 `text` 总是完整的 UTF-8:上游在多字节字符中间
断开时,残缺的尾巴留到下一块再发。
### 请求
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/state` | 上面那份快照 |
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致,另加的字段见下) |
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
| POST | `/api/sessions/new` | `{}` → `{id}` |
| POST | `/api/sessions/switch` | `{"id":"…"}` |
| POST | `/api/sessions/rename` | `{"id":"…"?,"title":"…"}`(不带 `id` = 当前会话);标题按字符截到 120 字节,空串 = 恢复默认(第一句提问);没这个会话 → 404 |
| POST | `/api/sessions/delete` | `{"id":"…"}` → `{ok,current}`;删的是当前会话时先开一个新会话(并发 `session` 事件);没这个会话 → 404 |
| POST | `/api/retry` | `{}` = 重新生成最后一轮;`{"text":"…"}` = 把最后一句提问换成新文字再跑(编辑重发)。先把最后一轮从会话里退掉,再开新一轮 → 202 `{ok,text}`;没有可重来的轮次 / 轮次在跑 → 409;空串或非字符串 → 400 |
| GET | `/api/export` | 当前会话的 Markdown(`text/markdown`,与 TUI `/export` 同一格式) |
| GET | `/api/diff[?path=p]` | 工作区的真实 diff:`{git,path,files:[{status,path}],diff,truncated}`。`files` 来自 `git status --short`(含未跟踪),`diff` 是 `git diff`(未暂存部分,限 512 KiB);不是 git 仓库 / 没装 git → `git:false`;10 s 超时 |
| GET | `/api/settings` | 运行设置:`protocol`,`protocols[]`,`max_steps`,`tool_profile`,`profiles[]`,`confirm{shell,write,net}`,`approval`,`tools[{name,perm,on,desc,…}]`,`sandbox{kind,active,labels?}` |
| POST | `/api/settings` | `{protocol?,max_steps?,tool_profile?,tools?:{名字:bool},confirm?:{shell?,write?,net?}}`;整体校验后才生效(任何一项不对 → 400,什么都不改),成功后发 `state` |
| GET | `/api/permissions` | 已保存的"总是允许"规则:`{text,tools[],shell_prefixes[]}`(来自 `<workspace>/.agent/permissions.json`) |
| DELETE | `/api/permissions` | 清空这些规则 |
| POST | `/api/init` | `{}` → 202 `{ok,text}`:开一轮,让 agent 分析仓库写 `AGENTS.md`(与 TUI `/init` 同一段提示词) |
| POST | `/api/skill` | `{"name":"…","task":"…"?}` → 202 `{ok,text}`:把 skill 正文内联进这条消息再开一轮(同 TUI `/skill`);没有这个 skill → 404;缺 `name` / 超过 63 字节 / `task` 不是字符串 → 400 |
| GET | `/api/extensions` | `{enabled,trusted,report,task_tool,mcp:[{name,source,command,state,tools,error}],plugins:[{name,source,desc,tools,mcp,skills,error}],skills:[{name,source,desc}],roles:[{name,source,desc,model,read_only}],runs:[{file,role,desc,status,steps,tools,ms}]}`;`state` = `starting/ready/failed/exited/off`,`runs` 是当前会话的子代理运行 |
| POST | `/api/extensions/reload` | 停掉 MCP 服务器和插件,重新读取它们和 skills → `{ok,trusted,report}` |
| POST | `/api/extensions/trust` | `{"on":true\|false}`:信任(或取消)当前工作区——决定它的 `.agent/plugins`、`.agent/mcp.json` 是否加载;写进 `settings.json` 后重新加载,回复同上 |
| POST | `/api/workspace` | `{"path":"/abs/dir"}` —— 换工作目录(权限规则/埋点/AGENTS.md 跟着换,扩展按新目录重新加载),并**在新目录里开一个新会话**(会话是按目录存的);返回 `{id}`,坏路径 → 400 |
| 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?,effort?,approval?,plan_mode?,show_thinking?}`;先整体校验再生效,model/effort/approval(`yolo` 除外)写进 `settings.json`;当前模型不吃 effort 时回 `warning`;带 `provider` → 400(改用 `/api/provider`) |
| GET | `/api/providers` | `{current, providers:[{name,api_base,model,has_key,key_from,key_env,source,current}]}` |
| GET | `/api/models[?refresh=1]` | 当前提供商的模型:`{provider,current,api_base,key_set,api_count,models:[{id,api,label,context,vision,reasoning,source,price?}]}`;先用缓存,过期/缺失/`refresh=1` 时向 `/v1/models` 拉;key 被拒 → 401(仍带内置模型和 `auth:true`),其它失败放在 `api_error` |
| POST | `/api/provider` | `{"name":"…","key":"…"?}`:切换提供商。缺 key → 400 `{need_key:true}`;key 被拒 → 401;没这个提供商 → 404;成功 → `{ok,provider,model,warning?}`,key 验证后存进 `credentials`(0600) |
| POST | `/api/key` | `{"key":"…"}`:换当前提供商的 key,验证与回复同上 |
| POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
| POST | `/api/compact` | 手动压缩上下文 |
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤32 MiB),进入"待发送"列表 |
| DELETE | `/api/attach` | 清空待发送图片 |
| GET | `/api/attach/<rel>` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) |
| POST | `/api/quit` | 关闭进程 |
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`sessions/delete`、
`workspace`、`undo`、`compact`、`attach`(上传/清空)、`provider`、`key`、`retry`、`diff`、
`POST /api/settings`、`DELETE /api/permissions`、`init`、`skill`、`extensions/reload`、
`extensions/trust` 以及改 `model/effort` 的 `config` 一律 **409**;
`approval`/`plan_mode`/`show_thinking` 可以中途改,`sessions/rename`、`export` 与
`GET /api/extensions` 在轮次进行中也能用。
`/api/session` 的每条消息在会话文件的字段之外还带:
| 字段 | 出现在 | 说明 |
|---|---|---|
| `html` | assistant | 已消毒的 Markdown 渲染结果(文本协议时只渲染说明文字,不含调用帧) |
| `text` | 文本协议的 assistant | 去掉调用帧后的说明文字(`content` 仍是原文) |
| `calls` | 带工具调用的 assistant | `[{id,name,args}]`:原生协议来自 `tool_calls`,文本协议从 `content` 解析 |
| `tool_result` | 工具结果 | `true`;原生协议 role 是 `tool`,文本协议 role 是 `user`(凭 `tool_call_id` 区分) |
| `out`,`exit`,`truncated` | 工具结果 | 去掉 `[tool …]` / `result … end` 外框后的输出(截到 32 KiB,按字符边界)与退出码 |
| `note` | 引擎插入的提示 | `true`;如中途切换计划模式时的 `[cst] …` 说明,不是用户说的话 |
客户端据此在刷新后重建"本轮工具调用"列表:`calls` 建行,`tool_call_id` 相同的结果填进去。
key 只出现在请求体里:回复、事件、日志都不会带它。`/api/provider` 与 `/api/key`
跟其它 `/api/*` 一样受 loopback 同源检查或 token 保护。
### 客户端接入示例(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"。
界面本身:左栏可收(顶栏按钮 / `?side=0`),右栏可收(`?panel=0`),主题 `?theme=dark|light`;
三个开关都会记进 `localStorage`,带查询参数时以参数为准。空状态只留一句提示,第一轮
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
### 命令与快捷键
输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全,
Enter 执行),`/model`、`/effort`、`/approval`、`/provider`、`/skill`、`/thinking`、`/details`
还会补全参数。`/usr/bin 是什么` 这种第一个词里带 `/` 的照常发送;未知命令留在输入框里并提示,
开头加个空格即可当普通消息发出。
| 命令 | 作用 |
|---|---|
| `/new` `/sessions` `/rename [标题]` | 新建 / 会话列表 / 重命名 |
| `/retry` `/edit` `/stop` `/undo` `/compact` | 重新生成 / 编辑重发 / 停止 / 撤销写入 / 压缩上下文 |
| `/export` `/copy` `/diff [文件]` | 导出 Markdown / 复制最后一条回答 / 工作区 diff |
| `/model [id]` `/effort [级别]` `/provider [名字]` `/key` | 模型、推理强度、提供商、API key |
| `/approval [ask\|auto\|yolo]` `/plan` `/build` | 审批权限、计划/构建模式 |
| `/settings`(`/protocol` `/tools` `/max-steps` `/permissions`) | 运行设置 |
| `/init` `/skill 名字 [任务]` `/skills` `/extensions`(`/mcp` `/plugins` `/agents`) | 写 AGENTS.md、用 skill、扩展状态 |
| `/thinking [on\|off]` `/details [on\|off]` | 思考过程默认展开(存 `settings.json`)、工具输出全部展开(存浏览器) |
| `/theme` `/palette` `/help` | 主题、命令面板、帮助 |
快捷键:Ctrl+P 命令面板,Ctrl+K 会话列表,Shift+Tab 循环审批(每次询问 → 自动 → 完全放行),
F1 或 `?` 帮助,Esc 关闭弹窗 / 取消编辑 / 停止当前一轮,Ctrl+B / Ctrl+J 收起左栏 / 右栏。
引擎的英文状态、错误和会话里的 `[cst]` 提示在页面上换成中文;一轮失败时(`turn_end.rc≠0`)
显示可读的原因(比如"API key 无效或已过期(HTTP 401)"、"用满了 30 步上限"),不再是 `rc=-1`。
TUI 里打 `/web` 会另起一个 WebUI 进程(`--web --workspace <当前目录> --new`),
日志在 `<workspace>/.agent/webui.log`:TUI 不能被 `cst_webui_run()` 的阻塞循环占住,
而且两个进程同时写一个会话文件会把它写坏,所以子进程总是开新会话。
自动压缩的门槛是模型上下文窗口的 **75%**(`CST_CTX_BUDGET` 可覆盖);到点后下一轮
开始前先总结历史,而不是等到塞满。
## 测试
```bash
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key
# (含 e2e_webui_settings.py:假 /v1/models、换提供商/key、中文思考流;
# e2e_webui_sessions.py:重新生成/编辑重发/导出/重命名/删除/历史字段;
# e2e_webui_workspace.py:diff、运行设置、权限规则;
# e2e_webui_ext.py:/init、/skill、扩展、信任、思考开关)
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl-heredoc ctl-heredoc
# (deepseek-ctl 录的是一次契约错误连发的失败运行,不能拿来跑这个)
```
手工验收清单(浏览器):
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):工具调用折叠成"本轮工具调用",
点开能看到参数和输出;搜索、重命名、删除可用。
4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。
5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
## 限制与已知问题
* 同一会话同时只能有一轮(`POST /api/turn` 返回 409);TUI 与 WebUI 目前是
**二选一**(`--web` 时不启动 TUI),同进程并存留待后续的 hooks 分发层。
* 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
* 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
* 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始,
这时应当重新拉一次 `/api/session`。
+12
View File
@@ -130,6 +130,12 @@ const cst_message_t *cst_session_get(const cst_session_t *s, int i);
const char *cst_session_path(const cst_session_t *s); const char *cst_session_path(const cst_session_t *s);
int cst_session_clear(cst_session_t *s, int keep_system); int cst_session_clear(cst_session_t *s, int keep_system);
int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n); int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n);
/* Tool output: role=tool, or a text-protocol user message answering a call. */
int cst_message_is_tool_result(const cst_message_t *m);
/* The transcript as Markdown, malloc'd (NULL on OOM). Tool output and call
* arguments are cut at 4000 bytes. Takes the session lock. */
char *cst_session_export_md(cst_session_t *s, const char *title,
const char *sid, const char *model);
/* Readers on another thread must hold the lock while using pointers returned /* Readers on another thread must hold the lock while using pointers returned
* by cst_session_get (append may reallocate). Append locks internally. */ * by cst_session_get (append may reallocate). Append locks internally. */
void cst_session_lock(cst_session_t *s); void cst_session_lock(cst_session_t *s);
@@ -321,6 +327,12 @@ cst_agent_config_t *cst_agent_config_mut(cst_agent_t *a);
void cst_agent_request_cancel(cst_agent_t *a); void cst_agent_request_cancel(cst_agent_t *a);
int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz); int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz);
int cst_agent_switch_session(cst_agent_t *a, const char *id); int cst_agent_switch_session(cst_agent_t *a, const char *id);
/* Re-root the agent at another directory. Per-workspace state (permission
* rules, metrics, project notes) follows, the system prompt is rebuilt and the
* token budget is re-measured. The session is not touched: the caller decides
* whether to keep it or start a new one. */
int cst_agent_set_workspace(cst_agent_t *a, const char *path, char *err,
size_t err_sz);
int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz); int cst_agent_compact(cst_agent_t *a, char *err, size_t err_sz);
/* Bare id of the current session (file name without .jsonl). */ /* Bare id of the current session (file name without .jsonl). */
void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz); void cst_agent_session_id(const cst_agent_t *a, char *out, size_t out_sz);
+108
View File
@@ -0,0 +1,108 @@
#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;
const char *last_event_id; /* an EventSource reconnecting */
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 (30 s at most) for the
* handler threads. Returns how many still run: their ud must stay alive. */
int cst_http_stop(cst_http_server_t *s);
/* Does nothing while handlers still run on 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 */
+79
View File
@@ -0,0 +1,79 @@
#ifndef CST_PROVIDER_SWITCH_H
#define CST_PROVIDER_SWITCH_H
/* Model lists, API keys and provider switches, shared by the TUI and the
* WebUI so both front ends validate and persist the same way.
*
* A key is checked with GET /models: a 401/403 (CST_MODELS_AUTH) rejects
* it, any other failure (offline, no /models route) lets it through
* unverified. A key that passes is saved to <config>/credentials. Keys are
* never copied into err. */
#include "cst_agent.h"
#include "cst_models.h"
#ifdef __cplusplus
extern "C" {
#endif
#define CST_PS_MAX_MODELS 512
#define CST_PS_UNKNOWN (-1) /* no such provider */
#define CST_PS_NEED_KEY (-10) /* no key given, none saved or in the env */
/* Non-negative results are flags: */
#define CST_PS_UNVERIFIED 1 /* /models failed for another reason; err says */
#define CST_PS_UNSAVED 2 /* the credentials file could not be written */
typedef struct {
char id[128];
int api; /* listed by the provider's /models (else built-in only) */
cst_model_info_t info;
} cst_ps_model_t;
/* GET /models; a good answer refreshes models-cache/<provider>.json. The
* count, CST_MODELS_AUTH, or another negative value with err set. */
int cst_ps_fetch(const char *provider, const char *api_base, const char *key,
char ids[][128], int max, char *err, size_t err_sz);
/* The cached list: count, or -1; *fresh when younger than the TTL. */
int cst_ps_cached(const char *provider, char ids[][128], int max, int *fresh);
/* Base URL for provider: cfg's own when it is the current provider (it may
* be overridden), else the preset's; "" when unknown. */
const char *cst_ps_base(const cst_agent_config_t *cfg, const char *provider);
/* The model picker list for cfg's provider: the API's ids (the cache while
* it is fresh, else a fetch; refresh always fetches) followed by the
* built-in names it did not list. *api_rc gets the fetch result (count,
* CST_MODELS_AUTH, -1 with err) or the cached count. Returns the count. */
int cst_ps_models(const cst_agent_config_t *cfg, int refresh,
cst_ps_model_t *out, int max, int *api_rc, char *err,
size_t err_sz);
/* Checks key against /models and saves it: CST_MODELS_AUTH (err set), or
* the CST_PS_* flags. When ids is given it gets the fetched list and *nids
* the raw fetch result. */
int cst_ps_verify_save(const char *provider, const char *api_base,
const char *key, char ids[][128], int max, int *nids,
char *err, size_t err_sz);
/* Point cfg at provider p with key: base URL, the remembered model (else
* the preset's), settings saved. */
void cst_ps_apply_provider(cst_agent_config_t *cfg, const cst_provider_t *p,
const char *key);
/* The whole switch without a prompt: key NULL/"" uses the saved or env
* key (else CST_PS_NEED_KEY); a given key is verified and saved first. A
* preset with no model takes the first one the API lists. The CST_PS_*
* flags, or CST_PS_UNKNOWN / CST_PS_NEED_KEY / CST_MODELS_AUTH. */
int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name,
const char *key, char *err, size_t err_sz);
/* Replace the current provider's key: verified, saved, put in cfg. Same
* results as cst_ps_verify_save. */
int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err,
size_t err_sz);
#ifdef __cplusplus
}
#endif
#endif
+2
View File
@@ -45,6 +45,8 @@ char *cst_skill_read(const char *workspace, const char *name,
* task. NULL when the skill does not exist. malloc'd. */ * task. NULL when the skill does not exist. malloc'd. */
char *cst_skill_prompt(const char *workspace, const char *name, char *cst_skill_prompt(const char *workspace, const char *name,
const char *task); const char *task);
/* User message for `/init`: have the agent write AGENTS.md. */
const char *cst_init_prompt(void);
/* Register the `skill` tool (idempotent). */ /* Register the `skill` tool (idempotent). */
int cst_skills_register_tool(void); int cst_skills_register_tool(void);
+11
View File
@@ -27,6 +27,17 @@ size_t cst_utf8_floor(const char *s, size_t n);
char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len); char *cst_utf8_sanitize(const char *s, size_t n, size_t *out_len);
int cst_utf8_valid(const char *s, size_t n); int cst_utf8_valid(const char *s, size_t n);
/* Re-chunks a byte stream so no piece ends inside a UTF-8 sequence: the
* incomplete tail (at most 3 bytes) waits in the hold for the next feed. */
typedef struct {
char b[4];
size_t n;
} cst_utf8_hold_t;
/* Returns the complete prefix of hold+s[0..n) (malloc'd, NUL-terminated,
* *out_len may be 0), or NULL on OOM. */
char *cst_utf8_hold_feed(cst_utf8_hold_t *h, const char *s, size_t n,
size_t *out_len);
/* Text files may start with a UTF-8 BOM (Windows editors): its length (0/3), /* Text files may start with a UTF-8 BOM (Windows editors): its length (0/3),
* and an in-place strip of the NUL-terminated s (*len updated when given). */ * and an in-place strip of the NUL-terminated s (*len updated when given). */
size_t cst_bom_len(const char *s, size_t n); size_t cst_bom_len(const char *s, size_t n);
+2 -2
View File
@@ -2,8 +2,8 @@
#define CST_VERSION_H #define CST_VERSION_H
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 7 #define CST_VERSION_MINOR 8
#define CST_VERSION_PATCH 1 #define CST_VERSION_PATCH 4
#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 */
+44
View File
@@ -0,0 +1,44 @@
#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
/* What --web listens on when --web-port/CST_WEB_PORT says nothing (the
* README and --help quote this number, so keep them in sync). */
#define CST_WEBUI_DEFAULT_PORT 8787
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 */
+4 -3
View File
@@ -114,9 +114,10 @@ int os_win_get_label(const char *path, char *out, size_t n);
int os_clipboard_set(const char *utf8, size_t n); int os_clipboard_set(const char *utf8, size_t n);
#endif #endif
/* Child processes get stdin=/dev/null, their own process group, and are /* Child processes get stdin=/dev/null and their own process group. They run
* killed after the timeout (CST_SHELL_TIMEOUT seconds, default 120) or when * with no time limit unless one is asked for (CST_SHELL_TIMEOUT seconds, or
* *flag becomes non-zero. Exit code 124 = timeout, 130 = cancelled. */ * os_set_run_timeout()); either way a non-zero *flag cancels them.
* Exit code 124 = killed by that timeout, 130 = cancelled. */
void os_set_cancel_flag(volatile int *flag); void os_set_cancel_flag(volatile int *flag);
volatile int *os_get_cancel_flag(void); volatile int *os_get_cancel_flag(void);
void os_set_run_timeout(int seconds); /* this thread; 0 = default */ void os_set_run_timeout(int seconds); /* this thread; 0 = default */
+299
View File
@@ -0,0 +1,299 @@
#!/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; }
# SIGTERM must shut the server down cleanly, even mid-turn (128+ = a crash)
stop_server() {
kill "$1" 2>/dev/null || return 0
n=0
while kill -0 "$1" 2>/dev/null && [ $n -lt 200 ]; do n=$((n + 1)); sleep 0.2; done
if kill -0 "$1" 2>/dev/null; then
kill -9 "$1" 2>/dev/null || true
fail "$2 hung on SIGTERM"
fi
st=0
wait "$1" 2>/dev/null || st=$?
[ "$st" -lt 128 ] || fail "$2 died with status $st on SIGTERM"
}
# 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)"
stop_server "$SRV_PID" "the first server" # its 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"
stop_server "$P_PID" "the ask-mode server"
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
# 9. ask_user waits for the answer, and the HTTP hardening checks (both
# need a scripted model: python3)
if command -v python3 > /dev/null; then
echo "== ask_user round trip"
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ask.py" > "$WORK/ask.txt" 2>&1 ||
{ cat "$WORK/ask.txt"; fail "the ask_user round trip"; }
echo "== HTTP hardening"
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_http.py" > "$WORK/http.txt" 2>&1 ||
{ cat "$WORK/http.txt"; fail "the HTTP hardening checks"; }
echo "== settings and streaming"
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_settings.py" > "$WORK/settings.txt" 2>&1 ||
{ cat "$WORK/settings.txt"; fail "the settings checks"; }
echo "== sessions, retry and export"
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_sessions.py" > "$WORK/sessions.txt" 2>&1 ||
{ cat "$WORK/sessions.txt"; fail "the session checks"; }
echo "== diff, run settings and permission rules"
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_workspace.py" > "$WORK/workspace.txt" 2>&1 ||
{ cat "$WORK/workspace.txt"; fail "the workspace checks"; }
echo "== /init, /skill, extensions and the thinking switch"
CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ext.py" > "$WORK/ext.txt" 2>&1 ||
{ cat "$WORK/ext.txt"; fail "the extension checks"; }
fi
echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))"
+164
View File
@@ -0,0 +1,164 @@
"""WebUI ask_user round trip against a fake OpenAI-compatible API.
python3 scripts/e2e_webui_ask.py (CST_AGENT= picks the binary)
Checks: the turn really waits for the answer, a page opened while the
question is open still receives it, and Last-Event-ID resumes the stream.
"""
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
REQS = []
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).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))
REQS.append(json.loads(self.rfile.read(n)))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
def ev(o):
self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n")
self.wfile.flush()
if len(REQS) == 1:
args = json.dumps({"question": "Pick a colour?", "options": "red\nblue"})
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [
{"index": 0, "id": "call_1", "type": "function",
"function": {"name": "ask_user", "arguments": args}}]}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]})
else:
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "done"}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
class Sse:
def __init__(self, base, last_id=None):
self.lines = []
req = urllib.request.Request(base + "/api/events")
if last_id is not None:
req.add_header("Last-Event-ID", str(last_id))
self.r = urllib.request.urlopen(req, timeout=60)
threading.Thread(target=self.pump, daemon=True).start()
def pump(self):
try:
for raw in self.r:
self.lines.append(raw.decode())
except Exception:
pass
def events(self, name):
out = []
for i, l in enumerate(self.lines):
if l.strip() == "event: " + name and i + 1 < len(self.lines):
out.append(json.loads(self.lines[i + 1][6:]))
return out
def ids(self):
return [int(l[4:]) for l in self.lines if l.startswith("id: ")]
def close(self):
try:
self.r.close()
except Exception:
pass
def wait(fn, t=15):
end = time.time() + t
while time.time() < end:
v = fn()
if v:
return v
time.sleep(0.1)
return None
def post(base, path, obj):
req = urllib.request.Request(base + path, data=json.dumps(obj).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
api = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=api.serve_forever, daemon=True).start()
ws = tempfile.mkdtemp(prefix="cst-ask-")
log = open(ws + "/server.log", "w+")
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new",
"--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
"--protocol", "native-eos", "--approval", "auto"],
stdout=log, stderr=subprocess.STDOUT)
try:
def port():
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return l.split(":")[3].split("/")[0]
pt = wait(port)
base = "http://127.0.0.1:%s" % pt
a = Sse(base)
time.sleep(0.3)
check(post(base, "/api/turn", {"text": "ask me"}) == 202, "turn accepted")
ask = wait(lambda: a.events("ask_user"))
check(bool(ask), "ask_user event raised")
time.sleep(1.5)
check(len(REQS) == 1, "the turn waits for the answer (requests so far: %d)" % len(REQS))
# a page opened now must still see the open question
b = Sse(base)
check(bool(wait(lambda: b.events("ask_user"), 5)), "a late page gets the open question")
# reconnect with Last-Event-ID: nothing before that id is resent
last = a.ids()[-1]
c = Sse(base, last_id=last - 1)
got = wait(lambda: c.ids(), 5)
check(bool(got) and got[0] == last, "Last-Event-ID resumes after that id (%s, want %d)" % (got[:1] if got else got, last))
if ask:
check(post(base, "/api/ask_user", {"id": ask[0]["id"], "text": "blue"}) == 200, "answer accepted")
end = wait(lambda: a.events("turn_end"), 20)
check(bool(end) and end[0].get("rc") == 0, "turn ended rc=0")
tool_msgs = [m for m in (REQS[1]["messages"] if len(REQS) > 1 else []) if m.get("role") == "tool"]
check(bool(tool_msgs) and "blue" in json.dumps(tool_msgs), "the answer reached the model: %s" % json.dumps(tool_msgs)[:200])
for s in (a, b, c):
s.close()
finally:
p.terminate()
try:
p.wait(20)
except subprocess.TimeoutExpired:
p.kill()
check(False, "server hung on SIGTERM")
check(p.returncode is not None and p.returncode < 128 and p.returncode >= 0, "clean exit (%s)" % p.returncode)
log.close()
shutil.rmtree(ws, ignore_errors=True)
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+227
View File
@@ -0,0 +1,227 @@
"""WebUI /init, /skill, extensions and the thinking switch against a fake API.
python3 scripts/e2e_webui_ext.py (CST_AGENT= picks the binary)
Checks: show_thinking is validated, lands in the state and in settings.json;
/api/skill inlines the skill and 404s an unknown one; /api/init sends the
AGENTS.md prompt; /api/extensions lists skills and the built-in roles;
trust and reload work and write settings.json; the changing calls are a 409
while a turn runs, the listing is not. HOME points at a temp dir.
"""
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
SEEN = [] # the full last user message of every request
class Api(BaseHTTPRequestHandler):
"""Answers "好的"; a prompt with "slow" takes 2 s."""
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).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):
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
users = [m.get("content") for m in req.get("messages", []) if m.get("role") == "user"]
last = users[-1] if users and isinstance(users[-1], str) else ""
SEEN.append(last)
if "slow" in last:
time.sleep(2)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
for o in ({"delta": {"role": "assistant", "content": "好的"}},
{"delta": {}, "finish_reason": "stop"}):
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash",
"choices": [dict(index=0, **o)]}
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
def wait(fn, t=15):
end = time.time() + t
while time.time() < end:
v = fn()
if v:
return v
time.sleep(0.1)
return None
def call(base, path, obj=None, method=None):
data = None if obj is None else json.dumps(obj).encode()
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read() or b"{}")
except ValueError:
return e.code, {}
def skill(ws, name, desc, body):
d = os.path.join(ws, ".agent", "skills", name)
os.makedirs(d, exist_ok=True)
with open(os.path.join(d, "SKILL.md"), "w", encoding="utf-8") as f:
f.write("---\nname: %s\ndescription: %s\n---\n\n%s\n" % (name, desc, body))
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
threading.Thread(target=api.serve_forever, daemon=True).start()
ws = tempfile.mkdtemp(prefix="cst-ext-")
home = tempfile.mkdtemp(prefix="cst-home-")
skill(ws, "demo", "演示用的 skill", "DEMO-INSTRUCTIONS: 先说你好。")
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
env.pop(k, None)
log = open(ws + "/server.log", "w+")
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
"--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
"--protocol", "native-eos", "--approval", "auto"],
stdout=log, stderr=subprocess.STDOUT, env=env)
def port():
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return l.split(":")[3].split("/")[0]
def settings():
try:
with open(home + "/.config/cst-agent/settings.json", encoding="utf-8") as f:
return json.load(f)
except OSError:
return {}
def busy(base):
return call(base, "/api/state")[1].get("status_busy")
def turn(base, path, body):
code, d = call(base, path, body)
if code == 202:
time.sleep(0.3)
wait(lambda: not busy(base), 20)
return code, d
try:
base = "http://127.0.0.1:%s" % wait(port)
# 1. the thinking switch
st = call(base, "/api/state")[1]
check(st.get("show_thinking") is False, "show_thinking starts off")
check(call(base, "/api/config", {"show_thinking": "yes"})[0] == 400, "show_thinking must be a bool")
code, _ = call(base, "/api/config", {"show_thinking": True})
check(code == 200 and call(base, "/api/state")[1].get("show_thinking") is True, "show_thinking on")
check(settings().get("show_thinking") is True, "show_thinking is saved in settings.json")
call(base, "/api/config", {"show_thinking": False})
check(settings() and not settings().get("show_thinking"), "and cleared again (%s)" % settings())
# 2. the extensions listing
code, ex = call(base, "/api/extensions")
check(code == 200 and ex.get("enabled") is True and ex.get("trusted") is False,
"extensions: enabled, not trusted (%s)" % code)
names = [s["name"] for s in ex.get("skills", [])]
check(names == ["demo"] and ex["skills"][0].get("desc") == "演示用的 skill"
and ex["skills"][0].get("source") == "workspace", "the workspace skill is listed (%s)" % ex.get("skills"))
roles = [r["name"] for r in ex.get("roles", [])]
check("explore" in roles and all(r.get("source") for r in ex["roles"]), "built-in roles are listed (%s)" % roles)
check(ex.get("mcp") == [] and ex.get("plugins") == [] and ex.get("runs") == [], "no MCP, plugins or runs")
check(ex.get("task_tool") is True and "1 skill" in ex.get("report", ""), "task tool on, report %r" % ex.get("report"))
# 3. /skill
code, d = turn(base, "/api/skill", {"name": "demo", "task": "做点事"})
check(code == 202 and "DEMO-INSTRUCTIONS" in d.get("text", "") and d["text"].endswith("做点事"),
"skill turn accepted (%s)" % code)
check(SEEN and "DEMO-INSTRUCTIONS" in SEEN[-1] and "做点事" in SEEN[-1], "the model saw the skill and the task")
code, d = turn(base, "/api/skill", {"name": "demo"})
check(code == 202 and SEEN[-1].endswith("Apply this skill now."), "a skill without a task")
check(call(base, "/api/skill", {"name": "nope"})[0] == 404, "unknown skill: 404")
check(call(base, "/api/skill", {"name": "../demo"})[0] == 404, "skill ../demo: 404")
check(call(base, "/api/skill", {})[0] == 400, "skill without a name: 400")
check(call(base, "/api/skill", {"name": "x" * 100})[0] == 400, "a long skill name: 400")
check(call(base, "/api/skill", {"name": "demo", "task": 5})[0] == 400, "a non-string task: 400")
# 4. /init
code, d = turn(base, "/api/init", {})
check(code == 202 and "AGENTS.md" in d.get("text", ""), "init accepted (%s)" % code)
check("AGENTS.md" in SEEN[-1], "the model saw the AGENTS.md prompt")
# 5. trust and reload
code, d = call(base, "/api/extensions/trust", {"on": True})
check(code == 200 and d.get("trusted") is True, "trust the workspace (%s %s)" % (code, d))
tw = settings().get("trusted_workspaces", [])
check(any(os.path.realpath(ws) == os.path.realpath(p) for p in tw), "settings.json lists it (%s)" % tw)
check(call(base, "/api/extensions")[1].get("trusted") is True, "the listing says trusted")
code, d = call(base, "/api/extensions/trust", {"on": False})
check(code == 200 and d.get("trusted") is False and not settings().get("trusted_workspaces"),
"untrust (%s)" % settings().get("trusted_workspaces"))
check(call(base, "/api/extensions/trust", {"on": "yes"})[0] == 400, "trust needs a bool")
skill(ws, "zeta", "第二个", "ZETA")
code, d = call(base, "/api/extensions/reload", {})
check(code == 200 and "2 skills" in d.get("report", ""), "reload sees the new skill (%s)" % d)
check([s["name"] for s in call(base, "/api/extensions")[1]["skills"]] == ["demo", "zeta"], "both listed")
# 6. while a turn runs
call(base, "/api/turn", {"text": "slow 慢一点"})
time.sleep(0.4)
check(call(base, "/api/extensions/reload", {})[0] == 409, "reload during a turn: 409")
check(call(base, "/api/extensions/trust", {"on": True})[0] == 409, "trust during a turn: 409")
check(call(base, "/api/skill", {"name": "demo"})[0] == 409, "skill during a turn: 409")
check(call(base, "/api/init", {})[0] == 409, "init during a turn: 409")
check(call(base, "/api/extensions")[0] == 200, "the listing works during a turn")
check(call(base, "/api/config", {"show_thinking": True})[0] == 200, "the thinking switch works during a turn")
wait(lambda: not busy(base), 20)
check(not settings().get("trusted_workspaces"), "the refused trust changed nothing")
# 7. another workspace brings its own skills
ws2 = tempfile.mkdtemp(prefix="cst-ext2-")
skill(ws2, "other", "另一个工作区的", "OTHER")
code, _ = call(base, "/api/workspace", {"path": ws2})
ex = call(base, "/api/extensions")[1]
check(code == 200 and [s["name"] for s in ex.get("skills", [])] == ["other"]
and ex.get("report") == "1 skill", "a workspace switch reloads the extensions (%s)" % ex.get("report"))
code, d = turn(base, "/api/skill", {"name": "other"})
check(code == 202 and "OTHER" in SEEN[-1], "and its skill runs")
shutil.rmtree(ws2, ignore_errors=True)
finally:
proc.terminate()
try:
proc.wait(20)
except subprocess.TimeoutExpired:
proc.kill()
check(False, "server hung on SIGTERM")
log.close()
shutil.rmtree(ws, ignore_errors=True)
shutil.rmtree(home, ignore_errors=True)
api.shutdown()
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+234
View File
@@ -0,0 +1,234 @@
"""WebUI HTTP hardening checks over raw sockets, against a scripted model.
python3 scripts/e2e_webui_http.py (CST_AGENT= picks the binary)
Cross-site requests, request parsing (Content-Length, NUL, long lines,
keep-alive), auth before the body, changes refused while a turn runs,
cancel waking a pending question, token mode and shutdown.
"""
import json, os, re, shutil, socket, subprocess, sys, tempfile, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
REQS = []
class Api(BaseHTTPRequestHandler):
"""First call: ask_user. Later calls: a plain answer."""
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).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))
REQS.append(json.loads(self.rfile.read(n)))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
def ev(o):
self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n")
self.wfile.flush()
if len(REQS) == 1:
args = json.dumps({"question": "Pick?", "options": "a\nb"})
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [
{"index": 0, "id": "call_1", "type": "function",
"function": {"name": "ask_user", "arguments": args}}]}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]})
else:
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
def raw(port, data, timeout=5, read_all=True):
"""Send bytes, return everything read until EOF/timeout."""
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
s.sendall(data)
out = b""
try:
while True:
b = s.recv(65536)
if not b:
break
out += b
if not read_all:
break
except socket.timeout:
pass
s.close()
return out.decode("latin1")
def status(resp):
# a pipelined response starts right after the previous body, not on a new line
return [int(m) for m in re.findall(r"HTTP/1\.1 (\d{3}) ", resp)]
def req(method, path, headers=None, body=b"", host=None):
h = {"Host": host or "127.0.0.1:%d" % PORT, "Connection": "close"}
h.update(headers or {})
if body or method == "POST":
h["Content-Length"] = str(len(body))
head = "%s %s HTTP/1.1\r\n" % (method, path) + "".join("%s: %s\r\n" % kv for kv in h.items()) + "\r\n"
return head.encode() + body
def start(ws, *extra):
log = open(os.path.join(ws, "server.log"), "w+")
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new",
"--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
"--protocol", "native-eos", "--approval", "auto", *extra],
stdout=log, stderr=subprocess.STDOUT)
end = time.time() + 10
while time.time() < end:
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return p, int(l.split(":")[3].split("/")[0]), log
time.sleep(0.1)
raise SystemExit("server did not start")
def stop(p, name):
p.terminate()
try:
p.wait(20)
except subprocess.TimeoutExpired:
p.kill()
check(False, name + " hung on SIGTERM")
return
check(0 <= p.returncode < 128, "%s exits cleanly (%s)" % (name, p.returncode))
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
threading.Thread(target=api.serve_forever, daemon=True).start()
root = tempfile.mkdtemp(prefix="cst-http-")
try:
ws = os.path.join(root, "a")
os.makedirs(ws)
p, PORT, log = start(ws)
me = "http://127.0.0.1:%d" % PORT
cfg = b'{"approval":"ask"}'
# 1. cross-site requests
r = raw(PORT, req("POST", "/api/config", {"Origin": "http://evil.example", "Content-Type": "text/plain"}, cfg))
check(status(r)[:1] == [401], "a cross-site POST is refused (%s)" % status(r))
r = raw(PORT, req("POST", "/api/config", {"Origin": "null"}, cfg))
check(status(r)[:1] == [401], "Origin: null is refused")
r = raw(PORT, req("POST", "/api/config", {"Origin": me, "Content-Type": "application/json"}, b'{"approval":"auto"}'))
check(status(r)[:1] == [200], "our own page may POST (%s)" % status(r))
r = raw(PORT, req("POST", "/api/config", {}, b'{"approval":"auto"}'))
check(status(r)[:1] == [200], "a client without Origin (curl) may POST")
r = raw(PORT, req("GET", "/api/attach/img-0000.png", host="evil.example"))
check(status(r)[:1] == [401], "session images need the Host check too (%s)" % status(r))
r = raw(PORT, req("GET", "/app.js"))
check(status(r)[:1] == [200], "assets are served")
# 2. request parsing
r = raw(PORT, req("GET", "/api/state%00x"))
check(status(r)[:1] == [400], "an encoded NUL in the path is a 400 (%s)" % status(r))
smuggle = b"GET /api/sessions HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: abc\r\n\r\n" % PORT).encode() + smuggle)
check(status(r) == [400], "Content-Length: abc is a 400, nothing smuggled (%s)" % status(r))
r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 0\r\nContent-Length: %d\r\n\r\n"
% (PORT, len(smuggle))).encode() + smuggle)
check(status(r) == [400], "two different Content-Lengths are a 400 (%s)" % status(r))
r = raw(PORT, ("GET /%s HTTP/1.1\r\nHost: x\r\n\r\n" % ("a" * 9000)).encode())
check(status(r)[:1] == [400], "an over-long request line is a 400 (%s)" % status(r))
r = raw(PORT, req("GET", "/api/state?x=http://a/b"))
check(status(r)[:1] == [200], "'://' in the query leaves the path alone (%s)" % status(r))
r = raw(PORT, req("GET", "/api/state", {"Connection": "Close"}), timeout=3)
check("Connection: close" in r, "Connection: Close (any case) closes")
ka = ("GET /api/state HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode()
big = ("POST /api/turn HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 5000000\r\n\r\n" % PORT).encode()
r = raw(PORT, ka + big)
check(status(r) == [200, 413], "a 413 after a keep-alive request still arrives (%s)" % status(r))
# 3. a pending question: changes are refused, approval may change, cancel wakes it
events = []
def sse():
s = socket.create_connection(("127.0.0.1", PORT))
s.sendall(("GET /api/events HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode())
buf = b""
s.settimeout(60)
try:
while True:
b = s.recv(65536)
if not b:
break
buf += b
events[:] = [l[7:] for l in buf.decode("latin1").split("\n") if l.startswith("event: ")]
except Exception:
pass
threading.Thread(target=sse, daemon=True).start()
time.sleep(0.4)
r = raw(PORT, req("POST", "/api/turn", {"Content-Type": "application/json"}, b'{"text":"go"}'))
check(status(r)[:1] == [202], "turn accepted")
end = time.time() + 15
while time.time() < end and "ask_user" not in events:
time.sleep(0.1)
check("ask_user" in events, "the question is open")
for path, body in (("/api/sessions/new", b""), ("/api/workspace", json.dumps({"path": root}).encode()),
("/api/compact", b""), ("/api/undo", b""), ("/api/config", b'{"model":"x"}')):
r = raw(PORT, req("POST", path, {"Content-Type": "application/json"}, body))
check(status(r)[:1] == [409], "%s is refused mid-turn (%s)" % (path, status(r)))
r = raw(PORT, req("POST", "/api/config", {"Content-Type": "application/json"}, b'{"approval":"yolo"}'))
check(status(r)[:1] == [200], "approval may change mid-turn")
r = raw(PORT, req("POST", "/api/cancel", {"Content-Type": "application/json"}, b"{}"))
end = time.time() + 10
while time.time() < end and "turn_end" not in events:
time.sleep(0.1)
check("turn_end" in events, "cancel ends a turn parked on a question")
r = raw(PORT, req("POST", "/api/sessions/new", {"Content-Type": "application/json"}))
check(status(r)[:1] == [200], "after the turn, a new session is fine (%s)" % status(r))
stop(p, "the server")
# 4. token mode: the page loads, the API and images do not without it
ws2 = os.path.join(root, "b")
os.makedirs(ws2)
p, PORT, log = start(ws2, "--web-token", "tok-x")
r = raw(PORT, req("GET", "/app.js"))
check(status(r)[:1] == [200], "token mode: app.js loads without the token (%s)" % status(r))
r = raw(PORT, req("GET", "/api/state"))
check(status(r)[:1] == [401], "token mode: the API needs it")
r = raw(PORT, req("GET", "/api/attach/img-0000.png"))
check(status(r)[:1] == [401], "token mode: images need it")
r = raw(PORT, req("GET", "/api/state", {"Cookie": "cst_token=tok-x"}))
check(status(r)[:1] == [200], "token mode: the cookie works")
t0 = time.time()
r = raw(PORT, ("POST /api/attach HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 33554431\r\n"
"Expect: 100-continue\r\n\r\n" % PORT).encode(), timeout=5)
check(status(r) == [401] and time.time() - t0 < 3,
"no token, no body read: 401 at once (%s, %.1fs)" % (status(r), time.time() - t0))
stop(p, "the token server")
# 5. option checks
rc = subprocess.run([BIN, "--web", "--web-port", "70000", "--workspace", ws2],
capture_output=True, timeout=10).returncode
check(rc != 0, "--web-port 70000 is refused")
rc = subprocess.run([BIN, "--web", "--web-token", "x" * 80, "--web-port", "0", "--workspace", ws2,
"--api-key", "k"], capture_output=True, timeout=10).returncode
check(rc != 0, "an over-long --web-token is refused")
finally:
shutil.rmtree(root, ignore_errors=True)
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+327
View File
@@ -0,0 +1,327 @@
"""WebUI session management against a fake OpenAI-compatible API.
python3 scripts/e2e_webui_sessions.py (CST_AGENT= picks the binary)
Checks: retry runs the last prompt again and edit-and-resend replaces it
(the model sees the new text, the session keeps one copy); retry is a 409
while a turn runs and when there is nothing to retry; export returns the
transcript as Markdown; rename, clear and delete work on the store, and
deleting the open session moves to a new one; history messages carry the
note / tool_result flags. HOME points at a temp dir.
"""
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
SEEN = []
def last_prompt(msgs):
"""The prompts are one line; a note before one shares its user message."""
for m in reversed(msgs):
c = m.get("content")
if m.get("role") == "user" and isinstance(c, str):
return c.rstrip("\n").split("\n")[-1]
return ""
class Api(BaseHTTPRequestHandler):
"""Answers "回答:<last prompt>"; a prompt with "slow" takes 2 s."""
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).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):
req = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
msgs = req.get("messages", [])
p = last_prompt(msgs)
tail = msgs[-1] if msgs else {}
tc = tail.get("content") if isinstance(tail.get("content"), str) else ""
answered = tail.get("role") == "tool" or tc.startswith("result ")
if not answered:
SEEN.append(p)
if "slow" in p:
time.sleep(2)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
if answered:
chunks = ({"delta": {"role": "assistant", "content": "读完了"}},
{"delta": {}, "finish_reason": "stop"})
elif p.startswith("native"):
args = json.dumps({"path": "hello.txt"})
chunks = ({"delta": {"role": "assistant", "tool_calls": [
{"index": 0, "id": "c1", "type": "function",
"function": {"name": "read_file", "arguments": args}}]}},
{"delta": {}, "finish_reason": "tool_calls"})
elif p.startswith("ctl"):
chunks = ({"delta": {"role": "assistant",
"content": "我来读。\ncall t1 read_file\narg path 9\nhello.txtend\n"}},
{"delta": {}, "finish_reason": "stop"})
else:
chunks = ({"delta": {"role": "assistant", "content": "回答:" + p}},
{"delta": {}, "finish_reason": "stop"})
for o in chunks:
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash",
"choices": [dict(index=0, **o)]}
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
self.wfile.flush()
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
def wait(fn, t=15):
end = time.time() + t
while time.time() < end:
v = fn()
if v:
return v
time.sleep(0.1)
return None
def call(base, path, obj=None, method=None, raw_reply=False):
data = None if obj is None else json.dumps(obj).encode()
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
raw = r.read()
if raw_reply:
return r.status, r.headers.get("Content-Type", ""), raw
return r.status, json.loads(raw or b"{}")
except urllib.error.HTTPError as e:
raw = e.read()
if raw_reply:
return e.code, e.headers.get("Content-Type", ""), raw
try:
return e.code, json.loads(raw or b"{}")
except ValueError:
return e.code, {}
class Sse:
def __init__(self, base):
self.lines = []
self.r = urllib.request.urlopen(base + "/api/events", timeout=60)
threading.Thread(target=self.pump, daemon=True).start()
def pump(self):
try:
for raw in self.r:
self.lines.append(raw)
except Exception:
pass
def count(self, name):
return sum(1 for l in self.lines if l.strip() == b"event: " + name.encode())
def close(self):
try:
self.r.close()
except Exception:
pass
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
threading.Thread(target=api.serve_forever, daemon=True).start()
ws = tempfile.mkdtemp(prefix="cst-sess-")
home = tempfile.mkdtemp(prefix="cst-home-")
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
env.pop(k, None)
log = open(ws + "/server.log", "w+")
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
"--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
"--protocol", "native-eos", "--approval", "auto"],
stdout=log, stderr=subprocess.STDOUT, env=env)
def port():
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return l.split(":")[3].split("/")[0]
def messages(base):
return call(base, "/api/session")[1].get("messages", [])
def prompts(base):
return [m["content"] for m in messages(base)
if m["role"] == "user" and not m.get("note") and not m.get("tool_result")]
def answers(base):
return [m["content"] for m in messages(base) if m["role"] == "assistant"]
def turn(base, sse, path, body, want=202):
n = sse.count("turn_end")
code, d = call(base, path, body)
if code == want == 202:
wait(lambda: sse.count("turn_end") > n, 20)
return code, d
try:
base = "http://127.0.0.1:%s" % wait(port)
sse = Sse(base)
time.sleep(0.3)
# 1. retry with nothing to retry, then a first turn
code, d = call(base, "/api/retry", {})
check(code == 409 and "nothing to retry" in d.get("error", ""), "retry on an empty session: 409 (%s)" % code)
turn(base, sse, "/api/turn", {"text": "第一句"})
check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"], "first turn stored")
# 2. retry: same prompt again, one copy in the session
code, d = turn(base, sse, "/api/retry", {})
check(code == 202 and d.get("text") == "第一句", "retry accepted (%s %s)" % (code, d))
check(SEEN[-1] == "第一句", "the model saw the same prompt again")
check(prompts(base) == ["第一句"] and answers(base) == ["回答:第一句"],
"after retry: one prompt, one answer (%s)" % prompts(base))
# 3. edit and resend: the new text replaces the old
code, d = turn(base, sse, "/api/retry", {"text": "改过的问题"})
check(code == 202 and d.get("text") == "改过的问题", "edit-and-resend accepted")
check(SEEN[-1] == "改过的问题", "the model saw the edited prompt")
check(prompts(base) == ["改过的问题"] and answers(base) == ["回答:改过的问题"],
"after edit: %s / %s" % (prompts(base), answers(base)))
check(call(base, "/api/retry", {"text": ""})[0] == 400, "an empty edit is a 400")
check(call(base, "/api/retry", {"text": 5})[0] == 400, "a non-string edit is a 400")
# 4. a second turn, then retry only touches the last one
turn(base, sse, "/api/turn", {"text": "第二句"})
turn(base, sse, "/api/retry", {})
check(prompts(base) == ["改过的问题", "第二句"] and len(answers(base)) == 2,
"retry keeps earlier turns: %s" % prompts(base))
# 5. retry while a turn runs is a 409
n = sse.count("turn_end")
call(base, "/api/turn", {"text": "slow 请慢一点"})
code, d = call(base, "/api/retry", {})
check(code == 409, "retry during a turn: 409 (%s)" % code)
wait(lambda: sse.count("turn_end") > n, 20)
# 6. a note (plan mode changed mid-session) is flagged, and retry skips it
call(base, "/api/config", {"plan_mode": True})
turn(base, sse, "/api/turn", {"text": "计划一下"})
ms = messages(base)
notes = [m for m in ms if m.get("note")]
check(len(notes) >= 1 and all(m["role"] == "user" for m in notes),
"a mid-session note is flagged note:true (%d)" % len(notes))
turn(base, sse, "/api/retry", {})
check(SEEN[-1] == "计划一下" and prompts(base)[-1] == "计划一下", "retry after a note reruns the prompt")
call(base, "/api/config", {"plan_mode": False})
# 7. export
code, ctype, raw = call(base, "/api/export", raw_reply=True)
md = raw.decode("utf-8", "replace")
check(code == 200 and ctype.startswith("text/markdown"), "export: %s %s" % (code, ctype))
check(md.startswith("# ") and "## User" in md and "改过的问题" in md and "## Assistant" in md
and "回答:第二句" in md and "第一句" not in md, "export has the transcript")
# 8. rename and clear
st = call(base, "/api/state")[1]
cur = st.get("session")
code, _ = call(base, "/api/sessions/rename", {"title": "我的会话"})
check(code == 200, "rename the open session")
ss = call(base, "/api/sessions")[1]
check(any(s["id"] == cur and s["title"] == "我的会话" for s in ss.get("sessions", [])),
"the list shows the new title")
code, _, raw = call(base, "/api/export", raw_reply=True)
check(raw.decode("utf-8", "replace").startswith("# 我的会话"), "export uses the title")
long = "长" * 100
call(base, "/api/sessions/rename", {"id": cur, "title": long})
t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0]
check(0 < len(t.encode()) <= 120 and t == "长" * len(t), "a long title is cut on a character (%d bytes)"
% len(t.encode()))
call(base, "/api/sessions/rename", {"title": ""})
t = [s["title"] for s in call(base, "/api/sessions")[1]["sessions"] if s["id"] == cur][0]
check(t == "改过的问题", "an empty title falls back to the first prompt (%r)" % t)
check(call(base, "/api/sessions/rename", {"id": "nope", "title": "x"})[0] == 404, "rename unknown: 404")
check(call(base, "/api/sessions/rename", {"id": "../x", "title": "x"})[0] == 404, "rename ../x: 404")
check(call(base, "/api/sessions/rename", {})[0] == 400, "rename without a title: 400")
# 9. delete another session, then the open one
code, d = call(base, "/api/sessions/new", {})
second = d.get("id")
check(code == 200 and second and second != cur, "a second session")
code, d = call(base, "/api/sessions/delete", {"id": cur})
check(code == 200 and d.get("current") == second, "delete a closed session (%s %s)" % (code, d))
ids = [s["id"] for s in call(base, "/api/sessions")[1]["sessions"]]
check(cur not in ids and not os.path.exists(os.path.join(ws, ".agent", "sessions", cur + ".jsonl")),
"it is gone from the list and the disk")
n = sse.count("session")
code, d = call(base, "/api/sessions/delete", {"id": second})
check(code == 200 and d.get("current") and d["current"] != second, "delete the open session (%s)" % d)
check(call(base, "/api/state")[1].get("session") == d.get("current"), "the state moved to the new one")
check(wait(lambda: sse.count("session") > n, 5), "a session event was sent")
check(call(base, "/api/sessions/delete", {"id": "nope"})[0] == 404, "delete unknown: 404")
check(call(base, "/api/sessions/delete", {})[0] == 400, "delete without an id: 400")
# 10. history: calls and results come back apart, for native and ctl
with open(os.path.join(ws, "hello.txt"), "w", encoding="utf-8") as f:
f.write("你好世界\n")
turn(base, sse, "/api/turn", {"text": "native 读文件"})
ms = messages(base)
a = [m for m in ms if m["role"] == "assistant" and m.get("calls")]
r = [m for m in ms if m.get("tool_result")]
check(len(a) == 1 and a[0]["calls"][0].get("name") == "read_file"
and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt" and a[0]["calls"][0].get("id") == "c1",
"native: the call is listed with its args (%s)" % (a[0].get("calls") if a else ms))
check(len(r) == 1 and r[0]["role"] == "tool" and r[0].get("tool_call_id") == "c1"
and "你好世界" in r[0].get("out", "") and not r[0]["out"].startswith("[tool ")
and r[0].get("exit") == 0 and r[0].get("truncated") is False,
"native: the result carries out/exit without the header (%s)" % r)
check(answers(base)[-1] == "读完了", "native: the turn finished")
check(call(base, "/api/settings", {"protocol": "ctl"})[0] == 200, "switch to the ctl protocol")
turn(base, sse, "/api/turn", {"text": "ctl 读文件"})
ms = messages(base)
a = [m for m in ms if m["role"] == "assistant" and m.get("calls") and m["calls"][0].get("id") == "t1"]
r = [m for m in ms if m.get("tool_result") and m.get("tool_call_id") == "t1"]
check(len(a) == 1 and a[0].get("text", "").strip() == "我来读。" and "call t1" in a[0]["content"]
and a[0]["calls"][0].get("args", {}).get("path") == "hello.txt"
and "call t1" not in a[0].get("html", ""),
"ctl: prose and calls are split (%s)" % (a[0] if a else ms[-4:]))
check(len(r) == 1 and r[0]["role"] == "user" and "你好世界" in r[0].get("out", "")
and not r[0]["out"].startswith("result ") and not r[0]["out"].endswith("end")
and r[0].get("exit") == 0,
"ctl: the result is flagged with out/exit (%s)" % r)
check(r and r[0]["content"] not in prompts(base), "ctl: the result is not a prompt")
call(base, "/api/settings", {"protocol": "native-eos"})
sse.close()
finally:
proc.terminate()
try:
proc.wait(20)
except subprocess.TimeoutExpired:
proc.kill()
check(False, "server hung on SIGTERM")
log.close()
shutil.rmtree(ws, ignore_errors=True)
shutil.rmtree(home, ignore_errors=True)
api.shutdown()
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+313
View File
@@ -0,0 +1,313 @@
"""WebUI settings and streaming against fake OpenAI-compatible APIs.
python3 scripts/e2e_webui_settings.py (CST_AGENT= picks the binary)
Checks: Chinese reasoning streams as whole characters in few events; the
provider list, the model list with capabilities, config validation and
persistence; a wrong key is rejected, a good one switches the provider
(requests then go to the new base URL) and is saved; and all of it
survives a restart. HOME and XDG_CONFIG_HOME point at a temp dir, so no
real credentials or settings are read or written.
"""
import json, os, shutil, stat, subprocess, sys, tempfile, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
THINK = ["好的,我先看看工作区里", "有哪些文件,然后用中文", "回答这个问题。😀 结束"]
ANSWER = "这是中文回答。"
GOOD_KEYS = {"sk-good", "sk-good2"}
B_REQS = []
def sse_reply(h, model, think, answer):
h.send_response(200)
h.send_header("Content-Type", "text/event-stream")
h.end_headers()
base = {"id": "x", "object": "chat.completion.chunk", "model": model}
def ev(o):
h.wfile.write(b"data: " + json.dumps({**base, **o}, ensure_ascii=False).encode() + b"\n\n")
h.wfile.flush()
for part in think:
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "reasoning_content": part}}]})
ev({"choices": [{"index": 0, "delta": {"content": answer}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
h.wfile.write(b"data: [DONE]\n\n")
h.wfile.flush()
def send_json(h, code, obj):
body = json.dumps(obj).encode()
h.send_response(code)
h.send_header("Content-Type", "application/json")
h.send_header("Content-Length", str(len(body)))
h.end_headers()
h.wfile.write(body)
class A(BaseHTTPRequestHandler):
"""The starting provider: any key, one model, Chinese reasoning."""
def log_message(self, *a):
pass
def do_GET(self):
send_json(self, 200, {"data": [{"id": "deepseek-flash"}]})
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0)))
sse_reply(self, "deepseek-flash", THINK, ANSWER)
class B(BaseHTTPRequestHandler):
"""The provider switched to: /models checks the key."""
def log_message(self, *a):
pass
def key_ok(self):
auth = self.headers.get("Authorization", "")
return auth.startswith("Bearer ") and auth[7:] in GOOD_KEYS
def do_GET(self):
if not self.key_ok():
return send_json(self, 401, {"error": {"message": "invalid api key"}})
send_json(self, 200, {"data": [{"id": "fake-vis"}, {"id": "fake-text"}]})
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))) or b"{}")
B_REQS.append({"auth": self.headers.get("Authorization", ""), "model": body.get("model")})
if not self.key_ok():
return send_json(self, 401, {"error": {"message": "invalid api key"}})
sse_reply(self, body.get("model") or "fake-vis", [], "B 的回答")
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
class Sse:
def __init__(self, base):
self.lines = []
self.r = urllib.request.urlopen(base + "/api/events", timeout=60)
threading.Thread(target=self.pump, daemon=True).start()
def pump(self):
try:
for raw in self.r:
self.lines.append(raw)
except Exception:
pass
def events(self, name):
out = []
for i, l in enumerate(self.lines):
if l.strip() == b"event: " + name.encode() and i + 1 < len(self.lines):
out.append(self.lines[i + 1][6:])
return out
def close(self):
try:
self.r.close()
except Exception:
pass
def wait(fn, t=15):
end = time.time() + t
while time.time() < end:
v = fn()
if v:
return v
time.sleep(0.1)
return None
def call(base, path, obj=None, method=None):
data = None if obj is None else json.dumps(obj).encode()
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=20) as r:
raw = r.read()
return r.status, json.loads(raw or b"{}"), raw
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw or b"{}"), raw
except ValueError:
return e.code, {}, raw
def serve(handler):
s = ThreadingHTTPServer(("127.0.0.1", 0), handler)
threading.Thread(target=s.serve_forever, daemon=True).start()
return s
api_a, api_b = serve(A), serve(B)
ws = tempfile.mkdtemp(prefix="cst-set-")
home = tempfile.mkdtemp(prefix="cst-home-")
cfgdir = home + "/.config/cst-agent"
os.makedirs(cfgdir)
with open(cfgdir + "/models.json", "w") as f:
json.dump({"providers": {"fakeb": {"api_base": "http://127.0.0.1:%d/v1" % api_b.server_address[1],
"key_env": "FAKEB_API_KEY", "default_model": ""}},
"models": [{"match": "fake-vis", "vision": True, "context": 200000},
{"match": "fake-text", "vision": False}]}, f)
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache")
for k in ("DEEPSEEK_API_KEY", "FAKEB_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
env.pop(k, None)
procs = []
def start(args, tag):
log = open("%s/%s.log" % (ws, tag), "w+")
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws] + args,
stdout=log, stderr=subprocess.STDOUT, env=env)
procs.append((p, log))
def port():
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return l.split(":")[3].split("/")[0]
pt = wait(port)
return p, "http://127.0.0.1:%s" % pt
def stop(p):
p.terminate()
try:
p.wait(20)
except subprocess.TimeoutExpired:
p.kill()
check(False, "server hung on SIGTERM")
check(p.returncode is not None and 0 <= p.returncode < 128, "clean exit (%s)" % p.returncode)
def leaked(raw):
return any(k.encode() in raw for k in GOOD_KEYS | {"sk-bad", "sk-x"})
try:
p, base = start(["--new", "--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api_a.server_address[1],
"--protocol", "native-eos", "--approval", "auto"], "first")
a = Sse(base)
time.sleep(0.3)
# 1. Chinese reasoning: whole characters, a handful of events
code, _, _ = call(base, "/api/turn", {"text": "用中文想一想"})
check(code == 202, "turn accepted")
end = wait(lambda: a.events("turn_end"), 20)
check(bool(end) and json.loads(end[0]).get("rc") == 0, "turn ended rc=0")
raw = a.events("think_delta")
ok = True
for r in raw:
try:
r.decode("utf-8")
except UnicodeDecodeError:
ok = False
check(ok, "every think_delta frame is valid UTF-8")
text = "".join(json.loads(r.decode("utf-8", "replace"))["text"] for r in raw)
want = "".join(THINK)
check(text == want and "\ufffd" not in text, "the thinking text round trips: %r" % text[:60])
check(0 < len(raw) <= 2 * len(THINK) + 2,
"think_delta events: %d for %d bytes" % (len(raw), len(want.encode())))
ans = "".join(json.loads(r)["text"] for r in a.events("assistant_delta"))
check(ans == ANSWER, "the answer round trips: %r" % ans)
# 2. providers and models
code, d, rawp = call(base, "/api/providers")
names = {x["name"]: x for x in d.get("providers", [])}
check(code == 200 and "deepseek" in names and "fakeb" in names, "provider list: %s" % sorted(names))
check(names.get("deepseek", {}).get("current") and names["deepseek"].get("has_key")
and names["deepseek"].get("key_from") == "session", "deepseek: current, key for this run")
check(names.get("fakeb", {}).get("has_key") is False and names["fakeb"].get("source") == "user",
"fakeb: no key yet, from models.json")
check(not leaked(rawp), "no key in /api/providers")
code, d, _ = call(base, "/api/models")
ids = [m["id"] for m in d.get("models", [])]
first = (d.get("models") or [{}])[0]
check(code == 200 and "deepseek-flash" in ids and first.get("api") is True
and "context" in first and "label" in first, "model list: %s" % ids[:5])
# 3. config: validation and persistence
check(call(base, "/api/config", {"effort": "bogus"})[0] == 400, "a bad effort is a 400")
check(call(base, "/api/config", {"provider": "fakeb"})[0] == 400, "provider via /api/config is a 400")
check(call(base, "/api/config", {"model": ""})[0] == 400, "an empty model is a 400")
code, d, _ = call(base, "/api/config", {"model": "deepseek-v4-pro", "effort": "high"})
check(code == 200 and d.get("ok"), "model + effort accepted")
code, s, _ = call(base, "/api/state")
check(s.get("model") == "deepseek-v4-pro" and s.get("effort") == "high", "the state has them")
check(s.get("effort_applies") is True and s.get("key_set") is True and "model_info" in s
and s.get("perm_timeout", 0) > 0 and "turn_usage" in s, "the state has the new fields")
st = open(cfgdir + "/settings.json").read() if os.path.exists(cfgdir + "/settings.json") else ""
check("deepseek-v4-pro" in st and "high" in st, "settings.json remembers model and effort")
# 4. switching provider: no key, a wrong key, a good key
code, d, _ = call(base, "/api/provider", {"name": "fakeb"})
check(code == 400 and d.get("need_key"), "fakeb without a key: need_key (%s)" % code)
code, d, rawp = call(base, "/api/provider", {"name": "fakeb", "key": "sk-bad"})
check(code == 401 and "拒绝" in d.get("error", ""), "a wrong key is rejected (%s %s)" % (code, d.get("error")))
check(not leaked(rawp), "no key in the rejection")
check(call(base, "/api/state")[1].get("provider") == "deepseek", "still on deepseek")
check(call(base, "/api/provider", {"name": "nope"})[0] == 404, "an unknown provider is a 404")
code, d, rawp = call(base, "/api/provider", {"name": "fakeb", "key": "sk-good"})
check(code == 200 and d.get("provider") == "fakeb" and d.get("model") == "fake-vis",
"switched to fakeb, first listed model picked (%s %s)" % (code, d))
check(not leaked(rawp), "no key in the switch reply")
creds = cfgdir + "/credentials"
cr = open(creds).read() if os.path.exists(creds) else ""
check("fakeb.api_key=sk-good" in cr, "the key was saved to credentials")
if os.name == "posix" and os.path.exists(creds):
check(stat.S_IMODE(os.stat(creds).st_mode) == 0o600, "credentials is 0600")
code, d, _ = call(base, "/api/models")
ms = {m["id"]: m for m in d.get("models", [])}
check("fake-vis" in ms and ms["fake-vis"].get("vision") is True and ms["fake-vis"].get("context") == 200000
and ms.get("fake-text", {}).get("vision") is False, "fakeb models with capabilities")
code, d, _ = call(base, "/api/config", {"effort": "low"})
check(code == 200 and d.get("warning"), "effort on a model without it: saved with a warning")
# 5. a turn now goes to the new base URL with the new key
n0 = len(B_REQS)
code, _, _ = call(base, "/api/turn", {"text": "hi B"})
check(code == 202, "turn on fakeb accepted")
wait(lambda: len(a.events("turn_end")) >= 2, 20)
check(len(B_REQS) > n0 and B_REQS[-1]["auth"] == "Bearer sk-good" and B_REQS[-1]["model"] == "fake-vis",
"the request reached fakeb: %s" % (B_REQS[-1:] or "none"))
# 6. replacing the key
code, d, _ = call(base, "/api/key", {"key": "sk-bad"})
check(code == 401, "a wrong replacement key is rejected (%s)" % code)
code, d, _ = call(base, "/api/key", {"key": "sk-good2"})
check(code == 200 and d.get("ok"), "the replacement key is accepted")
check("fakeb.api_key=sk-good2" in open(creds).read(), "the replacement key was saved")
check(call(base, "/api/key", {})[0] == 400, "an empty key body is a 400")
a.close()
stop(p)
# 7. the next start remembers provider, model and key
p, base = start(["--new"], "second")
code, s, raws = call(base, "/api/state")
check(s.get("provider") == "fakeb" and s.get("model") == "fake-vis" and s.get("key_set") is True
and s.get("key_from") == "saved", "restart: %s/%s key %s" % (s.get("provider"), s.get("model"), s.get("key_from")))
check(not leaked(raws), "no key in /api/state")
stop(p)
logs = b"".join(open(lg.name, "rb").read() for _, lg in procs)
check(not leaked(logs.replace(b"sk-x", b"")) , "no key in the server logs")
finally:
for p, log in procs:
if p.poll() is None:
p.kill()
log.close()
shutil.rmtree(ws, ignore_errors=True)
shutil.rmtree(home, ignore_errors=True)
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+206
View File
@@ -0,0 +1,206 @@
"""WebUI workspace views: git diff, run settings, saved permission rules.
python3 scripts/e2e_webui_workspace.py (CST_AGENT= picks the binary)
Checks: /api/diff lists git status and the working-tree diff (per file too),
an option-looking path is only ever a path, and a directory without git
says so; /api/settings reports protocol, tools, confirmations and the
sandbox, rejects a bad body without applying any of it, applies a good one,
and is a 409 while a turn runs; /api/permissions shows the saved rules and
DELETE clears them. HOME points at a temp dir.
"""
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
class Api(BaseHTTPRequestHandler):
"""A slow plain answer, so a turn is running for a while."""
def log_message(self, *a):
pass
def do_POST(self):
self.rfile.read(int(self.headers.get("Content-Length", 0)))
time.sleep(2)
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
for o in ({"delta": {"role": "assistant", "content": "好"}}, {"delta": {}, "finish_reason": "stop"}):
chunk = {"id": "x", "object": "chat.completion.chunk", "model": "m", "choices": [dict(index=0, **o)]}
self.wfile.write(b"data: " + json.dumps(chunk, ensure_ascii=False).encode() + b"\n\n")
self.wfile.write(b"data: [DONE]\n\n")
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
def wait(fn, t=15):
end = time.time() + t
while time.time() < end:
v = fn()
if v:
return v
time.sleep(0.1)
return None
def call(base, path, obj=None, method=None):
data = None if obj is None else json.dumps(obj).encode()
req = urllib.request.Request(base + path, data=data, method=method or ("POST" if data else "GET"),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return r.status, json.loads(r.read() or b"{}")
except urllib.error.HTTPError as e:
raw = e.read()
try:
return e.code, json.loads(raw or b"{}")
except ValueError:
return e.code, {}
def git(*args):
return subprocess.run(["git", "-C", ws] + list(args), capture_output=True, text=True)
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
threading.Thread(target=api.serve_forever, daemon=True).start()
ws = tempfile.mkdtemp(prefix="cst-ws-")
plain = tempfile.mkdtemp(prefix="cst-nogit-")
home = tempfile.mkdtemp(prefix="cst-home-")
env = dict(os.environ, HOME=home, XDG_CONFIG_HOME=home + "/.config",
XDG_DATA_HOME=home + "/.local/share", XDG_CACHE_HOME=home + "/.cache",
GIT_CONFIG_NOSYSTEM="1")
for k in ("DEEPSEEK_API_KEY", "OPENAI_API_KEY", "CST_API_KEY"):
env.pop(k, None)
have_git = shutil.which("git") is not None
if have_git:
git("init", "-q")
git("config", "user.email", "e2e@example.com")
git("config", "user.name", "e2e")
open(ws + "/a.txt", "w").write("one\n")
git("add", "a.txt")
git("commit", "-q", "-m", "first")
open(ws + "/a.txt", "w").write("one\n第二行\n")
open(ws + "/new.txt", "w").write("new\n")
os.makedirs(ws + "/.agent", exist_ok=True)
json.dump({"tools": ["write_file"], "shell_prefixes": ["make"]}, open(ws + "/.agent/permissions.json", "w"))
log = open(home + "/server.log", "w+")
proc = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", "--api-key", "sk-x",
"--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
"--protocol", "native-eos", "--approval", "auto"],
stdout=log, stderr=subprocess.STDOUT, env=env)
def port():
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return l.split(":")[3].split("/")[0]
try:
base = "http://127.0.0.1:%s" % wait(port)
# 1. diff
if have_git:
code, d = call(base, "/api/diff")
files = {f["path"]: f["status"] for f in d.get("files", [])}
check(code == 200 and d.get("git") is True, "diff: git repo seen")
check(files.get("a.txt") == " M" and files.get("new.txt") == "??", "status lists both files: %s" % files)
check("+第二行" in d.get("diff", "") and "a.txt" in d.get("diff", ""), "the diff has the change")
code, d = call(base, "/api/diff?path=a.txt")
check(code == 200 and "+第二行" in d.get("diff", "") and d.get("path") == "a.txt", "diff of one file")
code, d = call(base, "/api/diff?path=new.txt")
check(code == 200 and d.get("diff") == "", "an untracked file has no diff")
evil = ws + "/pwned"
code, d = call(base, "/api/diff?path=" + urllib.request.quote("--output=" + evil))
check(code == 200 and not os.path.exists(evil), "an option-looking path stays a path")
else:
print("SKIP diff checks: no git")
# 2. settings: what is there
code, s = call(base, "/api/settings")
names = [t["name"] for t in s.get("tools", [])]
check(code == 200 and s.get("protocol") == "native-eos" and len(s.get("protocols", [])) == 5,
"settings: protocol %s of %s" % (s.get("protocol"), s.get("protocols")))
check("read_file" in names and all("perm" in t and "on" in t for t in s["tools"]), "tool list: %d tools" % len(names))
check(set(s.get("confirm", {})) == {"shell", "write", "net"}, "confirm flags present")
check(isinstance(s.get("sandbox", {}).get("kind"), str), "sandbox kind: %s" % s.get("sandbox"))
victim = next((t["name"] for t in s["tools"] if t["perm"] == "net"), names[-1])
# 3. a bad body changes nothing
for body, why in (({"protocol": "nope"}, "unknown protocol"), ({"max_steps": 0}, "max_steps 0"),
({"max_steps": "5"}, "max_steps as text"), ({"tool_profile": "huge"}, "unknown profile"),
({"tools": {"nope": False}}, "unknown tool"), ({"tools": {victim: "off"}}, "tool value not bool"),
({"confirm": {"disk": True}}, "unknown confirm key"),
({"max_steps": 9, "protocol": "nope"}, "mixed good and bad")):
check(call(base, "/api/settings", body)[0] == 400, "400 for %s" % why)
check(call(base, "/api/settings")[1].get("max_steps") == s.get("max_steps"), "nothing applied from a bad body")
# 4. a good body applies
code, d = call(base, "/api/settings", {"protocol": "ctl", "max_steps": 7, "tool_profile": "minimal",
"tools": {victim: False}, "confirm": {"shell": True, "net": False}})
check(code == 200 and d.get("ok"), "settings accepted (%s %s)" % (code, d))
code, s2 = call(base, "/api/settings")
t2 = {t["name"]: t for t in s2.get("tools", [])}
check(s2.get("protocol") == "ctl" and s2.get("max_steps") == 7 and s2.get("tool_profile") == "minimal",
"protocol/steps/profile applied")
check(t2.get(victim, {}).get("off") is True and t2[victim]["on"] is False, "%s switched off" % victim)
check(s2["confirm"]["shell"] is True and s2["confirm"]["net"] is False, "confirm flags applied")
st = call(base, "/api/state")[1]
check(st.get("protocol_tools") == "ctl" and st.get("max_steps") == 7 and st.get("tool_profile") == "minimal",
"the state shows them")
call(base, "/api/settings", {"protocol": "native-eos", "tool_profile": "default", "tools": {victim: True}})
t3 = {t["name"]: t for t in call(base, "/api/settings")[1]["tools"]}
check(t3[victim]["off"] is False and t3[victim]["on"] is True, "%s back on" % victim)
# 5. permissions
code, p = call(base, "/api/permissions")
check(code == 200 and p.get("tools") == ["write_file"] and p.get("shell_prefixes") == ["make"]
and "write_file" in p.get("rules", ""), "saved rules: %s %s" % (p.get("tools"), p.get("shell_prefixes")))
code, _ = call(base, "/api/permissions", method="DELETE")
check(code == 200, "DELETE /api/permissions")
p = call(base, "/api/permissions")[1]
check(p.get("tools") == [] and p.get("shell_prefixes") == [], "the rules are gone")
saved = json.load(open(ws + "/.agent/permissions.json"))
check(saved.get("tools") == [] and saved.get("shell_prefixes") == [], "and gone from the file")
# 6. while a turn runs: settings and the rule reset are 409, reads still work
code, _ = call(base, "/api/turn", {"text": "慢慢来"})
check(code == 202, "a slow turn started")
check(call(base, "/api/settings", {"max_steps": 3})[0] == 409, "settings during a turn: 409")
check(call(base, "/api/permissions", method="DELETE")[0] == 409, "rule reset during a turn: 409")
check(call(base, "/api/settings")[0] == 200 and call(base, "/api/permissions")[0] == 200,
"reads during a turn: 200")
if have_git:
check(call(base, "/api/diff")[0] == 200, "diff during a turn: 200")
wait(lambda: not call(base, "/api/state")[1].get("status_busy"), 20)
# 7. a directory without git
if have_git:
code, _ = call(base, "/api/workspace", {"path": plain})
code, d = call(base, "/api/diff")
check(code == 200 and d.get("git") is False and "diff" not in d, "no git repo: git=false")
finally:
proc.terminate()
try:
proc.wait(20)
except subprocess.TimeoutExpired:
proc.kill()
check(False, "server hung on SIGTERM")
log.close()
for d in (ws, plain, home):
shutil.rmtree(d, ignore_errors=True)
api.shutdown()
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+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))
+65
View File
@@ -0,0 +1,65 @@
"""TUI /provider and /key against a fake API whose /models checks the key.
nix-shell -p python3Packages.pyte --run "python3 scripts/tui_keys/provider.py"
A wrong key is asked for again, a good one is saved and switches the
provider; /key replaces it. Same code path as the WebUI (provider_switch.c).
"""
import json, os, sys, threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from harness import Term
from keys import check, base
GOOD = {"sk-good", "sk-good2"}
class B(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
auth = self.headers.get("Authorization", "")
ok = auth.startswith("Bearer ") and auth[7:] in GOOD
body = json.dumps({"data": [{"id": "fake-one"}]} if ok else {"error": {"message": "bad key"}}).encode()
self.send_response(200 if ok else 401)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
srv = ThreadingHTTPServer(("127.0.0.1", 0), B)
threading.Thread(target=srv.serve_forever, daemon=True).start()
t = Term(args=("-b", base, "-k", "sk-test"))
cfg = os.path.join(t.home, ".config", "cst-agent")
os.makedirs(cfg, exist_ok=True)
t.close()
with open(os.path.join(cfg, "models.json"), "w") as f:
json.dump({"providers": {"fakeb": {"api_base": "http://127.0.0.1:%d/v1" % srv.server_address[1],
"key_env": "FAKEB_API_KEY", "default_model": ""}}}, f)
t = Term(args=("-b", base, "-k", "sk-test"), home=t.home, env={"FAKEB_API_KEY": ""})
creds = os.path.join(cfg, "credentials")
def screen():
return "\n".join(t.lines())
t.send("/provider fakeb\r", 0.8)
check("a key prompt for fakeb", "API key for fakeb" in screen(), t)
t.send("sk-bad\r", 1.5)
check("a wrong key is asked for again", "rejected that key" in screen(), t)
check("the wrong key is not saved", not os.path.exists(creds) or "sk-bad" not in open(creds).read(), t)
t.send("sk-good\r", 2.0)
check("switched to fakeb", "provider → fakeb" in screen(), t)
check("the good key is saved", os.path.exists(creds) and "fakeb.api_key=sk-good" in open(creds).read(), t)
check("no key on screen", "sk-good" not in screen() and "sk-bad" not in screen(), t)
t.send("/key\r", 0.8)
t.send("sk-good2\r", 2.0)
check("/key replaces it", "fakeb.api_key=sk-good2" in open(creds).read(), t)
t.close()
from keys import FAILS
print("FAILED:", len(FAILS))
sys.exit(min(len(FAILS), 100))
+76 -20
View File
@@ -1282,13 +1282,22 @@ static void on_mm(const ctl_event_t *ev, void *ud) {
} }
static void feed_visible(turn_t *t, const char *data, size_t len) { static void feed_visible(turn_t *t, const char *data, size_t len) {
if (!len || t->stop) /* cut after each '\n' and '>': a call only becomes ready there, and
return; * raw_mark must not take in what follows it in the same chunk */
cst_buf_append(&t->raw, data, len); while (len && !t->stop) {
if (t->ctl) size_t n = 0;
ctl_parser_feed(t->ctl, data, len); while (n < len && data[n] != '\n' && data[n] != '>')
else if (t->mm) n++;
mm_scan_feed(t->mm, data, len); if (n < len)
n++;
cst_buf_append(&t->raw, data, n);
if (t->ctl)
ctl_parser_feed(t->ctl, data, n);
else if (t->mm)
mm_scan_feed(t->mm, data, n);
data += n;
len -= n;
}
} }
static void think_out(turn_t *t, const char *s, size_t n) { static void think_out(turn_t *t, const char *s, size_t n) {
@@ -1306,10 +1315,16 @@ static void on_delta(const char *data, size_t len, void *ud) {
const char *want = t->in_think ? "</think>" : "<think>"; const char *want = t->in_think ? "</think>" : "<think>";
size_t wl = strlen(want); size_t wl = strlen(want);
if (t->taglen == 0 && c != '<') { if (t->taglen == 0 && c != '<') {
/* the whole run up to the next '<' in one call: byte by byte, a
* front end would get every CJK character in three invalid pieces */
size_t j = i + 1;
while (j < len && data[j] != '<')
j++;
if (t->in_think) if (t->in_think)
think_out(t, &c, 1); think_out(t, data + i, j - i);
else else
feed_visible(t, &c, 1); feed_visible(t, data + i, j - i);
i = j - 1;
continue; continue;
} }
t->tagbuf[t->taglen++] = c; t->tagbuf[t->taglen++] = c;
@@ -2558,9 +2573,9 @@ static int can_continue(const cst_agent_t *a, const turn_t *t,
return st->http_status > 0 && st->http_status < 400 && !st->client_stopped; return st->http_status > 0 && st->http_status < 400 && !st->client_stopped;
} }
/* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 60% of /* Prompt size at which the session is compacted: CST_CTX_BUDGET, else 75% of
* the context window capped at 256K tokens. Even a cached prompt is billed * the context window, capped at 256K tokens. Even a cached prompt is billed on
* on every step, so huge windows are not worth filling. */ * every step, so a 1M-token window is not worth filling. */
static long ctx_budget(const cst_agent_t *a) { static long ctx_budget(const cst_agent_t *a) {
const char *e = getenv("CST_CTX_BUDGET"); const char *e = getenv("CST_CTX_BUDGET");
if (e && atol(e) > 0) if (e && atol(e) > 0)
@@ -2568,7 +2583,7 @@ static long ctx_budget(const cst_agent_t *a) {
long win = cst_model_context_tokens(a->cfg.model); long win = cst_model_context_tokens(a->cfg.model);
if (win <= 0) if (win <= 0)
return 0; return 0;
long b = win / 10 * 6; long b = win / 4 * 3;
return b > 262144 ? 262144 : b; return b > 262144 ? 262144 : b;
} }
@@ -2933,13 +2948,18 @@ int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) {
return -1; return -1;
if (cst_agent_store_ensure(a->cfg.workspace_root) != 0) if (cst_agent_store_ensure(a->cfg.workspace_root) != 0)
return -1; return -1;
char id[128]; char id[128], path[4096];
if (cst_agent_store_new_id(id, sizeof(id)) != 0) /* ids are per second: never reopen (and append to) one that exists */
return -1; for (int tries = 0;; tries++) {
char path[4096]; if (tries == 16 || cst_agent_store_new_id(id, sizeof(id)) != 0 ||
if (cst_agent_store_path_for_id(a->cfg.workspace_root, id, path, cst_agent_store_path_for_id(a->cfg.workspace_root, id, path,
sizeof(path)) != 0) sizeof(path)) != 0)
return -1; return -1;
FILE *fp = fopen(path, "rb");
if (!fp)
break;
fclose(fp);
}
if (agent_reopen_session(a, path) != 0) if (agent_reopen_session(a, path) != 0)
return -1; return -1;
cst_agent_store_set_current(a->cfg.workspace_root, id); cst_agent_store_set_current(a->cfg.workspace_root, id);
@@ -2948,6 +2968,42 @@ int cst_agent_new_session(cst_agent_t *a, char *id_out, size_t id_sz) {
return 0; return 0;
} }
int cst_agent_set_workspace(cst_agent_t *a, const char *path, char *err,
size_t err_sz) {
char dummy[8];
if (!err || !err_sz) {
err = dummy;
err_sz = sizeof(dummy);
}
err[0] = 0;
if (!a || !path || !path[0]) {
snprintf(err, err_sz, "empty path");
return -1;
}
char abs[sizeof(a->cfg.workspace_root)];
if (os_realpath(path, abs, sizeof(abs)) != 0) {
snprintf(err, err_sz, "no such directory: %s", path);
return -1;
}
/* a file, or a tree we may not write to, fails here instead of halfway
* through the next turn */
if (cst_agent_store_ensure(abs) != 0) {
snprintf(err, err_sz, "cannot use as a workspace: %s", abs);
return -1;
}
snprintf(a->cfg.workspace_root, sizeof(a->cfg.workspace_root), "%s", abs);
/* everything that is kept per workspace follows the new root */
cst_perms_close(a->perms);
a->perms = cst_perms_open(a->cfg.workspace_root);
cst_metrics_close(a->metrics);
a->metrics = cst_metrics_open(a->cfg.workspace_root,
cst_session_path(a->session));
cst_agent_reload_notes(a);
ensure_system(a); /* the prompt names the directory */
a->last_prompt_tokens = 0; /* the old budget was measured elsewhere */
return 0;
}
int cst_agent_switch_session(cst_agent_t *a, const char *id) { int cst_agent_switch_session(cst_agent_t *a, const char *id) {
if (!a || !id || !id[0]) if (!a || !id || !id[0])
return -1; return -1;
+2 -1
View File
@@ -57,7 +57,8 @@ static void make_id(char *out, size_t n) {
time_t now = time(NULL); time_t now = time(NULL);
struct tm tm; struct tm tm;
os_localtime(&now, &tm); os_localtime(&now, &tm);
unsigned r = (unsigned)(os_getpid() ^ (unsigned)now); static unsigned seq;
unsigned r = (unsigned)(os_getpid() ^ (unsigned)now) + 0x9e37u * seq++;
snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900, snprintf(out, n, "%04d%02d%02d-%02d%02d%02d-%04x", tm.tm_year + 1900,
tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec, tm.tm_mon + 1, tm.tm_mday, tm.tm_hour, tm.tm_min, tm.tm_sec,
r & 0xffff); r & 0xffff);
+770
View File
@@ -0,0 +1,770 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE
#endif
#include "cst_http.h"
#include "cst_util.h"
#include "os_compat.h"
#include <errno.h>
#include <pthread.h>
#include <stdint.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;
int nreq; /* requests served on this connection */
int expect_continue; /* the client waits for "100 Continue" */
int keep_alive_req; /* what the client asked for */
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 -------------------------------------------------------------- */
/* deadline: os_now_ms() value; the whole read must be done by then, so a
* client trickling one byte at a time cannot hold the thread forever. */
static int fill(cst_http_conn_t *c, double deadline) {
if (c->rpos < c->rlen)
return 1;
double left = deadline - os_now_ms();
if (left <= 0)
return -2;
long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), (int)left + 1);
if (r <= 0)
return (int)r; /* 0 eof, -1 error, -2 timeout */
c->rpos = 0;
c->rlen = (size_t)r;
return 1;
}
#define RD_TOO_LONG (-3)
static int rd_line(cst_http_conn_t *c, char *out, size_t cap, double deadline) {
size_t n = 0;
for (;;) {
int rc = fill(c, deadline);
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 RD_TOO_LONG;
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;
}
/* -1 when the input encodes a NUL (out then holds what came before it). */
static int 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) {
out[o] = 0;
return -1;
}
out[o++] = ch;
p += 2;
continue;
}
}
out[o++] = (*p == '+' && plus_space) ? ' ' : *p;
}
out[o] = 0;
return 0;
}
/* Comma-separated header tokens, compared without case ("Close"). */
static int has_token(const char *val, const char *tok) {
size_t n = strlen(tok);
for (const char *p = val; p && *p;) {
while (*p == ' ' || *p == '\t' || *p == ',')
p++;
size_t i = 0;
while (i < n && p[i] && (p[i] | 0x20) == (tok[i] | 0x20))
i++;
if (i == n && (p[i] == 0 || p[i] == ',' || p[i] == ' ' || p[i] == '\t'))
return 1;
p = strchr(p, ',');
}
return 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->nreq == 0; /* a keep-alive pause gets the idle timeout */
memset(r, 0, sizeof(*r));
c->hdrs_used = 0;
c->expect_continue = 0;
*want_body = 0;
int rc = rd_line(c, line, sizeof(line),
os_now_ms() + (first ? s->o.read_timeout_ms
: s->o.idle_timeout_ms));
if (rc == RD_TOO_LONG)
return -1;
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 */
if (target[0] != '/') {
char *scheme = strstr(target, "://");
target = scheme ? strchr(scheme + 3, '/') : NULL;
}
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);
}
if (url_decode(raw, r->path, sizeof(r->path), 0) != 0)
return -1;
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;
double deadline = os_now_ms() + s->o.read_timeout_ms;
for (;;) {
rc = rd_line(c, line, sizeof(line), deadline);
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")) {
/* digits only, and one value: "5x" or two lengths would let the
* rest of the body run as a second request behind a proxy */
char *end = NULL;
errno = 0;
unsigned long long v = strtoull(val, &end, 10);
while (end && (*end == ' ' || *end == '\t'))
end++;
if (*val < '0' || *val > '9' || !end || *end || errno == ERANGE ||
v > (unsigned long long)SIZE_MAX ||
(have_length && content_length != (size_t)v))
return -1;
content_length = (size_t)v;
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 (has_token(val, "close"))
r->keep_alive = 0;
else if (has_token(val, "keep-alive"))
r->keep_alive = 1;
} else if (key_is(line, "expect")) {
/* answered once auth and the size cap have passed */
c->expect_continue = has_token(val, "100-continue");
} 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);
} else if (key_is(line, "last-event-id")) {
r->last_event_id = 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 const cst_http_route_t *find_route(const cst_http_server_t *s,
const cst_http_req_t *req) {
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, req->method) != 0)
continue;
if (strcmp(r->path, "*") == 0) {
fallback = r;
continue;
}
if (strcmp(r->path, req->path) == 0)
return r;
}
return fallback;
}
/* Route, auth and the size cap are settled before the body is read: an
* unauthenticated client must not make us buffer 32 MiB. */
static void serve(cst_http_server_t *s, cst_http_conn_t *c, size_t want) {
const cst_http_route_t *route = find_route(s, &c->req);
size_t cap = route && route->max_body ? route->max_body : s->o.max_body;
if (want > 0)
c->req.keep_alive = 0; /* until the body is read, the stream is ours */
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;
}
if (want > cap) {
cst_http_send_err(c, 413, "request body too large");
return;
}
if (want > 0) {
if (c->expect_continue) {
const char *cont = "HTTP/1.1 100 Continue\r\n\r\n";
os_net_send_all(c->sock, cont, strlen(cont), 5000);
}
char *body = rd_body(c, want, s->o.read_timeout_ms);
if (!body) {
c->failed = 1;
return;
}
c->body = body;
c->req.body = body;
c->req.body_len = want;
c->req.keep_alive = c->keep_alive_req;
}
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;
c->nreq++;
c->sent = 0;
c->streaming = 0;
c->keep_alive_req = c->req.keep_alive;
serve(s, c, want);
if (s->o.on_log) {
char line[512];
snprintf(line, sizeof(line), "%s %.480s -> %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;
c->req.last_event_id = NULL;
if (!c->req.keep_alive || s->stopping || c->failed)
break;
}
/* unlink first: stop() shuts down every listed socket, and a closed fd
* number may already belong to someone else */
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;
pthread_mutex_unlock(&s->mu);
os_net_shutdown(c->sock);
os_net_close(c->sock);
free(c);
pthread_mutex_lock(&s->mu);
s->nconn--;
pthread_cond_broadcast(&s->cv);
pthread_mutex_unlock(&s->mu);
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) {
if (!timed_out) { /* EMFILE and friends: the listener stays readable */
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_nsec += 100000000;
if (ts.tv_nsec >= 1000000000) {
ts.tv_sec++;
ts.tv_nsec -= 1000000000;
}
pthread_mutex_lock(&s->mu);
if (!s->stopping)
pthread_cond_timedwait(&s->cv, &s->mu, &ts);
pthread_mutex_unlock(&s->mu);
}
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;
}
int cst_http_stop(cst_http_server_t *s) {
if (!s)
return 0;
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);
}
int left = s->nconn;
pthread_mutex_unlock(&s->mu);
os_net_close(s->listener);
return left;
}
void cst_http_free(cst_http_server_t *s) {
if (!s)
return;
pthread_mutex_lock(&s->mu);
int left = s->nconn;
pthread_mutex_unlock(&s->mu);
if (left > 0)
return; /* a handler still runs on it (see stop()): leak, not crash */
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. */
+70 -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 (default 8787, 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"
@@ -247,6 +255,16 @@ static int demo_ctl_impl(const char *workspace) {
return 0; return 0;
} }
/* 0..65535, digits only: atoi turned "70000" into port 4464, "abc" into 0. */
static int parse_port(const char *s, int *out) {
char *end = NULL;
long v = strtol(s, &end, 10);
if (!s[0] || *end || v < 0 || v > 65535)
return -1;
*out = (int)v;
return 0;
}
int main(int argc, char **argv) { int main(int argc, char **argv) {
if (os_console_init() != 0) if (os_console_init() != 0)
fprintf(stderr, "warning: the process code page is not UTF-8 (Windows 10 " fprintf(stderr, "warning: the process code page is not UTF-8 (Windows 10 "
@@ -320,6 +338,19 @@ 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));
web.port = CST_WEBUI_DEFAULT_PORT; /* 0 would mean "pick a free one" */
const char *env = getenv("CST_WEB_PORT");
if (env && env[0] && parse_port(env, &web.port) != 0) {
fprintf(stderr, "CST_WEB_PORT=%s is not a port (0-65535)\n", env);
return 1;
}
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 +377,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 +490,33 @@ int main(int argc, char **argv) {
curl_global_cleanup(); curl_global_cleanup();
return rc; return rc;
} }
case 0x101:
web_mode = 1;
break;
case 0x102:
if (parse_port(optarg, &web.port) != 0) {
fprintf(stderr, "--web-port %s is not a port (0-65535)\n", optarg);
curl_global_cleanup();
return 1;
}
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 +664,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 +674,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 +718,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},
+335
View File
@@ -0,0 +1,335 @@
#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
#if !defined(_WIN32_WINNT) || _WIN32_WINNT < 0x0600
#undef _WIN32_WINNT
#define _WIN32_WINNT 0x0600 /* WSAPoll */
#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 <poll.h>
#include <signal.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))
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))
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
}
/* poll, not select: FD_SET past FD_SETSIZE writes out of bounds, and a
* failed connect shows up as an error event, not as writable, on Windows. */
static int wait_io(os_socket_t s, int want_write, int timeout_ms) {
#ifdef _WIN32
WSAPOLLFD p;
p.fd = NET_FD(s);
p.events = want_write ? POLLWRNORM : POLLRDNORM;
p.revents = 0;
int rc = WSAPoll(&p, 1, timeout_ms < 0 ? -1 : timeout_ms);
#else
struct pollfd p;
p.fd = NET_FD(s);
p.events = want_write ? POLLOUT : POLLIN;
p.revents = 0;
int rc = poll(&p, 1, timeout_ms < 0 ? -1 : timeout_ms);
#endif
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;
#ifdef _WIN32
/* SO_REUSEADDR on Windows lets another process bind the same port and
* take over our connections */
setsockopt(NET_FD(s), SOL_SOCKET, SO_EXCLUSIVEADDRUSE, (const char *)&one,
sizeof(one));
#else
setsockopt(NET_FD(s), SOL_SOCKET, SO_REUSEADDR, (const char *)&one,
sizeof(one));
#endif
#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);
}
+4 -3
View File
@@ -519,7 +519,7 @@ int os_run_timeout_effective(void) {
const char *e = getenv("CST_SHELL_TIMEOUT"); const char *e = getenv("CST_SHELL_TIMEOUT");
if (e && atoi(e) > 0) if (e && atoi(e) > 0)
return atoi(e); return atoi(e);
return 120; return 0; /* no limit: a build, a test suite or a long clone must finish */
} }
double os_now_ms(void) { double os_now_ms(void) {
@@ -662,7 +662,8 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
int truncated = 0, killed = 0; int truncated = 0, killed = 0;
int status = 0, reaped = 0, bg_killed = 0; int status = 0, reaped = 0, bg_killed = 0;
double reaped_at = 0, killed_at = 0; double reaped_at = 0, killed_at = 0;
double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0; int timeout_s = os_run_timeout_effective();
double deadline = timeout_s > 0 ? os_now_ms() + timeout_s * 1000.0 : 0;
for (;;) { for (;;) {
struct pollfd pfd = {pipefd[0], POLLIN, 0}; struct pollfd pfd = {pipefd[0], POLLIN, 0};
int pr = poll(&pfd, 1, 100); int pr = poll(&pfd, 1, 100);
@@ -692,7 +693,7 @@ int os_run_capture_ex(const char *cwd, const char *const argv[],
} }
/* Checked on every pass, so a chatty command still stops promptly. */ /* Checked on every pass, so a chatty command still stops promptly. */
if (!reaped && !killed && if (!reaped && !killed &&
((g_run_cancel && *g_run_cancel) || now > deadline)) { ((g_run_cancel && *g_run_cancel) || (deadline > 0 && now > deadline))) {
killed = (g_run_cancel && *g_run_cancel) ? 2 : 1; killed = (g_run_cancel && *g_run_cancel) ? 2 : 1;
killed_at = now; killed_at = now;
kill(-pid, SIGTERM); kill(-pid, SIGTERM);
+4 -1
View File
@@ -601,7 +601,10 @@ int os_run_capture_token(const char *cwd, const char *const argv[],
buf[0] = 0; buf[0] = 0;
int truncated = 0, killed = 0, reaped = 0, bg_killed = 0; int truncated = 0, killed = 0, reaped = 0, bg_killed = 0;
double reaped_at = 0, killed_at = 0; double reaped_at = 0, killed_at = 0;
double deadline = os_now_ms() + os_run_timeout_effective() * 1000.0; /* no timeout unless one was asked for: a deadline far in the future keeps
* every `now > deadline` check working without a second code path */
int timeout_s = os_run_timeout_effective();
double deadline = timeout_s > 0 ? os_now_ms() + timeout_s * 1000.0 : 1e18;
int idle = 0; /* empty polls in a row: wait longer once output stalls */ int idle = 0; /* empty polls in a row: wait longer once output stalls */
for (;;) { for (;;) {
DWORD avail = 0; DWORD avail = 0;
+193
View File
@@ -0,0 +1,193 @@
#include "cst_provider_switch.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int cst_ps_fetch(const char *provider, const char *api_base, const char *key,
char ids[][128], int max, char *err, size_t err_sz) {
cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m));
if (!m) {
snprintf(err, err_sz, "out of memory");
return -1;
}
int r = cst_llm_list_models_meta(api_base, key, m, max, err, err_sz);
if (r >= 0)
cst_models_cache_save(provider, api_base, m, r);
for (int i = 0; i < r; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
return r;
}
int cst_ps_cached(const char *provider, char ids[][128], int max, int *fresh) {
*fresh = 0;
cst_model_meta_t *m = calloc((size_t)(max > 0 ? max : 1), sizeof(*m));
if (!m)
return -1;
long age = 0;
int n = cst_models_cache_load(provider, m, max, &age);
for (int i = 0; i < n; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
*fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL;
return n > 0 ? n : -1;
}
const char *cst_ps_base(const cst_agent_config_t *cfg, const char *provider) {
if (cfg && strcmp(cfg->provider, provider) == 0)
return cfg->api_base;
const cst_provider_t *p = cst_provider_find(provider);
return p ? p->api_base : "";
}
int cst_ps_models(const cst_agent_config_t *cfg, int refresh,
cst_ps_model_t *out, int max, int *api_rc, char *err,
size_t err_sz) {
err[0] = 0;
*api_rc = -1;
char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids));
if (!ids) {
snprintf(err, err_sz, "out of memory");
return 0;
}
int fresh = 0;
int na = cst_ps_cached(cfg->provider, ids, CST_PS_MAX_MODELS, &fresh);
*api_rc = na;
if ((refresh || !fresh) && cfg->api_key[0]) {
char(*got)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*got));
if (got) {
int r = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, got,
CST_PS_MAX_MODELS, err, err_sz);
*api_rc = r;
if (r >= 0) {
memcpy(ids, got, (size_t)r * sizeof(*ids));
na = r;
}
free(got);
}
}
int n = 0;
for (int i = 0; i < na && n < max; i++, n++) {
snprintf(out[n].id, sizeof(out[n].id), "%s", ids[i]);
out[n].api = 1;
cst_model_info(cfg->provider, ids[i], &out[n].info);
}
char builtin[32][128];
int nb = cst_models_list(cfg->model, builtin, 32);
for (int i = 0; i < nb && n < max; i++) {
int dup = 0;
for (int k = 0; k < na && !dup; k++)
dup = strcmp(ids[k], builtin[i]) == 0;
if (dup)
continue;
memcpy(out[n].id, builtin[i], sizeof(out[n].id));
out[n].id[sizeof(out[n].id) - 1] = 0;
out[n].api = 0;
cst_model_info(cfg->provider, builtin[i], &out[n].info);
n++;
}
free(ids);
return n;
}
int cst_ps_verify_save(const char *provider, const char *api_base,
const char *key, char ids[][128], int max, int *nids,
char *err, size_t err_sz) {
err[0] = 0;
char(*own)[128] = NULL;
if (!ids) {
own = calloc(CST_PS_MAX_MODELS, sizeof(*own));
if (!own) {
snprintf(err, err_sz, "out of memory");
return CST_PS_UNVERIFIED | CST_PS_UNSAVED;
}
ids = own;
max = CST_PS_MAX_MODELS;
}
int r = cst_ps_fetch(provider, api_base, key, ids, max, err, err_sz);
free(own);
if (nids)
*nids = r;
if (r == CST_MODELS_AUTH)
return CST_MODELS_AUTH;
int flags = r < 0 ? CST_PS_UNVERIFIED : 0;
if (r >= 0)
err[0] = 0;
if (cst_creds_save(provider, key) != 0)
flags |= CST_PS_UNSAVED;
return flags;
}
void cst_ps_apply_provider(cst_agent_config_t *cfg, const cst_provider_t *p,
const char *key) {
cst_settings_t s;
cst_settings_load(&s);
const char *model = cst_settings_model(&s, p->name);
snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name);
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model);
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
cst_settings_remember(cfg);
}
int cst_ps_switch_provider(cst_agent_config_t *cfg, const char *name,
const char *key, char *err, size_t err_sz) {
err[0] = 0;
const cst_provider_t *p = name ? cst_provider_find(name) : NULL;
if (!p) {
snprintf(err, err_sz, "unknown provider '%.64s'", name ? name : "");
return CST_PS_UNKNOWN;
}
char k[512];
int flags = 0;
char(*ids)[128] = calloc(CST_PS_MAX_MODELS, sizeof(*ids));
int nids = -1;
if (key && key[0]) {
snprintf(k, sizeof(k), "%s", key);
flags = cst_ps_verify_save(p->name, p->api_base, k, ids,
ids ? CST_PS_MAX_MODELS : 0, &nids, err,
err_sz);
if (flags < 0) {
memset(k, 0, sizeof(k));
free(ids);
return flags;
}
} else if (cst_creds_resolve(p->name, k, sizeof(k), NULL) != 0) {
free(ids);
snprintf(err, err_sz, "%s needs an API key", p->name);
return CST_PS_NEED_KEY;
}
cst_ps_apply_provider(cfg, p, k);
memset(k, 0, sizeof(k));
if (!cfg->model[0] && ids) {
/* a router preset: the first model the API lists */
int fresh = 0;
if (nids <= 0)
nids = cst_ps_cached(p->name, ids, CST_PS_MAX_MODELS, &fresh);
if (nids <= 0) {
char e2[256];
nids = cst_ps_fetch(p->name, cfg->api_base, cfg->api_key, ids,
CST_PS_MAX_MODELS, e2, sizeof(e2));
}
if (nids > 0) {
snprintf(cfg->model, sizeof(cfg->model), "%s", ids[0]);
cst_settings_remember(cfg);
}
}
free(ids);
return flags;
}
int cst_ps_set_key(cst_agent_config_t *cfg, const char *key, char *err,
size_t err_sz) {
if (!key || !key[0]) {
snprintf(err, err_sz, "empty key");
return CST_PS_NEED_KEY;
}
int r = cst_ps_verify_save(cfg->provider, cfg->api_base, key, NULL, 0, NULL,
err, err_sz);
if (r >= 0)
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
return r;
}
+60
View File
@@ -8,6 +8,7 @@
#include "cJSON.h" #include "cJSON.h"
#include "cst_agent.h" #include "cst_agent.h"
#include "cst_attach.h" #include "cst_attach.h"
#include "cst_util.h"
#include <pthread.h> #include <pthread.h>
#include <stdio.h> #include <stdio.h>
@@ -409,3 +410,62 @@ int cst_session_rewrite(cst_session_t *s, const cst_message_t *msgs, int n) {
free(copy); free(copy);
return rc; return rc;
} }
int cst_message_is_tool_result(const cst_message_t *m) {
return m && (m->role == CST_ROLE_TOOL ||
(m->role == CST_ROLE_USER && m->tool_call_id[0]));
}
static void md_fence(cst_buf_t *b, const char *s, size_t max) {
size_t n = strlen(s);
if (n > max)
n = cst_utf8_floor(s, max);
cst_buf_puts(b, "````\n");
cst_buf_append(b, s, n);
cst_buf_puts(b, n < strlen(s) ? "\n…(truncated)\n````\n\n" : "\n````\n\n");
}
char *cst_session_export_md(cst_session_t *s, const char *title,
const char *sid, const char *model) {
if (!s)
return NULL;
cst_buf_t b = {0};
cst_buf_printf(&b, "# %s\n\nsession `%s` · model `%s`\n\n",
title ? title : "", sid ? sid : "", model ? model : "");
cst_session_lock(s);
for (int i = 0; i < cst_session_count(s); i++) {
const cst_message_t *m = cst_session_get(s, i);
const char *c = m->content ? m->content : "";
if (m->role == CST_ROLE_SYSTEM)
continue;
if (cst_message_is_tool_result(m)) {
cst_buf_printf(&b, "**Tool result** `%s`\n\n", m->tool_name);
md_fence(&b, c, 4000);
} else if (cst_message_is_note(m)) {
cst_buf_puts(&b, "> ");
cst_buf_append(&b, c, strcspn(c, "\n"));
cst_buf_puts(&b, "\n\n");
} else if (m->role == CST_ROLE_USER) {
cst_buf_puts(&b, "## User\n\n");
cst_buf_puts(&b, c);
cst_buf_puts(&b, "\n\n");
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
cst_buf_printf(&b, "- image `%s` (%s)\n", m->attach[k].name, d);
}
if (m->nattach)
cst_buf_puts(&b, "\n");
} else {
cst_buf_puts(&b, "## Assistant\n\n");
cst_buf_puts(&b, c);
cst_buf_puts(&b, "\n\n");
if (m->tool_calls_json && m->tool_calls_json[0]) {
cst_buf_puts(&b, "**Tool calls**\n\n");
md_fence(&b, m->tool_calls_json, 4000);
}
}
}
cst_session_unlock(s);
return cst_buf_steal(&b);
}
+10
View File
@@ -488,6 +488,16 @@ char *cst_skill_read(const char *ws, const char *name, const char *file,
return cst_buf_steal(&b); return cst_buf_steal(&b);
} }
const char *cst_init_prompt(void) {
return "Analyze this repository and write an AGENTS.md file at the "
"workspace root for future coding agents. Cover: what the project "
"is, how to build, test and run it (exact commands), the directory "
"layout, coding conventions you can infer from the code, and "
"anything surprising. Explore with list_dir/glob/grep/read_file "
"before writing. Keep it under 150 lines. If AGENTS.md already "
"exists, improve it rather than starting over.";
}
char *cst_skill_prompt(const char *ws, const char *name, const char *task) { char *cst_skill_prompt(const char *ws, const char *name, const char *task) {
int code = 0; int code = 0;
char *body = cst_skill_read(ws, name, NULL, &code); char *body = cst_skill_read(ws, name, NULL, &code);
+250 -155
View File
@@ -11,6 +11,7 @@
#include "cst_mcp.h" #include "cst_mcp.h"
#include "cst_models.h" #include "cst_models.h"
#include "cst_plugins.h" #include "cst_plugins.h"
#include "cst_provider_switch.h"
#include "cst_skills.h" #include "cst_skills.h"
#include "cst_subagent.h" #include "cst_subagent.h"
#include "cst_textsel.h" #include "cst_textsel.h"
@@ -378,6 +379,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];
@@ -1040,10 +1042,7 @@ static int find_tool_block(tui_t *t, const char *id) {
return -1; return -1;
} }
static int msg_is_tool_result(const cst_message_t *m) { #define msg_is_tool_result cst_message_is_tool_result
return m && (m->role == CST_ROLE_TOOL ||
(m->role == CST_ROLE_USER && m->tool_call_id[0]));
}
/* Strip the "[tool …]" / "result <id> exit=N <n>" header and "end" trailer. */ /* Strip the "[tool …]" / "result <id> exit=N <n>" header and "end" trailer. */
static void tool_result_view(const char *content, const char **body, static void tool_result_view(const char *content, const char **body,
@@ -3163,28 +3162,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++; return 0;
size_t target; t->cur = input_hit(t, cr + dir, cc);
if (dir < 0) {
if (ls == 0)
return 0;
target = line_start(t, ls - 1);
} 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;
} }
@@ -3897,46 +3884,11 @@ static void am_store(const char *provider, int r, char ids[][128],
pthread_mutex_unlock(&g_am_mu); pthread_mutex_unlock(&g_am_mu);
} }
/* GET /models; a good answer also refreshes models-cache/<provider>.json. */
static int fetch_models(const char *provider, const char *base,
const char *key, char ids[][128], int max, char *err,
size_t err_sz) {
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
if (!m) {
snprintf(err, err_sz, "out of memory");
return -1;
}
int r = cst_llm_list_models_meta(base, key, m, max, err, err_sz);
if (r >= 0)
cst_models_cache_save(provider, base, m, r);
for (int i = 0; i < r; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
return r;
}
/* The cached list for provider into ids: count, or -1; *fresh when it is
* younger than CST_MODELS_CACHE_TTL. */
static int cached_models(const char *provider, char ids[][128], int max,
int *fresh) {
*fresh = 0;
cst_model_meta_t *m = calloc((size_t)max, sizeof(*m));
if (!m)
return -1;
long age = 0;
int n = cst_models_cache_load(provider, m, max, &age);
for (int i = 0; i < n; i++)
snprintf(ids[i], 128, "%s", m[i].id);
free(m);
*fresh = n > 0 && age >= 0 && age < CST_MODELS_CACHE_TTL;
return n > 0 ? n : -1;
}
static void *probe_thread(void *arg) { static void *probe_thread(void *arg) {
probe_arg_t *a = arg; probe_arg_t *a = arg;
static char ids[AM_MAX][128]; /* one probe runs per provider switch */ static char ids[AM_MAX][128]; /* one probe runs per provider switch */
char err[256]; char err[256];
int r = fetch_models(a->provider, a->base, a->key, ids, AM_MAX, err, int r = cst_ps_fetch(a->provider, a->base, a->key, ids, AM_MAX, err,
sizeof(err)); sizeof(err));
if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache) if (r >= 0 || r == CST_MODELS_AUTH || !a->have_cache)
am_store(a->provider, r, ids, err); am_store(a->provider, r, ids, err);
@@ -3948,23 +3900,13 @@ static void *probe_thread(void *arg) {
return NULL; return NULL;
} }
/* Base URL for a provider: the live config for the current one (it may be
* overridden), else the preset. */
static const char *provider_base(const tui_t *t, const char *provider) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (cfg && strcmp(cfg->provider, provider) == 0)
return cfg->api_base;
const cst_provider_t *p = cst_provider_find(provider);
return p ? p->api_base : "";
}
static void start_probe(tui_t *t) { static void start_probe(tui_t *t) {
const cst_agent_config_t *cfg = cst_agent_config(t->agent); const cst_agent_config_t *cfg = cst_agent_config(t->agent);
if (!cfg || !cfg->api_key[0] || g_am_threads > 0) if (!cfg || !cfg->api_key[0] || g_am_threads > 0)
return; return;
static char ids[AM_MAX][128]; static char ids[AM_MAX][128];
int fresh = 0; int fresh = 0;
int nc = cached_models(cfg->provider, ids, AM_MAX, &fresh); int nc = cst_ps_cached(cfg->provider, ids, AM_MAX, &fresh);
pthread_mutex_lock(&g_am_mu); pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
pthread_mutex_unlock(&g_am_mu); pthread_mutex_unlock(&g_am_mu);
@@ -4050,13 +3992,14 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
return 0; return 0;
char dir[1024] = "the config directory"; char dir[1024] = "the config directory";
os_config_dir(dir, sizeof(dir)); os_config_dir(dir, sizeof(dir));
const char *base = provider_base(t, provider); const char *base = cst_ps_base(cst_agent_config(t->agent), provider);
char title[128], hint[1300]; char title[128], hint[1300];
snprintf(title, sizeof(title), "API key for %s", provider); snprintf(title, sizeof(title), "API key for %s", provider);
snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you " snprintf(hint, sizeof(hint), "%.200s · saved to %.900s/credentials (only you "
"can read it)", "can read it)",
base, dir); base, dir);
char note[300] = ""; char note[300] = "";
int flags;
for (;;) { for (;;) {
if (secret_input_run(t, title, hint, out, n) != 0) if (secret_input_run(t, title, hint, out, n) != 0)
return -1; return -1;
@@ -4064,8 +4007,10 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
draw_status(t); draw_status(t);
static char ids[AM_MAX][128]; static char ids[AM_MAX][128];
char err[256]; char err[256];
int r = fetch_models(provider, base, out, ids, AM_MAX, err, sizeof(err)); int r = -1;
if (r == CST_MODELS_AUTH) { flags = cst_ps_verify_save(provider, base, out, ids, AM_MAX, &r, err,
sizeof(err));
if (flags == CST_MODELS_AUTH) {
snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste " snprintf(hint, sizeof(hint), "%s rejected that key (%.200s) — paste "
"another one", "another one",
base, err); base, err);
@@ -4075,13 +4020,13 @@ static int obtain_key(tui_t *t, const char *provider, char *out, size_t n,
snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider); snprintf(g_am.provider, sizeof(g_am.provider), "%s", provider);
pthread_mutex_unlock(&g_am_mu); pthread_mutex_unlock(&g_am_mu);
am_store(provider, r, ids, err); am_store(provider, r, ids, err);
if (r < 0) if (flags & CST_PS_UNVERIFIED)
snprintf(note, sizeof(note), "could not verify the key (%.200s); " snprintf(note, sizeof(note), "could not verify the key (%.200s); "
"saved anyway", "saved anyway",
err); err);
break; break;
} }
if (cst_creds_save(provider, out) != 0) if (flags & CST_PS_UNSAVED)
set_status(t, "warning: could not save the key; it is used for this run " set_status(t, "warning: could not save the key; it is used for this run "
"only"); "only");
else if (note[0]) else if (note[0])
@@ -4313,7 +4258,7 @@ static void cmd_model(tui_t *t, char *arg, int *quit) {
set_status(t, "fetching the model list…"); set_status(t, "fetching the model list…");
draw_status(t); draw_status(t);
char err[256]; char err[256];
na = fetch_models(cfg->provider, cfg->api_base, cfg->api_key, names, na = cst_ps_fetch(cfg->provider, cfg->api_base, cfg->api_key, names,
AM_MAX, err, sizeof(err)); AM_MAX, err, sizeof(err));
pthread_mutex_lock(&g_am_mu); pthread_mutex_lock(&g_am_mu);
snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider); snprintf(g_am.provider, sizeof(g_am.provider), "%s", cfg->provider);
@@ -4447,15 +4392,8 @@ static void cmd_provider(tui_t *t, char *arg, int *quit) {
push_system_line(t, "(provider unchanged: %s needs an API key)", p->name); push_system_line(t, "(provider unchanged: %s needs an API key)", p->name);
return; return;
} }
cst_settings_t s; cst_ps_apply_provider(cfg, p, key);
cst_settings_load(&s);
const char *model = cst_settings_model(&s, p->name);
snprintf(cfg->provider, sizeof(cfg->provider), "%s", p->name);
snprintf(cfg->api_base, sizeof(cfg->api_base), "%s", p->api_base);
snprintf(cfg->model, sizeof(cfg->model), "%s", model ? model : p->model);
snprintf(cfg->api_key, sizeof(cfg->api_key), "%s", key);
memset(key, 0, sizeof(key)); memset(key, 0, sizeof(key));
cst_settings_remember(cfg);
push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base, push_system_line(t, "(provider → %s · %s · model %s)", p->name, p->api_base,
cfg->model); cfg->model);
if (am_models(p->name, NULL, 0) < 0) if (am_models(p->name, NULL, 0) < 0)
@@ -4833,12 +4771,6 @@ static void cmd_diff(tui_t *t, char *arg, int *quit) {
free(df.out); free(df.out);
} }
static void md_fence(FILE *fp, const char *s, size_t max) {
size_t n = strlen(s);
fprintf(fp, "````\n%.*s%s\n````\n\n", (int)(n > max ? max : n), s,
n > max ? "\n…(truncated)" : "");
}
static void cmd_export(tui_t *t, char *arg, int *quit) { static void cmd_export(tui_t *t, char *arg, int *quit) {
(void)quit; (void)quit;
const cst_agent_config_t *cfg = cst_agent_config(t->agent); const cst_agent_config_t *cfg = cst_agent_config(t->agent);
@@ -4858,44 +4790,20 @@ static void cmd_export(tui_t *t, char *arg, int *quit) {
*sl = 0; *sl = 0;
os_mkdir_p(dir, 0755); os_mkdir_p(dir, 0755);
} }
FILE *fp = fopen(path, "wb"); char *md = cst_session_export_md(cst_agent_session(t->agent), t->title, sid,
cfg->model);
FILE *fp = md ? fopen(path, "wb") : NULL;
if (!fp) { if (!fp) {
free(md);
push_system_line(t, "Error: cannot write %s", path); push_system_line(t, "Error: cannot write %s", path);
return; return;
} }
fprintf(fp, "# %s\n\nsession `%s` · model `%s`\n\n", t->title, sid, fputs(md, fp);
cfg->model); free(md);
cst_session_t *s = cst_agent_session(t->agent); if (fclose(fp) != 0) {
cst_session_lock(s); push_system_line(t, "Error: cannot write %s", path);
for (int i = 0; i < cst_session_count(s); i++) { return;
const cst_message_t *m = cst_session_get(s, i);
const char *c = m->content ? m->content : "";
if (m->role == CST_ROLE_SYSTEM)
continue;
if (msg_is_tool_result(m)) {
fprintf(fp, "**Tool result** `%s`\n\n", m->tool_name);
md_fence(fp, c, 4000);
} else if (cst_message_is_note(m)) {
fprintf(fp, "> %.*s\n\n", (int)strcspn(c, "\n"), c);
} else if (m->role == CST_ROLE_USER) {
fprintf(fp, "## User\n\n%s\n\n", c);
for (int k = 0; k < m->nattach; k++) {
char d[128];
cst_attach_describe(&m->attach[k], d, sizeof(d));
fprintf(fp, "- image `%s` (%s)\n", m->attach[k].name, d);
}
if (m->nattach)
fputc('\n', fp);
} else {
fprintf(fp, "## Assistant\n\n%s\n\n", c);
if (m->tool_calls_json && m->tool_calls_json[0]) {
fprintf(fp, "**Tool calls**\n\n");
md_fence(fp, m->tool_calls_json, 4000);
}
}
} }
cst_session_unlock(s);
fclose(fp);
push_system_line(t, "(exported to %s)", path); push_system_line(t, "(exported to %s)", path);
} }
@@ -5072,14 +4980,7 @@ static void cmd_init(tui_t *t, char *arg, int *quit) {
(void)arg; (void)arg;
(void)quit; (void)quit;
free(t->pending_prompt); free(t->pending_prompt);
t->pending_prompt = xstrdup( t->pending_prompt = xstrdup(cst_init_prompt());
"Analyze this repository and write an AGENTS.md file at the workspace "
"root for future coding agents. Cover: what the project is, how to "
"build, test and run it (exact commands), the directory layout, coding "
"conventions you can infer from the code, and anything surprising. "
"Explore with list_dir/glob/grep/read_file before writing. Keep it "
"under 150 lines. If AGENTS.md already exists, improve it rather than "
"starting over.");
} }
static void cmd_compact(tui_t *t, char *arg, int *quit) { static void cmd_compact(tui_t *t, char *arg, int *quit) {
@@ -5375,6 +5276,151 @@ static void cmd_quit(tui_t *t, char *arg, int *quit) {
*quit = 1; *quit = 1;
} }
/* /web: the WebUI is a second front end for the same engine and is designed to
* run as its own process, so this starts one on the same workspace and leaves
* the TUI holding the terminal. The child gets a fresh session: two writers on
* one session file is the one thing that would corrupt it. */
#ifndef _WIN32
#include <fcntl.h>
#include <sys/wait.h> /* waitpid: collect the WebUI started by the last /web */
#endif
#ifndef CST_WEBUI_DEFAULT_PORT
#define CST_WEBUI_DEFAULT_PORT 8787 /* keep in sync with include/cst_webui.h */
#endif
static void cmd_web(tui_t *t, char *arg, int *quit) {
(void)arg;
(void)quit;
#ifdef _WIN32
push_system_line(t, "open another terminal and run: cst-agent --web");
#else
static pid_t spawned = 0;
if (spawned > 0) { /* collect the one we started last time */
waitpid(spawned, NULL, WNOHANG);
spawned = 0;
}
char dir[4096], exe[4096 + 16];
if (os_exe_dir(dir, sizeof(dir)) != 0) {
push_system_line(t, "cannot locate this binary: run cst-agent --web yourself");
return;
}
snprintf(exe, sizeof(exe), "%s/cst-agent", dir);
const cst_agent_config_t *cfg = cst_agent_config(t->agent);
const char *ws = (cfg && cfg->workspace_root[0]) ? cfg->workspace_root : ".";
char logf[4096 + 32];
snprintf(logf, sizeof(logf), "%s/.agent", ws);
os_mkdir_p(logf, 0755);
snprintf(logf, sizeof(logf), "%s/.agent/webui.log", ws);
/* The WebUI talks to what this session talks to (-b, -m, a /provider
* switch...). The key goes through the environment, not argv (ps). */
const char *argv[20];
int na = 0;
argv[na++] = "cst-agent";
argv[na++] = "--web";
argv[na++] = "--workspace";
argv[na++] = ws;
argv[na++] = "--new";
if (cfg && cfg->provider[0])
argv[na++] = "--provider", argv[na++] = cfg->provider;
if (cfg && cfg->model[0])
argv[na++] = "--model", argv[na++] = cfg->model;
if (cfg && cfg->api_base[0])
argv[na++] = "--api-base", argv[na++] = cfg->api_base;
if (cfg && cfg->effort[0])
argv[na++] = "--effort", argv[na++] = cfg->effort;
if (cfg)
argv[na++] = "--approval", argv[na++] = cst_approval_name(cfg->approval);
argv[na] = NULL;
size_t nenv = 0;
while (environ[nenv])
nenv++;
char **envp = calloc(nenv + 2, sizeof(char *));
char keyvar[600] = "";
if (!envp) {
push_system_line(t, "out of memory");
return;
}
size_t ne = 0;
for (size_t i = 0; i < nenv; i++)
if (strncmp(environ[i], "CST_API_KEY=", 12) != 0)
envp[ne++] = environ[i];
if (cfg && cfg->api_key[0]) {
snprintf(keyvar, sizeof(keyvar), "CST_API_KEY=%s", cfg->api_key);
envp[ne++] = keyvar;
}
envp[ne] = NULL;
pid_t pid = fork();
if (pid < 0) {
free(envp);
push_system_line(t, "cannot start a second process");
return;
}
if (pid == 0) {
setsid();
/* never inherit the TUI's terminal: its output would land on our screen */
int in = open("/dev/null", O_RDONLY);
if (in >= 0 && in != STDIN_FILENO) {
dup2(in, STDIN_FILENO);
close(in);
}
int fd = open(logf, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0)
fd = open("/dev/null", O_WRONLY);
if (fd >= 0) {
dup2(fd, STDOUT_FILENO);
dup2(fd, STDERR_FILENO);
if (fd > 2)
close(fd);
}
execve(exe, (char *const *)argv, envp);
_exit(127);
}
free(envp);
spawned = pid;
/* Wait (2 s at most) for the URL it prints once it listens: the port may
* come from CST_WEB_PORT, or it may not start at all. */
char url[512] = "";
int died = 0;
for (int i = 0; i < 40 && !url[0] && !died; i++) {
usleep(50000);
FILE *fp = fopen(logf, "r");
char line[512];
while (fp && fgets(line, sizeof(line), fp)) {
if (strncmp(line, "webui: http", 11) == 0) {
line[strcspn(line, "\r\n")] = 0;
snprintf(url, sizeof(url), "%s", line + 7);
break;
}
}
if (fp)
fclose(fp);
if (!url[0] && waitpid(pid, NULL, WNOHANG) == pid) {
died = 1;
spawned = 0;
}
}
/* its --new made the WebUI's session CURRENT; a plain `cst-agent` later
* should still resume this one */
char sid[128] = "";
cst_agent_session_id(t->agent, sid, sizeof(sid));
if (sid[0])
cst_agent_store_set_current(ws, sid);
char msg[4200];
if (died)
snprintf(msg, sizeof(msg), "the WebUI did not start (port busy?): see %s",
logf);
else if (url[0])
snprintf(msg, sizeof(msg), "WebUI started in the background: %s (new session)",
url);
else
snprintf(msg, sizeof(msg), "WebUI starting in the background "
"(http://127.0.0.1:%d/ unless CST_WEB_PORT says "
"otherwise)", CST_WEBUI_DEFAULT_PORT);
push_system_line(t, msg);
snprintf(msg, sizeof(msg), "its log: %s", logf);
push_system_line(t, msg);
#endif
}
static const slash_cmd_t SLASH[] = { static const slash_cmd_t SLASH[] = {
{"help", "?", "", "list commands and keys", 1, cmd_help}, {"help", "?", "", "list commands and keys", 1, cmd_help},
{"new", "clear", "", "start a new session", 0, cmd_new}, {"new", "clear", "", "start a new session", 0, cmd_new},
@@ -5433,6 +5479,8 @@ static const slash_cmd_t SLASH[] = {
cmd_agents}, cmd_agents},
{"skill", "", "<name> [task]", "run a task with a skill loaded", 0, {"skill", "", "<name> [task]", "run a task with a skill loaded", 0,
cmd_skill}, cmd_skill},
{"web", "", "", "start the WebUI in the background (127.0.0.1:8787)", 1,
cmd_web},
{"quit", "q exit", "", "quit", 1, cmd_quit}, {"quit", "q exit", "", "quit", 1, cmd_quit},
}; };
@@ -5854,7 +5902,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 +5921,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 +6445,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;
return 0; if (k > 0)
int k = key_defined(s); return k;
return k > 0 ? k : 0; #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;
}
#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 +6716,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 +6807,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,15 +7120,22 @@ 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;
+20
View File
@@ -99,6 +99,26 @@ size_t cst_utf8_floor(const char *s, size_t n) {
return i - 1; /* sequence incomplete: cut before lead byte */ return i - 1; /* sequence incomplete: cut before lead byte */
} }
char *cst_utf8_hold_feed(cst_utf8_hold_t *h, const char *s, size_t n,
size_t *out_len) {
size_t have = h->n;
char *all = malloc(have + n + 1);
if (!all)
return NULL;
memcpy(all, h->b, have);
if (n)
memcpy(all + have, s, n);
size_t len = have + n;
size_t cut = cst_utf8_floor(all, len);
if (len - cut > sizeof(h->b))
cut = len;
h->n = len - cut;
memcpy(h->b, all + cut, h->n);
all[cut] = 0;
*out_len = cut;
return all;
}
static size_t utf8_seq_ok(const unsigned char *p, size_t left) { static size_t utf8_seq_ok(const unsigned char *p, size_t left) {
unsigned char c = p[0]; unsigned char c = p[0];
size_t need; size_t need;
+329
View File
@@ -0,0 +1,329 @@
#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, "=\"");
/* a single-quoted or bare value may hold '"': it must not end ours */
for (size_t i = 0; i < vlen; i++) {
if (val[i] == '"')
cst_buf_puts(b, "&quot;");
else if (val[i] == '<')
cst_buf_puts(b, "&lt;");
else if (val[i] == '>')
cst_buf_puts(b, "&gt;");
else
cst_buf_append(b, val + i, 1);
}
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;
}
+2647
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");
+320
View File
@@ -0,0 +1,320 @@
/* 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);
/* a quote inside a single-quoted or bare value cannot open a new attribute */
const char *quoted = "<a href='http://x\" onmouseover=\"alert(1)'>q</a>"
"<a href=http://y\"onmouseover=alert(2)//>r</a>";
html = cst_web_md_sanitize(quoted, strlen(quoted));
CHECK(html != NULL);
CHECK(strstr(html, "\" onmouseover") == NULL);
CHECK(strstr(html, "\"onmouseover") == NULL);
CHECK(strstr(html, "&quot;") != NULL);
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);
}
}
/* Streamed deltas cut anywhere must reach the browser as whole characters. */
static void test_utf8_hold(void) {
const char *src = "思考:先读 main.c,再改 😀 emoji。";
size_t len = strlen(src);
for (size_t step = 1; step <= 5; step++) {
cst_utf8_hold_t h = {0};
cst_buf_t out = {0};
for (size_t i = 0; i < len; i += step) {
size_t n = len - i < step ? len - i : step;
size_t got = 0;
char *piece = cst_utf8_hold_feed(&h, src + i, n, &got);
CHECK(piece != NULL);
CHECK(h.n < 4);
CHECK(cst_utf8_valid(piece, got));
CHECK(cst_buf_append(&out, piece, got) == 0);
free(piece);
}
CHECK(h.n == 0);
CHECK(out.len == len && memcmp(out.buf, src, len) == 0);
cst_buf_free(&out);
}
/* a stray continuation byte is not held forever */
cst_utf8_hold_t h = {0};
size_t got = 0;
char *piece = cst_utf8_hold_feed(&h, "\x80\x80\x80\x80", 4, &got);
CHECK(piece && got == 4 && h.n == 0);
free(piece);
}
int main(void) {
CHECK(os_net_init() == 0);
test_utf8_hold();
test_query();
test_sanitize();
test_http();
printf("test_webui: OK\n");
return 0;
}
+2354
View File
File diff suppressed because it is too large Load Diff
+239
View File
@@ -0,0 +1,239 @@
<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="dark light">
<title>CST-Agent</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- one icon set, drawn as a sprite so every glyph shares the same 24px grid,
stroke weight and round caps: mixed emoji/unicode never looks like a family -->
<svg id="atlas" aria-hidden="true" hidden>
<symbol id="i-menu" viewBox="0 0 24 24"><path d="M4 7h16M4 12h16M4 17h16"/></symbol>
<symbol id="i-panel-left" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="3"/><path d="M9.5 5v14"/></symbol>
<symbol id="i-panel-right" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="3"/><path d="M14.5 5v14"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="M6 9.5l6 5 6-5"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-refresh" viewBox="0 0 24 24"><path d="M20 12a8 8 0 1 1-2.6-5.9M20 4.5V9h-4.5"/></symbol>
<symbol id="i-settings" viewBox="0 0 24 24"><path d="M4 6.5h5M15 6.5h5M4 12h7M17 12h3M4 17.5h5M15 17.5h5"/><circle cx="11.5" cy="6.5" r="2"/><circle cx="15" cy="12" r="2"/><circle cx="11.5" cy="17.5" r="2"/></symbol>
<symbol id="i-moon" viewBox="0 0 24 24"><path d="M20 14.6A8.6 8.6 0 0 1 9.4 4 8.6 8.6 0 1 0 20 14.6z"/></symbol>
<symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.2 5.2l1.6 1.6M17.2 17.2l1.6 1.6M18.8 5.2l-1.6 1.6M6.8 17.2l-1.6 1.6"/></symbol>
<symbol id="i-more" viewBox="0 0 24 24"><circle cx="5.5" cy="12" r="1.6" class="f"/><circle cx="12" cy="12" r="1.6" class="f"/><circle cx="18.5" cy="12" r="1.6" class="f"/></symbol>
<symbol id="i-send" viewBox="0 0 24 24"><path d="M12 19.5V5M5.8 11.2 12 5l6.2 6.2"/></symbol>
<symbol id="i-stop" viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2.5" class="f"/></symbol>
<symbol id="i-image" viewBox="0 0 24 24"><rect x="3.5" y="5" width="17" height="14" rx="3"/><path d="M4 15.5 8.6 11l4 4 2.8-2.8L20.5 16"/><circle cx="9" cy="9.4" r="1.5"/></symbol>
<symbol id="i-clipboard" viewBox="0 0 24 24"><rect x="8.5" y="3.5" width="7" height="4" rx="1.6"/><path d="M15.5 5.5h3v15h-13v-15h3M9 12h6M9 15.5h4"/></symbol>
<symbol id="i-list" viewBox="0 0 24 24"><path d="M4 6.8h2M4 12h2M4 17.2h2M9 6.8h11M9 12h11M9 17.2h11"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8.5" y="8.5" width="11" height="11" rx="2.6"/><path d="M15.5 8.5V7a2.5 2.5 0 0 0-2.5-2.5H6.5A2.5 2.5 0 0 0 4 7v6.5A2.5 2.5 0 0 0 6.5 16h1.5"/></symbol>
<symbol id="i-compress" viewBox="0 0 24 24"><path d="M8 3.5V9H3M16 20.5V15h5M3 9h4M16 15h5"/></symbol>
<symbol id="i-undo" viewBox="0 0 24 24"><path d="M9.5 5.5 4 11l5.5 5.5"/><path d="M4 11h10.2a5.2 5.2 0 0 1 0 10.4H8.5"/></symbol>
<symbol id="i-close" viewBox="0 0 24 24"><path d="M6.5 6.5l11 11M17.5 6.5l-11 11"/></symbol>
<symbol id="i-check" viewBox="0 0 24 24"><path d="M5 12.6l4.6 4.6L19 7.6"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="M12 3.5 5 6.2v5.4c0 4.2 2.9 7.5 7 8.9 4.1-1.4 7-4.7 7-8.9V6.2z"/></symbol>
<symbol id="i-bolt" viewBox="0 0 24 24"><path d="M13.4 3 5.5 13.6H11l-.9 7.4L18.5 10H13z"/></symbol>
<symbol id="i-alert" viewBox="0 0 24 24"><path d="M12 4 2.8 20h18.4z"/><path d="M12 10v4.4M12 17.2v.2"/></symbol>
<symbol id="i-cpu" viewBox="0 0 24 24"><rect x="7" y="7" width="10" height="10" rx="2.6"/><path d="M10 3v4M14 3v4M10 17v4M14 17v4M3 10h4M3 14h4M17 10h4M17 14h4"/></symbol>
<symbol id="i-lv1" viewBox="0 0 24 24"><path d="M5 18.5V13h3.4v5.5z" class="f"/></symbol>
<symbol id="i-lv2" viewBox="0 0 24 24"><path d="M5 18.5V13h3.4v5.5zM10.3 18.5V9.6h3.4v8.9z" class="f"/></symbol>
<symbol id="i-lv3" viewBox="0 0 24 24"><path d="M5 18.5V13h3.4v5.5zM10.3 18.5V9.6h3.4v8.9zM15.6 18.5V6.4h3.4v12.1z" class="f"/></symbol>
<symbol id="i-lv4" viewBox="0 0 24 24"><path d="M4 18.5V13h2.6v5.5zM8.7 18.5V10h2.6v8.5zM13.4 18.5V6.8h2.6v11.7zM18.1 18.5V4h2.6v14.5z" class="f"/></symbol>
<symbol id="i-file" viewBox="0 0 24 24"><path d="M13.5 3.5H7A2.5 2.5 0 0 0 4.5 6v12A2.5 2.5 0 0 0 7 20.5h10A2.5 2.5 0 0 0 19.5 18V9.5z"/><path d="M13.5 3.5v6h6"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.2V12l3.2 2"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3.5 6.5A1.5 1.5 0 0 1 5 5h4l2 2.5h8A1.5 1.5 0 0 1 20.5 9v9A1.5 1.5 0 0 1 19 19.5H5A1.5 1.5 0 0 1 3.5 18z"/></symbol>
</svg>
<div id="app">
<!-- rail: identity, one primary action, sessions, identity again.
Everything else is a popover anchored where it belongs. -->
<aside id="side">
<div class="side-inner">
<div class="side-head">
<div class="brand">
<span class="brand-name">CST-Agent</span>
</div>
<button id="btn-collapse" class="icon" title="收起侧栏 (Ctrl+B)">
<svg class="i"><use href="#i-panel-left"/></svg>
</button>
</div>
<button id="btn-new" class="new-task">
<svg class="i"><use href="#i-plus"/></svg><span>新建任务</span>
</button>
<div class="side-sec">
<span>最近</span>
<button id="btn-refresh-sessions" class="icon tiny" title="刷新">
<svg class="i"><use href="#i-refresh"/></svg>
</button>
</div>
<input id="sess-search" class="sess-search" type="search" placeholder="搜索会话" autocomplete="off">
<div id="sessions-list" class="sessions"></div>
<div class="side-foot">
<div class="who">
<span class="avatar">C</span>
<span class="who-text">
<b id="foot-ws">workspace</b>
<i id="foot-session">-</i>
</span>
</div>
<button id="btn-settings" class="icon" title="设置">
<svg class="i"><use href="#i-settings"/></svg>
</button>
</div>
</div>
</aside>
<!-- main column -->
<main id="main">
<header id="topbar">
<button id="btn-side" class="icon" title="展开侧栏 (Ctrl+B)">
<svg class="i"><use href="#i-panel-left"/></svg>
</button>
<button id="btn-drawer" class="icon only-narrow" title="侧栏">
<svg class="i"><use href="#i-menu"/></svg>
</button>
<button id="chip-model" class="chip model" title="模型与推理强度">
<svg class="i"><use href="#i-cpu"/></svg><span class="lbl">-</span>
<svg class="i chev"><use href="#i-chevron"/></svg>
</button>
<span class="grow"></span>
<div class="seg" id="seg-mode" title="计划模式只读,不改文件">
<button data-mode="build">构建</button>
<button data-mode="plan">计划</button>
</div>
<button id="chip-approval" class="chip quiet" title="审批权限">
<svg class="i"><use href="#i-shield"/></svg><span class="lbl">auto</span>
<svg class="i chev"><use href="#i-chevron"/></svg>
</button>
<button id="btn-theme" class="icon" title="明暗切换">
<svg class="i only-dark"><use href="#i-moon"/></svg>
<svg class="i only-light"><use href="#i-sun"/></svg>
</button>
<button id="btn-panel" class="icon" title="运行详情 (Ctrl+J)">
<svg class="i"><use href="#i-panel-right"/></svg>
</button>
<button id="btn-menu" class="icon" title="更多">
<svg class="i"><use href="#i-more"/></svg>
</button>
</header>
<div id="scroll">
<section id="empty">
<p class="ready">准备好开始构建了吗</p>
</section>
<div id="log"></div>
</div>
<button id="to-bottom" class="round" hidden title="回到最新">
<svg class="i"><use href="#i-chevron"/></svg>
</button>
<footer id="composer">
<div id="pending" class="pending" hidden></div>
<div id="edit-bar" class="edit-bar" hidden>
<span>编辑上一条消息:发送后替换它并重新回答(文件改动保留,需要时先撤销)</span>
<span class="grow"></span>
<button id="btn-edit-cancel" class="link">取消</button>
</div>
<!-- permission gate: inline, the turn really is waiting on it -->
<div id="gate" class="gate" hidden>
<div class="gate-head">
<span class="gate-title" id="gate-title">需要确认</span>
<span class="grow"></span>
<span class="gate-timer" id="gate-timer"></span>
</div>
<pre id="gate-detail"></pre>
<div class="gate-keys" id="gate-keys"></div>
<div class="gate-row" id="gate-buttons"></div>
</div>
<div id="slash" class="slash" role="listbox" hidden></div>
<div class="box">
<textarea id="input" rows="1" placeholder="给 CST-Agent 派个任务…(/ 打开命令,F1 看快捷键)"></textarea>
<div class="box-row">
<button id="btn-plus" class="icon att" title="添加图片或计划模式">
<svg class="i"><use href="#i-plus"/></svg>
</button>
<button id="btn-undo" class="chip quiet" title="撤销上一次文件写入">
<svg class="i"><use href="#i-undo"/></svg><span>撤销</span>
</button>
<span class="grow"></span>
<button id="cost" class="cost" title="用量详情"></button>
<button id="btn-stop" class="icon stop" hidden title="停止">
<svg class="i"><use href="#i-stop"/></svg>
</button>
<button id="btn-send" class="round send" title="发送 (Enter)">
<svg class="i"><use href="#i-send"/></svg>
</button>
</div>
</div>
<div class="hint-line">
<span id="status">就绪</span>
<span class="grow"></span>
<span id="usage" class="quiet"></span>
</div>
<input type="file" id="file" accept="image/*" multiple hidden>
</footer>
</main>
<!-- right panel: the run, not the chat -->
<aside id="panel">
<div class="panel-inner">
<div class="panel-head">
<span>运行详情</span>
<span class="grow"></span>
<button id="btn-panel-close" class="icon tiny" title="关闭">
<svg class="i"><use href="#i-close"/></svg>
</button>
</div>
<section class="card" id="card-todo">
<h4>任务清单</h4>
<div id="todo-body" class="muted">模型还没有给出任务清单。</div>
</section>
<section class="card" id="card-files">
<h4>文件改动 <span class="badge" id="files-count">0</span>
<span class="grow"></span><button id="btn-diff" class="link" title="git status + git diff">查看 diff</button></h4>
<div id="files-body" class="muted">本轮还没有改动文件。</div>
</section>
<section class="card">
<h4>上下文</h4>
<div class="kv"><span>占用</span><b id="kv-ctx">0</b></div>
<div class="kv"><span>缓存命中</span><b id="kv-cache">-</b></div>
</section>
</div>
</aside>
</div>
<div id="backdrop"></div>
<div id="pop" class="pop" hidden></div>
<div id="modal" hidden><div id="modal-box"></div></div>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script>
/* Layout is decided here, before app.js runs and before the first paint: the
* rail must not flash open and then fold, and a broken script further down can
* no longer take the layout down with it. */
(() => {
const q = new URLSearchParams(location.search);
const pick = (key) => {
const v = q.get(key);
if (v === '0' || v === '1') return v === '0';
return localStorage.getItem('cst.' + key) === '0';
};
if (pick('side')) document.body.classList.add('no-side');
if (pick('panel')) document.body.classList.add('no-panel');
const t = q.get('theme') || localStorage.getItem('cst.theme');
if (t) document.documentElement.dataset.theme = t;
})();
</script>
<script src="app.js"></script>
</body>
</html>
+827
View File
@@ -0,0 +1,827 @@
/* CST-Agent WebUI - one stylesheet, no build step.
*
* Design rules
* - one fact, one home: sessions in the rail, model/approval/plan in the
* top bar, the run in the right panel, attachments in the composer;
* - settings open as popovers anchored to the control that owns them,
* with icon + label + one line of explanation + a check on the current
* value (the pattern all three reference apps use);
* - motion is short and eased: 120ms for feedback, 180ms for entrances,
* nothing bounces, and prefers-reduced-motion turns it all off. */
/* Tokens are named the way the reference desktop app names them: four text
* levels, three surfaces per zone (main / sidebar / composer), and borders
* mixed out of the foreground rather than hard-coded greys - which is what
* keeps the dark and the light sheet on the same visual rhythm. */
:root {
color-scheme: dark;
--text-primary: #f2f2f5;
--text-secondary: #a9a9b4;
--text-tertiary: #7d7d89;
--text-quaternary: #5e5e69;
--text-error: #f0616d;
--text-success: #3ecf95;
--main-surface-primary: #0d0d0f;
--main-surface-secondary: #141417;
--main-surface-tertiary: #1d1d22;
--sidebar-surface-primary: #111114;
--sidebar-surface-secondary: #18181c;
--sidebar-surface-tertiary: #202026;
--composer-surface-primary: #16161a;
--composer-surface-secondary: #202026;
--bg-code: #08080a;
/* every hover/active tint is alpha, so it lands correctly on any surface */
--hover: color-mix(in srgb, var(--text-primary) 7%, transparent);
--hover-strong: color-mix(in srgb, var(--text-primary) 13%, transparent);
--accent-primary: #3ecf95;
--accent-fg: #04241a;
--accent-weak: color-mix(in srgb, var(--accent-primary) 14%, transparent);
--accent-line: color-mix(in srgb, var(--accent-primary) 55%, transparent);
--link: var(--accent-primary);
--violet: #8b7bff;
--warn: #e0b341;
--selection-bg: color-mix(in srgb, var(--accent-primary) 30%, transparent);
--selection-fg: var(--text-primary);
--border-light: color-mix(in srgb, var(--text-primary) 14%, transparent);
--border-medium: color-mix(in srgb, var(--text-primary) 24%, transparent);
--border-heavy: color-mix(in srgb, var(--text-primary) 34%, transparent);
/* short aliases: the rest of the sheet keeps reading the same way */
--bg: var(--main-surface-primary);
--bg-soft: var(--main-surface-secondary);
--bg-lift: var(--main-surface-tertiary);
--bg-hover: var(--hover);
--fg: var(--text-primary);
--fg-dim: var(--text-secondary);
--fg-faint: var(--text-tertiary);
--fg-quat: var(--text-quaternary);
--line: var(--border-light);
--line-strong: var(--border-medium);
--accent: var(--accent-primary);
--danger: var(--text-error);
--rail: 252px;
--panel: 296px;
--radius: 12px;
--radius-lg: 22px;
--shadow: 0 16px 40px rgba(0, 0, 0, .45);
--shadow-sm: 0 6px 18px rgba(0, 0, 0, .3);
--mono: ui-monospace, SFMono-Regular, "JetBrains Mono", Menlo, Consolas, monospace;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, "Noto Sans CJK SC", sans-serif;
--ease: cubic-bezier(.22, .61, .36, 1);
--ease-pop: cubic-bezier(.2, .9, .3, 1.06); /* enters with a hint of overshoot */
--t-fast: 120ms;
--t: 180ms;
}
[data-theme="light"] {
color-scheme: light;
--text-primary: #14141a;
--text-secondary: #55555f;
--text-tertiary: #7c7c86;
--text-quaternary: #9c9ca6;
--text-error: #c8303f;
--text-success: #0f8f66;
--main-surface-primary: #ffffff;
--main-surface-secondary: #f7f7f9;
--main-surface-tertiary: #ffffff;
--sidebar-surface-primary: #fafafb;
--sidebar-surface-secondary: #f1f1f4;
--sidebar-surface-tertiary: #ffffff;
--composer-surface-primary: #ffffff;
--composer-surface-secondary: #f4f4f6;
--bg-code: #f5f5f7;
--accent-primary: #0f9d70;
--accent-fg: #ffffff;
--border-light: color-mix(in srgb, var(--text-primary) 12%, transparent);
--border-medium: color-mix(in srgb, var(--text-primary) 20%, transparent);
--border-heavy: color-mix(in srgb, var(--text-primary) 30%, transparent);
--warn: #a9761a;
--shadow: 0 16px 40px rgba(20, 20, 35, .14);
--shadow-sm: 0 6px 18px rgba(20, 20, 35, .1);
}
* { box-sizing: border-box; }
html, body { height: 100%; margin: 0; }
body {
background: var(--bg); color: var(--fg);
font: 15px/1.62 var(--sans);
-webkit-font-smoothing: antialiased;
-webkit-text-size-adjust: 100%;
overflow: hidden; /* columns scroll, never the page */
}
button, textarea, input { font: inherit; color: inherit; }
button { cursor: pointer; background: none; border: 0; }
button:disabled { cursor: default; }
.grow { flex: 1 1 auto; }
.quiet, .muted { color: var(--fg-dim); }
.tiny { font-size: 12px; }
[hidden] { display: none !important; }
::selection { background: var(--selection-bg); color: var(--selection-fg); }
/* one icon system: everything is drawn on the same 24px grid, stroked with
* the current text colour, so header, rail, popovers and composer match */
svg.i {
width: 18px; height: 18px; flex: 0 0 auto; display: block;
fill: none; stroke: currentColor; stroke-width: 1.7;
stroke-linecap: round; stroke-linejoin: round;
}
svg.i .f { fill: currentColor; stroke: none; }
.icon svg.i { width: 18px; height: 18px; }
.icon.tiny svg.i { width: 15px; height: 15px; }
.chip svg.i { width: 15px; height: 15px; }
.chip .chev { width: 13px; height: 13px; opacity: .6; }
.new-task svg.i { width: 17px; height: 17px; }
.send svg.i { width: 19px; height: 19px; stroke-width: 2; }
svg.i.only-light { display: none; }
[data-theme="light"] svg.i.only-dark { display: none; }
[data-theme="light"] svg.i.only-light { display: block; }
/* the changed-file rows in the run panel: path, then +added / -removed */
.fstat { display: flex; align-items: center; gap: 8px; padding: 5px 0; font-size: 13px; animation: fade-in var(--t) var(--ease) both; }
.fstat .fimg { width: 15px; height: 15px; color: var(--text-quaternary); }
.fstat .p {
flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
white-space: nowrap; font-family: var(--mono); font-size: 12px;
}
.fstat .a, .fstat .d { font-variant-numeric: tabular-nums; font-size: 12px; }
.fstat .a { color: var(--text-success); }
.fstat .d { color: var(--text-error); }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb {
background: var(--line-strong); border-radius: 99px;
border: 3px solid transparent; background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover { background: var(--fg-faint); background-clip: content-box; }
/* --- frame ---------------------------------------------------------------- */
#app {
display: grid;
grid-template-columns: var(--rail) minmax(0, 1fr) var(--panel);
grid-template-rows: minmax(0, 1fr);
height: 100dvh;
/* the rail keeps its track and collapses to 0px, so the two states have the
* same track count and the column width can actually be interpolated */
transition: grid-template-columns 300ms var(--ease);
}
/* a zeroed track plus display:none used to slide #main into column one and
* crush it to nothing; the rail now stays in flow and is clipped instead */
body.no-side #app { grid-template-columns: 0px minmax(0, 1fr) var(--panel); }
body.no-panel #app { grid-template-columns: var(--rail) minmax(0, 1fr) 0px; }
body.no-side.no-panel #app { grid-template-columns: 0px minmax(0, 1fr) 0px; }
/* the expand control only exists while the rail is folded away, so exactly one
* toggle is ever on screen */
#btn-side { display: none; }
body.no-side #btn-side { display: inline-flex; }
.icon {
width: 32px; height: 32px; border-radius: 9px; flex: 0 0 auto;
display: inline-flex; align-items: center; justify-content: center;
color: var(--fg-dim); font-size: 15px;
transition: background var(--t-fast), color var(--t-fast), transform 90ms var(--ease);
}
.icon:hover { background: var(--bg-hover); color: var(--fg); }
.icon:active { transform: scale(.94); }
.icon.tiny { width: 26px; height: 26px; font-size: 13px; }
.round { border-radius: 50%; }
.chip {
display: inline-flex; align-items: center; gap: 5px; height: 30px;
padding: 0 11px; border-radius: 99px; border: 1px solid transparent;
background: none; color: var(--fg-dim); font-size: 13px;
max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast), transform 90ms var(--ease);
}
.chip:hover { background: var(--bg-hover); color: var(--fg); }
.chip:active { transform: scale(.97); }
.chip.model { color: var(--fg); font-weight: 600; background: var(--bg-lift); border-color: var(--line); }
.chip.model:hover { border-color: var(--line-strong); }
.chip.warn { color: var(--warn); }
.chip .chev { font-size: 11px; opacity: .65; }
.badge {
min-width: 18px; height: 18px; padding: 0 5px; border-radius: 99px;
background: var(--accent-weak); color: var(--accent);
font-size: 11px; font-weight: 700;
display: inline-flex; align-items: center; justify-content: center;
}
/* --- rail ----------------------------------------------------------------- */
#side {
background: var(--sidebar-surface-primary); border-right: 1px solid var(--border-light);
min-width: 0; overflow: hidden; /* clips the rail while its track collapses */
}
/* the contents keep their own width and slide out, so nothing reflows mid-flight
* and the exit reads as one movement instead of a squeeze */
.side-inner {
width: var(--rail); height: 100%; min-width: 0;
display: flex; flex-direction: column; padding: 12px 10px 10px;
transition: transform 300ms var(--ease-pop), opacity 200ms var(--ease);
}
body.no-side .side-inner { transform: translateX(-18px); opacity: 0; }
body.no-side #side { pointer-events: none; }
.side-head { display: flex; align-items: center; gap: 2px; margin-bottom: 14px; }
/* the wordmark is a label, not a control: no logo tile, nothing to click */
.brand { flex: 1 1 auto; display: flex; align-items: center; min-width: 0; padding: 6px 8px; }
.brand-name {
font-weight: 700; letter-spacing: .2px; font-size: 15px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.new-task {
display: flex; align-items: center; gap: 9px; height: 40px; padding: 0 12px;
margin-bottom: 18px; border-radius: 12px; font-weight: 600;
background: var(--bg-lift); border: 1px solid var(--line);
transition: border-color var(--t-fast), background var(--t-fast), transform 90ms var(--ease);
}
.new-task:hover { border-color: var(--line-strong); background: var(--bg-hover); }
.new-task:active { transform: scale(.985); }
.new-task .plus { color: var(--accent); font-size: 16px; line-height: 1; }
.side-sec {
display: flex; align-items: center; gap: 6px; padding: 0 8px 6px;
color: var(--fg-faint); font-size: 11px; font-weight: 600;
letter-spacing: .7px; text-transform: uppercase;
}
.sessions { flex: 1 1 auto; overflow-y: auto; min-height: 40px; scrollbar-gutter: stable; }
.sess-item {
display: flex; align-items: baseline; gap: 8px; width: 100%;
padding: 7px 9px; border-radius: 9px; text-align: left;
color: var(--text-secondary); font-size: 13.5px;
animation: fade-in var(--t) var(--ease) both;
transition: background var(--t-fast), color var(--t-fast);
}
.sess-item:hover { background: var(--hover); color: var(--text-primary); }
.sess-item.on { background: var(--sidebar-surface-tertiary); color: var(--text-primary); font-weight: 600; }
.sess-item.on::before {
content: ""; width: 5px; height: 5px; border-radius: 50%;
background: var(--accent); flex: 0 0 auto; align-self: center;
}
.sess-item .t { flex: 1 1 auto; min-width: 0; overflow: hidden; }
.sess-item .ts { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* a title that does not fit fades at the edge and scrolls itself on hover, the
* way the reference sidebar handles long names (its data-marquee-text pattern) */
.sess-item .t.mq { mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
.sess-item:hover .t.mq { mask-image: none; }
.sess-item:hover .t.mq .ts {
width: max-content; text-overflow: clip;
animation: marquee 2.6s var(--ease) 160ms infinite alternate;
}
@keyframes marquee { from { transform: none; } to { transform: translateX(calc(-1 * var(--mq, 0px))); } }
.sess-item .n { font-size: 11px; color: var(--text-quaternary); font-weight: 400; white-space: nowrap; }
.side-foot {
display: flex; align-items: center; gap: 6px; padding-top: 10px;
margin-top: 6px; border-top: 1px solid var(--line);
}
.who { display: flex; align-items: center; gap: 9px; min-width: 0; flex: 1 1 auto; }
.avatar {
width: 28px; height: 28px; border-radius: 50%; flex: 0 0 auto;
background: linear-gradient(135deg, var(--violet), var(--accent));
color: #fff; font-weight: 800; font-size: 13px;
display: flex; align-items: center; justify-content: center;
}
.who-text { min-width: 0; display: flex; flex-direction: column; line-height: 1.3; }
.who-text b { font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.who-text i { font-size: 11px; color: var(--fg-faint); font-style: normal; }
/* --- top bar -------------------------------------------------------------- */
#main { display: flex; flex-direction: column; min-width: 0; position: relative; }
#topbar { display: flex; align-items: center; gap: 4px; padding: 9px 12px; border-bottom: 1px solid var(--line); }
.seg {
display: flex; background: var(--bg-soft); border: 1px solid var(--line);
border-radius: 99px; padding: 2px; position: relative;
}
.seg button {
height: 25px; padding: 0 12px; border-radius: 99px; color: var(--fg-dim); font-size: 12.5px;
transition: color var(--t-fast), background var(--t);
}
.seg button.on { background: var(--bg-lift); color: var(--fg); font-weight: 600; }
.only-narrow { display: none; }
#scroll {
flex: 1 1 auto; overflow-y: auto; padding: 22px 0 10px;
scroll-behavior: smooth; scrollbar-gutter: stable;
}
#log { max-width: 760px; margin: 0 auto; padding: 0 20px; }
#log:empty { display: none; }
/* --- empty state ---------------------------------------------------------- */
/* the first screen is one line: no logo, no suggestion wall. It goes away the
* moment the first message lands (see showEmpty). */
#empty { margin: 22vh auto 0; padding: 0 20px; text-align: center; }
#empty.gone { display: none; }
#empty .ready {
margin: 0; color: var(--text-tertiary); font-size: 15px; letter-spacing: .3px;
animation: fade-in 320ms var(--ease) both;
}
/* --- transcript ----------------------------------------------------------- */
.msg { display: flex; gap: 12px; padding: 13px 0 0; animation: fade-up var(--t) var(--ease) both; }
.msg .gutter { flex: 0 0 24px; padding-top: 2px; color: var(--fg-faint); font-size: 11px; text-align: center; user-select: none; }
.msg .body { min-width: 0; flex: 1 1 auto; }
.msg.user { justify-content: flex-end; }
.msg.user .gutter { display: none; }
.msg.user .body {
background: var(--bg-lift); border-radius: 18px 18px 6px 18px;
padding: 9px 15px; max-width: 76%; white-space: pre-wrap; word-break: break-word;
}
.msg.error .body { color: var(--danger); white-space: pre-wrap; }
.body > :first-child, .live-html > :first-child { margin-top: 0; }
.body > :last-child, .live-html > :last-child { margin-bottom: 0; }
.body h1, .body h2, .body h3, .body h4 { line-height: 1.35; margin: 1.1em 0 .5em; }
.body h1 { font-size: 1.45em; } .body h2 { font-size: 1.25em; } .body h3 { font-size: 1.1em; }
.body p, .body ul, .body ol { margin: .55em 0; }
.body ul, .body ol { padding-left: 1.4em; }
.body li { margin: .22em 0; }
.body a { color: var(--link); }
.body code {
font-family: var(--mono); font-size: .88em; background: var(--bg-code);
border: 1px solid var(--line); border-radius: 6px; padding: .1em .35em;
}
.body pre {
background: var(--bg-code); border: 1px solid var(--line);
border-radius: 12px; padding: 12px 14px; overflow-x: auto; margin: .75em 0;
}
.body pre code { background: none; border: 0; padding: 0; font-size: 13px; }
.body blockquote { margin: .75em 0; padding: .1em 0 .1em 12px; border-left: 3px solid var(--line-strong); color: var(--fg-dim); }
.body table { border-collapse: collapse; margin: .75em 0; font-size: 13.5px; display: block; overflow-x: auto; }
.body th, .body td { border: 1px solid var(--line); padding: 5px 9px; text-align: left; }
.body th { background: var(--bg-soft); }
.body img { max-width: 100%; border-radius: 10px; }
.body hr { border: 0; border-top: 1px solid var(--line); margin: 1.2em 0; }
/* a caret while tokens are still arriving */
.body.live > .live-html > :last-child::after {
content: ""; display: inline-block; width: 7px; height: 14px;
margin-left: 3px; vertical-align: -2px; border-radius: 2px;
background: var(--accent); animation: blink 1.1s steps(2, start) infinite;
}
.attach-strip { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; justify-content: flex-end; }
.attach-strip img {
width: 108px; height: 108px; object-fit: cover; border-radius: 12px;
border: 1px solid var(--line); animation: rise var(--t) var(--ease) both;
}
.turn-meta { display: flex; align-items: center; gap: 10px; padding: 10px 0 2px 36px; }
.turn-meta details > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12px; transition: color var(--t-fast); }
.turn-meta details > summary::-webkit-details-marker { display: none; }
.turn-meta details > summary:hover { color: var(--fg-dim); }
.msg:has(> .msg-actions) { flex-wrap: wrap; row-gap: 0; }
.msg-actions { display: flex; flex-basis: 100%; gap: 2px; padding: 4px 0 0 36px; opacity: 0; transition: opacity var(--t) var(--ease); }
.msg:hover .msg-actions, .msg-actions:focus-within { opacity: 1; }
.msg-actions button {
height: 26px; padding: 0 9px; border-radius: 8px; color: var(--fg-faint); font-size: 12px;
transition: background var(--t-fast), color var(--t-fast);
}
.msg-actions button:hover { background: var(--bg-hover); color: var(--fg); }
.think { margin: .1em 0 .5em; }
.think summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); }
.think summary::-webkit-details-marker { display: none; }
.think summary::before { content: "▸ "; }
.think[open] summary::before { content: "▾ "; }
.think summary:hover { color: var(--fg-dim); }
.think > div {
color: var(--fg-dim); font-size: 13.5px; white-space: pre-wrap;
padding: 6px 0 2px 14px; border-left: 2px solid var(--line);
animation: fade-in var(--t) var(--ease) both;
}
/* tool calls: one line, opens on demand (like "已执行 python · <<PY>") */
.turn-steps { padding: 6px 0 0 36px; }
.turn-steps > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); }
.turn-steps > summary::-webkit-details-marker { display: none; }
.turn-steps > summary::before { content: "⌘ "; }
.turn-steps > summary:hover { color: var(--fg-dim); }
.steps-box { padding: 2px 0 0 12px; margin-top: 4px; border-left: 2px solid var(--line); }
.step-line { animation: fade-up var(--t) var(--ease) both; }
.step-line > summary {
display: flex; align-items: baseline; gap: 8px; cursor: pointer; list-style: none;
padding: 5px 0; font-size: 13px; color: var(--fg-dim);
transition: color var(--t-fast);
}
.step-line > summary::-webkit-details-marker { display: none; }
.step-line > summary:hover { color: var(--fg); }
.step-line .name { font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
.step-line .args { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12px; }
.step-line .phase { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; }
.step-line.run .phase { color: var(--warn); }
.step-line.run .phase::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: var(--warn); animation: pulse 1.1s var(--ease) infinite;
}
.step-line.bad .phase, .step-line.bad .name { color: var(--danger); }
.step-line.done .phase::before { content: "✓"; }
.step-line.bad .phase::before { content: "✕"; }
/* a sub-agent's latest steps, next to its task */
.step-line .step-detail {
flex: 0 1 40%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-family: var(--mono); font-size: 11.5px; color: var(--fg-faint);
}
.step-line.done .step-detail { display: none; }
/* "[cst] plan mode is now ON" and other engine notes in the history */
.note-line {
padding: 6px 0 2px 36px; font-size: 12px; color: var(--fg-faint);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.note-line::before { content: "· "; }
.step-body { padding: 0 0 6px; animation: fade-in var(--t) var(--ease) both; }
.step-out, .step-args-full {
font-family: var(--mono); font-size: 12px; background: var(--bg-code);
border: 1px solid var(--line); border-radius: 10px; padding: 9px 11px;
margin: 4px 0 0; max-height: 300px; overflow: auto; white-space: pre-wrap; word-break: break-word;
}
#to-bottom {
position: absolute; right: 26px; bottom: 172px; z-index: 5;
width: 38px; height: 38px; display: flex; align-items: center; justify-content: center;
background: var(--bg-lift); border: 1px solid var(--line-strong); color: var(--fg-dim);
box-shadow: var(--shadow-sm); font-size: 16px;
animation: fade-in var(--t) var(--ease) both;
transition: color var(--t-fast), transform 90ms var(--ease), background var(--t-fast);
}
#to-bottom:hover { color: var(--fg); background: var(--bg-hover); }
#to-bottom:active { transform: scale(.94); }
/* --- composer ------------------------------------------------------------- */
#composer { padding: 8px 20px 12px; }
#composer > * { max-width: 760px; margin: 0 auto; }
.pending { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 0 8px; }
.pending .chip { background: var(--bg-soft); border-color: var(--line); }
.edit-bar {
display: flex; align-items: center; gap: 8px; padding: 6px 10px; margin-bottom: 8px;
border-radius: 10px; border: 1px solid var(--line); background: var(--bg-soft);
color: var(--fg-dim); font-size: 12.5px;
}
.edit-bar[hidden] { display: none; }
.msg.user .msg-actions { justify-content: flex-end; padding-left: 0; }
.sess-search {
width: calc(100% - 8px); margin: 0 4px 8px; height: 30px; padding: 0 10px;
border-radius: 9px; border: 1px solid var(--line); background: var(--bg-soft);
color: var(--fg); font-size: 13px;
}
.sess-item { cursor: pointer; }
#modal-box .sess-list { max-height: min(56vh, 520px); overflow-y: auto; margin: 4px 0 10px; }
#modal-box .sess button { flex: 0 0 auto; }
.sess-item .more {
flex: 0 0 auto; align-self: center; width: 22px; height: 22px; border-radius: 6px;
display: grid; place-items: center; color: var(--fg-faint); opacity: 0;
transition: opacity var(--t-fast), background var(--t-fast);
}
.sess-item:hover .more, .sess-item .more:focus-visible { opacity: 1; }
.sess-item .more:hover { background: var(--bg-hover); color: var(--fg); }
.sess-item .more .i { width: 16px; height: 16px; }
#modal-box .sess button.danger { color: var(--danger, #e5484d); }
button.fstat { width: 100%; text-align: left; border-radius: 7px; transition: background var(--t-fast); }
button.fstat:hover { background: var(--bg-hover); }
.danger-text { color: var(--danger); }
#modal-box.wide { width: min(980px, 100%); }
#modal-box h4.sub { margin: 16px 0 6px; font-size: 13px; color: var(--fg-dim); font-weight: 650; }
#modal-box .field select, #modal-box .field input[type=number] {
flex: 1 1 auto; min-width: 150px; background: var(--bg-lift); color: var(--fg);
border: 1px solid var(--line); border-radius: 10px; padding: 7px 10px; outline: none;
}
#modal-box .field > span { width: 84px; }
#modal-box .checks { display: flex; flex-wrap: wrap; gap: 14px; }
#modal-box .check { display: inline-flex; align-items: center; gap: 6px; font-size: 13px; cursor: pointer; }
#modal-box .tool-list { max-height: 34vh; overflow-y: auto; border: 1px solid var(--line); border-radius: 10px; padding: 4px; }
#modal-box .tool-row {
display: flex; align-items: center; gap: 8px; padding: 5px 6px; border-radius: 7px;
font-size: 12.5px; cursor: pointer;
}
#modal-box .tool-row:hover { background: var(--bg-hover); }
#modal-box .tool-row.dim code { opacity: .55; }
#modal-box .tool-row code { font-family: var(--mono); font-size: 12px; flex: 0 0 auto; }
#modal-box .tool-row .perm { flex: 0 0 auto; color: var(--fg-faint); font-size: 11px; border: 1px solid var(--line); border-radius: 6px; padding: 0 5px; }
#modal-box .tool-row .desc { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--fg-dim); }
#modal-box .diff-files { display: flex; flex-direction: column; gap: 1px; max-height: 24vh; overflow-y: auto; margin-bottom: 10px; }
#modal-box .diff-file {
display: flex; gap: 10px; align-items: center; text-align: left; padding: 5px 8px;
border-radius: 7px; font-size: 12.5px;
}
#modal-box .diff-file:hover { background: var(--bg-hover); }
#modal-box .diff-file.on { background: var(--accent-weak); }
#modal-box .diff-file .st { flex: 0 0 52px; color: var(--fg-faint); font-size: 11.5px; }
#modal-box .diff-file .p { font-family: var(--mono); overflow-wrap: anywhere; }
pre.diff {
margin: 0; padding: 10px 0; max-height: 52vh; overflow: auto; border-radius: 10px;
background: var(--bg-soft); border: 1px solid var(--line);
font-family: var(--mono); font-size: 12px; line-height: 1.5;
}
pre.diff span { display: block; padding: 0 12px; white-space: pre; min-height: 1.5em; }
pre.diff .add { color: var(--text-success); background: color-mix(in srgb, var(--text-success) 10%, transparent); }
pre.diff .del { color: var(--text-error); background: color-mix(in srgb, var(--text-error) 10%, transparent); }
pre.diff .hunk { color: var(--accent); }
pre.diff .h { color: var(--fg-faint); font-weight: 600; }
.box {
border: 1px solid var(--border-medium); border-radius: var(--radius-lg);
background: var(--composer-surface-primary); padding: 11px 13px 9px;
transition: border-color var(--t), box-shadow var(--t), background var(--t);
}
.box:hover { border-color: var(--border-heavy); }
.box:focus-within {
border-color: var(--accent-line);
box-shadow: 0 0 0 3px var(--accent-weak);
}
.box textarea {
width: 100%; border: 0; background: none; resize: none; outline: none;
max-height: 240px; min-height: 26px; padding: 2px 2px 8px;
caret-color: var(--accent);
}
.box textarea::placeholder { color: var(--fg-faint); }
.box-row { display: flex; align-items: center; gap: 6px; }
.att { border: 1px solid var(--line); font-size: 17px; line-height: 1; }
.cost { color: var(--fg-faint); font-size: 12px; padding: 0 4px; white-space: nowrap; }
.cost:hover { color: var(--fg-dim); }
.send {
width: 36px; height: 36px; flex: 0 0 auto; font-size: 17px; font-weight: 800;
background: var(--accent); color: var(--accent-fg);
transition: filter var(--t-fast), transform 90ms var(--ease), opacity var(--t-fast);
}
.send:hover { filter: brightness(1.08); }
.send:active { transform: scale(.94); }
.send:disabled { opacity: .38; }
.icon.stop { border: 1px solid var(--line); font-size: 12px; }
.hint-line { display: flex; align-items: center; gap: 10px; padding: 7px 6px 0; font-size: 12px; color: var(--fg-faint); }
/* permission gate: inline, the turn really is waiting on it */
.gate {
border: 1px solid color-mix(in srgb, var(--warn) 45%, transparent);
border-radius: var(--radius); padding: 11px 13px; margin-bottom: 10px;
background: color-mix(in srgb, var(--warn) 7%, var(--bg-soft));
animation: rise var(--t) var(--ease) both;
}
.gate-head { display: flex; align-items: center; gap: 8px; font-weight: 650; font-size: 14px; }
.gate-timer { color: var(--warn); font-size: 12.5px; font-variant-numeric: tabular-nums; }
.gate pre {
margin: 8px 0 0; background: var(--bg-code); border: 1px solid var(--line);
border-radius: 10px; padding: 9px 11px; max-height: 180px; overflow: auto;
font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word;
}
.gate-keys { padding: 8px 0 2px; color: var(--fg-dim); font-size: 12.5px; }
.gate-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
.gate-row button {
height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600;
border: 1px solid var(--line-strong); background: var(--bg-lift);
transition: filter var(--t-fast), transform 90ms var(--ease), background var(--t-fast);
}
.gate-row button.ok { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
.gate-row button.danger { color: var(--danger); }
.gate-row button:hover { filter: brightness(1.12); }
.gate-row button:active { transform: scale(.97); }
/* --- popovers: anchored, animated, and the only place settings live ------- */
.pop {
position: fixed; z-index: 45; min-width: 236px; max-width: 320px;
background: var(--main-surface-tertiary); border: 1px solid var(--border-medium);
border-radius: 14px; padding: 6px; box-shadow: var(--shadow);
animation: pop-in 160ms var(--ease) both;
}
.pop-item {
display: flex; align-items: flex-start; gap: 10px; width: 100%;
padding: 9px 10px; border-radius: 10px; text-align: left;
transition: background var(--t-fast);
}
.pop-item:hover { background: var(--bg-hover); }
.pop-item .pi { width: 18px; flex: 0 0 auto; text-align: center; color: var(--fg-dim); font-size: 14px; line-height: 1.4; }
/* title over description: two lines, never one run-on line */
.pop-item .pm { min-width: 0; flex: 1 1 auto; display: flex; flex-direction: column; gap: 2px; }
.pop-item .pt { font-size: 13.5px; font-weight: 600; line-height: 1.35; }
.pop-item .pd { font-size: 11.5px; color: var(--fg-faint); line-height: 1.4; }
.pop-item .pk { flex: 0 0 auto; color: var(--accent); font-size: 13px; }
.pop-item.on { background: var(--accent-weak); }
.pop-item.on .pi, .pop-item.on .pt { color: var(--accent); }
.pop-item.danger .pi, .pop-item.danger .pt { color: var(--danger); }
.pop-sep { height: 1px; background: var(--line); margin: 5px 4px; }
.pop-title { padding: 8px 10px 4px; color: var(--fg-faint); font-size: 11.5px; font-weight: 600; letter-spacing: .4px; }
.pop-input { display: flex; gap: 6px; padding: 4px 6px 6px; }
.pop-input input {
flex: 1 1 auto; min-width: 0; background: var(--bg-soft); border: 1px solid var(--line);
border-radius: 9px; padding: 7px 9px; outline: none; font-size: 13px;
caret-color: var(--accent);
transition: border-color var(--t-fast);
}
.pop-input input:focus { border-color: var(--accent-line); }
.pop-input button {
height: 32px; padding: 0 12px; border-radius: 9px; font-weight: 600; font-size: 13px;
background: var(--accent); color: var(--accent-fg);
transition: filter var(--t-fast), transform 90ms var(--ease);
}
.pop-input button:active { transform: scale(.96); }
.kv { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 5px 10px; font-size: 13px; }
.kv span { color: var(--fg-dim); }
.kv b { font-variant-numeric: tabular-nums; font-weight: 600; min-width: 0; overflow-wrap: anywhere; text-align: right; }
.pop { max-height: calc(100vh - 16px); overflow-y: auto; }
.pop:has(.pop-list) { min-width: min(360px, 94vw); max-width: min(440px, 94vw); }
.pop-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.pop-list { max-height: min(46vh, 420px); overflow-y: auto; }
.pop-list .pop-item .pt { overflow-wrap: anywhere; font-family: var(--mono); font-size: 12.5px; }
.pop-empty { padding: 10px; color: var(--fg-faint); font-size: 12.5px; }
.pop-note { padding: 2px 10px 6px; color: var(--fg-faint); font-size: 11.5px; line-height: 1.45; }
.pop-note:empty { display: none; }
.pop-note.bad { color: var(--danger); }
.pop-note.warn { color: var(--warn); }
.pop-item.dim { opacity: .55; }
button.link { color: var(--link); padding: 0 4px; font-size: inherit; text-decoration: underline; }
.ok-text { color: var(--text-success); }
.warn-text { color: var(--warn); }
/* providers and the key dialog */
#modal-box .prov {
display: flex; align-items: center; gap: 10px; padding: 9px 8px;
border-bottom: 1px solid var(--line); border-radius: 8px;
}
#modal-box .prov.on { background: var(--accent-weak); }
#modal-box .prov .pm { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
#modal-box .prov .pm span { overflow-wrap: anywhere; }
#modal-box .prov-acts button {
height: 30px; padding: 0 12px; border-radius: 9px; font-weight: 600; font-size: 13px;
border: 1px solid var(--line-strong); background: var(--bg-lift); white-space: nowrap;
}
#modal-box .prov-acts button.ok { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
#modal-box .key-input {
width: 100%; margin-top: 12px; background: var(--bg-lift); border: 1px solid var(--line);
border-radius: 10px; padding: 9px 11px; outline: none; font-family: var(--mono);
}
#modal-box .key-input:focus { border-color: var(--accent-line); }
#modal-box .key-err { min-height: 18px; margin-top: 6px; color: var(--danger); font-size: 12.5px; }
/* one line of feedback for anything that failed or needs a look */
.toast {
position: fixed; left: 50%; bottom: 24px; transform: translateX(-50%); z-index: 60;
max-width: min(560px, 92vw); padding: 10px 14px; border-radius: 12px;
background: var(--main-surface-tertiary); border: 1px solid var(--border-medium);
box-shadow: var(--shadow); font-size: 13px; line-height: 1.45; overflow-wrap: anywhere;
animation: fade-up 160ms var(--ease) both;
}
.toast.err { border-color: color-mix(in srgb, var(--danger) 55%, transparent); color: var(--danger); }
.toast.warn { border-color: color-mix(in srgb, var(--warn) 55%, transparent); color: var(--warn); }
.toast.ok { border-color: var(--accent-line); color: var(--accent); }
.toast[hidden] { display: none; }
/* --- right panel ---------------------------------------------------------- */
/* the right panel mirrors the rail: fixed-width contents, clipped by the track,
* so folding it in and out is one eased movement on each side */
#panel { background: var(--main-surface-secondary); border-left: 1px solid var(--border-light); min-width: 0; overflow: hidden; }
.panel-inner {
width: var(--panel); height: 100%; overflow-y: auto; padding: 12px;
transition: transform 300ms var(--ease-pop), opacity 200ms var(--ease);
}
body.no-panel .panel-inner { transform: translateX(18px); opacity: 0; }
body.no-panel #panel { pointer-events: none; }
.panel-head {
display: flex; align-items: center; gap: 6px; padding: 2px 4px 10px;
color: var(--fg-faint); font-size: 11px; font-weight: 600;
letter-spacing: .7px; text-transform: uppercase;
}
.card { border-top: 1px solid var(--line); padding: 12px 2px; }
.card:first-of-type { border-top: 0; }
.card h4 { margin: 0 0 8px; font-size: 12.5px; font-weight: 650; display: flex; align-items: center; gap: 6px; }
.card .kv { padding: 3px 0; }
.todo-list { margin: 0; padding: 0; list-style: none; font-size: 13.5px; }
.todo-list li { display: flex; gap: 8px; padding: 3px 0; animation: fade-in var(--t) var(--ease) both; transition: color var(--t-fast); }
.todo-list .boxmark { color: var(--fg-faint); }
.todo-list li.done { color: var(--fg-faint); text-decoration: line-through; }
.todo-list li.doing { color: var(--fg); font-weight: 600; }
#steps-body .step-line > summary { font-size: 12.5px; }
#steps-body .step-line .phase { font-size: 11px; }
/* --- modal sheet (session list) ------------------------------------------- */
#modal {
position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 40;
display: flex; align-items: center; justify-content: center; padding: 16px;
animation: fade-in var(--t) var(--ease) both;
}
#modal-box {
width: min(560px, 100%); max-height: 82vh; overflow-y: auto;
background: var(--main-surface-tertiary); border: 1px solid var(--border-medium);
border-radius: 18px; padding: 16px 18px; box-shadow: var(--shadow);
animation: pop-in 180ms var(--ease) both;
}
#modal-box h3 { margin: 0 0 12px; font-size: 17px; }
#modal-box .field { display: flex; align-items: center; gap: 8px; margin: 9px 0; flex-wrap: wrap; }
#modal-box .field > span { width: 70px; color: var(--fg-dim); font-size: 13px; }
#modal-box .field input {
flex: 1 1 auto; min-width: 150px; background: var(--bg-lift);
border: 1px solid var(--line); border-radius: 10px; padding: 8px 10px; outline: none;
}
#modal-box .buttons { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 12px; }
#modal-box .buttons button {
height: 34px; padding: 0 14px; border-radius: 10px; font-weight: 600;
border: 1px solid var(--line-strong); background: var(--bg-lift);
transition: filter var(--t-fast), transform 90ms var(--ease);
}
#modal-box .buttons button:active { transform: scale(.97); }
#modal-box .buttons button.ok { background: var(--accent); color: var(--accent-fg); border-color: transparent; }
#modal-box .buttons button.danger { color: var(--danger); }
#modal-box .sess {
display: flex; align-items: center; gap: 8px; padding: 8px 6px;
border-bottom: 1px solid var(--line); border-radius: 8px;
transition: background var(--t-fast);
}
#modal-box .sess:hover { background: var(--bg-hover); }
#modal-box .sess .id { font-family: var(--mono); font-size: 11.5px; color: var(--fg-faint); }
#modal-box .sess .t { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13.5px; }
/* --- commands: the list over the composer, the palette, the help sheet ---- */
#composer { position: relative; }
.slash {
position: absolute; left: 20px; right: 20px; bottom: calc(100% - 6px); z-index: 20;
max-height: min(46vh, 360px); overflow-y: auto; padding: 5px;
background: var(--main-surface-tertiary); border: 1px solid var(--border-medium);
border-radius: 14px; box-shadow: var(--shadow); animation: pop-in 140ms var(--ease) both;
}
.slash[hidden] { display: none; }
.slash-item, .palette-item {
display: flex; align-items: baseline; gap: 10px; width: 100%; padding: 7px 10px;
border-radius: 9px; text-align: left; font-size: 13px;
}
.slash-item.on, .palette-item.on { background: var(--accent-weak); }
.slash-item:hover, .palette-item:hover { background: var(--bg-hover); }
.slash-item code, .palette-item code { font-family: var(--mono); font-size: 12.5px; color: var(--fg); flex: 0 0 auto; }
.slash-item.on code, .palette-item.on code { color: var(--accent); }
.slash-item .hint { font-family: var(--mono); font-size: 11.5px; color: var(--fg-faint); flex: 0 0 auto; }
.slash-item .desc, .palette-item .desc {
flex: 1 1 auto; min-width: 0; color: var(--fg-dim); font-size: 12.5px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
#modal-box .palette-list { max-height: min(52vh, 460px); overflow-y: auto; margin-top: 8px; }
kbd {
font-family: var(--mono); font-size: 11px; color: var(--fg-dim); white-space: nowrap;
border: 1px solid var(--line-strong); border-bottom-width: 2px; border-radius: 6px; padding: 1px 6px;
background: var(--bg-soft);
}
.palette-item kbd { flex: 0 0 auto; }
#modal-box .keys-grid {
display: grid; grid-template-columns: max-content 1fr; gap: 7px 14px; align-items: baseline;
font-size: 13px;
}
#modal-box .keys-grid kbd { justify-self: start; }
#modal-box .keys-grid.cmds code { font-family: var(--mono); font-size: 12.5px; }
#modal-box .keys-grid span { color: var(--fg-dim); }
#modal-box .ext-list { display: flex; flex-direction: column; gap: 2px; }
#modal-box .ext-row {
display: flex; align-items: center; gap: 10px; padding: 7px 8px; border-radius: 8px;
border-bottom: 1px solid var(--line);
}
#modal-box .ext-row .pm { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
#modal-box .ext-row .pm > div:last-child:not(.ext-title) { overflow-wrap: anywhere; }
#modal-box .ext-title { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-size: 13.5px; }
#modal-box .ext-badge {
font-size: 11px; border: 1px solid var(--line-strong); border-radius: 6px; padding: 0 6px; color: var(--fg-dim);
}
#modal-box .ext-badge.ok { color: var(--accent); border-color: var(--accent-line); }
#modal-box .ext-badge.bad { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 55%, transparent); }
#modal-box .ext-row > button {
flex: 0 0 auto; height: 30px; padding: 0 12px; border-radius: 9px; font-weight: 600; font-size: 13px;
border: 1px solid var(--line-strong); background: var(--bg-lift); white-space: nowrap;
}
/* --- narrow screens: rail and panel become drawers ------------------------ */
#backdrop { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); z-index: 30; display: none; animation: fade-in var(--t) var(--ease) both; }
body.drawer-open #backdrop { display: block; }
@media (max-width: 900px) {
#app { grid-template-columns: minmax(0, 1fr) !important; transition: none; }
#side, #panel {
position: fixed; top: 0; bottom: 0; width: min(310px, 86vw); z-index: 31;
display: block; transition: transform 260ms var(--ease-pop);
}
.side-inner { width: auto; transform: none !important; opacity: 1 !important; }
#side { overflow: visible; pointer-events: auto; }
#side { left: 0; transform: translateX(-102%); }
#panel { right: 0; transform: translateX(102%); }
body.side-open #side, body.panel-open #panel { transform: none; }
.only-narrow { display: inline-flex; }
#to-bottom { bottom: 150px; right: 16px; }
.hero h1 { font-size: 21px; }
.msg.user .body { max-width: 88%; }
.turn-meta, .msg-actions, .turn-steps, .note-line { padding-left: 0; }
}
@media (min-width: 901px) { #backdrop { display: none !important; } }
/* --- motion --------------------------------------------------------------- */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes fade-up { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes rise { from { opacity: 0; transform: translateY(10px) scale(.99); } to { opacity: 1; transform: none; } }
@keyframes pop-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } to { opacity: 1; transform: none; } }
@keyframes pulse { 0%, 100% { opacity: .3; transform: scale(.85); } 50% { opacity: 1; transform: scale(1); } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
#scroll { scroll-behavior: auto; }
}