feat(terminal): refactor runtime and add mobile workspace (#2280)

Replace the legacy terminal flow with a shared authenticated WebSocket
runtime used across web, desktop, relay, and mobile surfaces.

- introduce the v3 terminal protocol with scoped attachments, snapshots,
  ordered output, bounded replay history, reconnects, and explicit lifecycle
- harden PTY creation, restart, resize, close, force-kill, idle cleanup,
  shell selection, login mode, environment sanitization, and appearance sync
- add runtime-aware terminal APIs with relay authentication and Electron parity
- add a fullscreen mobile terminal workspace with touch scrolling,
  long-press selection, safe-area controls, quick keys, and Ctrl/Alt input
- add terminal selection attachments, preview detection, project actions,
  shell settings, and localized UI
- harden Ghostty rendering, resize recovery, Unicode handling, block
  characters, line height, and stale-row behavior
- remove the obsolete terminal SSE path and update reverse-proxy guidance
- expand terminal runtime, transport, input, selection, and store coverage
- avoid duplicate web builds when preparing mobile assets in root CI builds
This commit is contained in:
Bohdan Triapitsyn
2026-07-17 13:17:21 +03:00
committed by GitHub
parent f5b4a267c0
commit d4a8c4d2e1
103 changed files with 4085 additions and 4496 deletions
@@ -40,7 +40,7 @@ Relay is not a separate link format: it is one transport candidate inside the un
Everything a client normally sends to the single OpenChamber origin:
- **HTTP** — REST endpoints and proxied OpenCode SDK calls under `/api/*`, plus `/auth/*` and `/health`.
- **SSE** — long-lived streamed responses (the event stream, notifications, terminal output fallback). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
- **SSE** — long-lived streamed responses (the event stream and notifications). These are just HTTP responses whose body streams; the tunnel needs no special SSE handling.
- **WebSocket** — the endpoints that use a real socket (the global event stream on platforms that support WS, terminal I/O, dictation).
The host dispatcher restricts tunneled traffic to explicit path allowlists (one for HTTP, one for WS).
@@ -58,7 +58,7 @@ The host dispatcher restricts tunneled traffic to explicit path allowlists (one
2. **Presence.** When the relay is enabled, the host opens one outbound control connection and waits.
3. **Connect.** The client connects for a given routing id; the relay notifies the host over the control connection; the host opens a matching per-client data connection.
4. **Handshake.** Over that connection pair, client and host run the E2EE handshake and derive a shared encrypted channel the relay cannot read.
5. **Traffic.** All normal app traffic is multiplexed and encrypted through that channel. On the host, decrypted requests are dispatched to the local server over loopback; responses stream back encrypted. Reconnects re-establish a fresh channel and the app's existing retry machinery recovers.
5. **Traffic.** All normal app traffic is multiplexed and encrypted through that channel. On the host, decrypted requests are dispatched to the local server over loopback with the actual loopback origin, so normal origin checks still apply without trusting client-supplied origin metadata; responses stream back encrypted. Reconnects re-establish a fresh channel and the app's existing retry machinery recovers.
## Candidate refresh (staying off the relay when direct works)
@@ -111,8 +111,19 @@ const startLoopbackOrigin = () =>
new Promise((resolve) => {
const server = http.createServer((req, res) => {
if (req.url === '/health') {
const expectedOrigin = `http://127.0.0.1:${server.address().port}`;
if (req.headers.origin !== expectedOrigin) {
res.writeHead(403, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid origin' }));
return;
}
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ ok: true, service: 'stub', relayConn: req.headers['x-openchamber-relay-connection'] || null }));
res.end(JSON.stringify({
ok: true,
service: 'stub',
relayConn: req.headers['x-openchamber-relay-connection'] || null,
origin: req.headers.origin,
}));
return;
}
res.writeHead(404);
@@ -269,6 +280,7 @@ describe('relay host-client integration', () => {
expect(result.status).toBe(200);
expect(result.body.ok).toBe(true);
expect(result.body.relayConn).toBe('conn-test-1');
expect(result.body.origin).toBe(`http://127.0.0.1:${origin.port}`);
// Every forwarded frame after the two plaintext handshake frames (client
// hello, host ready) must be binary.
+7 -3
View File
@@ -141,7 +141,7 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
// HTTP
// -------------------------------------------------------------------------
const buildRequestHeaders = (rawHeaders) => {
const buildRequestHeaders = (rawHeaders, loopbackOrigin) => {
const headers = {};
for (const [name, value] of Object.entries(rawHeaders)) {
if (typeof name !== 'string' || typeof value !== 'string') continue;
@@ -151,6 +151,9 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
headers[lower] = value;
}
headers['x-openchamber-relay-connection'] = connectionId;
// Browser-generated Origin is not visible to the tunnel client. Present the
// loopback origin being dialed and overwrite any client-supplied value.
headers.origin = loopbackOrigin;
return headers;
};
@@ -189,12 +192,13 @@ export const createTunnelHost = ({ connectionId, getLocalPort, sendFrame, getBuf
stream.noBody = true;
}
const url = `http://127.0.0.1:${getLocalPort()}${request.path}${request.query ? `?${request.query}` : ''}`;
const loopbackOrigin = `http://127.0.0.1:${getLocalPort()}`;
const url = `${loopbackOrigin}${request.path}${request.query ? `?${request.query}` : ''}`;
let response;
try {
response = await fetch(url, {
method,
headers: buildRequestHeaders(request.headers),
headers: buildRequestHeaders(request.headers, loopbackOrigin),
body: requestBody,
duplex: hasBody ? 'half' : undefined,
signal: stream.abort.signal,