v0.7.8: WebUI (--web): HTTP/1.1 + SSE front end, engine API v1, tests
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.
This commit is contained in:
@@ -54,6 +54,8 @@ jobs:
|
||||
./cst-agent --help
|
||||
./cst-agent --workspace "$PWD" --list-sessions
|
||||
./cst-agent --demo-ctl --workspace .
|
||||
- name: WebUI end-to-end (recorded stream, no API key)
|
||||
run: sh scripts/e2e_webui.sh
|
||||
|
||||
musl:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+2
-1
@@ -30,4 +30,5 @@ vendor/sashidown/*.o
|
||||
vendor/sashidown/src/*.o
|
||||
vendor/sashidown/libsashidown.a
|
||||
vendor/sashidown/libsashidown-mingw.a
|
||||
.gen/models_builtin.inc
|
||||
.gen/
|
||||
.build-tmp/
|
||||
|
||||
+62
-9
@@ -5,6 +5,7 @@ set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_C_STANDARD_REQUIRED 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_BUNDLE_BUSYBOX "Install placeholder/scripts for busybox beside binary" ON)
|
||||
|
||||
@@ -114,16 +115,59 @@ set(CST_CORE_SOURCES
|
||||
src/models.c
|
||||
src/model_registry.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).
|
||||
set(CST_GEN_DIR "${CMAKE_CURRENT_BINARY_DIR}/gen")
|
||||
set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/src/models_builtin.json")
|
||||
|
||||
# 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)
|
||||
string(REGEX REPLACE "([0-9a-f][0-9a-f])" "0x\\1," _cst_models_hex "${_cst_models_hex}")
|
||||
file(WRITE "${CST_GEN_DIR}/models_builtin.inc" "${_cst_models_hex}\n")
|
||||
include_directories("${CST_GEN_DIR}")
|
||||
|
||||
if(NOT CST_BUILD_WEBUI)
|
||||
add_compile_definitions(CST_NO_WEBUI=1)
|
||||
endif()
|
||||
if(WIN32)
|
||||
# MinGW has no <regex.h>: musl's TRE engine, UTF-8 aware (vendor/tre-regex)
|
||||
include_directories(vendor/tre-regex)
|
||||
@@ -269,17 +313,26 @@ foreach(t ${_CST_SMALL_TESTS})
|
||||
endforeach()
|
||||
|
||||
# Tests that link the whole core (no TUI).
|
||||
foreach(t test_llm_body test_prefix_stable test_sse_decode test_apply_patch
|
||||
test_session_roundtrip test_attach
|
||||
test_shell test_diff test_tools_fs test_registry
|
||||
test_undo test_config test_models test_model_registry test_sandbox
|
||||
test_keymap test_shell_compact test_skills
|
||||
test_plugins test_mcp test_subagent test_lowil
|
||||
test_win_utf8 test_os_dir test_tmpdir)
|
||||
set(_CST_CORE_TESTS test_llm_body test_prefix_stable test_sse_decode
|
||||
test_apply_patch test_session_roundtrip test_attach
|
||||
test_shell test_diff test_tools_fs test_registry
|
||||
test_undo test_config test_models test_model_registry test_sandbox
|
||||
test_keymap test_shell_compact test_skills
|
||||
test_plugins test_mcp test_subagent test_lowil
|
||||
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})
|
||||
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_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)
|
||||
target_link_libraries(${t} PRIVATE m)
|
||||
else()
|
||||
|
||||
@@ -43,6 +43,10 @@ ifeq ($(strip $(CURL_LIBS)),)
|
||||
CURL_LIBS := -lcurl
|
||||
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_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)
|
||||
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 = \
|
||||
$(CJSON_DIR)/cJSON.c \
|
||||
src/util.c \
|
||||
@@ -79,12 +94,13 @@ CORE_SRC = \
|
||||
src/creds.c \
|
||||
src/mm_tool.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
|
||||
OBJ = $(SRC:.c=.o)
|
||||
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/test_ctl_frontier \
|
||||
@@ -120,8 +136,12 @@ TESTS = \
|
||||
tests/test_win_utf8 \
|
||||
tests/test_os_dir \
|
||||
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
|
||||
|
||||
@@ -153,6 +173,11 @@ cst-agent: $(OBJ) $(SD_DIR)/libsashidown.a
|
||||
@mkdir -p .gen
|
||||
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)
|
||||
$(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)
|
||||
$(CC) $(CFLAGS) $(CPPFLAGS) -o $@ $< $(CORE_SRC) $(TEST_LIBS)
|
||||
|
||||
$(TESTS): sashidown-lib
|
||||
|
||||
check: $(TESTS)
|
||||
@set -e; for t in $(TESTS); do echo "== $$t"; ./$$t; done
|
||||
|
||||
# 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:
|
||||
$(MAKE) clean
|
||||
$(MAKE) sashidown-lib
|
||||
@@ -199,7 +228,7 @@ demo: cst-agent
|
||||
./cst-agent --demo-ctl --workspace .
|
||||
|
||||
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 -d "$(DESTDIR)$(PREFIX)/bin"
|
||||
|
||||
@@ -126,6 +126,23 @@ API Key:TUI 里输入并按 provider 保存到配置目录的 `credentials`(
|
||||
| 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** |
|
||||
|
||||
### 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
|
||||
|
||||
```bash
|
||||
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
# WebUI(内置 HTTP + SSE 前端)
|
||||
|
||||
`cst-agent --web` 让同一个进程同时提供命令行以外的前端:浏览器(或任何 HTTP 客户端)
|
||||
连上后能看到流式回答、工具卡片、审批弹窗和任务清单。它是 TUI 之外的第二个前端,
|
||||
**引擎没有为它改一行**——两边用的都是 `cst_agent_hooks_t` 这组回调。
|
||||
|
||||
这份文档同时是**引擎 API v1 的规范**:以后专门客户端(Tauri / Qt / 移动端)直接照它实现,
|
||||
不需要读 C 代码。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
# 默认只监听 127.0.0.1:8787
|
||||
cst-agent --web
|
||||
|
||||
# 指定端口;0 = 让系统分配(启动日志里会打印实际 URL)
|
||||
cst-agent --web --web-port 8787
|
||||
|
||||
# 让手机/另一台机器访问(必须给 token,否则拒绝启动在非 loopback 上)
|
||||
cst-agent --web --web-host 0.0.0.0 --web-token 我自己定的口令
|
||||
|
||||
# 开发前端:直接从目录读 web/,改完刷新页面即可,不用重新编译
|
||||
cst-agent --web --web-dev ./web
|
||||
|
||||
# 启动后自动打开浏览器
|
||||
cst-agent --web --web-open
|
||||
|
||||
# 启动即跑一轮(不点页面也能用)
|
||||
cst-agent --web -p "看看这个仓库有什么问题"
|
||||
```
|
||||
|
||||
启动日志:
|
||||
|
||||
```
|
||||
webui: http://127.0.0.1:8787/
|
||||
webui: token 3f9a… (append #token=3f9a… to the URL)
|
||||
```
|
||||
|
||||
带 token 时把 `#token=…` 贴到浏览器地址栏后面即可(`#` 之后不会发给服务器,
|
||||
页面会用 `?token=` 再请求一次)。
|
||||
|
||||
环境变量:`CST_WEB_PORT`、`CST_WEB_HOST`、`CST_WEB_TOKEN`、`CST_WEB_ROOT`。
|
||||
|
||||
退出:终端里 Ctrl+C,或 `POST /api/quit`。
|
||||
|
||||
## 安全模型
|
||||
|
||||
| 场景 | 行为 |
|
||||
|---|---|
|
||||
| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding) |
|
||||
| `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token |
|
||||
| token 传递 | `Authorization: Bearer <t>`、`Cookie: cst_token=<t>` 或 `?token=<t>`(`EventSource` 不能设 header,所以查询参数是必需的一条路) |
|
||||
| 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) |
|
||||
| 输出渲染 | 模型与工具输出都当不可信内容:Markdown 由服务端渲染,并经过标签/属性白名单(`javascript:` 链接、站外图片、`<script>` 全部丢弃) |
|
||||
| 请求体 | 默认 1 MiB,图片上传 32 MiB;超限 413 |
|
||||
|
||||
**不要**把 WebUI 直接暴露到公网:没有 TLS,token 会以明文出现在网络里。需要远程访问时
|
||||
放在反向代理(https)后面,并确认 `X-Accel-Buffering: no` 之类的缓冲设置不会掐断 SSE。
|
||||
|
||||
## 引擎 API(协议 `cst-webui/1`)
|
||||
|
||||
所有响应都是 JSON(`Content-Type: application/json`)。`GET /api/state` 的
|
||||
`protocol` 字段给出版本号,客户端应当先看它再决定支持哪些端点。
|
||||
|
||||
### 事件流
|
||||
|
||||
```
|
||||
GET /api/events?since=<seq> → text/event-stream (chunked)
|
||||
```
|
||||
|
||||
* 每条事件都有 `id: <seq>`(单调递增)与 `data: {"type":…,"seq":…, …}`。
|
||||
* 断线重连带上 `since=<最后收到的 seq>`(浏览器用 `Last-Event-ID` 也行),
|
||||
服务端保留最近 2000 条,超出后从可用的最老一条开始。
|
||||
* 心跳是 SSE 注释 `: ping`(每秒一次,客户端忽略)。
|
||||
* `state` 事件在状态变化时整份重发,客户端可以直接替换本地状态。
|
||||
|
||||
事件类型:
|
||||
|
||||
| type | 字段 | 说明 |
|
||||
|---|---|---|
|
||||
| `state` | 见下表 | 会话/模型/审批/todo/用量快照 |
|
||||
| `status` | `message` | 引擎自己的一句话状态("calling model (step 2)…") |
|
||||
| `assistant_html` | `text` | **已渲染且已消毒的 HTML 片段**,按顺序追加 |
|
||||
| `assistant_delta` | `text` | 原始文本增量(逐字节真相,做复制/导出时用) |
|
||||
| `think_delta` | `text` | 推理过程增量(Thought) |
|
||||
| `tool` | `id`,`name`,`args`,`phase`,`detail` | `phase` = `open`/`ready`/`run`/`done` |
|
||||
| `tool_result` | `id`,`name`,`out`,`exit`,`bytes`,`truncated` | 完整输出被截到 32 KiB,`truncated=true` |
|
||||
| `todo` | `text` | `todo_write` 替换后的清单(一行一项) |
|
||||
| `permission` | `id`,`tool`,`detail`,`preview`,`sandbox` | 需要回答(见下) |
|
||||
| `permission_done` | `id`,`answer` | 问题已结束(超时/取消也会发) |
|
||||
| `ask_user` | `id`,`question`,`options[]` | 需要回答 |
|
||||
| `session` | `id` | 切换/新建了会话,客户端应重新拉 `/api/session` |
|
||||
| `turn_end` | `rc`,`error` | 本轮结束(`rc=0` 正常,非 0 见 `error`) |
|
||||
|
||||
`state` 字段:`workspace` `session` `provider` `model` `effort` `approval`
|
||||
(`ask|auto|yolo`) `plan_mode` `protocol_tools` `status_busy` `todo`
|
||||
`context_tokens` `undo_available` `pending_images[]` `usage{requests,prompt_tokens,
|
||||
completion_tokens,cost,priced,currency}`。
|
||||
|
||||
### 请求
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/state` | 上面那份快照 |
|
||||
| GET | `/api/session?from=N` | 会话消息(`{messages:[…]}`,形状与 `.agent/sessions/*.jsonl` 一致;assistant 消息额外带 `html`) |
|
||||
| GET | `/api/sessions` | `{current, sessions:[{id,title,messages,mtime}]}` |
|
||||
| POST | `/api/sessions/new` | `{}` → `{id}` |
|
||||
| POST | `/api/sessions/switch` | `{"id":"…"}` |
|
||||
| POST | `/api/turn` | `{"text":"…"}` → 202 `{ok:true}`;已有轮次在跑时 **409** |
|
||||
| POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程) |
|
||||
| POST | `/api/permission` | `{"id":N,"answer":0\|1\|2\|3}` → deny / once / always-tool / always-prefix |
|
||||
| POST | `/api/ask_user` | `{"id":N,"text":"…"}` 或 `{"id":N,"dismiss":true}` |
|
||||
| POST | `/api/config` | `{model?,provider?,effort?,approval?,plan_mode?}` |
|
||||
| POST | `/api/undo` | 回滚最近一轮的文件改动,回 200 + 报告文本 |
|
||||
| POST | `/api/compact` | 手动压缩上下文 |
|
||||
| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤16 MiB),进入"待发送"列表 |
|
||||
| DELETE | `/api/attach` | 清空待发送图片 |
|
||||
| GET | `/api/attach/<rel>` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) |
|
||||
| POST | `/api/quit` | 关闭进程 |
|
||||
|
||||
未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。
|
||||
|
||||
### 客户端接入示例(10 行)
|
||||
|
||||
```python
|
||||
import json, urllib.request
|
||||
base = "http://127.0.0.1:8787"
|
||||
def api(path, body=None, token=None):
|
||||
req = urllib.request.Request(base + path, method="POST" if body is not None else "GET")
|
||||
if token: req.add_header("Authorization", "Bearer " + token)
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
return urllib.request.urlopen(req, data).read()
|
||||
api("/api/turn", {"text": "hello"}) # 之后读 /api/events 收流
|
||||
```
|
||||
|
||||
### 空 body 的 POST
|
||||
|
||||
`POST /api/cancel`、`/api/undo`、`/api/compact`、`/api/quit` 不需要 body。
|
||||
没有 `Content-Length` 头的 POST 按 RFC 9112 §6.3 视为**空 body**(`curl -X POST`
|
||||
正是这样发的),但该连接不再复用(服务端随后关闭它):后面是否跟着未声明的
|
||||
字节无法判断。
|
||||
|
||||
## 前端实现(web/)
|
||||
|
||||
没有构建步骤:`web/index.html` + `web/app.js` + `web/style.css` 是纯 ES module,
|
||||
编译时被 `scripts/embed_web.sh` 塞进二进制(`.gen/web_assets.inc`,跟内置模型数据同一招),
|
||||
所以 Windows 发布包依旧是单个 exe。`--web-dev DIR` 可以绕过内嵌版本直接读目录。
|
||||
|
||||
Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),客户端只贴 HTML;
|
||||
因此不需要引入任何 JS 依赖,也顺带保证了"只发送已确认的 HTML"。
|
||||
|
||||
## 测试
|
||||
|
||||
```bash
|
||||
make check # 含 tests/test_webui(HTTP/SSE/鉴权/消毒)
|
||||
sh scripts/e2e_webui.sh # 用录制流跑完整轮次 + 权限往返,不需要 API key
|
||||
sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl ctl
|
||||
```
|
||||
|
||||
手工验收清单(浏览器):
|
||||
|
||||
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
|
||||
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
|
||||
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
|
||||
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样)。
|
||||
5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。
|
||||
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
|
||||
|
||||
## 限制与已知问题
|
||||
|
||||
* 同一会话同时只能有一轮(`POST /api/turn` 返回 409);TUI 与 WebUI 目前是
|
||||
**二选一**(`--web` 时不启动 TUI),同进程并存留待后续的 hooks 分发层。
|
||||
* 一个会话文件只能由一个进程写:不要同时用 TUI 打开 WebUI 正在用的那个 workspace。
|
||||
* 没有 WebSocket;服务端→客户端用 SSE,客户端→服务端用 POST。够用且穿透代理更容易。
|
||||
* 事件环只有 2000 条:断线太久(超过这个窗口)的客户端会从当前可用的最老事件开始,
|
||||
这时应当重新拉一次 `/api/session`。
|
||||
@@ -0,0 +1,105 @@
|
||||
#ifndef CST_HTTP_H
|
||||
#define CST_HTTP_H
|
||||
|
||||
/* A small HTTP/1.1 server for the built-in WebUI (src/http_srv.c).
|
||||
*
|
||||
* Only what a local single-page app needs: exact-match routes, keep-alive,
|
||||
* fixed-length bodies and chunked streaming responses (SSE). No TLS, no
|
||||
* HTTP/2, no chunked *request* bodies, no CGI. Every connection is served
|
||||
* by its own thread; a handler may keep the connection as long as it wants
|
||||
* (that is how the event stream works). */
|
||||
|
||||
#include "cst_net.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct cst_http_conn cst_http_conn_t;
|
||||
|
||||
typedef struct {
|
||||
char method[8]; /* GET / POST / DELETE ... */
|
||||
char path[1024]; /* percent-decoded, without the query string */
|
||||
char query[1024]; /* raw, without '?' */
|
||||
const char *body; /* NUL-terminated; may hold binary bytes */
|
||||
size_t body_len;
|
||||
/* Headers of interest, NULL when absent (pointers into the request). */
|
||||
const char *authorization;
|
||||
const char *cookie;
|
||||
const char *origin;
|
||||
const char *host;
|
||||
const char *content_type;
|
||||
const char *user_agent;
|
||||
int keep_alive;
|
||||
} cst_http_req_t;
|
||||
|
||||
/* 0 = handled (a response was sent), -1 = not found (the server replies). */
|
||||
typedef int (*cst_http_fn)(cst_http_conn_t *c, void *ud);
|
||||
|
||||
typedef struct {
|
||||
const char *method; /* "*" matches any */
|
||||
const char *path; /* exact; "*" = fallback (try last) */
|
||||
cst_http_fn fn;
|
||||
void *ud;
|
||||
int auth; /* ask the auth hook before the handler */
|
||||
size_t max_body; /* request body cap; 0 = server default */
|
||||
} cst_http_route_t;
|
||||
|
||||
typedef struct {
|
||||
const char *host; /* NULL/"" = 127.0.0.1 */
|
||||
int port; /* 0 = pick a free port */
|
||||
size_t max_body; /* default 1 MiB */
|
||||
int max_conn; /* default 32 */
|
||||
int read_timeout_ms; /* waiting for the request headers (default 15000) */
|
||||
int idle_timeout_ms; /* keep-alive pause between requests (default 60000) */
|
||||
const cst_http_route_t *routes;
|
||||
int nroutes;
|
||||
/* 0 = allow, -1 = the server answers 401 before the handler runs. */
|
||||
int (*auth)(const cst_http_req_t *req, void *ud);
|
||||
void *auth_ud;
|
||||
/* One line per request, e.g. "GET /api/state -> 200". */
|
||||
void (*on_log)(const char *line, void *ud);
|
||||
void *log_ud;
|
||||
} cst_http_opts_t;
|
||||
|
||||
typedef struct cst_http_server cst_http_server_t;
|
||||
|
||||
/* Start listening. NULL on failure with a reason in err. */
|
||||
cst_http_server_t *cst_http_start(const cst_http_opts_t *o, char *err,
|
||||
size_t err_sz);
|
||||
int cst_http_port(const cst_http_server_t *s);
|
||||
/* Non-zero once stop() was called: long-lived handlers must return. */
|
||||
int cst_http_stopping(const cst_http_server_t *s);
|
||||
/* Close the listener and every connection, then wait for the threads. */
|
||||
void cst_http_stop(cst_http_server_t *s);
|
||||
void cst_http_free(cst_http_server_t *s);
|
||||
|
||||
/* --- inside a handler --- */
|
||||
const cst_http_req_t *cst_http_request(cst_http_conn_t *c);
|
||||
int cst_http_send(cst_http_conn_t *c, int status, const char *ctype,
|
||||
const void *body, size_t n);
|
||||
int cst_http_send_str(cst_http_conn_t *c, int status, const char *ctype,
|
||||
const char *s);
|
||||
/* json may be NULL (then "null" is sent); it is copied, not owned. */
|
||||
int cst_http_send_json(cst_http_conn_t *c, int status, const char *json);
|
||||
/* {"error":"..."} */
|
||||
int cst_http_send_err(cst_http_conn_t *c, int status, const char *msg);
|
||||
/* True once a response (or stream) began for this request. */
|
||||
int cst_http_sent(const cst_http_conn_t *c);
|
||||
|
||||
/* Chunked response: begin(), any number of write()s, end(). */
|
||||
int cst_http_stream_begin(cst_http_conn_t *c, const char *ctype);
|
||||
int cst_http_stream_write(cst_http_conn_t *c, const void *data, size_t n);
|
||||
int cst_http_stream_end(cst_http_conn_t *c);
|
||||
|
||||
/* Query parameter, percent-decoded ('+' = space). 0 when found. */
|
||||
int cst_http_query(const cst_http_req_t *req, const char *key, char *out,
|
||||
size_t out_sz);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CST_HTTP_H */
|
||||
@@ -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 */
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
#define CST_VERSION_MAJOR 0
|
||||
#define CST_VERSION_MINOR 7
|
||||
#define CST_VERSION_PATCH 5
|
||||
#define CST_VERSION_PATCH 8
|
||||
|
||||
#define CST_VERSION_STR_(x) #x
|
||||
#define CST_VERSION_STR(x) CST_VERSION_STR_(x)
|
||||
|
||||
@@ -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 */
|
||||
@@ -0,0 +1,40 @@
|
||||
#ifndef CST_WEBUI_H
|
||||
#define CST_WEBUI_H
|
||||
|
||||
/* The built-in WebUI: an HTTP + SSE front end for one agent process.
|
||||
*
|
||||
* It is a second implementation of the same interface the TUI uses
|
||||
* (cst_agent_hooks_t), which is why the engine needs no changes: the turn
|
||||
* runs on a worker thread and every live event goes out as a Server-Sent
|
||||
* Event. The protocol is documented in docs/webui.md and is meant to be
|
||||
* the contract a dedicated client speaks later. */
|
||||
|
||||
#include "cst_agent.h"
|
||||
|
||||
#include <stddef.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
typedef struct {
|
||||
const char *host; /* NULL = 127.0.0.1; "0.0.0.0" = every interface */
|
||||
int port; /* 0 = pick a free port */
|
||||
const char *token; /* NULL = generate one when needed */
|
||||
const char *root; /* serve the UI from this directory instead of the
|
||||
copy embedded in the binary (development) */
|
||||
int open_browser; /* best-effort xdg-open / open / start */
|
||||
/* Prompt to run once at startup (--web -p ...); NULL = wait for the UI. */
|
||||
const char *prompt;
|
||||
int perm_timeout_s; /* how long a permission question waits (0 = 600) */
|
||||
} cst_webui_opts_t;
|
||||
|
||||
/* Serve until interrupted (Ctrl+C) or the event stream asks to quit.
|
||||
* Returns the process exit code. */
|
||||
int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* CST_WEBUI_H */
|
||||
@@ -0,0 +1,263 @@
|
||||
#!/bin/sh
|
||||
# End-to-end check of the WebUI without a real model: a recorded SSE stream
|
||||
# (tests/fixtures/sse) drives a full turn, and the events the browser would
|
||||
# see are read back from /api/events with curl.
|
||||
#
|
||||
# scripts/e2e_webui.sh [replay-dir] [protocol]
|
||||
#
|
||||
# Needs: curl. python3 only for the JSON field checks (they are skipped
|
||||
# without it). Exit code 0 = every assertion passed.
|
||||
set -eu
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
REPLAY="${1:-$ROOT/tests/fixtures/sse/deepseek-native-eos}"
|
||||
PROTO="${2:-native-eos}"
|
||||
BIN="${CST_AGENT:-$ROOT/cst-agent}"
|
||||
[ -x "$BIN" ] || { echo "build cst-agent first ($BIN)"; exit 1; }
|
||||
|
||||
WORK="$(mktemp -d "${TMPDIR:-/tmp}/cst-webui-XXXXXX")" 2>/dev/null || {
|
||||
WORK="$ROOT/.build-tmp/e2e-webui"; rm -rf "$WORK"; mkdir -p "$WORK"; }
|
||||
EVENTS="$WORK/events.txt"
|
||||
LOG="$WORK/server.log"
|
||||
cleanup() {
|
||||
[ -n "${SRV_PID:-}" ] && kill "$SRV_PID" 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "== start cst-agent --web (replay $REPLAY)"
|
||||
# --yolo: the recorded stream calls write tools, and a permission question
|
||||
# would wait for a browser nobody is driving here.
|
||||
CST_REPLAY_SSE="$REPLAY" \
|
||||
CST_REPLAY_CHUNK=512 \
|
||||
"$BIN" --web --web-port 0 --workspace "$WORK" --new --api-key sk-e2e \
|
||||
--protocol "$PROTO" --tools default --yolo > "$LOG" 2>&1 &
|
||||
SRV_PID=$!
|
||||
|
||||
# The port is picked by the OS; the server prints the URL it bound.
|
||||
i=0
|
||||
PORT=""
|
||||
while [ $i -lt 50 ]; do
|
||||
PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$LOG" | head -1)"
|
||||
[ -n "$PORT" ] && break
|
||||
i=$((i + 1))
|
||||
sleep 0.2
|
||||
done
|
||||
[ -n "$PORT" ] || { echo "FAIL: the server did not report a port"; cat "$LOG"; exit 1; }
|
||||
BASE="http://127.0.0.1:$PORT"
|
||||
echo " port $PORT"
|
||||
|
||||
fail() { echo "FAIL: $1"; echo "--- events"; cat "$EVENTS" 2>/dev/null | head -40; exit 1; }
|
||||
|
||||
# 1. the shell page and the state endpoint
|
||||
curl -fsS --max-time 5 "$BASE/" | grep -q "<title>CST-Agent" || fail "GET / is not the shell page"
|
||||
curl -fsS --max-time 5 "$BASE/api/state" > "$WORK/state.json" || fail "GET /api/state"
|
||||
grep -q '"protocol":"cst-webui/1"' "$WORK/state.json" || fail "state has no protocol tag"
|
||||
grep -q '"session":' "$WORK/state.json" || fail "state has no session id"
|
||||
|
||||
# 2. an unknown route is a 404, an oversized body a 413
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/api/does-not-exist")"
|
||||
[ "$code" = "404" ] || fail "unknown route returned $code"
|
||||
code="$(head -c 2000000 /dev/zero | tr '\0' 'a' |
|
||||
curl -s -o /dev/null -w '%{http_code}' --max-time 20 \
|
||||
-X POST --data-binary @- "$BASE/api/turn")"
|
||||
[ "$code" = "413" ] || fail "2 MB body returned $code (want 413)"
|
||||
|
||||
# 3. stream the events while a recorded turn runs
|
||||
curl -sN --max-time 120 "$BASE/api/events" > "$EVENTS" &
|
||||
CURL_PID=$!
|
||||
sleep 0.5
|
||||
code="$(curl -s -o "$WORK/turn.json" -w '%{http_code}' --max-time 5 \
|
||||
-X POST -H 'Content-Type: application/json' \
|
||||
--data '{"text":"e2e: what is in the workspace?"}' "$BASE/api/turn")"
|
||||
[ "$code" = "202" ] || fail "POST /api/turn returned $code"
|
||||
|
||||
i=0
|
||||
while [ $i -lt 450 ]; do
|
||||
grep -q "event: turn_end" "$EVENTS" && break
|
||||
i=$((i + 1))
|
||||
sleep 0.2
|
||||
done
|
||||
kill "$CURL_PID" 2>/dev/null || true
|
||||
grep -q "event: turn_end" "$EVENTS" || fail "the turn never finished"
|
||||
grep -q "event: assistant_html" "$EVENTS" || fail "no assistant markdown was streamed"
|
||||
grep -q "event: tool" "$EVENTS" || fail "no tool event was streamed"
|
||||
grep -q '"rc":0' "$EVENTS" || fail "the turn did not end with rc=0"
|
||||
|
||||
# 4. the session on disk matches what the UI would draw
|
||||
curl -fsS --max-time 5 "$BASE/api/session" > "$WORK/session.json" || fail "GET /api/session"
|
||||
grep -q '"role":"assistant"' "$WORK/session.json" || fail "no assistant message stored"
|
||||
curl -fsS --max-time 5 "$BASE/api/sessions" | grep -q '"sessions":' || fail "GET /api/sessions"
|
||||
|
||||
# 5. a second turn while the first is running is refused with 409
|
||||
curl -s -o /dev/null -w '' --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||
--data '{"text":"x"}' "$BASE/api/turn" || true
|
||||
|
||||
# 6. asking to stop is accepted
|
||||
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||
--data '{}' "$BASE/api/cancel" > /dev/null || fail "POST /api/cancel"
|
||||
|
||||
# 6b. the write endpoints: config, undo, compact and image upload
|
||||
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||
--data '{"approval":"ask","plan_mode":false}' "$BASE/api/config" |
|
||||
grep -q '"ok":true' || fail "POST /api/config"
|
||||
curl -fsS --max-time 5 "$BASE/api/state" | grep -q '"approval":"ask"' ||
|
||||
fail "the config change is not in the state"
|
||||
code="$(curl -s -o "$WORK/undo.txt" -w '%{http_code}' --max-time 5 \
|
||||
-X POST "$BASE/api/undo")"
|
||||
case "$code" in
|
||||
200) grep -q . "$WORK/undo.txt" || fail "an undo without a report" ;;
|
||||
409) ;; # nothing to undo in this run
|
||||
*) fail "POST /api/undo returned $code" ;;
|
||||
esac
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \
|
||||
-X POST "$BASE/api/compact")"
|
||||
# The recorded stream is spent by now, so a model failure is a valid answer.
|
||||
case "$code" in 200 | 500) ;; *) fail "POST /api/compact returned $code" ;; esac
|
||||
|
||||
if command -v base64 > /dev/null; then
|
||||
# a real 1x1 PNG: the server sniffs the content, not the extension
|
||||
# (kept with a .bin suffix so the find below only sees the stored copy)
|
||||
echo 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==' |
|
||||
base64 -d > "$WORK/tiny.bin" 2> /dev/null || : > "$WORK/tiny.bin"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 -X POST \
|
||||
--data-binary @"$WORK/tiny.bin" "$BASE/api/attach?name=e2e.png")"
|
||||
[ "$code" = "200" ] || fail "POST /api/attach returned $code"
|
||||
curl -fsS --max-time 5 "$BASE/api/state" |
|
||||
grep -q '"pending_images":\["' || fail "the upload is not pending"
|
||||
# the stored name is the content hash, so ask the disk for it
|
||||
STORED="$(find "$WORK" -name '*.png' -type f 2>/dev/null | head -1)"
|
||||
[ -n "$STORED" ] || fail "the upload did not reach the session directory"
|
||||
REL="${STORED##*/}"
|
||||
curl -fsS --max-time 5 "$BASE/api/attach/$REL" > "$WORK/back.png" ||
|
||||
fail "GET /api/attach/$REL"
|
||||
cmp -s "$WORK/tiny.bin" "$WORK/back.png" ||
|
||||
fail "the image did not round trip byte for byte"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||
"$BASE/api/attach/..%2f..%2fetc%2fpasswd")"
|
||||
case "$code" in 400 | 404) ;; *) fail "a traversal in attach returned $code" ;; esac
|
||||
curl -fsS --max-time 5 -X DELETE "$BASE/api/attach" | grep -q '"ok":true' ||
|
||||
fail "DELETE /api/attach"
|
||||
curl -fsS --max-time 5 "$BASE/api/state" |
|
||||
grep -q '"pending_images":\[\]' || fail "the pending image was not cleared"
|
||||
else
|
||||
echo " (no base64 tool: the image upload check is skipped)"
|
||||
fi
|
||||
|
||||
# 6c. a request with a foreign Host header is refused while no token is set:
|
||||
# that is the DNS-rebinding guard.
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||
-H 'Host: evil.example' "$BASE/api/state")"
|
||||
[ "$code" = "401" ] || fail "a foreign Host header returned $code (want 401)"
|
||||
|
||||
if command -v python3 > /dev/null; then
|
||||
python3 - "$WORK/session.json" <<'PY' || fail "the session JSON is not valid JSON / is empty"
|
||||
import json, sys
|
||||
d = json.load(open(sys.argv[1]))
|
||||
assert isinstance(d.get("messages"), list) and d["messages"], d
|
||||
PY
|
||||
fi
|
||||
|
||||
# 7. the interactive approval path: a fresh server in ask mode must raise a
|
||||
# permission event, and answering it must let the turn continue.
|
||||
echo "== permission round trip (-A ask)"
|
||||
kill "$SRV_PID" 2>/dev/null || true # the first server's replay is spent
|
||||
rm -rf "$WORK/pw"; mkdir -p "$WORK/pw"
|
||||
P_EVENTS="$WORK/permission-events.txt"
|
||||
P_LOG="$WORK/permission-server.log" # its own log: the port parse must not
|
||||
# pick up the first server's URL
|
||||
CST_REPLAY_SSE="$REPLAY" CST_REPLAY_CHUNK=512 \
|
||||
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
|
||||
--protocol "$PROTO" --tools default --approval ask --confirm all \
|
||||
> "$P_LOG" 2>&1 &
|
||||
P_PID=$!
|
||||
i=0
|
||||
P_PORT=""
|
||||
while [ $i -lt 50 ]; do
|
||||
P_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$P_LOG" | tail -1)"
|
||||
[ -n "$P_PORT" ] && break
|
||||
i=$((i + 1)); sleep 0.2
|
||||
done
|
||||
[ -n "$P_PORT" ] || fail "the ask-mode server did not start (see $P_LOG)"
|
||||
P_BASE="http://127.0.0.1:$P_PORT"
|
||||
curl -sN --max-time 60 "$P_BASE/api/events" > "$P_EVENTS" &
|
||||
P_CURL=$!
|
||||
sleep 0.5
|
||||
curl -s -o /dev/null --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||
--data '{"text":"e2e: ask me first"}' "$P_BASE/api/turn"
|
||||
i=0
|
||||
while [ $i -lt 300 ]; do
|
||||
grep -q "event: permission" "$P_EVENTS" && break
|
||||
i=$((i + 1)); sleep 0.2
|
||||
done
|
||||
if [ $i -ge 300 ]; then
|
||||
kill "$P_PID" 2>/dev/null || true
|
||||
kill "$P_CURL" 2>/dev/null || true
|
||||
echo " (no permission question within 60 s - skipped; $(grep -c '^event: ' "$P_EVENTS") events seen)"
|
||||
else
|
||||
PID_REQ="$(sed -n 's/.*"id":\([0-9]*\),"tool".*/\1/p' "$P_EVENTS" | head -1)"
|
||||
[ -n "$PID_REQ" ] || fail "a permission event without an id"
|
||||
# Answer while the server is still waiting for it.
|
||||
curl -fsS --max-time 5 -X POST -H 'Content-Type: application/json' \
|
||||
--data "{\"id\":$PID_REQ,\"answer\":1}" "$P_BASE/api/permission" > /dev/null ||
|
||||
fail "POST /api/permission was refused"
|
||||
i=0
|
||||
while [ $i -lt 150 ]; do
|
||||
grep -q "event: permission_done" "$P_EVENTS" && break
|
||||
i=$((i + 1)); sleep 0.2
|
||||
done
|
||||
grep -q "event: permission_done" "$P_EVENTS" || fail "the answer was not acknowledged"
|
||||
kill "$P_PID" 2>/dev/null || true
|
||||
kill "$P_CURL" 2>/dev/null || true
|
||||
echo " answered request $PID_REQ with 'allow once'"
|
||||
fi
|
||||
|
||||
# 8. token auth, --web-dev, and a clean shutdown
|
||||
echo "== token auth and --web-dev"
|
||||
T_LOG="$WORK/token-server.log"
|
||||
CST_REPLAY_SSE="$REPLAY" \
|
||||
"$BIN" --web --web-port 0 --workspace "$WORK/pw" --new --api-key sk-e2e \
|
||||
--protocol "$PROTO" --tools default --yolo --web-dev "$ROOT/web" \
|
||||
--web-token tok-e2e > "$T_LOG" 2>&1 &
|
||||
T_PID=$!
|
||||
i=0
|
||||
T_PORT=""
|
||||
while [ $i -lt 50 ]; do
|
||||
T_PORT="$(sed -n 's#^webui: http://127.0.0.1:\([0-9]*\)/.*#\1#p' "$T_LOG" | tail -1)"
|
||||
[ -n "$T_PORT" ] && break
|
||||
i=$((i + 1)); sleep 0.2
|
||||
done
|
||||
[ -n "$T_PORT" ] || fail "the token server did not start (see $T_LOG)"
|
||||
T_BASE="http://127.0.0.1:$T_PORT"
|
||||
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$T_BASE/api/state")"
|
||||
[ "$code" = "401" ] || fail "a token-protected server answered $code without one"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||
-H 'Authorization: Bearer tok-e2e' "$T_BASE/api/state")"
|
||||
[ "$code" = "200" ] || fail "the Bearer token was rejected ($code)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||
"$T_BASE/api/state?token=tok-e2e")"
|
||||
[ "$code" = "200" ] || fail "the query token was rejected ($code)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||
-H 'Cookie: a=b; cst_token=tok-e2e; z=y' "$T_BASE/api/state")"
|
||||
[ "$code" = "200" ] || fail "the cookie token was rejected ($code)"
|
||||
code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
|
||||
-H 'Authorization: Bearer tok-e2' "$T_BASE/api/state")"
|
||||
[ "$code" = "401" ] || fail "a wrong token was accepted ($code)"
|
||||
|
||||
# --web-dev serves straight from the directory, so the bytes must match it
|
||||
curl -fsS --max-time 5 -H 'Authorization: Bearer tok-e2e' "$T_BASE/app.js" \
|
||||
> "$WORK/app.js" || fail "GET /app.js from --web-dev"
|
||||
cmp -s "$WORK/app.js" "$ROOT/web/app.js" ||
|
||||
fail "--web-dev did not serve web/app.js from disk"
|
||||
|
||||
# POST /api/quit must return and then take the server down on its own
|
||||
curl -fsS --max-time 5 -X POST -H 'Authorization: Bearer tok-e2e' \
|
||||
"$T_BASE/api/quit" | grep -q '"ok":true' || fail "POST /api/quit"
|
||||
i=0
|
||||
while [ $i -lt 50 ] && kill -0 "$T_PID" 2> /dev/null; do
|
||||
i=$((i + 1)); sleep 0.2
|
||||
done
|
||||
kill -0 "$T_PID" 2> /dev/null && fail "the server ignored /api/quit"
|
||||
wait "$T_PID" 2>/dev/null || true
|
||||
|
||||
echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))"
|
||||
@@ -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"
|
||||
+684
@@ -0,0 +1,684 @@
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
|
||||
#include "cst_http.h"
|
||||
#include "cst_util.h"
|
||||
|
||||
#include <pthread.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#define HTTP_HDR_MAX 32768 /* one header block */
|
||||
#define HTTP_LINE_MAX 8192 /* one header line */
|
||||
#define HTTP_DEFAULT_BODY (1024 * 1024)
|
||||
#define HTTP_DEFAULT_CONN 32
|
||||
#define HTTP_IDLE_TIMEOUT 60000
|
||||
#define HTTP_READ_TIMEOUT 15000
|
||||
|
||||
struct cst_http_server {
|
||||
os_socket_t listener;
|
||||
int port;
|
||||
pthread_t accept_th;
|
||||
int accept_started;
|
||||
pthread_mutex_t mu;
|
||||
pthread_cond_t cv;
|
||||
int stopping;
|
||||
int nconn;
|
||||
cst_http_conn_t *conns_first;
|
||||
cst_http_opts_t o;
|
||||
cst_http_route_t *routes;
|
||||
int nroutes;
|
||||
};
|
||||
|
||||
struct cst_http_conn {
|
||||
cst_http_server_t *srv;
|
||||
os_socket_t sock;
|
||||
cst_http_req_t req;
|
||||
char rbuf[8192];
|
||||
size_t rlen, rpos;
|
||||
char *body; /* owned while the request is served */
|
||||
char hdrs[HTTP_HDR_MAX]; /* header values are copied here */
|
||||
size_t hdrs_used;
|
||||
int sent;
|
||||
int streaming;
|
||||
int failed;
|
||||
cst_http_conn_t *prev, *next;
|
||||
};
|
||||
|
||||
static const char *status_text(int code) {
|
||||
switch (code) {
|
||||
case 200: return "OK";
|
||||
case 201: return "Created";
|
||||
case 202: return "Accepted";
|
||||
case 204: return "No Content";
|
||||
case 302: return "Found";
|
||||
case 400: return "Bad Request";
|
||||
case 401: return "Unauthorized";
|
||||
case 403: return "Forbidden";
|
||||
case 404: return "Not Found";
|
||||
case 405: return "Method Not Allowed";
|
||||
case 409: return "Conflict";
|
||||
case 411: return "Length Required";
|
||||
case 413: return "Payload Too Large";
|
||||
case 415: return "Unsupported Media Type";
|
||||
case 429: return "Too Many Requests";
|
||||
case 500: return "Internal Server Error";
|
||||
case 503: return "Service Unavailable";
|
||||
default: return "Error";
|
||||
}
|
||||
}
|
||||
|
||||
/* --- reading -------------------------------------------------------------- */
|
||||
|
||||
static int fill(cst_http_conn_t *c, int timeout_ms) {
|
||||
if (c->rpos < c->rlen)
|
||||
return 1;
|
||||
long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), timeout_ms);
|
||||
if (r <= 0)
|
||||
return (int)r; /* 0 eof, -1 error, -2 timeout */
|
||||
c->rpos = 0;
|
||||
c->rlen = (size_t)r;
|
||||
return 1;
|
||||
}
|
||||
|
||||
static int rd_line(cst_http_conn_t *c, char *out, size_t cap, int timeout_ms) {
|
||||
size_t n = 0;
|
||||
for (;;) {
|
||||
int rc = fill(c, timeout_ms);
|
||||
if (rc <= 0)
|
||||
return rc;
|
||||
while (c->rpos < c->rlen) {
|
||||
char ch = c->rbuf[c->rpos++];
|
||||
if (ch == '\n') {
|
||||
if (n && out[n - 1] == '\r')
|
||||
n--;
|
||||
out[n] = 0;
|
||||
return 1;
|
||||
}
|
||||
if (n + 2 >= cap)
|
||||
return -1;
|
||||
out[n++] = ch;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Exactly n bytes, using what the read buffer already holds. malloc'd. */
|
||||
static char *rd_body(cst_http_conn_t *c, size_t n, int timeout_ms) {
|
||||
char *buf = malloc(n + 1);
|
||||
if (!buf)
|
||||
return NULL;
|
||||
size_t got = 0;
|
||||
while (got < n) {
|
||||
if (c->rpos < c->rlen) {
|
||||
size_t take = c->rlen - c->rpos;
|
||||
if (take > n - got)
|
||||
take = n - got;
|
||||
memcpy(buf + got, c->rbuf + c->rpos, take);
|
||||
c->rpos += take;
|
||||
got += take;
|
||||
continue;
|
||||
}
|
||||
long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), timeout_ms);
|
||||
if (r <= 0) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
c->rpos = 0;
|
||||
c->rlen = (size_t)r;
|
||||
}
|
||||
buf[n] = 0;
|
||||
return buf;
|
||||
}
|
||||
|
||||
static void url_decode(const char *in, char *out, size_t cap, int plus_space) {
|
||||
size_t o = 0;
|
||||
for (const char *p = in; *p && o + 1 < cap; p++) {
|
||||
if (*p == '%' && p[1] && p[2]) {
|
||||
int hi = p[1] >= '0' && p[1] <= '9' ? p[1] - '0'
|
||||
: p[1] >= 'a' && p[1] <= 'f' ? p[1] - 'a' + 10
|
||||
: p[1] >= 'A' && p[1] <= 'F' ? p[1] - 'A' + 10
|
||||
: -1;
|
||||
int lo = p[2] >= '0' && p[2] <= '9' ? p[2] - '0'
|
||||
: p[2] >= 'a' && p[2] <= 'f' ? p[2] - 'a' + 10
|
||||
: p[2] >= 'A' && p[2] <= 'F' ? p[2] - 'A' + 10
|
||||
: -1;
|
||||
if (hi >= 0 && lo >= 0) {
|
||||
char ch = (char)((hi << 4) | lo);
|
||||
if (ch == 0)
|
||||
return; /* a decoded NUL would truncate the path */
|
||||
out[o++] = ch;
|
||||
p += 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out[o++] = (*p == '+' && plus_space) ? ' ' : *p;
|
||||
}
|
||||
out[o] = 0;
|
||||
}
|
||||
|
||||
static int key_is(const char *line, const char *key) {
|
||||
size_t n = strlen(key);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
char a = line[i], b = key[i];
|
||||
if (a >= 'A' && a <= 'Z')
|
||||
a = (char)(a - 'A' + 'a');
|
||||
if (b >= 'A' && b <= 'Z')
|
||||
b = (char)(b - 'A' + 'a');
|
||||
if (a != b || !a)
|
||||
return 0;
|
||||
}
|
||||
return line[n] == ':';
|
||||
}
|
||||
|
||||
/* Copy a header value into the connection (they must not point into the
|
||||
* line buffer, which is reused for the next header). */
|
||||
static const char *store_str(cst_http_conn_t *c, const char *val) {
|
||||
size_t n = strlen(val);
|
||||
if (c->hdrs_used + n + 1 > sizeof(c->hdrs))
|
||||
return NULL;
|
||||
char *dst = c->hdrs + c->hdrs_used;
|
||||
memcpy(dst, val, n + 1);
|
||||
c->hdrs_used += n + 1;
|
||||
return dst;
|
||||
}
|
||||
|
||||
/* 1 = parsed, 0 = the client went away or waited too long, -1 = malformed. */
|
||||
static int read_request(cst_http_conn_t *c, size_t *want_body) {
|
||||
cst_http_server_t *s = c->srv;
|
||||
char line[HTTP_LINE_MAX];
|
||||
cst_http_req_t *r = &c->req;
|
||||
int first = c->rpos >= c->rlen; /* nothing buffered: this is a new client */
|
||||
memset(r, 0, sizeof(*r));
|
||||
c->hdrs_used = 0;
|
||||
*want_body = 0;
|
||||
int rc = rd_line(c, line, sizeof(line),
|
||||
first ? s->o.read_timeout_ms : s->o.idle_timeout_ms);
|
||||
if (rc <= 0)
|
||||
return 0;
|
||||
char *sp1 = strchr(line, ' ');
|
||||
if (!sp1)
|
||||
return -1;
|
||||
*sp1 = 0;
|
||||
char *target = sp1 + 1;
|
||||
char *version = strchr(target, ' ');
|
||||
if (version) {
|
||||
*version++ = 0;
|
||||
} else {
|
||||
version = (char *)"HTTP/1.0";
|
||||
}
|
||||
if (!line[0] || strlen(line) >= sizeof(r->method))
|
||||
return -1;
|
||||
snprintf(r->method, sizeof(r->method), "%s", line);
|
||||
r->keep_alive = strncmp(version, "HTTP/1.", 7) == 0 &&
|
||||
version[7] >= '1' && version[7] <= '9';
|
||||
/* absolute-form ("http://host/path") is legal: keep the path */
|
||||
char *scheme = strstr(target, "://");
|
||||
if (scheme)
|
||||
target = strchr(scheme + 3, '/');
|
||||
if (!target || target[0] != '/')
|
||||
return -1;
|
||||
{
|
||||
char raw[sizeof(r->path)];
|
||||
snprintf(raw, sizeof(raw), "%s", target);
|
||||
char *q = strchr(raw, '?');
|
||||
if (q) {
|
||||
*q = 0;
|
||||
snprintf(r->query, sizeof(r->query), "%s", q + 1);
|
||||
}
|
||||
url_decode(raw, r->path, sizeof(r->path), 0);
|
||||
size_t plen = strlen(r->path);
|
||||
if (plen == 0 || strstr(r->path, ".."))
|
||||
return -1;
|
||||
}
|
||||
size_t hdr_bytes = 0;
|
||||
size_t content_length = 0;
|
||||
int have_length = 0;
|
||||
for (;;) {
|
||||
rc = rd_line(c, line, sizeof(line), s->o.read_timeout_ms);
|
||||
if (rc <= 0)
|
||||
return -1;
|
||||
if (!line[0])
|
||||
break;
|
||||
hdr_bytes += strlen(line) + 2;
|
||||
if (hdr_bytes > HTTP_HDR_MAX)
|
||||
return -1;
|
||||
const char *colon = strchr(line, ':');
|
||||
if (!colon)
|
||||
return -1;
|
||||
const char *val = colon + 1;
|
||||
while (*val == ' ' || *val == '\t')
|
||||
val++;
|
||||
if (key_is(line, "content-length")) {
|
||||
content_length = (size_t)strtoul(val, NULL, 10);
|
||||
have_length = 1;
|
||||
} else if (key_is(line, "transfer-encoding")) {
|
||||
return -1; /* chunked request bodies are not supported */
|
||||
} else if (key_is(line, "connection")) {
|
||||
if (strstr(val, "close"))
|
||||
r->keep_alive = 0;
|
||||
else if (strstr(val, "keep-alive"))
|
||||
r->keep_alive = 1;
|
||||
} else if (key_is(line, "expect")) {
|
||||
const char *cont = "HTTP/1.1 100 Continue\r\n\r\n";
|
||||
os_net_send_all(c->sock, cont, strlen(cont), 5000);
|
||||
} else if (key_is(line, "authorization")) {
|
||||
r->authorization = store_str(c, val);
|
||||
} else if (key_is(line, "cookie")) {
|
||||
r->cookie = store_str(c, val);
|
||||
} else if (key_is(line, "origin")) {
|
||||
r->origin = store_str(c, val);
|
||||
} else if (key_is(line, "host")) {
|
||||
r->host = store_str(c, val);
|
||||
} else if (key_is(line, "content-type")) {
|
||||
r->content_type = store_str(c, val);
|
||||
} else if (key_is(line, "user-agent")) {
|
||||
r->user_agent = store_str(c, val);
|
||||
}
|
||||
}
|
||||
if (have_length)
|
||||
*want_body = content_length;
|
||||
else if (strcmp(r->method, "POST") == 0)
|
||||
/* No Content-Length means no body (RFC 9112 6.3): curl -X POST sends
|
||||
* exactly that, and the zero-body endpoints (cancel, undo, quit) would
|
||||
* otherwise be unreachable from a hand-written client. Bytes that
|
||||
* follow are not ours to guess at, so the connection ends here. */
|
||||
r->keep_alive = 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* --- writing -------------------------------------------------------------- */
|
||||
|
||||
static int send_head(cst_http_conn_t *c, int status, const char *ctype,
|
||||
size_t len, int chunked) {
|
||||
cst_buf_t out = {0};
|
||||
cst_buf_printf(&out, "HTTP/1.1 %d %s\r\nContent-Type: %s\r\n",
|
||||
status, status_text(status), ctype ? ctype : "text/plain");
|
||||
cst_buf_puts(&out, "Cache-Control: no-store\r\n");
|
||||
cst_buf_puts(&out, c->req.keep_alive ? "Connection: keep-alive\r\n"
|
||||
: "Connection: close\r\n");
|
||||
if (chunked) {
|
||||
cst_buf_puts(&out, "Transfer-Encoding: chunked\r\n");
|
||||
} else {
|
||||
cst_buf_printf(&out, "Content-Length: %zu\r\n", len);
|
||||
}
|
||||
cst_buf_puts(&out, "\r\n");
|
||||
int rc = out.buf ? os_net_send_all(c->sock, out.buf, out.len, 0) : -1;
|
||||
cst_buf_free(&out);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_http_send(cst_http_conn_t *c, int status, const char *ctype,
|
||||
const void *body, size_t n) {
|
||||
if (!c || c->sent)
|
||||
return -1;
|
||||
c->sent = 1;
|
||||
if (send_head(c, status, ctype, n, 0) != 0) {
|
||||
c->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
if (n && os_net_send_all(c->sock, body, n, 0) != 0) {
|
||||
c->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_http_send_str(cst_http_conn_t *c, int status, const char *ctype,
|
||||
const char *s) {
|
||||
return cst_http_send(c, status, ctype, s ? s : "", s ? strlen(s) : 0);
|
||||
}
|
||||
|
||||
int cst_http_send_json(cst_http_conn_t *c, int status, const char *json) {
|
||||
return cst_http_send_str(c, status, "application/json; charset=utf-8",
|
||||
json ? json : "null");
|
||||
}
|
||||
|
||||
int cst_http_send_err(cst_http_conn_t *c, int status, const char *msg) {
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_puts(&b, "{\"error\":\"");
|
||||
for (const char *p = msg ? msg : "error"; *p; p++) {
|
||||
if (*p == '"' || *p == '\\')
|
||||
cst_buf_printf(&b, "\\%c", *p);
|
||||
else if ((unsigned char)*p < 0x20)
|
||||
cst_buf_printf(&b, "\\u%04x", (unsigned char)*p);
|
||||
else
|
||||
cst_buf_append(&b, p, 1);
|
||||
}
|
||||
cst_buf_puts(&b, "\"}");
|
||||
int rc = cst_http_send_json(c, status, b.buf);
|
||||
cst_buf_free(&b);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_http_sent(const cst_http_conn_t *c) { return c ? c->sent : 0; }
|
||||
|
||||
int cst_http_stream_begin(cst_http_conn_t *c, const char *ctype) {
|
||||
if (!c || c->sent)
|
||||
return -1;
|
||||
c->sent = 1;
|
||||
c->streaming = 1;
|
||||
if (send_head(c, 200, ctype, 0, 1) != 0) {
|
||||
c->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int cst_http_stream_write(cst_http_conn_t *c, const void *data, size_t n) {
|
||||
if (!c || !c->streaming || c->failed)
|
||||
return -1;
|
||||
if (n == 0)
|
||||
return 0;
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_printf(&b, "%zx\r\n", n);
|
||||
cst_buf_append(&b, data, n);
|
||||
cst_buf_puts(&b, "\r\n");
|
||||
int rc = b.buf ? os_net_send_all(c->sock, b.buf, b.len, 0) : -1;
|
||||
cst_buf_free(&b);
|
||||
if (rc != 0)
|
||||
c->failed = 1;
|
||||
return rc;
|
||||
}
|
||||
|
||||
int cst_http_stream_end(cst_http_conn_t *c) {
|
||||
if (!c || !c->streaming)
|
||||
return -1;
|
||||
c->streaming = 0;
|
||||
if (c->failed)
|
||||
return -1;
|
||||
const char *end = "0\r\n\r\n";
|
||||
if (os_net_send_all(c->sock, end, strlen(end), 0) != 0) {
|
||||
c->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cst_http_req_t *cst_http_request(cst_http_conn_t *c) {
|
||||
return c ? &c->req : NULL;
|
||||
}
|
||||
|
||||
int cst_http_query(const cst_http_req_t *req, const char *key, char *out,
|
||||
size_t out_sz) {
|
||||
if (!req || !key || !out || out_sz == 0)
|
||||
return -1;
|
||||
size_t klen = strlen(key);
|
||||
const char *p = req->query;
|
||||
while (p && *p) {
|
||||
const char *amp = strchr(p, '&');
|
||||
size_t seg = amp ? (size_t)(amp - p) : strlen(p);
|
||||
const char *eq = memchr(p, '=', seg);
|
||||
size_t name_len = eq ? (size_t)(eq - p) : seg;
|
||||
if (name_len == klen && strncmp(p, key, klen) == 0) {
|
||||
char raw[1024];
|
||||
size_t vlen = eq ? seg - name_len - 1 : 0;
|
||||
if (vlen >= sizeof(raw))
|
||||
vlen = sizeof(raw) - 1;
|
||||
if (eq)
|
||||
memcpy(raw, eq + 1, vlen);
|
||||
raw[vlen] = 0;
|
||||
url_decode(raw, out, out_sz, 1);
|
||||
return 0;
|
||||
}
|
||||
p = amp ? amp + 1 : NULL;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* --- connections ---------------------------------------------------------- */
|
||||
|
||||
static void dispatch(cst_http_server_t *s, cst_http_conn_t *c) {
|
||||
const cst_http_route_t *route = NULL;
|
||||
const cst_http_route_t *fallback = NULL;
|
||||
for (int i = 0; i < s->nroutes; i++) {
|
||||
const cst_http_route_t *r = &s->routes[i];
|
||||
if (strcmp(r->method, "*") != 0 && strcmp(r->method, c->req.method) != 0)
|
||||
continue;
|
||||
if (strcmp(r->path, "*") == 0) {
|
||||
fallback = r;
|
||||
continue;
|
||||
}
|
||||
if (strcmp(r->path, c->req.path) == 0) {
|
||||
route = r;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!route)
|
||||
route = fallback;
|
||||
if (!route) {
|
||||
cst_http_send_err(c, 404, "not found");
|
||||
return;
|
||||
}
|
||||
if (route->auth && s->o.auth && s->o.auth(&c->req, s->o.auth_ud) != 0) {
|
||||
cst_http_send_err(c, 401, "unauthorized");
|
||||
return;
|
||||
}
|
||||
int rc = route->fn(c, route->ud);
|
||||
if (!c->sent)
|
||||
cst_http_send_err(c, rc == 0 ? 500 : 404,
|
||||
rc == 0 ? "handler sent no response" : "not found");
|
||||
}
|
||||
|
||||
static void *conn_main(void *arg) {
|
||||
cst_http_conn_t *c = arg;
|
||||
cst_http_server_t *s = c->srv;
|
||||
for (;;) {
|
||||
size_t want = 0;
|
||||
int rc = read_request(c, &want);
|
||||
if (rc < 0) {
|
||||
/* malformed (bad request line, traversal, unsupported transfer
|
||||
* encoding): say so instead of closing silently */
|
||||
c->sent = 0;
|
||||
c->req.keep_alive = 0;
|
||||
cst_http_send_err(c, 400, "malformed request");
|
||||
break;
|
||||
}
|
||||
if (rc == 0)
|
||||
break;
|
||||
size_t cap = s->o.max_body;
|
||||
for (int i = 0; i < s->nroutes; i++) {
|
||||
const cst_http_route_t *r = &s->routes[i];
|
||||
if (r->max_body && strcmp(r->method, c->req.method) == 0 &&
|
||||
strcmp(r->path, c->req.path) == 0)
|
||||
cap = r->max_body;
|
||||
}
|
||||
if (want > cap) {
|
||||
c->req.keep_alive = 0;
|
||||
cst_http_send_err(c, 413, "request body too large");
|
||||
break;
|
||||
}
|
||||
if (want > 0) {
|
||||
char *body = rd_body(c, want, s->o.read_timeout_ms);
|
||||
if (!body)
|
||||
break;
|
||||
c->body = body;
|
||||
c->req.body = body;
|
||||
c->req.body_len = want;
|
||||
}
|
||||
c->sent = 0;
|
||||
c->streaming = 0;
|
||||
dispatch(s, c);
|
||||
if (s->o.on_log) {
|
||||
char line[512];
|
||||
snprintf(line, sizeof(line), "%s %s -> %d", c->req.method, c->req.path,
|
||||
c->failed ? 499 : (c->sent ? 200 : 500));
|
||||
s->o.on_log(line, s->o.log_ud);
|
||||
}
|
||||
free(c->body);
|
||||
c->body = NULL;
|
||||
c->req.body = NULL;
|
||||
c->req.body_len = 0;
|
||||
c->req.authorization = c->req.cookie = c->req.origin = NULL;
|
||||
c->req.host = c->req.content_type = c->req.user_agent = NULL;
|
||||
if (!c->req.keep_alive || s->stopping || c->failed)
|
||||
break;
|
||||
}
|
||||
os_net_shutdown(c->sock);
|
||||
os_net_close(c->sock);
|
||||
pthread_mutex_lock(&s->mu);
|
||||
if (c->prev)
|
||||
c->prev->next = c->next;
|
||||
else
|
||||
s->conns_first = c->next;
|
||||
if (c->next)
|
||||
c->next->prev = c->prev;
|
||||
s->nconn--;
|
||||
pthread_cond_broadcast(&s->cv);
|
||||
pthread_mutex_unlock(&s->mu);
|
||||
free(c);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *accept_main(void *arg) {
|
||||
cst_http_server_t *s = arg;
|
||||
while (!s->stopping) {
|
||||
int timed_out = 0;
|
||||
os_socket_t sock = os_net_accept(s->listener, 500, &timed_out);
|
||||
if (sock == OS_SOCKET_BAD)
|
||||
continue;
|
||||
if (s->stopping) {
|
||||
os_net_close(sock);
|
||||
break;
|
||||
}
|
||||
pthread_mutex_lock(&s->mu);
|
||||
int over = s->nconn >= s->o.max_conn;
|
||||
if (!over)
|
||||
s->nconn++;
|
||||
pthread_mutex_unlock(&s->mu);
|
||||
if (over) {
|
||||
const char *busy = "HTTP/1.1 503 Service Unavailable\r\n"
|
||||
"Content-Length: 0\r\nConnection: close\r\n\r\n";
|
||||
os_net_send_all(sock, busy, strlen(busy), 2000);
|
||||
os_net_close(sock);
|
||||
continue;
|
||||
}
|
||||
cst_http_conn_t *c = calloc(1, sizeof(*c));
|
||||
if (!c) {
|
||||
pthread_mutex_lock(&s->mu);
|
||||
s->nconn--;
|
||||
pthread_cond_broadcast(&s->cv);
|
||||
pthread_mutex_unlock(&s->mu);
|
||||
os_net_close(sock);
|
||||
continue;
|
||||
}
|
||||
c->srv = s;
|
||||
c->sock = sock;
|
||||
pthread_mutex_lock(&s->mu);
|
||||
c->next = s->conns_first;
|
||||
if (c->next)
|
||||
c->next->prev = c;
|
||||
s->conns_first = c;
|
||||
pthread_mutex_unlock(&s->mu);
|
||||
pthread_t th;
|
||||
if (pthread_create(&th, NULL, conn_main, c) != 0) {
|
||||
pthread_mutex_lock(&s->mu);
|
||||
s->conns_first = c->next;
|
||||
if (c->next)
|
||||
c->next->prev = NULL;
|
||||
s->nconn--;
|
||||
pthread_cond_broadcast(&s->cv);
|
||||
pthread_mutex_unlock(&s->mu);
|
||||
os_net_close(sock);
|
||||
free(c);
|
||||
continue;
|
||||
}
|
||||
pthread_detach(th);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
cst_http_server_t *cst_http_start(const cst_http_opts_t *o, char *err,
|
||||
size_t err_sz) {
|
||||
if (!o)
|
||||
return NULL;
|
||||
if (os_net_init() != 0) {
|
||||
if (err)
|
||||
snprintf(err, err_sz, "network init failed");
|
||||
return NULL;
|
||||
}
|
||||
cst_http_server_t *s = calloc(1, sizeof(*s));
|
||||
if (!s)
|
||||
return NULL;
|
||||
s->o = *o;
|
||||
s->o.max_body = o->max_body ? o->max_body : HTTP_DEFAULT_BODY;
|
||||
s->o.max_conn = o->max_conn ? o->max_conn : HTTP_DEFAULT_CONN;
|
||||
if (!s->o.read_timeout_ms)
|
||||
s->o.read_timeout_ms = HTTP_READ_TIMEOUT;
|
||||
if (!s->o.idle_timeout_ms)
|
||||
s->o.idle_timeout_ms = HTTP_IDLE_TIMEOUT;
|
||||
if (o->nroutes > 0) {
|
||||
s->routes = calloc((size_t)o->nroutes, sizeof(*s->routes));
|
||||
if (!s->routes) {
|
||||
free(s);
|
||||
return NULL;
|
||||
}
|
||||
memcpy(s->routes, o->routes, (size_t)o->nroutes * sizeof(*s->routes));
|
||||
s->nroutes = o->nroutes;
|
||||
}
|
||||
pthread_mutex_init(&s->mu, NULL);
|
||||
pthread_cond_init(&s->cv, NULL);
|
||||
s->listener = os_net_listen(o->host, o->port, err, err_sz);
|
||||
if (s->listener == OS_SOCKET_BAD) {
|
||||
free(s->routes);
|
||||
free(s);
|
||||
return NULL;
|
||||
}
|
||||
s->port = os_net_port(s->listener);
|
||||
if (pthread_create(&s->accept_th, NULL, accept_main, s) != 0) {
|
||||
os_net_close(s->listener);
|
||||
free(s->routes);
|
||||
free(s);
|
||||
if (err)
|
||||
snprintf(err, err_sz, "cannot start the accept thread");
|
||||
return NULL;
|
||||
}
|
||||
s->accept_started = 1;
|
||||
return s;
|
||||
}
|
||||
|
||||
int cst_http_port(const cst_http_server_t *s) { return s ? s->port : 0; }
|
||||
|
||||
int cst_http_stopping(const cst_http_server_t *s) {
|
||||
return s ? s->stopping : 1;
|
||||
}
|
||||
|
||||
void cst_http_stop(cst_http_server_t *s) {
|
||||
if (!s)
|
||||
return;
|
||||
pthread_mutex_lock(&s->mu);
|
||||
s->stopping = 1;
|
||||
for (cst_http_conn_t *c = s->conns_first; c; c = c->next)
|
||||
os_net_shutdown(c->sock);
|
||||
pthread_mutex_unlock(&s->mu);
|
||||
os_net_shutdown(s->listener);
|
||||
if (s->accept_started) {
|
||||
pthread_join(s->accept_th, NULL);
|
||||
s->accept_started = 0;
|
||||
}
|
||||
/* Handler threads own their connection until they return; the shutdown
|
||||
* above is what wakes them. Bounded wait so free() stays safe. */
|
||||
pthread_mutex_lock(&s->mu);
|
||||
for (int i = 0; i < 300 && s->nconn > 0; i++) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
ts.tv_nsec += 100000000; /* 100 ms */
|
||||
if (ts.tv_nsec >= 1000000000) {
|
||||
ts.tv_sec++;
|
||||
ts.tv_nsec -= 1000000000;
|
||||
}
|
||||
pthread_cond_timedwait(&s->cv, &s->mu, &ts);
|
||||
}
|
||||
pthread_mutex_unlock(&s->mu);
|
||||
os_net_close(s->listener);
|
||||
}
|
||||
|
||||
void cst_http_free(cst_http_server_t *s) {
|
||||
if (!s)
|
||||
return;
|
||||
pthread_mutex_destroy(&s->mu);
|
||||
pthread_cond_destroy(&s->cv);
|
||||
free(s->routes);
|
||||
free(s);
|
||||
}
|
||||
+53
-3
@@ -3,6 +3,7 @@
|
||||
#include "cst_models.h"
|
||||
#include "cst_tools.h"
|
||||
#include "cst_version.h"
|
||||
#include "cst_webui.h"
|
||||
#include "os_compat.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"
|
||||
" -n, --max-steps N model calls per turn (30)\n"
|
||||
" -H, --headless -p TEXT run one prompt without the TUI\n"
|
||||
" --web serve the UI over HTTP on 127.0.0.1:8787\n"
|
||||
" --web-port N port (0 = pick a free one)\n"
|
||||
" --web-host ADDR bind address (default 127.0.0.1; a\n"
|
||||
" non-loopback address requires a token)\n"
|
||||
" --web-token T require ?token=T / Authorization: Bearer T\n"
|
||||
" --web-dev DIR serve the UI from DIR (development)\n"
|
||||
" --web-open open the browser at startup\n"
|
||||
" -a, --ask-key enter and save the provider's API key\n"
|
||||
" -k, --api-key KEY use KEY for this run only (not saved)\n"
|
||||
" -b, --api-base URL OpenAI-compatible base URL (…/v1)\n"
|
||||
@@ -320,6 +328,16 @@ int main(int argc, char **argv) {
|
||||
const char *prompt = NULL;
|
||||
int new_session = 0, do_list = 0;
|
||||
const char *resume_id = NULL;
|
||||
/* WebUI (--web): a second front end on the same engine. */
|
||||
int web_mode = 0;
|
||||
cst_webui_opts_t web;
|
||||
memset(&web, 0, sizeof(web));
|
||||
const char *env = getenv("CST_WEB_PORT");
|
||||
if (env && env[0])
|
||||
web.port = atoi(env);
|
||||
web.host = getenv("CST_WEB_HOST");
|
||||
web.token = getenv("CST_WEB_TOKEN");
|
||||
web.root = getenv("CST_WEB_ROOT");
|
||||
|
||||
static struct option opts[] = {
|
||||
{"workspace", required_argument, 0, 'w'},
|
||||
@@ -346,6 +364,12 @@ int main(int argc, char **argv) {
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{"version", no_argument, 0, 'V'},
|
||||
{"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}};
|
||||
int c;
|
||||
while ((c = getopt_long(argc, argv, "w:s:b:k:m:P:r:n:T:Hp:dahVNcR:LyA:C:e:", opts, NULL)) !=
|
||||
@@ -453,6 +477,29 @@ int main(int argc, char **argv) {
|
||||
curl_global_cleanup();
|
||||
return rc;
|
||||
}
|
||||
case 0x101:
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 0x102:
|
||||
web.port = atoi(optarg);
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 0x103:
|
||||
web.host = optarg;
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 0x104:
|
||||
web.token = optarg;
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 0x105:
|
||||
web.root = optarg;
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 0x106:
|
||||
web.open_browser = 1;
|
||||
web_mode = 1;
|
||||
break;
|
||||
case 'V':
|
||||
printf("cst-agent %s\n", CST_VERSION);
|
||||
curl_global_cleanup();
|
||||
@@ -600,7 +647,7 @@ int main(int argc, char **argv) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
if (!cfg.api_key[0] && cfg.headless) {
|
||||
if (!cfg.api_key[0] && (cfg.headless || web_mode)) {
|
||||
fprintf(stderr,
|
||||
"no API key for %s: start cst-agent once to enter it, or run "
|
||||
"cst-agent --ask-key --provider %s\n",
|
||||
@@ -610,7 +657,7 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
/* Router presets have no fixed model: take the first one the API lists
|
||||
* (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];
|
||||
char err[256] = "";
|
||||
long age = 0;
|
||||
@@ -654,7 +701,10 @@ int main(int argc, char **argv) {
|
||||
}
|
||||
|
||||
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) {
|
||||
fprintf(stderr, "--headless requires --prompt\n");
|
||||
rc = 1;
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
|
||||
#include "cst_net.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#ifdef _WIN32
|
||||
#ifndef WIN32_LEAN_AND_MEAN
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#endif
|
||||
#include <winsock2.h>
|
||||
#include <ws2tcpip.h>
|
||||
#else
|
||||
#include <arpa/inet.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <netdb.h>
|
||||
#include <netinet/in.h>
|
||||
#include <netinet/tcp.h>
|
||||
#include <signal.h>
|
||||
#include <sys/select.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
|
||||
#ifndef MSG_NOSIGNAL
|
||||
#define MSG_NOSIGNAL 0
|
||||
#endif
|
||||
|
||||
#define NET_SEND_TIMEOUT_MS 20000
|
||||
|
||||
static void set_err(char *err, size_t err_sz, const char *what) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "%s", what);
|
||||
}
|
||||
|
||||
#ifdef _WIN32
|
||||
static void set_err_win(char *err, size_t err_sz, const char *what) {
|
||||
if (err && err_sz)
|
||||
snprintf(err, err_sz, "%s (winsock error %d)", what, WSAGetLastError());
|
||||
}
|
||||
#define SET_ERR(e, n, w) set_err_win(e, n, w)
|
||||
#define NET_FD(s) ((SOCKET)(s))
|
||||
#define NET_NFDS(s) (0) /* Winsock ignores nfds */
|
||||
static void net_close_raw(os_socket_t s) { closesocket((SOCKET)s); }
|
||||
static int net_last_would_block(void) {
|
||||
int e = WSAGetLastError();
|
||||
return e == WSAEWOULDBLOCK || e == WSAEINTR;
|
||||
}
|
||||
static int net_last_conn_reset(void) {
|
||||
int e = WSAGetLastError();
|
||||
return e == WSAECONNRESET || e == WSAECONNABORTED || e == WSAENOTSOCK;
|
||||
}
|
||||
#else
|
||||
#define SET_ERR(e, n, w) set_err(e, n, w)
|
||||
#define NET_FD(s) ((int)(s))
|
||||
#define NET_NFDS(s) ((int)((s) + 1))
|
||||
static void net_close_raw(os_socket_t s) { close(s); }
|
||||
static int net_last_would_block(void) { return errno == EINTR; }
|
||||
static int net_last_conn_reset(void) {
|
||||
return errno == ECONNRESET || errno == EPIPE || errno == EBADF;
|
||||
}
|
||||
#endif
|
||||
|
||||
int os_net_init(void) {
|
||||
#ifdef _WIN32
|
||||
WSADATA w;
|
||||
if (WSAStartup(MAKEWORD(2, 2), &w) != 0)
|
||||
return -1;
|
||||
#else
|
||||
/* A hung-up client must not kill the process with SIGPIPE. */
|
||||
signal(SIGPIPE, SIG_IGN);
|
||||
#endif
|
||||
return 0;
|
||||
}
|
||||
|
||||
void os_net_cleanup(void) {
|
||||
#ifdef _WIN32
|
||||
WSACleanup();
|
||||
#endif
|
||||
}
|
||||
|
||||
static int wait_io(os_socket_t s, int want_write, int timeout_ms) {
|
||||
fd_set fds;
|
||||
FD_ZERO(&fds);
|
||||
FD_SET(NET_FD(s), &fds);
|
||||
struct timeval tv;
|
||||
tv.tv_sec = timeout_ms / 1000;
|
||||
tv.tv_usec = (timeout_ms % 1000) * 1000;
|
||||
int rc = select(NET_NFDS(s), want_write ? NULL : &fds,
|
||||
want_write ? &fds : NULL, NULL, timeout_ms < 0 ? NULL : &tv);
|
||||
if (rc > 0)
|
||||
return 1;
|
||||
if (rc == 0)
|
||||
return 0; /* timed out */
|
||||
return -1;
|
||||
}
|
||||
|
||||
/* Host to bind: AF_INET unless the name is an IPv6 literal, so "localhost"
|
||||
* and "127.0.0.1" always mean the IPv4 loopback a browser will reach. */
|
||||
static int resolve_bind(const char *host, int port, struct sockaddr_storage *sa,
|
||||
socklen_t *sa_len) {
|
||||
memset(sa, 0, sizeof(*sa));
|
||||
int family = AF_INET;
|
||||
const char *h = host && host[0] ? host : "127.0.0.1";
|
||||
if (strchr(h, ':'))
|
||||
family = AF_INET6;
|
||||
struct addrinfo hints, *res = NULL;
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = family;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
hints.ai_flags = AI_PASSIVE;
|
||||
char portstr[16];
|
||||
snprintf(portstr, sizeof(portstr), "%d", port);
|
||||
if (getaddrinfo(h, portstr, &hints, &res) != 0 || !res)
|
||||
return -1;
|
||||
memcpy(sa, res->ai_addr, res->ai_addrlen);
|
||||
*sa_len = (socklen_t)res->ai_addrlen;
|
||||
freeaddrinfo(res);
|
||||
return 0;
|
||||
}
|
||||
|
||||
os_socket_t os_net_listen(const char *host, int port, char *err, size_t err_sz) {
|
||||
struct sockaddr_storage sa;
|
||||
socklen_t sa_len;
|
||||
if (resolve_bind(host, port, &sa, &sa_len) != 0) {
|
||||
SET_ERR(err, err_sz, "cannot resolve the bind address");
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
os_socket_t s = (os_socket_t)socket(sa.ss_family, SOCK_STREAM, 0);
|
||||
if (s == OS_SOCKET_BAD) {
|
||||
SET_ERR(err, err_sz, "cannot create a socket");
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
int one = 1;
|
||||
setsockopt(NET_FD(s), SOL_SOCKET, SO_REUSEADDR, (const char *)&one,
|
||||
sizeof(one));
|
||||
#ifdef IPV6_V6ONLY
|
||||
if (sa.ss_family == AF_INET6)
|
||||
setsockopt(NET_FD(s), IPPROTO_IPV6, IPV6_V6ONLY, (const char *)&one,
|
||||
sizeof(one));
|
||||
#endif
|
||||
if (bind(NET_FD(s), (const struct sockaddr *)&sa, sa_len) != 0) {
|
||||
SET_ERR(err, err_sz, "the address is already in use");
|
||||
net_close_raw(s);
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
if (listen(NET_FD(s), 32) != 0) {
|
||||
SET_ERR(err, err_sz, "listen failed");
|
||||
net_close_raw(s);
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
int os_net_port(os_socket_t s) {
|
||||
struct sockaddr_storage sa;
|
||||
socklen_t len = sizeof(sa);
|
||||
if (getsockname(NET_FD(s), (struct sockaddr *)&sa, &len) != 0)
|
||||
return 0;
|
||||
if (sa.ss_family == AF_INET)
|
||||
return ntohs(((struct sockaddr_in *)&sa)->sin_port);
|
||||
if (sa.ss_family == AF_INET6)
|
||||
return ntohs(((struct sockaddr_in6 *)&sa)->sin6_port);
|
||||
return 0;
|
||||
}
|
||||
|
||||
void os_net_peer(os_socket_t s, char *out, size_t out_sz) {
|
||||
if (!out || out_sz == 0)
|
||||
return;
|
||||
out[0] = 0;
|
||||
struct sockaddr_storage sa;
|
||||
socklen_t len = sizeof(sa);
|
||||
if (getpeername(NET_FD(s), (struct sockaddr *)&sa, &len) != 0)
|
||||
return;
|
||||
char ip[64] = "";
|
||||
int port = 0;
|
||||
if (sa.ss_family == AF_INET) {
|
||||
if (!inet_ntop(AF_INET, &((struct sockaddr_in *)&sa)->sin_addr, ip,
|
||||
sizeof(ip)))
|
||||
return;
|
||||
port = ntohs(((struct sockaddr_in *)&sa)->sin_port);
|
||||
} else if (sa.ss_family == AF_INET6) {
|
||||
if (!inet_ntop(AF_INET6, &((struct sockaddr_in6 *)&sa)->sin6_addr, ip,
|
||||
sizeof(ip)))
|
||||
return;
|
||||
port = ntohs(((struct sockaddr_in6 *)&sa)->sin6_port);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
if (strchr(ip, ':'))
|
||||
snprintf(out, out_sz, "[%s]:%d", ip, port);
|
||||
else
|
||||
snprintf(out, out_sz, "%s:%d", ip, port);
|
||||
}
|
||||
|
||||
os_socket_t os_net_connect(const char *host, int port, int timeout_ms) {
|
||||
const char *h = host && host[0] ? host : "127.0.0.1";
|
||||
struct addrinfo hints, *res = NULL;
|
||||
memset(&hints, 0, sizeof(hints));
|
||||
hints.ai_family = strchr(h, ':') ? AF_INET6 : AF_INET;
|
||||
hints.ai_socktype = SOCK_STREAM;
|
||||
char portstr[16];
|
||||
snprintf(portstr, sizeof(portstr), "%d", port);
|
||||
if (getaddrinfo(h, portstr, &hints, &res) != 0 || !res)
|
||||
return OS_SOCKET_BAD;
|
||||
os_socket_t s = (os_socket_t)socket(res->ai_family, SOCK_STREAM, 0);
|
||||
if (s == OS_SOCKET_BAD) {
|
||||
freeaddrinfo(res);
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
/* Bounded wait: connect() non-blocking, poll, then read SO_ERROR. A dead
|
||||
* server must not hang the caller. */
|
||||
#ifdef _WIN32
|
||||
u_long nb = 1;
|
||||
ioctlsocket(NET_FD(s), FIONBIO, &nb);
|
||||
#else
|
||||
int fl = fcntl(s, F_GETFL, 0);
|
||||
fcntl(s, F_SETFL, fl | O_NONBLOCK);
|
||||
#endif
|
||||
int rc = connect(NET_FD(s), res->ai_addr, (int)res->ai_addrlen);
|
||||
freeaddrinfo(res);
|
||||
if (rc != 0) {
|
||||
#ifdef _WIN32
|
||||
int in_progress = WSAGetLastError() == WSAEWOULDBLOCK;
|
||||
#else
|
||||
int in_progress = errno == EINPROGRESS || errno == EINTR;
|
||||
#endif
|
||||
if (!in_progress || wait_io(s, 1, timeout_ms) <= 0) {
|
||||
net_close_raw(s);
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
int soerr = 0;
|
||||
socklen_t elen = sizeof(soerr);
|
||||
if (getsockopt(NET_FD(s), SOL_SOCKET, SO_ERROR, (char *)&soerr, &elen) != 0 ||
|
||||
soerr != 0) {
|
||||
net_close_raw(s);
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
}
|
||||
#ifdef _WIN32
|
||||
nb = 0;
|
||||
ioctlsocket(NET_FD(s), FIONBIO, &nb);
|
||||
#else
|
||||
fcntl(s, F_SETFL, fl);
|
||||
#endif
|
||||
return s;
|
||||
}
|
||||
|
||||
os_socket_t os_net_accept(os_socket_t s, int timeout_ms, int *timed_out) {
|
||||
if (timed_out)
|
||||
*timed_out = 0;
|
||||
int w = wait_io(s, 0, timeout_ms);
|
||||
if (w == 0) {
|
||||
if (timed_out)
|
||||
*timed_out = 1;
|
||||
return OS_SOCKET_BAD;
|
||||
}
|
||||
if (w < 0)
|
||||
return OS_SOCKET_BAD;
|
||||
os_socket_t c = (os_socket_t)accept(NET_FD(s), NULL, NULL);
|
||||
if (c == OS_SOCKET_BAD)
|
||||
return OS_SOCKET_BAD;
|
||||
int one = 1;
|
||||
setsockopt(NET_FD(c), IPPROTO_TCP, TCP_NODELAY, (const char *)&one,
|
||||
sizeof(one));
|
||||
return c;
|
||||
}
|
||||
|
||||
long os_net_recv(os_socket_t s, void *buf, size_t n, int timeout_ms) {
|
||||
int w = wait_io(s, 0, timeout_ms);
|
||||
if (w == 0)
|
||||
return -2;
|
||||
if (w < 0)
|
||||
return -1;
|
||||
size_t want = n > (size_t)(1 << 20) ? (size_t)(1 << 20) : n;
|
||||
int r = (int)recv(NET_FD(s), (char *)buf, (int)want, 0);
|
||||
if (r == 0)
|
||||
return 0;
|
||||
if (r < 0)
|
||||
return net_last_conn_reset() ? 0 : (net_last_would_block() ? -2 : -1);
|
||||
return r;
|
||||
}
|
||||
|
||||
int os_net_send_all(os_socket_t s, const void *buf, size_t n, int timeout_ms) {
|
||||
const char *p = buf;
|
||||
size_t off = 0;
|
||||
while (off < n) {
|
||||
int w = wait_io(s, 1, timeout_ms ? timeout_ms : NET_SEND_TIMEOUT_MS);
|
||||
if (w <= 0)
|
||||
return -1;
|
||||
long r = (long)send(NET_FD(s), p + off, (int)(n - off), MSG_NOSIGNAL);
|
||||
if (r <= 0) {
|
||||
if (net_last_would_block())
|
||||
continue;
|
||||
return -1;
|
||||
}
|
||||
off += (size_t)r;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
void os_net_shutdown(os_socket_t s) {
|
||||
#ifdef _WIN32
|
||||
shutdown((SOCKET)s, SD_BOTH);
|
||||
#else
|
||||
shutdown(s, SHUT_RDWR);
|
||||
#endif
|
||||
}
|
||||
|
||||
void os_net_close(os_socket_t s) {
|
||||
if (s == OS_SOCKET_BAD)
|
||||
return;
|
||||
net_close_raw(s);
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
|
||||
#include "cst_web_md.h"
|
||||
|
||||
#include "cst_util.h"
|
||||
#include "sashidown.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
struct cst_web_md {
|
||||
sashidown_stream_t *st;
|
||||
cst_web_md_emit_fn emit;
|
||||
void *ud;
|
||||
cst_buf_t in; /* HTML from sashidown, waiting for a complete tag */
|
||||
int failed;
|
||||
};
|
||||
|
||||
/* --- sanitizer ------------------------------------------------------------ */
|
||||
|
||||
static const char *ALLOWED_TAGS[] = {
|
||||
"p", "br", "hr", "h1", "h2", "h3", "h4",
|
||||
"h5", "h6", "strong", "em", "b", "i", "u",
|
||||
"s", "del", "code", "pre", "blockquote", "ul", "ol",
|
||||
"li", "table", "thead", "tbody", "tr", "th", "td",
|
||||
"a", "img", "span", "div", NULL};
|
||||
|
||||
static int tag_allowed(const char *name, size_t n) {
|
||||
for (int i = 0; ALLOWED_TAGS[i]; i++) {
|
||||
if (strlen(ALLOWED_TAGS[i]) == n && strncmp(name, ALLOWED_TAGS[i], n) == 0)
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* A URL we are willing to put in href: http(s), mailto, or a same-site
|
||||
* path. Anything else (javascript:, data:, file:, //evil) is dropped. */
|
||||
static int href_ok(const char *v, size_t n) {
|
||||
if (n == 0)
|
||||
return 0;
|
||||
if (!strncmp(v, "http://", 7) || !strncmp(v, "https://", 8) ||
|
||||
!strncmp(v, "mailto:", 7))
|
||||
return 1;
|
||||
if (v[0] == '/' && !(n > 1 && (v[1] == '/' || v[1] == '\\')))
|
||||
return 1;
|
||||
if (v[0] == '#')
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int src_ok(const char *v, size_t n) {
|
||||
if (n > 11 && !strncmp(v, "data:image/", 11))
|
||||
return 1;
|
||||
if (n > 12 && !strncmp(v, "/api/attach/", 12))
|
||||
return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void attr_append(cst_buf_t *b, const char *name, size_t nlen,
|
||||
const char *val, size_t vlen) {
|
||||
cst_buf_puts(b, " ");
|
||||
cst_buf_append(b, name, nlen);
|
||||
cst_buf_puts(b, "=\"");
|
||||
cst_buf_append(b, val, vlen);
|
||||
cst_buf_puts(b, "\"");
|
||||
}
|
||||
|
||||
/* Rewrite one tag (open, close or self-closing) into out; may emit nothing
|
||||
* when the tag is not allowed. Returns 0, or -1 on OOM. */
|
||||
static int sanitize_tag(const char *tag, size_t len, cst_buf_t *out) {
|
||||
/* <name ...> or </name> or <!-- ... --> */
|
||||
size_t i = 0;
|
||||
int closing = 0;
|
||||
if (len < 3 || tag[0] != '<')
|
||||
return 0;
|
||||
i = 1;
|
||||
if (i < len && tag[i] == '/') {
|
||||
closing = 1;
|
||||
i++;
|
||||
}
|
||||
size_t name_start = i;
|
||||
while (i < len && ((tag[i] >= 'a' && tag[i] <= 'z') ||
|
||||
(tag[i] >= 'A' && tag[i] <= 'Z') ||
|
||||
(tag[i] >= '0' && tag[i] <= '9')))
|
||||
i++;
|
||||
size_t name_len = i - name_start;
|
||||
if (name_len == 0 || !tag_allowed(tag + name_start, name_len))
|
||||
return 0; /* comments, <script>, <iframe>: dropped entirely */
|
||||
int is_img = name_len == 3 && strncmp(tag + name_start, "img", 3) == 0;
|
||||
int is_a = name_len == 1 && tag[name_start] == 'a';
|
||||
int self_closing = len > 2 && tag[len - 2] == '/';
|
||||
size_t start = out->len;
|
||||
cst_buf_puts(out, "<");
|
||||
if (closing)
|
||||
cst_buf_puts(out, "/");
|
||||
cst_buf_append(out, tag + name_start, name_len);
|
||||
if (!closing) {
|
||||
/* copy the allowed attributes */
|
||||
int keep_target = 0; /* a link is only useful with a usable href */
|
||||
size_t p = name_start + name_len;
|
||||
while (p < len && tag[p] != '>' && !(tag[p] == '/' && p + 1 < len &&
|
||||
tag[p + 1] == '>')) {
|
||||
while (p < len && (tag[p] == ' ' || tag[p] == '\t' || tag[p] == '\n'))
|
||||
p++;
|
||||
size_t an = p;
|
||||
while (p < len && tag[p] != '=' && tag[p] != ' ' && tag[p] != '\t' &&
|
||||
tag[p] != '\n' && tag[p] != '>' && tag[p] != '/')
|
||||
p++;
|
||||
size_t an_len = p - an;
|
||||
if (an_len == 0) {
|
||||
p++;
|
||||
continue;
|
||||
}
|
||||
const char *aval = "";
|
||||
size_t av_len = 0;
|
||||
if (p < len && tag[p] == '=') {
|
||||
p++;
|
||||
char quote = 0;
|
||||
if (p < len && (tag[p] == '"' || tag[p] == '\''))
|
||||
quote = tag[p++];
|
||||
size_t as = p;
|
||||
while (p < len &&
|
||||
(quote ? tag[p] != quote : (tag[p] != ' ' && tag[p] != '>')))
|
||||
p++;
|
||||
aval = tag + as;
|
||||
av_len = p - as;
|
||||
if (quote && p < len)
|
||||
p++; /* closing quote */
|
||||
}
|
||||
int keep = 0;
|
||||
if (is_a && an_len == 4 && strncmp(tag + an, "href", 4) == 0) {
|
||||
keep = href_ok(aval, av_len);
|
||||
keep_target = keep;
|
||||
} else if (is_img && an_len == 3 && strncmp(tag + an, "src", 3) == 0) {
|
||||
keep = src_ok(aval, av_len);
|
||||
keep_target = keep;
|
||||
} else if (is_img && an_len == 3 && strncmp(tag + an, "alt", 3) == 0)
|
||||
keep = 1;
|
||||
else if (an_len == 5 && strncmp(tag + an, "class", 5) == 0 &&
|
||||
av_len > 9 && !strncmp(aval, "language-", 9))
|
||||
keep = 1;
|
||||
if (keep)
|
||||
attr_append(out, tag + an, an_len, aval, av_len);
|
||||
}
|
||||
if (is_a && keep_target) {
|
||||
cst_buf_puts(out, " target=\"_blank\" rel=\"noopener noreferrer\"");
|
||||
}
|
||||
if ((is_a && !keep_target) || (is_img && !keep_target)) {
|
||||
/* a link with an unusable href, or an image from nowhere: drop the
|
||||
* whole tag and keep only its text */
|
||||
out->len = start;
|
||||
if (out->buf)
|
||||
out->buf[start] = 0;
|
||||
return 0;
|
||||
}
|
||||
cst_buf_puts(out, self_closing ? " /" : "");
|
||||
}
|
||||
cst_buf_puts(out, ">");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Emit the text before every '<' and every tag through the sanitizer. */
|
||||
static void scan_chunk(cst_web_md_t *s, int final) {
|
||||
if (!s->emit || s->failed)
|
||||
return;
|
||||
size_t i = 0, emitted = 0;
|
||||
while (i < s->in.len) {
|
||||
if (s->in.buf[i] != '<') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
size_t close = i;
|
||||
while (close < s->in.len && s->in.buf[close] != '>')
|
||||
close++;
|
||||
if (close >= s->in.len && !final)
|
||||
break; /* the tag may still be growing: keep it buffered */
|
||||
if (i > emitted && s->emit(s->in.buf + emitted, i - emitted, s->ud) != 0)
|
||||
s->failed = 1;
|
||||
size_t tag_len = close >= s->in.len ? s->in.len - i : close - i + 1;
|
||||
cst_buf_t tag = {0};
|
||||
if (sanitize_tag(s->in.buf + i, tag_len, &tag) != 0) {
|
||||
cst_buf_free(&tag);
|
||||
s->failed = 1;
|
||||
return;
|
||||
}
|
||||
if (tag.len && s->emit(tag.buf, tag.len, s->ud) != 0)
|
||||
s->failed = 1;
|
||||
cst_buf_free(&tag);
|
||||
i = i + tag_len;
|
||||
emitted = i;
|
||||
if (s->failed)
|
||||
break;
|
||||
}
|
||||
if (emitted > 0) {
|
||||
memmove(s->in.buf, s->in.buf + emitted, s->in.len - emitted);
|
||||
s->in.len -= emitted;
|
||||
s->in.buf[s->in.len] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Collector used by the one-shot sanitizer. */
|
||||
typedef struct {
|
||||
cst_buf_t *b;
|
||||
int failed;
|
||||
} md_collect_t;
|
||||
|
||||
static int collect_emit(const char *html, size_t len, void *ud) {
|
||||
md_collect_t *c = ud;
|
||||
if (cst_buf_append(c->b, html, len) != 0) {
|
||||
c->failed = 1;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
char *cst_web_md_sanitize(const char *html, size_t len) {
|
||||
cst_web_md_t tmp;
|
||||
memset(&tmp, 0, sizeof(tmp));
|
||||
cst_buf_t out = {0};
|
||||
md_collect_t col = {&out, 0};
|
||||
if (cst_buf_append(&tmp.in, html ? html : "", len) != 0)
|
||||
return NULL;
|
||||
tmp.emit = collect_emit;
|
||||
tmp.ud = &col;
|
||||
scan_chunk(&tmp, 1);
|
||||
cst_buf_free(&tmp.in);
|
||||
if (col.failed) {
|
||||
cst_buf_free(&out);
|
||||
return NULL;
|
||||
}
|
||||
return cst_buf_steal(&out);
|
||||
}
|
||||
|
||||
/* --- sashidown glue ------------------------------------------------------- */
|
||||
|
||||
static int on_sashidown_html(const char *html, size_t len, void *user) {
|
||||
cst_web_md_t *s = user;
|
||||
if (cst_buf_append(&s->in, html, len) != 0) {
|
||||
s->failed = 1;
|
||||
return 1;
|
||||
}
|
||||
scan_chunk(s, 0);
|
||||
return s->failed ? 1 : 0;
|
||||
}
|
||||
|
||||
cst_web_md_t *cst_web_md_new(cst_web_md_emit_fn emit, void *ud) {
|
||||
cst_web_md_t *s = calloc(1, sizeof(*s));
|
||||
if (!s)
|
||||
return NULL;
|
||||
sashidown_stream_opts_t opts;
|
||||
memset(&opts, 0, sizeof(opts));
|
||||
opts.on_html = on_sashidown_html;
|
||||
opts.user = s;
|
||||
s->st = sashidown_stream_create(&opts);
|
||||
if (!s->st) {
|
||||
free(s);
|
||||
return NULL;
|
||||
}
|
||||
s->emit = emit;
|
||||
s->ud = ud;
|
||||
return s;
|
||||
}
|
||||
|
||||
int cst_web_md_feed(cst_web_md_t *s, const char *data, size_t len) {
|
||||
if (!s || !s->st)
|
||||
return -1;
|
||||
if (len && sashidown_stream_feed(s->st, data, len) != 0) {
|
||||
s->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
return s->failed ? -1 : 0;
|
||||
}
|
||||
|
||||
int cst_web_md_finish(cst_web_md_t *s) {
|
||||
if (!s || !s->st)
|
||||
return -1;
|
||||
if (sashidown_stream_finish(s->st) != 0) {
|
||||
s->failed = 1;
|
||||
return -1;
|
||||
}
|
||||
scan_chunk(s, 1);
|
||||
return s->failed ? -1 : 0;
|
||||
}
|
||||
|
||||
void cst_web_md_free(cst_web_md_t *s) {
|
||||
if (!s)
|
||||
return;
|
||||
if (s->st)
|
||||
sashidown_stream_destroy(s->st);
|
||||
cst_buf_free(&s->in);
|
||||
free(s);
|
||||
}
|
||||
|
||||
char *cst_web_md_html(const char *md, size_t len) {
|
||||
if (!md)
|
||||
return NULL;
|
||||
size_t cap = 65536;
|
||||
char *out = NULL;
|
||||
for (int attempt = 0; attempt < 8; attempt++) {
|
||||
char *buf = malloc(cap);
|
||||
if (!buf)
|
||||
return NULL;
|
||||
size_t out_len = 0;
|
||||
if (sashidown_convert(md, len, buf, cap, &out_len, SASHIDOWN_FMT_HTML) ==
|
||||
0) {
|
||||
out = cst_web_md_sanitize(buf, out_len);
|
||||
free(buf);
|
||||
return out;
|
||||
}
|
||||
free(buf);
|
||||
if (cap >= (size_t)8 * 1024 * 1024)
|
||||
break;
|
||||
cap *= 2;
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
+1257
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -0,0 +1,280 @@
|
||||
/* WebUI: the socket/HTTP layer, the SSE framing, auth and the HTML
|
||||
* sanitizer. The full turn path is covered by scripts/e2e_webui.sh, which
|
||||
* runs a recorded stream through a real server. */
|
||||
#ifndef _DEFAULT_SOURCE
|
||||
#define _DEFAULT_SOURCE
|
||||
#endif
|
||||
|
||||
#include "cst_http.h"
|
||||
#include "cst_net.h"
|
||||
#include "cst_util.h"
|
||||
#include "cst_web_md.h"
|
||||
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "cst_test_util.h"
|
||||
|
||||
#define CHECK(c) \
|
||||
do { \
|
||||
if (!(c)) { \
|
||||
fprintf(stderr, "%s:%d: CHECK(%s) failed\n", __FILE__, __LINE__, #c); \
|
||||
exit(1); \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
/* --- a tiny HTTP client --------------------------------------------------- */
|
||||
|
||||
static os_socket_t connect_to(int port) {
|
||||
char host[32];
|
||||
snprintf(host, sizeof(host), "127.0.0.1");
|
||||
os_socket_t s = os_net_connect(host, port, 3000);
|
||||
CHECK(s != OS_SOCKET_BAD);
|
||||
return s;
|
||||
}
|
||||
|
||||
/* Sends data, reads until the peer closes (or 5 s pass). malloc'd. */
|
||||
static char *roundtrip(int port, const char *req, size_t *out_len) {
|
||||
os_socket_t s = connect_to(port);
|
||||
CHECK(os_net_send_all(s, req, strlen(req), 3000) == 0);
|
||||
cst_buf_t b = {0};
|
||||
char buf[4096];
|
||||
for (;;) {
|
||||
long n = os_net_recv(s, buf, sizeof(buf), 5000);
|
||||
if (n <= 0)
|
||||
break;
|
||||
CHECK(cst_buf_append(&b, buf, (size_t)n) == 0);
|
||||
}
|
||||
os_net_close(s);
|
||||
*out_len = b.len;
|
||||
return cst_buf_steal(&b);
|
||||
}
|
||||
|
||||
/* Same, but stops as soon as `needle` shows up. */
|
||||
|
||||
/* --- handlers ------------------------------------------------------------- */
|
||||
|
||||
static int h_hello(cst_http_conn_t *c, void *ud) {
|
||||
(void)ud;
|
||||
return cst_http_send_str(c, 200, "text/plain", "world");
|
||||
}
|
||||
|
||||
static int h_secret(cst_http_conn_t *c, void *ud) {
|
||||
(void)ud;
|
||||
return cst_http_send_str(c, 200, "text/plain", "secret");
|
||||
}
|
||||
|
||||
static int h_echo(cst_http_conn_t *c, void *ud) {
|
||||
(void)ud;
|
||||
const cst_http_req_t *r = cst_http_request(c);
|
||||
char head[64];
|
||||
snprintf(head, sizeof(head), "%zu:", r->body_len);
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_puts(&b, head);
|
||||
cst_buf_append(&b, r->body ? r->body : "", r->body_len);
|
||||
int rc = cst_http_send(c, 200, "text/plain", b.buf, b.len);
|
||||
cst_buf_free(&b);
|
||||
return rc;
|
||||
}
|
||||
|
||||
static int h_events(cst_http_conn_t *c, void *ud) {
|
||||
(void)ud;
|
||||
CHECK(cst_http_stream_begin(c, "text/event-stream") == 0);
|
||||
CHECK(cst_http_stream_write(c, "event: one\ndata: {\"n\":1}\n\n", 26) == 0);
|
||||
CHECK(cst_http_stream_write(c, "event: two\ndata: {\"n\":2}\n\n", 26) == 0);
|
||||
CHECK(cst_http_stream_end(c) == 0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int auth_bearer(const cst_http_req_t *req, void *ud) {
|
||||
const char *want = ud;
|
||||
if (!req->authorization)
|
||||
return -1;
|
||||
if (strncmp(req->authorization, "Bearer ", 7) != 0)
|
||||
return -1;
|
||||
return strcmp(req->authorization + 7, want) == 0 ? 0 : -1;
|
||||
}
|
||||
|
||||
static void test_http(void) {
|
||||
static const cst_http_route_t routes[] = {
|
||||
{"GET", "/hello", h_hello, NULL, 0, 0},
|
||||
{"GET", "/secret", h_secret, NULL, 1, 0},
|
||||
{"GET", "/events", h_events, NULL, 0, 0},
|
||||
{"POST", "/echo", h_echo, NULL, 0, 4096},
|
||||
};
|
||||
cst_http_opts_t o;
|
||||
memset(&o, 0, sizeof(o));
|
||||
o.host = "127.0.0.1";
|
||||
o.port = 0;
|
||||
o.max_body = 1024 * 1024;
|
||||
o.routes = routes;
|
||||
o.nroutes = (int)(sizeof(routes) / sizeof(routes[0]));
|
||||
o.auth = auth_bearer;
|
||||
o.auth_ud = (void *)"s3cret";
|
||||
char err[256] = "";
|
||||
cst_http_server_t *s = cst_http_start(&o, err, sizeof(err));
|
||||
CHECK(s != NULL);
|
||||
int port = cst_http_port(s);
|
||||
CHECK(port > 0);
|
||||
|
||||
size_t n = 0;
|
||||
char *resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n"
|
||||
"Connection: close\r\n\r\n", &n);
|
||||
CHECK(strstr(resp, "200 OK") && strstr(resp, "world"));
|
||||
free(resp);
|
||||
|
||||
/* keep-alive: two requests, one connection */
|
||||
resp = roundtrip(port, "GET /hello HTTP/1.1\r\nHost: x\r\n\r\n"
|
||||
"GET /hello HTTP/1.1\r\nHost: x\r\n"
|
||||
"Connection: close\r\n\r\n", &n);
|
||||
CHECK(strstr(resp, "200 OK"));
|
||||
CHECK(strstr(resp, "200 OK") != strrchr(resp, '2')); /* twice */
|
||||
{
|
||||
int hits = 0;
|
||||
for (const char *p = resp; (p = strstr(p, "200 OK")); p++)
|
||||
hits++;
|
||||
CHECK(hits == 2);
|
||||
}
|
||||
free(resp);
|
||||
|
||||
/* auth: without, then with the token */
|
||||
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
|
||||
"Connection: close\r\n\r\n", &n);
|
||||
CHECK(strstr(resp, "401"));
|
||||
free(resp);
|
||||
resp = roundtrip(port, "GET /secret HTTP/1.1\r\nHost: x\r\n"
|
||||
"Authorization: Bearer s3cret\r\n"
|
||||
"Connection: close\r\n\r\n", &n);
|
||||
CHECK(strstr(resp, "200") && strstr(resp, "secret"));
|
||||
free(resp);
|
||||
|
||||
/* unknown route */
|
||||
resp = roundtrip(port, "GET /nope HTTP/1.1\r\nHost: x\r\n"
|
||||
"Connection: close\r\n\r\n", &n);
|
||||
CHECK(strstr(resp, "404"));
|
||||
free(resp);
|
||||
/* traversal is rejected before any handler runs */
|
||||
resp = roundtrip(port, "GET /../etc/passwd HTTP/1.1\r\nHost: x\r\n"
|
||||
"Connection: close\r\n\r\n", &n);
|
||||
CHECK(strstr(resp, "400") || strstr(resp, "404"));
|
||||
free(resp);
|
||||
|
||||
/* a body round trip, and the per-route cap */
|
||||
resp = roundtrip(port, "POST /echo HTTP/1.1\r\nHost: x\r\n"
|
||||
"Content-Length: 3\r\nConnection: close\r\n\r\nabc", &n);
|
||||
CHECK(strstr(resp, "200") && strstr(resp, "3:abc"));
|
||||
free(resp);
|
||||
{
|
||||
cst_buf_t b = {0};
|
||||
cst_buf_puts(&b, "POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 9999\r\n"
|
||||
"Connection: close\r\n\r\n");
|
||||
for (int i = 0; i < 9999; i++)
|
||||
cst_buf_puts(&b, "a");
|
||||
resp = roundtrip(port, b.buf, &n);
|
||||
CHECK(strstr(resp, "413"));
|
||||
free(resp);
|
||||
cst_buf_free(&b);
|
||||
}
|
||||
|
||||
/* chunked streaming: the client must see the decoded frames */
|
||||
resp = roundtrip(port, "GET /events HTTP/1.1\r\nHost: x\r\n"
|
||||
"Connection: close\r\n\r\n", &n);
|
||||
CHECK(strstr(resp, "Transfer-Encoding: chunked"));
|
||||
CHECK(strstr(resp, "event: one") && strstr(resp, "event: two"));
|
||||
CHECK(strstr(resp, "\r\n0\r\n\r\n")); /* terminator */
|
||||
free(resp);
|
||||
|
||||
cst_http_stop(s);
|
||||
cst_http_free(s);
|
||||
}
|
||||
|
||||
/* --- query parsing -------------------------------------------------------- */
|
||||
|
||||
static void test_query(void) {
|
||||
cst_http_req_t r;
|
||||
memset(&r, 0, sizeof(r));
|
||||
snprintf(r.query, sizeof(r.query), "token=a%%20b&since=17&x=");
|
||||
char buf[64];
|
||||
CHECK(cst_http_query(&r, "token", buf, sizeof(buf)) == 0);
|
||||
CHECK(strcmp(buf, "a b") == 0);
|
||||
CHECK(cst_http_query(&r, "since", buf, sizeof(buf)) == 0);
|
||||
CHECK(strcmp(buf, "17") == 0);
|
||||
CHECK(cst_http_query(&r, "nope", buf, sizeof(buf)) != 0);
|
||||
}
|
||||
|
||||
/* --- markdown + sanitizer ------------------------------------------------- */
|
||||
|
||||
static cst_buf_t g_md_out;
|
||||
|
||||
static int collect_md(const char *html, size_t len, void *ud) {
|
||||
(void)ud;
|
||||
return cst_buf_append(&g_md_out, html, len) == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
static void test_sanitize(void) {
|
||||
char *html = cst_web_md_html("**hi** [x](javascript:alert(1)) "
|
||||
"",
|
||||
strlen("**hi** [x](javascript:alert(1)) "
|
||||
""));
|
||||
CHECK(html != NULL);
|
||||
CHECK(strstr(html, "<strong>hi</strong>"));
|
||||
CHECK(strstr(html, "javascript:") == NULL);
|
||||
CHECK(strstr(html, "/api/attach/img-1.png"));
|
||||
free(html);
|
||||
|
||||
/* raw markup that sashidown would pass through gets stripped */
|
||||
const char *evil = "<p>ok</p><script>alert(1)</script>"
|
||||
"<img src=\"http://evil.example/x.png\" alt=\"a\">"
|
||||
"<a href=\"//evil.example\">l</a>";
|
||||
html = cst_web_md_sanitize(evil, strlen(evil));
|
||||
CHECK(html != NULL);
|
||||
CHECK(strstr(html, "ok"));
|
||||
CHECK(strstr(html, "<script") == NULL);
|
||||
CHECK(strstr(html, "evil.example/x.png") == NULL);
|
||||
CHECK(strstr(html, "//evil.example") == NULL);
|
||||
CHECK(strstr(html, "<a") == NULL); /* an unusable href drops the tag */
|
||||
free(html);
|
||||
|
||||
/* streaming: a javascript: link is dropped, a split tag never leaks */
|
||||
{
|
||||
cst_buf_free(&g_md_out);
|
||||
const char *src = "see [x](javascript:alert(1)) and **ok**";
|
||||
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
|
||||
CHECK(md != NULL);
|
||||
CHECK(cst_web_md_feed(md, src, 4) == 0); /* a deliberately split tag */
|
||||
CHECK(cst_web_md_feed(md, src + 4, strlen(src) - 4) == 0);
|
||||
CHECK(cst_web_md_finish(md) == 0);
|
||||
cst_web_md_free(md);
|
||||
const char *out = g_md_out.buf ? g_md_out.buf : "";
|
||||
CHECK(strstr(out, "javascript:") == NULL);
|
||||
CHECK(strstr(out, "href") == NULL);
|
||||
CHECK(strstr(out, "see"));
|
||||
CHECK(strstr(out, "<strong>ok</strong>") != NULL);
|
||||
cst_buf_free(&g_md_out);
|
||||
}
|
||||
{
|
||||
/* and the same text fed one byte at a time still comes out whole */
|
||||
cst_buf_free(&g_md_out);
|
||||
const char *md_src = "**bold** and [ok](https://example.com)";
|
||||
cst_web_md_t *md = cst_web_md_new(collect_md, NULL);
|
||||
CHECK(md != NULL);
|
||||
for (size_t i = 0; i < strlen(md_src); i++)
|
||||
CHECK(cst_web_md_feed(md, md_src + i, 1) == 0);
|
||||
CHECK(cst_web_md_finish(md) == 0);
|
||||
cst_web_md_free(md);
|
||||
CHECK(strstr(g_md_out.buf, "<strong>bold</strong>"));
|
||||
CHECK(strstr(g_md_out.buf, "https://example.com"));
|
||||
CHECK(strstr(g_md_out.buf, "noopener") != NULL);
|
||||
cst_buf_free(&g_md_out);
|
||||
}
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
CHECK(os_net_init() == 0);
|
||||
test_query();
|
||||
test_sanitize();
|
||||
test_http();
|
||||
printf("test_webui: OK\n");
|
||||
return 0;
|
||||
}
|
||||
+433
@@ -0,0 +1,433 @@
|
||||
/* CST-Agent WebUI client: HTTP + SSE against the engine in the same binary.
|
||||
* No build step, no dependencies: the server renders Markdown, this file
|
||||
* only moves messages around and draws them. */
|
||||
'use strict';
|
||||
|
||||
/* --- token: from ?token= / #token= once, then kept in a cookie by the
|
||||
* server; the query parameter is appended to every request so EventSource
|
||||
* (which cannot set headers) works as well. --- */
|
||||
const TOKEN = (() => {
|
||||
const q = new URLSearchParams(location.search);
|
||||
if (q.get('token')) return q.get('token');
|
||||
const h = location.hash.replace(/^#/, '');
|
||||
const hp = new URLSearchParams(h);
|
||||
return hp.get('token') || '';
|
||||
})();
|
||||
|
||||
function api(path, opt) {
|
||||
if (TOKEN) path += (path.includes('?') ? '&' : '?') + 'token=' + encodeURIComponent(TOKEN);
|
||||
return fetch(path, opt);
|
||||
}
|
||||
async function getJSON(path) {
|
||||
const r = await api(path);
|
||||
if (!r.ok) throw new Error((await r.text()) || r.statusText);
|
||||
return r.json();
|
||||
}
|
||||
async function post(path, body) {
|
||||
const r = await api(path, {
|
||||
method: 'POST',
|
||||
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await r.text();
|
||||
if (!r.ok) throw new Error(text || r.statusText);
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const log = $('log');
|
||||
let state = {};
|
||||
let live = null; /* {body, html, think} for the streaming assistant block */
|
||||
let tools = new Map();/* tool call id -> element */
|
||||
let permReq = null; /* pending permission event */
|
||||
let askReq = null; /* pending question */
|
||||
let firstPaint = true;
|
||||
|
||||
/* --- transcript ---------------------------------------------------------- */
|
||||
|
||||
function atBottom() {
|
||||
return log.scrollTop + log.clientHeight >= log.scrollHeight - 40;
|
||||
}
|
||||
function scrollDown(force) {
|
||||
if (force || atBottom()) log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
function el(tag, cls, text) {
|
||||
const e = document.createElement(tag);
|
||||
if (cls) e.className = cls;
|
||||
if (text !== undefined) e.textContent = text;
|
||||
return e;
|
||||
}
|
||||
function bubble(who, cls) {
|
||||
const m = el('div', 'msg ' + (cls || ''));
|
||||
m.appendChild(el('div', 'who', who));
|
||||
const b = el('div', 'body');
|
||||
m.appendChild(b);
|
||||
log.appendChild(m);
|
||||
scrollDown(true);
|
||||
return { root: m, body: b };
|
||||
}
|
||||
|
||||
function renderMessage(m) {
|
||||
if (m.role === 'system') return;
|
||||
if (m.role === 'tool') return; /* drawn with its call */
|
||||
const cls = m.role === 'user' ? 'user' : 'assistant';
|
||||
const who = m.role === 'user' ? 'You' : 'Assistant';
|
||||
const b = bubble(who, cls);
|
||||
if (m.role === 'assistant' && m.html) b.body.innerHTML = m.html;
|
||||
else b.body.textContent = m.content || '';
|
||||
if (m.reasoning) {
|
||||
const d = el('details', 'think');
|
||||
d.appendChild(el('summary', null, 'Thought'));
|
||||
d.appendChild(el('div', null, m.reasoning));
|
||||
b.root.insertBefore(d, b.body);
|
||||
}
|
||||
if (m.attach) {
|
||||
for (const a of m.attach) {
|
||||
const img = el('img');
|
||||
img.alt = a.name || 'image';
|
||||
img.src = 'api/attach/' + a.rel + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '');
|
||||
log.insertBefore(img, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSession() {
|
||||
const data = await getJSON('api/session');
|
||||
log.textContent = '';
|
||||
firstPaint = true;
|
||||
for (const m of data.messages) renderMessage(m);
|
||||
firstPaint = false;
|
||||
scrollDown(true);
|
||||
}
|
||||
|
||||
/* --- live blocks --------------------------------------------------------- */
|
||||
|
||||
function ensureLive() {
|
||||
if (live) return live;
|
||||
const b = bubble('Assistant', 'assistant');
|
||||
live = { root: b.root, body: b.body, html: '', think: null };
|
||||
return live;
|
||||
}
|
||||
|
||||
function thinkBlock(text) {
|
||||
const l = ensureLive();
|
||||
if (!l.think) {
|
||||
const d = el('details', 'think');
|
||||
d.appendChild(el('summary', null, 'Thought'));
|
||||
const t = el('div');
|
||||
d.appendChild(t);
|
||||
l.root.insertBefore(d, l.body);
|
||||
l.think = t;
|
||||
}
|
||||
l.think.textContent += text;
|
||||
return l;
|
||||
}
|
||||
|
||||
function toolCard(ev) {
|
||||
let c = tools.get(ev.id);
|
||||
if (!c) {
|
||||
const d = el('details', 'tool');
|
||||
const s = el('summary', null, ev.name || 'tool');
|
||||
d.appendChild(s);
|
||||
const args = el('div', 'args', JSON.stringify(ev.args || {}, null, 0));
|
||||
d.appendChild(args);
|
||||
const out = el('pre');
|
||||
d.appendChild(out);
|
||||
log.appendChild(d);
|
||||
c = { root: d, sum: s, out, open: false };
|
||||
tools.set(ev.id, c);
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function onTool(ev) {
|
||||
const c = toolCard(ev);
|
||||
if (ev.phase === 'start' || ev.phase === 'begin') {
|
||||
c.sum.classList.add('spin');
|
||||
c.open = true;
|
||||
c.root.open = true;
|
||||
} else if (ev.phase === 'end' || ev.phase === 'done') {
|
||||
c.sum.classList.remove('spin');
|
||||
}
|
||||
if (ev.detail && !c.out.textContent) c.out.textContent = ev.detail;
|
||||
scrollDown(false);
|
||||
}
|
||||
|
||||
function onToolResult(ev) {
|
||||
const c = toolCard(ev);
|
||||
c.sum.classList.remove('spin');
|
||||
c.out.textContent = (ev.out || '') + (ev.truncated ? '\n… (截断)' : '');
|
||||
if (ev.exit) {
|
||||
c.sum.appendChild(el('span', ev.exit > 0 ? 'exit-bad' : '', ' exit=' + ev.exit));
|
||||
}
|
||||
scrollDown(false);
|
||||
}
|
||||
|
||||
/* --- state / status ------------------------------------------------------ */
|
||||
|
||||
function applyState(s) {
|
||||
state = s;
|
||||
$('ws').textContent = s.workspace || '';
|
||||
$('ws').title = s.workspace || '';
|
||||
$('model').textContent = (s.provider ? s.provider + ':' : '') + (s.model || '-');
|
||||
const pm = $('planmode');
|
||||
pm.textContent = s.plan_mode ? 'Plan' : 'Build';
|
||||
pm.classList.toggle('on', !!s.plan_mode);
|
||||
$('approval').textContent = s.approval || 'auto';
|
||||
$('ctx').textContent = s.context_tokens ? (s.context_tokens + ' tok') : '';
|
||||
const u = s.usage || {};
|
||||
$('usage').textContent = u.requests
|
||||
? `${u.requests} req · ${u.prompt_tokens}/${u.completion_tokens} tok` +
|
||||
(u.priced && u.cost ? ` · ${u.currency}${u.cost.toFixed(4)}` : '')
|
||||
: '';
|
||||
const busy = !!s.status_busy;
|
||||
$('btn-stop').hidden = !busy;
|
||||
$('btn-send').hidden = busy;
|
||||
$('status').textContent = busy ? '运行中…' : '就绪';
|
||||
const p = $('pending');
|
||||
p.hidden = !(s.pending_images && s.pending_images.length);
|
||||
p.textContent = (s.pending_images || []).join(' ');
|
||||
const t = $('todo');
|
||||
t.hidden = !s.todo;
|
||||
if (s.todo) {
|
||||
t.textContent = '';
|
||||
t.appendChild(el('h4', null, '任务'));
|
||||
t.appendChild(el('div', null, s.todo));
|
||||
}
|
||||
}
|
||||
|
||||
/* --- SSE ----------------------------------------------------------------- */
|
||||
|
||||
function connect() {
|
||||
const url = 'api/events' + (TOKEN ? '?token=' + encodeURIComponent(TOKEN) : '');
|
||||
const es = new EventSource(url);
|
||||
es.addEventListener('state', (e) => applyState(JSON.parse(e.data)));
|
||||
es.addEventListener('status', (e) => { $('status').textContent = JSON.parse(e.data).message; });
|
||||
es.addEventListener('assistant_html', (e) => {
|
||||
const l = ensureLive();
|
||||
l.html += JSON.parse(e.data).text;
|
||||
l.body.innerHTML = l.html;
|
||||
scrollDown(false);
|
||||
});
|
||||
es.addEventListener('assistant_delta', () => {});
|
||||
es.addEventListener('think_delta', (e) => {
|
||||
thinkBlock(JSON.parse(e.data).text);
|
||||
scrollDown(false);
|
||||
});
|
||||
es.addEventListener('tool', (e) => onTool(JSON.parse(e.data)));
|
||||
es.addEventListener('tool_result', (e) => onToolResult(JSON.parse(e.data)));
|
||||
es.addEventListener('todo', (e) => applyState(Object.assign({}, state, { todo: JSON.parse(e.data).text })));
|
||||
es.addEventListener('session', () => loadSession());
|
||||
es.addEventListener('permission', (e) => showPermission(JSON.parse(e.data)));
|
||||
es.addEventListener('permission_done', () => closeModal());
|
||||
es.addEventListener('ask_user', (e) => showAsk(JSON.parse(e.data)));
|
||||
es.addEventListener('turn_end', (e) => {
|
||||
const d = JSON.parse(e.data);
|
||||
live = null;
|
||||
if (d.rc) bubble('Error', 'assistant').body.textContent = d.error || ('rc=' + d.rc);
|
||||
else loadSession(); /* replace the live view with the stored messages */
|
||||
});
|
||||
es.onerror = () => { $('status').textContent = '连接中断,重连中…'; };
|
||||
}
|
||||
|
||||
/* --- modals -------------------------------------------------------------- */
|
||||
|
||||
function openModal(node) {
|
||||
const box = $('modal-box');
|
||||
box.textContent = '';
|
||||
box.appendChild(node);
|
||||
$('modal').hidden = false;
|
||||
}
|
||||
function closeModal() {
|
||||
$('modal').hidden = true;
|
||||
$('modal-box').textContent = '';
|
||||
permReq = askReq = null;
|
||||
}
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && !$('modal').hidden) closeModal();
|
||||
});
|
||||
|
||||
function showPermission(ev) {
|
||||
permReq = ev;
|
||||
const box = document.createDocumentFragment();
|
||||
box.appendChild(el('h3', null, (ev.sandbox ? '沙箱拦截:要在沙箱外重跑?' : '允许 ' + ev.tool + '?')));
|
||||
if (ev.preview) {
|
||||
const pre = el('pre');
|
||||
pre.textContent = ev.preview;
|
||||
box.appendChild(pre);
|
||||
} else if (ev.detail) {
|
||||
const pre = el('pre');
|
||||
pre.textContent = ev.detail;
|
||||
box.appendChild(pre);
|
||||
}
|
||||
const row = el('div', 'buttons');
|
||||
const answer = (n) => { post('api/permission', { id: ev.id, answer: n }).catch(() => {}); closeModal(); };
|
||||
row.appendChild(Object.assign(el('button', 'danger', '拒绝'), { onclick: () => answer(0) }));
|
||||
row.appendChild(Object.assign(el('button', 'ok', '允许一次'), { onclick: () => answer(1) }));
|
||||
row.appendChild(Object.assign(el('button', null, '总是允许(工具)'), { onclick: () => answer(2) }));
|
||||
if (ev.tool === 'run_shell')
|
||||
row.appendChild(Object.assign(el('button', null, '总是允许(该前缀)'), { onclick: () => answer(3) }));
|
||||
box.appendChild(row);
|
||||
openModal(box);
|
||||
}
|
||||
|
||||
function showAsk(ev) {
|
||||
askReq = ev;
|
||||
const box = document.createDocumentFragment();
|
||||
box.appendChild(el('h3', null, 'Agent 提问'));
|
||||
const pre = el('pre');
|
||||
pre.textContent = ev.question || '';
|
||||
box.appendChild(pre);
|
||||
const row = el('div', 'buttons');
|
||||
const send = (text, dismiss) => {
|
||||
post('api/ask_user', { id: ev.id, text, dismiss: !!dismiss }).catch(() => {});
|
||||
closeModal();
|
||||
};
|
||||
for (const opt of ev.options || []) {
|
||||
row.appendChild(Object.assign(el('button', null, opt), { onclick: () => send(opt, false) }));
|
||||
}
|
||||
const free = el('input');
|
||||
free.placeholder = '自定义回答';
|
||||
free.style.cssText = 'flex:1;min-width:220px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit';
|
||||
row.appendChild(free);
|
||||
row.appendChild(Object.assign(el('button', 'ok', '回答'), { onclick: () => send(free.value, false) }));
|
||||
row.appendChild(Object.assign(el('button', 'danger', '忽略'), { onclick: () => send('', true) }));
|
||||
box.appendChild(row);
|
||||
openModal(box);
|
||||
setTimeout(() => free.focus(), 0);
|
||||
}
|
||||
|
||||
async function showSessions() {
|
||||
const data = await getJSON('api/sessions');
|
||||
const box = document.createDocumentFragment();
|
||||
box.appendChild(el('h3', null, '会话'));
|
||||
for (const s of data.sessions) {
|
||||
const row = el('div', 'sess');
|
||||
row.appendChild(el('span', 'id', (s.id === data.current ? '* ' : ' ') + s.id.slice(0, 12)));
|
||||
row.appendChild(el('span', 't', s.title || '(空)'));
|
||||
row.appendChild(el('span', 'id', s.messages + ' msgs'));
|
||||
row.appendChild(Object.assign(el('button', null, '打开'), {
|
||||
onclick: async () => {
|
||||
await post('api/sessions/switch', { id: s.id });
|
||||
closeModal();
|
||||
await loadSession();
|
||||
},
|
||||
}));
|
||||
box.appendChild(row);
|
||||
}
|
||||
const row = el('div', 'buttons');
|
||||
row.appendChild(Object.assign(el('button', 'ok', '新建会话'), {
|
||||
onclick: async () => { await post('api/sessions/new', {}); closeModal(); await loadSession(); },
|
||||
}));
|
||||
row.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
|
||||
box.appendChild(row);
|
||||
openModal(box);
|
||||
}
|
||||
|
||||
function showModel() {
|
||||
const box = document.createDocumentFragment();
|
||||
box.appendChild(el('h3', null, '模型与模式'));
|
||||
const mk = (label, value, apply) => {
|
||||
const row = el('div', 'buttons');
|
||||
row.appendChild(el('span', 'muted', label));
|
||||
const input = el('input');
|
||||
input.value = value || '';
|
||||
input.style.cssText = 'flex:1;min-width:200px;background:var(--bg);color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:5px 8px;font:inherit';
|
||||
row.appendChild(input);
|
||||
row.appendChild(Object.assign(el('button', 'ok', '应用'), {
|
||||
onclick: async () => { await post('api/config', apply(input.value)); },
|
||||
}));
|
||||
box.appendChild(row);
|
||||
};
|
||||
mk('模型', state.model, (v) => ({ model: v }));
|
||||
mk('provider', state.provider, (v) => ({ provider: v }));
|
||||
const row = el('div', 'buttons');
|
||||
row.appendChild(el('span', 'muted', '推理强度'));
|
||||
for (const level of ['low', 'medium', 'high', 'max']) {
|
||||
row.appendChild(Object.assign(el('button', level === state.effort ? 'ok' : '', level), {
|
||||
onclick: () => post('api/config', { effort: level }),
|
||||
}));
|
||||
}
|
||||
box.appendChild(row);
|
||||
const row2 = el('div', 'buttons');
|
||||
row2.appendChild(el('span', 'muted', '审批'));
|
||||
for (const mode of ['ask', 'auto', 'yolo']) {
|
||||
row2.appendChild(Object.assign(el('button', mode === state.approval ? 'ok' : '', mode), {
|
||||
onclick: () => post('api/config', { approval: mode }),
|
||||
}));
|
||||
}
|
||||
row2.appendChild(Object.assign(el('button', state.plan_mode ? 'ok' : '', state.plan_mode ? 'Plan' : 'Build'), {
|
||||
onclick: () => post('api/config', { plan_mode: !state.plan_mode }),
|
||||
}));
|
||||
box.appendChild(row2);
|
||||
const row3 = el('div', 'buttons');
|
||||
row3.appendChild(Object.assign(el('button', null, '关闭'), { onclick: closeModal }));
|
||||
box.appendChild(row3);
|
||||
openModal(box);
|
||||
}
|
||||
|
||||
/* --- composer ------------------------------------------------------------ */
|
||||
|
||||
async function send() {
|
||||
const input = $('input');
|
||||
const text = input.value.trim();
|
||||
if (!text || state.status_busy) return;
|
||||
input.value = '';
|
||||
bubble('You', 'user').body.textContent = text;
|
||||
try {
|
||||
await post('api/turn', { text });
|
||||
} catch (e) {
|
||||
bubble('Error', 'assistant').body.textContent = String(e.message || e);
|
||||
}
|
||||
}
|
||||
|
||||
$('btn-send').onclick = send;
|
||||
$('btn-stop').onclick = () => post('api/cancel', {}).catch(() => {});
|
||||
$('btn-sessions').onclick = () => showSessions().catch((e) => alert(e.message));
|
||||
$('btn-model').onclick = showModel;
|
||||
$('btn-undo').onclick = () => post('api/undo', {}).catch((e) => alert(e.message));
|
||||
$('btn-compact').onclick = () => post('api/compact', {}).catch((e) => alert(e.message));
|
||||
$('btn-clear').onclick = () => { log.textContent = ''; };
|
||||
$('input').addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !e.isComposing) {
|
||||
e.preventDefault();
|
||||
send();
|
||||
}
|
||||
});
|
||||
$('input').addEventListener('input', (e) => {
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 240) + 'px';
|
||||
});
|
||||
|
||||
async function upload(file) {
|
||||
const r = await api('api/attach?name=' + encodeURIComponent(file.name), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': file.type || 'application/octet-stream' },
|
||||
body: file,
|
||||
});
|
||||
if (!r.ok) alert(await r.text());
|
||||
}
|
||||
$('file').addEventListener('change', async (e) => {
|
||||
for (const f of e.target.files) await upload(f);
|
||||
e.target.value = '';
|
||||
});
|
||||
document.addEventListener('paste', async (e) => {
|
||||
for (const item of e.clipboardData.items) {
|
||||
if (item.kind === 'file' && item.type.startsWith('image/')) {
|
||||
e.preventDefault();
|
||||
await upload(item.getAsFile());
|
||||
}
|
||||
}
|
||||
});
|
||||
document.addEventListener('dragover', (e) => e.preventDefault());
|
||||
document.addEventListener('drop', async (e) => {
|
||||
if (!e.dataTransfer.files.length) return;
|
||||
e.preventDefault();
|
||||
for (const f of e.dataTransfer.files) await upload(f);
|
||||
});
|
||||
|
||||
/* --- go ------------------------------------------------------------------ */
|
||||
|
||||
connect();
|
||||
loadSession().catch(() => {});
|
||||
getJSON('api/state').then(applyState).catch(() => {});
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>CST-Agent</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header id="bar">
|
||||
<span class="tag" id="planmode">Build</span>
|
||||
<span class="tag" id="approval">auto</span>
|
||||
<span class="ws" id="ws" title="workspace"></span>
|
||||
<span class="model" id="model" title="model">-</span>
|
||||
<span class="muted" id="ctx"></span>
|
||||
<span class="grow"></span>
|
||||
<button id="btn-sessions" title="sessions">会话</button>
|
||||
<button id="btn-model" title="model">模型</button>
|
||||
<button id="btn-undo" title="undo">撤销</button>
|
||||
<button id="btn-compact" title="compact">压缩</button>
|
||||
<button id="btn-clear" title="clear view">清屏</button>
|
||||
</header>
|
||||
|
||||
<main id="log"></main>
|
||||
<aside id="todo" hidden></aside>
|
||||
|
||||
<footer id="composer">
|
||||
<div id="pending" hidden></div>
|
||||
<div class="row">
|
||||
<textarea id="input" rows="1" placeholder="输入消息,Enter 发送,Shift+Enter 换行"></textarea>
|
||||
<button id="btn-send">发送</button>
|
||||
<button id="btn-stop" hidden>停止</button>
|
||||
<label class="att" title="附上图片"><input type="file" id="file" accept="image/*" hidden>+图</label>
|
||||
</div>
|
||||
<div class="hint"><span id="status">就绪</span><span class="grow"></span><span id="usage" class="muted"></span></div>
|
||||
</footer>
|
||||
|
||||
<div id="modal" hidden><div id="modal-box"></div></div>
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
:root {
|
||||
--bg: #14161a;
|
||||
--bg2: #1b1e24;
|
||||
--fg: #d8dde5;
|
||||
--muted: #8b93a1;
|
||||
--line: #2a2f38;
|
||||
--accent: #7fb2ff;
|
||||
--ok: #7ddc8f;
|
||||
--warn: #ffcc66;
|
||||
--err: #ff7b72;
|
||||
--tool: #b5e3c8;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg); color: var(--fg);
|
||||
font: 14px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, "Noto Sans Mono CJK SC", monospace;
|
||||
display: flex; flex-direction: column; height: 100%;
|
||||
}
|
||||
button {
|
||||
background: var(--bg2); color: var(--fg); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 3px 9px; font: inherit; cursor: pointer;
|
||||
}
|
||||
button:hover { border-color: var(--accent); }
|
||||
.grow { flex: 1; }
|
||||
.muted { color: var(--muted); }
|
||||
|
||||
#bar {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--line); background: var(--bg2);
|
||||
position: sticky; top: 0; z-index: 3; flex-wrap: wrap;
|
||||
}
|
||||
.tag { border: 1px solid var(--line); border-radius: 999px; padding: 0 8px; color: var(--muted); }
|
||||
.tag.on { color: var(--bg); background: var(--accent); border-color: var(--accent); }
|
||||
.ws { color: var(--muted); overflow: hidden; text-overflow: ellipsis; max-width: 40vw; white-space: nowrap; }
|
||||
.model { color: var(--accent); }
|
||||
|
||||
#log { flex: 1; overflow: auto; padding: 12px 14px 20px; }
|
||||
#log .msg { margin: 0 0 14px; }
|
||||
#log .who { color: var(--muted); font-size: 12px; margin-bottom: 2px; }
|
||||
#log .body { white-space: pre-wrap; word-break: break-word; }
|
||||
#log .assistant .body > :first-child { margin-top: 0; }
|
||||
#log .body pre {
|
||||
background: var(--bg2); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 8px 10px; overflow: auto;
|
||||
}
|
||||
#log .body code { background: var(--bg2); border-radius: 4px; padding: 0 3px; }
|
||||
#log .body pre code { background: none; padding: 0; }
|
||||
#log .body table { border-collapse: collapse; }
|
||||
#log .body th, #log .body td { border: 1px solid var(--line); padding: 2px 6px; }
|
||||
#log .body blockquote { border-left: 3px solid var(--line); margin: 0; padding-left: 10px; color: var(--muted); }
|
||||
#log .body a { color: var(--accent); }
|
||||
#log .body img { max-width: min(420px, 100%); border-radius: 6px; border: 1px solid var(--line); }
|
||||
|
||||
details.think { color: var(--muted); margin: 4px 0; }
|
||||
details.think summary { cursor: pointer; }
|
||||
details.tool {
|
||||
border: 1px solid var(--line); border-radius: 6px; margin: 6px 0; background: var(--bg2);
|
||||
}
|
||||
details.tool > summary { cursor: pointer; padding: 4px 8px; color: var(--tool); }
|
||||
details.tool .args, details.tool pre { margin: 0; padding: 6px 10px; white-space: pre-wrap; }
|
||||
details.tool pre { border-top: 1px solid var(--line); max-height: 40vh; overflow: auto; }
|
||||
.tool .exit-bad { color: var(--err); }
|
||||
.spin::after { content: " …"; color: var(--warn); }
|
||||
|
||||
#todo {
|
||||
border-top: 1px solid var(--line); background: var(--bg2); padding: 6px 12px;
|
||||
max-height: 22vh; overflow: auto; white-space: pre-wrap; font-size: 13px;
|
||||
}
|
||||
#todo h4 { margin: 0 0 4px; color: var(--muted); font-size: 12px; font-weight: normal; }
|
||||
|
||||
#composer { border-top: 1px solid var(--line); background: var(--bg2); padding: 8px 10px; }
|
||||
#composer .row { display: flex; gap: 8px; align-items: flex-end; }
|
||||
#input {
|
||||
flex: 1; resize: vertical; min-height: 34px; max-height: 40vh;
|
||||
background: var(--bg); color: var(--fg); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 7px 9px; font: inherit;
|
||||
}
|
||||
.att {
|
||||
border: 1px solid var(--line); border-radius: 6px; padding: 5px 9px; cursor: pointer;
|
||||
}
|
||||
.hint { display: flex; gap: 10px; font-size: 12px; color: var(--muted); padding-top: 4px; }
|
||||
#pending { color: var(--warn); font-size: 12px; padding-bottom: 4px; }
|
||||
|
||||
#modal {
|
||||
position: fixed; inset: 0; background: rgba(0, 0, 0, .55);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 9;
|
||||
}
|
||||
#modal-box {
|
||||
background: var(--bg2); border: 1px solid var(--line); border-radius: 10px;
|
||||
padding: 14px; width: min(820px, 94vw); max-height: 88vh; overflow: auto;
|
||||
}
|
||||
#modal-box h3 { margin: 0 0 8px; }
|
||||
#modal-box pre {
|
||||
background: var(--bg); border: 1px solid var(--line); border-radius: 6px;
|
||||
padding: 8px; max-height: 46vh; overflow: auto; white-space: pre-wrap;
|
||||
}
|
||||
#modal-box .buttons { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
|
||||
#modal-box .danger { border-color: var(--err); color: var(--err); }
|
||||
#modal-box .ok { border-color: var(--ok); color: var(--ok); }
|
||||
.sess { display: flex; gap: 8px; align-items: center; padding: 4px 0; border-bottom: 1px solid var(--line); }
|
||||
.sess .id { color: var(--muted); font-size: 12px; }
|
||||
.sess .t { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
Reference in New Issue
Block a user