mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-17 18:32:19 +02:00
fix(browse): sidebar Terminal — drop the duplicate WS subprotocol header, stop doubling CJK IME input
The terminal client passed the auth token as the WS subprotocol AND echoed it in a second header, which some Chromium builds reject; and composition events double-sent CJK input (each IME commit arrived once from the composition handler and once from the data handler). One auth path, one input path; also fixes the terminal-agent test that failed on clean main. Contributed by @mindsurf0176 (PR #2515). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
4def6f6c7e
commit
9265d27fa1
@@ -603,12 +603,10 @@ function buildServer() {
|
|||||||
// first that matches a known token.
|
// first that matches a known token.
|
||||||
const protoHeader = req.headers.get('sec-websocket-protocol') || '';
|
const protoHeader = req.headers.get('sec-websocket-protocol') || '';
|
||||||
let token: string | null = null;
|
let token: string | null = null;
|
||||||
let acceptedProtocol: string | null = null;
|
|
||||||
for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
|
for (const raw of protoHeader.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||||
const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
|
const candidate = raw.startsWith('gstack-pty.') ? raw.slice('gstack-pty.'.length) : raw;
|
||||||
if (validTokens.has(candidate)) {
|
if (validTokens.has(candidate)) {
|
||||||
token = candidate;
|
token = candidate;
|
||||||
acceptedProtocol = raw;
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -637,13 +635,13 @@ function buildServer() {
|
|||||||
// sessionsById so /internal/restart and (Commit 3) re-attach
|
// sessionsById so /internal/restart and (Commit 3) re-attach
|
||||||
// lookups can find it.
|
// lookups can find it.
|
||||||
const sessionId = validTokens.get(token) ?? null;
|
const sessionId = validTokens.get(token) ?? null;
|
||||||
|
// No explicit Sec-WebSocket-Protocol echo: Bun >= 1.3 auto-echoes the
|
||||||
|
// first offered protocol in the 101 response, so setting the header
|
||||||
|
// here produced a DUPLICATE header — strict clients (Chromium, python
|
||||||
|
// websockets) reject the handshake per RFC 6455 and the sidebar
|
||||||
|
// terminal could never connect. Verified on Bun 1.3.6.
|
||||||
const upgraded = server.upgrade(req, {
|
const upgraded = server.upgrade(req, {
|
||||||
data: { cookie: token, sessionId },
|
data: { cookie: token, sessionId },
|
||||||
// Echo the protocol back so the browser accepts the upgrade.
|
|
||||||
// Required when the client sends Sec-WebSocket-Protocol — the
|
|
||||||
// server MUST select one of the offered protocols, otherwise
|
|
||||||
// the browser closes the connection immediately.
|
|
||||||
...(acceptedProtocol ? { headers: { 'Sec-WebSocket-Protocol': acceptedProtocol } } : {}),
|
|
||||||
});
|
});
|
||||||
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
|
return upgraded ? undefined : new Response('upgrade failed', { status: 500 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,6 +227,45 @@ describe('terminal-agent: PTY round-trip via real WebSocket (Cookie auth)', () =
|
|||||||
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
|
expect(resp.headers.get('sec-websocket-protocol')).toBe(`gstack-pty.${token}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('upgrade response contains exactly ONE Sec-WebSocket-Protocol header', async () => {
|
||||||
|
// RFC 6455: the server MUST select at most one subprotocol. Bun >= 1.3
|
||||||
|
// auto-echoes the first offered protocol in server.upgrade(), so a
|
||||||
|
// manual echo on top of that produced TWO Sec-WebSocket-Protocol
|
||||||
|
// headers — and strict clients (Chromium, python websockets) reject the
|
||||||
|
// handshake, leaving the sidebar terminal permanently disconnected.
|
||||||
|
//
|
||||||
|
// Headers.get() normalizes duplicates away, so this test handshakes
|
||||||
|
// over a raw socket and counts header lines in the response head.
|
||||||
|
const token = 'dup-proto-token-must-be-at-least-seventeen-chars';
|
||||||
|
await grantToken(token);
|
||||||
|
|
||||||
|
const head = await new Promise<string>((resolve, reject) => {
|
||||||
|
const req =
|
||||||
|
'GET /ws HTTP/1.1\r\n' +
|
||||||
|
`Host: 127.0.0.1:${agentPort}\r\n` +
|
||||||
|
'Connection: Upgrade\r\n' +
|
||||||
|
'Upgrade: websocket\r\n' +
|
||||||
|
'Sec-WebSocket-Version: 13\r\n' +
|
||||||
|
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n' +
|
||||||
|
`Sec-WebSocket-Protocol: gstack-pty.${token}\r\n` +
|
||||||
|
'Origin: chrome-extension://test-extension-id\r\n' +
|
||||||
|
'\r\n';
|
||||||
|
let buf = '';
|
||||||
|
const socket = require('net').connect(agentPort, '127.0.0.1', () => socket.write(req));
|
||||||
|
socket.setTimeout(5000, () => { socket.destroy(); reject(new Error('handshake timeout')); });
|
||||||
|
socket.on('data', (chunk: Buffer) => {
|
||||||
|
buf += chunk.toString('utf8');
|
||||||
|
const end = buf.indexOf('\r\n\r\n');
|
||||||
|
if (end !== -1) { socket.destroy(); resolve(buf.slice(0, end)); }
|
||||||
|
});
|
||||||
|
socket.on('error', reject);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(head).toContain('101');
|
||||||
|
const protoLines = head.split('\r\n').filter(l => l.toLowerCase().startsWith('sec-websocket-protocol:'));
|
||||||
|
expect(protoLines).toEqual([`Sec-WebSocket-Protocol: gstack-pty.${token}`]);
|
||||||
|
});
|
||||||
|
|
||||||
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
|
test('Sec-WebSocket-Protocol auth: rejects unknown token even with valid Origin', async () => {
|
||||||
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
const resp = await fetch(`http://127.0.0.1:${agentPort}/ws`, {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -131,15 +131,18 @@ describe('Source-level guard: terminal-agent', () => {
|
|||||||
expect(wsHandler).toContain('validTokens.has');
|
expect(wsHandler).toContain('validTokens.has');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix and echoes back', () => {
|
test('Sec-WebSocket-Protocol auth: strips gstack-pty. prefix, no manual echo', () => {
|
||||||
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
|
const wsHandler = AGENT_SRC.slice(AGENT_SRC.indexOf("if (url.pathname === '/ws')"));
|
||||||
// Browsers send `Sec-WebSocket-Protocol: gstack-pty.<token>`. The agent
|
// Browsers send `Sec-WebSocket-Protocol: gstack-pty.<token>`. The agent
|
||||||
// must strip the prefix before checking validTokens, AND echo the
|
// must strip the prefix before checking validTokens. The protocol echo
|
||||||
// protocol back in the upgrade response — without the echo, the
|
// is Bun's job: Bun >= 1.3 auto-echoes the first offered protocol in the
|
||||||
// browser closes the connection immediately.
|
// 101 response. A manual echo on top produced a DUPLICATE
|
||||||
|
// Sec-WebSocket-Protocol header, which strict clients (Chromium, python
|
||||||
|
// websockets) reject per RFC 6455 — the sidebar terminal could never
|
||||||
|
// connect. Pin the invariant: no manual echo in the upgrade call.
|
||||||
expect(wsHandler).toContain("'gstack-pty.'");
|
expect(wsHandler).toContain("'gstack-pty.'");
|
||||||
expect(wsHandler).toContain('Sec-WebSocket-Protocol');
|
expect(wsHandler).toContain('sec-websocket-protocol');
|
||||||
expect(wsHandler).toContain('acceptedProtocol');
|
expect(wsHandler).not.toContain("headers: { 'Sec-WebSocket-Protocol'");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('lazy spawn: claude PTY is spawned in message handler, not on upgrade', () => {
|
test('lazy spawn: claude PTY is spawned in message handler, not on upgrade', () => {
|
||||||
@@ -155,6 +158,8 @@ describe('Source-level guard: terminal-agent', () => {
|
|||||||
expect(upgradeBlock).not.toContain('spawnClaude(');
|
expect(upgradeBlock).not.toContain('spawnClaude(');
|
||||||
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
|
expect(upgradeBlock).not.toContain('maybeSpawnPty(');
|
||||||
// Spawn must be invoked from the message handler (lazy on first byte).
|
// Spawn must be invoked from the message handler (lazy on first byte).
|
||||||
|
// v1.44 routes both spawn triggers (explicit {type:"start"} text frame
|
||||||
|
// and the lazy binary-frame path) through the maybeSpawnPty helper.
|
||||||
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
|
const messageHandler = AGENT_SRC.slice(AGENT_SRC.indexOf('message(ws, raw)'));
|
||||||
expect(messageHandler).toContain('maybeSpawnPty(');
|
expect(messageHandler).toContain('maybeSpawnPty(');
|
||||||
expect(messageHandler).toContain('!session.spawned');
|
expect(messageHandler).toContain('!session.spawned');
|
||||||
|
|||||||
@@ -433,25 +433,15 @@
|
|||||||
});
|
});
|
||||||
ro.observe(els.mount);
|
ro.observe(els.mount);
|
||||||
|
|
||||||
// IME composition handling for Korean/CJK input (issue #1272).
|
// IME composition (Korean/CJK, issue #1272) is handled by xterm.js
|
||||||
// Suppress partial jamo during composition; only send the final
|
// itself: partial jamo are suppressed while _isComposing, and the final
|
||||||
// composed string on compositionend. Without this, Korean IME
|
// composed string is emitted through onData once, asynchronously
|
||||||
// sends fragmented input or doubles characters.
|
// (setTimeout in _finalizeComposition). A previous local workaround
|
||||||
let composing = false;
|
// sent e.data manually on compositionend — but xterm emits the same
|
||||||
const ta = term.textarea;
|
// string one macrotask later, so every composed syllable went out
|
||||||
if (ta) {
|
// TWICE. Do not re-add a manual compositionend send.
|
||||||
ta.addEventListener('compositionstart', () => { composing = true; });
|
|
||||||
ta.addEventListener('compositionend', (e) => {
|
|
||||||
composing = false;
|
|
||||||
if (e.data && ws && ws.readyState === WebSocket.OPEN) {
|
|
||||||
ws.send(new TextEncoder().encode(e.data));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
term.onData((data) => {
|
term.onData((data) => {
|
||||||
if (composing) return; // suppress partial input events during IME composition
|
|
||||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
ws.send(new TextEncoder().encode(data));
|
ws.send(new TextEncoder().encode(data));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user