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