v0.8.2: build fixes, WebUI security and correctness
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:
+12
-4
@@ -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
@@ -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 --- */
|
||||
|
||||
@@ -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
@@ -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"))"
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
+141
-55
@@ -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 */
|
||||
if (target[0] != '/') {
|
||||
char *scheme = strstr(target, "://");
|
||||
if (scheme)
|
||||
target = strchr(scheme + 3, '/');
|
||||
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;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
if (!route)
|
||||
route = 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
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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, "\"");
|
||||
}
|
||||
|
||||
|
||||
+222
-42
@@ -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};
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+31
-10
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user