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:
@@ -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)
|
||||
Reference in New Issue
Block a user