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
+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;