diff --git a/docs/webui.md b/docs/webui.md index 9b708e9..68baa7b 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -47,7 +47,9 @@ webui: token 3f9a… (append #token=3f9a… to the URL) | 场景 | 行为 | |---|---| -| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding) | +| 默认 `127.0.0.1` | 不需要 token;`Host` 必须是 loopback(防 DNS rebinding);带 `Origin` 的请求必须来自本页面自己(`http://`),否则 401 —— 防止任意网页跨站 POST 驱动 agent | +| 静态资源 | 页面本身与 `app.js`/`style.css` 不需要 token;`/api/attach/` 图片仍要(页面会把 token 写进 `SameSite=Strict` cookie) | +| 请求解析 | 鉴权与大小检查在读请求体**之前**完成;`Content-Length` 必须是纯数字且只有一个值;请求头整体 15 s 内收完 | | `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token | | token 传递 | `Authorization: Bearer `、`Cookie: cst_token=` 或 `?token=`(`EventSource` 不能设 header,所以查询参数是必需的一条路) | | 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) | @@ -70,7 +72,10 @@ GET /api/events?since= → text/event-stream (chunked) * 每条事件都有 `id: `(单调递增)与 `data: {"type":…,"seq":…, …}`。 * 断线重连带上 `since=<最后收到的 seq>`(浏览器用 `Last-Event-ID` 也行), - 服务端保留最近 2000 条,超出后从可用的最老一条开始。 + 服务端保留最近 2000 条,超出后从可用的最老一条开始;比当前 seq 还大(服务端重启过) + 就当作新连接。 +* 新连接(不带 `since`)只收之后的事件;但若有尚未回答的 `permission`/`ask_user`, + 会从那条事件开始补发,刷新页面不会丢掉正在等的问题。 * 心跳是 SSE 注释 `: ping`(每秒一次,客户端忽略)。 * `state` 事件在状态变化时整份重发,客户端可以直接替换本地状态。 @@ -109,18 +114,21 @@ completion_tokens,cached_tokens,cache_miss_tokens,cost,priced,currency}`。 | POST | `/api/sessions/switch` | `{"id":"…"}` | | POST | `/api/workspace` | `{"path":"/abs/dir"}` —— 换工作目录(权限规则/埋点/AGENTS.md 跟着换),并**在新目录里开一个新会话**(会话是按目录存的);返回 `{id}`,坏路径 → 400 | | POST | `/api/turn` | `{"text":"…"}` → 202 `{ok:true}`;已有轮次在跑时 **409** | -| POST | `/api/cancel` | `{}` —— 取消当前轮(不会杀进程) | +| 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),进入"待发送"列表 | +| POST | `/api/attach?name=x.png` | 请求体是图片原始字节(≤32 MiB),进入"待发送"列表 | | DELETE | `/api/attach` | 清空待发送图片 | | GET | `/api/attach/` | 读回已存进会话的图片(`rel` 由消息的 `attach[].rel` 给出) | | POST | `/api/quit` | 关闭进程 | 未命中路由 → `404 {"error":"not found"}`;需要 token 而没给 → `401`。 +轮次运行中(或另一个修改正在进行)时,`sessions/new`、`sessions/switch`、`workspace`、 +`undo`、`compact`、`attach`(上传/清空)以及改 `model/provider/effort` 的 `config` +一律 **409**;`approval`/`plan_mode` 可以中途改。 ### 客户端接入示例(10 行) diff --git a/include/cst_http.h b/include/cst_http.h index 730a7d9..e77a1b3 100644 --- a/include/cst_http.h +++ b/include/cst_http.h @@ -32,6 +32,7 @@ typedef struct { const char *host; const char *content_type; const char *user_agent; + const char *last_event_id; /* an EventSource reconnecting */ int keep_alive; } cst_http_req_t; @@ -72,8 +73,10 @@ cst_http_server_t *cst_http_start(const cst_http_opts_t *o, char *err, 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); +/* Close the listener and every connection, then wait (30 s at most) for the + * handler threads. Returns how many still run: their ud must stay alive. */ +int cst_http_stop(cst_http_server_t *s); +/* Does nothing while handlers still run on s. */ void cst_http_free(cst_http_server_t *s); /* --- inside a handler --- */ diff --git a/include/cst_version.h b/include/cst_version.h index 239ea94..d977551 100644 --- a/include/cst_version.h +++ b/include/cst_version.h @@ -3,7 +3,7 @@ #define CST_VERSION_MAJOR 0 #define CST_VERSION_MINOR 8 -#define CST_VERSION_PATCH 0 +#define CST_VERSION_PATCH 2 #define CST_VERSION_STR_(x) #x #define CST_VERSION_STR(x) CST_VERSION_STR_(x) diff --git a/scripts/e2e_webui.sh b/scripts/e2e_webui.sh index 9de291d..7e2bb5f 100644 --- a/scripts/e2e_webui.sh +++ b/scripts/e2e_webui.sh @@ -47,6 +47,19 @@ BASE="http://127.0.0.1:$PORT" echo " port $PORT" fail() { echo "FAIL: $1"; echo "--- events"; cat "$EVENTS" 2>/dev/null | head -40; exit 1; } +# SIGTERM must shut the server down cleanly, even mid-turn (128+ = a crash) +stop_server() { + kill "$1" 2>/dev/null || return 0 + n=0 + while kill -0 "$1" 2>/dev/null && [ $n -lt 200 ]; do n=$((n + 1)); sleep 0.2; done + if kill -0 "$1" 2>/dev/null; then + kill -9 "$1" 2>/dev/null || true + fail "$2 hung on SIGTERM" + fi + st=0 + wait "$1" 2>/dev/null || st=$? + [ "$st" -lt 128 ] || fail "$2 died with status $st on SIGTERM" +} # 1. the shell page and the state endpoint curl -fsS --max-time 5 "$BASE/" | grep -q "CST-Agent" || fail "GET / is not the shell page" @@ -160,7 +173,7 @@ 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 +stop_server "$SRV_PID" "the first server" # its replay is spent rm -rf "$WORK/pw"; mkdir -p "$WORK/pw" P_EVENTS="$WORK/permission-events.txt" P_LOG="$WORK/permission-server.log" # its own log: the port parse must not @@ -206,7 +219,7 @@ else 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 + stop_server "$P_PID" "the ask-mode server" kill "$P_CURL" 2>/dev/null || true echo " answered request $PID_REQ with 'allow once'" fi @@ -260,4 +273,15 @@ done kill -0 "$T_PID" 2> /dev/null && fail "the server ignored /api/quit" wait "$T_PID" 2>/dev/null || true +# 9. ask_user waits for the answer, and the HTTP hardening checks (both +# need a scripted model: python3) +if command -v python3 > /dev/null; then + echo "== ask_user round trip" + CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_ask.py" > "$WORK/ask.txt" 2>&1 || + { cat "$WORK/ask.txt"; fail "the ask_user round trip"; } + echo "== HTTP hardening" + CST_AGENT="$BIN" python3 "$ROOT/scripts/e2e_webui_http.py" > "$WORK/http.txt" 2>&1 || + { cat "$WORK/http.txt"; fail "the HTTP hardening checks"; } +fi + echo "== OK: (events: $(grep -c '^event: ' "$EVENTS"))" diff --git a/scripts/e2e_webui_ask.py b/scripts/e2e_webui_ask.py new file mode 100644 index 0000000..5a07cb8 --- /dev/null +++ b/scripts/e2e_webui_ask.py @@ -0,0 +1,164 @@ +"""WebUI ask_user round trip against a fake OpenAI-compatible API. + + python3 scripts/e2e_webui_ask.py (CST_AGENT= picks the binary) + +Checks: the turn really waits for the answer, a page opened while the +question is open still receives it, and Last-Event-ID resumes the stream. +""" +import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent")) +REQS = [] + + +class H(BaseHTTPRequestHandler): + def log_message(self, *a): + pass + + def do_GET(self): + body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + REQS.append(json.loads(self.rfile.read(n))) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"} + + def ev(o): + self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n") + self.wfile.flush() + if len(REQS) == 1: + args = json.dumps({"question": "Pick a colour?", "options": "red\nblue"}) + ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", + "function": {"name": "ask_user", "arguments": args}}]}}]}) + ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]}) + else: + ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "done"}}]}) + ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +fails = 0 + + +def check(ok, msg): + global fails + print(("PASS " if ok else "FAIL ") + msg) + fails += 0 if ok else 1 + + +class Sse: + def __init__(self, base, last_id=None): + self.lines = [] + req = urllib.request.Request(base + "/api/events") + if last_id is not None: + req.add_header("Last-Event-ID", str(last_id)) + self.r = urllib.request.urlopen(req, timeout=60) + threading.Thread(target=self.pump, daemon=True).start() + + def pump(self): + try: + for raw in self.r: + self.lines.append(raw.decode()) + except Exception: + pass + + def events(self, name): + out = [] + for i, l in enumerate(self.lines): + if l.strip() == "event: " + name and i + 1 < len(self.lines): + out.append(json.loads(self.lines[i + 1][6:])) + return out + + def ids(self): + return [int(l[4:]) for l in self.lines if l.startswith("id: ")] + + def close(self): + try: + self.r.close() + except Exception: + pass + + +def wait(fn, t=15): + end = time.time() + t + while time.time() < end: + v = fn() + if v: + return v + time.sleep(0.1) + return None + + +def post(base, path, obj): + req = urllib.request.Request(base + path, data=json.dumps(obj).encode(), + headers={"Content-Type": "application/json"}) + try: + with urllib.request.urlopen(req, timeout=5) as r: + return r.status + except urllib.error.HTTPError as e: + return e.code + + +api = ThreadingHTTPServer(("127.0.0.1", 0), H) +threading.Thread(target=api.serve_forever, daemon=True).start() +ws = tempfile.mkdtemp(prefix="cst-ask-") +log = open(ws + "/server.log", "w+") +p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", + "--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash", + "--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1], + "--protocol", "native-eos", "--approval", "auto"], + stdout=log, stderr=subprocess.STDOUT) +try: + def port(): + log.seek(0) + for l in log.read().splitlines(): + if l.startswith("webui: http://127.0.0.1:"): + return l.split(":")[3].split("/")[0] + pt = wait(port) + base = "http://127.0.0.1:%s" % pt + a = Sse(base) + time.sleep(0.3) + check(post(base, "/api/turn", {"text": "ask me"}) == 202, "turn accepted") + ask = wait(lambda: a.events("ask_user")) + check(bool(ask), "ask_user event raised") + time.sleep(1.5) + check(len(REQS) == 1, "the turn waits for the answer (requests so far: %d)" % len(REQS)) + # a page opened now must still see the open question + b = Sse(base) + check(bool(wait(lambda: b.events("ask_user"), 5)), "a late page gets the open question") + # reconnect with Last-Event-ID: nothing before that id is resent + last = a.ids()[-1] + c = Sse(base, last_id=last - 1) + got = wait(lambda: c.ids(), 5) + check(bool(got) and got[0] == last, "Last-Event-ID resumes after that id (%s, want %d)" % (got[:1] if got else got, last)) + if ask: + check(post(base, "/api/ask_user", {"id": ask[0]["id"], "text": "blue"}) == 200, "answer accepted") + end = wait(lambda: a.events("turn_end"), 20) + check(bool(end) and end[0].get("rc") == 0, "turn ended rc=0") + tool_msgs = [m for m in (REQS[1]["messages"] if len(REQS) > 1 else []) if m.get("role") == "tool"] + check(bool(tool_msgs) and "blue" in json.dumps(tool_msgs), "the answer reached the model: %s" % json.dumps(tool_msgs)[:200]) + for s in (a, b, c): + s.close() +finally: + p.terminate() + try: + p.wait(20) + except subprocess.TimeoutExpired: + p.kill() + check(False, "server hung on SIGTERM") + check(p.returncode is not None and p.returncode < 128 and p.returncode >= 0, "clean exit (%s)" % p.returncode) + log.close() + shutil.rmtree(ws, ignore_errors=True) +print("FAILED:", fails) +sys.exit(1 if fails else 0) diff --git a/scripts/e2e_webui_http.py b/scripts/e2e_webui_http.py new file mode 100644 index 0000000..2667cf0 --- /dev/null +++ b/scripts/e2e_webui_http.py @@ -0,0 +1,234 @@ +"""WebUI HTTP hardening checks over raw sockets, against a scripted model. + + python3 scripts/e2e_webui_http.py (CST_AGENT= picks the binary) + +Cross-site requests, request parsing (Content-Length, NUL, long lines, +keep-alive), auth before the body, changes refused while a turn runs, +cancel waking a pending question, token mode and shutdown. +""" +import json, os, re, shutil, socket, subprocess, sys, tempfile, threading, time +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent")) +REQS = [] + + +class Api(BaseHTTPRequestHandler): + """First call: ask_user. Later calls: a plain answer.""" + + def log_message(self, *a): + pass + + def do_GET(self): + body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): + n = int(self.headers.get("Content-Length", 0)) + REQS.append(json.loads(self.rfile.read(n))) + self.send_response(200) + self.send_header("Content-Type", "text/event-stream") + self.end_headers() + base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"} + + def ev(o): + self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n") + self.wfile.flush() + if len(REQS) == 1: + args = json.dumps({"question": "Pick?", "options": "a\nb"}) + ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [ + {"index": 0, "id": "call_1", "type": "function", + "function": {"name": "ask_user", "arguments": args}}]}}]}) + ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]}) + else: + ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}}]}) + ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]}) + self.wfile.write(b"data: [DONE]\n\n") + self.wfile.flush() + + +fails = 0 + + +def check(ok, msg): + global fails + print(("PASS " if ok else "FAIL ") + msg) + fails += 0 if ok else 1 + + +def raw(port, data, timeout=5, read_all=True): + """Send bytes, return everything read until EOF/timeout.""" + s = socket.create_connection(("127.0.0.1", port), timeout=timeout) + s.sendall(data) + out = b"" + try: + while True: + b = s.recv(65536) + if not b: + break + out += b + if not read_all: + break + except socket.timeout: + pass + s.close() + return out.decode("latin1") + + +def status(resp): + # a pipelined response starts right after the previous body, not on a new line + return [int(m) for m in re.findall(r"HTTP/1\.1 (\d{3}) ", resp)] + + +def req(method, path, headers=None, body=b"", host=None): + h = {"Host": host or "127.0.0.1:%d" % PORT, "Connection": "close"} + h.update(headers or {}) + if body or method == "POST": + h["Content-Length"] = str(len(body)) + head = "%s %s HTTP/1.1\r\n" % (method, path) + "".join("%s: %s\r\n" % kv for kv in h.items()) + "\r\n" + return head.encode() + body + + +def start(ws, *extra): + log = open(os.path.join(ws, "server.log"), "w+") + p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new", + "--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash", + "--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1], + "--protocol", "native-eos", "--approval", "auto", *extra], + stdout=log, stderr=subprocess.STDOUT) + end = time.time() + 10 + while time.time() < end: + log.seek(0) + for l in log.read().splitlines(): + if l.startswith("webui: http://127.0.0.1:"): + return p, int(l.split(":")[3].split("/")[0]), log + time.sleep(0.1) + raise SystemExit("server did not start") + + +def stop(p, name): + p.terminate() + try: + p.wait(20) + except subprocess.TimeoutExpired: + p.kill() + check(False, name + " hung on SIGTERM") + return + check(0 <= p.returncode < 128, "%s exits cleanly (%s)" % (name, p.returncode)) + + +api = ThreadingHTTPServer(("127.0.0.1", 0), Api) +threading.Thread(target=api.serve_forever, daemon=True).start() +root = tempfile.mkdtemp(prefix="cst-http-") +try: + ws = os.path.join(root, "a") + os.makedirs(ws) + p, PORT, log = start(ws) + me = "http://127.0.0.1:%d" % PORT + cfg = b'{"approval":"ask"}' + # 1. cross-site requests + r = raw(PORT, req("POST", "/api/config", {"Origin": "http://evil.example", "Content-Type": "text/plain"}, cfg)) + check(status(r)[:1] == [401], "a cross-site POST is refused (%s)" % status(r)) + r = raw(PORT, req("POST", "/api/config", {"Origin": "null"}, cfg)) + check(status(r)[:1] == [401], "Origin: null is refused") + r = raw(PORT, req("POST", "/api/config", {"Origin": me, "Content-Type": "application/json"}, b'{"approval":"auto"}')) + check(status(r)[:1] == [200], "our own page may POST (%s)" % status(r)) + r = raw(PORT, req("POST", "/api/config", {}, b'{"approval":"auto"}')) + check(status(r)[:1] == [200], "a client without Origin (curl) may POST") + r = raw(PORT, req("GET", "/api/attach/img-0000.png", host="evil.example")) + check(status(r)[:1] == [401], "session images need the Host check too (%s)" % status(r)) + r = raw(PORT, req("GET", "/app.js")) + check(status(r)[:1] == [200], "assets are served") + # 2. request parsing + r = raw(PORT, req("GET", "/api/state%00x")) + check(status(r)[:1] == [400], "an encoded NUL in the path is a 400 (%s)" % status(r)) + smuggle = b"GET /api/sessions HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n" + r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: abc\r\n\r\n" % PORT).encode() + smuggle) + check(status(r) == [400], "Content-Length: abc is a 400, nothing smuggled (%s)" % status(r)) + r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 0\r\nContent-Length: %d\r\n\r\n" + % (PORT, len(smuggle))).encode() + smuggle) + check(status(r) == [400], "two different Content-Lengths are a 400 (%s)" % status(r)) + r = raw(PORT, ("GET /%s HTTP/1.1\r\nHost: x\r\n\r\n" % ("a" * 9000)).encode()) + check(status(r)[:1] == [400], "an over-long request line is a 400 (%s)" % status(r)) + r = raw(PORT, req("GET", "/api/state?x=http://a/b")) + check(status(r)[:1] == [200], "'://' in the query leaves the path alone (%s)" % status(r)) + r = raw(PORT, req("GET", "/api/state", {"Connection": "Close"}), timeout=3) + check("Connection: close" in r, "Connection: Close (any case) closes") + ka = ("GET /api/state HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode() + big = ("POST /api/turn HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 5000000\r\n\r\n" % PORT).encode() + r = raw(PORT, ka + big) + check(status(r) == [200, 413], "a 413 after a keep-alive request still arrives (%s)" % status(r)) + # 3. a pending question: changes are refused, approval may change, cancel wakes it + events = [] + + def sse(): + s = socket.create_connection(("127.0.0.1", PORT)) + s.sendall(("GET /api/events HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode()) + buf = b"" + s.settimeout(60) + try: + while True: + b = s.recv(65536) + if not b: + break + buf += b + events[:] = [l[7:] for l in buf.decode("latin1").split("\n") if l.startswith("event: ")] + except Exception: + pass + threading.Thread(target=sse, daemon=True).start() + time.sleep(0.4) + r = raw(PORT, req("POST", "/api/turn", {"Content-Type": "application/json"}, b'{"text":"go"}')) + check(status(r)[:1] == [202], "turn accepted") + end = time.time() + 15 + while time.time() < end and "ask_user" not in events: + time.sleep(0.1) + check("ask_user" in events, "the question is open") + for path, body in (("/api/sessions/new", b""), ("/api/workspace", json.dumps({"path": root}).encode()), + ("/api/compact", b""), ("/api/undo", b""), ("/api/config", b'{"model":"x"}')): + r = raw(PORT, req("POST", path, {"Content-Type": "application/json"}, body)) + check(status(r)[:1] == [409], "%s is refused mid-turn (%s)" % (path, status(r))) + r = raw(PORT, req("POST", "/api/config", {"Content-Type": "application/json"}, b'{"approval":"yolo"}')) + check(status(r)[:1] == [200], "approval may change mid-turn") + r = raw(PORT, req("POST", "/api/cancel", {"Content-Type": "application/json"}, b"{}")) + end = time.time() + 10 + while time.time() < end and "turn_end" not in events: + time.sleep(0.1) + check("turn_end" in events, "cancel ends a turn parked on a question") + r = raw(PORT, req("POST", "/api/sessions/new", {"Content-Type": "application/json"})) + check(status(r)[:1] == [200], "after the turn, a new session is fine (%s)" % status(r)) + stop(p, "the server") + + # 4. token mode: the page loads, the API and images do not without it + ws2 = os.path.join(root, "b") + os.makedirs(ws2) + p, PORT, log = start(ws2, "--web-token", "tok-x") + r = raw(PORT, req("GET", "/app.js")) + check(status(r)[:1] == [200], "token mode: app.js loads without the token (%s)" % status(r)) + r = raw(PORT, req("GET", "/api/state")) + check(status(r)[:1] == [401], "token mode: the API needs it") + r = raw(PORT, req("GET", "/api/attach/img-0000.png")) + check(status(r)[:1] == [401], "token mode: images need it") + r = raw(PORT, req("GET", "/api/state", {"Cookie": "cst_token=tok-x"})) + check(status(r)[:1] == [200], "token mode: the cookie works") + t0 = time.time() + r = raw(PORT, ("POST /api/attach HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 33554431\r\n" + "Expect: 100-continue\r\n\r\n" % PORT).encode(), timeout=5) + check(status(r) == [401] and time.time() - t0 < 3, + "no token, no body read: 401 at once (%s, %.1fs)" % (status(r), time.time() - t0)) + stop(p, "the token server") + + # 5. option checks + rc = subprocess.run([BIN, "--web", "--web-port", "70000", "--workspace", ws2], + capture_output=True, timeout=10).returncode + check(rc != 0, "--web-port 70000 is refused") + rc = subprocess.run([BIN, "--web", "--web-token", "x" * 80, "--web-port", "0", "--workspace", ws2, + "--api-key", "k"], capture_output=True, timeout=10).returncode + check(rc != 0, "an over-long --web-token is refused") +finally: + shutil.rmtree(root, ignore_errors=True) +print("FAILED:", fails) +sys.exit(1 if fails else 0) diff --git a/src/http_srv.c b/src/http_srv.c index 0caae93..a9dbacb 100644 --- a/src/http_srv.c +++ b/src/http_srv.c @@ -4,8 +4,11 @@ #include "cst_http.h" #include "cst_util.h" +#include "os_compat.h" +#include <errno.h> #include <pthread.h> +#include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h> @@ -44,6 +47,9 @@ struct cst_http_conn { int sent; int streaming; int failed; + int nreq; /* requests served on this connection */ + int expect_continue; /* the client waits for "100 Continue" */ + int keep_alive_req; /* what the client asked for */ cst_http_conn_t *prev, *next; }; @@ -72,10 +78,15 @@ static const char *status_text(int code) { /* --- reading -------------------------------------------------------------- */ -static int fill(cst_http_conn_t *c, int timeout_ms) { +/* deadline: os_now_ms() value; the whole read must be done by then, so a + * client trickling one byte at a time cannot hold the thread forever. */ +static int fill(cst_http_conn_t *c, double deadline) { if (c->rpos < c->rlen) return 1; - long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), timeout_ms); + double left = deadline - os_now_ms(); + if (left <= 0) + return -2; + long r = os_net_recv(c->sock, c->rbuf, sizeof(c->rbuf), (int)left + 1); if (r <= 0) return (int)r; /* 0 eof, -1 error, -2 timeout */ c->rpos = 0; @@ -83,10 +94,12 @@ static int fill(cst_http_conn_t *c, int timeout_ms) { return 1; } -static int rd_line(cst_http_conn_t *c, char *out, size_t cap, int timeout_ms) { +#define RD_TOO_LONG (-3) + +static int rd_line(cst_http_conn_t *c, char *out, size_t cap, double deadline) { size_t n = 0; for (;;) { - int rc = fill(c, timeout_ms); + int rc = fill(c, deadline); if (rc <= 0) return rc; while (c->rpos < c->rlen) { @@ -98,7 +111,7 @@ static int rd_line(cst_http_conn_t *c, char *out, size_t cap, int timeout_ms) { return 1; } if (n + 2 >= cap) - return -1; + return RD_TOO_LONG; out[n++] = ch; } } @@ -132,7 +145,8 @@ static char *rd_body(cst_http_conn_t *c, size_t n, int timeout_ms) { return buf; } -static void url_decode(const char *in, char *out, size_t cap, int plus_space) { +/* -1 when the input encodes a NUL (out then holds what came before it). */ +static int url_decode(const char *in, char *out, size_t cap, int plus_space) { size_t o = 0; for (const char *p = in; *p && o + 1 < cap; p++) { if (*p == '%' && p[1] && p[2]) { @@ -146,8 +160,10 @@ static void url_decode(const char *in, char *out, size_t cap, int plus_space) { : -1; if (hi >= 0 && lo >= 0) { char ch = (char)((hi << 4) | lo); - if (ch == 0) - return; /* a decoded NUL would truncate the path */ + if (ch == 0) { + out[o] = 0; + return -1; + } out[o++] = ch; p += 2; continue; @@ -156,6 +172,23 @@ static void url_decode(const char *in, char *out, size_t cap, int plus_space) { out[o++] = (*p == '+' && plus_space) ? ' ' : *p; } out[o] = 0; + return 0; +} + +/* Comma-separated header tokens, compared without case ("Close"). */ +static int has_token(const char *val, const char *tok) { + size_t n = strlen(tok); + for (const char *p = val; p && *p;) { + while (*p == ' ' || *p == '\t' || *p == ',') + p++; + size_t i = 0; + while (i < n && p[i] && (p[i] | 0x20) == (tok[i] | 0x20)) + i++; + if (i == n && (p[i] == 0 || p[i] == ',' || p[i] == ' ' || p[i] == '\t')) + return 1; + p = strchr(p, ','); + } + return 0; } static int key_is(const char *line, const char *key) { @@ -189,12 +222,16 @@ 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 */ + int first = c->nreq == 0; /* a keep-alive pause gets the idle timeout */ memset(r, 0, sizeof(*r)); c->hdrs_used = 0; + c->expect_continue = 0; *want_body = 0; int rc = rd_line(c, line, sizeof(line), - first ? s->o.read_timeout_ms : s->o.idle_timeout_ms); + os_now_ms() + (first ? s->o.read_timeout_ms + : s->o.idle_timeout_ms)); + if (rc == RD_TOO_LONG) + return -1; if (rc <= 0) return 0; char *sp1 = strchr(line, ' '); @@ -214,9 +251,10 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) { 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[0] != '/') { + char *scheme = strstr(target, "://"); + target = scheme ? strchr(scheme + 3, '/') : NULL; + } if (!target || target[0] != '/') return -1; { @@ -227,7 +265,8 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) { *q = 0; snprintf(r->query, sizeof(r->query), "%s", q + 1); } - url_decode(raw, r->path, sizeof(r->path), 0); + if (url_decode(raw, r->path, sizeof(r->path), 0) != 0) + return -1; size_t plen = strlen(r->path); if (plen == 0 || strstr(r->path, "..")) return -1; @@ -235,8 +274,9 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) { size_t hdr_bytes = 0; size_t content_length = 0; int have_length = 0; + double deadline = os_now_ms() + s->o.read_timeout_ms; for (;;) { - rc = rd_line(c, line, sizeof(line), s->o.read_timeout_ms); + rc = rd_line(c, line, sizeof(line), deadline); if (rc <= 0) return -1; if (!line[0]) @@ -251,18 +291,29 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) { while (*val == ' ' || *val == '\t') val++; if (key_is(line, "content-length")) { - content_length = (size_t)strtoul(val, NULL, 10); + /* digits only, and one value: "5x" or two lengths would let the + * rest of the body run as a second request behind a proxy */ + char *end = NULL; + errno = 0; + unsigned long long v = strtoull(val, &end, 10); + while (end && (*end == ' ' || *end == '\t')) + end++; + if (*val < '0' || *val > '9' || !end || *end || errno == ERANGE || + v > (unsigned long long)SIZE_MAX || + (have_length && content_length != (size_t)v)) + return -1; + content_length = (size_t)v; have_length = 1; } else if (key_is(line, "transfer-encoding")) { return -1; /* chunked request bodies are not supported */ } else if (key_is(line, "connection")) { - if (strstr(val, "close")) + if (has_token(val, "close")) r->keep_alive = 0; - else if (strstr(val, "keep-alive")) + else if (has_token(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); + /* answered once auth and the size cap have passed */ + c->expect_continue = has_token(val, "100-continue"); } else if (key_is(line, "authorization")) { r->authorization = store_str(c, val); } else if (key_is(line, "cookie")) { @@ -275,6 +326,8 @@ static int read_request(cst_http_conn_t *c, size_t *want_body) { r->content_type = store_str(c, val); } else if (key_is(line, "user-agent")) { r->user_agent = store_str(c, val); + } else if (key_is(line, "last-event-id")) { + r->last_event_id = store_str(c, val); } } if (have_length) @@ -429,24 +482,30 @@ int cst_http_query(const cst_http_req_t *req, const char *key, char *out, /* --- connections ---------------------------------------------------------- */ -static void dispatch(cst_http_server_t *s, cst_http_conn_t *c) { - const cst_http_route_t *route = NULL; +static const cst_http_route_t *find_route(const cst_http_server_t *s, + const cst_http_req_t *req) { const cst_http_route_t *fallback = NULL; for (int i = 0; i < s->nroutes; i++) { const cst_http_route_t *r = &s->routes[i]; - if (strcmp(r->method, "*") != 0 && strcmp(r->method, c->req.method) != 0) + if (strcmp(r->method, "*") != 0 && strcmp(r->method, req->method) != 0) continue; if (strcmp(r->path, "*") == 0) { fallback = r; continue; } - if (strcmp(r->path, c->req.path) == 0) { - route = r; - break; - } + if (strcmp(r->path, req->path) == 0) + return r; } - if (!route) - route = fallback; + return fallback; +} + +/* Route, auth and the size cap are settled before the body is read: an + * unauthenticated client must not make us buffer 32 MiB. */ +static void serve(cst_http_server_t *s, cst_http_conn_t *c, size_t want) { + const cst_http_route_t *route = find_route(s, &c->req); + size_t cap = route && route->max_body ? route->max_body : s->o.max_body; + if (want > 0) + c->req.keep_alive = 0; /* until the body is read, the stream is ours */ if (!route) { cst_http_send_err(c, 404, "not found"); return; @@ -455,6 +514,25 @@ static void dispatch(cst_http_server_t *s, cst_http_conn_t *c) { cst_http_send_err(c, 401, "unauthorized"); return; } + if (want > cap) { + cst_http_send_err(c, 413, "request body too large"); + return; + } + if (want > 0) { + if (c->expect_continue) { + const char *cont = "HTTP/1.1 100 Continue\r\n\r\n"; + os_net_send_all(c->sock, cont, strlen(cont), 5000); + } + char *body = rd_body(c, want, s->o.read_timeout_ms); + if (!body) { + c->failed = 1; + return; + } + c->body = body; + c->req.body = body; + c->req.body_len = want; + c->req.keep_alive = c->keep_alive_req; + } int rc = route->fn(c, route->ud); if (!c->sent) cst_http_send_err(c, rc == 0 ? 500 : 404, @@ -477,32 +555,14 @@ static void *conn_main(void *arg) { } 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->nreq++; c->sent = 0; c->streaming = 0; - dispatch(s, c); + c->keep_alive_req = c->req.keep_alive; + serve(s, c, want); if (s->o.on_log) { char line[512]; - snprintf(line, sizeof(line), "%s %s -> %d", c->req.method, c->req.path, + snprintf(line, sizeof(line), "%s %.480s -> %d", c->req.method, c->req.path, c->failed ? 499 : (c->sent ? 200 : 500)); s->o.on_log(line, s->o.log_ud); } @@ -512,11 +572,12 @@ static void *conn_main(void *arg) { c->req.body_len = 0; c->req.authorization = c->req.cookie = c->req.origin = NULL; c->req.host = c->req.content_type = c->req.user_agent = NULL; + c->req.last_event_id = NULL; if (!c->req.keep_alive || s->stopping || c->failed) break; } - os_net_shutdown(c->sock); - os_net_close(c->sock); + /* unlink first: stop() shuts down every listed socket, and a closed fd + * number may already belong to someone else */ pthread_mutex_lock(&s->mu); if (c->prev) c->prev->next = c->next; @@ -524,10 +585,14 @@ static void *conn_main(void *arg) { s->conns_first = c->next; if (c->next) c->next->prev = c->prev; + pthread_mutex_unlock(&s->mu); + os_net_shutdown(c->sock); + os_net_close(c->sock); + free(c); + pthread_mutex_lock(&s->mu); s->nconn--; pthread_cond_broadcast(&s->cv); pthread_mutex_unlock(&s->mu); - free(c); return NULL; } @@ -536,8 +601,22 @@ static void *accept_main(void *arg) { while (!s->stopping) { int timed_out = 0; os_socket_t sock = os_net_accept(s->listener, 500, &timed_out); - if (sock == OS_SOCKET_BAD) + if (sock == OS_SOCKET_BAD) { + if (!timed_out) { /* EMFILE and friends: the listener stays readable */ + struct timespec ts; + clock_gettime(CLOCK_REALTIME, &ts); + ts.tv_nsec += 100000000; + if (ts.tv_nsec >= 1000000000) { + ts.tv_sec++; + ts.tv_nsec -= 1000000000; + } + pthread_mutex_lock(&s->mu); + if (!s->stopping) + pthread_cond_timedwait(&s->cv, &s->mu, &ts); + pthread_mutex_unlock(&s->mu); + } continue; + } if (s->stopping) { os_net_close(sock); break; @@ -644,9 +723,9 @@ int cst_http_stopping(const cst_http_server_t *s) { return s ? s->stopping : 1; } -void cst_http_stop(cst_http_server_t *s) { +int cst_http_stop(cst_http_server_t *s) { if (!s) - return; + return 0; pthread_mutex_lock(&s->mu); s->stopping = 1; for (cst_http_conn_t *c = s->conns_first; c; c = c->next) @@ -670,13 +749,20 @@ void cst_http_stop(cst_http_server_t *s) { } pthread_cond_timedwait(&s->cv, &s->mu, &ts); } + int left = s->nconn; pthread_mutex_unlock(&s->mu); os_net_close(s->listener); + return left; } void cst_http_free(cst_http_server_t *s) { if (!s) return; + pthread_mutex_lock(&s->mu); + int left = s->nconn; + pthread_mutex_unlock(&s->mu); + if (left > 0) + return; /* a handler still runs on it (see stop()): leak, not crash */ pthread_mutex_destroy(&s->mu); pthread_cond_destroy(&s->cv); free(s->routes); diff --git a/src/main.c b/src/main.c index 1e749f2..dc9f220 100644 --- a/src/main.c +++ b/src/main.c @@ -255,6 +255,16 @@ static int demo_ctl_impl(const char *workspace) { return 0; } +/* 0..65535, digits only: atoi turned "70000" into port 4464, "abc" into 0. */ +static int parse_port(const char *s, int *out) { + char *end = NULL; + long v = strtol(s, &end, 10); + if (!s[0] || *end || v < 0 || v > 65535) + return -1; + *out = (int)v; + return 0; +} + int main(int argc, char **argv) { if (os_console_init() != 0) fprintf(stderr, "warning: the process code page is not UTF-8 (Windows 10 " @@ -334,8 +344,10 @@ int main(int argc, char **argv) { memset(&web, 0, sizeof(web)); web.port = CST_WEBUI_DEFAULT_PORT; /* 0 would mean "pick a free one" */ const char *env = getenv("CST_WEB_PORT"); - if (env && env[0]) - web.port = atoi(env); + if (env && env[0] && parse_port(env, &web.port) != 0) { + fprintf(stderr, "CST_WEB_PORT=%s is not a port (0-65535)\n", env); + return 1; + } web.host = getenv("CST_WEB_HOST"); web.token = getenv("CST_WEB_TOKEN"); web.root = getenv("CST_WEB_ROOT"); @@ -482,7 +494,11 @@ int main(int argc, char **argv) { web_mode = 1; break; case 0x102: - web.port = atoi(optarg); + if (parse_port(optarg, &web.port) != 0) { + fprintf(stderr, "--web-port %s is not a port (0-65535)\n", optarg); + curl_global_cleanup(); + return 1; + } web_mode = 1; break; case 0x103: diff --git a/src/net.c b/src/net.c index 57e4955..a904118 100644 --- a/src/net.c +++ b/src/net.c @@ -12,6 +12,10 @@ #ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif +#if !defined(_WIN32_WINNT) || _WIN32_WINNT < 0x0600 +#undef _WIN32_WINNT +#define _WIN32_WINNT 0x0600 /* WSAPoll */ +#endif #include <winsock2.h> #include <ws2tcpip.h> #else @@ -21,8 +25,8 @@ #include <netdb.h> #include <netinet/in.h> #include <netinet/tcp.h> +#include <poll.h> #include <signal.h> -#include <sys/select.h> #include <sys/socket.h> #include <unistd.h> #endif @@ -45,7 +49,6 @@ static void set_err_win(char *err, size_t err_sz, const char *what) { } #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(); @@ -58,7 +61,6 @@ static int net_last_conn_reset(void) { #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) { @@ -84,15 +86,22 @@ void os_net_cleanup(void) { #endif } +/* poll, not select: FD_SET past FD_SETSIZE writes out of bounds, and a + * failed connect shows up as an error event, not as writable, on Windows. */ static int wait_io(os_socket_t s, int want_write, int timeout_ms) { - 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); +#ifdef _WIN32 + WSAPOLLFD p; + p.fd = NET_FD(s); + p.events = want_write ? POLLWRNORM : POLLRDNORM; + p.revents = 0; + int rc = WSAPoll(&p, 1, timeout_ms < 0 ? -1 : timeout_ms); +#else + struct pollfd p; + p.fd = NET_FD(s); + p.events = want_write ? POLLOUT : POLLIN; + p.revents = 0; + int rc = poll(&p, 1, timeout_ms < 0 ? -1 : timeout_ms); +#endif if (rc > 0) return 1; if (rc == 0) @@ -137,8 +146,15 @@ os_socket_t os_net_listen(const char *host, int port, char *err, size_t err_sz) return OS_SOCKET_BAD; } int one = 1; +#ifdef _WIN32 + /* SO_REUSEADDR on Windows lets another process bind the same port and + * take over our connections */ + setsockopt(NET_FD(s), SOL_SOCKET, SO_EXCLUSIVEADDRUSE, (const char *)&one, + sizeof(one)); +#else setsockopt(NET_FD(s), SOL_SOCKET, SO_REUSEADDR, (const char *)&one, sizeof(one)); +#endif #ifdef IPV6_V6ONLY if (sa.ss_family == AF_INET6) setsockopt(NET_FD(s), IPPROTO_IPV6, IPV6_V6ONLY, (const char *)&one, diff --git a/src/tui.c b/src/tui.c index bea04ab..ce00941 100644 --- a/src/tui.c +++ b/src/tui.c @@ -5369,6 +5369,7 @@ static void cmd_quit(tui_t *t, char *arg, int *quit) { * the TUI holding the terminal. The child gets a fresh session: two writers on * one session file is the one thing that would corrupt it. */ #ifndef _WIN32 +#include <fcntl.h> #include <sys/wait.h> /* waitpid: collect the WebUI started by the last /web */ #endif #ifndef CST_WEBUI_DEFAULT_PORT @@ -5393,33 +5394,117 @@ static void cmd_web(tui_t *t, char *arg, int *quit) { snprintf(exe, sizeof(exe), "%s/cst-agent", dir); const cst_agent_config_t *cfg = cst_agent_config(t->agent); const char *ws = (cfg && cfg->workspace_root[0]) ? cfg->workspace_root : "."; - char logf[4096]; + char logf[4096 + 32]; + snprintf(logf, sizeof(logf), "%s/.agent", ws); + os_mkdir_p(logf, 0755); snprintf(logf, sizeof(logf), "%s/.agent/webui.log", ws); + /* The WebUI talks to what this session talks to (-b, -m, a /provider + * switch...). The key goes through the environment, not argv (ps). */ + const char *argv[20]; + int na = 0; + argv[na++] = "cst-agent"; + argv[na++] = "--web"; + argv[na++] = "--workspace"; + argv[na++] = ws; + argv[na++] = "--new"; + if (cfg && cfg->provider[0]) + argv[na++] = "--provider", argv[na++] = cfg->provider; + if (cfg && cfg->model[0]) + argv[na++] = "--model", argv[na++] = cfg->model; + if (cfg && cfg->api_base[0]) + argv[na++] = "--api-base", argv[na++] = cfg->api_base; + if (cfg && cfg->effort[0]) + argv[na++] = "--effort", argv[na++] = cfg->effort; + if (cfg) + argv[na++] = "--approval", argv[na++] = cst_approval_name(cfg->approval); + argv[na] = NULL; + size_t nenv = 0; + while (environ[nenv]) + nenv++; + char **envp = calloc(nenv + 2, sizeof(char *)); + char keyvar[600] = ""; + if (!envp) { + push_system_line(t, "out of memory"); + return; + } + size_t ne = 0; + for (size_t i = 0; i < nenv; i++) + if (strncmp(environ[i], "CST_API_KEY=", 12) != 0) + envp[ne++] = environ[i]; + if (cfg && cfg->api_key[0]) { + snprintf(keyvar, sizeof(keyvar), "CST_API_KEY=%s", cfg->api_key); + envp[ne++] = keyvar; + } + envp[ne] = NULL; pid_t pid = fork(); if (pid < 0) { + free(envp); push_system_line(t, "cannot start a second process"); return; } if (pid == 0) { setsid(); + /* never inherit the TUI's terminal: its output would land on our screen */ + int in = open("/dev/null", O_RDONLY); + if (in >= 0 && in != STDIN_FILENO) { + dup2(in, STDIN_FILENO); + close(in); + } int fd = open(logf, O_WRONLY | O_CREAT | O_TRUNC, 0600); + if (fd < 0) + fd = open("/dev/null", O_WRONLY); if (fd >= 0) { dup2(fd, STDOUT_FILENO); dup2(fd, STDERR_FILENO); if (fd > 2) close(fd); } - execl(exe, "cst-agent", "--web", "--workspace", ws, "--new", (char *)NULL); + execve(exe, (char *const *)argv, envp); _exit(127); } + free(envp); spawned = pid; + /* Wait (2 s at most) for the URL it prints once it listens: the port may + * come from CST_WEB_PORT, or it may not start at all. */ + char url[512] = ""; + int died = 0; + for (int i = 0; i < 40 && !url[0] && !died; i++) { + usleep(50000); + FILE *fp = fopen(logf, "r"); + char line[512]; + while (fp && fgets(line, sizeof(line), fp)) { + if (strncmp(line, "webui: http", 11) == 0) { + line[strcspn(line, "\r\n")] = 0; + snprintf(url, sizeof(url), "%s", line + 7); + break; + } + } + if (fp) + fclose(fp); + if (!url[0] && waitpid(pid, NULL, WNOHANG) == pid) { + died = 1; + spawned = 0; + } + } + /* its --new made the WebUI's session CURRENT; a plain `cst-agent` later + * should still resume this one */ + char sid[128] = ""; + cst_agent_session_id(t->agent, sid, sizeof(sid)); + if (sid[0]) + cst_agent_store_set_current(ws, sid); char msg[4200]; - snprintf(msg, sizeof(msg), - "WebUI started in the background: http://127.0.0.1:%d/ (new session)", - CST_WEBUI_DEFAULT_PORT); + if (died) + snprintf(msg, sizeof(msg), "the WebUI did not start (port busy?): see %s", + logf); + else if (url[0]) + snprintf(msg, sizeof(msg), "WebUI started in the background: %s (new session)", + url); + else + snprintf(msg, sizeof(msg), "WebUI starting in the background " + "(http://127.0.0.1:%d/ unless CST_WEB_PORT says " + "otherwise)", CST_WEBUI_DEFAULT_PORT); push_system_line(t, msg); - snprintf(msg, sizeof(msg), "its log: %s (port busy? see the log, or pass " - "--web-port)", logf); + snprintf(msg, sizeof(msg), "its log: %s", logf); push_system_line(t, msg); #endif } diff --git a/src/web_md.c b/src/web_md.c index c204d6f..8ceebc5 100644 --- a/src/web_md.c +++ b/src/web_md.c @@ -64,7 +64,17 @@ static void attr_append(cst_buf_t *b, const char *name, size_t nlen, cst_buf_puts(b, " "); cst_buf_append(b, name, nlen); cst_buf_puts(b, "=\""); - cst_buf_append(b, val, vlen); + /* a single-quoted or bare value may hold '"': it must not end ours */ + for (size_t i = 0; i < vlen; i++) { + if (val[i] == '"') + cst_buf_puts(b, """); + else if (val[i] == '<') + cst_buf_puts(b, "<"); + else if (val[i] == '>') + cst_buf_puts(b, ">"); + else + cst_buf_append(b, val + i, 1); + } cst_buf_puts(b, "\""); } diff --git a/src/webui.c b/src/webui.c index f03528a..9c2afbc 100644 --- a/src/webui.c +++ b/src/webui.c @@ -72,11 +72,14 @@ typedef struct wui { wui_sse_t *subs; int busy; + int op_busy; /* a session/workspace/undo/compact/attach call runs */ + int turn_live; /* the turn thread still uses w; cleared as it exits */ uint64_t req_id; /* permission/ask id counter */ uint64_t perm_id; int perm_active, perm_answer; uint64_t ask_id; - int ask_active, ask_rc; + int ask_active, ask_rc; /* ask_rc: 0 pending, 1 answered, -1 dismissed */ + uint64_t pending_seq; /* event of the open permission/ask_user, 0 = none */ char ask_ans[WUI_ASK_MAX]; volatile int stopping; @@ -88,36 +91,44 @@ static uint64_t wui_first_seq(const wui_t *w) { return w->seq > WUI_RING ? w->seq - WUI_RING + 1 : 1; } -/* Owns data (a cJSON object). */ -static void wui_emit(wui_t *w, const char *type, cJSON *data) { +/* Owns data (a cJSON object). Returns the event's seq, 0 when dropped. */ +static uint64_t wui_emit(wui_t *w, const char *type, cJSON *data) { if (!data) data = cJSON_CreateObject(); if (!data) - return; + return 0; cJSON_AddStringToObject(data, "type", type); + /* the seq in the payload must match the SSE id, so it is taken under mu */ + pthread_mutex_lock(&w->mu); cJSON_AddNumberToObject(data, "seq", (double)(w->seq + 1)); char *json = cJSON_PrintUnformatted(data); cJSON_Delete(data); - if (!json) - return; cst_buf_t f = {0}; - cst_buf_printf(&f, "event: %s\ndata: %s\n\n", type, json); + if (json) + cst_buf_printf(&f, "event: %s\ndata: %s\n\n", type, json); free(json); if (!f.buf) { + pthread_mutex_unlock(&w->mu); cst_buf_free(&f); - return; + return 0; } - pthread_mutex_lock(&w->mu); - w->seq++; - int slot = (int)(w->seq % WUI_RING); + uint64_t seq = ++w->seq; + int slot = (int)(seq % WUI_RING); free(w->ring[slot].frame); w->ring[slot].frame = f.buf; /* stolen */ - w->ring[slot].seq = w->seq; + w->ring[slot].seq = seq; f.buf = NULL; for (wui_sse_t *s = w->subs; s; s = s->next) pthread_cond_broadcast(&s->cv); pthread_mutex_unlock(&w->mu); cst_buf_free(&f); + return seq; +} + +static void wui_set_pending(wui_t *w, uint64_t seq) { + pthread_mutex_lock(&w->mu); + w->pending_seq = seq; + pthread_mutex_unlock(&w->mu); } static void wui_emit_str(wui_t *w, const char *type, const char *key, @@ -379,7 +390,7 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail, strncmp(detail, CST_PERM_SANDBOX, strlen(CST_PERM_SANDBOX)) == 0); - wui_emit(w, "permission", o); + wui_set_pending(w, wui_emit(w, "permission", o)); } int timeout = w->perm_timeout_s > 0 ? w->perm_timeout_s : 600; /* Nobody is watching: do not hang the turn for ten minutes. */ @@ -404,6 +415,8 @@ static int wui_wait_permission(wui_t *w, const char *tool, const char *detail, break; } } + w->perm_active = 0; + w->pending_seq = 0; pthread_mutex_unlock(&w->mu); cJSON *e = cJSON_CreateObject(); if (e) { @@ -427,7 +440,7 @@ static int on_ask_user(const char *question, const char *options, char *ans, uint64_t id = w->req_id; w->ask_id = id; w->ask_active = 1; - w->ask_rc = -1; + w->ask_rc = 0; w->ask_ans[0] = 0; int has_client = w->subs != NULL; pthread_mutex_unlock(&w->mu); @@ -449,7 +462,7 @@ static int on_ask_user(const char *question, const char *options, char *ans, } p = nl ? nl + 1 : NULL; } - wui_emit(w, "ask_user", o); + wui_set_pending(w, wui_emit(w, "ask_user", o)); } int timeout = has_client ? 600 : 3; int rc = -1; @@ -472,6 +485,8 @@ static int on_ask_user(const char *question, const char *options, char *ans, break; } } + w->ask_active = 0; + w->pending_seq = 0; pthread_mutex_unlock(&w->mu); return rc; } @@ -544,6 +559,8 @@ static int wui_serve_asset(cst_http_conn_t *c, wui_t *w, const char *path) { return -1; } +static int wui_auth(const cst_http_req_t *req, void *ud); + static int h_asset(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); @@ -552,6 +569,9 @@ static int h_asset(cst_http_conn_t *c, void *ud) { * reachable. */ static const char prefix[] = "/api/attach/"; if (strncmp(req->path, prefix, sizeof(prefix) - 1) == 0) { + /* the fallback route is public (the page's own assets); images are not */ + if (wui_auth(req, w) != 0) + return cst_http_send_err(c, 401, "unauthorized"); const char *rel = req->path + sizeof(prefix) - 1; if (!cst_attach_rel_ok(rel)) return -1; @@ -569,8 +589,8 @@ static int h_asset(cst_http_conn_t *c, void *ud) { size_t n = strlen(rel); if (n > 4 && strcmp(rel + n - 4, ".png") == 0) ctype = "image/png"; - else if (n > 4 && (strcmp(rel + n - 4, ".jpg") == 0 || - strcmp(rel + n - 4, ".jpeg") == 0)) + else if ((n > 4 && strcmp(rel + n - 4, ".jpg") == 0) || + (n > 5 && strcmp(rel + n - 5, ".jpeg") == 0)) ctype = "image/jpeg"; else if (n > 4 && strcmp(rel + n - 4, ".gif") == 0) ctype = "image/gif"; @@ -630,11 +650,19 @@ static int h_events(cst_http_conn_t *c, void *ud) { char buf[32]; if (cst_http_query(req, "since", buf, sizeof(buf)) == 0) s.cursor = (uint64_t)strtoull(buf, NULL, 10); + else if (req->last_event_id) + s.cursor = (uint64_t)strtoull(req->last_event_id, NULL, 10); pthread_mutex_init(&s.mu, NULL); pthread_cond_init(&s.cv, NULL); pthread_mutex_lock(&w->mu); - if (s.cursor == 0) - s.cursor = w->seq; /* a fresh client wants new events only */ + /* 0 = a fresh client wants new events only; past the head = ids from an + * earlier server process */ + if (s.cursor == 0 || s.cursor > w->seq) { + s.cursor = w->seq; + /* a page opened while a question waits must still get to answer it */ + if (w->pending_seq && w->pending_seq <= w->seq) + s.cursor = w->pending_seq - 1; + } if (s.cursor < wui_first_seq(w) - 1) s.cursor = wui_first_seq(w) - 1; /* too far behind: resume at the head */ s.next = w->subs; @@ -741,14 +769,19 @@ static void *wui_turn_main(void *arg) { wui_end_turn(w, rc, err); free(t->prompt); free(t); + pthread_mutex_lock(&w->mu); + w->turn_live = 0; + pthread_cond_broadcast(&w->cv); + pthread_mutex_unlock(&w->mu); return NULL; } static int wui_start_turn(wui_t *w, const char *text, char *err, size_t err_sz) { pthread_mutex_lock(&w->mu); - if (w->busy) { + if (w->busy || w->op_busy) { pthread_mutex_unlock(&w->mu); - snprintf(err, err_sz, "a turn is already running"); + snprintf(err, err_sz, w->busy ? "a turn is already running" + : "another change is running"); return -1; } w->busy = 1; @@ -776,11 +809,15 @@ static int wui_start_turn(wui_t *w, const char *text, char *err, size_t err_sz) return -1; } pthread_t th; + pthread_mutex_lock(&w->mu); + w->turn_live = 1; + pthread_mutex_unlock(&w->mu); if (pthread_create(&th, NULL, wui_turn_main, t) != 0) { free(t->prompt); free(t); pthread_mutex_lock(&w->mu); w->busy = 0; + w->turn_live = 0; pthread_mutex_unlock(&w->mu); snprintf(err, err_sz, "cannot start the turn thread"); return -1; @@ -808,9 +845,45 @@ static int h_turn(cst_http_conn_t *c, void *ud) { "{\"ok\":true}"); } +/* The agent is not locked: switching sessions, the workspace, undo, compact + * and the pending images all change what a running turn reads. These run + * one at a time and never during a turn. */ +static int wui_claim(wui_t *w) { + pthread_mutex_lock(&w->mu); + int ok = !w->busy && !w->op_busy; + if (ok) + w->op_busy = 1; + pthread_mutex_unlock(&w->mu); + return ok ? 0 : -1; +} + +static void wui_release(wui_t *w) { + pthread_mutex_lock(&w->mu); + w->op_busy = 0; + pthread_mutex_unlock(&w->mu); +} + +#define WUI_EXCLUSIVE(name) \ + static int name(cst_http_conn_t *c, void *ud) { \ + wui_t *w = ud; \ + if (wui_claim(w) != 0) \ + return cst_http_send_err(c, 409, "busy: wait for the running turn"); \ + int rc = name##_x(c, ud); \ + wui_release(w); \ + return rc; \ + } + static int h_cancel(cst_http_conn_t *c, void *ud) { wui_t *w = ud; cst_agent_request_cancel(w->agent); + /* a turn parked on the gate or a question would not see the flag */ + pthread_mutex_lock(&w->mu); + if (w->perm_active && w->perm_answer < 0) + w->perm_answer = CST_PERM_DENY; + if (w->ask_active && w->ask_rc == 0) + w->ask_rc = -1; + pthread_cond_broadcast(&w->cv); + pthread_mutex_unlock(&w->mu); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } @@ -870,11 +943,15 @@ static int h_ask_user(cst_http_conn_t *c, void *ud) { static int h_sessions(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_agent_config_t *cfg = cst_agent_config(w->agent); - static char ids[200][128]; + char (*ids)[128] = malloc(200 * sizeof(*ids)); /* handlers run in parallel */ + if (!ids) + return cst_http_send_err(c, 500, "out of memory"); int n = cst_agent_store_list(cfg->workspace_root, ids, 200); cJSON *root = cJSON_CreateObject(); - if (!root) + if (!root) { + free(ids); return cst_http_send_err(c, 500, "out of memory"); + } char cur[128] = ""; cst_agent_store_get_current(cfg->workspace_root, cur, sizeof(cur)); cJSON_AddStringToObject(root, "current", cur); @@ -893,6 +970,7 @@ static int h_sessions(cst_http_conn_t *c, void *ud) { cJSON_AddNumberToObject(o, "mtime", (double)in.mtime); cJSON_AddItemToArray(arr, o); } + free(ids); char *json = cJSON_PrintUnformatted(root); cJSON_Delete(root); int rc = cst_http_send_json(c, 200, json); @@ -900,7 +978,7 @@ static int h_sessions(cst_http_conn_t *c, void *ud) { return rc; } -static int h_session_new(cst_http_conn_t *c, void *ud) { +static int h_session_new_x(cst_http_conn_t *c, void *ud) { wui_t *w = ud; char id[128] = ""; if (cst_agent_new_session(w->agent, id, sizeof(id)) != 0) @@ -920,7 +998,7 @@ static int h_session_new(cst_http_conn_t *c, void *ud) { /* POST /api/workspace {"path":"/abs/dir"} - re-root the agent. The session * store is per directory, so this always lands in a fresh session there. */ -static int h_workspace(cst_http_conn_t *c, void *ud) { +static int h_workspace_x(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL; @@ -950,7 +1028,7 @@ static int h_workspace(cst_http_conn_t *c, void *ud) { return rc; } -static int h_session_switch(cst_http_conn_t *c, void *ud) { +static int h_session_switch_x(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); cJSON *j = req->body_len ? cJSON_Parse(req->body) : NULL; @@ -975,6 +1053,15 @@ static int h_config(cst_http_conn_t *c, void *ud) { cJSON_Delete(j); return cst_http_send_err(c, 400, "expected a JSON object"); } + /* the strings are read by the turn as it builds each request; approval + * and plan mode may change mid-turn (say, to yolo at a pending gate) */ + int claimed = cJSON_GetObjectItem(j, "model") || + cJSON_GetObjectItem(j, "provider") || + cJSON_GetObjectItem(j, "effort"); + if (claimed && wui_claim(w) != 0) { + cJSON_Delete(j); + return cst_http_send_err(c, 409, "busy: wait for the running turn"); + } const cJSON *item; if ((item = cJSON_GetObjectItem(j, "model")) && cJSON_IsString(item)) snprintf(cfg->model, sizeof(cfg->model), "%s", item->valuestring); @@ -995,12 +1082,14 @@ static int h_config(cst_http_conn_t *c, void *ud) { cfg->read_only = cfg->plan_mode; } cJSON_Delete(j); + if (claimed) + wui_release(w); wui_emit_state(w); return cst_http_send_str(c, 200, "application/json; charset=utf-8", "{\"ok\":true}"); } -static int h_undo(cst_http_conn_t *c, void *ud) { +static int h_undo_x(cst_http_conn_t *c, void *ud) { wui_t *w = ud; char report[2048] = ""; if (cst_agent_undo(w->agent, report, sizeof(report)) != 0) @@ -1010,7 +1099,7 @@ static int h_undo(cst_http_conn_t *c, void *ud) { return cst_http_send_str(c, 200, "text/plain; charset=utf-8", report); } -static int h_compact(cst_http_conn_t *c, void *ud) { +static int h_compact_x(cst_http_conn_t *c, void *ud) { wui_t *w = ud; char err[256] = ""; if (cst_agent_compact(w->agent, err, sizeof(err)) != 0) @@ -1020,7 +1109,7 @@ static int h_compact(cst_http_conn_t *c, void *ud) { "{\"ok\":true}"); } -static int h_attach_upload(cst_http_conn_t *c, void *ud) { +static int h_attach_upload_x(cst_http_conn_t *c, void *ud) { wui_t *w = ud; const cst_http_req_t *req = cst_http_request(c); char name[256] = "pasted.png"; @@ -1036,7 +1125,7 @@ static int h_attach_upload(cst_http_conn_t *c, void *ud) { "{\"ok\":true}"); } -static int h_attach_clear(cst_http_conn_t *c, void *ud) { +static int h_attach_clear_x(cst_http_conn_t *c, void *ud) { wui_t *w = ud; cst_agent_pending_clear(w->agent); wui_emit_state(w); @@ -1044,6 +1133,14 @@ static int h_attach_clear(cst_http_conn_t *c, void *ud) { "{\"ok\":true}"); } +WUI_EXCLUSIVE(h_session_new) +WUI_EXCLUSIVE(h_workspace) +WUI_EXCLUSIVE(h_session_switch) +WUI_EXCLUSIVE(h_undo) +WUI_EXCLUSIVE(h_compact) +WUI_EXCLUSIVE(h_attach_upload) +WUI_EXCLUSIVE(h_attach_clear) + static int h_quit(cst_http_conn_t *c, void *ud) { wui_t *w = ud; w->stopping = 1; @@ -1107,10 +1204,51 @@ static int host_is_loopback(const char *host) { return 0; } +static int strncasecmp_ascii(const char *a, const char *b, size_t n) { + for (size_t i = 0; i < n; i++) { + char x = a[i], y = b[i]; + if (x >= 'A' && x <= 'Z') + x = (char)(x - 'A' + 'a'); + if (y >= 'A' && y <= 'Z') + y = (char)(y - 'A' + 'a'); + if (x != y) + return x < y ? -1 : 1; + if (!x) + return 0; + } + return 0; +} + +/* A browser names the page that sent a request in Origin. Without a token + * that is the only thing that tells our own page from any site the user + * visits: a no-cors POST with a text/plain body needs no preflight and + * carries Host: 127.0.0.1. So it must be this very origin. */ +static int origin_is_self(const char *origin, const char *host) { + if (!origin) + return 1; /* curl and friends; browsers always send it on POST */ + while (*origin == ' ') + origin++; + const char *rest = NULL; + if (strncmp(origin, "http://", 7) == 0) + rest = origin + 7; + else if (strncmp(origin, "https://", 8) == 0) + rest = origin + 8; + if (!rest || !host) + return 0; + while (*host == ' ') + host++; + size_t n = strlen(host); + while (n && (host[n - 1] == ' ' || host[n - 1] == '\t')) + n--; + return strlen(rest) == n && strncasecmp_ascii(rest, host, n) == 0; +} + static int wui_auth(const cst_http_req_t *req, void *ud) { wui_t *w = ud; if (!w->need_auth) - return host_is_loopback(req->host) ? 0 : -1; + return host_is_loopback(req->host) && origin_is_self(req->origin, req->host) + ? 0 + : -1; char buf[128]; if (req->authorization && strncmp(req->authorization, "Bearer ", 7) == 0 && token_matches(w, req->authorization + 7)) @@ -1158,23 +1296,21 @@ static void wui_make_token(char *out, size_t out_sz) { } static void open_browser(const char *url) { + char cmd[600]; #if defined(_WIN32) - char cmd[600]; snprintf(cmd, sizeof(cmd), "start \"\" \"%s\"", url); - os_run_shell_c(".", cmd, 0, NULL); #elif defined(__APPLE__) - char cmd[600]; snprintf(cmd, sizeof(cmd), "open '%s'", url); - os_run_shell_c(".", cmd, 0, NULL); #else - char cmd[600]; /* Termux has no xdg-open; termux-open-url hands the URL to Android. */ const char *opener = getenv("TERMUX_VERSION") ? "termux-open-url" : "xdg-open"; snprintf(cmd, sizeof(cmd), "%s '%s' >/dev/null 2>&1 &", opener, url); - os_run_shell_c(".", cmd, 0, NULL); #endif - (void)url; + os_run_result_t r; + memset(&r, 0, sizeof(r)); /* a NULL result makes the runner refuse */ + os_run_shell_c(".", cmd, 0, &r); + os_run_result_free(&r); } int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { @@ -1194,6 +1330,14 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { snprintf(w->host, sizeof(w->host), "%s", opts->host && opts->host[0] ? opts->host : "127.0.0.1"); if (opts->token && opts->token[0]) { + if (strlen(opts->token) >= sizeof(w->token)) { + fprintf(stderr, "webui: the token is longer than %d characters\n", + (int)sizeof(w->token) - 1); + pthread_cond_destroy(&w->cv); + pthread_mutex_destroy(&w->mu); + free(w); + return 1; + } snprintf(w->token, sizeof(w->token), "%s", opts->token); w->need_auth = 1; } else if (strcmp(w->host, "127.0.0.1") != 0 && @@ -1227,8 +1371,10 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { routes[i].ud = w; routes[i].auth = w->need_auth || strcmp(routes[i].path, "/") != 0; } - routes[0].auth = 0; /* the shell page itself sets the cookie */ - routes[sizeof(routes) / sizeof(routes[0]) - 1].auth = w->need_auth; + /* the shell page and its assets carry no token (app.js reads it from the + * URL and sets the cookie); h_asset checks /api/attach/ itself */ + routes[0].auth = 0; + routes[sizeof(routes) / sizeof(routes[0]) - 1].auth = 0; cst_http_opts_t ho; memset(&ho, 0, sizeof(ho)); @@ -1262,8 +1408,12 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { w->token); fflush(stderr); wui_emit_state(w); - if (opts->open_browser) - open_browser(url); + if (opts->open_browser) { + char full[sizeof(url) + 80]; + snprintf(full, sizeof(full), "%s%s%s", url, w->need_auth ? "#token=" : "", + w->need_auth ? w->token : ""); + open_browser(full); + } if (opts->prompt && opts->prompt[0]) { char err2[256] = ""; @@ -1281,8 +1431,38 @@ int cst_webui_run(cst_agent_t *agent, const cst_webui_opts_t *opts) { nanosleep(&ts, NULL); } fprintf(stderr, "webui: shutting down\n"); + /* Wake a turn parked on a permission/ask_user answer, cancel it and wait + * for its thread: it emits through w, and the agent is destroyed next. */ + pthread_mutex_lock(&w->mu); w->stopping = 1; - cst_http_stop(w->srv); + pthread_cond_broadcast(&w->cv); + int live = w->turn_live; + pthread_mutex_unlock(&w->mu); + cst_agent_request_cancel(w->agent); /* also a /api/compact in flight */ + if (live) { + struct timespec deadline; + clock_gettime(CLOCK_REALTIME, &deadline); + deadline.tv_sec += 30; + pthread_mutex_lock(&w->mu); + while (w->turn_live && + pthread_cond_timedwait(&w->cv, &w->mu, &deadline) == 0) + ; + live = w->turn_live; + pthread_mutex_unlock(&w->mu); + } + if (live) { + /* still running (a command ignoring cancel): the caller would destroy + * the agent under it, so leave without tearing anything down */ + fprintf(stderr, "webui: the running turn did not stop in 30 s\n"); + fflush(NULL); + _Exit(1); + } + if (cst_http_stop(w->srv) > 0) { + /* a handler still runs and uses w and the agent: same as above */ + fprintf(stderr, "webui: a request did not finish in 30 s\n"); + fflush(NULL); + _Exit(1); + } cst_http_free(w->srv); for (int i = 0; i < WUI_RING; i++) free(w->ring[i].frame); diff --git a/tests/test_webui.c b/tests/test_webui.c index 28f351f..f1d08ac 100644 --- a/tests/test_webui.c +++ b/tests/test_webui.c @@ -236,6 +236,16 @@ static void test_sanitize(void) { CHECK(strstr(html, "<a") == NULL); /* an unusable href drops the tag */ free(html); + /* a quote inside a single-quoted or bare value cannot open a new attribute */ + const char *quoted = "<a href='http://x\" onmouseover=\"alert(1)'>q</a>" + "<a href=http://y\"onmouseover=alert(2)//>r</a>"; + html = cst_web_md_sanitize(quoted, strlen(quoted)); + CHECK(html != NULL); + CHECK(strstr(html, "\" onmouseover") == NULL); + CHECK(strstr(html, "\"onmouseover") == NULL); + CHECK(strstr(html, """) != NULL); + free(html); + /* streaming: a javascript: link is dropped, a split tag never leaks */ { cst_buf_free(&g_md_out); diff --git a/web/app.js b/web/app.js index 9cc6a84..4aafbd9 100644 --- a/web/app.js +++ b/web/app.js @@ -4,14 +4,16 @@ * gate and the anchored popovers. Protocol: docs/webui.md. */ '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. --- */ +/* --- token: from ?token= / #token=; the query parameter is appended to + * every request so EventSource (which cannot set headers) works as well, + * and a same-site cookie carries it for the <img> tags in rendered + * Markdown. --- */ const TOKEN = (() => { const q = new URLSearchParams(location.search); if (q.get('token')) return q.get('token'); return new URLSearchParams(location.hash.replace(/^#/, '')).get('token') || ''; })(); +if (TOKEN) document.cookie = 'cst_token=' + encodeURIComponent(TOKEN) + '; path=/; SameSite=Strict'; const qs = (extra) => { const p = new URLSearchParams(extra || {}); if (TOKEN) p.set('token', TOKEN); @@ -259,7 +261,11 @@ function ensureLive() { if (live) return live; const b = bubble('assistant'); b.body.classList.add('live'); - live = { root: b.root, body: b.body, html: '' }; + /* the answer gets its own box: rewriting body.innerHTML would drop the + * reasoning <details> that thinkInto keeps at the top of body */ + const out = el('div', 'live-html'); + b.body.appendChild(out); + live = { root: b.root, body: b.body, out, html: '' }; return live; } function ensureSteps() { @@ -501,7 +507,7 @@ function applyState(s) { const x = el('button', 'icon tiny'); x.appendChild(icon('close')); x.title = '清空待发图片'; - x.onclick = () => post('api/attach', {}).catch(() => {}); + x.onclick = () => api('api/attach', { method: 'DELETE' }).catch(() => {}); chip.appendChild(x); p.appendChild(chip); } @@ -525,7 +531,7 @@ function connect() { markBusy(); const l = ensureLive(); l.html += d.text || ''; - l.body.innerHTML = l.html; + l.out.innerHTML = l.html; scrollDown(false); }); on('think_delta', (d) => { @@ -553,7 +559,20 @@ function connect() { if (d && d.rc) bubble('error').body.textContent = d.error || ('rc=' + d.rc); loadSession().then(loadSessions).catch(() => {}); }); - es.onerror = () => { $('status').textContent = '连接中断,重连中…'; }; + /* the browser resumes from Last-Event-ID; a restarted server has none of + * those events, so its state (and maybe session) is fetched afresh */ + let dropped = false; + es.onerror = () => { dropped = true; $('status').textContent = '连接中断,重连中…'; }; + es.onopen = () => { + if (!dropped) return; + dropped = false; + if (!busy) $('status').textContent = '就绪'; + const was = state.session; + getJSON('api/state').then((s) => { + applyState(s); + if (s.session !== was) loadSession().then(loadSessions).catch(() => {}); + }).catch(() => {}); + }; } /* --- approval gate ------------------------------------------------------- */ @@ -579,7 +598,7 @@ function showGate(ev) { b.onclick = () => answerGate(a.v); row.appendChild(b); } - $('gate-keys').textContent = '按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。'; + $('gate-keys').textContent = '焦点不在输入框时,按 y 允许一次,按 n 拒绝;超时未回答会被拒绝。'; $('status').textContent = '等待你的许可…'; let left = 600; clearInterval(gateTimer); @@ -590,7 +609,6 @@ function showGate(ev) { tick(); gateTimer = setInterval(tick, 1000); scrollDown(true); - $('input').focus(); } function hideGate() { clearInterval(gateTimer); @@ -612,7 +630,10 @@ async function answerGate(value) { } } window.addEventListener('keydown', (e) => { - if (permReq && !$('gate').hidden) { + /* typing "3" or "y" into the box must not grant a permission */ + const typing = e.isComposing || e.ctrlKey || e.metaKey || e.altKey || + (e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName))); + if (permReq && !$('gate').hidden && !typing) { if (e.key === '1' || e.key.toLowerCase() === 'y') answerGate(1); else if (e.key === '2') answerGate(2); else if (e.key === '3') answerGate(3); diff --git a/web/style.css b/web/style.css index 7c5be9a..a9885ec 100644 --- a/web/style.css +++ b/web/style.css @@ -340,8 +340,8 @@ body.no-side #side { pointer-events: none; } padding: 9px 15px; max-width: 76%; white-space: pre-wrap; word-break: break-word; } .msg.error .body { color: var(--danger); } -.body > :first-child { margin-top: 0; } -.body > :last-child { margin-bottom: 0; } +.body > :first-child, .live-html > :first-child { margin-top: 0; } +.body > :last-child, .live-html > :last-child { margin-bottom: 0; } .body h1, .body h2, .body h3, .body h4 { line-height: 1.35; margin: 1.1em 0 .5em; } .body h1 { font-size: 1.45em; } .body h2 { font-size: 1.25em; } .body h3 { font-size: 1.1em; } .body p, .body ul, .body ol { margin: .55em 0; } @@ -364,7 +364,7 @@ body.no-side #side { pointer-events: none; } .body img { max-width: 100%; border-radius: 10px; } .body hr { border: 0; border-top: 1px solid var(--line); margin: 1.2em 0; } /* a caret while tokens are still arriving */ -.body.live > :last-child::after { +.body.live > .live-html > :last-child::after { content: ""; display: inline-block; width: 7px; height: 14px; margin-left: 3px; vertical-align: -2px; border-radius: 2px; background: var(--accent); animation: blink 1.1s steps(2, start) infinite;