v0.8.2: build fixes, WebUI security and correctness
ci / linux (clang) (push) Successful in 5m45s
ci / linux (gcc) (push) Successful in 6m46s
ci / musl (push) Successful in 7m9s
ci / windows-cross (push) Successful in 11m52s

Build: <fcntl.h> in tui.c, format-truncation in http_srv.c and /web.

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

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

Markdown sanitizer escapes quotes in attribute values.

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

Tests: scripts/e2e_webui_ask.py, scripts/e2e_webui_http.py (run from
e2e_webui.sh), sanitizer quote test.
This commit is contained in:
WHC
2026-10-09 13:51:34 +08:00
parent adf3e9f024
commit e238f5f37d
15 changed files with 1001 additions and 144 deletions
+12 -4
View File
@@ -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://<Host>`),否则 401 —— 防止任意网页跨站 POST 驱动 agent |
| 静态资源 | 页面本身与 `app.js`/`style.css` 不需要 token;`/api/attach/<rel>` 图片仍要(页面会把 token 写进 `SameSite=Strict` cookie) |
| 请求解析 | 鉴权与大小检查在读请求体**之前**完成;`Content-Length` 必须是纯数字且只有一个值;请求头整体 15 s 内收完 |
| `--web-host 0.0.0.0` | 没给 `--web-token` 时自动生成随机 token 并打印;所有 `/api/*` 都要 token |
| token 传递 | `Authorization: Bearer <t>`、`Cookie: cst_token=<t>` 或 `?token=<t>`(`EventSource` 不能设 header,所以查询参数是必需的一条路) |
| 资源 | 只有白名单路由;路径里的 `..`/编码 NUL 一律 400;静态资源来自二进制内嵌(或 `--web-dev DIR`) |
@@ -70,7 +72,10 @@ GET /api/events?since=<seq> → text/event-stream (chunked)
* 每条事件都有 `id: <seq>`(单调递增)与 `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>` | 读回已存进会话的图片(`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 行)
+5 -2
View File
@@ -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 --- */
+1 -1
View File
@@ -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)
+26 -2
View File
@@ -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 "<title>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"))"
+164
View File
@@ -0,0 +1,164 @@
"""WebUI ask_user round trip against a fake OpenAI-compatible API.
python3 scripts/e2e_webui_ask.py (CST_AGENT= picks the binary)
Checks: the turn really waits for the answer, a page opened while the
question is open still receives it, and Last-Event-ID resumes the stream.
"""
import json, os, shutil, subprocess, sys, tempfile, threading, time, urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
REQS = []
class H(BaseHTTPRequestHandler):
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
REQS.append(json.loads(self.rfile.read(n)))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
def ev(o):
self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n")
self.wfile.flush()
if len(REQS) == 1:
args = json.dumps({"question": "Pick a colour?", "options": "red\nblue"})
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [
{"index": 0, "id": "call_1", "type": "function",
"function": {"name": "ask_user", "arguments": args}}]}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]})
else:
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "done"}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
class Sse:
def __init__(self, base, last_id=None):
self.lines = []
req = urllib.request.Request(base + "/api/events")
if last_id is not None:
req.add_header("Last-Event-ID", str(last_id))
self.r = urllib.request.urlopen(req, timeout=60)
threading.Thread(target=self.pump, daemon=True).start()
def pump(self):
try:
for raw in self.r:
self.lines.append(raw.decode())
except Exception:
pass
def events(self, name):
out = []
for i, l in enumerate(self.lines):
if l.strip() == "event: " + name and i + 1 < len(self.lines):
out.append(json.loads(self.lines[i + 1][6:]))
return out
def ids(self):
return [int(l[4:]) for l in self.lines if l.startswith("id: ")]
def close(self):
try:
self.r.close()
except Exception:
pass
def wait(fn, t=15):
end = time.time() + t
while time.time() < end:
v = fn()
if v:
return v
time.sleep(0.1)
return None
def post(base, path, obj):
req = urllib.request.Request(base + path, data=json.dumps(obj).encode(),
headers={"Content-Type": "application/json"})
try:
with urllib.request.urlopen(req, timeout=5) as r:
return r.status
except urllib.error.HTTPError as e:
return e.code
api = ThreadingHTTPServer(("127.0.0.1", 0), H)
threading.Thread(target=api.serve_forever, daemon=True).start()
ws = tempfile.mkdtemp(prefix="cst-ask-")
log = open(ws + "/server.log", "w+")
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new",
"--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
"--protocol", "native-eos", "--approval", "auto"],
stdout=log, stderr=subprocess.STDOUT)
try:
def port():
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return l.split(":")[3].split("/")[0]
pt = wait(port)
base = "http://127.0.0.1:%s" % pt
a = Sse(base)
time.sleep(0.3)
check(post(base, "/api/turn", {"text": "ask me"}) == 202, "turn accepted")
ask = wait(lambda: a.events("ask_user"))
check(bool(ask), "ask_user event raised")
time.sleep(1.5)
check(len(REQS) == 1, "the turn waits for the answer (requests so far: %d)" % len(REQS))
# a page opened now must still see the open question
b = Sse(base)
check(bool(wait(lambda: b.events("ask_user"), 5)), "a late page gets the open question")
# reconnect with Last-Event-ID: nothing before that id is resent
last = a.ids()[-1]
c = Sse(base, last_id=last - 1)
got = wait(lambda: c.ids(), 5)
check(bool(got) and got[0] == last, "Last-Event-ID resumes after that id (%s, want %d)" % (got[:1] if got else got, last))
if ask:
check(post(base, "/api/ask_user", {"id": ask[0]["id"], "text": "blue"}) == 200, "answer accepted")
end = wait(lambda: a.events("turn_end"), 20)
check(bool(end) and end[0].get("rc") == 0, "turn ended rc=0")
tool_msgs = [m for m in (REQS[1]["messages"] if len(REQS) > 1 else []) if m.get("role") == "tool"]
check(bool(tool_msgs) and "blue" in json.dumps(tool_msgs), "the answer reached the model: %s" % json.dumps(tool_msgs)[:200])
for s in (a, b, c):
s.close()
finally:
p.terminate()
try:
p.wait(20)
except subprocess.TimeoutExpired:
p.kill()
check(False, "server hung on SIGTERM")
check(p.returncode is not None and p.returncode < 128 and p.returncode >= 0, "clean exit (%s)" % p.returncode)
log.close()
shutil.rmtree(ws, ignore_errors=True)
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+234
View File
@@ -0,0 +1,234 @@
"""WebUI HTTP hardening checks over raw sockets, against a scripted model.
python3 scripts/e2e_webui_http.py (CST_AGENT= picks the binary)
Cross-site requests, request parsing (Content-Length, NUL, long lines,
keep-alive), auth before the body, changes refused while a turn runs,
cancel waking a pending question, token mode and shutdown.
"""
import json, os, re, shutil, socket, subprocess, sys, tempfile, threading, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
BIN = os.environ.get("CST_AGENT", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "cst-agent"))
REQS = []
class Api(BaseHTTPRequestHandler):
"""First call: ask_user. Later calls: a plain answer."""
def log_message(self, *a):
pass
def do_GET(self):
body = json.dumps({"data": [{"id": "deepseek-flash"}]}).encode()
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_POST(self):
n = int(self.headers.get("Content-Length", 0))
REQS.append(json.loads(self.rfile.read(n)))
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.end_headers()
base = {"id": "x", "object": "chat.completion.chunk", "model": "deepseek-flash"}
def ev(o):
self.wfile.write(b"data: " + json.dumps({**base, **o}).encode() + b"\n\n")
self.wfile.flush()
if len(REQS) == 1:
args = json.dumps({"question": "Pick?", "options": "a\nb"})
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "tool_calls": [
{"index": 0, "id": "call_1", "type": "function",
"function": {"name": "ask_user", "arguments": args}}]}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "tool_calls"}]})
else:
ev({"choices": [{"index": 0, "delta": {"role": "assistant", "content": "ok"}}]})
ev({"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]})
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
fails = 0
def check(ok, msg):
global fails
print(("PASS " if ok else "FAIL ") + msg)
fails += 0 if ok else 1
def raw(port, data, timeout=5, read_all=True):
"""Send bytes, return everything read until EOF/timeout."""
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
s.sendall(data)
out = b""
try:
while True:
b = s.recv(65536)
if not b:
break
out += b
if not read_all:
break
except socket.timeout:
pass
s.close()
return out.decode("latin1")
def status(resp):
# a pipelined response starts right after the previous body, not on a new line
return [int(m) for m in re.findall(r"HTTP/1\.1 (\d{3}) ", resp)]
def req(method, path, headers=None, body=b"", host=None):
h = {"Host": host or "127.0.0.1:%d" % PORT, "Connection": "close"}
h.update(headers or {})
if body or method == "POST":
h["Content-Length"] = str(len(body))
head = "%s %s HTTP/1.1\r\n" % (method, path) + "".join("%s: %s\r\n" % kv for kv in h.items()) + "\r\n"
return head.encode() + body
def start(ws, *extra):
log = open(os.path.join(ws, "server.log"), "w+")
p = subprocess.Popen([BIN, "--web", "--web-port", "0", "--workspace", ws, "--new",
"--api-key", "sk-x", "--provider", "deepseek", "--model", "deepseek-flash",
"--api-base", "http://127.0.0.1:%d/v1" % api.server_address[1],
"--protocol", "native-eos", "--approval", "auto", *extra],
stdout=log, stderr=subprocess.STDOUT)
end = time.time() + 10
while time.time() < end:
log.seek(0)
for l in log.read().splitlines():
if l.startswith("webui: http://127.0.0.1:"):
return p, int(l.split(":")[3].split("/")[0]), log
time.sleep(0.1)
raise SystemExit("server did not start")
def stop(p, name):
p.terminate()
try:
p.wait(20)
except subprocess.TimeoutExpired:
p.kill()
check(False, name + " hung on SIGTERM")
return
check(0 <= p.returncode < 128, "%s exits cleanly (%s)" % (name, p.returncode))
api = ThreadingHTTPServer(("127.0.0.1", 0), Api)
threading.Thread(target=api.serve_forever, daemon=True).start()
root = tempfile.mkdtemp(prefix="cst-http-")
try:
ws = os.path.join(root, "a")
os.makedirs(ws)
p, PORT, log = start(ws)
me = "http://127.0.0.1:%d" % PORT
cfg = b'{"approval":"ask"}'
# 1. cross-site requests
r = raw(PORT, req("POST", "/api/config", {"Origin": "http://evil.example", "Content-Type": "text/plain"}, cfg))
check(status(r)[:1] == [401], "a cross-site POST is refused (%s)" % status(r))
r = raw(PORT, req("POST", "/api/config", {"Origin": "null"}, cfg))
check(status(r)[:1] == [401], "Origin: null is refused")
r = raw(PORT, req("POST", "/api/config", {"Origin": me, "Content-Type": "application/json"}, b'{"approval":"auto"}'))
check(status(r)[:1] == [200], "our own page may POST (%s)" % status(r))
r = raw(PORT, req("POST", "/api/config", {}, b'{"approval":"auto"}'))
check(status(r)[:1] == [200], "a client without Origin (curl) may POST")
r = raw(PORT, req("GET", "/api/attach/img-0000.png", host="evil.example"))
check(status(r)[:1] == [401], "session images need the Host check too (%s)" % status(r))
r = raw(PORT, req("GET", "/app.js"))
check(status(r)[:1] == [200], "assets are served")
# 2. request parsing
r = raw(PORT, req("GET", "/api/state%00x"))
check(status(r)[:1] == [400], "an encoded NUL in the path is a 400 (%s)" % status(r))
smuggle = b"GET /api/sessions HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"
r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: abc\r\n\r\n" % PORT).encode() + smuggle)
check(status(r) == [400], "Content-Length: abc is a 400, nothing smuggled (%s)" % status(r))
r = raw(PORT, ("POST /api/cancel HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 0\r\nContent-Length: %d\r\n\r\n"
% (PORT, len(smuggle))).encode() + smuggle)
check(status(r) == [400], "two different Content-Lengths are a 400 (%s)" % status(r))
r = raw(PORT, ("GET /%s HTTP/1.1\r\nHost: x\r\n\r\n" % ("a" * 9000)).encode())
check(status(r)[:1] == [400], "an over-long request line is a 400 (%s)" % status(r))
r = raw(PORT, req("GET", "/api/state?x=http://a/b"))
check(status(r)[:1] == [200], "'://' in the query leaves the path alone (%s)" % status(r))
r = raw(PORT, req("GET", "/api/state", {"Connection": "Close"}), timeout=3)
check("Connection: close" in r, "Connection: Close (any case) closes")
ka = ("GET /api/state HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode()
big = ("POST /api/turn HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 5000000\r\n\r\n" % PORT).encode()
r = raw(PORT, ka + big)
check(status(r) == [200, 413], "a 413 after a keep-alive request still arrives (%s)" % status(r))
# 3. a pending question: changes are refused, approval may change, cancel wakes it
events = []
def sse():
s = socket.create_connection(("127.0.0.1", PORT))
s.sendall(("GET /api/events HTTP/1.1\r\nHost: 127.0.0.1:%d\r\n\r\n" % PORT).encode())
buf = b""
s.settimeout(60)
try:
while True:
b = s.recv(65536)
if not b:
break
buf += b
events[:] = [l[7:] for l in buf.decode("latin1").split("\n") if l.startswith("event: ")]
except Exception:
pass
threading.Thread(target=sse, daemon=True).start()
time.sleep(0.4)
r = raw(PORT, req("POST", "/api/turn", {"Content-Type": "application/json"}, b'{"text":"go"}'))
check(status(r)[:1] == [202], "turn accepted")
end = time.time() + 15
while time.time() < end and "ask_user" not in events:
time.sleep(0.1)
check("ask_user" in events, "the question is open")
for path, body in (("/api/sessions/new", b""), ("/api/workspace", json.dumps({"path": root}).encode()),
("/api/compact", b""), ("/api/undo", b""), ("/api/config", b'{"model":"x"}')):
r = raw(PORT, req("POST", path, {"Content-Type": "application/json"}, body))
check(status(r)[:1] == [409], "%s is refused mid-turn (%s)" % (path, status(r)))
r = raw(PORT, req("POST", "/api/config", {"Content-Type": "application/json"}, b'{"approval":"yolo"}'))
check(status(r)[:1] == [200], "approval may change mid-turn")
r = raw(PORT, req("POST", "/api/cancel", {"Content-Type": "application/json"}, b"{}"))
end = time.time() + 10
while time.time() < end and "turn_end" not in events:
time.sleep(0.1)
check("turn_end" in events, "cancel ends a turn parked on a question")
r = raw(PORT, req("POST", "/api/sessions/new", {"Content-Type": "application/json"}))
check(status(r)[:1] == [200], "after the turn, a new session is fine (%s)" % status(r))
stop(p, "the server")
# 4. token mode: the page loads, the API and images do not without it
ws2 = os.path.join(root, "b")
os.makedirs(ws2)
p, PORT, log = start(ws2, "--web-token", "tok-x")
r = raw(PORT, req("GET", "/app.js"))
check(status(r)[:1] == [200], "token mode: app.js loads without the token (%s)" % status(r))
r = raw(PORT, req("GET", "/api/state"))
check(status(r)[:1] == [401], "token mode: the API needs it")
r = raw(PORT, req("GET", "/api/attach/img-0000.png"))
check(status(r)[:1] == [401], "token mode: images need it")
r = raw(PORT, req("GET", "/api/state", {"Cookie": "cst_token=tok-x"}))
check(status(r)[:1] == [200], "token mode: the cookie works")
t0 = time.time()
r = raw(PORT, ("POST /api/attach HTTP/1.1\r\nHost: 127.0.0.1:%d\r\nContent-Length: 33554431\r\n"
"Expect: 100-continue\r\n\r\n" % PORT).encode(), timeout=5)
check(status(r) == [401] and time.time() - t0 < 3,
"no token, no body read: 401 at once (%s, %.1fs)" % (status(r), time.time() - t0))
stop(p, "the token server")
# 5. option checks
rc = subprocess.run([BIN, "--web", "--web-port", "70000", "--workspace", ws2],
capture_output=True, timeout=10).returncode
check(rc != 0, "--web-port 70000 is refused")
rc = subprocess.run([BIN, "--web", "--web-token", "x" * 80, "--web-port", "0", "--workspace", ws2,
"--api-key", "k"], capture_output=True, timeout=10).returncode
check(rc != 0, "an over-long --web-token is refused")
finally:
shutil.rmtree(root, ignore_errors=True)
print("FAILED:", fails)
sys.exit(1 if fails else 0)
+143 -57
View File
@@ -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);
+19 -3
View File
@@ -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:
+27 -11
View File
@@ -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,
+92 -7
View File
@@ -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
}
+11 -1
View File
@@ -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, "&quot;");
else if (val[i] == '<')
cst_buf_puts(b, "&lt;");
else if (val[i] == '>')
cst_buf_puts(b, "&gt;");
else
cst_buf_append(b, val + i, 1);
}
cst_buf_puts(b, "\"");
}
+223 -43
View File
@@ -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);
+10
View File
@@ -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, "&quot;") != NULL);
free(html);
/* streaming: a javascript: link is dropped, a split tag never leaks */
{
cst_buf_free(&g_md_out);
+31 -10
View File
@@ -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);
+3 -3
View File
@@ -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;